@aglyn/plugins-commerce 1.0.0-beta.201 → 1.0.0-beta.202
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +13 -13
- package/src/lib/components/console/gift-cards-card.component.js +34 -2
- package/src/lib/components/console/gift-cards-card.component.js.map +1 -1
- package/src/lib/model/commerce-gift-cards.d.ts +28 -0
- package/src/lib/model/commerce-gift-cards.js +39 -1
- package/src/lib/model/commerce-gift-cards.js.map +1 -1
- package/src/lib/server/billing-webhook.js +58 -2
- package/src/lib/server/billing-webhook.js.map +1 -1
- package/src/lib/server/cart-checkout.js +46 -8
- package/src/lib/server/cart-checkout.js.map +1 -1
- package/src/lib/server/checkout.js +23 -5
- package/src/lib/server/checkout.js.map +1 -1
- package/src/lib/server/download.js +12 -1
- package/src/lib/server/download.js.map +1 -1
- package/src/lib/server/draft-order.js +5 -2
- package/src/lib/server/draft-order.js.map +1 -1
- package/src/lib/server/feed.js +12 -2
- package/src/lib/server/feed.js.map +1 -1
- package/src/lib/server/gift-card-risk.d.ts +70 -0
- package/src/lib/server/gift-card-risk.js +123 -0
- package/src/lib/server/gift-card-risk.js.map +1 -0
- package/src/lib/server/gift-cards.js +37 -2
- package/src/lib/server/gift-cards.js.map +1 -1
- package/src/lib/server/paid-media-delivery.d.ts +26 -4
- package/src/lib/server/paid-media-delivery.js +36 -9
- package/src/lib/server/paid-media-delivery.js.map +1 -1
- package/src/lib/server/recurring-charges.d.ts +44 -0
- package/src/lib/server/recurring-charges.js +65 -0
- package/src/lib/server/recurring-charges.js.map +1 -0
- package/src/lib/server/refund.js +24 -0
- package/src/lib/server/refund.js.map +1 -1
- package/src/lib/server/reserve.js +14 -5
- package/src/lib/server/reserve.js.map +1 -1
- package/src/lib/server/stream.js +12 -1
- package/src/lib/server/stream.js.map +1 -1
- package/src/lib/server/subscription-portal.js +13 -5
- package/src/lib/server/subscription-portal.js.map +1 -1
- package/src/lib/server/supplier-outbox.js +22 -7
- package/src/lib/server/supplier-outbox.js.map +1 -1
- package/src/lib/server.js +15 -3
- package/src/lib/server.js.map +1 -1
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
14
|
* See the License for the specific language governing permissions and
|
|
15
15
|
* limitations under the License.
|
|
16
|
-
*/ import {
|
|
16
|
+
*/ import { readSiteReturnHost, siteReturnUrl } from "@aglyn/aglyn/app-utils/site-return-url";
|
|
17
|
+
import { firebaseAdmin } from "@aglyn/tenant-data-admin";
|
|
17
18
|
import { requireActiveMember } from "./membership.js";
|
|
18
19
|
/**
|
|
19
20
|
* Subscriber self-service (AGL-303): opens the Stripe Billing Portal for
|
|
@@ -38,7 +39,7 @@ import { requireActiveMember } from "./membership.js";
|
|
|
38
39
|
error: 'Missing hostId'
|
|
39
40
|
});
|
|
40
41
|
try {
|
|
41
|
-
var _auth_member_get, _req_headers_referer;
|
|
42
|
+
var _auth_member_get, _req_headers_referer, _req_headers_host;
|
|
42
43
|
var _subscriptions_docs_;
|
|
43
44
|
// Suspension gate (AGL-550): suspended members (AGL-546) cannot
|
|
44
45
|
// open the billing portal — 403'd with the session cookie cleared.
|
|
@@ -57,8 +58,15 @@ import { requireActiveMember } from "./membership.js";
|
|
|
57
58
|
error: 'No subscription found'
|
|
58
59
|
});
|
|
59
60
|
}
|
|
60
|
-
|
|
61
|
-
const
|
|
61
|
+
// The site's own page, never a header's say-so (AGL-3363).
|
|
62
|
+
const returnUrl = siteReturnUrl({
|
|
63
|
+
candidates: [
|
|
64
|
+
String((_req_headers_referer = req.headers.referer) != null ? _req_headers_referer : ''),
|
|
65
|
+
`https://${req.headers.host}`
|
|
66
|
+
],
|
|
67
|
+
site: await readSiteReturnHost(hostRef),
|
|
68
|
+
requestHost: String((_req_headers_host = req.headers.host) != null ? _req_headers_host : '')
|
|
69
|
+
});
|
|
62
70
|
const response = await fetch('https://api.stripe.com/v1/billing_portal/sessions', {
|
|
63
71
|
method: 'POST',
|
|
64
72
|
headers: {
|
|
@@ -67,7 +75,7 @@ import { requireActiveMember } from "./membership.js";
|
|
|
67
75
|
},
|
|
68
76
|
body: new URLSearchParams({
|
|
69
77
|
customer: String(customerId),
|
|
70
|
-
return_url:
|
|
78
|
+
return_url: returnUrl
|
|
71
79
|
}).toString()
|
|
72
80
|
});
|
|
73
81
|
const session = await response.json();
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/subscription-portal.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 { PluginApiHandler } from '@aglyn/aglyn/server'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin'\nimport { requireActiveMember } from './membership'\n\n/**\n * Subscriber self-service (AGL-303): opens the Stripe Billing Portal for\n * the signed-in member's storefront subscription (cancel, switch\n * payment method). Member-gated; the portal customer is matched by the\n * member's email through the recorded subscription doc.\n */\nexport const subscriptionPortalHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n if (!process.env.STRIPE_SECRET_KEY) {\n return res.status(501).json({ error: 'Billing is not configured.' })\n }\n const hostId = String(req.body?.hostId ?? '')\n if (!hostId) return res.status(400).json({ error: 'Missing hostId' })\n\n try {\n // Suspension gate (AGL-550): suspended members (AGL-546) cannot\n // open the billing portal — 403'd with the session cookie cleared.\n const auth = await requireActiveMember(req, res, hostId)\n if (!auth) return\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(hostId)\n const email = String(auth.member.get('email') ?? '')\n if (!email) return res.status(401).json({ error: 'Not signed in' })\n const subscriptions = await hostRef\n .collection('subscriptions')\n .where('customerEmail', '==', email)\n .limit(1)\n .get()\n const customerId = subscriptions.docs[0]?.get('stripeCustomerId')\n if (!customerId) {\n return res.status(404).json({ error: 'No subscription found' })\n }\n const
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/subscription-portal.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { readSiteReturnHost, siteReturnUrl } from '@aglyn/aglyn/app-utils/site-return-url'\nimport type { PluginApiHandler } from '@aglyn/aglyn/server'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin'\nimport { requireActiveMember } from './membership'\n\n/**\n * Subscriber self-service (AGL-303): opens the Stripe Billing Portal for\n * the signed-in member's storefront subscription (cancel, switch\n * payment method). Member-gated; the portal customer is matched by the\n * member's email through the recorded subscription doc.\n */\nexport const subscriptionPortalHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n if (!process.env.STRIPE_SECRET_KEY) {\n return res.status(501).json({ error: 'Billing is not configured.' })\n }\n const hostId = String(req.body?.hostId ?? '')\n if (!hostId) return res.status(400).json({ error: 'Missing hostId' })\n\n try {\n // Suspension gate (AGL-550): suspended members (AGL-546) cannot\n // open the billing portal — 403'd with the session cookie cleared.\n const auth = await requireActiveMember(req, res, hostId)\n if (!auth) return\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(hostId)\n const email = String(auth.member.get('email') ?? '')\n if (!email) return res.status(401).json({ error: 'Not signed in' })\n const subscriptions = await hostRef\n .collection('subscriptions')\n .where('customerEmail', '==', email)\n .limit(1)\n .get()\n const customerId = subscriptions.docs[0]?.get('stripeCustomerId')\n if (!customerId) {\n return res.status(404).json({ error: 'No subscription found' })\n }\n // The site's own page, never a header's say-so (AGL-3363).\n const returnUrl = siteReturnUrl({\n candidates: [String(req.headers.referer ?? ''), `https://${req.headers.host}`],\n site: await readSiteReturnHost(hostRef),\n requestHost: String(req.headers.host ?? ''),\n })\n const response = await fetch(\n 'https://api.stripe.com/v1/billing_portal/sessions',\n {\n method: 'POST',\n headers: {\n Authorization: `Bearer ${process.env.STRIPE_SECRET_KEY}`,\n 'Content-Type': 'application/x-www-form-urlencoded',\n },\n body: new URLSearchParams({\n customer: String(customerId),\n return_url: returnUrl,\n }).toString(),\n },\n )\n const session = await response.json()\n if (!response.ok || !session.url) {\n console.error('Portal error', session?.error)\n return res.status(502).json({ error: 'Portal unavailable' })\n }\n return res.status(200).json({ url: session.url })\n } catch (error) {\n console.error(error)\n return res.status(500).json({ error: 'Portal unavailable' })\n }\n}\n"],"names":["readSiteReturnHost","siteReturnUrl","firebaseAdmin","requireActiveMember","subscriptionPortalHandler","req","res","method","status","json","error","process","env","STRIPE_SECRET_KEY","hostId","String","body","auth","subscriptions","firestore","app","hostRef","collection","doc","email","member","get","where","limit","customerId","docs","returnUrl","candidates","headers","referer","host","site","requestHost","response","fetch","Authorization","URLSearchParams","customer","return_url","toString","session","ok","url","console"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,kBAAkB,EAAEC,aAAa,QAAQ,yCAAwC;AAE1F,SAASC,aAAa,QAAQ,2BAA0B;AACxD,SAASC,mBAAmB,QAAQ,kBAAc;AAElD;;;;;CAKC,GACD,OAAO,MAAMC,4BAA8C,OAAOC,KAAKC;;QAO/CD;IANtB,IAAIA,IAAIE,MAAM,KAAK,QAAQ;QACzB,OAAOD,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEC,OAAO;QAAqB;IAC5D;IACA,IAAI,CAACC,QAAQC,GAAG,CAACC,iBAAiB,EAAE;QAClC,OAAOP,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEC,OAAO;QAA6B;IACpE;IACA,MAAMI,SAASC,gBAAOV,YAAAA,IAAIW,IAAI,qBAARX,UAAUS,MAAM,mBAAI;IAC1C,IAAI,CAACA,QAAQ,OAAOR,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;QAAEC,OAAO;IAAiB;IAEnE,IAAI;YAOmBO,kBAaCZ,sBAEAA;YARHa;QAbnB,gEAAgE;QAChE,mEAAmE;QACnE,MAAMD,OAAO,MAAMd,oBAAoBE,KAAKC,KAAKQ;QACjD,IAAI,CAACG,MAAM;QACX,MAAME,YAAYjB,cAAckB,GAAG,GAAGD,SAAS;QAC/C,MAAME,UAAUF,UAAUG,UAAU,CAAC,SAASC,GAAG,CAACT;QAClD,MAAMU,QAAQT,QAAOE,mBAAAA,KAAKQ,MAAM,CAACC,GAAG,CAAC,oBAAhBT,mBAA4B;QACjD,IAAI,CAACO,OAAO,OAAOlB,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEC,OAAO;QAAgB;QACjE,MAAMQ,gBAAgB,MAAMG,QACzBC,UAAU,CAAC,iBACXK,KAAK,CAAC,iBAAiB,MAAMH,OAC7BI,KAAK,CAAC,GACNF,GAAG;QACN,MAAMG,cAAaX,uBAAAA,cAAcY,IAAI,CAAC,EAAE,qBAArBZ,qBAAuBQ,GAAG,CAAC;QAC9C,IAAI,CAACG,YAAY;YACf,OAAOvB,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;gBAAEC,OAAO;YAAwB;QAC/D;QACA,2DAA2D;QAC3D,MAAMqB,YAAY9B,cAAc;YAC9B+B,YAAY;gBAACjB,QAAOV,uBAAAA,IAAI4B,OAAO,CAACC,OAAO,YAAnB7B,uBAAuB;gBAAK,CAAC,QAAQ,EAAEA,IAAI4B,OAAO,CAACE,IAAI,EAAE;aAAC;YAC9EC,MAAM,MAAMpC,mBAAmBqB;YAC/BgB,aAAatB,QAAOV,oBAAAA,IAAI4B,OAAO,CAACE,IAAI,YAAhB9B,oBAAoB;QAC1C;QACA,MAAMiC,WAAW,MAAMC,MACrB,qDACA;YACEhC,QAAQ;YACR0B,SAAS;gBACPO,eAAe,CAAC,OAAO,EAAE7B,QAAQC,GAAG,CAACC,iBAAiB,EAAE;gBACxD,gBAAgB;YAClB;YACAG,MAAM,IAAIyB,gBAAgB;gBACxBC,UAAU3B,OAAOc;gBACjBc,YAAYZ;YACd,GAAGa,QAAQ;QACb;QAEF,MAAMC,UAAU,MAAMP,SAAS7B,IAAI;QACnC,IAAI,CAAC6B,SAASQ,EAAE,IAAI,CAACD,QAAQE,GAAG,EAAE;YAChCC,QAAQtC,KAAK,CAAC,gBAAgBmC,2BAAAA,QAASnC,KAAK;YAC5C,OAAOJ,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;gBAAEC,OAAO;YAAqB;QAC5D;QACA,OAAOJ,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEsC,KAAKF,QAAQE,GAAG;QAAC;IACjD,EAAE,OAAOrC,OAAO;QACdsC,QAAQtC,KAAK,CAACA;QACd,OAAOJ,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEC,OAAO;QAAqB;IAC5D;AACF,EAAC"}
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* See the License for the specific language governing permissions and
|
|
15
15
|
* limitations under the License.
|
|
16
16
|
*/ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
17
|
-
import { firebaseAdmin, notifyHostManagers } from "@aglyn/tenant-data-admin";
|
|
17
|
+
import { describeConfiguredUrlRefusal, fetchConfiguredPublicUrl, firebaseAdmin, flagLookalikeDomain, notifyHostManagers } from "@aglyn/tenant-data-admin";
|
|
18
18
|
import { isDocumentId } from "@aglyn/tenant-data-admin/server/document-id";
|
|
19
19
|
import { createHmac } from "crypto";
|
|
20
20
|
/**
|
|
@@ -230,8 +230,11 @@ import { createHmac } from "crypto";
|
|
|
230
230
|
const body = String((_data_body = data.body) != null ? _data_body : '');
|
|
231
231
|
let status;
|
|
232
232
|
try {
|
|
233
|
-
|
|
234
|
-
|
|
233
|
+
// A merchant-typed address, called from the platform's servers: https
|
|
234
|
+
// only, public addresses only (pinned), never a lookalike, never a
|
|
235
|
+
// followed redirect (AGL-3363). A bare `fetch` here reached any address a
|
|
236
|
+
// merchant typed, the cloud metadata endpoint included.
|
|
237
|
+
const called = await fetchConfiguredPublicUrl(url, {
|
|
235
238
|
method: 'POST',
|
|
236
239
|
headers: _extends({
|
|
237
240
|
'Content-Type': 'application/json'
|
|
@@ -241,13 +244,25 @@ import { createHmac } from "crypto";
|
|
|
241
244
|
body,
|
|
242
245
|
signal: AbortSignal.timeout(SUPPLIER_DELIVERY_TIMEOUT_MS)
|
|
243
246
|
});
|
|
244
|
-
|
|
245
|
-
|
|
247
|
+
if (called.ok === false) {
|
|
248
|
+
if (called.refusal === 'lookalike' && called.host) {
|
|
249
|
+
await flagLookalikeDomain({
|
|
250
|
+
kind: 'link',
|
|
251
|
+
hostId,
|
|
252
|
+
orgId: null,
|
|
253
|
+
domain: called.host,
|
|
254
|
+
where: 'a supplier order webhook'
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
return recordFailure(firestore, snapshot, data, attempts, now, `the supplier's webhook address was not called: ${describeConfiguredUrlRefusal(called.refusal)}`, undefined);
|
|
258
|
+
}
|
|
259
|
+
status = called.status;
|
|
260
|
+
if (!(status >= 200 && status < 300)) {
|
|
246
261
|
return recordFailure(firestore, snapshot, data, attempts, now, `supplier answered HTTP ${status}`, status);
|
|
247
262
|
}
|
|
248
263
|
} catch (error) {
|
|
249
|
-
var
|
|
250
|
-
return recordFailure(firestore, snapshot, data, attempts, now, String((
|
|
264
|
+
var _ref;
|
|
265
|
+
return recordFailure(firestore, snapshot, data, attempts, now, String((_ref = error == null ? void 0 : error.message) != null ? _ref : error), undefined);
|
|
251
266
|
}
|
|
252
267
|
// Delivered rows are DELETED rather than stamped — see the module note: the
|
|
253
268
|
// body holds the buyer's email and name, and a top-level document is outside
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/supplier-outbox.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { type PluginJobHostGate } from '@aglyn/aglyn/server'\nimport {\n firebaseAdmin,\n notifyHostManagers,\n} from '@aglyn/tenant-data-admin'\nimport { isDocumentId } from '@aglyn/tenant-data-admin/server/document-id'\nimport { createHmac } from 'crypto'\n\n/**\n * The durable outbox for the dropship supplier notification (AGL-2473).\n *\n * ## What was wrong\n *\n * `billing-webhook.ts` routed a paid dropship order inside\n * `void (async () => { … })()`, and the last thing that block did was\n * `await fetch(supplier.webhookUrl, …)` — an endpoint the MERCHANT typed into\n * a console field, on a host Aglyn neither runs nor monitors. Vercel freezes\n * the container the instant the response is written, so a supplier that is\n * slow, rate-limiting, or simply down is a supplier that is never told. The\n * order is paid, the buyer is thanked, the merchant sees \"Sent to supplier X\"\n * on the timeline, and NOTHING ANYWHERE records that the POST is still owed —\n * so no later pass can find it and no report can list it. It surfaces when the\n * customer asks where their parcel is.\n *\n * ## Why not just `await` it\n *\n * AGL-2161 fixed the other four fire-and-forget sites in that file and\n * deliberately left this one, because awaiting it is worse rather than better:\n * a supplier taking 30s pushes the handler past Stripe's delivery window,\n * Stripe redelivers, and the redelivery re-enters a fan-out whose only\n * idempotency is one `created` flag upstream. A silently dropped notification\n * would have been traded for a DUPLICATED order. `refund.ts` awaits its own\n * sibling and says so in a comment — correctly, because that sibling only\n * touches our own Firestore.\n *\n * So the line this module draws is not \"fast vs slow\" but **ours vs theirs**.\n * Everything the routing block does to Aglyn's own storage — the callback\n * token, the timeline entry, the supplier email through our own provider — is\n * now AWAITED on the response path, exactly like `refund.ts`. The one call to a\n * stranger's server becomes a row here, and the response no longer waits on it.\n *\n * ## The mechanism\n *\n * A Firestore-backed outbox drained by the plugin-job beat that already runs\n * the two commerce recovery passes (`server.ts`). No new infrastructure: the\n * beat is a Cloud Scheduler function POSTing the tenant runner route, and it\n * has carried bounded scans since AGL-2227.\n *\n * * **Idempotent enqueue.** The id is `{hostId}__{orderId}__{supplierId}` and\n * the write is `create()`, which REFUSES an existing document, so a\n * redelivery cannot reset a retry part-way through its backoff. The AGL-498\n * existence guard upstream already returns before this fan-out, but\n * `reconcile-stock.ts` documents at length why one flag over a dozen effects\n * is not something to lean on.\n * * **Backoff.** 1m, 5m, 15m, 1h, 6h — about seven and a half hours of cover,\n * which is a supplier's overnight deploy but not their week off.\n * * **Dead letter.** After {@link SUPPLIER_DELIVERY_MAX_ATTEMPTS} the row is\n * left as `failed`, the ORDER's own timeline is stamped, and host managers\n * get a notification. The merchant reading the order they were paid for can\n * see that it never reached the supplier — the thing the old code could not\n * tell anyone, because it wrote nothing down.\n *\n * ## Two placement decisions, both deliberate\n *\n * **TOP-LEVEL, not `hosts/{hostId}/supplierDeliveries`.** The host catch-all in\n * the rules file is PERMISSIVE by default — a subcollection nobody names in its\n * three exclusion lists is one every site editor can write from the browser —\n * and the row carries the exact JSON body we sign and POST, so an editor-\n * writable row is an editor-chosen payload delivered under the supplier's own\n * shared secret. A top-level collection matches no rule in\n * `cloud/firebase-firestore.rules` at all and is therefore denied to every\n * client by default, the same footing as `platform/pluginJobs`. It also keeps\n * the drain on an ordinary collection query: a collection-GROUP `where` would\n * need a `COLLECTION_GROUP_ASC` fieldOverride, and AGL-1793 is the record of\n * both commerce crons being silently dead in production for want of exactly\n * that. Neither a rules deploy nor an index deploy is owed for this change.\n *\n * **A delivered row is DELETED, not stamped `delivered`.** The body holds the\n * buyer's email and name, and a top-level document is outside the\n * `hosts/{hostId}` recursive delete an erasure request runs. Retaining the\n * happy path would accumulate third-party PII in a place erasure does not\n * reach, to record something the order's own `routed` timeline entry already\n * says. Dead-lettered rows survive, because those are the ones a human has to\n * act on and the merchant has been told about them.\n */\n\n/** Top-level collection. Matches no rule, so no client may read or write it. */\nexport const SUPPLIER_DELIVERY_COLLECTION = 'supplierDeliveries'\n\n/** Attempts before a delivery is dead-lettered. */\nexport const SUPPLIER_DELIVERY_MAX_ATTEMPTS = 6\n\n/**\n * Delay before the next retry, indexed by attempts ALREADY made.\n *\n * The first entry is 60s and the job beat is also 60s, so a supplier that\n * blipped is retried on the next tick rather than a quarter of an hour later —\n * the difference between a parcel that ships today and one that does not.\n */\nexport const SUPPLIER_DELIVERY_BACKOFF_MS: readonly number[] = [\n 60_000,\n 300_000,\n 900_000,\n 3_600_000,\n 21_600_000,\n]\n\n/**\n * Per-attempt timeout. Nothing user-facing waits on this — it exists so one\n * hung supplier cannot hold the shared job beat open for the whole scan.\n */\nexport const SUPPLIER_DELIVERY_TIMEOUT_MS = 10_000\n\n/** Rows examined per drain. Bounded like every other scan on the beat. */\nexport const SUPPLIER_DELIVERY_SCAN_LIMIT = 100\n\nexport type SupplierDeliveryStatus = 'pending' | 'failed'\n\nexport interface SupplierDeliveryRecord {\n status: SupplierDeliveryStatus\n hostId: string\n orderId: string\n supplierId: string\n supplierName: string\n /** The endpoint as configured when the order was routed, for the record. */\n url: string\n /** The exact JSON the supplier is sent; signed at delivery time. */\n body: string\n attempts: number\n nextAttemptAtMs: number\n createdAtMs: number\n lastError?: string\n lastStatus?: number\n failedAtMs?: number\n}\n\nexport interface SupplierDeliveryScanResult {\n scanned: number\n delivered: number\n retried: number\n deadLettered: number\n /** Retired without delivery because the merchant removed the endpoint. */\n cancelled: number\n /** Left pending, with the retry budget intact, because the site is locked. */\n skippedLocked: number\n}\n\n/** gRPC `Status.ALREADY_EXISTS` — what `create()` on a live path carries. */\nconst GRPC_ALREADY_EXISTS = 6\n\n/**\n * The delay owed after `attempts` failures, clamped to the last step.\n *\n * Clamped rather than extrapolated: the tail entry is already six hours, and a\n * dead letter is what comes after it, not a longer and longer silence.\n */\nexport function supplierDeliveryBackoffMs(attempts: number): number {\n const last = SUPPLIER_DELIVERY_BACKOFF_MS.length - 1\n return SUPPLIER_DELIVERY_BACKOFF_MS[\n Math.max(0, Math.min(attempts - 1, last))\n ]\n}\n\n/**\n * The outbox document id for one host/order/supplier triple.\n *\n * Deterministic, so `create()` is the idempotency. `__` rather than `:` or `/`:\n * a slash would make `.doc()` build a PATH instead of an id (AGL-1771), and the\n * separator has to survive a Stripe session id, which already contains `_`.\n * Firestore reserves ids fully matching `__.*__`; this never is, because all\n * three parts are non-empty and the id neither starts nor ends with `__`.\n */\nexport function supplierDeliveryId(\n hostId: string,\n orderId: string,\n supplierId: string,\n): string {\n return `${hostId}__${orderId}__${supplierId}`\n}\n\n/**\n * Records that a supplier POST is owed. One Firestore write, awaited.\n *\n * Returns `'queued'`, `'exists'` when a row for this triple is already in\n * flight, or `'skipped'`. NEVER THROWS: the caller is a paid order's fulfilment\n * path, and a queue that cannot be written must not take the receipt down with\n * it. The refusal is logged, which is strictly more than the old code managed\n * on the same failure.\n */\nexport async function enqueueSupplierDelivery(options: {\n firestore: FirebaseFirestore.Firestore\n hostId: string\n orderId: string\n supplierId: string\n supplierName: string\n url: string\n body: string\n now?: number\n}): Promise<'queued' | 'exists' | 'skipped'> {\n const { firestore, hostId, orderId, supplierId, supplierName, url, body } =\n options\n const now = options.now ?? Date.now()\n const id = supplierDeliveryId(hostId, orderId, supplierId)\n if (!isDocumentId(id)) {\n console.error('Supplier delivery not queued: unusable id', id)\n return 'skipped'\n }\n const record: SupplierDeliveryRecord = {\n status: 'pending',\n hostId,\n orderId,\n supplierId,\n supplierName,\n url,\n body,\n attempts: 0,\n // Due immediately: the next beat picks it up, at most a minute out.\n nextAttemptAtMs: now,\n createdAtMs: now,\n }\n try {\n await firestore\n .collection(SUPPLIER_DELIVERY_COLLECTION)\n .doc(id)\n .create(record as unknown as FirebaseFirestore.DocumentData)\n return 'queued'\n } catch (error) {\n if ((error as { code?: number })?.code === GRPC_ALREADY_EXISTS) {\n return 'exists'\n }\n console.error('Supplier delivery not queued', hostId, orderId, error)\n return 'skipped'\n }\n}\n\n/**\n * Stamps a routing failure on the order the merchant actually reads.\n *\n * `update()`, not a merge-set, for `recordRedemptionOrphan`'s reason: the order\n * exists — the delivery was queued from its own fulfilment — and a merge would\n * mint an order stub on the one path where it does not.\n */\nasync function stampOrderRoutingFailure(\n firestore: FirebaseFirestore.Firestore,\n hostId: string,\n orderId: string,\n detail: string,\n): Promise<void> {\n if (!isDocumentId(hostId) || !isDocumentId(orderId)) return\n await firestore\n .collection('hosts')\n .doc(hostId)\n .collection('orders')\n .doc(orderId)\n .update({\n timeline: firebaseAdmin.firestore.FieldValue.arrayUnion({\n atMs: Date.now(),\n event: 'routing-failed',\n detail,\n }),\n })\n .catch(() => undefined)\n}\n\ntype DeliveryOutcome = 'delivered' | 'retried' | 'dead-lettered' | 'cancelled'\n\n/**\n * One delivery attempt.\n *\n * Never throws: the drain is a shared beat, and one merchant's supplier must\n * not abort every other merchant's queue — the `process-restock.ts` lesson,\n * where a single poisoned row killed back-in-stock email platform-wide.\n */\nasync function attemptSupplierDelivery(\n firestore: FirebaseFirestore.Firestore,\n snapshot: FirebaseFirestore.QueryDocumentSnapshot,\n now: number,\n): Promise<DeliveryOutcome> {\n const data = snapshot.data() as SupplierDeliveryRecord\n const hostId = String(data.hostId ?? '')\n const attempts = Number(data.attempts ?? 0) + 1\n\n const retire = async (): Promise<'cancelled'> => {\n await snapshot.ref.delete().catch(() => undefined)\n return 'cancelled'\n }\n if (!isDocumentId(hostId) || !isDocumentId(String(data.supplierId ?? ''))) {\n return retire()\n }\n\n // The endpoint and the secret are read FRESH rather than frozen into the row:\n // a merchant correcting a typo'd URL corrects what is already queued, and the\n // supplier's shared secret is never copied into a second document.\n const supplierSnapshot = await firestore\n .collection('hosts')\n .doc(hostId)\n .collection('suppliers')\n .doc(String(data.supplierId))\n .get()\n .catch(() => null)\n // A read that FAILED is not a supplier that is gone. Retrying beats retiring\n // a real delivery on a transient Firestore error — the `reconcile-stock.ts`\n // rule that a failed read is not an absent marker.\n if (!supplierSnapshot) {\n return recordFailure(\n firestore,\n snapshot,\n data,\n attempts,\n now,\n 'could not read the supplier record',\n undefined,\n )\n }\n const url = String(supplierSnapshot.get('webhookUrl') ?? '')\n if (!url) {\n // Deliberately NOT a dead letter: the merchant removed the endpoint (or the\n // supplier), which is a decision rather than an outage. Alarming them about\n // a delivery they cancelled is how an alert stops being read.\n return retire()\n }\n const secret = String(supplierSnapshot.get('webhookSecret') ?? '')\n const body = String(data.body ?? '')\n\n let status: number\n try {\n const response = await fetch(url, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n // NO EMPTY-KEY HMAC (AGL-2455). A signature computed under `''` is one\n // any party can compute, and a supplier who also left their secret\n // unset would verify it and believe the delivery was authenticated. An\n // absent header fails their check closed; a forgeable one passes it.\n ...(secret\n ? {\n 'x-aglyn-signature': createHmac('sha256', secret)\n .update(body)\n .digest('hex'),\n }\n : {}),\n },\n body,\n signal: AbortSignal.timeout(SUPPLIER_DELIVERY_TIMEOUT_MS),\n })\n status = Number(response?.status ?? 0)\n if (!response?.ok) {\n return recordFailure(\n firestore,\n snapshot,\n data,\n attempts,\n now,\n `supplier answered HTTP ${status}`,\n status,\n )\n }\n } catch (error) {\n return recordFailure(\n firestore,\n snapshot,\n data,\n attempts,\n now,\n String((error as Error)?.message ?? error),\n undefined,\n )\n }\n\n // Delivered rows are DELETED rather than stamped — see the module note: the\n // body holds the buyer's email and name, and a top-level document is outside\n // the recursive delete an erasure request runs over `hosts/{hostId}`.\n await snapshot.ref.delete().catch(() => undefined)\n return 'delivered'\n}\n\n/** Books one failure: another retry, or the dead letter and its two alarms. */\nasync function recordFailure(\n firestore: FirebaseFirestore.Firestore,\n snapshot: FirebaseFirestore.QueryDocumentSnapshot,\n data: SupplierDeliveryRecord,\n attempts: number,\n now: number,\n reason: string,\n status: number | undefined,\n): Promise<'retried' | 'dead-lettered'> {\n const exhausted = attempts >= SUPPLIER_DELIVERY_MAX_ATTEMPTS\n await snapshot.ref\n .update({\n status: exhausted ? 'failed' : 'pending',\n attempts,\n lastError: reason,\n ...(status ? { lastStatus: status } : {}),\n ...(exhausted\n ? { failedAtMs: now }\n : { nextAttemptAtMs: now + supplierDeliveryBackoffMs(attempts) }),\n })\n .catch(() => undefined)\n if (!exhausted) return 'retried'\n\n const hostId = String(data.hostId ?? '')\n const orderId = String(data.orderId ?? '')\n const supplierName = String(data.supplierName ?? '') || 'the supplier'\n await stampOrderRoutingFailure(\n firestore,\n hostId,\n orderId,\n `Could not reach ${supplierName} after ${attempts} attempts ` +\n `(${reason}). This order was never routed.`,\n )\n // The bell as well as the timeline: nobody opens an order they have no\n // reason to suspect.\n await notifyHostManagers(hostId, {\n type: 'content.order',\n title: `Order not routed to ${supplierName}`,\n body: `Order ${orderId} could not be sent after ${attempts} attempts.`,\n link: `/${hostId}/products`,\n }).catch(() => undefined)\n return 'dead-lettered'\n}\n\n/**\n * Drains the supplier outbox. Bounded, and safe to run twice.\n *\n * `nextAttemptAtMs` is filtered IN MEMORY rather than added to the query, which\n * would need a composite index for one field's worth of selectivity. The cost\n * is that a row still inside its backoff occupies one of the\n * {@link SUPPLIER_DELIVERY_SCAN_LIMIT} slots; with at most\n * {@link SUPPLIER_DELIVERY_MAX_ATTEMPTS} attempts per row the queue always\n * drains rather than growing a permanent head.\n */\nexport async function scanSupplierDeliveries(\n /** The lockdown gate, injected by the caller (AGL-2495). Not optional. */\n gate: PluginJobHostGate,\n now: number = Date.now(),\n): Promise<SupplierDeliveryScanResult> {\n const firestore = firebaseAdmin.app().firestore()\n const pending = await firestore\n .collection(SUPPLIER_DELIVERY_COLLECTION)\n .where('status', '==', 'pending')\n .limit(SUPPLIER_DELIVERY_SCAN_LIMIT)\n .get()\n const result: SupplierDeliveryScanResult = {\n scanned: pending.size,\n delivered: 0,\n retried: 0,\n deadLettered: 0,\n cancelled: 0,\n skippedLocked: 0,\n }\n for (const snapshot of pending.docs) {\n const data = snapshot.data() as SupplierDeliveryRecord\n // LOCKDOWN (AGL-2495). The row is a TOP-LEVEL document carrying its own\n // `hostId` rather than living under `hosts/{hostId}`, so the host is read\n // from the record instead of the parent chain — the one job of the six\n // whose subject is not in its path.\n //\n // An outbound POST to the merchant's supplier is this platform acting in\n // a suspended merchant's name against a third party, which is precisely\n // what a takedown is for. The attempt also mutates the row (`attempts`,\n // `nextAttemptAtMs`, or the dead-letter) and can notify the host's\n // managers.\n //\n // SKIPPED, NOT DROPPED, and the strongest case of the six: the row stays\n // `pending` with its `attempts` count untouched, so a long lock cannot\n // burn a paid order's retry budget and dead-letter it for being locked.\n // The queue drains from where it stood the minute the lock lifts.\n if (await gate.isLocked(String(data?.hostId ?? ''))) {\n result.skippedLocked += 1\n continue\n }\n if (Number(data?.nextAttemptAtMs ?? 0) > now) continue\n const outcome = await attemptSupplierDelivery(\n firestore,\n snapshot,\n now,\n ).catch((error) => {\n console.error(\n 'Supplier delivery attempt failed',\n snapshot.ref.path,\n error,\n )\n return null\n })\n if (outcome === 'delivered') result.delivered += 1\n else if (outcome === 'retried') result.retried += 1\n else if (outcome === 'dead-lettered') result.deadLettered += 1\n else if (outcome === 'cancelled') result.cancelled += 1\n }\n return result\n}\n"],"names":["firebaseAdmin","notifyHostManagers","isDocumentId","createHmac","SUPPLIER_DELIVERY_COLLECTION","SUPPLIER_DELIVERY_MAX_ATTEMPTS","SUPPLIER_DELIVERY_BACKOFF_MS","SUPPLIER_DELIVERY_TIMEOUT_MS","SUPPLIER_DELIVERY_SCAN_LIMIT","GRPC_ALREADY_EXISTS","supplierDeliveryBackoffMs","attempts","last","length","Math","max","min","supplierDeliveryId","hostId","orderId","supplierId","enqueueSupplierDelivery","options","firestore","supplierName","url","body","now","Date","id","console","error","record","status","nextAttemptAtMs","createdAtMs","collection","doc","create","code","stampOrderRoutingFailure","detail","update","timeline","FieldValue","arrayUnion","atMs","event","catch","undefined","attemptSupplierDelivery","snapshot","data","supplierSnapshot","String","Number","retire","ref","delete","get","recordFailure","secret","response","fetch","method","headers","digest","signal","AbortSignal","timeout","ok","message","reason","exhausted","lastError","lastStatus","failedAtMs","type","title","link","scanSupplierDeliveries","gate","app","pending","where","limit","result","scanned","size","delivered","retried","deadLettered","cancelled","skippedLocked","docs","isLocked","outcome","path"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/supplier-outbox.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { type PluginJobHostGate } from '@aglyn/aglyn/server'\nimport {\n describeConfiguredUrlRefusal,\n fetchConfiguredPublicUrl,\n firebaseAdmin,\n flagLookalikeDomain,\n notifyHostManagers,\n} from '@aglyn/tenant-data-admin'\nimport { isDocumentId } from '@aglyn/tenant-data-admin/server/document-id'\nimport { createHmac } from 'crypto'\n\n/**\n * The durable outbox for the dropship supplier notification (AGL-2473).\n *\n * ## What was wrong\n *\n * `billing-webhook.ts` routed a paid dropship order inside\n * `void (async () => { … })()`, and the last thing that block did was\n * `await fetch(supplier.webhookUrl, …)` — an endpoint the MERCHANT typed into\n * a console field, on a host Aglyn neither runs nor monitors. Vercel freezes\n * the container the instant the response is written, so a supplier that is\n * slow, rate-limiting, or simply down is a supplier that is never told. The\n * order is paid, the buyer is thanked, the merchant sees \"Sent to supplier X\"\n * on the timeline, and NOTHING ANYWHERE records that the POST is still owed —\n * so no later pass can find it and no report can list it. It surfaces when the\n * customer asks where their parcel is.\n *\n * ## Why not just `await` it\n *\n * AGL-2161 fixed the other four fire-and-forget sites in that file and\n * deliberately left this one, because awaiting it is worse rather than better:\n * a supplier taking 30s pushes the handler past Stripe's delivery window,\n * Stripe redelivers, and the redelivery re-enters a fan-out whose only\n * idempotency is one `created` flag upstream. A silently dropped notification\n * would have been traded for a DUPLICATED order. `refund.ts` awaits its own\n * sibling and says so in a comment — correctly, because that sibling only\n * touches our own Firestore.\n *\n * So the line this module draws is not \"fast vs slow\" but **ours vs theirs**.\n * Everything the routing block does to Aglyn's own storage — the callback\n * token, the timeline entry, the supplier email through our own provider — is\n * now AWAITED on the response path, exactly like `refund.ts`. The one call to a\n * stranger's server becomes a row here, and the response no longer waits on it.\n *\n * ## The mechanism\n *\n * A Firestore-backed outbox drained by the plugin-job beat that already runs\n * the two commerce recovery passes (`server.ts`). No new infrastructure: the\n * beat is a Cloud Scheduler function POSTing the tenant runner route, and it\n * has carried bounded scans since AGL-2227.\n *\n * * **Idempotent enqueue.** The id is `{hostId}__{orderId}__{supplierId}` and\n * the write is `create()`, which REFUSES an existing document, so a\n * redelivery cannot reset a retry part-way through its backoff. The AGL-498\n * existence guard upstream already returns before this fan-out, but\n * `reconcile-stock.ts` documents at length why one flag over a dozen effects\n * is not something to lean on.\n * * **Backoff.** 1m, 5m, 15m, 1h, 6h — about seven and a half hours of cover,\n * which is a supplier's overnight deploy but not their week off.\n * * **Dead letter.** After {@link SUPPLIER_DELIVERY_MAX_ATTEMPTS} the row is\n * left as `failed`, the ORDER's own timeline is stamped, and host managers\n * get a notification. The merchant reading the order they were paid for can\n * see that it never reached the supplier — the thing the old code could not\n * tell anyone, because it wrote nothing down.\n *\n * ## Two placement decisions, both deliberate\n *\n * **TOP-LEVEL, not `hosts/{hostId}/supplierDeliveries`.** The host catch-all in\n * the rules file is PERMISSIVE by default — a subcollection nobody names in its\n * three exclusion lists is one every site editor can write from the browser —\n * and the row carries the exact JSON body we sign and POST, so an editor-\n * writable row is an editor-chosen payload delivered under the supplier's own\n * shared secret. A top-level collection matches no rule in\n * `cloud/firebase-firestore.rules` at all and is therefore denied to every\n * client by default, the same footing as `platform/pluginJobs`. It also keeps\n * the drain on an ordinary collection query: a collection-GROUP `where` would\n * need a `COLLECTION_GROUP_ASC` fieldOverride, and AGL-1793 is the record of\n * both commerce crons being silently dead in production for want of exactly\n * that. Neither a rules deploy nor an index deploy is owed for this change.\n *\n * **A delivered row is DELETED, not stamped `delivered`.** The body holds the\n * buyer's email and name, and a top-level document is outside the\n * `hosts/{hostId}` recursive delete an erasure request runs. Retaining the\n * happy path would accumulate third-party PII in a place erasure does not\n * reach, to record something the order's own `routed` timeline entry already\n * says. Dead-lettered rows survive, because those are the ones a human has to\n * act on and the merchant has been told about them.\n */\n\n/** Top-level collection. Matches no rule, so no client may read or write it. */\nexport const SUPPLIER_DELIVERY_COLLECTION = 'supplierDeliveries'\n\n/** Attempts before a delivery is dead-lettered. */\nexport const SUPPLIER_DELIVERY_MAX_ATTEMPTS = 6\n\n/**\n * Delay before the next retry, indexed by attempts ALREADY made.\n *\n * The first entry is 60s and the job beat is also 60s, so a supplier that\n * blipped is retried on the next tick rather than a quarter of an hour later —\n * the difference between a parcel that ships today and one that does not.\n */\nexport const SUPPLIER_DELIVERY_BACKOFF_MS: readonly number[] = [\n 60_000,\n 300_000,\n 900_000,\n 3_600_000,\n 21_600_000,\n]\n\n/**\n * Per-attempt timeout. Nothing user-facing waits on this — it exists so one\n * hung supplier cannot hold the shared job beat open for the whole scan.\n */\nexport const SUPPLIER_DELIVERY_TIMEOUT_MS = 10_000\n\n/** Rows examined per drain. Bounded like every other scan on the beat. */\nexport const SUPPLIER_DELIVERY_SCAN_LIMIT = 100\n\nexport type SupplierDeliveryStatus = 'pending' | 'failed'\n\nexport interface SupplierDeliveryRecord {\n status: SupplierDeliveryStatus\n hostId: string\n orderId: string\n supplierId: string\n supplierName: string\n /** The endpoint as configured when the order was routed, for the record. */\n url: string\n /** The exact JSON the supplier is sent; signed at delivery time. */\n body: string\n attempts: number\n nextAttemptAtMs: number\n createdAtMs: number\n lastError?: string\n lastStatus?: number\n failedAtMs?: number\n}\n\nexport interface SupplierDeliveryScanResult {\n scanned: number\n delivered: number\n retried: number\n deadLettered: number\n /** Retired without delivery because the merchant removed the endpoint. */\n cancelled: number\n /** Left pending, with the retry budget intact, because the site is locked. */\n skippedLocked: number\n}\n\n/** gRPC `Status.ALREADY_EXISTS` — what `create()` on a live path carries. */\nconst GRPC_ALREADY_EXISTS = 6\n\n/**\n * The delay owed after `attempts` failures, clamped to the last step.\n *\n * Clamped rather than extrapolated: the tail entry is already six hours, and a\n * dead letter is what comes after it, not a longer and longer silence.\n */\nexport function supplierDeliveryBackoffMs(attempts: number): number {\n const last = SUPPLIER_DELIVERY_BACKOFF_MS.length - 1\n return SUPPLIER_DELIVERY_BACKOFF_MS[\n Math.max(0, Math.min(attempts - 1, last))\n ]\n}\n\n/**\n * The outbox document id for one host/order/supplier triple.\n *\n * Deterministic, so `create()` is the idempotency. `__` rather than `:` or `/`:\n * a slash would make `.doc()` build a PATH instead of an id (AGL-1771), and the\n * separator has to survive a Stripe session id, which already contains `_`.\n * Firestore reserves ids fully matching `__.*__`; this never is, because all\n * three parts are non-empty and the id neither starts nor ends with `__`.\n */\nexport function supplierDeliveryId(\n hostId: string,\n orderId: string,\n supplierId: string,\n): string {\n return `${hostId}__${orderId}__${supplierId}`\n}\n\n/**\n * Records that a supplier POST is owed. One Firestore write, awaited.\n *\n * Returns `'queued'`, `'exists'` when a row for this triple is already in\n * flight, or `'skipped'`. NEVER THROWS: the caller is a paid order's fulfilment\n * path, and a queue that cannot be written must not take the receipt down with\n * it. The refusal is logged, which is strictly more than the old code managed\n * on the same failure.\n */\nexport async function enqueueSupplierDelivery(options: {\n firestore: FirebaseFirestore.Firestore\n hostId: string\n orderId: string\n supplierId: string\n supplierName: string\n url: string\n body: string\n now?: number\n}): Promise<'queued' | 'exists' | 'skipped'> {\n const { firestore, hostId, orderId, supplierId, supplierName, url, body } =\n options\n const now = options.now ?? Date.now()\n const id = supplierDeliveryId(hostId, orderId, supplierId)\n if (!isDocumentId(id)) {\n console.error('Supplier delivery not queued: unusable id', id)\n return 'skipped'\n }\n const record: SupplierDeliveryRecord = {\n status: 'pending',\n hostId,\n orderId,\n supplierId,\n supplierName,\n url,\n body,\n attempts: 0,\n // Due immediately: the next beat picks it up, at most a minute out.\n nextAttemptAtMs: now,\n createdAtMs: now,\n }\n try {\n await firestore\n .collection(SUPPLIER_DELIVERY_COLLECTION)\n .doc(id)\n .create(record as unknown as FirebaseFirestore.DocumentData)\n return 'queued'\n } catch (error) {\n if ((error as { code?: number })?.code === GRPC_ALREADY_EXISTS) {\n return 'exists'\n }\n console.error('Supplier delivery not queued', hostId, orderId, error)\n return 'skipped'\n }\n}\n\n/**\n * Stamps a routing failure on the order the merchant actually reads.\n *\n * `update()`, not a merge-set, for `recordRedemptionOrphan`'s reason: the order\n * exists — the delivery was queued from its own fulfilment — and a merge would\n * mint an order stub on the one path where it does not.\n */\nasync function stampOrderRoutingFailure(\n firestore: FirebaseFirestore.Firestore,\n hostId: string,\n orderId: string,\n detail: string,\n): Promise<void> {\n if (!isDocumentId(hostId) || !isDocumentId(orderId)) return\n await firestore\n .collection('hosts')\n .doc(hostId)\n .collection('orders')\n .doc(orderId)\n .update({\n timeline: firebaseAdmin.firestore.FieldValue.arrayUnion({\n atMs: Date.now(),\n event: 'routing-failed',\n detail,\n }),\n })\n .catch(() => undefined)\n}\n\ntype DeliveryOutcome = 'delivered' | 'retried' | 'dead-lettered' | 'cancelled'\n\n/**\n * One delivery attempt.\n *\n * Never throws: the drain is a shared beat, and one merchant's supplier must\n * not abort every other merchant's queue — the `process-restock.ts` lesson,\n * where a single poisoned row killed back-in-stock email platform-wide.\n */\nasync function attemptSupplierDelivery(\n firestore: FirebaseFirestore.Firestore,\n snapshot: FirebaseFirestore.QueryDocumentSnapshot,\n now: number,\n): Promise<DeliveryOutcome> {\n const data = snapshot.data() as SupplierDeliveryRecord\n const hostId = String(data.hostId ?? '')\n const attempts = Number(data.attempts ?? 0) + 1\n\n const retire = async (): Promise<'cancelled'> => {\n await snapshot.ref.delete().catch(() => undefined)\n return 'cancelled'\n }\n if (!isDocumentId(hostId) || !isDocumentId(String(data.supplierId ?? ''))) {\n return retire()\n }\n\n // The endpoint and the secret are read FRESH rather than frozen into the row:\n // a merchant correcting a typo'd URL corrects what is already queued, and the\n // supplier's shared secret is never copied into a second document.\n const supplierSnapshot = await firestore\n .collection('hosts')\n .doc(hostId)\n .collection('suppliers')\n .doc(String(data.supplierId))\n .get()\n .catch(() => null)\n // A read that FAILED is not a supplier that is gone. Retrying beats retiring\n // a real delivery on a transient Firestore error — the `reconcile-stock.ts`\n // rule that a failed read is not an absent marker.\n if (!supplierSnapshot) {\n return recordFailure(\n firestore,\n snapshot,\n data,\n attempts,\n now,\n 'could not read the supplier record',\n undefined,\n )\n }\n const url = String(supplierSnapshot.get('webhookUrl') ?? '')\n if (!url) {\n // Deliberately NOT a dead letter: the merchant removed the endpoint (or the\n // supplier), which is a decision rather than an outage. Alarming them about\n // a delivery they cancelled is how an alert stops being read.\n return retire()\n }\n const secret = String(supplierSnapshot.get('webhookSecret') ?? '')\n const body = String(data.body ?? '')\n\n let status: number\n try {\n // A merchant-typed address, called from the platform's servers: https\n // only, public addresses only (pinned), never a lookalike, never a\n // followed redirect (AGL-3363). A bare `fetch` here reached any address a\n // merchant typed, the cloud metadata endpoint included.\n const called = await fetchConfiguredPublicUrl(url, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n // NO EMPTY-KEY HMAC (AGL-2455). A signature computed under `''` is one\n // any party can compute, and a supplier who also left their secret\n // unset would verify it and believe the delivery was authenticated. An\n // absent header fails their check closed; a forgeable one passes it.\n ...(secret\n ? {\n 'x-aglyn-signature': createHmac('sha256', secret)\n .update(body)\n .digest('hex'),\n }\n : {}),\n },\n body,\n signal: AbortSignal.timeout(SUPPLIER_DELIVERY_TIMEOUT_MS),\n })\n if (called.ok === false) {\n if (called.refusal === 'lookalike' && called.host) {\n await flagLookalikeDomain({\n kind: 'link',\n hostId,\n orgId: null,\n domain: called.host,\n where: 'a supplier order webhook',\n })\n }\n return recordFailure(\n firestore,\n snapshot,\n data,\n attempts,\n now,\n `the supplier's webhook address was not called: ${describeConfiguredUrlRefusal(called.refusal)}`,\n undefined,\n )\n }\n status = called.status\n if (!(status >= 200 && status < 300)) {\n return recordFailure(\n firestore,\n snapshot,\n data,\n attempts,\n now,\n `supplier answered HTTP ${status}`,\n status,\n )\n }\n } catch (error) {\n return recordFailure(\n firestore,\n snapshot,\n data,\n attempts,\n now,\n String((error as Error)?.message ?? error),\n undefined,\n )\n }\n\n // Delivered rows are DELETED rather than stamped — see the module note: the\n // body holds the buyer's email and name, and a top-level document is outside\n // the recursive delete an erasure request runs over `hosts/{hostId}`.\n await snapshot.ref.delete().catch(() => undefined)\n return 'delivered'\n}\n\n/** Books one failure: another retry, or the dead letter and its two alarms. */\nasync function recordFailure(\n firestore: FirebaseFirestore.Firestore,\n snapshot: FirebaseFirestore.QueryDocumentSnapshot,\n data: SupplierDeliveryRecord,\n attempts: number,\n now: number,\n reason: string,\n status: number | undefined,\n): Promise<'retried' | 'dead-lettered'> {\n const exhausted = attempts >= SUPPLIER_DELIVERY_MAX_ATTEMPTS\n await snapshot.ref\n .update({\n status: exhausted ? 'failed' : 'pending',\n attempts,\n lastError: reason,\n ...(status ? { lastStatus: status } : {}),\n ...(exhausted\n ? { failedAtMs: now }\n : { nextAttemptAtMs: now + supplierDeliveryBackoffMs(attempts) }),\n })\n .catch(() => undefined)\n if (!exhausted) return 'retried'\n\n const hostId = String(data.hostId ?? '')\n const orderId = String(data.orderId ?? '')\n const supplierName = String(data.supplierName ?? '') || 'the supplier'\n await stampOrderRoutingFailure(\n firestore,\n hostId,\n orderId,\n `Could not reach ${supplierName} after ${attempts} attempts ` +\n `(${reason}). This order was never routed.`,\n )\n // The bell as well as the timeline: nobody opens an order they have no\n // reason to suspect.\n await notifyHostManagers(hostId, {\n type: 'content.order',\n title: `Order not routed to ${supplierName}`,\n body: `Order ${orderId} could not be sent after ${attempts} attempts.`,\n link: `/${hostId}/products`,\n }).catch(() => undefined)\n return 'dead-lettered'\n}\n\n/**\n * Drains the supplier outbox. Bounded, and safe to run twice.\n *\n * `nextAttemptAtMs` is filtered IN MEMORY rather than added to the query, which\n * would need a composite index for one field's worth of selectivity. The cost\n * is that a row still inside its backoff occupies one of the\n * {@link SUPPLIER_DELIVERY_SCAN_LIMIT} slots; with at most\n * {@link SUPPLIER_DELIVERY_MAX_ATTEMPTS} attempts per row the queue always\n * drains rather than growing a permanent head.\n */\nexport async function scanSupplierDeliveries(\n /** The lockdown gate, injected by the caller (AGL-2495). Not optional. */\n gate: PluginJobHostGate,\n now: number = Date.now(),\n): Promise<SupplierDeliveryScanResult> {\n const firestore = firebaseAdmin.app().firestore()\n const pending = await firestore\n .collection(SUPPLIER_DELIVERY_COLLECTION)\n .where('status', '==', 'pending')\n .limit(SUPPLIER_DELIVERY_SCAN_LIMIT)\n .get()\n const result: SupplierDeliveryScanResult = {\n scanned: pending.size,\n delivered: 0,\n retried: 0,\n deadLettered: 0,\n cancelled: 0,\n skippedLocked: 0,\n }\n for (const snapshot of pending.docs) {\n const data = snapshot.data() as SupplierDeliveryRecord\n // LOCKDOWN (AGL-2495). The row is a TOP-LEVEL document carrying its own\n // `hostId` rather than living under `hosts/{hostId}`, so the host is read\n // from the record instead of the parent chain — the one job of the six\n // whose subject is not in its path.\n //\n // An outbound POST to the merchant's supplier is this platform acting in\n // a suspended merchant's name against a third party, which is precisely\n // what a takedown is for. The attempt also mutates the row (`attempts`,\n // `nextAttemptAtMs`, or the dead-letter) and can notify the host's\n // managers.\n //\n // SKIPPED, NOT DROPPED, and the strongest case of the six: the row stays\n // `pending` with its `attempts` count untouched, so a long lock cannot\n // burn a paid order's retry budget and dead-letter it for being locked.\n // The queue drains from where it stood the minute the lock lifts.\n if (await gate.isLocked(String(data?.hostId ?? ''))) {\n result.skippedLocked += 1\n continue\n }\n if (Number(data?.nextAttemptAtMs ?? 0) > now) continue\n const outcome = await attemptSupplierDelivery(\n firestore,\n snapshot,\n now,\n ).catch((error) => {\n console.error(\n 'Supplier delivery attempt failed',\n snapshot.ref.path,\n error,\n )\n return null\n })\n if (outcome === 'delivered') result.delivered += 1\n else if (outcome === 'retried') result.retried += 1\n else if (outcome === 'dead-lettered') result.deadLettered += 1\n else if (outcome === 'cancelled') result.cancelled += 1\n }\n return result\n}\n"],"names":["describeConfiguredUrlRefusal","fetchConfiguredPublicUrl","firebaseAdmin","flagLookalikeDomain","notifyHostManagers","isDocumentId","createHmac","SUPPLIER_DELIVERY_COLLECTION","SUPPLIER_DELIVERY_MAX_ATTEMPTS","SUPPLIER_DELIVERY_BACKOFF_MS","SUPPLIER_DELIVERY_TIMEOUT_MS","SUPPLIER_DELIVERY_SCAN_LIMIT","GRPC_ALREADY_EXISTS","supplierDeliveryBackoffMs","attempts","last","length","Math","max","min","supplierDeliveryId","hostId","orderId","supplierId","enqueueSupplierDelivery","options","firestore","supplierName","url","body","now","Date","id","console","error","record","status","nextAttemptAtMs","createdAtMs","collection","doc","create","code","stampOrderRoutingFailure","detail","update","timeline","FieldValue","arrayUnion","atMs","event","catch","undefined","attemptSupplierDelivery","snapshot","data","supplierSnapshot","String","Number","retire","ref","delete","get","recordFailure","secret","called","method","headers","digest","signal","AbortSignal","timeout","ok","refusal","host","kind","orgId","domain","where","message","reason","exhausted","lastError","lastStatus","failedAtMs","type","title","link","scanSupplierDeliveries","gate","app","pending","limit","result","scanned","size","delivered","retried","deadLettered","cancelled","skippedLocked","docs","isLocked","outcome","path"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC;AAGD,SACEA,4BAA4B,EAC5BC,wBAAwB,EACxBC,aAAa,EACbC,mBAAmB,EACnBC,kBAAkB,QACb,2BAA0B;AACjC,SAASC,YAAY,QAAQ,8CAA6C;AAC1E,SAASC,UAAU,QAAQ,SAAQ;AAEnC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA4EC,GAED,8EAA8E,GAC9E,OAAO,MAAMC,+BAA+B,qBAAoB;AAEhE,iDAAiD,GACjD,OAAO,MAAMC,iCAAiC,EAAC;AAE/C;;;;;;CAMC,GACD,OAAO,MAAMC,+BAAkD;IAC7D;IACA;IACA;IACA;IACA;CACD,CAAA;AAED;;;CAGC,GACD,OAAO,MAAMC,+BAA+B,MAAM;AAElD,wEAAwE,GACxE,OAAO,MAAMC,+BAA+B,IAAG;AAiC/C,2EAA2E,GAC3E,MAAMC,sBAAsB;AAE5B;;;;;CAKC,GACD,OAAO,SAASC,0BAA0BC,QAAgB;IACxD,MAAMC,OAAON,6BAA6BO,MAAM,GAAG;IACnD,OAAOP,4BAA4B,CACjCQ,KAAKC,GAAG,CAAC,GAAGD,KAAKE,GAAG,CAACL,WAAW,GAAGC,OACpC;AACH;AAEA;;;;;;;;CAQC,GACD,OAAO,SAASK,mBACdC,MAAc,EACdC,OAAe,EACfC,UAAkB;IAElB,OAAO,GAAGF,OAAO,EAAE,EAAEC,QAAQ,EAAE,EAAEC,YAAY;AAC/C;AAEA;;;;;;;;CAQC,GACD,OAAO,eAAeC,wBAAwBC,OAS7C;QAGaA;IAFZ,MAAM,EAAEC,SAAS,EAAEL,MAAM,EAAEC,OAAO,EAAEC,UAAU,EAAEI,YAAY,EAAEC,GAAG,EAAEC,IAAI,EAAE,GACvEJ;IACF,MAAMK,OAAML,eAAAA,QAAQK,GAAG,YAAXL,eAAeM,KAAKD,GAAG;IACnC,MAAME,KAAKZ,mBAAmBC,QAAQC,SAASC;IAC/C,IAAI,CAAClB,aAAa2B,KAAK;QACrBC,QAAQC,KAAK,CAAC,6CAA6CF;QAC3D,OAAO;IACT;IACA,MAAMG,SAAiC;QACrCC,QAAQ;QACRf;QACAC;QACAC;QACAI;QACAC;QACAC;QACAf,UAAU;QACV,oEAAoE;QACpEuB,iBAAiBP;QACjBQ,aAAaR;IACf;IACA,IAAI;QACF,MAAMJ,UACHa,UAAU,CAAChC,8BACXiC,GAAG,CAACR,IACJS,MAAM,CAACN;QACV,OAAO;IACT,EAAE,OAAOD,OAAO;QACd,IAAI,CAACA,yBAAD,AAACA,MAA6BQ,IAAI,MAAK9B,qBAAqB;YAC9D,OAAO;QACT;QACAqB,QAAQC,KAAK,CAAC,gCAAgCb,QAAQC,SAASY;QAC/D,OAAO;IACT;AACF;AAEA;;;;;;CAMC,GACD,eAAeS,yBACbjB,SAAsC,EACtCL,MAAc,EACdC,OAAe,EACfsB,MAAc;IAEd,IAAI,CAACvC,aAAagB,WAAW,CAAChB,aAAaiB,UAAU;IACrD,MAAMI,UACHa,UAAU,CAAC,SACXC,GAAG,CAACnB,QACJkB,UAAU,CAAC,UACXC,GAAG,CAAClB,SACJuB,MAAM,CAAC;QACNC,UAAU5C,cAAcwB,SAAS,CAACqB,UAAU,CAACC,UAAU,CAAC;YACtDC,MAAMlB,KAAKD,GAAG;YACdoB,OAAO;YACPN;QACF;IACF,GACCO,KAAK,CAAC,IAAMC;AACjB;AAIA;;;;;;CAMC,GACD,eAAeC,wBACb3B,SAAsC,EACtC4B,QAAiD,EACjDxB,GAAW;QAGWyB,cACEA,gBAM0BA,kBA4B/BC,uBAOGA,wBACFD;IA5CpB,MAAMA,OAAOD,SAASC,IAAI;IAC1B,MAAMlC,SAASoC,QAAOF,eAAAA,KAAKlC,MAAM,YAAXkC,eAAe;IACrC,MAAMzC,WAAW4C,QAAOH,iBAAAA,KAAKzC,QAAQ,YAAbyC,iBAAiB,KAAK;IAE9C,MAAMI,SAAS;QACb,MAAML,SAASM,GAAG,CAACC,MAAM,GAAGV,KAAK,CAAC,IAAMC;QACxC,OAAO;IACT;IACA,IAAI,CAAC/C,aAAagB,WAAW,CAAChB,aAAaoD,QAAOF,mBAAAA,KAAKhC,UAAU,YAAfgC,mBAAmB,MAAM;QACzE,OAAOI;IACT;IAEA,8EAA8E;IAC9E,8EAA8E;IAC9E,mEAAmE;IACnE,MAAMH,mBAAmB,MAAM9B,UAC5Ba,UAAU,CAAC,SACXC,GAAG,CAACnB,QACJkB,UAAU,CAAC,aACXC,GAAG,CAACiB,OAAOF,KAAKhC,UAAU,GAC1BuC,GAAG,GACHX,KAAK,CAAC,IAAM;IACf,6EAA6E;IAC7E,4EAA4E;IAC5E,mDAAmD;IACnD,IAAI,CAACK,kBAAkB;QACrB,OAAOO,cACLrC,WACA4B,UACAC,MACAzC,UACAgB,KACA,sCACAsB;IAEJ;IACA,MAAMxB,MAAM6B,QAAOD,wBAAAA,iBAAiBM,GAAG,CAAC,yBAArBN,wBAAsC;IACzD,IAAI,CAAC5B,KAAK;QACR,4EAA4E;QAC5E,4EAA4E;QAC5E,8DAA8D;QAC9D,OAAO+B;IACT;IACA,MAAMK,SAASP,QAAOD,yBAAAA,iBAAiBM,GAAG,CAAC,4BAArBN,yBAAyC;IAC/D,MAAM3B,OAAO4B,QAAOF,aAAAA,KAAK1B,IAAI,YAAT0B,aAAa;IAEjC,IAAInB;IACJ,IAAI;QACF,sEAAsE;QACtE,mEAAmE;QACnE,0EAA0E;QAC1E,wDAAwD;QACxD,MAAM6B,SAAS,MAAMhE,yBAAyB2B,KAAK;YACjDsC,QAAQ;YACRC,SAAS;gBACP,gBAAgB;eAKZH,SACA;gBACE,qBAAqB1D,WAAW,UAAU0D,QACvCnB,MAAM,CAAChB,MACPuC,MAAM,CAAC;YACZ,IACA,CAAC;YAEPvC;YACAwC,QAAQC,YAAYC,OAAO,CAAC7D;QAC9B;QACA,IAAIuD,OAAOO,EAAE,KAAK,OAAO;YACvB,IAAIP,OAAOQ,OAAO,KAAK,eAAeR,OAAOS,IAAI,EAAE;gBACjD,MAAMvE,oBAAoB;oBACxBwE,MAAM;oBACNtD;oBACAuD,OAAO;oBACPC,QAAQZ,OAAOS,IAAI;oBACnBI,OAAO;gBACT;YACF;YACA,OAAOf,cACLrC,WACA4B,UACAC,MACAzC,UACAgB,KACA,CAAC,+CAA+C,EAAE9B,6BAA6BiE,OAAOQ,OAAO,GAAG,EAChGrB;QAEJ;QACAhB,SAAS6B,OAAO7B,MAAM;QACtB,IAAI,CAAEA,CAAAA,UAAU,OAAOA,SAAS,GAAE,GAAI;YACpC,OAAO2B,cACLrC,WACA4B,UACAC,MACAzC,UACAgB,KACA,CAAC,uBAAuB,EAAEM,QAAQ,EAClCA;QAEJ;IACF,EAAE,OAAOF,OAAO;;QACd,OAAO6B,cACLrC,WACA4B,UACAC,MACAzC,UACAgB,KACA2B,eAAQvB,yBAAD,AAACA,MAAiB6C,OAAO,mBAAI7C,QACpCkB;IAEJ;IAEA,4EAA4E;IAC5E,6EAA6E;IAC7E,sEAAsE;IACtE,MAAME,SAASM,GAAG,CAACC,MAAM,GAAGV,KAAK,CAAC,IAAMC;IACxC,OAAO;AACT;AAEA,6EAA6E,GAC7E,eAAeW,cACbrC,SAAsC,EACtC4B,QAAiD,EACjDC,IAA4B,EAC5BzC,QAAgB,EAChBgB,GAAW,EACXkD,MAAc,EACd5C,MAA0B;QAgBJmB,cACCA,eACKA;IAhB5B,MAAM0B,YAAYnE,YAAYN;IAC9B,MAAM8C,SAASM,GAAG,CACff,MAAM,CAAC;QACNT,QAAQ6C,YAAY,WAAW;QAC/BnE;QACAoE,WAAWF;OACP5C,SAAS;QAAE+C,YAAY/C;IAAO,IAAI,CAAC,GACnC6C,YACA;QAAEG,YAAYtD;IAAI,IAClB;QAAEO,iBAAiBP,MAAMjB,0BAA0BC;IAAU,IAElEqC,KAAK,CAAC,IAAMC;IACf,IAAI,CAAC6B,WAAW,OAAO;IAEvB,MAAM5D,SAASoC,QAAOF,eAAAA,KAAKlC,MAAM,YAAXkC,eAAe;IACrC,MAAMjC,UAAUmC,QAAOF,gBAAAA,KAAKjC,OAAO,YAAZiC,gBAAgB;IACvC,MAAM5B,eAAe8B,QAAOF,qBAAAA,KAAK5B,YAAY,YAAjB4B,qBAAqB,OAAO;IACxD,MAAMZ,yBACJjB,WACAL,QACAC,SACA,CAAC,gBAAgB,EAAEK,aAAa,OAAO,EAAEb,SAAS,UAAU,CAAC,GAC3D,CAAC,CAAC,EAAEkE,OAAO,+BAA+B,CAAC;IAE/C,uEAAuE;IACvE,qBAAqB;IACrB,MAAM5E,mBAAmBiB,QAAQ;QAC/BgE,MAAM;QACNC,OAAO,CAAC,oBAAoB,EAAE3D,cAAc;QAC5CE,MAAM,CAAC,MAAM,EAAEP,QAAQ,yBAAyB,EAAER,SAAS,UAAU,CAAC;QACtEyE,MAAM,CAAC,CAAC,EAAElE,OAAO,SAAS,CAAC;IAC7B,GAAG8B,KAAK,CAAC,IAAMC;IACf,OAAO;AACT;AAEA;;;;;;;;;CASC,GACD,OAAO,eAAeoC,uBACpB,wEAAwE,GACxEC,IAAuB,EACvB3D,MAAcC,KAAKD,GAAG,EAAE;IAExB,MAAMJ,YAAYxB,cAAcwF,GAAG,GAAGhE,SAAS;IAC/C,MAAMiE,UAAU,MAAMjE,UACnBa,UAAU,CAAChC,8BACXuE,KAAK,CAAC,UAAU,MAAM,WACtBc,KAAK,CAACjF,8BACNmD,GAAG;IACN,MAAM+B,SAAqC;QACzCC,SAASH,QAAQI,IAAI;QACrBC,WAAW;QACXC,SAAS;QACTC,cAAc;QACdC,WAAW;QACXC,eAAe;IACjB;IACA,KAAK,MAAM9C,YAAYqC,QAAQU,IAAI,CAAE;;QACnC,MAAM9C,OAAOD,SAASC,IAAI;QAC1B,wEAAwE;QACxE,0EAA0E;QAC1E,uEAAuE;QACvE,oCAAoC;QACpC,EAAE;QACF,yEAAyE;QACzE,wEAAwE;QACxE,wEAAwE;QACxE,mEAAmE;QACnE,YAAY;QACZ,EAAE;QACF,yEAAyE;QACzE,uEAAuE;QACvE,wEAAwE;QACxE,kEAAkE;QAClE,IAAI,MAAMkC,KAAKa,QAAQ,CAAC7C,eAAOF,wBAAAA,KAAMlC,MAAM,mBAAI,MAAM;YACnDwE,OAAOO,aAAa,IAAI;YACxB;QACF;QACA,IAAI1C,gBAAOH,wBAAAA,KAAMlB,eAAe,oBAAI,KAAKP,KAAK;QAC9C,MAAMyE,UAAU,MAAMlD,wBACpB3B,WACA4B,UACAxB,KACAqB,KAAK,CAAC,CAACjB;YACPD,QAAQC,KAAK,CACX,oCACAoB,SAASM,GAAG,CAAC4C,IAAI,EACjBtE;YAEF,OAAO;QACT;QACA,IAAIqE,YAAY,aAAaV,OAAOG,SAAS,IAAI;aAC5C,IAAIO,YAAY,WAAWV,OAAOI,OAAO,IAAI;aAC7C,IAAIM,YAAY,iBAAiBV,OAAOK,YAAY,IAAI;aACxD,IAAIK,YAAY,aAAaV,OAAOM,SAAS,IAAI;IACxD;IACA,OAAON;AACT"}
|
package/src/lib/server.js
CHANGED
|
@@ -25,6 +25,7 @@ import { isEmailConfigured } from "@aglyn/shared-util-email";
|
|
|
25
25
|
import { firebaseAdmin } from "@aglyn/tenant-data-admin";
|
|
26
26
|
import { BUNDLE_ID } from "./constants/bundle-common.js";
|
|
27
27
|
import { registerCommerceMediaPublishGuard } from "./server/media-publish-guard.js";
|
|
28
|
+
import { registerCommerceRecurringCharges } from "./server/recurring-charges.js";
|
|
28
29
|
import { registerProductCardReader } from "./server/product-card.js";
|
|
29
30
|
import { registerCommerceTaxProfile } from "./server/tax-profile.js";
|
|
30
31
|
import { commerceBillingWebhookHandler } from "./server/billing-webhook.js";
|
|
@@ -196,6 +197,14 @@ registerPluginJob({
|
|
|
196
197
|
}
|
|
197
198
|
}
|
|
198
199
|
});
|
|
200
|
+
/**
|
|
201
|
+
* A visitor's payment door: it opens a Stripe Checkout Session for whoever
|
|
202
|
+
* calls it, so the dispatcher holds it to the card-testing counters
|
|
203
|
+
* (AGL-3363). Declared here, beside the registration, so the door cannot
|
|
204
|
+
* exist without it.
|
|
205
|
+
*/ const CARD_PAYMENT_DOOR = {
|
|
206
|
+
cardPayment: true
|
|
207
|
+
};
|
|
199
208
|
/** Registers the commerce plugin's storefront API routes. */ export function registerCommerceApi() {
|
|
200
209
|
registerProductCardReader();
|
|
201
210
|
// The merchant's tax rule, for every plugin that prices a charge here.
|
|
@@ -211,10 +220,10 @@ registerPluginJob({
|
|
|
211
220
|
// only covers /products/* and /collections/*, so /products itself, and any
|
|
212
221
|
// designed page with a grid on it, needed this to render server-side.
|
|
213
222
|
registerSitePageEnricher(commerceSitePageEnricher);
|
|
214
|
-
registerPluginApiRoute('commerce/cart-checkout', cartCheckoutHandler);
|
|
223
|
+
registerPluginApiRoute('commerce/cart-checkout', cartCheckoutHandler, CARD_PAYMENT_DOOR);
|
|
215
224
|
registerPluginApiRoute('commerce/cart', cartHandler);
|
|
216
225
|
registerPluginApiRoute('commerce/catalog', catalogHandler);
|
|
217
|
-
registerPluginApiRoute('commerce/checkout', checkoutHandler);
|
|
226
|
+
registerPluginApiRoute('commerce/checkout', checkoutHandler, CARD_PAYMENT_DOOR);
|
|
218
227
|
registerPluginApiRoute('commerce/download', downloadHandler);
|
|
219
228
|
registerPluginApiRoute('commerce/feed', feedHandler);
|
|
220
229
|
registerPluginApiRoute('commerce/newsletter', newsletterHandler);
|
|
@@ -224,7 +233,7 @@ registerPluginJob({
|
|
|
224
233
|
registerPluginApiRoute('commerce/product', productHandler);
|
|
225
234
|
registerPluginApiRoute('commerce/related', relatedHandler);
|
|
226
235
|
registerPluginApiRoute('commerce/reservation-availability', reservationAvailabilityHandler);
|
|
227
|
-
registerPluginApiRoute('commerce/reserve', reserveHandler);
|
|
236
|
+
registerPluginApiRoute('commerce/reserve', reserveHandler, CARD_PAYMENT_DOOR);
|
|
228
237
|
registerPluginApiRoute('commerce/gate', gateHandler);
|
|
229
238
|
registerPluginApiRoute('commerce/member-feed', memberFeedHandler);
|
|
230
239
|
registerPluginApiRoute('commerce/stream', streamHandler);
|
|
@@ -259,6 +268,9 @@ registerPluginJob({
|
|
|
259
268
|
// back; what a product is, and which of its fields hold paid media, is
|
|
260
269
|
// this plugin's to know.
|
|
261
270
|
registerCommerceMediaPublishGuard();
|
|
271
|
+
// …and which memberships a site sells, for a security lockdown to pause
|
|
272
|
+
// (AGL-3364). The lockdown route runs on this surface.
|
|
273
|
+
registerCommerceRecurringCharges();
|
|
262
274
|
// …and the tax rule, for the webhook that confirms what they charged.
|
|
263
275
|
registerCommerceTaxProfile();
|
|
264
276
|
// Stripe webhook sections (AGL-418): orders/carts/drafts/reservations/
|
package/src/lib/server.js.map
CHANGED
|
@@ -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 { 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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"}
|
|
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 { registerCommerceRecurringCharges } from './server/recurring-charges'\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/**\n * A visitor's payment door: it opens a Stripe Checkout Session for whoever\n * calls it, so the dispatcher holds it to the card-testing counters\n * (AGL-3363). Declared here, beside the registration, so the door cannot\n * exist without it.\n */\nconst CARD_PAYMENT_DOOR = { cardPayment: true } as const\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, CARD_PAYMENT_DOOR)\n registerPluginApiRoute('commerce/cart', cartHandler)\n registerPluginApiRoute('commerce/catalog', catalogHandler)\n registerPluginApiRoute('commerce/checkout', checkoutHandler, CARD_PAYMENT_DOOR)\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, CARD_PAYMENT_DOOR)\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 which memberships a site sells, for a security lockdown to pause\n // (AGL-3364). The lockdown route runs on this surface.\n registerCommerceRecurringCharges()\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","registerCommerceRecurringCharges","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","CARD_PAYMENT_DOOR","cardPayment","registerCommerceApi","registerCommerceConsoleApi","recordShipment","app","firestore","mintDownloadToken"],"mappings":"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"}
|