@aglyn/plugins-commerce 1.0.0-beta.217 → 1.0.0-beta.219

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 (100) hide show
  1. package/README.md +1 -0
  2. package/package.json +15 -15
  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 +44 -18
  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 +1 -1
  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 +114 -63
  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/store-settings-card.component.js +2 -2
  29. package/src/lib/components/console/store-settings-card.component.js.map +1 -1
  30. package/src/lib/components/console/storefront-tax-summary-card.component.js +1 -1
  31. package/src/lib/components/console/storefront-tax-summary-card.component.js.map +1 -1
  32. package/src/lib/components/product-detail.js +2 -1
  33. package/src/lib/components/product-detail.js.map +1 -1
  34. package/src/lib/components/product-grid.js +1 -1
  35. package/src/lib/components/product-grid.js.map +1 -1
  36. package/src/lib/components/storefront-payment-element.js +36 -5
  37. package/src/lib/components/storefront-payment-element.js.map +1 -1
  38. package/src/lib/constants/storefront-payments.d.ts +33 -0
  39. package/src/lib/constants/storefront-payments.js +35 -0
  40. package/src/lib/constants/storefront-payments.js.map +1 -0
  41. package/src/lib/declarations.server.d.ts +3 -1
  42. package/src/lib/declarations.server.js +19 -1
  43. package/src/lib/declarations.server.js.map +1 -1
  44. package/src/lib/model/commerce-record-routes.d.ts +27 -0
  45. package/src/lib/model/commerce-record-routes.js +72 -0
  46. package/src/lib/model/commerce-record-routes.js.map +1 -0
  47. package/src/lib/model/commerce-search-sources.d.ts +21 -0
  48. package/src/lib/model/commerce-search-sources.js +40 -0
  49. package/src/lib/model/commerce-search-sources.js.map +1 -0
  50. package/src/lib/model/storefront-tax-summary.d.ts +121 -0
  51. package/src/lib/model/storefront-tax-summary.js +148 -0
  52. package/src/lib/model/storefront-tax-summary.js.map +1 -0
  53. package/src/lib/plan-entitlements.d.ts +35 -0
  54. package/src/lib/plan-entitlements.js +54 -0
  55. package/src/lib/plan-entitlements.js.map +1 -0
  56. package/src/lib/plugin.js +9 -0
  57. package/src/lib/plugin.js.map +1 -1
  58. package/src/lib/server/billing-webhook.js +135 -168
  59. package/src/lib/server/billing-webhook.js.map +1 -1
  60. package/src/lib/server/cart-checkout.js +2 -2
  61. package/src/lib/server/cart-checkout.js.map +1 -1
  62. package/src/lib/server/checkout.js +2 -2
  63. package/src/lib/server/checkout.js.map +1 -1
  64. package/src/lib/server/connect.js +5 -5
  65. package/src/lib/server/connect.js.map +1 -1
  66. package/src/lib/server/draft-order.js +2 -2
  67. package/src/lib/server/draft-order.js.map +1 -1
  68. package/src/lib/server/membership-content.js +1 -1
  69. package/src/lib/server/membership-content.js.map +1 -1
  70. package/src/lib/server/native-checkout.d.ts +20 -5
  71. package/src/lib/server/native-checkout.js +27 -6
  72. package/src/lib/server/native-checkout.js.map +1 -1
  73. package/src/lib/server/pos-order.js +2 -2
  74. package/src/lib/server/pos-order.js.map +1 -1
  75. package/src/lib/server/product-record-index.d.ts +19 -0
  76. package/src/lib/server/product-record-index.js +115 -0
  77. package/src/lib/server/product-record-index.js.map +1 -0
  78. package/src/lib/server/reserve.js +2 -2
  79. package/src/lib/server/reserve.js.map +1 -1
  80. package/src/lib/server/revenue-source.d.ts +130 -0
  81. package/src/lib/server/revenue-source.js +286 -0
  82. package/src/lib/server/revenue-source.js.map +1 -0
  83. package/src/lib/server/tax-profile.d.ts +8 -5
  84. package/src/lib/server/tax-profile.js +22 -5
  85. package/src/lib/server/tax-profile.js.map +1 -1
  86. package/src/lib/server/tax-return-source.d.ts +125 -0
  87. package/src/lib/server/tax-return-source.js +524 -0
  88. package/src/lib/server/tax-return-source.js.map +1 -0
  89. package/src/lib/server/tax-summary.d.ts +92 -0
  90. package/src/lib/server/tax-summary.js +258 -0
  91. package/src/lib/server/tax-summary.js.map +1 -0
  92. package/src/lib/server.d.ts +1 -0
  93. package/src/lib/server.js +23 -0
  94. package/src/lib/server.js.map +1 -1
  95. package/src/lib/tenant-emails.d.ts +33 -0
  96. package/src/lib/tenant-emails.js +511 -0
  97. package/src/lib/tenant-emails.js.map +1 -0
  98. package/src/lib/testing/stand-in-crm-record-routes.d.ts +25 -0
  99. package/src/lib/testing/stand-in-crm-record-routes.js +46 -0
  100. 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 (`storefrontProcessingCostCents`) 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 { storefrontProcessingCostCents } 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, storefrontProcessingCostCents(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 (`storefrontProcessingCostCents`) 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 { storefrontProcessingCostCents } 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, storefrontProcessingCostCents(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","storefrontProcessingCostCents","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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"}
@@ -17,13 +17,16 @@
17
17
  /**
18
18
  * The merchant's tax rule, published for every plugin that charges.
19
19
  *
20
- * A merchant sets tax once, in the store settings this plugin keeps. A plugin
21
- * that sells something else — an appointment — prices its own charge, and used
22
- * to import `resolveFlatTaxCents` and `storefrontTaxModeOf` from here to do it.
23
- * It asks the tax-profile contract now, and gets these same two functions: one
24
- * rounding rule, one reading of a settled payment, wherever money is taken.
20
+ * A merchant sets tax once, in the store settings this plugin keeps
21
+ * (`hosts/{hostId}/settings/store`, under `tax`). A plugin that sells
22
+ * something else — an appointment — prices its own charge by asking the
23
+ * tax-profile contract: for the site's rate for that kind of charge, read
24
+ * here, and for these same two functions over it — one rounding rule, one
25
+ * reading of a settled payment, wherever money is taken.
25
26
  *
26
27
  * Registered from BOTH server registrars, because a booking is priced in the
27
28
  * tenant app and confirmed by the console's billing webhook.
28
29
  */
29
30
  export declare function registerCommerceTaxProfile(): void;
31
+ /** One site's stored flat rate for a kind of charge, as stored. */
32
+ export declare function readFlatRate(hostId: string, charge: string): Promise<unknown>;
@@ -14,27 +14,44 @@
14
14
  * See the License for the specific language governing permissions and
15
15
  * limitations under the License.
16
16
  */ import { registerPluginTaxProfile } from "@aglyn/aglyn/plugin-manager/plugin-tax-profile";
17
+ import { firebaseAdmin } from "@aglyn/tenant-data-admin";
17
18
  import { BUNDLE_ID } from "../constants/bundle-common.js";
18
19
  import { resolveFlatTaxCents } from "../model/index.js";
19
20
  import { storefrontTaxModeOf } from "./storefront-tax.js";
20
21
  /**
21
22
  * The merchant's tax rule, published for every plugin that charges.
22
23
  *
23
- * A merchant sets tax once, in the store settings this plugin keeps. A plugin
24
- * that sells something else — an appointment — prices its own charge, and used
25
- * to import `resolveFlatTaxCents` and `storefrontTaxModeOf` from here to do it.
26
- * It asks the tax-profile contract now, and gets these same two functions: one
27
- * rounding rule, one reading of a settled payment, wherever money is taken.
24
+ * A merchant sets tax once, in the store settings this plugin keeps
25
+ * (`hosts/{hostId}/settings/store`, under `tax`). A plugin that sells
26
+ * something else — an appointment — prices its own charge by asking the
27
+ * tax-profile contract: for the site's rate for that kind of charge, read
28
+ * here, and for these same two functions over it — one rounding rule, one
29
+ * reading of a settled payment, wherever money is taken.
28
30
  *
29
31
  * Registered from BOTH server registrars, because a booking is priced in the
30
32
  * tenant app and confirmed by the console's billing webhook.
31
33
  */ export function registerCommerceTaxProfile() {
32
34
  registerPluginTaxProfile({
35
+ flatRate: (hostId, charge)=>readFlatRate(hostId, charge),
33
36
  flatTax: (rate, chargeCents, fallbackLabel)=>resolveFlatTaxCents(rate, chargeCents, fallbackLabel),
34
37
  taxModeOf: (settledPayment, manualTaxCents)=>storefrontTaxModeOf(settledPayment, manualTaxCents)
35
38
  }, {
36
39
  pluginId: BUNDLE_ID
37
40
  });
38
41
  }
42
+ /**
43
+ * The flat rates the store settings keep, by the kind of charge a caller
44
+ * names. A kind not listed has no rate here, and answers `undefined`.
45
+ */ const FLAT_RATE_CHARGES = {
46
+ service: 'service',
47
+ lodging: 'lodging'
48
+ };
49
+ /** One site's stored flat rate for a kind of charge, as stored. */ export async function readFlatRate(hostId, charge) {
50
+ const field = FLAT_RATE_CHARGES[charge];
51
+ if (!hostId || !field) return undefined;
52
+ const snapshot = await firebaseAdmin.app().firestore().collection('hosts').doc(hostId).collection('settings').doc('store').get();
53
+ const tax = snapshot.exists ? snapshot.get('tax') : undefined;
54
+ return tax == null ? void 0 : tax[field];
55
+ }
39
56
 
40
57
  //# sourceMappingURL=tax-profile.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/tax-profile.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 { registerPluginTaxProfile } from '@aglyn/aglyn/plugin-manager/plugin-tax-profile'\nimport { BUNDLE_ID } from '../constants/bundle-common'\nimport { type FlatTaxRate, resolveFlatTaxCents } from '../model'\nimport { storefrontTaxModeOf } from './storefront-tax'\n\n/**\n * The merchant's tax rule, published for every plugin that charges.\n *\n * A merchant sets tax once, in the store settings this plugin keeps. A plugin\n * that sells something else — an appointment — prices its own charge, and used\n * to import `resolveFlatTaxCents` and `storefrontTaxModeOf` from here to do it.\n * It asks the tax-profile contract now, and gets these same two functions: one\n * rounding rule, one reading of a settled payment, wherever money is taken.\n *\n * Registered from BOTH server registrars, because a booking is priced in the\n * tenant app and confirmed by the console's billing webhook.\n */\nexport function registerCommerceTaxProfile(): void {\n registerPluginTaxProfile(\n {\n flatTax: (rate, chargeCents, fallbackLabel) =>\n resolveFlatTaxCents(\n rate as FlatTaxRate | undefined | null,\n chargeCents,\n fallbackLabel,\n ),\n taxModeOf: (settledPayment, manualTaxCents) =>\n storefrontTaxModeOf(settledPayment, manualTaxCents),\n },\n { pluginId: BUNDLE_ID },\n )\n}\n"],"names":["registerPluginTaxProfile","BUNDLE_ID","resolveFlatTaxCents","storefrontTaxModeOf","registerCommerceTaxProfile","flatTax","rate","chargeCents","fallbackLabel","taxModeOf","settledPayment","manualTaxCents","pluginId"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,wBAAwB,QAAQ,iDAAgD;AACzF,SAASC,SAAS,QAAQ,gCAA4B;AACtD,SAA2BC,mBAAmB,QAAQ,oBAAU;AAChE,SAASC,mBAAmB,QAAQ,sBAAkB;AAEtD;;;;;;;;;;;CAWC,GACD,OAAO,SAASC;IACdJ,yBACE;QACEK,SAAS,CAACC,MAAMC,aAAaC,gBAC3BN,oBACEI,MACAC,aACAC;QAEJC,WAAW,CAACC,gBAAgBC,iBAC1BR,oBAAoBO,gBAAgBC;IACxC,GACA;QAAEC,UAAUX;IAAU;AAE1B"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/tax-profile.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 { registerPluginTaxProfile } from '@aglyn/aglyn/plugin-manager/plugin-tax-profile'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin'\nimport { BUNDLE_ID } from '../constants/bundle-common'\nimport { type FlatTaxRate, resolveFlatTaxCents, type TaxSettings } from '../model'\nimport { storefrontTaxModeOf } from './storefront-tax'\n\n/**\n * The merchant's tax rule, published for every plugin that charges.\n *\n * A merchant sets tax once, in the store settings this plugin keeps\n * (`hosts/{hostId}/settings/store`, under `tax`). A plugin that sells\n * something else — an appointment — prices its own charge by asking the\n * tax-profile contract: for the site's rate for that kind of charge, read\n * here, and for these same two functions over it — one rounding rule, one\n * reading of a settled payment, wherever money is taken.\n *\n * Registered from BOTH server registrars, because a booking is priced in the\n * tenant app and confirmed by the console's billing webhook.\n */\nexport function registerCommerceTaxProfile(): void {\n registerPluginTaxProfile(\n {\n flatRate: (hostId, charge) => readFlatRate(hostId, charge),\n flatTax: (rate, chargeCents, fallbackLabel) =>\n resolveFlatTaxCents(\n rate as FlatTaxRate | undefined | null,\n chargeCents,\n fallbackLabel,\n ),\n taxModeOf: (settledPayment, manualTaxCents) =>\n storefrontTaxModeOf(settledPayment, manualTaxCents),\n },\n { pluginId: BUNDLE_ID },\n )\n}\n\n/**\n * The flat rates the store settings keep, by the kind of charge a caller\n * names. A kind not listed has no rate here, and answers `undefined`.\n */\nconst FLAT_RATE_CHARGES: Readonly<Record<string, keyof TaxSettings>> = {\n service: 'service',\n lodging: 'lodging',\n}\n\n/** One site's stored flat rate for a kind of charge, as stored. */\nexport async function readFlatRate(\n hostId: string,\n charge: string,\n): Promise<unknown> {\n const field = FLAT_RATE_CHARGES[charge]\n if (!hostId || !field) return undefined\n const snapshot = await firebaseAdmin\n .app()\n .firestore()\n .collection('hosts')\n .doc(hostId)\n .collection('settings')\n .doc('store')\n .get()\n const tax = (snapshot.exists ? snapshot.get('tax') : undefined) as\n | TaxSettings\n | undefined\n return tax?.[field]\n}\n"],"names":["registerPluginTaxProfile","firebaseAdmin","BUNDLE_ID","resolveFlatTaxCents","storefrontTaxModeOf","registerCommerceTaxProfile","flatRate","hostId","charge","readFlatRate","flatTax","rate","chargeCents","fallbackLabel","taxModeOf","settledPayment","manualTaxCents","pluginId","FLAT_RATE_CHARGES","service","lodging","field","undefined","snapshot","app","firestore","collection","doc","get","tax","exists"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,wBAAwB,QAAQ,iDAAgD;AACzF,SAASC,aAAa,QAAQ,2BAA0B;AACxD,SAASC,SAAS,QAAQ,gCAA4B;AACtD,SAA2BC,mBAAmB,QAA0B,oBAAU;AAClF,SAASC,mBAAmB,QAAQ,sBAAkB;AAEtD;;;;;;;;;;;;CAYC,GACD,OAAO,SAASC;IACdL,yBACE;QACEM,UAAU,CAACC,QAAQC,SAAWC,aAAaF,QAAQC;QACnDE,SAAS,CAACC,MAAMC,aAAaC,gBAC3BV,oBACEQ,MACAC,aACAC;QAEJC,WAAW,CAACC,gBAAgBC,iBAC1BZ,oBAAoBW,gBAAgBC;IACxC,GACA;QAAEC,UAAUf;IAAU;AAE1B;AAEA;;;CAGC,GACD,MAAMgB,oBAAiE;IACrEC,SAAS;IACTC,SAAS;AACX;AAEA,iEAAiE,GACjE,OAAO,eAAeX,aACpBF,MAAc,EACdC,MAAc;IAEd,MAAMa,QAAQH,iBAAiB,CAACV,OAAO;IACvC,IAAI,CAACD,UAAU,CAACc,OAAO,OAAOC;IAC9B,MAAMC,WAAW,MAAMtB,cACpBuB,GAAG,GACHC,SAAS,GACTC,UAAU,CAAC,SACXC,GAAG,CAACpB,QACJmB,UAAU,CAAC,YACXC,GAAG,CAAC,SACJC,GAAG;IACN,MAAMC,MAAON,SAASO,MAAM,GAAGP,SAASK,GAAG,CAAC,SAASN;IAGrD,OAAOO,uBAAAA,GAAK,CAACR,MAAM;AACrB"}
@@ -0,0 +1,125 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ import type { TaxReturnFiling, TaxReturnFilingLine, TaxReturnFinding, TaxReturnSource, TaxReturnSourceAnswer, TaxReturnSourceRequest } from '@aglyn/aglyn/plugin-manager/plugin-tax-return-sources';
18
+ import { type StorefrontTaxReturnRowInput, type StorefrontTaxSummary } from '../model/storefront-tax-summary';
19
+ /** One row of the storefront listing, as the working papers carry it. */
20
+ export interface StorefrontTaxRow {
21
+ id: string;
22
+ hostId: string | null;
23
+ orgId: string | null;
24
+ paidAt: string | null;
25
+ taxMode: string | null;
26
+ taxLiability: string | null;
27
+ grossCents: number;
28
+ taxCents: number;
29
+ taxableSalesCents: number;
30
+ state: string | null;
31
+ country: string | null;
32
+ }
33
+ /** A liability bucket, ready to print. */
34
+ export interface StorefrontTaxBucketRow {
35
+ id: 'aglynLiable' | 'merchantManual' | 'connectedAccountLiable';
36
+ label: string;
37
+ /** Who owes it — the sentence that decides whether it is on this return. */
38
+ liability: string;
39
+ transactionCount: number;
40
+ grossDollars: string;
41
+ taxableSalesDollars: string;
42
+ taxCollectedDollars: string;
43
+ /** True when this row is money in the platform's balance under its registration. */
44
+ platformLiable: boolean;
45
+ }
46
+ /** One state's facilitated storefront sales, for the nexus question. */
47
+ export interface StorefrontFacilitatedJurisdictionRow {
48
+ jurisdiction: string;
49
+ /** True for the one jurisdiction this deployment files a return in. */
50
+ isFilingJurisdiction: boolean;
51
+ transactionCount: number;
52
+ totalSalesDollars: string;
53
+ taxCollectedDollars: string;
54
+ /** The part of `taxCollectedDollars` the platform holds and must remit. */
55
+ platformLiableTaxDollars: string;
56
+ /** True when NO tax was collected on any sale into this state. */
57
+ untaxed: boolean;
58
+ }
59
+ /**
60
+ * Storefront tax in the FILING jurisdiction that Stripe computed against the
61
+ * platform's own registrations.
62
+ *
63
+ * The one figure that decides whether a period can be filed from the filing
64
+ * lines alone. Deliberately excludes `merchantManual` — a merchant's own
65
+ * configured rate never touched those registrations. Read against the
66
+ * configured jurisdiction rather than Texas, because a hard-coded key answers
67
+ * `0.00` everywhere else — and a zero here reads as "nothing to decide" on
68
+ * the one finding that blocks filing.
69
+ */
70
+ export declare function storefrontPlatformLiableCents(summary: StorefrontTaxSummary | null | undefined, filing: TaxReturnFiling): number;
71
+ /** The storefront's findings. The platform raises the non-zero ones. */
72
+ export declare function storefrontTaxFindings(summary: StorefrontTaxSummary, filing: TaxReturnFiling): TaxReturnFinding[];
73
+ /**
74
+ * The two lines beneath the filing figures: the platform-liable tax and the
75
+ * merchant's own rate, separately, and outside every form item.
76
+ */
77
+ export declare function storefrontFilingLines(summary: StorefrontTaxSummary, filing: TaxReturnFiling): TaxReturnFilingLine[];
78
+ /** The three liability buckets, in the order a reader decides them. */
79
+ export declare function storefrontBucketRows(summary: StorefrontTaxSummary): StorefrontTaxBucketRow[];
80
+ /**
81
+ * FACILITATED SALES BY STATE — the economic-nexus question (AGL-1956).
82
+ *
83
+ * The question a state asks a marketplace facilitator is "how much did you
84
+ * facilitate INTO this state, and in how many transactions" — not "how much
85
+ * tax did you collect there". A state the operator is not registered in
86
+ * collects nothing by definition, which is exactly why collection cannot be
87
+ * the measure: the states worth watching are the ones showing $0 tax and a
88
+ * rising sales figure.
89
+ *
90
+ * So this SUMS the three liability buckets per jurisdiction. The rule that
91
+ * the operator's own invoices and storefront rows are never summed still
92
+ * holds — nothing here touches the operator's own sales; within the
93
+ * storefront collection the buckets differ only in WHO REMITS, and a nexus
94
+ * threshold counts the sale whoever remits it. Who remits is still carried
95
+ * per row, because "do we have nexus here" and "what do we owe here" are
96
+ * answered off the same rows and must not blur.
97
+ *
98
+ * ⚠️ A LOWER BOUND, deliberately rather than silently: `storefront-tax-record.ts`
99
+ * files no row at all for a sale whose `taxMode` resolves to `none`, so a
100
+ * wholly untaxed storefront sale is invisible here — the population nexus
101
+ * detection most needs. Closing it is a write-side change recorded on
102
+ * AGL-1956. The filing jurisdiction does not depend on any of this: a filer
103
+ * registered where it is established has no in-state threshold left to cross.
104
+ */
105
+ export declare function storefrontFacilitatedJurisdictionRows(summary: StorefrontTaxSummary | null | undefined, filing: TaxReturnFiling): StorefrontFacilitatedJurisdictionRow[];
106
+ /**
107
+ * The storefront's answer for one period, from rows already read. Pure, so
108
+ * the section a preparer reads can be proved from fixtures.
109
+ */
110
+ export declare function storefrontTaxReturnAnswer(read: {
111
+ rows: readonly StorefrontTaxReturnRowInput[];
112
+ truncated: boolean;
113
+ undatedRows: number;
114
+ }, request: TaxReturnSourceRequest): TaxReturnSourceAnswer;
115
+ /**
116
+ * The storefront's source, registered on the console's API surface: the
117
+ * staff return awaits that surface before it asks.
118
+ *
119
+ * Two queries, and the second is the honesty check: the period query ranges
120
+ * on `paidAt`, and a Firestore range query CANNOT match a document whose
121
+ * field is null — so a row that failed to date itself would be invisible to
122
+ * every period. The equality probe counts those rows, over all of them, and
123
+ * the platform blocks the return on any.
124
+ */
125
+ export declare const commerceTaxReturnSource: TaxReturnSource;