@aglyn/plugins-commerce 1.0.0-beta.291 → 1.0.0-beta.293

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 (115) hide show
  1. package/README.md +1 -0
  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/console/host-quotes-card.component.js +11 -9
  8. package/src/lib/components/console/host-quotes-card.component.js.map +1 -1
  9. package/src/lib/components/console/invoice-send.component.js +1 -1
  10. package/src/lib/components/console/invoice-send.component.js.map +1 -1
  11. package/src/lib/components/console/pos/pos-item-dialog.component.js +3 -2
  12. package/src/lib/components/console/pos/pos-item-dialog.component.js.map +1 -1
  13. package/src/lib/components/console/pos-kiosk-page.component.js +2 -1
  14. package/src/lib/components/console/pos-kiosk-page.component.js.map +1 -1
  15. package/src/lib/components/console/pos-page.component.js +1 -1
  16. package/src/lib/components/console/pos-page.component.js.map +1 -1
  17. package/src/lib/components/console/product-add-ons-fields.component.d.ts +41 -0
  18. package/src/lib/components/console/product-add-ons-fields.component.js +356 -0
  19. package/src/lib/components/console/product-add-ons-fields.component.js.map +1 -0
  20. package/src/lib/components/console/product-editor-dialog.component.js +31 -5
  21. package/src/lib/components/console/product-editor-dialog.component.js.map +1 -1
  22. package/src/lib/components/console/product-register-fields.component.d.ts +4 -5
  23. package/src/lib/components/console/product-register-fields.component.js +8 -197
  24. package/src/lib/components/console/product-register-fields.component.js.map +1 -1
  25. package/src/lib/components/console/products-hub-zone.component.js +6 -0
  26. package/src/lib/components/console/products-hub-zone.component.js.map +1 -1
  27. package/src/lib/components/console/quote-pay-link.component.d.ts +8 -1
  28. package/src/lib/components/console/quote-pay-link.component.js +29 -3
  29. package/src/lib/components/console/quote-pay-link.component.js.map +1 -1
  30. package/src/lib/components/console/quote-send.component.js +1 -4
  31. package/src/lib/components/console/quote-send.component.js.map +1 -1
  32. package/src/lib/components/product-add-ons.d.ts +28 -0
  33. package/src/lib/components/product-add-ons.js +136 -0
  34. package/src/lib/components/product-add-ons.js.map +1 -0
  35. package/src/lib/components/product-detail.d.ts +16 -0
  36. package/src/lib/components/product-detail.js +112 -19
  37. package/src/lib/components/product-detail.js.map +1 -1
  38. package/src/lib/components/product-grid.d.ts +8 -2
  39. package/src/lib/components/product-grid.js +6 -1
  40. package/src/lib/components/product-grid.js.map +1 -1
  41. package/src/lib/declarations.console-server.js +15 -0
  42. package/src/lib/declarations.console-server.js.map +1 -1
  43. package/src/lib/model/commerce-cart.d.ts +15 -3
  44. package/src/lib/model/commerce-cart.js +10 -3
  45. package/src/lib/model/commerce-cart.js.map +1 -1
  46. package/src/lib/model/commerce-invoices.d.ts +7 -0
  47. package/src/lib/model/commerce-invoices.js +11 -0
  48. package/src/lib/model/commerce-invoices.js.map +1 -1
  49. package/src/lib/model/commerce-orders.d.ts +6 -3
  50. package/src/lib/model/commerce-orders.js +3 -0
  51. package/src/lib/model/commerce-orders.js.map +1 -1
  52. package/src/lib/model/commerce-pos-kiosk.d.ts +1 -1
  53. package/src/lib/model/commerce-pos-kiosk.js +3 -2
  54. package/src/lib/model/commerce-pos-kiosk.js.map +1 -1
  55. package/src/lib/model/commerce-quote-email.d.ts +14 -0
  56. package/src/lib/model/commerce-quote-email.js +25 -9
  57. package/src/lib/model/commerce-quote-email.js.map +1 -1
  58. package/src/lib/model/commerce-quote-proofs.d.ts +7 -0
  59. package/src/lib/model/commerce-quote-proofs.js +15 -0
  60. package/src/lib/model/commerce-quote-proofs.js.map +1 -1
  61. package/src/lib/model/commerce-quotes.js +1 -1
  62. package/src/lib/model/commerce-quotes.js.map +1 -1
  63. package/src/lib/model/commerce.d.ts +18 -0
  64. package/src/lib/model/commerce.js +26 -0
  65. package/src/lib/model/commerce.js.map +1 -1
  66. package/src/lib/model/product-modifiers.d.ts +74 -4
  67. package/src/lib/model/product-modifiers.js +131 -10
  68. package/src/lib/model/product-modifiers.js.map +1 -1
  69. package/src/lib/server/billing-webhook.js +64 -43
  70. package/src/lib/server/billing-webhook.js.map +1 -1
  71. package/src/lib/server/cart-checkout.js +14 -2
  72. package/src/lib/server/cart-checkout.js.map +1 -1
  73. package/src/lib/server/cart.d.ts +5 -0
  74. package/src/lib/server/cart.js +41 -4
  75. package/src/lib/server/cart.js.map +1 -1
  76. package/src/lib/server/catalog.d.ts +6 -0
  77. package/src/lib/server/catalog.js +2 -0
  78. package/src/lib/server/catalog.js.map +1 -1
  79. package/src/lib/server/checkout-extras.js +3 -1
  80. package/src/lib/server/checkout-extras.js.map +1 -1
  81. package/src/lib/server/checkout.js +28 -3
  82. package/src/lib/server/checkout.js.map +1 -1
  83. package/src/lib/server/local-fulfillment.js +5 -1
  84. package/src/lib/server/local-fulfillment.js.map +1 -1
  85. package/src/lib/server/pos-offline-sync.js +1 -1
  86. package/src/lib/server/pos-offline-sync.js.map +1 -1
  87. package/src/lib/server/pos-order.js +1 -1
  88. package/src/lib/server/pos-order.js.map +1 -1
  89. package/src/lib/server/product.d.ts +26 -0
  90. package/src/lib/server/product.js +18 -0
  91. package/src/lib/server/product.js.map +1 -1
  92. package/src/lib/server/products-write.js +5 -2
  93. package/src/lib/server/products-write.js.map +1 -1
  94. package/src/lib/server/quote-email.js.map +1 -1
  95. package/src/lib/server/quote-link-token.d.ts +4 -2
  96. package/src/lib/server/quote-link-token.js +4 -2
  97. package/src/lib/server/quote-link-token.js.map +1 -1
  98. package/src/lib/server/quote-pay.d.ts +5 -1
  99. package/src/lib/server/quote-pay.js +5 -2
  100. package/src/lib/server/quote-pay.js.map +1 -1
  101. package/src/lib/server/quote-proofs.d.ts +9 -1
  102. package/src/lib/server/quote-proofs.js +92 -23
  103. package/src/lib/server/quote-proofs.js.map +1 -1
  104. package/src/lib/server/quote-upload-keys.d.ts +50 -0
  105. package/src/lib/server/quote-upload-keys.js +86 -0
  106. package/src/lib/server/quote-upload-keys.js.map +1 -0
  107. package/src/lib/server/quote-upload-sweep.d.ts +30 -0
  108. package/src/lib/server/quote-upload-sweep.js +117 -0
  109. package/src/lib/server/quote-upload-sweep.js.map +1 -0
  110. package/src/lib/utils/add-to-cart.d.ts +8 -0
  111. package/src/lib/utils/add-to-cart.js +10 -2
  112. package/src/lib/utils/add-to-cart.js.map +1 -1
  113. package/src/lib/utils/quote-email-content.d.ts +1 -0
  114. package/src/lib/utils/quote-email-content.js +7 -4
  115. package/src/lib/utils/quote-email-content.js.map +1 -1
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/model/commerce-pos-kiosk.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 { posTipFromPercent, posTipProblem, type PosReceiptChannel } from './commerce-pos'\nimport type { ModifierSelection, ProductModifierGroup } from './product-modifiers'\n\n/*==========================================\n * THE SELF-SERVICE KIOSK (AGL-3623).\n *\n * A register's second kind of paired screen: where the customer display\n * (AGL-3608) mirrors the cashier's basket, a kiosk lets the customer build\n * their own order — a product grid with categories and modifiers, a cart, a\n * tip, payment on the register's card reader or Tap to Pay, or \"pay at\n * counter\", which sends the order to the register's queue for a cashier.\n *\n * It pairs exactly like a display (the same six-digit code and the same\n * hashed device token, with `mode: 'kiosk'` on both), and it never holds a\n * staff session: every request carries the device token and the server\n * prices everything. These are the shapes the kiosk route and every client\n * of it — the web kiosk and the native POS app — agree on.\n *=========================================*/\n\n/** Which kind of paired screen a device token opens. */\nexport type PosDeviceMode = 'display' | 'kiosk'\n\n/** A product as the kiosk grid shows it: no cost, no stock count, no supplier. */\nexport interface PosKioskProduct {\n id: string\n name: string\n description?: string\n imageUrl?: string\n categoryIds: string[]\n /** The product's option axes (\"Size\": S, M, L), for the variant picker. */\n options: Array<{ name: string; values: string[] }>\n variants: PosKioskVariant[]\n modifierGroups: ProductModifierGroup[]\n}\n\nexport interface PosKioskVariant {\n id: string\n /** Option selections keyed by option name; empty for the default variant. */\n options: Record<string, string>\n priceCents: number\n /** A tracked variant at or below zero whose product refuses to oversell. */\n soldOut: boolean\n}\n\nexport interface PosKioskCategory {\n id: string\n name: string\n}\n\nexport interface PosKioskCatalog {\n products: PosKioskProduct[]\n categories: PosKioskCategory[]\n /** The store's currency, lower case (`usd`). */\n currency: string\n}\n\n/** What the kiosk offers, resolved on the server for one paired register. */\nexport interface PosKioskContext {\n branding: {\n name: string\n logoUrl: string | null\n logoDarkUrl: string | null\n message: string\n }\n currency: string\n tipping: { enabled: boolean; percentages: number[] }\n payments: {\n /** A Stripe Terminal smart reader is assigned to this register. */\n reader: boolean\n /** Card-present payments are switched on for the platform (Tap to Pay needs the native app too). */\n cardPresent: boolean\n /** \"Pay at counter\" sends the order to the register's queue. */\n payAtCounter: boolean\n }\n receipts: PosReceiptChannel[]\n offerMarketing: boolean\n /** Seconds without a touch before the kiosk asks \"Still there?\". */\n idleSeconds: number\n /** Stripe test mode: a test card can be tapped from the payment screen. */\n testMode: boolean\n}\n\n/** One cart line as the kiosk sends it: ids and a count, never a price. */\nexport interface PosKioskLine {\n productId: string\n variantId?: string\n quantity: number\n modifiers?: ModifierSelection[]\n}\n\n/** A line of a priced kiosk order, as the customer's screen shows it. */\nexport interface PosKioskSaleLine {\n name: string\n variantLabel?: string\n quantity: number\n amountCents: number\n}\n\nexport type PosKioskSaleStatus =\n /** Priced and waiting for a card at the kiosk. */\n | 'open'\n /** Sent to the register's queue; a cashier takes payment. */\n | 'queued'\n | 'paid'\n | 'voided'\n\n/** A kiosk order as its own screen reads it back. */\nexport interface PosKioskSale {\n orderId: string\n /** The order number the customer is called by. */\n number: number\n status: PosKioskSaleStatus\n lines: PosKioskSaleLine[]\n itemsCents: number\n discountCents: number\n taxCents: number\n totalCents: number\n tipCents: number\n paidCents: number\n dueCents: number\n /** The card payment in flight, if any. */\n payment: { id: string; status: string; failureMessage?: string } | null\n}\n\n/** Seconds without a touch before the kiosk asks \"Still there?\", by default. */\nexport const POS_KIOSK_IDLE_SECONDS_DEFAULT = 90\nexport const POS_KIOSK_IDLE_SECONDS_MIN = 30\nexport const POS_KIOSK_IDLE_SECONDS_MAX = 600\n\n/** How long \"Still there?\" waits before the kiosk clears itself. */\nexport const POS_KIOSK_IDLE_WARNING_SECONDS = 20\n\n/** The order-number screen returns to the start after this long. */\nexport const POS_KIOSK_DONE_SECONDS = 15\n\n/** Most lines one kiosk order carries. */\nexport const POS_KIOSK_MAX_LINES = 40\n\n/** Most of one line a customer may order at a kiosk. */\nexport const POS_KIOSK_MAX_QUANTITY = 20\n\n/** Most products the kiosk grid lists in one category. */\nexport const POS_KIOSK_CATALOG_LIMIT = 200\n\n/** Wrong staff PINs a kiosk takes before it locks its unlock screen. */\nexport const POS_KIOSK_UNLOCK_MAX_ATTEMPTS = 5\n\n/** How long a kiosk's unlock screen stays locked after too many wrong PINs. */\nexport const POS_KIOSK_UNLOCK_LOCKOUT_MS = 15 * 60 * 1000\n\n/** How long a staff unlock keeps the kiosk's settings open. */\nexport const POS_KIOSK_UNLOCK_TTL_MS = 5 * 60 * 1000\n\nconst ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/\n\n/**\n * The cart as the kiosk may send it, or why not. Ids are shape-checked and\n * quantities bounded here; everything else — whether the product exists, is\n * for sale, what it costs and whether its modifiers are valid — is the sale\n * route's to decide from the product documents.\n */\nexport function sanitizePosKioskLines(\n raw: unknown,\n): { ok: true; lines: PosKioskLine[] } | { ok: false; error: string } {\n if (!Array.isArray(raw) || raw.length === 0) return { ok: false, error: 'Your cart is empty.' }\n if (raw.length > POS_KIOSK_MAX_LINES) {\n return { ok: false, error: `An order can have at most ${POS_KIOSK_MAX_LINES} items.` }\n }\n const lines: PosKioskLine[] = []\n for (const entry of raw as Array<Record<string, unknown>>) {\n const productId = String(entry?.['productId'] ?? '')\n const variantId = entry?.['variantId'] == null ? '' : String(entry['variantId'])\n const quantity = Number(entry?.['quantity'])\n if (!ID_PATTERN.test(productId) || (variantId && !ID_PATTERN.test(variantId))) {\n return { ok: false, error: 'An item in your cart is no longer available.' }\n }\n if (!Number.isInteger(quantity) || quantity < 1 || quantity > POS_KIOSK_MAX_QUANTITY) {\n return { ok: false, error: `Order 1 to ${POS_KIOSK_MAX_QUANTITY} of each item.` }\n }\n const rawModifiers = Array.isArray(entry?.['modifiers']) ? (entry['modifiers'] as unknown[]) : []\n if (rawModifiers.length > 200) return { ok: false, error: 'Too many choices on one item.' }\n const modifiers: ModifierSelection[] = []\n for (const pick of rawModifiers as Array<Record<string, unknown>>) {\n const groupId = String(pick?.['groupId'] ?? '')\n const optionId = String(pick?.['optionId'] ?? '')\n if (!ID_PATTERN.test(groupId) || !ID_PATTERN.test(optionId)) {\n return { ok: false, error: 'A choice on an item is no longer offered.' }\n }\n modifiers.push({ groupId, optionId })\n }\n lines.push({\n productId,\n ...(variantId ? { variantId } : {}),\n quantity,\n ...(modifiers.length ? { modifiers } : {}),\n })\n }\n return { ok: true, lines }\n}\n\n/**\n * The tip a kiosk customer chose, recomputed from the server's own figure\n * for what is being paid: a percentage only from the store's presets, a\n * custom amount bounded like every register tip. Null when the choice is\n * not one the store offers.\n */\nexport function posKioskTipCents(\n choice: unknown,\n input: { baseCents: number; enabled: boolean; percentages: readonly number[]; percent?: unknown; cents?: unknown },\n): number | null {\n if (choice === undefined || choice === null || choice === 'none') return 0\n if (!input.enabled) return null\n if (choice === 'percent') {\n const percent = Math.round(Number(input.percent))\n if (!input.percentages.includes(percent)) return null\n return posTipFromPercent(input.baseCents, percent)\n }\n if (choice === 'custom') {\n const cents = Math.round(Number(input.cents))\n return posTipProblem(cents, input.baseCents) ? null : cents\n }\n return null\n}\n\n/** The idle timeout a merchant set, bounded. */\nexport function posKioskIdleSeconds(raw: unknown): number {\n const value = Math.round(Number(raw))\n if (!Number.isFinite(value) || value <= 0) return POS_KIOSK_IDLE_SECONDS_DEFAULT\n return Math.min(POS_KIOSK_IDLE_SECONDS_MAX, Math.max(POS_KIOSK_IDLE_SECONDS_MIN, value))\n}\n\n/**\n * The kiosk's estimate of its cart, from the catalog prices it was given —\n * shown while the customer shops and never sent. The order is priced again,\n * with tax, by the server when they check out.\n */\nexport function posKioskEstimateCents(\n lines: readonly PosKioskLine[],\n products: readonly PosKioskProduct[],\n): number {\n let total = 0\n for (const line of lines) {\n const product = products.find((candidate) => candidate.id === line.productId)\n if (!product) continue\n const variant =\n product.variants.find((candidate) => candidate.id === line.variantId) ?? product.variants[0]\n if (!variant) continue\n const extra = (line.modifiers ?? []).reduce((sum, pick) => {\n const group = product.modifierGroups.find((candidate) => candidate.id === pick.groupId)\n const option = group?.options.find((candidate) => candidate.id === pick.optionId)\n return sum + (option?.priceCents ?? 0)\n }, 0)\n total += (variant.priceCents + extra) * line.quantity\n }\n return total\n}\n"],"names":["posTipFromPercent","posTipProblem","POS_KIOSK_IDLE_SECONDS_DEFAULT","POS_KIOSK_IDLE_SECONDS_MIN","POS_KIOSK_IDLE_SECONDS_MAX","POS_KIOSK_IDLE_WARNING_SECONDS","POS_KIOSK_DONE_SECONDS","POS_KIOSK_MAX_LINES","POS_KIOSK_MAX_QUANTITY","POS_KIOSK_CATALOG_LIMIT","POS_KIOSK_UNLOCK_MAX_ATTEMPTS","POS_KIOSK_UNLOCK_LOCKOUT_MS","POS_KIOSK_UNLOCK_TTL_MS","ID_PATTERN","sanitizePosKioskLines","raw","Array","isArray","length","ok","error","lines","entry","productId","String","variantId","quantity","Number","test","isInteger","rawModifiers","modifiers","pick","groupId","optionId","push","posKioskTipCents","choice","input","undefined","enabled","percent","Math","round","percentages","includes","baseCents","cents","posKioskIdleSeconds","value","isFinite","min","max","posKioskEstimateCents","products","total","line","product","find","candidate","id","variant","variants","extra","reduce","sum","group","modifierGroups","option","options","priceCents"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/model/commerce-pos-kiosk.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 { posTipFromPercent, posTipProblem, type PosReceiptChannel } from './commerce-pos'\nimport { modifierOptionPriceCents, type ModifierSelection, type ProductModifierGroup } from './product-modifiers'\n\n/*==========================================\n * THE SELF-SERVICE KIOSK (AGL-3623).\n *\n * A register's second kind of paired screen: where the customer display\n * (AGL-3608) mirrors the cashier's basket, a kiosk lets the customer build\n * their own order — a product grid with categories and modifiers, a cart, a\n * tip, payment on the register's card reader or Tap to Pay, or \"pay at\n * counter\", which sends the order to the register's queue for a cashier.\n *\n * It pairs exactly like a display (the same six-digit code and the same\n * hashed device token, with `mode: 'kiosk'` on both), and it never holds a\n * staff session: every request carries the device token and the server\n * prices everything. These are the shapes the kiosk route and every client\n * of it — the web kiosk and the native POS app — agree on.\n *=========================================*/\n\n/** Which kind of paired screen a device token opens. */\nexport type PosDeviceMode = 'display' | 'kiosk'\n\n/** A product as the kiosk grid shows it: no cost, no stock count, no supplier. */\nexport interface PosKioskProduct {\n id: string\n name: string\n description?: string\n imageUrl?: string\n categoryIds: string[]\n /** The product's option axes (\"Size\": S, M, L), for the variant picker. */\n options: Array<{ name: string; values: string[] }>\n variants: PosKioskVariant[]\n modifierGroups: ProductModifierGroup[]\n}\n\nexport interface PosKioskVariant {\n id: string\n /** Option selections keyed by option name; empty for the default variant. */\n options: Record<string, string>\n priceCents: number\n /** A tracked variant at or below zero whose product refuses to oversell. */\n soldOut: boolean\n}\n\nexport interface PosKioskCategory {\n id: string\n name: string\n}\n\nexport interface PosKioskCatalog {\n products: PosKioskProduct[]\n categories: PosKioskCategory[]\n /** The store's currency, lower case (`usd`). */\n currency: string\n}\n\n/** What the kiosk offers, resolved on the server for one paired register. */\nexport interface PosKioskContext {\n branding: {\n name: string\n logoUrl: string | null\n logoDarkUrl: string | null\n message: string\n }\n currency: string\n tipping: { enabled: boolean; percentages: number[] }\n payments: {\n /** A Stripe Terminal smart reader is assigned to this register. */\n reader: boolean\n /** Card-present payments are switched on for the platform (Tap to Pay needs the native app too). */\n cardPresent: boolean\n /** \"Pay at counter\" sends the order to the register's queue. */\n payAtCounter: boolean\n }\n receipts: PosReceiptChannel[]\n offerMarketing: boolean\n /** Seconds without a touch before the kiosk asks \"Still there?\". */\n idleSeconds: number\n /** Stripe test mode: a test card can be tapped from the payment screen. */\n testMode: boolean\n}\n\n/** One cart line as the kiosk sends it: ids and a count, never a price. */\nexport interface PosKioskLine {\n productId: string\n variantId?: string\n quantity: number\n modifiers?: ModifierSelection[]\n}\n\n/** A line of a priced kiosk order, as the customer's screen shows it. */\nexport interface PosKioskSaleLine {\n name: string\n variantLabel?: string\n quantity: number\n amountCents: number\n}\n\nexport type PosKioskSaleStatus =\n /** Priced and waiting for a card at the kiosk. */\n | 'open'\n /** Sent to the register's queue; a cashier takes payment. */\n | 'queued'\n | 'paid'\n | 'voided'\n\n/** A kiosk order as its own screen reads it back. */\nexport interface PosKioskSale {\n orderId: string\n /** The order number the customer is called by. */\n number: number\n status: PosKioskSaleStatus\n lines: PosKioskSaleLine[]\n itemsCents: number\n discountCents: number\n taxCents: number\n totalCents: number\n tipCents: number\n paidCents: number\n dueCents: number\n /** The card payment in flight, if any. */\n payment: { id: string; status: string; failureMessage?: string } | null\n}\n\n/** Seconds without a touch before the kiosk asks \"Still there?\", by default. */\nexport const POS_KIOSK_IDLE_SECONDS_DEFAULT = 90\nexport const POS_KIOSK_IDLE_SECONDS_MIN = 30\nexport const POS_KIOSK_IDLE_SECONDS_MAX = 600\n\n/** How long \"Still there?\" waits before the kiosk clears itself. */\nexport const POS_KIOSK_IDLE_WARNING_SECONDS = 20\n\n/** The order-number screen returns to the start after this long. */\nexport const POS_KIOSK_DONE_SECONDS = 15\n\n/** Most lines one kiosk order carries. */\nexport const POS_KIOSK_MAX_LINES = 40\n\n/** Most of one line a customer may order at a kiosk. */\nexport const POS_KIOSK_MAX_QUANTITY = 20\n\n/** Most products the kiosk grid lists in one category. */\nexport const POS_KIOSK_CATALOG_LIMIT = 200\n\n/** Wrong staff PINs a kiosk takes before it locks its unlock screen. */\nexport const POS_KIOSK_UNLOCK_MAX_ATTEMPTS = 5\n\n/** How long a kiosk's unlock screen stays locked after too many wrong PINs. */\nexport const POS_KIOSK_UNLOCK_LOCKOUT_MS = 15 * 60 * 1000\n\n/** How long a staff unlock keeps the kiosk's settings open. */\nexport const POS_KIOSK_UNLOCK_TTL_MS = 5 * 60 * 1000\n\nconst ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/\n\n/**\n * The cart as the kiosk may send it, or why not. Ids are shape-checked and\n * quantities bounded here; everything else — whether the product exists, is\n * for sale, what it costs and whether its modifiers are valid — is the sale\n * route's to decide from the product documents.\n */\nexport function sanitizePosKioskLines(\n raw: unknown,\n): { ok: true; lines: PosKioskLine[] } | { ok: false; error: string } {\n if (!Array.isArray(raw) || raw.length === 0) return { ok: false, error: 'Your cart is empty.' }\n if (raw.length > POS_KIOSK_MAX_LINES) {\n return { ok: false, error: `An order can have at most ${POS_KIOSK_MAX_LINES} items.` }\n }\n const lines: PosKioskLine[] = []\n for (const entry of raw as Array<Record<string, unknown>>) {\n const productId = String(entry?.['productId'] ?? '')\n const variantId = entry?.['variantId'] == null ? '' : String(entry['variantId'])\n const quantity = Number(entry?.['quantity'])\n if (!ID_PATTERN.test(productId) || (variantId && !ID_PATTERN.test(variantId))) {\n return { ok: false, error: 'An item in your cart is no longer available.' }\n }\n if (!Number.isInteger(quantity) || quantity < 1 || quantity > POS_KIOSK_MAX_QUANTITY) {\n return { ok: false, error: `Order 1 to ${POS_KIOSK_MAX_QUANTITY} of each item.` }\n }\n const rawModifiers = Array.isArray(entry?.['modifiers']) ? (entry['modifiers'] as unknown[]) : []\n if (rawModifiers.length > 200) return { ok: false, error: 'Too many choices on one item.' }\n const modifiers: ModifierSelection[] = []\n for (const pick of rawModifiers as Array<Record<string, unknown>>) {\n const groupId = String(pick?.['groupId'] ?? '')\n const optionId = String(pick?.['optionId'] ?? '')\n if (!ID_PATTERN.test(groupId) || !ID_PATTERN.test(optionId)) {\n return { ok: false, error: 'A choice on an item is no longer offered.' }\n }\n modifiers.push({ groupId, optionId })\n }\n lines.push({\n productId,\n ...(variantId ? { variantId } : {}),\n quantity,\n ...(modifiers.length ? { modifiers } : {}),\n })\n }\n return { ok: true, lines }\n}\n\n/**\n * The tip a kiosk customer chose, recomputed from the server's own figure\n * for what is being paid: a percentage only from the store's presets, a\n * custom amount bounded like every register tip. Null when the choice is\n * not one the store offers.\n */\nexport function posKioskTipCents(\n choice: unknown,\n input: { baseCents: number; enabled: boolean; percentages: readonly number[]; percent?: unknown; cents?: unknown },\n): number | null {\n if (choice === undefined || choice === null || choice === 'none') return 0\n if (!input.enabled) return null\n if (choice === 'percent') {\n const percent = Math.round(Number(input.percent))\n if (!input.percentages.includes(percent)) return null\n return posTipFromPercent(input.baseCents, percent)\n }\n if (choice === 'custom') {\n const cents = Math.round(Number(input.cents))\n return posTipProblem(cents, input.baseCents) ? null : cents\n }\n return null\n}\n\n/** The idle timeout a merchant set, bounded. */\nexport function posKioskIdleSeconds(raw: unknown): number {\n const value = Math.round(Number(raw))\n if (!Number.isFinite(value) || value <= 0) return POS_KIOSK_IDLE_SECONDS_DEFAULT\n return Math.min(POS_KIOSK_IDLE_SECONDS_MAX, Math.max(POS_KIOSK_IDLE_SECONDS_MIN, value))\n}\n\n/**\n * The kiosk's estimate of its cart, from the catalog prices it was given —\n * shown while the customer shops and never sent. The order is priced again,\n * with tax, by the server when they check out.\n */\nexport function posKioskEstimateCents(\n lines: readonly PosKioskLine[],\n products: readonly PosKioskProduct[],\n): number {\n let total = 0\n for (const line of lines) {\n const product = products.find((candidate) => candidate.id === line.productId)\n if (!product) continue\n const variant =\n product.variants.find((candidate) => candidate.id === line.variantId) ?? product.variants[0]\n if (!variant) continue\n const extra = (line.modifiers ?? []).reduce((sum, pick) => {\n const group = product.modifierGroups.find((candidate) => candidate.id === pick.groupId)\n const option = group?.options.find((candidate) => candidate.id === pick.optionId)\n // A choice priced by variant adds its price on this one (AGL-4100).\n return sum + (option ? modifierOptionPriceCents(option, variant.id) : 0)\n }, 0)\n total += (variant.priceCents + extra) * line.quantity\n }\n return total\n}\n"],"names":["posTipFromPercent","posTipProblem","modifierOptionPriceCents","POS_KIOSK_IDLE_SECONDS_DEFAULT","POS_KIOSK_IDLE_SECONDS_MIN","POS_KIOSK_IDLE_SECONDS_MAX","POS_KIOSK_IDLE_WARNING_SECONDS","POS_KIOSK_DONE_SECONDS","POS_KIOSK_MAX_LINES","POS_KIOSK_MAX_QUANTITY","POS_KIOSK_CATALOG_LIMIT","POS_KIOSK_UNLOCK_MAX_ATTEMPTS","POS_KIOSK_UNLOCK_LOCKOUT_MS","POS_KIOSK_UNLOCK_TTL_MS","ID_PATTERN","sanitizePosKioskLines","raw","Array","isArray","length","ok","error","lines","entry","productId","String","variantId","quantity","Number","test","isInteger","rawModifiers","modifiers","pick","groupId","optionId","push","posKioskTipCents","choice","input","undefined","enabled","percent","Math","round","percentages","includes","baseCents","cents","posKioskIdleSeconds","value","isFinite","min","max","posKioskEstimateCents","products","total","line","product","find","candidate","id","variant","variants","extra","reduce","sum","group","modifierGroups","option","options","priceCents"],"mappings":"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"}
@@ -26,6 +26,14 @@ export declare const QUOTE_MAX_SEND_SUBJECT = 200;
26
26
  export declare function quoteSends(quote: Pick<OrderQuote, 'sends'> | null | undefined): QuoteSendRecord[];
27
27
  /** The last time the quote was emailed, or null. */
28
28
  export declare function lastQuoteSend(quote: Pick<OrderQuote, 'sends'> | null | undefined): QuoteSendRecord | null;
29
+ /**
30
+ * Whether emailing the quote now hands the customer a new revision of a quote
31
+ * they were already emailed: an earlier revision went out, and this one has
32
+ * not yet. The quote's first email (whatever revision it is at, since
33
+ * revisions nobody was sent replace nothing) and a resend of the revision
34
+ * already sent are neither, so their email does not call itself an update.
35
+ */
36
+ export declare function quoteSendIsNewRevision(quote: Pick<OrderQuote, 'sends' | 'revision'> | null | undefined): boolean;
29
37
  /**
30
38
  * Whether emailing the quote now gives the customer a new pay link: they were
31
39
  * emailed an earlier revision, and the link has not been replaced since.
@@ -35,6 +43,12 @@ export declare function quoteSendReplacesLink(quote: Pick<OrderQuote, 'sends' |
35
43
  export declare function quoteSendLabel(quote: Pick<OrderQuote, 'sends' | 'revision'> | null | undefined): string;
36
44
  /** Where the quote goes: what was typed in the dialog, else the customer's email. */
37
45
  export declare function quoteSendRecipient(order: Pick<HostOrder, 'quote'> | null | undefined, typed: string | null | undefined): string;
46
+ /**
47
+ * Who the console's send form starts addressed to: the customer's email, else
48
+ * where the quote was last emailed, so Send again and Send revision N go
49
+ * where the last one went when the quote names no email.
50
+ */
51
+ export declare function quoteSendPrefill(order: Pick<HostOrder, 'quote'> | null | undefined): string;
38
52
  export type QuoteSendRefusal = 'not-a-quote' | 'paid' | 'no-address' | 'bad-address';
39
53
  /** Why the quote cannot be emailed to `to` now, if it cannot. */
40
54
  export declare function quoteSendRefusal(order: Pick<HostOrder, 'channel' | 'status' | 'quote'> | null | undefined, to: string): QuoteSendRefusal | null;
@@ -49,28 +49,44 @@ const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
49
49
  const sends = quoteSends(quote);
50
50
  return sends.length ? sends[sends.length - 1] : null;
51
51
  }
52
+ /**
53
+ * Whether emailing the quote now hands the customer a new revision of a quote
54
+ * they were already emailed: an earlier revision went out, and this one has
55
+ * not yet. The quote's first email (whatever revision it is at, since
56
+ * revisions nobody was sent replace nothing) and a resend of the revision
57
+ * already sent are neither, so their email does not call itself an update.
58
+ */ export function quoteSendIsNewRevision(quote) {
59
+ var _ref;
60
+ const last = lastQuoteSend(quote);
61
+ return Boolean(last) && Number(last.revision) < Number((_ref = quote == null ? void 0 : quote.revision) != null ? _ref : 1);
62
+ }
52
63
  /**
53
64
  * Whether emailing the quote now gives the customer a new pay link: they were
54
65
  * emailed an earlier revision, and the link has not been replaced since.
55
66
  */ export function quoteSendReplacesLink(quote) {
56
- var _ref;
57
- const last = lastQuoteSend(quote);
58
- if (!last) return false;
59
- if (!(Number(last.revision) < Number((_ref = quote == null ? void 0 : quote.revision) != null ? _ref : 1))) return false;
60
- return Number(last.linkVersion) === quoteLinkVersion(quote);
67
+ var _lastQuoteSend;
68
+ if (!quoteSendIsNewRevision(quote)) return false;
69
+ return Number((_lastQuoteSend = lastQuoteSend(quote)) == null ? void 0 : _lastQuoteSend.linkVersion) === quoteLinkVersion(quote);
61
70
  }
62
71
  /** What the console's Send button says: `Send quote`, `Send again`, or `Send revision 2`. */ export function quoteSendLabel(quote) {
63
72
  var _ref;
64
- const last = lastQuoteSend(quote);
65
- if (!last) return 'Send quote';
66
- const revision = Number((_ref = quote == null ? void 0 : quote.revision) != null ? _ref : 1);
67
- return Number(last.revision) < revision ? `Send revision ${revision}` : 'Send again';
73
+ if (!lastQuoteSend(quote)) return 'Send quote';
74
+ return quoteSendIsNewRevision(quote) ? `Send revision ${Number((_ref = quote == null ? void 0 : quote.revision) != null ? _ref : 1)}` : 'Send again';
68
75
  }
69
76
  /** Where the quote goes: what was typed in the dialog, else the customer's email. */ export function quoteSendRecipient(order, typed) {
70
77
  var _ref;
71
78
  var _order_quote_customer, _order_quote;
72
79
  return String(typed != null ? typed : '').trim() || String((_ref = order == null ? void 0 : (_order_quote = order.quote) == null ? void 0 : (_order_quote_customer = _order_quote.customer) == null ? void 0 : _order_quote_customer.email) != null ? _ref : '').trim();
73
80
  }
81
+ /**
82
+ * Who the console's send form starts addressed to: the customer's email, else
83
+ * where the quote was last emailed, so Send again and Send revision N go
84
+ * where the last one went when the quote names no email.
85
+ */ export function quoteSendPrefill(order) {
86
+ var _ref;
87
+ var _lastQuoteSend;
88
+ return quoteSendRecipient(order, null) || String((_ref = (_lastQuoteSend = lastQuoteSend(order == null ? void 0 : order.quote)) == null ? void 0 : _lastQuoteSend.to) != null ? _ref : '').trim();
89
+ }
74
90
  /** Why the quote cannot be emailed to `to` now, if it cannot. */ export function quoteSendRefusal(order, to) {
75
91
  if (!order || !isQuote(order) || !order.quote) return 'not-a-quote';
76
92
  if (!isUnpaidQuote(order)) return 'paid';
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/model/commerce-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 { appendOrderEvent, type HostOrder } from './commerce-orders'\nimport { quoteLinkVersion } from './commerce-quote-pay'\nimport { latestQuoteResponse } from './commerce-quote-proofs'\nimport {\n formatQuoteNumber,\n isQuote,\n isUnpaidQuote,\n type OrderQuote,\n type QuoteEmailThread,\n type QuoteSendRecord,\n} from './commerce-quotes'\n\n/*==========================================\n * EMAILING A QUOTE TO THE CUSTOMER (AGL-4097).\n *\n * The business sends the quote from the console: one email in the site's\n * look with its message, the proof, the PDF and the pay button, in the\n * customer's existing conversation when there is one. Each send is recorded\n * on the quote (`quote.sends`) with the revision it carried and the pay link\n * version it carried, and the conversation is kept as `quote.emailThread`.\n *\n * THE PAY LINK ON A NEW REVISION. When the customer was emailed an earlier\n * revision, the first email of a new revision gives them a new pay link and\n * retires the old one (slice 2's Replace link), so the link in the earlier\n * email opens a page saying the quote was updated. A resend of the same\n * revision keeps the link. \"Was the link already replaced since that email\"\n * is read off the record (the link version it carried), which is also what\n * keeps a send that failed after replacing the link from replacing it again\n * when it is retried.\n *\n * Pure: the console's Send button and the server's send action read the\n * same answers.\n *=========================================*/\n\n/** The most sends a quote keeps on record; the oldest go first. */\nexport const QUOTE_MAX_SENDS = 50\n\n/** The longest message a send carries. */\nexport const QUOTE_MAX_SEND_MESSAGE = 5_000\n\n/** The longest subject a send carries. */\nexport const QUOTE_MAX_SEND_SUBJECT = 200\n\nconst EMAIL = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/\n\n/** The quote's sends on record, oldest first. */\nexport function quoteSends(quote: Pick<OrderQuote, 'sends'> | null | undefined): QuoteSendRecord[] {\n return Array.isArray(quote?.sends) ? quote.sends.filter((send) => send && typeof send === 'object') : []\n}\n\n/** The last time the quote was emailed, or null. */\nexport function lastQuoteSend(quote: Pick<OrderQuote, 'sends'> | null | undefined): QuoteSendRecord | null {\n const sends = quoteSends(quote)\n return sends.length ? sends[sends.length - 1] : null\n}\n\n/**\n * Whether emailing the quote now gives the customer a new pay link: they were\n * emailed an earlier revision, and the link has not been replaced since.\n */\nexport function quoteSendReplacesLink(\n quote: Pick<OrderQuote, 'sends' | 'revision' | 'linkVersion'> | null | undefined,\n): boolean {\n const last = lastQuoteSend(quote)\n if (!last) return false\n if (!(Number(last.revision) < Number(quote?.revision ?? 1))) return false\n return Number(last.linkVersion) === quoteLinkVersion(quote)\n}\n\n/** What the console's Send button says: `Send quote`, `Send again`, or `Send revision 2`. */\nexport function quoteSendLabel(quote: Pick<OrderQuote, 'sends' | 'revision'> | null | undefined): string {\n const last = lastQuoteSend(quote)\n if (!last) return 'Send quote'\n const revision = Number(quote?.revision ?? 1)\n return Number(last.revision) < revision ? `Send revision ${revision}` : 'Send again'\n}\n\n/** Where the quote goes: what was typed in the dialog, else the customer's email. */\nexport function quoteSendRecipient(\n order: Pick<HostOrder, 'quote'> | null | undefined,\n typed: string | null | undefined,\n): string {\n return String(typed ?? '').trim() || String(order?.quote?.customer?.email ?? '').trim()\n}\n\nexport type QuoteSendRefusal = 'not-a-quote' | 'paid' | 'no-address' | 'bad-address'\n\n/** Why the quote cannot be emailed to `to` now, if it cannot. */\nexport function quoteSendRefusal(\n order: Pick<HostOrder, 'channel' | 'status' | 'quote'> | null | undefined,\n to: string,\n): QuoteSendRefusal | null {\n if (!order || !isQuote(order) || !order.quote) return 'not-a-quote'\n if (!isUnpaidQuote(order)) return 'paid'\n if (!to) return 'no-address'\n if (to.length > 320 || !EMAIL.test(to)) return 'bad-address'\n return null\n}\n\nexport function quoteSendRefusalMessage(reason: QuoteSendRefusal): string {\n switch (reason) {\n case 'not-a-quote':\n return 'Unknown quote'\n case 'paid':\n return 'This quote has been paid, so it is an order now and is not sent again.'\n case 'no-address':\n return 'This quote has no email for the customer. Add one to the quote, or type where to send it.'\n case 'bad-address':\n return 'Enter a valid email address to send the quote to.'\n }\n}\n\n/**\n * The subject a send defaults to: the conversation's, so it stays one\n * conversation, else `Quote #1042 from Acme Signs`.\n */\nexport function quoteEmailDefaultSubject(\n order: Pick<HostOrder, 'number' | 'quote'>,\n businessName: string,\n quoteId?: string,\n): string {\n const thread = String(order.quote?.emailThread?.subject ?? '').trim()\n if (thread) return thread\n const name = String(businessName ?? '').trim()\n const number = formatQuoteNumber(order, quoteId)\n return name ? `${number} from ${name}` : number\n}\n\n/** What the console sends: where, under what subject, and the business's message. */\nexport interface QuoteSendInput {\n to: string\n subject: string\n message: string\n}\n\n/** A send as the route received it, trimmed, or what to fix. `to` and `subject` may be empty: the defaults fill them. */\nexport function parseQuoteSendInput(raw: unknown): { ok: true; input: QuoteSendInput } | { ok: false; error: string } {\n const source = (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>\n const text = (value: unknown) => (typeof value === 'string' ? value : '')\n const to = text(source['to']).trim()\n // A subject is one header line.\n const subject = text(source['subject']).replace(/[\\r\\n]+/g, ' ').trim()\n const message = text(source['message']).replace(/\\r\\n?/g, '\\n').trim()\n if (subject.length > QUOTE_MAX_SEND_SUBJECT) {\n return { ok: false, error: `Keep the subject under ${QUOTE_MAX_SEND_SUBJECT} characters.` }\n }\n if (message.length > QUOTE_MAX_SEND_MESSAGE) {\n return { ok: false, error: `Keep the message under ${QUOTE_MAX_SEND_MESSAGE.toLocaleString('en-US')} characters.` }\n }\n return { ok: true, input: { to, subject, message } }\n}\n\n/**\n * The quote once an email went out: the send on record, the conversation\n * moved on, a line on the timeline, and the quote at Sent, unless the\n * customer has already answered this revision (approved it, or asked for\n * changes), which stays what it says.\n */\nexport function quoteAfterSend(\n order: HostOrder,\n send: QuoteSendRecord,\n thread: QuoteEmailThread,\n): HostOrder {\n const quote = order.quote as OrderQuote\n const answered = latestQuoteResponse(quote) !== null\n return {\n ...order,\n timeline: appendOrderEvent(\n order,\n 'quote-sent',\n `Revision ${send.revision} emailed to ${send.to} by ${send.by}`,\n send.atMs,\n ),\n quote: {\n ...quote,\n stage: answered ? quote.stage : 'sent',\n sends: [...quoteSends(quote), send].slice(-QUOTE_MAX_SENDS),\n emailThread: thread,\n },\n }\n}\n"],"names":["appendOrderEvent","quoteLinkVersion","latestQuoteResponse","formatQuoteNumber","isQuote","isUnpaidQuote","QUOTE_MAX_SENDS","QUOTE_MAX_SEND_MESSAGE","QUOTE_MAX_SEND_SUBJECT","EMAIL","quoteSends","quote","Array","isArray","sends","filter","send","lastQuoteSend","length","quoteSendReplacesLink","last","Number","revision","linkVersion","quoteSendLabel","quoteSendRecipient","order","typed","String","trim","customer","email","quoteSendRefusal","to","test","quoteSendRefusalMessage","reason","quoteEmailDefaultSubject","businessName","quoteId","thread","emailThread","subject","name","number","parseQuoteSendInput","raw","source","text","value","replace","message","ok","error","toLocaleString","input","quoteAfterSend","answered","timeline","by","atMs","stage","slice"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/model/commerce-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 { appendOrderEvent, type HostOrder } from './commerce-orders'\nimport { quoteLinkVersion } from './commerce-quote-pay'\nimport { latestQuoteResponse } from './commerce-quote-proofs'\nimport {\n formatQuoteNumber,\n isQuote,\n isUnpaidQuote,\n type OrderQuote,\n type QuoteEmailThread,\n type QuoteSendRecord,\n} from './commerce-quotes'\n\n/*==========================================\n * EMAILING A QUOTE TO THE CUSTOMER (AGL-4097).\n *\n * The business sends the quote from the console: one email in the site's\n * look with its message, the proof, the PDF and the pay button, in the\n * customer's existing conversation when there is one. Each send is recorded\n * on the quote (`quote.sends`) with the revision it carried and the pay link\n * version it carried, and the conversation is kept as `quote.emailThread`.\n *\n * THE PAY LINK ON A NEW REVISION. When the customer was emailed an earlier\n * revision, the first email of a new revision gives them a new pay link and\n * retires the old one (slice 2's Replace link), so the link in the earlier\n * email opens a page saying the quote was updated. A resend of the same\n * revision keeps the link. \"Was the link already replaced since that email\"\n * is read off the record (the link version it carried), which is also what\n * keeps a send that failed after replacing the link from replacing it again\n * when it is retried.\n *\n * Pure: the console's Send button and the server's send action read the\n * same answers.\n *=========================================*/\n\n/** The most sends a quote keeps on record; the oldest go first. */\nexport const QUOTE_MAX_SENDS = 50\n\n/** The longest message a send carries. */\nexport const QUOTE_MAX_SEND_MESSAGE = 5_000\n\n/** The longest subject a send carries. */\nexport const QUOTE_MAX_SEND_SUBJECT = 200\n\nconst EMAIL = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/\n\n/** The quote's sends on record, oldest first. */\nexport function quoteSends(quote: Pick<OrderQuote, 'sends'> | null | undefined): QuoteSendRecord[] {\n return Array.isArray(quote?.sends) ? quote.sends.filter((send) => send && typeof send === 'object') : []\n}\n\n/** The last time the quote was emailed, or null. */\nexport function lastQuoteSend(quote: Pick<OrderQuote, 'sends'> | null | undefined): QuoteSendRecord | null {\n const sends = quoteSends(quote)\n return sends.length ? sends[sends.length - 1] : null\n}\n\n/**\n * Whether emailing the quote now hands the customer a new revision of a quote\n * they were already emailed: an earlier revision went out, and this one has\n * not yet. The quote's first email (whatever revision it is at, since\n * revisions nobody was sent replace nothing) and a resend of the revision\n * already sent are neither, so their email does not call itself an update.\n */\nexport function quoteSendIsNewRevision(quote: Pick<OrderQuote, 'sends' | 'revision'> | null | undefined): boolean {\n const last = lastQuoteSend(quote)\n return Boolean(last) && Number(last.revision) < Number(quote?.revision ?? 1)\n}\n\n/**\n * Whether emailing the quote now gives the customer a new pay link: they were\n * emailed an earlier revision, and the link has not been replaced since.\n */\nexport function quoteSendReplacesLink(\n quote: Pick<OrderQuote, 'sends' | 'revision' | 'linkVersion'> | null | undefined,\n): boolean {\n if (!quoteSendIsNewRevision(quote)) return false\n return Number(lastQuoteSend(quote)?.linkVersion) === quoteLinkVersion(quote)\n}\n\n/** What the console's Send button says: `Send quote`, `Send again`, or `Send revision 2`. */\nexport function quoteSendLabel(quote: Pick<OrderQuote, 'sends' | 'revision'> | null | undefined): string {\n if (!lastQuoteSend(quote)) return 'Send quote'\n return quoteSendIsNewRevision(quote) ? `Send revision ${Number(quote?.revision ?? 1)}` : 'Send again'\n}\n\n/** Where the quote goes: what was typed in the dialog, else the customer's email. */\nexport function quoteSendRecipient(\n order: Pick<HostOrder, 'quote'> | null | undefined,\n typed: string | null | undefined,\n): string {\n return String(typed ?? '').trim() || String(order?.quote?.customer?.email ?? '').trim()\n}\n\n/**\n * Who the console's send form starts addressed to: the customer's email, else\n * where the quote was last emailed, so Send again and Send revision N go\n * where the last one went when the quote names no email.\n */\nexport function quoteSendPrefill(order: Pick<HostOrder, 'quote'> | null | undefined): string {\n return quoteSendRecipient(order, null) || String(lastQuoteSend(order?.quote)?.to ?? '').trim()\n}\n\nexport type QuoteSendRefusal = 'not-a-quote' | 'paid' | 'no-address' | 'bad-address'\n\n/** Why the quote cannot be emailed to `to` now, if it cannot. */\nexport function quoteSendRefusal(\n order: Pick<HostOrder, 'channel' | 'status' | 'quote'> | null | undefined,\n to: string,\n): QuoteSendRefusal | null {\n if (!order || !isQuote(order) || !order.quote) return 'not-a-quote'\n if (!isUnpaidQuote(order)) return 'paid'\n if (!to) return 'no-address'\n if (to.length > 320 || !EMAIL.test(to)) return 'bad-address'\n return null\n}\n\nexport function quoteSendRefusalMessage(reason: QuoteSendRefusal): string {\n switch (reason) {\n case 'not-a-quote':\n return 'Unknown quote'\n case 'paid':\n return 'This quote has been paid, so it is an order now and is not sent again.'\n case 'no-address':\n return 'This quote has no email for the customer. Add one to the quote, or type where to send it.'\n case 'bad-address':\n return 'Enter a valid email address to send the quote to.'\n }\n}\n\n/**\n * The subject a send defaults to: the conversation's, so it stays one\n * conversation, else `Quote #1042 from Acme Signs`.\n */\nexport function quoteEmailDefaultSubject(\n order: Pick<HostOrder, 'number' | 'quote'>,\n businessName: string,\n quoteId?: string,\n): string {\n const thread = String(order.quote?.emailThread?.subject ?? '').trim()\n if (thread) return thread\n const name = String(businessName ?? '').trim()\n const number = formatQuoteNumber(order, quoteId)\n return name ? `${number} from ${name}` : number\n}\n\n/** What the console sends: where, under what subject, and the business's message. */\nexport interface QuoteSendInput {\n to: string\n subject: string\n message: string\n}\n\n/** A send as the route received it, trimmed, or what to fix. `to` and `subject` may be empty: the defaults fill them. */\nexport function parseQuoteSendInput(raw: unknown): { ok: true; input: QuoteSendInput } | { ok: false; error: string } {\n const source = (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>\n const text = (value: unknown) => (typeof value === 'string' ? value : '')\n const to = text(source['to']).trim()\n // A subject is one header line.\n const subject = text(source['subject']).replace(/[\\r\\n]+/g, ' ').trim()\n const message = text(source['message']).replace(/\\r\\n?/g, '\\n').trim()\n if (subject.length > QUOTE_MAX_SEND_SUBJECT) {\n return { ok: false, error: `Keep the subject under ${QUOTE_MAX_SEND_SUBJECT} characters.` }\n }\n if (message.length > QUOTE_MAX_SEND_MESSAGE) {\n return { ok: false, error: `Keep the message under ${QUOTE_MAX_SEND_MESSAGE.toLocaleString('en-US')} characters.` }\n }\n return { ok: true, input: { to, subject, message } }\n}\n\n/**\n * The quote once an email went out: the send on record, the conversation\n * moved on, a line on the timeline, and the quote at Sent, unless the\n * customer has already answered this revision (approved it, or asked for\n * changes), which stays what it says.\n */\nexport function quoteAfterSend(\n order: HostOrder,\n send: QuoteSendRecord,\n thread: QuoteEmailThread,\n): HostOrder {\n const quote = order.quote as OrderQuote\n const answered = latestQuoteResponse(quote) !== null\n return {\n ...order,\n timeline: appendOrderEvent(\n order,\n 'quote-sent',\n `Revision ${send.revision} emailed to ${send.to} by ${send.by}`,\n send.atMs,\n ),\n quote: {\n ...quote,\n stage: answered ? quote.stage : 'sent',\n sends: [...quoteSends(quote), send].slice(-QUOTE_MAX_SENDS),\n emailThread: thread,\n },\n }\n}\n"],"names":["appendOrderEvent","quoteLinkVersion","latestQuoteResponse","formatQuoteNumber","isQuote","isUnpaidQuote","QUOTE_MAX_SENDS","QUOTE_MAX_SEND_MESSAGE","QUOTE_MAX_SEND_SUBJECT","EMAIL","quoteSends","quote","Array","isArray","sends","filter","send","lastQuoteSend","length","quoteSendIsNewRevision","last","Boolean","Number","revision","quoteSendReplacesLink","linkVersion","quoteSendLabel","quoteSendRecipient","order","typed","String","trim","customer","email","quoteSendPrefill","to","quoteSendRefusal","test","quoteSendRefusalMessage","reason","quoteEmailDefaultSubject","businessName","quoteId","thread","emailThread","subject","name","number","parseQuoteSendInput","raw","source","text","value","replace","message","ok","error","toLocaleString","input","quoteAfterSend","answered","timeline","by","atMs","stage","slice"],"mappings":"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"}
@@ -55,6 +55,13 @@ export interface QuoteRevisionSnapshot {
55
55
  }
56
56
  /** The current revision's proofs. */
57
57
  export declare function currentQuoteProofs(quote: Pick<OrderQuote, 'proofs' | 'revision'> | null | undefined): QuoteProof[];
58
+ /**
59
+ * One of the proofs the quote has shown its customer, by id (AGL-4097): the
60
+ * current revision's, else an earlier revision's as its snapshot keeps it
61
+ * (`orders/{id}/revisions/{n}`). A proof the business took off a revision
62
+ * before the next one started is in neither, so it is not found.
63
+ */
64
+ export declare function quoteProofInHistory(quote: Pick<OrderQuote, 'proofs' | 'revision'> | null | undefined, snapshots: ReadonlyArray<Partial<Pick<QuoteRevisionSnapshot, 'proofs'>> | null | undefined>, proofId: string): QuoteProof | null;
58
65
  /** The customer's latest answer to the current revision, if they gave one. */
59
66
  export declare function latestQuoteResponse(quote: Pick<OrderQuote, 'responses' | 'revision'> | null | undefined): QuoteCustomerResponse | null;
60
67
  /** Whether the customer has answered the current revision. */
@@ -43,6 +43,21 @@ export const QUOTE_ARTWORK_PURPOSE = 'quote-artwork';
43
43
  const revision = Number((_ref = quote == null ? void 0 : quote.revision) != null ? _ref : 1);
44
44
  return ((_ref1 = quote == null ? void 0 : quote.proofs) != null ? _ref1 : []).filter((proof)=>Number(proof == null ? void 0 : proof.revision) === revision);
45
45
  }
46
+ /**
47
+ * One of the proofs the quote has shown its customer, by id (AGL-4097): the
48
+ * current revision's, else an earlier revision's as its snapshot keeps it
49
+ * (`orders/{id}/revisions/{n}`). A proof the business took off a revision
50
+ * before the next one started is in neither, so it is not found.
51
+ */ export function quoteProofInHistory(quote, snapshots, proofId) {
52
+ if (!proofId) return null;
53
+ const current = currentQuoteProofs(quote).find((proof)=>(proof == null ? void 0 : proof.id) === proofId);
54
+ if (current) return current;
55
+ for (const snapshot of snapshots){
56
+ const kept = (Array.isArray(snapshot == null ? void 0 : snapshot.proofs) ? snapshot.proofs : []).find((proof)=>(proof == null ? void 0 : proof.id) === proofId);
57
+ if (kept) return kept;
58
+ }
59
+ return null;
60
+ }
46
61
  /** The customer's latest answer to the current revision, if they gave one. */ export function latestQuoteResponse(quote) {
47
62
  var _ref, _ref1;
48
63
  const revision = Number((_ref = quote == null ? void 0 : quote.revision) != null ? _ref : 1);
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/model/commerce-quote-proofs.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 { appendOrderEvent, type HostOrder, type OrderAddress, type OrderLineItem, type OrderTotals } from './commerce-orders'\nimport {\n formatQuoteNumber,\n isQuote,\n isUnpaidQuote,\n type OrderQuote,\n type QuoteApproval,\n type QuoteCustomer,\n type QuoteCustomerResponse,\n type QuoteDiscount,\n type QuoteProof,\n type QuoteResponseFile,\n type QuoteShipping,\n type QuoteStage,\n type QuoteTaxBasis,\n} from './commerce-quotes'\nimport type { StorefrontTaxMode } from './commerce-tax-decision'\n\n/*==========================================\n * PROOFS, REVISIONS AND THE CUSTOMER'S ANSWER (AGL-4096).\n *\n * A quote's REVISION is the version the customer is looking at. The business\n * attaches proofs to the current revision; the customer approves them under\n * a typed name or asks for changes; the business then starts the next\n * revision, which keeps the one before exactly as it stood in\n * `hosts/{hostId}/orders/{id}/revisions/{n}` and starts the new one with no\n * proofs and no answer. Every revision is kept.\n *\n * Whether paying waits on an approval is the business's control\n * (`requireProofApproval`, AGL-4093) and `quotePaymentBlockedByApproval`\n * answers it; nothing here makes paying wait when it is off.\n *\n * Pure: the console route, the customer's route and (slice 5) the send that\n * starts a revision all call these, and write what they answer.\n *=========================================*/\n\n/** The most proofs one revision holds. */\nexport const QUOTE_MAX_PROOFS = 10\n\n/** The most answers the customer gives one revision; plenty for a change of mind. */\nexport const QUOTE_MAX_RESPONSES = 20\n\n/** The longest comment a customer may leave. */\nexport const QUOTE_MAX_COMMENT = 5_000\n\n/** The longest name an approval is signed with. */\nexport const QUOTE_MAX_APPROVER_NAME = 120\n\n/** What the visitor uploads are called for a quote: its proofs, and the customer's artwork. */\nexport const QUOTE_PROOF_PURPOSE = 'quote-proof'\nexport const QUOTE_ARTWORK_PURPOSE = 'quote-artwork'\n\n/** A revision as it stood when the next one started. */\nexport interface QuoteRevisionSnapshot {\n revision: number\n /** Where it stood with the business when it closed. */\n stage: QuoteStage\n lineItems: OrderLineItem[]\n totals: OrderTotals | null\n taxMode: StorefrontTaxMode | null\n tax: QuoteTaxBasis | null\n discount: QuoteDiscount | null\n shipping: QuoteShipping | null\n notes: string\n terms: string\n customer: QuoteCustomer | null\n billTo: OrderAddress | null\n shipTo: OrderAddress | null\n proofs: QuoteProof[]\n responses: QuoteCustomerResponse[]\n approval: QuoteApproval | null\n /** When the revision was last written by the business. */\n updatedAtMs: number\n /** When the next revision started, and who started it. */\n closedAtMs: number\n closedBy: string\n}\n\n/** The current revision's proofs. */\nexport function currentQuoteProofs(quote: Pick<OrderQuote, 'proofs' | 'revision'> | null | undefined): QuoteProof[] {\n const revision = Number(quote?.revision ?? 1)\n return (quote?.proofs ?? []).filter((proof) => Number(proof?.revision) === revision)\n}\n\n/** The customer's latest answer to the current revision, if they gave one. */\nexport function latestQuoteResponse(\n quote: Pick<OrderQuote, 'responses' | 'revision'> | null | undefined,\n): QuoteCustomerResponse | null {\n const revision = Number(quote?.revision ?? 1)\n const answers = (quote?.responses ?? []).filter((response) => Number(response?.revision) === revision)\n return answers.length ? answers[answers.length - 1] : null\n}\n\n/** Whether the customer has answered the current revision. */\nexport function quoteRevisionAnswered(quote: Pick<OrderQuote, 'responses' | 'revision'> | null | undefined): boolean {\n return latestQuoteResponse(quote) !== null\n}\n\n/*------------------------------------------\n * The business's proofs\n *-----------------------------------------*/\n\nexport type QuoteProofRefusal = 'not-a-quote' | 'paid' | 'answered' | 'full' | 'unknown-proof'\n\n/** Why the current revision's proofs cannot be changed now, if they cannot. */\nexport function quoteProofChangeRefusal(\n order: Pick<HostOrder, 'channel' | 'status' | 'quote'> | null | undefined,\n change: 'add' | 'remove',\n): QuoteProofRefusal | null {\n if (!order || !isQuote(order) || !order.quote) return 'not-a-quote'\n if (!isUnpaidQuote(order)) return 'paid'\n if (quoteRevisionAnswered(order.quote)) return 'answered'\n if (change === 'add' && currentQuoteProofs(order.quote).length >= QUOTE_MAX_PROOFS) return 'full'\n return null\n}\n\nexport function quoteProofRefusalMessage(reason: QuoteProofRefusal): string {\n switch (reason) {\n case 'not-a-quote':\n return 'Unknown quote'\n case 'paid':\n return 'This quote has been paid, so its proofs can no longer be changed.'\n case 'answered':\n return 'The customer has answered this revision. Start a new revision to send different proofs.'\n case 'full':\n return `A revision holds up to ${QUOTE_MAX_PROOFS} proofs. Remove one first.`\n case 'unknown-proof':\n return 'That proof is no longer on this quote.'\n }\n}\n\n/** The quote with a proof added to its current revision, edited by `by` at `atMs`. */\nexport function withQuoteProofAdded(\n order: HostOrder,\n proof: Omit<QuoteProof, 'revision' | 'addedAtMs' | 'addedBy'>,\n options: { atMs: number; by: string },\n): { ok: true; order: HostOrder; proof: QuoteProof } | { ok: false; reason: QuoteProofRefusal } {\n const refused = quoteProofChangeRefusal(order, 'add')\n if (refused) return { ok: false, reason: refused }\n const quote = order.quote as OrderQuote\n const added: QuoteProof = {\n id: proof.id,\n revision: Number(quote.revision ?? 1),\n name: proof.name,\n contentType: proof.contentType,\n size: proof.size,\n path: proof.path,\n addedAtMs: options.atMs,\n addedBy: options.by,\n }\n return {\n ok: true,\n proof: added,\n order: {\n ...order,\n timeline: appendOrderEvent(\n order,\n 'quote-proof-added',\n `Proof ${added.name} added to revision ${added.revision} by ${options.by}`,\n options.atMs,\n ),\n quote: {\n ...quote,\n proofs: [...currentQuoteProofs(quote).filter((entry) => entry.id !== added.id), added],\n updatedAtMs: options.atMs,\n updatedBy: options.by,\n },\n },\n }\n}\n\n/** The quote with one of the current revision's proofs taken off. */\nexport function withQuoteProofRemoved(\n order: HostOrder,\n proofId: string,\n options: { atMs: number; by: string },\n): { ok: true; order: HostOrder; proof: QuoteProof } | { ok: false; reason: QuoteProofRefusal } {\n const refused = quoteProofChangeRefusal(order, 'remove')\n if (refused) return { ok: false, reason: refused }\n const quote = order.quote as OrderQuote\n const proofs = currentQuoteProofs(quote)\n const proof = proofs.find((entry) => entry.id === proofId)\n if (!proof) return { ok: false, reason: 'unknown-proof' }\n return {\n ok: true,\n proof,\n order: {\n ...order,\n timeline: appendOrderEvent(\n order,\n 'quote-proof-removed',\n `Proof ${proof.name} removed from revision ${proof.revision} by ${options.by}`,\n options.atMs,\n ),\n quote: {\n ...quote,\n proofs: proofs.filter((entry) => entry.id !== proofId),\n updatedAtMs: options.atMs,\n updatedBy: options.by,\n },\n },\n }\n}\n\n/*------------------------------------------\n * Revisions\n *-----------------------------------------*/\n\nexport type QuoteRevisionRefusal = 'not-a-quote' | 'paid'\n\n/**\n * Starts the quote's next revision. The current one is SNAPSHOTTED as it\n * stands (its lines, totals, notes, terms, proofs and the customer's\n * answers); the quote moves to revision N+1 with no proofs and no answer,\n * back at Draft, edited by `by` at `atMs`. A paid quote starts none.\n *\n * The caller writes `snapshot` to `hosts/{hostId}/orders/{id}/revisions/{revision}`\n * (`String(snapshot.revision)`, created, never overwritten) and `order` over\n * the quote, in one transaction. The pay link's version is not touched: the\n * send that hands the customer the new revision decides that.\n */\nexport function startQuoteRevision(\n order: HostOrder,\n options: { atMs: number; by: string },\n):\n | { ok: true; snapshot: QuoteRevisionSnapshot; order: HostOrder; revision: number }\n | { ok: false; reason: QuoteRevisionRefusal; error: string } {\n if (!isQuote(order) || !order.quote) return { ok: false, reason: 'not-a-quote', error: 'Unknown quote' }\n if (!isUnpaidQuote(order)) {\n return { ok: false, reason: 'paid', error: 'This quote has been paid, so it takes no new revision.' }\n }\n const quote = order.quote\n const current = Math.max(1, Math.floor(Number(quote.revision ?? 1)) || 1)\n const responses = (quote.responses ?? []).filter((response) => Number(response?.revision) === current)\n const approval = quote.approval && Number(quote.approval.revision) === current ? quote.approval : null\n const snapshot: QuoteRevisionSnapshot = {\n revision: current,\n stage: quote.stage ?? 'draft',\n lineItems: order.lineItems ?? [],\n totals: order.totals ?? null,\n taxMode: order.taxMode ?? null,\n tax: quote.tax ?? null,\n discount: quote.discount ?? null,\n shipping: quote.shipping ?? null,\n notes: quote.notes ?? '',\n terms: quote.terms ?? '',\n customer: quote.customer ?? null,\n billTo: quote.billTo ?? null,\n shipTo: quote.shipTo ?? null,\n proofs: currentQuoteProofs(quote),\n responses,\n approval,\n updatedAtMs: Number(quote.updatedAtMs) || 0,\n closedAtMs: options.atMs,\n closedBy: options.by,\n }\n const next = current + 1\n return {\n ok: true,\n revision: current,\n snapshot,\n order: {\n ...order,\n timeline: appendOrderEvent(\n order,\n 'quote-revision-started',\n `Revision ${next} started by ${options.by}; revision ${current} is kept with its proofs and answers`,\n options.atMs,\n ),\n quote: {\n ...quote,\n revision: next,\n stage: 'draft',\n proofs: [],\n responses: [],\n approval: null,\n updatedAtMs: options.atMs,\n updatedBy: options.by,\n },\n },\n }\n}\n\n/*------------------------------------------\n * The customer's answer\n *-----------------------------------------*/\n\nexport type QuoteRespondRefusal = 'not-a-quote' | 'paid' | 'no-proof' | 'too-many'\n\n/** Why the customer cannot answer the current revision now, if they cannot. */\nexport function quoteRespondRefusal(\n order: Pick<HostOrder, 'channel' | 'status' | 'quote'> | null | undefined,\n): QuoteRespondRefusal | null {\n if (!order || !isQuote(order) || !order.quote) return 'not-a-quote'\n if (!isUnpaidQuote(order)) return 'paid'\n if (currentQuoteProofs(order.quote).length === 0) return 'no-proof'\n const revision = Number(order.quote.revision ?? 1)\n const given = (order.quote.responses ?? []).filter((response) => Number(response?.revision) === revision)\n if (given.length >= QUOTE_MAX_RESPONSES) return 'too-many'\n return null\n}\n\nexport function quoteRespondRefusalMessage(reason: QuoteRespondRefusal, storeName: string): string {\n const business = storeName || 'the business'\n switch (reason) {\n case 'not-a-quote':\n return 'We could not find that quote. Check the link you were sent.'\n case 'paid':\n return 'This quote has been paid, so it takes no more answers.'\n case 'no-proof':\n return `There is no proof on this quote to answer yet. ${storeName || 'The business'} will send one.`\n case 'too-many':\n return `This proof has had a lot of answers already. Contact ${business} directly.`\n }\n}\n\n/** What the customer sends: approve under a name, or changes with a comment and any files. */\nexport type QuoteResponseInput =\n | { action: 'approve'; name: string }\n | { action: 'changes'; comment: string; fileIds: string[] }\n\n/** The customer's answer as the route received it, checked and trimmed, or what to fix. */\nexport function parseQuoteResponseInput(raw: unknown): { ok: true; input: QuoteResponseInput } | { ok: false; error: string } {\n const source = (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>\n if (source['action'] === 'approve') {\n const name = typeof source['name'] === 'string' ? source['name'].replace(/\\s+/g, ' ').trim() : ''\n if (name.length < 2) return { ok: false, error: 'Type your name to approve the proof.' }\n return { ok: true, input: { action: 'approve', name: name.slice(0, QUOTE_MAX_APPROVER_NAME) } }\n }\n if (source['action'] === 'changes') {\n const comment = typeof source['comment'] === 'string' ? source['comment'].trim() : ''\n if (!comment) return { ok: false, error: 'Tell us what to change.' }\n const raws = Array.isArray(source['fileIds']) ? (source['fileIds'] as unknown[]) : []\n const named = raws.map((value) => String(value ?? '')).filter((value) => /^[A-Za-z0-9_-]{1,128}$/.test(value))\n if (named.length !== raws.length) return { ok: false, error: 'One of the files could not be attached. Send it again.' }\n const fileIds = [...new Set(named)]\n if (fileIds.length > QUOTE_MAX_PROOFS) return { ok: false, error: `Send up to ${QUOTE_MAX_PROOFS} files at a time.` }\n return { ok: true, input: { action: 'changes', comment: comment.slice(0, QUOTE_MAX_COMMENT), fileIds } }\n }\n return { ok: false, error: 'Approve the proof or ask for changes.' }\n}\n\n/**\n * The quote with the customer's answer recorded on its current revision.\n * Approving names the revision approved and moves the quote to Approved;\n * asking for changes moves it to Changes requested and withdraws an approval\n * of the same revision. The latest answer is the one that counts. The\n * business's edit stamp (`updatedAtMs`) is not touched: the quote's content\n * did not change, and the page that sent the answer can still pay it.\n */\nexport function applyQuoteResponse(\n order: HostOrder,\n input: QuoteResponseInput & { files?: QuoteResponseFile[] },\n context: { atMs: number; ip: string | null; userAgent: string | null },\n): { ok: true; order: HostOrder; response: QuoteCustomerResponse } | { ok: false; reason: QuoteRespondRefusal } {\n const refused = quoteRespondRefusal(order)\n if (refused) return { ok: false, reason: refused }\n const quote = order.quote as OrderQuote\n const revision = Number(quote.revision ?? 1)\n const ip = context.ip ? context.ip.slice(0, 64) : null\n const userAgent = context.userAgent ? context.userAgent.slice(0, 400) : null\n const response: QuoteCustomerResponse =\n input.action === 'approve'\n ? { action: 'approve', revision, atMs: context.atMs, name: input.name, ip, userAgent }\n : {\n action: 'changes',\n revision,\n atMs: context.atMs,\n comment: input.comment,\n ...(input.files?.length ? { files: input.files } : {}),\n ip,\n userAgent,\n }\n const responses = [...(quote.responses ?? []).filter((entry) => Number(entry?.revision) === revision), response]\n const number = formatQuoteNumber(order)\n const next: OrderQuote =\n response.action === 'approve'\n ? {\n ...quote,\n responses,\n stage: 'approved',\n approvedRevision: revision,\n approval: { revision, name: response.name ?? '', atMs: context.atMs, ip, userAgent },\n }\n : {\n ...quote,\n responses,\n stage: 'changes_requested',\n approvedRevision: Number(quote.approvedRevision) === revision ? null : (quote.approvedRevision ?? null),\n approval: null,\n }\n return {\n ok: true,\n response,\n order: {\n ...order,\n timeline: appendOrderEvent(\n order,\n response.action === 'approve' ? 'quote-approved' : 'quote-changes-requested',\n response.action === 'approve'\n ? `${number} revision ${revision} approved by the customer as ${response.name}`\n : `The customer asked for changes to ${number} revision ${revision}` +\n (response.files?.length ? ` and sent ${response.files.length} file${response.files.length === 1 ? '' : 's'}` : ''),\n context.atMs,\n ),\n quote: next,\n },\n }\n}\n\n/** One line on what the customer last said: `Approved by Jordan Lee`, `Changes requested`. */\nexport function describeQuoteResponse(response: Pick<QuoteCustomerResponse, 'action' | 'name'>): string {\n return response.action === 'approve' ? `Approved by ${response.name || 'the customer'}` : 'Changes requested'\n}\n"],"names":["appendOrderEvent","formatQuoteNumber","isQuote","isUnpaidQuote","QUOTE_MAX_PROOFS","QUOTE_MAX_RESPONSES","QUOTE_MAX_COMMENT","QUOTE_MAX_APPROVER_NAME","QUOTE_PROOF_PURPOSE","QUOTE_ARTWORK_PURPOSE","currentQuoteProofs","quote","revision","Number","proofs","filter","proof","latestQuoteResponse","answers","responses","response","length","quoteRevisionAnswered","quoteProofChangeRefusal","order","change","quoteProofRefusalMessage","reason","withQuoteProofAdded","options","refused","ok","added","id","name","contentType","size","path","addedAtMs","atMs","addedBy","by","timeline","entry","updatedAtMs","updatedBy","withQuoteProofRemoved","proofId","find","startQuoteRevision","error","current","Math","max","floor","approval","snapshot","stage","lineItems","totals","taxMode","tax","discount","shipping","notes","terms","customer","billTo","shipTo","closedAtMs","closedBy","next","quoteRespondRefusal","given","quoteRespondRefusalMessage","storeName","business","parseQuoteResponseInput","raw","source","replace","trim","input","action","slice","comment","raws","Array","isArray","named","map","value","String","test","fileIds","Set","applyQuoteResponse","context","ip","userAgent","files","number","approvedRevision","describeQuoteResponse"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/model/commerce-quote-proofs.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 { appendOrderEvent, type HostOrder, type OrderAddress, type OrderLineItem, type OrderTotals } from './commerce-orders'\nimport {\n formatQuoteNumber,\n isQuote,\n isUnpaidQuote,\n type OrderQuote,\n type QuoteApproval,\n type QuoteCustomer,\n type QuoteCustomerResponse,\n type QuoteDiscount,\n type QuoteProof,\n type QuoteResponseFile,\n type QuoteShipping,\n type QuoteStage,\n type QuoteTaxBasis,\n} from './commerce-quotes'\nimport type { StorefrontTaxMode } from './commerce-tax-decision'\n\n/*==========================================\n * PROOFS, REVISIONS AND THE CUSTOMER'S ANSWER (AGL-4096).\n *\n * A quote's REVISION is the version the customer is looking at. The business\n * attaches proofs to the current revision; the customer approves them under\n * a typed name or asks for changes; the business then starts the next\n * revision, which keeps the one before exactly as it stood in\n * `hosts/{hostId}/orders/{id}/revisions/{n}` and starts the new one with no\n * proofs and no answer. Every revision is kept.\n *\n * Whether paying waits on an approval is the business's control\n * (`requireProofApproval`, AGL-4093) and `quotePaymentBlockedByApproval`\n * answers it; nothing here makes paying wait when it is off.\n *\n * Pure: the console route, the customer's route and (slice 5) the send that\n * starts a revision all call these, and write what they answer.\n *=========================================*/\n\n/** The most proofs one revision holds. */\nexport const QUOTE_MAX_PROOFS = 10\n\n/** The most answers the customer gives one revision; plenty for a change of mind. */\nexport const QUOTE_MAX_RESPONSES = 20\n\n/** The longest comment a customer may leave. */\nexport const QUOTE_MAX_COMMENT = 5_000\n\n/** The longest name an approval is signed with. */\nexport const QUOTE_MAX_APPROVER_NAME = 120\n\n/** What the visitor uploads are called for a quote: its proofs, and the customer's artwork. */\nexport const QUOTE_PROOF_PURPOSE = 'quote-proof'\nexport const QUOTE_ARTWORK_PURPOSE = 'quote-artwork'\n\n/** A revision as it stood when the next one started. */\nexport interface QuoteRevisionSnapshot {\n revision: number\n /** Where it stood with the business when it closed. */\n stage: QuoteStage\n lineItems: OrderLineItem[]\n totals: OrderTotals | null\n taxMode: StorefrontTaxMode | null\n tax: QuoteTaxBasis | null\n discount: QuoteDiscount | null\n shipping: QuoteShipping | null\n notes: string\n terms: string\n customer: QuoteCustomer | null\n billTo: OrderAddress | null\n shipTo: OrderAddress | null\n proofs: QuoteProof[]\n responses: QuoteCustomerResponse[]\n approval: QuoteApproval | null\n /** When the revision was last written by the business. */\n updatedAtMs: number\n /** When the next revision started, and who started it. */\n closedAtMs: number\n closedBy: string\n}\n\n/** The current revision's proofs. */\nexport function currentQuoteProofs(quote: Pick<OrderQuote, 'proofs' | 'revision'> | null | undefined): QuoteProof[] {\n const revision = Number(quote?.revision ?? 1)\n return (quote?.proofs ?? []).filter((proof) => Number(proof?.revision) === revision)\n}\n\n/**\n * One of the proofs the quote has shown its customer, by id (AGL-4097): the\n * current revision's, else an earlier revision's as its snapshot keeps it\n * (`orders/{id}/revisions/{n}`). A proof the business took off a revision\n * before the next one started is in neither, so it is not found.\n */\nexport function quoteProofInHistory(\n quote: Pick<OrderQuote, 'proofs' | 'revision'> | null | undefined,\n snapshots: ReadonlyArray<Partial<Pick<QuoteRevisionSnapshot, 'proofs'>> | null | undefined>,\n proofId: string,\n): QuoteProof | null {\n if (!proofId) return null\n const current = currentQuoteProofs(quote).find((proof) => proof?.id === proofId)\n if (current) return current\n for (const snapshot of snapshots) {\n const kept = (Array.isArray(snapshot?.proofs) ? snapshot.proofs : []).find((proof) => proof?.id === proofId)\n if (kept) return kept\n }\n return null\n}\n\n/** The customer's latest answer to the current revision, if they gave one. */\nexport function latestQuoteResponse(\n quote: Pick<OrderQuote, 'responses' | 'revision'> | null | undefined,\n): QuoteCustomerResponse | null {\n const revision = Number(quote?.revision ?? 1)\n const answers = (quote?.responses ?? []).filter((response) => Number(response?.revision) === revision)\n return answers.length ? answers[answers.length - 1] : null\n}\n\n/** Whether the customer has answered the current revision. */\nexport function quoteRevisionAnswered(quote: Pick<OrderQuote, 'responses' | 'revision'> | null | undefined): boolean {\n return latestQuoteResponse(quote) !== null\n}\n\n/*------------------------------------------\n * The business's proofs\n *-----------------------------------------*/\n\nexport type QuoteProofRefusal = 'not-a-quote' | 'paid' | 'answered' | 'full' | 'unknown-proof'\n\n/** Why the current revision's proofs cannot be changed now, if they cannot. */\nexport function quoteProofChangeRefusal(\n order: Pick<HostOrder, 'channel' | 'status' | 'quote'> | null | undefined,\n change: 'add' | 'remove',\n): QuoteProofRefusal | null {\n if (!order || !isQuote(order) || !order.quote) return 'not-a-quote'\n if (!isUnpaidQuote(order)) return 'paid'\n if (quoteRevisionAnswered(order.quote)) return 'answered'\n if (change === 'add' && currentQuoteProofs(order.quote).length >= QUOTE_MAX_PROOFS) return 'full'\n return null\n}\n\nexport function quoteProofRefusalMessage(reason: QuoteProofRefusal): string {\n switch (reason) {\n case 'not-a-quote':\n return 'Unknown quote'\n case 'paid':\n return 'This quote has been paid, so its proofs can no longer be changed.'\n case 'answered':\n return 'The customer has answered this revision. Start a new revision to send different proofs.'\n case 'full':\n return `A revision holds up to ${QUOTE_MAX_PROOFS} proofs. Remove one first.`\n case 'unknown-proof':\n return 'That proof is no longer on this quote.'\n }\n}\n\n/** The quote with a proof added to its current revision, edited by `by` at `atMs`. */\nexport function withQuoteProofAdded(\n order: HostOrder,\n proof: Omit<QuoteProof, 'revision' | 'addedAtMs' | 'addedBy'>,\n options: { atMs: number; by: string },\n): { ok: true; order: HostOrder; proof: QuoteProof } | { ok: false; reason: QuoteProofRefusal } {\n const refused = quoteProofChangeRefusal(order, 'add')\n if (refused) return { ok: false, reason: refused }\n const quote = order.quote as OrderQuote\n const added: QuoteProof = {\n id: proof.id,\n revision: Number(quote.revision ?? 1),\n name: proof.name,\n contentType: proof.contentType,\n size: proof.size,\n path: proof.path,\n addedAtMs: options.atMs,\n addedBy: options.by,\n }\n return {\n ok: true,\n proof: added,\n order: {\n ...order,\n timeline: appendOrderEvent(\n order,\n 'quote-proof-added',\n `Proof ${added.name} added to revision ${added.revision} by ${options.by}`,\n options.atMs,\n ),\n quote: {\n ...quote,\n proofs: [...currentQuoteProofs(quote).filter((entry) => entry.id !== added.id), added],\n updatedAtMs: options.atMs,\n updatedBy: options.by,\n },\n },\n }\n}\n\n/** The quote with one of the current revision's proofs taken off. */\nexport function withQuoteProofRemoved(\n order: HostOrder,\n proofId: string,\n options: { atMs: number; by: string },\n): { ok: true; order: HostOrder; proof: QuoteProof } | { ok: false; reason: QuoteProofRefusal } {\n const refused = quoteProofChangeRefusal(order, 'remove')\n if (refused) return { ok: false, reason: refused }\n const quote = order.quote as OrderQuote\n const proofs = currentQuoteProofs(quote)\n const proof = proofs.find((entry) => entry.id === proofId)\n if (!proof) return { ok: false, reason: 'unknown-proof' }\n return {\n ok: true,\n proof,\n order: {\n ...order,\n timeline: appendOrderEvent(\n order,\n 'quote-proof-removed',\n `Proof ${proof.name} removed from revision ${proof.revision} by ${options.by}`,\n options.atMs,\n ),\n quote: {\n ...quote,\n proofs: proofs.filter((entry) => entry.id !== proofId),\n updatedAtMs: options.atMs,\n updatedBy: options.by,\n },\n },\n }\n}\n\n/*------------------------------------------\n * Revisions\n *-----------------------------------------*/\n\nexport type QuoteRevisionRefusal = 'not-a-quote' | 'paid'\n\n/**\n * Starts the quote's next revision. The current one is SNAPSHOTTED as it\n * stands (its lines, totals, notes, terms, proofs and the customer's\n * answers); the quote moves to revision N+1 with no proofs and no answer,\n * back at Draft, edited by `by` at `atMs`. A paid quote starts none.\n *\n * The caller writes `snapshot` to `hosts/{hostId}/orders/{id}/revisions/{revision}`\n * (`String(snapshot.revision)`, created, never overwritten) and `order` over\n * the quote, in one transaction. The pay link's version is not touched: the\n * send that hands the customer the new revision decides that.\n */\nexport function startQuoteRevision(\n order: HostOrder,\n options: { atMs: number; by: string },\n):\n | { ok: true; snapshot: QuoteRevisionSnapshot; order: HostOrder; revision: number }\n | { ok: false; reason: QuoteRevisionRefusal; error: string } {\n if (!isQuote(order) || !order.quote) return { ok: false, reason: 'not-a-quote', error: 'Unknown quote' }\n if (!isUnpaidQuote(order)) {\n return { ok: false, reason: 'paid', error: 'This quote has been paid, so it takes no new revision.' }\n }\n const quote = order.quote\n const current = Math.max(1, Math.floor(Number(quote.revision ?? 1)) || 1)\n const responses = (quote.responses ?? []).filter((response) => Number(response?.revision) === current)\n const approval = quote.approval && Number(quote.approval.revision) === current ? quote.approval : null\n const snapshot: QuoteRevisionSnapshot = {\n revision: current,\n stage: quote.stage ?? 'draft',\n lineItems: order.lineItems ?? [],\n totals: order.totals ?? null,\n taxMode: order.taxMode ?? null,\n tax: quote.tax ?? null,\n discount: quote.discount ?? null,\n shipping: quote.shipping ?? null,\n notes: quote.notes ?? '',\n terms: quote.terms ?? '',\n customer: quote.customer ?? null,\n billTo: quote.billTo ?? null,\n shipTo: quote.shipTo ?? null,\n proofs: currentQuoteProofs(quote),\n responses,\n approval,\n updatedAtMs: Number(quote.updatedAtMs) || 0,\n closedAtMs: options.atMs,\n closedBy: options.by,\n }\n const next = current + 1\n return {\n ok: true,\n revision: current,\n snapshot,\n order: {\n ...order,\n timeline: appendOrderEvent(\n order,\n 'quote-revision-started',\n `Revision ${next} started by ${options.by}; revision ${current} is kept with its proofs and answers`,\n options.atMs,\n ),\n quote: {\n ...quote,\n revision: next,\n stage: 'draft',\n proofs: [],\n responses: [],\n approval: null,\n updatedAtMs: options.atMs,\n updatedBy: options.by,\n },\n },\n }\n}\n\n/*------------------------------------------\n * The customer's answer\n *-----------------------------------------*/\n\nexport type QuoteRespondRefusal = 'not-a-quote' | 'paid' | 'no-proof' | 'too-many'\n\n/** Why the customer cannot answer the current revision now, if they cannot. */\nexport function quoteRespondRefusal(\n order: Pick<HostOrder, 'channel' | 'status' | 'quote'> | null | undefined,\n): QuoteRespondRefusal | null {\n if (!order || !isQuote(order) || !order.quote) return 'not-a-quote'\n if (!isUnpaidQuote(order)) return 'paid'\n if (currentQuoteProofs(order.quote).length === 0) return 'no-proof'\n const revision = Number(order.quote.revision ?? 1)\n const given = (order.quote.responses ?? []).filter((response) => Number(response?.revision) === revision)\n if (given.length >= QUOTE_MAX_RESPONSES) return 'too-many'\n return null\n}\n\nexport function quoteRespondRefusalMessage(reason: QuoteRespondRefusal, storeName: string): string {\n const business = storeName || 'the business'\n switch (reason) {\n case 'not-a-quote':\n return 'We could not find that quote. Check the link you were sent.'\n case 'paid':\n return 'This quote has been paid, so it takes no more answers.'\n case 'no-proof':\n return `There is no proof on this quote to answer yet. ${storeName || 'The business'} will send one.`\n case 'too-many':\n return `This proof has had a lot of answers already. Contact ${business} directly.`\n }\n}\n\n/** What the customer sends: approve under a name, or changes with a comment and any files. */\nexport type QuoteResponseInput =\n | { action: 'approve'; name: string }\n | { action: 'changes'; comment: string; fileIds: string[] }\n\n/** The customer's answer as the route received it, checked and trimmed, or what to fix. */\nexport function parseQuoteResponseInput(raw: unknown): { ok: true; input: QuoteResponseInput } | { ok: false; error: string } {\n const source = (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>\n if (source['action'] === 'approve') {\n const name = typeof source['name'] === 'string' ? source['name'].replace(/\\s+/g, ' ').trim() : ''\n if (name.length < 2) return { ok: false, error: 'Type your name to approve the proof.' }\n return { ok: true, input: { action: 'approve', name: name.slice(0, QUOTE_MAX_APPROVER_NAME) } }\n }\n if (source['action'] === 'changes') {\n const comment = typeof source['comment'] === 'string' ? source['comment'].trim() : ''\n if (!comment) return { ok: false, error: 'Tell us what to change.' }\n const raws = Array.isArray(source['fileIds']) ? (source['fileIds'] as unknown[]) : []\n const named = raws.map((value) => String(value ?? '')).filter((value) => /^[A-Za-z0-9_-]{1,128}$/.test(value))\n if (named.length !== raws.length) return { ok: false, error: 'One of the files could not be attached. Send it again.' }\n const fileIds = [...new Set(named)]\n if (fileIds.length > QUOTE_MAX_PROOFS) return { ok: false, error: `Send up to ${QUOTE_MAX_PROOFS} files at a time.` }\n return { ok: true, input: { action: 'changes', comment: comment.slice(0, QUOTE_MAX_COMMENT), fileIds } }\n }\n return { ok: false, error: 'Approve the proof or ask for changes.' }\n}\n\n/**\n * The quote with the customer's answer recorded on its current revision.\n * Approving names the revision approved and moves the quote to Approved;\n * asking for changes moves it to Changes requested and withdraws an approval\n * of the same revision. The latest answer is the one that counts. The\n * business's edit stamp (`updatedAtMs`) is not touched: the quote's content\n * did not change, and the page that sent the answer can still pay it.\n */\nexport function applyQuoteResponse(\n order: HostOrder,\n input: QuoteResponseInput & { files?: QuoteResponseFile[] },\n context: { atMs: number; ip: string | null; userAgent: string | null },\n): { ok: true; order: HostOrder; response: QuoteCustomerResponse } | { ok: false; reason: QuoteRespondRefusal } {\n const refused = quoteRespondRefusal(order)\n if (refused) return { ok: false, reason: refused }\n const quote = order.quote as OrderQuote\n const revision = Number(quote.revision ?? 1)\n const ip = context.ip ? context.ip.slice(0, 64) : null\n const userAgent = context.userAgent ? context.userAgent.slice(0, 400) : null\n const response: QuoteCustomerResponse =\n input.action === 'approve'\n ? { action: 'approve', revision, atMs: context.atMs, name: input.name, ip, userAgent }\n : {\n action: 'changes',\n revision,\n atMs: context.atMs,\n comment: input.comment,\n ...(input.files?.length ? { files: input.files } : {}),\n ip,\n userAgent,\n }\n const responses = [...(quote.responses ?? []).filter((entry) => Number(entry?.revision) === revision), response]\n const number = formatQuoteNumber(order)\n const next: OrderQuote =\n response.action === 'approve'\n ? {\n ...quote,\n responses,\n stage: 'approved',\n approvedRevision: revision,\n approval: { revision, name: response.name ?? '', atMs: context.atMs, ip, userAgent },\n }\n : {\n ...quote,\n responses,\n stage: 'changes_requested',\n approvedRevision: Number(quote.approvedRevision) === revision ? null : (quote.approvedRevision ?? null),\n approval: null,\n }\n return {\n ok: true,\n response,\n order: {\n ...order,\n timeline: appendOrderEvent(\n order,\n response.action === 'approve' ? 'quote-approved' : 'quote-changes-requested',\n response.action === 'approve'\n ? `${number} revision ${revision} approved by the customer as ${response.name}`\n : `The customer asked for changes to ${number} revision ${revision}` +\n (response.files?.length ? ` and sent ${response.files.length} file${response.files.length === 1 ? '' : 's'}` : ''),\n context.atMs,\n ),\n quote: next,\n },\n }\n}\n\n/** One line on what the customer last said: `Approved by Jordan Lee`, `Changes requested`. */\nexport function describeQuoteResponse(response: Pick<QuoteCustomerResponse, 'action' | 'name'>): string {\n return response.action === 'approve' ? `Approved by ${response.name || 'the customer'}` : 'Changes requested'\n}\n"],"names":["appendOrderEvent","formatQuoteNumber","isQuote","isUnpaidQuote","QUOTE_MAX_PROOFS","QUOTE_MAX_RESPONSES","QUOTE_MAX_COMMENT","QUOTE_MAX_APPROVER_NAME","QUOTE_PROOF_PURPOSE","QUOTE_ARTWORK_PURPOSE","currentQuoteProofs","quote","revision","Number","proofs","filter","proof","quoteProofInHistory","snapshots","proofId","current","find","id","snapshot","kept","Array","isArray","latestQuoteResponse","answers","responses","response","length","quoteRevisionAnswered","quoteProofChangeRefusal","order","change","quoteProofRefusalMessage","reason","withQuoteProofAdded","options","refused","ok","added","name","contentType","size","path","addedAtMs","atMs","addedBy","by","timeline","entry","updatedAtMs","updatedBy","withQuoteProofRemoved","startQuoteRevision","error","Math","max","floor","approval","stage","lineItems","totals","taxMode","tax","discount","shipping","notes","terms","customer","billTo","shipTo","closedAtMs","closedBy","next","quoteRespondRefusal","given","quoteRespondRefusalMessage","storeName","business","parseQuoteResponseInput","raw","source","replace","trim","input","action","slice","comment","raws","named","map","value","String","test","fileIds","Set","applyQuoteResponse","context","ip","userAgent","files","number","approvedRevision","describeQuoteResponse"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,gBAAgB,QAAiF,uBAAmB;AAC7H,SACEC,iBAAiB,EACjBC,OAAO,EACPC,aAAa,QAWR,uBAAmB;AAG1B;;;;;;;;;;;;;;;;2CAgB2C,GAE3C,wCAAwC,GACxC,OAAO,MAAMC,mBAAmB,GAAE;AAElC,mFAAmF,GACnF,OAAO,MAAMC,sBAAsB,GAAE;AAErC,8CAA8C,GAC9C,OAAO,MAAMC,oBAAoB,KAAK;AAEtC,iDAAiD,GACjD,OAAO,MAAMC,0BAA0B,IAAG;AAE1C,6FAA6F,GAC7F,OAAO,MAAMC,sBAAsB,cAAa;AAChD,OAAO,MAAMC,wBAAwB,gBAAe;AA4BpD,mCAAmC,GACnC,OAAO,SAASC,mBAAmBC,KAAiE;;IAClG,MAAMC,WAAWC,eAAOF,yBAAAA,MAAOC,QAAQ,mBAAI;IAC3C,OAAO,UAACD,yBAAAA,MAAOG,MAAM,oBAAI,EAAE,EAAEC,MAAM,CAAC,CAACC,QAAUH,OAAOG,yBAAAA,MAAOJ,QAAQ,MAAMA;AAC7E;AAEA;;;;;CAKC,GACD,OAAO,SAASK,oBACdN,KAAiE,EACjEO,SAA2F,EAC3FC,OAAe;IAEf,IAAI,CAACA,SAAS,OAAO;IACrB,MAAMC,UAAUV,mBAAmBC,OAAOU,IAAI,CAAC,CAACL,QAAUA,CAAAA,yBAAAA,MAAOM,EAAE,MAAKH;IACxE,IAAIC,SAAS,OAAOA;IACpB,KAAK,MAAMG,YAAYL,UAAW;QAChC,MAAMM,OAAO,AAACC,CAAAA,MAAMC,OAAO,CAACH,4BAAAA,SAAUT,MAAM,IAAIS,SAAST,MAAM,GAAG,EAAE,AAAD,EAAGO,IAAI,CAAC,CAACL,QAAUA,CAAAA,yBAAAA,MAAOM,EAAE,MAAKH;QACpG,IAAIK,MAAM,OAAOA;IACnB;IACA,OAAO;AACT;AAEA,4EAA4E,GAC5E,OAAO,SAASG,oBACdhB,KAAoE;;IAEpE,MAAMC,WAAWC,eAAOF,yBAAAA,MAAOC,QAAQ,mBAAI;IAC3C,MAAMgB,UAAU,UAACjB,yBAAAA,MAAOkB,SAAS,oBAAI,EAAE,EAAEd,MAAM,CAAC,CAACe,WAAajB,OAAOiB,4BAAAA,SAAUlB,QAAQ,MAAMA;IAC7F,OAAOgB,QAAQG,MAAM,GAAGH,OAAO,CAACA,QAAQG,MAAM,GAAG,EAAE,GAAG;AACxD;AAEA,4DAA4D,GAC5D,OAAO,SAASC,sBAAsBrB,KAAoE;IACxG,OAAOgB,oBAAoBhB,WAAW;AACxC;AAQA,6EAA6E,GAC7E,OAAO,SAASsB,wBACdC,KAAyE,EACzEC,MAAwB;IAExB,IAAI,CAACD,SAAS,CAAChC,QAAQgC,UAAU,CAACA,MAAMvB,KAAK,EAAE,OAAO;IACtD,IAAI,CAACR,cAAc+B,QAAQ,OAAO;IAClC,IAAIF,sBAAsBE,MAAMvB,KAAK,GAAG,OAAO;IAC/C,IAAIwB,WAAW,SAASzB,mBAAmBwB,MAAMvB,KAAK,EAAEoB,MAAM,IAAI3B,kBAAkB,OAAO;IAC3F,OAAO;AACT;AAEA,OAAO,SAASgC,yBAAyBC,MAAyB;IAChE,OAAQA;QACN,KAAK;YACH,OAAO;QACT,KAAK;YACH,OAAO;QACT,KAAK;YACH,OAAO;QACT,KAAK;YACH,OAAO,CAAC,uBAAuB,EAAEjC,iBAAiB,0BAA0B,CAAC;QAC/E,KAAK;YACH,OAAO;IACX;AACF;AAEA,oFAAoF,GACpF,OAAO,SAASkC,oBACdJ,KAAgB,EAChBlB,KAA6D,EAC7DuB,OAAqC;QAOlB5B;IALnB,MAAM6B,UAAUP,wBAAwBC,OAAO;IAC/C,IAAIM,SAAS,OAAO;QAAEC,IAAI;QAAOJ,QAAQG;IAAQ;IACjD,MAAM7B,QAAQuB,MAAMvB,KAAK;IACzB,MAAM+B,QAAoB;QACxBpB,IAAIN,MAAMM,EAAE;QACZV,UAAUC,QAAOF,kBAAAA,MAAMC,QAAQ,YAAdD,kBAAkB;QACnCgC,MAAM3B,MAAM2B,IAAI;QAChBC,aAAa5B,MAAM4B,WAAW;QAC9BC,MAAM7B,MAAM6B,IAAI;QAChBC,MAAM9B,MAAM8B,IAAI;QAChBC,WAAWR,QAAQS,IAAI;QACvBC,SAASV,QAAQW,EAAE;IACrB;IACA,OAAO;QACLT,IAAI;QACJzB,OAAO0B;QACPR,OAAO,aACFA;YACHiB,UAAUnD,iBACRkC,OACA,qBACA,CAAC,MAAM,EAAEQ,MAAMC,IAAI,CAAC,mBAAmB,EAAED,MAAM9B,QAAQ,CAAC,IAAI,EAAE2B,QAAQW,EAAE,EAAE,EAC1EX,QAAQS,IAAI;YAEdrC,OAAO,aACFA;gBACHG,QAAQ;uBAAIJ,mBAAmBC,OAAOI,MAAM,CAAC,CAACqC,QAAUA,MAAM9B,EAAE,KAAKoB,MAAMpB,EAAE;oBAAGoB;iBAAM;gBACtFW,aAAad,QAAQS,IAAI;gBACzBM,WAAWf,QAAQW,EAAE;;;IAG3B;AACF;AAEA,mEAAmE,GACnE,OAAO,SAASK,sBACdrB,KAAgB,EAChBf,OAAe,EACfoB,OAAqC;IAErC,MAAMC,UAAUP,wBAAwBC,OAAO;IAC/C,IAAIM,SAAS,OAAO;QAAEC,IAAI;QAAOJ,QAAQG;IAAQ;IACjD,MAAM7B,QAAQuB,MAAMvB,KAAK;IACzB,MAAMG,SAASJ,mBAAmBC;IAClC,MAAMK,QAAQF,OAAOO,IAAI,CAAC,CAAC+B,QAAUA,MAAM9B,EAAE,KAAKH;IAClD,IAAI,CAACH,OAAO,OAAO;QAAEyB,IAAI;QAAOJ,QAAQ;IAAgB;IACxD,OAAO;QACLI,IAAI;QACJzB;QACAkB,OAAO,aACFA;YACHiB,UAAUnD,iBACRkC,OACA,uBACA,CAAC,MAAM,EAAElB,MAAM2B,IAAI,CAAC,uBAAuB,EAAE3B,MAAMJ,QAAQ,CAAC,IAAI,EAAE2B,QAAQW,EAAE,EAAE,EAC9EX,QAAQS,IAAI;YAEdrC,OAAO,aACFA;gBACHG,QAAQA,OAAOC,MAAM,CAAC,CAACqC,QAAUA,MAAM9B,EAAE,KAAKH;gBAC9CkC,aAAad,QAAQS,IAAI;gBACzBM,WAAWf,QAAQW,EAAE;;;IAG3B;AACF;AAQA;;;;;;;;;;CAUC,GACD,OAAO,SAASM,mBACdtB,KAAgB,EAChBK,OAAqC;QASS5B,iBAC3BA,kBAIVA,cACIuB,kBACHA,eACCA,gBACJvB,YACKA,iBACAA,iBACHA,cACAA,cACGA,iBACFA,eACAA;IArBV,IAAI,CAACT,QAAQgC,UAAU,CAACA,MAAMvB,KAAK,EAAE,OAAO;QAAE8B,IAAI;QAAOJ,QAAQ;QAAeoB,OAAO;IAAgB;IACvG,IAAI,CAACtD,cAAc+B,QAAQ;QACzB,OAAO;YAAEO,IAAI;YAAOJ,QAAQ;YAAQoB,OAAO;QAAyD;IACtG;IACA,MAAM9C,QAAQuB,MAAMvB,KAAK;IACzB,MAAMS,UAAUsC,KAAKC,GAAG,CAAC,GAAGD,KAAKE,KAAK,CAAC/C,QAAOF,kBAAAA,MAAMC,QAAQ,YAAdD,kBAAkB,OAAO;IACvE,MAAMkB,YAAY,EAAClB,mBAAAA,MAAMkB,SAAS,YAAflB,mBAAmB,EAAE,EAAEI,MAAM,CAAC,CAACe,WAAajB,OAAOiB,4BAAAA,SAAUlB,QAAQ,MAAMQ;IAC9F,MAAMyC,WAAWlD,MAAMkD,QAAQ,IAAIhD,OAAOF,MAAMkD,QAAQ,CAACjD,QAAQ,MAAMQ,UAAUT,MAAMkD,QAAQ,GAAG;IAClG,MAAMtC,WAAkC;QACtCX,UAAUQ;QACV0C,KAAK,GAAEnD,eAAAA,MAAMmD,KAAK,YAAXnD,eAAe;QACtBoD,SAAS,GAAE7B,mBAAAA,MAAM6B,SAAS,YAAf7B,mBAAmB,EAAE;QAChC8B,MAAM,GAAE9B,gBAAAA,MAAM8B,MAAM,YAAZ9B,gBAAgB;QACxB+B,OAAO,GAAE/B,iBAAAA,MAAM+B,OAAO,YAAb/B,iBAAiB;QAC1BgC,GAAG,GAAEvD,aAAAA,MAAMuD,GAAG,YAATvD,aAAa;QAClBwD,QAAQ,GAAExD,kBAAAA,MAAMwD,QAAQ,YAAdxD,kBAAkB;QAC5ByD,QAAQ,GAAEzD,kBAAAA,MAAMyD,QAAQ,YAAdzD,kBAAkB;QAC5B0D,KAAK,GAAE1D,eAAAA,MAAM0D,KAAK,YAAX1D,eAAe;QACtB2D,KAAK,GAAE3D,eAAAA,MAAM2D,KAAK,YAAX3D,eAAe;QACtB4D,QAAQ,GAAE5D,kBAAAA,MAAM4D,QAAQ,YAAd5D,kBAAkB;QAC5B6D,MAAM,GAAE7D,gBAAAA,MAAM6D,MAAM,YAAZ7D,gBAAgB;QACxB8D,MAAM,GAAE9D,gBAAAA,MAAM8D,MAAM,YAAZ9D,gBAAgB;QACxBG,QAAQJ,mBAAmBC;QAC3BkB;QACAgC;QACAR,aAAaxC,OAAOF,MAAM0C,WAAW,KAAK;QAC1CqB,YAAYnC,QAAQS,IAAI;QACxB2B,UAAUpC,QAAQW,EAAE;IACtB;IACA,MAAM0B,OAAOxD,UAAU;IACvB,OAAO;QACLqB,IAAI;QACJ7B,UAAUQ;QACVG;QACAW,OAAO,aACFA;YACHiB,UAAUnD,iBACRkC,OACA,0BACA,CAAC,SAAS,EAAE0C,KAAK,YAAY,EAAErC,QAAQW,EAAE,CAAC,WAAW,EAAE9B,QAAQ,oCAAoC,CAAC,EACpGmB,QAAQS,IAAI;YAEdrC,OAAO,aACFA;gBACHC,UAAUgE;gBACVd,OAAO;gBACPhD,QAAQ,EAAE;gBACVe,WAAW,EAAE;gBACbgC,UAAU;gBACVR,aAAad,QAAQS,IAAI;gBACzBM,WAAWf,QAAQW,EAAE;;;IAG3B;AACF;AAQA,6EAA6E,GAC7E,OAAO,SAAS2B,oBACd3C,KAAyE;QAKjDA,uBACTA;IAJf,IAAI,CAACA,SAAS,CAAChC,QAAQgC,UAAU,CAACA,MAAMvB,KAAK,EAAE,OAAO;IACtD,IAAI,CAACR,cAAc+B,QAAQ,OAAO;IAClC,IAAIxB,mBAAmBwB,MAAMvB,KAAK,EAAEoB,MAAM,KAAK,GAAG,OAAO;IACzD,MAAMnB,WAAWC,QAAOqB,wBAAAA,MAAMvB,KAAK,CAACC,QAAQ,YAApBsB,wBAAwB;IAChD,MAAM4C,QAAQ,EAAC5C,yBAAAA,MAAMvB,KAAK,CAACkB,SAAS,YAArBK,yBAAyB,EAAE,EAAEnB,MAAM,CAAC,CAACe,WAAajB,OAAOiB,4BAAAA,SAAUlB,QAAQ,MAAMA;IAChG,IAAIkE,MAAM/C,MAAM,IAAI1B,qBAAqB,OAAO;IAChD,OAAO;AACT;AAEA,OAAO,SAAS0E,2BAA2B1C,MAA2B,EAAE2C,SAAiB;IACvF,MAAMC,WAAWD,aAAa;IAC9B,OAAQ3C;QACN,KAAK;YACH,OAAO;QACT,KAAK;YACH,OAAO;QACT,KAAK;YACH,OAAO,CAAC,+CAA+C,EAAE2C,aAAa,eAAe,eAAe,CAAC;QACvG,KAAK;YACH,OAAO,CAAC,qDAAqD,EAAEC,SAAS,UAAU,CAAC;IACvF;AACF;AAOA,yFAAyF,GACzF,OAAO,SAASC,wBAAwBC,GAAY;IAClD,MAAMC,SAAUD,OAAO,OAAOA,QAAQ,WAAWA,MAAM,CAAC;IACxD,IAAIC,MAAM,CAAC,SAAS,KAAK,WAAW;QAClC,MAAMzC,OAAO,OAAOyC,MAAM,CAAC,OAAO,KAAK,WAAWA,MAAM,CAAC,OAAO,CAACC,OAAO,CAAC,QAAQ,KAAKC,IAAI,KAAK;QAC/F,IAAI3C,KAAKZ,MAAM,GAAG,GAAG,OAAO;YAAEU,IAAI;YAAOgB,OAAO;QAAuC;QACvF,OAAO;YAAEhB,IAAI;YAAM8C,OAAO;gBAAEC,QAAQ;gBAAW7C,MAAMA,KAAK8C,KAAK,CAAC,GAAGlF;YAAyB;QAAE;IAChG;IACA,IAAI6E,MAAM,CAAC,SAAS,KAAK,WAAW;QAClC,MAAMM,UAAU,OAAON,MAAM,CAAC,UAAU,KAAK,WAAWA,MAAM,CAAC,UAAU,CAACE,IAAI,KAAK;QACnF,IAAI,CAACI,SAAS,OAAO;YAAEjD,IAAI;YAAOgB,OAAO;QAA0B;QACnE,MAAMkC,OAAOlE,MAAMC,OAAO,CAAC0D,MAAM,CAAC,UAAU,IAAKA,MAAM,CAAC,UAAU,GAAiB,EAAE;QACrF,MAAMQ,QAAQD,KAAKE,GAAG,CAAC,CAACC,QAAUC,OAAOD,gBAAAA,QAAS,KAAK/E,MAAM,CAAC,CAAC+E,QAAU,yBAAyBE,IAAI,CAACF;QACvG,IAAIF,MAAM7D,MAAM,KAAK4D,KAAK5D,MAAM,EAAE,OAAO;YAAEU,IAAI;YAAOgB,OAAO;QAAyD;QACtH,MAAMwC,UAAU;eAAI,IAAIC,IAAIN;SAAO;QACnC,IAAIK,QAAQlE,MAAM,GAAG3B,kBAAkB,OAAO;YAAEqC,IAAI;YAAOgB,OAAO,CAAC,WAAW,EAAErD,iBAAiB,iBAAiB,CAAC;QAAC;QACpH,OAAO;YAAEqC,IAAI;YAAM8C,OAAO;gBAAEC,QAAQ;gBAAWE,SAASA,QAAQD,KAAK,CAAC,GAAGnF;gBAAoB2F;YAAQ;QAAE;IACzG;IACA,OAAO;QAAExD,IAAI;QAAOgB,OAAO;IAAwC;AACrE;AAEA;;;;;;;CAOC,GACD,OAAO,SAAS0C,mBACdjE,KAAgB,EAChBqD,KAA2D,EAC3Da,OAAsE;QAK9CzF,iBAeDA,kBASamB,gBAM4CnB;QAnBpE4E,cAiCCzD;IA/Cb,MAAMU,UAAUqC,oBAAoB3C;IACpC,IAAIM,SAAS,OAAO;QAAEC,IAAI;QAAOJ,QAAQG;IAAQ;IACjD,MAAM7B,QAAQuB,MAAMvB,KAAK;IACzB,MAAMC,WAAWC,QAAOF,kBAAAA,MAAMC,QAAQ,YAAdD,kBAAkB;IAC1C,MAAM0F,KAAKD,QAAQC,EAAE,GAAGD,QAAQC,EAAE,CAACZ,KAAK,CAAC,GAAG,MAAM;IAClD,MAAMa,YAAYF,QAAQE,SAAS,GAAGF,QAAQE,SAAS,CAACb,KAAK,CAAC,GAAG,OAAO;IACxE,MAAM3D,WACJyD,MAAMC,MAAM,KAAK,YACb;QAAEA,QAAQ;QAAW5E;QAAUoC,MAAMoD,QAAQpD,IAAI;QAAEL,MAAM4C,MAAM5C,IAAI;QAAE0D;QAAIC;IAAU,IACnF;QACEd,QAAQ;QACR5E;QACAoC,MAAMoD,QAAQpD,IAAI;QAClB0C,SAASH,MAAMG,OAAO;OAClBH,EAAAA,eAAAA,MAAMgB,KAAK,qBAAXhB,aAAaxD,MAAM,IAAG;QAAEwE,OAAOhB,MAAMgB,KAAK;IAAC,IAAI,CAAC;QACpDF;QACAC;;IAER,MAAMzE,YAAY;WAAI,EAAClB,mBAAAA,MAAMkB,SAAS,YAAflB,mBAAmB,EAAE,EAAEI,MAAM,CAAC,CAACqC,QAAUvC,OAAOuC,yBAAAA,MAAOxC,QAAQ,MAAMA;QAAWkB;KAAS;IAChH,MAAM0E,SAASvG,kBAAkBiC;IACjC,MAAM0C,OACJ9C,SAAS0D,MAAM,KAAK,YAChB,aACK7E;QACHkB;QACAiC,OAAO;QACP2C,kBAAkB7F;QAClBiD,UAAU;YAAEjD;YAAU+B,IAAI,GAAEb,iBAAAA,SAASa,IAAI,YAAbb,iBAAiB;YAAIkB,MAAMoD,QAAQpD,IAAI;YAAEqD;YAAIC;QAAU;SAErF,aACK3F;QACHkB;QACAiC,OAAO;QACP2C,kBAAkB5F,OAAOF,MAAM8F,gBAAgB,MAAM7F,WAAW,QAAQD,0BAAAA,MAAM8F,gBAAgB,YAAtB9F,0BAA0B;QAClGkD,UAAU;;IAElB,OAAO;QACLpB,IAAI;QACJX;QACAI,OAAO,aACFA;YACHiB,UAAUnD,iBACRkC,OACAJ,SAAS0D,MAAM,KAAK,YAAY,mBAAmB,2BACnD1D,SAAS0D,MAAM,KAAK,YAChB,GAAGgB,OAAO,UAAU,EAAE5F,SAAS,6BAA6B,EAAEkB,SAASa,IAAI,EAAE,GAC7E,CAAC,kCAAkC,EAAE6D,OAAO,UAAU,EAAE5F,UAAU,GAC/DkB,CAAAA,EAAAA,kBAAAA,SAASyE,KAAK,qBAAdzE,gBAAgBC,MAAM,IAAG,CAAC,UAAU,EAAED,SAASyE,KAAK,CAACxE,MAAM,CAAC,KAAK,EAAED,SAASyE,KAAK,CAACxE,MAAM,KAAK,IAAI,KAAK,KAAK,GAAG,EAAC,GACtHqE,QAAQpD,IAAI;YAEdrC,OAAOiE;;IAEX;AACF;AAEA,4FAA4F,GAC5F,OAAO,SAAS8B,sBAAsB5E,QAAwD;IAC5F,OAAOA,SAAS0D,MAAM,KAAK,YAAY,CAAC,YAAY,EAAE1D,SAASa,IAAI,IAAI,gBAAgB,GAAG;AAC5F"}
@@ -432,7 +432,7 @@ function parseLine(raw, index) {
432
432
  error: `Set a price for ${product.name} before quoting it.`
433
433
  };
434
434
  }
435
- const chosen = resolveLineModifiers(product, request.modifiers);
435
+ const chosen = resolveLineModifiers(product, request.modifiers, variant.id);
436
436
  if (!chosen.ok) return {
437
437
  ok: false,
438
438
  error: (_chosen_error = chosen.error) != null ? _chosen_error : `Check the choices on ${product.name}.`