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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/package.json +13 -13
  2. package/src/lib/components/console/gift-cards-card.component.js +34 -2
  3. package/src/lib/components/console/gift-cards-card.component.js.map +1 -1
  4. package/src/lib/components/console/order-detail-dialog.component.js +12 -0
  5. package/src/lib/components/console/order-detail-dialog.component.js.map +1 -1
  6. package/src/lib/model/commerce-gift-cards.d.ts +28 -0
  7. package/src/lib/model/commerce-gift-cards.js +39 -1
  8. package/src/lib/model/commerce-gift-cards.js.map +1 -1
  9. package/src/lib/model/commerce-orders.d.ts +7 -0
  10. package/src/lib/model/commerce-orders.js.map +1 -1
  11. package/src/lib/server/billing-webhook.js +132 -14
  12. package/src/lib/server/billing-webhook.js.map +1 -1
  13. package/src/lib/server/cart-checkout.js +49 -10
  14. package/src/lib/server/cart-checkout.js.map +1 -1
  15. package/src/lib/server/catalog.js +18 -3
  16. package/src/lib/server/catalog.js.map +1 -1
  17. package/src/lib/server/checkout.js +26 -6
  18. package/src/lib/server/checkout.js.map +1 -1
  19. package/src/lib/server/download.js +12 -1
  20. package/src/lib/server/download.js.map +1 -1
  21. package/src/lib/server/draft-order.js +7 -3
  22. package/src/lib/server/draft-order.js.map +1 -1
  23. package/src/lib/server/feed.js +12 -2
  24. package/src/lib/server/feed.js.map +1 -1
  25. package/src/lib/server/gift-card-risk.d.ts +70 -0
  26. package/src/lib/server/gift-card-risk.js +123 -0
  27. package/src/lib/server/gift-card-risk.js.map +1 -0
  28. package/src/lib/server/gift-cards.js +41 -3
  29. package/src/lib/server/gift-cards.js.map +1 -1
  30. package/src/lib/server/membership-admin-password.js +8 -2
  31. package/src/lib/server/membership-admin-password.js.map +1 -1
  32. package/src/lib/server/membership-recover.js +4 -1
  33. package/src/lib/server/membership-recover.js.map +1 -1
  34. package/src/lib/server/paid-media-delivery.d.ts +26 -4
  35. package/src/lib/server/paid-media-delivery.js +36 -9
  36. package/src/lib/server/paid-media-delivery.js.map +1 -1
  37. package/src/lib/server/pos-order.js +3 -1
  38. package/src/lib/server/pos-order.js.map +1 -1
  39. package/src/lib/server/process-abandoned.js +6 -2
  40. package/src/lib/server/process-abandoned.js.map +1 -1
  41. package/src/lib/server/process-restock.js +6 -2
  42. package/src/lib/server/process-restock.js.map +1 -1
  43. package/src/lib/server/recurring-charges.d.ts +44 -0
  44. package/src/lib/server/recurring-charges.js +65 -0
  45. package/src/lib/server/recurring-charges.js.map +1 -0
  46. package/src/lib/server/refund.js +24 -0
  47. package/src/lib/server/refund.js.map +1 -1
  48. package/src/lib/server/reserve.js +17 -6
  49. package/src/lib/server/reserve.js.map +1 -1
  50. package/src/lib/server/stream.js +12 -1
  51. package/src/lib/server/stream.js.map +1 -1
  52. package/src/lib/server/subscription-portal.js +13 -5
  53. package/src/lib/server/subscription-portal.js.map +1 -1
  54. package/src/lib/server/supplier-outbox.js +22 -7
  55. package/src/lib/server/supplier-outbox.js.map +1 -1
  56. package/src/lib/server.js +15 -3
  57. 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\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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"}
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} from '@aglyn/aglyn/server'\nimport { composeHostComponentNodes } from '@aglyn/aglyn/app-utils/load-referenced-components'\n// The leaf, not the barrel: the console's `x-cron-secret` door asks the\n// site's lockdown directly (AGL-3356) — core's registry has no resolver in\n// the console process — and a spec that substitutes the barrel must still\n// reach the real verdict.\nimport { siteLockdownJobGate } from '@aglyn/tenant-data-admin/server/tenant-write-lockdown'\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(siteLockdownJobGate()))\n } catch (error) {\n console.error(error)\n return res.status(500).json({ error: 'Processing failed' })\n }\n}\n"],"names":["Aglyn","firebaseAdmin","getOrgForHost","hostSendingIdentity","meterHostEmail","isDeferrableSendResult","isEmailConfigured","loadHostEmail","renderLoadedHostEmail","sendEmail","composeHostComponentNodes","siteLockdownJobGate","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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"}
@@ -19,8 +19,12 @@ import * as CommerceModel from "../model/index.js";
19
19
  import { firebaseAdmin, getOrgForHost, hostSendingIdentity, meterHostEmail } from "@aglyn/tenant-data-admin";
20
20
  import { isDocumentId } from "@aglyn/tenant-data-admin/server/document-id";
21
21
  import { isDeferrableSendResult, isEmailConfigured, loadHostEmail, renderLoadedHostEmail, sendEmail } from "@aglyn/shared-util-email";
22
- import { pluginJobHostGate } from "@aglyn/aglyn/server";
23
22
  import { composeHostComponentNodes } from "@aglyn/aglyn/app-utils/load-referenced-components";
23
+ // The leaf, not the barrel: the console's `x-cron-secret` door asks the
24
+ // site's lockdown directly (AGL-3356) — core's registry has no resolver in
25
+ // the console process — and a spec that substitutes the barrel must still
26
+ // reach the real verdict.
27
+ import { siteLockdownJobGate } from "@aglyn/tenant-data-admin/server/tenant-write-lockdown";
24
28
  /**
25
29
  * The back-in-stock pass itself, separated from its HTTP door (AGL-2227).
26
30
  *
@@ -222,7 +226,7 @@ import { composeHostComponentNodes } from "@aglyn/aglyn/app-utils/load-reference
222
226
  }
223
227
  try {
224
228
  // The manual door asks the same question the beat does (AGL-2495).
225
- return res.status(200).json(await scanRestockAlerts(pluginJobHostGate()));
229
+ return res.status(200).json(await scanRestockAlerts(siteLockdownJobGate()));
226
230
  } catch (error) {
227
231
  console.error(error);
228
232
  return res.status(500).json({
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/process-restock.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport * as Aglyn from '@aglyn/aglyn/server'\nimport * as CommerceModel from '../model'\nimport {\n firebaseAdmin,\n getOrgForHost,\n hostSendingIdentity,\n meterHostEmail,\n} from '@aglyn/tenant-data-admin'\nimport { isDocumentId } from '@aglyn/tenant-data-admin/server/document-id'\nimport {\n isDeferrableSendResult,\n isEmailConfigured,\n 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\nexport interface RestockScanResult {\n scanned: number\n sent: number\n /** Alerts left unstamped because their site is locked (AGL-2495). */\n skippedLocked: number\n}\n\n/**\n * The back-in-stock pass itself, separated from its HTTP door (AGL-2227).\n *\n * Same finding as `process-abandoned.ts` beside it: `x-cron-secret`-gated and\n * **never invoked by anything**. `commerce/notify-restock` accepts the\n * shopper's \"tell me when it's back\" from the storefront and writes\n * `hosts/{hostId}/restockAlerts`; this is the only thing that would ever mail\n * them. Shoppers have been entering an address into a queue with no drain.\n *\n * Exported so `server.ts` can put it on the platform job beat.\n */\nexport async function scanRestockAlerts(\n /** The lockdown gate, injected by the caller (AGL-2495). Not optional. */\n gate: PluginJobHostGate,\n): Promise<RestockScanResult> {\n const firestore = firebaseAdmin.app().firestore()\n const alerts = await firestore\n .collectionGroup('restockAlerts')\n .where('notifiedAtMs', '==', null)\n .limit(200)\n .get()\n let sent = 0\n const productCache = new Map<string, CommerceModel.HostProduct | null>()\n // Resolve each host's designed template once per run (AGL-770).\n const templateCache = new Map<string, LoadedHostEmail | null>()\n // White-label brand per host (White-Label Phase 3): resolved once per host\n // from the owning org doc through the one shared resolver.\n const brandingByHost = new Map<string, Aglyn.ResolvedBrandingProfile>()\n /*\n * One identity resolution per SITE, not per message.\n *\n * The same reason the branding map above exists: this sweep runs across\n * every site, and an uncached resolution here would be two Firestore reads\n * per recipient rather than per host. Scoped to the run, so a domain\n * un-verified by the re-check sweep is picked up on the next one.\n */\n const identityByHost = new Map<\n string,\n Awaited<ReturnType<typeof hostSendingIdentity>>\n >()\n /** Each site's public origin, for the unsubscribe link on the alert. */\n const siteBaseByHost = new Map<string, string>()\n /**\n * Each site's consent group, off the same org read as its branding, so the\n * gate honors an unsubscribe from any site the org declared one sender\n * with this one, and the org's word on whether its sites wait for each\n * other's confirmation click.\n */\n const consentGroupByHost = new Map<string, Aglyn.ConsentGroup>()\n let skippedLocked = 0\n for (const docSnapshot of alerts.docs) {\n const hostRef = docSnapshot.ref.parent.parent\n if (!hostRef) continue\n // LOCKDOWN (AGL-2495), before anything in this body writes. The poisoned-\n // row retirement below stamps `notifiedAtMs`, which is a write, and the\n // email is a message sent in a suspended merchant's name — both wait.\n //\n // SKIPPED, NOT DROPPED: an alert that is not stamped is re-scanned on the\n // next beat, which is exactly the behaviour the `notifiedAtMs == null`\n // query already relies on.\n if (await gate.isLocked(hostRef.id)) {\n skippedLocked += 1\n continue\n }\n const data = docSnapshot.data() as any\n // AGL-1774: `productId` is a STORED FIELD written by an unauthenticated\n // POST (`notify-restock.ts`), and this line is where it becomes a path\n // component. `.doc()` appends a slash-separated path and throws\n // SYNCHRONOUSLY on an even component count — a throw that landed here,\n // outside the loop body's own error handling, so it aborted the entire\n // run. The scan is a `collectionGroup`, so that run is platform-wide;\n // and because the offending alert was never stamped `notifiedAtMs` it\n // returned on every subsequent run, taking every alert ordered after it\n // down too. One anonymous request stopped back-in-stock email for every\n // merchant, permanently.\n //\n // `notify-restock.ts` now refuses such a value at the door, but this\n // guard is not redundant with that one: rows written BEFORE the fix are\n // already in the database, and a create-time check does nothing about\n // them. Treated as \"no product\" rather than as an error, which is the\n // correct refusal by AGL-1760's test — there is no product, so there is\n // no email owed and no work to discard — and it stamps the alert below\n // so a poisoned row is retired instead of re-scanned forever.\n const productId = isDocumentId(data.productId) ? data.productId : ''\n const cacheKey = `${hostRef.id}:${productId}`\n if (productId && !productCache.has(cacheKey)) {\n const productSnapshot = await hostRef\n .collection('products')\n .doc(productId)\n .get()\n productCache.set(\n cacheKey,\n productSnapshot.exists\n ? CommerceModel.liftLegacyProduct(productSnapshot.data() as any)\n : null,\n )\n }\n const product = productId ? productCache.get(cacheKey) : null\n if (!product) {\n await docSnapshot.ref\n .set({ notifiedAtMs: Date.now(), skipped: true }, { merge: true })\n .catch(() => undefined)\n continue\n }\n const total = CommerceModel.productInventory(product)\n if (total != null && total <= 0) continue\n const productUrl = `/products/${product.slug}`\n let loaded = templateCache.get(hostRef.id)\n if (loaded === undefined) {\n loaded = await loadHostEmail(firestore, hostRef.id, 'back-in-stock', {\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(hostRef.id, loaded)\n const owner = await getOrgForHost(hostRef.id).catch(() => null)\n brandingByHost.set(\n hostRef.id,\n Aglyn.resolveBrandingProfile(owner?.org as never),\n )\n consentGroupByHost.set(\n hostRef.id,\n Aglyn.consentGroupForHost(\n (owner?.org as Record<string, unknown> | undefined) ?? null,\n hostRef.id,\n ),\n )\n // The site's own origin, for the unsubscribe link. Resolved once per\n // host beside the branding, because this sweep is a `collectionGroup`\n // over every site's alerts and a read per alert would be a read per\n // shopper.\n const hostSnapshot = await hostRef.get().catch(() => null)\n siteBaseByHost.set(\n hostRef.id,\n Aglyn.hostPublicOrigin({\n cname: hostSnapshot?.get('cname'),\n subdomain: hostSnapshot?.get('subdomain'),\n }) ?? '',\n )\n }\n const designed = loaded\n ? renderLoadedHostEmail(\n loaded,\n {\n 'product.name': String(product.name ?? ''),\n 'product.url': productUrl,\n },\n Aglyn.sanitizeAuthorHtml,\n )\n : null\n /*\n * MARKETING, and `'bulk'` priority, which this sweep is entitled to\n * because it is resumable: an alert left unstamped is re-scanned by the\n * `notifiedAtMs == null` query on the next beat, so a refusal means \"not\n * this hour\" rather than a shopper who asked to be told and never was.\n *\n * The gate adds the unsubscribe header pair and a visible link, checks\n * both suppression lists, and counts this against how much mail the\n * shopper has had from this site today.\n */\n const consentGroup =\n consentGroupByHost.get(hostRef.id) ?? Aglyn.soloConsentGroup(hostRef.id)\n const result = await sendEmail({\n to: String(data.email),\n subject: designed?.subject ?? `Back in stock: ${product.name}`,\n text:\n designed?.text ||\n `${product.name} is available again — grab it before it sells ` +\n `out:\\n\\n${productUrl}`,\n ...(designed?.html ? { html: designed.html } : {}),\n fromName: brandingByHost.get(hostRef.id)?.fromName,\n sendingIdentity: await hostSendingIdentity(hostRef.id, identityByHost),\n audience: 'tenant',\n context: 'restock alert',\n priority: 'bulk',\n /*\n * A restock alert IS the \"Product updates\" stream — the catalog entry\n * names restocks in its own description. Declared so a shopper who\n * unticked that stream on the preference page is not mailed it: the\n * gate asks the topic only for callers that name one, so a sender that\n * left this off would keep mailing them.\n */\n marketing: {\n hostId: hostRef.id,\n siteBase: siteBaseByHost.get(hostRef.id) ?? '',\n topicId: Aglyn.EMAIL_TOPIC_PRODUCT_UPDATES,\n consentHostIds: consentGroup.hostIds,\n consentAwaitsConfirmation: consentGroup.awaitsConfirmation,\n },\n })\n /*\n * SKIPPED, NOT DROPPED — the lockdown rule above, applied to the two\n * refusals a later beat can pass.\n *\n * `notifiedAtMs` is what retires an alert from this sweep. Stamping it\n * after the hourly ceiling or the frequency cap refused would drop a\n * request from a shopper who asked to be told; NOT stamping it after a\n * suppression or a rejection would re-read the same doomed row forever,\n * inside a window of two hundred, starving every alert behind it.\n */\n if (isDeferrableSendResult(result)) continue\n // Cost meter (AGL-1438). One alert per shopper who asked to be told,\n // like the abandoned-checkout reminder beside it, and on the DELIVERED\n // message only — a suppressed recipient produced no message and no cost.\n if (result.sent) await meterHostEmail(hostRef.id)\n await docSnapshot.ref\n .set({ notifiedAtMs: Date.now() }, { merge: true })\n .catch(() => undefined)\n if (result.sent) sent += 1\n }\n return { scanned: alerts.size, sent, skippedLocked }\n}\n\n/**\n * HTTP door for the pass above (AGL-326), kept for manual/ops invocation and\n * for the console's \"send now\" control.\n */\nexport const processRestockHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const cronSecret = process.env.CRON_SECRET\n if (!cronSecret || req.headers['x-cron-secret'] !== cronSecret) {\n return res.status(401).json({ error: 'Unauthorized' })\n }\n if (!isEmailConfigured()) {\n return res.status(501).json({ error: 'Email is not configured.' })\n }\n try {\n // The manual door asks the same question the beat does (AGL-2495).\n return res.status(200).json(await scanRestockAlerts(pluginJobHostGate()))\n } catch (error) {\n console.error(error)\n return res.status(500).json({ error: 'Processing failed' })\n }\n}\n"],"names":["Aglyn","CommerceModel","firebaseAdmin","getOrgForHost","hostSendingIdentity","meterHostEmail","isDocumentId","isDeferrableSendResult","isEmailConfigured","loadHostEmail","renderLoadedHostEmail","sendEmail","pluginJobHostGate","composeHostComponentNodes","scanRestockAlerts","gate","firestore","app","alerts","collectionGroup","where","limit","get","sent","productCache","Map","templateCache","brandingByHost","identityByHost","siteBaseByHost","consentGroupByHost","skippedLocked","docSnapshot","docs","product","hostRef","ref","parent","isLocked","id","data","productId","cacheKey","has","productSnapshot","collection","doc","set","exists","liftLegacyProduct","notifiedAtMs","Date","now","skipped","merge","catch","undefined","total","productInventory","productUrl","slug","loaded","compose","owner","resolveBrandingProfile","org","consentGroupForHost","hostSnapshot","hostPublicOrigin","cname","subdomain","designed","String","name","sanitizeAuthorHtml","consentGroup","soloConsentGroup","result","to","email","subject","text","html","fromName","sendingIdentity","audience","context","priority","marketing","hostId","siteBase","topicId","EMAIL_TOPIC_PRODUCT_UPDATES","consentHostIds","hostIds","consentAwaitsConfirmation","awaitsConfirmation","scanned","size","processRestockHandler","req","res","method","status","json","error","cronSecret","process","env","CRON_SECRET","headers","console"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/process-restock.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport * as Aglyn from '@aglyn/aglyn/server'\nimport * as CommerceModel from '../model'\nimport {\n firebaseAdmin,\n getOrgForHost,\n hostSendingIdentity,\n meterHostEmail,\n} from '@aglyn/tenant-data-admin'\nimport { isDocumentId } from '@aglyn/tenant-data-admin/server/document-id'\nimport {\n isDeferrableSendResult,\n isEmailConfigured,\n loadHostEmail,\n renderLoadedHostEmail,\n sendEmail,\n type LoadedHostEmail,\n} from '@aglyn/shared-util-email'\nimport {\n type PluginApiHandler,\n type PluginJobHostGate,\n} from '@aglyn/aglyn/server'\nimport { composeHostComponentNodes } from '@aglyn/aglyn/app-utils/load-referenced-components'\n// The leaf, not the barrel: the console's `x-cron-secret` door asks the\n// site's lockdown directly (AGL-3356) — core's registry has no resolver in\n// the console process — and a spec that substitutes the barrel must still\n// reach the real verdict.\nimport { siteLockdownJobGate } from '@aglyn/tenant-data-admin/server/tenant-write-lockdown'\n\nexport interface RestockScanResult {\n scanned: number\n sent: number\n /** Alerts left unstamped because their site is locked (AGL-2495). */\n skippedLocked: number\n}\n\n/**\n * The back-in-stock pass itself, separated from its HTTP door (AGL-2227).\n *\n * Same finding as `process-abandoned.ts` beside it: `x-cron-secret`-gated and\n * **never invoked by anything**. `commerce/notify-restock` accepts the\n * shopper's \"tell me when it's back\" from the storefront and writes\n * `hosts/{hostId}/restockAlerts`; this is the only thing that would ever mail\n * them. Shoppers have been entering an address into a queue with no drain.\n *\n * Exported so `server.ts` can put it on the platform job beat.\n */\nexport async function scanRestockAlerts(\n /** The lockdown gate, injected by the caller (AGL-2495). Not optional. */\n gate: PluginJobHostGate,\n): Promise<RestockScanResult> {\n const firestore = firebaseAdmin.app().firestore()\n const alerts = await firestore\n .collectionGroup('restockAlerts')\n .where('notifiedAtMs', '==', null)\n .limit(200)\n .get()\n let sent = 0\n const productCache = new Map<string, CommerceModel.HostProduct | null>()\n // Resolve each host's designed template once per run (AGL-770).\n const templateCache = new Map<string, LoadedHostEmail | null>()\n // White-label brand per host (White-Label Phase 3): resolved once per host\n // from the owning org doc through the one shared resolver.\n const brandingByHost = new Map<string, Aglyn.ResolvedBrandingProfile>()\n /*\n * One identity resolution per SITE, not per message.\n *\n * The same reason the branding map above exists: this sweep runs across\n * every site, and an uncached resolution here would be two Firestore reads\n * per recipient rather than per host. Scoped to the run, so a domain\n * un-verified by the re-check sweep is picked up on the next one.\n */\n const identityByHost = new Map<\n string,\n Awaited<ReturnType<typeof hostSendingIdentity>>\n >()\n /** Each site's public origin, for the unsubscribe link on the alert. */\n const siteBaseByHost = new Map<string, string>()\n /**\n * Each site's consent group, off the same org read as its branding, so the\n * gate honors an unsubscribe from any site the org declared one sender\n * with this one, and the org's word on whether its sites wait for each\n * other's confirmation click.\n */\n const consentGroupByHost = new Map<string, Aglyn.ConsentGroup>()\n let skippedLocked = 0\n for (const docSnapshot of alerts.docs) {\n const hostRef = docSnapshot.ref.parent.parent\n if (!hostRef) continue\n // LOCKDOWN (AGL-2495), before anything in this body writes. The poisoned-\n // row retirement below stamps `notifiedAtMs`, which is a write, and the\n // email is a message sent in a suspended merchant's name — both wait.\n //\n // SKIPPED, NOT DROPPED: an alert that is not stamped is re-scanned on the\n // next beat, which is exactly the behaviour the `notifiedAtMs == null`\n // query already relies on.\n if (await gate.isLocked(hostRef.id)) {\n skippedLocked += 1\n continue\n }\n const data = docSnapshot.data() as any\n // AGL-1774: `productId` is a STORED FIELD written by an unauthenticated\n // POST (`notify-restock.ts`), and this line is where it becomes a path\n // component. `.doc()` appends a slash-separated path and throws\n // SYNCHRONOUSLY on an even component count — a throw that landed here,\n // outside the loop body's own error handling, so it aborted the entire\n // run. The scan is a `collectionGroup`, so that run is platform-wide;\n // and because the offending alert was never stamped `notifiedAtMs` it\n // returned on every subsequent run, taking every alert ordered after it\n // down too. One anonymous request stopped back-in-stock email for every\n // merchant, permanently.\n //\n // `notify-restock.ts` now refuses such a value at the door, but this\n // guard is not redundant with that one: rows written BEFORE the fix are\n // already in the database, and a create-time check does nothing about\n // them. Treated as \"no product\" rather than as an error, which is the\n // correct refusal by AGL-1760's test — there is no product, so there is\n // no email owed and no work to discard — and it stamps the alert below\n // so a poisoned row is retired instead of re-scanned forever.\n const productId = isDocumentId(data.productId) ? data.productId : ''\n const cacheKey = `${hostRef.id}:${productId}`\n if (productId && !productCache.has(cacheKey)) {\n const productSnapshot = await hostRef\n .collection('products')\n .doc(productId)\n .get()\n productCache.set(\n cacheKey,\n productSnapshot.exists\n ? CommerceModel.liftLegacyProduct(productSnapshot.data() as any)\n : null,\n )\n }\n const product = productId ? productCache.get(cacheKey) : null\n if (!product) {\n await docSnapshot.ref\n .set({ notifiedAtMs: Date.now(), skipped: true }, { merge: true })\n .catch(() => undefined)\n continue\n }\n const total = CommerceModel.productInventory(product)\n if (total != null && total <= 0) continue\n const productUrl = `/products/${product.slug}`\n let loaded = templateCache.get(hostRef.id)\n if (loaded === undefined) {\n loaded = await loadHostEmail(firestore, hostRef.id, 'back-in-stock', {\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(hostRef.id, loaded)\n const owner = await getOrgForHost(hostRef.id).catch(() => null)\n brandingByHost.set(\n hostRef.id,\n Aglyn.resolveBrandingProfile(owner?.org as never),\n )\n consentGroupByHost.set(\n hostRef.id,\n Aglyn.consentGroupForHost(\n (owner?.org as Record<string, unknown> | undefined) ?? null,\n hostRef.id,\n ),\n )\n // The site's own origin, for the unsubscribe link. Resolved once per\n // host beside the branding, because this sweep is a `collectionGroup`\n // over every site's alerts and a read per alert would be a read per\n // shopper.\n const hostSnapshot = await hostRef.get().catch(() => null)\n siteBaseByHost.set(\n hostRef.id,\n Aglyn.hostPublicOrigin({\n cname: hostSnapshot?.get('cname'),\n subdomain: hostSnapshot?.get('subdomain'),\n }) ?? '',\n )\n }\n const designed = loaded\n ? renderLoadedHostEmail(\n loaded,\n {\n 'product.name': String(product.name ?? ''),\n 'product.url': productUrl,\n },\n Aglyn.sanitizeAuthorHtml,\n )\n : null\n /*\n * MARKETING, and `'bulk'` priority, which this sweep is entitled to\n * because it is resumable: an alert left unstamped is re-scanned by the\n * `notifiedAtMs == null` query on the next beat, so a refusal means \"not\n * this hour\" rather than a shopper who asked to be told and never was.\n *\n * The gate adds the unsubscribe header pair and a visible link, checks\n * both suppression lists, and counts this against how much mail the\n * shopper has had from this site today.\n */\n const consentGroup =\n consentGroupByHost.get(hostRef.id) ?? Aglyn.soloConsentGroup(hostRef.id)\n const result = await sendEmail({\n to: String(data.email),\n subject: designed?.subject ?? `Back in stock: ${product.name}`,\n text:\n designed?.text ||\n `${product.name} is available again — grab it before it sells ` +\n `out:\\n\\n${productUrl}`,\n ...(designed?.html ? { html: designed.html } : {}),\n fromName: brandingByHost.get(hostRef.id)?.fromName,\n sendingIdentity: await hostSendingIdentity(hostRef.id, identityByHost),\n audience: 'tenant',\n context: 'restock alert',\n priority: 'bulk',\n /*\n * A restock alert IS the \"Product updates\" stream — the catalog entry\n * names restocks in its own description. Declared so a shopper who\n * unticked that stream on the preference page is not mailed it: the\n * gate asks the topic only for callers that name one, so a sender that\n * left this off would keep mailing them.\n */\n marketing: {\n hostId: hostRef.id,\n siteBase: siteBaseByHost.get(hostRef.id) ?? '',\n topicId: Aglyn.EMAIL_TOPIC_PRODUCT_UPDATES,\n consentHostIds: consentGroup.hostIds,\n consentAwaitsConfirmation: consentGroup.awaitsConfirmation,\n },\n })\n /*\n * SKIPPED, NOT DROPPED — the lockdown rule above, applied to the two\n * refusals a later beat can pass.\n *\n * `notifiedAtMs` is what retires an alert from this sweep. Stamping it\n * after the hourly ceiling or the frequency cap refused would drop a\n * request from a shopper who asked to be told; NOT stamping it after a\n * suppression or a rejection would re-read the same doomed row forever,\n * inside a window of two hundred, starving every alert behind it.\n */\n if (isDeferrableSendResult(result)) continue\n // Cost meter (AGL-1438). One alert per shopper who asked to be told,\n // like the abandoned-checkout reminder beside it, and on the DELIVERED\n // message only — a suppressed recipient produced no message and no cost.\n if (result.sent) await meterHostEmail(hostRef.id)\n await docSnapshot.ref\n .set({ notifiedAtMs: Date.now() }, { merge: true })\n .catch(() => undefined)\n if (result.sent) sent += 1\n }\n return { scanned: alerts.size, sent, skippedLocked }\n}\n\n/**\n * HTTP door for the pass above (AGL-326), kept for manual/ops invocation and\n * for the console's \"send now\" control.\n */\nexport const processRestockHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const cronSecret = process.env.CRON_SECRET\n if (!cronSecret || req.headers['x-cron-secret'] !== cronSecret) {\n return res.status(401).json({ error: 'Unauthorized' })\n }\n if (!isEmailConfigured()) {\n return res.status(501).json({ error: 'Email is not configured.' })\n }\n try {\n // The manual door asks the same question the beat does (AGL-2495).\n return res.status(200).json(await scanRestockAlerts(siteLockdownJobGate()))\n } catch (error) {\n console.error(error)\n return res.status(500).json({ error: 'Processing failed' })\n }\n}\n"],"names":["Aglyn","CommerceModel","firebaseAdmin","getOrgForHost","hostSendingIdentity","meterHostEmail","isDocumentId","isDeferrableSendResult","isEmailConfigured","loadHostEmail","renderLoadedHostEmail","sendEmail","composeHostComponentNodes","siteLockdownJobGate","scanRestockAlerts","gate","firestore","app","alerts","collectionGroup","where","limit","get","sent","productCache","Map","templateCache","brandingByHost","identityByHost","siteBaseByHost","consentGroupByHost","skippedLocked","docSnapshot","docs","product","hostRef","ref","parent","isLocked","id","data","productId","cacheKey","has","productSnapshot","collection","doc","set","exists","liftLegacyProduct","notifiedAtMs","Date","now","skipped","merge","catch","undefined","total","productInventory","productUrl","slug","loaded","compose","owner","resolveBrandingProfile","org","consentGroupForHost","hostSnapshot","hostPublicOrigin","cname","subdomain","designed","String","name","sanitizeAuthorHtml","consentGroup","soloConsentGroup","result","to","email","subject","text","html","fromName","sendingIdentity","audience","context","priority","marketing","hostId","siteBase","topicId","EMAIL_TOPIC_PRODUCT_UPDATES","consentHostIds","hostIds","consentAwaitsConfirmation","awaitsConfirmation","scanned","size","processRestockHandler","req","res","method","status","json","error","cronSecret","process","env","CRON_SECRET","headers","console"],"mappings":"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"}
@@ -0,0 +1,44 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ import { type RecurringChargeRecord } from '@aglyn/aglyn/plugin-manager/plugin-recurring-charges';
18
+ /**
19
+ * Stripe statuses that can still produce a charge. `canceled` and
20
+ * `incomplete_expired` never bill again; `paused` (a trial that ended with
21
+ * no payment method) does not bill until a card is added, which the lock's
22
+ * pause would then catch on the next lock anyway.
23
+ */
24
+ export declare const LIVE_SUBSCRIPTION_STATUSES: readonly ["active", "trialing", "past_due", "unpaid", "incomplete"];
25
+ /** A Firestore handle's shape, as far as this reader uses it. */
26
+ export interface RecurringChargesFirestore {
27
+ collection(name: string): {
28
+ doc(id: string): {
29
+ collection(name: string): {
30
+ where(field: string, op: 'in', value: readonly string[]): {
31
+ get(): Promise<{
32
+ docs: Array<{
33
+ id: string;
34
+ }>;
35
+ }>;
36
+ };
37
+ };
38
+ };
39
+ };
40
+ }
41
+ /** Every live subscription the given sites sell. Throws if a read throws. */
42
+ export declare function listCommerceLiveSubscriptions(firestore: RecurringChargesFirestore, hostIds: readonly string[]): Promise<RecurringChargeRecord[]>;
43
+ /** Declares the storefront's subscriptions to the lockdown. */
44
+ export declare function registerCommerceRecurringCharges(): void;
@@ -0,0 +1,65 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ /*==========================================
17
+ * THE MEMBERSHIPS A SITE SELLS, FOR A SECURITY LOCKDOWN (AGL-3364).
18
+ *
19
+ * A storefront subscription (a membership, a recurring product) is
20
+ * recorded at `hosts/{hostId}/subscriptions/{stripeSubscriptionId}` when
21
+ * the checkout completes, and its `status` follows Stripe's
22
+ * `customer.subscription.*` events (see `billing-webhook.ts`). That record
23
+ * is the answer to "what does this site bill on a schedule": the lockdown
24
+ * asks here and pauses exactly these, rather than searching Stripe.
25
+ *=========================================*/ import { registerRecurringChargeSource } from "@aglyn/aglyn/plugin-manager/plugin-recurring-charges";
26
+ import { firebaseAdmin } from "@aglyn/tenant-data-admin";
27
+ import { BUNDLE_ID } from "../constants/bundle-common.js";
28
+ /**
29
+ * Stripe statuses that can still produce a charge. `canceled` and
30
+ * `incomplete_expired` never bill again; `paused` (a trial that ended with
31
+ * no payment method) does not bill until a card is added, which the lock's
32
+ * pause would then catch on the next lock anyway.
33
+ */ // AGL-1715-EXEMPT: "can this still charge", a superset of the live triple
34
+ // that adds `unpaid` and `incomplete` — a lock must pause every schedule
35
+ // Stripe could still bill, not only the ones that grant access.
36
+ export const LIVE_SUBSCRIPTION_STATUSES = [
37
+ 'active',
38
+ 'trialing',
39
+ 'past_due',
40
+ 'unpaid',
41
+ 'incomplete'
42
+ ];
43
+ /** Every live subscription the given sites sell. Throws if a read throws. */ export async function listCommerceLiveSubscriptions(firestore, hostIds) {
44
+ const found = [];
45
+ for (const hostId of hostIds){
46
+ if (!hostId) continue;
47
+ const snapshot = await firestore.collection('hosts').doc(hostId).collection('subscriptions').where('status', 'in', LIVE_SUBSCRIPTION_STATUSES).get();
48
+ for (const doc of snapshot.docs){
49
+ found.push({
50
+ subscriptionId: doc.id,
51
+ hostId
52
+ });
53
+ }
54
+ }
55
+ return found;
56
+ }
57
+ /** Declares the storefront's subscriptions to the lockdown. */ export function registerCommerceRecurringCharges() {
58
+ registerRecurringChargeSource({
59
+ listLiveSubscriptions: ({ hostIds })=>listCommerceLiveSubscriptions(firebaseAdmin.app().firestore(), hostIds)
60
+ }, {
61
+ pluginId: BUNDLE_ID
62
+ });
63
+ }
64
+
65
+ //# sourceMappingURL=recurring-charges.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/recurring-charges.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/*==========================================\n * THE MEMBERSHIPS A SITE SELLS, FOR A SECURITY LOCKDOWN (AGL-3364).\n *\n * A storefront subscription (a membership, a recurring product) is\n * recorded at `hosts/{hostId}/subscriptions/{stripeSubscriptionId}` when\n * the checkout completes, and its `status` follows Stripe's\n * `customer.subscription.*` events (see `billing-webhook.ts`). That record\n * is the answer to \"what does this site bill on a schedule\": the lockdown\n * asks here and pauses exactly these, rather than searching Stripe.\n *=========================================*/\n\nimport {\n type RecurringChargeRecord,\n registerRecurringChargeSource,\n} from '@aglyn/aglyn/plugin-manager/plugin-recurring-charges'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin'\nimport { BUNDLE_ID } from '../constants/bundle-common'\n\n/**\n * Stripe statuses that can still produce a charge. `canceled` and\n * `incomplete_expired` never bill again; `paused` (a trial that ended with\n * no payment method) does not bill until a card is added, which the lock's\n * pause would then catch on the next lock anyway.\n */\n// AGL-1715-EXEMPT: \"can this still charge\", a superset of the live triple\n// that adds `unpaid` and `incomplete` — a lock must pause every schedule\n// Stripe could still bill, not only the ones that grant access.\nexport const LIVE_SUBSCRIPTION_STATUSES = [\n 'active',\n 'trialing',\n 'past_due',\n 'unpaid',\n 'incomplete',\n] as const\n\n/** A Firestore handle's shape, as far as this reader uses it. */\nexport interface RecurringChargesFirestore {\n collection(name: string): {\n doc(id: string): {\n collection(name: string): {\n where(field: string, op: 'in', value: readonly string[]): {\n get(): Promise<{ docs: Array<{ id: string }> }>\n }\n }\n }\n }\n}\n\n/** Every live subscription the given sites sell. Throws if a read throws. */\nexport async function listCommerceLiveSubscriptions(\n firestore: RecurringChargesFirestore,\n hostIds: readonly string[],\n): Promise<RecurringChargeRecord[]> {\n const found: RecurringChargeRecord[] = []\n for (const hostId of hostIds) {\n if (!hostId) continue\n const snapshot = await firestore\n .collection('hosts')\n .doc(hostId)\n .collection('subscriptions')\n .where('status', 'in', LIVE_SUBSCRIPTION_STATUSES)\n .get()\n for (const doc of snapshot.docs) {\n found.push({ subscriptionId: doc.id, hostId })\n }\n }\n return found\n}\n\n/** Declares the storefront's subscriptions to the lockdown. */\nexport function registerCommerceRecurringCharges(): void {\n registerRecurringChargeSource(\n {\n listLiveSubscriptions: ({ hostIds }) =>\n listCommerceLiveSubscriptions(\n firebaseAdmin.app().firestore() as unknown as RecurringChargesFirestore,\n hostIds,\n ),\n },\n { pluginId: BUNDLE_ID },\n )\n}\n"],"names":["registerRecurringChargeSource","firebaseAdmin","BUNDLE_ID","LIVE_SUBSCRIPTION_STATUSES","listCommerceLiveSubscriptions","firestore","hostIds","found","hostId","snapshot","collection","doc","where","get","docs","push","subscriptionId","id","registerCommerceRecurringCharges","listLiveSubscriptions","app","pluginId"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;2CAS2C,GAE3C,SAEEA,6BAA6B,QACxB,uDAAsD;AAC7D,SAASC,aAAa,QAAQ,2BAA0B;AACxD,SAASC,SAAS,QAAQ,gCAA4B;AAEtD;;;;;CAKC,GACD,0EAA0E;AAC1E,yEAAyE;AACzE,gEAAgE;AAChE,OAAO,MAAMC,6BAA6B;IACxC;IACA;IACA;IACA;IACA;CACD,CAAS;AAeV,2EAA2E,GAC3E,OAAO,eAAeC,8BACpBC,SAAoC,EACpCC,OAA0B;IAE1B,MAAMC,QAAiC,EAAE;IACzC,KAAK,MAAMC,UAAUF,QAAS;QAC5B,IAAI,CAACE,QAAQ;QACb,MAAMC,WAAW,MAAMJ,UACpBK,UAAU,CAAC,SACXC,GAAG,CAACH,QACJE,UAAU,CAAC,iBACXE,KAAK,CAAC,UAAU,MAAMT,4BACtBU,GAAG;QACN,KAAK,MAAMF,OAAOF,SAASK,IAAI,CAAE;YAC/BP,MAAMQ,IAAI,CAAC;gBAAEC,gBAAgBL,IAAIM,EAAE;gBAAET;YAAO;QAC9C;IACF;IACA,OAAOD;AACT;AAEA,6DAA6D,GAC7D,OAAO,SAASW;IACdlB,8BACE;QACEmB,uBAAuB,CAAC,EAAEb,OAAO,EAAE,GACjCF,8BACEH,cAAcmB,GAAG,GAAGf,SAAS,IAC7BC;IAEN,GACA;QAAEe,UAAUnB;IAAU;AAE1B"}
@@ -20,6 +20,7 @@ import { firebaseAdmin, getOrgForHost } from "@aglyn/tenant-data-admin";
20
20
  import { resolveOrgPermissions } from "@aglyn/tenant-runtime/org-permissions";
21
21
  import { createHash } from "crypto";
22
22
  import { recordContactRefund } from "./contact-refund.js";
23
+ import { applyGiftCardRiskToOrder } from "./gift-card-risk.js";
23
24
  import { flagOrderRestock } from "./restock-flag.js";
24
25
  // Leaf import, not the barrel, for the reason `contact-refund.ts` states about
25
26
  // `updateExisting`: the specs in this library mock `@aglyn/tenant-data-admin`
@@ -528,6 +529,29 @@ import { reverseEmailAttributedRevenue } from "@aglyn/tenant-data-admin/server/e
528
529
  // restock ledgers below: the money has moved and nothing here may fail a
529
530
  // refund that already left the merchant's account.
530
531
  await retireLicenseKeys(hostRef, orderRef, orderId);
532
+ // GIFT CARDS THE REFUND TOOK BACK ARE VOIDED (AGL-3363). The money went
533
+ // back to the card that paid, so what is left on the cards that money
534
+ // bought goes too — every card on a full refund, and on a partial one
535
+ // only the cards bought by the lines it withdrew. A refund by amount with
536
+ // no lines named withdraws nothing, so it voids nothing. Same best-effort
537
+ // placement as the key retirement above: the refund has already moved.
538
+ if (fullyRefunded || requestedLineIds.length > 0) {
539
+ await applyGiftCardRiskToOrder({
540
+ firestore,
541
+ hostRef,
542
+ orderId,
543
+ action: _extends({
544
+ kind: 'void',
545
+ reason: 'refund'
546
+ }, fullyRefunded ? {} : {
547
+ productIds: requestedLineIds.map((index)=>{
548
+ var _ref;
549
+ var _order_lineItems_index, _order_lineItems;
550
+ return String((_ref = (_order_lineItems = order.lineItems) == null ? void 0 : (_order_lineItems_index = _order_lineItems[index]) == null ? void 0 : _order_lineItems_index.productId) != null ? _ref : '');
551
+ }).filter(Boolean)
552
+ })
553
+ });
554
+ }
531
555
  // The customer's side of the ledger (AGL-1754). Everything above records
532
556
  // the money on the ORDER; without this the buyer's `ltvCents` still counts
533
557
  // a sale they returned, and only ever rises.