@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.
@@ -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) to assemble
15
- * the bundle every table that keys a row by the customer, so the
16
- * export holds the whole record, not just the order/identity core.
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", "surveys", "reviews", "consentLedger",
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 this method does not (and
1055
- // cannot) re-verify; linking an unverified email would be account
1056
- // takeover. Only touches orders with no owner yet (customer_id IS
1057
- // NULL), so it never re-assigns another customer's order. Returns
1058
- // the count linked.
1059
- linkGuestOrdersByEmailHash: async function (customerId, emailHash) {
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 r = await query(
1065
- "UPDATE orders SET customer_id = ?1, updated_at = ?2 " +
1066
- "WHERE customer_id IS NULL AND customer_email_hash = ?3",
1067
- [customerId, _now(), emailHash],
1068
- );
1069
- return Number(r.rowCount || 0);
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 so a
38
- * cron / dunning job can transition it to `expired`.
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
- * listExpired({ as_of })
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
- if (!input || typeof input !== "object") {
720
- throw new TypeError("quotes.respondToQuote: input object required");
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
- // Compute totals from the priced lines + shipping + tax. Math
754
- // is integer-only; sum is bounded by MAX_LINES *
755
- // MAX_QUANTITY * MAX_UNIT_PRICE_MINOR which fits Number.
756
- var subtotal = 0;
757
- for (var i = 0; i < lines.length; i += 1) {
758
- var qty = Number(lines[i].quantity);
759
- var price = pricesBySku[lines[i].sku];
760
- subtotal += qty * price;
761
- }
762
- var total = subtotal + shipping + tax;
763
- _moneyMinor(total, "total_minor");
764
-
765
- // Update header + each line. The line update sets
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 (out of scope here; the
1183
- // operator updates the row directly to expired via
1184
- // `markExpired` when they own the cron).
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
@@ -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.