@aglyn/plugins-commerce 1.0.0-beta.201 → 1.0.0-beta.202
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +13 -13
- package/src/lib/components/console/gift-cards-card.component.js +34 -2
- package/src/lib/components/console/gift-cards-card.component.js.map +1 -1
- package/src/lib/model/commerce-gift-cards.d.ts +28 -0
- package/src/lib/model/commerce-gift-cards.js +39 -1
- package/src/lib/model/commerce-gift-cards.js.map +1 -1
- package/src/lib/server/billing-webhook.js +58 -2
- package/src/lib/server/billing-webhook.js.map +1 -1
- package/src/lib/server/cart-checkout.js +46 -8
- package/src/lib/server/cart-checkout.js.map +1 -1
- package/src/lib/server/checkout.js +23 -5
- package/src/lib/server/checkout.js.map +1 -1
- package/src/lib/server/download.js +12 -1
- package/src/lib/server/download.js.map +1 -1
- package/src/lib/server/draft-order.js +5 -2
- package/src/lib/server/draft-order.js.map +1 -1
- package/src/lib/server/feed.js +12 -2
- package/src/lib/server/feed.js.map +1 -1
- package/src/lib/server/gift-card-risk.d.ts +70 -0
- package/src/lib/server/gift-card-risk.js +123 -0
- package/src/lib/server/gift-card-risk.js.map +1 -0
- package/src/lib/server/gift-cards.js +37 -2
- package/src/lib/server/gift-cards.js.map +1 -1
- package/src/lib/server/paid-media-delivery.d.ts +26 -4
- package/src/lib/server/paid-media-delivery.js +36 -9
- package/src/lib/server/paid-media-delivery.js.map +1 -1
- package/src/lib/server/recurring-charges.d.ts +44 -0
- package/src/lib/server/recurring-charges.js +65 -0
- package/src/lib/server/recurring-charges.js.map +1 -0
- package/src/lib/server/refund.js +24 -0
- package/src/lib/server/refund.js.map +1 -1
- package/src/lib/server/reserve.js +14 -5
- package/src/lib/server/reserve.js.map +1 -1
- package/src/lib/server/stream.js +12 -1
- package/src/lib/server/stream.js.map +1 -1
- package/src/lib/server/subscription-portal.js +13 -5
- package/src/lib/server/subscription-portal.js.map +1 -1
- package/src/lib/server/supplier-outbox.js +22 -7
- package/src/lib/server/supplier-outbox.js.map +1 -1
- package/src/lib/server.js +15 -3
- package/src/lib/server.js.map +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/refund.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport * as Aglyn from '@aglyn/aglyn/server'\nimport * as CommerceModel from '../model'\nimport { firebaseAdmin, getOrgForHost } from '@aglyn/tenant-data-admin'\nimport { type PluginApiHandler } from '@aglyn/aglyn/server'\nimport { resolveOrgPermissions } from '@aglyn/tenant-runtime/org-permissions'\nimport { createHash } from 'crypto'\nimport { recordContactRefund } from './contact-refund'\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 // 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","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","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 { 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,3 +1,4 @@
|
|
|
1
|
+
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
1
2
|
/**
|
|
2
3
|
* @license
|
|
3
4
|
* Copyright 2026 Aglyn LLC
|
|
@@ -13,7 +14,7 @@
|
|
|
13
14
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
15
|
* See the License for the specific language governing permissions and
|
|
15
16
|
* limitations under the License.
|
|
16
|
-
*/ import {
|
|
17
|
+
*/ import { readSiteReturnHost, siteReturnUrl } from "@aglyn/aglyn/app-utils/site-return-url";
|
|
17
18
|
import * as Aglyn from "@aglyn/aglyn/server";
|
|
18
19
|
import * as CommerceModel from "../model/index.js";
|
|
19
20
|
import { claimAttempt } from "@aglyn/aglyn/server";
|
|
@@ -56,7 +57,7 @@ import { checkoutSessionCardAuthenticationParams } from "@aglyn/tenant-data-admi
|
|
|
56
57
|
}
|
|
57
58
|
let claim = null;
|
|
58
59
|
try {
|
|
59
|
-
var _ref1, _ref2, _req_headers_referer;
|
|
60
|
+
var _ref1, _ref2, _req_headers_referer, _req_headers_host;
|
|
60
61
|
var _ownerOrg_org, _ownerOrg_org1, _storeSnapshot_data;
|
|
61
62
|
const firestore = firebaseAdmin.app().firestore();
|
|
62
63
|
const hostRef = firestore.collection('hosts').doc(hostId);
|
|
@@ -353,9 +354,17 @@ import { checkoutSessionCardAuthenticationParams } from "@aglyn/tenant-data-admi
|
|
|
353
354
|
error: 'Those dates just sold out'
|
|
354
355
|
});
|
|
355
356
|
}
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
357
|
+
// The site's own page, never a header's say-so (AGL-3363): a caller
|
|
358
|
+
// writes `Referer` and `Host`, and this URL is where Stripe sends the
|
|
359
|
+
// payer — and what the receipt's download links are built from.
|
|
360
|
+
const backUrl = siteReturnUrl({
|
|
361
|
+
candidates: [
|
|
362
|
+
String((_req_headers_referer = req.headers.referer) != null ? _req_headers_referer : ''),
|
|
363
|
+
`https://${req.headers.host}`
|
|
364
|
+
],
|
|
365
|
+
site: await readSiteReturnHost(hostRef),
|
|
366
|
+
requestHost: String((_req_headers_host = req.headers.host) != null ? _req_headers_host : '')
|
|
367
|
+
});
|
|
359
368
|
const separator = backUrl.includes('?') ? '&' : '?';
|
|
360
369
|
const params = new URLSearchParams(_extends({
|
|
361
370
|
mode: 'payment'
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/reserve.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 type { AttemptClaim, PluginApiHandler } from '@aglyn/aglyn/server'\nimport * as Aglyn from '@aglyn/aglyn/server'\nimport * as CommerceModel from '../model'\nimport { claimAttempt } from '@aglyn/aglyn/server'\nimport { firebaseAdmin, getOrgForHost } from '@aglyn/tenant-data-admin'\nimport { connectLinkageIsReady } from '@aglyn/tenant-data-admin/server/stripe-account-mode'\nimport { checkoutSessionCardAuthenticationParams } from '@aglyn/tenant-data-admin/server/stripe-card-authentication'\n\n/**\n * Reserve a stay (AGL-310): server-side quote + availability check,\n * pending reservation doc, then a Stripe session for the deposit (or\n * full amount) on the merchant's account. Webhook completion confirms\n * the reservation; abandoned pending holds expire after 30 minutes and\n * stop blocking the calendar.\n */\nexport const reserveHandler: 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 body =\n typeof req.body === 'string' ? JSON.parse(req.body) : (req.body ?? {})\n const hostId = String(body.hostId ?? '')\n const resourceId = String(body.resourceId ?? '')\n const checkInDayMs = CommerceModel.toDayMs(Number(body.checkInDayMs ?? 0))\n const checkOutDayMs = CommerceModel.toDayMs(Number(body.checkOutDayMs ?? 0))\n const guestName = String(body.guestName ?? '').trim().slice(0, 120)\n const guestEmail = String(body.guestEmail ?? '').trim().toLowerCase().slice(0, 120)\n // One reservation attempt, minted by the widget (AGL-1697). Node lowercases\n // incoming headers, but read both spellings — the plugin API request type\n // makes no promise about casing.\n const idempotencyKey = String(\n req.headers['idempotency-key'] ?? req.headers['Idempotency-Key'] ?? '',\n ).trim().slice(0, 200)\n if (!hostId || !resourceId || !checkInDayMs || !checkOutDayMs) {\n return res.status(400).json({ error: 'Missing reservation details' })\n }\n\n let claim: AttemptClaim | null = null\n try {\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(hostId)\n // Owner + payment readiness, HOISTED above the claim (AGL-1697): both are\n // deterministic refusals a merchant fixes out of band, and neither should\n // burn the guest's attempt key.\n const ownerOrg = await getOrgForHost(hostId)\n // Plan gate, re-asked per request exactly as the storefront checkout\n // asks it (AGL-1873, the AGL-481 pattern): a lapsed org's storefront\n // must not keep taking reservation deposits — at the free plan's 0%\n // transaction fee — through the one shopper-facing commerce door that\n // skipped the entitlement. Deterministic and merchant-side, so it sits\n // here above the claim with the readiness check.\n if (!Aglyn.checkEntitlement(ownerOrg?.org as any, 'commerce')) {\n return res.status(403).json({ error: 'Reservations are not enabled' })\n }\n const ownerId = ownerOrg?.org?.ownerUid\n const ownerProfile = ownerId\n ? await firestore.collection('profiles').doc(String(ownerId)).get()\n : null\n const accountId = ownerProfile?.get('stripeAccountId')\n if (\n !connectLinkageIsReady(\n {\n accountId,\n chargesEnabled: ownerProfile?.get('stripeChargesEnabled'),\n accountLivemode: ownerProfile?.get('stripeAccountLivemode'),\n },\n { subject: `reserve host ${hostId}` },\n )\n ) {\n return res.status(409).json({ error: 'Payments are not set up yet' })\n }\n\n // The claim sits ABOVE the availability read, and that placement is the\n // fix for this handler's particular lie (AGL-1697): a retried attempt used\n // to collide with its OWN 30-minute pending hold and be told \"Those dates\n // just sold out\" — about dates the guest was holding — where the correct\n // answer was the original session URL. Replaying here means the retry\n // never reaches the availability check at all. Every refusal below the\n // claim releases it, because each is deterministic and retryable.\n const claimed = await claimAttempt(firestore, {\n kind: 'commerce-reserve',\n scopeId: hostId,\n orgId: String(ownerOrg?.org?.id ?? ''),\n key: idempotencyKey,\n busyMessage: 'This reservation is already being processed',\n })\n if ('replay' in claimed) {\n return res.status(claimed.replay.status).json(claimed.replay.body)\n }\n claim = claimed.claim\n\n // ONLY STAYS THAT COULD STILL OVERLAP, NEAREST FIRST (AGL-2159).\n //\n // This was `.where('resourceId','==',resourceId).limit(500)` with no\n // ordering, so Firestore returned 500 documents in `__name__` order — an\n // arbitrary slice of the resource's ENTIRE booking history. Every stay the\n // resource has ever had competed for those 500 slots on equal terms with\n // the ones that matter, so a cottage that had taken 500 bookings began\n // silently dropping LIVE reservations out of its own availability check\n // and confirming a second guest into an occupied room. Nothing about that\n // failure is visible: `isRangeAvailable` is handed a short list and\n // answers, correctly, that the range is free.\n //\n // `checkOutDayMs > checkInDayMs` is the one inequality that removes the\n // whole of the past — a stay that ended before the requested arrival\n // cannot overlap it — and ordering by the same field puts the stays\n // nearest the requested window at the front of the limit rather than\n // whichever ones happen to sort early by id. Needs the composite index\n // `reservations (resourceId ASC, checkOutDayMs ASC)`, added to\n // `cloud/firebase-firestore.indexes.json` and pinned by\n // `reserve-availability-indexes.spec.ts` — an equality plus an `orderBy`\n // on a second field is exactly the shape that reads as free and is not\n // (AGL-1793), and here a missing index throws rather than under-reporting.\n //\n // RESIDUAL, stated: the cap can still bite when a single resource holds\n // more than 500 stays that all end between the requested arrival and the\n // end of a genuinely overlapping one. That is a far narrower window than\n // \"500 bookings in this resource's lifetime\", but it is not zero, and the\n // sweep that would keep expired `pending` holds from filling those slots\n // is a scheduled job this change does not add (see the commit).\n const availabilityQuery = hostRef\n .collection('reservations')\n .where('resourceId', '==', resourceId)\n .where('checkOutDayMs', '>', checkInDayMs)\n .orderBy('checkOutDayMs')\n .limit(500)\n const [resourceSnapshot, reservationsSnapshot, storeSnapshot] =\n await Promise.all([\n hostRef.collection('resources').doc(resourceId).get(),\n availabilityQuery.get(),\n // The merchant's own lodging rate (AGL-1969). Read in the SAME round\n // trip as the availability query rather than as a fourth sequential\n // await — this path already runs three reads before it can answer, and\n // a tax lookup is not a reason to make a guest wait for a fourth.\n hostRef.collection('settings').doc('store').get(),\n ])\n const resource = resourceSnapshot.data() as CommerceModel.HostResource | undefined\n if (!resource) {\n await claim.release()\n return res.status(404).json({ error: 'Unknown resource' })\n }\n\n // Pending holds block for 30 minutes only, so abandoned checkouts\n // release the dates. The lapse itself lives in\n // `reservationHoldsDates`, which `isRangeAvailable` applies — this reads\n // the fields that rule needs and leaves the rule to the model, so the\n // storefront's date-picker cannot answer a different question from the\n // door that takes the money.\n //\n // Extracted so the pre-read below and the re-read inside the booking\n // transaction cannot drift apart (AGL-2450). Two copies of a filter that\n // decides whether a room is free is exactly the shape where one of them\n // quietly stops matching the other.\n const readLive = (snapshot: {\n docs: any[]\n }): CommerceModel.HostReservation[] =>\n snapshot.docs.map((docSnapshot) => ({\n checkInDayMs: Number(docSnapshot.get('checkInDayMs')),\n checkOutDayMs: Number(docSnapshot.get('checkOutDayMs')),\n status: String(\n docSnapshot.get('status'),\n ) as CommerceModel.ReservationStatus,\n createdAtMs: Number(docSnapshot.get('createdAtMs') ?? 0),\n })) as CommerceModel.HostReservation[]\n const now = Date.now()\n const live = readLive(reservationsSnapshot)\n if (\n !CommerceModel.isRangeAvailable(\n resource,\n live,\n checkInDayMs,\n checkOutDayMs,\n now,\n )\n ) {\n // A GENUINE sold-out (a keyed retry replayed before reaching here). The\n // guest picks other dates or the blocking stay is cancelled; either way\n // the key must survive the refusal.\n await claim.release()\n return res.status(409).json({ error: 'Those dates just sold out' })\n }\n const quote = CommerceModel.computeReservationQuote(\n resource,\n checkInDayMs,\n checkOutDayMs,\n )\n if (quote.problem) {\n await claim.release()\n return res.status(400).json({ error: quote.problem })\n }\n\n const chargeCents = quote.depositCents || quote.totalCents\n\n // NO DISCOUNT IS APPLIED HERE, AND THAT IS AN OPEN QUESTION RATHER THAN AN\n // OVERSIGHT (AGL-305).\n //\n // `hosts/{hostId}/discounts` reaches the cart, buy-now, the draft-order\n // payment link and the register. This door was deliberately left out,\n // because unlike those four it does not take a payment for goods — it takes\n // a DEPOSIT against a stay that is settled later, and \"apply 10% off\" has\n // three different meanings against that shape:\n //\n // 1. Discount the STAY. `quote.totalCents` falls, the deposit falls with\n // it because it is a percentage of the subtotal, and the balance falls\n // too. This is the only reading that is actually a discount on the\n // booking — and it reprices a merchant's lodging inventory from a\n // storefront promo code, which is a larger claim than any of the four\n // goods doors makes.\n // 2. Discount the DEPOSIT only. Strictly worse than doing nothing: the\n // balance is computed downstream as `totalCents - paidCents`, so every\n // cent taken off at booking is billed straight back at check-out. The\n // guest would be shown a discount and then charged for it, which is\n // the silent-reversal shape this codebase refuses elsewhere.\n // 3. Discount the BALANCE. Nothing visible at booking; the guest finds\n // out at the register. A promotion nobody can see when they buy is not\n // a promotion.\n //\n // What makes this a product decision and not an implementation one is that\n // the balance is not collected by any door this repo can teach. It is read\n // off the console's reservations card as `totalCents - paidCents` and\n // collected BY A HUMAN at the till, so option 1 is the only one that\n // survives the hand-off — and it survives it by changing the stay's stored\n // price, which is the merchant's revenue basis and what their analytics,\n // their folio and their register all read.\n //\n // The tax makes it sharper still. `lodgingTax` below is an occupancy rate,\n // not a sales rate, and its basis here is already a STATED LIMITATION — it\n // is charged on what is collected (the deposit) rather than on the stay.\n // Layering a discount on top asks whether occupancy tax is owed on the\n // discounted room rate or the gross one, which differs by jurisdiction, is\n // the merchant's own regime to answer, and is not a question this handler\n // can decide from a promo code.\n //\n // So: refusing to guess. A shopper who types a code here is not told it\n // worked — this path never reads one, so there is nothing to fall through\n // silently, which is the failure mode that mattered. When the owner picks\n // a reading, option 1 is the one the machinery already supports: reduce\n // `quote.subtotalCents` before the deposit is derived from it, and the\n // deposit, the balance, the fee and the tax all follow without further\n // change.\n\n // LODGING TAX: the MERCHANT'S OWN RATE, off unless they set one\n // (AGL-1969, answering the decision AGL-1953 recorded here).\n //\n // What this path used to do, and why it was only ever a holding position:\n // it computed no tax in either store mode, on two stated grounds. The\n // first is still true and is the reason for the shape below — a stay is\n // not goods. The AGL-285 zone editor configures a SALES rate, lodging is\n // an occupancy/hotel regime with its own rates, its own registration and\n // its own return, and reading `rates[]` for a night would be confidently\n // wrong rather than merely absent. Stripe Tax has the mirror problem: it\n // needs a lodging tax code this handler does not send, so\n // `automatic_tax` here would compute a goods rate on a room. Neither of\n // those changes, and neither is used.\n //\n // What DID change is that the merchant now has a field of their own to\n // answer in (`tax.lodging`), so \"Aglyn does not know the right number\"\n // stops being a reason to collect nothing. They type the rate, we compute\n // it, charge it, record it and stamp the regime; the settings card states\n // plainly that determining what is owed and to whom is theirs. Aglyn\n // takes no tax position here — this is the manual LODGING rate, which\n // never touches Aglyn's registrations.\n //\n // ⚠️ This comment used to say \"Terms §10.3 ... untouched by this\". §10.3\n // CHANGED on 2026-08-24 (AGL-1956): Aglyn accepts marketplace-facilitator\n // status and new §10.7 makes storefront sales tax Aglyn's where it has a\n // collection obligation. Lodging tax is still the merchant's, so this\n // handler is unaffected — but §10.3 is no longer the authority for that.\n //\n // DEFAULT OFF, and that is load-bearing: `resolveFlatTaxCents` answers\n // zero for an absent, zero, negative or out-of-range rate, so no existing\n // reservation's charge moves because this shipped.\n //\n // THE BASIS IS WHAT IS CHARGED, AND THAT IS A STATED LIMITATION RATHER\n // THAN AN ANSWER. `chargeCents` is `depositCents || totalCents`, so on a\n // deposit-taking resource the tax collected here is the rate on the\n // DEPOSIT, not on the stay. Whether a given jurisdiction wants occupancy\n // tax on the full stay at booking, on the deposit only, or on redemption\n // at check-out is a tax question, it differs by jurisdiction, and this\n // code deliberately does NOT decide it — the amount the platform actually\n // moves is the only basis available to it, the settings card says so in\n // the merchant's own view, and the residual is on AGL-1969.\n const taxSettings = ((storeSnapshot.data() as any)?.tax ??\n {}) as CommerceModel.TaxSettings\n const lodgingTax = CommerceModel.resolveFlatTaxCents(\n taxSettings.lodging,\n chargeCents,\n 'Lodging tax',\n )\n\n // Platform take PLUS Stripe's card cost at cost (AGL-2152). This is a\n // destination charge, so Stripe debits its percentage plus 30¢ from the\n // PLATFORM's balance; with the old take-only figure every tier carrying a\n // 0% service rate paid Aglyn nothing and cost it that fee on each\n // reservation.\n //\n // THE TWO BASES ARE NOW DIFFERENT NUMBERS (AGL-1969), and the split is\n // AGL-2317's rule: the platform take is computed on the ITEMS — the stay\n // — while Stripe's cost is passed through on the WHOLE charge, because\n // that is what Stripe actually debits. Taking a percentage of the lodging\n // tax would be taking a cut of the state's money, which no storefront\n // path does. The previous single-argument form is what made this a real\n // risk: it read as \"the fee base is the charge\" and would have silently\n // started taxing the tax the moment a tax line appeared.\n const feeCents = Aglyn.resolveTransactionFeeCents(\n ownerOrg?.org as any,\n 'service',\n chargeCents,\n chargeCents + lodgingTax.taxCents,\n )\n\n // THE HOLD IS TAKEN IN A TRANSACTION (AGL-2450).\n //\n // The check above is a fast refusal on a stale read, and on its own that is\n // all it ever was: the availability query ran, the quote and the fee were\n // computed, and only then did the hold land — so two guests requesting the\n // same dates both read a reservation set that lacked the other's booking,\n // both passed, and both got a `pending` row and a real payment. Nothing\n // downstream caught it. The webhook's transaction guards double-CONFIRMATION\n // of one booking against Stripe redelivery; it never re-asks availability,\n // so both holds confirmed cleanly into two guests in one room.\n //\n // `claimAttempt` does not help either, and it is worth being explicit about\n // why: it is keyed on the guest's own `Idempotency-Key`, so it dedupes\n // retries of ONE attempt. Two different guests carry two different keys and\n // never contend.\n //\n // Re-asking inside the transaction that writes is the whole guard — the\n // same shape as `decrementVariantStock` (AGL-2320), and easier to justify\n // here: the conflicting write lands BEFORE Stripe is contacted, so this\n // door can legitimately refuse rather than take the money and withhold the\n // room.\n const reservationRef = hostRef.collection('reservations').doc()\n const booked = await firestore.runTransaction(async (transaction: any) => {\n const fresh = await transaction.get(availabilityQuery)\n // Re-timed, not reusing `now`: a transaction that retries under\n // contention may run appreciably later, and the 30-minute pending window\n // has to be measured from when this attempt actually commits.\n if (\n !CommerceModel.isRangeAvailable(\n resource,\n readLive(fresh),\n checkInDayMs,\n checkOutDayMs,\n Date.now(),\n )\n ) {\n return false\n }\n // `create()`, not `set()`: the ref is freshly minted, so an id collision\n // is a bug rather than an overwrite to absorb silently.\n transaction.create(reservationRef, {\n resourceId,\n status: 'pending',\n checkInDayMs,\n checkOutDayMs,\n guestName: guestName || null,\n guestEmail: guestEmail || null,\n nights: quote.nights,\n totalCents: quote.totalCents,\n depositCents: quote.depositCents,\n paidCents: 0,\n createdAtMs: now,\n } satisfies CommerceModel.HostReservation)\n return true\n })\n if (!booked) {\n // Lost the race for these dates. Same refusal as the pre-read's, and the\n // key survives it for the same reason: the guest picks other dates or the\n // blocking stay is cancelled.\n await claim.release()\n return res.status(409).json({ error: 'Those dates just sold out' })\n }\n\n const referer = String(req.headers.referer ?? '')\n const origin = `https://${req.headers.host}`\n const backUrl = referer.startsWith('http') ? referer : origin\n const separator = backUrl.includes('?') ? '&' : '?'\n const params = new URLSearchParams({\n mode: 'payment',\n // 3-D Secure, from the one seam every card payment shares (AGL-3360).\n ...checkoutSessionCardAuthenticationParams('payment'),\n 'line_items[0][quantity]': '1',\n 'line_items[0][price_data][currency]': 'usd',\n 'line_items[0][price_data][unit_amount]': String(chargeCents),\n 'line_items[0][price_data][product_data][name]': `${resource.name} — ${\n quote.nights\n } night${quote.nights === 1 ? '' : 's'}${\n quote.depositCents && quote.depositCents < quote.totalCents\n ? ' (deposit)'\n : ''\n }`.slice(0, 120),\n // The merchant's lodging tax as an ORDINARY product line Stripe is\n // never told is tax — the AGL-1711 construction every other manual rate\n // rides. That is what makes the derived `taxMode` read `manual`: the\n // figure is the merchant's own, computed from their own rate, and never\n // against Aglyn's registrations the way `automatic_tax` would be\n // (AGL-1904). Absent entirely when no rate is set, so a default store's\n // session carries no tax parameter of any kind.\n ...(lodgingTax.taxCents > 0\n ? {\n 'line_items[1][quantity]': '1',\n 'line_items[1][price_data][currency]': 'usd',\n 'line_items[1][price_data][unit_amount]': String(\n lodgingTax.taxCents,\n ),\n 'line_items[1][price_data][product_data][name]': lodgingTax.label,\n }\n : {}),\n ...(feeCents > 0\n ? { 'payment_intent_data[application_fee_amount]': String(feeCents) }\n : {}),\n 'payment_intent_data[transfer_data][destination]': String(accountId),\n ...(guestEmail ? { customer_email: guestEmail } : {}),\n success_url: `${backUrl}${separator}reserved=1`,\n cancel_url: `${backUrl}${separator}reserved=0`,\n 'metadata[type]': 'commerce-reservation',\n 'metadata[hostId]': hostId,\n 'metadata[reservationId]': reservationRef.id,\n 'metadata[feeCents]': String(feeCents),\n // The only witness to the tax (AGL-1969). By the construction above\n // Stripe does not know the second line is tax, so it reports\n // `total_details.amount_tax: 0` — the session's own metadata is what\n // `storefront-tax-record.ts` and the confirmation branch read to record\n // the figure and derive the regime, exactly as `checkout.ts` stamps it\n // for a buy-now sale. Absent when there is no tax, never a reassuring\n // `'0'`: absent and zero are different facts on a back-book question.\n ...(lodgingTax.taxCents > 0\n ? {\n 'metadata[taxCents]': String(lodgingTax.taxCents),\n 'metadata[taxPct]': String(lodgingTax.pct),\n }\n : {}),\n })\n const response = await fetch(\n 'https://api.stripe.com/v1/checkout/sessions',\n {\n method: 'POST',\n headers: {\n Authorization: `Bearer ${process.env.STRIPE_SECRET_KEY}`,\n 'Content-Type': 'application/x-www-form-urlencoded',\n // The half that costs real money (AGL-1697). Stripe replays the\n // existing session for a repeated key instead of opening a second\n // one, covering the window where the claim is written but the\n // response never arrives.\n ...(claim.stripeKey ? { 'Idempotency-Key': claim.stripeKey } : {}),\n },\n body: params.toString(),\n },\n )\n const session = (await response.json()) as { url?: string; error?: any }\n if (!response.ok || !session.url) {\n console.error('Stripe reservation error', session.error)\n await reservationRef.delete().catch(() => undefined)\n // The hold is rolled back, so the key must come back too — the guest\n // presses the same button and the retry re-derives the same digest,\n // which Stripe replays if the session did get created.\n await claim.release()\n return res.status(502).json({ error: 'Payment setup failed' })\n }\n const payload = { url: session.url }\n await claim.record(200, payload)\n return res.status(200).json(payload)\n } catch (error) {\n console.error(error)\n // Release on the way out so a transient failure does not strand the key\n // (AGL-1691's rule): the hold may or may not have landed, and it expires\n // in 30 minutes either way.\n await claim?.release()\n return res.status(500).json({ error: 'Reservation failed' })\n }\n}\n"],"names":["Aglyn","CommerceModel","claimAttempt","firebaseAdmin","getOrgForHost","connectLinkageIsReady","checkoutSessionCardAuthenticationParams","reserveHandler","req","res","body","method","status","json","error","process","env","STRIPE_SECRET_KEY","JSON","parse","hostId","String","resourceId","checkInDayMs","toDayMs","Number","checkOutDayMs","guestName","trim","slice","guestEmail","toLowerCase","idempotencyKey","headers","claim","ownerOrg","storeSnapshot","firestore","app","hostRef","collection","doc","checkEntitlement","org","ownerId","ownerUid","ownerProfile","get","accountId","chargesEnabled","accountLivemode","subject","claimed","kind","scopeId","orgId","id","key","busyMessage","replay","availabilityQuery","where","orderBy","limit","resourceSnapshot","reservationsSnapshot","Promise","all","resource","data","release","readLive","snapshot","docs","map","docSnapshot","createdAtMs","now","Date","live","isRangeAvailable","quote","computeReservationQuote","problem","chargeCents","depositCents","totalCents","taxSettings","tax","lodgingTax","resolveFlatTaxCents","lodging","feeCents","resolveTransactionFeeCents","taxCents","reservationRef","booked","runTransaction","transaction","fresh","create","nights","paidCents","referer","origin","host","backUrl","startsWith","separator","includes","params","URLSearchParams","mode","name","label","customer_email","success_url","cancel_url","pct","response","fetch","Authorization","stripeKey","toString","session","ok","url","console","delete","catch","undefined","payload","record"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/reserve.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 { readSiteReturnHost, siteReturnUrl } from '@aglyn/aglyn/app-utils/site-return-url'\nimport type { AttemptClaim, PluginApiHandler } from '@aglyn/aglyn/server'\nimport * as Aglyn from '@aglyn/aglyn/server'\nimport * as CommerceModel from '../model'\nimport { claimAttempt } from '@aglyn/aglyn/server'\nimport { firebaseAdmin, getOrgForHost } from '@aglyn/tenant-data-admin'\nimport { connectLinkageIsReady } from '@aglyn/tenant-data-admin/server/stripe-account-mode'\nimport { checkoutSessionCardAuthenticationParams } from '@aglyn/tenant-data-admin/server/stripe-card-authentication'\n\n/**\n * Reserve a stay (AGL-310): server-side quote + availability check,\n * pending reservation doc, then a Stripe session for the deposit (or\n * full amount) on the merchant's account. Webhook completion confirms\n * the reservation; abandoned pending holds expire after 30 minutes and\n * stop blocking the calendar.\n */\nexport const reserveHandler: 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 body =\n typeof req.body === 'string' ? JSON.parse(req.body) : (req.body ?? {})\n const hostId = String(body.hostId ?? '')\n const resourceId = String(body.resourceId ?? '')\n const checkInDayMs = CommerceModel.toDayMs(Number(body.checkInDayMs ?? 0))\n const checkOutDayMs = CommerceModel.toDayMs(Number(body.checkOutDayMs ?? 0))\n const guestName = String(body.guestName ?? '').trim().slice(0, 120)\n const guestEmail = String(body.guestEmail ?? '').trim().toLowerCase().slice(0, 120)\n // One reservation attempt, minted by the widget (AGL-1697). Node lowercases\n // incoming headers, but read both spellings — the plugin API request type\n // makes no promise about casing.\n const idempotencyKey = String(\n req.headers['idempotency-key'] ?? req.headers['Idempotency-Key'] ?? '',\n ).trim().slice(0, 200)\n if (!hostId || !resourceId || !checkInDayMs || !checkOutDayMs) {\n return res.status(400).json({ error: 'Missing reservation details' })\n }\n\n let claim: AttemptClaim | null = null\n try {\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(hostId)\n // Owner + payment readiness, HOISTED above the claim (AGL-1697): both are\n // deterministic refusals a merchant fixes out of band, and neither should\n // burn the guest's attempt key.\n const ownerOrg = await getOrgForHost(hostId)\n // Plan gate, re-asked per request exactly as the storefront checkout\n // asks it (AGL-1873, the AGL-481 pattern): a lapsed org's storefront\n // must not keep taking reservation deposits — at the free plan's 0%\n // transaction fee — through the one shopper-facing commerce door that\n // skipped the entitlement. Deterministic and merchant-side, so it sits\n // here above the claim with the readiness check.\n if (!Aglyn.checkEntitlement(ownerOrg?.org as any, 'commerce')) {\n return res.status(403).json({ error: 'Reservations are not enabled' })\n }\n const ownerId = ownerOrg?.org?.ownerUid\n const ownerProfile = ownerId\n ? await firestore.collection('profiles').doc(String(ownerId)).get()\n : null\n const accountId = ownerProfile?.get('stripeAccountId')\n if (\n !connectLinkageIsReady(\n {\n accountId,\n chargesEnabled: ownerProfile?.get('stripeChargesEnabled'),\n accountLivemode: ownerProfile?.get('stripeAccountLivemode'),\n },\n { subject: `reserve host ${hostId}` },\n )\n ) {\n return res.status(409).json({ error: 'Payments are not set up yet' })\n }\n\n // The claim sits ABOVE the availability read, and that placement is the\n // fix for this handler's particular lie (AGL-1697): a retried attempt used\n // to collide with its OWN 30-minute pending hold and be told \"Those dates\n // just sold out\" — about dates the guest was holding — where the correct\n // answer was the original session URL. Replaying here means the retry\n // never reaches the availability check at all. Every refusal below the\n // claim releases it, because each is deterministic and retryable.\n const claimed = await claimAttempt(firestore, {\n kind: 'commerce-reserve',\n scopeId: hostId,\n orgId: String(ownerOrg?.org?.id ?? ''),\n key: idempotencyKey,\n busyMessage: 'This reservation is already being processed',\n })\n if ('replay' in claimed) {\n return res.status(claimed.replay.status).json(claimed.replay.body)\n }\n claim = claimed.claim\n\n // ONLY STAYS THAT COULD STILL OVERLAP, NEAREST FIRST (AGL-2159).\n //\n // This was `.where('resourceId','==',resourceId).limit(500)` with no\n // ordering, so Firestore returned 500 documents in `__name__` order — an\n // arbitrary slice of the resource's ENTIRE booking history. Every stay the\n // resource has ever had competed for those 500 slots on equal terms with\n // the ones that matter, so a cottage that had taken 500 bookings began\n // silently dropping LIVE reservations out of its own availability check\n // and confirming a second guest into an occupied room. Nothing about that\n // failure is visible: `isRangeAvailable` is handed a short list and\n // answers, correctly, that the range is free.\n //\n // `checkOutDayMs > checkInDayMs` is the one inequality that removes the\n // whole of the past — a stay that ended before the requested arrival\n // cannot overlap it — and ordering by the same field puts the stays\n // nearest the requested window at the front of the limit rather than\n // whichever ones happen to sort early by id. Needs the composite index\n // `reservations (resourceId ASC, checkOutDayMs ASC)`, added to\n // `cloud/firebase-firestore.indexes.json` and pinned by\n // `reserve-availability-indexes.spec.ts` — an equality plus an `orderBy`\n // on a second field is exactly the shape that reads as free and is not\n // (AGL-1793), and here a missing index throws rather than under-reporting.\n //\n // RESIDUAL, stated: the cap can still bite when a single resource holds\n // more than 500 stays that all end between the requested arrival and the\n // end of a genuinely overlapping one. That is a far narrower window than\n // \"500 bookings in this resource's lifetime\", but it is not zero, and the\n // sweep that would keep expired `pending` holds from filling those slots\n // is a scheduled job this change does not add (see the commit).\n const availabilityQuery = hostRef\n .collection('reservations')\n .where('resourceId', '==', resourceId)\n .where('checkOutDayMs', '>', checkInDayMs)\n .orderBy('checkOutDayMs')\n .limit(500)\n const [resourceSnapshot, reservationsSnapshot, storeSnapshot] =\n await Promise.all([\n hostRef.collection('resources').doc(resourceId).get(),\n availabilityQuery.get(),\n // The merchant's own lodging rate (AGL-1969). Read in the SAME round\n // trip as the availability query rather than as a fourth sequential\n // await — this path already runs three reads before it can answer, and\n // a tax lookup is not a reason to make a guest wait for a fourth.\n hostRef.collection('settings').doc('store').get(),\n ])\n const resource = resourceSnapshot.data() as CommerceModel.HostResource | undefined\n if (!resource) {\n await claim.release()\n return res.status(404).json({ error: 'Unknown resource' })\n }\n\n // Pending holds block for 30 minutes only, so abandoned checkouts\n // release the dates. The lapse itself lives in\n // `reservationHoldsDates`, which `isRangeAvailable` applies — this reads\n // the fields that rule needs and leaves the rule to the model, so the\n // storefront's date-picker cannot answer a different question from the\n // door that takes the money.\n //\n // Extracted so the pre-read below and the re-read inside the booking\n // transaction cannot drift apart (AGL-2450). Two copies of a filter that\n // decides whether a room is free is exactly the shape where one of them\n // quietly stops matching the other.\n const readLive = (snapshot: {\n docs: any[]\n }): CommerceModel.HostReservation[] =>\n snapshot.docs.map((docSnapshot) => ({\n checkInDayMs: Number(docSnapshot.get('checkInDayMs')),\n checkOutDayMs: Number(docSnapshot.get('checkOutDayMs')),\n status: String(\n docSnapshot.get('status'),\n ) as CommerceModel.ReservationStatus,\n createdAtMs: Number(docSnapshot.get('createdAtMs') ?? 0),\n })) as CommerceModel.HostReservation[]\n const now = Date.now()\n const live = readLive(reservationsSnapshot)\n if (\n !CommerceModel.isRangeAvailable(\n resource,\n live,\n checkInDayMs,\n checkOutDayMs,\n now,\n )\n ) {\n // A GENUINE sold-out (a keyed retry replayed before reaching here). The\n // guest picks other dates or the blocking stay is cancelled; either way\n // the key must survive the refusal.\n await claim.release()\n return res.status(409).json({ error: 'Those dates just sold out' })\n }\n const quote = CommerceModel.computeReservationQuote(\n resource,\n checkInDayMs,\n checkOutDayMs,\n )\n if (quote.problem) {\n await claim.release()\n return res.status(400).json({ error: quote.problem })\n }\n\n const chargeCents = quote.depositCents || quote.totalCents\n\n // NO DISCOUNT IS APPLIED HERE, AND THAT IS AN OPEN QUESTION RATHER THAN AN\n // OVERSIGHT (AGL-305).\n //\n // `hosts/{hostId}/discounts` reaches the cart, buy-now, the draft-order\n // payment link and the register. This door was deliberately left out,\n // because unlike those four it does not take a payment for goods — it takes\n // a DEPOSIT against a stay that is settled later, and \"apply 10% off\" has\n // three different meanings against that shape:\n //\n // 1. Discount the STAY. `quote.totalCents` falls, the deposit falls with\n // it because it is a percentage of the subtotal, and the balance falls\n // too. This is the only reading that is actually a discount on the\n // booking — and it reprices a merchant's lodging inventory from a\n // storefront promo code, which is a larger claim than any of the four\n // goods doors makes.\n // 2. Discount the DEPOSIT only. Strictly worse than doing nothing: the\n // balance is computed downstream as `totalCents - paidCents`, so every\n // cent taken off at booking is billed straight back at check-out. The\n // guest would be shown a discount and then charged for it, which is\n // the silent-reversal shape this codebase refuses elsewhere.\n // 3. Discount the BALANCE. Nothing visible at booking; the guest finds\n // out at the register. A promotion nobody can see when they buy is not\n // a promotion.\n //\n // What makes this a product decision and not an implementation one is that\n // the balance is not collected by any door this repo can teach. It is read\n // off the console's reservations card as `totalCents - paidCents` and\n // collected BY A HUMAN at the till, so option 1 is the only one that\n // survives the hand-off — and it survives it by changing the stay's stored\n // price, which is the merchant's revenue basis and what their analytics,\n // their folio and their register all read.\n //\n // The tax makes it sharper still. `lodgingTax` below is an occupancy rate,\n // not a sales rate, and its basis here is already a STATED LIMITATION — it\n // is charged on what is collected (the deposit) rather than on the stay.\n // Layering a discount on top asks whether occupancy tax is owed on the\n // discounted room rate or the gross one, which differs by jurisdiction, is\n // the merchant's own regime to answer, and is not a question this handler\n // can decide from a promo code.\n //\n // So: refusing to guess. A shopper who types a code here is not told it\n // worked — this path never reads one, so there is nothing to fall through\n // silently, which is the failure mode that mattered. When the owner picks\n // a reading, option 1 is the one the machinery already supports: reduce\n // `quote.subtotalCents` before the deposit is derived from it, and the\n // deposit, the balance, the fee and the tax all follow without further\n // change.\n\n // LODGING TAX: the MERCHANT'S OWN RATE, off unless they set one\n // (AGL-1969, answering the decision AGL-1953 recorded here).\n //\n // What this path used to do, and why it was only ever a holding position:\n // it computed no tax in either store mode, on two stated grounds. The\n // first is still true and is the reason for the shape below — a stay is\n // not goods. The AGL-285 zone editor configures a SALES rate, lodging is\n // an occupancy/hotel regime with its own rates, its own registration and\n // its own return, and reading `rates[]` for a night would be confidently\n // wrong rather than merely absent. Stripe Tax has the mirror problem: it\n // needs a lodging tax code this handler does not send, so\n // `automatic_tax` here would compute a goods rate on a room. Neither of\n // those changes, and neither is used.\n //\n // What DID change is that the merchant now has a field of their own to\n // answer in (`tax.lodging`), so \"Aglyn does not know the right number\"\n // stops being a reason to collect nothing. They type the rate, we compute\n // it, charge it, record it and stamp the regime; the settings card states\n // plainly that determining what is owed and to whom is theirs. Aglyn\n // takes no tax position here — this is the manual LODGING rate, which\n // never touches Aglyn's registrations.\n //\n // ⚠️ This comment used to say \"Terms §10.3 ... untouched by this\". §10.3\n // CHANGED on 2026-08-24 (AGL-1956): Aglyn accepts marketplace-facilitator\n // status and new §10.7 makes storefront sales tax Aglyn's where it has a\n // collection obligation. Lodging tax is still the merchant's, so this\n // handler is unaffected — but §10.3 is no longer the authority for that.\n //\n // DEFAULT OFF, and that is load-bearing: `resolveFlatTaxCents` answers\n // zero for an absent, zero, negative or out-of-range rate, so no existing\n // reservation's charge moves because this shipped.\n //\n // THE BASIS IS WHAT IS CHARGED, AND THAT IS A STATED LIMITATION RATHER\n // THAN AN ANSWER. `chargeCents` is `depositCents || totalCents`, so on a\n // deposit-taking resource the tax collected here is the rate on the\n // DEPOSIT, not on the stay. Whether a given jurisdiction wants occupancy\n // tax on the full stay at booking, on the deposit only, or on redemption\n // at check-out is a tax question, it differs by jurisdiction, and this\n // code deliberately does NOT decide it — the amount the platform actually\n // moves is the only basis available to it, the settings card says so in\n // the merchant's own view, and the residual is on AGL-1969.\n const taxSettings = ((storeSnapshot.data() as any)?.tax ??\n {}) as CommerceModel.TaxSettings\n const lodgingTax = CommerceModel.resolveFlatTaxCents(\n taxSettings.lodging,\n chargeCents,\n 'Lodging tax',\n )\n\n // Platform take PLUS Stripe's card cost at cost (AGL-2152). This is a\n // destination charge, so Stripe debits its percentage plus 30¢ from the\n // PLATFORM's balance; with the old take-only figure every tier carrying a\n // 0% service rate paid Aglyn nothing and cost it that fee on each\n // reservation.\n //\n // THE TWO BASES ARE NOW DIFFERENT NUMBERS (AGL-1969), and the split is\n // AGL-2317's rule: the platform take is computed on the ITEMS — the stay\n // — while Stripe's cost is passed through on the WHOLE charge, because\n // that is what Stripe actually debits. Taking a percentage of the lodging\n // tax would be taking a cut of the state's money, which no storefront\n // path does. The previous single-argument form is what made this a real\n // risk: it read as \"the fee base is the charge\" and would have silently\n // started taxing the tax the moment a tax line appeared.\n const feeCents = Aglyn.resolveTransactionFeeCents(\n ownerOrg?.org as any,\n 'service',\n chargeCents,\n chargeCents + lodgingTax.taxCents,\n )\n\n // THE HOLD IS TAKEN IN A TRANSACTION (AGL-2450).\n //\n // The check above is a fast refusal on a stale read, and on its own that is\n // all it ever was: the availability query ran, the quote and the fee were\n // computed, and only then did the hold land — so two guests requesting the\n // same dates both read a reservation set that lacked the other's booking,\n // both passed, and both got a `pending` row and a real payment. Nothing\n // downstream caught it. The webhook's transaction guards double-CONFIRMATION\n // of one booking against Stripe redelivery; it never re-asks availability,\n // so both holds confirmed cleanly into two guests in one room.\n //\n // `claimAttempt` does not help either, and it is worth being explicit about\n // why: it is keyed on the guest's own `Idempotency-Key`, so it dedupes\n // retries of ONE attempt. Two different guests carry two different keys and\n // never contend.\n //\n // Re-asking inside the transaction that writes is the whole guard — the\n // same shape as `decrementVariantStock` (AGL-2320), and easier to justify\n // here: the conflicting write lands BEFORE Stripe is contacted, so this\n // door can legitimately refuse rather than take the money and withhold the\n // room.\n const reservationRef = hostRef.collection('reservations').doc()\n const booked = await firestore.runTransaction(async (transaction: any) => {\n const fresh = await transaction.get(availabilityQuery)\n // Re-timed, not reusing `now`: a transaction that retries under\n // contention may run appreciably later, and the 30-minute pending window\n // has to be measured from when this attempt actually commits.\n if (\n !CommerceModel.isRangeAvailable(\n resource,\n readLive(fresh),\n checkInDayMs,\n checkOutDayMs,\n Date.now(),\n )\n ) {\n return false\n }\n // `create()`, not `set()`: the ref is freshly minted, so an id collision\n // is a bug rather than an overwrite to absorb silently.\n transaction.create(reservationRef, {\n resourceId,\n status: 'pending',\n checkInDayMs,\n checkOutDayMs,\n guestName: guestName || null,\n guestEmail: guestEmail || null,\n nights: quote.nights,\n totalCents: quote.totalCents,\n depositCents: quote.depositCents,\n paidCents: 0,\n createdAtMs: now,\n } satisfies CommerceModel.HostReservation)\n return true\n })\n if (!booked) {\n // Lost the race for these dates. Same refusal as the pre-read's, and the\n // key survives it for the same reason: the guest picks other dates or the\n // blocking stay is cancelled.\n await claim.release()\n return res.status(409).json({ error: 'Those dates just sold out' })\n }\n\n // The site's own page, never a header's say-so (AGL-3363): a caller\n // writes `Referer` and `Host`, and this URL is where Stripe sends the\n // payer — and what the receipt's download links are built from.\n const backUrl = siteReturnUrl({\n candidates: [String(req.headers.referer ?? ''), `https://${req.headers.host}`],\n site: await readSiteReturnHost(hostRef),\n requestHost: String(req.headers.host ?? ''),\n })\n const separator = backUrl.includes('?') ? '&' : '?'\n const params = new URLSearchParams({\n mode: 'payment',\n // 3-D Secure, from the one seam every card payment shares (AGL-3360).\n ...checkoutSessionCardAuthenticationParams('payment'),\n 'line_items[0][quantity]': '1',\n 'line_items[0][price_data][currency]': 'usd',\n 'line_items[0][price_data][unit_amount]': String(chargeCents),\n 'line_items[0][price_data][product_data][name]': `${resource.name} — ${\n quote.nights\n } night${quote.nights === 1 ? '' : 's'}${\n quote.depositCents && quote.depositCents < quote.totalCents\n ? ' (deposit)'\n : ''\n }`.slice(0, 120),\n // The merchant's lodging tax as an ORDINARY product line Stripe is\n // never told is tax — the AGL-1711 construction every other manual rate\n // rides. That is what makes the derived `taxMode` read `manual`: the\n // figure is the merchant's own, computed from their own rate, and never\n // against Aglyn's registrations the way `automatic_tax` would be\n // (AGL-1904). Absent entirely when no rate is set, so a default store's\n // session carries no tax parameter of any kind.\n ...(lodgingTax.taxCents > 0\n ? {\n 'line_items[1][quantity]': '1',\n 'line_items[1][price_data][currency]': 'usd',\n 'line_items[1][price_data][unit_amount]': String(\n lodgingTax.taxCents,\n ),\n 'line_items[1][price_data][product_data][name]': lodgingTax.label,\n }\n : {}),\n ...(feeCents > 0\n ? { 'payment_intent_data[application_fee_amount]': String(feeCents) }\n : {}),\n 'payment_intent_data[transfer_data][destination]': String(accountId),\n ...(guestEmail ? { customer_email: guestEmail } : {}),\n success_url: `${backUrl}${separator}reserved=1`,\n cancel_url: `${backUrl}${separator}reserved=0`,\n 'metadata[type]': 'commerce-reservation',\n 'metadata[hostId]': hostId,\n 'metadata[reservationId]': reservationRef.id,\n 'metadata[feeCents]': String(feeCents),\n // The only witness to the tax (AGL-1969). By the construction above\n // Stripe does not know the second line is tax, so it reports\n // `total_details.amount_tax: 0` — the session's own metadata is what\n // `storefront-tax-record.ts` and the confirmation branch read to record\n // the figure and derive the regime, exactly as `checkout.ts` stamps it\n // for a buy-now sale. Absent when there is no tax, never a reassuring\n // `'0'`: absent and zero are different facts on a back-book question.\n ...(lodgingTax.taxCents > 0\n ? {\n 'metadata[taxCents]': String(lodgingTax.taxCents),\n 'metadata[taxPct]': String(lodgingTax.pct),\n }\n : {}),\n })\n const response = await fetch(\n 'https://api.stripe.com/v1/checkout/sessions',\n {\n method: 'POST',\n headers: {\n Authorization: `Bearer ${process.env.STRIPE_SECRET_KEY}`,\n 'Content-Type': 'application/x-www-form-urlencoded',\n // The half that costs real money (AGL-1697). Stripe replays the\n // existing session for a repeated key instead of opening a second\n // one, covering the window where the claim is written but the\n // response never arrives.\n ...(claim.stripeKey ? { 'Idempotency-Key': claim.stripeKey } : {}),\n },\n body: params.toString(),\n },\n )\n const session = (await response.json()) as { url?: string; error?: any }\n if (!response.ok || !session.url) {\n console.error('Stripe reservation error', session.error)\n await reservationRef.delete().catch(() => undefined)\n // The hold is rolled back, so the key must come back too — the guest\n // presses the same button and the retry re-derives the same digest,\n // which Stripe replays if the session did get created.\n await claim.release()\n return res.status(502).json({ error: 'Payment setup failed' })\n }\n const payload = { url: session.url }\n await claim.record(200, payload)\n return res.status(200).json(payload)\n } catch (error) {\n console.error(error)\n // Release on the way out so a transient failure does not strand the key\n // (AGL-1691's rule): the hold may or may not have landed, and it expires\n // in 30 minutes either way.\n await claim?.release()\n return res.status(500).json({ error: 'Reservation failed' })\n }\n}\n"],"names":["readSiteReturnHost","siteReturnUrl","Aglyn","CommerceModel","claimAttempt","firebaseAdmin","getOrgForHost","connectLinkageIsReady","checkoutSessionCardAuthenticationParams","reserveHandler","req","res","body","method","status","json","error","process","env","STRIPE_SECRET_KEY","JSON","parse","hostId","String","resourceId","checkInDayMs","toDayMs","Number","checkOutDayMs","guestName","trim","slice","guestEmail","toLowerCase","idempotencyKey","headers","claim","ownerOrg","storeSnapshot","firestore","app","hostRef","collection","doc","checkEntitlement","org","ownerId","ownerUid","ownerProfile","get","accountId","chargesEnabled","accountLivemode","subject","claimed","kind","scopeId","orgId","id","key","busyMessage","replay","availabilityQuery","where","orderBy","limit","resourceSnapshot","reservationsSnapshot","Promise","all","resource","data","release","readLive","snapshot","docs","map","docSnapshot","createdAtMs","now","Date","live","isRangeAvailable","quote","computeReservationQuote","problem","chargeCents","depositCents","totalCents","taxSettings","tax","lodgingTax","resolveFlatTaxCents","lodging","feeCents","resolveTransactionFeeCents","taxCents","reservationRef","booked","runTransaction","transaction","fresh","create","nights","paidCents","backUrl","candidates","referer","host","site","requestHost","separator","includes","params","URLSearchParams","mode","name","label","customer_email","success_url","cancel_url","pct","response","fetch","Authorization","stripeKey","toString","session","ok","url","console","delete","catch","undefined","payload","record"],"mappings":"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"}
|
package/src/lib/server/stream.js
CHANGED
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
14
|
* See the License for the specific language governing permissions and
|
|
15
15
|
* limitations under the License.
|
|
16
|
-
*/ import {
|
|
16
|
+
*/ import { flagLookalikeDomain } from "@aglyn/tenant-data-admin/server/hosted-page-review";
|
|
17
|
+
import { MEDIA_CDN_RENDITION_AUTO, MEDIA_CDN_RENDITION_PARAM } from "@aglyn/aglyn/server";
|
|
17
18
|
import * as CommerceModel from "../model/index.js";
|
|
18
19
|
import { firebaseAdmin, GATED_VIDEO_SESSION_TTL_MS } from "@aglyn/tenant-data-admin";
|
|
19
20
|
import { createPaidMediaDeliveryIo, resolvePaidMediaDelivery } from "./paid-media-delivery.js";
|
|
@@ -167,6 +168,16 @@ const TTL_MS = 15 * 60 * 1000;
|
|
|
167
168
|
video,
|
|
168
169
|
refusal: delivery.refusal
|
|
169
170
|
}));
|
|
171
|
+
// A lookalike hotlink is filed for staff and the merchant (AGL-3363).
|
|
172
|
+
if (delivery.refusal === 'lookalike' && delivery.host) {
|
|
173
|
+
await flagLookalikeDomain({
|
|
174
|
+
kind: 'link',
|
|
175
|
+
hostId,
|
|
176
|
+
orgId: null,
|
|
177
|
+
domain: delivery.host,
|
|
178
|
+
where: "a product's video link"
|
|
179
|
+
});
|
|
180
|
+
}
|
|
170
181
|
return delivery.refusal === 'not-private' ? res.status(409).send('This video is not ready to play yet') : res.status(404).send('No video');
|
|
171
182
|
}
|
|
172
183
|
/*
|
|
@@ -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 {\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 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":["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","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'\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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"}
|