@aglyn/plugins-commerce 1.0.0-beta.232 → 1.0.0-beta.233
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +13 -13
- package/src/lib/components/cart-extras.d.ts +6 -0
- package/src/lib/components/cart-extras.js +4 -1
- package/src/lib/components/cart-extras.js.map +1 -1
- package/src/lib/components/cart-fulfillment.d.ts +117 -0
- package/src/lib/components/cart-fulfillment.js +367 -0
- package/src/lib/components/cart-fulfillment.js.map +1 -0
- package/src/lib/components/cart.js +52 -10
- package/src/lib/components/cart.js.map +1 -1
- package/src/lib/components/commerce-console-page.js +42 -1
- package/src/lib/components/commerce-console-page.js.map +1 -1
- package/src/lib/components/console/host-orders-card.component.js +38 -10
- package/src/lib/components/console/host-orders-card.component.js.map +1 -1
- package/src/lib/components/console/local-delivery-settings-card.component.d.ts +37 -0
- package/src/lib/components/console/local-delivery-settings-card.component.js +542 -0
- package/src/lib/components/console/local-delivery-settings-card.component.js.map +1 -0
- package/src/lib/components/console/locations-card.component.js +177 -3
- package/src/lib/components/console/locations-card.component.js.map +1 -1
- package/src/lib/components/console/order-detail-dialog.component.js +10 -0
- package/src/lib/components/console/order-detail-dialog.component.js.map +1 -1
- package/src/lib/components/console/order-local-fulfillment-panel.component.d.ts +44 -0
- package/src/lib/components/console/order-local-fulfillment-panel.component.js +265 -0
- package/src/lib/components/console/order-local-fulfillment-panel.component.js.map +1 -0
- package/src/lib/components/console/order-offline-notice.component.d.ts +12 -0
- package/src/lib/components/console/order-offline-notice.component.js +53 -0
- package/src/lib/components/console/order-offline-notice.component.js.map +1 -0
- package/src/lib/components/console/pickup-delivery-queue-card.component.d.ts +35 -0
- package/src/lib/components/console/pickup-delivery-queue-card.component.js +222 -0
- package/src/lib/components/console/pickup-delivery-queue-card.component.js.map +1 -0
- package/src/lib/components/console/pos/pos-api.d.ts +24 -1
- package/src/lib/components/console/pos/pos-api.js +13 -0
- package/src/lib/components/console/pos/pos-api.js.map +1 -1
- package/src/lib/components/console/pos/pos-item-dialog.component.d.ts +12 -0
- package/src/lib/components/console/pos/pos-item-dialog.component.js +10 -8
- package/src/lib/components/console/pos/pos-item-dialog.component.js.map +1 -1
- package/src/lib/components/console/pos/pos-kiosk-queue.component.d.ts +22 -0
- package/src/lib/components/console/pos/pos-kiosk-queue.component.js +168 -0
- package/src/lib/components/console/pos/pos-kiosk-queue.component.js.map +1 -0
- package/src/lib/components/console/pos/pos-tender-dialogs.component.d.ts +18 -0
- package/src/lib/components/console/pos/pos-tender-dialogs.component.js +116 -1
- package/src/lib/components/console/pos/pos-tender-dialogs.component.js.map +1 -1
- package/src/lib/components/console/pos/pos-tender-panel.component.d.ts +8 -0
- package/src/lib/components/console/pos/pos-tender-panel.component.js +77 -18
- package/src/lib/components/console/pos/pos-tender-panel.component.js.map +1 -1
- package/src/lib/components/console/pos/use-pos-display.d.ts +5 -2
- package/src/lib/components/console/pos/use-pos-display.js +3 -2
- package/src/lib/components/console/pos/use-pos-display.js.map +1 -1
- package/src/lib/components/console/pos-devices-card.component.js +2 -2
- package/src/lib/components/console/pos-devices-card.component.js.map +1 -1
- package/src/lib/components/console/pos-display/pairing-screen.d.ts +5 -2
- package/src/lib/components/console/pos-display/pairing-screen.js +7 -5
- package/src/lib/components/console/pos-display/pairing-screen.js.map +1 -1
- package/src/lib/components/console/pos-display/pos-display-api.d.ts +7 -1
- package/src/lib/components/console/pos-display/pos-display-api.js +7 -2
- package/src/lib/components/console/pos-display/pos-display-api.js.map +1 -1
- package/src/lib/components/console/pos-kiosk/kiosk-checkout.d.ts +41 -0
- package/src/lib/components/console/pos-kiosk/kiosk-checkout.js +275 -0
- package/src/lib/components/console/pos-kiosk/kiosk-checkout.js.map +1 -0
- package/src/lib/components/console/pos-kiosk/kiosk-menu.d.ts +32 -0
- package/src/lib/components/console/pos-kiosk/kiosk-menu.js +344 -0
- package/src/lib/components/console/pos-kiosk/kiosk-menu.js.map +1 -0
- package/src/lib/components/console/pos-kiosk/kiosk-staff.d.ts +12 -0
- package/src/lib/components/console/pos-kiosk/kiosk-staff.js +257 -0
- package/src/lib/components/console/pos-kiosk/kiosk-staff.js.map +1 -0
- package/src/lib/components/console/pos-kiosk/pos-kiosk-api.d.ts +114 -0
- package/src/lib/components/console/pos-kiosk/pos-kiosk-api.js +189 -0
- package/src/lib/components/console/pos-kiosk/pos-kiosk-api.js.map +1 -0
- package/src/lib/components/console/pos-kiosk/use-kiosk-idle.d.ts +16 -0
- package/src/lib/components/console/pos-kiosk/use-kiosk-idle.js +80 -0
- package/src/lib/components/console/pos-kiosk/use-kiosk-idle.js.map +1 -0
- package/src/lib/components/console/pos-kiosk-page.component.d.ts +28 -0
- package/src/lib/components/console/pos-kiosk-page.component.js +791 -0
- package/src/lib/components/console/pos-kiosk-page.component.js.map +1 -0
- package/src/lib/components/console/pos-offline/pos-offline-api.d.ts +44 -0
- package/src/lib/components/console/pos-offline/pos-offline-api.js +69 -0
- package/src/lib/components/console/pos-offline/pos-offline-api.js.map +1 -0
- package/src/lib/components/console/pos-offline/pos-offline-banner.component.d.ts +7 -0
- package/src/lib/components/console/pos-offline/pos-offline-banner.component.js +135 -0
- package/src/lib/components/console/pos-offline/pos-offline-banner.component.js.map +1 -0
- package/src/lib/components/console/pos-offline/pos-offline-checkout.component.d.ts +32 -0
- package/src/lib/components/console/pos-offline/pos-offline-checkout.component.js +287 -0
- package/src/lib/components/console/pos-offline/pos-offline-checkout.component.js.map +1 -0
- package/src/lib/components/console/pos-offline/pos-offline-store.d.ts +63 -0
- package/src/lib/components/console/pos-offline/pos-offline-store.js +145 -0
- package/src/lib/components/console/pos-offline/pos-offline-store.js.map +1 -0
- package/src/lib/components/console/pos-offline/register-offline.d.ts +24 -0
- package/src/lib/components/console/pos-offline/register-offline.js +24 -0
- package/src/lib/components/console/pos-offline/register-offline.js.map +1 -0
- package/src/lib/components/console/pos-offline/use-pos-offline.d.ts +85 -0
- package/src/lib/components/console/pos-offline/use-pos-offline.js +542 -0
- package/src/lib/components/console/pos-offline/use-pos-offline.js.map +1 -0
- package/src/lib/components/console/pos-ops/pos-shift-history-card.component.js +72 -17
- package/src/lib/components/console/pos-ops/pos-shift-history-card.component.js.map +1 -1
- package/src/lib/components/console/pos-page.component.js +174 -27
- package/src/lib/components/console/pos-page.component.js.map +1 -1
- package/src/lib/components/console/products-hub-card.component.js +35 -10
- package/src/lib/components/console/products-hub-card.component.js.map +1 -1
- package/src/lib/components/console/shipping-settings-card.component.js +1 -1
- package/src/lib/components/console/shipping-settings-card.component.js.map +1 -1
- package/src/lib/components/console/stock-movements-card.component.js +48 -16
- package/src/lib/components/console/stock-movements-card.component.js.map +1 -1
- package/src/lib/components/console/store-zones.d.ts +8 -1
- package/src/lib/components/console/store-zones.js +11 -1
- package/src/lib/components/console/store-zones.js.map +1 -1
- package/src/lib/components/order-status.js +66 -0
- package/src/lib/components/order-status.js.map +1 -1
- package/src/lib/components/product-detail.js +42 -15
- package/src/lib/components/product-detail.js.map +1 -1
- package/src/lib/constants/local-fulfillment-list-query.d.ts +48 -0
- package/src/lib/constants/local-fulfillment-list-query.js +84 -0
- package/src/lib/constants/local-fulfillment-list-query.js.map +1 -0
- package/src/lib/constants/orders-list-query.d.ts +7 -2
- package/src/lib/constants/orders-list-query.js +89 -8
- package/src/lib/constants/orders-list-query.js.map +1 -1
- package/src/lib/constants/pos-shift-list-query.d.ts +22 -0
- package/src/lib/constants/pos-shift-list-query.js +115 -0
- package/src/lib/constants/pos-shift-list-query.js.map +1 -0
- package/src/lib/constants/product-list-query.d.ts +6 -1
- package/src/lib/constants/product-list-query.js +77 -8
- package/src/lib/constants/product-list-query.js.map +1 -1
- package/src/lib/constants/stock-movement-query.d.ts +3 -2
- package/src/lib/constants/stock-movement-query.js +52 -8
- package/src/lib/constants/stock-movement-query.js.map +1 -1
- package/src/lib/model/buyer-notifications.d.ts +5 -3
- package/src/lib/model/buyer-notifications.js +25 -4
- package/src/lib/model/buyer-notifications.js.map +1 -1
- package/src/lib/model/commerce-orders.d.ts +59 -0
- package/src/lib/model/commerce-orders.js.map +1 -1
- package/src/lib/model/commerce-pos-kiosk.d.ts +175 -0
- package/src/lib/model/commerce-pos-kiosk.js +147 -0
- package/src/lib/model/commerce-pos-kiosk.js.map +1 -0
- package/src/lib/model/commerce-pos-offline.d.ts +234 -0
- package/src/lib/model/commerce-pos-offline.js +301 -0
- package/src/lib/model/commerce-pos-offline.js.map +1 -0
- package/src/lib/model/commerce-pos-ops.d.ts +16 -6
- package/src/lib/model/commerce-pos-ops.js +12 -4
- package/src/lib/model/commerce-pos-ops.js.map +1 -1
- package/src/lib/model/commerce-pos.d.ts +13 -1
- package/src/lib/model/commerce-pos.js +5 -4
- package/src/lib/model/commerce-pos.js.map +1 -1
- package/src/lib/model/commerce-stock-holds.d.ts +24 -3
- package/src/lib/model/commerce-stock-holds.js +26 -8
- package/src/lib/model/commerce-stock-holds.js.map +1 -1
- package/src/lib/model/commerce.d.ts +3 -0
- package/src/lib/model/commerce.js.map +1 -1
- package/src/lib/model/index.d.ts +4 -0
- package/src/lib/model/index.js +4 -0
- package/src/lib/model/index.js.map +1 -1
- package/src/lib/model/local-fulfillment-settings.d.ts +178 -0
- package/src/lib/model/local-fulfillment-settings.js +361 -0
- package/src/lib/model/local-fulfillment-settings.js.map +1 -0
- package/src/lib/model/order-list-fields.d.ts +10 -1
- package/src/lib/model/order-list-fields.js +1 -0
- package/src/lib/model/order-list-fields.js.map +1 -1
- package/src/lib/model/order-local-fulfillment.d.ts +131 -0
- package/src/lib/model/order-local-fulfillment.js +125 -0
- package/src/lib/model/order-local-fulfillment.js.map +1 -0
- package/src/lib/model/order-shipping-export.js +4 -0
- package/src/lib/model/order-shipping-export.js.map +1 -1
- package/src/lib/model/order-status-view.d.ts +20 -1
- package/src/lib/model/order-status-view.js +60 -6
- package/src/lib/model/order-status-view.js.map +1 -1
- package/src/lib/plugin-config.js +6 -2
- package/src/lib/plugin-config.js.map +1 -1
- package/src/lib/plugin.js +8 -0
- package/src/lib/plugin.js.map +1 -1
- package/src/lib/pos-kiosk-config.d.ts +26 -0
- package/src/lib/pos-kiosk-config.js +59 -0
- package/src/lib/pos-kiosk-config.js.map +1 -0
- package/src/lib/server/api-v1/openapi.js +8 -0
- package/src/lib/server/api-v1/openapi.js.map +1 -1
- package/src/lib/server/api-v1/order-view.d.ts +19 -0
- package/src/lib/server/api-v1/order-view.js +35 -0
- package/src/lib/server/api-v1/order-view.js.map +1 -1
- package/src/lib/server/billing-webhook.js +238 -87
- package/src/lib/server/billing-webhook.js.map +1 -1
- package/src/lib/server/cart-checkout.d.ts +2 -0
- package/src/lib/server/cart-checkout.js +173 -13
- package/src/lib/server/cart-checkout.js.map +1 -1
- package/src/lib/server/checkout-extras.js +18 -4
- package/src/lib/server/checkout-extras.js.map +1 -1
- package/src/lib/server/checkout.js +71 -14
- package/src/lib/server/checkout.js.map +1 -1
- package/src/lib/server/local-fulfillment-status.d.ts +86 -0
- package/src/lib/server/local-fulfillment-status.js +379 -0
- package/src/lib/server/local-fulfillment-status.js.map +1 -0
- package/src/lib/server/local-fulfillment.d.ts +190 -0
- package/src/lib/server/local-fulfillment.js +580 -0
- package/src/lib/server/local-fulfillment.js.map +1 -0
- package/src/lib/server/membership-register.js +3 -1
- package/src/lib/server/membership-register.js.map +1 -1
- package/src/lib/server/order-notifications.js +61 -0
- package/src/lib/server/order-notifications.js.map +1 -1
- package/src/lib/server/pos-display.d.ts +27 -1
- package/src/lib/server/pos-display.js +50 -9
- package/src/lib/server/pos-display.js.map +1 -1
- package/src/lib/server/pos-kiosk-principal.d.ts +29 -0
- package/src/lib/server/pos-kiosk-principal.js +42 -0
- package/src/lib/server/pos-kiosk-principal.js.map +1 -0
- package/src/lib/server/pos-kiosk.d.ts +54 -0
- package/src/lib/server/pos-kiosk.js +972 -0
- package/src/lib/server/pos-kiosk.js.map +1 -0
- package/src/lib/server/pos-offline-sync.d.ts +85 -0
- package/src/lib/server/pos-offline-sync.js +765 -0
- package/src/lib/server/pos-offline-sync.js.map +1 -0
- package/src/lib/server/pos-order.js +22 -4
- package/src/lib/server/pos-order.js.map +1 -1
- package/src/lib/server/pos-payment.d.ts +6 -0
- package/src/lib/server/pos-payment.js +225 -6
- package/src/lib/server/pos-payment.js.map +1 -1
- package/src/lib/server/pos-print.js +5 -0
- package/src/lib/server/pos-print.js.map +1 -1
- package/src/lib/server/pos-return.js +27 -0
- package/src/lib/server/pos-return.js.map +1 -1
- package/src/lib/server/pos-sale.d.ts +6 -0
- package/src/lib/server/pos-sale.js +2 -0
- package/src/lib/server/pos-sale.js.map +1 -1
- package/src/lib/server/pos-shift.js +8 -1
- package/src/lib/server/pos-shift.js.map +1 -1
- package/src/lib/server/refund.js +3 -0
- package/src/lib/server/refund.js.map +1 -1
- package/src/lib/server/stock-hold.d.ts +9 -1
- package/src/lib/server/stock-hold.js +15 -7
- package/src/lib/server/stock-hold.js.map +1 -1
- package/src/lib/server.js +16 -0
- package/src/lib/server.js.map +1 -1
- package/src/lib/tenant-emails.js +161 -0
- package/src/lib/tenant-emails.js.map +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/refund.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport * as Aglyn from '@aglyn/aglyn/server'\nimport * as CommerceModel from '../model'\nimport { firebaseAdmin, getOrgForHost } from '@aglyn/tenant-data-admin'\nimport { type PluginApiHandler } from '@aglyn/aglyn/server'\nimport { resolveOrgPermissions } from '@aglyn/tenant-runtime/org-permissions'\nimport { createHash } from 'crypto'\nimport { recordPluginPersonRefund } from '@aglyn/aglyn/plugin-manager/plugin-person-records'\nimport { applyGiftCardRiskToOrder } from './gift-card-risk'\nimport { flagOrderRestock } from './restock-flag'\nimport { notifyOrderBuyer } from './order-notifications'\nimport { ORDER_REFUNDED_EVENT } from '../model/order-events'\nimport { raiseOrderEvent } from './order-events'\nimport { reverseOrderConversion } from '@aglyn/aglyn/plugin-manager/plugin-conversion-credit'\nimport { createStripeRefund } from './stripe-refund'\nimport { posRegisterRefundAuthority } from './pos-refund-authority'\n\n/**\n * A claim on one refund attempt (AGL-1696), the same primitive the POS sale\n * path uses (AGL-1691, `cab8aa36e`).\n */\ninterface RefundClaim {\n /** Stripe idempotency key for this attempt, or null when no key was sent. */\n stripeKey: string | null\n record: (status: number, body: unknown) => Promise<void>\n release: () => Promise<void>\n}\n\n/**\n * Retires the licence keys a refund withdrew (AGL-2454).\n *\n * Reads the order back so the decision is made from what actually SETTLED\n * rather than from what this request asked for — a concurrent partial may have\n * closed a line between the two — and retires only the keys of products the\n * order no longer entitles at all. `refundedProductIds` is deliberately strict\n * about that: a buyer who returned one of two copies still holds the product,\n * so nothing is retired for them.\n *\n * `revokedAtMs` with `assignedAtMs` left standing is the retired state. Keeping\n * `assignedAtMs` matters: `assignLicenseKeys` claims from\n * `where('assignedAtMs','==',null)`, so clearing it would put the key straight\n * back in front of the next buyer — the reissue this must not do.\n *\n * Swallows everything. The refund has already moved money.\n */\nasync function retireLicenseKeys(\n hostRef: FirebaseFirestore.DocumentReference,\n orderRef: FirebaseFirestore.DocumentReference,\n orderId: string,\n): Promise<number> {\n try {\n const fresh = CommerceModel.liftLegacyOrder(\n ((await orderRef.get()).data() ?? {}) as any,\n )\n const withdrawn = new Set(CommerceModel.refundedProductIds(fresh))\n if (withdrawn.size === 0) return 0\n const assigned = await hostRef\n .collection('licenseKeys')\n .where('orderId', '==', orderId)\n .limit(500)\n .get()\n let retired = 0\n for (const keySnapshot of assigned.docs) {\n if (!withdrawn.has(String(keySnapshot.get('productId') ?? ''))) continue\n // Already retired by an earlier partial, or by the merchant's own Revoke\n // button. Skipped so a second refund does not restamp the timestamp and\n // make the retirement look newer than it is.\n if (keySnapshot.get('revokedAtMs') != null) continue\n await keySnapshot.ref\n .set({ revokedAtMs: Date.now(), revokedOrderId: orderId }, { merge: true })\n .catch(() => undefined)\n retired++\n }\n if (retired > 0) {\n await orderRef\n .update({\n timeline: firebaseAdmin.firestore.FieldValue.arrayUnion({\n atMs: Date.now(),\n event: 'license-retired',\n detail:\n `${retired} license key${retired === 1 ? '' : 's'} retired. ` +\n 'The buyer already holds the key string, so it is not returned ' +\n 'to the pool — reissuing it would give two people one secret.',\n }),\n })\n .catch(() => undefined)\n }\n return retired\n } catch (error) {\n console.error('License key retirement failed', orderId, error)\n return 0\n }\n}\n\n/**\n * Order refunds (AGL-287): full or partial via Stripe, site-admin only\n * (it moves money). Destination charges reverse the transfer and the\n * platform fee proportionally. Full refunds transition the order to\n * `refunded`; partial refunds accumulate `refundedCents` and stay in\n * the current status.\n *\n * Two SEPARATE controls guard the money (AGL-1696), and conflating them is\n * how the original went wrong:\n *\n * - The idempotency key stops a DUPLICATE refund — one attempt sent twice\n * because the response was lost, the admin double-clicked, or a client\n * retried. It is minted per attempt by the console and deliberately not\n * derived from the order or the amount: two $10 refunds on a $50 order are\n * two real refunds, exactly as a cashier ringing the same coffee twice is a\n * real second sale.\n * - The cap stops an OVER-refund — several partials summing past what was\n * captured, including two admins refunding at once, where the two attempts\n * are genuinely distinct and no key can help. That needs the counter read\n * and written inside one transaction.\n *\n * The original had a cap that looked like both and was neither: it read\n * `refundedCents` and wrote it back only AFTER the Stripe call, outside any\n * transaction. A guard that reads state the guarded operation writes too late\n * is not a guard — measured, two concurrent refunds each sent a full $50 to\n * Stripe with no idempotency header on either.\n */\nexport const refundHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n if (!process.env.STRIPE_SECRET_KEY) {\n return res.status(501).json({ error: 'Payments are not configured.' })\n }\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) return res.status(401).json({ error: 'Unauthenticated' })\n const body =\n typeof req.body === 'string' ? JSON.parse(req.body) : (req.body ?? {})\n const hostId = String(body.hostId ?? '')\n const orderId = String(body.orderId ?? '')\n const amountCents = body.amountCents == null ? null : Number(body.amountCents)\n /**\n * Lines the admin is refunding BY NAME (AGL-2454), and therefore the lines\n * whose digital entitlements come back. Optional: an amount-only refund is\n * still a legal refund and still revokes nothing per-line — see the guard\n * below for why that is a decision rather than an oversight.\n */\n const requestedLineIds: number[] = Array.isArray(body.lineItemIds)\n ? [\n ...new Set(\n (body.lineItemIds as unknown[])\n .map((value) => Math.round(Number(value)))\n .filter((value) => Number.isFinite(value) && value >= 0),\n ),\n ].sort((a, b) => a - b)\n : []\n // One refund attempt, minted by the console. Node lowercases incoming\n // headers, but read both spellings — the plugin API request type makes no\n // promise about casing.\n const idempotencyKey = String(\n req.headers['idempotency-key'] ?? req.headers['Idempotency-Key'] ?? '',\n )\n .trim()\n .slice(0, 200)\n if (!hostId || !orderId) {\n return res.status(400).json({ error: 'Missing hostId or orderId' })\n }\n\n let claim: RefundClaim | null = null\n try {\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(hostId)\n const hostSnapshot = await hostRef.get()\n if (!hostSnapshot.exists) {\n return res.status(404).json({ error: 'Unknown site' })\n }\n // TWO CHECKS, and they answer different questions (AGL-2372).\n //\n // The first is the HOST-level fact the Firestore rules also enforce, and\n // it stays: `memberRoles` is the projection the rules read, so dropping it\n // here would let this route and the database disagree.\n const memberRole = (hostSnapshot.get('memberRoles') ?? {})[decoded.uid]\n // The second is WHOSE admin, and it is the one this gate was missing.\n //\n // `memberRoles` is a per-host projection of `hostAccess`, and\n // `/api/hosts/members` will grant a SITE COLLABORATOR `admin` on one site\n // (`hostAccess: { [hostId]: 'admin' }`, `allHosts: false`). That writes the\n // literal string `'admin'` into `memberRoles[uid]` — byte-identical to an\n // org owner's. So the check above cannot tell a contractor invited to run\n // one microsite from the person who owns the business, and refunding is\n // money leaving that business.\n //\n // `orgWide` is the discriminator, and it needs no new role: it is\n // `isOrgWideMember` (AGL-1026) — owner/admin of the org, an explicit\n // `allHosts` member, or the legacy pre-`allHosts` shape — and it is false\n // for every scoped collaborator. Same pairing `pos-order.ts` uses.\n //\n // BOTH halves are required. `hostRole` is re-tested rather than assumed\n // from `memberRole`: an org-wide member can still be scoped down to\n // `editor` on this host, and `resolveOrgPermissions` is the resolver that\n // knows it. It fails CLOSED on a lookup error when a host is named\n // (AGL-506), and `denied()` returns `orgWide: false` / `hostRole: null`,\n // so an absent membership refuses rather than folding to permitted.\n const membership = await resolveOrgPermissions(decoded.uid, { hostId })\n const workspaceAdmin =\n memberRole === 'admin' &&\n membership.orgWide === true &&\n membership.hostRole === 'admin'\n // THE REGISTER'S OWN REFUNDS (AGL-3609). A member who works the register\n // — `admin` or `editor` on this site with `managePos` — may refund a POS\n // order up to the site's cashier refund limit, and above it with a\n // workspace admin's PIN. Decided below, once the order and the amount are\n // known; every other refund still needs the workspace admin above.\n const registerStaff =\n !workspaceAdmin &&\n (memberRole === 'admin' || memberRole === 'editor') &&\n membership.permissions?.managePos === true\n if (!workspaceAdmin && !registerStaff) {\n return res.status(403).json({\n error:\n memberRole !== 'admin'\n ? 'Refunds require a site admin'\n : 'Refunds require an admin of the whole workspace',\n })\n }\n const orderRef = hostRef.collection('orders').doc(orderId)\n const orderSnapshot = await orderRef.get()\n if (!orderSnapshot.exists) {\n return res.status(404).json({ error: 'Unknown order' })\n }\n const order = CommerceModel.liftLegacyOrder(orderSnapshot.data() as any)\n if (registerStaff) {\n const authority = await posRegisterRefundAuthority({\n hostId,\n hostRef,\n order,\n amountCents,\n lineCents: requestedLineIds.length\n ? CommerceModel.orderLineRefundCents(order, requestedLineIds)\n : null,\n registerId: String(body.registerId ?? (order as { registerId?: string }).registerId ?? ''),\n managerAssertion: body.managerAssertion,\n })\n if ('body' in authority) return res.status(403).json(authority.body)\n }\n\n // Replay a settled attempt before anything else can reject it. This read\n // is only a short-circuit, never the dedupe primitive — the atomic\n // `create()` below is. It has to run ahead of the status guard because a\n // retried FULL refund would otherwise be answered \"orders in refunded\n // cannot refund\", which is the right money outcome reported as a failure,\n // and an admin who reads it as a failure refunds again by hand.\n const claimRef = idempotencyKey\n ? firestore\n .collection('apiIdempotency')\n .doc(\n createHash('sha256')\n // Scoped by the order, so a client that reused one key across\n // two orders cannot dedupe two legitimately distinct refunds.\n // NOT by the amount: that would swallow a real second partial.\n .update(`refund:${hostId}:${orderId}:${idempotencyKey}`)\n .digest('hex'),\n )\n : null\n if (claimRef) {\n const prior = await claimRef.get()\n const priorResponse = prior.get('response')\n if (priorResponse) {\n return res\n .status(Number(prior.get('responseStatus') ?? 200))\n .json(priorResponse)\n }\n }\n\n if (!CommerceModel.canTransitionOrder(order.status, 'refunded')) {\n return res\n .status(409)\n .json({ error: `Orders in \"${order.status}\" cannot refund` })\n }\n // A refund does not withdraw a dispute (AGL-1809). While a chargeback is\n // formally open the bank has already pulled the disputed funds, Stripe's\n // refund API refuses the charge (`charge_disputed`), and a refund that did\n // go through would pay the shopper twice — the merchant loses the refund\n // AND the dispute plus its fee. Refused HERE, before the claim and the\n // reservation, so a refusal burns no idempotency key and strands nothing:\n // no state has been written yet (AGL-1754's contract). An open INQUIRY\n // (`warning_*`) deliberately passes — no funds have moved and Stripe names\n // a full refund as the way to resolve one before it escalates — and the\n // status guard above already turns away a LOST dispute, which parked the\n // order in `refunded`. This reads the pre-transaction snapshot; a dispute\n // webhook racing this exact request is caught by the `charge_disputed`\n // mapping on the Stripe response below.\n if (CommerceModel.orderDisputeBlocksRefund(order)) {\n return res.status(409).json({\n error:\n 'A chargeback is open on this order, so it was not refunded. ' +\n 'Refunding would not withdraw the dispute — the bank has already ' +\n 'taken the disputed amount, and a refund on top of it would pay ' +\n 'the shopper twice. Respond to the dispute or accept it in the ' +\n 'Stripe dashboard; refund any remainder once it settles.',\n })\n }\n // WHAT THE NAMED LINES ARE WORTH, AND WHY THE AMOUNT MAY NOT BE LESS\n // (AGL-2454).\n //\n // A refund carries an amount, not lines — that is the blocker this issue\n // names, and `restock-flag.ts:48-55` already records it for stock. It\n // cannot be solved by inference: deciding for the merchant which lines a\n // bare figure covers would be a guess about their goods. It CAN be solved\n // by asking, which is what naming lines does, and the amount is then\n // derived from them rather than typed beside them.\n //\n // An explicit `amountCents` may still be LARGER (the admin is refunding the\n // line plus its share of tax or shipping, which this items-only sum does\n // not include). It may not be SMALLER: revoking a line the refund did not\n // actually cover is the silent over-revocation this issue forbids in the\n // same breath as silent under-revocation.\n const orderLines = order.lineItems ?? []\n const invalidLine = requestedLineIds.find(\n (index) => index >= orderLines.length,\n )\n if (invalidLine != null) {\n return res\n .status(400)\n .json({ error: `Line ${invalidLine} is not on this order` })\n }\n // NET OF THE ORDER'S DISCOUNT, not the list price.\n //\n // This was the bare `unitAmountCents x quantity`, which is what the line\n // was LISTED at rather than what the buyer paid for it. On a discounted\n // order the two differ, and both directions of the error land on the\n // merchant: a $10 coupon over two $50 lines means each line cost $45, so\n // refunding one at $50 gave back $5 that was never taken, and the order\n // then held less than its remaining line was worth — so the second line\n // refund hit the cap below and was refused outright, leaving the merchant\n // unable to finish a refund they had already half-issued.\n //\n // `orderLineRefundCents` apportions the discount across every line by list\n // value and returns the named lines' share, so refunding all of them sums\n // to exactly what was charged and no cent is stranded or invented.\n const namedLinesCents = CommerceModel.orderLineRefundCents(\n order,\n requestedLineIds,\n )\n if (\n requestedLineIds.length > 0 &&\n amountCents != null &&\n Math.round(amountCents) < namedLinesCents\n ) {\n return res.status(400).json({\n error:\n 'That amount is less than the lines you selected are worth. ' +\n 'Refund the full value of those lines, or refund an amount ' +\n 'without selecting lines.',\n })\n }\n // A register sale paid with SEVERAL cards (AGL-3607) has no single\n // charge to refund: each refund is taken from one card that can cover it.\n const splitCards = CommerceModel.orderPayments(order).filter(\n (payment) =>\n payment.status === 'succeeded' &&\n CommerceModel.isCardPaymentMethod(payment.method) &&\n Boolean(payment.paymentIntentId),\n )\n const splitRefunds = (order as { paymentRefunds?: Record<string, number> }).paymentRefunds ?? {}\n let paymentIntentId =\n (splitCards.length > 1 ? splitCards[0].paymentIntentId : undefined) ??\n order.paymentIntentId ??\n // Legacy rows stored the checkout session as the doc id; resolve\n // the payment intent from Stripe.\n (await (async () => {\n const response = await fetch(\n `https://api.stripe.com/v1/checkout/sessions/${orderId}`,\n {\n headers: {\n Authorization: `Bearer ${process.env.STRIPE_SECRET_KEY}`,\n },\n },\n )\n const session = await response.json()\n return response.ok ? session?.payment_intent : null\n })())\n if (!paymentIntentId) {\n return res.status(409).json({ error: 'No payment to refund' })\n }\n\n // Point of no return: everything past here moves money. The claim is\n // `create()` — Firestore rejects a create on an existing document, and\n // that rejection IS the dedupe primitive. A read-then-write would race\n // exactly the double-submit it exists to stop. Storage reuses the REST\n // API's shape and its `orgId` field (AGL-618) rather than inventing a\n // second replay store, so `eraseOrgIdempotencyKeys` (AGL-1448) already\n // sweeps these on org erasure with no change there.\n if (claimRef) {\n const ownerOrg = await getOrgForHost(hostId)\n try {\n await claimRef.create({\n orgId: String(ownerOrg?.org?.id ?? '') || null,\n hostId,\n orderId,\n kind: 'commerce-refund',\n status: 'pending',\n createdAt: firebaseAdmin.firestore.FieldValue.serverTimestamp(),\n createdAtMs: Date.now(),\n // The second writer into `apiIdempotency` (AGL-1978). The shared\n // `claimAttempt` stamps this too; this local copy has to as well,\n // or refund claims are the one shape the TTL policy silently never\n // reaches — a policy that governs most of a collection reads, from\n // the outside, exactly like one that governs all of it.\n expiresAt: Aglyn.apiIdempotencyExpiry(),\n })\n } catch {\n const prior = await claimRef.get()\n const priorResponse = prior.get('response')\n if (priorResponse) {\n return res\n .status(Number(prior.get('responseStatus') ?? 200))\n .json(priorResponse)\n }\n // In flight, or stranded by a process that died mid-refund. Fail\n // CLOSED: the alternative is sending the money a second time.\n return res\n .status(409)\n .json({ error: 'This refund is already being processed' })\n }\n claim = {\n // The same digest goes to Stripe. That is the half that costs real\n // money: it covers the window where our claim is written but the\n // response never arrives, and makes Stripe replay its own refund\n // instead of moving the funds again.\n stripeKey: claimRef.id,\n record: async (status, payload) => {\n await claimRef\n .set(\n {\n status: 'done',\n responseStatus: status,\n response: payload,\n settledAtMs: Date.now(),\n },\n { merge: true },\n )\n .catch(() => undefined)\n },\n release: async () => {\n await claimRef.delete().catch(() => undefined)\n },\n }\n }\n\n // RESERVE. The cap is read and written in one transaction, so two\n // concurrent refunds cannot both see the same `refundedCents` — which is\n // the whole failure the old ordering had, since it wrote the counter only\n // after Stripe had already been asked to move the money. Reserving BEFORE\n // the call rather than after also fails in the safe direction: a lost\n // response leaves the amount counted, so the retry refunds less, never\n // more.\n let refundCents = 0\n let totalCents = 0\n await firestore.runTransaction(async (transaction) => {\n const fresh = CommerceModel.liftLegacyOrder(\n ((await transaction.get(orderRef)).data() ?? {}) as any,\n )\n totalCents = fresh.totals?.totalCents ?? Number(fresh.amountCents ?? 0)\n const alreadyRefunded = Number(fresh.refundedCents ?? 0)\n const remaining = totalCents - alreadyRefunded\n // Named lines with no amount refund exactly what those lines are worth;\n // named lines WITH an amount use the amount (already guarded above as\n // no smaller than the lines). Neither is the whole order, which is what\n // `amountCents == null` alone still means.\n const asked =\n amountCents != null\n ? Math.round(amountCents)\n : requestedLineIds.length > 0\n ? namedLinesCents\n : remaining\n refundCents = Math.min(asked, remaining)\n if (!(refundCents > 0)) {\n refundCents = 0\n return\n }\n transaction.set(\n orderRef,\n { refundedCents: alreadyRefunded + refundCents },\n { merge: true },\n )\n })\n if (!(refundCents > 0)) {\n // Nothing moved, so the attempt key is released rather than burned.\n await claim?.release()\n return res.status(400).json({ error: 'Nothing left to refund' })\n }\n // The cap bit into the named lines (AGL-2454): earlier partials have left\n // less on this order than the selected lines are worth. REFUSED rather\n // than refunded-and-revoked, because revoking a line for less than its\n // value is precisely the silent over-revocation this issue forbids. The\n // reservation is given back — the same compensation a Stripe refusal does\n // below, and for the same reason: nothing has left the account yet.\n if (requestedLineIds.length > 0 && refundCents < namedLinesCents) {\n await firestore\n .runTransaction(async (transaction) => {\n const current = Number(\n (await transaction.get(orderRef)).get('refundedCents') ?? 0,\n )\n transaction.set(\n orderRef,\n { refundedCents: Math.max(0, current - refundCents) },\n { merge: true },\n )\n })\n .catch(() => undefined)\n await claim?.release()\n return res.status(409).json({\n error:\n `Only $${(refundCents / 100).toFixed(2)} is left to refund on this ` +\n `order, and the lines you selected are worth $${(\n namedLinesCents / 100\n ).toFixed(2)}. Refund an amount without selecting lines instead.`,\n })\n }\n\n if (splitCards.length > 1) {\n // The card this refund comes off: the first with enough left on it.\n // A refund larger than any one card refuses with the largest that\n // fits, rather than splitting money across charges unasked; a part a\n // customer paid in cash or by gift card is handed back at the till.\n const capacity = (payment: CommerceModel.OrderPayment) =>\n payment.amountCents +\n Number(payment.tipCents ?? 0) -\n Number(splitRefunds[String(payment.paymentIntentId)] ?? 0)\n const card = splitCards.find((payment) => capacity(payment) >= refundCents)\n if (!card) {\n await firestore\n .runTransaction(async (transaction) => {\n const current = Number(\n (await transaction.get(orderRef)).get('refundedCents') ?? 0,\n )\n transaction.set(\n orderRef,\n { refundedCents: Math.max(0, current - refundCents) },\n { merge: true },\n )\n })\n .catch(() => undefined)\n await claim?.release()\n const largest = Math.max(0, ...splitCards.map(capacity))\n return res.status(409).json({\n error:\n 'This sale was paid with several cards, and no one card has ' +\n `$${(refundCents / 100).toFixed(2)} left to refund. Refund at most ` +\n `$${(largest / 100).toFixed(2)} at a time; hand back any part paid ` +\n 'in cash or by gift card at the register.',\n })\n }\n paymentIntentId = String(card.paymentIntentId)\n }\n\n const refund = await createStripeRefund({\n paymentIntentId: String(paymentIntentId),\n amountCents: refundCents,\n idempotencyKey: claim?.stripeKey ?? null,\n })\n if ('error' in refund) {\n console.error('Stripe refund error', refund.code, refund.error)\n // Stripe said no, so we KNOW no money moved: give the reservation back\n // and let the same attempt be tried again.\n await firestore\n .runTransaction(async (transaction) => {\n const current = Number(\n (await transaction.get(orderRef)).get('refundedCents') ?? 0,\n )\n transaction.set(\n orderRef,\n { refundedCents: Math.max(0, current - refundCents) },\n { merge: true },\n )\n })\n .catch(() => undefined)\n await claim?.release()\n // Stripe refusing BECAUSE OF A DISPUTE is the guard above arriving by\n // the other door — our order document simply didn't know yet (webhook\n // lag, or an order from before disputes were subscribed at all). Same\n // answer, same accuracy: a 409 naming the dispute (`createStripeRefund`\n // words it), not a 502 reading \"The charge you're attempting to refund\n // has been charged back\", which an admin has no reason to connect to\n // the Refund button they pressed.\n return res.status(refund.status === 409 ? 409 : 502).json({ error: refund.error })\n }\n\n let refundedCents = 0\n let fullyRefunded = false\n let closedTheOrder = false\n await firestore.runTransaction(async (transaction) => {\n const fresh = CommerceModel.liftLegacyOrder(\n ((await transaction.get(orderRef)).data() ?? {}) as any,\n )\n refundedCents = Number(fresh.refundedCents ?? 0)\n fullyRefunded = refundedCents >= totalCents\n // Whether THIS settle moved the order into `refunded`, which is not the\n // same question as whether the order is now fully refunded (AGL-1754).\n // Two partials that between them close an order can both reserve before\n // either settles, so both re-read the completed total and both compute\n // `fullyRefunded`. Writing `status: 'refunded'` twice is harmless;\n // incrementing a count twice is not. Reading the status inside the same\n // transaction that writes it makes the flip observable exactly once.\n closedTheOrder = fullyRefunded && fresh.status !== 'refunded'\n transaction.set(\n orderRef,\n {\n ...(fullyRefunded ? { status: 'refunded' } : {}),\n // What a shipping tool's feed asks to learn of it (AGL-3613).\n updatedAtMs: Date.now(),\n // Which card a split register sale's refund came off (AGL-3607).\n ...(splitCards.length > 1\n ? {\n paymentRefunds: {\n [String(paymentIntentId)]:\n firebaseAdmin.firestore.FieldValue.increment(refundCents),\n },\n }\n : {}),\n // The entitlement withdrawal, recorded WITH the money (AGL-2454).\n // `arrayUnion` rather than a written-back array: two admins refunding\n // different lines at once must not erase each other's, and this\n // transaction re-reads the order but a written array would still lose\n // a concurrent settle that committed between the two.\n ...(requestedLineIds.length > 0\n ? {\n refundedLineItemIds:\n firebaseAdmin.firestore.FieldValue.arrayUnion(\n ...requestedLineIds,\n ),\n }\n : {}),\n timeline: CommerceModel.appendOrderEvent(\n fresh,\n 'refund',\n `$${(refundCents / 100).toFixed(2)} refunded` +\n (fullyRefunded\n ? ' (full)'\n : requestedLineIds.length > 0\n ? ` — ${requestedLineIds.length} line${\n requestedLineIds.length === 1 ? '' : 's'\n } withdrawn`\n : ' — refunded by amount, no lines withdrawn'),\n ),\n },\n { merge: true },\n )\n })\n const payload = { refundedCents, fullyRefunded }\n await claim?.record(200, payload)\n // LICENCE KEYS ARE RETIRED, NEVER RETURNED TO THE POOL (AGL-2454).\n //\n // `assignLicenseKeys` stamps `assignedAtMs`, `orderId` and `email` onto a\n // pool document and nothing anywhere ever set them back — so a refunded\n // order consumed the merchant's key forever, and a merchant who sold one\n // key out of a hundred and refunded it had ninety-nine, permanently.\n //\n // Returning it to the pool is NOT the fix, and this is the decision the\n // issue asked for: the key string was mailed in the receipt and cannot be\n // invalidated by anything we own, so re-issuing it to the next paying\n // customer would hand two people one working secret. That is worse than\n // losing the key. A third state — retired: neither assigned to a live order\n // nor available — is the honest record, and `revokedAtMs` already IS that\n // state: the console's key dialog has written exactly this pair since it\n // shipped, so this reuses the merchant's own vocabulary rather than\n // inventing a second one.\n //\n // Best-effort and after the response is recorded, matching the contact and\n // restock ledgers below: the money has moved and nothing here may fail a\n // refund that already left the merchant's account.\n await retireLicenseKeys(hostRef, orderRef, orderId)\n // GIFT CARDS THE REFUND TOOK BACK ARE VOIDED (AGL-3363). The money went\n // back to the card that paid, so what is left on the cards that money\n // bought goes too — every card on a full refund, and on a partial one\n // only the cards bought by the lines it withdrew. A refund by amount with\n // no lines named withdraws nothing, so it voids nothing. Same best-effort\n // placement as the key retirement above: the refund has already moved.\n if (fullyRefunded || requestedLineIds.length > 0) {\n await applyGiftCardRiskToOrder({\n firestore,\n hostRef,\n orderId,\n action: {\n kind: 'void',\n reason: 'refund',\n ...(fullyRefunded\n ? {}\n : {\n productIds: requestedLineIds\n .map((index) => String(order.lineItems?.[index]?.productId ?? ''))\n .filter(Boolean),\n }),\n },\n })\n }\n // The customer's side of the ledger (AGL-1754). Everything above records\n // the money on the ORDER; without this the buyer's `ltvCents` still counts\n // a sale they returned, and only ever rises.\n //\n // Placed AFTER the attempt is recorded so a slow contacts write cannot\n // strand the claim: a retry that arrives while this is in flight replays\n // the recorded 200 instead of being turned away with \"already being\n // processed\". Awaited rather than fired off with `void` — the handler is\n // serverless, and work left running past the response is work the\n // container may be frozen before it finishes. The seam never throws, so\n // awaiting adds no way for this to fail a refund that has already left\n // the merchant's account.\n //\n // Through the plugin that keeps people (AGL-3080): what a refund does to\n // the customer's record is that plugin's to decide, and commerce writes\n // none of it.\n //\n // Amount is THIS attempt's `refundCents`, already capped against what was\n // left, so several partials sum to at most the order total — the same\n // number the order's own `refundedCents` follows. The retried and racing\n // cases need no key of their own: a keyed retry never reaches here (it\n // replays at the claim), and a keyless one is a genuinely new refund that\n // moved more money and should be counted.\n await recordPluginPersonRefund({\n hostId,\n refId: orderId,\n email: order.customerEmail,\n amountCents: refundCents,\n closedTheSale: closedTheOrder,\n })\n /*\n * The campaign's side of the same ledger.\n *\n * If a campaign was credited with this order, that credit is now partly\n * or wholly wrong — a campaign cannot go on being paid for a sale the\n * merchant reversed, and revenue attribution that only ever rises is the\n * flattering half of a measurement. Recorded beside the gross rather than\n * subtracted from it, for the reason the customer's record keeps its\n * refunds beside what they spent.\n *\n * Keyed by the ORDER and not by the buyer, so it needs no email and works\n * for a guest checkout: the attribution record holds which campaign and\n * which currency, and this reads them back. Same placement, same\n * swallow-all contract and same awaited call as the two ledgers around\n * it — nothing here may fail a refund that has already left the\n * merchant's account.\n */\n await reverseOrderConversion({\n hostId,\n orderId,\n amountCents: refundCents,\n closedTheOrder,\n })\n // The shelf's side of the ledger (AGL-1797). The sale decremented variant\n // inventory and nothing put it back, so a fully refunded order read one\n // unit light forever. This FLAGS rather than releases — a refund with no\n // return leaves the goods gone, and inventing stock the merchant does not\n // have is worse than under-counting it — and the merchant answers from the\n // stock adjustment they already have. Same placement and same swallow-all\n // contract as the contact write above, for the same reason: the money has\n // moved and the order records it, so nothing here may fail the refund.\n await flagOrderRestock({ hostId, orderId, kind: 'refund', closedTheOrder })\n // The buyer is told what came back (AGL-3610), once per Stripe refund id —\n // a keyed retry replays at the claim above and never reaches here.\n await notifyOrderBuyer({ hostId, orderId }, 'refunded', { refundId: refund.refundId, refundCents, refundLineIndexes: requestedLineIds, fullyRefunded })\n // Other plugins and the merchant's webhooks hear it too (AGL-3611), once per refund.\n await raiseOrderEvent(ORDER_REFUNDED_EVENT, { hostId, orderId, key: `refund:${refund.refundId || idempotencyKey}`, extra: { refund: { id: refund.refundId || null, amountCents: refundCents, lineItemIds: [...(requestedLineIds ?? [])], full: Boolean(fullyRefunded) } } })\n return res.status(200).json(payload)\n } catch (error) {\n console.error(error)\n // Deliberately NOT released, which is where this diverges from the POS\n // sale path (AGL-1691). If the refund call threw we do not know whether\n // Stripe moved the money, and the two failure directions are not\n // symmetric: a stranded key costs a support ticket, a released one costs a\n // second refund. The retry gets a 409 and a human reconciles.\n return res.status(500).json({ error: 'Refund failed' })\n }\n}\n"],"names":["Aglyn","CommerceModel","firebaseAdmin","getOrgForHost","resolveOrgPermissions","createHash","recordPluginPersonRefund","applyGiftCardRiskToOrder","flagOrderRestock","notifyOrderBuyer","ORDER_REFUNDED_EVENT","raiseOrderEvent","reverseOrderConversion","createStripeRefund","posRegisterRefundAuthority","retireLicenseKeys","hostRef","orderRef","orderId","fresh","liftLegacyOrder","get","data","withdrawn","Set","refundedProductIds","size","assigned","collection","where","limit","retired","keySnapshot","docs","has","String","ref","set","revokedAtMs","Date","now","revokedOrderId","merge","catch","undefined","update","timeline","firestore","FieldValue","arrayUnion","atMs","event","detail","error","console","refundHandler","req","res","body","method","status","json","process","env","STRIPE_SECRET_KEY","authorization","headers","idToken","startsWith","slice","length","JSON","parse","hostId","amountCents","Number","requestedLineIds","Array","isArray","lineItemIds","map","value","Math","round","filter","isFinite","sort","a","b","idempotencyKey","trim","claim","hostSnapshot","order","splitCards","membership","decoded","app","auth","verifyIdToken","doc","exists","memberRole","uid","workspaceAdmin","orgWide","hostRole","registerStaff","permissions","managePos","orderSnapshot","authority","lineCents","orderLineRefundCents","registerId","managerAssertion","claimRef","digest","prior","priorResponse","canTransitionOrder","orderDisputeBlocksRefund","orderLines","lineItems","invalidLine","find","index","namedLinesCents","orderPayments","payment","isCardPaymentMethod","Boolean","paymentIntentId","splitRefunds","paymentRefunds","response","fetch","Authorization","session","ok","payment_intent","ownerOrg","create","orgId","org","id","kind","createdAt","serverTimestamp","createdAtMs","expiresAt","apiIdempotencyExpiry","stripeKey","record","payload","responseStatus","settledAtMs","release","delete","refundCents","totalCents","runTransaction","transaction","totals","alreadyRefunded","refundedCents","remaining","asked","min","current","max","toFixed","capacity","tipCents","card","largest","refund","code","fullyRefunded","closedTheOrder","updatedAtMs","increment","refundedLineItemIds","appendOrderEvent","action","reason","productIds","productId","refId","email","customerEmail","closedTheSale","refundId","refundLineIndexes","key","extra","full"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/refund.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport * as Aglyn from '@aglyn/aglyn/server'\nimport * as CommerceModel from '../model'\nimport { firebaseAdmin, getOrgForHost } from '@aglyn/tenant-data-admin'\nimport { type PluginApiHandler } from '@aglyn/aglyn/server'\nimport { resolveOrgPermissions } from '@aglyn/tenant-runtime/org-permissions'\nimport { createHash } from 'crypto'\nimport { recordPluginPersonRefund } from '@aglyn/aglyn/plugin-manager/plugin-person-records'\nimport { applyGiftCardRiskToOrder } from './gift-card-risk'\nimport { flagOrderRestock } from './restock-flag'\nimport { notifyOrderBuyer } from './order-notifications'\nimport { ORDER_REFUNDED_EVENT } from '../model/order-events'\nimport { raiseOrderEvent } from './order-events'\nimport { reverseOrderConversion } from '@aglyn/aglyn/plugin-manager/plugin-conversion-credit'\nimport { createStripeRefund } from './stripe-refund'\nimport { posRegisterRefundAuthority } from './pos-refund-authority'\n\n/**\n * A claim on one refund attempt (AGL-1696), the same primitive the POS sale\n * path uses (AGL-1691, `cab8aa36e`).\n */\ninterface RefundClaim {\n /** Stripe idempotency key for this attempt, or null when no key was sent. */\n stripeKey: string | null\n record: (status: number, body: unknown) => Promise<void>\n release: () => Promise<void>\n}\n\n/**\n * Retires the licence keys a refund withdrew (AGL-2454).\n *\n * Reads the order back so the decision is made from what actually SETTLED\n * rather than from what this request asked for — a concurrent partial may have\n * closed a line between the two — and retires only the keys of products the\n * order no longer entitles at all. `refundedProductIds` is deliberately strict\n * about that: a buyer who returned one of two copies still holds the product,\n * so nothing is retired for them.\n *\n * `revokedAtMs` with `assignedAtMs` left standing is the retired state. Keeping\n * `assignedAtMs` matters: `assignLicenseKeys` claims from\n * `where('assignedAtMs','==',null)`, so clearing it would put the key straight\n * back in front of the next buyer — the reissue this must not do.\n *\n * Swallows everything. The refund has already moved money.\n */\nasync function retireLicenseKeys(\n hostRef: FirebaseFirestore.DocumentReference,\n orderRef: FirebaseFirestore.DocumentReference,\n orderId: string,\n): Promise<number> {\n try {\n const fresh = CommerceModel.liftLegacyOrder(\n ((await orderRef.get()).data() ?? {}) as any,\n )\n const withdrawn = new Set(CommerceModel.refundedProductIds(fresh))\n if (withdrawn.size === 0) return 0\n const assigned = await hostRef\n .collection('licenseKeys')\n .where('orderId', '==', orderId)\n .limit(500)\n .get()\n let retired = 0\n for (const keySnapshot of assigned.docs) {\n if (!withdrawn.has(String(keySnapshot.get('productId') ?? ''))) continue\n // Already retired by an earlier partial, or by the merchant's own Revoke\n // button. Skipped so a second refund does not restamp the timestamp and\n // make the retirement look newer than it is.\n if (keySnapshot.get('revokedAtMs') != null) continue\n await keySnapshot.ref\n .set({ revokedAtMs: Date.now(), revokedOrderId: orderId }, { merge: true })\n .catch(() => undefined)\n retired++\n }\n if (retired > 0) {\n await orderRef\n .update({\n timeline: firebaseAdmin.firestore.FieldValue.arrayUnion({\n atMs: Date.now(),\n event: 'license-retired',\n detail:\n `${retired} license key${retired === 1 ? '' : 's'} retired. ` +\n 'The buyer already holds the key string, so it is not returned ' +\n 'to the pool — reissuing it would give two people one secret.',\n }),\n })\n .catch(() => undefined)\n }\n return retired\n } catch (error) {\n console.error('License key retirement failed', orderId, error)\n return 0\n }\n}\n\n/**\n * Order refunds (AGL-287): full or partial via Stripe, site-admin only\n * (it moves money). Destination charges reverse the transfer and the\n * platform fee proportionally. Full refunds transition the order to\n * `refunded`; partial refunds accumulate `refundedCents` and stay in\n * the current status.\n *\n * Two SEPARATE controls guard the money (AGL-1696), and conflating them is\n * how the original went wrong:\n *\n * - The idempotency key stops a DUPLICATE refund — one attempt sent twice\n * because the response was lost, the admin double-clicked, or a client\n * retried. It is minted per attempt by the console and deliberately not\n * derived from the order or the amount: two $10 refunds on a $50 order are\n * two real refunds, exactly as a cashier ringing the same coffee twice is a\n * real second sale.\n * - The cap stops an OVER-refund — several partials summing past what was\n * captured, including two admins refunding at once, where the two attempts\n * are genuinely distinct and no key can help. That needs the counter read\n * and written inside one transaction.\n *\n * The original had a cap that looked like both and was neither: it read\n * `refundedCents` and wrote it back only AFTER the Stripe call, outside any\n * transaction. A guard that reads state the guarded operation writes too late\n * is not a guard — measured, two concurrent refunds each sent a full $50 to\n * Stripe with no idempotency header on either.\n */\nexport const refundHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n if (!process.env.STRIPE_SECRET_KEY) {\n return res.status(501).json({ error: 'Payments are not configured.' })\n }\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) return res.status(401).json({ error: 'Unauthenticated' })\n const body =\n typeof req.body === 'string' ? JSON.parse(req.body) : (req.body ?? {})\n const hostId = String(body.hostId ?? '')\n const orderId = String(body.orderId ?? '')\n const amountCents = body.amountCents == null ? null : Number(body.amountCents)\n /**\n * Lines the admin is refunding BY NAME (AGL-2454), and therefore the lines\n * whose digital entitlements come back. Optional: an amount-only refund is\n * still a legal refund and still revokes nothing per-line — see the guard\n * below for why that is a decision rather than an oversight.\n */\n const requestedLineIds: number[] = Array.isArray(body.lineItemIds)\n ? [\n ...new Set(\n (body.lineItemIds as unknown[])\n .map((value) => Math.round(Number(value)))\n .filter((value) => Number.isFinite(value) && value >= 0),\n ),\n ].sort((a, b) => a - b)\n : []\n // One refund attempt, minted by the console. Node lowercases incoming\n // headers, but read both spellings — the plugin API request type makes no\n // promise about casing.\n const idempotencyKey = String(\n req.headers['idempotency-key'] ?? req.headers['Idempotency-Key'] ?? '',\n )\n .trim()\n .slice(0, 200)\n if (!hostId || !orderId) {\n return res.status(400).json({ error: 'Missing hostId or orderId' })\n }\n\n let claim: RefundClaim | null = null\n try {\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(hostId)\n const hostSnapshot = await hostRef.get()\n if (!hostSnapshot.exists) {\n return res.status(404).json({ error: 'Unknown site' })\n }\n // TWO CHECKS, and they answer different questions (AGL-2372).\n //\n // The first is the HOST-level fact the Firestore rules also enforce, and\n // it stays: `memberRoles` is the projection the rules read, so dropping it\n // here would let this route and the database disagree.\n const memberRole = (hostSnapshot.get('memberRoles') ?? {})[decoded.uid]\n // The second is WHOSE admin, and it is the one this gate was missing.\n //\n // `memberRoles` is a per-host projection of `hostAccess`, and\n // `/api/hosts/members` will grant a SITE COLLABORATOR `admin` on one site\n // (`hostAccess: { [hostId]: 'admin' }`, `allHosts: false`). That writes the\n // literal string `'admin'` into `memberRoles[uid]` — byte-identical to an\n // org owner's. So the check above cannot tell a contractor invited to run\n // one microsite from the person who owns the business, and refunding is\n // money leaving that business.\n //\n // `orgWide` is the discriminator, and it needs no new role: it is\n // `isOrgWideMember` (AGL-1026) — owner/admin of the org, an explicit\n // `allHosts` member, or the legacy pre-`allHosts` shape — and it is false\n // for every scoped collaborator. Same pairing `pos-order.ts` uses.\n //\n // BOTH halves are required. `hostRole` is re-tested rather than assumed\n // from `memberRole`: an org-wide member can still be scoped down to\n // `editor` on this host, and `resolveOrgPermissions` is the resolver that\n // knows it. It fails CLOSED on a lookup error when a host is named\n // (AGL-506), and `denied()` returns `orgWide: false` / `hostRole: null`,\n // so an absent membership refuses rather than folding to permitted.\n const membership = await resolveOrgPermissions(decoded.uid, { hostId })\n const workspaceAdmin =\n memberRole === 'admin' &&\n membership.orgWide === true &&\n membership.hostRole === 'admin'\n // THE REGISTER'S OWN REFUNDS (AGL-3609). A member who works the register\n // — `admin` or `editor` on this site with `managePos` — may refund a POS\n // order up to the site's cashier refund limit, and above it with a\n // workspace admin's PIN. Decided below, once the order and the amount are\n // known; every other refund still needs the workspace admin above.\n const registerStaff =\n !workspaceAdmin &&\n (memberRole === 'admin' || memberRole === 'editor') &&\n membership.permissions?.managePos === true\n if (!workspaceAdmin && !registerStaff) {\n return res.status(403).json({\n error:\n memberRole !== 'admin'\n ? 'Refunds require a site admin'\n : 'Refunds require an admin of the whole workspace',\n })\n }\n const orderRef = hostRef.collection('orders').doc(orderId)\n const orderSnapshot = await orderRef.get()\n if (!orderSnapshot.exists) {\n return res.status(404).json({ error: 'Unknown order' })\n }\n const order = CommerceModel.liftLegacyOrder(orderSnapshot.data() as any)\n if (registerStaff) {\n const authority = await posRegisterRefundAuthority({\n hostId,\n hostRef,\n order,\n amountCents,\n lineCents: requestedLineIds.length\n ? CommerceModel.orderLineRefundCents(order, requestedLineIds)\n : null,\n registerId: String(body.registerId ?? (order as { registerId?: string }).registerId ?? ''),\n managerAssertion: body.managerAssertion,\n })\n if ('body' in authority) return res.status(403).json(authority.body)\n }\n\n // Replay a settled attempt before anything else can reject it. This read\n // is only a short-circuit, never the dedupe primitive — the atomic\n // `create()` below is. It has to run ahead of the status guard because a\n // retried FULL refund would otherwise be answered \"orders in refunded\n // cannot refund\", which is the right money outcome reported as a failure,\n // and an admin who reads it as a failure refunds again by hand.\n const claimRef = idempotencyKey\n ? firestore\n .collection('apiIdempotency')\n .doc(\n createHash('sha256')\n // Scoped by the order, so a client that reused one key across\n // two orders cannot dedupe two legitimately distinct refunds.\n // NOT by the amount: that would swallow a real second partial.\n .update(`refund:${hostId}:${orderId}:${idempotencyKey}`)\n .digest('hex'),\n )\n : null\n if (claimRef) {\n const prior = await claimRef.get()\n const priorResponse = prior.get('response')\n if (priorResponse) {\n return res\n .status(Number(prior.get('responseStatus') ?? 200))\n .json(priorResponse)\n }\n }\n\n if (!CommerceModel.canTransitionOrder(order.status, 'refunded')) {\n return res\n .status(409)\n .json({ error: `Orders in \"${order.status}\" cannot refund` })\n }\n // A refund does not withdraw a dispute (AGL-1809). While a chargeback is\n // formally open the bank has already pulled the disputed funds, Stripe's\n // refund API refuses the charge (`charge_disputed`), and a refund that did\n // go through would pay the shopper twice — the merchant loses the refund\n // AND the dispute plus its fee. Refused HERE, before the claim and the\n // reservation, so a refusal burns no idempotency key and strands nothing:\n // no state has been written yet (AGL-1754's contract). An open INQUIRY\n // (`warning_*`) deliberately passes — no funds have moved and Stripe names\n // a full refund as the way to resolve one before it escalates — and the\n // status guard above already turns away a LOST dispute, which parked the\n // order in `refunded`. This reads the pre-transaction snapshot; a dispute\n // webhook racing this exact request is caught by the `charge_disputed`\n // mapping on the Stripe response below.\n if (CommerceModel.orderDisputeBlocksRefund(order)) {\n return res.status(409).json({\n error:\n 'A chargeback is open on this order, so it was not refunded. ' +\n 'Refunding would not withdraw the dispute — the bank has already ' +\n 'taken the disputed amount, and a refund on top of it would pay ' +\n 'the shopper twice. Respond to the dispute or accept it in the ' +\n 'Stripe dashboard; refund any remainder once it settles.',\n })\n }\n // WHAT THE NAMED LINES ARE WORTH, AND WHY THE AMOUNT MAY NOT BE LESS\n // (AGL-2454).\n //\n // A refund carries an amount, not lines — that is the blocker this issue\n // names, and `restock-flag.ts:48-55` already records it for stock. It\n // cannot be solved by inference: deciding for the merchant which lines a\n // bare figure covers would be a guess about their goods. It CAN be solved\n // by asking, which is what naming lines does, and the amount is then\n // derived from them rather than typed beside them.\n //\n // An explicit `amountCents` may still be LARGER (the admin is refunding the\n // line plus its share of tax or shipping, which this items-only sum does\n // not include). It may not be SMALLER: revoking a line the refund did not\n // actually cover is the silent over-revocation this issue forbids in the\n // same breath as silent under-revocation.\n const orderLines = order.lineItems ?? []\n const invalidLine = requestedLineIds.find(\n (index) => index >= orderLines.length,\n )\n if (invalidLine != null) {\n return res\n .status(400)\n .json({ error: `Line ${invalidLine} is not on this order` })\n }\n // NET OF THE ORDER'S DISCOUNT, not the list price.\n //\n // This was the bare `unitAmountCents x quantity`, which is what the line\n // was LISTED at rather than what the buyer paid for it. On a discounted\n // order the two differ, and both directions of the error land on the\n // merchant: a $10 coupon over two $50 lines means each line cost $45, so\n // refunding one at $50 gave back $5 that was never taken, and the order\n // then held less than its remaining line was worth — so the second line\n // refund hit the cap below and was refused outright, leaving the merchant\n // unable to finish a refund they had already half-issued.\n //\n // `orderLineRefundCents` apportions the discount across every line by list\n // value and returns the named lines' share, so refunding all of them sums\n // to exactly what was charged and no cent is stranded or invented.\n const namedLinesCents = CommerceModel.orderLineRefundCents(\n order,\n requestedLineIds,\n )\n if (\n requestedLineIds.length > 0 &&\n amountCents != null &&\n Math.round(amountCents) < namedLinesCents\n ) {\n return res.status(400).json({\n error:\n 'That amount is less than the lines you selected are worth. ' +\n 'Refund the full value of those lines, or refund an amount ' +\n 'without selecting lines.',\n })\n }\n // A register sale paid with SEVERAL cards (AGL-3607) has no single\n // charge to refund: each refund is taken from one card that can cover it.\n const splitCards = CommerceModel.orderPayments(order).filter(\n (payment) =>\n payment.status === 'succeeded' &&\n CommerceModel.isCardPaymentMethod(payment.method) &&\n Boolean(payment.paymentIntentId),\n )\n const splitRefunds = (order as { paymentRefunds?: Record<string, number> }).paymentRefunds ?? {}\n let paymentIntentId =\n (splitCards.length > 1 ? splitCards[0].paymentIntentId : undefined) ??\n order.paymentIntentId ??\n // Legacy rows stored the checkout session as the doc id; resolve\n // the payment intent from Stripe.\n (await (async () => {\n const response = await fetch(\n `https://api.stripe.com/v1/checkout/sessions/${orderId}`,\n {\n headers: {\n Authorization: `Bearer ${process.env.STRIPE_SECRET_KEY}`,\n },\n },\n )\n const session = await response.json()\n return response.ok ? session?.payment_intent : null\n })())\n if (!paymentIntentId) {\n return res.status(409).json({ error: 'No payment to refund' })\n }\n\n // Point of no return: everything past here moves money. The claim is\n // `create()` — Firestore rejects a create on an existing document, and\n // that rejection IS the dedupe primitive. A read-then-write would race\n // exactly the double-submit it exists to stop. Storage reuses the REST\n // API's shape and its `orgId` field (AGL-618) rather than inventing a\n // second replay store, so `eraseOrgIdempotencyKeys` (AGL-1448) already\n // sweeps these on org erasure with no change there.\n if (claimRef) {\n const ownerOrg = await getOrgForHost(hostId)\n try {\n await claimRef.create({\n orgId: String(ownerOrg?.org?.id ?? '') || null,\n hostId,\n orderId,\n kind: 'commerce-refund',\n // The site, as every claim names its scope: the staff claims list\n // sorts by `scopeId`, and an `orderBy` drops a claim without it.\n scopeId: hostId,\n status: 'pending',\n createdAt: firebaseAdmin.firestore.FieldValue.serverTimestamp(),\n createdAtMs: Date.now(),\n // The second writer into `apiIdempotency` (AGL-1978). The shared\n // `claimAttempt` stamps this too; this local copy has to as well,\n // or refund claims are the one shape the TTL policy silently never\n // reaches — a policy that governs most of a collection reads, from\n // the outside, exactly like one that governs all of it.\n expiresAt: Aglyn.apiIdempotencyExpiry(),\n })\n } catch {\n const prior = await claimRef.get()\n const priorResponse = prior.get('response')\n if (priorResponse) {\n return res\n .status(Number(prior.get('responseStatus') ?? 200))\n .json(priorResponse)\n }\n // In flight, or stranded by a process that died mid-refund. Fail\n // CLOSED: the alternative is sending the money a second time.\n return res\n .status(409)\n .json({ error: 'This refund is already being processed' })\n }\n claim = {\n // The same digest goes to Stripe. That is the half that costs real\n // money: it covers the window where our claim is written but the\n // response never arrives, and makes Stripe replay its own refund\n // instead of moving the funds again.\n stripeKey: claimRef.id,\n record: async (status, payload) => {\n await claimRef\n .set(\n {\n status: 'done',\n responseStatus: status,\n response: payload,\n settledAtMs: Date.now(),\n },\n { merge: true },\n )\n .catch(() => undefined)\n },\n release: async () => {\n await claimRef.delete().catch(() => undefined)\n },\n }\n }\n\n // RESERVE. The cap is read and written in one transaction, so two\n // concurrent refunds cannot both see the same `refundedCents` — which is\n // the whole failure the old ordering had, since it wrote the counter only\n // after Stripe had already been asked to move the money. Reserving BEFORE\n // the call rather than after also fails in the safe direction: a lost\n // response leaves the amount counted, so the retry refunds less, never\n // more.\n let refundCents = 0\n let totalCents = 0\n await firestore.runTransaction(async (transaction) => {\n const fresh = CommerceModel.liftLegacyOrder(\n ((await transaction.get(orderRef)).data() ?? {}) as any,\n )\n totalCents = fresh.totals?.totalCents ?? Number(fresh.amountCents ?? 0)\n const alreadyRefunded = Number(fresh.refundedCents ?? 0)\n const remaining = totalCents - alreadyRefunded\n // Named lines with no amount refund exactly what those lines are worth;\n // named lines WITH an amount use the amount (already guarded above as\n // no smaller than the lines). Neither is the whole order, which is what\n // `amountCents == null` alone still means.\n const asked =\n amountCents != null\n ? Math.round(amountCents)\n : requestedLineIds.length > 0\n ? namedLinesCents\n : remaining\n refundCents = Math.min(asked, remaining)\n if (!(refundCents > 0)) {\n refundCents = 0\n return\n }\n transaction.set(\n orderRef,\n { refundedCents: alreadyRefunded + refundCents },\n { merge: true },\n )\n })\n if (!(refundCents > 0)) {\n // Nothing moved, so the attempt key is released rather than burned.\n await claim?.release()\n return res.status(400).json({ error: 'Nothing left to refund' })\n }\n // The cap bit into the named lines (AGL-2454): earlier partials have left\n // less on this order than the selected lines are worth. REFUSED rather\n // than refunded-and-revoked, because revoking a line for less than its\n // value is precisely the silent over-revocation this issue forbids. The\n // reservation is given back — the same compensation a Stripe refusal does\n // below, and for the same reason: nothing has left the account yet.\n if (requestedLineIds.length > 0 && refundCents < namedLinesCents) {\n await firestore\n .runTransaction(async (transaction) => {\n const current = Number(\n (await transaction.get(orderRef)).get('refundedCents') ?? 0,\n )\n transaction.set(\n orderRef,\n { refundedCents: Math.max(0, current - refundCents) },\n { merge: true },\n )\n })\n .catch(() => undefined)\n await claim?.release()\n return res.status(409).json({\n error:\n `Only $${(refundCents / 100).toFixed(2)} is left to refund on this ` +\n `order, and the lines you selected are worth $${(\n namedLinesCents / 100\n ).toFixed(2)}. Refund an amount without selecting lines instead.`,\n })\n }\n\n if (splitCards.length > 1) {\n // The card this refund comes off: the first with enough left on it.\n // A refund larger than any one card refuses with the largest that\n // fits, rather than splitting money across charges unasked; a part a\n // customer paid in cash or by gift card is handed back at the till.\n const capacity = (payment: CommerceModel.OrderPayment) =>\n payment.amountCents +\n Number(payment.tipCents ?? 0) -\n Number(splitRefunds[String(payment.paymentIntentId)] ?? 0)\n const card = splitCards.find((payment) => capacity(payment) >= refundCents)\n if (!card) {\n await firestore\n .runTransaction(async (transaction) => {\n const current = Number(\n (await transaction.get(orderRef)).get('refundedCents') ?? 0,\n )\n transaction.set(\n orderRef,\n { refundedCents: Math.max(0, current - refundCents) },\n { merge: true },\n )\n })\n .catch(() => undefined)\n await claim?.release()\n const largest = Math.max(0, ...splitCards.map(capacity))\n return res.status(409).json({\n error:\n 'This sale was paid with several cards, and no one card has ' +\n `$${(refundCents / 100).toFixed(2)} left to refund. Refund at most ` +\n `$${(largest / 100).toFixed(2)} at a time; hand back any part paid ` +\n 'in cash or by gift card at the register.',\n })\n }\n paymentIntentId = String(card.paymentIntentId)\n }\n\n const refund = await createStripeRefund({\n paymentIntentId: String(paymentIntentId),\n amountCents: refundCents,\n idempotencyKey: claim?.stripeKey ?? null,\n })\n if ('error' in refund) {\n console.error('Stripe refund error', refund.code, refund.error)\n // Stripe said no, so we KNOW no money moved: give the reservation back\n // and let the same attempt be tried again.\n await firestore\n .runTransaction(async (transaction) => {\n const current = Number(\n (await transaction.get(orderRef)).get('refundedCents') ?? 0,\n )\n transaction.set(\n orderRef,\n { refundedCents: Math.max(0, current - refundCents) },\n { merge: true },\n )\n })\n .catch(() => undefined)\n await claim?.release()\n // Stripe refusing BECAUSE OF A DISPUTE is the guard above arriving by\n // the other door — our order document simply didn't know yet (webhook\n // lag, or an order from before disputes were subscribed at all). Same\n // answer, same accuracy: a 409 naming the dispute (`createStripeRefund`\n // words it), not a 502 reading \"The charge you're attempting to refund\n // has been charged back\", which an admin has no reason to connect to\n // the Refund button they pressed.\n return res.status(refund.status === 409 ? 409 : 502).json({ error: refund.error })\n }\n\n let refundedCents = 0\n let fullyRefunded = false\n let closedTheOrder = false\n await firestore.runTransaction(async (transaction) => {\n const fresh = CommerceModel.liftLegacyOrder(\n ((await transaction.get(orderRef)).data() ?? {}) as any,\n )\n refundedCents = Number(fresh.refundedCents ?? 0)\n fullyRefunded = refundedCents >= totalCents\n // Whether THIS settle moved the order into `refunded`, which is not the\n // same question as whether the order is now fully refunded (AGL-1754).\n // Two partials that between them close an order can both reserve before\n // either settles, so both re-read the completed total and both compute\n // `fullyRefunded`. Writing `status: 'refunded'` twice is harmless;\n // incrementing a count twice is not. Reading the status inside the same\n // transaction that writes it makes the flip observable exactly once.\n closedTheOrder = fullyRefunded && fresh.status !== 'refunded'\n transaction.set(\n orderRef,\n {\n ...(fullyRefunded ? { status: 'refunded' } : {}),\n // What a shipping tool's feed asks to learn of it (AGL-3613).\n updatedAtMs: Date.now(),\n // Which card a split register sale's refund came off (AGL-3607).\n ...(splitCards.length > 1\n ? {\n paymentRefunds: {\n [String(paymentIntentId)]:\n firebaseAdmin.firestore.FieldValue.increment(refundCents),\n },\n }\n : {}),\n // The entitlement withdrawal, recorded WITH the money (AGL-2454).\n // `arrayUnion` rather than a written-back array: two admins refunding\n // different lines at once must not erase each other's, and this\n // transaction re-reads the order but a written array would still lose\n // a concurrent settle that committed between the two.\n ...(requestedLineIds.length > 0\n ? {\n refundedLineItemIds:\n firebaseAdmin.firestore.FieldValue.arrayUnion(\n ...requestedLineIds,\n ),\n }\n : {}),\n timeline: CommerceModel.appendOrderEvent(\n fresh,\n 'refund',\n `$${(refundCents / 100).toFixed(2)} refunded` +\n (fullyRefunded\n ? ' (full)'\n : requestedLineIds.length > 0\n ? ` — ${requestedLineIds.length} line${\n requestedLineIds.length === 1 ? '' : 's'\n } withdrawn`\n : ' — refunded by amount, no lines withdrawn'),\n ),\n },\n { merge: true },\n )\n })\n const payload = { refundedCents, fullyRefunded }\n await claim?.record(200, payload)\n // LICENCE KEYS ARE RETIRED, NEVER RETURNED TO THE POOL (AGL-2454).\n //\n // `assignLicenseKeys` stamps `assignedAtMs`, `orderId` and `email` onto a\n // pool document and nothing anywhere ever set them back — so a refunded\n // order consumed the merchant's key forever, and a merchant who sold one\n // key out of a hundred and refunded it had ninety-nine, permanently.\n //\n // Returning it to the pool is NOT the fix, and this is the decision the\n // issue asked for: the key string was mailed in the receipt and cannot be\n // invalidated by anything we own, so re-issuing it to the next paying\n // customer would hand two people one working secret. That is worse than\n // losing the key. A third state — retired: neither assigned to a live order\n // nor available — is the honest record, and `revokedAtMs` already IS that\n // state: the console's key dialog has written exactly this pair since it\n // shipped, so this reuses the merchant's own vocabulary rather than\n // inventing a second one.\n //\n // Best-effort and after the response is recorded, matching the contact and\n // restock ledgers below: the money has moved and nothing here may fail a\n // refund that already left the merchant's account.\n await retireLicenseKeys(hostRef, orderRef, orderId)\n // GIFT CARDS THE REFUND TOOK BACK ARE VOIDED (AGL-3363). The money went\n // back to the card that paid, so what is left on the cards that money\n // bought goes too — every card on a full refund, and on a partial one\n // only the cards bought by the lines it withdrew. A refund by amount with\n // no lines named withdraws nothing, so it voids nothing. Same best-effort\n // placement as the key retirement above: the refund has already moved.\n if (fullyRefunded || requestedLineIds.length > 0) {\n await applyGiftCardRiskToOrder({\n firestore,\n hostRef,\n orderId,\n action: {\n kind: 'void',\n reason: 'refund',\n ...(fullyRefunded\n ? {}\n : {\n productIds: requestedLineIds\n .map((index) => String(order.lineItems?.[index]?.productId ?? ''))\n .filter(Boolean),\n }),\n },\n })\n }\n // The customer's side of the ledger (AGL-1754). Everything above records\n // the money on the ORDER; without this the buyer's `ltvCents` still counts\n // a sale they returned, and only ever rises.\n //\n // Placed AFTER the attempt is recorded so a slow contacts write cannot\n // strand the claim: a retry that arrives while this is in flight replays\n // the recorded 200 instead of being turned away with \"already being\n // processed\". Awaited rather than fired off with `void` — the handler is\n // serverless, and work left running past the response is work the\n // container may be frozen before it finishes. The seam never throws, so\n // awaiting adds no way for this to fail a refund that has already left\n // the merchant's account.\n //\n // Through the plugin that keeps people (AGL-3080): what a refund does to\n // the customer's record is that plugin's to decide, and commerce writes\n // none of it.\n //\n // Amount is THIS attempt's `refundCents`, already capped against what was\n // left, so several partials sum to at most the order total — the same\n // number the order's own `refundedCents` follows. The retried and racing\n // cases need no key of their own: a keyed retry never reaches here (it\n // replays at the claim), and a keyless one is a genuinely new refund that\n // moved more money and should be counted.\n await recordPluginPersonRefund({\n hostId,\n refId: orderId,\n email: order.customerEmail,\n amountCents: refundCents,\n closedTheSale: closedTheOrder,\n })\n /*\n * The campaign's side of the same ledger.\n *\n * If a campaign was credited with this order, that credit is now partly\n * or wholly wrong — a campaign cannot go on being paid for a sale the\n * merchant reversed, and revenue attribution that only ever rises is the\n * flattering half of a measurement. Recorded beside the gross rather than\n * subtracted from it, for the reason the customer's record keeps its\n * refunds beside what they spent.\n *\n * Keyed by the ORDER and not by the buyer, so it needs no email and works\n * for a guest checkout: the attribution record holds which campaign and\n * which currency, and this reads them back. Same placement, same\n * swallow-all contract and same awaited call as the two ledgers around\n * it — nothing here may fail a refund that has already left the\n * merchant's account.\n */\n await reverseOrderConversion({\n hostId,\n orderId,\n amountCents: refundCents,\n closedTheOrder,\n })\n // The shelf's side of the ledger (AGL-1797). The sale decremented variant\n // inventory and nothing put it back, so a fully refunded order read one\n // unit light forever. This FLAGS rather than releases — a refund with no\n // return leaves the goods gone, and inventing stock the merchant does not\n // have is worse than under-counting it — and the merchant answers from the\n // stock adjustment they already have. Same placement and same swallow-all\n // contract as the contact write above, for the same reason: the money has\n // moved and the order records it, so nothing here may fail the refund.\n await flagOrderRestock({ hostId, orderId, kind: 'refund', closedTheOrder })\n // The buyer is told what came back (AGL-3610), once per Stripe refund id —\n // a keyed retry replays at the claim above and never reaches here.\n await notifyOrderBuyer({ hostId, orderId }, 'refunded', { refundId: refund.refundId, refundCents, refundLineIndexes: requestedLineIds, fullyRefunded })\n // Other plugins and the merchant's webhooks hear it too (AGL-3611), once per refund.\n await raiseOrderEvent(ORDER_REFUNDED_EVENT, { hostId, orderId, key: `refund:${refund.refundId || idempotencyKey}`, extra: { refund: { id: refund.refundId || null, amountCents: refundCents, lineItemIds: [...(requestedLineIds ?? [])], full: Boolean(fullyRefunded) } } })\n return res.status(200).json(payload)\n } catch (error) {\n console.error(error)\n // Deliberately NOT released, which is where this diverges from the POS\n // sale path (AGL-1691). If the refund call threw we do not know whether\n // Stripe moved the money, and the two failure directions are not\n // symmetric: a stranded key costs a support ticket, a released one costs a\n // second refund. The retry gets a 409 and a human reconciles.\n return res.status(500).json({ error: 'Refund failed' })\n }\n}\n"],"names":["Aglyn","CommerceModel","firebaseAdmin","getOrgForHost","resolveOrgPermissions","createHash","recordPluginPersonRefund","applyGiftCardRiskToOrder","flagOrderRestock","notifyOrderBuyer","ORDER_REFUNDED_EVENT","raiseOrderEvent","reverseOrderConversion","createStripeRefund","posRegisterRefundAuthority","retireLicenseKeys","hostRef","orderRef","orderId","fresh","liftLegacyOrder","get","data","withdrawn","Set","refundedProductIds","size","assigned","collection","where","limit","retired","keySnapshot","docs","has","String","ref","set","revokedAtMs","Date","now","revokedOrderId","merge","catch","undefined","update","timeline","firestore","FieldValue","arrayUnion","atMs","event","detail","error","console","refundHandler","req","res","body","method","status","json","process","env","STRIPE_SECRET_KEY","authorization","headers","idToken","startsWith","slice","length","JSON","parse","hostId","amountCents","Number","requestedLineIds","Array","isArray","lineItemIds","map","value","Math","round","filter","isFinite","sort","a","b","idempotencyKey","trim","claim","hostSnapshot","order","splitCards","membership","decoded","app","auth","verifyIdToken","doc","exists","memberRole","uid","workspaceAdmin","orgWide","hostRole","registerStaff","permissions","managePos","orderSnapshot","authority","lineCents","orderLineRefundCents","registerId","managerAssertion","claimRef","digest","prior","priorResponse","canTransitionOrder","orderDisputeBlocksRefund","orderLines","lineItems","invalidLine","find","index","namedLinesCents","orderPayments","payment","isCardPaymentMethod","Boolean","paymentIntentId","splitRefunds","paymentRefunds","response","fetch","Authorization","session","ok","payment_intent","ownerOrg","create","orgId","org","id","kind","scopeId","createdAt","serverTimestamp","createdAtMs","expiresAt","apiIdempotencyExpiry","stripeKey","record","payload","responseStatus","settledAtMs","release","delete","refundCents","totalCents","runTransaction","transaction","totals","alreadyRefunded","refundedCents","remaining","asked","min","current","max","toFixed","capacity","tipCents","card","largest","refund","code","fullyRefunded","closedTheOrder","updatedAtMs","increment","refundedLineItemIds","appendOrderEvent","action","reason","productIds","productId","refId","email","customerEmail","closedTheSale","refundId","refundLineIndexes","key","extra","full"],"mappings":"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"}
|
|
@@ -47,13 +47,16 @@ export interface StockHoldLine {
|
|
|
47
47
|
*
|
|
48
48
|
* - `sold-out` — the units are not there, or are spoken for by another live
|
|
49
49
|
* checkout. The one refusal a shopper is told about by name.
|
|
50
|
+
* - `sold-out-at-location` — the store has the units, but not free at the
|
|
51
|
+
* location the buyer chose to collect from or be delivered from (AGL-3624).
|
|
52
|
+
* The buyer can pick another location, or have it shipped.
|
|
50
53
|
* - `missing` — the product document is gone, deleted between the pricing
|
|
51
54
|
* loop's read and this transaction.
|
|
52
55
|
* - `error` — Firestore refused. The checkout is REFUSED rather than allowed
|
|
53
56
|
* through unreserved: passing a shopper to Stripe on a reservation that
|
|
54
57
|
* could not be taken is exactly the defect this file closes.
|
|
55
58
|
*/
|
|
56
|
-
export type StockHoldRefusal = 'sold-out' | 'missing' | 'error';
|
|
59
|
+
export type StockHoldRefusal = 'sold-out' | 'sold-out-at-location' | 'missing' | 'error';
|
|
57
60
|
/**
|
|
58
61
|
* A two-member union with `reason` and `productName` present on BOTH members
|
|
59
62
|
* rather than an intersection, for the reason `PromotionSlotOutcome` records:
|
|
@@ -115,6 +118,11 @@ export declare function holdStock(options: {
|
|
|
115
118
|
nowMs?: number;
|
|
116
119
|
/** Label for the failure log; never shown to a shopper. */
|
|
117
120
|
label: string;
|
|
121
|
+
/**
|
|
122
|
+
* Reserve at this location's stock bucket (AGL-3624): the pickup location
|
|
123
|
+
* the buyer chose, or the one local deliveries leave from.
|
|
124
|
+
*/
|
|
125
|
+
locationId?: string;
|
|
118
126
|
}): Promise<StockHoldOutcome>;
|
|
119
127
|
/**
|
|
120
128
|
* Release a reservation named only by its key — the webhook's door.
|
|
@@ -58,8 +58,9 @@ import { randomUUID } from "crypto";
|
|
|
58
58
|
* reservation, so the digital-goods storefront is byte-identical to what it was
|
|
59
59
|
* before this issue.
|
|
60
60
|
*/ export async function holdStock(options) {
|
|
61
|
-
var _options_nowMs, _outcome_productName;
|
|
61
|
+
var _options_locationId, _options_nowMs, _outcome_productName;
|
|
62
62
|
const { firestore, hostRef, holdKey, lines, label } = options;
|
|
63
|
+
const locationId = String((_options_locationId = options.locationId) != null ? _options_locationId : '').slice(0, 120) || undefined;
|
|
63
64
|
const nowMs = (_options_nowMs = options.nowMs) != null ? _options_nowMs : Date.now();
|
|
64
65
|
const expiresAtMs = nowMs + CommerceModel.STOCK_HOLD_TTL_MS;
|
|
65
66
|
const byProduct = groupLines(lines);
|
|
@@ -110,10 +111,13 @@ import { randomUUID } from "crypto";
|
|
|
110
111
|
if (!CommerceModel.canReserveStock(product, variantId, quantity, nowMs, // This attempt's own prior hold does not stand in its way: a
|
|
111
112
|
// retry must re-claim what it already reserved, or the second
|
|
112
113
|
// press of the same button refuses the shopper their own units.
|
|
113
|
-
holdKey)) {
|
|
114
|
+
holdKey, locationId)) {
|
|
114
115
|
var _ref1;
|
|
116
|
+
// Short HERE but not everywhere: the buyer is told the location
|
|
117
|
+
// is the problem, so they can pick another or have it shipped.
|
|
118
|
+
const elsewhere = Boolean(locationId) && CommerceModel.canReserveStock(product, variantId, quantity, nowMs, holdKey);
|
|
115
119
|
return {
|
|
116
|
-
kind: 'sold-out',
|
|
120
|
+
kind: elsewhere ? 'sold-out-at-location' : 'sold-out',
|
|
117
121
|
productName: String((_ref1 = product == null ? void 0 : product.name) != null ? _ref1 : '')
|
|
118
122
|
};
|
|
119
123
|
}
|
|
@@ -141,10 +145,12 @@ import { randomUUID } from "crypto";
|
|
|
141
145
|
// A whole-object write for THIS key, not a nested merge: the
|
|
142
146
|
// retry of an attempt whose cart shrank must not keep reserving
|
|
143
147
|
// the line it dropped, and a deep merge of `units` would.
|
|
144
|
-
[holdKey]: {
|
|
148
|
+
[holdKey]: _extends({
|
|
145
149
|
expiresAtMs,
|
|
146
150
|
units
|
|
147
|
-
}
|
|
151
|
+
}, locationId ? {
|
|
152
|
+
locationId
|
|
153
|
+
} : {})
|
|
148
154
|
})
|
|
149
155
|
}
|
|
150
156
|
});
|
|
@@ -162,7 +168,7 @@ import { randomUUID } from "crypto";
|
|
|
162
168
|
// names. Written last so it can never name a product whose hold did not
|
|
163
169
|
// commit; read by id (never queried) on `checkout.session.expired` and at
|
|
164
170
|
// settlement, so it needs no index of its own.
|
|
165
|
-
transaction.set(hostRef.collection('stockHolds').doc(holdKey), {
|
|
171
|
+
transaction.set(hostRef.collection('stockHolds').doc(holdKey), _extends({
|
|
166
172
|
productIds: heldProductIds,
|
|
167
173
|
lines: heldProductIds.flatMap((productId)=>[
|
|
168
174
|
...byProduct.get(productId)
|
|
@@ -173,7 +179,9 @@ import { randomUUID } from "crypto";
|
|
|
173
179
|
}))),
|
|
174
180
|
expiresAtMs,
|
|
175
181
|
createdAtMs: nowMs
|
|
176
|
-
}, {
|
|
182
|
+
}, locationId ? {
|
|
183
|
+
locationId
|
|
184
|
+
} : {}), {
|
|
177
185
|
merge: false
|
|
178
186
|
});
|
|
179
187
|
return {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/stock-hold.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport * as CommerceModel from '../model'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin'\nimport { randomUUID } from 'crypto'\n\n/**\n * Reserve-then-settle for STOCK (AGL-2356).\n *\n * The I/O half of `model/commerce-stock-holds.ts` — read that file first; it\n * carries the decision this one implements, including why the hold does not\n * touch `inventory` and why a lapsed hold is a release.\n *\n * This is the AGL-2453 promotion-slot shape applied to the one counter that\n * moves real goods, with two differences that matter:\n *\n * 1. **A cart holds N products, so the hold is ONE transaction over all of\n * them.** Firestore serialises the whole set, so a two-product cart either\n * reserves both or reserves neither — a partial reservation would charge a\n * shopper for a basket the store cannot fill, which is the failure this\n * issue exists to prevent, arriving through the fix.\n * 2. **The release needs an index.** A promotion hold is released against the\n * one document named in the session metadata; a cart hold spans documents\n * Stripe never hears about. `hosts/{hostId}/stockHolds/{holdKey}` is\n * written INSIDE the same transaction as the holds it names, so the index\n * cannot disagree with the reservations, and the session carries only the\n * key.\n */\n\n/** One reserved line, already resolved to a real variant. */\nexport interface StockHoldLine {\n productId: string\n variantId: string\n quantity: number\n}\n\n/**\n * Why a reservation could not be taken.\n *\n * - `sold-out` — the units are not there, or are spoken for by another live\n * checkout. The one refusal a shopper is told about by name.\n * - `missing` — the product document is gone, deleted between the pricing\n * loop's read and this transaction.\n * - `error` — Firestore refused. The checkout is REFUSED rather than allowed\n * through unreserved: passing a shopper to Stripe on a reservation that\n * could not be taken is exactly the defect this file closes.\n */\nexport type StockHoldRefusal = 'sold-out' | 'missing' | 'error'\n\n/**\n * A two-member union with `reason` and `productName` present on BOTH members\n * rather than an intersection, for the reason `PromotionSlotOutcome` records:\n * discriminated-union narrowing does not reach through an intersection under\n * this repo's compiler settings (`strictNullChecks` is off repo-wide), and the\n * intersection form fails to compile at every call site.\n */\nexport type StockHoldOutcome =\n | {\n ok: true\n reason?: undefined\n productName?: undefined\n /**\n * The key the hold was stored under, or `''` when nothing needed\n * reserving (every line untracked or backorder). Carried in the session\n * metadata so the webhook can release exactly this reservation; its\n * absence is what tells the webhook there is nothing to release.\n */\n holdKey: string\n /** Drop the reservation. Best-effort and never throws. */\n release: () => Promise<void>\n }\n | { ok: false; reason: StockHoldRefusal; productName: string }\n\n/**\n * A stable per-attempt hold key — the same rule as `promotionHoldKey`.\n *\n * `claim.stripeKey` when the client sent an Idempotency-Key, so a retry of one\n * attempt re-claims its OWN units rather than being refused by them. When it\n * did not, a fresh random key per request — NOT `String(claim.stripeKey)`,\n * which yields the literal `'null'` and would make every keyless shopper on the\n * site share one reservation, each reading the other's hold as their own retry\n * and both passing a shelf of one.\n */\nexport function stockHoldKey(stripeKey: string | null): string {\n return stripeKey || `anon-${randomUUID()}`\n}\n\n/** Two lines of one product COMPOUND — a cart holds variants, not products. */\nfunction groupLines(lines: StockHoldLine[]): Map<string, Map<string, number>> {\n const byProduct = new Map<string, Map<string, number>>()\n for (const line of lines) {\n const quantity = Math.round(Number(line?.quantity) || 0)\n if (!line?.productId || !line?.variantId || quantity <= 0) continue\n const variants = byProduct.get(line.productId) ?? new Map<string, number>()\n variants.set(line.variantId, (variants.get(line.variantId) ?? 0) + quantity)\n byProduct.set(line.productId, variants)\n }\n return byProduct\n}\n\n/**\n * Reserve every line, or refuse.\n *\n * The re-check and the write are ONE transaction over the SAME documents, which\n * is the whole fix: the old code read the shelf with a plain `.get()` at\n * session creation and the webhook decremented it minutes later without ever\n * re-asking, so N shoppers all read `inventory: 1` and all passed.\n * `Transaction.get()` takes a lock on each product document, so contending\n * checkouts SERIALISE — the loser re-runs its callback against the winner's\n * committed hold and is refused.\n *\n * Lines with nothing to reserve — an untracked variant, a `backorder` product —\n * write nothing at all, exactly as an uncapped promotion does. A cart made\n * entirely of those returns an empty `holdKey` and the session carries no\n * reservation, so the digital-goods storefront is byte-identical to what it was\n * before this issue.\n */\nexport async function holdStock(options: {\n firestore: any\n hostRef: any\n holdKey: string\n lines: StockHoldLine[]\n nowMs?: number\n /** Label for the failure log; never shown to a shopper. */\n label: string\n}): Promise<StockHoldOutcome> {\n const { firestore, hostRef, holdKey, lines, label } = options\n const nowMs = options.nowMs ?? Date.now()\n const expiresAtMs = nowMs + CommerceModel.STOCK_HOLD_TTL_MS\n const byProduct = groupLines(lines)\n const noop: StockHoldOutcome = {\n ok: true,\n holdKey: '',\n release: async () => undefined,\n }\n if (byProduct.size === 0) return noop\n\n // try/catch and NOT a trailing `.catch()`. A `runTransaction` that is\n // missing or throws synchronously — a broken client, or a test double that\n // never modelled one — never returns a promise for a `.catch()` to attach to,\n // so the throw escapes the guard and becomes a 500 from the caller's own\n // catch. That is still a refusal and never an unreserved sale, but it is a\n // refusal this function was supposed to have named.\n let outcome: any\n try {\n outcome = await firestore.runTransaction(async (transaction: any) => {\n const productIds = [...byProduct.keys()]\n // ALL READS BEFORE ANY WRITE, which Firestore requires and which is also\n // what makes the set atomic: every document is locked before the first\n // decision is taken against any of them.\n const snapshots = await Promise.all(\n productIds.map((productId) =>\n transaction.get(hostRef.collection('products').doc(productId)),\n ),\n )\n const writes: Array<{ ref: any; value: Record<string, unknown> }> = []\n const heldProductIds: string[] = []\n for (let index = 0; index < productIds.length; index++) {\n const productId = productIds[index]\n const snapshot = snapshots[index]\n if (!snapshot?.exists) {\n return { kind: 'missing' as const, productName: '' }\n }\n const product = CommerceModel.liftLegacyProduct(snapshot.data() as any)\n const wanted = byProduct.get(productId) as Map<string, number>\n const units: Record<string, number> = {}\n for (const [variantId, quantity] of wanted) {\n // `== null` is the test, never `!available` — `0` is a legitimate\n // answer here and `strictNullChecks` is off repo-wide.\n if (!CommerceModel.stockIsReservable(product, variantId, nowMs)) {\n continue\n }\n if (\n !CommerceModel.canReserveStock(\n product,\n variantId,\n quantity,\n nowMs,\n // This attempt's own prior hold does not stand in its way: a\n // retry must re-claim what it already reserved, or the second\n // press of the same button refuses the shopper their own units.\n holdKey,\n )\n ) {\n return {\n kind: 'sold-out' as const,\n productName: String(product?.name ?? ''),\n }\n }\n units[variantId] = quantity\n }\n if (Object.keys(units).length === 0) continue\n // Lapsed holds are removed by SENTINEL, never by writing back a locally\n // pruned copy of the map: `set(…, { merge: true })` DEEP-merges nested\n // maps, so an object with the key removed leaves the stored key exactly\n // where it was. Correctness does not depend on this sweep — every read\n // prunes again — but without it the document grows one dead key per\n // abandoned checkout, forever, on the one document class in this plugin\n // that is already the largest.\n const live = CommerceModel.pruneStockHolds(\n (snapshot.data() as any)?.stockHolds,\n nowMs,\n )\n const swept: Record<string, unknown> = {}\n for (const stale of Object.keys(\n (snapshot.data() as any)?.stockHolds ?? {},\n )) {\n if (stale !== holdKey && !live[stale]) {\n swept[stale] = firebaseAdmin.firestore.FieldValue.delete()\n }\n }\n writes.push({\n ref: hostRef.collection('products').doc(productId),\n value: {\n stockHolds: {\n ...swept,\n // A whole-object write for THIS key, not a nested merge: the\n // retry of an attempt whose cart shrank must not keep reserving\n // the line it dropped, and a deep merge of `units` would.\n [holdKey]: { expiresAtMs, units },\n },\n },\n })\n heldProductIds.push(productId)\n }\n if (writes.length === 0) return { kind: 'nothing-to-hold' as const }\n for (const write of writes) {\n transaction.set(write.ref, write.value, { merge: true })\n }\n // THE RELEASE INDEX, in the same transaction as the reservations it\n // names. Written last so it can never name a product whose hold did not\n // commit; read by id (never queried) on `checkout.session.expired` and at\n // settlement, so it needs no index of its own.\n transaction.set(\n hostRef.collection('stockHolds').doc(holdKey),\n {\n productIds: heldProductIds,\n lines: heldProductIds.flatMap((productId) =>\n [...(byProduct.get(productId) as Map<string, number>)].map(\n ([variantId, quantity]) => ({ productId, variantId, quantity }),\n ),\n ),\n expiresAtMs,\n createdAtMs: nowMs,\n },\n { merge: false },\n )\n return { kind: 'held' as const, productIds: heldProductIds }\n })\n } catch (error: unknown) {\n console.error('Stock hold failed', label, error)\n outcome = { kind: 'error' as const, productName: '' }\n }\n\n if (outcome.kind === 'nothing-to-hold') return noop\n if (outcome.kind === 'held') {\n const productIds: string[] = outcome.productIds ?? []\n return {\n ok: true,\n holdKey,\n // Captures the ids, so an in-request release costs no read. The webhook's\n // release goes through `releaseStockHold` and reads the index instead.\n release: () => dropStockHold(hostRef, holdKey, productIds),\n }\n }\n return {\n ok: false,\n reason: outcome.kind as StockHoldRefusal,\n productName: String(outcome.productName ?? ''),\n }\n}\n\n/**\n * Give the units back, given the products to give them back on.\n *\n * Bare merge-sets with the delete sentinel rather than a transaction: removing\n * one key is not a read-modify-write, and a release must not be able to fail\n * the response it is tidying up after. Swallows its own errors for the same\n * reason — the TTL still releases the units.\n */\nasync function dropStockHold(\n hostRef: any,\n holdKey: string,\n productIds: string[],\n): Promise<void> {\n if (!holdKey) return\n // ONE try/catch around the whole thing, and not a `.catch()` per call. A\n // release runs on the refusal paths — it is the last thing a 409 does on its\n // way out — so anything it throws lands in the CALLER's catch and turns a\n // clean, deliberate refusal into a 500 the shopper cannot act on. A trailing\n // `.catch()` does not cover that: a method that is missing or throws\n // synchronously never returns a promise for one to attach to.\n //\n // Swallowed rather than surfaced because the TTL still releases the units.\n // Tidying up a reservation must not be able to fail the response it is\n // tidying up after.\n try {\n for (const productId of productIds) {\n await hostRef\n .collection('products')\n .doc(productId)\n .set(\n {\n stockHolds: {\n [holdKey]: firebaseAdmin.firestore.FieldValue.delete(),\n },\n },\n { merge: true },\n )\n }\n await hostRef.collection('stockHolds').doc(holdKey).delete()\n } catch (error: unknown) {\n console.error('Stock hold release failed', holdKey, error)\n }\n}\n\n/**\n * Release a reservation named only by its key — the webhook's door.\n *\n * Reads the index written by `holdStock`, so `checkout.session.expired` needs\n * nothing in the session but `metadata[stockHoldKey]`. A missing index doc is\n * NOT an error: it means the hold was already released, or the session predates\n * this deploy, and in both cases there is nothing to do.\n */\nexport async function releaseStockHold(\n hostRef: any,\n holdKey: string,\n): Promise<void> {\n if (!holdKey) return\n const snapshot = await Promise.resolve()\n .then(() => hostRef.collection('stockHolds').doc(holdKey).get())\n .catch(() => null)\n const productIds: string[] = Array.isArray(snapshot?.get?.('productIds'))\n ? snapshot.get('productIds')\n : []\n await dropStockHold(hostRef, holdKey, productIds)\n}\n"],"names":["CommerceModel","firebaseAdmin","randomUUID","stockHoldKey","stripeKey","groupLines","lines","byProduct","Map","line","variants","quantity","Math","round","Number","productId","variantId","get","set","holdStock","options","outcome","firestore","hostRef","holdKey","label","nowMs","Date","now","expiresAtMs","STOCK_HOLD_TTL_MS","noop","ok","release","undefined","size","runTransaction","transaction","productIds","keys","snapshots","Promise","all","map","collection","doc","writes","heldProductIds","index","length","snapshot","exists","kind","productName","product","liftLegacyProduct","data","wanted","units","stockIsReservable","canReserveStock","String","name","Object","live","pruneStockHolds","stockHolds","swept","stale","FieldValue","delete","push","ref","value","write","merge","flatMap","createdAtMs","error","console","dropStockHold","reason","releaseStockHold","resolve","then","catch","Array","isArray"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/stock-hold.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport * as CommerceModel from '../model'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin'\nimport { randomUUID } from 'crypto'\n\n/**\n * Reserve-then-settle for STOCK (AGL-2356).\n *\n * The I/O half of `model/commerce-stock-holds.ts` — read that file first; it\n * carries the decision this one implements, including why the hold does not\n * touch `inventory` and why a lapsed hold is a release.\n *\n * This is the AGL-2453 promotion-slot shape applied to the one counter that\n * moves real goods, with two differences that matter:\n *\n * 1. **A cart holds N products, so the hold is ONE transaction over all of\n * them.** Firestore serialises the whole set, so a two-product cart either\n * reserves both or reserves neither — a partial reservation would charge a\n * shopper for a basket the store cannot fill, which is the failure this\n * issue exists to prevent, arriving through the fix.\n * 2. **The release needs an index.** A promotion hold is released against the\n * one document named in the session metadata; a cart hold spans documents\n * Stripe never hears about. `hosts/{hostId}/stockHolds/{holdKey}` is\n * written INSIDE the same transaction as the holds it names, so the index\n * cannot disagree with the reservations, and the session carries only the\n * key.\n */\n\n/** One reserved line, already resolved to a real variant. */\nexport interface StockHoldLine {\n productId: string\n variantId: string\n quantity: number\n}\n\n/**\n * Why a reservation could not be taken.\n *\n * - `sold-out` — the units are not there, or are spoken for by another live\n * checkout. The one refusal a shopper is told about by name.\n * - `sold-out-at-location` — the store has the units, but not free at the\n * location the buyer chose to collect from or be delivered from (AGL-3624).\n * The buyer can pick another location, or have it shipped.\n * - `missing` — the product document is gone, deleted between the pricing\n * loop's read and this transaction.\n * - `error` — Firestore refused. The checkout is REFUSED rather than allowed\n * through unreserved: passing a shopper to Stripe on a reservation that\n * could not be taken is exactly the defect this file closes.\n */\nexport type StockHoldRefusal = 'sold-out' | 'sold-out-at-location' | 'missing' | 'error'\n\n/**\n * A two-member union with `reason` and `productName` present on BOTH members\n * rather than an intersection, for the reason `PromotionSlotOutcome` records:\n * discriminated-union narrowing does not reach through an intersection under\n * this repo's compiler settings (`strictNullChecks` is off repo-wide), and the\n * intersection form fails to compile at every call site.\n */\nexport type StockHoldOutcome =\n | {\n ok: true\n reason?: undefined\n productName?: undefined\n /**\n * The key the hold was stored under, or `''` when nothing needed\n * reserving (every line untracked or backorder). Carried in the session\n * metadata so the webhook can release exactly this reservation; its\n * absence is what tells the webhook there is nothing to release.\n */\n holdKey: string\n /** Drop the reservation. Best-effort and never throws. */\n release: () => Promise<void>\n }\n | { ok: false; reason: StockHoldRefusal; productName: string }\n\n/**\n * A stable per-attempt hold key — the same rule as `promotionHoldKey`.\n *\n * `claim.stripeKey` when the client sent an Idempotency-Key, so a retry of one\n * attempt re-claims its OWN units rather than being refused by them. When it\n * did not, a fresh random key per request — NOT `String(claim.stripeKey)`,\n * which yields the literal `'null'` and would make every keyless shopper on the\n * site share one reservation, each reading the other's hold as their own retry\n * and both passing a shelf of one.\n */\nexport function stockHoldKey(stripeKey: string | null): string {\n return stripeKey || `anon-${randomUUID()}`\n}\n\n/** Two lines of one product COMPOUND — a cart holds variants, not products. */\nfunction groupLines(lines: StockHoldLine[]): Map<string, Map<string, number>> {\n const byProduct = new Map<string, Map<string, number>>()\n for (const line of lines) {\n const quantity = Math.round(Number(line?.quantity) || 0)\n if (!line?.productId || !line?.variantId || quantity <= 0) continue\n const variants = byProduct.get(line.productId) ?? new Map<string, number>()\n variants.set(line.variantId, (variants.get(line.variantId) ?? 0) + quantity)\n byProduct.set(line.productId, variants)\n }\n return byProduct\n}\n\n/**\n * Reserve every line, or refuse.\n *\n * The re-check and the write are ONE transaction over the SAME documents, which\n * is the whole fix: the old code read the shelf with a plain `.get()` at\n * session creation and the webhook decremented it minutes later without ever\n * re-asking, so N shoppers all read `inventory: 1` and all passed.\n * `Transaction.get()` takes a lock on each product document, so contending\n * checkouts SERIALISE — the loser re-runs its callback against the winner's\n * committed hold and is refused.\n *\n * Lines with nothing to reserve — an untracked variant, a `backorder` product —\n * write nothing at all, exactly as an uncapped promotion does. A cart made\n * entirely of those returns an empty `holdKey` and the session carries no\n * reservation, so the digital-goods storefront is byte-identical to what it was\n * before this issue.\n */\nexport async function holdStock(options: {\n firestore: any\n hostRef: any\n holdKey: string\n lines: StockHoldLine[]\n nowMs?: number\n /** Label for the failure log; never shown to a shopper. */\n label: string\n /**\n * Reserve at this location's stock bucket (AGL-3624): the pickup location\n * the buyer chose, or the one local deliveries leave from.\n */\n locationId?: string\n}): Promise<StockHoldOutcome> {\n const { firestore, hostRef, holdKey, lines, label } = options\n const locationId = String(options.locationId ?? '').slice(0, 120) || undefined\n const nowMs = options.nowMs ?? Date.now()\n const expiresAtMs = nowMs + CommerceModel.STOCK_HOLD_TTL_MS\n const byProduct = groupLines(lines)\n const noop: StockHoldOutcome = {\n ok: true,\n holdKey: '',\n release: async () => undefined,\n }\n if (byProduct.size === 0) return noop\n\n // try/catch and NOT a trailing `.catch()`. A `runTransaction` that is\n // missing or throws synchronously — a broken client, or a test double that\n // never modelled one — never returns a promise for a `.catch()` to attach to,\n // so the throw escapes the guard and becomes a 500 from the caller's own\n // catch. That is still a refusal and never an unreserved sale, but it is a\n // refusal this function was supposed to have named.\n let outcome: any\n try {\n outcome = await firestore.runTransaction(async (transaction: any) => {\n const productIds = [...byProduct.keys()]\n // ALL READS BEFORE ANY WRITE, which Firestore requires and which is also\n // what makes the set atomic: every document is locked before the first\n // decision is taken against any of them.\n const snapshots = await Promise.all(\n productIds.map((productId) =>\n transaction.get(hostRef.collection('products').doc(productId)),\n ),\n )\n const writes: Array<{ ref: any; value: Record<string, unknown> }> = []\n const heldProductIds: string[] = []\n for (let index = 0; index < productIds.length; index++) {\n const productId = productIds[index]\n const snapshot = snapshots[index]\n if (!snapshot?.exists) {\n return { kind: 'missing' as const, productName: '' }\n }\n const product = CommerceModel.liftLegacyProduct(snapshot.data() as any)\n const wanted = byProduct.get(productId) as Map<string, number>\n const units: Record<string, number> = {}\n for (const [variantId, quantity] of wanted) {\n // `== null` is the test, never `!available` — `0` is a legitimate\n // answer here and `strictNullChecks` is off repo-wide.\n if (!CommerceModel.stockIsReservable(product, variantId, nowMs)) {\n continue\n }\n if (\n !CommerceModel.canReserveStock(\n product,\n variantId,\n quantity,\n nowMs,\n // This attempt's own prior hold does not stand in its way: a\n // retry must re-claim what it already reserved, or the second\n // press of the same button refuses the shopper their own units.\n holdKey,\n locationId,\n )\n ) {\n // Short HERE but not everywhere: the buyer is told the location\n // is the problem, so they can pick another or have it shipped.\n const elsewhere =\n Boolean(locationId) &&\n CommerceModel.canReserveStock(product, variantId, quantity, nowMs, holdKey)\n return {\n kind: elsewhere ? ('sold-out-at-location' as const) : ('sold-out' as const),\n productName: String(product?.name ?? ''),\n }\n }\n units[variantId] = quantity\n }\n if (Object.keys(units).length === 0) continue\n // Lapsed holds are removed by SENTINEL, never by writing back a locally\n // pruned copy of the map: `set(…, { merge: true })` DEEP-merges nested\n // maps, so an object with the key removed leaves the stored key exactly\n // where it was. Correctness does not depend on this sweep — every read\n // prunes again — but without it the document grows one dead key per\n // abandoned checkout, forever, on the one document class in this plugin\n // that is already the largest.\n const live = CommerceModel.pruneStockHolds(\n (snapshot.data() as any)?.stockHolds,\n nowMs,\n )\n const swept: Record<string, unknown> = {}\n for (const stale of Object.keys(\n (snapshot.data() as any)?.stockHolds ?? {},\n )) {\n if (stale !== holdKey && !live[stale]) {\n swept[stale] = firebaseAdmin.firestore.FieldValue.delete()\n }\n }\n writes.push({\n ref: hostRef.collection('products').doc(productId),\n value: {\n stockHolds: {\n ...swept,\n // A whole-object write for THIS key, not a nested merge: the\n // retry of an attempt whose cart shrank must not keep reserving\n // the line it dropped, and a deep merge of `units` would.\n [holdKey]: { expiresAtMs, units, ...(locationId ? { locationId } : {}) },\n },\n },\n })\n heldProductIds.push(productId)\n }\n if (writes.length === 0) return { kind: 'nothing-to-hold' as const }\n for (const write of writes) {\n transaction.set(write.ref, write.value, { merge: true })\n }\n // THE RELEASE INDEX, in the same transaction as the reservations it\n // names. Written last so it can never name a product whose hold did not\n // commit; read by id (never queried) on `checkout.session.expired` and at\n // settlement, so it needs no index of its own.\n transaction.set(\n hostRef.collection('stockHolds').doc(holdKey),\n {\n productIds: heldProductIds,\n lines: heldProductIds.flatMap((productId) =>\n [...(byProduct.get(productId) as Map<string, number>)].map(\n ([variantId, quantity]) => ({ productId, variantId, quantity }),\n ),\n ),\n expiresAtMs,\n createdAtMs: nowMs,\n ...(locationId ? { locationId } : {}),\n },\n { merge: false },\n )\n return { kind: 'held' as const, productIds: heldProductIds }\n })\n } catch (error: unknown) {\n console.error('Stock hold failed', label, error)\n outcome = { kind: 'error' as const, productName: '' }\n }\n\n if (outcome.kind === 'nothing-to-hold') return noop\n if (outcome.kind === 'held') {\n const productIds: string[] = outcome.productIds ?? []\n return {\n ok: true,\n holdKey,\n // Captures the ids, so an in-request release costs no read. The webhook's\n // release goes through `releaseStockHold` and reads the index instead.\n release: () => dropStockHold(hostRef, holdKey, productIds),\n }\n }\n return {\n ok: false,\n reason: outcome.kind as StockHoldRefusal,\n productName: String(outcome.productName ?? ''),\n }\n}\n\n/**\n * Give the units back, given the products to give them back on.\n *\n * Bare merge-sets with the delete sentinel rather than a transaction: removing\n * one key is not a read-modify-write, and a release must not be able to fail\n * the response it is tidying up after. Swallows its own errors for the same\n * reason — the TTL still releases the units.\n */\nasync function dropStockHold(\n hostRef: any,\n holdKey: string,\n productIds: string[],\n): Promise<void> {\n if (!holdKey) return\n // ONE try/catch around the whole thing, and not a `.catch()` per call. A\n // release runs on the refusal paths — it is the last thing a 409 does on its\n // way out — so anything it throws lands in the CALLER's catch and turns a\n // clean, deliberate refusal into a 500 the shopper cannot act on. A trailing\n // `.catch()` does not cover that: a method that is missing or throws\n // synchronously never returns a promise for one to attach to.\n //\n // Swallowed rather than surfaced because the TTL still releases the units.\n // Tidying up a reservation must not be able to fail the response it is\n // tidying up after.\n try {\n for (const productId of productIds) {\n await hostRef\n .collection('products')\n .doc(productId)\n .set(\n {\n stockHolds: {\n [holdKey]: firebaseAdmin.firestore.FieldValue.delete(),\n },\n },\n { merge: true },\n )\n }\n await hostRef.collection('stockHolds').doc(holdKey).delete()\n } catch (error: unknown) {\n console.error('Stock hold release failed', holdKey, error)\n }\n}\n\n/**\n * Release a reservation named only by its key — the webhook's door.\n *\n * Reads the index written by `holdStock`, so `checkout.session.expired` needs\n * nothing in the session but `metadata[stockHoldKey]`. A missing index doc is\n * NOT an error: it means the hold was already released, or the session predates\n * this deploy, and in both cases there is nothing to do.\n */\nexport async function releaseStockHold(\n hostRef: any,\n holdKey: string,\n): Promise<void> {\n if (!holdKey) return\n const snapshot = await Promise.resolve()\n .then(() => hostRef.collection('stockHolds').doc(holdKey).get())\n .catch(() => null)\n const productIds: string[] = Array.isArray(snapshot?.get?.('productIds'))\n ? snapshot.get('productIds')\n : []\n await dropStockHold(hostRef, holdKey, productIds)\n}\n"],"names":["CommerceModel","firebaseAdmin","randomUUID","stockHoldKey","stripeKey","groupLines","lines","byProduct","Map","line","variants","quantity","Math","round","Number","productId","variantId","get","set","holdStock","options","outcome","firestore","hostRef","holdKey","label","locationId","String","slice","undefined","nowMs","Date","now","expiresAtMs","STOCK_HOLD_TTL_MS","noop","ok","release","size","runTransaction","transaction","productIds","keys","snapshots","Promise","all","map","collection","doc","writes","heldProductIds","index","length","snapshot","exists","kind","productName","product","liftLegacyProduct","data","wanted","units","stockIsReservable","canReserveStock","elsewhere","Boolean","name","Object","live","pruneStockHolds","stockHolds","swept","stale","FieldValue","delete","push","ref","value","write","merge","flatMap","createdAtMs","error","console","dropStockHold","reason","releaseStockHold","resolve","then","catch","Array","isArray"],"mappings":"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"}
|
package/src/lib/server.js
CHANGED
|
@@ -87,6 +87,7 @@ import { posPaymentHandler } from "./server/pos-payment.js";
|
|
|
87
87
|
import { posReadersHandler } from "./server/pos-readers.js";
|
|
88
88
|
import { posTerminalConnectionTokenHandler } from "./server/pos-terminal-connection-token.js";
|
|
89
89
|
import { posDisplayHandler } from "./server/pos-display.js";
|
|
90
|
+
import { posKioskHandler } from "./server/pos-kiosk.js";
|
|
90
91
|
import { registerPosOpsRoutes } from "./server/pos-ops-routes.js";
|
|
91
92
|
import { processAbandonedHandler, scanAbandonedCheckouts } from "./server/process-abandoned.js";
|
|
92
93
|
import { processRestockHandler, scanRestockAlerts } from "./server/process-restock.js";
|
|
@@ -268,6 +269,9 @@ registerPluginJob({
|
|
|
268
269
|
registerSitePageEnricher(commerceSitePageEnricher);
|
|
269
270
|
registerPluginApiRoute('commerce/cart-checkout', cartCheckoutHandler, CARD_PAYMENT_DOOR);
|
|
270
271
|
registerPluginApiRoute('commerce/cart', cartHandler);
|
|
272
|
+
// What the cart offers besides shipping (AGL-3624): pickup locations, and
|
|
273
|
+
// local delivery's fee, minimum and windows for a postal code.
|
|
274
|
+
registerPluginApiRoute('commerce/local-fulfillment-options', async (req, res)=>(await import("./server/local-fulfillment.js")).localFulfillmentOptionsHandler(req, res));
|
|
271
275
|
// Optional lines another plugin offers at the cart (AGL-3635).
|
|
272
276
|
registerPluginApiRoute('commerce/cart-extras', cartExtrasHandler);
|
|
273
277
|
registerPluginApiRoute('commerce/catalog', catalogHandler);
|
|
@@ -379,6 +383,10 @@ registerPluginJob({
|
|
|
379
383
|
// `PATCH /v1/sites/{id}/orders/{id}` records a shipment through the same
|
|
380
384
|
// transaction (`server/api-v1/orders-and-products.ts`).
|
|
381
385
|
registerPluginApiRoute('commerce/fulfill-order', fulfillOrderHandler);
|
|
386
|
+
// Pickup and local delivery steps (AGL-3624) — ready, picked up, out for
|
|
387
|
+
// delivery, delivered — from the console queue, the order dialog and the
|
|
388
|
+
// native Aglyn app. Loaded on first call.
|
|
389
|
+
registerPluginApiRoute('commerce/local-fulfillment', async (req, res)=>(await import("./server/local-fulfillment-status.js")).localFulfillmentHandler(req, res));
|
|
382
390
|
// "Resend receipt" from the order dialog (AGL-3610): by email, or by text
|
|
383
391
|
// when the platform's SMS provider is configured. Rate-limited per member
|
|
384
392
|
// and per order.
|
|
@@ -438,8 +446,16 @@ registerPluginJob({
|
|
|
438
446
|
registerPluginApiRoute('commerce/pos-payment', posPaymentHandler);
|
|
439
447
|
registerPluginApiRoute('commerce/pos-readers', posReadersHandler);
|
|
440
448
|
registerPluginApiRoute('commerce/pos-display', posDisplayHandler);
|
|
449
|
+
// The self-service kiosk (AGL-3623): a register's paired screen where the
|
|
450
|
+
// customer orders and pays. Its device token, never a staff session; the
|
|
451
|
+
// register's queue of "pay at counter" orders is gated like a sale.
|
|
452
|
+
registerPluginApiRoute('commerce/pos-kiosk', posKioskHandler);
|
|
441
453
|
// Shifts, staff PINs, the customer lookup and returns (AGL-3609).
|
|
442
454
|
registerPosOpsRoutes();
|
|
455
|
+
// The offline register (AGL-3625): the kit a register caches to sell cash
|
|
456
|
+
// while the connection is down, and the sync that records those sales once.
|
|
457
|
+
// Gated like a sale; loaded with its first call.
|
|
458
|
+
registerPluginApiRoute('commerce/pos-offline-sync', async (req, res)=>(await import("./server/pos-offline-sync.js")).posOfflineSyncHandler(req, res));
|
|
443
459
|
// The native Aglyn POS app's Stripe Terminal SDK: a connection token scoped
|
|
444
460
|
// to the site's Location, and the Location itself (AGL-3618). Gated like a
|
|
445
461
|
// sale.
|