@skuio/storefront 0.6.1 → 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/README.md +2 -2
- package/db/migrations/0008_quotes.sql +59 -0
- package/dist/api.d.ts +48 -4
- package/dist/api.js +112 -2
- package/dist/api.js.map +1 -1
- package/dist/{chunk-UCREHLZH.js → chunk-BD274TXU.js} +84 -2
- package/dist/chunk-BD274TXU.js.map +1 -0
- package/dist/{chunk-56ODALDY.js → chunk-NM5YP764.js} +1 -1
- package/dist/chunk-NM5YP764.js.map +1 -0
- package/dist/{chunk-X7VXO4QU.js → chunk-RUUUZ2EL.js} +4 -3
- package/dist/chunk-RUUUZ2EL.js.map +1 -0
- package/dist/{config-C4YCi7m4.d.ts → config-CCxTka_z.d.ts} +78 -4
- package/dist/index.d.ts +3 -3
- package/dist/index.js +7 -1
- package/dist/index.js.map +1 -1
- package/dist/mail.d.ts +10 -2
- package/dist/mail.js +9 -1
- package/dist/payments/bitcoin.d.ts +1 -1
- package/dist/payments/btcpay.d.ts +1 -1
- package/dist/payments/manual.d.ts +1 -1
- package/dist/payments/manual.js +1 -1
- package/dist/payments/paypal.d.ts +1 -1
- package/dist/payments/stripe.d.ts +1 -1
- package/dist/payments.d.ts +1 -1
- package/dist/store.d.ts +163 -8
- package/dist/store.js +516 -50
- package/dist/store.js.map +1 -1
- package/package.json +2 -2
- package/dist/chunk-56ODALDY.js.map +0 -1
- package/dist/chunk-UCREHLZH.js.map +0 -1
- package/dist/chunk-X7VXO4QU.js.map +0 -1
package/dist/store.js
CHANGED
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
regionForCountry,
|
|
15
15
|
shippingForRegion,
|
|
16
16
|
slugify
|
|
17
|
-
} from "./chunk-
|
|
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-
|
|
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-
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
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', ?, ?, ?,
|
|
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
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
email,
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
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
|
-
|
|
715
|
-
|
|
716
|
-
|
|
742
|
+
spec.discountCode,
|
|
743
|
+
spec.discountPartner,
|
|
744
|
+
spec.commission
|
|
717
745
|
),
|
|
718
|
-
...
|
|
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,
|
|
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
|
|
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.
|
|
1016
|
-
|
|
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.
|
|
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,
|