@aglyn/plugins-commerce 1.0.0-beta.221 → 1.0.0-beta.223
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/README.md +4 -2
- package/package.json +14 -13
- package/src/lib/components/console/catalog-organization-card.component.js +9 -0
- package/src/lib/components/console/catalog-organization-card.component.js.map +1 -1
- package/src/lib/components/console/discounts-card.component.js +8 -0
- package/src/lib/components/console/discounts-card.component.js.map +1 -1
- package/src/lib/components/console/gift-cards-card.component.js +8 -0
- package/src/lib/components/console/gift-cards-card.component.js.map +1 -1
- package/src/lib/components/console/host-coupons-card.component.js +8 -0
- package/src/lib/components/console/host-coupons-card.component.js.map +1 -1
- package/src/lib/components/console/host-orders-card.component.js +34 -70
- package/src/lib/components/console/host-orders-card.component.js.map +1 -1
- package/src/lib/components/console/product-zones.d.ts +7 -6
- package/src/lib/components/console/product-zones.js +5 -4
- package/src/lib/components/console/product-zones.js.map +1 -1
- package/src/lib/components/console/products-hub-card.component.js +86 -284
- package/src/lib/components/console/products-hub-card.component.js.map +1 -1
- package/src/lib/declarations.console-server.d.ts +38 -0
- package/src/lib/declarations.console-server.js +67 -0
- package/src/lib/declarations.console-server.js.map +1 -0
- package/src/lib/model/commerce-gift-cards.d.ts +26 -0
- package/src/lib/model/commerce-gift-cards.js +42 -0
- package/src/lib/model/commerce-gift-cards.js.map +1 -1
- package/src/lib/model/commerce-orders.d.ts +0 -57
- package/src/lib/model/commerce-orders.js +0 -80
- package/src/lib/model/commerce-orders.js.map +1 -1
- package/src/lib/model/index.d.ts +0 -1
- package/src/lib/model/index.js +0 -1
- package/src/lib/model/index.js.map +1 -1
- package/src/lib/plugin.js +5 -0
- package/src/lib/plugin.js.map +1 -1
- package/src/lib/server/api-v1/openapi.d.ts +27 -0
- package/src/lib/server/api-v1/openapi.js +219 -0
- package/src/lib/server/api-v1/openapi.js.map +1 -0
- package/src/lib/server/api-v1/orders-and-products.d.ts +19 -0
- package/src/lib/server/api-v1/orders-and-products.js +419 -0
- package/src/lib/server/api-v1/orders-and-products.js.map +1 -0
- package/src/lib/server/billing-webhook.js +10 -13
- package/src/lib/server/billing-webhook.js.map +1 -1
- package/src/lib/server/download.js +4 -1
- package/src/lib/server/download.js.map +1 -1
- package/src/lib/server/fulfill-order.d.ts +52 -11
- package/src/lib/server/fulfill-order.js +6 -9
- package/src/lib/server/fulfill-order.js.map +1 -1
- package/src/lib/server/gift-card-issue.d.ts +57 -0
- package/src/lib/server/gift-card-issue.js +155 -0
- package/src/lib/server/gift-card-issue.js.map +1 -0
- package/src/lib/server/gift-cards.js +25 -84
- package/src/lib/server/gift-cards.js.map +1 -1
- package/src/lib/server/membership-register.js +9 -8
- package/src/lib/server/membership-register.js.map +1 -1
- package/src/lib/server/newsletter.js +7 -5
- package/src/lib/server/newsletter.js.map +1 -1
- package/src/lib/server/paid-media-delivery.d.ts +21 -0
- package/src/lib/server/paid-media-delivery.js +23 -5
- package/src/lib/server/paid-media-delivery.js.map +1 -1
- package/src/lib/server/paid-media-serve-count.d.ts +28 -0
- package/src/lib/server/paid-media-serve-count.js +46 -0
- package/src/lib/server/paid-media-serve-count.js.map +1 -0
- package/src/lib/server/person-eraser.d.ts +45 -0
- package/src/lib/server/person-eraser.js +57 -0
- package/src/lib/server/person-eraser.js.map +1 -0
- package/src/lib/server/refund.js +15 -15
- package/src/lib/server/refund.js.map +1 -1
- package/src/lib/server/restock-flag.d.ts +2 -2
- package/src/lib/server/restock-flag.js +1 -1
- package/src/lib/server/restock-flag.js.map +1 -1
- package/src/lib/server/stream.js +4 -1
- package/src/lib/server/stream.js.map +1 -1
- package/src/lib/server.js +5 -21
- package/src/lib/server.js.map +1 -1
- package/src/lib/testing/stand-in-person-records.d.ts +43 -0
- package/src/lib/testing/stand-in-person-records.js +63 -0
- package/src/lib/testing/stand-in-person-records.js.map +1 -0
- package/src/lib/transfer/gift-card-confirm-step.component.d.ts +22 -0
- package/src/lib/transfer/gift-card-confirm-step.component.js +259 -0
- package/src/lib/transfer/gift-card-confirm-step.component.js.map +1 -0
- package/src/lib/transfer/gift-cards-plan.d.ts +29 -0
- package/src/lib/transfer/gift-cards-plan.js +40 -0
- package/src/lib/transfer/gift-cards-plan.js.map +1 -0
- package/src/lib/transfer/gift-cards.server.d.ts +34 -0
- package/src/lib/transfer/gift-cards.server.js +296 -0
- package/src/lib/transfer/gift-cards.server.js.map +1 -0
- package/src/lib/transfer/list-filter.d.ts +29 -0
- package/src/lib/transfer/list-filter.js +112 -0
- package/src/lib/transfer/list-filter.js.map +1 -0
- package/src/lib/transfer/product-import-results.d.ts +29 -0
- package/src/lib/transfer/product-import-results.js +49 -0
- package/src/lib/transfer/product-import-results.js.map +1 -0
- package/src/lib/transfer/product-import-step.component.d.ts +30 -0
- package/src/lib/transfer/product-import-step.component.js +99 -0
- package/src/lib/transfer/product-import-step.component.js.map +1 -0
- package/src/lib/transfer/product-transfer.d.ts +211 -0
- package/src/lib/transfer/product-transfer.js +1594 -0
- package/src/lib/transfer/product-transfer.js.map +1 -0
- package/src/lib/transfer/products.server.d.ts +44 -0
- package/src/lib/transfer/products.server.js +550 -0
- package/src/lib/transfer/products.server.js.map +1 -0
- package/src/lib/transfer/records-transfer.d.ts +182 -0
- package/src/lib/transfer/records-transfer.js +1153 -0
- package/src/lib/transfer/records-transfer.js.map +1 -0
- package/src/lib/transfer/records.server.d.ts +68 -0
- package/src/lib/transfer/records.server.js +455 -0
- package/src/lib/transfer/records.server.js.map +1 -0
- package/src/lib/transfer/register-transfer-resources.d.ts +17 -0
- package/src/lib/transfer/register-transfer-resources.js +171 -0
- package/src/lib/transfer/register-transfer-resources.js.map +1 -0
- package/src/lib/transfer/register-transfer-ui.d.ts +23 -0
- package/src/lib/transfer/register-transfer-ui.js +78 -0
- package/src/lib/transfer/register-transfer-ui.js.map +1 -0
- package/src/lib/transfer/server-common.d.ts +76 -0
- package/src/lib/transfer/server-common.js +165 -0
- package/src/lib/transfer/server-common.js.map +1 -0
- package/src/lib/transfer/transfer-header-actions.component.d.ts +39 -0
- package/src/lib/transfer/transfer-header-actions.component.js +79 -0
- package/src/lib/transfer/transfer-header-actions.component.js.map +1 -0
- package/src/lib/transfer/transfer-keys.d.ts +31 -0
- package/src/lib/transfer/transfer-keys.js +56 -0
- package/src/lib/transfer/transfer-keys.js.map +1 -0
- package/src/lib/model/commerce-io.d.ts +0 -36
- package/src/lib/model/commerce-io.js +0 -245
- package/src/lib/model/commerce-io.js.map +0 -1
- package/src/lib/server/contact-refund.d.ts +0 -117
- package/src/lib/server/contact-refund.js +0 -196
- package/src/lib/server/contact-refund.js.map +0 -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 { recordContactRefund } from './contact-refund'\nimport { applyGiftCardRiskToOrder } from './gift-card-risk'\nimport { flagOrderRestock } from './restock-flag'\n// Leaf import, not the barrel, for the reason `contact-refund.ts` states about\n// `updateExisting`: the specs in this library mock `@aglyn/tenant-data-admin`\n// wholesale, and a permissive stub would turn a reversal that never happened\n// green.\nimport { reverseEmailAttributedRevenue } from '@aglyn/tenant-data-admin/server/email-revenue-attribution'\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 if (memberRole !== 'admin') {\n return res.status(403).json({ error: 'Refunds require a site admin' })\n }\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 if (!membership.orgWide || membership.hostRole !== 'admin') {\n return res\n .status(403)\n .json({ error: 'Refunds require an admin of the whole workspace' })\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\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 const paymentIntentId =\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 const params = new URLSearchParams({\n payment_intent: String(paymentIntentId),\n amount: String(refundCents),\n reverse_transfer: 'true',\n refund_application_fee: 'true',\n })\n const response = await fetch('https://api.stripe.com/v1/refunds', {\n method: 'POST',\n headers: {\n Authorization: `Bearer ${process.env.STRIPE_SECRET_KEY}`,\n 'Content-Type': 'application/x-www-form-urlencoded',\n ...(claim?.stripeKey\n ? { 'Idempotency-Key': claim.stripeKey }\n : {}),\n },\n body: params.toString(),\n })\n const refund = await response.json()\n if (!response.ok) {\n console.error('Stripe refund error', 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, not a 502 reading\n // \"The charge you're attempting to refund has been charged back\", which\n // an admin has no reason to connect to the Refund button they pressed.\n const stripeCode = String(refund?.error?.code ?? '')\n if (\n stripeCode === 'charge_disputed' ||\n stripeCode === 'refund_disputed_payment'\n ) {\n return res.status(409).json({\n error:\n 'Stripe refused this refund because the charge is disputed. ' +\n 'Respond to the dispute or accept it in the Stripe dashboard; ' +\n 'refund any remainder once it settles.',\n })\n }\n return res\n .status(502)\n .json({ error: refund?.error?.message ?? 'Refund failed' })\n }\n\n // SETTLE. Re-read inside the transaction: a concurrent partial may have\n // reserved against the same order, and the timeline must be appended to\n // whatever is there now rather than to the snapshot read at the top.\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 // 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. `recordContactRefund`\n // swallows its own failures, so awaiting adds no way for this to fail a\n // refund that has already left the merchant's account.\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 recordContactRefund({\n hostId,\n orderId,\n email: order.customerEmail,\n amountCents: refundCents,\n 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 `recordContactRefund` above records\n * `refundedCents` beside `ltvCents`.\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 reverseEmailAttributedRevenue({\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 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","recordContactRefund","applyGiftCardRiskToOrder","flagOrderRestock","reverseEmailAttributedRevenue","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","decoded","app","auth","verifyIdToken","doc","exists","memberRole","uid","membership","orgWide","hostRole","orderSnapshot","claimRef","digest","prior","priorResponse","canTransitionOrder","orderDisputeBlocksRefund","orderLines","lineItems","invalidLine","find","index","namedLinesCents","orderLineRefundCents","paymentIntentId","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","params","URLSearchParams","amount","reverse_transfer","refund_application_fee","toString","refund","stripeCode","code","message","fullyRefunded","closedTheOrder","refundedLineItemIds","appendOrderEvent","action","reason","productIds","productId","Boolean","email","customerEmail"],"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 { reverseOrderConversion } from '@aglyn/aglyn/plugin-manager/plugin-conversion-credit'\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 if (memberRole !== 'admin') {\n return res.status(403).json({ error: 'Refunds require a site admin' })\n }\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 if (!membership.orgWide || membership.hostRole !== 'admin') {\n return res\n .status(403)\n .json({ error: 'Refunds require an admin of the whole workspace' })\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\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 const paymentIntentId =\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 const params = new URLSearchParams({\n payment_intent: String(paymentIntentId),\n amount: String(refundCents),\n reverse_transfer: 'true',\n refund_application_fee: 'true',\n })\n const response = await fetch('https://api.stripe.com/v1/refunds', {\n method: 'POST',\n headers: {\n Authorization: `Bearer ${process.env.STRIPE_SECRET_KEY}`,\n 'Content-Type': 'application/x-www-form-urlencoded',\n ...(claim?.stripeKey\n ? { 'Idempotency-Key': claim.stripeKey }\n : {}),\n },\n body: params.toString(),\n })\n const refund = await response.json()\n if (!response.ok) {\n console.error('Stripe refund error', 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, not a 502 reading\n // \"The charge you're attempting to refund has been charged back\", which\n // an admin has no reason to connect to the Refund button they pressed.\n const stripeCode = String(refund?.error?.code ?? '')\n if (\n stripeCode === 'charge_disputed' ||\n stripeCode === 'refund_disputed_payment'\n ) {\n return res.status(409).json({\n error:\n 'Stripe refused this refund because the charge is disputed. ' +\n 'Respond to the dispute or accept it in the Stripe dashboard; ' +\n 'refund any remainder once it settles.',\n })\n }\n return res\n .status(502)\n .json({ error: refund?.error?.message ?? 'Refund failed' })\n }\n\n // SETTLE. Re-read inside the transaction: a concurrent partial may have\n // reserved against the same order, and the timeline must be appended to\n // whatever is there now rather than to the snapshot read at the top.\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 // 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 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","reverseOrderConversion","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","decoded","app","auth","verifyIdToken","doc","exists","memberRole","uid","membership","orgWide","hostRole","orderSnapshot","claimRef","digest","prior","priorResponse","canTransitionOrder","orderDisputeBlocksRefund","orderLines","lineItems","invalidLine","find","index","namedLinesCents","orderLineRefundCents","paymentIntentId","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","params","URLSearchParams","amount","reverse_transfer","refund_application_fee","toString","refund","stripeCode","code","message","fullyRefunded","closedTheOrder","refundedLineItemIds","appendOrderEvent","action","reason","productIds","productId","Boolean","refId","email","customerEmail","closedTheSale"],"mappings":"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"}
|
|
@@ -98,7 +98,7 @@ import * as CommerceModel from '../model';
|
|
|
98
98
|
* `saleReleaseCaps` and `capRestockLines` are shared with the cancellation
|
|
99
99
|
* writer, so what this ASKS and what that RETURNS cannot drift apart.
|
|
100
100
|
*
|
|
101
|
-
* FAILURES ARE SWALLOWED, like
|
|
101
|
+
* FAILURES ARE SWALLOWED, like the person's refund's. The money has already
|
|
102
102
|
* moved and the order already records it; a flag that cannot be written must
|
|
103
103
|
* not fail a refund that has left the merchant's account.
|
|
104
104
|
*/
|
|
@@ -109,7 +109,7 @@ export declare function flagOrderRestock(options: {
|
|
|
109
109
|
kind: 'refund' | 'chargeback';
|
|
110
110
|
/**
|
|
111
111
|
* True only for the write that moved the order INTO `refunded` — the same
|
|
112
|
-
* signal `
|
|
112
|
+
* signal the person's refund takes (`closedTheSale`), decided by the caller from the status
|
|
113
113
|
* transition it made rather than from the total it read, because
|
|
114
114
|
* `fullyRefunded` is not a once-only signal (AGL-1754).
|
|
115
115
|
*/
|
|
@@ -99,7 +99,7 @@ import { firebaseAdmin } from "@aglyn/tenant-data-admin";
|
|
|
99
99
|
* `saleReleaseCaps` and `capRestockLines` are shared with the cancellation
|
|
100
100
|
* writer, so what this ASKS and what that RETURNS cannot drift apart.
|
|
101
101
|
*
|
|
102
|
-
* FAILURES ARE SWALLOWED, like
|
|
102
|
+
* FAILURES ARE SWALLOWED, like the person's refund's. The money has already
|
|
103
103
|
* moved and the order already records it; a flag that cannot be written must
|
|
104
104
|
* not fail a refund that has left the merchant's account.
|
|
105
105
|
*/ export async function flagOrderRestock(options) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/restock-flag.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'\n\n/**\n * Stock left off the shelf by a reversed order (AGL-1797).\n *\n * The checkout webhook decrements variant inventory on a sale and NOTHING put\n * it back — not `refund.ts`, not the lost-dispute handler (AGL-1787) — so a\n * fully reversed order left the merchant's count permanently one lower than\n * their shelf, compounding with every return.\n *\n * THIS FLAGS. IT DOES NOT RELEASE, and that is the decision.\n *\n * \"Increment on reversal\" is the obvious fix and it is wrong in two of the\n * three cases it would fire on:\n *\n * - a **returned** item does come back, but only once it is RECEIVED, and no\n * fulfilment event records receipt — `OrderFulfillment` has no returned\n * state, so the moment an automatic increment would need does not exist;\n * - a **refund with no return** (goodwill, damaged, lost in post) leaves the\n * goods gone. Incrementing there invents inventory the merchant does not\n * have, and that is strictly worse than the bug it replaces: the bug\n * under-counts and refuses a sale that could have been made, while the\n * \"fix\" over-counts and TAKES a sale that cannot be filled;\n * - a **chargeback** is the clearest do-not-restock case there is. The shopper\n * kept the item and took the money, and `product_not_received` — the most\n * common reason code — asserts the goods shipped.\n *\n * The quantities are not always knowable on a partial. A refund requested as an\n * AMOUNT selects no line — \"$17 of a $62 order\" names nothing — and that is\n * recorded rather than papered over: `quantity` is at most the units the line\n * sold, capped to what the count actually gave up, and `fullyReversed` says\n * whether that bound is a tight one.\n *\n * A refund that NAMES ITS LINES is scoped to them (AGL-2325). When this was\n * written no reversal recorded which lines it covered; AGL-2454 changed that,\n * and `refund.ts` commits `refundedLineItemIds` with the money, before this\n * flag re-reads the order. A one-line refund on a three-line order that still\n * asked about all three was the same over-statement this module already fixed\n * on the units axis, arriving by a door that opened afterwards. See\n * `reversalLineScope` for the three cases that stay whole-order.\n *\n * So the merchant is asked. They are the only party who knows whether the\n * goods came back, and by the time they read this they know it.\n *\n * WHAT IS DELIBERATELY NOT BUILT HERE. No stock writer and no\n * `InventoryAdjustment` row: the products hub's \"Adjust stock\" already writes\n * the variant counts and logs the adjustment with a reason, so a second writer\n * for one number would be a re-implementation, not an extension. And an\n * adjustment row for stock that has NOT moved would corrupt the very ledger\n * that hub renders as history. This writes the question; the existing action\n * answers it.\n *\n * WHERE THE MERCHANT SEES IT. The order timeline, which the console order\n * dialog already renders, so this needs no UI change to be visible at all. The\n * `restockCheck` record beside it is the structured half a badge and a\n * one-click action read; that console work is filed, not smuggled in.\n *\n * NO NOTIFICATION. A refund is initiated BY an admin in the console, so telling\n * them about their own click is noise, and the chargeback door already sends a\n * manager notification for the dispute itself — a second one for the same event\n * would train them to ignore both.\n *\n * ONLY WHAT THE SALE DECREMENTED IS FLAGGED. Tracking lives on the product\n * variant, not the order line, so this reads the products and applies the sale\n * path's own test (`variant.inventory != null`). A store that tracks no stock\n * therefore gets NO flag rather than a prompt it can never act on, which is the\n * difference between a signal and noise. It costs a handful of reads on an\n * event as rare as a reversal.\n *\n * The `inventoryAdjustments` ledger was the tempting shortcut for SELECTING the\n * lines and it is not usable for that: the buy-now/subscription sale path\n * decrements stock without writing a row (`billing-webhook.ts`, the\n * `productId` branch), so the ledger has holes the order's own line items do\n * not. The lines are still chosen from the order.\n *\n * IT IS READ TO BOUND THE NUMBER, THOUGH (AGL-2325), and the holes are exactly\n * why that is safe in this direction. `line.quantity` is the units the line\n * SOLD, and on a backorder product the inventory floor absorbed part or all of\n * the decrement, so the prompt named units the count never gave up. The sale\n * rows' `appliedDelta` says how many it did, and only pairs the ledger\n * DESCRIBES are capped — a line it is silent about keeps the upper bound, so a\n * hole costs an over-stated question rather than stock stranded off the shelf.\n * `saleReleaseCaps` and `capRestockLines` are shared with the cancellation\n * writer, so what this ASKS and what that RETURNS cannot drift apart.\n *\n * FAILURES ARE SWALLOWED, like `recordContactRefund`'s. The money has already\n * moved and the order already records it; a flag that cannot be written must\n * not fail a refund that has left the merchant's account.\n */\nexport async function flagOrderRestock(options: {\n hostId: string\n orderId: string\n /** Which door the money left by. Only the wording differs. */\n kind: 'refund' | 'chargeback'\n /**\n * True only for the write that moved the order INTO `refunded` — the same\n * signal `recordContactRefund` takes, decided by the caller from the status\n * transition it made rather than from the total it read, because\n * `fullyRefunded` is not a once-only signal (AGL-1754).\n */\n closedTheOrder: boolean\n}): Promise<void> {\n try {\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(options.hostId)\n const orderRef = hostRef.collection('orders').doc(options.orderId)\n const snapshot = await orderRef.get()\n if (!snapshot.exists) return\n const order = CommerceModel.liftLegacyOrder(\n (snapshot.data() ?? {}) as never,\n )\n // Once per open question — but \"the question\" is now a set of LINES, not\n // the order (AGL-2325). A second partial that withdraws a line the open\n // prompt already names adds nothing the merchant does not have; one that\n // withdraws a DIFFERENT line is a genuinely new question, and swallowing it\n // left those units off the shelf with nothing recording that they had gone.\n // A check they already ANSWERED is a new question either way, because\n // stock moved since.\n const open =\n order.restockCheck && !order.restockCheck.resolution\n ? order.restockCheck\n : null\n const covered = open ? openCheckCoverage(open) : null\n // An open question that names no lines cannot be told apart from one that\n // names these, so it stands. See `openCheckCoverage`.\n if (open && covered === null) return\n // AN ORDER THAT WAS CANCELLED HAS NO RESTOCK QUESTION LEFT (AGL-2149).\n // Until `cancelled: ['refunded']` this was unreachable — a cancelled order\n // could not refund at all — and reaching it now would be the double-count\n // this flag exists to avoid: `cancel-order.ts` already put the units back\n // and wrote the `reason: 'cancellation'` ledger rows, so a fresh \"N units\n // may need restocking\" prompt on top of that invites the merchant to\n // restock the same units a SECOND time. (`cancel-order.ts` handles the\n // opposite order of events — refund first, then cancel — by resolving the\n // open check with what it actually did.)\n //\n // Read from the TIMELINE, not from `order.status`: a full refund flips the\n // order to `refunded` in the transaction above this call, so by the time\n // the flag runs the cancellation is no longer the current status — but the\n // event it stamped is still there, and it is the durable record of the\n // release. A partial refund would leave `cancelled` standing and a status\n // test would catch that one case and silently miss the full one.\n //\n // The cases where the cancel released nothing are covered by the same skip:\n // a `pending` cancel and a POS card order with no `sale` ledger row never\n // decremented anything, so nothing is missing from the shelf to ask about.\n if ((order.timeline ?? []).some((event) => event.event === 'cancelled')) {\n return\n }\n\n // WHICH LINES THIS REVERSAL TOOK BACK, plus the ones an open question\n // already names (AGL-2325). Extending rather than replacing is what keeps\n // ONE prompt on the order: a second partial that withdraws a new line\n // re-asks the whole standing question, so the merchant answers once and\n // the units of the earlier line are not dropped on the way.\n const scope = reversalLineScope(order, options.kind, options.closedTheOrder)\n const extended = scope && covered ? new Set([...scope, ...covered]) : scope\n const { lines: allLines } = await resolveTrackedRestockLines(\n hostRef,\n order,\n // A product this flag cannot read drops out rather than failing the\n // flag: the money has already moved. The cancellation writer (AGL-1808)\n // passes its TRANSACTION's read instead, because a line it silently\n // dropped would leave stock off the shelf it just promised to return.\n (ref) => ref.get().catch(() => null),\n )\n const lines =\n extended == null\n ? allLines\n : allLines.filter((line) => extended.has(line.lineIndex))\n // Nothing was ever decremented for this order, so nothing is missing from\n // the shelf. Writing a prompt here would be the noise that teaches a\n // merchant to ignore the ones that matter.\n if (lines.length === 0) return\n\n // THE PROMPT COUNTS WHAT THE SHELF ACTUALLY LOST, NOT WHAT THE SALE SOLD\n // (AGL-2325). `line.quantity` is the units the line sold, and on a\n // BACKORDER product the inventory floor absorbed part or all of the\n // decrement — stock 0, three units sell, the count stays 0 — so the\n // merchant was asked to restock units the count never gave up. The same\n // `appliedDelta` treatment AGL-2149 gave the cancellation cap, from the\n // same ledger rows and through the same shared helpers, so the question\n // this asks and the units a cancellation would return cannot disagree.\n //\n // The under-prompt this could have caused is what the cap rule already\n // rules out: only pairs the ledger DESCRIBES are capped, so a line it says\n // nothing about keeps the old upper bound rather than being zeroed by a\n // ledger with holes in it. A read that FAILS degrades the same way, to no\n // caps at all — an over-stated prompt is a question the merchant can\n // answer, a missing one is stock left off the shelf for good.\n const capped = capRestockLines(\n lines,\n await readSaleReleaseCaps(hostRef, options.orderId),\n )\n // Every tracked line was absorbed by the floor, so nothing is missing from\n // the shelf and there is no question to ask.\n if (capped.length === 0) return\n\n // Asked of the CAPPED lines, so a line the floor absorbed is not counted\n // as something the open prompt is missing — it would re-flag on every\n // reversal forever.\n const asking = new Set(capped.map((line) => line.lineIndex))\n if (covered && alreadyCovered(asking, covered)) return\n\n const units = capped.reduce((sum, line) => sum + line.quantity, 0)\n const flaggedAtMs = Date.now()\n const record: CommerceModel.OrderRestockCheck = {\n kind: options.kind,\n lines: capped,\n units,\n fullyReversed: options.closedTheOrder,\n flaggedAtMs,\n }\n await firestore.runTransaction(async (transaction) => {\n const fresh = await transaction.get(orderRef)\n if (!fresh.exists) return\n const current = CommerceModel.liftLegacyOrder(\n (fresh.data() ?? {}) as never,\n )\n // Re-asked inside the transaction that writes it, so two reversals\n // settling at once cannot both flag — the same mechanism AGL-1754 used to\n // make its status flip observable exactly once, and the reason this is\n // not a bare `set(…, { merge: true })`. Re-asked in the same LINE terms\n // as the check above (AGL-2325): a concurrent flag that already covers\n // every line this one would ask about has asked the question, while one\n // covering a different line leaves this one still worth writing.\n const currentOpen =\n current.restockCheck && !current.restockCheck.resolution\n ? current.restockCheck\n : null\n if (currentOpen) {\n const currentCoverage = openCheckCoverage(currentOpen)\n if (\n currentCoverage === null ||\n alreadyCovered(asking, currentCoverage)\n ) {\n return\n }\n }\n // `update()`, and the whole `restockCheck` map at that. A merge recurses,\n // so a re-flag after a merchant answered would inherit the previous\n // `resolution`/`resolvedAtMs` and read as already handled while asking a\n // brand-new question. `update()` replaces a nested map wholesale, which\n // is the semantics this wants, and the transaction has just proven the\n // document exists so it cannot `NOT_FOUND`.\n transaction.update(orderRef, {\n restockCheck: record,\n timeline: CommerceModel.appendOrderEvent(\n current,\n 'restock-check',\n `${units} ${units === 1 ? 'unit' : 'units'} may need restocking` +\n (options.kind === 'chargeback'\n ? ' — the shopper kept the goods unless they came back'\n : '') +\n (options.closedTheOrder ? '' : ' (partial reversal, at most)'),\n flaggedAtMs,\n ),\n })\n })\n } catch (error) {\n console.error('flagOrderRestock failed', error)\n }\n}\n\n/**\n * The order lines THIS reversal took back, or `null` for \"all of them\"\n * (AGL-2325).\n *\n * When AGL-2325 was filed a refund was an AMOUNT and nothing else — \"$17 of a\n * $62 order\" selected no line — so naming every tracked line and labelling the\n * total an upper bound was the only honest answer available. AGL-2454 ended\n * that: `refund.ts` records `refundedLineItemIds` in the same transaction that\n * moves the money, and it commits before this flag re-reads the order. A\n * one-line refund on a three-line order that still asks about all three is the\n * same over-statement this issue is about, arriving by a door that opened after\n * it was written.\n *\n * `null` — the whole order — for the three cases where a line list is not the\n * scope:\n *\n * - a **chargeback** reverses the entire payment, so an earlier line-scoped\n * partial must not narrow it to the one line that admin picked;\n * - a refund that **closed the order** took everything back, however the\n * individual attempts were requested;\n * - a refund by **amount alone**, which names no line. That is the pre-AGL-2454\n * shape and still the shape of one requested without a line selection, and\n * it degrades to the upper bound this has always carried rather than to\n * silence.\n */\nfunction reversalLineScope(\n order: CommerceModel.HostOrder,\n kind: 'refund' | 'chargeback',\n closedTheOrder: boolean,\n): Set<number> | null {\n if (kind === 'chargeback' || closedTheOrder) return null\n const withdrawn = new Set<number>()\n for (const value of order.refundedLineItemIds ?? []) {\n const index = Math.round(Number(value))\n if (Number.isFinite(index) && index >= 0) withdrawn.add(index)\n }\n return withdrawn.size > 0 ? withdrawn : null\n}\n\n/**\n * Which order lines an OPEN restock question already covers (AGL-2325), or\n * `null` when that cannot be told.\n *\n * `null` for a check written before `lineIndex` existed, and it is read as\n * \"covers the whole order\" by the one caller — suppressing, which is the\n * behaviour those checks were written under and the safe way to be wrong: a\n * duplicate prompt for units already asked about would teach a merchant to\n * ignore the ones that matter.\n */\nfunction openCheckCoverage(\n check: CommerceModel.OrderRestockCheck,\n): Set<number> | null {\n const covered = new Set<number>()\n for (const line of check.lines ?? []) {\n if (line.lineIndex == null) return null\n covered.add(line.lineIndex)\n }\n return covered\n}\n\n/** Does the open question already cover every line this reversal names? */\nfunction alreadyCovered(\n scope: ReadonlySet<number>,\n covered: ReadonlySet<number>,\n): boolean {\n for (const index of scope) if (!covered.has(index)) return false\n return true\n}\n\n/**\n * This order's sale-release caps, read outside any transaction (AGL-2325).\n *\n * SWALLOWS, like everything else on this path: the money has already moved,\n * and an empty map means \"cap nothing\", which leaves the prompt at the upper\n * bound it has always carried. One equality filter, so the automatic\n * single-field index answers it; the `reason` test runs here, over the handful\n * of rows one order has — the same query shape the cancellation writer runs\n * inside its transaction.\n */\nasync function readSaleReleaseCaps(\n hostRef: FirebaseFirestore.DocumentReference,\n orderId: string,\n): Promise<Map<string, number>> {\n try {\n const rows = await hostRef\n .collection('inventoryAdjustments')\n .where('orderId', '==', orderId)\n .get()\n return saleReleaseCaps(\n rows.docs.filter((row) => row.get('reason') === 'sale'),\n )\n } catch (error) {\n console.error('readSaleReleaseCaps failed', error)\n return new Map()\n }\n}\n\n/**\n * Key for one product+variant pair in a release-cap map.\n *\n * The separator is U+0000 written as an ESCAPE, not as a raw byte (AGL-2355).\n * Emitted literally it made `file(1)` call the source `data`, so grep skipped\n * the whole file as binary and an AGL-2320 sweep missed a call site in it.\n * Same character at runtime. NUL is still the right separator: it cannot occur\n * in a Firestore document id, so no pair can collide.\n */\nexport const restockCapKey = (productId: string, variantId: string): string =>\n `${productId}\\u0000${variantId}`\n\n/**\n * How many units of each product+variant a sale ACTUALLY took off the shelf,\n * read from that order's `reason: 'sale'` ledger rows (AGL-2149).\n *\n * The variant inventory writer floors at zero, and that floor silently absorbs\n * the overshoot on a BACKORDER product: stock 0, `canPurchase` admits the sale\n * because the policy says to, three units sell, `Math.max(0, 0 + -3)` leaves\n * the count at 0. The rows carry both numbers for exactly this — `delta` stays\n * the units SOLD, which is the merchant's history, and `appliedDelta` says how\n * many the count could give up.\n *\n * The writer is deliberately NOT named here: `stock-decrement-race.spec.ts`\n * sweeps every server source for that identifier to prove no file outside its\n * allowlist hand-rolls a stock write, and it matches the bare name, so even a\n * comment mentioning it would read as a call site. This module writes no stock\n * at all — it writes the question.\n *\n * Shared by the cancellation writer, which uses it to bound how much stock\n * goes back, and by the restock flag, which uses it to bound what it CLAIMS\n * went missing (AGL-2325). Extracted rather than copied: the two must not\n * drift, because a flag that names more units than the cancellation would\n * return is the double-count both of them exist to avoid.\n */\nexport function saleReleaseCaps(\n saleRows: readonly { get: (field: string) => unknown }[],\n): Map<string, number> {\n const caps = new Map<string, number>()\n for (const row of saleRows) {\n const productId = String(row.get('productId') ?? '')\n const variantId = String(row.get('variantId') ?? '')\n if (!productId || !variantId) continue\n const moved = Number(row.get('appliedDelta') ?? row.get('delta') ?? 0)\n // Sale rows are negative; the cap is how many units left the shelf.\n caps.set(\n restockCapKey(productId, variantId),\n (caps.get(restockCapKey(productId, variantId)) ?? 0) +\n Math.max(0, -moved),\n )\n }\n return caps\n}\n\n/**\n * Lines narrowed to what the count actually gave up, dropping any that gave up\n * nothing (AGL-2149, AGL-2325).\n *\n * ONLY PAIRS THE LEDGER DESCRIBES ARE CAPPED, and that asymmetry is the whole\n * safety argument. A pair with no row is left at the units it sold, which is\n * what keeps the pre-AGL-1807 draft links — decremented with no row at all —\n * and the buy-now path from being silently zeroed by a ledger that has holes\n * in it. So an absent ledger degrades to the old upper bound rather than to\n * saying nothing moved.\n *\n * The budget is consumed as it is spent, so two lines of the same\n * product+variant share one rather than each claiming it whole. Takes a COPY,\n * so a caller may reuse the map it passed.\n */\nexport function capRestockLines(\n lines: readonly CommerceModel.OrderRestockLine[],\n caps: ReadonlyMap<string, number>,\n): CommerceModel.OrderRestockLine[] {\n const budgets = new Map(caps)\n const capped: CommerceModel.OrderRestockLine[] = []\n for (const line of lines) {\n const key = restockCapKey(line.productId, line.variantId)\n const budget = budgets.get(key)\n const quantity =\n budget == null ? line.quantity : Math.min(line.quantity, budget)\n if (budget != null) budgets.set(key, budget - quantity)\n if (quantity <= 0) continue\n capped.push({ ...line, quantity })\n }\n return capped\n}\n\n/** What an order's tracked lines resolved to, with the products behind them. */\nexport interface TrackedRestockLines {\n lines: CommerceModel.OrderRestockLine[]\n /**\n * The product doc behind each line, by product id — read once each, and\n * handed back so a caller that WRITES stock (AGL-1808) does not read them a\n * second time. Only products that contributed a line appear.\n */\n products: Map<string, CommerceModel.HostProduct>\n}\n\n/**\n * The order's lines whose stock the sale actually took off the shelf.\n *\n * The test is the sale path's own — `variant.inventory != null` on the variant\n * that sold — read from the product as it stands NOW, because that is the\n * count a restock would be applied to. A product deleted since the sale, or a\n * variant removed from it, therefore drops out: there is no number left to put\n * anything back into.\n *\n * Products are read once each, so a three-line order of the same product costs\n * one read rather than three.\n *\n * `read` is the caller's, and the choice is not cosmetic (AGL-1808): the flag\n * passes a swallowing `get()` because a product it cannot read must not fail a\n * refund that already moved, while the cancellation writer passes its\n * TRANSACTION's `get` so every product it is about to increment is read under\n * the same transaction that writes it — and so a read failure aborts rather\n * than quietly shortening the list.\n */\nexport async function resolveTrackedRestockLines(\n hostRef: FirebaseFirestore.DocumentReference,\n order: CommerceModel.HostOrder,\n read: (\n ref: FirebaseFirestore.DocumentReference,\n ) => Promise<{ exists?: boolean; data?: () => unknown } | null>,\n): Promise<TrackedRestockLines> {\n const products = new Map<string, CommerceModel.HostProduct | null>()\n const lines: CommerceModel.OrderRestockLine[] = []\n const orderLines = order.lineItems ?? []\n for (let lineIndex = 0; lineIndex < orderLines.length; lineIndex += 1) {\n const line = orderLines[lineIndex]\n const productId = String(line?.productId ?? '')\n const quantity = Math.round(Number(line?.quantity ?? 0))\n if (!productId || !(quantity > 0)) continue\n // Firestore reserves `__…__` document ids and `.doc()` throws on one\n // SYNCHRONOUSLY, which no `.catch()` on the returned promise would see. A\n // single corrupt line item must not cost the whole flag.\n if (/^__.*__$/.test(productId)) continue\n if (!products.has(productId)) {\n const snapshot = await read(hostRef.collection('products').doc(productId))\n products.set(\n productId,\n snapshot?.exists\n ? CommerceModel.liftLegacyProduct((snapshot.data() ?? {}) as never)\n : null,\n )\n }\n const product = products.get(productId)\n if (!product) continue\n const variantId = line.variantId ?? product.variants?.[0]?.id\n const tracked = (product.variants ?? []).some(\n (variant) => variant.id === variantId && variant.inventory != null,\n )\n if (!variantId || !tracked) continue\n lines.push({\n productId,\n variantId,\n quantity,\n lineIndex,\n ...(line.name ? { name: line.name } : {}),\n ...(line.variantLabel ? { variantLabel: line.variantLabel } : {}),\n })\n }\n const resolved = new Map<string, CommerceModel.HostProduct>()\n for (const line of lines) {\n const product = products.get(line.productId)\n if (product) resolved.set(line.productId, product)\n }\n return { lines, products: resolved }\n}\n"],"names":["CommerceModel","firebaseAdmin","flagOrderRestock","options","snapshot","order","firestore","app","hostRef","collection","doc","hostId","orderRef","orderId","get","exists","liftLegacyOrder","data","open","restockCheck","resolution","covered","openCheckCoverage","timeline","some","event","scope","reversalLineScope","kind","closedTheOrder","extended","Set","lines","allLines","resolveTrackedRestockLines","ref","catch","filter","line","has","lineIndex","length","capped","capRestockLines","readSaleReleaseCaps","asking","map","alreadyCovered","units","reduce","sum","quantity","flaggedAtMs","Date","now","record","fullyReversed","runTransaction","transaction","fresh","current","currentOpen","currentCoverage","update","appendOrderEvent","error","console","withdrawn","value","refundedLineItemIds","index","Math","round","Number","isFinite","add","size","check","rows","where","saleReleaseCaps","docs","row","Map","restockCapKey","productId","variantId","saleRows","caps","String","moved","set","max","budgets","key","budget","min","push","read","products","orderLines","lineItems","product","test","liftLegacyProduct","variants","id","tracked","variant","inventory","name","variantLabel","resolved"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/restock-flag.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'\n\n/**\n * Stock left off the shelf by a reversed order (AGL-1797).\n *\n * The checkout webhook decrements variant inventory on a sale and NOTHING put\n * it back — not `refund.ts`, not the lost-dispute handler (AGL-1787) — so a\n * fully reversed order left the merchant's count permanently one lower than\n * their shelf, compounding with every return.\n *\n * THIS FLAGS. IT DOES NOT RELEASE, and that is the decision.\n *\n * \"Increment on reversal\" is the obvious fix and it is wrong in two of the\n * three cases it would fire on:\n *\n * - a **returned** item does come back, but only once it is RECEIVED, and no\n * fulfilment event records receipt — `OrderFulfillment` has no returned\n * state, so the moment an automatic increment would need does not exist;\n * - a **refund with no return** (goodwill, damaged, lost in post) leaves the\n * goods gone. Incrementing there invents inventory the merchant does not\n * have, and that is strictly worse than the bug it replaces: the bug\n * under-counts and refuses a sale that could have been made, while the\n * \"fix\" over-counts and TAKES a sale that cannot be filled;\n * - a **chargeback** is the clearest do-not-restock case there is. The shopper\n * kept the item and took the money, and `product_not_received` — the most\n * common reason code — asserts the goods shipped.\n *\n * The quantities are not always knowable on a partial. A refund requested as an\n * AMOUNT selects no line — \"$17 of a $62 order\" names nothing — and that is\n * recorded rather than papered over: `quantity` is at most the units the line\n * sold, capped to what the count actually gave up, and `fullyReversed` says\n * whether that bound is a tight one.\n *\n * A refund that NAMES ITS LINES is scoped to them (AGL-2325). When this was\n * written no reversal recorded which lines it covered; AGL-2454 changed that,\n * and `refund.ts` commits `refundedLineItemIds` with the money, before this\n * flag re-reads the order. A one-line refund on a three-line order that still\n * asked about all three was the same over-statement this module already fixed\n * on the units axis, arriving by a door that opened afterwards. See\n * `reversalLineScope` for the three cases that stay whole-order.\n *\n * So the merchant is asked. They are the only party who knows whether the\n * goods came back, and by the time they read this they know it.\n *\n * WHAT IS DELIBERATELY NOT BUILT HERE. No stock writer and no\n * `InventoryAdjustment` row: the products hub's \"Adjust stock\" already writes\n * the variant counts and logs the adjustment with a reason, so a second writer\n * for one number would be a re-implementation, not an extension. And an\n * adjustment row for stock that has NOT moved would corrupt the very ledger\n * that hub renders as history. This writes the question; the existing action\n * answers it.\n *\n * WHERE THE MERCHANT SEES IT. The order timeline, which the console order\n * dialog already renders, so this needs no UI change to be visible at all. The\n * `restockCheck` record beside it is the structured half a badge and a\n * one-click action read; that console work is filed, not smuggled in.\n *\n * NO NOTIFICATION. A refund is initiated BY an admin in the console, so telling\n * them about their own click is noise, and the chargeback door already sends a\n * manager notification for the dispute itself — a second one for the same event\n * would train them to ignore both.\n *\n * ONLY WHAT THE SALE DECREMENTED IS FLAGGED. Tracking lives on the product\n * variant, not the order line, so this reads the products and applies the sale\n * path's own test (`variant.inventory != null`). A store that tracks no stock\n * therefore gets NO flag rather than a prompt it can never act on, which is the\n * difference between a signal and noise. It costs a handful of reads on an\n * event as rare as a reversal.\n *\n * The `inventoryAdjustments` ledger was the tempting shortcut for SELECTING the\n * lines and it is not usable for that: the buy-now/subscription sale path\n * decrements stock without writing a row (`billing-webhook.ts`, the\n * `productId` branch), so the ledger has holes the order's own line items do\n * not. The lines are still chosen from the order.\n *\n * IT IS READ TO BOUND THE NUMBER, THOUGH (AGL-2325), and the holes are exactly\n * why that is safe in this direction. `line.quantity` is the units the line\n * SOLD, and on a backorder product the inventory floor absorbed part or all of\n * the decrement, so the prompt named units the count never gave up. The sale\n * rows' `appliedDelta` says how many it did, and only pairs the ledger\n * DESCRIBES are capped — a line it is silent about keeps the upper bound, so a\n * hole costs an over-stated question rather than stock stranded off the shelf.\n * `saleReleaseCaps` and `capRestockLines` are shared with the cancellation\n * writer, so what this ASKS and what that RETURNS cannot drift apart.\n *\n * FAILURES ARE SWALLOWED, like the person's refund's. The money has already\n * moved and the order already records it; a flag that cannot be written must\n * not fail a refund that has left the merchant's account.\n */\nexport async function flagOrderRestock(options: {\n hostId: string\n orderId: string\n /** Which door the money left by. Only the wording differs. */\n kind: 'refund' | 'chargeback'\n /**\n * True only for the write that moved the order INTO `refunded` — the same\n * signal the person's refund takes (`closedTheSale`), decided by the caller from the status\n * transition it made rather than from the total it read, because\n * `fullyRefunded` is not a once-only signal (AGL-1754).\n */\n closedTheOrder: boolean\n}): Promise<void> {\n try {\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(options.hostId)\n const orderRef = hostRef.collection('orders').doc(options.orderId)\n const snapshot = await orderRef.get()\n if (!snapshot.exists) return\n const order = CommerceModel.liftLegacyOrder(\n (snapshot.data() ?? {}) as never,\n )\n // Once per open question — but \"the question\" is now a set of LINES, not\n // the order (AGL-2325). A second partial that withdraws a line the open\n // prompt already names adds nothing the merchant does not have; one that\n // withdraws a DIFFERENT line is a genuinely new question, and swallowing it\n // left those units off the shelf with nothing recording that they had gone.\n // A check they already ANSWERED is a new question either way, because\n // stock moved since.\n const open =\n order.restockCheck && !order.restockCheck.resolution\n ? order.restockCheck\n : null\n const covered = open ? openCheckCoverage(open) : null\n // An open question that names no lines cannot be told apart from one that\n // names these, so it stands. See `openCheckCoverage`.\n if (open && covered === null) return\n // AN ORDER THAT WAS CANCELLED HAS NO RESTOCK QUESTION LEFT (AGL-2149).\n // Until `cancelled: ['refunded']` this was unreachable — a cancelled order\n // could not refund at all — and reaching it now would be the double-count\n // this flag exists to avoid: `cancel-order.ts` already put the units back\n // and wrote the `reason: 'cancellation'` ledger rows, so a fresh \"N units\n // may need restocking\" prompt on top of that invites the merchant to\n // restock the same units a SECOND time. (`cancel-order.ts` handles the\n // opposite order of events — refund first, then cancel — by resolving the\n // open check with what it actually did.)\n //\n // Read from the TIMELINE, not from `order.status`: a full refund flips the\n // order to `refunded` in the transaction above this call, so by the time\n // the flag runs the cancellation is no longer the current status — but the\n // event it stamped is still there, and it is the durable record of the\n // release. A partial refund would leave `cancelled` standing and a status\n // test would catch that one case and silently miss the full one.\n //\n // The cases where the cancel released nothing are covered by the same skip:\n // a `pending` cancel and a POS card order with no `sale` ledger row never\n // decremented anything, so nothing is missing from the shelf to ask about.\n if ((order.timeline ?? []).some((event) => event.event === 'cancelled')) {\n return\n }\n\n // WHICH LINES THIS REVERSAL TOOK BACK, plus the ones an open question\n // already names (AGL-2325). Extending rather than replacing is what keeps\n // ONE prompt on the order: a second partial that withdraws a new line\n // re-asks the whole standing question, so the merchant answers once and\n // the units of the earlier line are not dropped on the way.\n const scope = reversalLineScope(order, options.kind, options.closedTheOrder)\n const extended = scope && covered ? new Set([...scope, ...covered]) : scope\n const { lines: allLines } = await resolveTrackedRestockLines(\n hostRef,\n order,\n // A product this flag cannot read drops out rather than failing the\n // flag: the money has already moved. The cancellation writer (AGL-1808)\n // passes its TRANSACTION's read instead, because a line it silently\n // dropped would leave stock off the shelf it just promised to return.\n (ref) => ref.get().catch(() => null),\n )\n const lines =\n extended == null\n ? allLines\n : allLines.filter((line) => extended.has(line.lineIndex))\n // Nothing was ever decremented for this order, so nothing is missing from\n // the shelf. Writing a prompt here would be the noise that teaches a\n // merchant to ignore the ones that matter.\n if (lines.length === 0) return\n\n // THE PROMPT COUNTS WHAT THE SHELF ACTUALLY LOST, NOT WHAT THE SALE SOLD\n // (AGL-2325). `line.quantity` is the units the line sold, and on a\n // BACKORDER product the inventory floor absorbed part or all of the\n // decrement — stock 0, three units sell, the count stays 0 — so the\n // merchant was asked to restock units the count never gave up. The same\n // `appliedDelta` treatment AGL-2149 gave the cancellation cap, from the\n // same ledger rows and through the same shared helpers, so the question\n // this asks and the units a cancellation would return cannot disagree.\n //\n // The under-prompt this could have caused is what the cap rule already\n // rules out: only pairs the ledger DESCRIBES are capped, so a line it says\n // nothing about keeps the old upper bound rather than being zeroed by a\n // ledger with holes in it. A read that FAILS degrades the same way, to no\n // caps at all — an over-stated prompt is a question the merchant can\n // answer, a missing one is stock left off the shelf for good.\n const capped = capRestockLines(\n lines,\n await readSaleReleaseCaps(hostRef, options.orderId),\n )\n // Every tracked line was absorbed by the floor, so nothing is missing from\n // the shelf and there is no question to ask.\n if (capped.length === 0) return\n\n // Asked of the CAPPED lines, so a line the floor absorbed is not counted\n // as something the open prompt is missing — it would re-flag on every\n // reversal forever.\n const asking = new Set(capped.map((line) => line.lineIndex))\n if (covered && alreadyCovered(asking, covered)) return\n\n const units = capped.reduce((sum, line) => sum + line.quantity, 0)\n const flaggedAtMs = Date.now()\n const record: CommerceModel.OrderRestockCheck = {\n kind: options.kind,\n lines: capped,\n units,\n fullyReversed: options.closedTheOrder,\n flaggedAtMs,\n }\n await firestore.runTransaction(async (transaction) => {\n const fresh = await transaction.get(orderRef)\n if (!fresh.exists) return\n const current = CommerceModel.liftLegacyOrder(\n (fresh.data() ?? {}) as never,\n )\n // Re-asked inside the transaction that writes it, so two reversals\n // settling at once cannot both flag — the same mechanism AGL-1754 used to\n // make its status flip observable exactly once, and the reason this is\n // not a bare `set(…, { merge: true })`. Re-asked in the same LINE terms\n // as the check above (AGL-2325): a concurrent flag that already covers\n // every line this one would ask about has asked the question, while one\n // covering a different line leaves this one still worth writing.\n const currentOpen =\n current.restockCheck && !current.restockCheck.resolution\n ? current.restockCheck\n : null\n if (currentOpen) {\n const currentCoverage = openCheckCoverage(currentOpen)\n if (\n currentCoverage === null ||\n alreadyCovered(asking, currentCoverage)\n ) {\n return\n }\n }\n // `update()`, and the whole `restockCheck` map at that. A merge recurses,\n // so a re-flag after a merchant answered would inherit the previous\n // `resolution`/`resolvedAtMs` and read as already handled while asking a\n // brand-new question. `update()` replaces a nested map wholesale, which\n // is the semantics this wants, and the transaction has just proven the\n // document exists so it cannot `NOT_FOUND`.\n transaction.update(orderRef, {\n restockCheck: record,\n timeline: CommerceModel.appendOrderEvent(\n current,\n 'restock-check',\n `${units} ${units === 1 ? 'unit' : 'units'} may need restocking` +\n (options.kind === 'chargeback'\n ? ' — the shopper kept the goods unless they came back'\n : '') +\n (options.closedTheOrder ? '' : ' (partial reversal, at most)'),\n flaggedAtMs,\n ),\n })\n })\n } catch (error) {\n console.error('flagOrderRestock failed', error)\n }\n}\n\n/**\n * The order lines THIS reversal took back, or `null` for \"all of them\"\n * (AGL-2325).\n *\n * When AGL-2325 was filed a refund was an AMOUNT and nothing else — \"$17 of a\n * $62 order\" selected no line — so naming every tracked line and labelling the\n * total an upper bound was the only honest answer available. AGL-2454 ended\n * that: `refund.ts` records `refundedLineItemIds` in the same transaction that\n * moves the money, and it commits before this flag re-reads the order. A\n * one-line refund on a three-line order that still asks about all three is the\n * same over-statement this issue is about, arriving by a door that opened after\n * it was written.\n *\n * `null` — the whole order — for the three cases where a line list is not the\n * scope:\n *\n * - a **chargeback** reverses the entire payment, so an earlier line-scoped\n * partial must not narrow it to the one line that admin picked;\n * - a refund that **closed the order** took everything back, however the\n * individual attempts were requested;\n * - a refund by **amount alone**, which names no line. That is the pre-AGL-2454\n * shape and still the shape of one requested without a line selection, and\n * it degrades to the upper bound this has always carried rather than to\n * silence.\n */\nfunction reversalLineScope(\n order: CommerceModel.HostOrder,\n kind: 'refund' | 'chargeback',\n closedTheOrder: boolean,\n): Set<number> | null {\n if (kind === 'chargeback' || closedTheOrder) return null\n const withdrawn = new Set<number>()\n for (const value of order.refundedLineItemIds ?? []) {\n const index = Math.round(Number(value))\n if (Number.isFinite(index) && index >= 0) withdrawn.add(index)\n }\n return withdrawn.size > 0 ? withdrawn : null\n}\n\n/**\n * Which order lines an OPEN restock question already covers (AGL-2325), or\n * `null` when that cannot be told.\n *\n * `null` for a check written before `lineIndex` existed, and it is read as\n * \"covers the whole order\" by the one caller — suppressing, which is the\n * behaviour those checks were written under and the safe way to be wrong: a\n * duplicate prompt for units already asked about would teach a merchant to\n * ignore the ones that matter.\n */\nfunction openCheckCoverage(\n check: CommerceModel.OrderRestockCheck,\n): Set<number> | null {\n const covered = new Set<number>()\n for (const line of check.lines ?? []) {\n if (line.lineIndex == null) return null\n covered.add(line.lineIndex)\n }\n return covered\n}\n\n/** Does the open question already cover every line this reversal names? */\nfunction alreadyCovered(\n scope: ReadonlySet<number>,\n covered: ReadonlySet<number>,\n): boolean {\n for (const index of scope) if (!covered.has(index)) return false\n return true\n}\n\n/**\n * This order's sale-release caps, read outside any transaction (AGL-2325).\n *\n * SWALLOWS, like everything else on this path: the money has already moved,\n * and an empty map means \"cap nothing\", which leaves the prompt at the upper\n * bound it has always carried. One equality filter, so the automatic\n * single-field index answers it; the `reason` test runs here, over the handful\n * of rows one order has — the same query shape the cancellation writer runs\n * inside its transaction.\n */\nasync function readSaleReleaseCaps(\n hostRef: FirebaseFirestore.DocumentReference,\n orderId: string,\n): Promise<Map<string, number>> {\n try {\n const rows = await hostRef\n .collection('inventoryAdjustments')\n .where('orderId', '==', orderId)\n .get()\n return saleReleaseCaps(\n rows.docs.filter((row) => row.get('reason') === 'sale'),\n )\n } catch (error) {\n console.error('readSaleReleaseCaps failed', error)\n return new Map()\n }\n}\n\n/**\n * Key for one product+variant pair in a release-cap map.\n *\n * The separator is U+0000 written as an ESCAPE, not as a raw byte (AGL-2355).\n * Emitted literally it made `file(1)` call the source `data`, so grep skipped\n * the whole file as binary and an AGL-2320 sweep missed a call site in it.\n * Same character at runtime. NUL is still the right separator: it cannot occur\n * in a Firestore document id, so no pair can collide.\n */\nexport const restockCapKey = (productId: string, variantId: string): string =>\n `${productId}\\u0000${variantId}`\n\n/**\n * How many units of each product+variant a sale ACTUALLY took off the shelf,\n * read from that order's `reason: 'sale'` ledger rows (AGL-2149).\n *\n * The variant inventory writer floors at zero, and that floor silently absorbs\n * the overshoot on a BACKORDER product: stock 0, `canPurchase` admits the sale\n * because the policy says to, three units sell, `Math.max(0, 0 + -3)` leaves\n * the count at 0. The rows carry both numbers for exactly this — `delta` stays\n * the units SOLD, which is the merchant's history, and `appliedDelta` says how\n * many the count could give up.\n *\n * The writer is deliberately NOT named here: `stock-decrement-race.spec.ts`\n * sweeps every server source for that identifier to prove no file outside its\n * allowlist hand-rolls a stock write, and it matches the bare name, so even a\n * comment mentioning it would read as a call site. This module writes no stock\n * at all — it writes the question.\n *\n * Shared by the cancellation writer, which uses it to bound how much stock\n * goes back, and by the restock flag, which uses it to bound what it CLAIMS\n * went missing (AGL-2325). Extracted rather than copied: the two must not\n * drift, because a flag that names more units than the cancellation would\n * return is the double-count both of them exist to avoid.\n */\nexport function saleReleaseCaps(\n saleRows: readonly { get: (field: string) => unknown }[],\n): Map<string, number> {\n const caps = new Map<string, number>()\n for (const row of saleRows) {\n const productId = String(row.get('productId') ?? '')\n const variantId = String(row.get('variantId') ?? '')\n if (!productId || !variantId) continue\n const moved = Number(row.get('appliedDelta') ?? row.get('delta') ?? 0)\n // Sale rows are negative; the cap is how many units left the shelf.\n caps.set(\n restockCapKey(productId, variantId),\n (caps.get(restockCapKey(productId, variantId)) ?? 0) +\n Math.max(0, -moved),\n )\n }\n return caps\n}\n\n/**\n * Lines narrowed to what the count actually gave up, dropping any that gave up\n * nothing (AGL-2149, AGL-2325).\n *\n * ONLY PAIRS THE LEDGER DESCRIBES ARE CAPPED, and that asymmetry is the whole\n * safety argument. A pair with no row is left at the units it sold, which is\n * what keeps the pre-AGL-1807 draft links — decremented with no row at all —\n * and the buy-now path from being silently zeroed by a ledger that has holes\n * in it. So an absent ledger degrades to the old upper bound rather than to\n * saying nothing moved.\n *\n * The budget is consumed as it is spent, so two lines of the same\n * product+variant share one rather than each claiming it whole. Takes a COPY,\n * so a caller may reuse the map it passed.\n */\nexport function capRestockLines(\n lines: readonly CommerceModel.OrderRestockLine[],\n caps: ReadonlyMap<string, number>,\n): CommerceModel.OrderRestockLine[] {\n const budgets = new Map(caps)\n const capped: CommerceModel.OrderRestockLine[] = []\n for (const line of lines) {\n const key = restockCapKey(line.productId, line.variantId)\n const budget = budgets.get(key)\n const quantity =\n budget == null ? line.quantity : Math.min(line.quantity, budget)\n if (budget != null) budgets.set(key, budget - quantity)\n if (quantity <= 0) continue\n capped.push({ ...line, quantity })\n }\n return capped\n}\n\n/** What an order's tracked lines resolved to, with the products behind them. */\nexport interface TrackedRestockLines {\n lines: CommerceModel.OrderRestockLine[]\n /**\n * The product doc behind each line, by product id — read once each, and\n * handed back so a caller that WRITES stock (AGL-1808) does not read them a\n * second time. Only products that contributed a line appear.\n */\n products: Map<string, CommerceModel.HostProduct>\n}\n\n/**\n * The order's lines whose stock the sale actually took off the shelf.\n *\n * The test is the sale path's own — `variant.inventory != null` on the variant\n * that sold — read from the product as it stands NOW, because that is the\n * count a restock would be applied to. A product deleted since the sale, or a\n * variant removed from it, therefore drops out: there is no number left to put\n * anything back into.\n *\n * Products are read once each, so a three-line order of the same product costs\n * one read rather than three.\n *\n * `read` is the caller's, and the choice is not cosmetic (AGL-1808): the flag\n * passes a swallowing `get()` because a product it cannot read must not fail a\n * refund that already moved, while the cancellation writer passes its\n * TRANSACTION's `get` so every product it is about to increment is read under\n * the same transaction that writes it — and so a read failure aborts rather\n * than quietly shortening the list.\n */\nexport async function resolveTrackedRestockLines(\n hostRef: FirebaseFirestore.DocumentReference,\n order: CommerceModel.HostOrder,\n read: (\n ref: FirebaseFirestore.DocumentReference,\n ) => Promise<{ exists?: boolean; data?: () => unknown } | null>,\n): Promise<TrackedRestockLines> {\n const products = new Map<string, CommerceModel.HostProduct | null>()\n const lines: CommerceModel.OrderRestockLine[] = []\n const orderLines = order.lineItems ?? []\n for (let lineIndex = 0; lineIndex < orderLines.length; lineIndex += 1) {\n const line = orderLines[lineIndex]\n const productId = String(line?.productId ?? '')\n const quantity = Math.round(Number(line?.quantity ?? 0))\n if (!productId || !(quantity > 0)) continue\n // Firestore reserves `__…__` document ids and `.doc()` throws on one\n // SYNCHRONOUSLY, which no `.catch()` on the returned promise would see. A\n // single corrupt line item must not cost the whole flag.\n if (/^__.*__$/.test(productId)) continue\n if (!products.has(productId)) {\n const snapshot = await read(hostRef.collection('products').doc(productId))\n products.set(\n productId,\n snapshot?.exists\n ? CommerceModel.liftLegacyProduct((snapshot.data() ?? {}) as never)\n : null,\n )\n }\n const product = products.get(productId)\n if (!product) continue\n const variantId = line.variantId ?? product.variants?.[0]?.id\n const tracked = (product.variants ?? []).some(\n (variant) => variant.id === variantId && variant.inventory != null,\n )\n if (!variantId || !tracked) continue\n lines.push({\n productId,\n variantId,\n quantity,\n lineIndex,\n ...(line.name ? { name: line.name } : {}),\n ...(line.variantLabel ? { variantLabel: line.variantLabel } : {}),\n })\n }\n const resolved = new Map<string, CommerceModel.HostProduct>()\n for (const line of lines) {\n const product = products.get(line.productId)\n if (product) resolved.set(line.productId, product)\n }\n return { lines, products: resolved }\n}\n"],"names":["CommerceModel","firebaseAdmin","flagOrderRestock","options","snapshot","order","firestore","app","hostRef","collection","doc","hostId","orderRef","orderId","get","exists","liftLegacyOrder","data","open","restockCheck","resolution","covered","openCheckCoverage","timeline","some","event","scope","reversalLineScope","kind","closedTheOrder","extended","Set","lines","allLines","resolveTrackedRestockLines","ref","catch","filter","line","has","lineIndex","length","capped","capRestockLines","readSaleReleaseCaps","asking","map","alreadyCovered","units","reduce","sum","quantity","flaggedAtMs","Date","now","record","fullyReversed","runTransaction","transaction","fresh","current","currentOpen","currentCoverage","update","appendOrderEvent","error","console","withdrawn","value","refundedLineItemIds","index","Math","round","Number","isFinite","add","size","check","rows","where","saleReleaseCaps","docs","row","Map","restockCapKey","productId","variantId","saleRows","caps","String","moved","set","max","budgets","key","budget","min","push","read","products","orderLines","lineItems","product","test","liftLegacyProduct","variants","id","tracked","variant","inventory","name","variantLabel","resolved"],"mappings":"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"}
|
package/src/lib/server/stream.js
CHANGED
|
@@ -22,6 +22,7 @@ import { createHmac, timingSafeEqual } from "crypto";
|
|
|
22
22
|
import { requireActiveMember } from "./membership.js";
|
|
23
23
|
import { checkMemberEntitlement } from "./gate.js";
|
|
24
24
|
import { tokenSigningSecret } from "./download.js";
|
|
25
|
+
import { countOffRouteServe } from "./paid-media-serve-count.js";
|
|
25
26
|
const TTL_MS = 15 * 60 * 1000;
|
|
26
27
|
/**
|
|
27
28
|
* Signed with the dedicated, fail-closed `TOKEN_SIGNING_SECRET` (AGL-509).
|
|
@@ -190,7 +191,9 @@ const TTL_MS = 15 * 60 * 1000;
|
|
|
190
191
|
* minted after the rendition is picked, so there the `Accept` shaped this
|
|
191
192
|
* `Location`, and saying so is correct even under `no-store`.
|
|
192
193
|
*/ if (delivery.via === 'delivery') res.setHeader('Vary', 'Accept');
|
|
193
|
-
|
|
194
|
+
res.redirect(302, delivery.location);
|
|
195
|
+
await countOffRouteServe(app.firestore(), delivery);
|
|
196
|
+
return;
|
|
194
197
|
} catch (error) {
|
|
195
198
|
console.error(error);
|
|
196
199
|
return res.status(500).json({
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/stream.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 { flagLookalikeDomain } from '@aglyn/tenant-data-admin/server/hosted-page-review'\nimport {\n MEDIA_CDN_RENDITION_AUTO,\n MEDIA_CDN_RENDITION_PARAM,\n type PluginApiHandler,\n} from '@aglyn/aglyn/server'\nimport * as CommerceModel from '../model'\nimport {\n firebaseAdmin,\n GATED_VIDEO_SESSION_TTL_MS,\n} from '@aglyn/tenant-data-admin'\nimport {\n createPaidMediaDeliveryIo,\n resolvePaidMediaDelivery,\n} from './paid-media-delivery'\nimport { createHmac, timingSafeEqual } from 'crypto'\nimport { requireActiveMember } from './membership'\nimport { checkMemberEntitlement } from './gate'\nimport { tokenSigningSecret } from './download'\n\nconst TTL_MS = 15 * 60 * 1000\n\n/**\n * Signed with the dedicated, fail-closed `TOKEN_SIGNING_SECRET` (AGL-509).\n * AGL-509 converted the download and supplier tokens but missed this call\n * site (AGL-689): the old `STRIPE_SECRET_KEY ?? 'aglyn'` key left the whole\n * payload — `stream:${hostId}:${productId}:${video}:${exp}`, all public\n * identifiers — forgeable on any deploy without the Stripe key, and the\n * short TTL bought nothing because the forger chooses `exp`.\n */\nfunction sign(hostId: string, productId: string, video: number, exp: number) {\n return createHmac('sha256', tokenSigningSecret())\n .update(`stream:${hostId}:${productId}:${video}:${exp}`)\n .digest('hex')\n .slice(0, 32)\n}\n\n/**\n * Constant-time signature compare (AGL-512, closed here by AGL-1881).\n *\n * The presented signature was compared with `!==`, which short-circuits on\n * the first differing byte — the exact defect AGL-512 closed in `download.ts`\n * and `supplier-update.ts` and missed here, one directory over. It matters\n * more on this route than it reads: the signature is a truncated HMAC over\n * values the caller already knows (`hostId`, `productId`, `video`, `exp`), so\n * a byte-at-a-time oracle recovers a token for a video the caller was never\n * entitled to, and the 15-minute TTL does not bound the attempt rate.\n *\n * Length is checked first because `timingSafeEqual` THROWS on a length\n * mismatch rather than returning false, and the expected length here is a\n * public constant (32 hex chars) — leaking it costs nothing.\n */\nfunction signatureMatches(presented: string, expected: string): boolean {\n const a = Buffer.from(String(presented ?? ''), 'utf8')\n const b = Buffer.from(String(expected ?? ''), 'utf8')\n if (a.length !== b.length) return false\n return timingSafeEqual(new Uint8Array(a), new Uint8Array(b))\n}\n\n/**\n * Gated video streaming (AGL-315). POST (member session) checks the\n * entitlement and mints a short-TTL signed URL; GET with a valid signature\n * redirects to a media URL that itself expires (AGL-2814). The mint step is\n * the server-enforced gate, a shared stream link dies within 15 minutes, and\n * the media URL behind it dies with the viewing session.\n */\nexport const streamHandler: PluginApiHandler = async (req, res) => {\n const hostId = String(\n (req.method === 'POST' ? req.body?.hostId : req.query.hostId) ?? '',\n )\n const productId = String(\n (req.method === 'POST' ? req.body?.productId : req.query.productId) ?? '',\n )\n const video = Math.max(\n 0,\n Number(\n (req.method === 'POST' ? req.body?.video : req.query.video) ?? 0,\n ),\n )\n if (!hostId || !productId) {\n return res.status(400).json({ error: 'Missing hostId or productId' })\n }\n\n try {\n if (req.method === 'POST') {\n // Suspension gate (AGL-550): suspended members (AGL-546) cannot\n // mint stream URLs — 403'd with the session cookie cleared.\n const auth = await requireActiveMember(req, res, hostId, 'Sign in first')\n if (!auth) return\n const email = String(auth.member.get('email') ?? '')\n const entitled =\n email && (await checkMemberEntitlement(hostId, email, productId))\n if (!entitled) {\n return res.status(403).json({ error: 'Not entitled' })\n }\n const exp = Date.now() + TTL_MS\n const url =\n `/api/commerce/stream?hostId=${encodeURIComponent(hostId)}` +\n `&productId=${encodeURIComponent(productId)}&video=${video}` +\n `&exp=${exp}&sig=${sign(hostId, productId, video, exp)}`\n res.setHeader('Cache-Control', 'private, no-store')\n return res.status(200).json({ url, expiresAtMs: exp })\n }\n\n // GET: signature + expiry gate, then redirect to the media.\n const exp = Number(req.query.exp ?? 0)\n const sig = String(req.query.sig ?? '')\n if (\n !exp ||\n exp < Date.now() ||\n !signatureMatches(sig, sign(hostId, productId, video, exp))\n ) {\n return res.status(403).send('Link expired — reload the page')\n }\n const app = firebaseAdmin.app()\n const productSnapshot = await app\n .firestore()\n .collection('hosts')\n .doc(hostId)\n .collection('products')\n .doc(productId)\n .get()\n const product = CommerceModel.liftLegacyProduct(\n (productSnapshot.data() as any) ?? {},\n )\n const file = product.gatedVideos?.[video]\n if (!file?.url) return res.status(404).send('No video')\n /**\n * The redirect lands on a link that expires (AGL-2814).\n *\n * Everything above only guards the 302. What the 302 points at is what\n * the buyer, and anyone who copies the `Location` out of the buyer's\n * network panel, keeps. So `resolvePaidMediaDelivery` hands back one of\n * three things and never a permanent URL:\n *\n * - a CDN URL signed for {@link GATED_VIDEO_SESSION_TTL_MS}, when the video\n * is a PRIVATE asset in this site's library or its org's. The CDN refuses\n * a private asset on every path without an unexpired signature.\n * - a V4 signed Storage read with the same lifetime, for an object in those\n * libraries that no media document owns. A raw download URL's token lives\n * as long as the object, so it is never the redirect.\n * - an author-typed hotlink, unchanged, because nothing of ours runs there.\n *\n * A video whose asset is still PUBLIC is refused rather than delivered:\n * its URL already works for anyone, so redirecting a buyer to it is the\n * leak. The 409 is what the gated player reports to the viewer, and the\n * log line is what tells the operator which product needs its video made\n * private.\n *\n * `?r=auto` rides inside the signed URL (AGL-2766). The signature covers\n * `(scope, mediaId, exp)` and not the parameter, so it asks the CDN for the\n * best delivery copy of the one asset the signature names and can reach no\n * other. Nothing the caller sends reaches this `Location`: the GET half\n * reads `hostId`, `productId`, `video`, `exp` and `sig`, and builds the\n * target from the product document.\n *\n * With a delivery provider configured and its release flag on for the\n * owning org, a video with a current copy there is redirected straight to\n * the provider's short-lived URL instead (AGL-2824), for the same session\n * and the same rendition the CDN would pick. `Accept` is the one request\n * header that reaches that choice, and only to pick among the asset's own\n * encodings.\n */\n const delivery = await resolvePaidMediaDelivery({\n stored: file.url,\n hostId,\n ttlMs: GATED_VIDEO_SESSION_TTL_MS,\n cdnParams: [[MEDIA_CDN_RENDITION_PARAM, MEDIA_CDN_RENDITION_AUTO]],\n delivery: { accept: req.headers?.['accept'] },\n io: createPaidMediaDeliveryIo({\n firestore: app.firestore(),\n bucket: app\n .storage()\n .bucket(process.env['NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET']),\n }),\n })\n // Per-member and time-boxed, whichever way it goes: no cache may keep the\n // redirect past the session it opens, nor a refusal past the fix.\n res.setHeader('Cache-Control', 'private, no-store')\n if (delivery.ok === false) {\n console.error(\n '[commerce/stream] gated video not delivered',\n JSON.stringify({ hostId, productId, video, refusal: delivery.refusal }),\n )\n // A lookalike hotlink is filed for staff and the merchant (AGL-3363).\n if (delivery.refusal === 'lookalike' && delivery.host) {\n await flagLookalikeDomain({\n kind: 'link',\n hostId,\n orgId: null,\n domain: delivery.host,\n where: \"a product's video link\",\n })\n }\n return delivery.refusal === 'not-private'\n ? res.status(409).send('This video is not ready to play yet')\n : res.status(404).send('No video')\n }\n /*\n * `Vary: Accept` only when this response chose the rendition (AGL-2824).\n *\n * A signed CDN URL is the same for every client: the `Location` is a\n * function of the product document and the clock, not of the request's\n * `Accept`, and the negotiation happens on the SECOND response, where\n * `serveMediaCdn` declares `Vary` (AGL-2766). A delivery provider's URL is\n * minted after the rendition is picked, so there the `Accept` shaped this\n * `Location`, and saying so is correct even under `no-store`.\n */\n if (delivery.via === 'delivery') res.setHeader('Vary', 'Accept')\n return res.redirect(302, delivery.location)\n } catch (error) {\n console.error(error)\n return res.status(500).json({ error: 'Stream unavailable' })\n }\n}\n"],"names":["flagLookalikeDomain","MEDIA_CDN_RENDITION_AUTO","MEDIA_CDN_RENDITION_PARAM","CommerceModel","firebaseAdmin","GATED_VIDEO_SESSION_TTL_MS","createPaidMediaDeliveryIo","resolvePaidMediaDelivery","createHmac","timingSafeEqual","requireActiveMember","checkMemberEntitlement","tokenSigningSecret","TTL_MS","sign","hostId","productId","video","exp","update","digest","slice","signatureMatches","presented","expected","a","Buffer","from","String","b","length","Uint8Array","streamHandler","req","res","method","body","query","Math","max","Number","status","json","error","productSnapshot","product","auth","email","member","get","entitled","Date","now","url","encodeURIComponent","setHeader","expiresAtMs","sig","send","app","firestore","collection","doc","liftLegacyProduct","data","file","gatedVideos","delivery","stored","ttlMs","cdnParams","accept","headers","io","bucket","storage","process","env","ok","console","JSON","stringify","refusal","host","kind","orgId","domain","where","via","redirect","location"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/stream.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 { flagLookalikeDomain } from '@aglyn/tenant-data-admin/server/hosted-page-review'\nimport {\n MEDIA_CDN_RENDITION_AUTO,\n MEDIA_CDN_RENDITION_PARAM,\n type PluginApiHandler,\n} from '@aglyn/aglyn/server'\nimport * as CommerceModel from '../model'\nimport {\n firebaseAdmin,\n GATED_VIDEO_SESSION_TTL_MS,\n} from '@aglyn/tenant-data-admin'\nimport {\n createPaidMediaDeliveryIo,\n resolvePaidMediaDelivery,\n} from './paid-media-delivery'\nimport { createHmac, timingSafeEqual } from 'crypto'\nimport { requireActiveMember } from './membership'\nimport { checkMemberEntitlement } from './gate'\nimport { tokenSigningSecret } from './download'\nimport { countOffRouteServe } from './paid-media-serve-count'\n\nconst TTL_MS = 15 * 60 * 1000\n\n/**\n * Signed with the dedicated, fail-closed `TOKEN_SIGNING_SECRET` (AGL-509).\n * AGL-509 converted the download and supplier tokens but missed this call\n * site (AGL-689): the old `STRIPE_SECRET_KEY ?? 'aglyn'` key left the whole\n * payload — `stream:${hostId}:${productId}:${video}:${exp}`, all public\n * identifiers — forgeable on any deploy without the Stripe key, and the\n * short TTL bought nothing because the forger chooses `exp`.\n */\nfunction sign(hostId: string, productId: string, video: number, exp: number) {\n return createHmac('sha256', tokenSigningSecret())\n .update(`stream:${hostId}:${productId}:${video}:${exp}`)\n .digest('hex')\n .slice(0, 32)\n}\n\n/**\n * Constant-time signature compare (AGL-512, closed here by AGL-1881).\n *\n * The presented signature was compared with `!==`, which short-circuits on\n * the first differing byte — the exact defect AGL-512 closed in `download.ts`\n * and `supplier-update.ts` and missed here, one directory over. It matters\n * more on this route than it reads: the signature is a truncated HMAC over\n * values the caller already knows (`hostId`, `productId`, `video`, `exp`), so\n * a byte-at-a-time oracle recovers a token for a video the caller was never\n * entitled to, and the 15-minute TTL does not bound the attempt rate.\n *\n * Length is checked first because `timingSafeEqual` THROWS on a length\n * mismatch rather than returning false, and the expected length here is a\n * public constant (32 hex chars) — leaking it costs nothing.\n */\nfunction signatureMatches(presented: string, expected: string): boolean {\n const a = Buffer.from(String(presented ?? ''), 'utf8')\n const b = Buffer.from(String(expected ?? ''), 'utf8')\n if (a.length !== b.length) return false\n return timingSafeEqual(new Uint8Array(a), new Uint8Array(b))\n}\n\n/**\n * Gated video streaming (AGL-315). POST (member session) checks the\n * entitlement and mints a short-TTL signed URL; GET with a valid signature\n * redirects to a media URL that itself expires (AGL-2814). The mint step is\n * the server-enforced gate, a shared stream link dies within 15 minutes, and\n * the media URL behind it dies with the viewing session.\n */\nexport const streamHandler: PluginApiHandler = async (req, res) => {\n const hostId = String(\n (req.method === 'POST' ? req.body?.hostId : req.query.hostId) ?? '',\n )\n const productId = String(\n (req.method === 'POST' ? req.body?.productId : req.query.productId) ?? '',\n )\n const video = Math.max(\n 0,\n Number(\n (req.method === 'POST' ? req.body?.video : req.query.video) ?? 0,\n ),\n )\n if (!hostId || !productId) {\n return res.status(400).json({ error: 'Missing hostId or productId' })\n }\n\n try {\n if (req.method === 'POST') {\n // Suspension gate (AGL-550): suspended members (AGL-546) cannot\n // mint stream URLs — 403'd with the session cookie cleared.\n const auth = await requireActiveMember(req, res, hostId, 'Sign in first')\n if (!auth) return\n const email = String(auth.member.get('email') ?? '')\n const entitled =\n email && (await checkMemberEntitlement(hostId, email, productId))\n if (!entitled) {\n return res.status(403).json({ error: 'Not entitled' })\n }\n const exp = Date.now() + TTL_MS\n const url =\n `/api/commerce/stream?hostId=${encodeURIComponent(hostId)}` +\n `&productId=${encodeURIComponent(productId)}&video=${video}` +\n `&exp=${exp}&sig=${sign(hostId, productId, video, exp)}`\n res.setHeader('Cache-Control', 'private, no-store')\n return res.status(200).json({ url, expiresAtMs: exp })\n }\n\n // GET: signature + expiry gate, then redirect to the media.\n const exp = Number(req.query.exp ?? 0)\n const sig = String(req.query.sig ?? '')\n if (\n !exp ||\n exp < Date.now() ||\n !signatureMatches(sig, sign(hostId, productId, video, exp))\n ) {\n return res.status(403).send('Link expired — reload the page')\n }\n const app = firebaseAdmin.app()\n const productSnapshot = await app\n .firestore()\n .collection('hosts')\n .doc(hostId)\n .collection('products')\n .doc(productId)\n .get()\n const product = CommerceModel.liftLegacyProduct(\n (productSnapshot.data() as any) ?? {},\n )\n const file = product.gatedVideos?.[video]\n if (!file?.url) return res.status(404).send('No video')\n /**\n * The redirect lands on a link that expires (AGL-2814).\n *\n * Everything above only guards the 302. What the 302 points at is what\n * the buyer, and anyone who copies the `Location` out of the buyer's\n * network panel, keeps. So `resolvePaidMediaDelivery` hands back one of\n * three things and never a permanent URL:\n *\n * - a CDN URL signed for {@link GATED_VIDEO_SESSION_TTL_MS}, when the video\n * is a PRIVATE asset in this site's library or its org's. The CDN refuses\n * a private asset on every path without an unexpired signature.\n * - a V4 signed Storage read with the same lifetime, for an object in those\n * libraries that no media document owns. A raw download URL's token lives\n * as long as the object, so it is never the redirect.\n * - an author-typed hotlink, unchanged, because nothing of ours runs there.\n *\n * A video whose asset is still PUBLIC is refused rather than delivered:\n * its URL already works for anyone, so redirecting a buyer to it is the\n * leak. The 409 is what the gated player reports to the viewer, and the\n * log line is what tells the operator which product needs its video made\n * private.\n *\n * `?r=auto` rides inside the signed URL (AGL-2766). The signature covers\n * `(scope, mediaId, exp)` and not the parameter, so it asks the CDN for the\n * best delivery copy of the one asset the signature names and can reach no\n * other. Nothing the caller sends reaches this `Location`: the GET half\n * reads `hostId`, `productId`, `video`, `exp` and `sig`, and builds the\n * target from the product document.\n *\n * With a delivery provider configured and its release flag on for the\n * owning org, a video with a current copy there is redirected straight to\n * the provider's short-lived URL instead (AGL-2824), for the same session\n * and the same rendition the CDN would pick. `Accept` is the one request\n * header that reaches that choice, and only to pick among the asset's own\n * encodings.\n */\n const delivery = await resolvePaidMediaDelivery({\n stored: file.url,\n hostId,\n ttlMs: GATED_VIDEO_SESSION_TTL_MS,\n cdnParams: [[MEDIA_CDN_RENDITION_PARAM, MEDIA_CDN_RENDITION_AUTO]],\n delivery: { accept: req.headers?.['accept'] },\n io: createPaidMediaDeliveryIo({\n firestore: app.firestore(),\n bucket: app\n .storage()\n .bucket(process.env['NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET']),\n }),\n })\n // Per-member and time-boxed, whichever way it goes: no cache may keep the\n // redirect past the session it opens, nor a refusal past the fix.\n res.setHeader('Cache-Control', 'private, no-store')\n if (delivery.ok === false) {\n console.error(\n '[commerce/stream] gated video not delivered',\n JSON.stringify({ hostId, productId, video, refusal: delivery.refusal }),\n )\n // A lookalike hotlink is filed for staff and the merchant (AGL-3363).\n if (delivery.refusal === 'lookalike' && delivery.host) {\n await flagLookalikeDomain({\n kind: 'link',\n hostId,\n orgId: null,\n domain: delivery.host,\n where: \"a product's video link\",\n })\n }\n return delivery.refusal === 'not-private'\n ? res.status(409).send('This video is not ready to play yet')\n : res.status(404).send('No video')\n }\n /*\n * `Vary: Accept` only when this response chose the rendition (AGL-2824).\n *\n * A signed CDN URL is the same for every client: the `Location` is a\n * function of the product document and the clock, not of the request's\n * `Accept`, and the negotiation happens on the SECOND response, where\n * `serveMediaCdn` declares `Vary` (AGL-2766). A delivery provider's URL is\n * minted after the rendition is picked, so there the `Accept` shaped this\n * `Location`, and saying so is correct even under `no-store`.\n */\n if (delivery.via === 'delivery') res.setHeader('Vary', 'Accept')\n res.redirect(302, delivery.location)\n await countOffRouteServe(app.firestore(), delivery)\n return\n } catch (error) {\n console.error(error)\n return res.status(500).json({ error: 'Stream unavailable' })\n }\n}\n"],"names":["flagLookalikeDomain","MEDIA_CDN_RENDITION_AUTO","MEDIA_CDN_RENDITION_PARAM","CommerceModel","firebaseAdmin","GATED_VIDEO_SESSION_TTL_MS","createPaidMediaDeliveryIo","resolvePaidMediaDelivery","createHmac","timingSafeEqual","requireActiveMember","checkMemberEntitlement","tokenSigningSecret","countOffRouteServe","TTL_MS","sign","hostId","productId","video","exp","update","digest","slice","signatureMatches","presented","expected","a","Buffer","from","String","b","length","Uint8Array","streamHandler","req","res","method","body","query","Math","max","Number","status","json","error","productSnapshot","product","auth","email","member","get","entitled","Date","now","url","encodeURIComponent","setHeader","expiresAtMs","sig","send","app","firestore","collection","doc","liftLegacyProduct","data","file","gatedVideos","delivery","stored","ttlMs","cdnParams","accept","headers","io","bucket","storage","process","env","ok","console","JSON","stringify","refusal","host","kind","orgId","domain","where","via","redirect","location"],"mappings":"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"}
|