@blamejs/blamejs-shop 0.4.25 → 0.4.27
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/CHANGELOG.md +4 -0
- package/README.md +2 -2
- package/SECURITY.md +33 -1
- package/lib/admin.js +302 -15
- package/lib/asset-manifest.json +3 -3
- package/lib/compliance-export.js +18 -7
- package/lib/order.js +96 -12
- package/lib/quotes.js +216 -82
- package/lib/save-for-later.js +46 -0
- package/lib/security-middleware.js +20 -1
- package/lib/store-credit.js +41 -0
- package/lib/storefront.js +30 -4
- package/lib/suggestion-box.js +124 -0
- package/package.json +1 -1
package/lib/compliance-export.js
CHANGED
|
@@ -11,9 +11,10 @@
|
|
|
11
11
|
* The primitive owns the request lifecycle + composes per-domain
|
|
12
12
|
* readers (customers, order, order-notes, subscriptions,
|
|
13
13
|
* addresses, payment-methods, support-tickets, loyalty, reviews,
|
|
14
|
-
* consent ledger, wishlist, surveys, recently-viewed
|
|
15
|
-
*
|
|
16
|
-
*
|
|
14
|
+
* consent ledger, wishlist, surveys, recently-viewed, suggestion
|
|
15
|
+
* box, save-for-later, store credit) to assemble the bundle —
|
|
16
|
+
* every table that keys a row by the customer, so the export holds
|
|
17
|
+
* the whole record, not just the order/identity core.
|
|
17
18
|
* Delivery (email / signed URL / secure
|
|
18
19
|
* download portal) is the operator worker's concern — this
|
|
19
20
|
* primitive returns the bundle as structured JSON and stamps
|
|
@@ -83,7 +84,8 @@
|
|
|
83
84
|
* orders, subscriptions, addresses, payment
|
|
84
85
|
* methods, support tickets, loyalty, reviews,
|
|
85
86
|
* consent ledger, wishlist, surveys,
|
|
86
|
-
* recently-viewed
|
|
87
|
+
* recently-viewed, suggestion box,
|
|
88
|
+
* save-for-later, store credit).
|
|
87
89
|
* - `orders_only` — only `order` + `orderNotes` contribute;
|
|
88
90
|
* identity / loyalty / subscriptions /
|
|
89
91
|
* addresses / payment methods / support
|
|
@@ -101,7 +103,7 @@
|
|
|
101
103
|
* @related customers, order, orderNotes, subscriptions, addresses,
|
|
102
104
|
* paymentMethods, supportTickets, loyalty, reviews,
|
|
103
105
|
* consentLedger, wishlist, customerSurveys, recentlyViewed,
|
|
104
|
-
* orderExport
|
|
106
|
+
* suggestionBox, saveForLater, storeCredit, orderExport
|
|
105
107
|
*/
|
|
106
108
|
|
|
107
109
|
var b = require("./vendor/blamejs");
|
|
@@ -188,6 +190,11 @@ var SCOPE_SECTIONS = Object.freeze({
|
|
|
188
190
|
"customers", "addresses", "order", "orderNotes",
|
|
189
191
|
"subscriptions", "paymentMethods", "supportTickets", "loyalty",
|
|
190
192
|
"reviews", "consentLedger", "wishlist", "surveys", "recentlyViewed",
|
|
193
|
+
// Customer-authored feedback (ideas + complaints, keyed by id or a
|
|
194
|
+
// hashed email), the save-for-later holdover list, and the store-
|
|
195
|
+
// credit wallet ledger — each keys a row by the customer, so a
|
|
196
|
+
// subject-access export holds them too.
|
|
197
|
+
"suggestionBox", "saveForLater", "storeCredit",
|
|
191
198
|
]),
|
|
192
199
|
orders_only: Object.freeze(["order", "orderNotes"]),
|
|
193
200
|
identity_only: Object.freeze(["customers", "addresses"]),
|
|
@@ -361,6 +368,9 @@ function create(opts) {
|
|
|
361
368
|
wishlist: opts.wishlist || null,
|
|
362
369
|
surveys: opts.surveys || null,
|
|
363
370
|
recentlyViewed: opts.recentlyViewed || null,
|
|
371
|
+
suggestionBox: opts.suggestionBox || null,
|
|
372
|
+
saveForLater: opts.saveForLater || null,
|
|
373
|
+
storeCredit: opts.storeCredit || null,
|
|
364
374
|
};
|
|
365
375
|
|
|
366
376
|
// ---- requestExport -------------------------------------------------
|
|
@@ -609,9 +619,10 @@ function create(opts) {
|
|
|
609
619
|
}
|
|
610
620
|
|
|
611
621
|
var domainOrder = [
|
|
612
|
-
"recentlyViewed", "wishlist", "
|
|
622
|
+
"recentlyViewed", "wishlist", "saveForLater", "suggestionBox",
|
|
623
|
+
"surveys", "reviews", "consentLedger",
|
|
613
624
|
"supportTickets", "orderNotes", "order", "subscriptions",
|
|
614
|
-
"paymentMethods", "loyalty", "addresses", "customers",
|
|
625
|
+
"paymentMethods", "loyalty", "storeCredit", "addresses", "customers",
|
|
615
626
|
];
|
|
616
627
|
var perDomain = [];
|
|
617
628
|
var domainsAbsent = [];
|
package/lib/order.js
CHANGED
|
@@ -110,6 +110,14 @@ var ORDER_STATES = Object.freeze([
|
|
|
110
110
|
"pending", "paid", "fulfilling", "shipped", "delivered", "refunded", "cancelled",
|
|
111
111
|
]);
|
|
112
112
|
|
|
113
|
+
// The proof routes a guest-order reconciliation can be attributed to.
|
|
114
|
+
// "verified-email" = an OIDC sign-in whose provider verified the address;
|
|
115
|
+
// "magic-link" = the buyer clicked a sign-in link emailed to the address.
|
|
116
|
+
// Both prove control of the email — the trust anchor for the attach. An
|
|
117
|
+
// unknown linked_via falls back to "verified-email" so a typo can't write
|
|
118
|
+
// an unfiltered audit string.
|
|
119
|
+
var RECONCILE_LINKED_VIA = Object.freeze(["verified-email", "magic-link"]);
|
|
120
|
+
|
|
113
121
|
// Cursor key for listForCustomer — paginates by (updated_at DESC, id
|
|
114
122
|
// DESC) so a newly transitioned order surfaces at the top of the
|
|
115
123
|
// customer's order history without a stable-id tie-break flake.
|
|
@@ -1051,22 +1059,98 @@ function create(opts) {
|
|
|
1051
1059
|
|
|
1052
1060
|
// Claim guest orders into a customer account by matching the
|
|
1053
1061
|
// recorded buyer-email hash. The CALLER must only pass a hash for an
|
|
1054
|
-
// email the identity provider VERIFIED
|
|
1055
|
-
//
|
|
1056
|
-
//
|
|
1057
|
-
//
|
|
1058
|
-
//
|
|
1059
|
-
|
|
1062
|
+
// email the identity provider VERIFIED (an OIDC email_verified claim)
|
|
1063
|
+
// or the buyer has otherwise proven control of (clicking an emailed
|
|
1064
|
+
// magic-link) — this method does not (and cannot) re-verify; linking
|
|
1065
|
+
// an unverified email would be account takeover. Only touches orders
|
|
1066
|
+
// with no owner yet (customer_id IS NULL), so it never re-assigns
|
|
1067
|
+
// another customer's order.
|
|
1068
|
+
//
|
|
1069
|
+
// Each newly-attached order also gets one append-only row in
|
|
1070
|
+
// guest_order_reconciliations recording WHEN it was attached, to WHICH
|
|
1071
|
+
// customer, by WHICH proof (linked_via), and the email_hash matched —
|
|
1072
|
+
// so a disputed link is traceable and an operator/DSR review can replay
|
|
1073
|
+
// it without inferring it from a customer_id that was overwritten. The
|
|
1074
|
+
// audit row is the verified linking key only (the plaintext address is
|
|
1075
|
+
// never stored anywhere).
|
|
1076
|
+
//
|
|
1077
|
+
// Idempotent at BOTH layers: the per-order claim-guard UPDATE only
|
|
1078
|
+
// flips a still-unowned order (so a re-run links nothing new), and the
|
|
1079
|
+
// audit INSERT is INSERT-OR-IGNORE against a UNIQUE (order_id,
|
|
1080
|
+
// customer_id) index (so even a forced re-write records nothing new).
|
|
1081
|
+
// The audit table is optional — when the migration hasn't run (a
|
|
1082
|
+
// partial-schema test, or a deploy mid-migration) the INSERT throws and
|
|
1083
|
+
// is swallowed so reconciliation never fails on the audit write; the
|
|
1084
|
+
// attach (the load-bearing effect) still lands.
|
|
1085
|
+
//
|
|
1086
|
+
// `linkedVia` names the proof route — defaulted + constrained to a known
|
|
1087
|
+
// token so a typo can't write an unfiltered string; an unknown value
|
|
1088
|
+
// falls back to "verified-email". Returns the count linked.
|
|
1089
|
+
linkGuestOrdersByEmailHash: async function (customerId, emailHash, opts2) {
|
|
1060
1090
|
_uuid(customerId, "customer id");
|
|
1061
1091
|
if (typeof emailHash !== "string" || !emailHash.length) {
|
|
1062
1092
|
throw new TypeError("order.linkGuestOrdersByEmailHash: emailHash must be a non-empty string");
|
|
1063
1093
|
}
|
|
1064
|
-
var
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
)
|
|
1069
|
-
|
|
1094
|
+
var linkedVia = (opts2 && opts2.linked_via) || "verified-email";
|
|
1095
|
+
if (RECONCILE_LINKED_VIA.indexOf(linkedVia) === -1) linkedVia = "verified-email";
|
|
1096
|
+
|
|
1097
|
+
// The candidate set: every still-unowned order placed under this
|
|
1098
|
+
// verified email. Claiming per-id (not one bulk UPDATE) is what lets
|
|
1099
|
+
// the audit trail name the exact orders attached AND keeps the attach
|
|
1100
|
+
// race-safe — a concurrent reconciliation for the same email can't
|
|
1101
|
+
// double-claim because each UPDATE re-checks customer_id IS NULL.
|
|
1102
|
+
var candidates = (await query(
|
|
1103
|
+
"SELECT id FROM orders WHERE customer_id IS NULL AND customer_email_hash = ?1",
|
|
1104
|
+
[emailHash],
|
|
1105
|
+
)).rows;
|
|
1106
|
+
var linked = 0;
|
|
1107
|
+
for (var i = 0; i < candidates.length; i += 1) {
|
|
1108
|
+
var orderId = candidates[i].id;
|
|
1109
|
+
var ts = _now();
|
|
1110
|
+
// Claim-guard: only flip an order STILL unowned at write time.
|
|
1111
|
+
var upd = await query(
|
|
1112
|
+
"UPDATE orders SET customer_id = ?1, updated_at = ?2 " +
|
|
1113
|
+
"WHERE id = ?3 AND customer_id IS NULL",
|
|
1114
|
+
[customerId, ts, orderId],
|
|
1115
|
+
);
|
|
1116
|
+
if (Number(upd.rowCount || 0) === 0) continue; // lost the race — another writer claimed it.
|
|
1117
|
+
linked += 1;
|
|
1118
|
+
// Append-only audit row. INSERT OR IGNORE against the UNIQUE
|
|
1119
|
+
// (order_id, customer_id) index, so a re-run records nothing new.
|
|
1120
|
+
// Swallowed on a missing-table error so the attach never fails on
|
|
1121
|
+
// the audit write (the attach already landed above).
|
|
1122
|
+
try {
|
|
1123
|
+
await query(
|
|
1124
|
+
"INSERT OR IGNORE INTO guest_order_reconciliations " +
|
|
1125
|
+
"(id, order_id, customer_id, email_hash, linked_via, occurred_at) " +
|
|
1126
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
|
1127
|
+
[b.uuid.v7(), orderId, customerId, emailHash, linkedVia, ts],
|
|
1128
|
+
);
|
|
1129
|
+
} catch (_e) { /* drop-silent — audit table optional; the attach is the load-bearing effect */ }
|
|
1130
|
+
}
|
|
1131
|
+
return linked;
|
|
1132
|
+
},
|
|
1133
|
+
|
|
1134
|
+
// Every guest order reconciled into a customer account, newest first —
|
|
1135
|
+
// the operator/DSR "how did this order get attached?" view. Returns the
|
|
1136
|
+
// append-only audit rows (order_id, email_hash, linked_via, occurred_at)
|
|
1137
|
+
// for the customer; an account that never claimed a guest order returns
|
|
1138
|
+
// []. Defensive: when the audit table hasn't been migrated yet the read
|
|
1139
|
+
// throws and is collapsed to [] (the feature degrades to "no trail",
|
|
1140
|
+
// never a 500).
|
|
1141
|
+
reconciliationsForCustomer: async function (customerId) {
|
|
1142
|
+
_uuid(customerId, "customer id");
|
|
1143
|
+
try {
|
|
1144
|
+
var rows = (await query(
|
|
1145
|
+
"SELECT id, order_id, customer_id, email_hash, linked_via, occurred_at " +
|
|
1146
|
+
"FROM guest_order_reconciliations WHERE customer_id = ?1 " +
|
|
1147
|
+
"ORDER BY occurred_at DESC, id DESC",
|
|
1148
|
+
[customerId],
|
|
1149
|
+
)).rows;
|
|
1150
|
+
return rows;
|
|
1151
|
+
} catch (_e) {
|
|
1152
|
+
return [];
|
|
1153
|
+
}
|
|
1070
1154
|
},
|
|
1071
1155
|
|
|
1072
1156
|
// Has this customer purchased this product? True iff an order
|
package/lib/quotes.js
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
*
|
|
23
23
|
* requested -> responded -> accepted -> converted (happy path)
|
|
24
24
|
* |
|
|
25
|
+
* +-> responded (reprice — operator revises the offer)
|
|
25
26
|
* +-> rejected (customer says no)
|
|
26
27
|
* +-> expired (valid_until passed)
|
|
27
28
|
* +-> cancelled (cancelled before accept)
|
|
@@ -34,8 +35,17 @@
|
|
|
34
35
|
* expired -> * (terminal)
|
|
35
36
|
*
|
|
36
37
|
* `valid_until` is the operator-set expiry timestamp (ms). After
|
|
37
|
-
* it elapses, `listExpired({ as_of })` surfaces the row
|
|
38
|
-
*
|
|
38
|
+
* it elapses, `listExpired({ as_of })` surfaces the row and
|
|
39
|
+
* `expireDue({ as_of })` — driven by the worker cron's
|
|
40
|
+
* `/_/quote-expiry-tick` — transitions each due row to `expired`
|
|
41
|
+
* through the same atomic status claim every other verb uses, so
|
|
42
|
+
* a concurrent accept / reprice and the sweep can never both win.
|
|
43
|
+
*
|
|
44
|
+
* `response_version` counts the operator's pricing passes:
|
|
45
|
+
* `respondToQuote` stamps 1, each `repriceQuote` on a
|
|
46
|
+
* still-responded quote increments it. The customer's view-token
|
|
47
|
+
* link is NOT rotated by a reprice — the link they already hold
|
|
48
|
+
* resolves the same row and renders the new pricing.
|
|
39
49
|
*
|
|
40
50
|
* `total_minor` is the operator-quoted grand total — the sum of
|
|
41
51
|
* the quote_lines line-totals plus `shipping_minor` + `tax_minor`.
|
|
@@ -78,6 +88,10 @@
|
|
|
78
88
|
* tax_minor?, valid_until, currency,
|
|
79
89
|
* operator_notes?, delivery_terms?,
|
|
80
90
|
* payment_terms? })
|
|
91
|
+
* repriceQuote({ quote_id, line_prices, shipping_minor?,
|
|
92
|
+
* tax_minor?, valid_until, currency,
|
|
93
|
+
* operator_notes?, delivery_terms?,
|
|
94
|
+
* payment_terms? })
|
|
81
95
|
* customerAccept({ quote_id, accepted_by_customer })
|
|
82
96
|
* customerReject({ quote_id, reject_reason? })
|
|
83
97
|
* cancelQuote({ quote_id, cancel_reason })
|
|
@@ -85,10 +99,15 @@
|
|
|
85
99
|
* getQuote(quote_id)
|
|
86
100
|
* quotesForCustomer(customer_id, { status?, limit? })
|
|
87
101
|
* pendingResponse({ limit? })
|
|
88
|
-
*
|
|
102
|
+
* listByStatus({ status, limit? })
|
|
103
|
+
* listExpired({ as_of, limit? })
|
|
104
|
+
* expireDue({ as_of, limit? })
|
|
105
|
+
* markExpired({ quote_id, as_of })
|
|
89
106
|
*
|
|
90
107
|
* Storage: `migrations-d1/0102_quotes.sql` — two tables, `quotes`
|
|
91
108
|
* + `quote_lines`. ON DELETE CASCADE from quote -> lines.
|
|
109
|
+
* `0227_quote_response_version.sql` adds the response_version
|
|
110
|
+
* revision counter.
|
|
92
111
|
*
|
|
93
112
|
* @primitive quotes
|
|
94
113
|
* @related b.fsm, b.uuid, b.guardUuid, shop.cart, shop.order
|
|
@@ -152,6 +171,7 @@ var b = require("./vendor/blamejs");
|
|
|
152
171
|
// fires it:
|
|
153
172
|
//
|
|
154
173
|
// respond requested -> responded (operator prices the quote)
|
|
174
|
+
// reprice responded -> responded (operator revises the offer)
|
|
155
175
|
// accept responded -> accepted (customer accepts)
|
|
156
176
|
// reject responded -> rejected (customer declines)
|
|
157
177
|
// cancel requested|responded -> cancelled (either side pulls it)
|
|
@@ -163,6 +183,7 @@ var b = require("./vendor/blamejs");
|
|
|
163
183
|
var QUOTE_TRANSITIONS = Object.freeze([
|
|
164
184
|
{ from: "requested", to: "responded", on: "respond" },
|
|
165
185
|
{ from: "requested", to: "cancelled", on: "cancel" },
|
|
186
|
+
{ from: "responded", to: "responded", on: "reprice" },
|
|
166
187
|
{ from: "responded", to: "accepted", on: "accept" },
|
|
167
188
|
{ from: "responded", to: "rejected", on: "reject" },
|
|
168
189
|
{ from: "responded", to: "cancelled", on: "cancel" },
|
|
@@ -454,6 +475,7 @@ function _hydrateQuote(row) {
|
|
|
454
475
|
tax_minor: row.tax_minor == null ? null : Number(row.tax_minor),
|
|
455
476
|
total_minor: row.total_minor == null ? null : Number(row.total_minor),
|
|
456
477
|
currency: row.currency == null ? null : row.currency,
|
|
478
|
+
response_version: row.response_version == null ? null : Number(row.response_version),
|
|
457
479
|
valid_until: row.valid_until == null ? null : Number(row.valid_until),
|
|
458
480
|
accepted_at: row.accepted_at == null ? null : Number(row.accepted_at),
|
|
459
481
|
accepted_by_customer: row.accepted_by_customer == null ? null : row.accepted_by_customer,
|
|
@@ -590,6 +612,101 @@ function create(opts) {
|
|
|
590
612
|
}
|
|
591
613
|
}
|
|
592
614
|
|
|
615
|
+
// Shared body of respondToQuote (event "respond", requested -> responded)
|
|
616
|
+
// and repriceQuote (event "reprice", responded -> responded). Validates the
|
|
617
|
+
// full pricing payload, asks the FSM whether the edge is legal for the
|
|
618
|
+
// row's CURRENT status, recomputes the totals server-side, then claims the
|
|
619
|
+
// transition atomically (`AND status = <the legal from-state>`) so a
|
|
620
|
+
// concurrent accept / reject / cancel / expire sweep and this write can
|
|
621
|
+
// never both commit. respond stamps response_version = 1; reprice
|
|
622
|
+
// increments it (COALESCE'd so a row priced before the column shipped
|
|
623
|
+
// lands on 2). Neither path touches view_token_hash — the customer's
|
|
624
|
+
// existing link keeps working and renders the latest pricing.
|
|
625
|
+
async function _applyQuoteResponse(input, event, verbLabel) {
|
|
626
|
+
if (!input || typeof input !== "object") {
|
|
627
|
+
throw new TypeError("quotes." + verbLabel + ": input object required");
|
|
628
|
+
}
|
|
629
|
+
var quoteId = _id(input.quote_id, "quote_id");
|
|
630
|
+
var currency = _currency(input.currency);
|
|
631
|
+
var shipping = input.shipping_minor == null ? 0 : input.shipping_minor;
|
|
632
|
+
var tax = input.tax_minor == null ? 0 : input.tax_minor;
|
|
633
|
+
_moneyMinor(shipping, "shipping_minor");
|
|
634
|
+
_moneyMinor(tax, "tax_minor");
|
|
635
|
+
var validUntil = _ts(input.valid_until, "valid_until");
|
|
636
|
+
var operatorNotes = _optLongText(input.operator_notes, "operator_notes", MAX_OPERATOR_NOTES_LEN);
|
|
637
|
+
var delivery = _optShortText(input.delivery_terms, "delivery_terms", MAX_TERMS_LEN);
|
|
638
|
+
var payment = _optShortText(input.payment_terms, "payment_terms", MAX_TERMS_LEN);
|
|
639
|
+
|
|
640
|
+
var current = await _getQuoteRaw(quoteId);
|
|
641
|
+
if (!current) {
|
|
642
|
+
var miss = new Error("quotes." + verbLabel + ": quote " + quoteId + " not found");
|
|
643
|
+
miss.code = "QUOTE_NOT_FOUND";
|
|
644
|
+
throw miss;
|
|
645
|
+
}
|
|
646
|
+
_assertTransition(current.status, event, verbLabel);
|
|
647
|
+
// The FSM only declares this event from one source state (respond:
|
|
648
|
+
// requested, reprice: responded); having passed the assert, the row's
|
|
649
|
+
// current status IS that state — it pins the atomic claim below.
|
|
650
|
+
var fromStatus = current.status;
|
|
651
|
+
|
|
652
|
+
var lines = await _getLinesRaw(quoteId);
|
|
653
|
+
var lineSkus = lines.map(function (r) { return r.sku; });
|
|
654
|
+
var pricesBySku = _validateLinePrices(input.line_prices, lineSkus);
|
|
655
|
+
|
|
656
|
+
// valid_until must be strictly in the future. A past expiry on
|
|
657
|
+
// a fresh response / revision is operator error — the quote would
|
|
658
|
+
// be expired the moment it lands.
|
|
659
|
+
var ts = _now();
|
|
660
|
+
if (validUntil <= ts) {
|
|
661
|
+
throw new TypeError("quotes." + verbLabel + ": valid_until must be in the future");
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// Compute totals from the priced lines + shipping + tax. Math
|
|
665
|
+
// is integer-only; sum is bounded by MAX_LINES *
|
|
666
|
+
// MAX_QUANTITY * MAX_UNIT_PRICE_MINOR which fits Number.
|
|
667
|
+
var subtotal = 0;
|
|
668
|
+
for (var i = 0; i < lines.length; i += 1) {
|
|
669
|
+
var qty = Number(lines[i].quantity);
|
|
670
|
+
var price = pricesBySku[lines[i].sku];
|
|
671
|
+
subtotal += qty * price;
|
|
672
|
+
}
|
|
673
|
+
var total = subtotal + shipping + tax;
|
|
674
|
+
_moneyMinor(total, "total_minor");
|
|
675
|
+
|
|
676
|
+
// Update header + each line. The line update sets unit_price_minor +
|
|
677
|
+
// currency per-line. The header UPDATE claims the transition atomically
|
|
678
|
+
// (`AND status = ?`) so a concurrent settle can't both commit against
|
|
679
|
+
// the same row — for reprice that same clause also means an accept that
|
|
680
|
+
// lands first wins and the revision refuses instead of clobbering the
|
|
681
|
+
// accepted price.
|
|
682
|
+
var versionSql = event === "respond" ? "1" : "COALESCE(response_version, 1) + 1";
|
|
683
|
+
var claim = await query(
|
|
684
|
+
"UPDATE quotes SET status = 'responded', shipping_minor = ?1, " +
|
|
685
|
+
"tax_minor = ?2, total_minor = ?3, currency = ?4, valid_until = ?5, " +
|
|
686
|
+
"operator_notes = ?6, " +
|
|
687
|
+
"response_version = " + versionSql + ", " +
|
|
688
|
+
"delivery_terms = COALESCE(?7, delivery_terms), " +
|
|
689
|
+
"payment_terms = COALESCE(?8, payment_terms), " +
|
|
690
|
+
"updated_at = ?9 WHERE id = ?10 AND status = ?11",
|
|
691
|
+
[shipping, tax, total, currency, validUntil, operatorNotes,
|
|
692
|
+
delivery, payment, ts, quoteId, fromStatus],
|
|
693
|
+
);
|
|
694
|
+
if (Number(claim.rowCount || 0) !== 1) {
|
|
695
|
+
var raced = new Error("quotes." + verbLabel + ": refused — quote " + quoteId +
|
|
696
|
+
" is no longer in the " + fromStatus + " state (settled by a concurrent call)");
|
|
697
|
+
raced.code = "QUOTE_TRANSITION_REFUSED";
|
|
698
|
+
throw raced;
|
|
699
|
+
}
|
|
700
|
+
for (var j = 0; j < lines.length; j += 1) {
|
|
701
|
+
var line = lines[j];
|
|
702
|
+
await query(
|
|
703
|
+
"UPDATE quote_lines SET unit_price_minor = ?1, currency = ?2 WHERE id = ?3",
|
|
704
|
+
[pricesBySku[line.sku], currency, line.id],
|
|
705
|
+
);
|
|
706
|
+
}
|
|
707
|
+
return await _hydrated(quoteId);
|
|
708
|
+
}
|
|
709
|
+
|
|
593
710
|
return {
|
|
594
711
|
QUOTE_STATUSES: QUOTE_STATUSES.slice(),
|
|
595
712
|
TERMINAL_STATUSES: TERMINAL_STATUSES.slice(),
|
|
@@ -714,84 +831,25 @@ function create(opts) {
|
|
|
714
831
|
// sets shipping + tax + grand total currency, and stamps an
|
|
715
832
|
// expiry. The total_minor is computed from the priced lines +
|
|
716
833
|
// shipping_minor + tax_minor; the caller doesn't pass it (and
|
|
717
|
-
// can't override it — the math is the contract).
|
|
834
|
+
// can't override it — the math is the contract). Stamps
|
|
835
|
+
// response_version = 1 (the first pricing pass).
|
|
718
836
|
respondToQuote: async function (input) {
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
}
|
|
722
|
-
var quoteId = _id(input.quote_id, "quote_id");
|
|
723
|
-
var currency = _currency(input.currency);
|
|
724
|
-
var shipping = input.shipping_minor == null ? 0 : input.shipping_minor;
|
|
725
|
-
var tax = input.tax_minor == null ? 0 : input.tax_minor;
|
|
726
|
-
_moneyMinor(shipping, "shipping_minor");
|
|
727
|
-
_moneyMinor(tax, "tax_minor");
|
|
728
|
-
var validUntil = _ts(input.valid_until, "valid_until");
|
|
729
|
-
var operatorNotes = _optLongText(input.operator_notes, "operator_notes", MAX_OPERATOR_NOTES_LEN);
|
|
730
|
-
var delivery = _optShortText(input.delivery_terms, "delivery_terms", MAX_TERMS_LEN);
|
|
731
|
-
var payment = _optShortText(input.payment_terms, "payment_terms", MAX_TERMS_LEN);
|
|
732
|
-
|
|
733
|
-
var current = await _getQuoteRaw(quoteId);
|
|
734
|
-
if (!current) {
|
|
735
|
-
var miss = new Error("quotes.respondToQuote: quote " + quoteId + " not found");
|
|
736
|
-
miss.code = "QUOTE_NOT_FOUND";
|
|
737
|
-
throw miss;
|
|
738
|
-
}
|
|
739
|
-
_assertTransition(current.status, "respond", "respondToQuote");
|
|
740
|
-
|
|
741
|
-
var lines = await _getLinesRaw(quoteId);
|
|
742
|
-
var lineSkus = lines.map(function (r) { return r.sku; });
|
|
743
|
-
var pricesBySku = _validateLinePrices(input.line_prices, lineSkus);
|
|
744
|
-
|
|
745
|
-
// valid_until must be strictly in the future. A past expiry on
|
|
746
|
-
// a fresh response is operator error — the quote would be
|
|
747
|
-
// expired the moment it lands.
|
|
748
|
-
var ts = _now();
|
|
749
|
-
if (validUntil <= ts) {
|
|
750
|
-
throw new TypeError("quotes.respondToQuote: valid_until must be in the future");
|
|
751
|
-
}
|
|
837
|
+
return await _applyQuoteResponse(input, "respond", "respondToQuote");
|
|
838
|
+
},
|
|
752
839
|
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
// unit_price_minor + currency per-line so a future single-line
|
|
767
|
-
// re-quote (out of scope here) has a place to land. The header
|
|
768
|
-
// UPDATE claims the requested -> responded transition atomically
|
|
769
|
-
// (`AND status = 'requested'`) so a concurrent cancel/respond can't
|
|
770
|
-
// both commit against the same row.
|
|
771
|
-
var claim = await query(
|
|
772
|
-
"UPDATE quotes SET status = 'responded', shipping_minor = ?1, " +
|
|
773
|
-
"tax_minor = ?2, total_minor = ?3, currency = ?4, valid_until = ?5, " +
|
|
774
|
-
"operator_notes = ?6, " +
|
|
775
|
-
"delivery_terms = COALESCE(?7, delivery_terms), " +
|
|
776
|
-
"payment_terms = COALESCE(?8, payment_terms), " +
|
|
777
|
-
"updated_at = ?9 WHERE id = ?10 AND status = 'requested'",
|
|
778
|
-
[shipping, tax, total, currency, validUntil, operatorNotes,
|
|
779
|
-
delivery, payment, ts, quoteId],
|
|
780
|
-
);
|
|
781
|
-
if (Number(claim.rowCount || 0) !== 1) {
|
|
782
|
-
var raced = new Error("quotes.respondToQuote: refused — quote " + quoteId +
|
|
783
|
-
" is no longer in the requested state (settled by a concurrent call)");
|
|
784
|
-
raced.code = "QUOTE_TRANSITION_REFUSED";
|
|
785
|
-
throw raced;
|
|
786
|
-
}
|
|
787
|
-
for (var j = 0; j < lines.length; j += 1) {
|
|
788
|
-
var line = lines[j];
|
|
789
|
-
await query(
|
|
790
|
-
"UPDATE quote_lines SET unit_price_minor = ?1, currency = ?2 WHERE id = ?3",
|
|
791
|
-
[pricesBySku[line.sku], currency, line.id],
|
|
792
|
-
);
|
|
793
|
-
}
|
|
794
|
-
return await _hydrated(quoteId);
|
|
840
|
+
// FSM: responded -> responded (the reprice edge). Operator revises
|
|
841
|
+
// a quote the customer hasn't settled yet — improved line prices,
|
|
842
|
+
// fresh shipping / tax / validity window, an updated note. Same
|
|
843
|
+
// payload contract as respondToQuote (every line re-priced; the
|
|
844
|
+
// math is the contract), refused with QUOTE_TRANSITION_REFUSED on
|
|
845
|
+
// any other state so a settled (accepted / declined / expired /
|
|
846
|
+
// withdrawn) quote can never be silently re-opened. Increments
|
|
847
|
+
// response_version so the revision is visible on the row, and
|
|
848
|
+
// deliberately leaves view_token_hash untouched — the link the
|
|
849
|
+
// customer already holds keeps resolving and renders the new
|
|
850
|
+
// pricing.
|
|
851
|
+
repriceQuote: async function (input) {
|
|
852
|
+
return await _applyQuoteResponse(input, "reprice", "repriceQuote");
|
|
795
853
|
},
|
|
796
854
|
|
|
797
855
|
// FSM: responded -> accepted. Customer accepts the operator's
|
|
@@ -1176,12 +1234,39 @@ function create(opts) {
|
|
|
1176
1234
|
return out;
|
|
1177
1235
|
},
|
|
1178
1236
|
|
|
1237
|
+
// List quotes in one lifecycle state, newest activity first — the
|
|
1238
|
+
// operator console's status filter (e.g. the expired view that shows
|
|
1239
|
+
// what the cron sweep transitioned). Validated against QUOTE_STATUSES
|
|
1240
|
+
// so a garbage status throws rather than silently matching nothing.
|
|
1241
|
+
listByStatus: async function (listOpts) {
|
|
1242
|
+
if (!listOpts || typeof listOpts !== "object") {
|
|
1243
|
+
throw new TypeError("quotes.listByStatus: input object required");
|
|
1244
|
+
}
|
|
1245
|
+
var status = _status(listOpts.status);
|
|
1246
|
+
var limit = listOpts.limit == null ? DEFAULT_LIMIT : listOpts.limit;
|
|
1247
|
+
if (!Number.isInteger(limit) || limit <= 0 || limit > MAX_LIMIT) {
|
|
1248
|
+
throw new TypeError("quotes.listByStatus: limit must be 1..." + MAX_LIMIT);
|
|
1249
|
+
}
|
|
1250
|
+
var r = await query(
|
|
1251
|
+
"SELECT * FROM quotes WHERE status = ?1 " +
|
|
1252
|
+
"ORDER BY updated_at DESC, id DESC LIMIT ?2",
|
|
1253
|
+
[status, limit],
|
|
1254
|
+
);
|
|
1255
|
+
var out = [];
|
|
1256
|
+
for (var i = 0; i < r.rows.length; i += 1) {
|
|
1257
|
+
var hydrated = _hydrateQuote(r.rows[i]);
|
|
1258
|
+
hydrated.lines = (await _getLinesRaw(r.rows[i].id)).map(_hydrateLine);
|
|
1259
|
+
out.push(hydrated);
|
|
1260
|
+
}
|
|
1261
|
+
return out;
|
|
1262
|
+
},
|
|
1263
|
+
|
|
1179
1264
|
// List responded quotes whose `valid_until` has elapsed. A cron
|
|
1180
1265
|
// job walks the result and either fires `customerAccept` (rare —
|
|
1181
1266
|
// requires the customer-side timing) or transitions each row to
|
|
1182
|
-
// expired via an operator-side step (
|
|
1183
|
-
//
|
|
1184
|
-
//
|
|
1267
|
+
// expired via an operator-side step (`expireDue` is that sweep;
|
|
1268
|
+
// `markExpired` is the single-row flip when the operator owns the
|
|
1269
|
+
// cron).
|
|
1185
1270
|
listExpired: async function (input) {
|
|
1186
1271
|
if (!input || typeof input !== "object") {
|
|
1187
1272
|
throw new TypeError("quotes.listExpired: input object required");
|
|
@@ -1206,6 +1291,55 @@ function create(opts) {
|
|
|
1206
1291
|
return out;
|
|
1207
1292
|
},
|
|
1208
1293
|
|
|
1294
|
+
// One bounded expiry sweep — the worker cron's `/_/quote-expiry-tick`
|
|
1295
|
+
// drives this once a minute. Scans up to `limit` responded quotes whose
|
|
1296
|
+
// valid_until has elapsed as of `as_of` and flips each to expired
|
|
1297
|
+
// through an atomic conditional UPDATE that re-checks BOTH the status
|
|
1298
|
+
// AND the elapsed expiry, so:
|
|
1299
|
+
// - two overlapping ticks can't double-transition a row (the loser's
|
|
1300
|
+
// UPDATE matches nothing and is counted as skipped, not an error);
|
|
1301
|
+
// - a customer accept / reject / operator cancel that lands between
|
|
1302
|
+
// the scan and the flip wins — the flip refuses;
|
|
1303
|
+
// - a reprice that lands in that window and pushes valid_until back
|
|
1304
|
+
// into the future also wins — the `valid_until <= as_of` re-check
|
|
1305
|
+
// keeps a freshly revised quote alive.
|
|
1306
|
+
// Per-row failures never abort the pass; the summary reports what
|
|
1307
|
+
// happened. Input validation throws (a cron caller with a bad clock /
|
|
1308
|
+
// batch size is a config bug to surface, not to swallow).
|
|
1309
|
+
expireDue: async function (input) {
|
|
1310
|
+
if (!input || typeof input !== "object") {
|
|
1311
|
+
throw new TypeError("quotes.expireDue: input object required");
|
|
1312
|
+
}
|
|
1313
|
+
var asOf = _ts(input.as_of, "as_of");
|
|
1314
|
+
var limit = input.limit == null ? DEFAULT_LIMIT : input.limit;
|
|
1315
|
+
if (!Number.isInteger(limit) || limit <= 0 || limit > MAX_LIMIT) {
|
|
1316
|
+
throw new TypeError("quotes.expireDue: limit must be 1..." + MAX_LIMIT);
|
|
1317
|
+
}
|
|
1318
|
+
// The machine is the single source of truth for the expire edge — the
|
|
1319
|
+
// sweep's `status = 'responded'` claims below encode the same source
|
|
1320
|
+
// state, and this assert keeps them honest if the FSM ever changes.
|
|
1321
|
+
_assertTransition("responded", "expire", "expireDue");
|
|
1322
|
+
var r = await query(
|
|
1323
|
+
"SELECT id FROM quotes WHERE status = 'responded' " +
|
|
1324
|
+
"AND valid_until IS NOT NULL AND valid_until <= ?1 " +
|
|
1325
|
+
"ORDER BY valid_until ASC, id ASC LIMIT ?2",
|
|
1326
|
+
[asOf, limit],
|
|
1327
|
+
);
|
|
1328
|
+
var expired = 0;
|
|
1329
|
+
var skipped = 0;
|
|
1330
|
+
for (var i = 0; i < r.rows.length; i += 1) {
|
|
1331
|
+
var claim = await query(
|
|
1332
|
+
"UPDATE quotes SET status = 'expired', updated_at = ?1 " +
|
|
1333
|
+
"WHERE id = ?2 AND status = 'responded' " +
|
|
1334
|
+
"AND valid_until IS NOT NULL AND valid_until <= ?3",
|
|
1335
|
+
[asOf, r.rows[i].id, asOf],
|
|
1336
|
+
);
|
|
1337
|
+
if (Number(claim.rowCount || 0) === 1) expired += 1;
|
|
1338
|
+
else skipped += 1;
|
|
1339
|
+
}
|
|
1340
|
+
return { scanned: r.rows.length, expired: expired, skipped: skipped };
|
|
1341
|
+
},
|
|
1342
|
+
|
|
1209
1343
|
// Operator-side expiry flip. Walks a single quote whose
|
|
1210
1344
|
// valid_until has elapsed and moves it from responded -> expired.
|
|
1211
1345
|
// Refuses if the quote is not in the responded state or if the
|
package/lib/save-for-later.js
CHANGED
|
@@ -56,6 +56,12 @@
|
|
|
56
56
|
* current; returns the count of rows actually changed.
|
|
57
57
|
* - `expireOlderThan(days)` — operator-scheduler entry point
|
|
58
58
|
* that prunes saves older than the supplied age.
|
|
59
|
+
* - `exportForCustomer(customer_id)` — DSR export reader: the
|
|
60
|
+
* subject's saved rows.
|
|
61
|
+
* - `eraseForCustomer(customer_id, { dry_run? })` — DSR erasure:
|
|
62
|
+
* deletes the subject's saved rows (pure personalization, no
|
|
63
|
+
* retention basis). Returns the `{ table, deleted }` reader
|
|
64
|
+
* contract.
|
|
59
65
|
*
|
|
60
66
|
* Storage:
|
|
61
67
|
* - `save_for_later` (migration `0041_save_for_later.sql`).
|
|
@@ -641,6 +647,46 @@ function create(opts) {
|
|
|
641
647
|
return { changed: changed };
|
|
642
648
|
},
|
|
643
649
|
|
|
650
|
+
// ---- exportForCustomer / eraseForCustomer (DSR) -----------------
|
|
651
|
+
//
|
|
652
|
+
// Subject-access-request hooks consumed by complianceExport. A
|
|
653
|
+
// save-for-later row keys directly on `customer_id` — there is no
|
|
654
|
+
// hashed-identity branch (every row is account-bound).
|
|
655
|
+
//
|
|
656
|
+
// exportForCustomer(customer_id) — the subject's saved rows. Pure
|
|
657
|
+
// read; capped at MAX_LIMIT so one customer can't unbounded-stream
|
|
658
|
+
// the export.
|
|
659
|
+
exportForCustomer: async function (customerId) {
|
|
660
|
+
var cid = _uuid(customerId, "customer_id");
|
|
661
|
+
var rows = (await query(
|
|
662
|
+
"SELECT * FROM save_for_later WHERE customer_id = ?1 " +
|
|
663
|
+
"ORDER BY saved_at DESC, id DESC LIMIT ?2",
|
|
664
|
+
[cid, MAX_LIMIT],
|
|
665
|
+
)).rows;
|
|
666
|
+
return rows;
|
|
667
|
+
},
|
|
668
|
+
|
|
669
|
+
// eraseForCustomer(customer_id, { dry_run }) — GDPR Art. 17
|
|
670
|
+
// erasure. A save-for-later list is pure personalization with no
|
|
671
|
+
// retention basis, so erasure DELETES every row for the customer
|
|
672
|
+
// (the same effect as `clear`, returned in the complianceExport
|
|
673
|
+
// reader contract shape `{ table, deleted }`). dry_run counts the
|
|
674
|
+
// rows it WOULD remove without mutating. A re-run finds none
|
|
675
|
+
// (idempotent).
|
|
676
|
+
eraseForCustomer: async function (customerId, opts) {
|
|
677
|
+
var cid = _uuid(customerId, "customer_id");
|
|
678
|
+
var dryRun = !!(opts && opts.dry_run);
|
|
679
|
+
if (dryRun) {
|
|
680
|
+
var c = (await query(
|
|
681
|
+
"SELECT COUNT(*) AS n FROM save_for_later WHERE customer_id = ?1",
|
|
682
|
+
[cid],
|
|
683
|
+
)).rows[0];
|
|
684
|
+
return { table: "save_for_later", deleted: c ? Number(c.n) : 0 };
|
|
685
|
+
}
|
|
686
|
+
var r = await query("DELETE FROM save_for_later WHERE customer_id = ?1", [cid]);
|
|
687
|
+
return { table: "save_for_later", deleted: Number(r.rowCount || 0) };
|
|
688
|
+
},
|
|
689
|
+
|
|
644
690
|
// Operator scheduler entry point: remove every save older than
|
|
645
691
|
// the supplied age. Returns the count of removed rows so the
|
|
646
692
|
// cron / scheduled-worker layer can emit a metric.
|