@aglyn/plugins-commerce 1.0.0-beta.219 → 1.0.0-beta.221
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -0
- package/package.json +14 -14
- package/src/lib/components/console/catalog-organization-card.component.js +626 -446
- package/src/lib/components/console/catalog-organization-card.component.js.map +1 -1
- package/src/lib/components/console/payments-settings-card.component.js +1 -1
- package/src/lib/components/console/payments-settings-card.component.js.map +1 -1
- package/src/lib/components/console/product-editor-dialog.component.js +1127 -846
- package/src/lib/components/console/product-editor-dialog.component.js.map +1 -1
- package/src/lib/components/console/site-member-purchases.component.d.ts +24 -0
- package/src/lib/components/console/site-member-purchases.component.js +339 -0
- package/src/lib/components/console/site-member-purchases.component.js.map +1 -0
- package/src/lib/components/member-signup.js +2 -2
- package/src/lib/components/member-signup.js.map +1 -1
- package/src/lib/components/newsletter-signup.js +2 -2
- package/src/lib/components/newsletter-signup.js.map +1 -1
- package/src/lib/constants/operator-alerts.js +5 -3
- package/src/lib/constants/operator-alerts.js.map +1 -1
- package/src/lib/model/commerce-io.js +1 -1
- package/src/lib/model/commerce-io.js.map +1 -1
- package/src/lib/model/commerce-record-lists.d.ts +31 -0
- package/src/lib/model/commerce-record-lists.js +45 -0
- package/src/lib/model/commerce-record-lists.js.map +1 -0
- package/src/lib/model/commerce-record-routes.d.ts +18 -0
- package/src/lib/model/commerce-record-routes.js +1 -1
- package/src/lib/model/commerce-record-routes.js.map +1 -1
- package/src/lib/model/commerce-search-sources.js +4 -2
- package/src/lib/model/commerce-search-sources.js.map +1 -1
- package/src/lib/model/commerce-topics.d.ts +20 -0
- package/src/lib/model/commerce-topics.js +33 -0
- package/src/lib/model/commerce-topics.js.map +1 -0
- package/src/lib/model/index.d.ts +1 -0
- package/src/lib/model/index.js +1 -0
- package/src/lib/model/index.js.map +1 -1
- package/src/lib/model/product-record.d.ts +32 -0
- package/src/lib/model/product-record.js +82 -0
- package/src/lib/model/product-record.js.map +1 -0
- package/src/lib/model/site-member-purchases.d.ts +48 -0
- package/src/lib/model/site-member-purchases.js +50 -0
- package/src/lib/model/site-member-purchases.js.map +1 -0
- package/src/lib/plugin.js +12 -0
- package/src/lib/plugin.js.map +1 -1
- package/src/lib/server/billing-webhook.js +410 -208
- package/src/lib/server/billing-webhook.js.map +1 -1
- package/src/lib/server/cart-checkout.js +1 -1
- package/src/lib/server/cart-checkout.js.map +1 -1
- package/src/lib/server/gift-cards.js +7 -3
- package/src/lib/server/gift-cards.js.map +1 -1
- package/src/lib/server/low-stock.js +3 -1
- package/src/lib/server/low-stock.js.map +1 -1
- package/src/lib/server/member-post.js +2 -2
- package/src/lib/server/member-post.js.map +1 -1
- package/src/lib/server/membership-admin-password.js +4 -1
- package/src/lib/server/membership-admin-password.js.map +1 -1
- package/src/lib/server/membership-register.js +13 -2
- package/src/lib/server/membership-register.js.map +1 -1
- package/src/lib/server/newsletter.js +32 -19
- package/src/lib/server/newsletter.js.map +1 -1
- package/src/lib/server/pos-order.js +18 -10
- package/src/lib/server/pos-order.js.map +1 -1
- package/src/lib/server/process-abandoned.js +4 -2
- package/src/lib/server/process-abandoned.js.map +1 -1
- package/src/lib/server/process-restock.js +7 -5
- package/src/lib/server/process-restock.js.map +1 -1
- package/src/lib/server/product-record-index.js +5 -37
- package/src/lib/server/product-record-index.js.map +1 -1
- package/src/lib/server/reconcile-stock.js +1 -1
- package/src/lib/server/reconcile-stock.js.map +1 -1
- package/src/lib/server/reserve-stock.js +6 -1
- package/src/lib/server/reserve-stock.js.map +1 -1
- package/src/lib/server/revenue-source.js +3 -3
- package/src/lib/server/revenue-source.js.map +1 -1
- package/src/lib/server/supplier-outbox.js +16 -1
- package/src/lib/server/supplier-outbox.js.map +1 -1
- package/src/lib/server/supplier-update.js +19 -9
- package/src/lib/server/supplier-update.js.map +1 -1
- package/src/lib/starter-templates.d.ts +19 -0
- package/src/lib/starter-templates.js +157 -0
- package/src/lib/starter-templates.js.map +1 -0
- package/src/lib/tenant-emails.js +54 -14
- package/src/lib/tenant-emails.js.map +1 -1
- package/src/lib/testing/stand-in-record-system.d.ts +11 -2
- package/src/lib/testing/stand-in-record-system.js +5 -3
- package/src/lib/testing/stand-in-record-system.js.map +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/reserve-stock.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 { notifyHostManagers } from '@aglyn/tenant-data-admin'\nimport * as CommerceModel from '../model'\n\n/**\n * THE ATOMIC STOCK DECREMENT (AGL-2320).\n *\n * Every stock write in this plugin was a read → compute → `set(…, {merge:true})`\n * of the whole `variants` array, with the read often far upstream of the write:\n * the cart branch of `billing-webhook.ts` reads its products, then runs a\n * transaction, a `cartRef.delete()`, a checkout-doc read, licence-key\n * assignment and an email send before it decrements. Two sales landing in that\n * window both computed from the same `variants` and both wrote; the later won\n * and one decrement was LOST. The ledger showed two `sale` rows while the count\n * moved once, so the shelf was short by one and the storefront went on offering\n * the unit it had already sold — the loss compounds, because phantom stock is\n * what admits the next oversell.\n *\n * `inventory` lives INSIDE the `variants` array, not at a document field path,\n * so `FieldValue.increment` is not expressible against it. That is why this was\n * filed as blocked on a data-model change. It is not the only way to make a\n * decrement safe: a transaction is. `Transaction.get()` takes a lock on the\n * product document, so two concurrent decrements SERIALIZE — the loser re-runs\n * its callback against the winner's committed state and subtracts from the real\n * remainder. The read-modify-write stays, the schema is untouched, and no\n * migration is needed. The cost is one contended retry, not one extra read.\n *\n * WHAT THIS DOES NOT DO. It does not refuse a sale. Every storefront path\n * gates on `canPurchase` when the Stripe Checkout session is created and\n * decrements minutes later in the webhook, by which time the money has moved —\n * refusing there would take payment and withhold goods. Closing the last-unit\n * oversell needs a HOLD taken at session creation and released when the session\n * expires, which is a feature with a new failure mode (an abandoned cart\n * sitting on the last unit for the session's lifetime) and is deliberately not\n * being introduced days before launch. So the oversell is made LOUD instead of\n * silent: when the fresh read proves the shelf could not cover the sale, the\n * managers are told, with the number that was actually short.\n *\n * The floor in `adjustVariantInventory` stays — a shelf count does not go\n * negative, and every reader assumes it cannot (AGL-2149). What changes is that\n * `applied` is now computed from the state the write actually lands on, so the\n * ledger's `appliedDelta` tells the truth under concurrency. Before this, two\n * concurrent sales of one unit each computed `appliedDelta: -1` from the same\n * stale `inventory: 1`, and the ledger claimed two units left a shelf that only\n * ever held one.\n */\nexport interface StockDecrementOutcome {\n /**\n * What the count ACTUALLY moved by — zero or negative, and never more than\n * the shelf held. The caller writes this as the ledger row's `appliedDelta`\n * (AGL-2149) when it differs from `requested`.\n */\n applied: number\n /** The units sold, negated. */\n requested: number\n /**\n * The product as read INSIDE the transaction, and the same product with the\n * committed variants. Both null when nothing was written — a missing product,\n * an untracked variant, or a failed commit. Handed to\n * `alertLowStockCrossing` as the pre/post pair, which is now the pair the\n * write actually crossed rather than one computed from an upstream read.\n */\n before: CommerceModel.HostProduct | null\n after: CommerceModel.HostProduct | null\n /**\n * The commit did not land. Distinct from `applied === 0`, which is a real\n * outcome (a deny-policy product already at zero). A caller must not write a\n * ledger row for a failed commit: a row whose count never moved is exactly\n * what makes a later cancellation hand back units nobody has.\n */\n failed: boolean\n}\n\n/**\n * What the ledger row joins this movement to (AGL-2161).\n *\n * Passing this is what makes the count and the row ONE write. Omitting it\n * decrements without a row, which only `reserve-stock`'s own specs do — every\n * production caller passes it, and `stock-decrement-race.spec.ts` fails the\n * build if one stops.\n */\nexport interface StockLedgerJoin {\n /** Why the count moved. Every sale path passes `sale`. */\n reason: CommerceModel.InventoryAdjustmentReason\n /**\n * The document this movement is answerable to. `cancel-order.ts` queries\n * `where('orderId','==',…)` to decide whether to restock at all, so a row\n * that names the wrong id is as bad as a missing one.\n */\n orderId: string\n}\n\n/**\n * Decrements one variant's stock inside a transaction and reports what the\n * shelf could actually give up. Never throws: a stock write must not fail a\n * webhook, because Stripe would redeliver and the order would be minted twice.\n *\n * ## THE LEDGER ROW RIDES INSIDE THE TRANSACTION (AGL-2161)\n *\n * The count and its `inventoryAdjustments` row used to be two separate writes\n * at six call sites, the second one a bare\n * `.add({…}).catch(() => undefined)`. Either could land without the other and\n * nothing anywhere said so:\n *\n * - **row lost, count moved** — `cancel-order.ts:206-224` reads the rows for\n * an order to decide whether a POS card sale was ever decremented, and reads\n * `appliedDelta ?? delta` to cap the restock. No row means it refuses to\n * release that stock at all, so the units are stranded permanently.\n * - **count lost, row written** — the reverse hands back units nobody has, the\n * failure `StockDecrementOutcome.failed` already exists to prevent.\n *\n * Both targets hang off the same `hostRef`, so nothing ever stopped them\n * sharing one transaction; they simply did not. They do now, and the divergence\n * is not made *loud*, it is made **impossible** — which is the stronger of the\n * two options, and available here only because the helper already owned the\n * transaction the count was written in.\n *\n * Consequences worth stating, because they change five call sites at once:\n *\n * - A ledger failure now ROLLS BACK the decrement instead of being swallowed.\n * That is the direction that keeps the two readings in agreement: the sale\n * is still paid, the shelf is untouched, and `reconcile-stock.ts` reports a\n * missing decrement — which is true, and was previously a false positive\n * whenever only the row was lost.\n * - The row is still auto-id'd. A deterministic id keyed on the order would\n * make redelivery idempotent, but two lines of one product in one order are\n * two legitimate rows (`billing-webhook-low-stock.spec.ts`, \"logs both sale\n * rows\"), and a deterministic key would silently fold them into one and\n * under-cap the restock. Redelivery stays bounded upstream, where it was.\n */\nexport async function decrementVariantStock(options: {\n firestore: any\n hostRef: any\n hostId: string\n productId: string\n variantId: string\n /** Units sold; sign is ignored. */\n quantity: number\n locationId?: string\n /**\n * The ledger row to write in the SAME transaction as the count (AGL-2161).\n * Omit only where no movement should be recorded.\n */\n ledger?: StockLedgerJoin\n}): Promise<StockDecrementOutcome> {\n const {\n firestore,\n hostRef,\n hostId,\n productId,\n variantId,\n quantity,\n locationId,\n ledger,\n } = options\n const requested = -Math.abs(Math.round(Number(quantity) || 0))\n const nothing: StockDecrementOutcome = {\n applied: 0,\n requested,\n before: null,\n after: null,\n failed: false,\n }\n if (requested === 0) return nothing\n let outcome: StockDecrementOutcome\n try {\n outcome = await firestore.runTransaction(async (transaction: any) => {\n const productRef = hostRef.collection('products').doc(productId)\n const snapshot = await transaction.get(productRef)\n if (!snapshot?.exists) return nothing\n const before = CommerceModel.liftLegacyProduct(snapshot.data() as any)\n const tracked = before.variants.some(\n (variant) => variant.id === variantId && variant.inventory != null,\n )\n if (!tracked) return nothing\n const variants = CommerceModel.adjustVariantInventory(\n before,\n variantId,\n requested,\n locationId,\n )\n // Computed from the SAME read the write is derived from, inside the same\n // transaction — which is the whole point (AGL-2320).\n const applied = CommerceModel.appliedVariantInventoryDelta(\n before,\n variantId,\n requested,\n locationId,\n )\n const after = { ...before, variants }\n transaction.set(\n productRef,\n {\n variants,\n // The total and the storefront's In stock verdict (AGL-3321).\n ...CommerceModel.productStockFields(after),\n },\n { merge: true },\n )\n // Same transaction, so the count and the row commit together or neither\n // does (AGL-2161). `applied` is the figure computed above from the read\n // this write is derived from, so `appliedDelta` tells the truth under\n // contention exactly as it does for the count.\n if (ledger) {\n transaction.set(\n hostRef.collection('inventoryAdjustments').doc(),\n {\n productId,\n variantId,\n delta: requested,\n ...(applied !== requested ? { appliedDelta: applied } : {}),\n reason: ledger.reason,\n orderId: ledger.orderId,\n ...(locationId ? { locationId } : {}),\n atMs: Date.now(),\n } satisfies CommerceModel.InventoryAdjustment,\n )\n }\n return { applied, requested, before, after, failed: false }\n })\n } catch (error) {\n // Swallowed like the `.catch(() => undefined)` this replaces, but no longer\n // silently: the caller skips its ledger row, and the count and the ledger\n // stay in agreement about a decrement that did not happen.\n console.error('Stock decrement failed', hostId, productId, variantId, error)\n return { ...nothing, failed: true }\n }\n // The oversell is now LOUD (AGL-2320). `content.lowStock` is deliberately\n // reused rather than a new notification type: the type union carries a label\n // map, a category and a per-user mute, and \"you are out of this\" is the\n // category a merchant who cares about stock is already subscribed to. A\n // backorder product selling past zero\n // is the merchant's own choice and already understood (AGL-2149), so only a\n // deny-policy shortfall is reported — that is the one the merchant did not\n // ask for and previously got no signal about at all.\n const short = outcome.applied - outcome.requested\n if (\n short > 0 &&\n outcome.before &&\n outcome.before.oversellPolicy !== 'backorder'\n ) {\n void notifyHostManagers(hostId, {\n type: 'content.lowStock',\n title: `Oversold — ${outcome.before.name}`,\n body:\n `${Math.abs(outcome.requested)} sold, ${Math.abs(outcome.applied)} ` +\n `came off the shelf. ${short} unit${short === 1 ? '' : 's'} were not ` +\n 'in stock — the sale is paid and needs restocking or refunding.',\n link: `/${hostId}/products`,\n })\n }\n return outcome\n}\n"],"names":["notifyHostManagers","CommerceModel","decrementVariantStock","options","firestore","hostRef","hostId","productId","variantId","quantity","locationId","ledger","requested","Math","abs","round","Number","nothing","applied","before","after","failed","outcome","runTransaction","transaction","productRef","collection","doc","snapshot","get","exists","liftLegacyProduct","data","tracked","variants","some","variant","id","inventory","adjustVariantInventory","appliedVariantInventoryDelta","set","productStockFields","merge","delta","appliedDelta","reason","orderId","atMs","Date","now","error","console","short","oversellPolicy","type","title","name","body","link"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/reserve-stock.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 { notifyHostManagers } from '@aglyn/tenant-data-admin'\nimport * as CommerceModel from '../model'\n\n/**\n * THE ATOMIC STOCK DECREMENT (AGL-2320).\n *\n * Every stock write in this plugin was a read → compute → `set(…, {merge:true})`\n * of the whole `variants` array, with the read often far upstream of the write:\n * the cart branch of `billing-webhook.ts` reads its products, then runs a\n * transaction, a `cartRef.delete()`, a checkout-doc read, licence-key\n * assignment and an email send before it decrements. Two sales landing in that\n * window both computed from the same `variants` and both wrote; the later won\n * and one decrement was LOST. The ledger showed two `sale` rows while the count\n * moved once, so the shelf was short by one and the storefront went on offering\n * the unit it had already sold — the loss compounds, because phantom stock is\n * what admits the next oversell.\n *\n * `inventory` lives INSIDE the `variants` array, not at a document field path,\n * so `FieldValue.increment` is not expressible against it. That is why this was\n * filed as blocked on a data-model change. It is not the only way to make a\n * decrement safe: a transaction is. `Transaction.get()` takes a lock on the\n * product document, so two concurrent decrements SERIALIZE — the loser re-runs\n * its callback against the winner's committed state and subtracts from the real\n * remainder. The read-modify-write stays, the schema is untouched, and no\n * migration is needed. The cost is one contended retry, not one extra read.\n *\n * WHAT THIS DOES NOT DO. It does not refuse a sale. Every storefront path\n * gates on `canPurchase` when the Stripe Checkout session is created and\n * decrements minutes later in the webhook, by which time the money has moved —\n * refusing there would take payment and withhold goods. Closing the last-unit\n * oversell needs a HOLD taken at session creation and released when the session\n * expires, which is a feature with a new failure mode (an abandoned cart\n * sitting on the last unit for the session's lifetime) and is deliberately not\n * being introduced days before launch. So the oversell is made LOUD instead of\n * silent: when the fresh read proves the shelf could not cover the sale, the\n * managers are told, with the number that was actually short.\n *\n * The floor in `adjustVariantInventory` stays — a shelf count does not go\n * negative, and every reader assumes it cannot (AGL-2149). What changes is that\n * `applied` is now computed from the state the write actually lands on, so the\n * ledger's `appliedDelta` tells the truth under concurrency. Before this, two\n * concurrent sales of one unit each computed `appliedDelta: -1` from the same\n * stale `inventory: 1`, and the ledger claimed two units left a shelf that only\n * ever held one.\n */\nexport interface StockDecrementOutcome {\n /**\n * What the count ACTUALLY moved by — zero or negative, and never more than\n * the shelf held. The caller writes this as the ledger row's `appliedDelta`\n * (AGL-2149) when it differs from `requested`.\n */\n applied: number\n /** The units sold, negated. */\n requested: number\n /**\n * The product as read INSIDE the transaction, and the same product with the\n * committed variants. Both null when nothing was written — a missing product,\n * an untracked variant, or a failed commit. Handed to\n * `alertLowStockCrossing` as the pre/post pair, which is now the pair the\n * write actually crossed rather than one computed from an upstream read.\n */\n before: CommerceModel.HostProduct | null\n after: CommerceModel.HostProduct | null\n /**\n * The commit did not land. Distinct from `applied === 0`, which is a real\n * outcome (a deny-policy product already at zero). A caller must not write a\n * ledger row for a failed commit: a row whose count never moved is exactly\n * what makes a later cancellation hand back units nobody has.\n */\n failed: boolean\n}\n\n/**\n * What the ledger row joins this movement to (AGL-2161).\n *\n * Passing this is what makes the count and the row ONE write. Omitting it\n * decrements without a row, which only `reserve-stock`'s own specs do — every\n * production caller passes it, and `stock-decrement-race.spec.ts` fails the\n * build if one stops.\n */\nexport interface StockLedgerJoin {\n /** Why the count moved. Every sale path passes `sale`. */\n reason: CommerceModel.InventoryAdjustmentReason\n /**\n * The document this movement is answerable to. `cancel-order.ts` queries\n * `where('orderId','==',…)` to decide whether to restock at all, so a row\n * that names the wrong id is as bad as a missing one.\n */\n orderId: string\n}\n\n/**\n * Decrements one variant's stock inside a transaction and reports what the\n * shelf could actually give up. Never throws: a stock write must not fail a\n * webhook, because Stripe would redeliver and the order would be minted twice.\n *\n * ## THE LEDGER ROW RIDES INSIDE THE TRANSACTION (AGL-2161)\n *\n * The count and its `inventoryAdjustments` row used to be two separate writes\n * at six call sites, the second one a bare\n * `.add({…}).catch(() => undefined)`. Either could land without the other and\n * nothing anywhere said so:\n *\n * - **row lost, count moved** — `cancel-order.ts:206-224` reads the rows for\n * an order to decide whether a POS card sale was ever decremented, and reads\n * `appliedDelta ?? delta` to cap the restock. No row means it refuses to\n * release that stock at all, so the units are stranded permanently.\n * - **count lost, row written** — the reverse hands back units nobody has, the\n * failure `StockDecrementOutcome.failed` already exists to prevent.\n *\n * Both targets hang off the same `hostRef`, so nothing ever stopped them\n * sharing one transaction; they simply did not. They do now, and the divergence\n * is not made *loud*, it is made **impossible** — which is the stronger of the\n * two options, and available here only because the helper already owned the\n * transaction the count was written in.\n *\n * Consequences worth stating, because they change five call sites at once:\n *\n * - A ledger failure now ROLLS BACK the decrement instead of being swallowed.\n * That is the direction that keeps the two readings in agreement: the sale\n * is still paid, the shelf is untouched, and `reconcile-stock.ts` reports a\n * missing decrement — which is true, and was previously a false positive\n * whenever only the row was lost.\n * - The row is still auto-id'd. A deterministic id keyed on the order would\n * make redelivery idempotent, but two lines of one product in one order are\n * two legitimate rows (`billing-webhook-low-stock.spec.ts`, \"logs both sale\n * rows\"), and a deterministic key would silently fold them into one and\n * under-cap the restock. Redelivery stays bounded upstream, where it was.\n */\nexport async function decrementVariantStock(options: {\n firestore: any\n hostRef: any\n hostId: string\n productId: string\n variantId: string\n /** Units sold; sign is ignored. */\n quantity: number\n locationId?: string\n /**\n * The ledger row to write in the SAME transaction as the count (AGL-2161).\n * Omit only where no movement should be recorded.\n */\n ledger?: StockLedgerJoin\n}): Promise<StockDecrementOutcome> {\n const {\n firestore,\n hostRef,\n hostId,\n productId,\n variantId,\n quantity,\n locationId,\n ledger,\n } = options\n const requested = -Math.abs(Math.round(Number(quantity) || 0))\n const nothing: StockDecrementOutcome = {\n applied: 0,\n requested,\n before: null,\n after: null,\n failed: false,\n }\n if (requested === 0) return nothing\n let outcome: StockDecrementOutcome\n try {\n outcome = await firestore.runTransaction(async (transaction: any) => {\n const productRef = hostRef.collection('products').doc(productId)\n const snapshot = await transaction.get(productRef)\n if (!snapshot?.exists) return nothing\n const before = CommerceModel.liftLegacyProduct(snapshot.data() as any)\n const tracked = before.variants.some(\n (variant) => variant.id === variantId && variant.inventory != null,\n )\n if (!tracked) return nothing\n const variants = CommerceModel.adjustVariantInventory(\n before,\n variantId,\n requested,\n locationId,\n )\n // Computed from the SAME read the write is derived from, inside the same\n // transaction — which is the whole point (AGL-2320).\n const applied = CommerceModel.appliedVariantInventoryDelta(\n before,\n variantId,\n requested,\n locationId,\n )\n const after = { ...before, variants }\n transaction.set(\n productRef,\n {\n variants,\n // The total and the storefront's In stock verdict (AGL-3321).\n ...CommerceModel.productStockFields(after),\n },\n { merge: true },\n )\n // Same transaction, so the count and the row commit together or neither\n // does (AGL-2161). `applied` is the figure computed above from the read\n // this write is derived from, so `appliedDelta` tells the truth under\n // contention exactly as it does for the count.\n if (ledger) {\n transaction.set(\n hostRef.collection('inventoryAdjustments').doc(),\n {\n productId,\n variantId,\n delta: requested,\n ...(applied !== requested ? { appliedDelta: applied } : {}),\n reason: ledger.reason,\n orderId: ledger.orderId,\n ...(locationId ? { locationId } : {}),\n atMs: Date.now(),\n } satisfies CommerceModel.InventoryAdjustment,\n )\n }\n return { applied, requested, before, after, failed: false }\n })\n } catch (error) {\n // Swallowed like the `.catch(() => undefined)` this replaces, but no longer\n // silently: the caller skips its ledger row, and the count and the ledger\n // stay in agreement about a decrement that did not happen.\n console.error('Stock decrement failed', hostId, productId, variantId, error)\n return { ...nothing, failed: true }\n }\n // The oversell is now LOUD (AGL-2320). `content.lowStock` is deliberately\n // reused rather than a new notification type: the type union carries a label\n // map, a category and a per-user mute, and \"you are out of this\" is the\n // category a merchant who cares about stock is already subscribed to. A\n // backorder product selling past zero\n // is the merchant's own choice and already understood (AGL-2149), so only a\n // deny-policy shortfall is reported — that is the one the merchant did not\n // ask for and previously got no signal about at all.\n const short = outcome.applied - outcome.requested\n if (\n short > 0 &&\n outcome.before &&\n outcome.before.oversellPolicy !== 'backorder'\n ) {\n // What was sold, where, and what is short, in the body (AGL-3432),\n // which an email reader sees without the title.\n const sold = Math.abs(outcome.requested)\n const inStock = Math.abs(outcome.applied)\n const onShelf =\n inStock === 0\n ? 'none was'\n : `only ${inStock} ${inStock === 1 ? 'was' : 'were'}`\n void notifyHostManagers(hostId, {\n type: 'content.lowStock',\n title: `Oversold — ${outcome.before.name}`,\n body:\n `An order on {site} was paid for ${sold}× ${outcome.before.name}, ` +\n `but ${onShelf} in stock. ` +\n `${short} unit${short === 1 ? ' is' : 's are'} short: restock ` +\n `${short === 1 ? 'it' : 'them'} or refund the difference.`,\n link: `/${hostId}/products`,\n })\n }\n return outcome\n}\n"],"names":["notifyHostManagers","CommerceModel","decrementVariantStock","options","firestore","hostRef","hostId","productId","variantId","quantity","locationId","ledger","requested","Math","abs","round","Number","nothing","applied","before","after","failed","outcome","runTransaction","transaction","productRef","collection","doc","snapshot","get","exists","liftLegacyProduct","data","tracked","variants","some","variant","id","inventory","adjustVariantInventory","appliedVariantInventoryDelta","set","productStockFields","merge","delta","appliedDelta","reason","orderId","atMs","Date","now","error","console","short","oversellPolicy","sold","inStock","onShelf","type","title","name","body","link"],"mappings":"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"}
|
|
@@ -35,7 +35,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
35
35
|
* it would report the 30¢ Stripe just took as money the platform made.
|
|
36
36
|
*
|
|
37
37
|
* So the pass-through is recomputed with the SAME helper the fee was charged
|
|
38
|
-
* from (`
|
|
38
|
+
* from (`saleProcessingCostCents`) and subtracted. Both halves are
|
|
39
39
|
* reported, because "we collected X of which Y was Stripe's" is the sentence a
|
|
40
40
|
* margin figure needs.
|
|
41
41
|
*
|
|
@@ -64,7 +64,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
64
64
|
* and the count beside the figure is how a reader sees how much of the book
|
|
65
65
|
* that is.
|
|
66
66
|
*/ import { firebaseAdmin } from "@aglyn/tenant-data-admin";
|
|
67
|
-
import {
|
|
67
|
+
import { saleProcessingCostCents } from "@aglyn/aglyn/server";
|
|
68
68
|
import { PLATFORM_BRAND_NAME } from "@aglyn/aglyn/app-utils/platform-brand";
|
|
69
69
|
import { stripeIdIsTestMode } from "@aglyn/aglyn/app-utils/stripe-deployment-mode";
|
|
70
70
|
import { groupRevenueAttribution } from "@aglyn/aglyn/plugin-manager/plugin-revenue-sources";
|
|
@@ -142,7 +142,7 @@ export function commerceSettledSummary(rows, truncated = false) {
|
|
|
142
142
|
// subscription cycle's as a rate (AGL-2655). Clamped to the fee itself: the
|
|
143
143
|
// recomputed cost can exceed a fee charged under an older rate, and a
|
|
144
144
|
// negative take would subtract from another order's real margin.
|
|
145
|
-
const passThrough = Math.min(fee,
|
|
145
|
+
const passThrough = Math.min(fee, saleProcessingCostCents(gross));
|
|
146
146
|
const take = fee - passThrough;
|
|
147
147
|
const refunded = Math.min(gross, Math.max(0, cents(row == null ? void 0 : row.refundedCents)));
|
|
148
148
|
const takeRefunded = gross > 0 ? Math.round(refunded * take / gross) : 0;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/revenue-source.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\n/**\n * The storefront's take on the operator's revenue report (AGL-2486, AGL-3080).\n *\n * Answered through the platform's revenue source contract\n * (`@aglyn/aglyn/plugin-manager/plugin-revenue-sources`). The report folds the\n * operator's own invoices; this is what storefront orders earned it — and the\n * one figure on that page most likely to be reported wrongly.\n *\n * An order's `feeCents` is the Connect `application_fee_amount`, which since\n * AGL-2152 is the advertised take PLUS Stripe's processing cost:\n *\n * fee = take%(goods) + processing%(charge) + 30¢\n *\n * Every storefront charge is a DESTINATION charge, so Stripe moves the whole\n * amount to the merchant and debits its processing fee from the PLATFORM's\n * balance; the pass-through half of that fee exists purely to recover a cost\n * the platform has already paid. Reporting `feeCents` as earnings would\n * overstate the margin on every single storefront sale — and on a small order\n * it would report the 30¢ Stripe just took as money the platform made.\n *\n * So the pass-through is recomputed with the SAME helper the fee was charged\n * from (`storefrontProcessingCostCents`) and subtracted. Both halves are\n * reported, because \"we collected X of which Y was Stripe's\" is the sentence a\n * margin figure needs.\n *\n * The recomputation is an estimate in one direction only: it re-derives the\n * cost from the stored charge amount rather than reading Stripe's actual\n * balance-transaction fee, and it prices it at the DEAREST enabled payment\n * method's rate (BNPL, 6%) because that is the rate the fee was charged at.\n * A card-family order really costs 2.9%, so on those orders this subtracts\n * more than Stripe took and the take is UNDERSTATED — the safe direction, and\n * stated on the page rather than left to be discovered.\n *\n * ## Subscription cycles, and the one cycle this understates\n *\n * A storefront SUBSCRIPTION cycle carries a `subscriptionId`. Since AGL-2655\n * its fee carries the same pass-through, folded into\n * `application_fee_percent` as a rate because a Stripe subscription cannot\n * express a fixed 30¢ — so it is netted out here exactly as a one-time sale's\n * is. The percent is sized on the recurring goods and the recomputed cost here\n * is sized on the whole amount paid, tax included, so a taxed cycle subtracts\n * slightly more than was recovered: understated, the safe direction.\n *\n * A subscription sold before the pass-through existed is carried onto it by\n * the renewal re-price (AGL-2289) at its next paid invoice — but THAT invoice\n * was billed at the bare take, so its row's `feeCents` holds no pass-through\n * and the clamp reports its take as zero. One cycle per legacy subscription,\n * and the count beside the figure is how a reader sees how much of the book\n * that is.\n */\n\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin'\nimport { storefrontProcessingCostCents } from '@aglyn/aglyn/server'\nimport { PLATFORM_BRAND_NAME } from '@aglyn/aglyn/app-utils/platform-brand'\nimport { stripeIdIsTestMode } from '@aglyn/aglyn/app-utils/stripe-deployment-mode'\nimport {\n groupRevenueAttribution,\n type RevenueAttribution,\n type RevenueSource,\n type RevenueSourceAnswer,\n} from '@aglyn/aglyn/plugin-manager/plugin-revenue-sources'\n\n/** One `hosts/{hostId}/orders/{orderId}` row — a storefront sale. */\nexport interface CommerceOrderRowInput {\n id: string\n /** What the shopper paid, tax and shipping included. */\n amountCents?: unknown\n /**\n * The Connect `application_fee_amount`: the platform's advertised take PLUS\n * Stripe's processing cost passed through at cost (AGL-2152). NOT margin —\n * see the module note.\n */\n feeCents?: unknown\n /**\n * Present ONLY on a storefront SUBSCRIPTION renewal, and it changes how\n * `feeCents` must be read — see the module note.\n */\n subscriptionId?: unknown\n /** Merchant refunds AND chargebacks both land here (`refund.ts`). */\n refundedCents?: unknown\n /**\n * WHICH storefront the order belongs to — the attribution key.\n *\n * Orders live at `hosts/{hostId}/orders/{orderId}`, so this is lifted from\n * the document PATH by the reader rather than read from a field. It costs no\n * extra read and cannot disagree with where the document actually is.\n */\n hostId?: unknown\n createdAt?: unknown\n /**\n * Whether Stripe moved real money, stamped from `event.livemode` by the\n * webhook. Authoritative when present, and absent on every order written\n * before that stamp shipped — which is why {@link isTestModeOrderRow} falls\n * back to the id below rather than treating absence as an answer.\n */\n livemode?: unknown\n /**\n * The Checkout Session that produced the order. It carries its own mode\n * (`cs_test_…`), which a subscription renewal's invoice id does not — so\n * this is the fallback discriminator for rows predating `livemode`.\n */\n checkoutSessionId?: unknown\n}\n\nexport interface CommerceSettled {\n transactionCount: number\n /** Shopper spend across storefronts. Overwhelmingly the merchant's. */\n grossCents: number\n /** The whole `application_fee_amount` the platform collected. NOT margin. */\n applicationFeeCents: number\n /** Stripe's processing cost this recovered, at cost. Subtracted, never earnings. */\n processingPassThroughCents: number\n /** Application fee minus the pass-through: the advertised take. */\n commissionCents: number\n /** Take handed back with refunds and chargebacks, pro-rata. */\n commissionRefundedCents: number\n /** Take net of reversals. */\n commissionNetCents: number\n /**\n * Storefront SUBSCRIPTION cycles in this period. Counted because a cycle\n * billed before its subscription was re-priced onto the pass-through\n * reports no take at all — see the module note.\n */\n subscriptionOrders: number\n /**\n * The sweep hit its ceiling, so every figure above is a LOWER BOUND and must\n * not be quoted as a total. Narrow the period instead.\n */\n truncated: boolean\n}\n\n/** Cents from anything, defaulting to 0 rather than NaN. */\nfunction cents(value: unknown): number {\n const parsed = Number(value ?? 0)\n return Number.isFinite(parsed) ? Math.round(parsed) : 0\n}\n\n/**\n * Whether this order row was a test-mode rehearsal.\n *\n * A recorded `livemode` wins, and the session id's prefix is the fallback for\n * rows written before anything recorded it. An order with neither signal is\n * LIVE: a POS cash sale carries no Stripe session at all, and answering \"test\"\n * for anything unidentifiable would erase genuine revenue from the report.\n */\nfunction isTestModeOrderRow(row: CommerceOrderRowInput): boolean {\n if (typeof row.livemode === 'boolean') return !row.livemode\n return stripeIdIsTestMode(row.checkoutSessionId ?? row.id)\n}\n\nexport function commerceSettledSummary(\n rows: readonly CommerceOrderRowInput[],\n truncated = false,\n): CommerceSettled {\n const out: CommerceSettled = {\n transactionCount: 0,\n grossCents: 0,\n applicationFeeCents: 0,\n processingPassThroughCents: 0,\n commissionCents: 0,\n commissionRefundedCents: 0,\n commissionNetCents: 0,\n subscriptionOrders: 0,\n truncated: truncated === true,\n }\n for (const row of rows ?? []) {\n // A REHEARSAL IS NOT REVENUE. A smoke-test checkout writes a real order\n // document — Stripe never moved money for it, and its session id says so.\n // Skipped ENTIRELY rather than counted at zero, because `transactionCount`\n // is read as \"how many sales\", and a rehearsal is not one.\n if (isTestModeOrderRow(row)) continue\n const split = commerceOrderTake(row)\n out.transactionCount += 1\n out.grossCents += split.gross\n out.applicationFeeCents += split.fee\n if (split.isSubscriptionOrder) out.subscriptionOrders += 1\n out.processingPassThroughCents += split.passThrough\n out.commissionCents += split.take\n out.commissionRefundedCents += split.takeRefunded\n out.commissionNetCents += split.takeNet\n }\n return out\n}\n\n/**\n * One storefront order's take, in ONE place.\n *\n * Shared with the per-host attribution so a host table cannot sum to\n * something other than the storefront line above it.\n *\n * A zero-fee order contributes its gross and its count and nothing else —\n * there is no take to attribute, and inventing one from the gross would\n * report a merchant's money as the platform's.\n */\nexport function commerceOrderTake(row: CommerceOrderRowInput | null): {\n gross: number\n fee: number\n isSubscriptionOrder: boolean\n passThrough: number\n take: number\n takeRefunded: number\n takeNet: number\n} {\n const gross = cents(row?.amountCents)\n const fee = Math.max(0, cents(row?.feeCents))\n const isSubscriptionOrder =\n typeof row?.subscriptionId === 'string' && row.subscriptionId.length > 0\n if (fee <= 0) {\n return {\n gross,\n fee,\n isSubscriptionOrder,\n passThrough: 0,\n take: 0,\n takeRefunded: 0,\n takeNet: 0,\n }\n }\n // Every fee bundles the recovery — a one-time sale's in cents, a\n // subscription cycle's as a rate (AGL-2655). Clamped to the fee itself: the\n // recomputed cost can exceed a fee charged under an older rate, and a\n // negative take would subtract from another order's real margin.\n const passThrough = Math.min(fee, storefrontProcessingCostCents(gross))\n const take = fee - passThrough\n const refunded = Math.min(gross, Math.max(0, cents(row?.refundedCents)))\n const takeRefunded = gross > 0 ? Math.round((refunded * take) / gross) : 0\n return {\n gross,\n fee,\n isSubscriptionOrder,\n passThrough,\n take,\n takeRefunded,\n takeNet: take - takeRefunded,\n }\n}\n\n/**\n * Storefront take by HOST.\n *\n * By host and not by org on purpose: one org can run several storefronts, and\n * \"which store earns\" is the question a per-org roll-up destroys.\n */\nexport function commerceHostAttribution(\n rows: readonly CommerceOrderRowInput[],\n limit = 100,\n): RevenueAttribution {\n return groupRevenueAttribution(\n (rows ?? []).map((row) => {\n const split = commerceOrderTake(row)\n return {\n key: String(row?.hostId ?? ''),\n detail: '',\n gain: split.takeNet,\n loss: split.takeRefunded,\n }\n }),\n limit,\n 'Host not recorded',\n )\n}\n\n/**\n * The storefront's answer for one period, from figures already folded. Pure,\n * so what the report prints can be proved from fixtures.\n *\n * `failed` is a sweep that could not run — a collection-group index this\n * deployment has not built answers FAILED_PRECONDITION — and it is reported as\n * a failure with figures of zero, never as a cap: \"we read part of it\" and \"we\n * read none of it\" have different remedies.\n */\nexport function commerceRevenueAnswer(read: {\n settled: CommerceSettled\n byHost: RevenueAttribution\n failed: boolean\n}): RevenueSourceAnswer {\n const brand = PLATFORM_BRAND_NAME\n const { settled } = read\n return {\n id: 'commerce',\n name: 'storefront orders',\n earned: {\n label: 'Storefront commission',\n cents: Number(settled.commissionNetCents ?? 0),\n note:\n 'The advertised take on merchant storefront sales, net of refunds — ' +\n 'with Stripe’s card processing removed. The platform fee charged on a ' +\n 'storefront sale bundles that processing cost and passes it through at ' +\n 'cost; it is a recovery, not earnings.',\n },\n grossToNet: [\n {\n label: 'Storefront sales (shopper gross)',\n cents: settled.grossCents,\n deduction: false,\n note:\n 'The merchant’s. It transfers straight to their connected account ' +\n 'and is shown only for scale.',\n },\n {\n label: 'Storefront platform fee collected',\n cents: settled.applicationFeeCents,\n deduction: false,\n note: `Not all ${brand}’s — see the next line.`,\n },\n {\n label: '— less card processing passed through at cost',\n cents: settled.processingPassThroughCents,\n deduction: true,\n note: `Stripe’s. On a destination charge Stripe debits ${brand}’s balance for processing, and this half of the fee recovers exactly that. It is a recovery, not earnings, and reporting it as revenue would overstate every storefront sale.`,\n },\n ],\n notes:\n Number(settled.subscriptionOrders ?? 0) > 0\n ? [\n {\n label: `${settled.subscriptionOrders} storefront subscription cycles — one billed before its re-price reports no take`,\n tone: 'warning',\n },\n ]\n : [],\n attribution: [\n {\n id: 'byHost',\n heading: 'Storefront take by host',\n unit: 'Storefront',\n countLabel: 'Orders',\n empty: `No storefront order settled in this period. Note this is ${brand}'s take only — the shopper's spend is the merchant's money and is never counted here.`,\n chartEmpty: 'No storefront earned a take in this period — nothing to plot yet.',\n ...read.byHost,\n },\n ],\n attributionNote:\n \"Storefront figures above are the advertised take with Stripe's \" +\n 'processing cost already subtracted, on the basis stated in “Gross ' +\n 'versus net” below. Attribution is by host rather than by org because ' +\n 'one org can run several storefronts, and rolling them up destroys the ' +\n 'question.',\n truncated: settled.truncated,\n failure: read.failed\n ? {\n title: 'Storefront orders could not be read',\n detail:\n 'The storefront commission below reads $0 because the query ' +\n 'failed, not because there were no sales. The sweep needs the ' +\n 'COLLECTION_GROUP index on orders.createdAtMs — check it is still ' +\n 'declared in the Firestore index config and actually deployed, ' +\n 'since indexes ship separately from the app.',\n }\n : null,\n summary: settled,\n }\n}\n\n/**\n * The storefront's source, registered on the console's API surface: the\n * staff revenue report awaits that surface before it asks.\n */\nexport const commerceRevenueSource: RevenueSource = {\n async read(request) {\n const firestore = firebaseAdmin.app().firestore()\n // Storefront orders live per host (`hosts/{hostId}/orders`), so this is a\n // collection-GROUP range — ranged on `createdAtMs`, the NUMBER, and not on\n // the `createdAt` Timestamp beside it. A collection-group range needs a\n // COLLECTION_GROUP-scoped single-field index — Firestore's automatic ones\n // are COLLECTION scope only — and the index that is declared and deployed\n // is on `createdAtMs` (AGL-1793). The `createdAt` form answers\n // FAILED_PRECONDITION on every request, which is how the report once read\n // $0 for every storefront.\n let swept: {\n docs: FirebaseFirestore.QueryDocumentSnapshot[]\n truncated: boolean\n } | null\n try {\n swept = await request.sweep<FirebaseFirestore.QueryDocumentSnapshot>(\n firestore\n .collectionGroup('orders')\n .where('createdAtMs', '>=', request.start.getTime())\n .where('createdAtMs', '<', request.end.getTime()),\n 'createdAtMs',\n )\n } catch {\n swept = null\n }\n const rows: CommerceOrderRowInput[] = (swept?.docs ?? []).map((doc) => ({\n id: doc.id,\n // The storefront that earned the take is in the PATH.\n hostId: doc.ref.parent.parent?.id ?? '',\n ...(doc.data() as object),\n }))\n const settled = commerceSettledSummary(rows, swept?.truncated === true)\n const byHost = commerceHostAttribution(rows, request.attributionLimit)\n await request.nameRows(byHost.rows, {\n collection: 'hosts',\n nameField: 'displayName',\n detailField: 'subdomain',\n })\n return commerceRevenueAnswer({ settled, byHost, failed: swept === null })\n },\n}\n"],"names":["firebaseAdmin","storefrontProcessingCostCents","PLATFORM_BRAND_NAME","stripeIdIsTestMode","groupRevenueAttribution","cents","value","parsed","Number","isFinite","Math","round","isTestModeOrderRow","row","livemode","checkoutSessionId","id","commerceSettledSummary","rows","truncated","out","transactionCount","grossCents","applicationFeeCents","processingPassThroughCents","commissionCents","commissionRefundedCents","commissionNetCents","subscriptionOrders","split","commerceOrderTake","gross","fee","isSubscriptionOrder","passThrough","take","takeRefunded","takeNet","amountCents","max","feeCents","subscriptionId","length","min","refunded","refundedCents","commerceHostAttribution","limit","map","key","String","hostId","detail","gain","loss","commerceRevenueAnswer","read","settled","brand","name","earned","label","note","grossToNet","deduction","notes","tone","attribution","heading","unit","countLabel","empty","chartEmpty","byHost","attributionNote","failure","failed","title","summary","commerceRevenueSource","request","firestore","app","swept","sweep","collectionGroup","where","start","getTime","end","docs","doc","ref","parent","data","attributionLimit","nameRows","collection","nameField","detailField"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/revenue-source.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\n/**\n * The storefront's take on the operator's revenue report (AGL-2486, AGL-3080).\n *\n * Answered through the platform's revenue source contract\n * (`@aglyn/aglyn/plugin-manager/plugin-revenue-sources`). The report folds the\n * operator's own invoices; this is what storefront orders earned it — and the\n * one figure on that page most likely to be reported wrongly.\n *\n * An order's `feeCents` is the Connect `application_fee_amount`, which since\n * AGL-2152 is the advertised take PLUS Stripe's processing cost:\n *\n * fee = take%(goods) + processing%(charge) + 30¢\n *\n * Every storefront charge is a DESTINATION charge, so Stripe moves the whole\n * amount to the merchant and debits its processing fee from the PLATFORM's\n * balance; the pass-through half of that fee exists purely to recover a cost\n * the platform has already paid. Reporting `feeCents` as earnings would\n * overstate the margin on every single storefront sale — and on a small order\n * it would report the 30¢ Stripe just took as money the platform made.\n *\n * So the pass-through is recomputed with the SAME helper the fee was charged\n * from (`saleProcessingCostCents`) and subtracted. Both halves are\n * reported, because \"we collected X of which Y was Stripe's\" is the sentence a\n * margin figure needs.\n *\n * The recomputation is an estimate in one direction only: it re-derives the\n * cost from the stored charge amount rather than reading Stripe's actual\n * balance-transaction fee, and it prices it at the DEAREST enabled payment\n * method's rate (BNPL, 6%) because that is the rate the fee was charged at.\n * A card-family order really costs 2.9%, so on those orders this subtracts\n * more than Stripe took and the take is UNDERSTATED — the safe direction, and\n * stated on the page rather than left to be discovered.\n *\n * ## Subscription cycles, and the one cycle this understates\n *\n * A storefront SUBSCRIPTION cycle carries a `subscriptionId`. Since AGL-2655\n * its fee carries the same pass-through, folded into\n * `application_fee_percent` as a rate because a Stripe subscription cannot\n * express a fixed 30¢ — so it is netted out here exactly as a one-time sale's\n * is. The percent is sized on the recurring goods and the recomputed cost here\n * is sized on the whole amount paid, tax included, so a taxed cycle subtracts\n * slightly more than was recovered: understated, the safe direction.\n *\n * A subscription sold before the pass-through existed is carried onto it by\n * the renewal re-price (AGL-2289) at its next paid invoice — but THAT invoice\n * was billed at the bare take, so its row's `feeCents` holds no pass-through\n * and the clamp reports its take as zero. One cycle per legacy subscription,\n * and the count beside the figure is how a reader sees how much of the book\n * that is.\n */\n\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin'\nimport { saleProcessingCostCents } from '@aglyn/aglyn/server'\nimport { PLATFORM_BRAND_NAME } from '@aglyn/aglyn/app-utils/platform-brand'\nimport { stripeIdIsTestMode } from '@aglyn/aglyn/app-utils/stripe-deployment-mode'\nimport {\n groupRevenueAttribution,\n type RevenueAttribution,\n type RevenueSource,\n type RevenueSourceAnswer,\n} from '@aglyn/aglyn/plugin-manager/plugin-revenue-sources'\n\n/** One `hosts/{hostId}/orders/{orderId}` row — a storefront sale. */\nexport interface CommerceOrderRowInput {\n id: string\n /** What the shopper paid, tax and shipping included. */\n amountCents?: unknown\n /**\n * The Connect `application_fee_amount`: the platform's advertised take PLUS\n * Stripe's processing cost passed through at cost (AGL-2152). NOT margin —\n * see the module note.\n */\n feeCents?: unknown\n /**\n * Present ONLY on a storefront SUBSCRIPTION renewal, and it changes how\n * `feeCents` must be read — see the module note.\n */\n subscriptionId?: unknown\n /** Merchant refunds AND chargebacks both land here (`refund.ts`). */\n refundedCents?: unknown\n /**\n * WHICH storefront the order belongs to — the attribution key.\n *\n * Orders live at `hosts/{hostId}/orders/{orderId}`, so this is lifted from\n * the document PATH by the reader rather than read from a field. It costs no\n * extra read and cannot disagree with where the document actually is.\n */\n hostId?: unknown\n createdAt?: unknown\n /**\n * Whether Stripe moved real money, stamped from `event.livemode` by the\n * webhook. Authoritative when present, and absent on every order written\n * before that stamp shipped — which is why {@link isTestModeOrderRow} falls\n * back to the id below rather than treating absence as an answer.\n */\n livemode?: unknown\n /**\n * The Checkout Session that produced the order. It carries its own mode\n * (`cs_test_…`), which a subscription renewal's invoice id does not — so\n * this is the fallback discriminator for rows predating `livemode`.\n */\n checkoutSessionId?: unknown\n}\n\nexport interface CommerceSettled {\n transactionCount: number\n /** Shopper spend across storefronts. Overwhelmingly the merchant's. */\n grossCents: number\n /** The whole `application_fee_amount` the platform collected. NOT margin. */\n applicationFeeCents: number\n /** Stripe's processing cost this recovered, at cost. Subtracted, never earnings. */\n processingPassThroughCents: number\n /** Application fee minus the pass-through: the advertised take. */\n commissionCents: number\n /** Take handed back with refunds and chargebacks, pro-rata. */\n commissionRefundedCents: number\n /** Take net of reversals. */\n commissionNetCents: number\n /**\n * Storefront SUBSCRIPTION cycles in this period. Counted because a cycle\n * billed before its subscription was re-priced onto the pass-through\n * reports no take at all — see the module note.\n */\n subscriptionOrders: number\n /**\n * The sweep hit its ceiling, so every figure above is a LOWER BOUND and must\n * not be quoted as a total. Narrow the period instead.\n */\n truncated: boolean\n}\n\n/** Cents from anything, defaulting to 0 rather than NaN. */\nfunction cents(value: unknown): number {\n const parsed = Number(value ?? 0)\n return Number.isFinite(parsed) ? Math.round(parsed) : 0\n}\n\n/**\n * Whether this order row was a test-mode rehearsal.\n *\n * A recorded `livemode` wins, and the session id's prefix is the fallback for\n * rows written before anything recorded it. An order with neither signal is\n * LIVE: a POS cash sale carries no Stripe session at all, and answering \"test\"\n * for anything unidentifiable would erase genuine revenue from the report.\n */\nfunction isTestModeOrderRow(row: CommerceOrderRowInput): boolean {\n if (typeof row.livemode === 'boolean') return !row.livemode\n return stripeIdIsTestMode(row.checkoutSessionId ?? row.id)\n}\n\nexport function commerceSettledSummary(\n rows: readonly CommerceOrderRowInput[],\n truncated = false,\n): CommerceSettled {\n const out: CommerceSettled = {\n transactionCount: 0,\n grossCents: 0,\n applicationFeeCents: 0,\n processingPassThroughCents: 0,\n commissionCents: 0,\n commissionRefundedCents: 0,\n commissionNetCents: 0,\n subscriptionOrders: 0,\n truncated: truncated === true,\n }\n for (const row of rows ?? []) {\n // A REHEARSAL IS NOT REVENUE. A smoke-test checkout writes a real order\n // document — Stripe never moved money for it, and its session id says so.\n // Skipped ENTIRELY rather than counted at zero, because `transactionCount`\n // is read as \"how many sales\", and a rehearsal is not one.\n if (isTestModeOrderRow(row)) continue\n const split = commerceOrderTake(row)\n out.transactionCount += 1\n out.grossCents += split.gross\n out.applicationFeeCents += split.fee\n if (split.isSubscriptionOrder) out.subscriptionOrders += 1\n out.processingPassThroughCents += split.passThrough\n out.commissionCents += split.take\n out.commissionRefundedCents += split.takeRefunded\n out.commissionNetCents += split.takeNet\n }\n return out\n}\n\n/**\n * One storefront order's take, in ONE place.\n *\n * Shared with the per-host attribution so a host table cannot sum to\n * something other than the storefront line above it.\n *\n * A zero-fee order contributes its gross and its count and nothing else —\n * there is no take to attribute, and inventing one from the gross would\n * report a merchant's money as the platform's.\n */\nexport function commerceOrderTake(row: CommerceOrderRowInput | null): {\n gross: number\n fee: number\n isSubscriptionOrder: boolean\n passThrough: number\n take: number\n takeRefunded: number\n takeNet: number\n} {\n const gross = cents(row?.amountCents)\n const fee = Math.max(0, cents(row?.feeCents))\n const isSubscriptionOrder =\n typeof row?.subscriptionId === 'string' && row.subscriptionId.length > 0\n if (fee <= 0) {\n return {\n gross,\n fee,\n isSubscriptionOrder,\n passThrough: 0,\n take: 0,\n takeRefunded: 0,\n takeNet: 0,\n }\n }\n // Every fee bundles the recovery — a one-time sale's in cents, a\n // subscription cycle's as a rate (AGL-2655). Clamped to the fee itself: the\n // recomputed cost can exceed a fee charged under an older rate, and a\n // negative take would subtract from another order's real margin.\n const passThrough = Math.min(fee, saleProcessingCostCents(gross))\n const take = fee - passThrough\n const refunded = Math.min(gross, Math.max(0, cents(row?.refundedCents)))\n const takeRefunded = gross > 0 ? Math.round((refunded * take) / gross) : 0\n return {\n gross,\n fee,\n isSubscriptionOrder,\n passThrough,\n take,\n takeRefunded,\n takeNet: take - takeRefunded,\n }\n}\n\n/**\n * Storefront take by HOST.\n *\n * By host and not by org on purpose: one org can run several storefronts, and\n * \"which store earns\" is the question a per-org roll-up destroys.\n */\nexport function commerceHostAttribution(\n rows: readonly CommerceOrderRowInput[],\n limit = 100,\n): RevenueAttribution {\n return groupRevenueAttribution(\n (rows ?? []).map((row) => {\n const split = commerceOrderTake(row)\n return {\n key: String(row?.hostId ?? ''),\n detail: '',\n gain: split.takeNet,\n loss: split.takeRefunded,\n }\n }),\n limit,\n 'Host not recorded',\n )\n}\n\n/**\n * The storefront's answer for one period, from figures already folded. Pure,\n * so what the report prints can be proved from fixtures.\n *\n * `failed` is a sweep that could not run — a collection-group index this\n * deployment has not built answers FAILED_PRECONDITION — and it is reported as\n * a failure with figures of zero, never as a cap: \"we read part of it\" and \"we\n * read none of it\" have different remedies.\n */\nexport function commerceRevenueAnswer(read: {\n settled: CommerceSettled\n byHost: RevenueAttribution\n failed: boolean\n}): RevenueSourceAnswer {\n const brand = PLATFORM_BRAND_NAME\n const { settled } = read\n return {\n id: 'commerce',\n name: 'storefront orders',\n earned: {\n label: 'Storefront commission',\n cents: Number(settled.commissionNetCents ?? 0),\n note:\n 'The advertised take on merchant storefront sales, net of refunds — ' +\n 'with Stripe’s card processing removed. The platform fee charged on a ' +\n 'storefront sale bundles that processing cost and passes it through at ' +\n 'cost; it is a recovery, not earnings.',\n },\n grossToNet: [\n {\n label: 'Storefront sales (shopper gross)',\n cents: settled.grossCents,\n deduction: false,\n note:\n 'The merchant’s. It transfers straight to their connected account ' +\n 'and is shown only for scale.',\n },\n {\n label: 'Storefront platform fee collected',\n cents: settled.applicationFeeCents,\n deduction: false,\n note: `Not all ${brand}’s — see the next line.`,\n },\n {\n label: '— less card processing passed through at cost',\n cents: settled.processingPassThroughCents,\n deduction: true,\n note: `Stripe’s. On a destination charge Stripe debits ${brand}’s balance for processing, and this half of the fee recovers exactly that. It is a recovery, not earnings, and reporting it as revenue would overstate every storefront sale.`,\n },\n ],\n notes:\n Number(settled.subscriptionOrders ?? 0) > 0\n ? [\n {\n label: `${settled.subscriptionOrders} storefront subscription cycles — one billed before its re-price reports no take`,\n tone: 'warning',\n },\n ]\n : [],\n attribution: [\n {\n id: 'byHost',\n heading: 'Storefront take by host',\n unit: 'Storefront',\n countLabel: 'Orders',\n empty: `No storefront order settled in this period. Note this is ${brand}'s take only — the shopper's spend is the merchant's money and is never counted here.`,\n chartEmpty: 'No storefront earned a take in this period — nothing to plot yet.',\n ...read.byHost,\n },\n ],\n attributionNote:\n \"Storefront figures above are the advertised take with Stripe's \" +\n 'processing cost already subtracted, on the basis stated in “Gross ' +\n 'versus net” below. Attribution is by host rather than by org because ' +\n 'one org can run several storefronts, and rolling them up destroys the ' +\n 'question.',\n truncated: settled.truncated,\n failure: read.failed\n ? {\n title: 'Storefront orders could not be read',\n detail:\n 'The storefront commission below reads $0 because the query ' +\n 'failed, not because there were no sales. The sweep needs the ' +\n 'COLLECTION_GROUP index on orders.createdAtMs — check it is still ' +\n 'declared in the Firestore index config and actually deployed, ' +\n 'since indexes ship separately from the app.',\n }\n : null,\n summary: settled,\n }\n}\n\n/**\n * The storefront's source, registered on the console's API surface: the\n * staff revenue report awaits that surface before it asks.\n */\nexport const commerceRevenueSource: RevenueSource = {\n async read(request) {\n const firestore = firebaseAdmin.app().firestore()\n // Storefront orders live per host (`hosts/{hostId}/orders`), so this is a\n // collection-GROUP range — ranged on `createdAtMs`, the NUMBER, and not on\n // the `createdAt` Timestamp beside it. A collection-group range needs a\n // COLLECTION_GROUP-scoped single-field index — Firestore's automatic ones\n // are COLLECTION scope only — and the index that is declared and deployed\n // is on `createdAtMs` (AGL-1793). The `createdAt` form answers\n // FAILED_PRECONDITION on every request, which is how the report once read\n // $0 for every storefront.\n let swept: {\n docs: FirebaseFirestore.QueryDocumentSnapshot[]\n truncated: boolean\n } | null\n try {\n swept = await request.sweep<FirebaseFirestore.QueryDocumentSnapshot>(\n firestore\n .collectionGroup('orders')\n .where('createdAtMs', '>=', request.start.getTime())\n .where('createdAtMs', '<', request.end.getTime()),\n 'createdAtMs',\n )\n } catch {\n swept = null\n }\n const rows: CommerceOrderRowInput[] = (swept?.docs ?? []).map((doc) => ({\n id: doc.id,\n // The storefront that earned the take is in the PATH.\n hostId: doc.ref.parent.parent?.id ?? '',\n ...(doc.data() as object),\n }))\n const settled = commerceSettledSummary(rows, swept?.truncated === true)\n const byHost = commerceHostAttribution(rows, request.attributionLimit)\n await request.nameRows(byHost.rows, {\n collection: 'hosts',\n nameField: 'displayName',\n detailField: 'subdomain',\n })\n return commerceRevenueAnswer({ settled, byHost, failed: swept === null })\n },\n}\n"],"names":["firebaseAdmin","saleProcessingCostCents","PLATFORM_BRAND_NAME","stripeIdIsTestMode","groupRevenueAttribution","cents","value","parsed","Number","isFinite","Math","round","isTestModeOrderRow","row","livemode","checkoutSessionId","id","commerceSettledSummary","rows","truncated","out","transactionCount","grossCents","applicationFeeCents","processingPassThroughCents","commissionCents","commissionRefundedCents","commissionNetCents","subscriptionOrders","split","commerceOrderTake","gross","fee","isSubscriptionOrder","passThrough","take","takeRefunded","takeNet","amountCents","max","feeCents","subscriptionId","length","min","refunded","refundedCents","commerceHostAttribution","limit","map","key","String","hostId","detail","gain","loss","commerceRevenueAnswer","read","settled","brand","name","earned","label","note","grossToNet","deduction","notes","tone","attribution","heading","unit","countLabel","empty","chartEmpty","byHost","attributionNote","failure","failed","title","summary","commerceRevenueSource","request","firestore","app","swept","sweep","collectionGroup","where","start","getTime","end","docs","doc","ref","parent","data","attributionLimit","nameRows","collection","nameField","detailField"],"mappings":"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"}
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
*/ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
17
17
|
import { describeConfiguredUrlRefusal, fetchConfiguredPublicUrl, firebaseAdmin, flagLookalikeDomain, notifyHostManagers } from "@aglyn/tenant-data-admin";
|
|
18
18
|
import { isDocumentId } from "@aglyn/tenant-data-admin/server/document-id";
|
|
19
|
+
import { formatOrderNumber } from "../model/commerce-orders.js";
|
|
19
20
|
import { createHmac } from "crypto";
|
|
20
21
|
/**
|
|
21
22
|
* The durable outbox for the dropship supplier notification (AGL-2473).
|
|
@@ -191,6 +192,18 @@ import { createHmac } from "crypto";
|
|
|
191
192
|
})
|
|
192
193
|
}).catch(()=>undefined);
|
|
193
194
|
}
|
|
195
|
+
/**
|
|
196
|
+
* The number the merchant knows an order by ("#1042"), for a notice about it
|
|
197
|
+
* (AGL-3432). The queued row carries only the document id, which is the Stripe
|
|
198
|
+
* session id and appears nowhere in the console. A read that fails falls back
|
|
199
|
+
* to the id, so the notice still goes out.
|
|
200
|
+
*/ async function orderLabelFor(firestore, hostId, orderId) {
|
|
201
|
+
if (!isDocumentId(hostId) || !isDocumentId(orderId)) return orderId;
|
|
202
|
+
const number = await firestore.collection('hosts').doc(hostId).collection('orders').doc(orderId).get().then((snapshot)=>snapshot.get('number')).catch(()=>undefined);
|
|
203
|
+
return formatOrderNumber({
|
|
204
|
+
number: number != null ? number : undefined
|
|
205
|
+
}, orderId);
|
|
206
|
+
}
|
|
194
207
|
/**
|
|
195
208
|
* One delivery attempt.
|
|
196
209
|
*
|
|
@@ -291,10 +304,12 @@ import { createHmac } from "crypto";
|
|
|
291
304
|
await stampOrderRoutingFailure(firestore, hostId, orderId, `Could not reach ${supplierName} after ${attempts} attempts ` + `(${reason}). This order was never routed.`);
|
|
292
305
|
// The bell as well as the timeline: nobody opens an order they have no
|
|
293
306
|
// reason to suspect.
|
|
307
|
+
// The consequence and the action (AGL-3432): the supplier has not been
|
|
308
|
+
// asked to ship this order, so the merchant has to.
|
|
294
309
|
await notifyHostManagers(hostId, {
|
|
295
310
|
type: 'content.order',
|
|
296
311
|
title: `Order not routed to ${supplierName}`,
|
|
297
|
-
body: `Order ${orderId}
|
|
312
|
+
body: `Order ${await orderLabelFor(firestore, hostId, orderId)} on {site} ` + `never reached ${supplierName}: ${attempts} delivery attempts failed ` + `(${reason}). They have not been asked to ship it; send it to them ` + 'yourself or fulfill it by hand.',
|
|
298
313
|
link: `/${hostId}/products`
|
|
299
314
|
}).catch(()=>undefined);
|
|
300
315
|
return 'dead-lettered';
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/supplier-outbox.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 PluginJobHostGate } from '@aglyn/aglyn/server'\nimport {\n describeConfiguredUrlRefusal,\n fetchConfiguredPublicUrl,\n firebaseAdmin,\n flagLookalikeDomain,\n notifyHostManagers,\n} from '@aglyn/tenant-data-admin'\nimport { isDocumentId } from '@aglyn/tenant-data-admin/server/document-id'\nimport { createHmac } from 'crypto'\n\n/**\n * The durable outbox for the dropship supplier notification (AGL-2473).\n *\n * ## What was wrong\n *\n * `billing-webhook.ts` routed a paid dropship order inside\n * `void (async () => { … })()`, and the last thing that block did was\n * `await fetch(supplier.webhookUrl, …)` — an endpoint the MERCHANT typed into\n * a console field, on a host Aglyn neither runs nor monitors. Vercel freezes\n * the container the instant the response is written, so a supplier that is\n * slow, rate-limiting, or simply down is a supplier that is never told. The\n * order is paid, the buyer is thanked, the merchant sees \"Sent to supplier X\"\n * on the timeline, and NOTHING ANYWHERE records that the POST is still owed —\n * so no later pass can find it and no report can list it. It surfaces when the\n * customer asks where their parcel is.\n *\n * ## Why not just `await` it\n *\n * AGL-2161 fixed the other four fire-and-forget sites in that file and\n * deliberately left this one, because awaiting it is worse rather than better:\n * a supplier taking 30s pushes the handler past Stripe's delivery window,\n * Stripe redelivers, and the redelivery re-enters a fan-out whose only\n * idempotency is one `created` flag upstream. A silently dropped notification\n * would have been traded for a DUPLICATED order. `refund.ts` awaits its own\n * sibling and says so in a comment — correctly, because that sibling only\n * touches our own Firestore.\n *\n * So the line this module draws is not \"fast vs slow\" but **ours vs theirs**.\n * Everything the routing block does to Aglyn's own storage — the callback\n * token, the timeline entry, the supplier email through our own provider — is\n * now AWAITED on the response path, exactly like `refund.ts`. The one call to a\n * stranger's server becomes a row here, and the response no longer waits on it.\n *\n * ## The mechanism\n *\n * A Firestore-backed outbox drained by the plugin-job beat that already runs\n * the two commerce recovery passes (`server.ts`). No new infrastructure: the\n * beat is a Cloud Scheduler function POSTing the tenant runner route, and it\n * has carried bounded scans since AGL-2227.\n *\n * * **Idempotent enqueue.** The id is `{hostId}__{orderId}__{supplierId}` and\n * the write is `create()`, which REFUSES an existing document, so a\n * redelivery cannot reset a retry part-way through its backoff. The AGL-498\n * existence guard upstream already returns before this fan-out, but\n * `reconcile-stock.ts` documents at length why one flag over a dozen effects\n * is not something to lean on.\n * * **Backoff.** 1m, 5m, 15m, 1h, 6h — about seven and a half hours of cover,\n * which is a supplier's overnight deploy but not their week off.\n * * **Dead letter.** After {@link SUPPLIER_DELIVERY_MAX_ATTEMPTS} the row is\n * left as `failed`, the ORDER's own timeline is stamped, and host managers\n * get a notification. The merchant reading the order they were paid for can\n * see that it never reached the supplier — the thing the old code could not\n * tell anyone, because it wrote nothing down.\n *\n * ## Two placement decisions, both deliberate\n *\n * **TOP-LEVEL, not `hosts/{hostId}/supplierDeliveries`.** The host catch-all in\n * the rules file is PERMISSIVE by default — a subcollection nobody names in its\n * three exclusion lists is one every site editor can write from the browser —\n * and the row carries the exact JSON body we sign and POST, so an editor-\n * writable row is an editor-chosen payload delivered under the supplier's own\n * shared secret. A top-level collection matches no rule in\n * `cloud/firebase-firestore.rules` at all and is therefore denied to every\n * client by default, the same footing as `platform/pluginJobs`. It also keeps\n * the drain on an ordinary collection query: a collection-GROUP `where` would\n * need a `COLLECTION_GROUP_ASC` fieldOverride, and AGL-1793 is the record of\n * both commerce crons being silently dead in production for want of exactly\n * that. Neither a rules deploy nor an index deploy is owed for this change.\n *\n * **A delivered row is DELETED, not stamped `delivered`.** The body holds the\n * buyer's email and name, and a top-level document is outside the\n * `hosts/{hostId}` recursive delete an erasure request runs. Retaining the\n * happy path would accumulate third-party PII in a place erasure does not\n * reach, to record something the order's own `routed` timeline entry already\n * says. Dead-lettered rows survive, because those are the ones a human has to\n * act on and the merchant has been told about them.\n */\n\n/** Top-level collection. Matches no rule, so no client may read or write it. */\nexport const SUPPLIER_DELIVERY_COLLECTION = 'supplierDeliveries'\n\n/** Attempts before a delivery is dead-lettered. */\nexport const SUPPLIER_DELIVERY_MAX_ATTEMPTS = 6\n\n/**\n * Delay before the next retry, indexed by attempts ALREADY made.\n *\n * The first entry is 60s and the job beat is also 60s, so a supplier that\n * blipped is retried on the next tick rather than a quarter of an hour later —\n * the difference between a parcel that ships today and one that does not.\n */\nexport const SUPPLIER_DELIVERY_BACKOFF_MS: readonly number[] = [\n 60_000,\n 300_000,\n 900_000,\n 3_600_000,\n 21_600_000,\n]\n\n/**\n * Per-attempt timeout. Nothing user-facing waits on this — it exists so one\n * hung supplier cannot hold the shared job beat open for the whole scan.\n */\nexport const SUPPLIER_DELIVERY_TIMEOUT_MS = 10_000\n\n/** Rows examined per drain. Bounded like every other scan on the beat. */\nexport const SUPPLIER_DELIVERY_SCAN_LIMIT = 100\n\nexport type SupplierDeliveryStatus = 'pending' | 'failed'\n\nexport interface SupplierDeliveryRecord {\n status: SupplierDeliveryStatus\n hostId: string\n orderId: string\n supplierId: string\n supplierName: string\n /** The endpoint as configured when the order was routed, for the record. */\n url: string\n /** The exact JSON the supplier is sent; signed at delivery time. */\n body: string\n attempts: number\n nextAttemptAtMs: number\n createdAtMs: number\n lastError?: string\n lastStatus?: number\n failedAtMs?: number\n}\n\nexport interface SupplierDeliveryScanResult {\n scanned: number\n delivered: number\n retried: number\n deadLettered: number\n /** Retired without delivery because the merchant removed the endpoint. */\n cancelled: number\n /** Left pending, with the retry budget intact, because the site is locked. */\n skippedLocked: number\n}\n\n/** gRPC `Status.ALREADY_EXISTS` — what `create()` on a live path carries. */\nconst GRPC_ALREADY_EXISTS = 6\n\n/**\n * The delay owed after `attempts` failures, clamped to the last step.\n *\n * Clamped rather than extrapolated: the tail entry is already six hours, and a\n * dead letter is what comes after it, not a longer and longer silence.\n */\nexport function supplierDeliveryBackoffMs(attempts: number): number {\n const last = SUPPLIER_DELIVERY_BACKOFF_MS.length - 1\n return SUPPLIER_DELIVERY_BACKOFF_MS[\n Math.max(0, Math.min(attempts - 1, last))\n ]\n}\n\n/**\n * The outbox document id for one host/order/supplier triple.\n *\n * Deterministic, so `create()` is the idempotency. `__` rather than `:` or `/`:\n * a slash would make `.doc()` build a PATH instead of an id (AGL-1771), and the\n * separator has to survive a Stripe session id, which already contains `_`.\n * Firestore reserves ids fully matching `__.*__`; this never is, because all\n * three parts are non-empty and the id neither starts nor ends with `__`.\n */\nexport function supplierDeliveryId(\n hostId: string,\n orderId: string,\n supplierId: string,\n): string {\n return `${hostId}__${orderId}__${supplierId}`\n}\n\n/**\n * Records that a supplier POST is owed. One Firestore write, awaited.\n *\n * Returns `'queued'`, `'exists'` when a row for this triple is already in\n * flight, or `'skipped'`. NEVER THROWS: the caller is a paid order's fulfilment\n * path, and a queue that cannot be written must not take the receipt down with\n * it. The refusal is logged, which is strictly more than the old code managed\n * on the same failure.\n */\nexport async function enqueueSupplierDelivery(options: {\n firestore: FirebaseFirestore.Firestore\n hostId: string\n orderId: string\n supplierId: string\n supplierName: string\n url: string\n body: string\n now?: number\n}): Promise<'queued' | 'exists' | 'skipped'> {\n const { firestore, hostId, orderId, supplierId, supplierName, url, body } =\n options\n const now = options.now ?? Date.now()\n const id = supplierDeliveryId(hostId, orderId, supplierId)\n if (!isDocumentId(id)) {\n console.error('Supplier delivery not queued: unusable id', id)\n return 'skipped'\n }\n const record: SupplierDeliveryRecord = {\n status: 'pending',\n hostId,\n orderId,\n supplierId,\n supplierName,\n url,\n body,\n attempts: 0,\n // Due immediately: the next beat picks it up, at most a minute out.\n nextAttemptAtMs: now,\n createdAtMs: now,\n }\n try {\n await firestore\n .collection(SUPPLIER_DELIVERY_COLLECTION)\n .doc(id)\n .create(record as unknown as FirebaseFirestore.DocumentData)\n return 'queued'\n } catch (error) {\n if ((error as { code?: number })?.code === GRPC_ALREADY_EXISTS) {\n return 'exists'\n }\n console.error('Supplier delivery not queued', hostId, orderId, error)\n return 'skipped'\n }\n}\n\n/**\n * Stamps a routing failure on the order the merchant actually reads.\n *\n * `update()`, not a merge-set, for `recordRedemptionOrphan`'s reason: the order\n * exists — the delivery was queued from its own fulfilment — and a merge would\n * mint an order stub on the one path where it does not.\n */\nasync function stampOrderRoutingFailure(\n firestore: FirebaseFirestore.Firestore,\n hostId: string,\n orderId: string,\n detail: string,\n): Promise<void> {\n if (!isDocumentId(hostId) || !isDocumentId(orderId)) return\n await firestore\n .collection('hosts')\n .doc(hostId)\n .collection('orders')\n .doc(orderId)\n .update({\n timeline: firebaseAdmin.firestore.FieldValue.arrayUnion({\n atMs: Date.now(),\n event: 'routing-failed',\n detail,\n }),\n })\n .catch(() => undefined)\n}\n\ntype DeliveryOutcome = 'delivered' | 'retried' | 'dead-lettered' | 'cancelled'\n\n/**\n * One delivery attempt.\n *\n * Never throws: the drain is a shared beat, and one merchant's supplier must\n * not abort every other merchant's queue — the `process-restock.ts` lesson,\n * where a single poisoned row killed back-in-stock email platform-wide.\n */\nasync function attemptSupplierDelivery(\n firestore: FirebaseFirestore.Firestore,\n snapshot: FirebaseFirestore.QueryDocumentSnapshot,\n now: number,\n): Promise<DeliveryOutcome> {\n const data = snapshot.data() as SupplierDeliveryRecord\n const hostId = String(data.hostId ?? '')\n const attempts = Number(data.attempts ?? 0) + 1\n\n const retire = async (): Promise<'cancelled'> => {\n await snapshot.ref.delete().catch(() => undefined)\n return 'cancelled'\n }\n if (!isDocumentId(hostId) || !isDocumentId(String(data.supplierId ?? ''))) {\n return retire()\n }\n\n // The endpoint and the secret are read FRESH rather than frozen into the row:\n // a merchant correcting a typo'd URL corrects what is already queued, and the\n // supplier's shared secret is never copied into a second document.\n const supplierSnapshot = await firestore\n .collection('hosts')\n .doc(hostId)\n .collection('suppliers')\n .doc(String(data.supplierId))\n .get()\n .catch(() => null)\n // A read that FAILED is not a supplier that is gone. Retrying beats retiring\n // a real delivery on a transient Firestore error — the `reconcile-stock.ts`\n // rule that a failed read is not an absent marker.\n if (!supplierSnapshot) {\n return recordFailure(\n firestore,\n snapshot,\n data,\n attempts,\n now,\n 'could not read the supplier record',\n undefined,\n )\n }\n const url = String(supplierSnapshot.get('webhookUrl') ?? '')\n if (!url) {\n // Deliberately NOT a dead letter: the merchant removed the endpoint (or the\n // supplier), which is a decision rather than an outage. Alarming them about\n // a delivery they cancelled is how an alert stops being read.\n return retire()\n }\n const secret = String(supplierSnapshot.get('webhookSecret') ?? '')\n const body = String(data.body ?? '')\n\n let status: number\n try {\n // A merchant-typed address, called from the platform's servers: https\n // only, public addresses only (pinned), never a lookalike, never a\n // followed redirect (AGL-3363). A bare `fetch` here reached any address a\n // merchant typed, the cloud metadata endpoint included.\n const called = await fetchConfiguredPublicUrl(url, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n // NO EMPTY-KEY HMAC (AGL-2455). A signature computed under `''` is one\n // any party can compute, and a supplier who also left their secret\n // unset would verify it and believe the delivery was authenticated. An\n // absent header fails their check closed; a forgeable one passes it.\n ...(secret\n ? {\n 'x-aglyn-signature': createHmac('sha256', secret)\n .update(body)\n .digest('hex'),\n }\n : {}),\n },\n body,\n signal: AbortSignal.timeout(SUPPLIER_DELIVERY_TIMEOUT_MS),\n })\n if (called.ok === false) {\n if (called.refusal === 'lookalike' && called.host) {\n await flagLookalikeDomain({\n kind: 'link',\n hostId,\n orgId: null,\n domain: called.host,\n where: 'a supplier order webhook',\n })\n }\n return recordFailure(\n firestore,\n snapshot,\n data,\n attempts,\n now,\n `the supplier's webhook address was not called: ${describeConfiguredUrlRefusal(called.refusal)}`,\n undefined,\n )\n }\n status = called.status\n if (!(status >= 200 && status < 300)) {\n return recordFailure(\n firestore,\n snapshot,\n data,\n attempts,\n now,\n `supplier answered HTTP ${status}`,\n status,\n )\n }\n } catch (error) {\n return recordFailure(\n firestore,\n snapshot,\n data,\n attempts,\n now,\n String((error as Error)?.message ?? error),\n undefined,\n )\n }\n\n // Delivered rows are DELETED rather than stamped — see the module note: the\n // body holds the buyer's email and name, and a top-level document is outside\n // the recursive delete an erasure request runs over `hosts/{hostId}`.\n await snapshot.ref.delete().catch(() => undefined)\n return 'delivered'\n}\n\n/** Books one failure: another retry, or the dead letter and its two alarms. */\nasync function recordFailure(\n firestore: FirebaseFirestore.Firestore,\n snapshot: FirebaseFirestore.QueryDocumentSnapshot,\n data: SupplierDeliveryRecord,\n attempts: number,\n now: number,\n reason: string,\n status: number | undefined,\n): Promise<'retried' | 'dead-lettered'> {\n const exhausted = attempts >= SUPPLIER_DELIVERY_MAX_ATTEMPTS\n await snapshot.ref\n .update({\n status: exhausted ? 'failed' : 'pending',\n attempts,\n lastError: reason,\n ...(status ? { lastStatus: status } : {}),\n ...(exhausted\n ? { failedAtMs: now }\n : { nextAttemptAtMs: now + supplierDeliveryBackoffMs(attempts) }),\n })\n .catch(() => undefined)\n if (!exhausted) return 'retried'\n\n const hostId = String(data.hostId ?? '')\n const orderId = String(data.orderId ?? '')\n const supplierName = String(data.supplierName ?? '') || 'the supplier'\n await stampOrderRoutingFailure(\n firestore,\n hostId,\n orderId,\n `Could not reach ${supplierName} after ${attempts} attempts ` +\n `(${reason}). This order was never routed.`,\n )\n // The bell as well as the timeline: nobody opens an order they have no\n // reason to suspect.\n await notifyHostManagers(hostId, {\n type: 'content.order',\n title: `Order not routed to ${supplierName}`,\n body: `Order ${orderId} could not be sent after ${attempts} attempts.`,\n link: `/${hostId}/products`,\n }).catch(() => undefined)\n return 'dead-lettered'\n}\n\n/**\n * Drains the supplier outbox. Bounded, and safe to run twice.\n *\n * `nextAttemptAtMs` is filtered IN MEMORY rather than added to the query, which\n * would need a composite index for one field's worth of selectivity. The cost\n * is that a row still inside its backoff occupies one of the\n * {@link SUPPLIER_DELIVERY_SCAN_LIMIT} slots; with at most\n * {@link SUPPLIER_DELIVERY_MAX_ATTEMPTS} attempts per row the queue always\n * drains rather than growing a permanent head.\n */\nexport async function scanSupplierDeliveries(\n /** The lockdown gate, injected by the caller (AGL-2495). Not optional. */\n gate: PluginJobHostGate,\n now: number = Date.now(),\n): Promise<SupplierDeliveryScanResult> {\n const firestore = firebaseAdmin.app().firestore()\n const pending = await firestore\n .collection(SUPPLIER_DELIVERY_COLLECTION)\n .where('status', '==', 'pending')\n .limit(SUPPLIER_DELIVERY_SCAN_LIMIT)\n .get()\n const result: SupplierDeliveryScanResult = {\n scanned: pending.size,\n delivered: 0,\n retried: 0,\n deadLettered: 0,\n cancelled: 0,\n skippedLocked: 0,\n }\n for (const snapshot of pending.docs) {\n const data = snapshot.data() as SupplierDeliveryRecord\n // LOCKDOWN (AGL-2495). The row is a TOP-LEVEL document carrying its own\n // `hostId` rather than living under `hosts/{hostId}`, so the host is read\n // from the record instead of the parent chain — the one job of the six\n // whose subject is not in its path.\n //\n // An outbound POST to the merchant's supplier is this platform acting in\n // a suspended merchant's name against a third party, which is precisely\n // what a takedown is for. The attempt also mutates the row (`attempts`,\n // `nextAttemptAtMs`, or the dead-letter) and can notify the host's\n // managers.\n //\n // SKIPPED, NOT DROPPED, and the strongest case of the six: the row stays\n // `pending` with its `attempts` count untouched, so a long lock cannot\n // burn a paid order's retry budget and dead-letter it for being locked.\n // The queue drains from where it stood the minute the lock lifts.\n if (await gate.isLocked(String(data?.hostId ?? ''))) {\n result.skippedLocked += 1\n continue\n }\n if (Number(data?.nextAttemptAtMs ?? 0) > now) continue\n const outcome = await attemptSupplierDelivery(\n firestore,\n snapshot,\n now,\n ).catch((error) => {\n console.error(\n 'Supplier delivery attempt failed',\n snapshot.ref.path,\n error,\n )\n return null\n })\n if (outcome === 'delivered') result.delivered += 1\n else if (outcome === 'retried') result.retried += 1\n else if (outcome === 'dead-lettered') result.deadLettered += 1\n else if (outcome === 'cancelled') result.cancelled += 1\n }\n return result\n}\n"],"names":["describeConfiguredUrlRefusal","fetchConfiguredPublicUrl","firebaseAdmin","flagLookalikeDomain","notifyHostManagers","isDocumentId","createHmac","SUPPLIER_DELIVERY_COLLECTION","SUPPLIER_DELIVERY_MAX_ATTEMPTS","SUPPLIER_DELIVERY_BACKOFF_MS","SUPPLIER_DELIVERY_TIMEOUT_MS","SUPPLIER_DELIVERY_SCAN_LIMIT","GRPC_ALREADY_EXISTS","supplierDeliveryBackoffMs","attempts","last","length","Math","max","min","supplierDeliveryId","hostId","orderId","supplierId","enqueueSupplierDelivery","options","firestore","supplierName","url","body","now","Date","id","console","error","record","status","nextAttemptAtMs","createdAtMs","collection","doc","create","code","stampOrderRoutingFailure","detail","update","timeline","FieldValue","arrayUnion","atMs","event","catch","undefined","attemptSupplierDelivery","snapshot","data","supplierSnapshot","String","Number","retire","ref","delete","get","recordFailure","secret","called","method","headers","digest","signal","AbortSignal","timeout","ok","refusal","host","kind","orgId","domain","where","message","reason","exhausted","lastError","lastStatus","failedAtMs","type","title","link","scanSupplierDeliveries","gate","app","pending","limit","result","scanned","size","delivered","retried","deadLettered","cancelled","skippedLocked","docs","isLocked","outcome","path"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/supplier-outbox.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 PluginJobHostGate } from '@aglyn/aglyn/server'\nimport {\n describeConfiguredUrlRefusal,\n fetchConfiguredPublicUrl,\n firebaseAdmin,\n flagLookalikeDomain,\n notifyHostManagers,\n} from '@aglyn/tenant-data-admin'\nimport { isDocumentId } from '@aglyn/tenant-data-admin/server/document-id'\nimport { formatOrderNumber } from '../model/commerce-orders'\nimport { createHmac } from 'crypto'\n\n/**\n * The durable outbox for the dropship supplier notification (AGL-2473).\n *\n * ## What was wrong\n *\n * `billing-webhook.ts` routed a paid dropship order inside\n * `void (async () => { … })()`, and the last thing that block did was\n * `await fetch(supplier.webhookUrl, …)` — an endpoint the MERCHANT typed into\n * a console field, on a host Aglyn neither runs nor monitors. Vercel freezes\n * the container the instant the response is written, so a supplier that is\n * slow, rate-limiting, or simply down is a supplier that is never told. The\n * order is paid, the buyer is thanked, the merchant sees \"Sent to supplier X\"\n * on the timeline, and NOTHING ANYWHERE records that the POST is still owed —\n * so no later pass can find it and no report can list it. It surfaces when the\n * customer asks where their parcel is.\n *\n * ## Why not just `await` it\n *\n * AGL-2161 fixed the other four fire-and-forget sites in that file and\n * deliberately left this one, because awaiting it is worse rather than better:\n * a supplier taking 30s pushes the handler past Stripe's delivery window,\n * Stripe redelivers, and the redelivery re-enters a fan-out whose only\n * idempotency is one `created` flag upstream. A silently dropped notification\n * would have been traded for a DUPLICATED order. `refund.ts` awaits its own\n * sibling and says so in a comment — correctly, because that sibling only\n * touches our own Firestore.\n *\n * So the line this module draws is not \"fast vs slow\" but **ours vs theirs**.\n * Everything the routing block does to Aglyn's own storage — the callback\n * token, the timeline entry, the supplier email through our own provider — is\n * now AWAITED on the response path, exactly like `refund.ts`. The one call to a\n * stranger's server becomes a row here, and the response no longer waits on it.\n *\n * ## The mechanism\n *\n * A Firestore-backed outbox drained by the plugin-job beat that already runs\n * the two commerce recovery passes (`server.ts`). No new infrastructure: the\n * beat is a Cloud Scheduler function POSTing the tenant runner route, and it\n * has carried bounded scans since AGL-2227.\n *\n * * **Idempotent enqueue.** The id is `{hostId}__{orderId}__{supplierId}` and\n * the write is `create()`, which REFUSES an existing document, so a\n * redelivery cannot reset a retry part-way through its backoff. The AGL-498\n * existence guard upstream already returns before this fan-out, but\n * `reconcile-stock.ts` documents at length why one flag over a dozen effects\n * is not something to lean on.\n * * **Backoff.** 1m, 5m, 15m, 1h, 6h — about seven and a half hours of cover,\n * which is a supplier's overnight deploy but not their week off.\n * * **Dead letter.** After {@link SUPPLIER_DELIVERY_MAX_ATTEMPTS} the row is\n * left as `failed`, the ORDER's own timeline is stamped, and host managers\n * get a notification. The merchant reading the order they were paid for can\n * see that it never reached the supplier — the thing the old code could not\n * tell anyone, because it wrote nothing down.\n *\n * ## Two placement decisions, both deliberate\n *\n * **TOP-LEVEL, not `hosts/{hostId}/supplierDeliveries`.** The host catch-all in\n * the rules file is PERMISSIVE by default — a subcollection nobody names in its\n * three exclusion lists is one every site editor can write from the browser —\n * and the row carries the exact JSON body we sign and POST, so an editor-\n * writable row is an editor-chosen payload delivered under the supplier's own\n * shared secret. A top-level collection matches no rule in\n * `cloud/firebase-firestore.rules` at all and is therefore denied to every\n * client by default, the same footing as `platform/pluginJobs`. It also keeps\n * the drain on an ordinary collection query: a collection-GROUP `where` would\n * need a `COLLECTION_GROUP_ASC` fieldOverride, and AGL-1793 is the record of\n * both commerce crons being silently dead in production for want of exactly\n * that. Neither a rules deploy nor an index deploy is owed for this change.\n *\n * **A delivered row is DELETED, not stamped `delivered`.** The body holds the\n * buyer's email and name, and a top-level document is outside the\n * `hosts/{hostId}` recursive delete an erasure request runs. Retaining the\n * happy path would accumulate third-party PII in a place erasure does not\n * reach, to record something the order's own `routed` timeline entry already\n * says. Dead-lettered rows survive, because those are the ones a human has to\n * act on and the merchant has been told about them.\n */\n\n/** Top-level collection. Matches no rule, so no client may read or write it. */\nexport const SUPPLIER_DELIVERY_COLLECTION = 'supplierDeliveries'\n\n/** Attempts before a delivery is dead-lettered. */\nexport const SUPPLIER_DELIVERY_MAX_ATTEMPTS = 6\n\n/**\n * Delay before the next retry, indexed by attempts ALREADY made.\n *\n * The first entry is 60s and the job beat is also 60s, so a supplier that\n * blipped is retried on the next tick rather than a quarter of an hour later —\n * the difference between a parcel that ships today and one that does not.\n */\nexport const SUPPLIER_DELIVERY_BACKOFF_MS: readonly number[] = [\n 60_000,\n 300_000,\n 900_000,\n 3_600_000,\n 21_600_000,\n]\n\n/**\n * Per-attempt timeout. Nothing user-facing waits on this — it exists so one\n * hung supplier cannot hold the shared job beat open for the whole scan.\n */\nexport const SUPPLIER_DELIVERY_TIMEOUT_MS = 10_000\n\n/** Rows examined per drain. Bounded like every other scan on the beat. */\nexport const SUPPLIER_DELIVERY_SCAN_LIMIT = 100\n\nexport type SupplierDeliveryStatus = 'pending' | 'failed'\n\nexport interface SupplierDeliveryRecord {\n status: SupplierDeliveryStatus\n hostId: string\n orderId: string\n supplierId: string\n supplierName: string\n /** The endpoint as configured when the order was routed, for the record. */\n url: string\n /** The exact JSON the supplier is sent; signed at delivery time. */\n body: string\n attempts: number\n nextAttemptAtMs: number\n createdAtMs: number\n lastError?: string\n lastStatus?: number\n failedAtMs?: number\n}\n\nexport interface SupplierDeliveryScanResult {\n scanned: number\n delivered: number\n retried: number\n deadLettered: number\n /** Retired without delivery because the merchant removed the endpoint. */\n cancelled: number\n /** Left pending, with the retry budget intact, because the site is locked. */\n skippedLocked: number\n}\n\n/** gRPC `Status.ALREADY_EXISTS` — what `create()` on a live path carries. */\nconst GRPC_ALREADY_EXISTS = 6\n\n/**\n * The delay owed after `attempts` failures, clamped to the last step.\n *\n * Clamped rather than extrapolated: the tail entry is already six hours, and a\n * dead letter is what comes after it, not a longer and longer silence.\n */\nexport function supplierDeliveryBackoffMs(attempts: number): number {\n const last = SUPPLIER_DELIVERY_BACKOFF_MS.length - 1\n return SUPPLIER_DELIVERY_BACKOFF_MS[\n Math.max(0, Math.min(attempts - 1, last))\n ]\n}\n\n/**\n * The outbox document id for one host/order/supplier triple.\n *\n * Deterministic, so `create()` is the idempotency. `__` rather than `:` or `/`:\n * a slash would make `.doc()` build a PATH instead of an id (AGL-1771), and the\n * separator has to survive a Stripe session id, which already contains `_`.\n * Firestore reserves ids fully matching `__.*__`; this never is, because all\n * three parts are non-empty and the id neither starts nor ends with `__`.\n */\nexport function supplierDeliveryId(\n hostId: string,\n orderId: string,\n supplierId: string,\n): string {\n return `${hostId}__${orderId}__${supplierId}`\n}\n\n/**\n * Records that a supplier POST is owed. One Firestore write, awaited.\n *\n * Returns `'queued'`, `'exists'` when a row for this triple is already in\n * flight, or `'skipped'`. NEVER THROWS: the caller is a paid order's fulfilment\n * path, and a queue that cannot be written must not take the receipt down with\n * it. The refusal is logged, which is strictly more than the old code managed\n * on the same failure.\n */\nexport async function enqueueSupplierDelivery(options: {\n firestore: FirebaseFirestore.Firestore\n hostId: string\n orderId: string\n supplierId: string\n supplierName: string\n url: string\n body: string\n now?: number\n}): Promise<'queued' | 'exists' | 'skipped'> {\n const { firestore, hostId, orderId, supplierId, supplierName, url, body } =\n options\n const now = options.now ?? Date.now()\n const id = supplierDeliveryId(hostId, orderId, supplierId)\n if (!isDocumentId(id)) {\n console.error('Supplier delivery not queued: unusable id', id)\n return 'skipped'\n }\n const record: SupplierDeliveryRecord = {\n status: 'pending',\n hostId,\n orderId,\n supplierId,\n supplierName,\n url,\n body,\n attempts: 0,\n // Due immediately: the next beat picks it up, at most a minute out.\n nextAttemptAtMs: now,\n createdAtMs: now,\n }\n try {\n await firestore\n .collection(SUPPLIER_DELIVERY_COLLECTION)\n .doc(id)\n .create(record as unknown as FirebaseFirestore.DocumentData)\n return 'queued'\n } catch (error) {\n if ((error as { code?: number })?.code === GRPC_ALREADY_EXISTS) {\n return 'exists'\n }\n console.error('Supplier delivery not queued', hostId, orderId, error)\n return 'skipped'\n }\n}\n\n/**\n * Stamps a routing failure on the order the merchant actually reads.\n *\n * `update()`, not a merge-set, for `recordRedemptionOrphan`'s reason: the order\n * exists — the delivery was queued from its own fulfilment — and a merge would\n * mint an order stub on the one path where it does not.\n */\nasync function stampOrderRoutingFailure(\n firestore: FirebaseFirestore.Firestore,\n hostId: string,\n orderId: string,\n detail: string,\n): Promise<void> {\n if (!isDocumentId(hostId) || !isDocumentId(orderId)) return\n await firestore\n .collection('hosts')\n .doc(hostId)\n .collection('orders')\n .doc(orderId)\n .update({\n timeline: firebaseAdmin.firestore.FieldValue.arrayUnion({\n atMs: Date.now(),\n event: 'routing-failed',\n detail,\n }),\n })\n .catch(() => undefined)\n}\n\n/**\n * The number the merchant knows an order by (\"#1042\"), for a notice about it\n * (AGL-3432). The queued row carries only the document id, which is the Stripe\n * session id and appears nowhere in the console. A read that fails falls back\n * to the id, so the notice still goes out.\n */\nasync function orderLabelFor(\n firestore: FirebaseFirestore.Firestore,\n hostId: string,\n orderId: string,\n): Promise<string> {\n if (!isDocumentId(hostId) || !isDocumentId(orderId)) return orderId\n const number = await firestore\n .collection('hosts')\n .doc(hostId)\n .collection('orders')\n .doc(orderId)\n .get()\n .then((snapshot) => snapshot.get('number') as number | undefined)\n .catch(() => undefined)\n return formatOrderNumber({ number: number ?? undefined }, orderId)\n}\n\ntype DeliveryOutcome = 'delivered' | 'retried' | 'dead-lettered' | 'cancelled'\n\n/**\n * One delivery attempt.\n *\n * Never throws: the drain is a shared beat, and one merchant's supplier must\n * not abort every other merchant's queue — the `process-restock.ts` lesson,\n * where a single poisoned row killed back-in-stock email platform-wide.\n */\nasync function attemptSupplierDelivery(\n firestore: FirebaseFirestore.Firestore,\n snapshot: FirebaseFirestore.QueryDocumentSnapshot,\n now: number,\n): Promise<DeliveryOutcome> {\n const data = snapshot.data() as SupplierDeliveryRecord\n const hostId = String(data.hostId ?? '')\n const attempts = Number(data.attempts ?? 0) + 1\n\n const retire = async (): Promise<'cancelled'> => {\n await snapshot.ref.delete().catch(() => undefined)\n return 'cancelled'\n }\n if (!isDocumentId(hostId) || !isDocumentId(String(data.supplierId ?? ''))) {\n return retire()\n }\n\n // The endpoint and the secret are read FRESH rather than frozen into the row:\n // a merchant correcting a typo'd URL corrects what is already queued, and the\n // supplier's shared secret is never copied into a second document.\n const supplierSnapshot = await firestore\n .collection('hosts')\n .doc(hostId)\n .collection('suppliers')\n .doc(String(data.supplierId))\n .get()\n .catch(() => null)\n // A read that FAILED is not a supplier that is gone. Retrying beats retiring\n // a real delivery on a transient Firestore error — the `reconcile-stock.ts`\n // rule that a failed read is not an absent marker.\n if (!supplierSnapshot) {\n return recordFailure(\n firestore,\n snapshot,\n data,\n attempts,\n now,\n 'could not read the supplier record',\n undefined,\n )\n }\n const url = String(supplierSnapshot.get('webhookUrl') ?? '')\n if (!url) {\n // Deliberately NOT a dead letter: the merchant removed the endpoint (or the\n // supplier), which is a decision rather than an outage. Alarming them about\n // a delivery they cancelled is how an alert stops being read.\n return retire()\n }\n const secret = String(supplierSnapshot.get('webhookSecret') ?? '')\n const body = String(data.body ?? '')\n\n let status: number\n try {\n // A merchant-typed address, called from the platform's servers: https\n // only, public addresses only (pinned), never a lookalike, never a\n // followed redirect (AGL-3363). A bare `fetch` here reached any address a\n // merchant typed, the cloud metadata endpoint included.\n const called = await fetchConfiguredPublicUrl(url, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n // NO EMPTY-KEY HMAC (AGL-2455). A signature computed under `''` is one\n // any party can compute, and a supplier who also left their secret\n // unset would verify it and believe the delivery was authenticated. An\n // absent header fails their check closed; a forgeable one passes it.\n ...(secret\n ? {\n 'x-aglyn-signature': createHmac('sha256', secret)\n .update(body)\n .digest('hex'),\n }\n : {}),\n },\n body,\n signal: AbortSignal.timeout(SUPPLIER_DELIVERY_TIMEOUT_MS),\n })\n if (called.ok === false) {\n if (called.refusal === 'lookalike' && called.host) {\n await flagLookalikeDomain({\n kind: 'link',\n hostId,\n orgId: null,\n domain: called.host,\n where: 'a supplier order webhook',\n })\n }\n return recordFailure(\n firestore,\n snapshot,\n data,\n attempts,\n now,\n `the supplier's webhook address was not called: ${describeConfiguredUrlRefusal(called.refusal)}`,\n undefined,\n )\n }\n status = called.status\n if (!(status >= 200 && status < 300)) {\n return recordFailure(\n firestore,\n snapshot,\n data,\n attempts,\n now,\n `supplier answered HTTP ${status}`,\n status,\n )\n }\n } catch (error) {\n return recordFailure(\n firestore,\n snapshot,\n data,\n attempts,\n now,\n String((error as Error)?.message ?? error),\n undefined,\n )\n }\n\n // Delivered rows are DELETED rather than stamped — see the module note: the\n // body holds the buyer's email and name, and a top-level document is outside\n // the recursive delete an erasure request runs over `hosts/{hostId}`.\n await snapshot.ref.delete().catch(() => undefined)\n return 'delivered'\n}\n\n/** Books one failure: another retry, or the dead letter and its two alarms. */\nasync function recordFailure(\n firestore: FirebaseFirestore.Firestore,\n snapshot: FirebaseFirestore.QueryDocumentSnapshot,\n data: SupplierDeliveryRecord,\n attempts: number,\n now: number,\n reason: string,\n status: number | undefined,\n): Promise<'retried' | 'dead-lettered'> {\n const exhausted = attempts >= SUPPLIER_DELIVERY_MAX_ATTEMPTS\n await snapshot.ref\n .update({\n status: exhausted ? 'failed' : 'pending',\n attempts,\n lastError: reason,\n ...(status ? { lastStatus: status } : {}),\n ...(exhausted\n ? { failedAtMs: now }\n : { nextAttemptAtMs: now + supplierDeliveryBackoffMs(attempts) }),\n })\n .catch(() => undefined)\n if (!exhausted) return 'retried'\n\n const hostId = String(data.hostId ?? '')\n const orderId = String(data.orderId ?? '')\n const supplierName = String(data.supplierName ?? '') || 'the supplier'\n await stampOrderRoutingFailure(\n firestore,\n hostId,\n orderId,\n `Could not reach ${supplierName} after ${attempts} attempts ` +\n `(${reason}). This order was never routed.`,\n )\n // The bell as well as the timeline: nobody opens an order they have no\n // reason to suspect.\n // The consequence and the action (AGL-3432): the supplier has not been\n // asked to ship this order, so the merchant has to.\n await notifyHostManagers(hostId, {\n type: 'content.order',\n title: `Order not routed to ${supplierName}`,\n body:\n `Order ${await orderLabelFor(firestore, hostId, orderId)} on {site} ` +\n `never reached ${supplierName}: ${attempts} delivery attempts failed ` +\n `(${reason}). They have not been asked to ship it; send it to them ` +\n 'yourself or fulfill it by hand.',\n link: `/${hostId}/products`,\n }).catch(() => undefined)\n return 'dead-lettered'\n}\n\n/**\n * Drains the supplier outbox. Bounded, and safe to run twice.\n *\n * `nextAttemptAtMs` is filtered IN MEMORY rather than added to the query, which\n * would need a composite index for one field's worth of selectivity. The cost\n * is that a row still inside its backoff occupies one of the\n * {@link SUPPLIER_DELIVERY_SCAN_LIMIT} slots; with at most\n * {@link SUPPLIER_DELIVERY_MAX_ATTEMPTS} attempts per row the queue always\n * drains rather than growing a permanent head.\n */\nexport async function scanSupplierDeliveries(\n /** The lockdown gate, injected by the caller (AGL-2495). Not optional. */\n gate: PluginJobHostGate,\n now: number = Date.now(),\n): Promise<SupplierDeliveryScanResult> {\n const firestore = firebaseAdmin.app().firestore()\n const pending = await firestore\n .collection(SUPPLIER_DELIVERY_COLLECTION)\n .where('status', '==', 'pending')\n .limit(SUPPLIER_DELIVERY_SCAN_LIMIT)\n .get()\n const result: SupplierDeliveryScanResult = {\n scanned: pending.size,\n delivered: 0,\n retried: 0,\n deadLettered: 0,\n cancelled: 0,\n skippedLocked: 0,\n }\n for (const snapshot of pending.docs) {\n const data = snapshot.data() as SupplierDeliveryRecord\n // LOCKDOWN (AGL-2495). The row is a TOP-LEVEL document carrying its own\n // `hostId` rather than living under `hosts/{hostId}`, so the host is read\n // from the record instead of the parent chain — the one job of the six\n // whose subject is not in its path.\n //\n // An outbound POST to the merchant's supplier is this platform acting in\n // a suspended merchant's name against a third party, which is precisely\n // what a takedown is for. The attempt also mutates the row (`attempts`,\n // `nextAttemptAtMs`, or the dead-letter) and can notify the host's\n // managers.\n //\n // SKIPPED, NOT DROPPED, and the strongest case of the six: the row stays\n // `pending` with its `attempts` count untouched, so a long lock cannot\n // burn a paid order's retry budget and dead-letter it for being locked.\n // The queue drains from where it stood the minute the lock lifts.\n if (await gate.isLocked(String(data?.hostId ?? ''))) {\n result.skippedLocked += 1\n continue\n }\n if (Number(data?.nextAttemptAtMs ?? 0) > now) continue\n const outcome = await attemptSupplierDelivery(\n firestore,\n snapshot,\n now,\n ).catch((error) => {\n console.error(\n 'Supplier delivery attempt failed',\n snapshot.ref.path,\n error,\n )\n return null\n })\n if (outcome === 'delivered') result.delivered += 1\n else if (outcome === 'retried') result.retried += 1\n else if (outcome === 'dead-lettered') result.deadLettered += 1\n else if (outcome === 'cancelled') result.cancelled += 1\n }\n return result\n}\n"],"names":["describeConfiguredUrlRefusal","fetchConfiguredPublicUrl","firebaseAdmin","flagLookalikeDomain","notifyHostManagers","isDocumentId","formatOrderNumber","createHmac","SUPPLIER_DELIVERY_COLLECTION","SUPPLIER_DELIVERY_MAX_ATTEMPTS","SUPPLIER_DELIVERY_BACKOFF_MS","SUPPLIER_DELIVERY_TIMEOUT_MS","SUPPLIER_DELIVERY_SCAN_LIMIT","GRPC_ALREADY_EXISTS","supplierDeliveryBackoffMs","attempts","last","length","Math","max","min","supplierDeliveryId","hostId","orderId","supplierId","enqueueSupplierDelivery","options","firestore","supplierName","url","body","now","Date","id","console","error","record","status","nextAttemptAtMs","createdAtMs","collection","doc","create","code","stampOrderRoutingFailure","detail","update","timeline","FieldValue","arrayUnion","atMs","event","catch","undefined","orderLabelFor","number","get","then","snapshot","attemptSupplierDelivery","data","supplierSnapshot","String","Number","retire","ref","delete","recordFailure","secret","called","method","headers","digest","signal","AbortSignal","timeout","ok","refusal","host","kind","orgId","domain","where","message","reason","exhausted","lastError","lastStatus","failedAtMs","type","title","link","scanSupplierDeliveries","gate","app","pending","limit","result","scanned","size","delivered","retried","deadLettered","cancelled","skippedLocked","docs","isLocked","outcome","path"],"mappings":"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"}
|
|
@@ -106,7 +106,7 @@ import { tokenSigningSecret } from "./download.js";
|
|
|
106
106
|
});
|
|
107
107
|
}
|
|
108
108
|
try {
|
|
109
|
-
var _outcome_remaining;
|
|
109
|
+
var _outcome_remaining, _ref;
|
|
110
110
|
const firestore = firebaseAdmin.app().firestore();
|
|
111
111
|
const orderRef = firestore.collection('hosts').doc(hostId).collection('orders').doc(orderId);
|
|
112
112
|
// A GET NEVER MOVES THE ORDER (AGL-2268). The supplier email carries this
|
|
@@ -120,7 +120,11 @@ import { tokenSigningSecret } from "./download.js";
|
|
|
120
120
|
if (req.method === 'GET') {
|
|
121
121
|
res.setHeader('Content-Type', 'text/html');
|
|
122
122
|
const field = (name, value)=>`<input type="hidden" name="${name}" value="${escapeHtml(value)}">`;
|
|
123
|
-
|
|
123
|
+
// The carrier and the number are FIELDS the supplier fills in
|
|
124
|
+
// (AGL-3432), so the emailed link needs no placeholders for them to
|
|
125
|
+
// edit in the URL. A value a link does carry arrives filled in.
|
|
126
|
+
const input = (name, label, value)=>`<p><label>${label}<br>` + `<input type="text" name="${name}" value="${escapeHtml(value)}"></label></p>`;
|
|
127
|
+
return res.status(200).send('<h3>Record this shipment</h3>' + '<p>Enter the carrier and tracking number, then press the button ' + 'to record it against the order.</p>' + '<form method="POST">' + field('hostId', hostId) + field('orderId', orderId) + field('token', token) + input('carrier', 'Carrier', carrier) + input('trackingNumber', 'Tracking number', trackingNumber) + '<button type="submit">Mark as shipped</button>' + '</form>');
|
|
124
128
|
}
|
|
125
129
|
// THE READ, THE TRANSITION CHECK AND THE WRITE ARE ONE ACT (AGL-2268).
|
|
126
130
|
// They used to be a plain `get()`, a check, and a `set(…, {merge:true})`,
|
|
@@ -258,7 +262,8 @@ import { tokenSigningSecret } from "./download.js";
|
|
|
258
262
|
},
|
|
259
263
|
order,
|
|
260
264
|
nextStatus,
|
|
261
|
-
remaining
|
|
265
|
+
remaining,
|
|
266
|
+
supplierId: postingSupplier
|
|
262
267
|
};
|
|
263
268
|
});
|
|
264
269
|
if (outcome.status !== 200) {
|
|
@@ -269,14 +274,19 @@ import { tokenSigningSecret } from "./download.js";
|
|
|
269
274
|
// as done, which is the same misreport the status literal made — the
|
|
270
275
|
// merchant would stop watching an order that is two thirds unshipped.
|
|
271
276
|
const outstanding = Number((_outcome_remaining = outcome.remaining) != null ? _outcome_remaining : 0);
|
|
272
|
-
|
|
277
|
+
const orderLabel = CommerceModel.formatOrderNumber(outcome.order, orderId);
|
|
278
|
+
// Which supplier, which order, where, and the tracking (AGL-3432).
|
|
279
|
+
const supplierId = outcome.supplierId;
|
|
280
|
+
const supplierName = supplierId ? String((_ref = await firestore.collection('hosts').doc(hostId).collection('suppliers').doc(supplierId).get().then((snapshot)=>snapshot.get('name')).catch(()=>'')) != null ? _ref : '').trim() : '';
|
|
281
|
+
void notifyHostManagers(hostId, {
|
|
273
282
|
type: 'content.order',
|
|
274
|
-
title: `Supplier shipped ${
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
283
|
+
title: `Supplier shipped ${orderLabel}${outstanding > 0 ? ` — ${outstanding} line${outstanding === 1 ? '' : 's'} still to ship` : ''}`,
|
|
284
|
+
body: `${supplierName || 'Your supplier'} shipped their part of order ` + `${orderLabel} on {site}.` + (trackingNumber ? ` Tracking: ${[
|
|
285
|
+
carrier,
|
|
286
|
+
trackingNumber
|
|
287
|
+
].filter(Boolean).join(' ')}.` : ' No tracking number was given.') + (outstanding > 0 ? ` ${outstanding} line${outstanding === 1 ? ' is' : 's are'} still to ship.` : ''),
|
|
278
288
|
link: `/${hostId}/products`
|
|
279
|
-
})
|
|
289
|
+
});
|
|
280
290
|
return res.status(200).json(outcome.body);
|
|
281
291
|
} catch (error) {
|
|
282
292
|
console.error(error);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/supplier-update.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, notifyHostManagers } from '@aglyn/tenant-data-admin'\nimport { escapeHtml } from '../utils/escape-html'\nimport { createHmac, timingSafeEqual } from 'crypto'\nimport { tokenSigningSecret } from './download'\nimport { type PluginApiHandler } from '@aglyn/aglyn/server'\n\n/**\n * Constant-time compare for the per-order supplier token (AGL-2268).\n *\n * `!==` on a secret leaks its prefix through response timing, and this token is\n * a bearer credential for a route with no account behind it — the only thing\n * standing between the open internet and marking a merchant's order shipped.\n * `download.ts` has compared its own token this way since it shipped; this is\n * the sibling that did not.\n *\n * Length is compared first and OUTSIDE the constant-time call, because\n * `timingSafeEqual` throws on a length mismatch. That leaks the token's LENGTH\n * and nothing else, which is the same concession `download.ts` makes.\n */\nfunction tokenMatches(stored: unknown, presented: string): boolean {\n const expected = typeof stored === 'string' ? stored : ''\n if (!expected || !presented) return false\n const a = Buffer.from(presented)\n const b = Buffer.from(expected)\n if (a.length !== b.length) return false\n return timingSafeEqual(new Uint8Array(a), new Uint8Array(b))\n}\n\n/**\n * WHICH supplier is posting (AGL-2455), and the observation that makes the\n * issue's \"structural blocker\" not structural after all.\n *\n * The order document holds ONE `supplierToken` field, so the issue's reading\n * was that scoping lines per supplier would strand every other supplier's lines\n * permanently: only the last token written could authenticate, and the others\n * would have no valid credential at all. That reading is right about the stored\n * field and wrong about the token.\n *\n * The token is not a random secret that has to be stored to be known. It is\n * `HMAC(hostId:orderId:supplierId)` under `TOKEN_SIGNING_SECRET`\n * (`billing-webhook.ts`), a pure function of three identifiers the order\n * already carries — `supplierId` is stamped onto every line at purchase time.\n * So the expected token for EVERY supplier on the order can be re-derived here\n * and compared, which both identifies the poster and lets every supplier\n * authenticate. No schema change, no migration, no map.\n *\n * Every candidate is compared even after a match, so the work done is a\n * function of the order's supplier count and not of which supplier posted.\n *\n * Returns `null` when nothing matches; the stored-scalar fallback is the\n * caller's, because only it knows whether that fallback is ambiguous.\n */\nfunction resolvePostingSupplier(\n hostId: string,\n orderId: string,\n order: CommerceModel.HostOrder,\n presented: string,\n): string | null {\n let secret: string\n try {\n secret = tokenSigningSecret()\n } catch {\n // Unset secret: no token could have been MINTED either, so there is nothing\n // to derive. Fall through to the stored scalar rather than throwing a 500\n // at a supplier who cannot do anything about it.\n return null\n }\n let matched: string | null = null\n for (const supplierId of CommerceModel.orderSupplierIds(order)) {\n const expected = createHmac('sha256', secret)\n .update(`${hostId}:${orderId}:${supplierId}`)\n .digest('hex')\n .slice(0, 32)\n if (tokenMatches(expected, presented)) matched = supplierId\n }\n return matched\n}\n\n\n/**\n * Supplier tracking callback (AGL-289): the routed order carried a\n * per-order token; posting (or GET-ing, for email links) tracking with\n * it fulfills the order. Token-gated — suppliers have no Aglyn account.\n */\nexport const supplierUpdateHandler: PluginApiHandler = async (req, res) => {\n const source = req.method === 'POST' ? { ...req.query, ...(typeof req.body === 'string' ? JSON.parse(req.body || '{}') : (req.body ?? {})) } : req.query\n const hostId = String(source.hostId ?? '')\n const orderId = String(source.orderId ?? '')\n const token = String(source.token ?? '')\n const trackingNumber = String(source.trackingNumber ?? '').slice(0, 60)\n const carrier = String(source.carrier ?? '').slice(0, 40)\n if (!hostId || !orderId || !token) {\n return res.status(400).json({ error: 'Missing hostId, orderId, or token' })\n }\n // Firestore reserves `__…__` ids and `.doc()` throws SYNCHRONOUSLY on one,\n // which fell into the catch below and answered 500 to what is a 400\n // (AGL-2455). `fulfill-order.ts` has had this guard since it shipped; this is\n // the sibling route that did not.\n if (/^__.*__$/.test(hostId) || /^__.*__$/.test(orderId)) {\n return res.status(400).json({ error: 'Missing hostId, orderId, or token' })\n }\n\n try {\n const firestore = firebaseAdmin.app().firestore()\n const orderRef = firestore\n .collection('hosts')\n .doc(hostId)\n .collection('orders')\n .doc(orderId)\n // A GET NEVER MOVES THE ORDER (AGL-2268). The supplier email carries this\n // URL, and a GET that fulfils means every link scanner, spam filter,\n // preview generator and browser prefetch on the path between us and the\n // supplier's inbox marks the order shipped — before anything shipped, and\n // with no tracking number on it. The link still works and the email is\n // unchanged: a GET renders a one-button page that POSTs the same\n // parameters, so the supplier's experience is one extra click and the\n // machines that open mail for a living take no action at all.\n if (req.method === 'GET') {\n res.setHeader('Content-Type', 'text/html')\n const field = (name: string, value: string) =>\n `<input type=\"hidden\" name=\"${name}\" value=\"${escapeHtml(value)}\">`\n return res.status(200).send(\n '<h3>Confirm this shipment</h3>' +\n '<p>Press the button to record it against the order.</p>' +\n '<form method=\"POST\">' +\n field('hostId', hostId) +\n field('orderId', orderId) +\n field('token', token) +\n field('carrier', carrier) +\n field('trackingNumber', trackingNumber) +\n '<button type=\"submit\">Mark as shipped</button>' +\n '</form>',\n )\n }\n\n // THE READ, THE TRANSITION CHECK AND THE WRITE ARE ONE ACT (AGL-2268).\n // They used to be a plain `get()`, a check, and a `set(…, {merge:true})`,\n // which is the exact stale-read hole AGL-1808/1818/1819 closed on the\n // console's own fulfil, cancel and refund routes — reopened here, on the\n // one door with no account behind it. A supplier POST racing a refund read\n // `paid`, the refund landed, and the POST then wrote `fulfilled` over\n // `refunded`. `ORDER_TRANSITIONS` says `refunded: []`, and the cost is not\n // cosmetic: `gate.ts`, `download.ts`, `membership-account.ts` and\n // `reviews.ts` all withdraw a shopper's entitlement by matching that\n // literal, so the refunded buyer got their downloads back.\n const outcome = await firebaseAdmin\n .app()\n .firestore()\n .runTransaction(async (transaction) => {\n const orderSnapshot = await transaction.get(orderRef)\n if (!orderSnapshot.exists) {\n return { status: 404, body: { error: 'Unknown order' } as any }\n }\n const order = CommerceModel.liftLegacyOrder(orderSnapshot.data() as any)\n // ONE SUPPLIER SHIPS ONE SUPPLIER'S LINES (AGL-2455).\n //\n // `lineItemIds` used to be every index on the order and the status was\n // written as the literal `'fulfilled'`, so the first supplier to post\n // marked the whole order shipped — lines they had never seen included —\n // and the second supplier's POST met a 409. The buyer was told\n // everything shipped with one carrier and one tracking number.\n const suppliers = CommerceModel.orderSupplierIds(order)\n const postingSupplier = resolvePostingSupplier(\n hostId,\n orderId,\n order,\n token,\n )\n let myLines: number[]\n if (postingSupplier) {\n myLines = CommerceModel.supplierLineItemIds(order, postingSupplier)\n } else if (tokenMatches(orderSnapshot.get('supplierToken'), token)) {\n // The STORED scalar matched but no supplier on the order derives to\n // it — an order whose lines carry no `supplierId`, which is every\n // order routed before that field was stamped.\n //\n // With at most one supplier there is only one door, so claiming every\n // line is the same answer this route has always given and is right.\n // With TWO OR MORE, the scalar cannot say whose token it is, and\n // guessing would either close lines a supplier never shipped (the\n // defect) or strand them (the naive fix). REFUSED LOUDLY instead:\n // the merchant fulfils from the console, where they can see the\n // lines, and the message says so rather than leaving them to work it\n // out from an order that silently went quiet.\n if (suppliers.length > 1) {\n return {\n status: 409,\n body: {\n error:\n 'This order has more than one supplier and the link cannot ' +\n 'say which of them you are. Nothing was changed. Please ask ' +\n 'the merchant to record this shipment from their console.',\n } as any,\n }\n }\n myLines = (order.lineItems ?? []).map((_line, index) => index)\n } else {\n return { status: 403, body: { error: 'Invalid token' } as any }\n }\n if (myLines.length === 0) {\n return {\n status: 409,\n body: { error: 'No lines on this order are yours' } as any,\n }\n }\n const coveredBefore = CommerceModel.coveredLineItemIds(order)\n if (myLines.every((index) => coveredBefore.has(index))) {\n // Every line of theirs is already shipped: a redelivered POST, or the\n // supplier pressing the emailed button twice. Refused rather than\n // appending a second copy of the same fulfillment — the same rule\n // `fulfill-order.ts` applies to a retried click.\n return {\n status: 409,\n body: {\n error:\n suppliers.length > 1\n ? 'Your lines on this order are already marked shipped'\n : `Order is already ${order.status}`,\n } as any,\n }\n }\n const coveredAfter = new Set([...coveredBefore, ...myLines])\n // COMPUTED, not written as a literal. `partially_fulfilled` has been in\n // `ORDER_TRANSITIONS` since orders shipped and nothing ever wrote it.\n const nextStatus = CommerceModel.statusAfterFulfilling(\n order,\n coveredAfter,\n )\n // Re-asked inside the transaction that writes, as AGL-2268 established:\n // a supplier POST racing a refund must not write over `refunded`. Only\n // when the status actually MOVES — a second supplier posting onto an\n // already `partially_fulfilled` order changes nothing about the status\n // and must not be refused by a table that has no self-edge.\n if (\n nextStatus !== order.status &&\n !CommerceModel.canTransitionOrder(order.status, nextStatus)\n ) {\n return {\n status: 409,\n body: { error: `Order is already ${order.status}` } as any,\n }\n }\n const fulfillment: CommerceModel.OrderFulfillment = {\n id: `supplier-${Date.now().toString(36)}`,\n lineItemIds: myLines,\n ...(carrier ? { carrier } : {}),\n ...(trackingNumber ? { trackingNumber } : {}),\n atMs: Date.now(),\n }\n const remaining = (order.lineItems ?? []).filter(\n (_line, index) => !coveredAfter.has(index),\n ).length\n transaction.set(\n orderRef,\n {\n status: nextStatus,\n fulfillments: [...(order.fulfillments ?? []), fulfillment],\n timeline: CommerceModel.appendOrderEvent(\n order,\n nextStatus,\n `Supplier shipped ${myLines.length} of ${\n (order.lineItems ?? []).length\n } line${(order.lineItems ?? []).length === 1 ? '' : 's'}` +\n (trackingNumber\n ? ` — ${carrier || 'tracking'} ${trackingNumber}`\n : '') +\n (remaining > 0\n ? `. ${remaining} line${remaining === 1 ? '' : 's'} still to ship.`\n : ''),\n ),\n },\n { merge: true },\n )\n return {\n status: 200,\n body: { ok: true, lineItemIds: myLines, orderStatus: nextStatus } as any,\n order,\n nextStatus,\n remaining,\n }\n })\n if (outcome.status !== 200) {\n return res.status(outcome.status).json(outcome.body)\n }\n // The merchant is told what is STILL OUTSTANDING (AGL-2455). \"Supplier\n // shipped #1042\" on an order where one of three suppliers has posted reads\n // as done, which is the same misreport the status literal made — the\n // merchant would stop watching an order that is two thirds unshipped.\n const outstanding = Number((outcome as any).remaining ?? 0)\n void notifyHostManagers(hostId, {\n type: 'content.order',\n title: `Supplier shipped ${CommerceModel.formatOrderNumber(\n outcome.order as CommerceModel.HostOrder,\n orderId,\n )}${outstanding > 0 ? ` — ${outstanding} line${outstanding === 1 ? '' : 's'} still to ship` : ''}`,\n ...(trackingNumber ? { body: `${carrier} ${trackingNumber}` } : {}),\n link: `/${hostId}/products`,\n })\n return res.status(200).json(outcome.body)\n } catch (error) {\n console.error(error)\n return res.status(500).json({ error: 'Update failed' })\n }\n}\n"],"names":["CommerceModel","firebaseAdmin","notifyHostManagers","escapeHtml","createHmac","timingSafeEqual","tokenSigningSecret","tokenMatches","stored","presented","expected","a","Buffer","from","b","length","Uint8Array","resolvePostingSupplier","hostId","orderId","order","secret","matched","supplierId","orderSupplierIds","update","digest","slice","supplierUpdateHandler","req","res","source","method","query","body","JSON","parse","String","token","trackingNumber","carrier","status","json","error","test","firestore","app","orderRef","collection","doc","setHeader","field","name","value","send","outcome","runTransaction","transaction","orderSnapshot","get","exists","liftLegacyOrder","data","suppliers","postingSupplier","myLines","supplierLineItemIds","lineItems","map","_line","index","coveredBefore","coveredLineItemIds","every","has","coveredAfter","Set","nextStatus","statusAfterFulfilling","canTransitionOrder","fulfillment","id","Date","now","toString","lineItemIds","atMs","remaining","filter","set","fulfillments","timeline","appendOrderEvent","merge","ok","orderStatus","outstanding","Number","type","title","formatOrderNumber","link","console"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/supplier-update.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, notifyHostManagers } from '@aglyn/tenant-data-admin'\nimport { escapeHtml } from '../utils/escape-html'\nimport { createHmac, timingSafeEqual } from 'crypto'\nimport { tokenSigningSecret } from './download'\nimport { type PluginApiHandler } from '@aglyn/aglyn/server'\n\n/**\n * Constant-time compare for the per-order supplier token (AGL-2268).\n *\n * `!==` on a secret leaks its prefix through response timing, and this token is\n * a bearer credential for a route with no account behind it — the only thing\n * standing between the open internet and marking a merchant's order shipped.\n * `download.ts` has compared its own token this way since it shipped; this is\n * the sibling that did not.\n *\n * Length is compared first and OUTSIDE the constant-time call, because\n * `timingSafeEqual` throws on a length mismatch. That leaks the token's LENGTH\n * and nothing else, which is the same concession `download.ts` makes.\n */\nfunction tokenMatches(stored: unknown, presented: string): boolean {\n const expected = typeof stored === 'string' ? stored : ''\n if (!expected || !presented) return false\n const a = Buffer.from(presented)\n const b = Buffer.from(expected)\n if (a.length !== b.length) return false\n return timingSafeEqual(new Uint8Array(a), new Uint8Array(b))\n}\n\n/**\n * WHICH supplier is posting (AGL-2455), and the observation that makes the\n * issue's \"structural blocker\" not structural after all.\n *\n * The order document holds ONE `supplierToken` field, so the issue's reading\n * was that scoping lines per supplier would strand every other supplier's lines\n * permanently: only the last token written could authenticate, and the others\n * would have no valid credential at all. That reading is right about the stored\n * field and wrong about the token.\n *\n * The token is not a random secret that has to be stored to be known. It is\n * `HMAC(hostId:orderId:supplierId)` under `TOKEN_SIGNING_SECRET`\n * (`billing-webhook.ts`), a pure function of three identifiers the order\n * already carries — `supplierId` is stamped onto every line at purchase time.\n * So the expected token for EVERY supplier on the order can be re-derived here\n * and compared, which both identifies the poster and lets every supplier\n * authenticate. No schema change, no migration, no map.\n *\n * Every candidate is compared even after a match, so the work done is a\n * function of the order's supplier count and not of which supplier posted.\n *\n * Returns `null` when nothing matches; the stored-scalar fallback is the\n * caller's, because only it knows whether that fallback is ambiguous.\n */\nfunction resolvePostingSupplier(\n hostId: string,\n orderId: string,\n order: CommerceModel.HostOrder,\n presented: string,\n): string | null {\n let secret: string\n try {\n secret = tokenSigningSecret()\n } catch {\n // Unset secret: no token could have been MINTED either, so there is nothing\n // to derive. Fall through to the stored scalar rather than throwing a 500\n // at a supplier who cannot do anything about it.\n return null\n }\n let matched: string | null = null\n for (const supplierId of CommerceModel.orderSupplierIds(order)) {\n const expected = createHmac('sha256', secret)\n .update(`${hostId}:${orderId}:${supplierId}`)\n .digest('hex')\n .slice(0, 32)\n if (tokenMatches(expected, presented)) matched = supplierId\n }\n return matched\n}\n\n\n/**\n * Supplier tracking callback (AGL-289): the routed order carried a\n * per-order token; posting (or GET-ing, for email links) tracking with\n * it fulfills the order. Token-gated — suppliers have no Aglyn account.\n */\nexport const supplierUpdateHandler: PluginApiHandler = async (req, res) => {\n const source = req.method === 'POST' ? { ...req.query, ...(typeof req.body === 'string' ? JSON.parse(req.body || '{}') : (req.body ?? {})) } : req.query\n const hostId = String(source.hostId ?? '')\n const orderId = String(source.orderId ?? '')\n const token = String(source.token ?? '')\n const trackingNumber = String(source.trackingNumber ?? '').slice(0, 60)\n const carrier = String(source.carrier ?? '').slice(0, 40)\n if (!hostId || !orderId || !token) {\n return res.status(400).json({ error: 'Missing hostId, orderId, or token' })\n }\n // Firestore reserves `__…__` ids and `.doc()` throws SYNCHRONOUSLY on one,\n // which fell into the catch below and answered 500 to what is a 400\n // (AGL-2455). `fulfill-order.ts` has had this guard since it shipped; this is\n // the sibling route that did not.\n if (/^__.*__$/.test(hostId) || /^__.*__$/.test(orderId)) {\n return res.status(400).json({ error: 'Missing hostId, orderId, or token' })\n }\n\n try {\n const firestore = firebaseAdmin.app().firestore()\n const orderRef = firestore\n .collection('hosts')\n .doc(hostId)\n .collection('orders')\n .doc(orderId)\n // A GET NEVER MOVES THE ORDER (AGL-2268). The supplier email carries this\n // URL, and a GET that fulfils means every link scanner, spam filter,\n // preview generator and browser prefetch on the path between us and the\n // supplier's inbox marks the order shipped — before anything shipped, and\n // with no tracking number on it. The link still works and the email is\n // unchanged: a GET renders a one-button page that POSTs the same\n // parameters, so the supplier's experience is one extra click and the\n // machines that open mail for a living take no action at all.\n if (req.method === 'GET') {\n res.setHeader('Content-Type', 'text/html')\n const field = (name: string, value: string) =>\n `<input type=\"hidden\" name=\"${name}\" value=\"${escapeHtml(value)}\">`\n // The carrier and the number are FIELDS the supplier fills in\n // (AGL-3432), so the emailed link needs no placeholders for them to\n // edit in the URL. A value a link does carry arrives filled in.\n const input = (name: string, label: string, value: string) =>\n `<p><label>${label}<br>` +\n `<input type=\"text\" name=\"${name}\" value=\"${escapeHtml(value)}\"></label></p>`\n return res.status(200).send(\n '<h3>Record this shipment</h3>' +\n '<p>Enter the carrier and tracking number, then press the button ' +\n 'to record it against the order.</p>' +\n '<form method=\"POST\">' +\n field('hostId', hostId) +\n field('orderId', orderId) +\n field('token', token) +\n input('carrier', 'Carrier', carrier) +\n input('trackingNumber', 'Tracking number', trackingNumber) +\n '<button type=\"submit\">Mark as shipped</button>' +\n '</form>',\n )\n }\n\n // THE READ, THE TRANSITION CHECK AND THE WRITE ARE ONE ACT (AGL-2268).\n // They used to be a plain `get()`, a check, and a `set(…, {merge:true})`,\n // which is the exact stale-read hole AGL-1808/1818/1819 closed on the\n // console's own fulfil, cancel and refund routes — reopened here, on the\n // one door with no account behind it. A supplier POST racing a refund read\n // `paid`, the refund landed, and the POST then wrote `fulfilled` over\n // `refunded`. `ORDER_TRANSITIONS` says `refunded: []`, and the cost is not\n // cosmetic: `gate.ts`, `download.ts`, `membership-account.ts` and\n // `reviews.ts` all withdraw a shopper's entitlement by matching that\n // literal, so the refunded buyer got their downloads back.\n const outcome = await firebaseAdmin\n .app()\n .firestore()\n .runTransaction(async (transaction) => {\n const orderSnapshot = await transaction.get(orderRef)\n if (!orderSnapshot.exists) {\n return { status: 404, body: { error: 'Unknown order' } as any }\n }\n const order = CommerceModel.liftLegacyOrder(orderSnapshot.data() as any)\n // ONE SUPPLIER SHIPS ONE SUPPLIER'S LINES (AGL-2455).\n //\n // `lineItemIds` used to be every index on the order and the status was\n // written as the literal `'fulfilled'`, so the first supplier to post\n // marked the whole order shipped — lines they had never seen included —\n // and the second supplier's POST met a 409. The buyer was told\n // everything shipped with one carrier and one tracking number.\n const suppliers = CommerceModel.orderSupplierIds(order)\n const postingSupplier = resolvePostingSupplier(\n hostId,\n orderId,\n order,\n token,\n )\n let myLines: number[]\n if (postingSupplier) {\n myLines = CommerceModel.supplierLineItemIds(order, postingSupplier)\n } else if (tokenMatches(orderSnapshot.get('supplierToken'), token)) {\n // The STORED scalar matched but no supplier on the order derives to\n // it — an order whose lines carry no `supplierId`, which is every\n // order routed before that field was stamped.\n //\n // With at most one supplier there is only one door, so claiming every\n // line is the same answer this route has always given and is right.\n // With TWO OR MORE, the scalar cannot say whose token it is, and\n // guessing would either close lines a supplier never shipped (the\n // defect) or strand them (the naive fix). REFUSED LOUDLY instead:\n // the merchant fulfils from the console, where they can see the\n // lines, and the message says so rather than leaving them to work it\n // out from an order that silently went quiet.\n if (suppliers.length > 1) {\n return {\n status: 409,\n body: {\n error:\n 'This order has more than one supplier and the link cannot ' +\n 'say which of them you are. Nothing was changed. Please ask ' +\n 'the merchant to record this shipment from their console.',\n } as any,\n }\n }\n myLines = (order.lineItems ?? []).map((_line, index) => index)\n } else {\n return { status: 403, body: { error: 'Invalid token' } as any }\n }\n if (myLines.length === 0) {\n return {\n status: 409,\n body: { error: 'No lines on this order are yours' } as any,\n }\n }\n const coveredBefore = CommerceModel.coveredLineItemIds(order)\n if (myLines.every((index) => coveredBefore.has(index))) {\n // Every line of theirs is already shipped: a redelivered POST, or the\n // supplier pressing the emailed button twice. Refused rather than\n // appending a second copy of the same fulfillment — the same rule\n // `fulfill-order.ts` applies to a retried click.\n return {\n status: 409,\n body: {\n error:\n suppliers.length > 1\n ? 'Your lines on this order are already marked shipped'\n : `Order is already ${order.status}`,\n } as any,\n }\n }\n const coveredAfter = new Set([...coveredBefore, ...myLines])\n // COMPUTED, not written as a literal. `partially_fulfilled` has been in\n // `ORDER_TRANSITIONS` since orders shipped and nothing ever wrote it.\n const nextStatus = CommerceModel.statusAfterFulfilling(\n order,\n coveredAfter,\n )\n // Re-asked inside the transaction that writes, as AGL-2268 established:\n // a supplier POST racing a refund must not write over `refunded`. Only\n // when the status actually MOVES — a second supplier posting onto an\n // already `partially_fulfilled` order changes nothing about the status\n // and must not be refused by a table that has no self-edge.\n if (\n nextStatus !== order.status &&\n !CommerceModel.canTransitionOrder(order.status, nextStatus)\n ) {\n return {\n status: 409,\n body: { error: `Order is already ${order.status}` } as any,\n }\n }\n const fulfillment: CommerceModel.OrderFulfillment = {\n id: `supplier-${Date.now().toString(36)}`,\n lineItemIds: myLines,\n ...(carrier ? { carrier } : {}),\n ...(trackingNumber ? { trackingNumber } : {}),\n atMs: Date.now(),\n }\n const remaining = (order.lineItems ?? []).filter(\n (_line, index) => !coveredAfter.has(index),\n ).length\n transaction.set(\n orderRef,\n {\n status: nextStatus,\n fulfillments: [...(order.fulfillments ?? []), fulfillment],\n timeline: CommerceModel.appendOrderEvent(\n order,\n nextStatus,\n `Supplier shipped ${myLines.length} of ${\n (order.lineItems ?? []).length\n } line${(order.lineItems ?? []).length === 1 ? '' : 's'}` +\n (trackingNumber\n ? ` — ${carrier || 'tracking'} ${trackingNumber}`\n : '') +\n (remaining > 0\n ? `. ${remaining} line${remaining === 1 ? '' : 's'} still to ship.`\n : ''),\n ),\n },\n { merge: true },\n )\n return {\n status: 200,\n body: { ok: true, lineItemIds: myLines, orderStatus: nextStatus } as any,\n order,\n nextStatus,\n remaining,\n supplierId: postingSupplier,\n }\n })\n if (outcome.status !== 200) {\n return res.status(outcome.status).json(outcome.body)\n }\n // The merchant is told what is STILL OUTSTANDING (AGL-2455). \"Supplier\n // shipped #1042\" on an order where one of three suppliers has posted reads\n // as done, which is the same misreport the status literal made — the\n // merchant would stop watching an order that is two thirds unshipped.\n const outstanding = Number((outcome as any).remaining ?? 0)\n const orderLabel = CommerceModel.formatOrderNumber(\n outcome.order as CommerceModel.HostOrder,\n orderId,\n )\n // Which supplier, which order, where, and the tracking (AGL-3432).\n const supplierId = (outcome as any).supplierId as string | null\n const supplierName = supplierId\n ? String(\n (await firestore\n .collection('hosts')\n .doc(hostId)\n .collection('suppliers')\n .doc(supplierId)\n .get()\n .then((snapshot) => snapshot.get('name'))\n .catch(() => '')) ?? '',\n ).trim()\n : ''\n void notifyHostManagers(hostId, {\n type: 'content.order',\n title: `Supplier shipped ${orderLabel}${outstanding > 0 ? ` — ${outstanding} line${outstanding === 1 ? '' : 's'} still to ship` : ''}`,\n body:\n `${supplierName || 'Your supplier'} shipped their part of order ` +\n `${orderLabel} on {site}.` +\n (trackingNumber\n ? ` Tracking: ${[carrier, trackingNumber].filter(Boolean).join(' ')}.`\n : ' No tracking number was given.') +\n (outstanding > 0\n ? ` ${outstanding} line${outstanding === 1 ? ' is' : 's are'} still to ship.`\n : ''),\n link: `/${hostId}/products`,\n })\n return res.status(200).json(outcome.body)\n } catch (error) {\n console.error(error)\n return res.status(500).json({ error: 'Update failed' })\n }\n}\n"],"names":["CommerceModel","firebaseAdmin","notifyHostManagers","escapeHtml","createHmac","timingSafeEqual","tokenSigningSecret","tokenMatches","stored","presented","expected","a","Buffer","from","b","length","Uint8Array","resolvePostingSupplier","hostId","orderId","order","secret","matched","supplierId","orderSupplierIds","update","digest","slice","supplierUpdateHandler","req","res","source","method","query","body","JSON","parse","String","token","trackingNumber","carrier","status","json","error","test","firestore","app","orderRef","collection","doc","setHeader","field","name","value","input","label","send","outcome","runTransaction","transaction","orderSnapshot","get","exists","liftLegacyOrder","data","suppliers","postingSupplier","myLines","supplierLineItemIds","lineItems","map","_line","index","coveredBefore","coveredLineItemIds","every","has","coveredAfter","Set","nextStatus","statusAfterFulfilling","canTransitionOrder","fulfillment","id","Date","now","toString","lineItemIds","atMs","remaining","filter","set","fulfillments","timeline","appendOrderEvent","merge","ok","orderStatus","outstanding","Number","orderLabel","formatOrderNumber","supplierName","then","snapshot","catch","trim","type","title","Boolean","join","link","console"],"mappings":"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"}
|