@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
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/supplier-update.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport * as Aglyn from '@aglyn/aglyn/server'\nimport * as CommerceModel from '../model'\nimport { firebaseAdmin, notifyHostManagers } from '@aglyn/tenant-data-admin'\nimport { escapeHtml } from '../utils/escape-html'\nimport { createHmac, timingSafeEqual } from 'crypto'\nimport { tokenSigningSecret } from './download'\nimport { type PluginApiHandler } from '@aglyn/aglyn/server'\n\n/**\n * Constant-time compare for the per-order supplier token (AGL-2268).\n *\n * `!==` on a secret leaks its prefix through response timing, and this token is\n * a bearer credential for a route with no account behind it — the only thing\n * standing between the open internet and marking a merchant's order shipped.\n * `download.ts` has compared its own token this way since it shipped; this is\n * the sibling that did not.\n *\n * Length is compared first and OUTSIDE the constant-time call, because\n * `timingSafeEqual` throws on a length mismatch. That leaks the token's LENGTH\n * and nothing else, which is the same concession `download.ts` makes.\n */\nfunction tokenMatches(stored: unknown, presented: string): boolean {\n const expected = typeof stored === 'string' ? stored : ''\n if (!expected || !presented) return false\n const a = Buffer.from(presented)\n const b = Buffer.from(expected)\n if (a.length !== b.length) return false\n return timingSafeEqual(new Uint8Array(a), new Uint8Array(b))\n}\n\n/**\n * WHICH supplier is posting (AGL-2455), and the observation that makes the\n * issue's \"structural blocker\" not structural after all.\n *\n * The order document holds ONE `supplierToken` field, so the issue's reading\n * was that scoping lines per supplier would strand every other supplier's lines\n * permanently: only the last token written could authenticate, and the others\n * would have no valid credential at all. That reading is right about the stored\n * field and wrong about the token.\n *\n * The token is not a random secret that has to be stored to be known. It is\n * `HMAC(hostId:orderId:supplierId)` under `TOKEN_SIGNING_SECRET`\n * (`billing-webhook.ts`), a pure function of three identifiers the order\n * already carries — `supplierId` is stamped onto every line at purchase time.\n * So the expected token for EVERY supplier on the order can be re-derived here\n * and compared, which both identifies the poster and lets every supplier\n * authenticate. No schema change, no migration, no map.\n *\n * Every candidate is compared even after a match, so the work done is a\n * function of the order's supplier count and not of which supplier posted.\n *\n * Returns `null` when nothing matches; the stored-scalar fallback is the\n * caller's, because only it knows whether that fallback is ambiguous.\n */\nfunction resolvePostingSupplier(\n hostId: string,\n orderId: string,\n order: CommerceModel.HostOrder,\n presented: string,\n): string | null {\n let secret: string\n try {\n secret = tokenSigningSecret()\n } catch {\n // Unset secret: no token could have been MINTED either, so there is nothing\n // to derive. Fall through to the stored scalar rather than throwing a 500\n // at a supplier who cannot do anything about it.\n return null\n }\n let matched: string | null = null\n for (const supplierId of CommerceModel.orderSupplierIds(order)) {\n const expected = createHmac('sha256', secret)\n .update(`${hostId}:${orderId}:${supplierId}`)\n .digest('hex')\n .slice(0, 32)\n if (tokenMatches(expected, presented)) matched = supplierId\n }\n return matched\n}\n\n\n/**\n * Supplier tracking callback (AGL-289): the routed order carried a\n * per-order token; posting (or GET-ing, for email links) tracking with\n * it fulfills the order. Token-gated — suppliers have no Aglyn account.\n */\nexport const supplierUpdateHandler: PluginApiHandler = async (req, res) => {\n const source = req.method === 'POST' ? { ...req.query, ...(typeof req.body === 'string' ? JSON.parse(req.body || '{}') : (req.body ?? {})) } : req.query\n const hostId = String(source.hostId ?? '')\n const orderId = String(source.orderId ?? '')\n const token = String(source.token ?? '')\n const trackingNumber = String(source.trackingNumber ?? '').slice(0, 60)\n const carrier = String(source.carrier ?? '').slice(0, 40)\n if (!hostId || !orderId || !token) {\n return res.status(400).json({ error: 'Missing hostId, orderId, or token' })\n }\n // Firestore reserves `__…__` ids and `.doc()` throws SYNCHRONOUSLY on one,\n // which fell into the catch below and answered 500 to what is a 400\n // (AGL-2455). `fulfill-order.ts` has had this guard since it shipped; this is\n // the sibling route that did not.\n if (/^__.*__$/.test(hostId) || /^__.*__$/.test(orderId)) {\n return res.status(400).json({ error: 'Missing hostId, orderId, or token' })\n }\n\n try {\n const firestore = firebaseAdmin.app().firestore()\n const orderRef = firestore\n .collection('hosts')\n .doc(hostId)\n .collection('orders')\n .doc(orderId)\n // A GET NEVER MOVES THE ORDER (AGL-2268). The supplier email carries this\n // URL, and a GET that fulfils means every link scanner, spam filter,\n // preview generator and browser prefetch on the path between us and the\n // supplier's inbox marks the order shipped — before anything shipped, and\n // with no tracking number on it. The link still works and the email is\n // unchanged: a GET renders a one-button page that POSTs the same\n // parameters, so the supplier's experience is one extra click and the\n // machines that open mail for a living take no action at all.\n if (req.method === 'GET') {\n res.setHeader('Content-Type', 'text/html')\n const field = (name: string, value: string) =>\n `<input type=\"hidden\" name=\"${name}\" value=\"${escapeHtml(value)}\">`\n return res.status(200).send(\n '<h3>Confirm this shipment</h3>' +\n '<p>Press the button to record it against the order.</p>' +\n '<form method=\"POST\">' +\n field('hostId', hostId) +\n field('orderId', orderId) +\n field('token', token) +\n field('carrier', carrier) +\n field('trackingNumber', trackingNumber) +\n '<button type=\"submit\">Mark as shipped</button>' +\n '</form>',\n )\n }\n\n // THE READ, THE TRANSITION CHECK AND THE WRITE ARE ONE ACT (AGL-2268).\n // They used to be a plain `get()`, a check, and a `set(…, {merge:true})`,\n // which is the exact stale-read hole AGL-1808/1818/1819 closed on the\n // console's own fulfil, cancel and refund routes — reopened here, on the\n // one door with no account behind it. A supplier POST racing a refund read\n // `paid`, the refund landed, and the POST then wrote `fulfilled` over\n // `refunded`. `ORDER_TRANSITIONS` says `refunded: []`, and the cost is not\n // cosmetic: `gate.ts`, `download.ts`, `membership-account.ts` and\n // `reviews.ts` all withdraw a shopper's entitlement by matching that\n // literal, so the refunded buyer got their downloads back.\n const outcome = await firebaseAdmin\n .app()\n .firestore()\n .runTransaction(async (transaction) => {\n const orderSnapshot = await transaction.get(orderRef)\n if (!orderSnapshot.exists) {\n return { status: 404, body: { error: 'Unknown order' } as any }\n }\n const order = CommerceModel.liftLegacyOrder(orderSnapshot.data() as any)\n // ONE SUPPLIER SHIPS ONE SUPPLIER'S LINES (AGL-2455).\n //\n // `lineItemIds` used to be every index on the order and the status was\n // written as the literal `'fulfilled'`, so the first supplier to post\n // marked the whole order shipped — lines they had never seen included —\n // and the second supplier's POST met a 409. The buyer was told\n // everything shipped with one carrier and one tracking number.\n const suppliers = CommerceModel.orderSupplierIds(order)\n const postingSupplier = resolvePostingSupplier(\n hostId,\n orderId,\n order,\n token,\n )\n let myLines: number[]\n if (postingSupplier) {\n myLines = CommerceModel.supplierLineItemIds(order, postingSupplier)\n } else if (tokenMatches(orderSnapshot.get('supplierToken'), token)) {\n // The STORED scalar matched but no supplier on the order derives to\n // it — an order whose lines carry no `supplierId`, which is every\n // order routed before that field was stamped.\n //\n // With at most one supplier there is only one door, so claiming every\n // line is the same answer this route has always given and is right.\n // With TWO OR MORE, the scalar cannot say whose token it is, and\n // guessing would either close lines a supplier never shipped (the\n // defect) or strand them (the naive fix). REFUSED LOUDLY instead:\n // the merchant fulfils from the console, where they can see the\n // lines, and the message says so rather than leaving them to work it\n // out from an order that silently went quiet.\n if (suppliers.length > 1) {\n return {\n status: 409,\n body: {\n error:\n 'This order has more than one supplier and the link cannot ' +\n 'say which of them you are. Nothing was changed. Please ask ' +\n 'the merchant to record this shipment from their console.',\n } as any,\n }\n }\n myLines = (order.lineItems ?? []).map((_line, index) => index)\n } else {\n return { status: 403, body: { error: 'Invalid token' } as any }\n }\n if (myLines.length === 0) {\n return {\n status: 409,\n body: { error: 'No lines on this order are yours' } as any,\n }\n }\n const coveredBefore = CommerceModel.coveredLineItemIds(order)\n if (myLines.every((index) => coveredBefore.has(index))) {\n // Every line of theirs is already shipped: a redelivered POST, or the\n // supplier pressing the emailed button twice. Refused rather than\n // appending a second copy of the same fulfillment — the same rule\n // `fulfill-order.ts` applies to a retried click.\n return {\n status: 409,\n body: {\n error:\n suppliers.length > 1\n ? 'Your lines on this order are already marked shipped'\n : `Order is already ${order.status}`,\n } as any,\n }\n }\n const coveredAfter = new Set([...coveredBefore, ...myLines])\n // COMPUTED, not written as a literal. `partially_fulfilled` has been in\n // `ORDER_TRANSITIONS` since orders shipped and nothing ever wrote it.\n const nextStatus = CommerceModel.statusAfterFulfilling(\n order,\n coveredAfter,\n )\n // Re-asked inside the transaction that writes, as AGL-2268 established:\n // a supplier POST racing a refund must not write over `refunded`. Only\n // when the status actually MOVES — a second supplier posting onto an\n // already `partially_fulfilled` order changes nothing about the status\n // and must not be refused by a table that has no self-edge.\n if (\n nextStatus !== order.status &&\n !CommerceModel.canTransitionOrder(order.status, nextStatus)\n ) {\n return {\n status: 409,\n body: { error: `Order is already ${order.status}` } as any,\n }\n }\n const fulfillment: CommerceModel.OrderFulfillment = {\n id: `supplier-${Date.now().toString(36)}`,\n lineItemIds: myLines,\n ...(carrier ? { carrier } : {}),\n ...(trackingNumber ? { trackingNumber } : {}),\n atMs: Date.now(),\n }\n const remaining = (order.lineItems ?? []).filter(\n (_line, index) => !coveredAfter.has(index),\n ).length\n transaction.set(\n orderRef,\n {\n status: nextStatus,\n fulfillments: [...(order.fulfillments ?? []), fulfillment],\n timeline: CommerceModel.appendOrderEvent(\n order,\n nextStatus,\n `Supplier shipped ${myLines.length} of ${\n (order.lineItems ?? []).length\n } line${(order.lineItems ?? []).length === 1 ? '' : 's'}` +\n (trackingNumber\n ? ` — ${carrier || 'tracking'} ${trackingNumber}`\n : '') +\n (remaining > 0\n ? `. ${remaining} line${remaining === 1 ? '' : 's'} still to ship.`\n : ''),\n ),\n },\n { merge: true },\n )\n return {\n status: 200,\n body: { ok: true, lineItemIds: myLines, orderStatus: nextStatus } as any,\n order,\n nextStatus,\n remaining,\n }\n })\n if (outcome.status !== 200) {\n return res.status(outcome.status).json(outcome.body)\n }\n // The merchant is told what is STILL OUTSTANDING (AGL-2455). \"Supplier\n // shipped #1042\" on an order where one of three suppliers has posted reads\n // as done, which is the same misreport the status literal made — the\n // merchant would stop watching an order that is two thirds unshipped.\n const outstanding = Number((outcome as any).remaining ?? 0)\n void notifyHostManagers(hostId, {\n type: 'content.order',\n title: `Supplier shipped ${CommerceModel.formatOrderNumber(\n outcome.order as CommerceModel.HostOrder,\n orderId,\n )}${outstanding > 0 ? ` — ${outstanding} line${outstanding === 1 ? '' : 's'} still to ship` : ''}`,\n ...(trackingNumber ? { body: `${carrier} ${trackingNumber}` } : {}),\n link: `/${hostId}/products`,\n })\n return res.status(200).json(outcome.body)\n } catch (error) {\n console.error(error)\n return res.status(500).json({ error: 'Update failed' })\n }\n}\n"],"names":["CommerceModel","firebaseAdmin","notifyHostManagers","escapeHtml","createHmac","timingSafeEqual","tokenSigningSecret","tokenMatches","stored","presented","expected","a","Buffer","from","b","length","Uint8Array","resolvePostingSupplier","hostId","orderId","order","secret","matched","supplierId","orderSupplierIds","update","digest","slice","supplierUpdateHandler","req","res","source","method","query","body","JSON","parse","String","token","trackingNumber","carrier","status","json","error","test","firestore","app","orderRef","collection","doc","setHeader","field","name","value","send","outcome","runTransaction","transaction","orderSnapshot","get","exists","liftLegacyOrder","data","suppliers","postingSupplier","myLines","supplierLineItemIds","lineItems","map","_line","index","coveredBefore","coveredLineItemIds","every","has","coveredAfter","Set","nextStatus","statusAfterFulfilling","canTransitionOrder","fulfillment","id","Date","now","toString","lineItemIds","atMs","remaining","filter","set","fulfillments","timeline","appendOrderEvent","merge","ok","orderStatus","outstanding","Number","type","title","formatOrderNumber","link","console"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/supplier-update.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport * as Aglyn from '@aglyn/aglyn/server'\nimport * as CommerceModel from '../model'\nimport { firebaseAdmin, notifyHostManagers } from '@aglyn/tenant-data-admin'\nimport { escapeHtml } from '../utils/escape-html'\nimport { createHmac, timingSafeEqual } from 'crypto'\nimport { tokenSigningSecret } from './download'\nimport { type PluginApiHandler } from '@aglyn/aglyn/server'\n\n/**\n * Constant-time compare for the per-order supplier token (AGL-2268).\n *\n * `!==` on a secret leaks its prefix through response timing, and this token is\n * a bearer credential for a route with no account behind it — the only thing\n * standing between the open internet and marking a merchant's order shipped.\n * `download.ts` has compared its own token this way since it shipped; this is\n * the sibling that did not.\n *\n * Length is compared first and OUTSIDE the constant-time call, because\n * `timingSafeEqual` throws on a length mismatch. That leaks the token's LENGTH\n * and nothing else, which is the same concession `download.ts` makes.\n */\nfunction tokenMatches(stored: unknown, presented: string): boolean {\n const expected = typeof stored === 'string' ? stored : ''\n if (!expected || !presented) return false\n const a = Buffer.from(presented)\n const b = Buffer.from(expected)\n if (a.length !== b.length) return false\n return timingSafeEqual(new Uint8Array(a), new Uint8Array(b))\n}\n\n/**\n * WHICH supplier is posting (AGL-2455), and the observation that makes the\n * issue's \"structural blocker\" not structural after all.\n *\n * The order document holds ONE `supplierToken` field, so the issue's reading\n * was that scoping lines per supplier would strand every other supplier's lines\n * permanently: only the last token written could authenticate, and the others\n * would have no valid credential at all. That reading is right about the stored\n * field and wrong about the token.\n *\n * The token is not a random secret that has to be stored to be known. It is\n * `HMAC(hostId:orderId:supplierId)` under `TOKEN_SIGNING_SECRET`\n * (`billing-webhook.ts`), a pure function of three identifiers the order\n * already carries — `supplierId` is stamped onto every line at purchase time.\n * So the expected token for EVERY supplier on the order can be re-derived here\n * and compared, which both identifies the poster and lets every supplier\n * authenticate. No schema change, no migration, no map.\n *\n * Every candidate is compared even after a match, so the work done is a\n * function of the order's supplier count and not of which supplier posted.\n *\n * Returns `null` when nothing matches; the stored-scalar fallback is the\n * caller's, because only it knows whether that fallback is ambiguous.\n */\nfunction resolvePostingSupplier(\n hostId: string,\n orderId: string,\n order: CommerceModel.HostOrder,\n presented: string,\n): string | null {\n let secret: string\n try {\n secret = tokenSigningSecret()\n } catch {\n // Unset secret: no token could have been MINTED either, so there is nothing\n // to derive. Fall through to the stored scalar rather than throwing a 500\n // at a supplier who cannot do anything about it.\n return null\n }\n let matched: string | null = null\n for (const supplierId of CommerceModel.orderSupplierIds(order)) {\n const expected = createHmac('sha256', secret)\n .update(`${hostId}:${orderId}:${supplierId}`)\n .digest('hex')\n .slice(0, 32)\n if (tokenMatches(expected, presented)) matched = supplierId\n }\n return matched\n}\n\n\n/**\n * Supplier tracking callback (AGL-289): the routed order carried a\n * per-order token; posting (or GET-ing, for email links) tracking with\n * it fulfills the order. Token-gated — suppliers have no Aglyn account.\n */\nexport const supplierUpdateHandler: PluginApiHandler = async (req, res) => {\n const source = req.method === 'POST' ? { ...req.query, ...(typeof req.body === 'string' ? JSON.parse(req.body || '{}') : (req.body ?? {})) } : req.query\n const hostId = String(source.hostId ?? '')\n const orderId = String(source.orderId ?? '')\n const token = String(source.token ?? '')\n const trackingNumber = String(source.trackingNumber ?? '').slice(0, 60)\n const carrier = String(source.carrier ?? '').slice(0, 40)\n if (!hostId || !orderId || !token) {\n return res.status(400).json({ error: 'Missing hostId, orderId, or token' })\n }\n // Firestore reserves `__…__` ids and `.doc()` throws SYNCHRONOUSLY on one,\n // which fell into the catch below and answered 500 to what is a 400\n // (AGL-2455). `fulfill-order.ts` has had this guard since it shipped; this is\n // the sibling route that did not.\n if (/^__.*__$/.test(hostId) || /^__.*__$/.test(orderId)) {\n return res.status(400).json({ error: 'Missing hostId, orderId, or token' })\n }\n\n try {\n const firestore = firebaseAdmin.app().firestore()\n const orderRef = firestore\n .collection('hosts')\n .doc(hostId)\n .collection('orders')\n .doc(orderId)\n // A GET NEVER MOVES THE ORDER (AGL-2268). The supplier email carries this\n // URL, and a GET that fulfils means every link scanner, spam filter,\n // preview generator and browser prefetch on the path between us and the\n // supplier's inbox marks the order shipped — before anything shipped, and\n // with no tracking number on it. The link still works and the email is\n // unchanged: a GET renders a one-button page that POSTs the same\n // parameters, so the supplier's experience is one extra click and the\n // machines that open mail for a living take no action at all.\n if (req.method === 'GET') {\n res.setHeader('Content-Type', 'text/html')\n const field = (name: string, value: string) =>\n `<input type=\"hidden\" name=\"${name}\" value=\"${escapeHtml(value)}\">`\n // The carrier and the number are FIELDS the supplier fills in\n // (AGL-3432), so the emailed link needs no placeholders for them to\n // edit in the URL. A value a link does carry arrives filled in.\n const input = (name: string, label: string, value: string) =>\n `<p><label>${label}<br>` +\n `<input type=\"text\" name=\"${name}\" value=\"${escapeHtml(value)}\"></label></p>`\n return res.status(200).send(\n '<h3>Record this shipment</h3>' +\n '<p>Enter the carrier and tracking number, then press the button ' +\n 'to record it against the order.</p>' +\n '<form method=\"POST\">' +\n field('hostId', hostId) +\n field('orderId', orderId) +\n field('token', token) +\n input('carrier', 'Carrier', carrier) +\n input('trackingNumber', 'Tracking number', trackingNumber) +\n '<button type=\"submit\">Mark as shipped</button>' +\n '</form>',\n )\n }\n\n // THE READ, THE TRANSITION CHECK AND THE WRITE ARE ONE ACT (AGL-2268).\n // They used to be a plain `get()`, a check, and a `set(…, {merge:true})`,\n // which is the exact stale-read hole AGL-1808/1818/1819 closed on the\n // console's own fulfil, cancel and refund routes — reopened here, on the\n // one door with no account behind it. A supplier POST racing a refund read\n // `paid`, the refund landed, and the POST then wrote `fulfilled` over\n // `refunded`. `ORDER_TRANSITIONS` says `refunded: []`, and the cost is not\n // cosmetic: `gate.ts`, `download.ts`, `membership-account.ts` and\n // `reviews.ts` all withdraw a shopper's entitlement by matching that\n // literal, so the refunded buyer got their downloads back.\n const outcome = await firebaseAdmin\n .app()\n .firestore()\n .runTransaction(async (transaction) => {\n const orderSnapshot = await transaction.get(orderRef)\n if (!orderSnapshot.exists) {\n return { status: 404, body: { error: 'Unknown order' } as any }\n }\n const order = CommerceModel.liftLegacyOrder(orderSnapshot.data() as any)\n // ONE SUPPLIER SHIPS ONE SUPPLIER'S LINES (AGL-2455).\n //\n // `lineItemIds` used to be every index on the order and the status was\n // written as the literal `'fulfilled'`, so the first supplier to post\n // marked the whole order shipped — lines they had never seen included —\n // and the second supplier's POST met a 409. The buyer was told\n // everything shipped with one carrier and one tracking number.\n const suppliers = CommerceModel.orderSupplierIds(order)\n const postingSupplier = resolvePostingSupplier(\n hostId,\n orderId,\n order,\n token,\n )\n let myLines: number[]\n if (postingSupplier) {\n myLines = CommerceModel.supplierLineItemIds(order, postingSupplier)\n } else if (tokenMatches(orderSnapshot.get('supplierToken'), token)) {\n // The STORED scalar matched but no supplier on the order derives to\n // it — an order whose lines carry no `supplierId`, which is every\n // order routed before that field was stamped.\n //\n // With at most one supplier there is only one door, so claiming every\n // line is the same answer this route has always given and is right.\n // With TWO OR MORE, the scalar cannot say whose token it is, and\n // guessing would either close lines a supplier never shipped (the\n // defect) or strand them (the naive fix). REFUSED LOUDLY instead:\n // the merchant fulfils from the console, where they can see the\n // lines, and the message says so rather than leaving them to work it\n // out from an order that silently went quiet.\n if (suppliers.length > 1) {\n return {\n status: 409,\n body: {\n error:\n 'This order has more than one supplier and the link cannot ' +\n 'say which of them you are. Nothing was changed. Please ask ' +\n 'the merchant to record this shipment from their console.',\n } as any,\n }\n }\n myLines = (order.lineItems ?? []).map((_line, index) => index)\n } else {\n return { status: 403, body: { error: 'Invalid token' } as any }\n }\n if (myLines.length === 0) {\n return {\n status: 409,\n body: { error: 'No lines on this order are yours' } as any,\n }\n }\n const coveredBefore = CommerceModel.coveredLineItemIds(order)\n if (myLines.every((index) => coveredBefore.has(index))) {\n // Every line of theirs is already shipped: a redelivered POST, or the\n // supplier pressing the emailed button twice. Refused rather than\n // appending a second copy of the same fulfillment — the same rule\n // `fulfill-order.ts` applies to a retried click.\n return {\n status: 409,\n body: {\n error:\n suppliers.length > 1\n ? 'Your lines on this order are already marked shipped'\n : `Order is already ${order.status}`,\n } as any,\n }\n }\n const coveredAfter = new Set([...coveredBefore, ...myLines])\n // COMPUTED, not written as a literal. `partially_fulfilled` has been in\n // `ORDER_TRANSITIONS` since orders shipped and nothing ever wrote it.\n const nextStatus = CommerceModel.statusAfterFulfilling(\n order,\n coveredAfter,\n )\n // Re-asked inside the transaction that writes, as AGL-2268 established:\n // a supplier POST racing a refund must not write over `refunded`. Only\n // when the status actually MOVES — a second supplier posting onto an\n // already `partially_fulfilled` order changes nothing about the status\n // and must not be refused by a table that has no self-edge.\n if (\n nextStatus !== order.status &&\n !CommerceModel.canTransitionOrder(order.status, nextStatus)\n ) {\n return {\n status: 409,\n body: { error: `Order is already ${order.status}` } as any,\n }\n }\n const fulfillment: CommerceModel.OrderFulfillment = {\n id: `supplier-${Date.now().toString(36)}`,\n lineItemIds: myLines,\n ...(carrier ? { carrier } : {}),\n ...(trackingNumber ? { trackingNumber } : {}),\n atMs: Date.now(),\n }\n const remaining = (order.lineItems ?? []).filter(\n (_line, index) => !coveredAfter.has(index),\n ).length\n transaction.set(\n orderRef,\n {\n status: nextStatus,\n fulfillments: [...(order.fulfillments ?? []), fulfillment],\n timeline: CommerceModel.appendOrderEvent(\n order,\n nextStatus,\n `Supplier shipped ${myLines.length} of ${\n (order.lineItems ?? []).length\n } line${(order.lineItems ?? []).length === 1 ? '' : 's'}` +\n (trackingNumber\n ? ` — ${carrier || 'tracking'} ${trackingNumber}`\n : '') +\n (remaining > 0\n ? `. ${remaining} line${remaining === 1 ? '' : 's'} still to ship.`\n : ''),\n ),\n },\n { merge: true },\n )\n return {\n status: 200,\n body: { ok: true, lineItemIds: myLines, orderStatus: nextStatus } as any,\n order,\n nextStatus,\n remaining,\n supplierId: postingSupplier,\n }\n })\n if (outcome.status !== 200) {\n return res.status(outcome.status).json(outcome.body)\n }\n // The merchant is told what is STILL OUTSTANDING (AGL-2455). \"Supplier\n // shipped #1042\" on an order where one of three suppliers has posted reads\n // as done, which is the same misreport the status literal made — the\n // merchant would stop watching an order that is two thirds unshipped.\n const outstanding = Number((outcome as any).remaining ?? 0)\n const orderLabel = CommerceModel.formatOrderNumber(\n outcome.order as CommerceModel.HostOrder,\n orderId,\n )\n // Which supplier, which order, where, and the tracking (AGL-3432).\n const supplierId = (outcome as any).supplierId as string | null\n const supplierName = supplierId\n ? String(\n (await firestore\n .collection('hosts')\n .doc(hostId)\n .collection('suppliers')\n .doc(supplierId)\n .get()\n .then((snapshot) => snapshot.get('name'))\n .catch(() => '')) ?? '',\n ).trim()\n : ''\n void notifyHostManagers(hostId, {\n type: 'content.order',\n title: `Supplier shipped ${orderLabel}${outstanding > 0 ? ` — ${outstanding} line${outstanding === 1 ? '' : 's'} still to ship` : ''}`,\n body:\n `${supplierName || 'Your supplier'} shipped their part of order ` +\n `${orderLabel} on {site}.` +\n (trackingNumber\n ? ` Tracking: ${[carrier, trackingNumber].filter(Boolean).join(' ')}.`\n : ' No tracking number was given.') +\n (outstanding > 0\n ? ` ${outstanding} line${outstanding === 1 ? ' is' : 's are'} still to ship.`\n : ''),\n link: `/${hostId}/products`,\n })\n return res.status(200).json(outcome.body)\n } catch (error) {\n console.error(error)\n return res.status(500).json({ error: 'Update failed' })\n }\n}\n"],"names":["CommerceModel","firebaseAdmin","notifyHostManagers","escapeHtml","createHmac","timingSafeEqual","tokenSigningSecret","tokenMatches","stored","presented","expected","a","Buffer","from","b","length","Uint8Array","resolvePostingSupplier","hostId","orderId","order","secret","matched","supplierId","orderSupplierIds","update","digest","slice","supplierUpdateHandler","req","res","source","method","query","body","JSON","parse","String","token","trackingNumber","carrier","status","json","error","test","firestore","app","orderRef","collection","doc","setHeader","field","name","value","input","label","send","outcome","runTransaction","transaction","orderSnapshot","get","exists","liftLegacyOrder","data","suppliers","postingSupplier","myLines","supplierLineItemIds","lineItems","map","_line","index","coveredBefore","coveredLineItemIds","every","has","coveredAfter","Set","nextStatus","statusAfterFulfilling","canTransitionOrder","fulfillment","id","Date","now","toString","lineItemIds","atMs","remaining","filter","set","fulfillments","timeline","appendOrderEvent","merge","ok","orderStatus","outstanding","Number","orderLabel","formatOrderNumber","supplierName","then","snapshot","catch","trim","type","title","Boolean","join","link","console"],"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;