@aglyn/plugins-commerce 1.0.0-beta.221 → 1.0.0-beta.223

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 (125) hide show
  1. package/README.md +4 -2
  2. package/package.json +14 -13
  3. package/src/lib/components/console/catalog-organization-card.component.js +9 -0
  4. package/src/lib/components/console/catalog-organization-card.component.js.map +1 -1
  5. package/src/lib/components/console/discounts-card.component.js +8 -0
  6. package/src/lib/components/console/discounts-card.component.js.map +1 -1
  7. package/src/lib/components/console/gift-cards-card.component.js +8 -0
  8. package/src/lib/components/console/gift-cards-card.component.js.map +1 -1
  9. package/src/lib/components/console/host-coupons-card.component.js +8 -0
  10. package/src/lib/components/console/host-coupons-card.component.js.map +1 -1
  11. package/src/lib/components/console/host-orders-card.component.js +34 -70
  12. package/src/lib/components/console/host-orders-card.component.js.map +1 -1
  13. package/src/lib/components/console/product-zones.d.ts +7 -6
  14. package/src/lib/components/console/product-zones.js +5 -4
  15. package/src/lib/components/console/product-zones.js.map +1 -1
  16. package/src/lib/components/console/products-hub-card.component.js +86 -284
  17. package/src/lib/components/console/products-hub-card.component.js.map +1 -1
  18. package/src/lib/declarations.console-server.d.ts +38 -0
  19. package/src/lib/declarations.console-server.js +67 -0
  20. package/src/lib/declarations.console-server.js.map +1 -0
  21. package/src/lib/model/commerce-gift-cards.d.ts +26 -0
  22. package/src/lib/model/commerce-gift-cards.js +42 -0
  23. package/src/lib/model/commerce-gift-cards.js.map +1 -1
  24. package/src/lib/model/commerce-orders.d.ts +0 -57
  25. package/src/lib/model/commerce-orders.js +0 -80
  26. package/src/lib/model/commerce-orders.js.map +1 -1
  27. package/src/lib/model/index.d.ts +0 -1
  28. package/src/lib/model/index.js +0 -1
  29. package/src/lib/model/index.js.map +1 -1
  30. package/src/lib/plugin.js +5 -0
  31. package/src/lib/plugin.js.map +1 -1
  32. package/src/lib/server/api-v1/openapi.d.ts +27 -0
  33. package/src/lib/server/api-v1/openapi.js +219 -0
  34. package/src/lib/server/api-v1/openapi.js.map +1 -0
  35. package/src/lib/server/api-v1/orders-and-products.d.ts +19 -0
  36. package/src/lib/server/api-v1/orders-and-products.js +419 -0
  37. package/src/lib/server/api-v1/orders-and-products.js.map +1 -0
  38. package/src/lib/server/billing-webhook.js +10 -13
  39. package/src/lib/server/billing-webhook.js.map +1 -1
  40. package/src/lib/server/download.js +4 -1
  41. package/src/lib/server/download.js.map +1 -1
  42. package/src/lib/server/fulfill-order.d.ts +52 -11
  43. package/src/lib/server/fulfill-order.js +6 -9
  44. package/src/lib/server/fulfill-order.js.map +1 -1
  45. package/src/lib/server/gift-card-issue.d.ts +57 -0
  46. package/src/lib/server/gift-card-issue.js +155 -0
  47. package/src/lib/server/gift-card-issue.js.map +1 -0
  48. package/src/lib/server/gift-cards.js +25 -84
  49. package/src/lib/server/gift-cards.js.map +1 -1
  50. package/src/lib/server/membership-register.js +9 -8
  51. package/src/lib/server/membership-register.js.map +1 -1
  52. package/src/lib/server/newsletter.js +7 -5
  53. package/src/lib/server/newsletter.js.map +1 -1
  54. package/src/lib/server/paid-media-delivery.d.ts +21 -0
  55. package/src/lib/server/paid-media-delivery.js +23 -5
  56. package/src/lib/server/paid-media-delivery.js.map +1 -1
  57. package/src/lib/server/paid-media-serve-count.d.ts +28 -0
  58. package/src/lib/server/paid-media-serve-count.js +46 -0
  59. package/src/lib/server/paid-media-serve-count.js.map +1 -0
  60. package/src/lib/server/person-eraser.d.ts +45 -0
  61. package/src/lib/server/person-eraser.js +57 -0
  62. package/src/lib/server/person-eraser.js.map +1 -0
  63. package/src/lib/server/refund.js +15 -15
  64. package/src/lib/server/refund.js.map +1 -1
  65. package/src/lib/server/restock-flag.d.ts +2 -2
  66. package/src/lib/server/restock-flag.js +1 -1
  67. package/src/lib/server/restock-flag.js.map +1 -1
  68. package/src/lib/server/stream.js +4 -1
  69. package/src/lib/server/stream.js.map +1 -1
  70. package/src/lib/server.js +5 -21
  71. package/src/lib/server.js.map +1 -1
  72. package/src/lib/testing/stand-in-person-records.d.ts +43 -0
  73. package/src/lib/testing/stand-in-person-records.js +63 -0
  74. package/src/lib/testing/stand-in-person-records.js.map +1 -0
  75. package/src/lib/transfer/gift-card-confirm-step.component.d.ts +22 -0
  76. package/src/lib/transfer/gift-card-confirm-step.component.js +259 -0
  77. package/src/lib/transfer/gift-card-confirm-step.component.js.map +1 -0
  78. package/src/lib/transfer/gift-cards-plan.d.ts +29 -0
  79. package/src/lib/transfer/gift-cards-plan.js +40 -0
  80. package/src/lib/transfer/gift-cards-plan.js.map +1 -0
  81. package/src/lib/transfer/gift-cards.server.d.ts +34 -0
  82. package/src/lib/transfer/gift-cards.server.js +296 -0
  83. package/src/lib/transfer/gift-cards.server.js.map +1 -0
  84. package/src/lib/transfer/list-filter.d.ts +29 -0
  85. package/src/lib/transfer/list-filter.js +112 -0
  86. package/src/lib/transfer/list-filter.js.map +1 -0
  87. package/src/lib/transfer/product-import-results.d.ts +29 -0
  88. package/src/lib/transfer/product-import-results.js +49 -0
  89. package/src/lib/transfer/product-import-results.js.map +1 -0
  90. package/src/lib/transfer/product-import-step.component.d.ts +30 -0
  91. package/src/lib/transfer/product-import-step.component.js +99 -0
  92. package/src/lib/transfer/product-import-step.component.js.map +1 -0
  93. package/src/lib/transfer/product-transfer.d.ts +211 -0
  94. package/src/lib/transfer/product-transfer.js +1594 -0
  95. package/src/lib/transfer/product-transfer.js.map +1 -0
  96. package/src/lib/transfer/products.server.d.ts +44 -0
  97. package/src/lib/transfer/products.server.js +550 -0
  98. package/src/lib/transfer/products.server.js.map +1 -0
  99. package/src/lib/transfer/records-transfer.d.ts +182 -0
  100. package/src/lib/transfer/records-transfer.js +1153 -0
  101. package/src/lib/transfer/records-transfer.js.map +1 -0
  102. package/src/lib/transfer/records.server.d.ts +68 -0
  103. package/src/lib/transfer/records.server.js +455 -0
  104. package/src/lib/transfer/records.server.js.map +1 -0
  105. package/src/lib/transfer/register-transfer-resources.d.ts +17 -0
  106. package/src/lib/transfer/register-transfer-resources.js +171 -0
  107. package/src/lib/transfer/register-transfer-resources.js.map +1 -0
  108. package/src/lib/transfer/register-transfer-ui.d.ts +23 -0
  109. package/src/lib/transfer/register-transfer-ui.js +78 -0
  110. package/src/lib/transfer/register-transfer-ui.js.map +1 -0
  111. package/src/lib/transfer/server-common.d.ts +76 -0
  112. package/src/lib/transfer/server-common.js +165 -0
  113. package/src/lib/transfer/server-common.js.map +1 -0
  114. package/src/lib/transfer/transfer-header-actions.component.d.ts +39 -0
  115. package/src/lib/transfer/transfer-header-actions.component.js +79 -0
  116. package/src/lib/transfer/transfer-header-actions.component.js.map +1 -0
  117. package/src/lib/transfer/transfer-keys.d.ts +31 -0
  118. package/src/lib/transfer/transfer-keys.js +56 -0
  119. package/src/lib/transfer/transfer-keys.js.map +1 -0
  120. package/src/lib/model/commerce-io.d.ts +0 -36
  121. package/src/lib/model/commerce-io.js +0 -245
  122. package/src/lib/model/commerce-io.js.map +0 -1
  123. package/src/lib/server/contact-refund.d.ts +0 -117
  124. package/src/lib/server/contact-refund.js +0 -196
  125. package/src/lib/server/contact-refund.js.map +0 -1
@@ -18,6 +18,7 @@ import * as CommerceModel from "../model/index.js";
18
18
  import { firebaseAdmin, PAID_DOWNLOAD_LINK_TTL_MS } from "@aglyn/tenant-data-admin";
19
19
  import { createPaidMediaDeliveryIo, resolvePaidMediaDelivery } from "./paid-media-delivery.js";
20
20
  import { createHmac, timingSafeEqual } from "crypto";
21
+ import { countOffRouteServe } from "./paid-media-serve-count.js";
21
22
  /**
22
23
  * Signing secret for commerce tokens (AGL-509). A dedicated env var with NO
23
24
  * fallback: the previous `STRIPE_SECRET_KEY ?? 'aglyn'` recipe made download
@@ -207,7 +208,9 @@ function signDownload(hostId, orderId, exp) {
207
208
  // The link dies within the hour, so a refund, an expired receipt token or
208
209
  // a spent limit also ends what a forwarded `Location` can fetch: the next
209
210
  // download has to come back through this route and its checks.
210
- return res.redirect(302, delivery.location);
211
+ res.redirect(302, delivery.location);
212
+ await countOffRouteServe(firestore, delivery);
213
+ return;
211
214
  } catch (error) {
212
215
  console.error(error);
213
216
  return res.status(500).send('Download unavailable');
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/download.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 { flagLookalikeDomain } from '@aglyn/tenant-data-admin/server/hosted-page-review'\nimport type { PluginApiHandler } from '@aglyn/aglyn/server'\nimport * as Aglyn from '@aglyn/aglyn/server'\nimport * as CommerceModel from '../model'\nimport {\n firebaseAdmin,\n PAID_DOWNLOAD_LINK_TTL_MS,\n} from '@aglyn/tenant-data-admin'\nimport {\n createPaidMediaDeliveryIo,\n resolvePaidMediaDelivery,\n} from './paid-media-delivery'\nimport { createHmac, timingSafeEqual } from 'crypto'\n\n/**\n * Signing secret for commerce tokens (AGL-509). A dedicated env var with NO\n * fallback: the previous `STRIPE_SECRET_KEY ?? 'aglyn'` recipe made download\n * and supplier tokens forgeable on any deploy that had not set the Stripe key\n * (payloads like `download:${hostId}:${orderId}` are guessable). Fails closed.\n */\nexport function tokenSigningSecret(): string {\n const secret = process.env.TOKEN_SIGNING_SECRET\n if (!secret) {\n throw new Error('TOKEN_SIGNING_SECRET is not configured')\n }\n return secret\n}\n\n/** Download links expire 90 days after minting (AGL-514). */\nconst DOWNLOAD_TOKEN_TTL_MS = 90 * 24 * 60 * 60 * 1000\n\nfunction signDownload(hostId: string, orderId: string, exp: number): string {\n return createHmac('sha256', tokenSigningSecret())\n .update(`download:${hostId}:${orderId}:${exp}`)\n .digest('hex')\n .slice(0, 32)\n}\n\n/**\n * Order-scoped download token (AGL-302). Carries an embedded expiry (AGL-514)\n * so a leaked receipt link doesn't grant perpetual re-download; the per-order\n * download limit still bounds use within the window. Format: `${expMs}.${sig}`.\n */\nexport function mintDownloadToken(hostId: string, orderId: string): string {\n const exp = Date.now() + DOWNLOAD_TOKEN_TTL_MS\n return `${exp}.${signDownload(hostId, orderId, exp)}`\n}\n\n/** Constant-time verify of a download token, including its expiry. */\nexport function verifyDownloadToken(\n hostId: string,\n orderId: string,\n token: string,\n): boolean {\n const dot = token.indexOf('.')\n if (dot <= 0) return false\n const exp = Number(token.slice(0, dot))\n const sig = token.slice(dot + 1)\n if (!Number.isFinite(exp) || Date.now() > exp) return false\n const expected = signDownload(hostId, orderId, exp)\n const a = Buffer.from(sig)\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 * Digital delivery (AGL-302): token-gated download for a paid order's\n * digital product. Buyers always get the product's CURRENT files (new\n * versions re-deliver automatically); attempts count against the\n * product's download limit per order.\n */\nexport const downloadHandler: PluginApiHandler = async (req, res) => {\n const hostId = String(req.query.hostId ?? '')\n const orderId = String(req.query.orderId ?? '')\n const token = String(req.query.token ?? '')\n const productId = String(req.query.productId ?? '')\n const fileIndex = Math.max(0, Number(req.query.file ?? 0))\n if (!hostId || !orderId || !token || !productId) {\n return res.status(400).send('Missing parameters')\n }\n if (!verifyDownloadToken(hostId, orderId, token)) {\n return res.status(403).send('Invalid or expired download link')\n }\n try {\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(hostId)\n const orderRef = hostRef.collection('orders').doc(orderId)\n const [orderSnapshot, productSnapshot] = await Promise.all([\n orderRef.get(),\n hostRef.collection('products').doc(productId).get(),\n ])\n if (!orderSnapshot.exists) return res.status(404).send('Unknown order')\n const order = CommerceModel.liftLegacyOrder(orderSnapshot.data() as any)\n // ONE ENTITLEMENT TEST, AND IT KNOWS ABOUT PARTIAL REFUNDS (AGL-2454).\n //\n // This used to be a status test against the literal `'refunded'` followed\n // by an ownership test, and `refund.ts` writes that literal only when the\n // order is FULLY refunded — so a 99%-refunded order stayed `paid` and this\n // gate went on serving the files. The two questions are now one call, and\n // it also answers \"was THIS line refunded by name\", which is the case a\n // line-scoped refund creates.\n //\n // The order-level refusal is kept separate from the ownership one so the\n // shopper is told which of the two happened; collapsing them would answer\n // \"not part of this order\" to a buyer whose own order was cancelled.\n if (!CommerceModel.orderEntitlesProduct(order, 'any')) {\n return res.status(403).send('This order cannot download files')\n }\n if (!CommerceModel.orderContainsProduct(order, productId)) {\n return res.status(403).send('Not part of this order')\n }\n if (!CommerceModel.orderEntitlesProduct(order, productId)) {\n return res.status(403).send('This purchase was refunded')\n }\n const product = CommerceModel.liftLegacyProduct(\n (productSnapshot.data() as any) ?? {},\n )\n const file = product.digitalFiles?.[fileIndex]\n if (!file?.url) return res.status(404).send('No file available')\n\n /**\n * Where the redirect lands, decided BEFORE an attempt is counted\n * (AGL-2847).\n *\n * Everything above only guards the 302, so what the 302 points at is what\n * a buyer, and anyone they forward the `Location` to, keeps. The resolver\n * hands back a link that expires, or nothing: a CDN URL signed for\n * {@link PAID_DOWNLOAD_LINK_TTL_MS} for a PRIVATE file in this site's\n * library or its org's, a V4 signed Storage read for an object no media\n * document owns, or an author-typed hotlink unchanged. A raw Storage\n * download URL is never the answer, and a file that is still public is\n * refused, because its URL already works for anyone forever.\n *\n * Resolving first means a refusal costs the buyer no attempt: the limit\n * meters files that were handed out, not the seller's setup.\n *\n * `?download=1` rides inside the signed URL and makes the CDN answer with\n * `attachment`. The signature covers `(scope, mediaId, exp)` and not the\n * parameter, so it chooses a disposition for the one file signed and can\n * reach no other.\n */\n const delivery = await resolvePaidMediaDelivery({\n stored: file.url,\n hostId,\n ttlMs: PAID_DOWNLOAD_LINK_TTL_MS,\n cdnParams: [['download', '1']],\n io: createPaidMediaDeliveryIo({\n firestore,\n bucket: firebaseAdmin\n .app()\n .storage()\n .bucket(process.env['NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET']),\n }),\n })\n if (delivery.ok === false) {\n res.setHeader('Cache-Control', 'no-store')\n console.error(\n '[commerce/download] paid file not delivered',\n JSON.stringify({\n hostId,\n orderId,\n productId,\n file: fileIndex,\n refusal: delivery.refusal,\n }),\n )\n // A lookalike hotlink is filed for staff and the merchant (AGL-3363).\n if (delivery.refusal === 'lookalike' && delivery.host) {\n await flagLookalikeDomain({\n kind: 'link',\n hostId,\n orgId: null,\n domain: delivery.host,\n where: \"a product's download link\",\n })\n }\n return delivery.refusal === 'not-private'\n ? res\n .status(409)\n .send('This file is not ready to download yet — contact the seller')\n : res.status(404).send('No file available')\n }\n\n // ATTEMPT ACCOUNTING, IN ONE TRANSACTION (AGL-2275).\n //\n // This was a read-then-write across an await, from a snapshot fetched\n // before the check, with the failure swallowed — so N parallel requests\n // all read the same count, all passed the limit, and all wrote\n // `attempts + 1`. The counter advanced by one for N downloads, which\n // makes `downloadLimit` unenforceable rather than merely approximate: a\n // buyer who fans out ten requests takes ten copies against a limit of one.\n // For a paid digital product that is the merchant's goods given away.\n //\n // It also wrote the WHOLE `downloadAttempts` map back from that stale\n // snapshot, so a concurrent download of a DIFFERENT product in the same\n // order had its increment erased.\n //\n // The half-finished conversion was visible in the file: `attemptsKey`\n // built the dotted path `downloadAttempts.{productId}` and the next line\n // threw it away with `void attemptsKey`. The dotted path is the right\n // instrument and needs `update()` — which merges only that nested key, and\n // whose \"document must exist\" precondition the transaction's own read has\n // just satisfied. `set(..., { merge: true })` cannot express it: a dotted\n // key there is a literal field name with a dot in it.\n const attemptsKey = `downloadAttempts.${productId}`\n const limit =\n product.downloadLimit != null\n ? Math.max(1, product.downloadLimit)\n : null\n const withinLimit = await firestore.runTransaction(async (transaction) => {\n const fresh = await transaction.get(orderRef)\n if (!fresh.exists) return false\n const attempts = Number(\n (fresh.get('downloadAttempts') ?? {})[productId] ?? 0,\n )\n if (limit != null && attempts >= limit) return false\n transaction.update(orderRef, { [attemptsKey]: attempts + 1 })\n return true\n })\n if (!withinLimit) {\n return res\n .status(429)\n .send('Download limit reached — contact the seller for help')\n }\n // Per-order and time-boxed: no cache may keep a redirect that carries a\n // signature past the link it opens.\n res.setHeader('Cache-Control', 'no-store')\n // The link dies within the hour, so a refund, an expired receipt token or\n // a spent limit also ends what a forwarded `Location` can fetch: the next\n // download has to come back through this route and its checks.\n return res.redirect(302, delivery.location)\n } catch (error) {\n console.error(error)\n return res.status(500).send('Download unavailable')\n }\n}\n"],"names":["flagLookalikeDomain","CommerceModel","firebaseAdmin","PAID_DOWNLOAD_LINK_TTL_MS","createPaidMediaDeliveryIo","resolvePaidMediaDelivery","createHmac","timingSafeEqual","tokenSigningSecret","secret","process","env","TOKEN_SIGNING_SECRET","Error","DOWNLOAD_TOKEN_TTL_MS","signDownload","hostId","orderId","exp","update","digest","slice","mintDownloadToken","Date","now","verifyDownloadToken","token","dot","indexOf","Number","sig","isFinite","expected","a","Buffer","from","b","length","Uint8Array","downloadHandler","req","res","String","query","productId","fileIndex","Math","max","file","status","send","productSnapshot","product","firestore","app","hostRef","collection","doc","orderRef","orderSnapshot","Promise","all","get","exists","order","liftLegacyOrder","data","orderEntitlesProduct","orderContainsProduct","liftLegacyProduct","digitalFiles","url","delivery","stored","ttlMs","cdnParams","io","bucket","storage","ok","setHeader","console","error","JSON","stringify","refusal","host","kind","orgId","domain","where","attemptsKey","limit","downloadLimit","withinLimit","runTransaction","transaction","fresh","attempts","redirect","location"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/download.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 { flagLookalikeDomain } from '@aglyn/tenant-data-admin/server/hosted-page-review'\nimport type { PluginApiHandler } from '@aglyn/aglyn/server'\nimport * as Aglyn from '@aglyn/aglyn/server'\nimport * as CommerceModel from '../model'\nimport {\n firebaseAdmin,\n PAID_DOWNLOAD_LINK_TTL_MS,\n} from '@aglyn/tenant-data-admin'\nimport {\n createPaidMediaDeliveryIo,\n resolvePaidMediaDelivery,\n} from './paid-media-delivery'\nimport { createHmac, timingSafeEqual } from 'crypto'\nimport { countOffRouteServe } from './paid-media-serve-count'\n\n/**\n * Signing secret for commerce tokens (AGL-509). A dedicated env var with NO\n * fallback: the previous `STRIPE_SECRET_KEY ?? 'aglyn'` recipe made download\n * and supplier tokens forgeable on any deploy that had not set the Stripe key\n * (payloads like `download:${hostId}:${orderId}` are guessable). Fails closed.\n */\nexport function tokenSigningSecret(): string {\n const secret = process.env.TOKEN_SIGNING_SECRET\n if (!secret) {\n throw new Error('TOKEN_SIGNING_SECRET is not configured')\n }\n return secret\n}\n\n/** Download links expire 90 days after minting (AGL-514). */\nconst DOWNLOAD_TOKEN_TTL_MS = 90 * 24 * 60 * 60 * 1000\n\nfunction signDownload(hostId: string, orderId: string, exp: number): string {\n return createHmac('sha256', tokenSigningSecret())\n .update(`download:${hostId}:${orderId}:${exp}`)\n .digest('hex')\n .slice(0, 32)\n}\n\n/**\n * Order-scoped download token (AGL-302). Carries an embedded expiry (AGL-514)\n * so a leaked receipt link doesn't grant perpetual re-download; the per-order\n * download limit still bounds use within the window. Format: `${expMs}.${sig}`.\n */\nexport function mintDownloadToken(hostId: string, orderId: string): string {\n const exp = Date.now() + DOWNLOAD_TOKEN_TTL_MS\n return `${exp}.${signDownload(hostId, orderId, exp)}`\n}\n\n/** Constant-time verify of a download token, including its expiry. */\nexport function verifyDownloadToken(\n hostId: string,\n orderId: string,\n token: string,\n): boolean {\n const dot = token.indexOf('.')\n if (dot <= 0) return false\n const exp = Number(token.slice(0, dot))\n const sig = token.slice(dot + 1)\n if (!Number.isFinite(exp) || Date.now() > exp) return false\n const expected = signDownload(hostId, orderId, exp)\n const a = Buffer.from(sig)\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 * Digital delivery (AGL-302): token-gated download for a paid order's\n * digital product. Buyers always get the product's CURRENT files (new\n * versions re-deliver automatically); attempts count against the\n * product's download limit per order.\n */\nexport const downloadHandler: PluginApiHandler = async (req, res) => {\n const hostId = String(req.query.hostId ?? '')\n const orderId = String(req.query.orderId ?? '')\n const token = String(req.query.token ?? '')\n const productId = String(req.query.productId ?? '')\n const fileIndex = Math.max(0, Number(req.query.file ?? 0))\n if (!hostId || !orderId || !token || !productId) {\n return res.status(400).send('Missing parameters')\n }\n if (!verifyDownloadToken(hostId, orderId, token)) {\n return res.status(403).send('Invalid or expired download link')\n }\n try {\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(hostId)\n const orderRef = hostRef.collection('orders').doc(orderId)\n const [orderSnapshot, productSnapshot] = await Promise.all([\n orderRef.get(),\n hostRef.collection('products').doc(productId).get(),\n ])\n if (!orderSnapshot.exists) return res.status(404).send('Unknown order')\n const order = CommerceModel.liftLegacyOrder(orderSnapshot.data() as any)\n // ONE ENTITLEMENT TEST, AND IT KNOWS ABOUT PARTIAL REFUNDS (AGL-2454).\n //\n // This used to be a status test against the literal `'refunded'` followed\n // by an ownership test, and `refund.ts` writes that literal only when the\n // order is FULLY refunded — so a 99%-refunded order stayed `paid` and this\n // gate went on serving the files. The two questions are now one call, and\n // it also answers \"was THIS line refunded by name\", which is the case a\n // line-scoped refund creates.\n //\n // The order-level refusal is kept separate from the ownership one so the\n // shopper is told which of the two happened; collapsing them would answer\n // \"not part of this order\" to a buyer whose own order was cancelled.\n if (!CommerceModel.orderEntitlesProduct(order, 'any')) {\n return res.status(403).send('This order cannot download files')\n }\n if (!CommerceModel.orderContainsProduct(order, productId)) {\n return res.status(403).send('Not part of this order')\n }\n if (!CommerceModel.orderEntitlesProduct(order, productId)) {\n return res.status(403).send('This purchase was refunded')\n }\n const product = CommerceModel.liftLegacyProduct(\n (productSnapshot.data() as any) ?? {},\n )\n const file = product.digitalFiles?.[fileIndex]\n if (!file?.url) return res.status(404).send('No file available')\n\n /**\n * Where the redirect lands, decided BEFORE an attempt is counted\n * (AGL-2847).\n *\n * Everything above only guards the 302, so what the 302 points at is what\n * a buyer, and anyone they forward the `Location` to, keeps. The resolver\n * hands back a link that expires, or nothing: a CDN URL signed for\n * {@link PAID_DOWNLOAD_LINK_TTL_MS} for a PRIVATE file in this site's\n * library or its org's, a V4 signed Storage read for an object no media\n * document owns, or an author-typed hotlink unchanged. A raw Storage\n * download URL is never the answer, and a file that is still public is\n * refused, because its URL already works for anyone forever.\n *\n * Resolving first means a refusal costs the buyer no attempt: the limit\n * meters files that were handed out, not the seller's setup.\n *\n * `?download=1` rides inside the signed URL and makes the CDN answer with\n * `attachment`. The signature covers `(scope, mediaId, exp)` and not the\n * parameter, so it chooses a disposition for the one file signed and can\n * reach no other.\n */\n const delivery = await resolvePaidMediaDelivery({\n stored: file.url,\n hostId,\n ttlMs: PAID_DOWNLOAD_LINK_TTL_MS,\n cdnParams: [['download', '1']],\n io: createPaidMediaDeliveryIo({\n firestore,\n bucket: firebaseAdmin\n .app()\n .storage()\n .bucket(process.env['NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET']),\n }),\n })\n if (delivery.ok === false) {\n res.setHeader('Cache-Control', 'no-store')\n console.error(\n '[commerce/download] paid file not delivered',\n JSON.stringify({\n hostId,\n orderId,\n productId,\n file: fileIndex,\n refusal: delivery.refusal,\n }),\n )\n // A lookalike hotlink is filed for staff and the merchant (AGL-3363).\n if (delivery.refusal === 'lookalike' && delivery.host) {\n await flagLookalikeDomain({\n kind: 'link',\n hostId,\n orgId: null,\n domain: delivery.host,\n where: \"a product's download link\",\n })\n }\n return delivery.refusal === 'not-private'\n ? res\n .status(409)\n .send('This file is not ready to download yet — contact the seller')\n : res.status(404).send('No file available')\n }\n\n // ATTEMPT ACCOUNTING, IN ONE TRANSACTION (AGL-2275).\n //\n // This was a read-then-write across an await, from a snapshot fetched\n // before the check, with the failure swallowed — so N parallel requests\n // all read the same count, all passed the limit, and all wrote\n // `attempts + 1`. The counter advanced by one for N downloads, which\n // makes `downloadLimit` unenforceable rather than merely approximate: a\n // buyer who fans out ten requests takes ten copies against a limit of one.\n // For a paid digital product that is the merchant's goods given away.\n //\n // It also wrote the WHOLE `downloadAttempts` map back from that stale\n // snapshot, so a concurrent download of a DIFFERENT product in the same\n // order had its increment erased.\n //\n // The half-finished conversion was visible in the file: `attemptsKey`\n // built the dotted path `downloadAttempts.{productId}` and the next line\n // threw it away with `void attemptsKey`. The dotted path is the right\n // instrument and needs `update()` — which merges only that nested key, and\n // whose \"document must exist\" precondition the transaction's own read has\n // just satisfied. `set(..., { merge: true })` cannot express it: a dotted\n // key there is a literal field name with a dot in it.\n const attemptsKey = `downloadAttempts.${productId}`\n const limit =\n product.downloadLimit != null\n ? Math.max(1, product.downloadLimit)\n : null\n const withinLimit = await firestore.runTransaction(async (transaction) => {\n const fresh = await transaction.get(orderRef)\n if (!fresh.exists) return false\n const attempts = Number(\n (fresh.get('downloadAttempts') ?? {})[productId] ?? 0,\n )\n if (limit != null && attempts >= limit) return false\n transaction.update(orderRef, { [attemptsKey]: attempts + 1 })\n return true\n })\n if (!withinLimit) {\n return res\n .status(429)\n .send('Download limit reached — contact the seller for help')\n }\n // Per-order and time-boxed: no cache may keep a redirect that carries a\n // signature past the link it opens.\n res.setHeader('Cache-Control', 'no-store')\n // The link dies within the hour, so a refund, an expired receipt token or\n // a spent limit also ends what a forwarded `Location` can fetch: the next\n // download has to come back through this route and its checks.\n res.redirect(302, delivery.location)\n await countOffRouteServe(firestore, delivery)\n return\n } catch (error) {\n console.error(error)\n return res.status(500).send('Download unavailable')\n }\n}\n"],"names":["flagLookalikeDomain","CommerceModel","firebaseAdmin","PAID_DOWNLOAD_LINK_TTL_MS","createPaidMediaDeliveryIo","resolvePaidMediaDelivery","createHmac","timingSafeEqual","countOffRouteServe","tokenSigningSecret","secret","process","env","TOKEN_SIGNING_SECRET","Error","DOWNLOAD_TOKEN_TTL_MS","signDownload","hostId","orderId","exp","update","digest","slice","mintDownloadToken","Date","now","verifyDownloadToken","token","dot","indexOf","Number","sig","isFinite","expected","a","Buffer","from","b","length","Uint8Array","downloadHandler","req","res","String","query","productId","fileIndex","Math","max","file","status","send","productSnapshot","product","firestore","app","hostRef","collection","doc","orderRef","orderSnapshot","Promise","all","get","exists","order","liftLegacyOrder","data","orderEntitlesProduct","orderContainsProduct","liftLegacyProduct","digitalFiles","url","delivery","stored","ttlMs","cdnParams","io","bucket","storage","ok","setHeader","console","error","JSON","stringify","refusal","host","kind","orgId","domain","where","attemptsKey","limit","downloadLimit","withinLimit","runTransaction","transaction","fresh","attempts","redirect","location"],"mappings":"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"}
@@ -14,23 +14,64 @@
14
14
  * See the License for the specific language governing permissions and
15
15
  * limitations under the License.
16
16
  */
17
- import { type PluginApiHandler, type RecordShipmentOutcome, type RecordShipmentRequest } from '@aglyn/aglyn/server';
17
+ import { type PluginApiHandler } from '@aglyn/aglyn/server';
18
+ /**
19
+ * The two fulfilment-side transitions, and ONLY those.
20
+ *
21
+ * `cancelled` releases stock under its own transaction and `refunded` moves
22
+ * money under another, so neither belongs to a write whose contract is "a
23
+ * forward status flip plus a timeline entry, no stock moved, no money moved".
24
+ * Widening this union is how a caller gets a door around the specifics those
25
+ * routes exist to enforce — the type is the door being shut.
26
+ */
27
+ export type OrderFulfilmentTarget = 'fulfilled' | 'delivered';
28
+ export interface RecordShipmentRequest {
29
+ /** Site that owns the order. The CALLER has already proven ownership. */
30
+ hostId: string;
31
+ orderId: string;
32
+ to: OrderFulfilmentTarget;
33
+ /** Free text, e.g. `'UPS'`. Bounded by the caller. */
34
+ carrier?: string;
35
+ trackingNumber?: string;
36
+ }
37
+ /**
38
+ * What happened, as a domain fact rather than an HTTP status — each caller
39
+ * maps it into its own error vocabulary (the console route into its JSON
40
+ * shape, `/v1` into the published error envelope), because those two
41
+ * vocabularies are contracts with different audiences and neither may leak
42
+ * into the other.
43
+ *
44
+ * `already` is a SUCCESS and is distinct from `recorded` on purpose: a retried
45
+ * request finds the order in the target status and returns without writing, so
46
+ * a lost response can never append a second copy of the same shipment. A
47
+ * caller that folded the two together would still be correct about the state
48
+ * and would lose the only signal that says "this was your retry".
49
+ */
50
+ export type RecordShipmentOutcome = {
51
+ outcome: 'recorded';
52
+ } | {
53
+ outcome: 'already';
54
+ } | {
55
+ outcome: 'no_such_order';
56
+ }
57
+ /** The transition rule refused. `from` is the status that refused it. */
58
+ | {
59
+ outcome: 'blocked';
60
+ from: string;
61
+ };
18
62
  /**
19
63
  * Record a shipment — the transaction, with NO authorization of any kind
20
64
  * (AGL-2461).
21
65
  *
22
66
  * This is the whole of what `fulfillOrderHandler` used to do below its auth
23
67
  * preamble, lifted out unchanged so a SECOND caller can have it: the customer
24
- * REST API's `PATCH /v1/sites/{id}/orders/{id}`, which authenticates an org
25
- * API key and so shares not one line of this function's former preamble.
26
- *
27
- * Lifted rather than copied, and that is the entire point of the change. The
28
- * console app may not import this library (the `scope:app` boundary), so the
29
- * alternative on the table was a second `ORDER_TRANSITIONS` inside `/v1` —
30
- * two tables that drift into the API writing a status the console forbids.
31
- * One implementation, reached from the app through the core
32
- * `registerOrderFulfilmentService` registry, is the fix; see that registry's
33
- * docblock for why the edge runs that way.
68
+ * REST API's `PATCH /v1/sites/{id}/orders/{id}`
69
+ * (`api-v1/orders-and-products.ts`), which authenticates an org API key and
70
+ * so shares not one line of this function's former preamble.
71
+ *
72
+ * Lifted rather than copied, and that is the entire point: a second
73
+ * `ORDER_TRANSITIONS` inside `/v1` would be two tables that drift into the
74
+ * API writing a status the console forbids.
34
75
  *
35
76
  * **The caller authorizes.** Everything this function knows is the transition
36
77
  * rule. It does not know who is asking, and it will happily move any order on
@@ -23,16 +23,13 @@ import { firebaseAdmin } from "@aglyn/tenant-data-admin";
23
23
  *
24
24
  * This is the whole of what `fulfillOrderHandler` used to do below its auth
25
25
  * preamble, lifted out unchanged so a SECOND caller can have it: the customer
26
- * REST API's `PATCH /v1/sites/{id}/orders/{id}`, which authenticates an org
27
- * API key and so shares not one line of this function's former preamble.
26
+ * REST API's `PATCH /v1/sites/{id}/orders/{id}`
27
+ * (`api-v1/orders-and-products.ts`), which authenticates an org API key and
28
+ * so shares not one line of this function's former preamble.
28
29
  *
29
- * Lifted rather than copied, and that is the entire point of the change. The
30
- * console app may not import this library (the `scope:app` boundary), so the
31
- * alternative on the table was a second `ORDER_TRANSITIONS` inside `/v1` —
32
- * two tables that drift into the API writing a status the console forbids.
33
- * One implementation, reached from the app through the core
34
- * `registerOrderFulfilmentService` registry, is the fix; see that registry's
35
- * docblock for why the edge runs that way.
30
+ * Lifted rather than copied, and that is the entire point: a second
31
+ * `ORDER_TRANSITIONS` inside `/v1` would be two tables that drift into the
32
+ * API writing a status the console forbids.
36
33
  *
37
34
  * **The caller authorizes.** Everything this function knows is the transition
38
35
  * rule. It does not know who is asking, and it will happily move any order on
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/fulfill-order.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 CommerceModel from '../model'\nimport {\n createResourceUid,\n type OrderFulfilmentTarget,\n type PluginApiHandler,\n type RecordShipmentOutcome,\n type RecordShipmentRequest,\n} from '@aglyn/aglyn/server'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin'\n\n/**\n * Record a shipment — the transaction, with NO authorization of any kind\n * (AGL-2461).\n *\n * This is the whole of what `fulfillOrderHandler` used to do below its auth\n * preamble, lifted out unchanged so a SECOND caller can have it: the customer\n * REST API's `PATCH /v1/sites/{id}/orders/{id}`, which authenticates an org\n * API key and so shares not one line of this function's former preamble.\n *\n * Lifted rather than copied, and that is the entire point of the change. The\n * console app may not import this library (the `scope:app` boundary), so the\n * alternative on the table was a second `ORDER_TRANSITIONS` inside `/v1` —\n * two tables that drift into the API writing a status the console forbids.\n * One implementation, reached from the app through the core\n * `registerOrderFulfilmentService` registry, is the fix; see that registry's\n * docblock for why the edge runs that way.\n *\n * **The caller authorizes.** Everything this function knows is the transition\n * rule. It does not know who is asking, and it will happily move any order on\n * any host — `hostId` is taken on trust because its two callers establish\n * trust in ways that have nothing in common (a Firebase uid in `memberRoles`\n * for the console; an org credential that owns the host for `/v1`).\n *\n * Every guarantee AGL-1819 built is inside the transaction and therefore\n * inherited by both callers: `canTransitionOrder` is re-asked under the write\n * so a stale caller cannot skip a state, the already-in-target case returns\n * without writing so a retry cannot append a duplicate shipment, and\n * `lineItemIds` and the timeline are computed from the transaction's OWN read\n * so a note landed from another tab survives.\n */\nexport async function recordOrderShipment({\n hostId,\n orderId,\n to,\n carrier,\n trackingNumber,\n}: RecordShipmentRequest): Promise<RecordShipmentOutcome> {\n const firestore = firebaseAdmin.app().firestore()\n const orderRef = firestore\n .collection('hosts')\n .doc(hostId)\n .collection('orders')\n .doc(orderId)\n\n return firestore.runTransaction(\n async (transaction): Promise<RecordShipmentOutcome> => {\n const snapshot = await transaction.get(orderRef)\n if (!snapshot.exists) return { outcome: 'no_such_order' }\n const order = CommerceModel.liftLegacyOrder(\n (snapshot.data() ?? {}) as never,\n )\n // A redelivered click, or the second of two admins. Success because it\n // IS the state the caller asked for — but nothing is written, so a\n // retry cannot append a second copy of the same fulfillment.\n if (order.status === to) return { outcome: 'already' }\n if (!CommerceModel.canTransitionOrder(order.status, to)) {\n return { outcome: 'blocked', from: order.status }\n }\n\n const atMs = Date.now()\n const patch: Record<string, unknown> =\n to === 'fulfilled'\n ? {\n status: 'fulfilled',\n fulfillments: [\n ...(order.fulfillments ?? []),\n {\n id: createResourceUid(),\n lineItemIds: (order.lineItems ?? []).map(\n (_line, index) => index,\n ),\n ...(carrier ? { carrier } : {}),\n ...(trackingNumber ? { trackingNumber } : {}),\n atMs,\n } satisfies CommerceModel.OrderFulfillment,\n ],\n timeline: CommerceModel.appendOrderEvent(\n order,\n 'fulfilled',\n trackingNumber\n ? `${carrier || 'Shipped'} ${trackingNumber}`\n : 'Fulfilled',\n atMs,\n ),\n }\n : {\n status: 'delivered',\n timeline: CommerceModel.appendOrderEvent(\n order,\n 'delivered',\n undefined,\n atMs,\n ),\n }\n transaction.update(orderRef, patch)\n return { outcome: 'recorded' }\n },\n )\n}\n\n/**\n * Fulfil and mark-delivered, with the transition re-asked under the write\n * (AGL-1819) — the last two order-status writes the console made with a client\n * `updateDoc`, moved behind the same shape as `cancel-order.ts`.\n *\n * THE STALE-DIALOG HOLE IS THE SAME ONE AGL-1818 CLOSED FOR CANCEL.\n * `canTransitionOrder` was consulted only to RENDER the buttons, so a dialog\n * opened on a `paid` order that was refunded (or cancelled) in another tab\n * could still write `fulfilled` straight onto it — `ORDER_TRANSITIONS` stayed\n * advisory because nothing consulted it on the way IN. Re-reading the order\n * inside the transaction that writes the status is what turns the table into\n * a guarantee; the race comes back as a 409 naming the state that refused.\n *\n * ONE ROUTE FOR THE TWO FULFILMENT-SIDE TRANSITIONS, and ONLY those. They are\n * the same act — a forward status flip plus a timeline entry, no stock moved,\n * no money moved — so they share a handler parameterized on `to`. It refuses\n * every other target with a 400 on purpose: `cancelled` releases stock under\n * its own transaction (`cancel-order.ts`) and `refunded` moves money\n * (`refund.ts`), so admitting them here would hand callers a door around\n * exactly the specifics those routes exist to enforce.\n *\n * NO STOCK MOVES HERE. The sale decremented inventory when the money landed\n * (see `cancel-order.ts`'s header); shipping the goods changes what is on the\n * shelf not at all. An open `restockCheck` — a partial refund's question,\n * flagged before the merchant ships the rest — survives untouched: the write\n * is an `update()` naming only status, fulfillments and timeline, and the\n * question is still the merchant's to answer.\n *\n * NO NOTIFICATION EITHER, matching the client write this replaces: the\n * merchant fulfilled the order themselves, and `notifyHostManagers` would\n * tell them what they just did. (`supplier-update.ts` notifies because there\n * the SUPPLIER acted and the merchant is the one who needs to hear it.)\n *\n * ONCE. A retried request — a lost response, a second click — finds the order\n * already in the target status and returns success WITHOUT writing: appending\n * a second copy of the same fulfillment is the double this guard exists to\n * stop. It reports `already: true` so the console can say so. A fulfil retried\n * after the order moved on to `delivered` in another tab is a 409 instead,\n * which is the truth: that state cannot be fulfilled (again).\n *\n * THE SERVER'S ORDER, NOT THE DIALOG'S: `lineItemIds` and the timeline append\n * are computed from the transaction's own read, so a note landed from another\n * tab survives — the client write it replaces appended to the dialog's stale\n * copy and dropped it.\n *\n * Role gate: `admin` and `editor`, the role the client write already had\n * under the rules' host catch-all — same reasoning as `cancel-order.ts`.\n */\nexport const fulfillOrderHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) return res.status(401).json({ error: 'Unauthenticated' })\n const body =\n typeof req.body === 'string' ? JSON.parse(req.body) : (req.body ?? {})\n const hostId = String(body.hostId ?? '')\n const orderId = String(body.orderId ?? '')\n if (!hostId || !orderId) {\n return res.status(400).json({ error: 'Missing hostId or orderId' })\n }\n // Firestore reserves `__…__` ids and `.doc()` throws SYNCHRONOUSLY on one,\n // which would surface as a 500 on a caller's typo rather than the 400 it is.\n if (/^__.*__$/.test(hostId) || /^__.*__$/.test(orderId)) {\n return res.status(400).json({ error: 'Missing hostId or orderId' })\n }\n const to = String(body.to ?? '') as OrderFulfilmentTarget\n if (to !== 'fulfilled' && to !== 'delivered') {\n // Deliberately NOT a generic transition endpoint — see the header.\n return res.status(400).json({ error: 'to must be fulfilled or delivered' })\n }\n const carrier = String(body.carrier ?? '').slice(0, 40)\n const trackingNumber = String(body.trackingNumber ?? '').slice(0, 60)\n\n try {\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(hostId)\n const hostSnapshot = await hostRef.get()\n if (!hostSnapshot.exists) {\n return res.status(404).json({ error: 'Unknown site' })\n }\n const memberRole = (hostSnapshot.get('memberRoles') ?? {})[decoded.uid]\n if (memberRole !== 'admin' && memberRole !== 'editor') {\n return res.status(403).json({ error: 'Not permitted' })\n }\n\n // Authorized above; the shipment itself is the shared transaction. The\n // wire shape below is unchanged — the console dialog branches on `ok`,\n // `already` and the 409's `error` string, and this route's contract is\n // with that dialog, not with the registry's vocabulary.\n const outcome = await recordOrderShipment({\n hostId,\n orderId,\n to,\n carrier,\n trackingNumber,\n })\n if (outcome.outcome === 'no_such_order') {\n return res.status(404).json({ error: 'Unknown order' })\n }\n if (outcome.outcome === 'blocked') {\n return res.status(409).json({\n error:\n to === 'delivered'\n ? `Orders in \"${outcome.from}\" cannot be marked delivered`\n : `Orders in \"${outcome.from}\" cannot be fulfilled`,\n })\n }\n return res\n .status(200)\n .json(\n outcome.outcome === 'already' ? { ok: true, already: true } : { ok: true },\n )\n } catch (error) {\n // Nothing landed — the transaction is all-or-nothing — so the order is\n // still where it was and the merchant can try again.\n console.error('fulfillOrder failed', error)\n return res.status(500).json({\n error: to === 'delivered' ? 'Mark delivered failed' : 'Fulfill failed',\n })\n }\n}\n"],"names":["CommerceModel","createResourceUid","firebaseAdmin","recordOrderShipment","hostId","orderId","to","carrier","trackingNumber","firestore","app","orderRef","collection","doc","runTransaction","transaction","snapshot","order","get","exists","outcome","liftLegacyOrder","data","status","canTransitionOrder","from","atMs","Date","now","patch","fulfillments","id","lineItemIds","lineItems","map","_line","index","timeline","appendOrderEvent","undefined","update","fulfillOrderHandler","req","res","body","method","json","error","authorization","String","headers","idToken","startsWith","slice","length","JSON","parse","test","hostSnapshot","decoded","auth","verifyIdToken","hostRef","memberRole","uid","ok","already","console"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/fulfill-order.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 CommerceModel from '../model'\nimport { createResourceUid, type PluginApiHandler } from '@aglyn/aglyn/server'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin'\n\n/**\n * The two fulfilment-side transitions, and ONLY those.\n *\n * `cancelled` releases stock under its own transaction and `refunded` moves\n * money under another, so neither belongs to a write whose contract is \"a\n * forward status flip plus a timeline entry, no stock moved, no money moved\".\n * Widening this union is how a caller gets a door around the specifics those\n * routes exist to enforce — the type is the door being shut.\n */\nexport type OrderFulfilmentTarget = 'fulfilled' | 'delivered'\n\nexport interface RecordShipmentRequest {\n /** Site that owns the order. The CALLER has already proven ownership. */\n hostId: string\n orderId: string\n to: OrderFulfilmentTarget\n /** Free text, e.g. `'UPS'`. Bounded by the caller. */\n carrier?: string\n trackingNumber?: string\n}\n\n/**\n * What happened, as a domain fact rather than an HTTP status — each caller\n * maps it into its own error vocabulary (the console route into its JSON\n * shape, `/v1` into the published error envelope), because those two\n * vocabularies are contracts with different audiences and neither may leak\n * into the other.\n *\n * `already` is a SUCCESS and is distinct from `recorded` on purpose: a retried\n * request finds the order in the target status and returns without writing, so\n * a lost response can never append a second copy of the same shipment. A\n * caller that folded the two together would still be correct about the state\n * and would lose the only signal that says \"this was your retry\".\n */\nexport type RecordShipmentOutcome =\n | { outcome: 'recorded' }\n | { outcome: 'already' }\n | { outcome: 'no_such_order' }\n /** The transition rule refused. `from` is the status that refused it. */\n | { outcome: 'blocked'; from: string }\n\n/**\n * Record a shipment — the transaction, with NO authorization of any kind\n * (AGL-2461).\n *\n * This is the whole of what `fulfillOrderHandler` used to do below its auth\n * preamble, lifted out unchanged so a SECOND caller can have it: the customer\n * REST API's `PATCH /v1/sites/{id}/orders/{id}`\n * (`api-v1/orders-and-products.ts`), which authenticates an org API key and\n * so shares not one line of this function's former preamble.\n *\n * Lifted rather than copied, and that is the entire point: a second\n * `ORDER_TRANSITIONS` inside `/v1` would be two tables that drift into the\n * API writing a status the console forbids.\n *\n * **The caller authorizes.** Everything this function knows is the transition\n * rule. It does not know who is asking, and it will happily move any order on\n * any host — `hostId` is taken on trust because its two callers establish\n * trust in ways that have nothing in common (a Firebase uid in `memberRoles`\n * for the console; an org credential that owns the host for `/v1`).\n *\n * Every guarantee AGL-1819 built is inside the transaction and therefore\n * inherited by both callers: `canTransitionOrder` is re-asked under the write\n * so a stale caller cannot skip a state, the already-in-target case returns\n * without writing so a retry cannot append a duplicate shipment, and\n * `lineItemIds` and the timeline are computed from the transaction's OWN read\n * so a note landed from another tab survives.\n */\nexport async function recordOrderShipment({\n hostId,\n orderId,\n to,\n carrier,\n trackingNumber,\n}: RecordShipmentRequest): Promise<RecordShipmentOutcome> {\n const firestore = firebaseAdmin.app().firestore()\n const orderRef = firestore\n .collection('hosts')\n .doc(hostId)\n .collection('orders')\n .doc(orderId)\n\n return firestore.runTransaction(\n async (transaction): Promise<RecordShipmentOutcome> => {\n const snapshot = await transaction.get(orderRef)\n if (!snapshot.exists) return { outcome: 'no_such_order' }\n const order = CommerceModel.liftLegacyOrder(\n (snapshot.data() ?? {}) as never,\n )\n // A redelivered click, or the second of two admins. Success because it\n // IS the state the caller asked for — but nothing is written, so a\n // retry cannot append a second copy of the same fulfillment.\n if (order.status === to) return { outcome: 'already' }\n if (!CommerceModel.canTransitionOrder(order.status, to)) {\n return { outcome: 'blocked', from: order.status }\n }\n\n const atMs = Date.now()\n const patch: Record<string, unknown> =\n to === 'fulfilled'\n ? {\n status: 'fulfilled',\n fulfillments: [\n ...(order.fulfillments ?? []),\n {\n id: createResourceUid(),\n lineItemIds: (order.lineItems ?? []).map(\n (_line, index) => index,\n ),\n ...(carrier ? { carrier } : {}),\n ...(trackingNumber ? { trackingNumber } : {}),\n atMs,\n } satisfies CommerceModel.OrderFulfillment,\n ],\n timeline: CommerceModel.appendOrderEvent(\n order,\n 'fulfilled',\n trackingNumber\n ? `${carrier || 'Shipped'} ${trackingNumber}`\n : 'Fulfilled',\n atMs,\n ),\n }\n : {\n status: 'delivered',\n timeline: CommerceModel.appendOrderEvent(\n order,\n 'delivered',\n undefined,\n atMs,\n ),\n }\n transaction.update(orderRef, patch)\n return { outcome: 'recorded' }\n },\n )\n}\n\n/**\n * Fulfil and mark-delivered, with the transition re-asked under the write\n * (AGL-1819) — the last two order-status writes the console made with a client\n * `updateDoc`, moved behind the same shape as `cancel-order.ts`.\n *\n * THE STALE-DIALOG HOLE IS THE SAME ONE AGL-1818 CLOSED FOR CANCEL.\n * `canTransitionOrder` was consulted only to RENDER the buttons, so a dialog\n * opened on a `paid` order that was refunded (or cancelled) in another tab\n * could still write `fulfilled` straight onto it — `ORDER_TRANSITIONS` stayed\n * advisory because nothing consulted it on the way IN. Re-reading the order\n * inside the transaction that writes the status is what turns the table into\n * a guarantee; the race comes back as a 409 naming the state that refused.\n *\n * ONE ROUTE FOR THE TWO FULFILMENT-SIDE TRANSITIONS, and ONLY those. They are\n * the same act — a forward status flip plus a timeline entry, no stock moved,\n * no money moved — so they share a handler parameterized on `to`. It refuses\n * every other target with a 400 on purpose: `cancelled` releases stock under\n * its own transaction (`cancel-order.ts`) and `refunded` moves money\n * (`refund.ts`), so admitting them here would hand callers a door around\n * exactly the specifics those routes exist to enforce.\n *\n * NO STOCK MOVES HERE. The sale decremented inventory when the money landed\n * (see `cancel-order.ts`'s header); shipping the goods changes what is on the\n * shelf not at all. An open `restockCheck` — a partial refund's question,\n * flagged before the merchant ships the rest — survives untouched: the write\n * is an `update()` naming only status, fulfillments and timeline, and the\n * question is still the merchant's to answer.\n *\n * NO NOTIFICATION EITHER, matching the client write this replaces: the\n * merchant fulfilled the order themselves, and `notifyHostManagers` would\n * tell them what they just did. (`supplier-update.ts` notifies because there\n * the SUPPLIER acted and the merchant is the one who needs to hear it.)\n *\n * ONCE. A retried request — a lost response, a second click — finds the order\n * already in the target status and returns success WITHOUT writing: appending\n * a second copy of the same fulfillment is the double this guard exists to\n * stop. It reports `already: true` so the console can say so. A fulfil retried\n * after the order moved on to `delivered` in another tab is a 409 instead,\n * which is the truth: that state cannot be fulfilled (again).\n *\n * THE SERVER'S ORDER, NOT THE DIALOG'S: `lineItemIds` and the timeline append\n * are computed from the transaction's own read, so a note landed from another\n * tab survives — the client write it replaces appended to the dialog's stale\n * copy and dropped it.\n *\n * Role gate: `admin` and `editor`, the role the client write already had\n * under the rules' host catch-all — same reasoning as `cancel-order.ts`.\n */\nexport const fulfillOrderHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) return res.status(401).json({ error: 'Unauthenticated' })\n const body =\n typeof req.body === 'string' ? JSON.parse(req.body) : (req.body ?? {})\n const hostId = String(body.hostId ?? '')\n const orderId = String(body.orderId ?? '')\n if (!hostId || !orderId) {\n return res.status(400).json({ error: 'Missing hostId or orderId' })\n }\n // Firestore reserves `__…__` ids and `.doc()` throws SYNCHRONOUSLY on one,\n // which would surface as a 500 on a caller's typo rather than the 400 it is.\n if (/^__.*__$/.test(hostId) || /^__.*__$/.test(orderId)) {\n return res.status(400).json({ error: 'Missing hostId or orderId' })\n }\n const to = String(body.to ?? '') as OrderFulfilmentTarget\n if (to !== 'fulfilled' && to !== 'delivered') {\n // Deliberately NOT a generic transition endpoint — see the header.\n return res.status(400).json({ error: 'to must be fulfilled or delivered' })\n }\n const carrier = String(body.carrier ?? '').slice(0, 40)\n const trackingNumber = String(body.trackingNumber ?? '').slice(0, 60)\n\n try {\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(hostId)\n const hostSnapshot = await hostRef.get()\n if (!hostSnapshot.exists) {\n return res.status(404).json({ error: 'Unknown site' })\n }\n const memberRole = (hostSnapshot.get('memberRoles') ?? {})[decoded.uid]\n if (memberRole !== 'admin' && memberRole !== 'editor') {\n return res.status(403).json({ error: 'Not permitted' })\n }\n\n // Authorized above; the shipment itself is the shared transaction. The\n // wire shape below is unchanged — the console dialog branches on `ok`,\n // `already` and the 409's `error` string, and this route's contract is\n // with that dialog, not with the registry's vocabulary.\n const outcome = await recordOrderShipment({\n hostId,\n orderId,\n to,\n carrier,\n trackingNumber,\n })\n if (outcome.outcome === 'no_such_order') {\n return res.status(404).json({ error: 'Unknown order' })\n }\n if (outcome.outcome === 'blocked') {\n return res.status(409).json({\n error:\n to === 'delivered'\n ? `Orders in \"${outcome.from}\" cannot be marked delivered`\n : `Orders in \"${outcome.from}\" cannot be fulfilled`,\n })\n }\n return res\n .status(200)\n .json(\n outcome.outcome === 'already' ? { ok: true, already: true } : { ok: true },\n )\n } catch (error) {\n // Nothing landed — the transaction is all-or-nothing — so the order is\n // still where it was and the merchant can try again.\n console.error('fulfillOrder failed', error)\n return res.status(500).json({\n error: to === 'delivered' ? 'Mark delivered failed' : 'Fulfill failed',\n })\n }\n}\n"],"names":["CommerceModel","createResourceUid","firebaseAdmin","recordOrderShipment","hostId","orderId","to","carrier","trackingNumber","firestore","app","orderRef","collection","doc","runTransaction","transaction","snapshot","order","get","exists","outcome","liftLegacyOrder","data","status","canTransitionOrder","from","atMs","Date","now","patch","fulfillments","id","lineItemIds","lineItems","map","_line","index","timeline","appendOrderEvent","undefined","update","fulfillOrderHandler","req","res","body","method","json","error","authorization","String","headers","idToken","startsWith","slice","length","JSON","parse","test","hostSnapshot","decoded","auth","verifyIdToken","hostRef","memberRole","uid","ok","already","console"],"mappings":"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"}
@@ -0,0 +1,57 @@
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
+ /** A refusal the person can act on, with the status a route answers it with. */
18
+ export declare class GiftCardIssueRefusal extends Error {
19
+ readonly status: number;
20
+ readonly reason: 'amount' | 'code' | 'taken';
21
+ constructor(status: number, reason: 'amount' | 'code' | 'taken', message: string);
22
+ }
23
+ /** `GC-` + 12 uppercase hex, the shape `billing-webhook.ts` also mints. */
24
+ export declare function mintGiftCardCode(): string;
25
+ /** Where an imported card came from, so a retried row finds the card it already issued. */
26
+ export interface GiftCardIssueSource {
27
+ importJobId: string;
28
+ importRow: number;
29
+ }
30
+ export interface IssueGiftCardInput {
31
+ firestore: FirebaseFirestore.Firestore;
32
+ hostId: string;
33
+ amountCents: number;
34
+ recipientEmail?: string | null;
35
+ note?: string | null;
36
+ /** A code the merchant brings; one is minted when absent. */
37
+ code?: string | null;
38
+ /** Who issued it, for the card and the activity line. */
39
+ issuer: {
40
+ uid: string;
41
+ email?: string | null;
42
+ };
43
+ /** Email the recipient their code (when there is a recipient and mail is set up). */
44
+ email: boolean;
45
+ /** The workspace that owns the site, for the email's sender name. */
46
+ ownerOrg?: unknown;
47
+ source?: GiftCardIssueSource;
48
+ }
49
+ /** What the activity log names a card by: never the whole code, which spends it. */
50
+ export declare function giftCardActivityName(code: string): string;
51
+ /** The activity target a card is filed under (`pluginId:noun`, AGL-2978). */
52
+ export declare const GIFT_CARD_ACTIVITY_TYPE: "commerce:giftCard";
53
+ /** Issues one card. Throws {@link GiftCardIssueRefusal} for what the person can fix. */
54
+ export declare function issueGiftCard(input: IssueGiftCardInput): Promise<{
55
+ code: string;
56
+ emailed: boolean;
57
+ }>;
@@ -0,0 +1,155 @@
1
+ import { _ as _extends } from "@swc/helpers/_/_extends";
2
+ /**
3
+ * @license
4
+ * Copyright 2026 Aglyn LLC
5
+ *
6
+ * Licensed under the Apache License, Version 2.0 (the "License");
7
+ * you may not use this file except in compliance with the License.
8
+ * You may obtain a copy of the License at
9
+ *
10
+ * http://www.apache.org/licenses/LICENSE-2.0
11
+ *
12
+ * Unless required by applicable law or agreed to in writing, software
13
+ * distributed under the License is distributed on an "AS IS" BASIS,
14
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ * See the License for the specific language governing permissions and
16
+ * limitations under the License.
17
+ */ import * as Aglyn from "@aglyn/aglyn/server";
18
+ import { randomBytes } from "crypto";
19
+ import { hostSendingIdentity, logHostActivity, meterHostEmail, renderHostEmailWithTokens } from "@aglyn/tenant-data-admin";
20
+ import { isEmailConfigured, sendEmail } from "@aglyn/shared-util-email";
21
+ import { giftCardAmountProblem, giftCardCodeProblem } from "../model/commerce-gift-cards.js";
22
+ import { giftCardSearchTokens } from "../model/gift-card-search.js";
23
+ /*
24
+ * ISSUING A GIFT CARD (AGL-2226, AGL-3551): the one path a card comes into
25
+ * being by a merchant's hand — the Gift cards card's Issue, one card at a
26
+ * time, and the gift card import, one card per row. A sale's cards are
27
+ * minted by the billing webhook from the order.
28
+ *
29
+ * What issuing is, wherever it is asked from:
30
+ *
31
+ * - the amount is above zero and at most {@link GIFT_CARD_ISSUE_MAX_CENTS};
32
+ * - the code is minted, or a code the merchant brings (an import carries
33
+ * the codes shoppers already hold) — and a code another card holds is
34
+ * refused, never overwritten: the document is CREATED, so two issues of
35
+ * one code cannot both land;
36
+ * - the card records who issued it (`issuedBy`) and for how much
37
+ * (`initialCents`), and the site's activity log gets a line naming it;
38
+ * - the recipient is emailed the code only when asked, and the answer says
39
+ * whether the email actually went.
40
+ */ /** A refusal the person can act on, with the status a route answers it with. */ export class GiftCardIssueRefusal extends Error {
41
+ constructor(status, reason, message){
42
+ super(message), this.status = status, this.reason = reason;
43
+ this.name = 'GiftCardIssueRefusal';
44
+ }
45
+ }
46
+ /** `GC-` + 12 uppercase hex, the shape `billing-webhook.ts` also mints. */ export function mintGiftCardCode() {
47
+ return `GC-${randomBytes(6).toString('hex').toUpperCase()}`;
48
+ }
49
+ /** What the activity log names a card by: never the whole code, which spends it. */ export function giftCardActivityName(code) {
50
+ return `Gift card ending ${code.slice(-4)}`;
51
+ }
52
+ /** The activity target a card is filed under (`pluginId:noun`, AGL-2978). */ export const GIFT_CARD_ACTIVITY_TYPE = 'commerce:giftCard';
53
+ const alreadyExists = (error)=>{
54
+ const code = error == null ? void 0 : error.code;
55
+ return code === 6 || code === 'already-exists' || code === 'ALREADY_EXISTS';
56
+ };
57
+ /** Issues one card. Throws {@link GiftCardIssueRefusal} for what the person can fix. */ export async function issueGiftCard(input) {
58
+ var _input_code, _input_recipientEmail, _input_note, _input_issuer_email;
59
+ const { firestore, hostId } = input;
60
+ const amountCents = Math.round(Number(input.amountCents));
61
+ const amountProblem = giftCardAmountProblem(amountCents);
62
+ if (amountProblem) throw new GiftCardIssueRefusal(400, 'amount', amountProblem);
63
+ const brought = (_input_code = input.code) != null ? _input_code : null;
64
+ if (brought) {
65
+ const codeProblem = giftCardCodeProblem(brought);
66
+ if (codeProblem) throw new GiftCardIssueRefusal(400, 'code', codeProblem);
67
+ }
68
+ const code = brought != null ? brought : mintGiftCardCode();
69
+ const recipientEmail = String((_input_recipientEmail = input.recipientEmail) != null ? _input_recipientEmail : '').trim().slice(0, 200);
70
+ const note = String((_input_note = input.note) != null ? _input_note : '').trim().slice(0, 200);
71
+ try {
72
+ await firestore.collection('hosts').doc(hostId).collection('giftCards').doc(code).create(_extends({
73
+ initialCents: amountCents,
74
+ balanceCents: amountCents,
75
+ recipientEmail: recipientEmail || null,
76
+ // Null rather than absent: the field is what tells a reader whether a
77
+ // card came from a sale or from the console, and `undefined` would
78
+ // make an issued-by-hand card indistinguishable from a legacy row.
79
+ orderId: null,
80
+ issuedBy: input.issuer.uid
81
+ }, note ? {
82
+ note
83
+ } : {}, input.source ? {
84
+ importJobId: input.source.importJobId,
85
+ importRow: input.source.importRow
86
+ } : {}, {
87
+ // What the console's Gift cards search asks (AGL-3321).
88
+ searchTokens: giftCardSearchTokens(code, recipientEmail || null),
89
+ createdAtMs: Date.now()
90
+ }));
91
+ } catch (error) {
92
+ if (alreadyExists(error)) {
93
+ throw new GiftCardIssueRefusal(409, 'taken', `A gift card with the code ${code} already exists.`);
94
+ }
95
+ throw error;
96
+ }
97
+ await logHostActivity(hostId, {
98
+ uid: input.issuer.uid,
99
+ email: (_input_issuer_email = input.issuer.email) != null ? _input_issuer_email : null
100
+ }, input.source ? 'Issued gift card from an import' : 'Issued gift card', {
101
+ type: GIFT_CARD_ACTIVITY_TYPE,
102
+ id: code,
103
+ name: giftCardActivityName(code)
104
+ });
105
+ let emailed = false;
106
+ if (input.email && recipientEmail && isEmailConfigured()) {
107
+ var _ref;
108
+ const value = `$${(amountCents / 100).toFixed(2)}`;
109
+ // The same site-designed template the purchase path uses (AGL-771), so
110
+ // a hand-issued card arrives looking like a bought one — with the
111
+ // merchant's note (AGL-3432). A card sent by hand often goes to someone
112
+ // who has never visited the site, and the note is the only part of the
113
+ // message the merchant wrote.
114
+ const designed = await renderHostEmailWithTokens(firestore, hostId, 'gift-card', {
115
+ 'giftcard.code': code,
116
+ 'giftcard.value': value,
117
+ 'giftcard.note': note
118
+ });
119
+ const sent = await sendEmail(_extends({
120
+ to: recipientEmail,
121
+ subject: (_ref = designed == null ? void 0 : designed.subject) != null ? _ref : 'Your gift card',
122
+ text: (designed == null ? void 0 : designed.text) || (note ? `${note}\n\n` : '') + `Gift card code: ${code}\nValue: ${value}\n\n` + 'Enter it at checkout to apply the balance.'
123
+ }, (designed == null ? void 0 : designed.html) ? {
124
+ html: designed.html
125
+ } : {}, {
126
+ fromName: Aglyn.resolveBrandingProfile(input.ownerOrg).fromName,
127
+ sendingIdentity: await hostSendingIdentity(hostId),
128
+ audience: 'tenant',
129
+ context: 'gift card',
130
+ // Owed to the recipient by their own order: the phishing
131
+ // screen's soft rules never hold it (AGL-3356).
132
+ owedFor: 'order'
133
+ })).then(()=>true).catch(()=>false);
134
+ if (sent) {
135
+ // Cost meter (AGL-1438). Transactional: the card has already been
136
+ // minted and the recipient is owed the code, so a quota may count
137
+ // this send but never refuse it. Metered on the SUCCESS path only,
138
+ // matching every other sender — a send the provider rejected costs
139
+ // nothing, and a meter that counted it would inflate the COGS figure
140
+ // exactly when mail was broken.
141
+ await meterHostEmail(hostId);
142
+ }
143
+ // The truth, not the intent: the console shows this back to whoever
144
+ // issued the card, and reporting a delivered email for a send that
145
+ // threw is how a customer ends up waiting for a code that is not
146
+ // coming.
147
+ emailed = sent;
148
+ }
149
+ return {
150
+ code,
151
+ emailed
152
+ };
153
+ }
154
+
155
+ //# sourceMappingURL=gift-card-issue.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/gift-card-issue.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 { randomBytes } from 'crypto'\nimport {\n hostSendingIdentity,\n logHostActivity,\n meterHostEmail,\n renderHostEmailWithTokens,\n} from '@aglyn/tenant-data-admin'\nimport { isEmailConfigured, sendEmail } from '@aglyn/shared-util-email'\nimport {\n GIFT_CARD_ISSUE_MAX_CENTS,\n giftCardAmountProblem,\n giftCardCodeProblem,\n} from '../model/commerce-gift-cards'\nimport { giftCardSearchTokens } from '../model/gift-card-search'\n\n/*\n * ISSUING A GIFT CARD (AGL-2226, AGL-3551): the one path a card comes into\n * being by a merchant's hand — the Gift cards card's Issue, one card at a\n * time, and the gift card import, one card per row. A sale's cards are\n * minted by the billing webhook from the order.\n *\n * What issuing is, wherever it is asked from:\n *\n * - the amount is above zero and at most {@link GIFT_CARD_ISSUE_MAX_CENTS};\n * - the code is minted, or a code the merchant brings (an import carries\n * the codes shoppers already hold) — and a code another card holds is\n * refused, never overwritten: the document is CREATED, so two issues of\n * one code cannot both land;\n * - the card records who issued it (`issuedBy`) and for how much\n * (`initialCents`), and the site's activity log gets a line naming it;\n * - the recipient is emailed the code only when asked, and the answer says\n * whether the email actually went.\n */\n\n/** A refusal the person can act on, with the status a route answers it with. */\nexport class GiftCardIssueRefusal extends Error {\n constructor(\n readonly status: number,\n readonly reason: 'amount' | 'code' | 'taken',\n message: string,\n ) {\n super(message)\n this.name = 'GiftCardIssueRefusal'\n }\n}\n\n/** `GC-` + 12 uppercase hex, the shape `billing-webhook.ts` also mints. */\nexport function mintGiftCardCode(): string {\n return `GC-${randomBytes(6).toString('hex').toUpperCase()}`\n}\n\n/** Where an imported card came from, so a retried row finds the card it already issued. */\nexport interface GiftCardIssueSource {\n importJobId: string\n importRow: number\n}\n\nexport interface IssueGiftCardInput {\n firestore: FirebaseFirestore.Firestore\n hostId: string\n amountCents: number\n recipientEmail?: string | null\n note?: string | null\n /** A code the merchant brings; one is minted when absent. */\n code?: string | null\n /** Who issued it, for the card and the activity line. */\n issuer: { uid: string; email?: string | null }\n /** Email the recipient their code (when there is a recipient and mail is set up). */\n email: boolean\n /** The workspace that owns the site, for the email's sender name. */\n ownerOrg?: unknown\n source?: GiftCardIssueSource\n}\n\n/** What the activity log names a card by: never the whole code, which spends it. */\nexport function giftCardActivityName(code: string): string {\n return `Gift card ending ${code.slice(-4)}`\n}\n\n/** The activity target a card is filed under (`pluginId:noun`, AGL-2978). */\nexport const GIFT_CARD_ACTIVITY_TYPE = 'commerce:giftCard' as const\n\nconst alreadyExists = (error: unknown): boolean => {\n const code = (error as { code?: unknown } | null)?.code\n return code === 6 || code === 'already-exists' || code === 'ALREADY_EXISTS'\n}\n\n/** Issues one card. Throws {@link GiftCardIssueRefusal} for what the person can fix. */\nexport async function issueGiftCard(input: IssueGiftCardInput): Promise<{ code: string; emailed: boolean }> {\n const { firestore, hostId } = input\n const amountCents = Math.round(Number(input.amountCents))\n const amountProblem = giftCardAmountProblem(amountCents)\n if (amountProblem) throw new GiftCardIssueRefusal(400, 'amount', amountProblem)\n const brought = input.code ?? null\n if (brought) {\n const codeProblem = giftCardCodeProblem(brought)\n if (codeProblem) throw new GiftCardIssueRefusal(400, 'code', codeProblem)\n }\n const code = brought ?? mintGiftCardCode()\n const recipientEmail = String(input.recipientEmail ?? '').trim().slice(0, 200)\n const note = String(input.note ?? '').trim().slice(0, 200)\n try {\n await firestore\n .collection('hosts')\n .doc(hostId)\n .collection('giftCards')\n .doc(code)\n .create({\n initialCents: amountCents,\n balanceCents: amountCents,\n recipientEmail: recipientEmail || null,\n // Null rather than absent: the field is what tells a reader whether a\n // card came from a sale or from the console, and `undefined` would\n // make an issued-by-hand card indistinguishable from a legacy row.\n orderId: null,\n issuedBy: input.issuer.uid,\n ...(note ? { note } : {}),\n ...(input.source ? { importJobId: input.source.importJobId, importRow: input.source.importRow } : {}),\n // What the console's Gift cards search asks (AGL-3321).\n searchTokens: giftCardSearchTokens(code, recipientEmail || null),\n createdAtMs: Date.now(),\n })\n } catch (error) {\n if (alreadyExists(error)) {\n throw new GiftCardIssueRefusal(409, 'taken', `A gift card with the code ${code} already exists.`)\n }\n throw error\n }\n\n await logHostActivity(\n hostId,\n { uid: input.issuer.uid, email: input.issuer.email ?? null },\n input.source ? 'Issued gift card from an import' : 'Issued gift card',\n { type: GIFT_CARD_ACTIVITY_TYPE, id: code, name: giftCardActivityName(code) },\n )\n\n let emailed = false\n if (input.email && recipientEmail && isEmailConfigured()) {\n const value = `$${(amountCents / 100).toFixed(2)}`\n // The same site-designed template the purchase path uses (AGL-771), so\n // a hand-issued card arrives looking like a bought one — with the\n // merchant's note (AGL-3432). A card sent by hand often goes to someone\n // who has never visited the site, and the note is the only part of the\n // message the merchant wrote.\n const designed = await renderHostEmailWithTokens(firestore, hostId, 'gift-card', {\n 'giftcard.code': code,\n 'giftcard.value': value,\n 'giftcard.note': note,\n })\n const sent = await sendEmail({\n to: recipientEmail,\n subject: designed?.subject ?? 'Your gift card',\n text:\n designed?.text ||\n (note ? `${note}\\n\\n` : '') +\n `Gift card code: ${code}\\nValue: ${value}\\n\\n` +\n 'Enter it at checkout to apply the balance.',\n ...(designed?.html ? { html: designed.html } : {}),\n fromName: Aglyn.resolveBrandingProfile(input.ownerOrg as never).fromName,\n sendingIdentity: await hostSendingIdentity(hostId),\n audience: 'tenant',\n context: 'gift card',\n // Owed to the recipient by their own order: the phishing\n // screen's soft rules never hold it (AGL-3356).\n owedFor: 'order',\n })\n .then(() => true)\n .catch(() => false)\n if (sent) {\n // Cost meter (AGL-1438). Transactional: the card has already been\n // minted and the recipient is owed the code, so a quota may count\n // this send but never refuse it. Metered on the SUCCESS path only,\n // matching every other sender — a send the provider rejected costs\n // nothing, and a meter that counted it would inflate the COGS figure\n // exactly when mail was broken.\n await meterHostEmail(hostId)\n }\n // The truth, not the intent: the console shows this back to whoever\n // issued the card, and reporting a delivered email for a send that\n // threw is how a customer ends up waiting for a code that is not\n // coming.\n emailed = sent\n }\n return { code, emailed }\n}\n"],"names":["Aglyn","randomBytes","hostSendingIdentity","logHostActivity","meterHostEmail","renderHostEmailWithTokens","isEmailConfigured","sendEmail","giftCardAmountProblem","giftCardCodeProblem","giftCardSearchTokens","GiftCardIssueRefusal","Error","status","reason","message","name","mintGiftCardCode","toString","toUpperCase","giftCardActivityName","code","slice","GIFT_CARD_ACTIVITY_TYPE","alreadyExists","error","issueGiftCard","input","firestore","hostId","amountCents","Math","round","Number","amountProblem","brought","codeProblem","recipientEmail","String","trim","note","collection","doc","create","initialCents","balanceCents","orderId","issuedBy","issuer","uid","source","importJobId","importRow","searchTokens","createdAtMs","Date","now","email","type","id","emailed","value","toFixed","designed","sent","to","subject","text","html","fromName","resolveBrandingProfile","ownerOrg","sendingIdentity","audience","context","owedFor","then","catch"],"mappings":"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"}