@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.
- package/README.md +6 -0
- package/package.json +14 -14
- package/src/index.d.ts +1 -0
- package/src/index.js +1 -19
- package/src/index.js.map +1 -1
- package/src/lib/components/cart.js +2 -1
- package/src/lib/components/cart.js.map +1 -1
- package/src/lib/components/console/catalog-organization-card.component.js +665 -459
- package/src/lib/components/console/catalog-organization-card.component.js.map +1 -1
- package/src/lib/components/console/host-orders-card.component.js +2 -1
- package/src/lib/components/console/host-orders-card.component.js.map +1 -1
- package/src/lib/components/console/member-posts-card.component.js +1 -1
- package/src/lib/components/console/member-posts-card.component.js.map +1 -1
- package/src/lib/components/console/order-detail-dialog.component.js +8 -7
- package/src/lib/components/console/order-detail-dialog.component.js.map +1 -1
- package/src/lib/components/console/payments-settings-card.component.js +2 -2
- package/src/lib/components/console/payments-settings-card.component.js.map +1 -1
- package/src/lib/components/console/product-editor-dialog.component.js +1221 -889
- package/src/lib/components/console/product-editor-dialog.component.js.map +1 -1
- package/src/lib/components/console/product-zones.d.ts +184 -0
- package/src/lib/components/console/product-zones.js +54 -0
- package/src/lib/components/console/product-zones.js.map +1 -0
- package/src/lib/components/console/products-hub-card.component.js +3 -2
- package/src/lib/components/console/products-hub-card.component.js.map +1 -1
- package/src/lib/components/console/products-hub-zone.component.d.ts +1 -1
- package/src/lib/components/console/products-hub-zone.component.js +2 -1
- package/src/lib/components/console/products-hub-zone.component.js.map +1 -1
- package/src/lib/components/console/site-member-purchases.component.d.ts +24 -0
- package/src/lib/components/console/site-member-purchases.component.js +339 -0
- package/src/lib/components/console/site-member-purchases.component.js.map +1 -0
- package/src/lib/components/console/store-settings-card.component.js +2 -2
- package/src/lib/components/console/store-settings-card.component.js.map +1 -1
- package/src/lib/components/console/storefront-tax-summary-card.component.js +1 -1
- package/src/lib/components/console/storefront-tax-summary-card.component.js.map +1 -1
- package/src/lib/components/member-signup.js +2 -2
- package/src/lib/components/member-signup.js.map +1 -1
- package/src/lib/components/newsletter-signup.js +2 -2
- package/src/lib/components/newsletter-signup.js.map +1 -1
- package/src/lib/components/product-detail.js +2 -1
- package/src/lib/components/product-detail.js.map +1 -1
- package/src/lib/components/product-grid.js +1 -1
- package/src/lib/components/product-grid.js.map +1 -1
- package/src/lib/constants/operator-alerts.js +5 -3
- package/src/lib/constants/operator-alerts.js.map +1 -1
- package/src/lib/constants/storefront-payments.d.ts +33 -0
- package/src/lib/constants/storefront-payments.js +35 -0
- package/src/lib/constants/storefront-payments.js.map +1 -0
- package/src/lib/declarations.server.d.ts +3 -1
- package/src/lib/declarations.server.js +19 -1
- package/src/lib/declarations.server.js.map +1 -1
- package/src/lib/model/commerce-io.js +1 -1
- package/src/lib/model/commerce-io.js.map +1 -1
- package/src/lib/model/commerce-record-lists.d.ts +31 -0
- package/src/lib/model/commerce-record-lists.js +45 -0
- package/src/lib/model/commerce-record-lists.js.map +1 -0
- package/src/lib/model/commerce-record-routes.d.ts +45 -0
- package/src/lib/model/commerce-record-routes.js +72 -0
- package/src/lib/model/commerce-record-routes.js.map +1 -0
- package/src/lib/model/commerce-search-sources.d.ts +21 -0
- package/src/lib/model/commerce-search-sources.js +42 -0
- package/src/lib/model/commerce-search-sources.js.map +1 -0
- package/src/lib/model/commerce-topics.d.ts +20 -0
- package/src/lib/model/commerce-topics.js +33 -0
- package/src/lib/model/commerce-topics.js.map +1 -0
- package/src/lib/model/index.d.ts +1 -0
- package/src/lib/model/index.js +1 -0
- package/src/lib/model/index.js.map +1 -1
- package/src/lib/model/product-record.d.ts +32 -0
- package/src/lib/model/product-record.js +82 -0
- package/src/lib/model/product-record.js.map +1 -0
- package/src/lib/model/site-member-purchases.d.ts +48 -0
- package/src/lib/model/site-member-purchases.js +50 -0
- package/src/lib/model/site-member-purchases.js.map +1 -0
- package/src/lib/model/storefront-tax-summary.d.ts +121 -0
- package/src/lib/model/storefront-tax-summary.js +148 -0
- package/src/lib/model/storefront-tax-summary.js.map +1 -0
- package/src/lib/plan-entitlements.d.ts +35 -0
- package/src/lib/plan-entitlements.js +54 -0
- package/src/lib/plan-entitlements.js.map +1 -0
- package/src/lib/plugin.js +21 -0
- package/src/lib/plugin.js.map +1 -1
- package/src/lib/server/billing-webhook.js +387 -248
- package/src/lib/server/billing-webhook.js.map +1 -1
- package/src/lib/server/cart-checkout.js +3 -3
- package/src/lib/server/cart-checkout.js.map +1 -1
- package/src/lib/server/checkout.js +2 -2
- package/src/lib/server/checkout.js.map +1 -1
- package/src/lib/server/connect.js +5 -5
- package/src/lib/server/connect.js.map +1 -1
- package/src/lib/server/draft-order.js +2 -2
- package/src/lib/server/draft-order.js.map +1 -1
- package/src/lib/server/gift-cards.js +7 -3
- package/src/lib/server/gift-cards.js.map +1 -1
- package/src/lib/server/low-stock.js +3 -1
- package/src/lib/server/low-stock.js.map +1 -1
- package/src/lib/server/member-post.js +2 -2
- package/src/lib/server/member-post.js.map +1 -1
- package/src/lib/server/membership-admin-password.js +4 -1
- package/src/lib/server/membership-admin-password.js.map +1 -1
- package/src/lib/server/membership-content.js +1 -1
- package/src/lib/server/membership-content.js.map +1 -1
- package/src/lib/server/membership-register.js +13 -2
- package/src/lib/server/membership-register.js.map +1 -1
- package/src/lib/server/newsletter.js +32 -19
- package/src/lib/server/newsletter.js.map +1 -1
- package/src/lib/server/pos-order.js +3 -3
- package/src/lib/server/pos-order.js.map +1 -1
- package/src/lib/server/process-abandoned.js +4 -2
- package/src/lib/server/process-abandoned.js.map +1 -1
- package/src/lib/server/process-restock.js +7 -5
- package/src/lib/server/process-restock.js.map +1 -1
- package/src/lib/server/product-record-index.d.ts +19 -0
- package/src/lib/server/product-record-index.js +83 -0
- package/src/lib/server/product-record-index.js.map +1 -0
- package/src/lib/server/reconcile-stock.js +1 -1
- package/src/lib/server/reconcile-stock.js.map +1 -1
- package/src/lib/server/reserve-stock.js +6 -1
- package/src/lib/server/reserve-stock.js.map +1 -1
- package/src/lib/server/reserve.js +2 -2
- package/src/lib/server/reserve.js.map +1 -1
- package/src/lib/server/revenue-source.d.ts +130 -0
- package/src/lib/server/revenue-source.js +286 -0
- package/src/lib/server/revenue-source.js.map +1 -0
- package/src/lib/server/supplier-outbox.js +16 -1
- package/src/lib/server/supplier-outbox.js.map +1 -1
- package/src/lib/server/supplier-update.js +19 -9
- package/src/lib/server/supplier-update.js.map +1 -1
- package/src/lib/server/tax-profile.d.ts +8 -5
- package/src/lib/server/tax-profile.js +22 -5
- package/src/lib/server/tax-profile.js.map +1 -1
- package/src/lib/server/tax-return-source.d.ts +125 -0
- package/src/lib/server/tax-return-source.js +524 -0
- package/src/lib/server/tax-return-source.js.map +1 -0
- package/src/lib/server/tax-summary.d.ts +92 -0
- package/src/lib/server/tax-summary.js +258 -0
- package/src/lib/server/tax-summary.js.map +1 -0
- package/src/lib/server.d.ts +1 -0
- package/src/lib/server.js +23 -0
- package/src/lib/server.js.map +1 -1
- package/src/lib/starter-templates.d.ts +19 -0
- package/src/lib/starter-templates.js +157 -0
- package/src/lib/starter-templates.js.map +1 -0
- package/src/lib/tenant-emails.d.ts +33 -0
- package/src/lib/tenant-emails.js +551 -0
- package/src/lib/tenant-emails.js.map +1 -0
- package/src/lib/testing/stand-in-crm-record-routes.d.ts +25 -0
- package/src/lib/testing/stand-in-crm-record-routes.js +46 -0
- 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
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
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 *
|
|
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;
|