@skuio/storefront 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/store.js CHANGED
@@ -14,7 +14,7 @@ import {
14
14
  regionForCountry,
15
15
  shippingForRegion,
16
16
  slugify
17
- } from "./chunk-56ODALDY.js";
17
+ } from "./chunk-NM5YP764.js";
18
18
  import {
19
19
  API_KEY_PREFIX,
20
20
  CONNECT_CODE_MINUTES,
@@ -36,15 +36,18 @@ import {
36
36
  randomToken,
37
37
  secretsEqual,
38
38
  sha256Hex
39
- } from "./chunk-X7VXO4QU.js";
39
+ } from "./chunk-RUUUZ2EL.js";
40
40
  import {
41
41
  magicLinkMail,
42
42
  merchantNewOrderMail,
43
+ merchantQuoteMail,
43
44
  orderConfirmationMail,
44
45
  ownerPasswordResetMail,
45
46
  partnerSignInMail,
47
+ quoteReadyMail,
48
+ quoteReceivedMail,
46
49
  shippingConfirmationMail
47
- } from "./chunk-UCREHLZH.js";
50
+ } from "./chunk-BD274TXU.js";
48
51
  import "./chunk-4PTKC5PH.js";
49
52
 
50
53
  // src/store/cursor.ts
@@ -86,7 +89,7 @@ var CatalogService = class {
86
89
  }
87
90
  ctx;
88
91
  ttlMs;
89
- /** The catalogue snapshot: active products with availability, and the category tree. */
92
+ /** The catalog snapshot: active products with availability, and the category tree. */
90
93
  load() {
91
94
  const hit = cache.get(this.ctx.db);
92
95
  if (hit && hit.expires > Date.now()) return hit.catalog;
@@ -161,7 +164,7 @@ var CatalogService = class {
161
164
  return found;
162
165
  }
163
166
  /**
164
- * Products in keyset order, for an operations system importing the catalogue.
167
+ * Products in keyset order, for an operations system importing the catalog.
165
168
  * Ordered by `(updated_at, sku)` so a cursor is strictly monotonic and a
166
169
  * re-run from a watermark never repeats or skips a row.
167
170
  */
@@ -593,7 +596,7 @@ var CheckoutService = class {
593
596
  return region ? shippingForRegion(this.ctx.config.shipping, this.ctx.config.shipToCountries, region).options : [];
594
597
  }
595
598
  /**
596
- * Price a set of lines from the catalogue. Quantities are clamped to the
599
+ * Price a set of lines from the catalog. Quantities are clamped to the
597
600
  * store's per-line maximum; unknown or unavailable SKUs fail validation.
598
601
  */
599
602
  async quote(items, country, shippingOptionId, discountCode) {
@@ -659,13 +662,7 @@ var CheckoutService = class {
659
662
  const details = {};
660
663
  const email = normalizeEmail(input.email ?? "");
661
664
  if (!EMAIL.test(email)) details.email = "must be a valid email address";
662
- const shipTo = cleanAddress(input.shippingAddress) ?? {};
663
- if (!shipTo.name) details["shipping_address.name"] = "is required";
664
- if (!shipTo.line1) details["shipping_address.line1"] = "is required";
665
- if (!shipTo.city) details["shipping_address.city"] = "is required";
666
- if (!shipTo.country || shipTo.country.length !== 2) details["shipping_address.country"] = "is required";
667
- else shipTo.country = shipTo.country.toUpperCase();
668
- if (shipTo.country) Object.assign(details, checkAddressFormat(shipTo));
665
+ const shipTo = checkShippingAddress(input.shippingAddress, details);
669
666
  if (this.ctx.config.checkout.collectPhone && !input.walletContact && !input.phone?.trim()) details.phone = "is required";
670
667
  let quote = null;
671
668
  try {
@@ -675,61 +672,96 @@ var CheckoutService = class {
675
672
  else throw error;
676
673
  }
677
674
  if (Object.keys(details).length || !quote) throw invalid("Checkout is incomplete", details);
675
+ const metadata = Object.fromEntries(Object.entries(input.metadata ?? {}).filter(([, v]) => typeof v === "string").slice(0, 20));
676
+ return this.placePricedOrder({
677
+ email,
678
+ name: input.name?.trim() || shipTo.name || null,
679
+ phone: input.phone?.trim() || null,
680
+ currency: quote.currency,
681
+ lines: quote.lines,
682
+ subtotal: quote.subtotal,
683
+ shipping: quote.shipping,
684
+ tax: 0,
685
+ discount: quote.discount,
686
+ total: quote.total,
687
+ shippingAddress: shipTo,
688
+ billingAddress: input.billingAddress ? cleanAddress(input.billingAddress) : null,
689
+ shippingMethod: { code: quote.option.id, name: quote.option.label },
690
+ notes: input.notes ?? null,
691
+ metadata,
692
+ discountCode: quote.discountCode ? normalizeDiscountCode(quote.discountCode.code) : null,
693
+ discountPartner: quote.discountCode?.partner ?? null,
694
+ commission: quote.commission,
695
+ createdEvent: {
696
+ ...quote.discountCode ? { discount_code: normalizeDiscountCode(quote.discountCode.code) } : {},
697
+ ...quote.quantityTier ? { quantity_tier: quote.quantityTier.minQuantity } : {},
698
+ ...quote.discount ? { discount: quote.discount } : {}
699
+ }
700
+ });
701
+ }
702
+ /**
703
+ * Insert an order whose every amount is already decided. The checkout prices from the catalog first; an accepted
704
+ * store quote (QuotesService.accept) passes the offered prices. Nothing on the Store API reaches this: an order is
705
+ * only ever placed by a buyer.
706
+ */
707
+ async placePricedOrder(spec) {
678
708
  const db = this.ctx.db;
679
709
  const now = this.ctx.now();
680
710
  const orderId = newId("ord");
681
- const name = input.name?.trim() || shipTo.name || null;
682
- const phone = input.phone?.trim() || null;
683
- const metadata = Object.fromEntries(Object.entries(input.metadata ?? {}).filter(([, v]) => typeof v === "string").slice(0, 20));
684
711
  const statements = [
685
712
  db.prepare(
686
713
  `INSERT INTO customers (id, email, name, phone, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)
687
714
  ON CONFLICT (email) DO UPDATE SET name = COALESCE(excluded.name, customers.name), phone = COALESCE(excluded.phone, customers.phone), updated_at = excluded.updated_at`
688
- ).bind(newId("cus"), email, name, phone, now, now),
715
+ ).bind(newId("cus"), spec.email, spec.name, spec.phone, now, now),
689
716
  db.prepare(
690
717
  `INSERT INTO orders (id, number, status, payment_status, currency, subtotal, shipping, tax, discount, total, customer_id, email,
691
718
  shipping_address_json, billing_address_json, shipping_method_code, shipping_method_name, notes, metadata_json, access_key, created_at, updated_at,
692
719
  discount_code, discount_partner, partner_commission)
693
- VALUES (?, (SELECT MAX(COALESCE(MAX(number), 0) + 1, ?) FROM orders), 'pending_payment', 'unpaid', ?, ?, ?, 0, ?, ?, (SELECT id FROM customers WHERE email = ?), ?,
720
+ VALUES (?, (SELECT MAX(COALESCE(MAX(number), 0) + 1, ?) FROM orders), 'pending_payment', 'unpaid', ?, ?, ?, ?, ?, ?, (SELECT id FROM customers WHERE email = ?), ?,
694
721
  ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
695
722
  ).bind(
696
723
  orderId,
697
724
  this.ctx.config.orders.numberStart,
698
- quote.currency,
699
- quote.subtotal,
700
- quote.shipping,
701
- quote.discount,
702
- quote.total,
703
- email,
704
- email,
705
- JSON.stringify(shipTo),
706
- input.billingAddress ? JSON.stringify(cleanAddress(input.billingAddress)) : null,
707
- quote.option.id,
708
- quote.option.label,
709
- input.notes?.trim().slice(0, 2e3) || null,
710
- JSON.stringify(metadata),
725
+ spec.currency,
726
+ spec.subtotal,
727
+ spec.shipping,
728
+ spec.tax,
729
+ spec.discount,
730
+ spec.total,
731
+ spec.email,
732
+ spec.email,
733
+ JSON.stringify(spec.shippingAddress),
734
+ spec.billingAddress ? JSON.stringify(spec.billingAddress) : null,
735
+ spec.shippingMethod?.code ?? null,
736
+ spec.shippingMethod?.name ?? null,
737
+ spec.notes?.trim().slice(0, 2e3) || null,
738
+ JSON.stringify(spec.metadata),
711
739
  randomToken(16),
712
740
  now,
713
741
  now,
714
- quote.discountCode ? normalizeDiscountCode(quote.discountCode.code) : null,
715
- quote.discountCode?.partner ?? null,
716
- quote.commission
742
+ spec.discountCode,
743
+ spec.discountPartner,
744
+ spec.commission
717
745
  ),
718
- ...quote.lines.map(
746
+ ...spec.lines.map(
719
747
  (l, i) => db.prepare(`INSERT INTO order_lines (id, order_id, sku, name, quantity, unit_amount, total_amount, fulfilled_quantity, image_url, position) VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?)`).bind(newId("lin"), orderId, l.sku, l.name, l.quantity, l.unitAmount, l.totalAmount, l.image, i)
720
748
  ),
721
- this.events.statement(orderId, "created", {
722
- lines: quote.lines.length,
723
- total: quote.total,
724
- ...quote.discountCode ? { discount_code: normalizeDiscountCode(quote.discountCode.code) } : {},
725
- ...quote.quantityTier ? { quantity_tier: quote.quantityTier.minQuantity } : {},
726
- ...quote.discount ? { discount: quote.discount } : {}
727
- })
749
+ this.events.statement(orderId, "created", { lines: spec.lines.length, total: spec.total, ...spec.createdEvent })
728
750
  ];
729
751
  await db.batch(statements);
730
752
  return this.orders.require(orderId);
731
753
  }
732
754
  };
755
+ function checkShippingAddress(address, details) {
756
+ const shipTo = cleanAddress(address) ?? {};
757
+ if (!shipTo.name) details["shipping_address.name"] = "is required";
758
+ if (!shipTo.line1) details["shipping_address.line1"] = "is required";
759
+ if (!shipTo.city) details["shipping_address.city"] = "is required";
760
+ if (!shipTo.country || shipTo.country.length !== 2) details["shipping_address.country"] = "is required";
761
+ else shipTo.country = shipTo.country.toUpperCase();
762
+ if (shipTo.country) Object.assign(details, checkAddressFormat(shipTo));
763
+ return shipTo;
764
+ }
733
765
  function clampQuantity(n, max) {
734
766
  return Number.isFinite(n) ? Math.min(Math.max(Math.trunc(n), 1), max) : 1;
735
767
  }
@@ -983,21 +1015,31 @@ var InventoryService = class {
983
1015
  const now = this.ctx.now();
984
1016
  const unknown = [];
985
1017
  const statements = [];
1018
+ let updated = 0;
986
1019
  for (const item of items) {
987
1020
  if (!known.has(item.sku)) {
988
1021
  unknown.push(item.sku);
989
1022
  continue;
990
1023
  }
1024
+ updated++;
1025
+ const available = Math.trunc(item.available);
1026
+ const backorder = item.allow_backorder ? 1 : 0;
1027
+ statements.push(
1028
+ this.ctx.db.prepare(
1029
+ `UPDATE products SET updated_at = ? WHERE sku = ?
1030
+ AND NOT EXISTS (SELECT 1 FROM inventory WHERE sku = ? AND available = ? AND allow_backorder = ?)`
1031
+ ).bind(now, item.sku, item.sku, available, backorder)
1032
+ );
991
1033
  statements.push(
992
1034
  this.ctx.db.prepare(
993
1035
  `INSERT INTO inventory (sku, available, allow_backorder, updated_at) VALUES (?, ?, ?, ?)
994
1036
  ON CONFLICT (sku) DO UPDATE SET available = excluded.available, allow_backorder = excluded.allow_backorder, updated_at = excluded.updated_at`
995
- ).bind(item.sku, Math.trunc(item.available), item.allow_backorder ? 1 : 0, now)
1037
+ ).bind(item.sku, available, backorder, now)
996
1038
  );
997
1039
  }
998
1040
  for (const part of chunk(statements, 250)) await this.ctx.db.batch(part);
999
1041
  if (statements.length) this.invalidate();
1000
- return { updated: statements.length, unknown_skus: [...new Set(unknown)] };
1042
+ return { updated, unknown_skus: [...new Set(unknown)] };
1001
1043
  }
1002
1044
  /** Availability for a set of SKUs; SKUs without a row are untracked (undefined). */
1003
1045
  async availability(skus) {
@@ -1012,9 +1054,10 @@ var InventoryService = class {
1012
1054
  /** Statements that move tracked stock by `delta` per SKU (negative to consume). Untracked SKUs are untouched. */
1013
1055
  adjustStatements(lines, direction) {
1014
1056
  const now = this.ctx.now();
1015
- return lines.map(
1016
- (l) => this.ctx.db.prepare(`UPDATE inventory SET available = available + ?, updated_at = ? WHERE sku = ?`).bind(direction * l.quantity, now, l.sku)
1017
- );
1057
+ return lines.flatMap((l) => [
1058
+ this.ctx.db.prepare(`UPDATE products SET updated_at = ? WHERE sku = ? AND EXISTS (SELECT 1 FROM inventory WHERE sku = ?)`).bind(now, l.sku, l.sku),
1059
+ this.ctx.db.prepare(`UPDATE inventory SET available = available + ?, updated_at = ? WHERE sku = ?`).bind(direction * l.quantity, now, l.sku)
1060
+ ]);
1018
1061
  }
1019
1062
  };
1020
1063
 
@@ -1550,6 +1593,425 @@ var OwnersService = class {
1550
1593
  };
1551
1594
  var DUMMY_HASH = "$pbkdf2-sha256$i=210000$AAAAAAAAAAAAAAAAAAAAAA$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
1552
1595
 
1596
+ // src/store/quotes.ts
1597
+ var QUOTE_SELECT = `SELECT q.*, c.name AS customer_name, c.phone AS customer_phone FROM quotes q JOIN customers c ON c.id = q.customer_id`;
1598
+ var OFFERABLE = ["requested", "quoted", "expired"];
1599
+ var encodeCursor2 = (updatedAt, id) => btoa(`${updatedAt}|${id}`).replace(/=+$/, "");
1600
+ var decodeCursor2 = (cursor) => {
1601
+ try {
1602
+ const raw = atob(cursor);
1603
+ const i = raw.lastIndexOf("|");
1604
+ return i < 0 ? null : { updatedAt: raw.slice(0, i), id: raw.slice(i + 1) };
1605
+ } catch {
1606
+ return null;
1607
+ }
1608
+ };
1609
+ var clean = (v, max) => typeof v === "string" && v.trim() ? v.trim().slice(0, max) : null;
1610
+ var QuotesService = class {
1611
+ constructor(ctx, catalog, checkout, orders) {
1612
+ this.ctx = ctx;
1613
+ this.catalog = catalog;
1614
+ this.checkout = checkout;
1615
+ this.orders = orders;
1616
+ }
1617
+ ctx;
1618
+ catalog;
1619
+ checkout;
1620
+ orders;
1621
+ // ---- reads -------------------------------------------------------------
1622
+ async get(id) {
1623
+ const [quote] = await this.hydrate(`${QUOTE_SELECT} WHERE q.id = ?`, [id]);
1624
+ return quote ?? null;
1625
+ }
1626
+ async require(id) {
1627
+ const quote = await this.get(id);
1628
+ if (!quote) throw notFound("Quote");
1629
+ return quote;
1630
+ }
1631
+ /** The emailed link's lookup: id plus the access key. This is how a guest gets back to their quote. */
1632
+ async getWithKey(id, key) {
1633
+ const quote = await this.get(id);
1634
+ return quote && key && quote.accessKey === key ? quote : null;
1635
+ }
1636
+ async forCustomer(customerId) {
1637
+ return this.hydrate(`${QUOTE_SELECT} WHERE q.customer_id = ? ORDER BY q.created_at DESC, q.number DESC`, [customerId]);
1638
+ }
1639
+ async getForCustomer(id, customerId) {
1640
+ const [quote] = await this.hydrate(`${QUOTE_SELECT} WHERE q.id = ? AND q.customer_id = ?`, [id, customerId]);
1641
+ return quote ?? null;
1642
+ }
1643
+ async count() {
1644
+ const row = await this.ctx.db.prepare(`SELECT COUNT(*) AS n FROM quotes`).first();
1645
+ return row?.n ?? 0;
1646
+ }
1647
+ /** Quotes changed since a watermark, oldest first, keyset-paged on (updated_at, id); `updatedSince` is inclusive. */
1648
+ async changedSince(updatedSince, cursor, limit) {
1649
+ const size = Math.min(Math.max(Math.trunc(limit) || 50, 1), 200);
1650
+ const where = [];
1651
+ const args = [];
1652
+ if (cursor) {
1653
+ const c = decodeCursor2(cursor);
1654
+ if (!c) throw invalid("Invalid cursor", { cursor: "is not a cursor this store issued" });
1655
+ where.push(`(q.updated_at, q.id) > (?, ?)`);
1656
+ args.push(c.updatedAt, c.id);
1657
+ } else if (updatedSince) {
1658
+ where.push(`q.updated_at >= ?`);
1659
+ args.push(updatedSince);
1660
+ }
1661
+ const quotes = await this.hydrate(`${QUOTE_SELECT}${where.length ? ` WHERE ${where.join(" AND ")}` : ""} ORDER BY q.updated_at ASC, q.id ASC LIMIT ?`, [...args, size + 1]);
1662
+ const page = quotes.slice(0, size);
1663
+ const last = page[page.length - 1];
1664
+ return { quotes: page, nextCursor: quotes.length > size && last ? encodeCursor2(last.updatedAt, last.id) : null };
1665
+ }
1666
+ async events(id) {
1667
+ const { results } = await this.ctx.db.prepare(`SELECT * FROM quote_events WHERE quote_id = ? ORDER BY id ASC`).bind(id).all();
1668
+ return results.map((r) => ({ id: r.id, type: r.type, data: parseJson(r.data_json, null), createdAt: r.created_at }));
1669
+ }
1670
+ // ---- buyer -------------------------------------------------------------
1671
+ /**
1672
+ * Record a request for quote. The buyer needs no account and no password: the customer row is keyed by email,
1673
+ * exactly as a guest checkout's is, and the emailed access-key link is how they come back. Sold-out products may
1674
+ * be quoted (lead time is part of the answer); unknown or withdrawn ones may not. Throttled per email address.
1675
+ */
1676
+ async request(input) {
1677
+ const details = {};
1678
+ const email = normalizeEmail(input.email ?? "");
1679
+ if (!EMAIL.test(email)) details.email = "must be a valid email address";
1680
+ const name = clean(input.name, 200);
1681
+ if (!name) details.name = "is required";
1682
+ const items = input.items ?? [];
1683
+ if (!items.length) details.items = "is empty";
1684
+ if (items.length > MAX_CART_LINES) details.items = `has more than ${MAX_CART_LINES} lines`;
1685
+ const max = this.ctx.config.quotes.maxQuantity;
1686
+ const catalog = await this.catalog.load();
1687
+ const merged = /* @__PURE__ */ new Map();
1688
+ items.forEach((item, i) => {
1689
+ const quantity = Number(item.quantity);
1690
+ if (!Number.isInteger(quantity) || quantity < 1 || quantity > max) {
1691
+ details[`items.${i}.quantity`] = `must be a whole number from 1 to ${max}`;
1692
+ return;
1693
+ }
1694
+ if (!catalog.product(item.sku)) {
1695
+ details[`items.${i}.sku`] = "is not a product in this store";
1696
+ return;
1697
+ }
1698
+ merged.set(item.sku, Math.min((merged.get(item.sku) ?? 0) + quantity, max));
1699
+ });
1700
+ if (Object.keys(details).length) throw invalid("Quote request is incomplete", details);
1701
+ await this.assertNotFlooding(email);
1702
+ const db = this.ctx.db;
1703
+ const now = this.ctx.now();
1704
+ const id = newId("quo");
1705
+ const address = cleanAddress(input.shippingAddress);
1706
+ if (address?.country) address.country = address.country.toUpperCase();
1707
+ const metadata = Object.fromEntries(Object.entries(input.metadata ?? {}).filter(([, v]) => typeof v === "string").slice(0, 20));
1708
+ const lines = [...merged].map(([sku, quantity], position) => {
1709
+ const product = catalog.product(sku);
1710
+ return { sku, quantity, position, name: product.name, list: product.price.amount, image: product.images[0]?.src ?? null };
1711
+ });
1712
+ await db.batch([
1713
+ db.prepare(
1714
+ `INSERT INTO customers (id, email, name, phone, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)
1715
+ ON CONFLICT (email) DO UPDATE SET name = COALESCE(excluded.name, customers.name), phone = COALESCE(excluded.phone, customers.phone), updated_at = excluded.updated_at`
1716
+ ).bind(newId("cus"), email, name, clean(input.phone, 50), now, now),
1717
+ db.prepare(
1718
+ `INSERT INTO quotes (id, number, status, customer_id, email, company, message, shipping_address_json, currency, metadata_json, access_key, created_at, updated_at)
1719
+ VALUES (?, (SELECT MAX(COALESCE(MAX(number), 0) + 1, ?) FROM quotes), 'requested', (SELECT id FROM customers WHERE email = ?), ?, ?, ?, ?, ?, ?, ?, ?, ?)`
1720
+ ).bind(
1721
+ id,
1722
+ this.ctx.config.quotes.numberStart,
1723
+ email,
1724
+ email,
1725
+ clean(input.company, 200),
1726
+ clean(input.message, 4e3),
1727
+ address && Object.keys(address).length ? JSON.stringify(address) : null,
1728
+ this.ctx.config.currency.toUpperCase(),
1729
+ JSON.stringify(metadata),
1730
+ randomToken(16),
1731
+ now,
1732
+ now
1733
+ ),
1734
+ ...lines.map(
1735
+ (l) => db.prepare(`INSERT INTO quote_lines (id, quote_id, sku, name, quantity, list_amount, image_url, position) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).bind(newId("qln"), id, l.sku, l.name, l.quantity, l.list, l.image, l.position)
1736
+ ),
1737
+ this.event(id, "requested", { lines: lines.map((l) => ({ sku: l.sku, quantity: l.quantity })) })
1738
+ ]);
1739
+ const quote = await this.require(id);
1740
+ await this.send(id, "quote_received", quote.customer.email, quoteReceivedMail(quote, this.mailContext()));
1741
+ await this.notifyMerchant(quote, "requested");
1742
+ return quote;
1743
+ }
1744
+ /**
1745
+ * Accept a priced quote: places an order at exactly the offered prices (lines, shipping, tax) and links it. The
1746
+ * order is pay-later (`payment_provider: manual`, never expires) — the operations system invoices and marks it
1747
+ * paid, as B2B terms usually go; a store that wants card payment can start an adapter on the returned order.
1748
+ * A second accept returns the same order.
1749
+ */
1750
+ async accept(id, input = {}) {
1751
+ await this.expireStale();
1752
+ const quote = await this.require(id);
1753
+ if (quote.status === "accepted" && quote.orderId) return { quote, order: await this.orders.require(quote.orderId) };
1754
+ if (quote.status === "expired") throw conflict("This quote has expired; ask for it to be renewed");
1755
+ if (quote.status !== "quoted" || !quote.total) throw conflict(quote.status === "requested" ? "This quote has not been priced yet" : `A ${quote.status} quote cannot be accepted`);
1756
+ const details = {};
1757
+ const shipTo = checkShippingAddress(input.shippingAddress ?? quote.shippingAddress, details);
1758
+ if (Object.keys(details).length) throw invalid("A shipping address is needed to accept this quote", details);
1759
+ const now = this.ctx.now();
1760
+ const claimed = await this.ctx.db.prepare(`UPDATE quotes SET status = 'accepted', accepted_at = ?, updated_at = ? WHERE id = ? AND status = 'quoted' AND (expires_at IS NULL OR expires_at > ?)`).bind(now, now, id, now).run();
1761
+ if ((claimed.meta?.changes ?? 0) === 0) {
1762
+ const current = await this.require(id);
1763
+ if (current.status === "accepted" && current.orderId) return { quote: current, order: await this.orders.require(current.orderId) };
1764
+ throw conflict("This quote changed while you were accepting it; reload it and try again");
1765
+ }
1766
+ const po = clean(input.poNumber, 100);
1767
+ const lines = quote.lines.map((l) => ({
1768
+ sku: l.sku,
1769
+ name: l.name,
1770
+ quantity: l.quantity,
1771
+ unitAmount: l.unitAmount.amount,
1772
+ totalAmount: l.totalAmount.amount,
1773
+ image: l.image ?? null
1774
+ }));
1775
+ let order;
1776
+ try {
1777
+ order = await this.checkout.placePricedOrder({
1778
+ email: quote.customer.email,
1779
+ name: quote.customer.name ?? shipTo.name ?? null,
1780
+ phone: clean(input.phone, 50) ?? quote.customer.phone,
1781
+ currency: quote.currency,
1782
+ lines,
1783
+ subtotal: quote.subtotal.amount,
1784
+ shipping: quote.shipping?.amount ?? 0,
1785
+ tax: quote.tax?.amount ?? 0,
1786
+ discount: 0,
1787
+ total: quote.total.amount,
1788
+ shippingAddress: shipTo,
1789
+ billingAddress: null,
1790
+ shippingMethod: { code: "quote", name: `As quoted (${this.ctx.config.quotes.numberPrefix}${quote.number})` },
1791
+ notes: [po ? `PO ${po}` : null, clean(input.notes, 1800)].filter(Boolean).join("\n") || null,
1792
+ metadata: {
1793
+ quote_id: quote.id,
1794
+ quote_number: `${this.ctx.config.quotes.numberPrefix}${quote.number}`,
1795
+ ...po ? { po_number: po } : {},
1796
+ ...quote.externalReference ? { quote_external_reference: quote.externalReference } : {}
1797
+ },
1798
+ discountCode: null,
1799
+ discountPartner: null,
1800
+ commission: 0,
1801
+ createdEvent: { quote_id: quote.id }
1802
+ });
1803
+ } catch (error) {
1804
+ await this.ctx.db.prepare(`UPDATE quotes SET status = 'quoted', accepted_at = NULL, updated_at = ? WHERE id = ? AND status = 'accepted' AND order_id IS NULL`).bind(this.ctx.now(), id).run();
1805
+ throw error;
1806
+ }
1807
+ await this.orders.startPayment(order.id, { provider: "manual", kind: "instructions" });
1808
+ await this.ctx.db.batch([
1809
+ this.ctx.db.prepare(`UPDATE quotes SET order_id = ?, shipping_address_json = ?, updated_at = ? WHERE id = ?`).bind(order.id, JSON.stringify(shipTo), this.ctx.now(), id),
1810
+ this.event(id, "accepted", { order_id: order.id, order_number: order.number, ...po ? { po_number: po } : {} })
1811
+ ]);
1812
+ const placed = await this.orders.require(order.id);
1813
+ await this.orders.notifyReceived(placed);
1814
+ const accepted = await this.require(id);
1815
+ await this.notifyMerchant(accepted, "accepted", placed.number);
1816
+ return { quote: accepted, order: placed };
1817
+ }
1818
+ /** The buyer turns a quote down — before or after it is priced. */
1819
+ async decline(id, reason = null) {
1820
+ const quote = await this.require(id);
1821
+ if (quote.status === "declined") return quote;
1822
+ if (quote.status !== "requested" && quote.status !== "quoted" && quote.status !== "expired") throw conflict(`A ${quote.status} quote cannot be declined`);
1823
+ const now = this.ctx.now();
1824
+ const why = clean(reason, 1e3);
1825
+ await this.ctx.db.batch([
1826
+ this.ctx.db.prepare(`UPDATE quotes SET status = 'declined', declined_at = ?, decline_reason = ?, updated_at = ? WHERE id = ?`).bind(now, why, now, id),
1827
+ this.event(id, "declined", why ? { reason: why } : null)
1828
+ ]);
1829
+ const declined = await this.require(id);
1830
+ await this.notifyMerchant(declined, "declined");
1831
+ return declined;
1832
+ }
1833
+ // ---- operations system (Store API) ------------------------------------
1834
+ /**
1835
+ * Price a quote. The lines replace the request's: the operations system may change a quantity, drop a line or add
1836
+ * an alternative (a SKU the store does not list needs a `name`). Re-offering a live or lapsed quote revises it.
1837
+ */
1838
+ async offer(id, input) {
1839
+ const quote = await this.require(id);
1840
+ if (!OFFERABLE.includes(quote.status)) throw conflict(`A ${quote.status} quote cannot be re-priced`);
1841
+ const currency = quote.currency.toUpperCase();
1842
+ const details = {};
1843
+ if (!input.lines.length) details["offer.lines"] = "must have at least one line";
1844
+ if (input.lines.length > MAX_CART_LINES) details["offer.lines"] = `has more than ${MAX_CART_LINES} lines`;
1845
+ const catalog = await this.catalog.load();
1846
+ const max = this.ctx.config.quotes.maxQuantity;
1847
+ const requested = new Map(quote.lines.map((l) => [l.sku, l]));
1848
+ const seen = /* @__PURE__ */ new Set();
1849
+ const lines = input.lines.map((l, i) => {
1850
+ const product = catalog.product(l.sku);
1851
+ const name = clean(l.name, 300) ?? product?.name ?? requested.get(l.sku)?.name ?? null;
1852
+ if (!name) details[`offer.lines.${i}.name`] = "is required for a SKU this store does not list";
1853
+ if (seen.has(l.sku)) details[`offer.lines.${i}.sku`] = "appears twice";
1854
+ seen.add(l.sku);
1855
+ if (l.quantity > max) details[`offer.lines.${i}.quantity`] = `must be at most ${max}`;
1856
+ if (l.unit_amount.currency.toUpperCase() !== currency) details[`offer.lines.${i}.unit_amount.currency`] = `must be ${currency}`;
1857
+ return {
1858
+ sku: l.sku,
1859
+ name: name ?? l.sku,
1860
+ quantity: l.quantity,
1861
+ unit: l.unit_amount.amount,
1862
+ total: l.unit_amount.amount * l.quantity,
1863
+ list: product?.price.amount ?? requested.get(l.sku)?.listAmount?.amount ?? null,
1864
+ image: product?.images[0]?.src ?? requested.get(l.sku)?.image ?? null
1865
+ };
1866
+ });
1867
+ for (const [key, m] of [["shipping", input.shipping], ["tax", input.tax]]) {
1868
+ if (m && m.currency.toUpperCase() !== currency) details[`offer.${key}.currency`] = `must be ${currency}`;
1869
+ }
1870
+ const now = this.ctx.now();
1871
+ if (input.expires_at && input.expires_at <= now) details["offer.expires_at"] = "must be in the future";
1872
+ if (Object.keys(details).length) throw invalid("Offer is invalid", details);
1873
+ const subtotal = lines.reduce((n, l) => n + l.total, 0);
1874
+ const shipping = input.shipping?.amount ?? 0;
1875
+ const tax = input.tax?.amount ?? 0;
1876
+ const total = subtotal + shipping + tax;
1877
+ const db = this.ctx.db;
1878
+ const statements = [
1879
+ db.prepare(`DELETE FROM quote_lines WHERE quote_id = ?`).bind(id),
1880
+ ...lines.map(
1881
+ (l, position) => db.prepare(`INSERT INTO quote_lines (id, quote_id, sku, name, quantity, list_amount, unit_amount, total_amount, image_url, position) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).bind(newId("qln"), id, l.sku, l.name, l.quantity, l.list, l.unit, l.total, l.image, position)
1882
+ ),
1883
+ db.prepare(
1884
+ `UPDATE quotes SET status = 'quoted', subtotal = ?, shipping = ?, tax = ?, total = ?, offer_note = ?, expires_at = ?, quoted_at = ?, updated_at = ?
1885
+ WHERE id = ? AND status IN ('requested', 'quoted', 'expired')`
1886
+ ).bind(subtotal, shipping, tax, total, clean(input.note, 4e3), input.expires_at ?? null, now, now, id),
1887
+ this.event(id, quote.status === "requested" ? "quoted" : "requoted", { total, lines: lines.length, expires_at: input.expires_at ?? null })
1888
+ ];
1889
+ await db.batch(statements);
1890
+ const priced = await this.require(id);
1891
+ if (input.notify_customer !== false) await this.send(id, "quote_ready", priced.customer.email, quoteReadyMail(priced, this.mailContext()));
1892
+ return priced;
1893
+ }
1894
+ async setExternalReference(id, reference) {
1895
+ const quote = await this.require(id);
1896
+ if (quote.externalReference === reference) return quote;
1897
+ const now = this.ctx.now();
1898
+ await this.ctx.db.batch([
1899
+ this.ctx.db.prepare(`UPDATE quotes SET external_reference = ?, updated_at = ? WHERE id = ?`).bind(reference, now, id),
1900
+ this.event(id, "external_reference_set", { reference })
1901
+ ]);
1902
+ return this.require(id);
1903
+ }
1904
+ /** The merchant withdraws a quote. An accepted one has an order: cancel that instead. */
1905
+ async cancel(id, reason = null) {
1906
+ const quote = await this.require(id);
1907
+ if (quote.status === "cancelled") return quote;
1908
+ if (quote.status === "accepted") throw conflict("An accepted quote has become an order; cancel the order instead");
1909
+ const now = this.ctx.now();
1910
+ await this.ctx.db.batch([
1911
+ this.ctx.db.prepare(`UPDATE quotes SET status = 'cancelled', cancelled_at = ?, updated_at = ? WHERE id = ?`).bind(now, now, id),
1912
+ this.event(id, "cancelled", reason ? { reason } : null)
1913
+ ]);
1914
+ return this.require(id);
1915
+ }
1916
+ /**
1917
+ * Lapse offers past their `expires_at`. Lazy, like abandoned checkouts: it runs at the start of the Store API quote
1918
+ * list and of an accept, so no cron is needed and a consumer never sees a lapsed offer as live.
1919
+ */
1920
+ async expireStale(limit = 200) {
1921
+ const now = this.ctx.now();
1922
+ const { results } = await this.ctx.db.prepare(`SELECT id FROM quotes WHERE status = 'quoted' AND expires_at IS NOT NULL AND expires_at <= ? ORDER BY expires_at LIMIT ?`).bind(now, Math.min(Math.max(limit, 1), 500)).all();
1923
+ if (!results.length) return 0;
1924
+ const statements = results.flatMap((r) => [
1925
+ this.ctx.db.prepare(`UPDATE quotes SET status = 'expired', updated_at = ? WHERE id = ? AND status = 'quoted'`).bind(now, r.id),
1926
+ this.event(r.id, "expired")
1927
+ ]);
1928
+ for (const part of chunk(statements, 250)) await this.ctx.db.batch(part);
1929
+ return results.length;
1930
+ }
1931
+ // ---- internals ---------------------------------------------------------
1932
+ async assertNotFlooding(email) {
1933
+ const since = new Date(new Date(this.ctx.now()).getTime() - 36e5).toISOString();
1934
+ const row = await this.ctx.db.prepare(`SELECT COUNT(*) AS n FROM quotes WHERE email = ? AND created_at > ?`).bind(email, since).first();
1935
+ if ((row?.n ?? 0) >= this.ctx.config.quotes.maxRequestsPerHour) {
1936
+ throw new StoreError("rate_limited", "Too many quote requests from this address. Please try again in an hour, or contact us directly.");
1937
+ }
1938
+ }
1939
+ event(quoteId, type, data = null) {
1940
+ return this.ctx.db.prepare(`INSERT INTO quote_events (quote_id, type, data_json, created_at) VALUES (?, ?, ?, ?)`).bind(quoteId, type, data ? JSON.stringify(data) : null, this.ctx.now());
1941
+ }
1942
+ mailContext() {
1943
+ return { config: this.ctx.config, siteUrl: this.ctx.siteUrl };
1944
+ }
1945
+ /** The merchant hears about requests and answers at QUOTE_NOTIFICATION_EMAIL, else ORDER_NOTIFICATION_EMAIL. */
1946
+ async notifyMerchant(quote, event, orderNumber) {
1947
+ const to = this.ctx.env.QUOTE_NOTIFICATION_EMAIL || this.ctx.env.ORDER_NOTIFICATION_EMAIL;
1948
+ if (to) await this.send(quote.id, `merchant_quote_${event}`, to, merchantQuoteMail(quote, this.mailContext(), event, orderNumber), quote.customer.email);
1949
+ }
1950
+ async send(quoteId, kind, to, mail, replyTo) {
1951
+ try {
1952
+ await this.ctx.mail.send({ to, ...mail, ...replyTo ? { replyTo } : {} });
1953
+ await this.event(quoteId, "email_sent", { kind, to }).run();
1954
+ } catch (error) {
1955
+ const message = error instanceof Error ? error.message : String(error);
1956
+ this.ctx.log(`quotes: ${kind} email failed`, { quoteId, to, error: message });
1957
+ await this.event(quoteId, "email_failed", { kind, to, error: message }).run().catch(() => {
1958
+ });
1959
+ }
1960
+ }
1961
+ async hydrate(sql, args) {
1962
+ const { results: rows } = await this.ctx.db.prepare(sql).bind(...args).all();
1963
+ if (!rows.length) return [];
1964
+ const lines = /* @__PURE__ */ new Map();
1965
+ for (const part of chunk(rows.map((r) => r.id))) {
1966
+ const { results } = await this.ctx.db.prepare(`SELECT * FROM quote_lines WHERE quote_id IN (${placeholders(part.length)}) ORDER BY position, id`).bind(...part).all();
1967
+ for (const r of results) (lines.get(r.quote_id) ?? lines.set(r.quote_id, []).get(r.quote_id)).push(r);
1968
+ }
1969
+ return rows.map((r) => rowToQuote(r, lines.get(r.id) ?? []));
1970
+ }
1971
+ };
1972
+ function rowToQuote(r, lines) {
1973
+ const money = (amount) => amount === null || amount === void 0 ? null : { amount, currency: r.currency };
1974
+ return {
1975
+ id: r.id,
1976
+ number: String(r.number),
1977
+ status: r.status,
1978
+ currency: r.currency,
1979
+ customer: { id: r.customer_id, email: r.email, name: r.customer_name, phone: r.customer_phone },
1980
+ company: r.company,
1981
+ message: r.message,
1982
+ shippingAddress: r.shipping_address_json ? parseJson(r.shipping_address_json, null) : null,
1983
+ lines: lines.map(
1984
+ (l) => ({
1985
+ id: l.id,
1986
+ sku: l.sku,
1987
+ name: l.name,
1988
+ quantity: l.quantity,
1989
+ listAmount: money(l.list_amount),
1990
+ unitAmount: money(l.unit_amount),
1991
+ totalAmount: money(l.total_amount),
1992
+ ...l.image_url ? { image: l.image_url } : {}
1993
+ })
1994
+ ),
1995
+ subtotal: money(r.subtotal),
1996
+ shipping: money(r.shipping),
1997
+ tax: money(r.tax),
1998
+ total: money(r.total),
1999
+ offerNote: r.offer_note,
2000
+ expiresAt: r.expires_at,
2001
+ externalReference: r.external_reference,
2002
+ orderId: r.order_id,
2003
+ declineReason: r.decline_reason,
2004
+ metadata: parseJson(r.metadata_json, {}),
2005
+ accessKey: r.access_key,
2006
+ createdAt: r.created_at,
2007
+ updatedAt: r.updated_at,
2008
+ quotedAt: r.quoted_at,
2009
+ acceptedAt: r.accepted_at,
2010
+ declinedAt: r.declined_at,
2011
+ cancelledAt: r.cancelled_at
2012
+ };
2013
+ }
2014
+
1553
2015
  // src/store/sessions.ts
1554
2016
  var MAGIC_LINK_MINUTES = 15;
1555
2017
  var SESSION_DAYS = 30;
@@ -1633,7 +2095,7 @@ function bindable(value) {
1633
2095
  }
1634
2096
 
1635
2097
  // src/store/index.ts
1636
- var STORE_PLATFORM_VERSION = "2.1.0";
2098
+ var STORE_PLATFORM_VERSION = "2.2.0";
1637
2099
  function createStore(options) {
1638
2100
  const env = options.env ?? {};
1639
2101
  const clock = options.clock ?? (() => /* @__PURE__ */ new Date());
@@ -1652,6 +2114,7 @@ function createStore(options) {
1652
2114
  const customers = new CustomersService(ctx);
1653
2115
  const orders = new OrdersService(ctx, events, inventory);
1654
2116
  const checkout = new CheckoutService(ctx, catalog, orders, events);
2117
+ const quotes = new QuotesService(ctx, catalog, checkout, orders);
1655
2118
  const sessions = new SessionsService(ctx, customers);
1656
2119
  const owners = new OwnersService(ctx);
1657
2120
  const connections = new ConnectionsService(ctx);
@@ -1668,20 +2131,21 @@ function createStore(options) {
1668
2131
  customers,
1669
2132
  orders,
1670
2133
  checkout,
2134
+ quotes,
1671
2135
  sessions,
1672
2136
  events,
1673
2137
  owners,
1674
2138
  connections,
1675
2139
  partners,
1676
2140
  async health() {
1677
- const [counts, orderCount, customerCount, ownerCount] = await Promise.all([catalog.counts(), orders.count(), customers.count(), owners.count()]);
2141
+ const [counts, orderCount, customerCount, ownerCount, quoteCount] = await Promise.all([catalog.counts(), orders.count(), customers.count(), owners.count(), quotes.count()]);
1678
2142
  return {
1679
2143
  name: ctx.config.name,
1680
2144
  url: ctx.config.url,
1681
2145
  currency: ctx.config.currency.toUpperCase(),
1682
2146
  locale: ctx.config.locale,
1683
2147
  version: STORE_PLATFORM_VERSION,
1684
- counts: { ...counts, orders: orderCount, customers: customerCount },
2148
+ counts: { ...counts, orders: orderCount, customers: customerCount, quotes: quoteCount },
1685
2149
  owner_configured: ownerCount > 0,
1686
2150
  time: ctx.now()
1687
2151
  };
@@ -1709,6 +2173,7 @@ export {
1709
2173
  PASSWORD_ITERATIONS,
1710
2174
  PASSWORD_MIN_LENGTH,
1711
2175
  PartnersService,
2176
+ QuotesService,
1712
2177
  RateLimiter,
1713
2178
  SCOPE_DESCRIPTIONS,
1714
2179
  SESSION_DAYS,
@@ -1716,6 +2181,7 @@ export {
1716
2181
  SessionsService,
1717
2182
  StoreError,
1718
2183
  bindable,
2184
+ checkShippingAddress,
1719
2185
  createStore,
1720
2186
  deriveAvailability,
1721
2187
  hashPassword,