@aglyn/plugins-commerce 1.0.0-beta.290 → 1.0.0-beta.292

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 (183) hide show
  1. package/README.md +4 -2
  2. package/package.json +13 -13
  3. package/src/lib/components/cart-fulfillment.d.ts +3 -0
  4. package/src/lib/components/cart-fulfillment.js.map +1 -1
  5. package/src/lib/components/cart.js +14 -4
  6. package/src/lib/components/cart.js.map +1 -1
  7. package/src/lib/components/commerce-console-page.js +2 -1
  8. package/src/lib/components/commerce-console-page.js.map +1 -1
  9. package/src/lib/components/console/host-orders-card.component.d.ts +6 -0
  10. package/src/lib/components/console/host-orders-card.component.js +3 -2
  11. package/src/lib/components/console/host-orders-card.component.js.map +1 -1
  12. package/src/lib/components/console/invoice-send.component.d.ts +44 -0
  13. package/src/lib/components/console/invoice-send.component.js +237 -0
  14. package/src/lib/components/console/invoice-send.component.js.map +1 -0
  15. package/src/lib/components/console/order-detail-dialog.component.d.ts +5 -0
  16. package/src/lib/components/console/order-detail-dialog.component.js +9 -2
  17. package/src/lib/components/console/order-detail-dialog.component.js.map +1 -1
  18. package/src/lib/components/console/order-invoice.component.d.ts +9 -0
  19. package/src/lib/components/console/order-invoice.component.js +27 -2
  20. package/src/lib/components/console/order-invoice.component.js.map +1 -1
  21. package/src/lib/components/console/pos/pos-item-dialog.component.js +3 -2
  22. package/src/lib/components/console/pos/pos-item-dialog.component.js.map +1 -1
  23. package/src/lib/components/console/pos-kiosk-page.component.js +2 -1
  24. package/src/lib/components/console/pos-kiosk-page.component.js.map +1 -1
  25. package/src/lib/components/console/pos-page.component.js +1 -1
  26. package/src/lib/components/console/pos-page.component.js.map +1 -1
  27. package/src/lib/components/console/product-add-ons-fields.component.d.ts +41 -0
  28. package/src/lib/components/console/product-add-ons-fields.component.js +356 -0
  29. package/src/lib/components/console/product-add-ons-fields.component.js.map +1 -0
  30. package/src/lib/components/console/product-editor-dialog.component.js +31 -5
  31. package/src/lib/components/console/product-editor-dialog.component.js.map +1 -1
  32. package/src/lib/components/console/product-register-fields.component.d.ts +4 -5
  33. package/src/lib/components/console/product-register-fields.component.js +8 -197
  34. package/src/lib/components/console/product-register-fields.component.js.map +1 -1
  35. package/src/lib/components/console/products-hub-zone.component.js +6 -0
  36. package/src/lib/components/console/products-hub-zone.component.js.map +1 -1
  37. package/src/lib/components/console/quote-editor-dialog.component.d.ts +29 -0
  38. package/src/lib/components/console/quote-editor-dialog.component.js +102 -4
  39. package/src/lib/components/console/quote-editor-dialog.component.js.map +1 -1
  40. package/src/lib/components/console/quote-pay-link.component.d.ts +8 -1
  41. package/src/lib/components/console/quote-pay-link.component.js +29 -3
  42. package/src/lib/components/console/quote-pay-link.component.js.map +1 -1
  43. package/src/lib/components/console/quote-proofs.component.d.ts +11 -0
  44. package/src/lib/components/console/quote-proofs.component.js +68 -0
  45. package/src/lib/components/console/quote-proofs.component.js.map +1 -1
  46. package/src/lib/components/console/quote-send.component.d.ts +42 -0
  47. package/src/lib/components/console/quote-send.component.js +304 -0
  48. package/src/lib/components/console/quote-send.component.js.map +1 -0
  49. package/src/lib/components/console/quote-start.component.d.ts +45 -0
  50. package/src/lib/components/console/quote-start.component.js +182 -0
  51. package/src/lib/components/console/quote-start.component.js.map +1 -0
  52. package/src/lib/components/product-add-ons.d.ts +28 -0
  53. package/src/lib/components/product-add-ons.js +136 -0
  54. package/src/lib/components/product-add-ons.js.map +1 -0
  55. package/src/lib/components/product-detail.d.ts +16 -0
  56. package/src/lib/components/product-detail.js +112 -19
  57. package/src/lib/components/product-detail.js.map +1 -1
  58. package/src/lib/components/product-grid.d.ts +8 -2
  59. package/src/lib/components/product-grid.js +6 -1
  60. package/src/lib/components/product-grid.js.map +1 -1
  61. package/src/lib/components/product-options-field-body.d.ts +44 -0
  62. package/src/lib/components/product-options-field-body.js +260 -0
  63. package/src/lib/components/product-options-field-body.js.map +1 -0
  64. package/src/lib/components/product-options-field.d.ts +32 -0
  65. package/src/lib/components/product-options-field.js +128 -0
  66. package/src/lib/components/product-options-field.js.map +1 -0
  67. package/src/lib/components/quote-page-body.js +39 -20
  68. package/src/lib/components/quote-page-body.js.map +1 -1
  69. package/src/lib/model/commerce-cart.d.ts +15 -3
  70. package/src/lib/model/commerce-cart.js +10 -3
  71. package/src/lib/model/commerce-cart.js.map +1 -1
  72. package/src/lib/model/commerce-invoices.d.ts +139 -0
  73. package/src/lib/model/commerce-invoices.js +343 -0
  74. package/src/lib/model/commerce-invoices.js.map +1 -0
  75. package/src/lib/model/commerce-orders.d.ts +35 -5
  76. package/src/lib/model/commerce-orders.js +3 -0
  77. package/src/lib/model/commerce-orders.js.map +1 -1
  78. package/src/lib/model/commerce-pos-kiosk.d.ts +1 -1
  79. package/src/lib/model/commerce-pos-kiosk.js +3 -2
  80. package/src/lib/model/commerce-pos-kiosk.js.map +1 -1
  81. package/src/lib/model/commerce-product-field.d.ts +23 -0
  82. package/src/lib/model/commerce-product-field.js +26 -0
  83. package/src/lib/model/commerce-product-field.js.map +1 -0
  84. package/src/lib/model/commerce-quote-email.d.ts +81 -0
  85. package/src/lib/model/commerce-quote-email.js +170 -0
  86. package/src/lib/model/commerce-quote-email.js.map +1 -0
  87. package/src/lib/model/commerce-quote-pay.d.ts +49 -6
  88. package/src/lib/model/commerce-quote-pay.js +69 -36
  89. package/src/lib/model/commerce-quote-pay.js.map +1 -1
  90. package/src/lib/model/commerce-quote-request.d.ts +93 -0
  91. package/src/lib/model/commerce-quote-request.js +207 -0
  92. package/src/lib/model/commerce-quote-request.js.map +1 -0
  93. package/src/lib/model/commerce-quotes.d.ts +81 -0
  94. package/src/lib/model/commerce-quotes.js +1 -1
  95. package/src/lib/model/commerce-quotes.js.map +1 -1
  96. package/src/lib/model/commerce.d.ts +18 -0
  97. package/src/lib/model/commerce.js +26 -0
  98. package/src/lib/model/commerce.js.map +1 -1
  99. package/src/lib/model/index.d.ts +3 -0
  100. package/src/lib/model/index.js +3 -0
  101. package/src/lib/model/index.js.map +1 -1
  102. package/src/lib/model/product-modifiers.d.ts +74 -4
  103. package/src/lib/model/product-modifiers.js +131 -10
  104. package/src/lib/model/product-modifiers.js.map +1 -1
  105. package/src/lib/plugin.js +19 -0
  106. package/src/lib/plugin.js.map +1 -1
  107. package/src/lib/server/billing-webhook.js +164 -98
  108. package/src/lib/server/billing-webhook.js.map +1 -1
  109. package/src/lib/server/cart-checkout.js +14 -2
  110. package/src/lib/server/cart-checkout.js.map +1 -1
  111. package/src/lib/server/cart.d.ts +5 -0
  112. package/src/lib/server/cart.js +41 -4
  113. package/src/lib/server/cart.js.map +1 -1
  114. package/src/lib/server/catalog.d.ts +6 -0
  115. package/src/lib/server/catalog.js +2 -0
  116. package/src/lib/server/catalog.js.map +1 -1
  117. package/src/lib/server/checkout-extras.js +3 -1
  118. package/src/lib/server/checkout-extras.js.map +1 -1
  119. package/src/lib/server/checkout.js +28 -3
  120. package/src/lib/server/checkout.js.map +1 -1
  121. package/src/lib/server/commerce-pdf.d.ts +5 -1
  122. package/src/lib/server/commerce-pdf.js +37 -10
  123. package/src/lib/server/commerce-pdf.js.map +1 -1
  124. package/src/lib/server/draft-order.js +23 -3
  125. package/src/lib/server/draft-order.js.map +1 -1
  126. package/src/lib/server/invoice-email.d.ts +76 -0
  127. package/src/lib/server/invoice-email.js +183 -0
  128. package/src/lib/server/invoice-email.js.map +1 -0
  129. package/src/lib/server/local-fulfillment.js +5 -1
  130. package/src/lib/server/local-fulfillment.js.map +1 -1
  131. package/src/lib/server/order-invoice-send.d.ts +43 -0
  132. package/src/lib/server/order-invoice-send.js +218 -0
  133. package/src/lib/server/order-invoice-send.js.map +1 -0
  134. package/src/lib/server/order-status.d.ts +11 -0
  135. package/src/lib/server/order-status.js +44 -8
  136. package/src/lib/server/order-status.js.map +1 -1
  137. package/src/lib/server/person-eraser.d.ts +3 -2
  138. package/src/lib/server/person-eraser.js +25 -6
  139. package/src/lib/server/person-eraser.js.map +1 -1
  140. package/src/lib/server/pos-offline-sync.js +1 -1
  141. package/src/lib/server/pos-offline-sync.js.map +1 -1
  142. package/src/lib/server/pos-order.js +1 -1
  143. package/src/lib/server/pos-order.js.map +1 -1
  144. package/src/lib/server/product.d.ts +26 -0
  145. package/src/lib/server/product.js +18 -0
  146. package/src/lib/server/product.js.map +1 -1
  147. package/src/lib/server/products-write.js +5 -2
  148. package/src/lib/server/products-write.js.map +1 -1
  149. package/src/lib/server/quote-email.d.ts +85 -0
  150. package/src/lib/server/quote-email.js +211 -0
  151. package/src/lib/server/quote-email.js.map +1 -0
  152. package/src/lib/server/quote-link-token.d.ts +11 -0
  153. package/src/lib/server/quote-link-token.js +21 -0
  154. package/src/lib/server/quote-link-token.js.map +1 -1
  155. package/src/lib/server/quote-pay.d.ts +8 -3
  156. package/src/lib/server/quote-pay.js +105 -43
  157. package/src/lib/server/quote-pay.js.map +1 -1
  158. package/src/lib/server/quote.d.ts +11 -0
  159. package/src/lib/server/quote.js +309 -12
  160. package/src/lib/server/quote.js.map +1 -1
  161. package/src/lib/server/site-page-resolver.js +2 -1
  162. package/src/lib/server/site-page-resolver.js.map +1 -1
  163. package/src/lib/server.js +5 -0
  164. package/src/lib/server.js.map +1 -1
  165. package/src/lib/site.js +8 -0
  166. package/src/lib/site.js.map +1 -1
  167. package/src/lib/tenant-emails.js +243 -0
  168. package/src/lib/tenant-emails.js.map +1 -1
  169. package/src/lib/utils/add-to-cart.d.ts +8 -0
  170. package/src/lib/utils/add-to-cart.js +10 -2
  171. package/src/lib/utils/add-to-cart.js.map +1 -1
  172. package/src/lib/utils/commerce-document-pdf.d.ts +5 -0
  173. package/src/lib/utils/commerce-document-pdf.js +6 -1
  174. package/src/lib/utils/commerce-document-pdf.js.map +1 -1
  175. package/src/lib/utils/invoice-email-content.d.ts +45 -0
  176. package/src/lib/utils/invoice-email-content.js +126 -0
  177. package/src/lib/utils/invoice-email-content.js.map +1 -0
  178. package/src/lib/utils/order-invoice.d.ts +1 -1
  179. package/src/lib/utils/order-invoice.js +14 -6
  180. package/src/lib/utils/order-invoice.js.map +1 -1
  181. package/src/lib/utils/quote-email-content.d.ts +63 -0
  182. package/src/lib/utils/quote-email-content.js +157 -0
  183. package/src/lib/utils/quote-email-content.js.map +1 -0
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/quote-email.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 { siteContactEmail, resolveHostToken, type HostTokenSource } from '@aglyn/aglyn/app-utils/host-tokens'\nimport { resolveBrandingProfile } from '@aglyn/aglyn/app-utils/plan-entitlements'\nimport {\n emailThreadAfterSend,\n isEmailConfigured,\n mintEmailMessageId,\n nextThreadHeaders,\n PLATFORM_EMAIL_PALETTE,\n readEmailThread,\n resolveEmailColor,\n sendEmail,\n type EmailTheme,\n type RenderedHostEmail,\n type SendEmailOptions,\n type SendEmailResult,\n type SendingIdentityVerdict,\n} from '@aglyn/shared-util-email'\nimport {\n firebaseAdmin,\n hostSendingIdentity,\n meterHostEmail,\n renderHostEmailWithTokens,\n siteEmailFrame,\n transactionalAddressSuppression,\n} from '@aglyn/tenant-data-admin'\nimport * as CommerceModel from '../model'\nimport { QUOTE_EMAIL_KEY, quoteEmailContent, type QuoteEmailColors } from '../utils/quote-email-content'\nimport type { quotePdfFile } from './commerce-pdf'\nimport type { CommercePdfFile } from './pdf-response'\n\n/*==========================================\n * SENDING THE QUOTE EMAIL (AGL-4097). Loaded only by the console's Send\n * quote (`POST commerce/quote { action: 'send' }`), so the renderer, the PDF\n * writer and sharp stay out of every other request.\n *\n * One email, the `quote-sent` catalog key in the site's theme and its header\n * and footer (or the site's own design of it), with the quote's PDF attached:\n *\n * - FROM the site's sending identity, under the site's name;\n * - REPLY-TO the site's contact email (its listing's, else its support\n * email), else the email of the person sending, so a reply reaches the\n * business either way, as an inbox reply's does;\n * - IN THE CUSTOMER'S CONVERSATION: a `Message-ID` minted on the sending\n * domain, and, when the quote already has a conversation (an earlier\n * send, or a request's confirmation), `In-Reply-To` and `References`\n * from it, else the root of the one it starts in `References`\n * (`nextThreadHeaders`). The provider's own id is returned beside ours,\n * because a provider may replace a caller's `Message-ID` (in production\n * one does), and a replaced one can then be traced;\n * - METERED as a site's transactional email;\n * - REFUSED only for an address that bounced for good or reported mail as\n * spam (`transactionalAddressSuppression`): the customer asked for the\n * quote, so an unsubscribe from the site's newsletter does not stop it,\n * as it does not stop a receipt (AGL-4098).\n *\n * The route decides everything about the quote (who may send, the link, the\n * stage); this module only says the email and sends it.\n *=========================================*/\n\n/** What the route hands over for one send. */\nexport interface QuoteDeliveryInput {\n hostId: string\n quoteId: string\n /** The quote as it stands, after any pay link replacement this send made. */\n order: CommerceModel.HostOrder\n host: Record<string, unknown>\n store: Record<string, unknown> | null\n owner: { id?: string; org?: unknown } | null\n to: string\n /** The subject as typed; empty takes the default. */\n subject: string\n message: string\n payUrl: string\n proofUrl: (proofId: string) => string | null\n linkReplaced: boolean\n /** The sending member's email: the Reply-To when the site has no contact email. */\n senderEmail: string | null\n atMs: number\n}\n\nexport type QuoteDeliveryResult =\n | {\n sent: true\n messageId: string\n providerId: string | null\n subject: string\n replyTo: string | null\n thread: CommerceModel.QuoteEmailThread\n }\n | { sent: false; status: number; error: string }\n\n/** The calls the send makes, injectable for specs. */\nexport interface QuoteMailerDeps {\n configured(): boolean\n sendingIdentity(hostId: string): Promise<SendingIdentityVerdict>\n render(hostId: string, key: string, tokens: Record<string, string>): Promise<RenderedHostEmail | null>\n /** The site's email theme, for the colors the composed proof markup draws with. */\n theme(hostId: string): Promise<EmailTheme | null>\n /** The quote's PDF; loads the PDF writer and sharp only here. */\n quotePdf(input: Parameters<typeof quotePdfFile>[0]): Promise<CommercePdfFile>\n send(options: SendEmailOptions): Promise<SendEmailResult>\n meter(hostId: string): Promise<void>\n /** The bounce and complaint list only: a quote is mail the customer asked for. */\n suppression(email: string): Promise<'suppressed-platform' | null>\n}\n\nexport function defaultQuoteMailerDeps(): QuoteMailerDeps {\n const firestore = () => firebaseAdmin.app().firestore()\n return {\n configured: () => isEmailConfigured(),\n sendingIdentity: (hostId) => hostSendingIdentity(hostId),\n render: (hostId, key, tokens) => renderHostEmailWithTokens(firestore(), hostId, key, tokens),\n theme: async (hostId) => (await siteEmailFrame(firestore(), hostId, QUOTE_EMAIL_KEY)).theme,\n quotePdf: async (input) => (await import('./commerce-pdf')).quotePdfFile(input),\n send: (options) => sendEmail(options),\n meter: async (hostId) => {\n await meterHostEmail(hostId)\n },\n suppression: (email) => transactionalAddressSuppression(email),\n }\n}\n\n/** What the console's Send quote reaches: whether mail can go, whether this address may be mailed, and the send. */\nexport interface QuoteMailer {\n configured(): boolean\n suppression(email: string): Promise<'suppressed-platform' | null>\n deliver(input: QuoteDeliveryInput): Promise<QuoteDeliveryResult>\n}\n\nconst EMAIL = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/\n\n/** The business's name as its documents print it. */\nexport function quoteBusinessName(host: Record<string, unknown>): string {\n const named = resolveHostToken('businessName', host as HostTokenSource)\n return String(named ?? '').trim() || String(host['name'] ?? '').trim()\n}\n\n/**\n * Who a reply reaches: the site's contact email, else the person sending, as\n * an inbox reply answers to them. Null only when neither is an address.\n */\nexport function quoteReplyTo(host: Record<string, unknown>, senderEmail: string | null | undefined): string | null {\n const contact = siteContactEmail(host as never)\n if (contact && EMAIL.test(contact)) return contact\n const sender = String(senderEmail ?? '').trim()\n return EMAIL.test(sender) ? sender : null\n}\n\n/** The theme's colors for the composed proof markup, the platform's where the site's cannot answer. */\nfunction proofColors(theme: EmailTheme | null): QuoteEmailColors {\n const read = (token: string) =>\n resolveEmailColor(token, theme?.palette) ?? resolveEmailColor(token, PLATFORM_EMAIL_PALETTE) ?? 'inherit'\n return { border: read('divider'), muted: read('text.secondary') }\n}\n\n/** A refused send in the words the console shows. */\nfunction refusalOf(result: Extract<SendEmailResult, { sent: false }>): { status: number; error: string } {\n switch (result.reason) {\n case 'unconfigured':\n return { status: 503, error: 'Email isn’t set up here, so the quote wasn’t sent.' }\n case 'rate-limited':\n return { status: 429, error: 'Too many emails are going out right now. Try again in a minute.' }\n case 'suppressed':\n case 'undeliverable':\n return { status: 409, error: 'This address can’t receive email, so the quote wasn’t sent. Copy the pay link and send it another way.' }\n case 'unverified-domain':\n return { status: 409, error: 'This site’s sending domain isn’t verified, so the quote wasn’t sent. Check Email settings.' }\n case 'suspended':\n return { status: 423, error: 'This site can’t send email right now, so the quote wasn’t sent.' }\n case 'held-for-review':\n return { status: 409, error: `The email was held for review, so it hasn’t gone out yet.${result.detail ? ` ${result.detail}` : ''}` }\n default:\n return { status: 502, error: 'The quote email could not be sent. Try again.' }\n }\n}\n\n/** The quote email, said and sent. */\nexport function quoteMailer(deps: QuoteMailerDeps = defaultQuoteMailerDeps()): QuoteMailer {\n return {\n configured: () => deps.configured(),\n suppression: (email) => deps.suppression(email),\n deliver: async (input) => {\n const { hostId, quoteId, order, host } = input\n const businessName = quoteBusinessName(host)\n const subject = input.subject || CommerceModel.quoteEmailDefaultSubject(order, businessName, quoteId)\n const [identity, theme] = await Promise.all([\n deps.sendingIdentity(hostId),\n deps.theme(hostId).catch(() => null),\n ])\n\n const content = quoteEmailContent({\n order,\n quoteId,\n businessName,\n currency: String(input.store?.['currency'] ?? 'USD'),\n subject,\n message: input.message,\n payUrl: input.payUrl,\n proofUrl: input.proofUrl,\n linkReplaced: input.linkReplaced,\n colors: proofColors(theme),\n radius: theme?.buttonRadius,\n })\n\n // The PDF is what the email says is attached; without it nothing goes.\n let pdf: CommercePdfFile\n try {\n pdf = await deps.quotePdf({\n hostId,\n quoteId,\n order,\n host,\n store: input.store,\n owner: input.owner,\n payUrl: input.payUrl,\n })\n } catch (error) {\n console.error('[commerce/quote-email] the PDF could not be made', { hostId, quoteId, error })\n return { sent: false, status: 500, error: 'The quote’s PDF could not be made, so nothing was sent. Try again.' }\n }\n\n const rendered = await deps.render(hostId, QUOTE_EMAIL_KEY, content.tokens)\n const thread = readEmailThread(order.quote?.emailThread)\n const siteHost = String(host['cname'] ?? '').trim() || null\n const messageId = mintEmailMessageId([identity?.domain, identity?.from, siteHost], {\n label: 'quote',\n atMs: input.atMs,\n })\n const replyTo = quoteReplyTo(host, input.senderEmail)\n\n const result = await deps.send({\n to: input.to,\n // The subject as sent, whatever a design's own says: it is what keeps\n // the conversation together, and it was typed for this send.\n subject,\n text: rendered?.text || content.text,\n ...(rendered?.html ? { html: rendered.html } : {}),\n headers: nextThreadHeaders(thread, messageId),\n ...(replyTo ? { replyTo } : {}),\n fromName: resolveBrandingProfile(input.owner?.org as never).fromName,\n sendingIdentity: identity,\n audience: 'tenant',\n context: 'quote',\n attachments: [\n {\n filename: pdf.filename,\n contentType: 'application/pdf',\n content: Buffer.from(pdf.bytes).toString('base64'),\n },\n ],\n })\n if (!result.sent) return { sent: false, ...refusalOf(result as Extract<SendEmailResult, { sent: false }>) }\n // Cost meter (AGL-1438), as every transactional site email.\n await deps.meter(hostId).catch((error) => console.error('[commerce/quote-email] not metered', error))\n return {\n sent: true,\n messageId,\n providerId: (result as Extract<SendEmailResult, { sent: true }>).id ?? null,\n subject,\n replyTo,\n thread: emailThreadAfterSend(thread, { messageId, subject }),\n }\n },\n }\n}\n"],"names":["siteContactEmail","resolveHostToken","resolveBrandingProfile","emailThreadAfterSend","isEmailConfigured","mintEmailMessageId","nextThreadHeaders","PLATFORM_EMAIL_PALETTE","readEmailThread","resolveEmailColor","sendEmail","firebaseAdmin","hostSendingIdentity","meterHostEmail","renderHostEmailWithTokens","siteEmailFrame","transactionalAddressSuppression","CommerceModel","QUOTE_EMAIL_KEY","quoteEmailContent","defaultQuoteMailerDeps","firestore","app","configured","sendingIdentity","hostId","render","key","tokens","theme","quotePdf","input","quotePdfFile","send","options","meter","suppression","email","EMAIL","quoteBusinessName","host","named","String","trim","quoteReplyTo","senderEmail","contact","test","sender","proofColors","read","token","palette","border","muted","refusalOf","result","reason","status","error","detail","quoteMailer","deps","deliver","order","quoteId","businessName","subject","quoteEmailDefaultSubject","identity","Promise","all","catch","content","currency","store","message","payUrl","proofUrl","linkReplaced","colors","radius","buttonRadius","pdf","owner","console","sent","rendered","thread","quote","emailThread","siteHost","messageId","domain","from","label","atMs","replyTo","to","text","html","headers","fromName","org","audience","context","attachments","filename","contentType","Buffer","bytes","toString","providerId","id"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,gBAAgB,EAAEC,gBAAgB,QAA8B,qCAAoC;AAC7G,SAASC,sBAAsB,QAAQ,2CAA0C;AACjF,SACEC,oBAAoB,EACpBC,iBAAiB,EACjBC,kBAAkB,EAClBC,iBAAiB,EACjBC,sBAAsB,EACtBC,eAAe,EACfC,iBAAiB,EACjBC,SAAS,QAMJ,2BAA0B;AACjC,SACEC,aAAa,EACbC,mBAAmB,EACnBC,cAAc,EACdC,yBAAyB,EACzBC,cAAc,EACdC,+BAA+B,QAC1B,2BAA0B;AACjC,YAAYC,mBAAmB,oBAAU;AACzC,SAASC,eAAe,EAAEC,iBAAiB,QAA+B,kCAA8B;AAgFxG,OAAO,SAASC;IACd,MAAMC,YAAY,IAAMV,cAAcW,GAAG,GAAGD,SAAS;IACrD,OAAO;QACLE,YAAY,IAAMnB;QAClBoB,iBAAiB,CAACC,SAAWb,oBAAoBa;QACjDC,QAAQ,CAACD,QAAQE,KAAKC,SAAWd,0BAA0BO,aAAaI,QAAQE,KAAKC;QACrFC,OAAO,OAAOJ,SAAW,AAAC,CAAA,MAAMV,eAAeM,aAAaI,QAAQP,gBAAe,EAAGW,KAAK;QAC3FC,UAAU,OAAOC,QAAU,AAAC,CAAA,MAAM,MAAM,CAAC,oBAAgB,EAAGC,YAAY,CAACD;QACzEE,MAAM,CAACC,UAAYxB,UAAUwB;QAC7BC,OAAO,OAAOV;YACZ,MAAMZ,eAAeY;QACvB;QACAW,aAAa,CAACC,QAAUrB,gCAAgCqB;IAC1D;AACF;AASA,MAAMC,QAAQ;AAEd,mDAAmD,GACnD,OAAO,SAASC,kBAAkBC,IAA6B;QAEjBA;IAD5C,MAAMC,QAAQxC,iBAAiB,gBAAgBuC;IAC/C,OAAOE,OAAOD,gBAAAA,QAAS,IAAIE,IAAI,MAAMD,QAAOF,aAAAA,IAAI,CAAC,OAAO,YAAZA,aAAgB,IAAIG,IAAI;AACtE;AAEA;;;CAGC,GACD,OAAO,SAASC,aAAaJ,IAA6B,EAAEK,WAAsC;IAChG,MAAMC,UAAU9C,iBAAiBwC;IACjC,IAAIM,WAAWR,MAAMS,IAAI,CAACD,UAAU,OAAOA;IAC3C,MAAME,SAASN,OAAOG,sBAAAA,cAAe,IAAIF,IAAI;IAC7C,OAAOL,MAAMS,IAAI,CAACC,UAAUA,SAAS;AACvC;AAEA,qGAAqG,GACrG,SAASC,YAAYpB,KAAwB;IAC3C,MAAMqB,OAAO,CAACC;YACZ1C,MAAAA;gBAAAA,QAAAA,qBAAAA,kBAAkB0C,OAAOtB,yBAAAA,MAAOuB,OAAO,aAAvC3C,qBAA4CA,kBAAkB0C,OAAO5C,mCAArEE,OAAgG;;IAClG,OAAO;QAAE4C,QAAQH,KAAK;QAAYI,OAAOJ,KAAK;IAAkB;AAClE;AAEA,mDAAmD,GACnD,SAASK,UAAUC,MAAiD;IAClE,OAAQA,OAAOC,MAAM;QACnB,KAAK;YACH,OAAO;gBAAEC,QAAQ;gBAAKC,OAAO;YAAqD;QACpF,KAAK;YACH,OAAO;gBAAED,QAAQ;gBAAKC,OAAO;YAAkE;QACjG,KAAK;QACL,KAAK;YACH,OAAO;gBAAED,QAAQ;gBAAKC,OAAO;YAAyG;QACxI,KAAK;YACH,OAAO;gBAAED,QAAQ;gBAAKC,OAAO;YAA6F;QAC5H,KAAK;YACH,OAAO;gBAAED,QAAQ;gBAAKC,OAAO;YAAkE;QACjG,KAAK;YACH,OAAO;gBAAED,QAAQ;gBAAKC,OAAO,CAAC,yDAAyD,EAAEH,OAAOI,MAAM,GAAG,CAAC,CAAC,EAAEJ,OAAOI,MAAM,EAAE,GAAG,IAAI;YAAC;QACtI;YACE,OAAO;gBAAEF,QAAQ;gBAAKC,OAAO;YAAgD;IACjF;AACF;AAEA,oCAAoC,GACpC,OAAO,SAASE,YAAYC,OAAwB1C,wBAAwB;IAC1E,OAAO;QACLG,YAAY,IAAMuC,KAAKvC,UAAU;QACjCa,aAAa,CAACC,QAAUyB,KAAK1B,WAAW,CAACC;QACzC0B,SAAS,OAAOhC;sBA0CUS,aAkCV;gBA/DKT,cA4BYiC,cAiBIjC;YAzDnC,MAAM,EAAEN,MAAM,EAAEwC,OAAO,EAAED,KAAK,EAAExB,IAAI,EAAE,GAAGT;YACzC,MAAMmC,eAAe3B,kBAAkBC;YACvC,MAAM2B,UAAUpC,MAAMoC,OAAO,IAAIlD,cAAcmD,wBAAwB,CAACJ,OAAOE,cAAcD;YAC7F,MAAM,CAACI,UAAUxC,MAAM,GAAG,MAAMyC,QAAQC,GAAG,CAAC;gBAC1CT,KAAKtC,eAAe,CAACC;gBACrBqC,KAAKjC,KAAK,CAACJ,QAAQ+C,KAAK,CAAC,IAAM;aAChC;YAED,MAAMC,UAAUtD,kBAAkB;gBAChC6C;gBACAC;gBACAC;gBACAQ,UAAUhC,gBAAOX,eAAAA,MAAM4C,KAAK,qBAAX5C,YAAa,CAAC,WAAW,mBAAI;gBAC9CoC;gBACAS,SAAS7C,MAAM6C,OAAO;gBACtBC,QAAQ9C,MAAM8C,MAAM;gBACpBC,UAAU/C,MAAM+C,QAAQ;gBACxBC,cAAchD,MAAMgD,YAAY;gBAChCC,QAAQ/B,YAAYpB;gBACpBoD,MAAM,EAAEpD,yBAAAA,MAAOqD,YAAY;YAC7B;YAEA,uEAAuE;YACvE,IAAIC;YACJ,IAAI;gBACFA,MAAM,MAAMrB,KAAKhC,QAAQ,CAAC;oBACxBL;oBACAwC;oBACAD;oBACAxB;oBACAmC,OAAO5C,MAAM4C,KAAK;oBAClBS,OAAOrD,MAAMqD,KAAK;oBAClBP,QAAQ9C,MAAM8C,MAAM;gBACtB;YACF,EAAE,OAAOlB,OAAO;gBACd0B,QAAQ1B,KAAK,CAAC,oDAAoD;oBAAElC;oBAAQwC;oBAASN;gBAAM;gBAC3F,OAAO;oBAAE2B,MAAM;oBAAO5B,QAAQ;oBAAKC,OAAO;gBAAqE;YACjH;YAEA,MAAM4B,WAAW,MAAMzB,KAAKpC,MAAM,CAACD,QAAQP,iBAAiBuD,QAAQ7C,MAAM;YAC1E,MAAM4D,SAAShF,iBAAgBwD,eAAAA,MAAMyB,KAAK,qBAAXzB,aAAa0B,WAAW;YACvD,MAAMC,WAAWjD,QAAOF,cAAAA,IAAI,CAAC,QAAQ,YAAbA,cAAiB,IAAIG,IAAI,MAAM;YACvD,MAAMiD,YAAYvF,mBAAmB;gBAACgE,4BAAAA,SAAUwB,MAAM;gBAAExB,4BAAAA,SAAUyB,IAAI;gBAAEH;aAAS,EAAE;gBACjFI,OAAO;gBACPC,MAAMjE,MAAMiE,IAAI;YAClB;YACA,MAAMC,UAAUrD,aAAaJ,MAAMT,MAAMc,WAAW;YAEpD,MAAMW,SAAS,MAAMM,KAAK7B,IAAI,CAAC;gBAC7BiE,IAAInE,MAAMmE,EAAE;gBACZ,sEAAsE;gBACtE,6DAA6D;gBAC7D/B;gBACAgC,MAAMZ,CAAAA,4BAAAA,SAAUY,IAAI,KAAI1B,QAAQ0B,IAAI;eAChCZ,CAAAA,4BAAAA,SAAUa,IAAI,IAAG;gBAAEA,MAAMb,SAASa,IAAI;YAAC,IAAI,CAAC;gBAChDC,SAAS/F,kBAAkBkF,QAAQI;eAC/BK,UAAU;gBAAEA;YAAQ,IAAI,CAAC;gBAC7BK,UAAUpG,wBAAuB6B,eAAAA,MAAMqD,KAAK,qBAAXrD,aAAawE,GAAG,EAAWD,QAAQ;gBACpE9E,iBAAiB6C;gBACjBmC,UAAU;gBACVC,SAAS;gBACTC,aAAa;oBACX;wBACEC,UAAUxB,IAAIwB,QAAQ;wBACtBC,aAAa;wBACbnC,SAASoC,OAAOf,IAAI,CAACX,IAAI2B,KAAK,EAAEC,QAAQ,CAAC;oBAC3C;iBACD;;YAEH,IAAI,CAACvD,OAAO8B,IAAI,EAAE,OAAO;gBAAEA,MAAM;eAAU/B,UAAUC;YACrD,4DAA4D;YAC5D,MAAMM,KAAK3B,KAAK,CAACV,QAAQ+C,KAAK,CAAC,CAACb,QAAU0B,QAAQ1B,KAAK,CAAC,sCAAsCA;YAC9F,OAAO;gBACL2B,MAAM;gBACNM;gBACAoB,UAAU,GAAE,aAAA,AAACxD,OAAoDyD,EAAE,YAAvD,aAA2D;gBACvE9C;gBACA8B;gBACAT,QAAQrF,qBAAqBqF,QAAQ;oBAAEI;oBAAWzB;gBAAQ;YAC5D;QACF;IACF;AACF"}
@@ -37,3 +37,14 @@ export declare function quotePayLinkUrl(host: {
37
37
  cname?: unknown;
38
38
  subdomain?: unknown;
39
39
  } | null | undefined, hostId: string, quoteId: string, version: number): string | null;
40
+ /**
41
+ * The absolute address of one of the current revision's proofs, behind the
42
+ * same signed link as the page (`GET /api/commerce/quote-proof`, AGL-4096),
43
+ * or null when the site has no public address or no signing secret. What the
44
+ * quote email draws a proof image from (AGL-4097); a link the business
45
+ * replaces stops serving it, with the page.
46
+ */
47
+ export declare function quoteProofLinkUrl(host: {
48
+ cname?: unknown;
49
+ subdomain?: unknown;
50
+ } | null | undefined, hostId: string, quoteId: string, version: number, proofId: string): string | null;
@@ -93,5 +93,26 @@ function signQuoteLink(hostId, quoteId, version) {
93
93
  if (!origin || !token) return null;
94
94
  return `${origin}${quotePageHref(quoteId, token)}`;
95
95
  }
96
+ /**
97
+ * The absolute address of one of the current revision's proofs, behind the
98
+ * same signed link as the page (`GET /api/commerce/quote-proof`, AGL-4096),
99
+ * or null when the site has no public address or no signing secret. What the
100
+ * quote email draws a proof image from (AGL-4097); a link the business
101
+ * replaces stops serving it, with the page.
102
+ */ export function quoteProofLinkUrl(host, hostId, quoteId, version, proofId) {
103
+ const origin = hostPublicOrigin({
104
+ cname: typeof (host == null ? void 0 : host.cname) === 'string' ? host.cname : null,
105
+ subdomain: typeof (host == null ? void 0 : host.subdomain) === 'string' ? host.subdomain : null
106
+ });
107
+ const token = mintQuoteLinkToken(hostId, quoteId, version);
108
+ if (!origin || !token || !proofId) return null;
109
+ const query = new URLSearchParams({
110
+ hostId,
111
+ q: quoteId,
112
+ t: token,
113
+ file: proofId
114
+ });
115
+ return `${origin}/api/commerce/quote-proof?${query.toString()}`;
116
+ }
96
117
 
97
118
  //# sourceMappingURL=quote-link-token.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/quote-link-token.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 { hostPublicOrigin } from '@aglyn/aglyn/server'\nimport { createHmac, timingSafeEqual } from 'crypto'\nimport { quotePageHref } from '../constants/quote-page'\nimport { tokenSigningSecret } from './download'\n\n/**\n * A quote's pay link (AGL-4094): `/pay-quote?q=<quoteId>&t=<token>` on the\n * business's own site, where the customer sees the quote and pays it.\n *\n * The token is `<version>.<signature>`: the quote's link version\n * (`quote.linkVersion`, absent reading as 1) and the first 32 hex characters of\n * `HMAC(quote-link:hostId:quoteId:version)` under the commerce\n * `TOKEN_SIGNING_SECRET`. A pure function of what the quote already holds, so\n * nothing is stored per link and every message that carried the link carries\n * the same working one. The `quote-link:` prefix keeps it from being replayed\n * as an order-status, download or supplier token, which sign other payloads\n * under the same secret.\n *\n * NO EXPIRY. A quote is paid when the customer is ready, and a link that died\n * first would be a quote they could not pay. The version is what retires a\n * link: replacing it bumps the version on the quote, and a link signed for an\n * older version still verifies, so its page can say the quote was replaced,\n * without showing any of it.\n */\nconst SIGNATURE_LENGTH = 32\nconst MAX_VERSION = 1_000_000\n\nfunction signQuoteLink(hostId: string, quoteId: string, version: number): string {\n return createHmac('sha256', tokenSigningSecret())\n .update(`quote-link:${hostId}:${quoteId}:${version}`)\n .digest('hex')\n .slice(0, SIGNATURE_LENGTH)\n}\n\n/** The token's two halves, or null for a token not shaped like one. */\nexport function parseQuoteLinkToken(token: string): { version: number; signature: string } | null {\n const match = /^([1-9]\\d{0,6})\\.([0-9a-f]{32})$/.exec(String(token ?? ''))\n if (!match) return null\n const version = Number(match[1])\n if (!Number.isSafeInteger(version) || version > MAX_VERSION) return null\n return { version, signature: match[2] }\n}\n\n/** The token for one version of a quote's link, or null when the signing secret is unset. */\nexport function mintQuoteLinkToken(hostId: string, quoteId: string, version: number): string | null {\n if (!hostId || !quoteId || !Number.isSafeInteger(version) || version < 1 || version > MAX_VERSION) {\n return null\n }\n try {\n return `${version}.${signQuoteLink(hostId, quoteId, version)}`\n } catch {\n return null\n }\n}\n\n/**\n * Checks a presented token in constant time and answers the link version it\n * was signed for, or null for a token that is wrong, malformed or unverifiable\n * (no secret). The caller compares the version with the quote's own.\n */\nexport function verifyQuoteLinkToken(hostId: string, quoteId: string, token: string): number | null {\n if (!hostId || !quoteId) return null\n const parsed = parseQuoteLinkToken(token)\n if (!parsed) return null\n let expected: string\n try {\n expected = signQuoteLink(hostId, quoteId, parsed.version)\n } catch {\n return null\n }\n const a = Buffer.from(parsed.signature)\n const b = Buffer.from(expected)\n if (a.length !== b.length) return null\n return timingSafeEqual(new Uint8Array(a), new Uint8Array(b)) ? parsed.version : null\n}\n\n/**\n * The absolute pay link for a quote at a link version, or null when either\n * half is missing: a site with no public address, or no signing secret. What\n * the console copies, and what a quote or invoice email puts in front of the\n * customer.\n */\nexport function quotePayLinkUrl(\n host: { cname?: unknown; subdomain?: unknown } | null | undefined,\n hostId: string,\n quoteId: string,\n version: number,\n): string | null {\n const origin = hostPublicOrigin({\n cname: typeof host?.cname === 'string' ? host.cname : null,\n subdomain: typeof host?.subdomain === 'string' ? host.subdomain : null,\n })\n const token = mintQuoteLinkToken(hostId, quoteId, version)\n if (!origin || !token) return null\n return `${origin}${quotePageHref(quoteId, token)}`\n}\n"],"names":["hostPublicOrigin","createHmac","timingSafeEqual","quotePageHref","tokenSigningSecret","SIGNATURE_LENGTH","MAX_VERSION","signQuoteLink","hostId","quoteId","version","update","digest","slice","parseQuoteLinkToken","token","match","exec","String","Number","isSafeInteger","signature","mintQuoteLinkToken","verifyQuoteLinkToken","parsed","expected","a","Buffer","from","b","length","Uint8Array","quotePayLinkUrl","host","origin","cname","subdomain"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,gBAAgB,QAAQ,sBAAqB;AACtD,SAASC,UAAU,EAAEC,eAAe,QAAQ,SAAQ;AACpD,SAASC,aAAa,QAAQ,6BAAyB;AACvD,SAASC,kBAAkB,QAAQ,gBAAY;AAE/C;;;;;;;;;;;;;;;;;;CAkBC,GACD,MAAMC,mBAAmB;AACzB,MAAMC,cAAc;AAEpB,SAASC,cAAcC,MAAc,EAAEC,OAAe,EAAEC,OAAe;IACrE,OAAOT,WAAW,UAAUG,sBACzBO,MAAM,CAAC,CAAC,WAAW,EAAEH,OAAO,CAAC,EAAEC,QAAQ,CAAC,EAAEC,SAAS,EACnDE,MAAM,CAAC,OACPC,KAAK,CAAC,GAAGR;AACd;AAEA,qEAAqE,GACrE,OAAO,SAASS,oBAAoBC,KAAa;IAC/C,MAAMC,QAAQ,mCAAmCC,IAAI,CAACC,OAAOH,gBAAAA,QAAS;IACtE,IAAI,CAACC,OAAO,OAAO;IACnB,MAAMN,UAAUS,OAAOH,KAAK,CAAC,EAAE;IAC/B,IAAI,CAACG,OAAOC,aAAa,CAACV,YAAYA,UAAUJ,aAAa,OAAO;IACpE,OAAO;QAAEI;QAASW,WAAWL,KAAK,CAAC,EAAE;IAAC;AACxC;AAEA,2FAA2F,GAC3F,OAAO,SAASM,mBAAmBd,MAAc,EAAEC,OAAe,EAAEC,OAAe;IACjF,IAAI,CAACF,UAAU,CAACC,WAAW,CAACU,OAAOC,aAAa,CAACV,YAAYA,UAAU,KAAKA,UAAUJ,aAAa;QACjG,OAAO;IACT;IACA,IAAI;QACF,OAAO,GAAGI,QAAQ,CAAC,EAAEH,cAAcC,QAAQC,SAASC,UAAU;IAChE,EAAE,eAAM;QACN,OAAO;IACT;AACF;AAEA;;;;CAIC,GACD,OAAO,SAASa,qBAAqBf,MAAc,EAAEC,OAAe,EAAEM,KAAa;IACjF,IAAI,CAACP,UAAU,CAACC,SAAS,OAAO;IAChC,MAAMe,SAASV,oBAAoBC;IACnC,IAAI,CAACS,QAAQ,OAAO;IACpB,IAAIC;IACJ,IAAI;QACFA,WAAWlB,cAAcC,QAAQC,SAASe,OAAOd,OAAO;IAC1D,EAAE,eAAM;QACN,OAAO;IACT;IACA,MAAMgB,IAAIC,OAAOC,IAAI,CAACJ,OAAOH,SAAS;IACtC,MAAMQ,IAAIF,OAAOC,IAAI,CAACH;IACtB,IAAIC,EAAEI,MAAM,KAAKD,EAAEC,MAAM,EAAE,OAAO;IAClC,OAAO5B,gBAAgB,IAAI6B,WAAWL,IAAI,IAAIK,WAAWF,MAAML,OAAOd,OAAO,GAAG;AAClF;AAEA;;;;;CAKC,GACD,OAAO,SAASsB,gBACdC,IAAiE,EACjEzB,MAAc,EACdC,OAAe,EACfC,OAAe;IAEf,MAAMwB,SAASlC,iBAAiB;QAC9BmC,OAAO,QAAOF,wBAAAA,KAAME,KAAK,MAAK,WAAWF,KAAKE,KAAK,GAAG;QACtDC,WAAW,QAAOH,wBAAAA,KAAMG,SAAS,MAAK,WAAWH,KAAKG,SAAS,GAAG;IACpE;IACA,MAAMrB,QAAQO,mBAAmBd,QAAQC,SAASC;IAClD,IAAI,CAACwB,UAAU,CAACnB,OAAO,OAAO;IAC9B,OAAO,GAAGmB,SAAS/B,cAAcM,SAASM,QAAQ;AACpD"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/quote-link-token.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 { hostPublicOrigin } from '@aglyn/aglyn/server'\nimport { createHmac, timingSafeEqual } from 'crypto'\nimport { quotePageHref } from '../constants/quote-page'\nimport { tokenSigningSecret } from './download'\n\n/**\n * A quote's pay link (AGL-4094): `/pay-quote?q=<quoteId>&t=<token>` on the\n * business's own site, where the customer sees the quote and pays it.\n *\n * The token is `<version>.<signature>`: the quote's link version\n * (`quote.linkVersion`, absent reading as 1) and the first 32 hex characters of\n * `HMAC(quote-link:hostId:quoteId:version)` under the commerce\n * `TOKEN_SIGNING_SECRET`. A pure function of what the quote already holds, so\n * nothing is stored per link and every message that carried the link carries\n * the same working one. The `quote-link:` prefix keeps it from being replayed\n * as an order-status, download or supplier token, which sign other payloads\n * under the same secret.\n *\n * NO EXPIRY. A quote is paid when the customer is ready, and a link that died\n * first would be a quote they could not pay. The version is what retires a\n * link: replacing it bumps the version on the quote, and a link signed for an\n * older version still verifies, so its page can say the quote was replaced,\n * without showing any of it.\n */\nconst SIGNATURE_LENGTH = 32\nconst MAX_VERSION = 1_000_000\n\nfunction signQuoteLink(hostId: string, quoteId: string, version: number): string {\n return createHmac('sha256', tokenSigningSecret())\n .update(`quote-link:${hostId}:${quoteId}:${version}`)\n .digest('hex')\n .slice(0, SIGNATURE_LENGTH)\n}\n\n/** The token's two halves, or null for a token not shaped like one. */\nexport function parseQuoteLinkToken(token: string): { version: number; signature: string } | null {\n const match = /^([1-9]\\d{0,6})\\.([0-9a-f]{32})$/.exec(String(token ?? ''))\n if (!match) return null\n const version = Number(match[1])\n if (!Number.isSafeInteger(version) || version > MAX_VERSION) return null\n return { version, signature: match[2] }\n}\n\n/** The token for one version of a quote's link, or null when the signing secret is unset. */\nexport function mintQuoteLinkToken(hostId: string, quoteId: string, version: number): string | null {\n if (!hostId || !quoteId || !Number.isSafeInteger(version) || version < 1 || version > MAX_VERSION) {\n return null\n }\n try {\n return `${version}.${signQuoteLink(hostId, quoteId, version)}`\n } catch {\n return null\n }\n}\n\n/**\n * Checks a presented token in constant time and answers the link version it\n * was signed for, or null for a token that is wrong, malformed or unverifiable\n * (no secret). The caller compares the version with the quote's own.\n */\nexport function verifyQuoteLinkToken(hostId: string, quoteId: string, token: string): number | null {\n if (!hostId || !quoteId) return null\n const parsed = parseQuoteLinkToken(token)\n if (!parsed) return null\n let expected: string\n try {\n expected = signQuoteLink(hostId, quoteId, parsed.version)\n } catch {\n return null\n }\n const a = Buffer.from(parsed.signature)\n const b = Buffer.from(expected)\n if (a.length !== b.length) return null\n return timingSafeEqual(new Uint8Array(a), new Uint8Array(b)) ? parsed.version : null\n}\n\n/**\n * The absolute pay link for a quote at a link version, or null when either\n * half is missing: a site with no public address, or no signing secret. What\n * the console copies, and what a quote or invoice email puts in front of the\n * customer.\n */\nexport function quotePayLinkUrl(\n host: { cname?: unknown; subdomain?: unknown } | null | undefined,\n hostId: string,\n quoteId: string,\n version: number,\n): string | null {\n const origin = hostPublicOrigin({\n cname: typeof host?.cname === 'string' ? host.cname : null,\n subdomain: typeof host?.subdomain === 'string' ? host.subdomain : null,\n })\n const token = mintQuoteLinkToken(hostId, quoteId, version)\n if (!origin || !token) return null\n return `${origin}${quotePageHref(quoteId, token)}`\n}\n\n/**\n * The absolute address of one of the current revision's proofs, behind the\n * same signed link as the page (`GET /api/commerce/quote-proof`, AGL-4096),\n * or null when the site has no public address or no signing secret. What the\n * quote email draws a proof image from (AGL-4097); a link the business\n * replaces stops serving it, with the page.\n */\nexport function quoteProofLinkUrl(\n host: { cname?: unknown; subdomain?: unknown } | null | undefined,\n hostId: string,\n quoteId: string,\n version: number,\n proofId: string,\n): string | null {\n const origin = hostPublicOrigin({\n cname: typeof host?.cname === 'string' ? host.cname : null,\n subdomain: typeof host?.subdomain === 'string' ? host.subdomain : null,\n })\n const token = mintQuoteLinkToken(hostId, quoteId, version)\n if (!origin || !token || !proofId) return null\n const query = new URLSearchParams({ hostId, q: quoteId, t: token, file: proofId })\n return `${origin}/api/commerce/quote-proof?${query.toString()}`\n}\n"],"names":["hostPublicOrigin","createHmac","timingSafeEqual","quotePageHref","tokenSigningSecret","SIGNATURE_LENGTH","MAX_VERSION","signQuoteLink","hostId","quoteId","version","update","digest","slice","parseQuoteLinkToken","token","match","exec","String","Number","isSafeInteger","signature","mintQuoteLinkToken","verifyQuoteLinkToken","parsed","expected","a","Buffer","from","b","length","Uint8Array","quotePayLinkUrl","host","origin","cname","subdomain","quoteProofLinkUrl","proofId","query","URLSearchParams","q","t","file","toString"],"mappings":"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"}
@@ -69,12 +69,17 @@ export interface LoadedQuote {
69
69
  order: CommerceModel.HostOrder;
70
70
  storeName: string;
71
71
  store: FirebaseFirestore.DocumentSnapshot;
72
+ /** A quote, or an order's invoice (only for a route that opens invoices). */
73
+ kind: CommerceModel.OrderPayLinkKind;
72
74
  }
73
75
  /**
74
76
  * The quote a link names, after its token: the quote, the replaced answer for
75
77
  * a link to an older version, or the one not-found answer for everything else.
78
+ * With `invoices`, a draft order's invoice too (AGL-4099).
76
79
  */
77
- export declare function loadLinkedQuote(deps: QuotePayDeps, hostId: string, quoteId: string, token: string): Promise<{
80
+ export declare function loadLinkedQuote(deps: QuotePayDeps, hostId: string, quoteId: string, token: string, options?: {
81
+ invoices?: boolean;
82
+ }): Promise<{
78
83
  loaded: LoadedQuote;
79
84
  } | {
80
85
  outcome: QuoteLinkOutcome;
@@ -88,12 +93,12 @@ export declare function quoteLinkParams(source: Record<string, unknown>): {
88
93
  };
89
94
  /** `GET /api/commerce/quote-view`: the quote page's data. */
90
95
  export declare function handleQuoteView(deps: QuotePayDeps, req: PluginApiRequest): Promise<QuoteLinkOutcome>;
91
- /** The Checkout Session a quote's Pay opens, as form params. Pure over its inputs. */
96
+ /** The Checkout Session a quote's (or an invoice's) Pay opens, as form params. Pure over its inputs. */
92
97
  export declare function buildQuoteSessionParams(input: {
93
98
  hostId: string;
94
99
  quoteId: string;
95
100
  order: CommerceModel.HostOrder;
96
- charge: CommerceModel.QuoteCheckoutCharge;
101
+ charge: CommerceModel.QuoteCheckoutCharge & Partial<Pick<CommerceModel.InvoiceCheckoutCharge, 'shippingOptions' | 'shippingCountries'>>;
97
102
  accountId: string;
98
103
  feeCents: number;
99
104
  taxRateId: string;
@@ -30,7 +30,8 @@ import { orderStatusUrl } from "./order-status-token.js";
30
30
  import { ensureCheckoutDomain } from "./payment-method-domains.js";
31
31
  import { verifyQuoteLinkToken } from "./quote-link-token.js";
32
32
  /*==========================================
33
- * THE CUSTOMER'S QUOTE PAGE AND ITS PAY BUTTON (AGL-4094).
33
+ * THE CUSTOMER'S QUOTE PAGE AND ITS PAY BUTTON (AGL-4094), AND AN
34
+ * INVOICE'S (AGL-4099).
34
35
  *
35
36
  * GET /api/commerce/quote-view?hostId=&q=<quoteId>&t=<token>
36
37
  * → 200 QuotePageView | 200 { state: 'replaced', storeName } | 404
@@ -53,6 +54,16 @@ import { verifyQuoteLinkToken } from "./quote-link-token.js";
53
54
  *
54
55
  * The payment completes in `billing-webhook.ts`, which turns this same
55
56
  * document into the order (`paidQuoteOrder`); nothing here marks it paid.
57
+ *
58
+ * AN INVOICE'S LINK IS THIS ONE (AGL-4099). A draft order the business sent
59
+ * an invoice for opens on the same page from the same kind of link (its
60
+ * version on `payLink.version`, `commerce-invoices.ts`), as "Invoice #N" with
61
+ * no proofs or approval, and Pay opens the same kind of fresh session from
62
+ * its stored lines, discount, tax and the shipping rates it was made with.
63
+ * Its webhook is the draft branch it always completed through, which flips
64
+ * the draft to paid without restamping its list fields. The proof and answer
65
+ * routes stay a quote's alone: `loadLinkedQuote` opens an invoice only for
66
+ * the routes that ask (`{ invoices: true }`).
56
67
  *=========================================*/ /** Lookups and presses one address may make per site, per ten minutes. */ export const QUOTE_VIEW_LOOKUPS_PER_WINDOW = 60;
57
68
  export const QUOTE_PAY_PRESSES_PER_WINDOW = 20;
58
69
  export const QUOTE_WINDOW_MS = 10 * 60000;
@@ -95,6 +106,13 @@ const NOT_FOUND = {
95
106
  error: 'We could not find that quote. Check the link you were sent.'
96
107
  }
97
108
  };
109
+ /** The not-found answer of a route an invoice's link reaches too: it names neither. */ const NOT_FOUND_ANY = {
110
+ status: 404,
111
+ body: {
112
+ error: 'We could not find that quote or invoice. Check the link you were sent.'
113
+ }
114
+ };
115
+ /** `quote` or `invoice`, as the customer reads it. */ const nounOf = (kind)=>kind === 'invoice' ? 'invoice' : 'quote';
98
116
  function bodyOf(req) {
99
117
  const raw = req.body;
100
118
  if (typeof raw === 'string') {
@@ -137,11 +155,13 @@ function headerOf(req, name) {
137
155
  /**
138
156
  * The quote a link names, after its token: the quote, the replaced answer for
139
157
  * a link to an older version, or the one not-found answer for everything else.
140
- */ export async function loadLinkedQuote(deps, hostId, quoteId, token) {
158
+ * With `invoices`, a draft order's invoice too (AGL-4099).
159
+ */ export async function loadLinkedQuote(deps, hostId, quoteId, token, options = {}) {
141
160
  var _hostSnapshot_data, _resolveHostToken;
161
+ const notFound = options.invoices ? NOT_FOUND_ANY : NOT_FOUND;
142
162
  const version = verifyQuoteLinkToken(hostId, quoteId, token);
143
163
  if (version === null) return {
144
- outcome: NOT_FOUND
164
+ outcome: notFound
145
165
  };
146
166
  const hostRef = deps.firestore().collection('hosts').doc(hostId);
147
167
  const [hostSnapshot, store, orderSnapshot] = await Promise.all([
@@ -150,17 +170,18 @@ function headerOf(req, name) {
150
170
  hostRef.collection('orders').doc(quoteId).get()
151
171
  ]);
152
172
  if (!hostSnapshot.exists || !orderSnapshot.exists) return {
153
- outcome: NOT_FOUND
173
+ outcome: notFound
154
174
  };
155
175
  const order = orderSnapshot.data();
156
- if (!CommerceModel.isQuote(order) || !order.quote) return {
157
- outcome: NOT_FOUND
176
+ const kind = CommerceModel.orderPayLinkKind(order);
177
+ if (kind === null || kind === 'invoice' && !options.invoices) return {
178
+ outcome: notFound
158
179
  };
159
180
  const host = (_hostSnapshot_data = hostSnapshot.data()) != null ? _hostSnapshot_data : {};
160
181
  const storeName = String((_resolveHostToken = resolveHostToken('businessName', host)) != null ? _resolveHostToken : '');
161
- const current = CommerceModel.quoteLinkVersion(order.quote);
182
+ const current = CommerceModel.orderPayLinkVersion(order);
162
183
  if (version > current) return {
163
- outcome: NOT_FOUND
184
+ outcome: notFound
164
185
  };
165
186
  if (version < current) {
166
187
  return {
@@ -168,9 +189,10 @@ function headerOf(req, name) {
168
189
  status: 410,
169
190
  body: {
170
191
  state: 'replaced',
192
+ kind,
171
193
  storeName,
172
194
  replaced: true,
173
- error: `This quote was updated, and ${storeName || 'the business'} sent you a newer link.`
195
+ error: `This ${nounOf(kind)} was updated, and ${storeName || 'the business'} sent you a newer link.`
174
196
  }
175
197
  }
176
198
  };
@@ -181,7 +203,8 @@ function headerOf(req, name) {
181
203
  host,
182
204
  order,
183
205
  storeName,
184
- store
206
+ store,
207
+ kind
185
208
  }
186
209
  };
187
210
  }
@@ -215,39 +238,51 @@ function headerOf(req, name) {
215
238
  };
216
239
  const refused = await limitedQuoteLink(deps, req, `commerce-quote-view:${hostId}`, QUOTE_VIEW_LOOKUPS_PER_WINDOW);
217
240
  if (refused) return refused;
218
- const linked = await loadLinkedQuote(deps, hostId, quoteId, token);
241
+ const linked = await loadLinkedQuote(deps, hostId, quoteId, token, {
242
+ invoices: true
243
+ });
219
244
  if ('outcome' in linked) {
220
245
  // The replaced page is a page, not a failure: answered as data.
221
246
  if (linked.outcome.status === 410) {
222
- const { storeName } = linked.outcome.body;
247
+ const { storeName, kind } = linked.outcome.body;
223
248
  return {
224
249
  status: 200,
225
250
  body: {
226
251
  state: 'replaced',
252
+ kind,
227
253
  storeName
228
254
  }
229
255
  };
230
256
  }
231
257
  return linked.outcome;
232
258
  }
233
- const { host, order, storeName, store } = linked.loaded;
234
- const view = CommerceModel.buildQuotePageView({
259
+ const { host, order, storeName, store, kind } = linked.loaded;
260
+ const currency = String((_store_get = store.get('currency')) != null ? _store_get : 'USD');
261
+ const receiptUrl = order.status === 'pending' ? null : orderStatusUrl(host, hostId, quoteId);
262
+ const view = kind === 'quote' ? CommerceModel.buildQuotePageView({
235
263
  order,
236
264
  orderId: quoteId,
237
265
  storeName,
238
- currency: String((_store_get = store.get('currency')) != null ? _store_get : 'USD'),
239
- receiptUrl: CommerceModel.isUnpaidQuote(order) ? null : orderStatusUrl(host, hostId, quoteId)
266
+ currency,
267
+ receiptUrl
268
+ }) : CommerceModel.buildInvoicePageView({
269
+ order,
270
+ orderId: quoteId,
271
+ storeName,
272
+ currency,
273
+ receiptUrl
240
274
  });
241
275
  return {
242
276
  status: 200,
243
277
  body: view
244
278
  };
245
279
  }
246
- /** The Checkout Session a quote's Pay opens, as form params. Pure over its inputs. */ export function buildQuoteSessionParams(input) {
247
- var _ref;
280
+ /** The Checkout Session a quote's (or an invoice's) Pay opens, as form params. Pure over its inputs. */ export function buildQuoteSessionParams(input) {
281
+ var _order_quote, _charge_shippingOptions, _ref;
248
282
  var _quote_customer;
249
283
  const { charge, order } = input;
250
- const quote = order.quote;
284
+ const invoice = CommerceModel.orderPayLinkKind(order) === 'invoice';
285
+ const quote = (_order_quote = order.quote) != null ? _order_quote : {};
251
286
  const params = new URLSearchParams(_extends({
252
287
  mode: 'payment'
253
288
  }, checkoutSessionCardAuthenticationParams('payment'), charge.taxAtPayment ? {
@@ -267,7 +302,14 @@ function headerOf(req, name) {
267
302
  // Shipping the business priced for the quote's ship-to, charged as the one
268
303
  // option, with the address asked for where it ships (and where Stripe Tax
269
304
  // reads the tax from). The quote's own address stands when it asks nothing.
270
- if (charge.shippingCents > 0) {
305
+ // An invoiced draft offers the rates it was made with, and the buyer picks
306
+ // one, as on the draft's first link (AGL-4099).
307
+ const rates = (_charge_shippingOptions = charge.shippingOptions) != null ? _charge_shippingOptions : [];
308
+ if (rates.length > 0) {
309
+ var _charge_shippingCountries;
310
+ CommerceModel.appendShippingAddressCollectionParams(params, ((_charge_shippingCountries = charge.shippingCountries) == null ? void 0 : _charge_shippingCountries.length) ? charge.shippingCountries : CommerceModel.CHECKOUT_SHIPPING_COUNTRIES);
311
+ CommerceModel.appendCheckoutShippingParams(params, rates);
312
+ } else if (charge.shippingCents > 0) {
271
313
  var _ref1;
272
314
  var _quote_shipTo;
273
315
  const country = String((_ref1 = (_quote_shipTo = quote.shipTo) == null ? void 0 : _quote_shipTo.country) != null ? _ref1 : '').toUpperCase();
@@ -282,7 +324,7 @@ function headerOf(req, name) {
282
324
  }
283
325
  ]);
284
326
  }
285
- const shippingFloorCents = charge.shippingCents;
327
+ const shippingFloorCents = rates.length > 0 ? CommerceModel.shippingFloorCents(rates) : charge.shippingCents;
286
328
  for (const [key, value] of Object.entries(CommerceModel.destinationChargeParams({
287
329
  accountId: input.accountId,
288
330
  feeCents: input.feeCents,
@@ -292,7 +334,7 @@ function headerOf(req, name) {
292
334
  }))){
293
335
  params.set(key, value);
294
336
  }
295
- const email = String((_ref = (_quote_customer = quote.customer) == null ? void 0 : _quote_customer.email) != null ? _ref : '').trim();
337
+ const email = String((_ref = invoice ? order.customerEmail : (_quote_customer = quote.customer) == null ? void 0 : _quote_customer.email) != null ? _ref : '').trim();
296
338
  if (email) params.set('customer_email', email);
297
339
  const separator = input.returnUrl.includes('?') ? '&' : '?';
298
340
  params.set('success_url', `${input.returnUrl}${separator}session_id={CHECKOUT_SESSION_ID}`);
@@ -303,9 +345,18 @@ function headerOf(req, name) {
303
345
  params.set('metadata[type]', 'commerce-draft');
304
346
  params.set('metadata[hostId]', input.hostId);
305
347
  params.set('metadata[orderId]', input.quoteId);
306
- params.set('metadata[quoteId]', input.quoteId);
307
- params.set('metadata[quoteLinkVersion]', String(CommerceModel.quoteLinkVersion(quote)));
308
- params.set('metadata[quoteUpdatedAtMs]', String(Number(quote.updatedAtMs) || 0));
348
+ if (invoice) {
349
+ // An invoice's link version, which the draft branch compares as a
350
+ // quote's (AGL-4099), and the promotion the draft was priced with, which
351
+ // it counts once the draft is paid (no hold: the draft's lapsed with its
352
+ // first session).
353
+ params.set('metadata[payLinkVersion]', String(CommerceModel.orderPayLinkVersion(order)));
354
+ if (order.discountId) params.set('metadata[discountId]', String(order.discountId));
355
+ } else {
356
+ params.set('metadata[quoteId]', input.quoteId);
357
+ params.set('metadata[quoteLinkVersion]', String(CommerceModel.quoteLinkVersion(quote)));
358
+ params.set('metadata[quoteUpdatedAtMs]', String(Number(quote.updatedAtMs) || 0));
359
+ }
309
360
  params.set('metadata[feeCents]', String(input.feeCents));
310
361
  // A tax charged as its own line is invisible to Stripe's tax total; this
311
362
  // witness is what records it as the business's tax.
@@ -362,20 +413,24 @@ const refusal = (status, reason, error, extra = {})=>({
362
413
  };
363
414
  const refused = await limitedQuoteLink(deps, req, `commerce-quote-pay:${hostId}`, QUOTE_PAY_PRESSES_PER_WINDOW);
364
415
  if (refused) return refused;
365
- const linked = await loadLinkedQuote(deps, hostId, quoteId, token);
416
+ const linked = await loadLinkedQuote(deps, hostId, quoteId, token, {
417
+ invoices: true
418
+ });
366
419
  if ('outcome' in linked) return linked.outcome;
367
- const { hostRef, host, order, storeName, store } = linked.loaded;
420
+ const { hostRef, host, order, storeName, store, kind } = linked.loaded;
421
+ const noun = nounOf(kind);
368
422
  // Every refusal the customer can be given is above the claim, so none of
369
423
  // them spends the press. `v` is the edit of the quote the page showed: a
370
424
  // quote changed since is shown again before it is paid, never charged unseen.
425
+ // An invoice is not edited, so it sends none.
371
426
  const seen = Number(bodyOf(req)['v']);
372
- if (Number.isFinite(seen) && seen > 0 && seen !== (Number((_order_quote = order.quote) == null ? void 0 : _order_quote.updatedAtMs) || 0)) {
427
+ if (kind === 'quote' && Number.isFinite(seen) && seen > 0 && seen !== (Number((_order_quote = order.quote) == null ? void 0 : _order_quote.updatedAtMs) || 0)) {
373
428
  return refusal(409, 'updated', QUOTE_UPDATED_MESSAGE);
374
429
  }
375
- const blocked = CommerceModel.quotePayRefusal(order, quoteId);
376
- if (blocked) return refusal(409, blocked, CommerceModel.quotePayRefusalMessage(blocked, storeName));
430
+ const blocked = CommerceModel.invoicePayRefusal(order, quoteId);
431
+ if (blocked) return refusal(409, blocked, CommerceModel.invoicePayRefusalMessage(blocked, storeName, kind));
377
432
  const owner = await deps.orgForHost(hostId);
378
- const unavailable = refusal(409, 'payments-unavailable', `${storeName || 'This business'} can’t take payments online right now. Contact them to pay this quote.`);
433
+ const unavailable = refusal(409, 'payments-unavailable', `${storeName || 'This business'} can’t take payments online right now. Contact them to pay this ${noun}.`);
379
434
  if (!checkEntitlement(owner == null ? void 0 : owner.org, 'commerce')) return unavailable;
380
435
  const ownerUid = owner == null ? void 0 : (_owner_org = owner.org) == null ? void 0 : _owner_org.ownerUid;
381
436
  const profile = ownerUid ? await deps.firestore().collection('profiles').doc(String(ownerUid)).get() : null;
@@ -385,17 +440,20 @@ const refusal = (status, reason, error, extra = {})=>({
385
440
  chargesEnabled: profile == null ? void 0 : profile.get('stripeChargesEnabled'),
386
441
  accountLivemode: profile == null ? void 0 : profile.get('stripeAccountLivemode')
387
442
  }, {
388
- subject: `quote ${quoteId} on host ${hostId}`
443
+ subject: `${noun} ${quoteId} on host ${hostId}`
389
444
  });
390
445
  if (!ready) return unavailable;
391
446
  const taxSettings = (_store_get = store.get('tax')) != null ? _store_get : {};
392
447
  if (CommerceModel.storefrontTaxDecision({
393
448
  settings: taxSettings
394
449
  }).kind === 'undecided') {
395
- return refusal(409, 'tax-undecided', CommerceModel.quotePayRefusalMessage('tax-undecided', storeName));
450
+ return refusal(409, 'tax-undecided', CommerceModel.invoicePayRefusalMessage('tax-undecided', storeName, kind));
396
451
  }
397
- const charge = CommerceModel.quoteCheckoutCharge(order, quoteId);
398
- const feeCents = resolveTransactionFeeCents(owner == null ? void 0 : owner.org, charge.feeProductType, charge.goodsCents, charge.totalCents);
452
+ const charge = CommerceModel.invoiceCheckoutCharge(order, quoteId);
453
+ // The fee is sized on the most the buyer can pay, as a draft's first link
454
+ // sized it on its dearest rate.
455
+ const dearestRateCents = charge.shippingOptions.reduce((most, rate)=>Math.max(most, rate.amountCents), 0);
456
+ const feeCents = resolveTransactionFeeCents(owner == null ? void 0 : owner.org, charge.feeProductType, charge.goodsCents, charge.totalCents + dearestRateCents);
399
457
  const nativeMode = await deps.nativeMode(String((_ref = owner == null ? void 0 : owner.id) != null ? _ref : ''));
400
458
  const site = host;
401
459
  const origin = siteReturnOrigin({
@@ -407,8 +465,11 @@ const refusal = (status, reason, error, extra = {})=>({
407
465
  requestHost: headerOf(req, 'host')
408
466
  });
409
467
  const returnUrl = `${origin}${quotePageHref(quoteId, token)}`;
468
+ // A draft's first session (the one its paymentLinkUrl opens) is closed
469
+ // here too, so the draft has one way to pay from the first press on.
410
470
  const openedFor = (_order_checkoutSessionId = order.checkoutSessionId) != null ? _order_checkoutSessionId : '';
411
471
  const openedAtEdit = Number((_order_quote1 = order.quote) == null ? void 0 : _order_quote1.updatedAtMs) || 0;
472
+ const openedAtVersion = CommerceModel.orderPayLinkVersion(order);
412
473
  const claimed = await claimAttempt(deps.firestore(), {
413
474
  kind: 'commerce-quote-pay',
414
475
  scopeId: `${hostId}:${quoteId}`,
@@ -438,7 +499,7 @@ const refusal = (status, reason, error, extra = {})=>({
438
499
  const previous = await deps.expireSession(openedFor);
439
500
  if (previous === 'complete') {
440
501
  await claim.release();
441
- return refusal(409, 'paid', 'This quote has just been paid. Your receipt is on its way.', {
502
+ return refusal(409, 'paid', `This ${noun} has just been paid. Your receipt is on its way.`, {
442
503
  confirming: true
443
504
  });
444
505
  }
@@ -519,29 +580,30 @@ const refusal = (status, reason, error, extra = {})=>({
519
580
  var _current_quote;
520
581
  const snapshot = await transaction.get(orderRef);
521
582
  const current = snapshot.exists ? snapshot.data() : null;
522
- if (!current || !CommerceModel.isQuote(current)) return {
523
- outcome: NOT_FOUND,
583
+ if (!current || CommerceModel.orderPayLinkKind(current) !== kind) return {
584
+ outcome: NOT_FOUND_ANY,
524
585
  displaced: ''
525
586
  };
526
- if (!CommerceModel.isUnpaidQuote(current)) {
587
+ if (current.status !== 'pending') {
588
+ const closed = current.status === 'cancelled' || current.status === 'refunded';
527
589
  return {
528
- outcome: refusal(409, 'paid', CommerceModel.quotePayRefusalMessage('paid', storeName)),
590
+ outcome: refusal(409, closed ? 'closed' : 'paid', CommerceModel.invoicePayRefusalMessage(closed ? 'closed' : 'paid', storeName, kind)),
529
591
  displaced: ''
530
592
  };
531
593
  }
532
- if (CommerceModel.quoteLinkVersion(current.quote) !== CommerceModel.quoteLinkVersion(order.quote)) {
594
+ if (CommerceModel.orderPayLinkVersion(current) !== openedAtVersion) {
533
595
  return {
534
596
  outcome: {
535
597
  status: 410,
536
598
  body: {
537
599
  replaced: true,
538
- error: `This quote was updated, and ${storeName || 'the business'} sent you a newer link.`
600
+ error: `This ${noun} was updated, and ${storeName || 'the business'} sent you a newer link.`
539
601
  }
540
602
  },
541
603
  displaced: ''
542
604
  };
543
605
  }
544
- if ((Number((_current_quote = current.quote) == null ? void 0 : _current_quote.updatedAtMs) || 0) !== openedAtEdit) {
606
+ if (kind === 'quote' && (Number((_current_quote = current.quote) == null ? void 0 : _current_quote.updatedAtMs) || 0) !== openedAtEdit) {
545
607
  return {
546
608
  outcome: refusal(409, 'updated', QUOTE_UPDATED_MESSAGE),
547
609
  displaced: ''