@blamejs/blamejs-shop 0.4.26 → 0.4.28
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 +13 -0
- package/lib/admin.js +499 -54
- package/lib/asset-manifest.json +3 -3
- package/lib/checkout.js +263 -19
- package/lib/order.js +69 -3
- package/lib/payment.js +80 -5
- package/lib/quotes.js +216 -82
- package/lib/refund-automation.js +50 -7
- package/lib/security-middleware.js +40 -0
- package/lib/storefront.js +21 -2
- package/package.json +1 -1
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/refund-automation.js
CHANGED
|
@@ -342,6 +342,11 @@ function create(opts) {
|
|
|
342
342
|
var refundPolicyHandle = opts.refundPolicy || null;
|
|
343
343
|
var returnsHandle = opts.returns || null;
|
|
344
344
|
var paymentHandle = opts.payment || null;
|
|
345
|
+
// PayPal adapter — PayPal-captured orders refund against their CAPTURE id
|
|
346
|
+
// through this handle (executeAutoRefund routes by the request's
|
|
347
|
+
// `provider`). Absent, PayPal-provider requests are refused with a clear
|
|
348
|
+
// error instead of dialing Stripe with a PayPal id.
|
|
349
|
+
var paypalHandle = opts.paypal || null;
|
|
345
350
|
var riskProfileHandle = opts.customerRiskProfile || null;
|
|
346
351
|
|
|
347
352
|
// Per-factory monotonic clock. Two decisions written against the
|
|
@@ -664,6 +669,28 @@ function create(opts) {
|
|
|
664
669
|
}
|
|
665
670
|
paymentIntent = input.payment_intent;
|
|
666
671
|
}
|
|
672
|
+
// Which provider captured the order's charge — routes the refund dial.
|
|
673
|
+
// 'stripe' (default — the original surface) refunds the payment_intent
|
|
674
|
+
// through the `payment` handle; 'paypal' refunds the CAPTURE
|
|
675
|
+
// (`paypal_capture_id`, never the PayPal order id) through the `paypal`
|
|
676
|
+
// handle, with the amount named in `currency`. Validated up front so a
|
|
677
|
+
// request can never reach the wrong provider's API.
|
|
678
|
+
var provider = input.provider == null ? "stripe" : input.provider;
|
|
679
|
+
if (provider !== "stripe" && provider !== "paypal") {
|
|
680
|
+
throw new TypeError("refundAutomation.executeAutoRefund: provider must be 'stripe' or 'paypal'");
|
|
681
|
+
}
|
|
682
|
+
var paypalCaptureId = null;
|
|
683
|
+
var paypalCurrency = null;
|
|
684
|
+
if (provider === "paypal") {
|
|
685
|
+
if (typeof input.paypal_capture_id !== "string" || !input.paypal_capture_id.length) {
|
|
686
|
+
throw new TypeError("refundAutomation.executeAutoRefund: paypal_capture_id required when provider is 'paypal'");
|
|
687
|
+
}
|
|
688
|
+
if (typeof input.currency !== "string" || !CURRENCY_RE.test(input.currency)) {
|
|
689
|
+
throw new TypeError("refundAutomation.executeAutoRefund: currency (ISO 4217 alpha) required when provider is 'paypal'");
|
|
690
|
+
}
|
|
691
|
+
paypalCaptureId = input.paypal_capture_id;
|
|
692
|
+
paypalCurrency = input.currency;
|
|
693
|
+
}
|
|
667
694
|
|
|
668
695
|
var verdict = await evaluateForRefundRequest({
|
|
669
696
|
order_id: orderId,
|
|
@@ -685,20 +712,36 @@ function create(opts) {
|
|
|
685
712
|
[id, orderId, customerId, verdict.applied_rule, amount, reason, ts],
|
|
686
713
|
);
|
|
687
714
|
|
|
688
|
-
// Compose
|
|
689
|
-
//
|
|
690
|
-
//
|
|
691
|
-
//
|
|
692
|
-
//
|
|
715
|
+
// Compose the provider refund when the matching handle is wired,
|
|
716
|
+
// routed by `provider`. Absent the matching handle the primitive still
|
|
717
|
+
// records the decision so the operator can drive the actual refund
|
|
718
|
+
// out-of-band — EXCEPT a PayPal request with no PayPal handle, which is
|
|
719
|
+
// refused up front (falling through to the Stripe handle would dial
|
|
720
|
+
// Stripe with PayPal identifiers). The decision row above is already
|
|
721
|
+
// written either way, mirroring the decision-before-payment ordering.
|
|
693
722
|
var paymentResult = null;
|
|
694
|
-
if (
|
|
723
|
+
if (provider === "paypal") {
|
|
724
|
+
if (paypalHandle && typeof paypalHandle.refund === "function") {
|
|
725
|
+
// Each auto-refund decision is its own refund — the decision id as
|
|
726
|
+
// the idempotency key keeps a retry of THIS decision deduplicated at
|
|
727
|
+
// PayPal (the adapter folds it into the PayPal-Request-Id) while two
|
|
728
|
+
// distinct decisions on the same capture stay distinct refunds.
|
|
729
|
+
paymentResult = await paypalHandle.refund({
|
|
730
|
+
capture_id: paypalCaptureId,
|
|
731
|
+
amount_minor: amount,
|
|
732
|
+
currency: paypalCurrency,
|
|
733
|
+
}, "auto-refund:" + id);
|
|
734
|
+
} else if (paymentHandle) {
|
|
735
|
+
throw new TypeError("refundAutomation.executeAutoRefund: provider is 'paypal' but no paypal handle is wired — refusing to refund PayPal identifiers through the Stripe handle");
|
|
736
|
+
}
|
|
737
|
+
} else if (paymentHandle && typeof paymentHandle.refund === "function") {
|
|
695
738
|
var refundInput = {
|
|
696
739
|
amount_minor: amount,
|
|
697
740
|
reason: reason,
|
|
698
741
|
metadata: { order_id: orderId, customer_id: customerId, applied_rule: verdict.applied_rule },
|
|
699
742
|
};
|
|
700
743
|
if (paymentIntent != null) refundInput.payment_intent = paymentIntent;
|
|
701
|
-
paymentResult = await paymentHandle.refund(refundInput);
|
|
744
|
+
paymentResult = await paymentHandle.refund(refundInput, "auto-refund:" + id);
|
|
702
745
|
}
|
|
703
746
|
|
|
704
747
|
return {
|
|
@@ -77,6 +77,14 @@ var WEBHOOK_PATHS = [
|
|
|
77
77
|
// could wedge the health signal.
|
|
78
78
|
var HEALTH_PATH = "/_/health";
|
|
79
79
|
|
|
80
|
+
// Per-client-IP budget on POST /api/webhooks/paypal (see the limiter in
|
|
81
|
+
// mountRouteGuards). Generous against PayPal's real delivery cadence —
|
|
82
|
+
// redelivery is ~25 attempts per event spread over days, so even a
|
|
83
|
+
// post-downtime backlog flush of distinct events sits far under this —
|
|
84
|
+
// while bounding how many verify-webhook-signature dials a spammer can
|
|
85
|
+
// force per minute.
|
|
86
|
+
var PAYPAL_WEBHOOK_BUDGET_PER_MINUTE = 120;
|
|
87
|
+
|
|
80
88
|
// Worker→container internal endpoints — machine-to-machine POSTs over
|
|
81
89
|
// the Cloudflare service binding (cron ticks + the InventoryLock DO's
|
|
82
90
|
// low-stock event), each authenticated FIRST thing in its handler by a
|
|
@@ -101,6 +109,7 @@ var INTERNAL_BRIDGE_PATHS = [
|
|
|
101
109
|
"/_/campaign-send-tick",
|
|
102
110
|
"/_/customer-portal-expire",
|
|
103
111
|
"/_/stale-order-reap",
|
|
112
|
+
"/_/quote-expiry-tick",
|
|
104
113
|
];
|
|
105
114
|
|
|
106
115
|
// Public well-known paths fetched by third-party verification crawlers
|
|
@@ -744,6 +753,36 @@ function mountRouteGuards(r) {
|
|
|
744
753
|
return clientKey(req) + "|" + (req.pathname || req.url || "/");
|
|
745
754
|
},
|
|
746
755
|
});
|
|
756
|
+
// --- PayPal webhook per-IP budget -----------------------------------
|
|
757
|
+
//
|
|
758
|
+
// The webhook paths are exempt from the global + tight limiters above
|
|
759
|
+
// (a processor's server-to-server POST is unthrottleable by a human
|
|
760
|
+
// budget), but /api/webhooks/paypal is uniquely expensive to probe:
|
|
761
|
+
// verification is a server-to-server dial to PayPal's
|
|
762
|
+
// verify-webhook-signature API, so every header-complete spam POST costs
|
|
763
|
+
// an outbound request. The adapter's verify dial rides its own circuit
|
|
764
|
+
// (never the payment circuit — lib/payment.js), so spam can't fast-fail
|
|
765
|
+
// checkouts; this budget bounds the outbound dial volume itself. Sized
|
|
766
|
+
// for PayPal's real delivery shape: legitimate redelivery after downtime
|
|
767
|
+
// is ~25 attempts per event spread over days, so even a backlog flush of
|
|
768
|
+
// many distinct events sits far under this per-minute ceiling — and a
|
|
769
|
+
// clipped delivery is never lost: the limiter answers 429, PayPal treats
|
|
770
|
+
// any non-2xx as retry-later and redelivers. Stripe's webhook keeps no
|
|
771
|
+
// budget — its verification is a local HMAC (no dial to amplify).
|
|
772
|
+
var PAYPAL_WEBHOOK_PATH = "/api/webhooks/paypal";
|
|
773
|
+
var paypalWebhookLimiter = b.middleware.rateLimit({
|
|
774
|
+
backend: "memory",
|
|
775
|
+
algorithm: "fixed-window",
|
|
776
|
+
max: PAYPAL_WEBHOOK_BUDGET_PER_MINUTE,
|
|
777
|
+
windowMs: C.TIME.minutes(1),
|
|
778
|
+
keyFn: clientKey,
|
|
779
|
+
});
|
|
780
|
+
r.use(function paypalWebhookRateGuard(req, res, next) {
|
|
781
|
+
var pathname = req.pathname || req.url || "/";
|
|
782
|
+
if (pathname !== PAYPAL_WEBHOOK_PATH) return next();
|
|
783
|
+
return paypalWebhookLimiter(req, res, next);
|
|
784
|
+
});
|
|
785
|
+
|
|
747
786
|
r.use(function tightRateGuard(req, res, next) {
|
|
748
787
|
var pathname = req.pathname || req.url || "/";
|
|
749
788
|
// Never throttle the webhook or health paths.
|
|
@@ -780,6 +819,7 @@ module.exports = {
|
|
|
780
819
|
CSP_HOSTS: CSP_HOSTS,
|
|
781
820
|
WEBHOOK_PATHS: WEBHOOK_PATHS,
|
|
782
821
|
HEALTH_PATH: HEALTH_PATH,
|
|
822
|
+
PAYPAL_WEBHOOK_BUDGET_PER_MINUTE: PAYPAL_WEBHOOK_BUDGET_PER_MINUTE,
|
|
783
823
|
TIGHT_PREFIXES: TIGHT_PREFIXES,
|
|
784
824
|
EDGE_POST_PATHS: EDGE_POST_PATHS,
|
|
785
825
|
PUBLIC_WELL_KNOWN_PATHS: PUBLIC_WELL_KNOWN_PATHS,
|
package/lib/storefront.js
CHANGED
|
@@ -10930,7 +10930,13 @@ function renderQuotePage(opts) {
|
|
|
10930
10930
|
var lineRows = (q.lines || []).map(function (l) {
|
|
10931
10931
|
var unit = l.unit_price_minor == null ? "—" : pricing.format(l.unit_price_minor, l.currency || currency);
|
|
10932
10932
|
var total = l.unit_price_minor == null ? "—" : pricing.format(l.unit_price_minor * l.quantity, l.currency || currency);
|
|
10933
|
-
|
|
10933
|
+
// Per-line note — free text captured on the RFQ line, rendered under
|
|
10934
|
+
// the item so the priced offer reads against what was asked for.
|
|
10935
|
+
// Escaped like every other free-text field on this page.
|
|
10936
|
+
var note = l.notes
|
|
10937
|
+
? "<div class=\"quote-line__note\">" + esc(l.notes) + "</div>"
|
|
10938
|
+
: "";
|
|
10939
|
+
return "<tr><td>" + esc(l.sku) + note + "</td><td class=\"num\">" + esc(String(l.quantity)) + "</td>" +
|
|
10934
10940
|
"<td class=\"num\">" + esc(unit) + "</td><td class=\"num\">" + esc(total) + "</td></tr>";
|
|
10935
10941
|
}).join("");
|
|
10936
10942
|
var rowsHtml =
|
|
@@ -11019,11 +11025,17 @@ function renderQuoteList(opts) {
|
|
|
11019
11025
|
var currency = q.currency || "USD";
|
|
11020
11026
|
var total = q.total_minor == null ? "Awaiting price" : pricing.format(q.total_minor, currency);
|
|
11021
11027
|
var statusLabel = q.status === "responded" ? "ready to review" : q.status;
|
|
11028
|
+
// Make the acceptance window visible from the list — a plain
|
|
11029
|
+
// server-rendered date on quotes that are still open to accept.
|
|
11030
|
+
var valid = (q.status === "responded" && q.valid_until)
|
|
11031
|
+
? "<span class=\"quote-list__valid\">Valid until " + esc(new Date(Number(q.valid_until)).toUTCString()) + "</span>"
|
|
11032
|
+
: "";
|
|
11022
11033
|
return "<li class=\"quote-list__item\">" +
|
|
11023
11034
|
"<a href=\"/account/quotes/" + esc(q.id) + "\">" +
|
|
11024
11035
|
"<span class=\"quote-list__id\">Quote " + esc(String(q.id).slice(0, 8)) + "</span>" +
|
|
11025
11036
|
"<span class=\"quote-list__status\">" + esc(statusLabel) + "</span>" +
|
|
11026
11037
|
"<span class=\"quote-list__total\">" + esc(total) + "</span>" +
|
|
11038
|
+
valid +
|
|
11027
11039
|
"</a></li>";
|
|
11028
11040
|
}).join("");
|
|
11029
11041
|
body =
|
|
@@ -14964,6 +14976,10 @@ function mount(router, deps) {
|
|
|
14964
14976
|
selected_shipping_id: body.selected_shipping_id || defaultShipId || "std",
|
|
14965
14977
|
customer: { email: body.email, name: body.name },
|
|
14966
14978
|
gift_card_code: body.gift_card_code || undefined,
|
|
14979
|
+
// Loyalty points the signed-in shopper asked to spend — same
|
|
14980
|
+
// parse + credit handling as the card-form confirm, so both
|
|
14981
|
+
// payment buttons honor identical credits.
|
|
14982
|
+
loyalty_redeem_points: _parseRedeemPoints(body.loyalty_redeem_points),
|
|
14967
14983
|
codes: ppCodes.length ? ppCodes : undefined,
|
|
14968
14984
|
idempotency_key: "paypal:" + c.id + ":" + b.uuid.v7(),
|
|
14969
14985
|
return_url: body.return_url || undefined,
|
|
@@ -14979,7 +14995,10 @@ function mount(router, deps) {
|
|
|
14979
14995
|
return _json(200, { id: created.paypal_order_id, order_id: created.order.id });
|
|
14980
14996
|
} catch (e) {
|
|
14981
14997
|
var ecode = (e && typeof e.code === "string") ? e.code : "";
|
|
14982
|
-
|
|
14998
|
+
// Customer-correctable credit errors (bad gift-card code,
|
|
14999
|
+
// insufficient loyalty balance, points on a guest cart) are 400s
|
|
15000
|
+
// whose message the button surfaces inline.
|
|
15001
|
+
var gcErr = ecode.indexOf("GIFTCARD_") === 0 || ecode.indexOf("LOYALTY_") === 0;
|
|
14983
15002
|
// Out-of-stock is a 409 (conflict) carrying the friendly per-line
|
|
14984
15003
|
// message so the PayPal button surfaces it; nothing was charged
|
|
14985
15004
|
// and the mid-confirm holds were already released.
|
package/package.json
CHANGED