@aglyn/plugins-commerce 1.0.0-beta.199 → 1.0.0-beta.200

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 (97) hide show
  1. package/package.json +13 -13
  2. package/src/lib/components/console/catalog-organization-card.component.js +48 -3
  3. package/src/lib/components/console/catalog-organization-card.component.js.map +1 -1
  4. package/src/lib/components/console/gift-cards-card.component.d.ts +9 -0
  5. package/src/lib/components/console/gift-cards-card.component.js +53 -28
  6. package/src/lib/components/console/gift-cards-card.component.js.map +1 -1
  7. package/src/lib/components/console/host-orders-card.component.d.ts +26 -0
  8. package/src/lib/components/console/host-orders-card.component.js +264 -194
  9. package/src/lib/components/console/host-orders-card.component.js.map +1 -1
  10. package/src/lib/components/console/pos-page.component.d.ts +14 -0
  11. package/src/lib/components/console/pos-page.component.js +86 -45
  12. package/src/lib/components/console/pos-page.component.js.map +1 -1
  13. package/src/lib/components/console/product-editor-dialog.component.js +30 -6
  14. package/src/lib/components/console/product-editor-dialog.component.js.map +1 -1
  15. package/src/lib/components/console/product-slugs.d.ts +43 -0
  16. package/src/lib/components/console/product-slugs.js +60 -0
  17. package/src/lib/components/console/product-slugs.js.map +1 -0
  18. package/src/lib/components/console/products-hub-card.component.d.ts +2 -2
  19. package/src/lib/components/console/products-hub-card.component.js +229 -282
  20. package/src/lib/components/console/products-hub-card.component.js.map +1 -1
  21. package/src/lib/components/console/products-hub-zone.component.d.ts +10 -5
  22. package/src/lib/components/console/products-hub-zone.component.js +32 -9
  23. package/src/lib/components/console/products-hub-zone.component.js.map +1 -1
  24. package/src/lib/components/console/recovery-queue-card.component.d.ts +37 -5
  25. package/src/lib/components/console/recovery-queue-card.component.js +107 -86
  26. package/src/lib/components/console/recovery-queue-card.component.js.map +1 -1
  27. package/src/lib/components/console/reservations-card.component.js +15 -3
  28. package/src/lib/components/console/reservations-card.component.js.map +1 -1
  29. package/src/lib/components/console/smart-collections.d.ts +32 -0
  30. package/src/lib/components/console/smart-collections.js +57 -0
  31. package/src/lib/components/console/smart-collections.js.map +1 -0
  32. package/src/lib/components/console/stock-movements-card.component.d.ts +13 -4
  33. package/src/lib/components/console/stock-movements-card.component.js +142 -180
  34. package/src/lib/components/console/stock-movements-card.component.js.map +1 -1
  35. package/src/lib/components/product-grid.d.ts +5 -0
  36. package/src/lib/components/product-grid.js +95 -34
  37. package/src/lib/components/product-grid.js.map +1 -1
  38. package/src/lib/constants/orders-list-query.d.ts +40 -0
  39. package/src/lib/constants/orders-list-query.js +205 -0
  40. package/src/lib/constants/orders-list-query.js.map +1 -0
  41. package/src/lib/constants/product-list-query.d.ts +42 -0
  42. package/src/lib/constants/product-list-query.js +217 -0
  43. package/src/lib/constants/product-list-query.js.map +1 -0
  44. package/src/lib/constants/stock-movement-query.d.ts +27 -0
  45. package/src/lib/constants/stock-movement-query.js +110 -0
  46. package/src/lib/constants/stock-movement-query.js.map +1 -0
  47. package/src/lib/constants/storefront-catalog-query.d.ts +103 -0
  48. package/src/lib/constants/storefront-catalog-query.js +312 -0
  49. package/src/lib/constants/storefront-catalog-query.js.map +1 -0
  50. package/src/lib/model/checkout-recovery.d.ts +37 -0
  51. package/src/lib/model/checkout-recovery.js +44 -0
  52. package/src/lib/model/checkout-recovery.js.map +1 -0
  53. package/src/lib/model/commerce.d.ts +80 -0
  54. package/src/lib/model/commerce.js +67 -6
  55. package/src/lib/model/commerce.js.map +1 -1
  56. package/src/lib/model/gift-card-search.d.ts +24 -0
  57. package/src/lib/model/gift-card-search.js +48 -0
  58. package/src/lib/model/gift-card-search.js.map +1 -0
  59. package/src/lib/model/index.d.ts +1 -0
  60. package/src/lib/model/index.js +1 -0
  61. package/src/lib/model/index.js.map +1 -1
  62. package/src/lib/model/order-list-fields.d.ts +80 -0
  63. package/src/lib/model/order-list-fields.js +112 -0
  64. package/src/lib/model/order-list-fields.js.map +1 -0
  65. package/src/lib/server/billing-webhook.js +108 -89
  66. package/src/lib/server/billing-webhook.js.map +1 -1
  67. package/src/lib/server/cancel-order.js +6 -6
  68. package/src/lib/server/cancel-order.js.map +1 -1
  69. package/src/lib/server/cart-checkout.js +6 -0
  70. package/src/lib/server/cart-checkout.js.map +1 -1
  71. package/src/lib/server/catalog.d.ts +46 -30
  72. package/src/lib/server/catalog.js +492 -170
  73. package/src/lib/server/catalog.js.map +1 -1
  74. package/src/lib/server/collection-membership.d.ts +35 -0
  75. package/src/lib/server/collection-membership.js +172 -0
  76. package/src/lib/server/collection-membership.js.map +1 -0
  77. package/src/lib/server/draft-order.js +3 -2
  78. package/src/lib/server/draft-order.js.map +1 -1
  79. package/src/lib/server/gift-cards.js +3 -0
  80. package/src/lib/server/gift-cards.js.map +1 -1
  81. package/src/lib/server/member-name-search.d.ts +17 -0
  82. package/src/lib/server/member-name-search.js +23 -1
  83. package/src/lib/server/member-name-search.js.map +1 -1
  84. package/src/lib/server/membership-account.js +7 -3
  85. package/src/lib/server/membership-account.js.map +1 -1
  86. package/src/lib/server/membership-register.js +3 -1
  87. package/src/lib/server/membership-register.js.map +1 -1
  88. package/src/lib/server/pos-order.js +6 -4
  89. package/src/lib/server/pos-order.js.map +1 -1
  90. package/src/lib/server/process-abandoned.js +14 -5
  91. package/src/lib/server/process-abandoned.js.map +1 -1
  92. package/src/lib/server/reserve-stock.js +3 -4
  93. package/src/lib/server/reserve-stock.js.map +1 -1
  94. package/src/lib/server/site-page-enricher.js +3 -3
  95. package/src/lib/server/site-page-enricher.js.map +1 -1
  96. package/src/lib/server.js +4 -0
  97. package/src/lib/server.js.map +1 -1
@@ -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 loadHostEmail,\n renderLoadedHostEmail,\n sendEmail,\n type LoadedHostEmail,\n} from '@aglyn/shared-util-email'\nimport {\n type PluginApiHandler,\n type PluginJobHostGate,\n pluginJobHostGate,\n} from '@aglyn/aglyn/server'\nimport { composeHostComponentNodes } from '@aglyn/aglyn/app-utils/load-referenced-components'\n\nconst REMIND_AFTER_MS = 60 * 60 * 1000\nconst GIVE_UP_AFTER_MS = 7 * 24 * 60 * 60 * 1000\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, LoadedHostEmail | 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 if (!data.email || data.remindedAtMs) continue\n if (now - createdAtMs < REMIND_AFTER_MS) continue\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 (!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 loadHostEmail(firestore, hostId, 'abandoned-cart', {\n // The site's header and footer blocks, grafted once per site for the\n // whole sweep (AGL-3287).\n compose: composeHostComponentNodes,\n })\n templateCache.set(hostId, loaded)\n }\n const designed = loaded\n ? renderLoadedHostEmail(\n loaded,\n { 'cart.url': String(data.resumeUrl ?? '') },\n Aglyn.sanitizeAuthorHtml,\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 await docSnapshot.ref\n .set({ remindedAtMs: now }, { 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(pluginJobHostGate()))\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","loadHostEmail","renderLoadedHostEmail","sendEmail","pluginJobHostGate","composeHostComponentNodes","REMIND_AFTER_MS","GIVE_UP_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","email","remindedAtMs","set","status","merge","catch","undefined","has","org","checkEntitlement","resolveBrandingProfile","consentGroupForHost","hostSnapshot","collection","doc","hostPublicOrigin","cname","subdomain","loaded","compose","designed","String","resumeUrl","sanitizeAuthorHtml","consentGroup","soloConsentGroup","result","to","subject","text","html","fromName","sendingIdentity","audience","context","priority","marketing","siteBase","topicId","EMAIL_TOPIC_MARKETING","consentHostIds","hostIds","consentAwaitsConfirmation","awaitsConfirmation","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 loadHostEmail,\n renderLoadedHostEmail,\n sendEmail,\n type LoadedHostEmail,\n} from '@aglyn/shared-util-email'\nimport {\n type PluginApiHandler,\n type PluginJobHostGate,\n pluginJobHostGate,\n} from '@aglyn/aglyn/server'\nimport { composeHostComponentNodes } from '@aglyn/aglyn/app-utils/load-referenced-components'\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, LoadedHostEmail | 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 loadHostEmail(firestore, hostId, 'abandoned-cart', {\n // The site's header and footer blocks, grafted once per site for the\n // whole sweep (AGL-3287).\n compose: composeHostComponentNodes,\n })\n templateCache.set(hostId, loaded)\n }\n const designed = loaded\n ? renderLoadedHostEmail(\n loaded,\n { 'cart.url': String(data.resumeUrl ?? '') },\n Aglyn.sanitizeAuthorHtml,\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(pluginJobHostGate()))\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","loadHostEmail","renderLoadedHostEmail","sendEmail","pluginJobHostGate","composeHostComponentNodes","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","compose","designed","String","resumeUrl","sanitizeAuthorHtml","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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"}
@@ -80,10 +80,9 @@ import * as CommerceModel from "../model/index.js";
80
80
  const after = _extends({}, before, {
81
81
  variants
82
82
  });
83
- transaction.set(productRef, {
84
- variants,
85
- inventory: CommerceModel.productInventory(after)
86
- }, {
83
+ transaction.set(productRef, _extends({
84
+ variants
85
+ }, CommerceModel.productStockFields(after)), {
87
86
  merge: true
88
87
  });
89
88
  // Same transaction, so the count and the row commit together or neither
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/reserve-stock.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { notifyHostManagers } from '@aglyn/tenant-data-admin'\nimport * as CommerceModel from '../model'\n\n/**\n * THE ATOMIC STOCK DECREMENT (AGL-2320).\n *\n * Every stock write in this plugin was a read → compute → `set(…, {merge:true})`\n * of the whole `variants` array, with the read often far upstream of the write:\n * the cart branch of `billing-webhook.ts` reads its products, then runs a\n * transaction, a `cartRef.delete()`, a checkout-doc read, licence-key\n * assignment and an email send before it decrements. Two sales landing in that\n * window both computed from the same `variants` and both wrote; the later won\n * and one decrement was LOST. The ledger showed two `sale` rows while the count\n * moved once, so the shelf was short by one and the storefront went on offering\n * the unit it had already sold — the loss compounds, because phantom stock is\n * what admits the next oversell.\n *\n * `inventory` lives INSIDE the `variants` array, not at a document field path,\n * so `FieldValue.increment` is not expressible against it. That is why this was\n * filed as blocked on a data-model change. It is not the only way to make a\n * decrement safe: a transaction is. `Transaction.get()` takes a lock on the\n * product document, so two concurrent decrements SERIALIZE — the loser re-runs\n * its callback against the winner's committed state and subtracts from the real\n * remainder. The read-modify-write stays, the schema is untouched, and no\n * migration is needed. The cost is one contended retry, not one extra read.\n *\n * WHAT THIS DOES NOT DO. It does not refuse a sale. Every storefront path\n * gates on `canPurchase` when the Stripe Checkout session is created and\n * decrements minutes later in the webhook, by which time the money has moved —\n * refusing there would take payment and withhold goods. Closing the last-unit\n * oversell needs a HOLD taken at session creation and released when the session\n * expires, which is a feature with a new failure mode (an abandoned cart\n * sitting on the last unit for the session's lifetime) and is deliberately not\n * being introduced days before launch. So the oversell is made LOUD instead of\n * silent: when the fresh read proves the shelf could not cover the sale, the\n * managers are told, with the number that was actually short.\n *\n * The floor in `adjustVariantInventory` stays — a shelf count does not go\n * negative, and every reader assumes it cannot (AGL-2149). What changes is that\n * `applied` is now computed from the state the write actually lands on, so the\n * ledger's `appliedDelta` tells the truth under concurrency. Before this, two\n * concurrent sales of one unit each computed `appliedDelta: -1` from the same\n * stale `inventory: 1`, and the ledger claimed two units left a shelf that only\n * ever held one.\n */\nexport interface StockDecrementOutcome {\n /**\n * What the count ACTUALLY moved by — zero or negative, and never more than\n * the shelf held. The caller writes this as the ledger row's `appliedDelta`\n * (AGL-2149) when it differs from `requested`.\n */\n applied: number\n /** The units sold, negated. */\n requested: number\n /**\n * The product as read INSIDE the transaction, and the same product with the\n * committed variants. Both null when nothing was written — a missing product,\n * an untracked variant, or a failed commit. Handed to\n * `alertLowStockCrossing` as the pre/post pair, which is now the pair the\n * write actually crossed rather than one computed from an upstream read.\n */\n before: CommerceModel.HostProduct | null\n after: CommerceModel.HostProduct | null\n /**\n * The commit did not land. Distinct from `applied === 0`, which is a real\n * outcome (a deny-policy product already at zero). A caller must not write a\n * ledger row for a failed commit: a row whose count never moved is exactly\n * what makes a later cancellation hand back units nobody has.\n */\n failed: boolean\n}\n\n/**\n * What the ledger row joins this movement to (AGL-2161).\n *\n * Passing this is what makes the count and the row ONE write. Omitting it\n * decrements without a row, which only `reserve-stock`'s own specs do — every\n * production caller passes it, and `stock-decrement-race.spec.ts` fails the\n * build if one stops.\n */\nexport interface StockLedgerJoin {\n /** Why the count moved. Every sale path passes `sale`. */\n reason: CommerceModel.InventoryAdjustmentReason\n /**\n * The document this movement is answerable to. `cancel-order.ts` queries\n * `where('orderId','==',…)` to decide whether to restock at all, so a row\n * that names the wrong id is as bad as a missing one.\n */\n orderId: string\n}\n\n/**\n * Decrements one variant's stock inside a transaction and reports what the\n * shelf could actually give up. Never throws: a stock write must not fail a\n * webhook, because Stripe would redeliver and the order would be minted twice.\n *\n * ## THE LEDGER ROW RIDES INSIDE THE TRANSACTION (AGL-2161)\n *\n * The count and its `inventoryAdjustments` row used to be two separate writes\n * at six call sites, the second one a bare\n * `.add({…}).catch(() => undefined)`. Either could land without the other and\n * nothing anywhere said so:\n *\n * - **row lost, count moved** — `cancel-order.ts:206-224` reads the rows for\n * an order to decide whether a POS card sale was ever decremented, and reads\n * `appliedDelta ?? delta` to cap the restock. No row means it refuses to\n * release that stock at all, so the units are stranded permanently.\n * - **count lost, row written** — the reverse hands back units nobody has, the\n * failure `StockDecrementOutcome.failed` already exists to prevent.\n *\n * Both targets hang off the same `hostRef`, so nothing ever stopped them\n * sharing one transaction; they simply did not. They do now, and the divergence\n * is not made *loud*, it is made **impossible** — which is the stronger of the\n * two options, and available here only because the helper already owned the\n * transaction the count was written in.\n *\n * Consequences worth stating, because they change five call sites at once:\n *\n * - A ledger failure now ROLLS BACK the decrement instead of being swallowed.\n * That is the direction that keeps the two readings in agreement: the sale\n * is still paid, the shelf is untouched, and `reconcile-stock.ts` reports a\n * missing decrement — which is true, and was previously a false positive\n * whenever only the row was lost.\n * - The row is still auto-id'd. A deterministic id keyed on the order would\n * make redelivery idempotent, but two lines of one product in one order are\n * two legitimate rows (`billing-webhook-low-stock.spec.ts`, \"logs both sale\n * rows\"), and a deterministic key would silently fold them into one and\n * under-cap the restock. Redelivery stays bounded upstream, where it was.\n */\nexport async function decrementVariantStock(options: {\n firestore: any\n hostRef: any\n hostId: string\n productId: string\n variantId: string\n /** Units sold; sign is ignored. */\n quantity: number\n locationId?: string\n /**\n * The ledger row to write in the SAME transaction as the count (AGL-2161).\n * Omit only where no movement should be recorded.\n */\n ledger?: StockLedgerJoin\n}): Promise<StockDecrementOutcome> {\n const {\n firestore,\n hostRef,\n hostId,\n productId,\n variantId,\n quantity,\n locationId,\n ledger,\n } = options\n const requested = -Math.abs(Math.round(Number(quantity) || 0))\n const nothing: StockDecrementOutcome = {\n applied: 0,\n requested,\n before: null,\n after: null,\n failed: false,\n }\n if (requested === 0) return nothing\n let outcome: StockDecrementOutcome\n try {\n outcome = await firestore.runTransaction(async (transaction: any) => {\n const productRef = hostRef.collection('products').doc(productId)\n const snapshot = await transaction.get(productRef)\n if (!snapshot?.exists) return nothing\n const before = CommerceModel.liftLegacyProduct(snapshot.data() as any)\n const tracked = before.variants.some(\n (variant) => variant.id === variantId && variant.inventory != null,\n )\n if (!tracked) return nothing\n const variants = CommerceModel.adjustVariantInventory(\n before,\n variantId,\n requested,\n locationId,\n )\n // Computed from the SAME read the write is derived from, inside the same\n // transaction — which is the whole point (AGL-2320).\n const applied = CommerceModel.appliedVariantInventoryDelta(\n before,\n variantId,\n requested,\n locationId,\n )\n const after = { ...before, variants }\n transaction.set(\n productRef,\n {\n variants,\n inventory: CommerceModel.productInventory(after),\n },\n { merge: true },\n )\n // Same transaction, so the count and the row commit together or neither\n // does (AGL-2161). `applied` is the figure computed above from the read\n // this write is derived from, so `appliedDelta` tells the truth under\n // contention exactly as it does for the count.\n if (ledger) {\n transaction.set(\n hostRef.collection('inventoryAdjustments').doc(),\n {\n productId,\n variantId,\n delta: requested,\n ...(applied !== requested ? { appliedDelta: applied } : {}),\n reason: ledger.reason,\n orderId: ledger.orderId,\n ...(locationId ? { locationId } : {}),\n atMs: Date.now(),\n } satisfies CommerceModel.InventoryAdjustment,\n )\n }\n return { applied, requested, before, after, failed: false }\n })\n } catch (error) {\n // Swallowed like the `.catch(() => undefined)` this replaces, but no longer\n // silently: the caller skips its ledger row, and the count and the ledger\n // stay in agreement about a decrement that did not happen.\n console.error('Stock decrement failed', hostId, productId, variantId, error)\n return { ...nothing, failed: true }\n }\n // The oversell is now LOUD (AGL-2320). `content.lowStock` is deliberately\n // reused rather than a new notification type: the type union carries a label\n // map, a category and a per-user mute, and \"you are out of this\" is the\n // category a merchant who cares about stock is already subscribed to. A\n // backorder product selling past zero\n // is the merchant's own choice and already understood (AGL-2149), so only a\n // deny-policy shortfall is reported — that is the one the merchant did not\n // ask for and previously got no signal about at all.\n const short = outcome.applied - outcome.requested\n if (\n short > 0 &&\n outcome.before &&\n outcome.before.oversellPolicy !== 'backorder'\n ) {\n void notifyHostManagers(hostId, {\n type: 'content.lowStock',\n title: `Oversold — ${outcome.before.name}`,\n body:\n `${Math.abs(outcome.requested)} sold, ${Math.abs(outcome.applied)} ` +\n `came off the shelf. ${short} unit${short === 1 ? '' : 's'} were not ` +\n 'in stock — the sale is paid and needs restocking or refunding.',\n link: `/${hostId}/products`,\n })\n }\n return outcome\n}\n"],"names":["notifyHostManagers","CommerceModel","decrementVariantStock","options","firestore","hostRef","hostId","productId","variantId","quantity","locationId","ledger","requested","Math","abs","round","Number","nothing","applied","before","after","failed","outcome","runTransaction","transaction","productRef","collection","doc","snapshot","get","exists","liftLegacyProduct","data","tracked","variants","some","variant","id","inventory","adjustVariantInventory","appliedVariantInventoryDelta","set","productInventory","merge","delta","appliedDelta","reason","orderId","atMs","Date","now","error","console","short","oversellPolicy","type","title","name","body","link"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/reserve-stock.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { notifyHostManagers } from '@aglyn/tenant-data-admin'\nimport * as CommerceModel from '../model'\n\n/**\n * THE ATOMIC STOCK DECREMENT (AGL-2320).\n *\n * Every stock write in this plugin was a read → compute → `set(…, {merge:true})`\n * of the whole `variants` array, with the read often far upstream of the write:\n * the cart branch of `billing-webhook.ts` reads its products, then runs a\n * transaction, a `cartRef.delete()`, a checkout-doc read, licence-key\n * assignment and an email send before it decrements. Two sales landing in that\n * window both computed from the same `variants` and both wrote; the later won\n * and one decrement was LOST. The ledger showed two `sale` rows while the count\n * moved once, so the shelf was short by one and the storefront went on offering\n * the unit it had already sold — the loss compounds, because phantom stock is\n * what admits the next oversell.\n *\n * `inventory` lives INSIDE the `variants` array, not at a document field path,\n * so `FieldValue.increment` is not expressible against it. That is why this was\n * filed as blocked on a data-model change. It is not the only way to make a\n * decrement safe: a transaction is. `Transaction.get()` takes a lock on the\n * product document, so two concurrent decrements SERIALIZE — the loser re-runs\n * its callback against the winner's committed state and subtracts from the real\n * remainder. The read-modify-write stays, the schema is untouched, and no\n * migration is needed. The cost is one contended retry, not one extra read.\n *\n * WHAT THIS DOES NOT DO. It does not refuse a sale. Every storefront path\n * gates on `canPurchase` when the Stripe Checkout session is created and\n * decrements minutes later in the webhook, by which time the money has moved —\n * refusing there would take payment and withhold goods. Closing the last-unit\n * oversell needs a HOLD taken at session creation and released when the session\n * expires, which is a feature with a new failure mode (an abandoned cart\n * sitting on the last unit for the session's lifetime) and is deliberately not\n * being introduced days before launch. So the oversell is made LOUD instead of\n * silent: when the fresh read proves the shelf could not cover the sale, the\n * managers are told, with the number that was actually short.\n *\n * The floor in `adjustVariantInventory` stays — a shelf count does not go\n * negative, and every reader assumes it cannot (AGL-2149). What changes is that\n * `applied` is now computed from the state the write actually lands on, so the\n * ledger's `appliedDelta` tells the truth under concurrency. Before this, two\n * concurrent sales of one unit each computed `appliedDelta: -1` from the same\n * stale `inventory: 1`, and the ledger claimed two units left a shelf that only\n * ever held one.\n */\nexport interface StockDecrementOutcome {\n /**\n * What the count ACTUALLY moved by — zero or negative, and never more than\n * the shelf held. The caller writes this as the ledger row's `appliedDelta`\n * (AGL-2149) when it differs from `requested`.\n */\n applied: number\n /** The units sold, negated. */\n requested: number\n /**\n * The product as read INSIDE the transaction, and the same product with the\n * committed variants. Both null when nothing was written — a missing product,\n * an untracked variant, or a failed commit. Handed to\n * `alertLowStockCrossing` as the pre/post pair, which is now the pair the\n * write actually crossed rather than one computed from an upstream read.\n */\n before: CommerceModel.HostProduct | null\n after: CommerceModel.HostProduct | null\n /**\n * The commit did not land. Distinct from `applied === 0`, which is a real\n * outcome (a deny-policy product already at zero). A caller must not write a\n * ledger row for a failed commit: a row whose count never moved is exactly\n * what makes a later cancellation hand back units nobody has.\n */\n failed: boolean\n}\n\n/**\n * What the ledger row joins this movement to (AGL-2161).\n *\n * Passing this is what makes the count and the row ONE write. Omitting it\n * decrements without a row, which only `reserve-stock`'s own specs do — every\n * production caller passes it, and `stock-decrement-race.spec.ts` fails the\n * build if one stops.\n */\nexport interface StockLedgerJoin {\n /** Why the count moved. Every sale path passes `sale`. */\n reason: CommerceModel.InventoryAdjustmentReason\n /**\n * The document this movement is answerable to. `cancel-order.ts` queries\n * `where('orderId','==',…)` to decide whether to restock at all, so a row\n * that names the wrong id is as bad as a missing one.\n */\n orderId: string\n}\n\n/**\n * Decrements one variant's stock inside a transaction and reports what the\n * shelf could actually give up. Never throws: a stock write must not fail a\n * webhook, because Stripe would redeliver and the order would be minted twice.\n *\n * ## THE LEDGER ROW RIDES INSIDE THE TRANSACTION (AGL-2161)\n *\n * The count and its `inventoryAdjustments` row used to be two separate writes\n * at six call sites, the second one a bare\n * `.add({…}).catch(() => undefined)`. Either could land without the other and\n * nothing anywhere said so:\n *\n * - **row lost, count moved** — `cancel-order.ts:206-224` reads the rows for\n * an order to decide whether a POS card sale was ever decremented, and reads\n * `appliedDelta ?? delta` to cap the restock. No row means it refuses to\n * release that stock at all, so the units are stranded permanently.\n * - **count lost, row written** — the reverse hands back units nobody has, the\n * failure `StockDecrementOutcome.failed` already exists to prevent.\n *\n * Both targets hang off the same `hostRef`, so nothing ever stopped them\n * sharing one transaction; they simply did not. They do now, and the divergence\n * is not made *loud*, it is made **impossible** — which is the stronger of the\n * two options, and available here only because the helper already owned the\n * transaction the count was written in.\n *\n * Consequences worth stating, because they change five call sites at once:\n *\n * - A ledger failure now ROLLS BACK the decrement instead of being swallowed.\n * That is the direction that keeps the two readings in agreement: the sale\n * is still paid, the shelf is untouched, and `reconcile-stock.ts` reports a\n * missing decrement — which is true, and was previously a false positive\n * whenever only the row was lost.\n * - The row is still auto-id'd. A deterministic id keyed on the order would\n * make redelivery idempotent, but two lines of one product in one order are\n * two legitimate rows (`billing-webhook-low-stock.spec.ts`, \"logs both sale\n * rows\"), and a deterministic key would silently fold them into one and\n * under-cap the restock. Redelivery stays bounded upstream, where it was.\n */\nexport async function decrementVariantStock(options: {\n firestore: any\n hostRef: any\n hostId: string\n productId: string\n variantId: string\n /** Units sold; sign is ignored. */\n quantity: number\n locationId?: string\n /**\n * The ledger row to write in the SAME transaction as the count (AGL-2161).\n * Omit only where no movement should be recorded.\n */\n ledger?: StockLedgerJoin\n}): Promise<StockDecrementOutcome> {\n const {\n firestore,\n hostRef,\n hostId,\n productId,\n variantId,\n quantity,\n locationId,\n ledger,\n } = options\n const requested = -Math.abs(Math.round(Number(quantity) || 0))\n const nothing: StockDecrementOutcome = {\n applied: 0,\n requested,\n before: null,\n after: null,\n failed: false,\n }\n if (requested === 0) return nothing\n let outcome: StockDecrementOutcome\n try {\n outcome = await firestore.runTransaction(async (transaction: any) => {\n const productRef = hostRef.collection('products').doc(productId)\n const snapshot = await transaction.get(productRef)\n if (!snapshot?.exists) return nothing\n const before = CommerceModel.liftLegacyProduct(snapshot.data() as any)\n const tracked = before.variants.some(\n (variant) => variant.id === variantId && variant.inventory != null,\n )\n if (!tracked) return nothing\n const variants = CommerceModel.adjustVariantInventory(\n before,\n variantId,\n requested,\n locationId,\n )\n // Computed from the SAME read the write is derived from, inside the same\n // transaction — which is the whole point (AGL-2320).\n const applied = CommerceModel.appliedVariantInventoryDelta(\n before,\n variantId,\n requested,\n locationId,\n )\n const after = { ...before, variants }\n transaction.set(\n productRef,\n {\n variants,\n // The total and the storefront's In stock verdict (AGL-3321).\n ...CommerceModel.productStockFields(after),\n },\n { merge: true },\n )\n // Same transaction, so the count and the row commit together or neither\n // does (AGL-2161). `applied` is the figure computed above from the read\n // this write is derived from, so `appliedDelta` tells the truth under\n // contention exactly as it does for the count.\n if (ledger) {\n transaction.set(\n hostRef.collection('inventoryAdjustments').doc(),\n {\n productId,\n variantId,\n delta: requested,\n ...(applied !== requested ? { appliedDelta: applied } : {}),\n reason: ledger.reason,\n orderId: ledger.orderId,\n ...(locationId ? { locationId } : {}),\n atMs: Date.now(),\n } satisfies CommerceModel.InventoryAdjustment,\n )\n }\n return { applied, requested, before, after, failed: false }\n })\n } catch (error) {\n // Swallowed like the `.catch(() => undefined)` this replaces, but no longer\n // silently: the caller skips its ledger row, and the count and the ledger\n // stay in agreement about a decrement that did not happen.\n console.error('Stock decrement failed', hostId, productId, variantId, error)\n return { ...nothing, failed: true }\n }\n // The oversell is now LOUD (AGL-2320). `content.lowStock` is deliberately\n // reused rather than a new notification type: the type union carries a label\n // map, a category and a per-user mute, and \"you are out of this\" is the\n // category a merchant who cares about stock is already subscribed to. A\n // backorder product selling past zero\n // is the merchant's own choice and already understood (AGL-2149), so only a\n // deny-policy shortfall is reported — that is the one the merchant did not\n // ask for and previously got no signal about at all.\n const short = outcome.applied - outcome.requested\n if (\n short > 0 &&\n outcome.before &&\n outcome.before.oversellPolicy !== 'backorder'\n ) {\n void notifyHostManagers(hostId, {\n type: 'content.lowStock',\n title: `Oversold — ${outcome.before.name}`,\n body:\n `${Math.abs(outcome.requested)} sold, ${Math.abs(outcome.applied)} ` +\n `came off the shelf. ${short} unit${short === 1 ? '' : 's'} were not ` +\n 'in stock — the sale is paid and needs restocking or refunding.',\n link: `/${hostId}/products`,\n })\n }\n return outcome\n}\n"],"names":["notifyHostManagers","CommerceModel","decrementVariantStock","options","firestore","hostRef","hostId","productId","variantId","quantity","locationId","ledger","requested","Math","abs","round","Number","nothing","applied","before","after","failed","outcome","runTransaction","transaction","productRef","collection","doc","snapshot","get","exists","liftLegacyProduct","data","tracked","variants","some","variant","id","inventory","adjustVariantInventory","appliedVariantInventoryDelta","set","productStockFields","merge","delta","appliedDelta","reason","orderId","atMs","Date","now","error","console","short","oversellPolicy","type","title","name","body","link"],"mappings":"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"}
@@ -89,9 +89,9 @@ import { createCatalogReadScope, queryPublicCatalog } from "./catalog.js";
89
89
  const grids = collectGridNodes(nodes).slice(0, MAX_SEEDED_GRIDS);
90
90
  if (!grids.length) return undefined;
91
91
  const seeds = {};
92
- // One scope for this render, so four grids on a page cost one catalog read
93
- // instead of four identical ones. Each grid still filters and sorts that
94
- // snapshot for itself — the seeds stay keyed by node id.
92
+ // One scope for this render, so grids asking the same question share one
93
+ // catalog query, and each different question reads only its own page
94
+ // (AGL-3321) — the seeds stay keyed by node id.
95
95
  const reads = createCatalogReadScope();
96
96
  await Promise.all(grids.map(async (node)=>{
97
97
  var _ref, _node_resolvedProps, _props_source;
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/site-page-enricher.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 { SitePageEnricher } from '@aglyn/aglyn/server'\nimport {\n createCatalogReadScope,\n queryPublicCatalog,\n type PublicCatalogResult,\n} from './catalog'\n\n/** Component id of the block this enricher seeds; persisted, never renamed. */\nconst PRODUCT_GRID_ID = 'product-grid'\n\n/**\n * Grids seeded per page. A page with a dozen grids would turn one render\n * into a dozen catalog reads, so the walk stops after this many — the rest\n * fetch client-side exactly as they did before, which is a slower first\n * paint rather than a broken page.\n */\nconst MAX_SEEDED_GRIDS = 4\n\ninterface GridProps {\n source?: 'all' | 'collection' | 'category' | 'tag'\n collectionId?: string\n collectionSlug?: string\n categoryId?: string\n categorySlug?: string\n tag?: string\n sort?: string\n maxItems?: number | string\n pageSize?: number | string\n showCategories?: boolean\n showPriceFilter?: boolean\n}\n\ninterface ComposedNode {\n $id?: string\n componentId?: string\n props?: Record<string, unknown>\n resolvedProps?: Record<string, unknown>\n /** Child node IDS — denormalized nodes do not nest (see below). */\n nodes?: string[]\n}\n\n/**\n * Every product-grid node on the page.\n *\n * `composeScreenNodes` returns `Record<string, node>` — a DENORMALIZED FLAT\n * MAP keyed by node id, whose children are id STRINGS in `nodes`. It does not\n * return a tree, and there is no `children` array anywhere in it.\n *\n * The first version of this walked `children` recursively and therefore\n * matched nothing at all, on any page. It shipped green because the unit\n * tests fed it a nested fixture — the fixture encoded the assumption instead\n * of the actual contract, so the tests only ever proved the walker was\n * self-consistent. Iterating the map's values is both correct and simpler;\n * the flat shape means no recursion is needed.\n *\n * Sorted by id so that the MAX_SEEDED_GRIDS cap takes a deterministic subset\n * rather than whichever ones the map happened to enumerate first.\n */\nfunction collectGridNodes(nodes: unknown): ComposedNode[] {\n if (!nodes || typeof nodes !== 'object') return []\n const found: ComposedNode[] = []\n for (const value of Object.values(nodes as Record<string, unknown>)) {\n if (!value || typeof value !== 'object' || Array.isArray(value)) continue\n const shaped = value as ComposedNode\n if (shaped.componentId === PRODUCT_GRID_ID && shaped.$id) {\n found.push(shaped)\n }\n }\n return found.sort((a, b) => String(a.$id).localeCompare(String(b.$id)))\n}\n\n/** Positive integer from a props value that may arrive as a string. */\nfunction positiveInt(value: unknown): number | undefined {\n const parsed = Number(value)\n if (!Number.isFinite(parsed) || parsed <= 0) return undefined\n return Math.floor(parsed)\n}\n\n/**\n * The collection slug a grid on a `/collections/{slug}` page follows when\n * it has no pinned collection — the server-side twin of the grid's\n * `collectionSlugFromLocation()`, which reads `window.location`.\n */\nfunction collectionSlugFromPath(path: string): string {\n const match = path.match(/\\/collections\\/([^/?#]+)/)\n return match ? decodeURIComponent(match[1]) : ''\n}\n\n/**\n * Seeds each product grid's FIRST, UNFILTERED page (AGL-659).\n *\n * `/products` shipped 11KB of HTML with zero words in it: the grid fetches\n * its catalog in a `useEffect`, so the server rendered skeleton cards and\n * crawlers indexed an empty shell. The fix is to run the same query on the\n * server and hand the result down through `pageData`.\n *\n * Three things this deliberately does NOT do:\n *\n * - It does not seed anything the visitor can change. Search text, category\n * chips, type chips and the price slider all start empty on mount, so a\n * props-derived query is exactly what the grid's own first fetch would\n * send. Seed a filtered page and SSR would disagree with hydration.\n * - It does not seed later pages. Load more still goes to the API.\n * - It does not share one slice between grids. Seeds are keyed by node id,\n * because a collection-scoped grid and an all-products grid on the same\n * page must not receive each other's items — wrong products would get\n * indexed and visibly swap on hydrate.\n *\n * Failures are swallowed by the enricher runner, and the grid falls back to\n * fetching. A catalog read that errors must not take a page down.\n */\nexport const commerceSitePageEnricher: SitePageEnricher = async ({\n hostId,\n path,\n nodes,\n}) => {\n const grids = collectGridNodes(nodes).slice(0, MAX_SEEDED_GRIDS)\n if (!grids.length) return undefined\n\n const seeds: Record<string, PublicCatalogResult> = {}\n // One scope for this render, so four grids on a page cost one catalog read\n // instead of four identical ones. Each grid still filters and sorts that\n // snapshot for itself — the seeds stay keyed by node id.\n const reads = createCatalogReadScope()\n await Promise.all(\n grids.map(async (node) => {\n const props = (node.resolvedProps ??\n node.props ??\n {}) as unknown as GridProps\n const source = props.source ?? 'all'\n // Ids first (rename-safe, AGL-343), slugs as the legacy path — the\n // same precedence the grid applies when it builds its own params.\n const collectionSlug =\n source === 'collection'\n ? props.collectionSlug || collectionSlugFromPath(path)\n : props.collectionSlug\n const pageLimit = positiveInt(props.pageSize)\n const limit = pageLimit ?? positiveInt(props.maxItems)\n\n try {\n const result = await queryPublicCatalog({\n hostId,\n ...(source === 'collection'\n ? props.collectionId\n ? { collectionId: props.collectionId }\n : collectionSlug\n ? { collectionSlug }\n : {}\n : {}),\n ...(source === 'category'\n ? props.categoryId\n ? { categoryId: props.categoryId }\n : props.categorySlug\n ? { categorySlug: props.categorySlug }\n : {}\n : {}),\n ...(source === 'tag' && props.tag ? { tag: props.tag } : {}),\n ...(props.sort ? { sort: props.sort } : {}),\n ...(limit ? { limit } : {}),\n // Facets ride along only when a control needs them, matching the\n // grid's `facets=1` condition on its first request.\n facets: Boolean(props.showCategories || props.showPriceFilter),\n reads,\n })\n seeds[node.$id as string] = result\n } catch (error) {\n // One grid failing leaves the others seeded; this one fetches.\n console.error('commerce grid seed failed', node.$id, error)\n }\n }),\n )\n\n if (!Object.keys(seeds).length) return undefined\n\n return {\n // Merged shallowly into the page props, so this key is the whole\n // `pageData` for a screen page. Screens never reach a page resolver\n // (the loader returns a resolver's answer before enrichers run), so\n // there is no resolver-written pageData here to preserve.\n pageData: { commerce: { grids: seeds } },\n }\n}\n\nexport default commerceSitePageEnricher\n"],"names":["createCatalogReadScope","queryPublicCatalog","PRODUCT_GRID_ID","MAX_SEEDED_GRIDS","collectGridNodes","nodes","found","value","Object","values","Array","isArray","shaped","componentId","$id","push","sort","a","b","String","localeCompare","positiveInt","parsed","Number","isFinite","undefined","Math","floor","collectionSlugFromPath","path","match","decodeURIComponent","commerceSitePageEnricher","hostId","grids","slice","length","seeds","reads","Promise","all","map","node","props","resolvedProps","source","collectionSlug","pageLimit","pageSize","limit","maxItems","result","collectionId","categoryId","categorySlug","tag","facets","Boolean","showCategories","showPriceFilter","error","console","keys","pageData","commerce"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/site-page-enricher.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 { SitePageEnricher } from '@aglyn/aglyn/server'\nimport {\n createCatalogReadScope,\n queryPublicCatalog,\n type PublicCatalogResult,\n} from './catalog'\n\n/** Component id of the block this enricher seeds; persisted, never renamed. */\nconst PRODUCT_GRID_ID = 'product-grid'\n\n/**\n * Grids seeded per page. A page with a dozen grids would turn one render\n * into a dozen catalog reads, so the walk stops after this many — the rest\n * fetch client-side exactly as they did before, which is a slower first\n * paint rather than a broken page.\n */\nconst MAX_SEEDED_GRIDS = 4\n\ninterface GridProps {\n source?: 'all' | 'collection' | 'category' | 'tag'\n collectionId?: string\n collectionSlug?: string\n categoryId?: string\n categorySlug?: string\n tag?: string\n sort?: string\n maxItems?: number | string\n pageSize?: number | string\n showCategories?: boolean\n showPriceFilter?: boolean\n}\n\ninterface ComposedNode {\n $id?: string\n componentId?: string\n props?: Record<string, unknown>\n resolvedProps?: Record<string, unknown>\n /** Child node IDS — denormalized nodes do not nest (see below). */\n nodes?: string[]\n}\n\n/**\n * Every product-grid node on the page.\n *\n * `composeScreenNodes` returns `Record<string, node>` — a DENORMALIZED FLAT\n * MAP keyed by node id, whose children are id STRINGS in `nodes`. It does not\n * return a tree, and there is no `children` array anywhere in it.\n *\n * The first version of this walked `children` recursively and therefore\n * matched nothing at all, on any page. It shipped green because the unit\n * tests fed it a nested fixture — the fixture encoded the assumption instead\n * of the actual contract, so the tests only ever proved the walker was\n * self-consistent. Iterating the map's values is both correct and simpler;\n * the flat shape means no recursion is needed.\n *\n * Sorted by id so that the MAX_SEEDED_GRIDS cap takes a deterministic subset\n * rather than whichever ones the map happened to enumerate first.\n */\nfunction collectGridNodes(nodes: unknown): ComposedNode[] {\n if (!nodes || typeof nodes !== 'object') return []\n const found: ComposedNode[] = []\n for (const value of Object.values(nodes as Record<string, unknown>)) {\n if (!value || typeof value !== 'object' || Array.isArray(value)) continue\n const shaped = value as ComposedNode\n if (shaped.componentId === PRODUCT_GRID_ID && shaped.$id) {\n found.push(shaped)\n }\n }\n return found.sort((a, b) => String(a.$id).localeCompare(String(b.$id)))\n}\n\n/** Positive integer from a props value that may arrive as a string. */\nfunction positiveInt(value: unknown): number | undefined {\n const parsed = Number(value)\n if (!Number.isFinite(parsed) || parsed <= 0) return undefined\n return Math.floor(parsed)\n}\n\n/**\n * The collection slug a grid on a `/collections/{slug}` page follows when\n * it has no pinned collection — the server-side twin of the grid's\n * `collectionSlugFromLocation()`, which reads `window.location`.\n */\nfunction collectionSlugFromPath(path: string): string {\n const match = path.match(/\\/collections\\/([^/?#]+)/)\n return match ? decodeURIComponent(match[1]) : ''\n}\n\n/**\n * Seeds each product grid's FIRST, UNFILTERED page (AGL-659).\n *\n * `/products` shipped 11KB of HTML with zero words in it: the grid fetches\n * its catalog in a `useEffect`, so the server rendered skeleton cards and\n * crawlers indexed an empty shell. The fix is to run the same query on the\n * server and hand the result down through `pageData`.\n *\n * Three things this deliberately does NOT do:\n *\n * - It does not seed anything the visitor can change. Search text, category\n * chips, type chips and the price slider all start empty on mount, so a\n * props-derived query is exactly what the grid's own first fetch would\n * send. Seed a filtered page and SSR would disagree with hydration.\n * - It does not seed later pages. Load more still goes to the API.\n * - It does not share one slice between grids. Seeds are keyed by node id,\n * because a collection-scoped grid and an all-products grid on the same\n * page must not receive each other's items — wrong products would get\n * indexed and visibly swap on hydrate.\n *\n * Failures are swallowed by the enricher runner, and the grid falls back to\n * fetching. A catalog read that errors must not take a page down.\n */\nexport const commerceSitePageEnricher: SitePageEnricher = async ({\n hostId,\n path,\n nodes,\n}) => {\n const grids = collectGridNodes(nodes).slice(0, MAX_SEEDED_GRIDS)\n if (!grids.length) return undefined\n\n const seeds: Record<string, PublicCatalogResult> = {}\n // One scope for this render, so grids asking the same question share one\n // catalog query, and each different question reads only its own page\n // (AGL-3321) — the seeds stay keyed by node id.\n const reads = createCatalogReadScope()\n await Promise.all(\n grids.map(async (node) => {\n const props = (node.resolvedProps ??\n node.props ??\n {}) as unknown as GridProps\n const source = props.source ?? 'all'\n // Ids first (rename-safe, AGL-343), slugs as the legacy path — the\n // same precedence the grid applies when it builds its own params.\n const collectionSlug =\n source === 'collection'\n ? props.collectionSlug || collectionSlugFromPath(path)\n : props.collectionSlug\n const pageLimit = positiveInt(props.pageSize)\n const limit = pageLimit ?? positiveInt(props.maxItems)\n\n try {\n const result = await queryPublicCatalog({\n hostId,\n ...(source === 'collection'\n ? props.collectionId\n ? { collectionId: props.collectionId }\n : collectionSlug\n ? { collectionSlug }\n : {}\n : {}),\n ...(source === 'category'\n ? props.categoryId\n ? { categoryId: props.categoryId }\n : props.categorySlug\n ? { categorySlug: props.categorySlug }\n : {}\n : {}),\n ...(source === 'tag' && props.tag ? { tag: props.tag } : {}),\n ...(props.sort ? { sort: props.sort } : {}),\n ...(limit ? { limit } : {}),\n // Facets ride along only when a control needs them, matching the\n // grid's `facets=1` condition on its first request.\n facets: Boolean(props.showCategories || props.showPriceFilter),\n reads,\n })\n seeds[node.$id as string] = result\n } catch (error) {\n // One grid failing leaves the others seeded; this one fetches.\n console.error('commerce grid seed failed', node.$id, error)\n }\n }),\n )\n\n if (!Object.keys(seeds).length) return undefined\n\n return {\n // Merged shallowly into the page props, so this key is the whole\n // `pageData` for a screen page. Screens never reach a page resolver\n // (the loader returns a resolver's answer before enrichers run), so\n // there is no resolver-written pageData here to preserve.\n pageData: { commerce: { grids: seeds } },\n }\n}\n\nexport default commerceSitePageEnricher\n"],"names":["createCatalogReadScope","queryPublicCatalog","PRODUCT_GRID_ID","MAX_SEEDED_GRIDS","collectGridNodes","nodes","found","value","Object","values","Array","isArray","shaped","componentId","$id","push","sort","a","b","String","localeCompare","positiveInt","parsed","Number","isFinite","undefined","Math","floor","collectionSlugFromPath","path","match","decodeURIComponent","commerceSitePageEnricher","hostId","grids","slice","length","seeds","reads","Promise","all","map","node","props","resolvedProps","source","collectionSlug","pageLimit","pageSize","limit","maxItems","result","collectionId","categoryId","categorySlug","tag","facets","Boolean","showCategories","showPriceFilter","error","console","keys","pageData","commerce"],"mappings":"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"}
package/src/lib/server.js CHANGED
@@ -62,6 +62,7 @@ import { subscriptionPortalHandler } from "./server/subscription-portal.js";
62
62
  import { reviewsHandler } from "./server/reviews.js";
63
63
  import { connectHandler } from "./server/connect.js";
64
64
  import { cancelOrderHandler } from "./server/cancel-order.js";
65
+ import { collectionMembershipHandler } from "./server/collection-membership.js";
65
66
  import { draftOrderHandler } from "./server/draft-order.js";
66
67
  import { fulfillOrderHandler, recordOrderShipment } from "./server/fulfill-order.js";
67
68
  import { giftCardsHandler } from "./server/gift-cards.js";
@@ -295,6 +296,9 @@ registerPluginJob({
295
296
  // catch-all in the Firestore rules would otherwise let a client write
296
297
  // its own `balanceCents`, which checkout applies as amount-off.
297
298
  registerPluginApiRoute('commerce/gift-cards', giftCardsHandler);
299
+ // A smart collection's rules changed or it was deleted: re-stamp which
300
+ // products it holds (AGL-3321), the membership the storefront queries.
301
+ registerPluginApiRoute('commerce/collection-membership', collectionMembershipHandler);
298
302
  // A site member's password help (AGL-914) and removal (AGL-3308), from the
299
303
  // Users page drawer and the Inbox. Console-auth, not the visitor cookie the
300
304
  // storefront's `membership/*` routes take, so they are served where the
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/plugins/commerce/src/lib/server.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n\n/**\n * Server half of the commerce plugin (AGL-396): the site-facing storefront\n * API handlers, registered with the plugin API registry and served by the\n * tenant dispatcher at their unchanged `/api/commerce/*` URLs. This module\n * pulls in firebase-admin + Stripe, so it is NOT re-exported from the client\n * barrel — apps import `@aglyn/plugins-commerce/server` only from their\n * (server-only) API dispatcher registration.\n */\n\nimport {\n registerBillingWebhookHandler,\n registerOrderFulfilmentService,\n registerPluginApiRoute,\n registerPluginJob,\n registerSitePageEnricher,\n registerSitePageResolver,\n registerPluginPermissions,\n registerPluginConfigSchema,\n} from '@aglyn/aglyn/server'\nimport { isEmailConfigured } from '@aglyn/shared-util-email'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin'\nimport { BUNDLE_ID } from './constants/bundle-common'\nimport { registerCommerceMediaPublishGuard } from './server/media-publish-guard'\nimport { registerProductCardReader } from './server/product-card'\nimport { registerCommerceTaxProfile } from './server/tax-profile'\nimport { commerceBillingWebhookHandler } from './server/billing-webhook'\nimport { registerOrderFigureReaders } from './server/order-figures'\nimport { COMMERCE_PERMISSIONS } from './model/plugin-permissions'\nimport { COMMERCE_CONFIG_SCHEMA } from './plugin-config'\nimport { commerceSitePageEnricher } from './server/site-page-enricher'\nimport { commerceSitePageResolver } from './server/site-page-resolver'\nimport { cartCheckoutHandler } from './server/cart-checkout'\nimport { cartHandler } from './server/cart'\nimport { catalogHandler } from './server/catalog'\nimport { checkoutHandler } from './server/checkout'\nimport { downloadHandler } from './server/download'\nimport { feedHandler } from './server/feed'\nimport { newsletterHandler } from './server/newsletter'\nimport { notifyRestockHandler } from './server/notify-restock'\nimport { productHandler } from './server/product'\nimport { relatedHandler } from './server/related'\nimport { reservationAvailabilityHandler } from './server/reservation-availability'\nimport { gateHandler } from './server/gate'\nimport { memberFeedHandler } from './server/member-feed'\nimport { membershipAccountHandler } from './server/membership-account'\nimport { membershipAdminPasswordHandler } from './server/membership-admin-password'\nimport { membershipAdminRemoveHandler } from './server/membership-admin-remove'\nimport { membershipContentHandler } from './server/membership-content'\nimport { membershipLoginHandler } from './server/membership-login'\nimport { membershipLogoutHandler } from './server/membership-logout'\nimport { membershipRecoverHandler } from './server/membership-recover'\nimport { membershipRegisterHandler } from './server/membership-register'\nimport { membershipResetHandler } from './server/membership-reset'\nimport { membershipWishlistHandler } from './server/membership-wishlist'\nimport { reserveHandler } from './server/reserve'\nimport { streamHandler } from './server/stream'\nimport { subscriptionPortalHandler } from './server/subscription-portal'\nimport { reviewsHandler } from './server/reviews'\nimport { connectHandler } from './server/connect'\nimport { cancelOrderHandler } from './server/cancel-order'\nimport { draftOrderHandler } from './server/draft-order'\nimport {\n fulfillOrderHandler,\n recordOrderShipment,\n} from './server/fulfill-order'\nimport { giftCardsHandler } from './server/gift-cards'\nimport { memberPostHandler } from './server/member-post'\nimport { orderAnalyticsHandler } from './server/order-analytics'\nimport { posOrderHandler } from './server/pos-order'\nimport {\n processAbandonedHandler,\n scanAbandonedCheckouts,\n} from './server/process-abandoned'\nimport { processRestockHandler, scanRestockAlerts } from './server/process-restock'\nimport { scanStockDecrements } from './server/reconcile-stock'\nimport { refundHandler } from './server/refund'\nimport { scanSupplierDeliveries } from './server/supplier-outbox'\nimport { supplierUpdateHandler } from './server/supplier-update'\n\n/**\n * The two commerce beats (AGL-2227).\n *\n * `commerce/process-abandoned` and `commerce/process-restock` have existed\n * since AGL-323/326 as `x-cron-secret` HTTP doors, and `registerCommerceConsoleApi`\n * below has called them \"the scheduler-driven jobs\" in a comment that whole\n * time. **Nothing scheduled them.** Not `scheduled-crons.yml` (its table names\n * 11 paths; neither of these), not `vercel.json` (no `crons` key at all), not\n * `registerPluginJob` — the only two registrations in the repo were scheduled\n * publishing and the bookings hold-expiry.\n *\n * The cost was a Pro-tier entitlement (`abandonedCart`) that had never sent an\n * email, and a storefront \"notify me when it's back\" form writing into a queue\n * with no drain. AGL-1793 had already added the collection-group indexes these\n * two scans need, which is the clearest evidence they were always meant to run.\n *\n * Module scope, like the bookings job beside it: the runner route reaches jobs\n * through `ensureAll(['tenantApi'])`, and a registration inside a `register*`\n * function would depend on which entry point happened to be loaded.\n *\n * 15 minutes, not 1: `process-abandoned` will not remind a checkout younger\n * than an hour and `process-restock` only mails alerts whose product already\n * has stock, so a tighter beat buys nothing and costs two collection-group\n * scans a minute. Both are bounded (200 docs) and idempotent — each pass\n * stamps what it sent — so an overlapping or repeated beat cannot double-send.\n */\nconst RECOVERY_JOB_INTERVAL_MINUTES = 15\n\nregisterPluginJob({\n pluginId: BUNDLE_ID,\n name: 'abandoned-checkout-recovery',\n intervalMinutes: RECOVERY_JOB_INTERVAL_MINUTES,\n description:\n 'Email one recovery reminder per stalled checkout (abandonedCart plans).',\n lockdown: { scope: 'per-host' },\n handler: async (gate) => {\n // Quietly, not as an error: email is optional per deployment, and a beat\n // that logs every minute on a self-host without Resend buries everything\n // else in the log.\n if (!isEmailConfigured()) return\n const { sent } = await scanAbandonedCheckouts(gate)\n if (sent) console.info(`commerce: sent ${sent} abandoned-cart reminders`)\n },\n})\n\nregisterPluginJob({\n pluginId: BUNDLE_ID,\n name: 'back-in-stock-alerts',\n intervalMinutes: RECOVERY_JOB_INTERVAL_MINUTES,\n description: 'Email shoppers whose requested product is in stock again.',\n lockdown: { scope: 'per-host' },\n handler: async (gate) => {\n if (!isEmailConfigured()) return\n const { sent } = await scanRestockAlerts(gate)\n if (sent) console.info(`commerce: sent ${sent} back-in-stock alerts`)\n },\n})\n\n/**\n * The missing-decrement detector (AGL-2358).\n *\n * HOURLY, not on the 15-minute recovery beat: it is a detector for a rare\n * process death, not a queue drain, and nothing it finds gets less true for\n * waiting an hour. The cost of the beat is what sets the interval — one\n * collection-group read of the platform's most recent orders, plus two\n * queries per host that has any — and an hour keeps that off the same tick as\n * the two scans above.\n *\n * NOT gated on `isEmailConfigured()`, unlike its two neighbours. Those send\n * mail; this writes a console notification and a log line, both of which work\n * on a self-host with no mail provider at all — and a stock count that is\n * silently wrong is exactly what a self-hoster least wants suppressed by a\n * setting about email.\n */\nregisterPluginJob({\n pluginId: BUNDLE_ID,\n name: 'stock-decrement-reconciliation',\n intervalMinutes: 60,\n description:\n 'Report paid orders whose stock decrement never landed (AGL-2358).',\n lockdown: { scope: 'per-host' },\n handler: async (gate) => {\n const scan = await scanStockDecrements(gate)\n if (scan.missingLines || scan.truncatedHosts) {\n console.warn(\n `commerce: ${scan.missingLines} order lines across ${scan.hosts} ` +\n `sites have no sale ledger row (${scan.reportedOrders} newly ` +\n `reported, ${scan.truncatedHosts} sites' ledger window truncated)`,\n )\n }\n },\n})\n\n/**\n * The dropship supplier outbox drain (AGL-2473).\n *\n * EVERY MINUTE, unlike the three above, and the interval is the point rather\n * than an oversight. This is the only one of the four that a BUYER is waiting\n * on: the row it drains is a paid order that has not been routed to whoever\n * ships it, and every minute it sits is a minute the parcel is not moving. The\n * first backoff step is also 60s, so a supplier that blipped is retried on the\n * next tick.\n *\n * The cost that set fifteen minutes for the recovery pair does not apply. Those\n * are collection-GROUP scans across every site on the platform; this is one\n * ordinary equality query against a top-level collection that is EMPTY in the\n * ordinary case — an empty result bills a single read, so the whole beat is\n * ~1,440 reads a day whether or not anyone is dropshipping.\n *\n * Not gated on `isEmailConfigured()`. The supplier notification is an HTTP POST\n * to the merchant's own supplier, and a self-host with no mail provider still\n * has dropship orders to route.\n */\nregisterPluginJob({\n pluginId: BUNDLE_ID,\n name: 'supplier-webhook-delivery',\n intervalMinutes: 1,\n description:\n 'Deliver queued dropship supplier notifications, with backoff and a ' +\n 'dead letter that tells the merchant (AGL-2473).',\n lockdown: { scope: 'per-host' },\n handler: async (gate) => {\n const scan = await scanSupplierDeliveries(gate)\n if (scan.deadLettered) {\n console.warn(\n `commerce: ${scan.deadLettered} dropship orders could not be routed ` +\n `to their supplier and have been reported on the order`,\n )\n }\n },\n})\n\n/** Registers the commerce plugin's storefront API routes. */\nexport function registerCommerceApi(): void {\n registerProductCardReader()\n // The merchant's tax rule, for every plugin that prices a charge here.\n registerCommerceTaxProfile()\n registerPluginPermissions(COMMERCE_PERMISSIONS)\n // Merchant-settable register discount ceiling (AGL-2161). Registered on\n // BOTH surfaces, like the permissions above: the POS route reads it\n // server-side, and the settings card renders it from the same schema.\n registerPluginConfigSchema(COMMERCE_CONFIG_SCHEMA)\n // PDP/PLP template pages (AGL-418): /products/* + /collections/*.\n registerSitePageResolver(commerceSitePageResolver)\n // Seeds product grids on ordinary screens (AGL-659) — the resolver above\n // only covers /products/* and /collections/*, so /products itself, and any\n // designed page with a grid on it, needed this to render server-side.\n registerSitePageEnricher(commerceSitePageEnricher)\n registerPluginApiRoute('commerce/cart-checkout', cartCheckoutHandler)\n registerPluginApiRoute('commerce/cart', cartHandler)\n registerPluginApiRoute('commerce/catalog', catalogHandler)\n registerPluginApiRoute('commerce/checkout', checkoutHandler)\n registerPluginApiRoute('commerce/download', downloadHandler)\n registerPluginApiRoute('commerce/feed', feedHandler)\n registerPluginApiRoute('commerce/newsletter', newsletterHandler)\n registerPluginApiRoute('commerce/notify-restock', notifyRestockHandler)\n // GA-safe order projection for the storefront `purchase` (AGL-1641).\n registerPluginApiRoute('commerce/order-analytics', orderAnalyticsHandler)\n registerPluginApiRoute('commerce/product', productHandler)\n registerPluginApiRoute('commerce/related', relatedHandler)\n registerPluginApiRoute('commerce/reservation-availability', reservationAvailabilityHandler)\n registerPluginApiRoute('commerce/reserve', reserveHandler)\n registerPluginApiRoute('commerce/gate', gateHandler)\n registerPluginApiRoute('commerce/member-feed', memberFeedHandler)\n registerPluginApiRoute('commerce/stream', streamHandler)\n registerPluginApiRoute('commerce/subscription-portal', subscriptionPortalHandler)\n registerPluginApiRoute('commerce/reviews', reviewsHandler)\n registerPluginApiRoute('membership/account', membershipAccountHandler)\n registerPluginApiRoute('membership/content', membershipContentHandler)\n registerPluginApiRoute('membership/login', membershipLoginHandler)\n registerPluginApiRoute('membership/logout', membershipLogoutHandler)\n // Password recovery pair (AGL-552): request + complete.\n registerPluginApiRoute('membership/recover', membershipRecoverHandler)\n registerPluginApiRoute('membership/register', membershipRegisterHandler)\n registerPluginApiRoute('membership/reset', membershipResetHandler)\n registerPluginApiRoute('membership/wishlist', membershipWishlistHandler)\n}\n\n/**\n * Registers the commerce plugin's console-side API routes (AGL-396):\n * merchant/staff operations (Connect onboarding, refunds, draft & POS\n * orders, member posts), the supplier tracking callback, and the HTTP doors\n * for the abandoned-cart / restock passes.\n *\n * Those last two are NOT what schedules them — the `registerPluginJob` calls\n * above are (AGL-2227). This comment used to call them \"the scheduler-driven\n * jobs\", which is how they stayed dark for months: it asserted the wiring\n * instead of having it.\n */\nexport function registerCommerceConsoleApi(): void {\n // What a product looks like to a surface that is not this plugin's — a\n // campaign email that features one asks here rather than importing the model.\n registerProductCardReader()\n // …and why a file somebody is SELLING may not be made public (AGL-3080).\n // The media library asks before it hands an asset its permanent CDN URL\n // back; what a product is, and which of its fields hold paid media, is\n // this plugin's to know.\n registerCommerceMediaPublishGuard()\n // …and the tax rule, for the webhook that confirms what they charged.\n registerCommerceTaxProfile()\n // Stripe webhook sections (AGL-418): orders/carts/drafts/reservations/\n // subscriptions ride the platform webhook via the hook registry.\n registerBillingWebhookHandler(commerceBillingWebhookHandler)\n // Cancel + stock release in one transaction (AGL-1808). Server-side because\n // the release depends on the transition rule, and a client write could not\n // re-ask it under the same lock that flips the status.\n registerPluginApiRoute('commerce/cancel-order', cancelOrderHandler)\n registerPluginApiRoute('commerce/connect', connectHandler)\n registerPluginApiRoute('commerce/draft-order', draftOrderHandler)\n // Fulfil + mark-delivered with the transition re-asked under the write\n // (AGL-1819) — the same stale-dialog hole cancel-order closes, minus the\n // stock release those two transitions never had.\n registerPluginApiRoute('commerce/fulfill-order', fulfillOrderHandler)\n // The same transaction, offered to the HOST APP as a capability (AGL-2461)\n // so `/v1` can record a shipment without importing this library — which\n // `eslint.config.mjs`'s `scope:app` boundary forbids, and which is why the\n // public REST API could take an order and never ship it.\n //\n // Registered HERE, in the consoleApi surface, beside the route it shares an\n // implementation with: `/v1` reaches it through the console's server plugin\n // loader, the same `ensureAll(['consoleApi'])` batch the plugin API\n // dispatcher runs. A registration at module scope would instead depend on\n // which entry point a process happened to import.\n //\n // NOTHING ABOUT AUTHORIZATION CROSSES THIS LINE. The capability is a\n // pre-authorized domain operation and `/v1` does its own gating (key scope,\n // plan entitlement, org-owns-host, plugin-enabled-for-site) before it calls.\n registerOrderFulfilmentService({\n pluginId: BUNDLE_ID,\n recordShipment: recordOrderShipment,\n })\n // Issue / void store credit (AGL-2226). Server-side because the host\n // catch-all in the Firestore rules would otherwise let a client write\n // its own `balanceCents`, which checkout applies as amount-off.\n registerPluginApiRoute('commerce/gift-cards', giftCardsHandler)\n // A site member's password help (AGL-914) and removal (AGL-3308), from the\n // Users page drawer and the Inbox. Console-auth, not the visitor cookie the\n // storefront's `membership/*` routes take, so they are served where the\n // console posts: the console's own dispatcher loads this surface only.\n registerPluginApiRoute(\n 'membership/admin-password',\n membershipAdminPasswordHandler,\n )\n registerPluginApiRoute('membership/admin-remove', membershipAdminRemoveHandler)\n registerPluginApiRoute('commerce/member-post', memberPostHandler)\n registerPluginApiRoute('commerce/pos-order', posOrderHandler)\n registerPluginApiRoute('commerce/process-abandoned', processAbandonedHandler)\n registerPluginApiRoute('commerce/process-restock', processRestockHandler)\n registerPluginApiRoute('commerce/refund', refundHandler)\n registerPluginApiRoute('commerce/supplier-update', supplierUpdateHandler)\n // The store's sales as figure tables (AGL-2915), for the AI plugin's\n // insights to read by id rather than by reading orders. The console runs\n // insight jobs, so the console surface registers them.\n registerOrderFigureReaders(() => firebaseAdmin.app().firestore())\n}\n\n// Shared with the (still app-side) membership/account route.\nexport { mintDownloadToken } from './server/download'\n\n// Site-member session primitives, shared with the (still app-side)\n// membership/* routes until those migrate too (AGL-396).\nexport * from './server/membership'\n"],"names":["registerBillingWebhookHandler","registerOrderFulfilmentService","registerPluginApiRoute","registerPluginJob","registerSitePageEnricher","registerSitePageResolver","registerPluginPermissions","registerPluginConfigSchema","isEmailConfigured","firebaseAdmin","BUNDLE_ID","registerCommerceMediaPublishGuard","registerProductCardReader","registerCommerceTaxProfile","commerceBillingWebhookHandler","registerOrderFigureReaders","COMMERCE_PERMISSIONS","COMMERCE_CONFIG_SCHEMA","commerceSitePageEnricher","commerceSitePageResolver","cartCheckoutHandler","cartHandler","catalogHandler","checkoutHandler","downloadHandler","feedHandler","newsletterHandler","notifyRestockHandler","productHandler","relatedHandler","reservationAvailabilityHandler","gateHandler","memberFeedHandler","membershipAccountHandler","membershipAdminPasswordHandler","membershipAdminRemoveHandler","membershipContentHandler","membershipLoginHandler","membershipLogoutHandler","membershipRecoverHandler","membershipRegisterHandler","membershipResetHandler","membershipWishlistHandler","reserveHandler","streamHandler","subscriptionPortalHandler","reviewsHandler","connectHandler","cancelOrderHandler","draftOrderHandler","fulfillOrderHandler","recordOrderShipment","giftCardsHandler","memberPostHandler","orderAnalyticsHandler","posOrderHandler","processAbandonedHandler","scanAbandonedCheckouts","processRestockHandler","scanRestockAlerts","scanStockDecrements","refundHandler","scanSupplierDeliveries","supplierUpdateHandler","RECOVERY_JOB_INTERVAL_MINUTES","pluginId","name","intervalMinutes","description","lockdown","scope","handler","gate","sent","console","info","scan","missingLines","truncatedHosts","warn","hosts","reportedOrders","deadLettered","registerCommerceApi","registerCommerceConsoleApi","recordShipment","app","firestore","mintDownloadToken"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../libs/plugins/commerce/src/lib/server.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n\n/**\n * Server half of the commerce plugin (AGL-396): the site-facing storefront\n * API handlers, registered with the plugin API registry and served by the\n * tenant dispatcher at their unchanged `/api/commerce/*` URLs. This module\n * pulls in firebase-admin + Stripe, so it is NOT re-exported from the client\n * barrel — apps import `@aglyn/plugins-commerce/server` only from their\n * (server-only) API dispatcher registration.\n */\n\nimport {\n registerBillingWebhookHandler,\n registerOrderFulfilmentService,\n registerPluginApiRoute,\n registerPluginJob,\n registerSitePageEnricher,\n registerSitePageResolver,\n registerPluginPermissions,\n registerPluginConfigSchema,\n} from '@aglyn/aglyn/server'\nimport { isEmailConfigured } from '@aglyn/shared-util-email'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin'\nimport { BUNDLE_ID } from './constants/bundle-common'\nimport { registerCommerceMediaPublishGuard } from './server/media-publish-guard'\nimport { registerProductCardReader } from './server/product-card'\nimport { registerCommerceTaxProfile } from './server/tax-profile'\nimport { commerceBillingWebhookHandler } from './server/billing-webhook'\nimport { registerOrderFigureReaders } from './server/order-figures'\nimport { COMMERCE_PERMISSIONS } from './model/plugin-permissions'\nimport { COMMERCE_CONFIG_SCHEMA } from './plugin-config'\nimport { commerceSitePageEnricher } from './server/site-page-enricher'\nimport { commerceSitePageResolver } from './server/site-page-resolver'\nimport { cartCheckoutHandler } from './server/cart-checkout'\nimport { cartHandler } from './server/cart'\nimport { catalogHandler } from './server/catalog'\nimport { checkoutHandler } from './server/checkout'\nimport { downloadHandler } from './server/download'\nimport { feedHandler } from './server/feed'\nimport { newsletterHandler } from './server/newsletter'\nimport { notifyRestockHandler } from './server/notify-restock'\nimport { productHandler } from './server/product'\nimport { relatedHandler } from './server/related'\nimport { reservationAvailabilityHandler } from './server/reservation-availability'\nimport { gateHandler } from './server/gate'\nimport { memberFeedHandler } from './server/member-feed'\nimport { membershipAccountHandler } from './server/membership-account'\nimport { membershipAdminPasswordHandler } from './server/membership-admin-password'\nimport { membershipAdminRemoveHandler } from './server/membership-admin-remove'\nimport { membershipContentHandler } from './server/membership-content'\nimport { membershipLoginHandler } from './server/membership-login'\nimport { membershipLogoutHandler } from './server/membership-logout'\nimport { membershipRecoverHandler } from './server/membership-recover'\nimport { membershipRegisterHandler } from './server/membership-register'\nimport { membershipResetHandler } from './server/membership-reset'\nimport { membershipWishlistHandler } from './server/membership-wishlist'\nimport { reserveHandler } from './server/reserve'\nimport { streamHandler } from './server/stream'\nimport { subscriptionPortalHandler } from './server/subscription-portal'\nimport { reviewsHandler } from './server/reviews'\nimport { connectHandler } from './server/connect'\nimport { cancelOrderHandler } from './server/cancel-order'\nimport { collectionMembershipHandler } from './server/collection-membership'\nimport { draftOrderHandler } from './server/draft-order'\nimport {\n fulfillOrderHandler,\n recordOrderShipment,\n} from './server/fulfill-order'\nimport { giftCardsHandler } from './server/gift-cards'\nimport { memberPostHandler } from './server/member-post'\nimport { orderAnalyticsHandler } from './server/order-analytics'\nimport { posOrderHandler } from './server/pos-order'\nimport {\n processAbandonedHandler,\n scanAbandonedCheckouts,\n} from './server/process-abandoned'\nimport { processRestockHandler, scanRestockAlerts } from './server/process-restock'\nimport { scanStockDecrements } from './server/reconcile-stock'\nimport { refundHandler } from './server/refund'\nimport { scanSupplierDeliveries } from './server/supplier-outbox'\nimport { supplierUpdateHandler } from './server/supplier-update'\n\n/**\n * The two commerce beats (AGL-2227).\n *\n * `commerce/process-abandoned` and `commerce/process-restock` have existed\n * since AGL-323/326 as `x-cron-secret` HTTP doors, and `registerCommerceConsoleApi`\n * below has called them \"the scheduler-driven jobs\" in a comment that whole\n * time. **Nothing scheduled them.** Not `scheduled-crons.yml` (its table names\n * 11 paths; neither of these), not `vercel.json` (no `crons` key at all), not\n * `registerPluginJob` — the only two registrations in the repo were scheduled\n * publishing and the bookings hold-expiry.\n *\n * The cost was a Pro-tier entitlement (`abandonedCart`) that had never sent an\n * email, and a storefront \"notify me when it's back\" form writing into a queue\n * with no drain. AGL-1793 had already added the collection-group indexes these\n * two scans need, which is the clearest evidence they were always meant to run.\n *\n * Module scope, like the bookings job beside it: the runner route reaches jobs\n * through `ensureAll(['tenantApi'])`, and a registration inside a `register*`\n * function would depend on which entry point happened to be loaded.\n *\n * 15 minutes, not 1: `process-abandoned` will not remind a checkout younger\n * than an hour and `process-restock` only mails alerts whose product already\n * has stock, so a tighter beat buys nothing and costs two collection-group\n * scans a minute. Both are bounded (200 docs) and idempotent — each pass\n * stamps what it sent — so an overlapping or repeated beat cannot double-send.\n */\nconst RECOVERY_JOB_INTERVAL_MINUTES = 15\n\nregisterPluginJob({\n pluginId: BUNDLE_ID,\n name: 'abandoned-checkout-recovery',\n intervalMinutes: RECOVERY_JOB_INTERVAL_MINUTES,\n description:\n 'Email one recovery reminder per stalled checkout (abandonedCart plans).',\n lockdown: { scope: 'per-host' },\n handler: async (gate) => {\n // Quietly, not as an error: email is optional per deployment, and a beat\n // that logs every minute on a self-host without Resend buries everything\n // else in the log.\n if (!isEmailConfigured()) return\n const { sent } = await scanAbandonedCheckouts(gate)\n if (sent) console.info(`commerce: sent ${sent} abandoned-cart reminders`)\n },\n})\n\nregisterPluginJob({\n pluginId: BUNDLE_ID,\n name: 'back-in-stock-alerts',\n intervalMinutes: RECOVERY_JOB_INTERVAL_MINUTES,\n description: 'Email shoppers whose requested product is in stock again.',\n lockdown: { scope: 'per-host' },\n handler: async (gate) => {\n if (!isEmailConfigured()) return\n const { sent } = await scanRestockAlerts(gate)\n if (sent) console.info(`commerce: sent ${sent} back-in-stock alerts`)\n },\n})\n\n/**\n * The missing-decrement detector (AGL-2358).\n *\n * HOURLY, not on the 15-minute recovery beat: it is a detector for a rare\n * process death, not a queue drain, and nothing it finds gets less true for\n * waiting an hour. The cost of the beat is what sets the interval — one\n * collection-group read of the platform's most recent orders, plus two\n * queries per host that has any — and an hour keeps that off the same tick as\n * the two scans above.\n *\n * NOT gated on `isEmailConfigured()`, unlike its two neighbours. Those send\n * mail; this writes a console notification and a log line, both of which work\n * on a self-host with no mail provider at all — and a stock count that is\n * silently wrong is exactly what a self-hoster least wants suppressed by a\n * setting about email.\n */\nregisterPluginJob({\n pluginId: BUNDLE_ID,\n name: 'stock-decrement-reconciliation',\n intervalMinutes: 60,\n description:\n 'Report paid orders whose stock decrement never landed (AGL-2358).',\n lockdown: { scope: 'per-host' },\n handler: async (gate) => {\n const scan = await scanStockDecrements(gate)\n if (scan.missingLines || scan.truncatedHosts) {\n console.warn(\n `commerce: ${scan.missingLines} order lines across ${scan.hosts} ` +\n `sites have no sale ledger row (${scan.reportedOrders} newly ` +\n `reported, ${scan.truncatedHosts} sites' ledger window truncated)`,\n )\n }\n },\n})\n\n/**\n * The dropship supplier outbox drain (AGL-2473).\n *\n * EVERY MINUTE, unlike the three above, and the interval is the point rather\n * than an oversight. This is the only one of the four that a BUYER is waiting\n * on: the row it drains is a paid order that has not been routed to whoever\n * ships it, and every minute it sits is a minute the parcel is not moving. The\n * first backoff step is also 60s, so a supplier that blipped is retried on the\n * next tick.\n *\n * The cost that set fifteen minutes for the recovery pair does not apply. Those\n * are collection-GROUP scans across every site on the platform; this is one\n * ordinary equality query against a top-level collection that is EMPTY in the\n * ordinary case — an empty result bills a single read, so the whole beat is\n * ~1,440 reads a day whether or not anyone is dropshipping.\n *\n * Not gated on `isEmailConfigured()`. The supplier notification is an HTTP POST\n * to the merchant's own supplier, and a self-host with no mail provider still\n * has dropship orders to route.\n */\nregisterPluginJob({\n pluginId: BUNDLE_ID,\n name: 'supplier-webhook-delivery',\n intervalMinutes: 1,\n description:\n 'Deliver queued dropship supplier notifications, with backoff and a ' +\n 'dead letter that tells the merchant (AGL-2473).',\n lockdown: { scope: 'per-host' },\n handler: async (gate) => {\n const scan = await scanSupplierDeliveries(gate)\n if (scan.deadLettered) {\n console.warn(\n `commerce: ${scan.deadLettered} dropship orders could not be routed ` +\n `to their supplier and have been reported on the order`,\n )\n }\n },\n})\n\n/** Registers the commerce plugin's storefront API routes. */\nexport function registerCommerceApi(): void {\n registerProductCardReader()\n // The merchant's tax rule, for every plugin that prices a charge here.\n registerCommerceTaxProfile()\n registerPluginPermissions(COMMERCE_PERMISSIONS)\n // Merchant-settable register discount ceiling (AGL-2161). Registered on\n // BOTH surfaces, like the permissions above: the POS route reads it\n // server-side, and the settings card renders it from the same schema.\n registerPluginConfigSchema(COMMERCE_CONFIG_SCHEMA)\n // PDP/PLP template pages (AGL-418): /products/* + /collections/*.\n registerSitePageResolver(commerceSitePageResolver)\n // Seeds product grids on ordinary screens (AGL-659) — the resolver above\n // only covers /products/* and /collections/*, so /products itself, and any\n // designed page with a grid on it, needed this to render server-side.\n registerSitePageEnricher(commerceSitePageEnricher)\n registerPluginApiRoute('commerce/cart-checkout', cartCheckoutHandler)\n registerPluginApiRoute('commerce/cart', cartHandler)\n registerPluginApiRoute('commerce/catalog', catalogHandler)\n registerPluginApiRoute('commerce/checkout', checkoutHandler)\n registerPluginApiRoute('commerce/download', downloadHandler)\n registerPluginApiRoute('commerce/feed', feedHandler)\n registerPluginApiRoute('commerce/newsletter', newsletterHandler)\n registerPluginApiRoute('commerce/notify-restock', notifyRestockHandler)\n // GA-safe order projection for the storefront `purchase` (AGL-1641).\n registerPluginApiRoute('commerce/order-analytics', orderAnalyticsHandler)\n registerPluginApiRoute('commerce/product', productHandler)\n registerPluginApiRoute('commerce/related', relatedHandler)\n registerPluginApiRoute('commerce/reservation-availability', reservationAvailabilityHandler)\n registerPluginApiRoute('commerce/reserve', reserveHandler)\n registerPluginApiRoute('commerce/gate', gateHandler)\n registerPluginApiRoute('commerce/member-feed', memberFeedHandler)\n registerPluginApiRoute('commerce/stream', streamHandler)\n registerPluginApiRoute('commerce/subscription-portal', subscriptionPortalHandler)\n registerPluginApiRoute('commerce/reviews', reviewsHandler)\n registerPluginApiRoute('membership/account', membershipAccountHandler)\n registerPluginApiRoute('membership/content', membershipContentHandler)\n registerPluginApiRoute('membership/login', membershipLoginHandler)\n registerPluginApiRoute('membership/logout', membershipLogoutHandler)\n // Password recovery pair (AGL-552): request + complete.\n registerPluginApiRoute('membership/recover', membershipRecoverHandler)\n registerPluginApiRoute('membership/register', membershipRegisterHandler)\n registerPluginApiRoute('membership/reset', membershipResetHandler)\n registerPluginApiRoute('membership/wishlist', membershipWishlistHandler)\n}\n\n/**\n * Registers the commerce plugin's console-side API routes (AGL-396):\n * merchant/staff operations (Connect onboarding, refunds, draft & POS\n * orders, member posts), the supplier tracking callback, and the HTTP doors\n * for the abandoned-cart / restock passes.\n *\n * Those last two are NOT what schedules them — the `registerPluginJob` calls\n * above are (AGL-2227). This comment used to call them \"the scheduler-driven\n * jobs\", which is how they stayed dark for months: it asserted the wiring\n * instead of having it.\n */\nexport function registerCommerceConsoleApi(): void {\n // What a product looks like to a surface that is not this plugin's — a\n // campaign email that features one asks here rather than importing the model.\n registerProductCardReader()\n // …and why a file somebody is SELLING may not be made public (AGL-3080).\n // The media library asks before it hands an asset its permanent CDN URL\n // back; what a product is, and which of its fields hold paid media, is\n // this plugin's to know.\n registerCommerceMediaPublishGuard()\n // …and the tax rule, for the webhook that confirms what they charged.\n registerCommerceTaxProfile()\n // Stripe webhook sections (AGL-418): orders/carts/drafts/reservations/\n // subscriptions ride the platform webhook via the hook registry.\n registerBillingWebhookHandler(commerceBillingWebhookHandler)\n // Cancel + stock release in one transaction (AGL-1808). Server-side because\n // the release depends on the transition rule, and a client write could not\n // re-ask it under the same lock that flips the status.\n registerPluginApiRoute('commerce/cancel-order', cancelOrderHandler)\n registerPluginApiRoute('commerce/connect', connectHandler)\n registerPluginApiRoute('commerce/draft-order', draftOrderHandler)\n // Fulfil + mark-delivered with the transition re-asked under the write\n // (AGL-1819) — the same stale-dialog hole cancel-order closes, minus the\n // stock release those two transitions never had.\n registerPluginApiRoute('commerce/fulfill-order', fulfillOrderHandler)\n // The same transaction, offered to the HOST APP as a capability (AGL-2461)\n // so `/v1` can record a shipment without importing this library — which\n // `eslint.config.mjs`'s `scope:app` boundary forbids, and which is why the\n // public REST API could take an order and never ship it.\n //\n // Registered HERE, in the consoleApi surface, beside the route it shares an\n // implementation with: `/v1` reaches it through the console's server plugin\n // loader, the same `ensureAll(['consoleApi'])` batch the plugin API\n // dispatcher runs. A registration at module scope would instead depend on\n // which entry point a process happened to import.\n //\n // NOTHING ABOUT AUTHORIZATION CROSSES THIS LINE. The capability is a\n // pre-authorized domain operation and `/v1` does its own gating (key scope,\n // plan entitlement, org-owns-host, plugin-enabled-for-site) before it calls.\n registerOrderFulfilmentService({\n pluginId: BUNDLE_ID,\n recordShipment: recordOrderShipment,\n })\n // Issue / void store credit (AGL-2226). Server-side because the host\n // catch-all in the Firestore rules would otherwise let a client write\n // its own `balanceCents`, which checkout applies as amount-off.\n registerPluginApiRoute('commerce/gift-cards', giftCardsHandler)\n // A smart collection's rules changed or it was deleted: re-stamp which\n // products it holds (AGL-3321), the membership the storefront queries.\n registerPluginApiRoute('commerce/collection-membership', collectionMembershipHandler)\n // A site member's password help (AGL-914) and removal (AGL-3308), from the\n // Users page drawer and the Inbox. Console-auth, not the visitor cookie the\n // storefront's `membership/*` routes take, so they are served where the\n // console posts: the console's own dispatcher loads this surface only.\n registerPluginApiRoute(\n 'membership/admin-password',\n membershipAdminPasswordHandler,\n )\n registerPluginApiRoute('membership/admin-remove', membershipAdminRemoveHandler)\n registerPluginApiRoute('commerce/member-post', memberPostHandler)\n registerPluginApiRoute('commerce/pos-order', posOrderHandler)\n registerPluginApiRoute('commerce/process-abandoned', processAbandonedHandler)\n registerPluginApiRoute('commerce/process-restock', processRestockHandler)\n registerPluginApiRoute('commerce/refund', refundHandler)\n registerPluginApiRoute('commerce/supplier-update', supplierUpdateHandler)\n // The store's sales as figure tables (AGL-2915), for the AI plugin's\n // insights to read by id rather than by reading orders. The console runs\n // insight jobs, so the console surface registers them.\n registerOrderFigureReaders(() => firebaseAdmin.app().firestore())\n}\n\n// Shared with the (still app-side) membership/account route.\nexport { mintDownloadToken } from './server/download'\n\n// Site-member session primitives, shared with the (still app-side)\n// membership/* routes until those migrate too (AGL-396).\nexport * from './server/membership'\n"],"names":["registerBillingWebhookHandler","registerOrderFulfilmentService","registerPluginApiRoute","registerPluginJob","registerSitePageEnricher","registerSitePageResolver","registerPluginPermissions","registerPluginConfigSchema","isEmailConfigured","firebaseAdmin","BUNDLE_ID","registerCommerceMediaPublishGuard","registerProductCardReader","registerCommerceTaxProfile","commerceBillingWebhookHandler","registerOrderFigureReaders","COMMERCE_PERMISSIONS","COMMERCE_CONFIG_SCHEMA","commerceSitePageEnricher","commerceSitePageResolver","cartCheckoutHandler","cartHandler","catalogHandler","checkoutHandler","downloadHandler","feedHandler","newsletterHandler","notifyRestockHandler","productHandler","relatedHandler","reservationAvailabilityHandler","gateHandler","memberFeedHandler","membershipAccountHandler","membershipAdminPasswordHandler","membershipAdminRemoveHandler","membershipContentHandler","membershipLoginHandler","membershipLogoutHandler","membershipRecoverHandler","membershipRegisterHandler","membershipResetHandler","membershipWishlistHandler","reserveHandler","streamHandler","subscriptionPortalHandler","reviewsHandler","connectHandler","cancelOrderHandler","collectionMembershipHandler","draftOrderHandler","fulfillOrderHandler","recordOrderShipment","giftCardsHandler","memberPostHandler","orderAnalyticsHandler","posOrderHandler","processAbandonedHandler","scanAbandonedCheckouts","processRestockHandler","scanRestockAlerts","scanStockDecrements","refundHandler","scanSupplierDeliveries","supplierUpdateHandler","RECOVERY_JOB_INTERVAL_MINUTES","pluginId","name","intervalMinutes","description","lockdown","scope","handler","gate","sent","console","info","scan","missingLines","truncatedHosts","warn","hosts","reportedOrders","deadLettered","registerCommerceApi","registerCommerceConsoleApi","recordShipment","app","firestore","mintDownloadToken"],"mappings":"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"}