@aglyn/plugins-commerce 1.0.0-beta.218 → 1.0.0-beta.220

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.
Files changed (148) hide show
  1. package/README.md +6 -0
  2. package/package.json +14 -14
  3. package/src/index.d.ts +1 -0
  4. package/src/index.js +1 -19
  5. package/src/index.js.map +1 -1
  6. package/src/lib/components/cart.js +2 -1
  7. package/src/lib/components/cart.js.map +1 -1
  8. package/src/lib/components/console/catalog-organization-card.component.js +665 -459
  9. package/src/lib/components/console/catalog-organization-card.component.js.map +1 -1
  10. package/src/lib/components/console/host-orders-card.component.js +2 -1
  11. package/src/lib/components/console/host-orders-card.component.js.map +1 -1
  12. package/src/lib/components/console/member-posts-card.component.js +1 -1
  13. package/src/lib/components/console/member-posts-card.component.js.map +1 -1
  14. package/src/lib/components/console/order-detail-dialog.component.js +8 -7
  15. package/src/lib/components/console/order-detail-dialog.component.js.map +1 -1
  16. package/src/lib/components/console/payments-settings-card.component.js +2 -2
  17. package/src/lib/components/console/payments-settings-card.component.js.map +1 -1
  18. package/src/lib/components/console/product-editor-dialog.component.js +1221 -889
  19. package/src/lib/components/console/product-editor-dialog.component.js.map +1 -1
  20. package/src/lib/components/console/product-zones.d.ts +184 -0
  21. package/src/lib/components/console/product-zones.js +54 -0
  22. package/src/lib/components/console/product-zones.js.map +1 -0
  23. package/src/lib/components/console/products-hub-card.component.js +3 -2
  24. package/src/lib/components/console/products-hub-card.component.js.map +1 -1
  25. package/src/lib/components/console/products-hub-zone.component.d.ts +1 -1
  26. package/src/lib/components/console/products-hub-zone.component.js +2 -1
  27. package/src/lib/components/console/products-hub-zone.component.js.map +1 -1
  28. package/src/lib/components/console/site-member-purchases.component.d.ts +24 -0
  29. package/src/lib/components/console/site-member-purchases.component.js +339 -0
  30. package/src/lib/components/console/site-member-purchases.component.js.map +1 -0
  31. package/src/lib/components/console/store-settings-card.component.js +2 -2
  32. package/src/lib/components/console/store-settings-card.component.js.map +1 -1
  33. package/src/lib/components/console/storefront-tax-summary-card.component.js +1 -1
  34. package/src/lib/components/console/storefront-tax-summary-card.component.js.map +1 -1
  35. package/src/lib/components/member-signup.js +2 -2
  36. package/src/lib/components/member-signup.js.map +1 -1
  37. package/src/lib/components/newsletter-signup.js +2 -2
  38. package/src/lib/components/newsletter-signup.js.map +1 -1
  39. package/src/lib/components/product-detail.js +2 -1
  40. package/src/lib/components/product-detail.js.map +1 -1
  41. package/src/lib/components/product-grid.js +1 -1
  42. package/src/lib/components/product-grid.js.map +1 -1
  43. package/src/lib/constants/operator-alerts.js +5 -3
  44. package/src/lib/constants/operator-alerts.js.map +1 -1
  45. package/src/lib/constants/storefront-payments.d.ts +33 -0
  46. package/src/lib/constants/storefront-payments.js +35 -0
  47. package/src/lib/constants/storefront-payments.js.map +1 -0
  48. package/src/lib/declarations.server.d.ts +3 -1
  49. package/src/lib/declarations.server.js +19 -1
  50. package/src/lib/declarations.server.js.map +1 -1
  51. package/src/lib/model/commerce-io.js +1 -1
  52. package/src/lib/model/commerce-io.js.map +1 -1
  53. package/src/lib/model/commerce-record-lists.d.ts +31 -0
  54. package/src/lib/model/commerce-record-lists.js +45 -0
  55. package/src/lib/model/commerce-record-lists.js.map +1 -0
  56. package/src/lib/model/commerce-record-routes.d.ts +45 -0
  57. package/src/lib/model/commerce-record-routes.js +72 -0
  58. package/src/lib/model/commerce-record-routes.js.map +1 -0
  59. package/src/lib/model/commerce-search-sources.d.ts +21 -0
  60. package/src/lib/model/commerce-search-sources.js +42 -0
  61. package/src/lib/model/commerce-search-sources.js.map +1 -0
  62. package/src/lib/model/commerce-topics.d.ts +20 -0
  63. package/src/lib/model/commerce-topics.js +33 -0
  64. package/src/lib/model/commerce-topics.js.map +1 -0
  65. package/src/lib/model/index.d.ts +1 -0
  66. package/src/lib/model/index.js +1 -0
  67. package/src/lib/model/index.js.map +1 -1
  68. package/src/lib/model/product-record.d.ts +32 -0
  69. package/src/lib/model/product-record.js +82 -0
  70. package/src/lib/model/product-record.js.map +1 -0
  71. package/src/lib/model/site-member-purchases.d.ts +48 -0
  72. package/src/lib/model/site-member-purchases.js +50 -0
  73. package/src/lib/model/site-member-purchases.js.map +1 -0
  74. package/src/lib/model/storefront-tax-summary.d.ts +121 -0
  75. package/src/lib/model/storefront-tax-summary.js +148 -0
  76. package/src/lib/model/storefront-tax-summary.js.map +1 -0
  77. package/src/lib/plan-entitlements.d.ts +35 -0
  78. package/src/lib/plan-entitlements.js +54 -0
  79. package/src/lib/plan-entitlements.js.map +1 -0
  80. package/src/lib/plugin.js +21 -0
  81. package/src/lib/plugin.js.map +1 -1
  82. package/src/lib/server/billing-webhook.js +387 -248
  83. package/src/lib/server/billing-webhook.js.map +1 -1
  84. package/src/lib/server/cart-checkout.js +3 -3
  85. package/src/lib/server/cart-checkout.js.map +1 -1
  86. package/src/lib/server/checkout.js +2 -2
  87. package/src/lib/server/checkout.js.map +1 -1
  88. package/src/lib/server/connect.js +5 -5
  89. package/src/lib/server/connect.js.map +1 -1
  90. package/src/lib/server/draft-order.js +2 -2
  91. package/src/lib/server/draft-order.js.map +1 -1
  92. package/src/lib/server/gift-cards.js +7 -3
  93. package/src/lib/server/gift-cards.js.map +1 -1
  94. package/src/lib/server/low-stock.js +3 -1
  95. package/src/lib/server/low-stock.js.map +1 -1
  96. package/src/lib/server/member-post.js +2 -2
  97. package/src/lib/server/member-post.js.map +1 -1
  98. package/src/lib/server/membership-admin-password.js +4 -1
  99. package/src/lib/server/membership-admin-password.js.map +1 -1
  100. package/src/lib/server/membership-content.js +1 -1
  101. package/src/lib/server/membership-content.js.map +1 -1
  102. package/src/lib/server/membership-register.js +13 -2
  103. package/src/lib/server/membership-register.js.map +1 -1
  104. package/src/lib/server/newsletter.js +32 -19
  105. package/src/lib/server/newsletter.js.map +1 -1
  106. package/src/lib/server/pos-order.js +3 -3
  107. package/src/lib/server/pos-order.js.map +1 -1
  108. package/src/lib/server/process-abandoned.js +4 -2
  109. package/src/lib/server/process-abandoned.js.map +1 -1
  110. package/src/lib/server/process-restock.js +7 -5
  111. package/src/lib/server/process-restock.js.map +1 -1
  112. package/src/lib/server/product-record-index.d.ts +19 -0
  113. package/src/lib/server/product-record-index.js +83 -0
  114. package/src/lib/server/product-record-index.js.map +1 -0
  115. package/src/lib/server/reconcile-stock.js +1 -1
  116. package/src/lib/server/reconcile-stock.js.map +1 -1
  117. package/src/lib/server/reserve-stock.js +6 -1
  118. package/src/lib/server/reserve-stock.js.map +1 -1
  119. package/src/lib/server/reserve.js +2 -2
  120. package/src/lib/server/reserve.js.map +1 -1
  121. package/src/lib/server/revenue-source.d.ts +130 -0
  122. package/src/lib/server/revenue-source.js +286 -0
  123. package/src/lib/server/revenue-source.js.map +1 -0
  124. package/src/lib/server/supplier-outbox.js +16 -1
  125. package/src/lib/server/supplier-outbox.js.map +1 -1
  126. package/src/lib/server/supplier-update.js +19 -9
  127. package/src/lib/server/supplier-update.js.map +1 -1
  128. package/src/lib/server/tax-profile.d.ts +8 -5
  129. package/src/lib/server/tax-profile.js +22 -5
  130. package/src/lib/server/tax-profile.js.map +1 -1
  131. package/src/lib/server/tax-return-source.d.ts +125 -0
  132. package/src/lib/server/tax-return-source.js +524 -0
  133. package/src/lib/server/tax-return-source.js.map +1 -0
  134. package/src/lib/server/tax-summary.d.ts +92 -0
  135. package/src/lib/server/tax-summary.js +258 -0
  136. package/src/lib/server/tax-summary.js.map +1 -0
  137. package/src/lib/server.d.ts +1 -0
  138. package/src/lib/server.js +23 -0
  139. package/src/lib/server.js.map +1 -1
  140. package/src/lib/starter-templates.d.ts +19 -0
  141. package/src/lib/starter-templates.js +157 -0
  142. package/src/lib/starter-templates.js.map +1 -0
  143. package/src/lib/tenant-emails.d.ts +33 -0
  144. package/src/lib/tenant-emails.js +551 -0
  145. package/src/lib/tenant-emails.js.map +1 -0
  146. package/src/lib/testing/stand-in-crm-record-routes.d.ts +25 -0
  147. package/src/lib/testing/stand-in-crm-record-routes.js +46 -0
  148. package/src/lib/testing/stand-in-crm-record-routes.js.map +1 -0
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/process-abandoned.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 {\n firebaseAdmin,\n getOrgForHost,\n hostSendingIdentity,\n meterHostEmail,\n} from '@aglyn/tenant-data-admin'\nimport {\n isDeferrableSendResult,\n isEmailConfigured,\n sendEmail,\n} from '@aglyn/shared-util-email'\nimport {\n type PluginApiHandler,\n type PluginJobHostGate,\n} from '@aglyn/aglyn/server'\n// The leaf, not the barrel: the console's `x-cron-secret` door asks the\n// site's lockdown directly (AGL-3356) — core's registry has no resolver in\n// the console process — and a spec that substitutes the barrel must still\n// reach the real verdict.\nimport { siteLockdownJobGate } from '@aglyn/tenant-data-admin/server/tenant-write-lockdown'\nimport {\n loadHostEmailWithTokens,\n renderLoadedHostEmailWithTokens,\n type LoadedHostEmailWithTokens,\n} from '@aglyn/tenant-data-admin/server/host-email-tokens'\n\nimport {\n CHECKOUT_GIVE_UP_AFTER_MS as GIVE_UP_AFTER_MS,\n CHECKOUT_REMIND_AFTER_MS as REMIND_AFTER_MS,\n type CheckoutRecoveryState,\n} from '../model/checkout-recovery'\n\n/** One pass over the open checkouts. Bounded (200) and idempotent. */\nexport interface AbandonedScanResult {\n scanned: number\n sent: number\n /** Rows left untouched because their site is locked (AGL-2495). */\n skippedLocked: number\n}\n\n/**\n * The abandoned-checkout pass itself, separated from its HTTP door (AGL-2227).\n *\n * It was only ever reachable through `processAbandonedHandler`, which is\n * `x-cron-secret`-gated — and **nothing has ever POSTed to it**. It is not in\n * `.github/workflows/scheduled-crons.yml`, there is no `crons` key in\n * `vercel.json`, and it was not a `registerPluginJob`. `server.ts` described\n * these as \"the scheduler-driven jobs\" in a comment, which is how a dead\n * feature stays invisible: the code asserted the wiring rather than having it.\n *\n * So `abandonedCart` — a Pro-tier entitlement, sold on /pricing, with a\n * console row added by AGL-2081 telling the merchant their template needs it —\n * had never sent one email. Exporting the scan lets `server.ts` register it on\n * the platform job beat (the every-minute `pluginJobsBeat` that AGL-2176\n * proved out) while the HTTP door stays for manual/ops invocation and for the\n * console's \"send now\" control.\n */\nexport async function scanAbandonedCheckouts(\n /**\n * The lockdown gate, injected by whoever drives the pass (AGL-2495). NOT\n * optional: a default would make \"forgot to thread it\" compile, which is\n * the whole failure this parameter exists to make impossible.\n */\n gate: PluginJobHostGate,\n): Promise<AbandonedScanResult> {\n const firestore = firebaseAdmin.app().firestore()\n const now = Date.now()\n // Collection-group over every host's checkouts.\n const openCheckouts = await firestore\n .collectionGroup('checkouts')\n .where('status', '==', 'open')\n .limit(200)\n .get()\n let sent = 0\n const entitledHosts = new Map<string, boolean>()\n // White-label brand per host (White-Label Phase 3): resolved once per host\n // alongside the entitlement, from the same org doc, through the one shared\n // resolver — so the recovery email's sender reads as the store's brand.\n const brandingByHost = new Map<string, Aglyn.ResolvedBrandingProfile>()\n /*\n * One identity resolution per SITE, not per message.\n *\n * The same reason the branding map above exists: this sweep runs across\n * every site, and an uncached resolution here would be two Firestore reads\n * per recipient rather than per host. Scoped to the run, so a domain\n * un-verified by the re-check sweep is picked up on the next one.\n */\n const identityByHost = new Map<\n string,\n Awaited<ReturnType<typeof hostSendingIdentity>>\n >()\n /** Each site's public origin, for the unsubscribe link on the reminder. */\n const siteBaseByHost = new Map<string, string>()\n /**\n * Each site's consent group, off the same org read as its entitlement, so\n * the gate honors an unsubscribe from any site the org declared one sender\n * with this one, and the org's word on whether its sites wait for each\n * other's confirmation click.\n */\n const consentGroupByHost = new Map<string, Aglyn.ConsentGroup>()\n // Resolve each host's designed template once per run (AGL-770).\n const templateCache = new Map<string, LoadedHostEmailWithTokens | null>()\n let skippedLocked = 0\n for (const docSnapshot of openCheckouts.docs) {\n const data = docSnapshot.data() as any\n // `hosts/{hostId}/checkouts/{id}` — the grandparent is the host.\n const hostId = docSnapshot.ref.parent.parent?.id\n if (!hostId) continue\n // LOCKDOWN (AGL-2495), and it is the FIRST thing in the loop body rather\n // than sitting next to the email below, because the `status: 'expired'`\n // stamp further down is itself a write for this host. Reordered for that\n // reason: the cheap filters used to run first, and one of them wrote.\n //\n // SKIPPED, NOT DROPPED: `continue` leaves the checkout `open` and\n // unstamped, so the reminder is sent — or the row expired — on the first\n // beat after the lift. Nothing here marks a checkout done, so declining\n // to act IS leaving it for later.\n if (await gate.isLocked(hostId)) {\n skippedLocked += 1\n continue\n }\n const createdAtMs = Number(data.createdAtMs ?? 0)\n // Expiry comes BEFORE the email and reminded skips. This pass reads 200\n // open checkouts, and a checkout that never had an address, or was\n // already reminded, used to be skipped while still `open` — forever — so\n // enough of them would fill every pass and starve the ones still owed a\n // reminder. Past the give-up age every open checkout closes, whatever\n // else it is.\n if (now - createdAtMs > GIVE_UP_AFTER_MS) {\n await docSnapshot.ref\n .set({ status: 'expired' }, { merge: true })\n .catch(() => undefined)\n continue\n }\n if (!data.email || data.remindedAtMs) continue\n if (now - createdAtMs < REMIND_AFTER_MS) continue\n if (!entitledHosts.has(hostId)) {\n const org = await getOrgForHost(hostId).catch(() => null)\n entitledHosts.set(\n hostId,\n Aglyn.checkEntitlement(org?.org as any, 'abandonedCart'),\n )\n brandingByHost.set(hostId, Aglyn.resolveBrandingProfile(org?.org as any))\n consentGroupByHost.set(\n hostId,\n Aglyn.consentGroupForHost(\n (org?.org as Record<string, unknown> | undefined) ?? null,\n hostId,\n ),\n )\n // The site's own origin, for the unsubscribe link. Resolved once per\n // host for the same reason the branding beside it is: this sweep is a\n // `collectionGroup` over every site's checkouts, and a read per\n // reminder would be a read per shopper.\n const hostSnapshot = await firestore\n .collection('hosts')\n .doc(hostId)\n .get()\n .catch(() => null)\n siteBaseByHost.set(\n hostId,\n Aglyn.hostPublicOrigin({\n cname: hostSnapshot?.get('cname'),\n subdomain: hostSnapshot?.get('subdomain'),\n }) ?? '',\n )\n }\n if (!entitledHosts.get(hostId)) continue\n let loaded = templateCache.get(hostId)\n if (loaded === undefined) {\n loaded = await // The template, or the built-in copy in the site's header and\n // footer, once per site for the whole batch (AGL-3287, AGL-3370).\n loadHostEmailWithTokens(firestore, hostId, 'abandoned-cart')\n templateCache.set(hostId, loaded)\n }\n const designed = loaded\n ? renderLoadedHostEmailWithTokens(\n loaded,\n { 'cart.url': String(data.resumeUrl ?? '') },\n )\n : null\n /*\n * MARKETING, and `'bulk'` priority, which this sweep is entitled to\n * precisely because it is resumable: a checkout left unstamped below is\n * re-scanned on the next beat, so a refusal means \"not this hour\" rather\n * than a reminder nobody ever gets.\n *\n * The gate adds the unsubscribe header pair and a visible link, checks\n * both suppression lists, and counts this against how much mail the\n * shopper has had from this site today.\n */\n const consentGroup =\n consentGroupByHost.get(hostId) ?? Aglyn.soloConsentGroup(hostId)\n const result = await sendEmail({\n to: String(data.email),\n subject: designed?.subject ?? 'You left something in your cart',\n text:\n designed?.text ||\n 'Your cart is still waiting — pick up where you left off:\\n\\n' +\n `${data.resumeUrl ?? ''}\\n\\n` +\n 'Your items are held but not reserved, so they may sell out.',\n ...(designed?.html ? { html: designed.html } : {}),\n fromName: brandingByHost.get(hostId)?.fromName,\n sendingIdentity: await hostSendingIdentity(hostId, identityByHost),\n audience: 'tenant',\n context: 'abandoned cart',\n priority: 'bulk',\n /*\n * A cart reminder is promotional mail — the \"Promotions and offers\"\n * stream, which is also where a campaign lands by default. Declared so\n * a shopper who left that stream stops receiving these; the gate asks\n * the topic only for callers that name one.\n */\n marketing: {\n hostId,\n siteBase: siteBaseByHost.get(hostId) ?? '',\n topicId: Aglyn.EMAIL_TOPIC_MARKETING,\n consentHostIds: consentGroup.hostIds,\n consentAwaitsConfirmation: consentGroup.awaitsConfirmation,\n },\n })\n /*\n * SKIPPED, NOT DROPPED — the lockdown rule above, applied to the two\n * refusals a later beat can pass.\n *\n * `remindedAtMs` is what retires a checkout from this sweep. Stamping it\n * after the hourly ceiling or the frequency cap refused would turn a\n * deferral into a reminder nobody ever gets; NOT stamping it after a\n * suppression or a rejection would re-read the same doomed row on every\n * beat until it crowded out the checkouts that could still be reminded.\n * `isDeferrableSendResult` is the one place that distinction is made.\n */\n if (isDeferrableSendResult(result)) continue\n // Cost meter (AGL-1438). One reminder per abandoned checkout, triggered\n // by that shopper's own action rather than composed as a broadcast, so\n // it counts toward cost without entering the campaign cap. On the\n // DELIVERED message only: a suppressed recipient produced no message, so\n // there is no cost to record.\n if (result.sent) await meterHostEmail(hostId)\n // `recoveryState` beside it, so the console's recovery queue counts this\n // checkout as reminded by query rather than by reading it (AGL-3321).\n const reminded: CheckoutRecoveryState = 'reminded'\n await docSnapshot.ref\n .set({ remindedAtMs: now, recoveryState: reminded }, { merge: true })\n .catch(() => undefined)\n if (result.sent) sent += 1\n }\n return { scanned: openCheckouts.size, sent, skippedLocked }\n}\n\n/**\n * HTTP door for the pass above (AGL-323). Kept `x-cron-secret`-gated so an\n * external scheduler or an operator can still drive it; the job registration\n * in `server.ts` is what makes it actually run.\n */\nexport const processAbandonedHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const cronSecret = process.env.CRON_SECRET\n if (!cronSecret) {\n return res.status(501).json({ error: 'Not configured (CRON_SECRET).' })\n }\n if (req.headers['x-cron-secret'] !== cronSecret) {\n return res.status(401).json({ error: 'Unauthorized' })\n }\n if (!isEmailConfigured()) {\n return res.status(501).json({ error: 'Email is not configured.' })\n }\n try {\n // The manual door asks the same question the beat does (AGL-2495): a\n // forced pass is still a pass, and `x-cron-secret` is not an argument\n // for mailing a suspended site's shoppers.\n return res.status(200).json(await scanAbandonedCheckouts(siteLockdownJobGate()))\n } catch (error) {\n console.error(error)\n return res.status(500).json({ error: 'Processing failed' })\n }\n}\n"],"names":["Aglyn","firebaseAdmin","getOrgForHost","hostSendingIdentity","meterHostEmail","isDeferrableSendResult","isEmailConfigured","sendEmail","siteLockdownJobGate","loadHostEmailWithTokens","renderLoadedHostEmailWithTokens","CHECKOUT_GIVE_UP_AFTER_MS","GIVE_UP_AFTER_MS","CHECKOUT_REMIND_AFTER_MS","REMIND_AFTER_MS","scanAbandonedCheckouts","gate","firestore","app","now","Date","openCheckouts","collectionGroup","where","limit","get","sent","entitledHosts","Map","brandingByHost","identityByHost","siteBaseByHost","consentGroupByHost","templateCache","skippedLocked","docSnapshot","docs","data","hostId","ref","parent","id","isLocked","createdAtMs","Number","set","status","merge","catch","undefined","email","remindedAtMs","has","org","checkEntitlement","resolveBrandingProfile","consentGroupForHost","hostSnapshot","collection","doc","hostPublicOrigin","cname","subdomain","loaded","designed","String","resumeUrl","consentGroup","soloConsentGroup","result","to","subject","text","html","fromName","sendingIdentity","audience","context","priority","marketing","siteBase","topicId","EMAIL_TOPIC_MARKETING","consentHostIds","hostIds","consentAwaitsConfirmation","awaitsConfirmation","reminded","recoveryState","scanned","size","processAbandonedHandler","req","res","method","json","error","cronSecret","process","env","CRON_SECRET","headers","console"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/process-abandoned.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 {\n firebaseAdmin,\n getOrgForHost,\n hostSendingIdentity,\n meterHostEmail,\n} from '@aglyn/tenant-data-admin'\nimport {\n isDeferrableSendResult,\n isEmailConfigured,\n sendEmail,\n} from '@aglyn/shared-util-email'\nimport {\n type PluginApiHandler,\n type PluginJobHostGate,\n} from '@aglyn/aglyn/server'\n// The leaf, not the barrel: the console's `x-cron-secret` door asks the\n// site's lockdown directly (AGL-3356) — core's registry has no resolver in\n// the console process — and a spec that substitutes the barrel must still\n// reach the real verdict.\nimport { siteLockdownJobGate } from '@aglyn/tenant-data-admin/server/tenant-write-lockdown'\nimport {\n loadHostEmailWithTokens,\n renderLoadedHostEmailWithTokens,\n type LoadedHostEmailWithTokens,\n} from '@aglyn/tenant-data-admin/server/host-email-tokens'\n\nimport {\n CHECKOUT_GIVE_UP_AFTER_MS as GIVE_UP_AFTER_MS,\n CHECKOUT_REMIND_AFTER_MS as REMIND_AFTER_MS,\n type CheckoutRecoveryState,\n} from '../model/checkout-recovery'\n\n/** One pass over the open checkouts. Bounded (200) and idempotent. */\nexport interface AbandonedScanResult {\n scanned: number\n sent: number\n /** Rows left untouched because their site is locked (AGL-2495). */\n skippedLocked: number\n}\n\n/**\n * The abandoned-checkout pass itself, separated from its HTTP door (AGL-2227).\n *\n * It was only ever reachable through `processAbandonedHandler`, which is\n * `x-cron-secret`-gated — and **nothing has ever POSTed to it**. It is not in\n * `.github/workflows/scheduled-crons.yml`, there is no `crons` key in\n * `vercel.json`, and it was not a `registerPluginJob`. `server.ts` described\n * these as \"the scheduler-driven jobs\" in a comment, which is how a dead\n * feature stays invisible: the code asserted the wiring rather than having it.\n *\n * So `abandonedCart` — a Pro-tier entitlement, sold on /pricing, with a\n * console row added by AGL-2081 telling the merchant their template needs it —\n * had never sent one email. Exporting the scan lets `server.ts` register it on\n * the platform job beat (the every-minute `pluginJobsBeat` that AGL-2176\n * proved out) while the HTTP door stays for manual/ops invocation and for the\n * console's \"send now\" control.\n */\nexport async function scanAbandonedCheckouts(\n /**\n * The lockdown gate, injected by whoever drives the pass (AGL-2495). NOT\n * optional: a default would make \"forgot to thread it\" compile, which is\n * the whole failure this parameter exists to make impossible.\n */\n gate: PluginJobHostGate,\n): Promise<AbandonedScanResult> {\n const firestore = firebaseAdmin.app().firestore()\n const now = Date.now()\n // Collection-group over every host's checkouts.\n const openCheckouts = await firestore\n .collectionGroup('checkouts')\n .where('status', '==', 'open')\n .limit(200)\n .get()\n let sent = 0\n const entitledHosts = new Map<string, boolean>()\n // White-label brand per host (White-Label Phase 3): resolved once per host\n // alongside the entitlement, from the same org doc, through the one shared\n // resolver — so the recovery email's sender reads as the store's brand.\n const brandingByHost = new Map<string, Aglyn.ResolvedBrandingProfile>()\n /*\n * One identity resolution per SITE, not per message.\n *\n * The same reason the branding map above exists: this sweep runs across\n * every site, and an uncached resolution here would be two Firestore reads\n * per recipient rather than per host. Scoped to the run, so a domain\n * un-verified by the re-check sweep is picked up on the next one.\n */\n const identityByHost = new Map<\n string,\n Awaited<ReturnType<typeof hostSendingIdentity>>\n >()\n /** Each site's public origin, for the unsubscribe link on the reminder. */\n const siteBaseByHost = new Map<string, string>()\n /**\n * Each site's consent group, off the same org read as its entitlement, so\n * the gate honors an unsubscribe from any site the org declared one sender\n * with this one, and the org's word on whether its sites wait for each\n * other's confirmation click.\n */\n const consentGroupByHost = new Map<string, Aglyn.ConsentGroup>()\n // Resolve each host's designed template once per run (AGL-770).\n const templateCache = new Map<string, LoadedHostEmailWithTokens | null>()\n let skippedLocked = 0\n for (const docSnapshot of openCheckouts.docs) {\n const data = docSnapshot.data() as any\n // `hosts/{hostId}/checkouts/{id}` — the grandparent is the host.\n const hostId = docSnapshot.ref.parent.parent?.id\n if (!hostId) continue\n // LOCKDOWN (AGL-2495), and it is the FIRST thing in the loop body rather\n // than sitting next to the email below, because the `status: 'expired'`\n // stamp further down is itself a write for this host. Reordered for that\n // reason: the cheap filters used to run first, and one of them wrote.\n //\n // SKIPPED, NOT DROPPED: `continue` leaves the checkout `open` and\n // unstamped, so the reminder is sent — or the row expired — on the first\n // beat after the lift. Nothing here marks a checkout done, so declining\n // to act IS leaving it for later.\n if (await gate.isLocked(hostId)) {\n skippedLocked += 1\n continue\n }\n const createdAtMs = Number(data.createdAtMs ?? 0)\n // Expiry comes BEFORE the email and reminded skips. This pass reads 200\n // open checkouts, and a checkout that never had an address, or was\n // already reminded, used to be skipped while still `open` — forever — so\n // enough of them would fill every pass and starve the ones still owed a\n // reminder. Past the give-up age every open checkout closes, whatever\n // else it is.\n if (now - createdAtMs > GIVE_UP_AFTER_MS) {\n await docSnapshot.ref\n .set({ status: 'expired' }, { merge: true })\n .catch(() => undefined)\n continue\n }\n if (!data.email || data.remindedAtMs) continue\n if (now - createdAtMs < REMIND_AFTER_MS) continue\n if (!entitledHosts.has(hostId)) {\n const org = await getOrgForHost(hostId).catch(() => null)\n entitledHosts.set(\n hostId,\n Aglyn.checkEntitlement(org?.org as any, 'abandonedCart'),\n )\n brandingByHost.set(hostId, Aglyn.resolveBrandingProfile(org?.org as any))\n consentGroupByHost.set(\n hostId,\n Aglyn.consentGroupForHost(\n (org?.org as Record<string, unknown> | undefined) ?? null,\n hostId,\n ),\n )\n // The site's own origin, for the unsubscribe link. Resolved once per\n // host for the same reason the branding beside it is: this sweep is a\n // `collectionGroup` over every site's checkouts, and a read per\n // reminder would be a read per shopper.\n const hostSnapshot = await firestore\n .collection('hosts')\n .doc(hostId)\n .get()\n .catch(() => null)\n siteBaseByHost.set(\n hostId,\n Aglyn.hostPublicOrigin({\n cname: hostSnapshot?.get('cname'),\n subdomain: hostSnapshot?.get('subdomain'),\n }) ?? '',\n )\n }\n if (!entitledHosts.get(hostId)) continue\n let loaded = templateCache.get(hostId)\n if (loaded === undefined) {\n loaded = await // The template, or the built-in copy in the site's header and\n // footer, once per site for the whole batch (AGL-3287, AGL-3370).\n loadHostEmailWithTokens(firestore, hostId, 'abandoned-cart')\n templateCache.set(hostId, loaded)\n }\n const designed = loaded\n ? renderLoadedHostEmailWithTokens(\n loaded,\n { 'cart.url': String(data.resumeUrl ?? '') },\n )\n : null\n /*\n * MARKETING, and `'bulk'` priority, which this sweep is entitled to\n * precisely because it is resumable: a checkout left unstamped below is\n * re-scanned on the next beat, so a refusal means \"not this hour\" rather\n * than a reminder nobody ever gets.\n *\n * The gate adds the unsubscribe header pair and a visible link, checks\n * both suppression lists, and counts this against how much mail the\n * shopper has had from this site today.\n */\n const consentGroup =\n consentGroupByHost.get(hostId) ?? Aglyn.soloConsentGroup(hostId)\n const result = await sendEmail({\n to: String(data.email),\n subject: designed?.subject ?? 'You left something in your cart',\n text:\n designed?.text ||\n 'You left items in your cart. Pick up where you left off:\\n\\n' +\n `${data.resumeUrl ?? ''}\\n\\n` +\n // Not \"held\" (AGL-3432): the checkout's stock hold expires after\n // 31 minutes, before this reminder goes out an hour in.\n 'Nothing in your cart is held for you.',\n ...(designed?.html ? { html: designed.html } : {}),\n fromName: brandingByHost.get(hostId)?.fromName,\n sendingIdentity: await hostSendingIdentity(hostId, identityByHost),\n audience: 'tenant',\n context: 'abandoned cart',\n priority: 'bulk',\n /*\n * A cart reminder is promotional mail — the \"Promotions and offers\"\n * stream, which is also where a campaign lands by default. Declared so\n * a shopper who left that stream stops receiving these; the gate asks\n * the topic only for callers that name one.\n */\n marketing: {\n hostId,\n siteBase: siteBaseByHost.get(hostId) ?? '',\n topicId: Aglyn.DEFAULT_SUBSCRIPTION_TOPIC_ID,\n consentHostIds: consentGroup.hostIds,\n consentAwaitsConfirmation: consentGroup.awaitsConfirmation,\n },\n })\n /*\n * SKIPPED, NOT DROPPED — the lockdown rule above, applied to the two\n * refusals a later beat can pass.\n *\n * `remindedAtMs` is what retires a checkout from this sweep. Stamping it\n * after the hourly ceiling or the frequency cap refused would turn a\n * deferral into a reminder nobody ever gets; NOT stamping it after a\n * suppression or a rejection would re-read the same doomed row on every\n * beat until it crowded out the checkouts that could still be reminded.\n * `isDeferrableSendResult` is the one place that distinction is made.\n */\n if (isDeferrableSendResult(result)) continue\n // Cost meter (AGL-1438). One reminder per abandoned checkout, triggered\n // by that shopper's own action rather than composed as a broadcast, so\n // it counts toward cost without entering the campaign cap. On the\n // DELIVERED message only: a suppressed recipient produced no message, so\n // there is no cost to record.\n if (result.sent) await meterHostEmail(hostId)\n // `recoveryState` beside it, so the console's recovery queue counts this\n // checkout as reminded by query rather than by reading it (AGL-3321).\n const reminded: CheckoutRecoveryState = 'reminded'\n await docSnapshot.ref\n .set({ remindedAtMs: now, recoveryState: reminded }, { merge: true })\n .catch(() => undefined)\n if (result.sent) sent += 1\n }\n return { scanned: openCheckouts.size, sent, skippedLocked }\n}\n\n/**\n * HTTP door for the pass above (AGL-323). Kept `x-cron-secret`-gated so an\n * external scheduler or an operator can still drive it; the job registration\n * in `server.ts` is what makes it actually run.\n */\nexport const processAbandonedHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const cronSecret = process.env.CRON_SECRET\n if (!cronSecret) {\n return res.status(501).json({ error: 'Not configured (CRON_SECRET).' })\n }\n if (req.headers['x-cron-secret'] !== cronSecret) {\n return res.status(401).json({ error: 'Unauthorized' })\n }\n if (!isEmailConfigured()) {\n return res.status(501).json({ error: 'Email is not configured.' })\n }\n try {\n // The manual door asks the same question the beat does (AGL-2495): a\n // forced pass is still a pass, and `x-cron-secret` is not an argument\n // for mailing a suspended site's shoppers.\n return res.status(200).json(await scanAbandonedCheckouts(siteLockdownJobGate()))\n } catch (error) {\n console.error(error)\n return res.status(500).json({ error: 'Processing failed' })\n }\n}\n"],"names":["Aglyn","firebaseAdmin","getOrgForHost","hostSendingIdentity","meterHostEmail","isDeferrableSendResult","isEmailConfigured","sendEmail","siteLockdownJobGate","loadHostEmailWithTokens","renderLoadedHostEmailWithTokens","CHECKOUT_GIVE_UP_AFTER_MS","GIVE_UP_AFTER_MS","CHECKOUT_REMIND_AFTER_MS","REMIND_AFTER_MS","scanAbandonedCheckouts","gate","firestore","app","now","Date","openCheckouts","collectionGroup","where","limit","get","sent","entitledHosts","Map","brandingByHost","identityByHost","siteBaseByHost","consentGroupByHost","templateCache","skippedLocked","docSnapshot","docs","data","hostId","ref","parent","id","isLocked","createdAtMs","Number","set","status","merge","catch","undefined","email","remindedAtMs","has","org","checkEntitlement","resolveBrandingProfile","consentGroupForHost","hostSnapshot","collection","doc","hostPublicOrigin","cname","subdomain","loaded","designed","String","resumeUrl","consentGroup","soloConsentGroup","result","to","subject","text","html","fromName","sendingIdentity","audience","context","priority","marketing","siteBase","topicId","DEFAULT_SUBSCRIPTION_TOPIC_ID","consentHostIds","hostIds","consentAwaitsConfirmation","awaitsConfirmation","reminded","recoveryState","scanned","size","processAbandonedHandler","req","res","method","json","error","cronSecret","process","env","CRON_SECRET","headers","console"],"mappings":"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"}
@@ -62,7 +62,7 @@ import { loadHostEmailWithTokens, renderLoadedHostEmailWithTokens } from "@aglyn
62
62
  */ const consentGroupByHost = new Map();
63
63
  let skippedLocked = 0;
64
64
  for (const docSnapshot of alerts.docs){
65
- var _product_name, _consentGroupByHost_get, _ref, _siteBaseByHost_get;
65
+ var _siteBaseByHost_get, _product_name, _consentGroupByHost_get, _ref, _siteBaseByHost_get1;
66
66
  var _brandingByHost_get;
67
67
  const hostRef = docSnapshot.ref.parent.parent;
68
68
  if (!hostRef) continue;
@@ -114,7 +114,6 @@ import { loadHostEmailWithTokens, renderLoadedHostEmailWithTokens } from "@aglyn
114
114
  }
115
115
  const total = CommerceModel.productInventory(product);
116
116
  if (total != null && total <= 0) continue;
117
- const productUrl = `/products/${product.slug}`;
118
117
  let loaded = templateCache.get(hostRef.id);
119
118
  if (loaded === undefined) {
120
119
  var _ref1, _Aglyn_hostPublicOrigin;
@@ -134,6 +133,9 @@ import { loadHostEmailWithTokens, renderLoadedHostEmailWithTokens } from "@aglyn
134
133
  subdomain: hostSnapshot == null ? void 0 : hostSnapshot.get('subdomain')
135
134
  })) != null ? _Aglyn_hostPublicOrigin : '');
136
135
  }
136
+ // ABSOLUTE, on the site's own origin (AGL-3432): a bare `/products/…`
137
+ // path is a dead link in every inbox, and it is the email's only button.
138
+ const productUrl = `${(_siteBaseByHost_get = siteBaseByHost.get(hostRef.id)) != null ? _siteBaseByHost_get : ''}/products/${product.slug}`;
137
139
  const designed = loaded ? renderLoadedHostEmailWithTokens(loaded, {
138
140
  'product.name': String((_product_name = product.name) != null ? _product_name : ''),
139
141
  'product.url': productUrl
@@ -151,7 +153,7 @@ import { loadHostEmailWithTokens, renderLoadedHostEmailWithTokens } from "@aglyn
151
153
  const result = await sendEmail(_extends({
152
154
  to: String(data.email),
153
155
  subject: (_ref = designed == null ? void 0 : designed.subject) != null ? _ref : `Back in stock: ${product.name}`,
154
- text: (designed == null ? void 0 : designed.text) || `${product.name} is available again — grab it before it sells ` + `out:\n\n${productUrl}`
156
+ text: (designed == null ? void 0 : designed.text) || `${product.name} is back in stock:\n\n${productUrl}`
155
157
  }, (designed == null ? void 0 : designed.html) ? {
156
158
  html: designed.html
157
159
  } : {}, {
@@ -168,8 +170,8 @@ import { loadHostEmailWithTokens, renderLoadedHostEmailWithTokens } from "@aglyn
168
170
  * left this off would keep mailing them.
169
171
  */ marketing: {
170
172
  hostId: hostRef.id,
171
- siteBase: (_siteBaseByHost_get = siteBaseByHost.get(hostRef.id)) != null ? _siteBaseByHost_get : '',
172
- topicId: Aglyn.EMAIL_TOPIC_PRODUCT_UPDATES,
173
+ siteBase: (_siteBaseByHost_get1 = siteBaseByHost.get(hostRef.id)) != null ? _siteBaseByHost_get1 : '',
174
+ topicId: CommerceModel.PRODUCT_UPDATES_TOPIC_ID,
173
175
  consentHostIds: consentGroup.hostIds,
174
176
  consentAwaitsConfirmation: consentGroup.awaitsConfirmation
175
177
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/process-restock.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 {\n firebaseAdmin,\n getOrgForHost,\n hostSendingIdentity,\n meterHostEmail,\n} from '@aglyn/tenant-data-admin'\nimport { isDocumentId } from '@aglyn/tenant-data-admin/server/document-id'\nimport {\n isDeferrableSendResult,\n isEmailConfigured,\n sendEmail,\n} from '@aglyn/shared-util-email'\nimport {\n type PluginApiHandler,\n type PluginJobHostGate,\n} from '@aglyn/aglyn/server'\n// The leaf, not the barrel: the console's `x-cron-secret` door asks the\n// site's lockdown directly (AGL-3356) — core's registry has no resolver in\n// the console process — and a spec that substitutes the barrel must still\n// reach the real verdict.\nimport { siteLockdownJobGate } from '@aglyn/tenant-data-admin/server/tenant-write-lockdown'\nimport {\n loadHostEmailWithTokens,\n renderLoadedHostEmailWithTokens,\n type LoadedHostEmailWithTokens,\n} from '@aglyn/tenant-data-admin/server/host-email-tokens'\n\nexport interface RestockScanResult {\n scanned: number\n sent: number\n /** Alerts left unstamped because their site is locked (AGL-2495). */\n skippedLocked: number\n}\n\n/**\n * The back-in-stock pass itself, separated from its HTTP door (AGL-2227).\n *\n * Same finding as `process-abandoned.ts` beside it: `x-cron-secret`-gated and\n * **never invoked by anything**. `commerce/notify-restock` accepts the\n * shopper's \"tell me when it's back\" from the storefront and writes\n * `hosts/{hostId}/restockAlerts`; this is the only thing that would ever mail\n * them. Shoppers have been entering an address into a queue with no drain.\n *\n * Exported so `server.ts` can put it on the platform job beat.\n */\nexport async function scanRestockAlerts(\n /** The lockdown gate, injected by the caller (AGL-2495). Not optional. */\n gate: PluginJobHostGate,\n): Promise<RestockScanResult> {\n const firestore = firebaseAdmin.app().firestore()\n const alerts = await firestore\n .collectionGroup('restockAlerts')\n .where('notifiedAtMs', '==', null)\n .limit(200)\n .get()\n let sent = 0\n const productCache = new Map<string, CommerceModel.HostProduct | null>()\n // Resolve each host's designed template once per run (AGL-770).\n const templateCache = new Map<string, LoadedHostEmailWithTokens | null>()\n // White-label brand per host (White-Label Phase 3): resolved once per host\n // from the owning org doc through the one shared resolver.\n const brandingByHost = new Map<string, Aglyn.ResolvedBrandingProfile>()\n /*\n * One identity resolution per SITE, not per message.\n *\n * The same reason the branding map above exists: this sweep runs across\n * every site, and an uncached resolution here would be two Firestore reads\n * per recipient rather than per host. Scoped to the run, so a domain\n * un-verified by the re-check sweep is picked up on the next one.\n */\n const identityByHost = new Map<\n string,\n Awaited<ReturnType<typeof hostSendingIdentity>>\n >()\n /** Each site's public origin, for the unsubscribe link on the alert. */\n const siteBaseByHost = new Map<string, string>()\n /**\n * Each site's consent group, off the same org read as its branding, so the\n * gate honors an unsubscribe from any site the org declared one sender\n * with this one, and the org's word on whether its sites wait for each\n * other's confirmation click.\n */\n const consentGroupByHost = new Map<string, Aglyn.ConsentGroup>()\n let skippedLocked = 0\n for (const docSnapshot of alerts.docs) {\n const hostRef = docSnapshot.ref.parent.parent\n if (!hostRef) continue\n // LOCKDOWN (AGL-2495), before anything in this body writes. The poisoned-\n // row retirement below stamps `notifiedAtMs`, which is a write, and the\n // email is a message sent in a suspended merchant's name — both wait.\n //\n // SKIPPED, NOT DROPPED: an alert that is not stamped is re-scanned on the\n // next beat, which is exactly the behaviour the `notifiedAtMs == null`\n // query already relies on.\n if (await gate.isLocked(hostRef.id)) {\n skippedLocked += 1\n continue\n }\n const data = docSnapshot.data() as any\n // AGL-1774: `productId` is a STORED FIELD written by an unauthenticated\n // POST (`notify-restock.ts`), and this line is where it becomes a path\n // component. `.doc()` appends a slash-separated path and throws\n // SYNCHRONOUSLY on an even component count — a throw that landed here,\n // outside the loop body's own error handling, so it aborted the entire\n // run. The scan is a `collectionGroup`, so that run is platform-wide;\n // and because the offending alert was never stamped `notifiedAtMs` it\n // returned on every subsequent run, taking every alert ordered after it\n // down too. One anonymous request stopped back-in-stock email for every\n // merchant, permanently.\n //\n // `notify-restock.ts` now refuses such a value at the door, but this\n // guard is not redundant with that one: rows written BEFORE the fix are\n // already in the database, and a create-time check does nothing about\n // them. Treated as \"no product\" rather than as an error, which is the\n // correct refusal by AGL-1760's test — there is no product, so there is\n // no email owed and no work to discard — and it stamps the alert below\n // so a poisoned row is retired instead of re-scanned forever.\n const productId = isDocumentId(data.productId) ? data.productId : ''\n const cacheKey = `${hostRef.id}:${productId}`\n if (productId && !productCache.has(cacheKey)) {\n const productSnapshot = await hostRef\n .collection('products')\n .doc(productId)\n .get()\n productCache.set(\n cacheKey,\n productSnapshot.exists\n ? CommerceModel.liftLegacyProduct(productSnapshot.data() as any)\n : null,\n )\n }\n const product = productId ? productCache.get(cacheKey) : null\n if (!product) {\n await docSnapshot.ref\n .set({ notifiedAtMs: Date.now(), skipped: true }, { merge: true })\n .catch(() => undefined)\n continue\n }\n const total = CommerceModel.productInventory(product)\n if (total != null && total <= 0) continue\n const productUrl = `/products/${product.slug}`\n let loaded = templateCache.get(hostRef.id)\n if (loaded === undefined) {\n loaded = await // The template, or the built-in copy in the site's header and\n // footer, once per site for the whole batch (AGL-3287, AGL-3370).\n loadHostEmailWithTokens(firestore, hostRef.id, 'back-in-stock')\n templateCache.set(hostRef.id, loaded)\n const owner = await getOrgForHost(hostRef.id).catch(() => null)\n brandingByHost.set(\n hostRef.id,\n Aglyn.resolveBrandingProfile(owner?.org as never),\n )\n consentGroupByHost.set(\n hostRef.id,\n Aglyn.consentGroupForHost(\n (owner?.org as Record<string, unknown> | undefined) ?? null,\n hostRef.id,\n ),\n )\n // The site's own origin, for the unsubscribe link. Resolved once per\n // host beside the branding, because this sweep is a `collectionGroup`\n // over every site's alerts and a read per alert would be a read per\n // shopper.\n const hostSnapshot = await hostRef.get().catch(() => null)\n siteBaseByHost.set(\n hostRef.id,\n Aglyn.hostPublicOrigin({\n cname: hostSnapshot?.get('cname'),\n subdomain: hostSnapshot?.get('subdomain'),\n }) ?? '',\n )\n }\n const designed = loaded\n ? renderLoadedHostEmailWithTokens(\n loaded,\n {\n 'product.name': String(product.name ?? ''),\n 'product.url': productUrl,\n },\n )\n : null\n /*\n * MARKETING, and `'bulk'` priority, which this sweep is entitled to\n * because it is resumable: an alert left unstamped is re-scanned by the\n * `notifiedAtMs == null` query on the next beat, so a refusal means \"not\n * this hour\" rather than a shopper who asked to be told and never was.\n *\n * The gate adds the unsubscribe header pair and a visible link, checks\n * both suppression lists, and counts this against how much mail the\n * shopper has had from this site today.\n */\n const consentGroup =\n consentGroupByHost.get(hostRef.id) ?? Aglyn.soloConsentGroup(hostRef.id)\n const result = await sendEmail({\n to: String(data.email),\n subject: designed?.subject ?? `Back in stock: ${product.name}`,\n text:\n designed?.text ||\n `${product.name} is available again — grab it before it sells ` +\n `out:\\n\\n${productUrl}`,\n ...(designed?.html ? { html: designed.html } : {}),\n fromName: brandingByHost.get(hostRef.id)?.fromName,\n sendingIdentity: await hostSendingIdentity(hostRef.id, identityByHost),\n audience: 'tenant',\n context: 'restock alert',\n priority: 'bulk',\n /*\n * A restock alert IS the \"Product updates\" stream — the catalog entry\n * names restocks in its own description. Declared so a shopper who\n * unticked that stream on the preference page is not mailed it: the\n * gate asks the topic only for callers that name one, so a sender that\n * left this off would keep mailing them.\n */\n marketing: {\n hostId: hostRef.id,\n siteBase: siteBaseByHost.get(hostRef.id) ?? '',\n topicId: Aglyn.EMAIL_TOPIC_PRODUCT_UPDATES,\n consentHostIds: consentGroup.hostIds,\n consentAwaitsConfirmation: consentGroup.awaitsConfirmation,\n },\n })\n /*\n * SKIPPED, NOT DROPPED — the lockdown rule above, applied to the two\n * refusals a later beat can pass.\n *\n * `notifiedAtMs` is what retires an alert from this sweep. Stamping it\n * after the hourly ceiling or the frequency cap refused would drop a\n * request from a shopper who asked to be told; NOT stamping it after a\n * suppression or a rejection would re-read the same doomed row forever,\n * inside a window of two hundred, starving every alert behind it.\n */\n if (isDeferrableSendResult(result)) continue\n // Cost meter (AGL-1438). One alert per shopper who asked to be told,\n // like the abandoned-checkout reminder beside it, and on the DELIVERED\n // message only — a suppressed recipient produced no message and no cost.\n if (result.sent) await meterHostEmail(hostRef.id)\n await docSnapshot.ref\n .set({ notifiedAtMs: Date.now() }, { merge: true })\n .catch(() => undefined)\n if (result.sent) sent += 1\n }\n return { scanned: alerts.size, sent, skippedLocked }\n}\n\n/**\n * HTTP door for the pass above (AGL-326), kept for manual/ops invocation and\n * for the console's \"send now\" control.\n */\nexport const processRestockHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const cronSecret = process.env.CRON_SECRET\n if (!cronSecret || req.headers['x-cron-secret'] !== cronSecret) {\n return res.status(401).json({ error: 'Unauthorized' })\n }\n if (!isEmailConfigured()) {\n return res.status(501).json({ error: 'Email is not configured.' })\n }\n try {\n // The manual door asks the same question the beat does (AGL-2495).\n return res.status(200).json(await scanRestockAlerts(siteLockdownJobGate()))\n } catch (error) {\n console.error(error)\n return res.status(500).json({ error: 'Processing failed' })\n }\n}\n"],"names":["Aglyn","CommerceModel","firebaseAdmin","getOrgForHost","hostSendingIdentity","meterHostEmail","isDocumentId","isDeferrableSendResult","isEmailConfigured","sendEmail","siteLockdownJobGate","loadHostEmailWithTokens","renderLoadedHostEmailWithTokens","scanRestockAlerts","gate","firestore","app","alerts","collectionGroup","where","limit","get","sent","productCache","Map","templateCache","brandingByHost","identityByHost","siteBaseByHost","consentGroupByHost","skippedLocked","docSnapshot","docs","product","hostRef","ref","parent","isLocked","id","data","productId","cacheKey","has","productSnapshot","collection","doc","set","exists","liftLegacyProduct","notifiedAtMs","Date","now","skipped","merge","catch","undefined","total","productInventory","productUrl","slug","loaded","owner","resolveBrandingProfile","org","consentGroupForHost","hostSnapshot","hostPublicOrigin","cname","subdomain","designed","String","name","consentGroup","soloConsentGroup","result","to","email","subject","text","html","fromName","sendingIdentity","audience","context","priority","marketing","hostId","siteBase","topicId","EMAIL_TOPIC_PRODUCT_UPDATES","consentHostIds","hostIds","consentAwaitsConfirmation","awaitsConfirmation","scanned","size","processRestockHandler","req","res","method","status","json","error","cronSecret","process","env","CRON_SECRET","headers","console"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/process-restock.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 {\n firebaseAdmin,\n getOrgForHost,\n hostSendingIdentity,\n meterHostEmail,\n} from '@aglyn/tenant-data-admin'\nimport { isDocumentId } from '@aglyn/tenant-data-admin/server/document-id'\nimport {\n isDeferrableSendResult,\n isEmailConfigured,\n sendEmail,\n} from '@aglyn/shared-util-email'\nimport {\n type PluginApiHandler,\n type PluginJobHostGate,\n} from '@aglyn/aglyn/server'\n// The leaf, not the barrel: the console's `x-cron-secret` door asks the\n// site's lockdown directly (AGL-3356) — core's registry has no resolver in\n// the console process — and a spec that substitutes the barrel must still\n// reach the real verdict.\nimport { siteLockdownJobGate } from '@aglyn/tenant-data-admin/server/tenant-write-lockdown'\nimport {\n loadHostEmailWithTokens,\n renderLoadedHostEmailWithTokens,\n type LoadedHostEmailWithTokens,\n} from '@aglyn/tenant-data-admin/server/host-email-tokens'\n\nexport interface RestockScanResult {\n scanned: number\n sent: number\n /** Alerts left unstamped because their site is locked (AGL-2495). */\n skippedLocked: number\n}\n\n/**\n * The back-in-stock pass itself, separated from its HTTP door (AGL-2227).\n *\n * Same finding as `process-abandoned.ts` beside it: `x-cron-secret`-gated and\n * **never invoked by anything**. `commerce/notify-restock` accepts the\n * shopper's \"tell me when it's back\" from the storefront and writes\n * `hosts/{hostId}/restockAlerts`; this is the only thing that would ever mail\n * them. Shoppers have been entering an address into a queue with no drain.\n *\n * Exported so `server.ts` can put it on the platform job beat.\n */\nexport async function scanRestockAlerts(\n /** The lockdown gate, injected by the caller (AGL-2495). Not optional. */\n gate: PluginJobHostGate,\n): Promise<RestockScanResult> {\n const firestore = firebaseAdmin.app().firestore()\n const alerts = await firestore\n .collectionGroup('restockAlerts')\n .where('notifiedAtMs', '==', null)\n .limit(200)\n .get()\n let sent = 0\n const productCache = new Map<string, CommerceModel.HostProduct | null>()\n // Resolve each host's designed template once per run (AGL-770).\n const templateCache = new Map<string, LoadedHostEmailWithTokens | null>()\n // White-label brand per host (White-Label Phase 3): resolved once per host\n // from the owning org doc through the one shared resolver.\n const brandingByHost = new Map<string, Aglyn.ResolvedBrandingProfile>()\n /*\n * One identity resolution per SITE, not per message.\n *\n * The same reason the branding map above exists: this sweep runs across\n * every site, and an uncached resolution here would be two Firestore reads\n * per recipient rather than per host. Scoped to the run, so a domain\n * un-verified by the re-check sweep is picked up on the next one.\n */\n const identityByHost = new Map<\n string,\n Awaited<ReturnType<typeof hostSendingIdentity>>\n >()\n /** Each site's public origin, for the unsubscribe link on the alert. */\n const siteBaseByHost = new Map<string, string>()\n /**\n * Each site's consent group, off the same org read as its branding, so the\n * gate honors an unsubscribe from any site the org declared one sender\n * with this one, and the org's word on whether its sites wait for each\n * other's confirmation click.\n */\n const consentGroupByHost = new Map<string, Aglyn.ConsentGroup>()\n let skippedLocked = 0\n for (const docSnapshot of alerts.docs) {\n const hostRef = docSnapshot.ref.parent.parent\n if (!hostRef) continue\n // LOCKDOWN (AGL-2495), before anything in this body writes. The poisoned-\n // row retirement below stamps `notifiedAtMs`, which is a write, and the\n // email is a message sent in a suspended merchant's name — both wait.\n //\n // SKIPPED, NOT DROPPED: an alert that is not stamped is re-scanned on the\n // next beat, which is exactly the behaviour the `notifiedAtMs == null`\n // query already relies on.\n if (await gate.isLocked(hostRef.id)) {\n skippedLocked += 1\n continue\n }\n const data = docSnapshot.data() as any\n // AGL-1774: `productId` is a STORED FIELD written by an unauthenticated\n // POST (`notify-restock.ts`), and this line is where it becomes a path\n // component. `.doc()` appends a slash-separated path and throws\n // SYNCHRONOUSLY on an even component count — a throw that landed here,\n // outside the loop body's own error handling, so it aborted the entire\n // run. The scan is a `collectionGroup`, so that run is platform-wide;\n // and because the offending alert was never stamped `notifiedAtMs` it\n // returned on every subsequent run, taking every alert ordered after it\n // down too. One anonymous request stopped back-in-stock email for every\n // merchant, permanently.\n //\n // `notify-restock.ts` now refuses such a value at the door, but this\n // guard is not redundant with that one: rows written BEFORE the fix are\n // already in the database, and a create-time check does nothing about\n // them. Treated as \"no product\" rather than as an error, which is the\n // correct refusal by AGL-1760's test — there is no product, so there is\n // no email owed and no work to discard — and it stamps the alert below\n // so a poisoned row is retired instead of re-scanned forever.\n const productId = isDocumentId(data.productId) ? data.productId : ''\n const cacheKey = `${hostRef.id}:${productId}`\n if (productId && !productCache.has(cacheKey)) {\n const productSnapshot = await hostRef\n .collection('products')\n .doc(productId)\n .get()\n productCache.set(\n cacheKey,\n productSnapshot.exists\n ? CommerceModel.liftLegacyProduct(productSnapshot.data() as any)\n : null,\n )\n }\n const product = productId ? productCache.get(cacheKey) : null\n if (!product) {\n await docSnapshot.ref\n .set({ notifiedAtMs: Date.now(), skipped: true }, { merge: true })\n .catch(() => undefined)\n continue\n }\n const total = CommerceModel.productInventory(product)\n if (total != null && total <= 0) continue\n let loaded = templateCache.get(hostRef.id)\n if (loaded === undefined) {\n loaded = await // The template, or the built-in copy in the site's header and\n // footer, once per site for the whole batch (AGL-3287, AGL-3370).\n loadHostEmailWithTokens(firestore, hostRef.id, 'back-in-stock')\n templateCache.set(hostRef.id, loaded)\n const owner = await getOrgForHost(hostRef.id).catch(() => null)\n brandingByHost.set(\n hostRef.id,\n Aglyn.resolveBrandingProfile(owner?.org as never),\n )\n consentGroupByHost.set(\n hostRef.id,\n Aglyn.consentGroupForHost(\n (owner?.org as Record<string, unknown> | undefined) ?? null,\n hostRef.id,\n ),\n )\n // The site's own origin, for the unsubscribe link. Resolved once per\n // host beside the branding, because this sweep is a `collectionGroup`\n // over every site's alerts and a read per alert would be a read per\n // shopper.\n const hostSnapshot = await hostRef.get().catch(() => null)\n siteBaseByHost.set(\n hostRef.id,\n Aglyn.hostPublicOrigin({\n cname: hostSnapshot?.get('cname'),\n subdomain: hostSnapshot?.get('subdomain'),\n }) ?? '',\n )\n }\n // ABSOLUTE, on the site's own origin (AGL-3432): a bare `/products/…`\n // path is a dead link in every inbox, and it is the email's only button.\n const productUrl = `${siteBaseByHost.get(hostRef.id) ?? ''}/products/${product.slug}`\n const designed = loaded\n ? renderLoadedHostEmailWithTokens(\n loaded,\n {\n 'product.name': String(product.name ?? ''),\n 'product.url': productUrl,\n },\n )\n : null\n /*\n * MARKETING, and `'bulk'` priority, which this sweep is entitled to\n * because it is resumable: an alert left unstamped is re-scanned by the\n * `notifiedAtMs == null` query on the next beat, so a refusal means \"not\n * this hour\" rather than a shopper who asked to be told and never was.\n *\n * The gate adds the unsubscribe header pair and a visible link, checks\n * both suppression lists, and counts this against how much mail the\n * shopper has had from this site today.\n */\n const consentGroup =\n consentGroupByHost.get(hostRef.id) ?? Aglyn.soloConsentGroup(hostRef.id)\n const result = await sendEmail({\n to: String(data.email),\n subject: designed?.subject ?? `Back in stock: ${product.name}`,\n text:\n designed?.text ||\n `${product.name} is back in stock:\\n\\n${productUrl}`,\n ...(designed?.html ? { html: designed.html } : {}),\n fromName: brandingByHost.get(hostRef.id)?.fromName,\n sendingIdentity: await hostSendingIdentity(hostRef.id, identityByHost),\n audience: 'tenant',\n context: 'restock alert',\n priority: 'bulk',\n /*\n * A restock alert IS the \"Product updates\" stream — the catalog entry\n * names restocks in its own description. Declared so a shopper who\n * unticked that stream on the preference page is not mailed it: the\n * gate asks the topic only for callers that name one, so a sender that\n * left this off would keep mailing them.\n */\n marketing: {\n hostId: hostRef.id,\n siteBase: siteBaseByHost.get(hostRef.id) ?? '',\n topicId: CommerceModel.PRODUCT_UPDATES_TOPIC_ID,\n consentHostIds: consentGroup.hostIds,\n consentAwaitsConfirmation: consentGroup.awaitsConfirmation,\n },\n })\n /*\n * SKIPPED, NOT DROPPED — the lockdown rule above, applied to the two\n * refusals a later beat can pass.\n *\n * `notifiedAtMs` is what retires an alert from this sweep. Stamping it\n * after the hourly ceiling or the frequency cap refused would drop a\n * request from a shopper who asked to be told; NOT stamping it after a\n * suppression or a rejection would re-read the same doomed row forever,\n * inside a window of two hundred, starving every alert behind it.\n */\n if (isDeferrableSendResult(result)) continue\n // Cost meter (AGL-1438). One alert per shopper who asked to be told,\n // like the abandoned-checkout reminder beside it, and on the DELIVERED\n // message only — a suppressed recipient produced no message and no cost.\n if (result.sent) await meterHostEmail(hostRef.id)\n await docSnapshot.ref\n .set({ notifiedAtMs: Date.now() }, { merge: true })\n .catch(() => undefined)\n if (result.sent) sent += 1\n }\n return { scanned: alerts.size, sent, skippedLocked }\n}\n\n/**\n * HTTP door for the pass above (AGL-326), kept for manual/ops invocation and\n * for the console's \"send now\" control.\n */\nexport const processRestockHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const cronSecret = process.env.CRON_SECRET\n if (!cronSecret || req.headers['x-cron-secret'] !== cronSecret) {\n return res.status(401).json({ error: 'Unauthorized' })\n }\n if (!isEmailConfigured()) {\n return res.status(501).json({ error: 'Email is not configured.' })\n }\n try {\n // The manual door asks the same question the beat does (AGL-2495).\n return res.status(200).json(await scanRestockAlerts(siteLockdownJobGate()))\n } catch (error) {\n console.error(error)\n return res.status(500).json({ error: 'Processing failed' })\n }\n}\n"],"names":["Aglyn","CommerceModel","firebaseAdmin","getOrgForHost","hostSendingIdentity","meterHostEmail","isDocumentId","isDeferrableSendResult","isEmailConfigured","sendEmail","siteLockdownJobGate","loadHostEmailWithTokens","renderLoadedHostEmailWithTokens","scanRestockAlerts","gate","firestore","app","alerts","collectionGroup","where","limit","get","sent","productCache","Map","templateCache","brandingByHost","identityByHost","siteBaseByHost","consentGroupByHost","skippedLocked","docSnapshot","docs","product","hostRef","ref","parent","isLocked","id","data","productId","cacheKey","has","productSnapshot","collection","doc","set","exists","liftLegacyProduct","notifiedAtMs","Date","now","skipped","merge","catch","undefined","total","productInventory","loaded","owner","resolveBrandingProfile","org","consentGroupForHost","hostSnapshot","hostPublicOrigin","cname","subdomain","productUrl","slug","designed","String","name","consentGroup","soloConsentGroup","result","to","email","subject","text","html","fromName","sendingIdentity","audience","context","priority","marketing","hostId","siteBase","topicId","PRODUCT_UPDATES_TOPIC_ID","consentHostIds","hostIds","consentAwaitsConfirmation","awaitsConfirmation","scanned","size","processRestockHandler","req","res","method","status","json","error","cronSecret","process","env","CRON_SECRET","headers","console"],"mappings":"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"}
@@ -0,0 +1,19 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ import type { PluginRecordIndex } from '@aglyn/aglyn/plugin-manager/plugin-record-index';
18
+ export declare const productRecordIndex: PluginRecordIndex;
19
+ export declare const productCategoryRecordIndex: PluginRecordIndex;
@@ -0,0 +1,83 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ import { firebaseAdmin } from "@aglyn/tenant-data-admin/server/firebase-admin";
17
+ import { productIndexedRecord } from "../model/product-record.js";
18
+ /**
19
+ * The commerce plugin's record indexes (AGL-3080): its products and its
20
+ * product categories, as another plugin reads them — an AI job enriching a
21
+ * catalog, a composer binding the products an email names — without knowing
22
+ * where they are stored or what a deleted one looks like.
23
+ *
24
+ * Both are a SITE's (`hostId` required; an org-only scope answers nothing).
25
+ * A deleted product and an unnamed one are left out of every answer.
26
+ *
27
+ * A product is `productIndexedRecord`'s (`model/product-record.ts`), which
28
+ * documents its facts. A category's `facts` are empty: it is its name.
29
+ */ const str = (value)=>typeof value === 'string' ? value : '';
30
+ function hostCollection(hostId, name) {
31
+ return firebaseAdmin.app().firestore().collection('hosts').doc(hostId).collection(name);
32
+ }
33
+ export const productRecordIndex = {
34
+ async list ({ hostId, limit }) {
35
+ if (!hostId || limit <= 0) return {
36
+ records: [],
37
+ truncated: false
38
+ };
39
+ // One more than asked, so `truncated` is a fact rather than a guess; the
40
+ // deleted rows are filtered after the read, like every reader did.
41
+ const snapshot = await hostCollection(hostId, 'products').limit(limit + 1).get();
42
+ const records = snapshot.docs.map((doc)=>productIndexedRecord(doc.id, doc.data())).filter((record)=>record !== null);
43
+ return {
44
+ records: records.slice(0, limit),
45
+ truncated: snapshot.size > limit
46
+ };
47
+ },
48
+ async get ({ hostId, id }) {
49
+ if (!hostId || !id) return null;
50
+ const snapshot = await hostCollection(hostId, 'products').doc(id).get();
51
+ return snapshot.exists ? productIndexedRecord(snapshot.id, snapshot.data()) : null;
52
+ }
53
+ };
54
+ export const productCategoryRecordIndex = {
55
+ async list ({ hostId, limit }) {
56
+ if (!hostId || limit <= 0) return {
57
+ records: [],
58
+ truncated: false
59
+ };
60
+ const snapshot = await hostCollection(hostId, 'productCategories').select('name').limit(limit + 1).get();
61
+ const records = snapshot.docs.map((doc)=>({
62
+ id: doc.id,
63
+ name: str(doc.get('name')),
64
+ facts: {}
65
+ })).filter((record)=>record.name);
66
+ return {
67
+ records: records.slice(0, limit),
68
+ truncated: snapshot.size > limit
69
+ };
70
+ },
71
+ async get ({ hostId, id }) {
72
+ if (!hostId || !id) return null;
73
+ const snapshot = await hostCollection(hostId, 'productCategories').doc(id).get();
74
+ const name = snapshot.exists ? str(snapshot.get('name')) : '';
75
+ return name ? {
76
+ id: snapshot.id,
77
+ name,
78
+ facts: {}
79
+ } : null;
80
+ }
81
+ };
82
+
83
+ //# sourceMappingURL=product-record-index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/product-record-index.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 {\n PluginIndexedRecord,\n PluginRecordIndex,\n} from '@aglyn/aglyn/plugin-manager/plugin-record-index'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin/server/firebase-admin'\nimport { productIndexedRecord } from '../model/product-record'\n\n/**\n * The commerce plugin's record indexes (AGL-3080): its products and its\n * product categories, as another plugin reads them — an AI job enriching a\n * catalog, a composer binding the products an email names — without knowing\n * where they are stored or what a deleted one looks like.\n *\n * Both are a SITE's (`hostId` required; an org-only scope answers nothing).\n * A deleted product and an unnamed one are left out of every answer.\n *\n * A product is `productIndexedRecord`'s (`model/product-record.ts`), which\n * documents its facts. A category's `facts` are empty: it is its name.\n */\n\nconst str = (value: unknown): string => (typeof value === 'string' ? value : '')\n\nfunction hostCollection(hostId: string, name: 'products' | 'productCategories') {\n return firebaseAdmin.app().firestore().collection('hosts').doc(hostId).collection(name)\n}\n\nexport const productRecordIndex: PluginRecordIndex = {\n async list({ hostId, limit }) {\n if (!hostId || limit <= 0) return { records: [], truncated: false }\n // One more than asked, so `truncated` is a fact rather than a guess; the\n // deleted rows are filtered after the read, like every reader did.\n const snapshot = await hostCollection(hostId, 'products').limit(limit + 1).get()\n const records = snapshot.docs\n .map((doc) => productIndexedRecord(doc.id, doc.data()))\n .filter((record): record is PluginIndexedRecord => record !== null)\n return { records: records.slice(0, limit), truncated: snapshot.size > limit }\n },\n async get({ hostId, id }) {\n if (!hostId || !id) return null\n const snapshot = await hostCollection(hostId, 'products').doc(id).get()\n return snapshot.exists ? productIndexedRecord(snapshot.id, snapshot.data()) : null\n },\n}\n\nexport const productCategoryRecordIndex: PluginRecordIndex = {\n async list({ hostId, limit }) {\n if (!hostId || limit <= 0) return { records: [], truncated: false }\n const snapshot = await hostCollection(hostId, 'productCategories')\n .select('name')\n .limit(limit + 1)\n .get()\n const records = snapshot.docs\n .map((doc) => ({ id: doc.id, name: str(doc.get('name')), facts: {} }))\n .filter((record) => record.name)\n return { records: records.slice(0, limit), truncated: snapshot.size > limit }\n },\n async get({ hostId, id }) {\n if (!hostId || !id) return null\n const snapshot = await hostCollection(hostId, 'productCategories').doc(id).get()\n const name = snapshot.exists ? str(snapshot.get('name')) : ''\n return name ? { id: snapshot.id, name, facts: {} } : null\n },\n}\n"],"names":["firebaseAdmin","productIndexedRecord","str","value","hostCollection","hostId","name","app","firestore","collection","doc","productRecordIndex","list","limit","records","truncated","snapshot","get","docs","map","id","data","filter","record","slice","size","exists","productCategoryRecordIndex","select","facts"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAMD,SAASA,aAAa,QAAQ,iDAAgD;AAC9E,SAASC,oBAAoB,QAAQ,6BAAyB;AAE9D;;;;;;;;;;;CAWC,GAED,MAAMC,MAAM,CAACC,QAA4B,OAAOA,UAAU,WAAWA,QAAQ;AAE7E,SAASC,eAAeC,MAAc,EAAEC,IAAsC;IAC5E,OAAON,cAAcO,GAAG,GAAGC,SAAS,GAAGC,UAAU,CAAC,SAASC,GAAG,CAACL,QAAQI,UAAU,CAACH;AACpF;AAEA,OAAO,MAAMK,qBAAwC;IACnD,MAAMC,MAAK,EAAEP,MAAM,EAAEQ,KAAK,EAAE;QAC1B,IAAI,CAACR,UAAUQ,SAAS,GAAG,OAAO;YAAEC,SAAS,EAAE;YAAEC,WAAW;QAAM;QAClE,yEAAyE;QACzE,mEAAmE;QACnE,MAAMC,WAAW,MAAMZ,eAAeC,QAAQ,YAAYQ,KAAK,CAACA,QAAQ,GAAGI,GAAG;QAC9E,MAAMH,UAAUE,SAASE,IAAI,CAC1BC,GAAG,CAAC,CAACT,MAAQT,qBAAqBS,IAAIU,EAAE,EAAEV,IAAIW,IAAI,KAClDC,MAAM,CAAC,CAACC,SAA0CA,WAAW;QAChE,OAAO;YAAET,SAASA,QAAQU,KAAK,CAAC,GAAGX;YAAQE,WAAWC,SAASS,IAAI,GAAGZ;QAAM;IAC9E;IACA,MAAMI,KAAI,EAAEZ,MAAM,EAAEe,EAAE,EAAE;QACtB,IAAI,CAACf,UAAU,CAACe,IAAI,OAAO;QAC3B,MAAMJ,WAAW,MAAMZ,eAAeC,QAAQ,YAAYK,GAAG,CAACU,IAAIH,GAAG;QACrE,OAAOD,SAASU,MAAM,GAAGzB,qBAAqBe,SAASI,EAAE,EAAEJ,SAASK,IAAI,MAAM;IAChF;AACF,EAAC;AAED,OAAO,MAAMM,6BAAgD;IAC3D,MAAMf,MAAK,EAAEP,MAAM,EAAEQ,KAAK,EAAE;QAC1B,IAAI,CAACR,UAAUQ,SAAS,GAAG,OAAO;YAAEC,SAAS,EAAE;YAAEC,WAAW;QAAM;QAClE,MAAMC,WAAW,MAAMZ,eAAeC,QAAQ,qBAC3CuB,MAAM,CAAC,QACPf,KAAK,CAACA,QAAQ,GACdI,GAAG;QACN,MAAMH,UAAUE,SAASE,IAAI,CAC1BC,GAAG,CAAC,CAACT,MAAS,CAAA;gBAAEU,IAAIV,IAAIU,EAAE;gBAAEd,MAAMJ,IAAIQ,IAAIO,GAAG,CAAC;gBAAUY,OAAO,CAAC;YAAE,CAAA,GAClEP,MAAM,CAAC,CAACC,SAAWA,OAAOjB,IAAI;QACjC,OAAO;YAAEQ,SAASA,QAAQU,KAAK,CAAC,GAAGX;YAAQE,WAAWC,SAASS,IAAI,GAAGZ;QAAM;IAC9E;IACA,MAAMI,KAAI,EAAEZ,MAAM,EAAEe,EAAE,EAAE;QACtB,IAAI,CAACf,UAAU,CAACe,IAAI,OAAO;QAC3B,MAAMJ,WAAW,MAAMZ,eAAeC,QAAQ,qBAAqBK,GAAG,CAACU,IAAIH,GAAG;QAC9E,MAAMX,OAAOU,SAASU,MAAM,GAAGxB,IAAIc,SAASC,GAAG,CAAC,WAAW;QAC3D,OAAOX,OAAO;YAAEc,IAAIJ,SAASI,EAAE;YAAEd;YAAMuB,OAAO,CAAC;QAAE,IAAI;IACvD;AACF,EAAC"}
@@ -276,7 +276,7 @@ import * as CommerceModel from "../model/index.js";
276
276
  void notifyHostManagers(hostId, {
277
277
  type: 'content.lowStock',
278
278
  title: `Stock never came off the shelf — order ${label}`,
279
- body: `Order ${label} is ${lines[0].orderStatus} but ${units} unit` + `${units === 1 ? '' : 's'} of ` + `${lines.map((line)=>line.productName).join(', ')} were never ` + 'taken off the count, so the storefront is still offering stock ' + 'that has been sold. Adjust the count by hand.',
279
+ body: `Order ${label} on {site} is ${lines[0].orderStatus} but ${units} unit` + `${units === 1 ? '' : 's'} of ` + `${lines.map((line)=>line.productName).join(', ')} were never ` + 'taken off the count, so the storefront is still offering stock ' + 'that has been sold. Adjust the count by hand.',
280
280
  link: `/${hostId}/products`
281
281
  });
282
282
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/reconcile-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 { type PluginJobHostGate } from '@aglyn/aglyn/server'\nimport { firebaseAdmin, notifyHostManagers } from '@aglyn/tenant-data-admin'\nimport * as CommerceModel from '../model'\n\n/**\n * THE MISSING-DECREMENT DETECTOR (AGL-2358).\n *\n * Every order-minting branch of `billing-webhook.ts` commits the order in a\n * transaction and decrements stock AFTER it, and every branch guards on a\n * replay flag that transaction set — `created` for cart and buy-now,\n * `flipped` for the draft/POS-card branch, the invoice-id guard for a\n * subscription cycle. So a process that dies in the window between the two\n * loses the decrement PERMANENTLY: Stripe redelivers, the guard says the\n * order already exists, and the handler returns without ever reaching the\n * stock write. The merchant has a paid order and an untouched shelf, and\n * before this nothing anywhere recorded that they had diverged.\n *\n * `/api/billing/webhook`'s own claim marker makes the same window wider, not\n * narrower: AGL-2157 decided that an event which throws mid-dispatch KEEPS\n * its claim — deliberately, because re-running the dispatch could double-apply\n * a gift-card spend or a coupon redemption — and told staff to \"reconcile by\n * hand\". This is the thing they reconcile with. It is also the only detector\n * for the death AGL-2157 cannot catch at all: a timeout, an OOM or a deploy\n * kill unwinds no stack, so that route's `catch` never runs and no staff\n * notice is ever raised.\n *\n * WHY A DETECTOR AND NOT THE FIX. The two real fixes were both weighed and\n * both rejected for this side of the Sept 1 freeze:\n *\n * - Folding the decrement into the order transaction would restructure the\n * checkout path's largest and most contended transaction, days before\n * launch, on the path that takes money. The cart branch alone would have\n * to pull N product reads into a transaction that currently reads two\n * documents, and every contended retry would then re-run order numbering.\n * - A second replay flag with a re-entry path is worse, not better. The\n * `created` guard is not protecting the decrement — it is protecting\n * licence-key assignment, coupon redemption counters, gift-card balances,\n * receipt email and contact ingestion, all behind ONE flag. Any re-entry\n * past it needs every one of those to become individually idempotent\n * first, which is precisely the \"per-EFFECT idempotency sweep\" AGL-2157\n * named as the real fix and deferred for the same reason.\n *\n * And the flag is not even the only lock. Deleting it does not make a\n * redelivery decrement: `cartRef.delete()` runs between the guard and the\n * inventory loop, so the second delivery iterates an empty cart. A\n * resumable path would additionally have to stop reading the cart and\n * start reading the order's own `lineItems`. That is measured, not\n * assumed — `stock-decrement-reconciliation.spec.ts` pins it.\n *\n * This is read-only against orders, products and the ledger. The only thing\n * it writes is its own report marker, in a collection nothing else touches.\n */\n\n/**\n * The statuses an order can only be in if its stock decrement was ATTEMPTED.\n *\n * `pending` is excluded because it is the pre-payment state of a draft link\n * and a POS card sale — neither has decremented yet, and both are supposed to\n * look exactly like this. `cancelled` and `refunded` are excluded because\n * `cancel-order.ts` and `refund.ts` write `reason: 'cancellation'` /\n * `'refund'` rows and may have released the units already; a sale row missing\n * from an order that has been through a release is a question this detector\n * cannot answer from the ledger alone, and guessing would be worse than\n * staying quiet.\n */\nconst DECREMENTED_STATUSES: ReadonlySet<string> = new Set([\n 'paid',\n 'partially_fulfilled',\n 'fulfilled',\n 'delivered',\n])\n\n/**\n * How long an order gets to finish its own decrement before it counts as\n * diverged. The decrement runs milliseconds to seconds after the order\n * commits, so ten minutes is far past any healthy webhook — and a beat that\n * flagged an order the webhook was still working on would report a divergence\n * that fixed itself, which is the fastest way to make a signal ignored.\n */\nconst DEFAULT_GRACE_MS = 10 * 60 * 1000\n\nexport interface MissingSaleDecrement {\n hostId: string\n orderId: string\n /** The merchant-facing sequential number, when the doc carries one. */\n orderNumber: number | null\n orderStatus: string\n orderCreatedAtMs: number\n productId: string\n /** Live product name — the order's snapshot name is used as a fallback. */\n productName: string\n variantId: string\n /** Units the order says were sold, and the shelf therefore still holds. */\n quantity: number\n}\n\nexport interface StockReconciliationResult {\n ordersScanned: number\n /** Orders that reached the ledger comparison (post status/grace filter). */\n ordersChecked: number\n missing: MissingSaleDecrement[]\n /**\n * The ledger read hit its limit before reaching back as far as the oldest\n * order considered, so orders older than the ledger window were DROPPED\n * rather than judged against rows that were never read.\n *\n * Recorded and reported rather than swallowed, for the AGL-2321 reason:\n * `siteSizeTruncated` exists because a number that is silently a lower\n * bound is worse than one that says so. A truncated pass under-reports; it\n * must never be read as \"all matched\".\n */\n ledgerTruncated: boolean\n}\n\n/**\n * Compares one host's recently-decremented orders against the\n * `reason: 'sale'` rows joined to them, and reports the lines whose decrement\n * is missing.\n *\n * Reads are bounded and cheap in the healthy case: two collection queries per\n * host, and a product read ONLY for a line that already looks divergent.\n *\n * Two equality-free queries so the automatic single-field indexes answer them\n * (the `cancel-order.ts` rule): `orderBy(createdAtMs desc).limit()` and\n * `orderBy(atMs desc).limit()`, with the status test applied in memory over\n * the handful of documents that come back.\n */\nexport async function reconcileHostStockDecrements(options: {\n firestore: any\n hostRef: any\n hostId: string\n nowMs?: number\n graceMs?: number\n orderLimit?: number\n ledgerLimit?: number\n}): Promise<StockReconciliationResult> {\n const {\n hostRef,\n hostId,\n nowMs = Date.now(),\n graceMs = DEFAULT_GRACE_MS,\n orderLimit = 100,\n ledgerLimit = 500,\n } = options\n const empty: StockReconciliationResult = {\n ordersScanned: 0,\n ordersChecked: 0,\n missing: [],\n ledgerTruncated: false,\n }\n const orderSnapshot = await hostRef\n .collection('orders')\n .orderBy('createdAtMs', 'desc')\n .limit(orderLimit)\n .get()\n .catch(() => null)\n if (!orderSnapshot) return empty\n const orderDocs: any[] = orderSnapshot.docs ?? []\n const settledBefore = nowMs - graceMs\n let candidates = orderDocs\n .map((doc) => ({\n id: String(doc.id),\n status: String(doc.get('status') ?? ''),\n createdAtMs: Number(doc.get('createdAtMs') ?? 0),\n number: doc.get('number') == null ? null : Number(doc.get('number')),\n lineItems: (doc.get('lineItems') ?? []) as CommerceModel.OrderLineItem[],\n }))\n .filter(\n (order) =>\n DECREMENTED_STATUSES.has(order.status) &&\n order.createdAtMs > 0 &&\n order.createdAtMs <= settledBefore,\n )\n if (!candidates.length) {\n return { ...empty, ordersScanned: orderDocs.length }\n }\n const ledgerSnapshot = await hostRef\n .collection('inventoryAdjustments')\n .orderBy('atMs', 'desc')\n .limit(ledgerLimit)\n .get()\n .catch(() => null)\n if (!ledgerSnapshot) {\n return { ...empty, ordersScanned: orderDocs.length }\n }\n const ledgerDocs: any[] = ledgerSnapshot.docs ?? []\n // `orderId|productId` -> the variant ids that order's sale rows name.\n const soldVariants = new Map<string, Set<string>>()\n let oldestLedgerMs = Number.POSITIVE_INFINITY\n for (const row of ledgerDocs) {\n const atMs = Number(row.get('atMs') ?? 0)\n if (atMs > 0) oldestLedgerMs = Math.min(oldestLedgerMs, atMs)\n if (row.get('reason') !== 'sale') continue\n const orderId = String(row.get('orderId') ?? '')\n const productId = String(row.get('productId') ?? '')\n if (!orderId || !productId) continue\n const key = `${orderId}|${productId}`\n const seen = soldVariants.get(key) ?? new Set<string>()\n seen.add(String(row.get('variantId') ?? ''))\n soldVariants.set(key, seen)\n }\n // A ledger read that hit its limit says nothing about anything older than\n // its oldest row. Those orders are dropped, not judged.\n const ledgerTruncated =\n ledgerDocs.length >= ledgerLimit &&\n Number.isFinite(oldestLedgerMs) &&\n candidates.some((order) => order.createdAtMs < oldestLedgerMs)\n if (ledgerTruncated) {\n candidates = candidates.filter(\n (order) => order.createdAtMs >= oldestLedgerMs,\n )\n }\n const missing: MissingSaleDecrement[] = []\n const productCache = new Map<string, CommerceModel.HostProduct | null>()\n for (const order of candidates) {\n for (const line of order.lineItems ?? []) {\n const productId = String(line?.productId ?? '')\n const quantity = Number(line?.quantity ?? 0)\n if (!productId || quantity <= 0) continue\n // A digital or service line never decrements — `decrementVariantStock`\n // refuses an untracked variant — so its missing sale row is the correct\n // outcome and must not cost a product read on every pass. The line's\n // own purchase-time snapshot answers this; anything else falls through\n // to the live tracked-ness test below.\n if (line?.productType === 'digital' || line?.productType === 'service') {\n continue\n }\n const sold = soldVariants.get(`${order.id}|${productId}`)\n const declaredVariantId = line?.variantId ? String(line.variantId) : ''\n if (declaredVariantId && sold?.has(declaredVariantId)) continue\n if (!declaredVariantId && sold?.size) continue\n // Only now is a product read worth taking: this line already looks\n // divergent, and the live product is what says whether it could ever\n // have decremented at all.\n if (!productCache.has(productId)) {\n const productDoc = await hostRef\n .collection('products')\n .doc(productId)\n .get()\n .catch(() => null)\n productCache.set(\n productId,\n productDoc?.exists\n ? CommerceModel.liftLegacyProduct(productDoc.data() as any)\n : null,\n )\n }\n const product = productCache.get(productId)\n // A product deleted since the sale is the `unresolvedLines` case the\n // webhook already reports on the order itself (AGL-2149). Nothing here\n // can tell whether its variant was tracked, so it is left alone.\n if (!product) continue\n const variantId =\n declaredVariantId || String(product.variants[0]?.id ?? '')\n if (!variantId) continue\n // Re-asked for a line that named no variant: the ledger row records the\n // RESOLVED id, so the default variant has to be resolved before the\n // absence means anything.\n if (!declaredVariantId && sold?.has(variantId)) continue\n const tracked = product.variants.some(\n (variant) => variant.id === variantId && variant.inventory != null,\n )\n // An untracked variant is a shelf nobody is counting. Its sale row is\n // absent by design, and this is also the one place the detector can be\n // wrong in the merchant's favour: tracking turned ON after the sale\n // makes an order that correctly never decremented look divergent. The\n // window is bounded by `orderLimit` and the report says what it is.\n if (!tracked) continue\n missing.push({\n hostId,\n orderId: order.id,\n orderNumber: order.number,\n orderStatus: order.status,\n orderCreatedAtMs: order.createdAtMs,\n productId,\n productName: String(product.name ?? line?.name ?? 'Product'),\n variantId,\n quantity,\n })\n }\n }\n return {\n ordersScanned: orderDocs.length,\n ordersChecked: candidates.length,\n missing,\n ledgerTruncated,\n }\n}\n\n/**\n * Reports a host's divergences to its managers, once per order.\n *\n * The marker in `hosts/{hostId}/inventoryReconciliation/{orderId}` is the only\n * write this whole pass makes, and it lives in a collection nothing else on\n * the platform reads or writes — the detector must not be able to affect the\n * money path it is watching. Without it a beat would re-nag every hour about\n * an order the merchant has already corrected, and an alert that repeats\n * forever is one that gets muted.\n */\nexport async function reportMissingSaleDecrements(\n hostId: string,\n missing: readonly MissingSaleDecrement[],\n): Promise<number> {\n if (!missing.length) return 0\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(hostId)\n const byOrder = new Map<string, MissingSaleDecrement[]>()\n for (const row of missing) {\n byOrder.set(row.orderId, [...(byOrder.get(row.orderId) ?? []), row])\n }\n let reported = 0\n for (const [orderId, lines] of byOrder) {\n const markerRef = hostRef.collection('inventoryReconciliation').doc(orderId)\n const marker = await markerRef.get().catch(() => null)\n // A read that FAILED is not a marker that is absent. Skipping here costs\n // one delayed report; treating it as absent would re-notify every pass\n // for as long as the read kept failing.\n if (!marker || marker.exists) continue\n const units = lines.reduce((sum, line) => sum + line.quantity, 0)\n const label =\n lines[0].orderNumber == null ? orderId : `#${lines[0].orderNumber}`\n await markerRef\n .set({\n orderId,\n reportedAtMs: Date.now(),\n lines: lines.map((line) => ({\n productId: line.productId,\n variantId: line.variantId,\n quantity: line.quantity,\n })),\n })\n .catch(() => undefined)\n reported += 1\n console.error('commerce stock decrement missing', {\n hostId,\n orderId,\n lines: lines.map((line) => ({\n productId: line.productId,\n variantId: line.variantId,\n quantity: line.quantity,\n })),\n })\n void notifyHostManagers(hostId, {\n type: 'content.lowStock',\n title: `Stock never came off the shelf — order ${label}`,\n body:\n `Order ${label} is ${lines[0].orderStatus} but ${units} unit` +\n `${units === 1 ? '' : 's'} of ` +\n `${lines.map((line) => line.productName).join(', ')} were never ` +\n 'taken off the count, so the storefront is still offering stock ' +\n 'that has been sold. Adjust the count by hand.',\n link: `/${hostId}/products`,\n })\n }\n return reported\n}\n\nexport interface StockReconciliationScan {\n hosts: number\n ordersScanned: number\n missingLines: number\n reportedOrders: number\n truncatedHosts: number\n /** Hosts left unreconciled because they are locked (AGL-2495). */\n skippedLocked: number\n}\n\n/**\n * The platform beat: the most recent orders across every host, grouped by\n * host and reconciled one host at a time.\n *\n * Ordered by `createdAtMs` and NOT filtered by status, because an equality\n * filter plus an ordering is a composite index and the ordering alone is a\n * field override — the same trade `cancel-order.ts` takes. Every order writer\n * in the plugin stamps `createdAtMs` (cart, buy-now, draft, POS cash, POS\n * card, subscription cycle), so nothing is invisible to the ordering.\n */\nexport async function scanStockDecrements(\n /** The lockdown gate, injected by the caller (AGL-2495). Not optional. */\n gate: PluginJobHostGate,\n options?: {\n orderLimit?: number\n nowMs?: number\n },\n): Promise<StockReconciliationScan> {\n const firestore = firebaseAdmin.app().firestore()\n const snapshot = await firestore\n .collectionGroup('orders')\n .orderBy('createdAtMs', 'desc')\n .limit(options?.orderLimit ?? 300)\n .get()\n .catch((error: unknown) => {\n console.error('commerce stock reconciliation scan failed', error)\n return null\n })\n const scan: StockReconciliationScan = {\n hosts: 0,\n ordersScanned: 0,\n missingLines: 0,\n reportedOrders: 0,\n truncatedHosts: 0,\n skippedLocked: 0,\n }\n if (!snapshot) return scan\n const hostIds = new Set<string>()\n for (const doc of (snapshot.docs ?? []) as any[]) {\n const hostRef = doc.ref?.parent?.parent\n // `hosts/{hostId}/orders` is the only shape this collection group has\n // today; the check is what keeps that true if a second one appears.\n if (!hostRef || hostRef.parent?.id !== 'hosts') continue\n hostIds.add(String(hostRef.id))\n }\n scan.hosts = hostIds.size\n for (const hostId of hostIds) {\n // LOCKDOWN (AGL-2495). This pass posts a console notification and stamps\n // the order timeline, so it is a write for the host — and on a site taken\n // down for abuse or a legal order, telling its managers about stock is\n // both a mutation and a message the takedown was meant to stop.\n //\n // SKIPPED, NOT DROPPED: the reconciliation is a DETECTOR with no cursor.\n // It re-derives everything from the last N orders on every beat, so a\n // host skipped now is reconciled in full on the first hourly beat after\n // the lift, and nothing it would have found is lost by waiting.\n if (await gate.isLocked(hostId)) {\n scan.skippedLocked += 1\n continue\n }\n const result = await reconcileHostStockDecrements({\n firestore,\n hostRef: firestore.collection('hosts').doc(hostId),\n hostId,\n ...(options?.nowMs == null ? {} : { nowMs: options.nowMs }),\n })\n scan.ordersScanned += result.ordersScanned\n scan.missingLines += result.missing.length\n if (result.ledgerTruncated) scan.truncatedHosts += 1\n scan.reportedOrders += await reportMissingSaleDecrements(\n hostId,\n result.missing,\n )\n }\n return scan\n}\n"],"names":["firebaseAdmin","notifyHostManagers","CommerceModel","DECREMENTED_STATUSES","Set","DEFAULT_GRACE_MS","reconcileHostStockDecrements","options","orderSnapshot","ledgerSnapshot","hostRef","hostId","nowMs","Date","now","graceMs","orderLimit","ledgerLimit","empty","ordersScanned","ordersChecked","missing","ledgerTruncated","collection","orderBy","limit","get","catch","orderDocs","docs","settledBefore","candidates","map","doc","id","String","status","createdAtMs","Number","number","lineItems","filter","order","has","length","ledgerDocs","soldVariants","Map","oldestLedgerMs","POSITIVE_INFINITY","row","atMs","Math","min","orderId","productId","key","seen","add","set","isFinite","some","productCache","line","product","quantity","productType","sold","declaredVariantId","variantId","size","productDoc","exists","liftLegacyProduct","data","variants","tracked","variant","inventory","push","orderNumber","orderStatus","orderCreatedAtMs","productName","name","reportMissingSaleDecrements","firestore","app","byOrder","reported","lines","markerRef","marker","units","reduce","sum","label","reportedAtMs","undefined","console","error","type","title","body","join","link","scanStockDecrements","gate","snapshot","collectionGroup","scan","hosts","missingLines","reportedOrders","truncatedHosts","skippedLocked","hostIds","ref","parent","isLocked","result"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/reconcile-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 { type PluginJobHostGate } from '@aglyn/aglyn/server'\nimport { firebaseAdmin, notifyHostManagers } from '@aglyn/tenant-data-admin'\nimport * as CommerceModel from '../model'\n\n/**\n * THE MISSING-DECREMENT DETECTOR (AGL-2358).\n *\n * Every order-minting branch of `billing-webhook.ts` commits the order in a\n * transaction and decrements stock AFTER it, and every branch guards on a\n * replay flag that transaction set — `created` for cart and buy-now,\n * `flipped` for the draft/POS-card branch, the invoice-id guard for a\n * subscription cycle. So a process that dies in the window between the two\n * loses the decrement PERMANENTLY: Stripe redelivers, the guard says the\n * order already exists, and the handler returns without ever reaching the\n * stock write. The merchant has a paid order and an untouched shelf, and\n * before this nothing anywhere recorded that they had diverged.\n *\n * `/api/billing/webhook`'s own claim marker makes the same window wider, not\n * narrower: AGL-2157 decided that an event which throws mid-dispatch KEEPS\n * its claim — deliberately, because re-running the dispatch could double-apply\n * a gift-card spend or a coupon redemption — and told staff to \"reconcile by\n * hand\". This is the thing they reconcile with. It is also the only detector\n * for the death AGL-2157 cannot catch at all: a timeout, an OOM or a deploy\n * kill unwinds no stack, so that route's `catch` never runs and no staff\n * notice is ever raised.\n *\n * WHY A DETECTOR AND NOT THE FIX. The two real fixes were both weighed and\n * both rejected for this side of the Sept 1 freeze:\n *\n * - Folding the decrement into the order transaction would restructure the\n * checkout path's largest and most contended transaction, days before\n * launch, on the path that takes money. The cart branch alone would have\n * to pull N product reads into a transaction that currently reads two\n * documents, and every contended retry would then re-run order numbering.\n * - A second replay flag with a re-entry path is worse, not better. The\n * `created` guard is not protecting the decrement — it is protecting\n * licence-key assignment, coupon redemption counters, gift-card balances,\n * receipt email and contact ingestion, all behind ONE flag. Any re-entry\n * past it needs every one of those to become individually idempotent\n * first, which is precisely the \"per-EFFECT idempotency sweep\" AGL-2157\n * named as the real fix and deferred for the same reason.\n *\n * And the flag is not even the only lock. Deleting it does not make a\n * redelivery decrement: `cartRef.delete()` runs between the guard and the\n * inventory loop, so the second delivery iterates an empty cart. A\n * resumable path would additionally have to stop reading the cart and\n * start reading the order's own `lineItems`. That is measured, not\n * assumed — `stock-decrement-reconciliation.spec.ts` pins it.\n *\n * This is read-only against orders, products and the ledger. The only thing\n * it writes is its own report marker, in a collection nothing else touches.\n */\n\n/**\n * The statuses an order can only be in if its stock decrement was ATTEMPTED.\n *\n * `pending` is excluded because it is the pre-payment state of a draft link\n * and a POS card sale — neither has decremented yet, and both are supposed to\n * look exactly like this. `cancelled` and `refunded` are excluded because\n * `cancel-order.ts` and `refund.ts` write `reason: 'cancellation'` /\n * `'refund'` rows and may have released the units already; a sale row missing\n * from an order that has been through a release is a question this detector\n * cannot answer from the ledger alone, and guessing would be worse than\n * staying quiet.\n */\nconst DECREMENTED_STATUSES: ReadonlySet<string> = new Set([\n 'paid',\n 'partially_fulfilled',\n 'fulfilled',\n 'delivered',\n])\n\n/**\n * How long an order gets to finish its own decrement before it counts as\n * diverged. The decrement runs milliseconds to seconds after the order\n * commits, so ten minutes is far past any healthy webhook — and a beat that\n * flagged an order the webhook was still working on would report a divergence\n * that fixed itself, which is the fastest way to make a signal ignored.\n */\nconst DEFAULT_GRACE_MS = 10 * 60 * 1000\n\nexport interface MissingSaleDecrement {\n hostId: string\n orderId: string\n /** The merchant-facing sequential number, when the doc carries one. */\n orderNumber: number | null\n orderStatus: string\n orderCreatedAtMs: number\n productId: string\n /** Live product name — the order's snapshot name is used as a fallback. */\n productName: string\n variantId: string\n /** Units the order says were sold, and the shelf therefore still holds. */\n quantity: number\n}\n\nexport interface StockReconciliationResult {\n ordersScanned: number\n /** Orders that reached the ledger comparison (post status/grace filter). */\n ordersChecked: number\n missing: MissingSaleDecrement[]\n /**\n * The ledger read hit its limit before reaching back as far as the oldest\n * order considered, so orders older than the ledger window were DROPPED\n * rather than judged against rows that were never read.\n *\n * Recorded and reported rather than swallowed, for the AGL-2321 reason:\n * `siteSizeTruncated` exists because a number that is silently a lower\n * bound is worse than one that says so. A truncated pass under-reports; it\n * must never be read as \"all matched\".\n */\n ledgerTruncated: boolean\n}\n\n/**\n * Compares one host's recently-decremented orders against the\n * `reason: 'sale'` rows joined to them, and reports the lines whose decrement\n * is missing.\n *\n * Reads are bounded and cheap in the healthy case: two collection queries per\n * host, and a product read ONLY for a line that already looks divergent.\n *\n * Two equality-free queries so the automatic single-field indexes answer them\n * (the `cancel-order.ts` rule): `orderBy(createdAtMs desc).limit()` and\n * `orderBy(atMs desc).limit()`, with the status test applied in memory over\n * the handful of documents that come back.\n */\nexport async function reconcileHostStockDecrements(options: {\n firestore: any\n hostRef: any\n hostId: string\n nowMs?: number\n graceMs?: number\n orderLimit?: number\n ledgerLimit?: number\n}): Promise<StockReconciliationResult> {\n const {\n hostRef,\n hostId,\n nowMs = Date.now(),\n graceMs = DEFAULT_GRACE_MS,\n orderLimit = 100,\n ledgerLimit = 500,\n } = options\n const empty: StockReconciliationResult = {\n ordersScanned: 0,\n ordersChecked: 0,\n missing: [],\n ledgerTruncated: false,\n }\n const orderSnapshot = await hostRef\n .collection('orders')\n .orderBy('createdAtMs', 'desc')\n .limit(orderLimit)\n .get()\n .catch(() => null)\n if (!orderSnapshot) return empty\n const orderDocs: any[] = orderSnapshot.docs ?? []\n const settledBefore = nowMs - graceMs\n let candidates = orderDocs\n .map((doc) => ({\n id: String(doc.id),\n status: String(doc.get('status') ?? ''),\n createdAtMs: Number(doc.get('createdAtMs') ?? 0),\n number: doc.get('number') == null ? null : Number(doc.get('number')),\n lineItems: (doc.get('lineItems') ?? []) as CommerceModel.OrderLineItem[],\n }))\n .filter(\n (order) =>\n DECREMENTED_STATUSES.has(order.status) &&\n order.createdAtMs > 0 &&\n order.createdAtMs <= settledBefore,\n )\n if (!candidates.length) {\n return { ...empty, ordersScanned: orderDocs.length }\n }\n const ledgerSnapshot = await hostRef\n .collection('inventoryAdjustments')\n .orderBy('atMs', 'desc')\n .limit(ledgerLimit)\n .get()\n .catch(() => null)\n if (!ledgerSnapshot) {\n return { ...empty, ordersScanned: orderDocs.length }\n }\n const ledgerDocs: any[] = ledgerSnapshot.docs ?? []\n // `orderId|productId` -> the variant ids that order's sale rows name.\n const soldVariants = new Map<string, Set<string>>()\n let oldestLedgerMs = Number.POSITIVE_INFINITY\n for (const row of ledgerDocs) {\n const atMs = Number(row.get('atMs') ?? 0)\n if (atMs > 0) oldestLedgerMs = Math.min(oldestLedgerMs, atMs)\n if (row.get('reason') !== 'sale') continue\n const orderId = String(row.get('orderId') ?? '')\n const productId = String(row.get('productId') ?? '')\n if (!orderId || !productId) continue\n const key = `${orderId}|${productId}`\n const seen = soldVariants.get(key) ?? new Set<string>()\n seen.add(String(row.get('variantId') ?? ''))\n soldVariants.set(key, seen)\n }\n // A ledger read that hit its limit says nothing about anything older than\n // its oldest row. Those orders are dropped, not judged.\n const ledgerTruncated =\n ledgerDocs.length >= ledgerLimit &&\n Number.isFinite(oldestLedgerMs) &&\n candidates.some((order) => order.createdAtMs < oldestLedgerMs)\n if (ledgerTruncated) {\n candidates = candidates.filter(\n (order) => order.createdAtMs >= oldestLedgerMs,\n )\n }\n const missing: MissingSaleDecrement[] = []\n const productCache = new Map<string, CommerceModel.HostProduct | null>()\n for (const order of candidates) {\n for (const line of order.lineItems ?? []) {\n const productId = String(line?.productId ?? '')\n const quantity = Number(line?.quantity ?? 0)\n if (!productId || quantity <= 0) continue\n // A digital or service line never decrements — `decrementVariantStock`\n // refuses an untracked variant — so its missing sale row is the correct\n // outcome and must not cost a product read on every pass. The line's\n // own purchase-time snapshot answers this; anything else falls through\n // to the live tracked-ness test below.\n if (line?.productType === 'digital' || line?.productType === 'service') {\n continue\n }\n const sold = soldVariants.get(`${order.id}|${productId}`)\n const declaredVariantId = line?.variantId ? String(line.variantId) : ''\n if (declaredVariantId && sold?.has(declaredVariantId)) continue\n if (!declaredVariantId && sold?.size) continue\n // Only now is a product read worth taking: this line already looks\n // divergent, and the live product is what says whether it could ever\n // have decremented at all.\n if (!productCache.has(productId)) {\n const productDoc = await hostRef\n .collection('products')\n .doc(productId)\n .get()\n .catch(() => null)\n productCache.set(\n productId,\n productDoc?.exists\n ? CommerceModel.liftLegacyProduct(productDoc.data() as any)\n : null,\n )\n }\n const product = productCache.get(productId)\n // A product deleted since the sale is the `unresolvedLines` case the\n // webhook already reports on the order itself (AGL-2149). Nothing here\n // can tell whether its variant was tracked, so it is left alone.\n if (!product) continue\n const variantId =\n declaredVariantId || String(product.variants[0]?.id ?? '')\n if (!variantId) continue\n // Re-asked for a line that named no variant: the ledger row records the\n // RESOLVED id, so the default variant has to be resolved before the\n // absence means anything.\n if (!declaredVariantId && sold?.has(variantId)) continue\n const tracked = product.variants.some(\n (variant) => variant.id === variantId && variant.inventory != null,\n )\n // An untracked variant is a shelf nobody is counting. Its sale row is\n // absent by design, and this is also the one place the detector can be\n // wrong in the merchant's favour: tracking turned ON after the sale\n // makes an order that correctly never decremented look divergent. The\n // window is bounded by `orderLimit` and the report says what it is.\n if (!tracked) continue\n missing.push({\n hostId,\n orderId: order.id,\n orderNumber: order.number,\n orderStatus: order.status,\n orderCreatedAtMs: order.createdAtMs,\n productId,\n productName: String(product.name ?? line?.name ?? 'Product'),\n variantId,\n quantity,\n })\n }\n }\n return {\n ordersScanned: orderDocs.length,\n ordersChecked: candidates.length,\n missing,\n ledgerTruncated,\n }\n}\n\n/**\n * Reports a host's divergences to its managers, once per order.\n *\n * The marker in `hosts/{hostId}/inventoryReconciliation/{orderId}` is the only\n * write this whole pass makes, and it lives in a collection nothing else on\n * the platform reads or writes — the detector must not be able to affect the\n * money path it is watching. Without it a beat would re-nag every hour about\n * an order the merchant has already corrected, and an alert that repeats\n * forever is one that gets muted.\n */\nexport async function reportMissingSaleDecrements(\n hostId: string,\n missing: readonly MissingSaleDecrement[],\n): Promise<number> {\n if (!missing.length) return 0\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(hostId)\n const byOrder = new Map<string, MissingSaleDecrement[]>()\n for (const row of missing) {\n byOrder.set(row.orderId, [...(byOrder.get(row.orderId) ?? []), row])\n }\n let reported = 0\n for (const [orderId, lines] of byOrder) {\n const markerRef = hostRef.collection('inventoryReconciliation').doc(orderId)\n const marker = await markerRef.get().catch(() => null)\n // A read that FAILED is not a marker that is absent. Skipping here costs\n // one delayed report; treating it as absent would re-notify every pass\n // for as long as the read kept failing.\n if (!marker || marker.exists) continue\n const units = lines.reduce((sum, line) => sum + line.quantity, 0)\n const label =\n lines[0].orderNumber == null ? orderId : `#${lines[0].orderNumber}`\n await markerRef\n .set({\n orderId,\n reportedAtMs: Date.now(),\n lines: lines.map((line) => ({\n productId: line.productId,\n variantId: line.variantId,\n quantity: line.quantity,\n })),\n })\n .catch(() => undefined)\n reported += 1\n console.error('commerce stock decrement missing', {\n hostId,\n orderId,\n lines: lines.map((line) => ({\n productId: line.productId,\n variantId: line.variantId,\n quantity: line.quantity,\n })),\n })\n void notifyHostManagers(hostId, {\n type: 'content.lowStock',\n title: `Stock never came off the shelf — order ${label}`,\n body:\n `Order ${label} on {site} is ${lines[0].orderStatus} but ${units} unit` +\n `${units === 1 ? '' : 's'} of ` +\n `${lines.map((line) => line.productName).join(', ')} were never ` +\n 'taken off the count, so the storefront is still offering stock ' +\n 'that has been sold. Adjust the count by hand.',\n link: `/${hostId}/products`,\n })\n }\n return reported\n}\n\nexport interface StockReconciliationScan {\n hosts: number\n ordersScanned: number\n missingLines: number\n reportedOrders: number\n truncatedHosts: number\n /** Hosts left unreconciled because they are locked (AGL-2495). */\n skippedLocked: number\n}\n\n/**\n * The platform beat: the most recent orders across every host, grouped by\n * host and reconciled one host at a time.\n *\n * Ordered by `createdAtMs` and NOT filtered by status, because an equality\n * filter plus an ordering is a composite index and the ordering alone is a\n * field override — the same trade `cancel-order.ts` takes. Every order writer\n * in the plugin stamps `createdAtMs` (cart, buy-now, draft, POS cash, POS\n * card, subscription cycle), so nothing is invisible to the ordering.\n */\nexport async function scanStockDecrements(\n /** The lockdown gate, injected by the caller (AGL-2495). Not optional. */\n gate: PluginJobHostGate,\n options?: {\n orderLimit?: number\n nowMs?: number\n },\n): Promise<StockReconciliationScan> {\n const firestore = firebaseAdmin.app().firestore()\n const snapshot = await firestore\n .collectionGroup('orders')\n .orderBy('createdAtMs', 'desc')\n .limit(options?.orderLimit ?? 300)\n .get()\n .catch((error: unknown) => {\n console.error('commerce stock reconciliation scan failed', error)\n return null\n })\n const scan: StockReconciliationScan = {\n hosts: 0,\n ordersScanned: 0,\n missingLines: 0,\n reportedOrders: 0,\n truncatedHosts: 0,\n skippedLocked: 0,\n }\n if (!snapshot) return scan\n const hostIds = new Set<string>()\n for (const doc of (snapshot.docs ?? []) as any[]) {\n const hostRef = doc.ref?.parent?.parent\n // `hosts/{hostId}/orders` is the only shape this collection group has\n // today; the check is what keeps that true if a second one appears.\n if (!hostRef || hostRef.parent?.id !== 'hosts') continue\n hostIds.add(String(hostRef.id))\n }\n scan.hosts = hostIds.size\n for (const hostId of hostIds) {\n // LOCKDOWN (AGL-2495). This pass posts a console notification and stamps\n // the order timeline, so it is a write for the host — and on a site taken\n // down for abuse or a legal order, telling its managers about stock is\n // both a mutation and a message the takedown was meant to stop.\n //\n // SKIPPED, NOT DROPPED: the reconciliation is a DETECTOR with no cursor.\n // It re-derives everything from the last N orders on every beat, so a\n // host skipped now is reconciled in full on the first hourly beat after\n // the lift, and nothing it would have found is lost by waiting.\n if (await gate.isLocked(hostId)) {\n scan.skippedLocked += 1\n continue\n }\n const result = await reconcileHostStockDecrements({\n firestore,\n hostRef: firestore.collection('hosts').doc(hostId),\n hostId,\n ...(options?.nowMs == null ? {} : { nowMs: options.nowMs }),\n })\n scan.ordersScanned += result.ordersScanned\n scan.missingLines += result.missing.length\n if (result.ledgerTruncated) scan.truncatedHosts += 1\n scan.reportedOrders += await reportMissingSaleDecrements(\n hostId,\n result.missing,\n )\n }\n return scan\n}\n"],"names":["firebaseAdmin","notifyHostManagers","CommerceModel","DECREMENTED_STATUSES","Set","DEFAULT_GRACE_MS","reconcileHostStockDecrements","options","orderSnapshot","ledgerSnapshot","hostRef","hostId","nowMs","Date","now","graceMs","orderLimit","ledgerLimit","empty","ordersScanned","ordersChecked","missing","ledgerTruncated","collection","orderBy","limit","get","catch","orderDocs","docs","settledBefore","candidates","map","doc","id","String","status","createdAtMs","Number","number","lineItems","filter","order","has","length","ledgerDocs","soldVariants","Map","oldestLedgerMs","POSITIVE_INFINITY","row","atMs","Math","min","orderId","productId","key","seen","add","set","isFinite","some","productCache","line","product","quantity","productType","sold","declaredVariantId","variantId","size","productDoc","exists","liftLegacyProduct","data","variants","tracked","variant","inventory","push","orderNumber","orderStatus","orderCreatedAtMs","productName","name","reportMissingSaleDecrements","firestore","app","byOrder","reported","lines","markerRef","marker","units","reduce","sum","label","reportedAtMs","undefined","console","error","type","title","body","join","link","scanStockDecrements","gate","snapshot","collectionGroup","scan","hosts","missingLines","reportedOrders","truncatedHosts","skippedLocked","hostIds","ref","parent","isLocked","result"],"mappings":"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"}
@@ -132,10 +132,15 @@ import * as CommerceModel from "../model/index.js";
132
132
  // ask for and previously got no signal about at all.
133
133
  const short = outcome.applied - outcome.requested;
134
134
  if (short > 0 && outcome.before && outcome.before.oversellPolicy !== 'backorder') {
135
+ // What was sold, where, and what is short, in the body (AGL-3432),
136
+ // which an email reader sees without the title.
137
+ const sold = Math.abs(outcome.requested);
138
+ const inStock = Math.abs(outcome.applied);
139
+ const onShelf = inStock === 0 ? 'none was' : `only ${inStock} ${inStock === 1 ? 'was' : 'were'}`;
135
140
  void notifyHostManagers(hostId, {
136
141
  type: 'content.lowStock',
137
142
  title: `Oversold — ${outcome.before.name}`,
138
- body: `${Math.abs(outcome.requested)} sold, ${Math.abs(outcome.applied)} ` + `came off the shelf. ${short} unit${short === 1 ? '' : 's'} were not ` + 'in stock — the sale is paid and needs restocking or refunding.',
143
+ body: `An order on {site} was paid for ${sold}× ${outcome.before.name}, ` + `but ${onShelf} in stock. ` + `${short} unit${short === 1 ? ' is' : 's are'} short: restock ` + `${short === 1 ? 'it' : 'them'} or refund the difference.`,
139
144
  link: `/${hostId}/products`
140
145
  });
141
146
  }