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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (148) hide show
  1. package/README.md +6 -0
  2. package/package.json +14 -14
  3. package/src/index.d.ts +1 -0
  4. package/src/index.js +1 -19
  5. package/src/index.js.map +1 -1
  6. package/src/lib/components/cart.js +2 -1
  7. package/src/lib/components/cart.js.map +1 -1
  8. package/src/lib/components/console/catalog-organization-card.component.js +665 -459
  9. package/src/lib/components/console/catalog-organization-card.component.js.map +1 -1
  10. package/src/lib/components/console/host-orders-card.component.js +2 -1
  11. package/src/lib/components/console/host-orders-card.component.js.map +1 -1
  12. package/src/lib/components/console/member-posts-card.component.js +1 -1
  13. package/src/lib/components/console/member-posts-card.component.js.map +1 -1
  14. package/src/lib/components/console/order-detail-dialog.component.js +8 -7
  15. package/src/lib/components/console/order-detail-dialog.component.js.map +1 -1
  16. package/src/lib/components/console/payments-settings-card.component.js +2 -2
  17. package/src/lib/components/console/payments-settings-card.component.js.map +1 -1
  18. package/src/lib/components/console/product-editor-dialog.component.js +1221 -889
  19. package/src/lib/components/console/product-editor-dialog.component.js.map +1 -1
  20. package/src/lib/components/console/product-zones.d.ts +184 -0
  21. package/src/lib/components/console/product-zones.js +54 -0
  22. package/src/lib/components/console/product-zones.js.map +1 -0
  23. package/src/lib/components/console/products-hub-card.component.js +3 -2
  24. package/src/lib/components/console/products-hub-card.component.js.map +1 -1
  25. package/src/lib/components/console/products-hub-zone.component.d.ts +1 -1
  26. package/src/lib/components/console/products-hub-zone.component.js +2 -1
  27. package/src/lib/components/console/products-hub-zone.component.js.map +1 -1
  28. package/src/lib/components/console/site-member-purchases.component.d.ts +24 -0
  29. package/src/lib/components/console/site-member-purchases.component.js +339 -0
  30. package/src/lib/components/console/site-member-purchases.component.js.map +1 -0
  31. package/src/lib/components/console/store-settings-card.component.js +2 -2
  32. package/src/lib/components/console/store-settings-card.component.js.map +1 -1
  33. package/src/lib/components/console/storefront-tax-summary-card.component.js +1 -1
  34. package/src/lib/components/console/storefront-tax-summary-card.component.js.map +1 -1
  35. package/src/lib/components/member-signup.js +2 -2
  36. package/src/lib/components/member-signup.js.map +1 -1
  37. package/src/lib/components/newsletter-signup.js +2 -2
  38. package/src/lib/components/newsletter-signup.js.map +1 -1
  39. package/src/lib/components/product-detail.js +2 -1
  40. package/src/lib/components/product-detail.js.map +1 -1
  41. package/src/lib/components/product-grid.js +1 -1
  42. package/src/lib/components/product-grid.js.map +1 -1
  43. package/src/lib/constants/operator-alerts.js +5 -3
  44. package/src/lib/constants/operator-alerts.js.map +1 -1
  45. package/src/lib/constants/storefront-payments.d.ts +33 -0
  46. package/src/lib/constants/storefront-payments.js +35 -0
  47. package/src/lib/constants/storefront-payments.js.map +1 -0
  48. package/src/lib/declarations.server.d.ts +3 -1
  49. package/src/lib/declarations.server.js +19 -1
  50. package/src/lib/declarations.server.js.map +1 -1
  51. package/src/lib/model/commerce-io.js +1 -1
  52. package/src/lib/model/commerce-io.js.map +1 -1
  53. package/src/lib/model/commerce-record-lists.d.ts +31 -0
  54. package/src/lib/model/commerce-record-lists.js +45 -0
  55. package/src/lib/model/commerce-record-lists.js.map +1 -0
  56. package/src/lib/model/commerce-record-routes.d.ts +45 -0
  57. package/src/lib/model/commerce-record-routes.js +72 -0
  58. package/src/lib/model/commerce-record-routes.js.map +1 -0
  59. package/src/lib/model/commerce-search-sources.d.ts +21 -0
  60. package/src/lib/model/commerce-search-sources.js +42 -0
  61. package/src/lib/model/commerce-search-sources.js.map +1 -0
  62. package/src/lib/model/commerce-topics.d.ts +20 -0
  63. package/src/lib/model/commerce-topics.js +33 -0
  64. package/src/lib/model/commerce-topics.js.map +1 -0
  65. package/src/lib/model/index.d.ts +1 -0
  66. package/src/lib/model/index.js +1 -0
  67. package/src/lib/model/index.js.map +1 -1
  68. package/src/lib/model/product-record.d.ts +32 -0
  69. package/src/lib/model/product-record.js +82 -0
  70. package/src/lib/model/product-record.js.map +1 -0
  71. package/src/lib/model/site-member-purchases.d.ts +48 -0
  72. package/src/lib/model/site-member-purchases.js +50 -0
  73. package/src/lib/model/site-member-purchases.js.map +1 -0
  74. package/src/lib/model/storefront-tax-summary.d.ts +121 -0
  75. package/src/lib/model/storefront-tax-summary.js +148 -0
  76. package/src/lib/model/storefront-tax-summary.js.map +1 -0
  77. package/src/lib/plan-entitlements.d.ts +35 -0
  78. package/src/lib/plan-entitlements.js +54 -0
  79. package/src/lib/plan-entitlements.js.map +1 -0
  80. package/src/lib/plugin.js +21 -0
  81. package/src/lib/plugin.js.map +1 -1
  82. package/src/lib/server/billing-webhook.js +387 -248
  83. package/src/lib/server/billing-webhook.js.map +1 -1
  84. package/src/lib/server/cart-checkout.js +3 -3
  85. package/src/lib/server/cart-checkout.js.map +1 -1
  86. package/src/lib/server/checkout.js +2 -2
  87. package/src/lib/server/checkout.js.map +1 -1
  88. package/src/lib/server/connect.js +5 -5
  89. package/src/lib/server/connect.js.map +1 -1
  90. package/src/lib/server/draft-order.js +2 -2
  91. package/src/lib/server/draft-order.js.map +1 -1
  92. package/src/lib/server/gift-cards.js +7 -3
  93. package/src/lib/server/gift-cards.js.map +1 -1
  94. package/src/lib/server/low-stock.js +3 -1
  95. package/src/lib/server/low-stock.js.map +1 -1
  96. package/src/lib/server/member-post.js +2 -2
  97. package/src/lib/server/member-post.js.map +1 -1
  98. package/src/lib/server/membership-admin-password.js +4 -1
  99. package/src/lib/server/membership-admin-password.js.map +1 -1
  100. package/src/lib/server/membership-content.js +1 -1
  101. package/src/lib/server/membership-content.js.map +1 -1
  102. package/src/lib/server/membership-register.js +13 -2
  103. package/src/lib/server/membership-register.js.map +1 -1
  104. package/src/lib/server/newsletter.js +32 -19
  105. package/src/lib/server/newsletter.js.map +1 -1
  106. package/src/lib/server/pos-order.js +3 -3
  107. package/src/lib/server/pos-order.js.map +1 -1
  108. package/src/lib/server/process-abandoned.js +4 -2
  109. package/src/lib/server/process-abandoned.js.map +1 -1
  110. package/src/lib/server/process-restock.js +7 -5
  111. package/src/lib/server/process-restock.js.map +1 -1
  112. package/src/lib/server/product-record-index.d.ts +19 -0
  113. package/src/lib/server/product-record-index.js +83 -0
  114. package/src/lib/server/product-record-index.js.map +1 -0
  115. package/src/lib/server/reconcile-stock.js +1 -1
  116. package/src/lib/server/reconcile-stock.js.map +1 -1
  117. package/src/lib/server/reserve-stock.js +6 -1
  118. package/src/lib/server/reserve-stock.js.map +1 -1
  119. package/src/lib/server/reserve.js +2 -2
  120. package/src/lib/server/reserve.js.map +1 -1
  121. package/src/lib/server/revenue-source.d.ts +130 -0
  122. package/src/lib/server/revenue-source.js +286 -0
  123. package/src/lib/server/revenue-source.js.map +1 -0
  124. package/src/lib/server/supplier-outbox.js +16 -1
  125. package/src/lib/server/supplier-outbox.js.map +1 -1
  126. package/src/lib/server/supplier-update.js +19 -9
  127. package/src/lib/server/supplier-update.js.map +1 -1
  128. package/src/lib/server/tax-profile.d.ts +8 -5
  129. package/src/lib/server/tax-profile.js +22 -5
  130. package/src/lib/server/tax-profile.js.map +1 -1
  131. package/src/lib/server/tax-return-source.d.ts +125 -0
  132. package/src/lib/server/tax-return-source.js +524 -0
  133. package/src/lib/server/tax-return-source.js.map +1 -0
  134. package/src/lib/server/tax-summary.d.ts +92 -0
  135. package/src/lib/server/tax-summary.js +258 -0
  136. package/src/lib/server/tax-summary.js.map +1 -0
  137. package/src/lib/server.d.ts +1 -0
  138. package/src/lib/server.js +23 -0
  139. package/src/lib/server.js.map +1 -1
  140. package/src/lib/starter-templates.d.ts +19 -0
  141. package/src/lib/starter-templates.js +157 -0
  142. package/src/lib/starter-templates.js.map +1 -0
  143. package/src/lib/tenant-emails.d.ts +33 -0
  144. package/src/lib/tenant-emails.js +551 -0
  145. package/src/lib/tenant-emails.js.map +1 -0
  146. package/src/lib/testing/stand-in-crm-record-routes.d.ts +25 -0
  147. package/src/lib/testing/stand-in-crm-record-routes.js +46 -0
  148. package/src/lib/testing/stand-in-crm-record-routes.js.map +1 -0
@@ -0,0 +1,286 @@
1
+ import { _ as _extends } from "@swc/helpers/_/_extends";
2
+ /**
3
+ * @license
4
+ * Copyright 2026 Aglyn LLC
5
+ *
6
+ * Licensed under the Apache License, Version 2.0 (the "License");
7
+ * you may not use this file except in compliance with the License.
8
+ * You may obtain a copy of the License at
9
+ *
10
+ * http://www.apache.org/licenses/LICENSE-2.0
11
+ *
12
+ * Unless required by applicable law or agreed to in writing, software
13
+ * distributed under the License is distributed on an "AS IS" BASIS,
14
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ * See the License for the specific language governing permissions and
16
+ * limitations under the License.
17
+ */ /**
18
+ * The storefront's take on the operator's revenue report (AGL-2486, AGL-3080).
19
+ *
20
+ * Answered through the platform's revenue source contract
21
+ * (`@aglyn/aglyn/plugin-manager/plugin-revenue-sources`). The report folds the
22
+ * operator's own invoices; this is what storefront orders earned it — and the
23
+ * one figure on that page most likely to be reported wrongly.
24
+ *
25
+ * An order's `feeCents` is the Connect `application_fee_amount`, which since
26
+ * AGL-2152 is the advertised take PLUS Stripe's processing cost:
27
+ *
28
+ * fee = take%(goods) + processing%(charge) + 30¢
29
+ *
30
+ * Every storefront charge is a DESTINATION charge, so Stripe moves the whole
31
+ * amount to the merchant and debits its processing fee from the PLATFORM's
32
+ * balance; the pass-through half of that fee exists purely to recover a cost
33
+ * the platform has already paid. Reporting `feeCents` as earnings would
34
+ * overstate the margin on every single storefront sale — and on a small order
35
+ * it would report the 30¢ Stripe just took as money the platform made.
36
+ *
37
+ * So the pass-through is recomputed with the SAME helper the fee was charged
38
+ * from (`saleProcessingCostCents`) and subtracted. Both halves are
39
+ * reported, because "we collected X of which Y was Stripe's" is the sentence a
40
+ * margin figure needs.
41
+ *
42
+ * The recomputation is an estimate in one direction only: it re-derives the
43
+ * cost from the stored charge amount rather than reading Stripe's actual
44
+ * balance-transaction fee, and it prices it at the DEAREST enabled payment
45
+ * method's rate (BNPL, 6%) because that is the rate the fee was charged at.
46
+ * A card-family order really costs 2.9%, so on those orders this subtracts
47
+ * more than Stripe took and the take is UNDERSTATED — the safe direction, and
48
+ * stated on the page rather than left to be discovered.
49
+ *
50
+ * ## Subscription cycles, and the one cycle this understates
51
+ *
52
+ * A storefront SUBSCRIPTION cycle carries a `subscriptionId`. Since AGL-2655
53
+ * its fee carries the same pass-through, folded into
54
+ * `application_fee_percent` as a rate because a Stripe subscription cannot
55
+ * express a fixed 30¢ — so it is netted out here exactly as a one-time sale's
56
+ * is. The percent is sized on the recurring goods and the recomputed cost here
57
+ * is sized on the whole amount paid, tax included, so a taxed cycle subtracts
58
+ * slightly more than was recovered: understated, the safe direction.
59
+ *
60
+ * A subscription sold before the pass-through existed is carried onto it by
61
+ * the renewal re-price (AGL-2289) at its next paid invoice — but THAT invoice
62
+ * was billed at the bare take, so its row's `feeCents` holds no pass-through
63
+ * and the clamp reports its take as zero. One cycle per legacy subscription,
64
+ * and the count beside the figure is how a reader sees how much of the book
65
+ * that is.
66
+ */ import { firebaseAdmin } from "@aglyn/tenant-data-admin";
67
+ import { saleProcessingCostCents } from "@aglyn/aglyn/server";
68
+ import { PLATFORM_BRAND_NAME } from "@aglyn/aglyn/app-utils/platform-brand";
69
+ import { stripeIdIsTestMode } from "@aglyn/aglyn/app-utils/stripe-deployment-mode";
70
+ import { groupRevenueAttribution } from "@aglyn/aglyn/plugin-manager/plugin-revenue-sources";
71
+ /** Cents from anything, defaulting to 0 rather than NaN. */ function cents(value) {
72
+ const parsed = Number(value != null ? value : 0);
73
+ return Number.isFinite(parsed) ? Math.round(parsed) : 0;
74
+ }
75
+ /**
76
+ * Whether this order row was a test-mode rehearsal.
77
+ *
78
+ * A recorded `livemode` wins, and the session id's prefix is the fallback for
79
+ * rows written before anything recorded it. An order with neither signal is
80
+ * LIVE: a POS cash sale carries no Stripe session at all, and answering "test"
81
+ * for anything unidentifiable would erase genuine revenue from the report.
82
+ */ function isTestModeOrderRow(row) {
83
+ var _row_checkoutSessionId;
84
+ if (typeof row.livemode === 'boolean') return !row.livemode;
85
+ return stripeIdIsTestMode((_row_checkoutSessionId = row.checkoutSessionId) != null ? _row_checkoutSessionId : row.id);
86
+ }
87
+ export function commerceSettledSummary(rows, truncated = false) {
88
+ const out = {
89
+ transactionCount: 0,
90
+ grossCents: 0,
91
+ applicationFeeCents: 0,
92
+ processingPassThroughCents: 0,
93
+ commissionCents: 0,
94
+ commissionRefundedCents: 0,
95
+ commissionNetCents: 0,
96
+ subscriptionOrders: 0,
97
+ truncated: truncated === true
98
+ };
99
+ for (const row of rows != null ? rows : []){
100
+ // A REHEARSAL IS NOT REVENUE. A smoke-test checkout writes a real order
101
+ // document — Stripe never moved money for it, and its session id says so.
102
+ // Skipped ENTIRELY rather than counted at zero, because `transactionCount`
103
+ // is read as "how many sales", and a rehearsal is not one.
104
+ if (isTestModeOrderRow(row)) continue;
105
+ const split = commerceOrderTake(row);
106
+ out.transactionCount += 1;
107
+ out.grossCents += split.gross;
108
+ out.applicationFeeCents += split.fee;
109
+ if (split.isSubscriptionOrder) out.subscriptionOrders += 1;
110
+ out.processingPassThroughCents += split.passThrough;
111
+ out.commissionCents += split.take;
112
+ out.commissionRefundedCents += split.takeRefunded;
113
+ out.commissionNetCents += split.takeNet;
114
+ }
115
+ return out;
116
+ }
117
+ /**
118
+ * One storefront order's take, in ONE place.
119
+ *
120
+ * Shared with the per-host attribution so a host table cannot sum to
121
+ * something other than the storefront line above it.
122
+ *
123
+ * A zero-fee order contributes its gross and its count and nothing else —
124
+ * there is no take to attribute, and inventing one from the gross would
125
+ * report a merchant's money as the platform's.
126
+ */ export function commerceOrderTake(row) {
127
+ const gross = cents(row == null ? void 0 : row.amountCents);
128
+ const fee = Math.max(0, cents(row == null ? void 0 : row.feeCents));
129
+ const isSubscriptionOrder = typeof (row == null ? void 0 : row.subscriptionId) === 'string' && row.subscriptionId.length > 0;
130
+ if (fee <= 0) {
131
+ return {
132
+ gross,
133
+ fee,
134
+ isSubscriptionOrder,
135
+ passThrough: 0,
136
+ take: 0,
137
+ takeRefunded: 0,
138
+ takeNet: 0
139
+ };
140
+ }
141
+ // Every fee bundles the recovery — a one-time sale's in cents, a
142
+ // subscription cycle's as a rate (AGL-2655). Clamped to the fee itself: the
143
+ // recomputed cost can exceed a fee charged under an older rate, and a
144
+ // negative take would subtract from another order's real margin.
145
+ const passThrough = Math.min(fee, saleProcessingCostCents(gross));
146
+ const take = fee - passThrough;
147
+ const refunded = Math.min(gross, Math.max(0, cents(row == null ? void 0 : row.refundedCents)));
148
+ const takeRefunded = gross > 0 ? Math.round(refunded * take / gross) : 0;
149
+ return {
150
+ gross,
151
+ fee,
152
+ isSubscriptionOrder,
153
+ passThrough,
154
+ take,
155
+ takeRefunded,
156
+ takeNet: take - takeRefunded
157
+ };
158
+ }
159
+ /**
160
+ * Storefront take by HOST.
161
+ *
162
+ * By host and not by org on purpose: one org can run several storefronts, and
163
+ * "which store earns" is the question a per-org roll-up destroys.
164
+ */ export function commerceHostAttribution(rows, limit = 100) {
165
+ return groupRevenueAttribution((rows != null ? rows : []).map((row)=>{
166
+ var _ref;
167
+ const split = commerceOrderTake(row);
168
+ return {
169
+ key: String((_ref = row == null ? void 0 : row.hostId) != null ? _ref : ''),
170
+ detail: '',
171
+ gain: split.takeNet,
172
+ loss: split.takeRefunded
173
+ };
174
+ }), limit, 'Host not recorded');
175
+ }
176
+ /**
177
+ * The storefront's answer for one period, from figures already folded. Pure,
178
+ * so what the report prints can be proved from fixtures.
179
+ *
180
+ * `failed` is a sweep that could not run — a collection-group index this
181
+ * deployment has not built answers FAILED_PRECONDITION — and it is reported as
182
+ * a failure with figures of zero, never as a cap: "we read part of it" and "we
183
+ * read none of it" have different remedies.
184
+ */ export function commerceRevenueAnswer(read) {
185
+ var _settled_commissionNetCents, _settled_subscriptionOrders;
186
+ const brand = PLATFORM_BRAND_NAME;
187
+ const { settled } = read;
188
+ return {
189
+ id: 'commerce',
190
+ name: 'storefront orders',
191
+ earned: {
192
+ label: 'Storefront commission',
193
+ cents: Number((_settled_commissionNetCents = settled.commissionNetCents) != null ? _settled_commissionNetCents : 0),
194
+ note: 'The advertised take on merchant storefront sales, net of refunds — ' + 'with Stripe’s card processing removed. The platform fee charged on a ' + 'storefront sale bundles that processing cost and passes it through at ' + 'cost; it is a recovery, not earnings.'
195
+ },
196
+ grossToNet: [
197
+ {
198
+ label: 'Storefront sales (shopper gross)',
199
+ cents: settled.grossCents,
200
+ deduction: false,
201
+ note: 'The merchant’s. It transfers straight to their connected account ' + 'and is shown only for scale.'
202
+ },
203
+ {
204
+ label: 'Storefront platform fee collected',
205
+ cents: settled.applicationFeeCents,
206
+ deduction: false,
207
+ note: `Not all ${brand}’s — see the next line.`
208
+ },
209
+ {
210
+ label: '— less card processing passed through at cost',
211
+ cents: settled.processingPassThroughCents,
212
+ deduction: true,
213
+ note: `Stripe’s. On a destination charge Stripe debits ${brand}’s balance for processing, and this half of the fee recovers exactly that. It is a recovery, not earnings, and reporting it as revenue would overstate every storefront sale.`
214
+ }
215
+ ],
216
+ notes: Number((_settled_subscriptionOrders = settled.subscriptionOrders) != null ? _settled_subscriptionOrders : 0) > 0 ? [
217
+ {
218
+ label: `${settled.subscriptionOrders} storefront subscription cycles — one billed before its re-price reports no take`,
219
+ tone: 'warning'
220
+ }
221
+ ] : [],
222
+ attribution: [
223
+ _extends({
224
+ id: 'byHost',
225
+ heading: 'Storefront take by host',
226
+ unit: 'Storefront',
227
+ countLabel: 'Orders',
228
+ empty: `No storefront order settled in this period. Note this is ${brand}'s take only — the shopper's spend is the merchant's money and is never counted here.`,
229
+ chartEmpty: 'No storefront earned a take in this period — nothing to plot yet.'
230
+ }, read.byHost)
231
+ ],
232
+ attributionNote: "Storefront figures above are the advertised take with Stripe's " + 'processing cost already subtracted, on the basis stated in “Gross ' + 'versus net” below. Attribution is by host rather than by org because ' + 'one org can run several storefronts, and rolling them up destroys the ' + 'question.',
233
+ truncated: settled.truncated,
234
+ failure: read.failed ? {
235
+ title: 'Storefront orders could not be read',
236
+ detail: 'The storefront commission below reads $0 because the query ' + 'failed, not because there were no sales. The sweep needs the ' + 'COLLECTION_GROUP index on orders.createdAtMs — check it is still ' + 'declared in the Firestore index config and actually deployed, ' + 'since indexes ship separately from the app.'
237
+ } : null,
238
+ summary: settled
239
+ };
240
+ }
241
+ /**
242
+ * The storefront's source, registered on the console's API surface: the
243
+ * staff revenue report awaits that surface before it asks.
244
+ */ export const commerceRevenueSource = {
245
+ async read (request) {
246
+ var _ref;
247
+ const firestore = firebaseAdmin.app().firestore();
248
+ // Storefront orders live per host (`hosts/{hostId}/orders`), so this is a
249
+ // collection-GROUP range — ranged on `createdAtMs`, the NUMBER, and not on
250
+ // the `createdAt` Timestamp beside it. A collection-group range needs a
251
+ // COLLECTION_GROUP-scoped single-field index — Firestore's automatic ones
252
+ // are COLLECTION scope only — and the index that is declared and deployed
253
+ // is on `createdAtMs` (AGL-1793). The `createdAt` form answers
254
+ // FAILED_PRECONDITION on every request, which is how the report once read
255
+ // $0 for every storefront.
256
+ let swept;
257
+ try {
258
+ swept = await request.sweep(firestore.collectionGroup('orders').where('createdAtMs', '>=', request.start.getTime()).where('createdAtMs', '<', request.end.getTime()), 'createdAtMs');
259
+ } catch (unused) {
260
+ swept = null;
261
+ }
262
+ const rows = ((_ref = swept == null ? void 0 : swept.docs) != null ? _ref : []).map((doc)=>{
263
+ var _ref;
264
+ var _doc_ref_parent_parent;
265
+ return _extends({
266
+ id: doc.id,
267
+ // The storefront that earned the take is in the PATH.
268
+ hostId: (_ref = (_doc_ref_parent_parent = doc.ref.parent.parent) == null ? void 0 : _doc_ref_parent_parent.id) != null ? _ref : ''
269
+ }, doc.data());
270
+ });
271
+ const settled = commerceSettledSummary(rows, (swept == null ? void 0 : swept.truncated) === true);
272
+ const byHost = commerceHostAttribution(rows, request.attributionLimit);
273
+ await request.nameRows(byHost.rows, {
274
+ collection: 'hosts',
275
+ nameField: 'displayName',
276
+ detailField: 'subdomain'
277
+ });
278
+ return commerceRevenueAnswer({
279
+ settled,
280
+ byHost,
281
+ failed: swept === null
282
+ });
283
+ }
284
+ };
285
+
286
+ //# sourceMappingURL=revenue-source.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/revenue-source.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * The storefront's take on the operator's revenue report (AGL-2486, AGL-3080).\n *\n * Answered through the platform's revenue source contract\n * (`@aglyn/aglyn/plugin-manager/plugin-revenue-sources`). The report folds the\n * operator's own invoices; this is what storefront orders earned it — and the\n * one figure on that page most likely to be reported wrongly.\n *\n * An order's `feeCents` is the Connect `application_fee_amount`, which since\n * AGL-2152 is the advertised take PLUS Stripe's processing cost:\n *\n * fee = take%(goods) + processing%(charge) + 30¢\n *\n * Every storefront charge is a DESTINATION charge, so Stripe moves the whole\n * amount to the merchant and debits its processing fee from the PLATFORM's\n * balance; the pass-through half of that fee exists purely to recover a cost\n * the platform has already paid. Reporting `feeCents` as earnings would\n * overstate the margin on every single storefront sale — and on a small order\n * it would report the 30¢ Stripe just took as money the platform made.\n *\n * So the pass-through is recomputed with the SAME helper the fee was charged\n * from (`saleProcessingCostCents`) and subtracted. Both halves are\n * reported, because \"we collected X of which Y was Stripe's\" is the sentence a\n * margin figure needs.\n *\n * The recomputation is an estimate in one direction only: it re-derives the\n * cost from the stored charge amount rather than reading Stripe's actual\n * balance-transaction fee, and it prices it at the DEAREST enabled payment\n * method's rate (BNPL, 6%) because that is the rate the fee was charged at.\n * A card-family order really costs 2.9%, so on those orders this subtracts\n * more than Stripe took and the take is UNDERSTATED — the safe direction, and\n * stated on the page rather than left to be discovered.\n *\n * ## Subscription cycles, and the one cycle this understates\n *\n * A storefront SUBSCRIPTION cycle carries a `subscriptionId`. Since AGL-2655\n * its fee carries the same pass-through, folded into\n * `application_fee_percent` as a rate because a Stripe subscription cannot\n * express a fixed 30¢ — so it is netted out here exactly as a one-time sale's\n * is. The percent is sized on the recurring goods and the recomputed cost here\n * is sized on the whole amount paid, tax included, so a taxed cycle subtracts\n * slightly more than was recovered: understated, the safe direction.\n *\n * A subscription sold before the pass-through existed is carried onto it by\n * the renewal re-price (AGL-2289) at its next paid invoice — but THAT invoice\n * was billed at the bare take, so its row's `feeCents` holds no pass-through\n * and the clamp reports its take as zero. One cycle per legacy subscription,\n * and the count beside the figure is how a reader sees how much of the book\n * that is.\n */\n\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin'\nimport { saleProcessingCostCents } from '@aglyn/aglyn/server'\nimport { PLATFORM_BRAND_NAME } from '@aglyn/aglyn/app-utils/platform-brand'\nimport { stripeIdIsTestMode } from '@aglyn/aglyn/app-utils/stripe-deployment-mode'\nimport {\n groupRevenueAttribution,\n type RevenueAttribution,\n type RevenueSource,\n type RevenueSourceAnswer,\n} from '@aglyn/aglyn/plugin-manager/plugin-revenue-sources'\n\n/** One `hosts/{hostId}/orders/{orderId}` row — a storefront sale. */\nexport interface CommerceOrderRowInput {\n id: string\n /** What the shopper paid, tax and shipping included. */\n amountCents?: unknown\n /**\n * The Connect `application_fee_amount`: the platform's advertised take PLUS\n * Stripe's processing cost passed through at cost (AGL-2152). NOT margin —\n * see the module note.\n */\n feeCents?: unknown\n /**\n * Present ONLY on a storefront SUBSCRIPTION renewal, and it changes how\n * `feeCents` must be read — see the module note.\n */\n subscriptionId?: unknown\n /** Merchant refunds AND chargebacks both land here (`refund.ts`). */\n refundedCents?: unknown\n /**\n * WHICH storefront the order belongs to — the attribution key.\n *\n * Orders live at `hosts/{hostId}/orders/{orderId}`, so this is lifted from\n * the document PATH by the reader rather than read from a field. It costs no\n * extra read and cannot disagree with where the document actually is.\n */\n hostId?: unknown\n createdAt?: unknown\n /**\n * Whether Stripe moved real money, stamped from `event.livemode` by the\n * webhook. Authoritative when present, and absent on every order written\n * before that stamp shipped — which is why {@link isTestModeOrderRow} falls\n * back to the id below rather than treating absence as an answer.\n */\n livemode?: unknown\n /**\n * The Checkout Session that produced the order. It carries its own mode\n * (`cs_test_…`), which a subscription renewal's invoice id does not — so\n * this is the fallback discriminator for rows predating `livemode`.\n */\n checkoutSessionId?: unknown\n}\n\nexport interface CommerceSettled {\n transactionCount: number\n /** Shopper spend across storefronts. Overwhelmingly the merchant's. */\n grossCents: number\n /** The whole `application_fee_amount` the platform collected. NOT margin. */\n applicationFeeCents: number\n /** Stripe's processing cost this recovered, at cost. Subtracted, never earnings. */\n processingPassThroughCents: number\n /** Application fee minus the pass-through: the advertised take. */\n commissionCents: number\n /** Take handed back with refunds and chargebacks, pro-rata. */\n commissionRefundedCents: number\n /** Take net of reversals. */\n commissionNetCents: number\n /**\n * Storefront SUBSCRIPTION cycles in this period. Counted because a cycle\n * billed before its subscription was re-priced onto the pass-through\n * reports no take at all — see the module note.\n */\n subscriptionOrders: number\n /**\n * The sweep hit its ceiling, so every figure above is a LOWER BOUND and must\n * not be quoted as a total. Narrow the period instead.\n */\n truncated: boolean\n}\n\n/** Cents from anything, defaulting to 0 rather than NaN. */\nfunction cents(value: unknown): number {\n const parsed = Number(value ?? 0)\n return Number.isFinite(parsed) ? Math.round(parsed) : 0\n}\n\n/**\n * Whether this order row was a test-mode rehearsal.\n *\n * A recorded `livemode` wins, and the session id's prefix is the fallback for\n * rows written before anything recorded it. An order with neither signal is\n * LIVE: a POS cash sale carries no Stripe session at all, and answering \"test\"\n * for anything unidentifiable would erase genuine revenue from the report.\n */\nfunction isTestModeOrderRow(row: CommerceOrderRowInput): boolean {\n if (typeof row.livemode === 'boolean') return !row.livemode\n return stripeIdIsTestMode(row.checkoutSessionId ?? row.id)\n}\n\nexport function commerceSettledSummary(\n rows: readonly CommerceOrderRowInput[],\n truncated = false,\n): CommerceSettled {\n const out: CommerceSettled = {\n transactionCount: 0,\n grossCents: 0,\n applicationFeeCents: 0,\n processingPassThroughCents: 0,\n commissionCents: 0,\n commissionRefundedCents: 0,\n commissionNetCents: 0,\n subscriptionOrders: 0,\n truncated: truncated === true,\n }\n for (const row of rows ?? []) {\n // A REHEARSAL IS NOT REVENUE. A smoke-test checkout writes a real order\n // document — Stripe never moved money for it, and its session id says so.\n // Skipped ENTIRELY rather than counted at zero, because `transactionCount`\n // is read as \"how many sales\", and a rehearsal is not one.\n if (isTestModeOrderRow(row)) continue\n const split = commerceOrderTake(row)\n out.transactionCount += 1\n out.grossCents += split.gross\n out.applicationFeeCents += split.fee\n if (split.isSubscriptionOrder) out.subscriptionOrders += 1\n out.processingPassThroughCents += split.passThrough\n out.commissionCents += split.take\n out.commissionRefundedCents += split.takeRefunded\n out.commissionNetCents += split.takeNet\n }\n return out\n}\n\n/**\n * One storefront order's take, in ONE place.\n *\n * Shared with the per-host attribution so a host table cannot sum to\n * something other than the storefront line above it.\n *\n * A zero-fee order contributes its gross and its count and nothing else —\n * there is no take to attribute, and inventing one from the gross would\n * report a merchant's money as the platform's.\n */\nexport function commerceOrderTake(row: CommerceOrderRowInput | null): {\n gross: number\n fee: number\n isSubscriptionOrder: boolean\n passThrough: number\n take: number\n takeRefunded: number\n takeNet: number\n} {\n const gross = cents(row?.amountCents)\n const fee = Math.max(0, cents(row?.feeCents))\n const isSubscriptionOrder =\n typeof row?.subscriptionId === 'string' && row.subscriptionId.length > 0\n if (fee <= 0) {\n return {\n gross,\n fee,\n isSubscriptionOrder,\n passThrough: 0,\n take: 0,\n takeRefunded: 0,\n takeNet: 0,\n }\n }\n // Every fee bundles the recovery — a one-time sale's in cents, a\n // subscription cycle's as a rate (AGL-2655). Clamped to the fee itself: the\n // recomputed cost can exceed a fee charged under an older rate, and a\n // negative take would subtract from another order's real margin.\n const passThrough = Math.min(fee, saleProcessingCostCents(gross))\n const take = fee - passThrough\n const refunded = Math.min(gross, Math.max(0, cents(row?.refundedCents)))\n const takeRefunded = gross > 0 ? Math.round((refunded * take) / gross) : 0\n return {\n gross,\n fee,\n isSubscriptionOrder,\n passThrough,\n take,\n takeRefunded,\n takeNet: take - takeRefunded,\n }\n}\n\n/**\n * Storefront take by HOST.\n *\n * By host and not by org on purpose: one org can run several storefronts, and\n * \"which store earns\" is the question a per-org roll-up destroys.\n */\nexport function commerceHostAttribution(\n rows: readonly CommerceOrderRowInput[],\n limit = 100,\n): RevenueAttribution {\n return groupRevenueAttribution(\n (rows ?? []).map((row) => {\n const split = commerceOrderTake(row)\n return {\n key: String(row?.hostId ?? ''),\n detail: '',\n gain: split.takeNet,\n loss: split.takeRefunded,\n }\n }),\n limit,\n 'Host not recorded',\n )\n}\n\n/**\n * The storefront's answer for one period, from figures already folded. Pure,\n * so what the report prints can be proved from fixtures.\n *\n * `failed` is a sweep that could not run — a collection-group index this\n * deployment has not built answers FAILED_PRECONDITION — and it is reported as\n * a failure with figures of zero, never as a cap: \"we read part of it\" and \"we\n * read none of it\" have different remedies.\n */\nexport function commerceRevenueAnswer(read: {\n settled: CommerceSettled\n byHost: RevenueAttribution\n failed: boolean\n}): RevenueSourceAnswer {\n const brand = PLATFORM_BRAND_NAME\n const { settled } = read\n return {\n id: 'commerce',\n name: 'storefront orders',\n earned: {\n label: 'Storefront commission',\n cents: Number(settled.commissionNetCents ?? 0),\n note:\n 'The advertised take on merchant storefront sales, net of refunds — ' +\n 'with Stripe’s card processing removed. The platform fee charged on a ' +\n 'storefront sale bundles that processing cost and passes it through at ' +\n 'cost; it is a recovery, not earnings.',\n },\n grossToNet: [\n {\n label: 'Storefront sales (shopper gross)',\n cents: settled.grossCents,\n deduction: false,\n note:\n 'The merchant’s. It transfers straight to their connected account ' +\n 'and is shown only for scale.',\n },\n {\n label: 'Storefront platform fee collected',\n cents: settled.applicationFeeCents,\n deduction: false,\n note: `Not all ${brand}’s — see the next line.`,\n },\n {\n label: '— less card processing passed through at cost',\n cents: settled.processingPassThroughCents,\n deduction: true,\n note: `Stripe’s. On a destination charge Stripe debits ${brand}’s balance for processing, and this half of the fee recovers exactly that. It is a recovery, not earnings, and reporting it as revenue would overstate every storefront sale.`,\n },\n ],\n notes:\n Number(settled.subscriptionOrders ?? 0) > 0\n ? [\n {\n label: `${settled.subscriptionOrders} storefront subscription cycles — one billed before its re-price reports no take`,\n tone: 'warning',\n },\n ]\n : [],\n attribution: [\n {\n id: 'byHost',\n heading: 'Storefront take by host',\n unit: 'Storefront',\n countLabel: 'Orders',\n empty: `No storefront order settled in this period. Note this is ${brand}'s take only — the shopper's spend is the merchant's money and is never counted here.`,\n chartEmpty: 'No storefront earned a take in this period — nothing to plot yet.',\n ...read.byHost,\n },\n ],\n attributionNote:\n \"Storefront figures above are the advertised take with Stripe's \" +\n 'processing cost already subtracted, on the basis stated in “Gross ' +\n 'versus net” below. Attribution is by host rather than by org because ' +\n 'one org can run several storefronts, and rolling them up destroys the ' +\n 'question.',\n truncated: settled.truncated,\n failure: read.failed\n ? {\n title: 'Storefront orders could not be read',\n detail:\n 'The storefront commission below reads $0 because the query ' +\n 'failed, not because there were no sales. The sweep needs the ' +\n 'COLLECTION_GROUP index on orders.createdAtMs — check it is still ' +\n 'declared in the Firestore index config and actually deployed, ' +\n 'since indexes ship separately from the app.',\n }\n : null,\n summary: settled,\n }\n}\n\n/**\n * The storefront's source, registered on the console's API surface: the\n * staff revenue report awaits that surface before it asks.\n */\nexport const commerceRevenueSource: RevenueSource = {\n async read(request) {\n const firestore = firebaseAdmin.app().firestore()\n // Storefront orders live per host (`hosts/{hostId}/orders`), so this is a\n // collection-GROUP range — ranged on `createdAtMs`, the NUMBER, and not on\n // the `createdAt` Timestamp beside it. A collection-group range needs a\n // COLLECTION_GROUP-scoped single-field index — Firestore's automatic ones\n // are COLLECTION scope only — and the index that is declared and deployed\n // is on `createdAtMs` (AGL-1793). The `createdAt` form answers\n // FAILED_PRECONDITION on every request, which is how the report once read\n // $0 for every storefront.\n let swept: {\n docs: FirebaseFirestore.QueryDocumentSnapshot[]\n truncated: boolean\n } | null\n try {\n swept = await request.sweep<FirebaseFirestore.QueryDocumentSnapshot>(\n firestore\n .collectionGroup('orders')\n .where('createdAtMs', '>=', request.start.getTime())\n .where('createdAtMs', '<', request.end.getTime()),\n 'createdAtMs',\n )\n } catch {\n swept = null\n }\n const rows: CommerceOrderRowInput[] = (swept?.docs ?? []).map((doc) => ({\n id: doc.id,\n // The storefront that earned the take is in the PATH.\n hostId: doc.ref.parent.parent?.id ?? '',\n ...(doc.data() as object),\n }))\n const settled = commerceSettledSummary(rows, swept?.truncated === true)\n const byHost = commerceHostAttribution(rows, request.attributionLimit)\n await request.nameRows(byHost.rows, {\n collection: 'hosts',\n nameField: 'displayName',\n detailField: 'subdomain',\n })\n return commerceRevenueAnswer({ settled, byHost, failed: swept === null })\n },\n}\n"],"names":["firebaseAdmin","saleProcessingCostCents","PLATFORM_BRAND_NAME","stripeIdIsTestMode","groupRevenueAttribution","cents","value","parsed","Number","isFinite","Math","round","isTestModeOrderRow","row","livemode","checkoutSessionId","id","commerceSettledSummary","rows","truncated","out","transactionCount","grossCents","applicationFeeCents","processingPassThroughCents","commissionCents","commissionRefundedCents","commissionNetCents","subscriptionOrders","split","commerceOrderTake","gross","fee","isSubscriptionOrder","passThrough","take","takeRefunded","takeNet","amountCents","max","feeCents","subscriptionId","length","min","refunded","refundedCents","commerceHostAttribution","limit","map","key","String","hostId","detail","gain","loss","commerceRevenueAnswer","read","settled","brand","name","earned","label","note","grossToNet","deduction","notes","tone","attribution","heading","unit","countLabel","empty","chartEmpty","byHost","attributionNote","failure","failed","title","summary","commerceRevenueSource","request","firestore","app","swept","sweep","collectionGroup","where","start","getTime","end","docs","doc","ref","parent","data","attributionLimit","nameRows","collection","nameField","detailField"],"mappings":"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"}
@@ -16,6 +16,7 @@
16
16
  */ import { _ as _extends } from "@swc/helpers/_/_extends";
17
17
  import { describeConfiguredUrlRefusal, fetchConfiguredPublicUrl, firebaseAdmin, flagLookalikeDomain, notifyHostManagers } from "@aglyn/tenant-data-admin";
18
18
  import { isDocumentId } from "@aglyn/tenant-data-admin/server/document-id";
19
+ import { formatOrderNumber } from "../model/commerce-orders.js";
19
20
  import { createHmac } from "crypto";
20
21
  /**
21
22
  * The durable outbox for the dropship supplier notification (AGL-2473).
@@ -191,6 +192,18 @@ import { createHmac } from "crypto";
191
192
  })
192
193
  }).catch(()=>undefined);
193
194
  }
195
+ /**
196
+ * The number the merchant knows an order by ("#1042"), for a notice about it
197
+ * (AGL-3432). The queued row carries only the document id, which is the Stripe
198
+ * session id and appears nowhere in the console. A read that fails falls back
199
+ * to the id, so the notice still goes out.
200
+ */ async function orderLabelFor(firestore, hostId, orderId) {
201
+ if (!isDocumentId(hostId) || !isDocumentId(orderId)) return orderId;
202
+ const number = await firestore.collection('hosts').doc(hostId).collection('orders').doc(orderId).get().then((snapshot)=>snapshot.get('number')).catch(()=>undefined);
203
+ return formatOrderNumber({
204
+ number: number != null ? number : undefined
205
+ }, orderId);
206
+ }
194
207
  /**
195
208
  * One delivery attempt.
196
209
  *
@@ -291,10 +304,12 @@ import { createHmac } from "crypto";
291
304
  await stampOrderRoutingFailure(firestore, hostId, orderId, `Could not reach ${supplierName} after ${attempts} attempts ` + `(${reason}). This order was never routed.`);
292
305
  // The bell as well as the timeline: nobody opens an order they have no
293
306
  // reason to suspect.
307
+ // The consequence and the action (AGL-3432): the supplier has not been
308
+ // asked to ship this order, so the merchant has to.
294
309
  await notifyHostManagers(hostId, {
295
310
  type: 'content.order',
296
311
  title: `Order not routed to ${supplierName}`,
297
- body: `Order ${orderId} could not be sent after ${attempts} attempts.`,
312
+ body: `Order ${await orderLabelFor(firestore, hostId, orderId)} on {site} ` + `never reached ${supplierName}: ${attempts} delivery attempts failed ` + `(${reason}). They have not been asked to ship it; send it to them ` + 'yourself or fulfill it by hand.',
298
313
  link: `/${hostId}/products`
299
314
  }).catch(()=>undefined);
300
315
  return 'dead-lettered';
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/supplier-outbox.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { type PluginJobHostGate } from '@aglyn/aglyn/server'\nimport {\n describeConfiguredUrlRefusal,\n fetchConfiguredPublicUrl,\n firebaseAdmin,\n flagLookalikeDomain,\n notifyHostManagers,\n} from '@aglyn/tenant-data-admin'\nimport { isDocumentId } from '@aglyn/tenant-data-admin/server/document-id'\nimport { createHmac } from 'crypto'\n\n/**\n * The durable outbox for the dropship supplier notification (AGL-2473).\n *\n * ## What was wrong\n *\n * `billing-webhook.ts` routed a paid dropship order inside\n * `void (async () => { … })()`, and the last thing that block did was\n * `await fetch(supplier.webhookUrl, …)` — an endpoint the MERCHANT typed into\n * a console field, on a host Aglyn neither runs nor monitors. Vercel freezes\n * the container the instant the response is written, so a supplier that is\n * slow, rate-limiting, or simply down is a supplier that is never told. The\n * order is paid, the buyer is thanked, the merchant sees \"Sent to supplier X\"\n * on the timeline, and NOTHING ANYWHERE records that the POST is still owed —\n * so no later pass can find it and no report can list it. It surfaces when the\n * customer asks where their parcel is.\n *\n * ## Why not just `await` it\n *\n * AGL-2161 fixed the other four fire-and-forget sites in that file and\n * deliberately left this one, because awaiting it is worse rather than better:\n * a supplier taking 30s pushes the handler past Stripe's delivery window,\n * Stripe redelivers, and the redelivery re-enters a fan-out whose only\n * idempotency is one `created` flag upstream. A silently dropped notification\n * would have been traded for a DUPLICATED order. `refund.ts` awaits its own\n * sibling and says so in a comment — correctly, because that sibling only\n * touches our own Firestore.\n *\n * So the line this module draws is not \"fast vs slow\" but **ours vs theirs**.\n * Everything the routing block does to Aglyn's own storage — the callback\n * token, the timeline entry, the supplier email through our own provider — is\n * now AWAITED on the response path, exactly like `refund.ts`. The one call to a\n * stranger's server becomes a row here, and the response no longer waits on it.\n *\n * ## The mechanism\n *\n * A Firestore-backed outbox drained by the plugin-job beat that already runs\n * the two commerce recovery passes (`server.ts`). No new infrastructure: the\n * beat is a Cloud Scheduler function POSTing the tenant runner route, and it\n * has carried bounded scans since AGL-2227.\n *\n * * **Idempotent enqueue.** The id is `{hostId}__{orderId}__{supplierId}` and\n * the write is `create()`, which REFUSES an existing document, so a\n * redelivery cannot reset a retry part-way through its backoff. The AGL-498\n * existence guard upstream already returns before this fan-out, but\n * `reconcile-stock.ts` documents at length why one flag over a dozen effects\n * is not something to lean on.\n * * **Backoff.** 1m, 5m, 15m, 1h, 6h — about seven and a half hours of cover,\n * which is a supplier's overnight deploy but not their week off.\n * * **Dead letter.** After {@link SUPPLIER_DELIVERY_MAX_ATTEMPTS} the row is\n * left as `failed`, the ORDER's own timeline is stamped, and host managers\n * get a notification. The merchant reading the order they were paid for can\n * see that it never reached the supplier — the thing the old code could not\n * tell anyone, because it wrote nothing down.\n *\n * ## Two placement decisions, both deliberate\n *\n * **TOP-LEVEL, not `hosts/{hostId}/supplierDeliveries`.** The host catch-all in\n * the rules file is PERMISSIVE by default — a subcollection nobody names in its\n * three exclusion lists is one every site editor can write from the browser —\n * and the row carries the exact JSON body we sign and POST, so an editor-\n * writable row is an editor-chosen payload delivered under the supplier's own\n * shared secret. A top-level collection matches no rule in\n * `cloud/firebase-firestore.rules` at all and is therefore denied to every\n * client by default, the same footing as `platform/pluginJobs`. It also keeps\n * the drain on an ordinary collection query: a collection-GROUP `where` would\n * need a `COLLECTION_GROUP_ASC` fieldOverride, and AGL-1793 is the record of\n * both commerce crons being silently dead in production for want of exactly\n * that. Neither a rules deploy nor an index deploy is owed for this change.\n *\n * **A delivered row is DELETED, not stamped `delivered`.** The body holds the\n * buyer's email and name, and a top-level document is outside the\n * `hosts/{hostId}` recursive delete an erasure request runs. Retaining the\n * happy path would accumulate third-party PII in a place erasure does not\n * reach, to record something the order's own `routed` timeline entry already\n * says. Dead-lettered rows survive, because those are the ones a human has to\n * act on and the merchant has been told about them.\n */\n\n/** Top-level collection. Matches no rule, so no client may read or write it. */\nexport const SUPPLIER_DELIVERY_COLLECTION = 'supplierDeliveries'\n\n/** Attempts before a delivery is dead-lettered. */\nexport const SUPPLIER_DELIVERY_MAX_ATTEMPTS = 6\n\n/**\n * Delay before the next retry, indexed by attempts ALREADY made.\n *\n * The first entry is 60s and the job beat is also 60s, so a supplier that\n * blipped is retried on the next tick rather than a quarter of an hour later —\n * the difference between a parcel that ships today and one that does not.\n */\nexport const SUPPLIER_DELIVERY_BACKOFF_MS: readonly number[] = [\n 60_000,\n 300_000,\n 900_000,\n 3_600_000,\n 21_600_000,\n]\n\n/**\n * Per-attempt timeout. Nothing user-facing waits on this — it exists so one\n * hung supplier cannot hold the shared job beat open for the whole scan.\n */\nexport const SUPPLIER_DELIVERY_TIMEOUT_MS = 10_000\n\n/** Rows examined per drain. Bounded like every other scan on the beat. */\nexport const SUPPLIER_DELIVERY_SCAN_LIMIT = 100\n\nexport type SupplierDeliveryStatus = 'pending' | 'failed'\n\nexport interface SupplierDeliveryRecord {\n status: SupplierDeliveryStatus\n hostId: string\n orderId: string\n supplierId: string\n supplierName: string\n /** The endpoint as configured when the order was routed, for the record. */\n url: string\n /** The exact JSON the supplier is sent; signed at delivery time. */\n body: string\n attempts: number\n nextAttemptAtMs: number\n createdAtMs: number\n lastError?: string\n lastStatus?: number\n failedAtMs?: number\n}\n\nexport interface SupplierDeliveryScanResult {\n scanned: number\n delivered: number\n retried: number\n deadLettered: number\n /** Retired without delivery because the merchant removed the endpoint. */\n cancelled: number\n /** Left pending, with the retry budget intact, because the site is locked. */\n skippedLocked: number\n}\n\n/** gRPC `Status.ALREADY_EXISTS` — what `create()` on a live path carries. */\nconst GRPC_ALREADY_EXISTS = 6\n\n/**\n * The delay owed after `attempts` failures, clamped to the last step.\n *\n * Clamped rather than extrapolated: the tail entry is already six hours, and a\n * dead letter is what comes after it, not a longer and longer silence.\n */\nexport function supplierDeliveryBackoffMs(attempts: number): number {\n const last = SUPPLIER_DELIVERY_BACKOFF_MS.length - 1\n return SUPPLIER_DELIVERY_BACKOFF_MS[\n Math.max(0, Math.min(attempts - 1, last))\n ]\n}\n\n/**\n * The outbox document id for one host/order/supplier triple.\n *\n * Deterministic, so `create()` is the idempotency. `__` rather than `:` or `/`:\n * a slash would make `.doc()` build a PATH instead of an id (AGL-1771), and the\n * separator has to survive a Stripe session id, which already contains `_`.\n * Firestore reserves ids fully matching `__.*__`; this never is, because all\n * three parts are non-empty and the id neither starts nor ends with `__`.\n */\nexport function supplierDeliveryId(\n hostId: string,\n orderId: string,\n supplierId: string,\n): string {\n return `${hostId}__${orderId}__${supplierId}`\n}\n\n/**\n * Records that a supplier POST is owed. One Firestore write, awaited.\n *\n * Returns `'queued'`, `'exists'` when a row for this triple is already in\n * flight, or `'skipped'`. NEVER THROWS: the caller is a paid order's fulfilment\n * path, and a queue that cannot be written must not take the receipt down with\n * it. The refusal is logged, which is strictly more than the old code managed\n * on the same failure.\n */\nexport async function enqueueSupplierDelivery(options: {\n firestore: FirebaseFirestore.Firestore\n hostId: string\n orderId: string\n supplierId: string\n supplierName: string\n url: string\n body: string\n now?: number\n}): Promise<'queued' | 'exists' | 'skipped'> {\n const { firestore, hostId, orderId, supplierId, supplierName, url, body } =\n options\n const now = options.now ?? Date.now()\n const id = supplierDeliveryId(hostId, orderId, supplierId)\n if (!isDocumentId(id)) {\n console.error('Supplier delivery not queued: unusable id', id)\n return 'skipped'\n }\n const record: SupplierDeliveryRecord = {\n status: 'pending',\n hostId,\n orderId,\n supplierId,\n supplierName,\n url,\n body,\n attempts: 0,\n // Due immediately: the next beat picks it up, at most a minute out.\n nextAttemptAtMs: now,\n createdAtMs: now,\n }\n try {\n await firestore\n .collection(SUPPLIER_DELIVERY_COLLECTION)\n .doc(id)\n .create(record as unknown as FirebaseFirestore.DocumentData)\n return 'queued'\n } catch (error) {\n if ((error as { code?: number })?.code === GRPC_ALREADY_EXISTS) {\n return 'exists'\n }\n console.error('Supplier delivery not queued', hostId, orderId, error)\n return 'skipped'\n }\n}\n\n/**\n * Stamps a routing failure on the order the merchant actually reads.\n *\n * `update()`, not a merge-set, for `recordRedemptionOrphan`'s reason: the order\n * exists — the delivery was queued from its own fulfilment — and a merge would\n * mint an order stub on the one path where it does not.\n */\nasync function stampOrderRoutingFailure(\n firestore: FirebaseFirestore.Firestore,\n hostId: string,\n orderId: string,\n detail: string,\n): Promise<void> {\n if (!isDocumentId(hostId) || !isDocumentId(orderId)) return\n await firestore\n .collection('hosts')\n .doc(hostId)\n .collection('orders')\n .doc(orderId)\n .update({\n timeline: firebaseAdmin.firestore.FieldValue.arrayUnion({\n atMs: Date.now(),\n event: 'routing-failed',\n detail,\n }),\n })\n .catch(() => undefined)\n}\n\ntype DeliveryOutcome = 'delivered' | 'retried' | 'dead-lettered' | 'cancelled'\n\n/**\n * One delivery attempt.\n *\n * Never throws: the drain is a shared beat, and one merchant's supplier must\n * not abort every other merchant's queue — the `process-restock.ts` lesson,\n * where a single poisoned row killed back-in-stock email platform-wide.\n */\nasync function attemptSupplierDelivery(\n firestore: FirebaseFirestore.Firestore,\n snapshot: FirebaseFirestore.QueryDocumentSnapshot,\n now: number,\n): Promise<DeliveryOutcome> {\n const data = snapshot.data() as SupplierDeliveryRecord\n const hostId = String(data.hostId ?? '')\n const attempts = Number(data.attempts ?? 0) + 1\n\n const retire = async (): Promise<'cancelled'> => {\n await snapshot.ref.delete().catch(() => undefined)\n return 'cancelled'\n }\n if (!isDocumentId(hostId) || !isDocumentId(String(data.supplierId ?? ''))) {\n return retire()\n }\n\n // The endpoint and the secret are read FRESH rather than frozen into the row:\n // a merchant correcting a typo'd URL corrects what is already queued, and the\n // supplier's shared secret is never copied into a second document.\n const supplierSnapshot = await firestore\n .collection('hosts')\n .doc(hostId)\n .collection('suppliers')\n .doc(String(data.supplierId))\n .get()\n .catch(() => null)\n // A read that FAILED is not a supplier that is gone. Retrying beats retiring\n // a real delivery on a transient Firestore error — the `reconcile-stock.ts`\n // rule that a failed read is not an absent marker.\n if (!supplierSnapshot) {\n return recordFailure(\n firestore,\n snapshot,\n data,\n attempts,\n now,\n 'could not read the supplier record',\n undefined,\n )\n }\n const url = String(supplierSnapshot.get('webhookUrl') ?? '')\n if (!url) {\n // Deliberately NOT a dead letter: the merchant removed the endpoint (or the\n // supplier), which is a decision rather than an outage. Alarming them about\n // a delivery they cancelled is how an alert stops being read.\n return retire()\n }\n const secret = String(supplierSnapshot.get('webhookSecret') ?? '')\n const body = String(data.body ?? '')\n\n let status: number\n try {\n // A merchant-typed address, called from the platform's servers: https\n // only, public addresses only (pinned), never a lookalike, never a\n // followed redirect (AGL-3363). A bare `fetch` here reached any address a\n // merchant typed, the cloud metadata endpoint included.\n const called = await fetchConfiguredPublicUrl(url, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n // NO EMPTY-KEY HMAC (AGL-2455). A signature computed under `''` is one\n // any party can compute, and a supplier who also left their secret\n // unset would verify it and believe the delivery was authenticated. An\n // absent header fails their check closed; a forgeable one passes it.\n ...(secret\n ? {\n 'x-aglyn-signature': createHmac('sha256', secret)\n .update(body)\n .digest('hex'),\n }\n : {}),\n },\n body,\n signal: AbortSignal.timeout(SUPPLIER_DELIVERY_TIMEOUT_MS),\n })\n if (called.ok === false) {\n if (called.refusal === 'lookalike' && called.host) {\n await flagLookalikeDomain({\n kind: 'link',\n hostId,\n orgId: null,\n domain: called.host,\n where: 'a supplier order webhook',\n })\n }\n return recordFailure(\n firestore,\n snapshot,\n data,\n attempts,\n now,\n `the supplier's webhook address was not called: ${describeConfiguredUrlRefusal(called.refusal)}`,\n undefined,\n )\n }\n status = called.status\n if (!(status >= 200 && status < 300)) {\n return recordFailure(\n firestore,\n snapshot,\n data,\n attempts,\n now,\n `supplier answered HTTP ${status}`,\n status,\n )\n }\n } catch (error) {\n return recordFailure(\n firestore,\n snapshot,\n data,\n attempts,\n now,\n String((error as Error)?.message ?? error),\n undefined,\n )\n }\n\n // Delivered rows are DELETED rather than stamped — see the module note: the\n // body holds the buyer's email and name, and a top-level document is outside\n // the recursive delete an erasure request runs over `hosts/{hostId}`.\n await snapshot.ref.delete().catch(() => undefined)\n return 'delivered'\n}\n\n/** Books one failure: another retry, or the dead letter and its two alarms. */\nasync function recordFailure(\n firestore: FirebaseFirestore.Firestore,\n snapshot: FirebaseFirestore.QueryDocumentSnapshot,\n data: SupplierDeliveryRecord,\n attempts: number,\n now: number,\n reason: string,\n status: number | undefined,\n): Promise<'retried' | 'dead-lettered'> {\n const exhausted = attempts >= SUPPLIER_DELIVERY_MAX_ATTEMPTS\n await snapshot.ref\n .update({\n status: exhausted ? 'failed' : 'pending',\n attempts,\n lastError: reason,\n ...(status ? { lastStatus: status } : {}),\n ...(exhausted\n ? { failedAtMs: now }\n : { nextAttemptAtMs: now + supplierDeliveryBackoffMs(attempts) }),\n })\n .catch(() => undefined)\n if (!exhausted) return 'retried'\n\n const hostId = String(data.hostId ?? '')\n const orderId = String(data.orderId ?? '')\n const supplierName = String(data.supplierName ?? '') || 'the supplier'\n await stampOrderRoutingFailure(\n firestore,\n hostId,\n orderId,\n `Could not reach ${supplierName} after ${attempts} attempts ` +\n `(${reason}). This order was never routed.`,\n )\n // The bell as well as the timeline: nobody opens an order they have no\n // reason to suspect.\n await notifyHostManagers(hostId, {\n type: 'content.order',\n title: `Order not routed to ${supplierName}`,\n body: `Order ${orderId} could not be sent after ${attempts} attempts.`,\n link: `/${hostId}/products`,\n }).catch(() => undefined)\n return 'dead-lettered'\n}\n\n/**\n * Drains the supplier outbox. Bounded, and safe to run twice.\n *\n * `nextAttemptAtMs` is filtered IN MEMORY rather than added to the query, which\n * would need a composite index for one field's worth of selectivity. The cost\n * is that a row still inside its backoff occupies one of the\n * {@link SUPPLIER_DELIVERY_SCAN_LIMIT} slots; with at most\n * {@link SUPPLIER_DELIVERY_MAX_ATTEMPTS} attempts per row the queue always\n * drains rather than growing a permanent head.\n */\nexport async function scanSupplierDeliveries(\n /** The lockdown gate, injected by the caller (AGL-2495). Not optional. */\n gate: PluginJobHostGate,\n now: number = Date.now(),\n): Promise<SupplierDeliveryScanResult> {\n const firestore = firebaseAdmin.app().firestore()\n const pending = await firestore\n .collection(SUPPLIER_DELIVERY_COLLECTION)\n .where('status', '==', 'pending')\n .limit(SUPPLIER_DELIVERY_SCAN_LIMIT)\n .get()\n const result: SupplierDeliveryScanResult = {\n scanned: pending.size,\n delivered: 0,\n retried: 0,\n deadLettered: 0,\n cancelled: 0,\n skippedLocked: 0,\n }\n for (const snapshot of pending.docs) {\n const data = snapshot.data() as SupplierDeliveryRecord\n // LOCKDOWN (AGL-2495). The row is a TOP-LEVEL document carrying its own\n // `hostId` rather than living under `hosts/{hostId}`, so the host is read\n // from the record instead of the parent chain — the one job of the six\n // whose subject is not in its path.\n //\n // An outbound POST to the merchant's supplier is this platform acting in\n // a suspended merchant's name against a third party, which is precisely\n // what a takedown is for. The attempt also mutates the row (`attempts`,\n // `nextAttemptAtMs`, or the dead-letter) and can notify the host's\n // managers.\n //\n // SKIPPED, NOT DROPPED, and the strongest case of the six: the row stays\n // `pending` with its `attempts` count untouched, so a long lock cannot\n // burn a paid order's retry budget and dead-letter it for being locked.\n // The queue drains from where it stood the minute the lock lifts.\n if (await gate.isLocked(String(data?.hostId ?? ''))) {\n result.skippedLocked += 1\n continue\n }\n if (Number(data?.nextAttemptAtMs ?? 0) > now) continue\n const outcome = await attemptSupplierDelivery(\n firestore,\n snapshot,\n now,\n ).catch((error) => {\n console.error(\n 'Supplier delivery attempt failed',\n snapshot.ref.path,\n error,\n )\n return null\n })\n if (outcome === 'delivered') result.delivered += 1\n else if (outcome === 'retried') result.retried += 1\n else if (outcome === 'dead-lettered') result.deadLettered += 1\n else if (outcome === 'cancelled') result.cancelled += 1\n }\n return result\n}\n"],"names":["describeConfiguredUrlRefusal","fetchConfiguredPublicUrl","firebaseAdmin","flagLookalikeDomain","notifyHostManagers","isDocumentId","createHmac","SUPPLIER_DELIVERY_COLLECTION","SUPPLIER_DELIVERY_MAX_ATTEMPTS","SUPPLIER_DELIVERY_BACKOFF_MS","SUPPLIER_DELIVERY_TIMEOUT_MS","SUPPLIER_DELIVERY_SCAN_LIMIT","GRPC_ALREADY_EXISTS","supplierDeliveryBackoffMs","attempts","last","length","Math","max","min","supplierDeliveryId","hostId","orderId","supplierId","enqueueSupplierDelivery","options","firestore","supplierName","url","body","now","Date","id","console","error","record","status","nextAttemptAtMs","createdAtMs","collection","doc","create","code","stampOrderRoutingFailure","detail","update","timeline","FieldValue","arrayUnion","atMs","event","catch","undefined","attemptSupplierDelivery","snapshot","data","supplierSnapshot","String","Number","retire","ref","delete","get","recordFailure","secret","called","method","headers","digest","signal","AbortSignal","timeout","ok","refusal","host","kind","orgId","domain","where","message","reason","exhausted","lastError","lastStatus","failedAtMs","type","title","link","scanSupplierDeliveries","gate","app","pending","limit","result","scanned","size","delivered","retried","deadLettered","cancelled","skippedLocked","docs","isLocked","outcome","path"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/supplier-outbox.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { type PluginJobHostGate } from '@aglyn/aglyn/server'\nimport {\n describeConfiguredUrlRefusal,\n fetchConfiguredPublicUrl,\n firebaseAdmin,\n flagLookalikeDomain,\n notifyHostManagers,\n} from '@aglyn/tenant-data-admin'\nimport { isDocumentId } from '@aglyn/tenant-data-admin/server/document-id'\nimport { formatOrderNumber } from '../model/commerce-orders'\nimport { createHmac } from 'crypto'\n\n/**\n * The durable outbox for the dropship supplier notification (AGL-2473).\n *\n * ## What was wrong\n *\n * `billing-webhook.ts` routed a paid dropship order inside\n * `void (async () => { … })()`, and the last thing that block did was\n * `await fetch(supplier.webhookUrl, …)` — an endpoint the MERCHANT typed into\n * a console field, on a host Aglyn neither runs nor monitors. Vercel freezes\n * the container the instant the response is written, so a supplier that is\n * slow, rate-limiting, or simply down is a supplier that is never told. The\n * order is paid, the buyer is thanked, the merchant sees \"Sent to supplier X\"\n * on the timeline, and NOTHING ANYWHERE records that the POST is still owed —\n * so no later pass can find it and no report can list it. It surfaces when the\n * customer asks where their parcel is.\n *\n * ## Why not just `await` it\n *\n * AGL-2161 fixed the other four fire-and-forget sites in that file and\n * deliberately left this one, because awaiting it is worse rather than better:\n * a supplier taking 30s pushes the handler past Stripe's delivery window,\n * Stripe redelivers, and the redelivery re-enters a fan-out whose only\n * idempotency is one `created` flag upstream. A silently dropped notification\n * would have been traded for a DUPLICATED order. `refund.ts` awaits its own\n * sibling and says so in a comment — correctly, because that sibling only\n * touches our own Firestore.\n *\n * So the line this module draws is not \"fast vs slow\" but **ours vs theirs**.\n * Everything the routing block does to Aglyn's own storage — the callback\n * token, the timeline entry, the supplier email through our own provider — is\n * now AWAITED on the response path, exactly like `refund.ts`. The one call to a\n * stranger's server becomes a row here, and the response no longer waits on it.\n *\n * ## The mechanism\n *\n * A Firestore-backed outbox drained by the plugin-job beat that already runs\n * the two commerce recovery passes (`server.ts`). No new infrastructure: the\n * beat is a Cloud Scheduler function POSTing the tenant runner route, and it\n * has carried bounded scans since AGL-2227.\n *\n * * **Idempotent enqueue.** The id is `{hostId}__{orderId}__{supplierId}` and\n * the write is `create()`, which REFUSES an existing document, so a\n * redelivery cannot reset a retry part-way through its backoff. The AGL-498\n * existence guard upstream already returns before this fan-out, but\n * `reconcile-stock.ts` documents at length why one flag over a dozen effects\n * is not something to lean on.\n * * **Backoff.** 1m, 5m, 15m, 1h, 6h — about seven and a half hours of cover,\n * which is a supplier's overnight deploy but not their week off.\n * * **Dead letter.** After {@link SUPPLIER_DELIVERY_MAX_ATTEMPTS} the row is\n * left as `failed`, the ORDER's own timeline is stamped, and host managers\n * get a notification. The merchant reading the order they were paid for can\n * see that it never reached the supplier — the thing the old code could not\n * tell anyone, because it wrote nothing down.\n *\n * ## Two placement decisions, both deliberate\n *\n * **TOP-LEVEL, not `hosts/{hostId}/supplierDeliveries`.** The host catch-all in\n * the rules file is PERMISSIVE by default — a subcollection nobody names in its\n * three exclusion lists is one every site editor can write from the browser —\n * and the row carries the exact JSON body we sign and POST, so an editor-\n * writable row is an editor-chosen payload delivered under the supplier's own\n * shared secret. A top-level collection matches no rule in\n * `cloud/firebase-firestore.rules` at all and is therefore denied to every\n * client by default, the same footing as `platform/pluginJobs`. It also keeps\n * the drain on an ordinary collection query: a collection-GROUP `where` would\n * need a `COLLECTION_GROUP_ASC` fieldOverride, and AGL-1793 is the record of\n * both commerce crons being silently dead in production for want of exactly\n * that. Neither a rules deploy nor an index deploy is owed for this change.\n *\n * **A delivered row is DELETED, not stamped `delivered`.** The body holds the\n * buyer's email and name, and a top-level document is outside the\n * `hosts/{hostId}` recursive delete an erasure request runs. Retaining the\n * happy path would accumulate third-party PII in a place erasure does not\n * reach, to record something the order's own `routed` timeline entry already\n * says. Dead-lettered rows survive, because those are the ones a human has to\n * act on and the merchant has been told about them.\n */\n\n/** Top-level collection. Matches no rule, so no client may read or write it. */\nexport const SUPPLIER_DELIVERY_COLLECTION = 'supplierDeliveries'\n\n/** Attempts before a delivery is dead-lettered. */\nexport const SUPPLIER_DELIVERY_MAX_ATTEMPTS = 6\n\n/**\n * Delay before the next retry, indexed by attempts ALREADY made.\n *\n * The first entry is 60s and the job beat is also 60s, so a supplier that\n * blipped is retried on the next tick rather than a quarter of an hour later —\n * the difference between a parcel that ships today and one that does not.\n */\nexport const SUPPLIER_DELIVERY_BACKOFF_MS: readonly number[] = [\n 60_000,\n 300_000,\n 900_000,\n 3_600_000,\n 21_600_000,\n]\n\n/**\n * Per-attempt timeout. Nothing user-facing waits on this — it exists so one\n * hung supplier cannot hold the shared job beat open for the whole scan.\n */\nexport const SUPPLIER_DELIVERY_TIMEOUT_MS = 10_000\n\n/** Rows examined per drain. Bounded like every other scan on the beat. */\nexport const SUPPLIER_DELIVERY_SCAN_LIMIT = 100\n\nexport type SupplierDeliveryStatus = 'pending' | 'failed'\n\nexport interface SupplierDeliveryRecord {\n status: SupplierDeliveryStatus\n hostId: string\n orderId: string\n supplierId: string\n supplierName: string\n /** The endpoint as configured when the order was routed, for the record. */\n url: string\n /** The exact JSON the supplier is sent; signed at delivery time. */\n body: string\n attempts: number\n nextAttemptAtMs: number\n createdAtMs: number\n lastError?: string\n lastStatus?: number\n failedAtMs?: number\n}\n\nexport interface SupplierDeliveryScanResult {\n scanned: number\n delivered: number\n retried: number\n deadLettered: number\n /** Retired without delivery because the merchant removed the endpoint. */\n cancelled: number\n /** Left pending, with the retry budget intact, because the site is locked. */\n skippedLocked: number\n}\n\n/** gRPC `Status.ALREADY_EXISTS` — what `create()` on a live path carries. */\nconst GRPC_ALREADY_EXISTS = 6\n\n/**\n * The delay owed after `attempts` failures, clamped to the last step.\n *\n * Clamped rather than extrapolated: the tail entry is already six hours, and a\n * dead letter is what comes after it, not a longer and longer silence.\n */\nexport function supplierDeliveryBackoffMs(attempts: number): number {\n const last = SUPPLIER_DELIVERY_BACKOFF_MS.length - 1\n return SUPPLIER_DELIVERY_BACKOFF_MS[\n Math.max(0, Math.min(attempts - 1, last))\n ]\n}\n\n/**\n * The outbox document id for one host/order/supplier triple.\n *\n * Deterministic, so `create()` is the idempotency. `__` rather than `:` or `/`:\n * a slash would make `.doc()` build a PATH instead of an id (AGL-1771), and the\n * separator has to survive a Stripe session id, which already contains `_`.\n * Firestore reserves ids fully matching `__.*__`; this never is, because all\n * three parts are non-empty and the id neither starts nor ends with `__`.\n */\nexport function supplierDeliveryId(\n hostId: string,\n orderId: string,\n supplierId: string,\n): string {\n return `${hostId}__${orderId}__${supplierId}`\n}\n\n/**\n * Records that a supplier POST is owed. One Firestore write, awaited.\n *\n * Returns `'queued'`, `'exists'` when a row for this triple is already in\n * flight, or `'skipped'`. NEVER THROWS: the caller is a paid order's fulfilment\n * path, and a queue that cannot be written must not take the receipt down with\n * it. The refusal is logged, which is strictly more than the old code managed\n * on the same failure.\n */\nexport async function enqueueSupplierDelivery(options: {\n firestore: FirebaseFirestore.Firestore\n hostId: string\n orderId: string\n supplierId: string\n supplierName: string\n url: string\n body: string\n now?: number\n}): Promise<'queued' | 'exists' | 'skipped'> {\n const { firestore, hostId, orderId, supplierId, supplierName, url, body } =\n options\n const now = options.now ?? Date.now()\n const id = supplierDeliveryId(hostId, orderId, supplierId)\n if (!isDocumentId(id)) {\n console.error('Supplier delivery not queued: unusable id', id)\n return 'skipped'\n }\n const record: SupplierDeliveryRecord = {\n status: 'pending',\n hostId,\n orderId,\n supplierId,\n supplierName,\n url,\n body,\n attempts: 0,\n // Due immediately: the next beat picks it up, at most a minute out.\n nextAttemptAtMs: now,\n createdAtMs: now,\n }\n try {\n await firestore\n .collection(SUPPLIER_DELIVERY_COLLECTION)\n .doc(id)\n .create(record as unknown as FirebaseFirestore.DocumentData)\n return 'queued'\n } catch (error) {\n if ((error as { code?: number })?.code === GRPC_ALREADY_EXISTS) {\n return 'exists'\n }\n console.error('Supplier delivery not queued', hostId, orderId, error)\n return 'skipped'\n }\n}\n\n/**\n * Stamps a routing failure on the order the merchant actually reads.\n *\n * `update()`, not a merge-set, for `recordRedemptionOrphan`'s reason: the order\n * exists — the delivery was queued from its own fulfilment — and a merge would\n * mint an order stub on the one path where it does not.\n */\nasync function stampOrderRoutingFailure(\n firestore: FirebaseFirestore.Firestore,\n hostId: string,\n orderId: string,\n detail: string,\n): Promise<void> {\n if (!isDocumentId(hostId) || !isDocumentId(orderId)) return\n await firestore\n .collection('hosts')\n .doc(hostId)\n .collection('orders')\n .doc(orderId)\n .update({\n timeline: firebaseAdmin.firestore.FieldValue.arrayUnion({\n atMs: Date.now(),\n event: 'routing-failed',\n detail,\n }),\n })\n .catch(() => undefined)\n}\n\n/**\n * The number the merchant knows an order by (\"#1042\"), for a notice about it\n * (AGL-3432). The queued row carries only the document id, which is the Stripe\n * session id and appears nowhere in the console. A read that fails falls back\n * to the id, so the notice still goes out.\n */\nasync function orderLabelFor(\n firestore: FirebaseFirestore.Firestore,\n hostId: string,\n orderId: string,\n): Promise<string> {\n if (!isDocumentId(hostId) || !isDocumentId(orderId)) return orderId\n const number = await firestore\n .collection('hosts')\n .doc(hostId)\n .collection('orders')\n .doc(orderId)\n .get()\n .then((snapshot) => snapshot.get('number') as number | undefined)\n .catch(() => undefined)\n return formatOrderNumber({ number: number ?? undefined }, orderId)\n}\n\ntype DeliveryOutcome = 'delivered' | 'retried' | 'dead-lettered' | 'cancelled'\n\n/**\n * One delivery attempt.\n *\n * Never throws: the drain is a shared beat, and one merchant's supplier must\n * not abort every other merchant's queue — the `process-restock.ts` lesson,\n * where a single poisoned row killed back-in-stock email platform-wide.\n */\nasync function attemptSupplierDelivery(\n firestore: FirebaseFirestore.Firestore,\n snapshot: FirebaseFirestore.QueryDocumentSnapshot,\n now: number,\n): Promise<DeliveryOutcome> {\n const data = snapshot.data() as SupplierDeliveryRecord\n const hostId = String(data.hostId ?? '')\n const attempts = Number(data.attempts ?? 0) + 1\n\n const retire = async (): Promise<'cancelled'> => {\n await snapshot.ref.delete().catch(() => undefined)\n return 'cancelled'\n }\n if (!isDocumentId(hostId) || !isDocumentId(String(data.supplierId ?? ''))) {\n return retire()\n }\n\n // The endpoint and the secret are read FRESH rather than frozen into the row:\n // a merchant correcting a typo'd URL corrects what is already queued, and the\n // supplier's shared secret is never copied into a second document.\n const supplierSnapshot = await firestore\n .collection('hosts')\n .doc(hostId)\n .collection('suppliers')\n .doc(String(data.supplierId))\n .get()\n .catch(() => null)\n // A read that FAILED is not a supplier that is gone. Retrying beats retiring\n // a real delivery on a transient Firestore error — the `reconcile-stock.ts`\n // rule that a failed read is not an absent marker.\n if (!supplierSnapshot) {\n return recordFailure(\n firestore,\n snapshot,\n data,\n attempts,\n now,\n 'could not read the supplier record',\n undefined,\n )\n }\n const url = String(supplierSnapshot.get('webhookUrl') ?? '')\n if (!url) {\n // Deliberately NOT a dead letter: the merchant removed the endpoint (or the\n // supplier), which is a decision rather than an outage. Alarming them about\n // a delivery they cancelled is how an alert stops being read.\n return retire()\n }\n const secret = String(supplierSnapshot.get('webhookSecret') ?? '')\n const body = String(data.body ?? '')\n\n let status: number\n try {\n // A merchant-typed address, called from the platform's servers: https\n // only, public addresses only (pinned), never a lookalike, never a\n // followed redirect (AGL-3363). A bare `fetch` here reached any address a\n // merchant typed, the cloud metadata endpoint included.\n const called = await fetchConfiguredPublicUrl(url, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n // NO EMPTY-KEY HMAC (AGL-2455). A signature computed under `''` is one\n // any party can compute, and a supplier who also left their secret\n // unset would verify it and believe the delivery was authenticated. An\n // absent header fails their check closed; a forgeable one passes it.\n ...(secret\n ? {\n 'x-aglyn-signature': createHmac('sha256', secret)\n .update(body)\n .digest('hex'),\n }\n : {}),\n },\n body,\n signal: AbortSignal.timeout(SUPPLIER_DELIVERY_TIMEOUT_MS),\n })\n if (called.ok === false) {\n if (called.refusal === 'lookalike' && called.host) {\n await flagLookalikeDomain({\n kind: 'link',\n hostId,\n orgId: null,\n domain: called.host,\n where: 'a supplier order webhook',\n })\n }\n return recordFailure(\n firestore,\n snapshot,\n data,\n attempts,\n now,\n `the supplier's webhook address was not called: ${describeConfiguredUrlRefusal(called.refusal)}`,\n undefined,\n )\n }\n status = called.status\n if (!(status >= 200 && status < 300)) {\n return recordFailure(\n firestore,\n snapshot,\n data,\n attempts,\n now,\n `supplier answered HTTP ${status}`,\n status,\n )\n }\n } catch (error) {\n return recordFailure(\n firestore,\n snapshot,\n data,\n attempts,\n now,\n String((error as Error)?.message ?? error),\n undefined,\n )\n }\n\n // Delivered rows are DELETED rather than stamped — see the module note: the\n // body holds the buyer's email and name, and a top-level document is outside\n // the recursive delete an erasure request runs over `hosts/{hostId}`.\n await snapshot.ref.delete().catch(() => undefined)\n return 'delivered'\n}\n\n/** Books one failure: another retry, or the dead letter and its two alarms. */\nasync function recordFailure(\n firestore: FirebaseFirestore.Firestore,\n snapshot: FirebaseFirestore.QueryDocumentSnapshot,\n data: SupplierDeliveryRecord,\n attempts: number,\n now: number,\n reason: string,\n status: number | undefined,\n): Promise<'retried' | 'dead-lettered'> {\n const exhausted = attempts >= SUPPLIER_DELIVERY_MAX_ATTEMPTS\n await snapshot.ref\n .update({\n status: exhausted ? 'failed' : 'pending',\n attempts,\n lastError: reason,\n ...(status ? { lastStatus: status } : {}),\n ...(exhausted\n ? { failedAtMs: now }\n : { nextAttemptAtMs: now + supplierDeliveryBackoffMs(attempts) }),\n })\n .catch(() => undefined)\n if (!exhausted) return 'retried'\n\n const hostId = String(data.hostId ?? '')\n const orderId = String(data.orderId ?? '')\n const supplierName = String(data.supplierName ?? '') || 'the supplier'\n await stampOrderRoutingFailure(\n firestore,\n hostId,\n orderId,\n `Could not reach ${supplierName} after ${attempts} attempts ` +\n `(${reason}). This order was never routed.`,\n )\n // The bell as well as the timeline: nobody opens an order they have no\n // reason to suspect.\n // The consequence and the action (AGL-3432): the supplier has not been\n // asked to ship this order, so the merchant has to.\n await notifyHostManagers(hostId, {\n type: 'content.order',\n title: `Order not routed to ${supplierName}`,\n body:\n `Order ${await orderLabelFor(firestore, hostId, orderId)} on {site} ` +\n `never reached ${supplierName}: ${attempts} delivery attempts failed ` +\n `(${reason}). They have not been asked to ship it; send it to them ` +\n 'yourself or fulfill it by hand.',\n link: `/${hostId}/products`,\n }).catch(() => undefined)\n return 'dead-lettered'\n}\n\n/**\n * Drains the supplier outbox. Bounded, and safe to run twice.\n *\n * `nextAttemptAtMs` is filtered IN MEMORY rather than added to the query, which\n * would need a composite index for one field's worth of selectivity. The cost\n * is that a row still inside its backoff occupies one of the\n * {@link SUPPLIER_DELIVERY_SCAN_LIMIT} slots; with at most\n * {@link SUPPLIER_DELIVERY_MAX_ATTEMPTS} attempts per row the queue always\n * drains rather than growing a permanent head.\n */\nexport async function scanSupplierDeliveries(\n /** The lockdown gate, injected by the caller (AGL-2495). Not optional. */\n gate: PluginJobHostGate,\n now: number = Date.now(),\n): Promise<SupplierDeliveryScanResult> {\n const firestore = firebaseAdmin.app().firestore()\n const pending = await firestore\n .collection(SUPPLIER_DELIVERY_COLLECTION)\n .where('status', '==', 'pending')\n .limit(SUPPLIER_DELIVERY_SCAN_LIMIT)\n .get()\n const result: SupplierDeliveryScanResult = {\n scanned: pending.size,\n delivered: 0,\n retried: 0,\n deadLettered: 0,\n cancelled: 0,\n skippedLocked: 0,\n }\n for (const snapshot of pending.docs) {\n const data = snapshot.data() as SupplierDeliveryRecord\n // LOCKDOWN (AGL-2495). The row is a TOP-LEVEL document carrying its own\n // `hostId` rather than living under `hosts/{hostId}`, so the host is read\n // from the record instead of the parent chain — the one job of the six\n // whose subject is not in its path.\n //\n // An outbound POST to the merchant's supplier is this platform acting in\n // a suspended merchant's name against a third party, which is precisely\n // what a takedown is for. The attempt also mutates the row (`attempts`,\n // `nextAttemptAtMs`, or the dead-letter) and can notify the host's\n // managers.\n //\n // SKIPPED, NOT DROPPED, and the strongest case of the six: the row stays\n // `pending` with its `attempts` count untouched, so a long lock cannot\n // burn a paid order's retry budget and dead-letter it for being locked.\n // The queue drains from where it stood the minute the lock lifts.\n if (await gate.isLocked(String(data?.hostId ?? ''))) {\n result.skippedLocked += 1\n continue\n }\n if (Number(data?.nextAttemptAtMs ?? 0) > now) continue\n const outcome = await attemptSupplierDelivery(\n firestore,\n snapshot,\n now,\n ).catch((error) => {\n console.error(\n 'Supplier delivery attempt failed',\n snapshot.ref.path,\n error,\n )\n return null\n })\n if (outcome === 'delivered') result.delivered += 1\n else if (outcome === 'retried') result.retried += 1\n else if (outcome === 'dead-lettered') result.deadLettered += 1\n else if (outcome === 'cancelled') result.cancelled += 1\n }\n return result\n}\n"],"names":["describeConfiguredUrlRefusal","fetchConfiguredPublicUrl","firebaseAdmin","flagLookalikeDomain","notifyHostManagers","isDocumentId","formatOrderNumber","createHmac","SUPPLIER_DELIVERY_COLLECTION","SUPPLIER_DELIVERY_MAX_ATTEMPTS","SUPPLIER_DELIVERY_BACKOFF_MS","SUPPLIER_DELIVERY_TIMEOUT_MS","SUPPLIER_DELIVERY_SCAN_LIMIT","GRPC_ALREADY_EXISTS","supplierDeliveryBackoffMs","attempts","last","length","Math","max","min","supplierDeliveryId","hostId","orderId","supplierId","enqueueSupplierDelivery","options","firestore","supplierName","url","body","now","Date","id","console","error","record","status","nextAttemptAtMs","createdAtMs","collection","doc","create","code","stampOrderRoutingFailure","detail","update","timeline","FieldValue","arrayUnion","atMs","event","catch","undefined","orderLabelFor","number","get","then","snapshot","attemptSupplierDelivery","data","supplierSnapshot","String","Number","retire","ref","delete","recordFailure","secret","called","method","headers","digest","signal","AbortSignal","timeout","ok","refusal","host","kind","orgId","domain","where","message","reason","exhausted","lastError","lastStatus","failedAtMs","type","title","link","scanSupplierDeliveries","gate","app","pending","limit","result","scanned","size","delivered","retried","deadLettered","cancelled","skippedLocked","docs","isLocked","outcome","path"],"mappings":"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"}
@@ -106,7 +106,7 @@ import { tokenSigningSecret } from "./download.js";
106
106
  });
107
107
  }
108
108
  try {
109
- var _outcome_remaining;
109
+ var _outcome_remaining, _ref;
110
110
  const firestore = firebaseAdmin.app().firestore();
111
111
  const orderRef = firestore.collection('hosts').doc(hostId).collection('orders').doc(orderId);
112
112
  // A GET NEVER MOVES THE ORDER (AGL-2268). The supplier email carries this
@@ -120,7 +120,11 @@ import { tokenSigningSecret } from "./download.js";
120
120
  if (req.method === 'GET') {
121
121
  res.setHeader('Content-Type', 'text/html');
122
122
  const field = (name, value)=>`<input type="hidden" name="${name}" value="${escapeHtml(value)}">`;
123
- return res.status(200).send('<h3>Confirm this shipment</h3>' + '<p>Press the button to record it against the order.</p>' + '<form method="POST">' + field('hostId', hostId) + field('orderId', orderId) + field('token', token) + field('carrier', carrier) + field('trackingNumber', trackingNumber) + '<button type="submit">Mark as shipped</button>' + '</form>');
123
+ // The carrier and the number are FIELDS the supplier fills in
124
+ // (AGL-3432), so the emailed link needs no placeholders for them to
125
+ // edit in the URL. A value a link does carry arrives filled in.
126
+ const input = (name, label, value)=>`<p><label>${label}<br>` + `<input type="text" name="${name}" value="${escapeHtml(value)}"></label></p>`;
127
+ return res.status(200).send('<h3>Record this shipment</h3>' + '<p>Enter the carrier and tracking number, then press the button ' + 'to record it against the order.</p>' + '<form method="POST">' + field('hostId', hostId) + field('orderId', orderId) + field('token', token) + input('carrier', 'Carrier', carrier) + input('trackingNumber', 'Tracking number', trackingNumber) + '<button type="submit">Mark as shipped</button>' + '</form>');
124
128
  }
125
129
  // THE READ, THE TRANSITION CHECK AND THE WRITE ARE ONE ACT (AGL-2268).
126
130
  // They used to be a plain `get()`, a check, and a `set(…, {merge:true})`,
@@ -258,7 +262,8 @@ import { tokenSigningSecret } from "./download.js";
258
262
  },
259
263
  order,
260
264
  nextStatus,
261
- remaining
265
+ remaining,
266
+ supplierId: postingSupplier
262
267
  };
263
268
  });
264
269
  if (outcome.status !== 200) {
@@ -269,14 +274,19 @@ import { tokenSigningSecret } from "./download.js";
269
274
  // as done, which is the same misreport the status literal made — the
270
275
  // merchant would stop watching an order that is two thirds unshipped.
271
276
  const outstanding = Number((_outcome_remaining = outcome.remaining) != null ? _outcome_remaining : 0);
272
- void notifyHostManagers(hostId, _extends({
277
+ const orderLabel = CommerceModel.formatOrderNumber(outcome.order, orderId);
278
+ // Which supplier, which order, where, and the tracking (AGL-3432).
279
+ const supplierId = outcome.supplierId;
280
+ const supplierName = supplierId ? String((_ref = await firestore.collection('hosts').doc(hostId).collection('suppliers').doc(supplierId).get().then((snapshot)=>snapshot.get('name')).catch(()=>'')) != null ? _ref : '').trim() : '';
281
+ void notifyHostManagers(hostId, {
273
282
  type: 'content.order',
274
- title: `Supplier shipped ${CommerceModel.formatOrderNumber(outcome.order, orderId)}${outstanding > 0 ? ` — ${outstanding} line${outstanding === 1 ? '' : 's'} still to ship` : ''}`
275
- }, trackingNumber ? {
276
- body: `${carrier} ${trackingNumber}`
277
- } : {}, {
283
+ title: `Supplier shipped ${orderLabel}${outstanding > 0 ? ` — ${outstanding} line${outstanding === 1 ? '' : 's'} still to ship` : ''}`,
284
+ body: `${supplierName || 'Your supplier'} shipped their part of order ` + `${orderLabel} on {site}.` + (trackingNumber ? ` Tracking: ${[
285
+ carrier,
286
+ trackingNumber
287
+ ].filter(Boolean).join(' ')}.` : ' No tracking number was given.') + (outstanding > 0 ? ` ${outstanding} line${outstanding === 1 ? ' is' : 's are'} still to ship.` : ''),
278
288
  link: `/${hostId}/products`
279
- }));
289
+ });
280
290
  return res.status(200).json(outcome.body);
281
291
  } catch (error) {
282
292
  console.error(error);