@blamejs/blamejs-shop 0.4.18 → 0.4.20
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/lib/admin.js +60 -18
- package/lib/asset-manifest.json +1 -1
- package/lib/cart.js +44 -0
- package/lib/checkout.js +188 -51
- package/lib/click-and-collect.js +30 -15
- package/lib/giftcards.js +60 -0
- package/lib/order.js +111 -1
- package/lib/returns.js +45 -5
- package/lib/storefront.js +37 -0
- package/lib/webhooks.js +55 -5
- package/lib/wishlist-alerts.js +182 -43
- package/lib/wishlist-digest.js +13 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.4.x
|
|
10
10
|
|
|
11
|
+
- v0.4.20 (2026-06-06) — **Payment integrity: refunds, gift-card balances, and checkout submission are race-proof, and abandoned checkouts return gift-card funds.** A money-path hardening release. Concurrent refund clicks on the same return can no longer reach the payment provider twice. A gift card that part-paid a checkout gets its balance back when the order is cancelled, fails payment, or is reaped as stale. Double-submitting checkout can no longer create two charges and two orders. Return requests are refused server-side on orders that aren't in a returnable state, the refund confirmation screen shows the currency the provider will actually refund, and a buy-online-pickup-in-store order now reaches its delivered state when picked up. **Fixed:** *A return can only be refunded once, even under concurrent requests* — Two simultaneous refund requests for the same approved return could both pass the status check and both reach the payment provider, each with a fresh idempotency key — a double refund. The refund now claims the return atomically before the provider is called (the second request answers 409), the provider call uses a key derived from the return itself so even a retry collapses into the same refund, and a provider failure releases the claim so the refund can be retried. · *Gift-card funds come back when a part-paid checkout dies* — When a gift card partially covered a checkout and the order was then cancelled, failed payment, or sat abandoned until reaped, the card's debit was never returned — the balance was permanently gone. Redemptions are now reversed on those order transitions: the card balance is restored (a fully drained card is reactivated), the reversal is idempotent, and it rides the same order lifecycle that releases inventory holds. · *Double-submitting checkout creates one charge and one order* — Two concurrent checkout submissions could both pass the cart-status read and each create a payment intent and an order. The cart is now claimed atomically as the single gate — the second submission is redirected back to the cart — and the payment-provider idempotency key is derived from the cart, so even a duplicate that slipped through would collapse into one charge on the provider's side. A failure before the order exists releases the claim so the customer can retry. · *Return requests are validated server-side* — The return-request form and its submission are now refused on orders that are not in a returnable state — a refunded, cancelled, or unpaid order answers with a clear message instead of opening a return that could feed a second refund downstream. Previously only the button's visibility enforced this. · *A failed gift-card settlement no longer strands a completed payment* — If recording a gift card's redemption failed after the payment had already succeeded and the order existed, the checkout aborted mid-way — leaving a paid order with a cart that never converted. The settlement step now completes the order regardless and surfaces the failure for follow-up instead of stranding the purchase. · *The refund confirmation shows the currency the provider refunds* — The confirmation screen displayed the return's approved currency, but the provider refunds in the original charge's currency. The screen now reads the same source the refund uses. · *Pickup orders reach their delivered state* — Marking a pickup order as picked up drove an order transition that was only legal for shipped orders, and the failure was silently swallowed — the pickup schedule said picked up while the order stayed in its paid state. The order lifecycle now has a pickup-completion transition, so a picked-up order genuinely lands in delivered and downstream reporting sees it.
|
|
12
|
+
|
|
13
|
+
- v0.4.19 (2026-06-06) — **Wishlist alerts fire once per event instead of repeating daily, stale digests catch up with a single email, and webhook delivery stops throttling itself.** A set of notification and delivery fixes. Back-in-stock and price-drop wishlist alerts now fire only when something actually changes — a steadily in-stock item no longer re-alerts every day. A wishlist digest whose schedule fell behind sends one catch-up email instead of one per minute until current. Outbound webhook delivery no longer counts its own rate-limit refusals against the limit (which kept the throttle tripped once hit), retrying an exhausted delivery no longer duplicates its dead-letter record, and the blog RSS feed renders correctly when a post title or body contains dollar-sign sequences. **Fixed:** *Back-in-stock and price-drop alerts fire on the change, not the state* — The wishlist alert sweep evaluated the current state of each item, so anything in stock re-alerted every wishlister on every sweep — the daily dedupe window just made the flood daily. Alerts now track the last-observed state per customer and item: back-in-stock fires only on an out-of-stock-to-in-stock transition, price-drop only when the price falls below the level already alerted, and a recovered price re-arms the alert. A steadily in-stock item never re-fires. The weekly cap and dedupe are also enforced atomically, so two overlapping sweeps can't double-send past the cap. · *A stale digest enrollment catches up with one email* — A wishlist digest whose next-send time had fallen far behind advanced one period per scheduler tick and sent an email each time — a subscriber could receive dozens of digests in an hour while the schedule caught up. Catching up now snaps the schedule to the present and sends at most one digest. · *Webhook delivery rate limiting no longer feeds on its own refusals* — The outbound webhook rate-limit gate counted its own refusal records toward the limit, so once an endpoint tripped the throttle it could stay tripped indefinitely, and every suppressed attempt persisted another row. Refusals are no longer counted by the gate and collapse to a single record per endpoint per window. · *Retrying an exhausted webhook delivery is idempotent* — Manually retrying a delivery that had already exhausted its attempts inserted a fresh dead-letter record on every click. Retry of an exhausted or already-delivered delivery now answers as a clean no-op, and the dead-letter table enforces one record per delivery. · *The RSS feed renders posts containing dollar-sign sequences* — The feed assembled items with a plain string replacement, which gives `$` sequences special meaning — a post title or body containing a dollar sign followed by a backtick or ampersand could corrupt the entire feed XML. The feed now uses the same literal splice the page renderers use. The feed's author field also no longer exposes an internal identifier; it carries the shop name, matching the blog pages.
|
|
14
|
+
|
|
11
15
|
- v0.4.18 (2026-06-06) — **Staff accounts: per-operator credentials with owner, manager, and viewer roles — the shared admin key becomes break-glass.** The admin console gains multi-operator support. Create staff accounts at the new Operators screen, each with its own password and optional API key, and assign a least-privilege role: owner (everything, including operator management), manager (catalog, orders, customers, marketing), or viewer (read-only). Role enforcement happens at the admin write layer on every state-changing request — a viewer is refused the write itself, not just the menu link. The shared ADMIN_API_KEY keeps working as the bootstrap and break-glass owner credential, so upgrading can never lock a store out; once staff accounts exist, treat it like a recovery key. **Added:** *Operators console with per-operator credentials* — `/admin/operators` creates and disables staff accounts. Each operator gets their own password (Argon2id) and optionally a personal API key for the JSON surface, shown once at creation. The screen lists who created each operator and when; disabling one takes effect on their very next request — the signed-in session re-reads the live account row, so there is no revocation lag. · *Owner / manager / viewer roles, enforced on the write itself* — Every admin mutation passes a single permission gate keyed to the action being performed: owner holds every permission including operator management and settings; manager covers catalog, orders, customers, and marketing; viewer holds none — every POST, PUT, and DELETE is refused with a 403, regardless of how the request arrives (browser form or bearer JSON). Read screens remain available to any authenticated operator. Role-denied attempts are recorded in the audit chain alongside every operator-management action. · *Bootstrap and break-glass: the shared key still works* — `ADMIN_API_KEY` resolves to the owner role exactly as before — a store with no operator rows behaves identically to the previous release, and the first staff account is created while signed in with the shared key. Unknown sign-in attempts burn a real password verification against a fixed dummy hash, so response timing does not reveal whether an account exists.
|
|
12
16
|
|
|
13
17
|
- v0.4.17 (2026-06-06) — **Email campaigns: consent-gated broadcasts to the newsletter list, with one-click unsubscribe and a send ledger that never re-mails.** The admin console gains an Email campaigns screen: author a broadcast, target a mailing audience, preview it, test-send to yourself, and send. Consent is the design center — the recipient set resolves at send time from the newsletter list (the only place a deliverable address exists; customer accounts keep only an email hash), every recipient is re-checked against the unsubscribe flag and the marketing suppression list at the moment their message sends, and every message carries one-click unsubscribe headers plus an in-body link. Sending drains in rate-bounded batches on the scheduled tick, one bad address never aborts a campaign, and a per-recipient ledger makes a resumed send never deliver twice. **Added:** *Campaign console: author, preview, test-send, send* — `/admin/campaigns` lists campaigns with status and delivered / failed / skipped counts, and a campaign editor takes a subject and a Markdown-or-plaintext body. The body renders escape-by-default with an https-only link gate — the same rendering discipline as the blog — so markup or script in a body lands as inert text in the inbox and in the console list. Preview and an operator-addressed test send are available before any real send. · *Consent resolved per recipient, at the send moment* — The reachable-recipient count shown before sending is computed live from the newsletter list, excluding anyone unsubscribed or on the marketing suppression list — and the same two checks run again for each recipient at the moment their message sends, so someone who unsubscribes mid-broadcast is skipped. Customer accounts keep only an email hash by design, so the newsletter list is the only deliverable-address source and the console says exactly how many recipients are reachable. · *One-click unsubscribe on every broadcast* — Every campaign message carries the RFC 8058 `List-Unsubscribe` / `List-Unsubscribe-Post` headers (so mail clients show their native unsubscribe control), an RFC 2919 `List-Id`, and an in-body unsubscribe link through the existing newsletter opt-out flow. · *Rate-bounded, resumable sending* — Sends drain in batches on the scheduled tick with a reserved hourly budget, so a large campaign spreads out rather than bursting. Each recipient's outcome lands in a send ledger keyed uniquely per campaign and recipient — a send interrupted by the rate budget or a restart resumes where it left off and never mails anyone twice. A failing address is counted and shown, never fatal to the rest of the campaign.
|
package/lib/admin.js
CHANGED
|
@@ -4622,26 +4622,60 @@ function mount(router, deps) {
|
|
|
4622
4622
|
function (id, body) { return returns.markReceived(id, { operator_notes: body.operator_notes || undefined }); },
|
|
4623
4623
|
));
|
|
4624
4624
|
|
|
4625
|
-
// Issue the provider refund for an RMA (when a captured intent exists)
|
|
4626
|
-
//
|
|
4627
|
-
//
|
|
4628
|
-
//
|
|
4629
|
-
//
|
|
4630
|
-
//
|
|
4625
|
+
// Issue the provider refund for an RMA (when a captured intent exists).
|
|
4626
|
+
//
|
|
4627
|
+
// Two guards stop a concurrent double-click / two-operator race from
|
|
4628
|
+
// refunding the customer twice:
|
|
4629
|
+
//
|
|
4630
|
+
// 1. The RMA is CLAIMED atomically (received → refunded) BEFORE the
|
|
4631
|
+
// provider call. returns.refund's conditional UPDATE matches the
|
|
4632
|
+
// row for exactly one racer; the loser gets RMA_TRANSITION_REFUSED
|
|
4633
|
+
// (mapped to 409) and never reaches the provider. The claim runs
|
|
4634
|
+
// first so a winner holds the row while money moves.
|
|
4635
|
+
// 2. The Stripe idempotency key is DETERMINISTIC in the return id
|
|
4636
|
+
// ("rma-refund:<rma.id>") — no per-request uuid. Even if a logic
|
|
4637
|
+
// regression let two calls reach the provider, Stripe collapses
|
|
4638
|
+
// them onto one Refund (same key → same result), so the customer is
|
|
4639
|
+
// never paid back twice. The deterministic key is also stable
|
|
4640
|
+
// across an operator retry of the SAME refund.
|
|
4641
|
+
//
|
|
4642
|
+
// On a provider failure the claim is RELEASED (refunded → received) so a
|
|
4643
|
+
// retry can run — the RMA never strands in `refunded` with no money
|
|
4644
|
+
// moved. The refund amount is the RMA's approved refund_amount_minor
|
|
4645
|
+
// (set at approve time); absent one, the provider issues a full refund
|
|
4646
|
+
// of the intent IN THE ORDER'S CHARGE CURRENCY (a Stripe refund against a
|
|
4647
|
+
// PaymentIntent has no currency of its own — it always returns the
|
|
4648
|
+
// charge currency, which is order2.currency).
|
|
4631
4649
|
async function _rmaProviderRefund(rma, order2, body) {
|
|
4632
|
-
|
|
4633
|
-
|
|
4634
|
-
|
|
4635
|
-
|
|
4636
|
-
|
|
4637
|
-
|
|
4638
|
-
|
|
4639
|
-
var updated = await returns.refund(rma.id, {
|
|
4650
|
+
// Claim the RMA first. A lost claim (RMA_TRANSITION_REFUSED) bubbles
|
|
4651
|
+
// to the route, which maps it to 409 via _returnsClientError — the
|
|
4652
|
+
// loser of a concurrent refund never touches the provider. The note
|
|
4653
|
+
// stamped at claim time is the operator's own text when supplied, else
|
|
4654
|
+
// a stable marker; the live provider refund id is on the audit row +
|
|
4655
|
+
// the JSON response, so it stays reconcilable without a second write.
|
|
4656
|
+
var claimed = await returns.refund(rma.id, {
|
|
4640
4657
|
operator_notes: (body.operator_notes && body.operator_notes.length)
|
|
4641
4658
|
? body.operator_notes
|
|
4642
|
-
:
|
|
4659
|
+
: "provider refund issued",
|
|
4643
4660
|
});
|
|
4644
|
-
|
|
4661
|
+
var idem = "rma-refund:" + rma.id;
|
|
4662
|
+
var refund;
|
|
4663
|
+
try {
|
|
4664
|
+
refund = await payment.refund({
|
|
4665
|
+
payment_intent: order2.payment_intent_id,
|
|
4666
|
+
amount_minor: (rma.refund_amount_minor != null && rma.refund_amount_minor > 0) ? rma.refund_amount_minor : undefined,
|
|
4667
|
+
reason: "requested_by_customer",
|
|
4668
|
+
metadata: { order_id: order2.id, rma_id: rma.id, rma_code: rma.rma_code || "" },
|
|
4669
|
+
}, idem);
|
|
4670
|
+
} catch (e) {
|
|
4671
|
+
// Provider call failed — release the claim so the operator can retry
|
|
4672
|
+
// a transient failure. A release failure can't be recovered here, so
|
|
4673
|
+
// surface the original provider error (the operator-actionable one).
|
|
4674
|
+
try { await returns.releaseRefundClaim(rma.id); }
|
|
4675
|
+
catch (_releaseErr) { /* drop-silent — the original provider error below is the actionable one */ }
|
|
4676
|
+
throw e;
|
|
4677
|
+
}
|
|
4678
|
+
return { refund: refund, rma: claimed };
|
|
4645
4679
|
}
|
|
4646
4680
|
|
|
4647
4681
|
// Browser confirmation interstitial for a provider-backed RMA refund —
|
|
@@ -4665,9 +4699,17 @@ function mount(router, deps) {
|
|
|
4665
4699
|
// record-only form handles it.
|
|
4666
4700
|
return _redirect(res, "/admin/returns/" + enc + "?err=1");
|
|
4667
4701
|
}
|
|
4702
|
+
// Format the confirm amount in the ORDER'S CHARGE CURRENCY, not the
|
|
4703
|
+
// RMA's approved refund_currency. A Stripe refund against a captured
|
|
4704
|
+
// PaymentIntent carries no currency of its own — it always settles in
|
|
4705
|
+
// the charge currency. The two differ on a multi-currency shop (an
|
|
4706
|
+
// RMA approved with the default USD currency against a EUR order), and
|
|
4707
|
+
// showing the RMA currency would promise the operator a different
|
|
4708
|
+
// figure than the provider actually refunds.
|
|
4709
|
+
var refundCcy = rmaCtx.order.currency;
|
|
4668
4710
|
var amount = (rma.refund_amount_minor != null && rma.refund_amount_minor > 0)
|
|
4669
|
-
? pricing.format(rma.refund_amount_minor,
|
|
4670
|
-
: pricing.format(rmaCtx.order.grand_total_minor,
|
|
4711
|
+
? pricing.format(rma.refund_amount_minor, refundCcy)
|
|
4712
|
+
: pricing.format(rmaCtx.order.grand_total_minor, refundCcy);
|
|
4671
4713
|
_sendHtml(res, 200, renderAdminConfirm({
|
|
4672
4714
|
shop_name: deps.shop_name, nav_available: navAvailable, active: "returns",
|
|
4673
4715
|
heading: "Refund return " + _htmlEscape(rma.rma_code || rma.id.slice(0, 8)) + "?",
|
package/lib/asset-manifest.json
CHANGED
package/lib/cart.js
CHANGED
|
@@ -354,6 +354,50 @@ function create(opts) {
|
|
|
354
354
|
return (await query("SELECT * FROM carts WHERE id = ?1", [cartId])).rows[0];
|
|
355
355
|
},
|
|
356
356
|
|
|
357
|
+
// Atomically claim an ACTIVE cart for checkout. The `AND status =
|
|
358
|
+
// 'active'` predicate is the serialization point: on D1 a single UPDATE
|
|
359
|
+
// is atomic, so when two concurrent POST /checkout requests race the same
|
|
360
|
+
// cart (a double-click, two tabs) exactly ONE flips it to 'converted' and
|
|
361
|
+
// matches the row; the other matches zero rows and loses. The winner runs
|
|
362
|
+
// the (single) charge + order; the loser is told the checkout is already
|
|
363
|
+
// in progress and never creates a second PaymentIntent or order.
|
|
364
|
+
//
|
|
365
|
+
// Returns `{ claimed: true, cart }` for the winner, `{ claimed: false,
|
|
366
|
+
// cart }` for the loser (cart is the post-claim row so the caller can
|
|
367
|
+
// redirect to its order), or `{ claimed: false, cart: null }` when the
|
|
368
|
+
// cart id doesn't exist at all. A non-active cart (already converted /
|
|
369
|
+
// abandoned) loses the claim — the existing checkout owns it.
|
|
370
|
+
claimForCheckout: async function (cartId) {
|
|
371
|
+
_uuid(cartId, "cart_id");
|
|
372
|
+
var ts = _now();
|
|
373
|
+
var r = await query(
|
|
374
|
+
"UPDATE carts SET status = 'converted', updated_at = ?1 WHERE id = ?2 AND status = 'active'",
|
|
375
|
+
[ts, cartId],
|
|
376
|
+
);
|
|
377
|
+
var cart = (await query("SELECT * FROM carts WHERE id = ?1", [cartId])).rows[0] || null;
|
|
378
|
+
return { claimed: Number(r.rowCount || 0) === 1, cart: cart };
|
|
379
|
+
},
|
|
380
|
+
|
|
381
|
+
// Release a checkout claim back to 'active' so the buyer can retry. The
|
|
382
|
+
// checkout flow claims the cart (claimForCheckout) BEFORE the charge so a
|
|
383
|
+
// concurrent racer is locked out; if the charge / order creation then
|
|
384
|
+
// throws BEFORE the order exists, the claimer releases the cart so the
|
|
385
|
+
// shopper isn't stranded on a permanently-'converted' cart with no order
|
|
386
|
+
// to show. Atomic + self-targeting: `AND status = 'converted'` makes a
|
|
387
|
+
// double-release a no-op, and only the claimer (which set 'converted')
|
|
388
|
+
// ever calls this, so it can't resurrect a cart a DIFFERENT completed
|
|
389
|
+
// checkout legitimately converted. Returns true when the cart was
|
|
390
|
+
// released, false when there was nothing to release.
|
|
391
|
+
releaseCheckoutClaim: async function (cartId) {
|
|
392
|
+
_uuid(cartId, "cart_id");
|
|
393
|
+
var ts = _now();
|
|
394
|
+
var r = await query(
|
|
395
|
+
"UPDATE carts SET status = 'active', updated_at = ?1 WHERE id = ?2 AND status = 'converted'",
|
|
396
|
+
[ts, cartId],
|
|
397
|
+
);
|
|
398
|
+
return Number(r.rowCount || 0) === 1;
|
|
399
|
+
},
|
|
400
|
+
|
|
357
401
|
// ---- abandoned-cart visibility (operator dashboard) ---------------
|
|
358
402
|
//
|
|
359
403
|
// A cart is "abandoned" for dashboard purposes when it is still
|
package/lib/checkout.js
CHANGED
|
@@ -413,6 +413,15 @@ function create(deps) {
|
|
|
413
413
|
// the order id. Called once per order, immediately after the order
|
|
414
414
|
// row exists, so a card is never burned for an order that failed to
|
|
415
415
|
// create.
|
|
416
|
+
//
|
|
417
|
+
// The ledger debit is BEST-EFFORT: the card row's balance (decremented by
|
|
418
|
+
// redeem above) is the authoritative spendable balance, and the ledger is
|
|
419
|
+
// the audit trail. A ledger write failure (e.g. an unmigrated ledger table,
|
|
420
|
+
// or a ledger view that lags the card row) must not throw out of the
|
|
421
|
+
// post-create redeem path and strand the already-created order + its live
|
|
422
|
+
// PaymentIntent. The card-row debit already happened; the ledger row is
|
|
423
|
+
// reconcilable from the redemption record. Same posture the admin issue
|
|
424
|
+
// route takes when seeding the opening credit.
|
|
416
425
|
async function _redeemGiftCard(resolved, orderId) {
|
|
417
426
|
var redemption = await giftcards.redeem({
|
|
418
427
|
code: resolved.code_plain,
|
|
@@ -420,15 +429,58 @@ function create(deps) {
|
|
|
420
429
|
amount_minor: resolved.applied_minor,
|
|
421
430
|
});
|
|
422
431
|
if (giftCardLedger) {
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
432
|
+
try {
|
|
433
|
+
await giftCardLedger.debit({
|
|
434
|
+
gift_card_id: resolved.card.id,
|
|
435
|
+
order_id: orderId,
|
|
436
|
+
amount_minor: resolved.applied_minor,
|
|
437
|
+
});
|
|
438
|
+
} catch (ledgerErr) {
|
|
439
|
+
// Surface for reconciliation, but never let the audit-row write undo
|
|
440
|
+
// a card debit that already landed or strand the placed order.
|
|
441
|
+
try {
|
|
442
|
+
b.audit.safeEmit({
|
|
443
|
+
action: "checkout.giftcard.ledger_debit.error",
|
|
444
|
+
outcome: "failure",
|
|
445
|
+
metadata: {
|
|
446
|
+
gift_card_id: resolved.card.id,
|
|
447
|
+
order_id: orderId,
|
|
448
|
+
amount_minor: resolved.applied_minor,
|
|
449
|
+
message: (ledgerErr && ledgerErr.message) || String(ledgerErr),
|
|
450
|
+
},
|
|
451
|
+
});
|
|
452
|
+
} catch (_auditErr) { /* drop-silent — the redemption record is the durable trail */ }
|
|
453
|
+
}
|
|
428
454
|
}
|
|
429
455
|
return redemption;
|
|
430
456
|
}
|
|
431
457
|
|
|
458
|
+
// Run a post-create credit redemption (gift-card / loyalty burn) so a
|
|
459
|
+
// failure can NEVER strand the already-created order + its live
|
|
460
|
+
// PaymentIntent. The order row and the PaymentIntent exist by the time
|
|
461
|
+
// this runs, and the buyer will pay the credit-reduced amount — so a
|
|
462
|
+
// throw out of the redeem (a card that raced to zero, a transient DB
|
|
463
|
+
// error) must not bubble up and leave the cart un-converted with an
|
|
464
|
+
// orphaned charge. The failure is captured to the audit sink for operator
|
|
465
|
+
// reconciliation (the credit reduced the charge but the debit didn't
|
|
466
|
+
// land, so the operator settles the card / points by hand) and the flow
|
|
467
|
+
// continues. `fn` returns the redeem promise (or null when there's no
|
|
468
|
+
// credit of that kind to burn).
|
|
469
|
+
async function _settleCreditPostCreate(kind, orderId, fn) {
|
|
470
|
+
try {
|
|
471
|
+
return await fn();
|
|
472
|
+
} catch (e) {
|
|
473
|
+
try {
|
|
474
|
+
b.audit.safeEmit({
|
|
475
|
+
action: "checkout." + kind + ".redeem.error",
|
|
476
|
+
outcome: "failure",
|
|
477
|
+
metadata: { order_id: orderId, message: (e && e.message) || String(e) },
|
|
478
|
+
});
|
|
479
|
+
} catch (_auditErr) { /* drop-silent — never let the audit write mask the original failure */ }
|
|
480
|
+
return null;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
432
484
|
// Resolve an optional loyalty points credit against a priced quote.
|
|
433
485
|
// `points` is the number of points the customer asked to spend;
|
|
434
486
|
// `customerId` is the signed-in customer's UUID (a guest cart has
|
|
@@ -693,14 +745,23 @@ function create(deps) {
|
|
|
693
745
|
|
|
694
746
|
// Compose a quote from a cart + ship-to + (optional) selected
|
|
695
747
|
// shipping service. Pure read — no DB writes.
|
|
696
|
-
async function _buildQuote(input) {
|
|
748
|
+
async function _buildQuote(input, qOpts) {
|
|
697
749
|
if (!input || typeof input !== "object") throw new TypeError("checkout.quote: input required");
|
|
698
750
|
_uuid(input.cart_id, "cart_id");
|
|
699
751
|
_shipTo(input.ship_to);
|
|
752
|
+
qOpts = qOpts || {};
|
|
700
753
|
|
|
701
754
|
var c = await cart.get(input.cart_id);
|
|
702
755
|
if (!c) throw new TypeError("checkout: cart " + input.cart_id + " not found");
|
|
703
|
-
|
|
756
|
+
// A live preview (quote) requires the cart to be active. confirm() claims
|
|
757
|
+
// the cart to 'converted' BEFORE building the quote (the single-charge
|
|
758
|
+
// gate against a double-submit), so on that path the claimed status is
|
|
759
|
+
// permitted via qOpts.allowClaimed — the cart confirm is settling IS
|
|
760
|
+
// 'converted', and rejecting it here would break the very flow that owns
|
|
761
|
+
// the conversion.
|
|
762
|
+
if (c.status !== "active" && !(qOpts.allowClaimed && c.status === "converted")) {
|
|
763
|
+
throw new TypeError("checkout: cart status is " + c.status + ", cannot quote");
|
|
764
|
+
}
|
|
704
765
|
var rawLines = await cart.listLines(c.id);
|
|
705
766
|
if (rawLines.length === 0) throw new TypeError("checkout: cart is empty");
|
|
706
767
|
// Reapply the active quantity-break for each line at confirm time —
|
|
@@ -816,43 +877,84 @@ function create(deps) {
|
|
|
816
877
|
throw new TypeError("checkout: customer.name — Enter a name of 120 characters or fewer.");
|
|
817
878
|
}
|
|
818
879
|
if (!input.selected_shipping_id) throw new TypeError("checkout.confirm: selected_shipping_id required");
|
|
819
|
-
var idempotencyKey = input.idempotency_key;
|
|
820
|
-
if (typeof idempotencyKey !== "string" || idempotencyKey.length < 8) {
|
|
821
|
-
throw new TypeError("checkout.confirm: idempotency_key (≥8 chars) required");
|
|
822
|
-
}
|
|
823
880
|
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
881
|
+
// Atomic single-charge gate against a double-submit. Two concurrent
|
|
882
|
+
// POST /checkout requests for the same cart (a double-click, two tabs,
|
|
883
|
+
// a retried form post) must NOT each create a PaymentIntent + order.
|
|
884
|
+
// claimForCheckout flips the cart 'active' → 'converted' in one atomic
|
|
885
|
+
// UPDATE; exactly one racer matches the row and proceeds, the other
|
|
886
|
+
// loses the claim and is told the checkout is already in progress —
|
|
887
|
+
// never a second charge. A non-active cart (already converting /
|
|
888
|
+
// converted / abandoned) loses for the same reason. The claim runs
|
|
889
|
+
// BEFORE the PaymentIntent so the loser can't slip a charge in.
|
|
890
|
+
var claim = await cart.claimForCheckout(input.cart_id);
|
|
891
|
+
if (!claim.cart) throw new TypeError("checkout: cart " + input.cart_id + " not found");
|
|
892
|
+
if (!claim.claimed) {
|
|
893
|
+
// Lost the claim — another checkout for this cart is in flight or
|
|
894
|
+
// already completed. Surface a typed signal so the storefront can
|
|
895
|
+
// redirect to the in-progress order rather than starting a second.
|
|
896
|
+
var inProgress = new Error("checkout: a checkout is already in progress for this cart");
|
|
897
|
+
inProgress.code = "CHECKOUT_IN_PROGRESS";
|
|
898
|
+
throw inProgress;
|
|
831
899
|
}
|
|
832
900
|
|
|
833
|
-
//
|
|
834
|
-
//
|
|
835
|
-
//
|
|
836
|
-
//
|
|
837
|
-
//
|
|
838
|
-
//
|
|
839
|
-
//
|
|
840
|
-
//
|
|
841
|
-
// error never strands held stock.
|
|
842
|
-
var stockHolds = await _placeStockHolds(quote.lines);
|
|
843
|
-
// Conditional rollback: the catch releases the placed holds ONLY when
|
|
844
|
-
// no pending order was created. Once createFromCart succeeds, the
|
|
845
|
-
// order row OWNS those holds — releasing them here would double-free
|
|
846
|
-
// (the paid decrement / cancel release, or the stale-pending reaper,
|
|
847
|
-
// settles them once the order exists), and a floored blanket release
|
|
848
|
-
// could even eat a CONCURRENT shopper's hold on the same SKU. The
|
|
849
|
-
// context object is mutated inside _confirmAfterHolds the instant the
|
|
850
|
-
// order is created.
|
|
901
|
+
// The cart is claimed ('converted'); from here a throw BEFORE the order
|
|
902
|
+
// is created must RELEASE the claim ('converted' → 'active') so the
|
|
903
|
+
// shopper can retry. Once the order exists the claim is permanent (the
|
|
904
|
+
// order owns the conversion) — the reaper / cancel edge handles an
|
|
905
|
+
// abandoned pending order, not a re-openable cart. rollbackCtx, shared
|
|
906
|
+
// with _confirmAfterHolds, flips orderCreated true the instant the order
|
|
907
|
+
// row exists so neither the stock-hold release nor the claim release
|
|
908
|
+
// fires once the order owns them.
|
|
851
909
|
var rollbackCtx = { orderCreated: false };
|
|
852
910
|
try {
|
|
853
|
-
|
|
911
|
+
// Deterministic Stripe idempotency key, derived from the claimed
|
|
912
|
+
// cart. One cart = one checkout (the claim guarantees it), so even if
|
|
913
|
+
// a double-fire reached the provider, Stripe collapses both onto the
|
|
914
|
+
// same PaymentIntent (same key → same intent). The caller-supplied
|
|
915
|
+
// idempotency_key is ignored for the provider call — the cart claim
|
|
916
|
+
// is the authoritative single-charge identity — but still accepted on
|
|
917
|
+
// the input for backward compatibility / the record-only paths.
|
|
918
|
+
var idempotencyKey = "checkout:" + input.cart_id;
|
|
919
|
+
|
|
920
|
+
var quote = await _buildQuote(input, { allowClaimed: true });
|
|
921
|
+
if (!quote.selected_shipping) {
|
|
922
|
+
// _buildQuote already threw above, but defense in depth.
|
|
923
|
+
throw new TypeError("checkout.confirm: selected_shipping resolution returned null");
|
|
924
|
+
}
|
|
925
|
+
if (quote.totals.grand_total_minor <= 0) {
|
|
926
|
+
throw new TypeError("checkout.confirm: grand_total_minor must be > 0 (zero-total orders use a separate freebie flow)");
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
// Reserve stock for every shippable, non-exempt line BEFORE any
|
|
930
|
+
// charge. Insufficient stock throws a coded INSUFFICIENT_STOCK error
|
|
931
|
+
// here (no PaymentIntent, no order) which the storefront re-renders
|
|
932
|
+
// inline. The holds placed here ride into the pending order and are
|
|
933
|
+
// converted to a real shelf debit on the paid transition (or released
|
|
934
|
+
// when the order is cancelled / expires).
|
|
935
|
+
var stockHolds = await _placeStockHolds(quote.lines);
|
|
936
|
+
// Stash the placed holds on rollbackCtx so the single catch below can
|
|
937
|
+
// release them when a later step throws before the order is created.
|
|
938
|
+
rollbackCtx.stockHolds = stockHolds;
|
|
939
|
+
return await this._confirmAfterHolds(input, quote, email, stockHolds, rollbackCtx, idempotencyKey);
|
|
854
940
|
} catch (e) {
|
|
855
|
-
|
|
941
|
+
// Only roll back when no pending order was created. Once
|
|
942
|
+
// createFromCart succeeds the order OWNS the holds + the conversion —
|
|
943
|
+
// releasing the holds would double-free (the paid decrement / cancel
|
|
944
|
+
// release, or the stale-pending reaper, settles them) and could eat a
|
|
945
|
+
// concurrent shopper's hold on the same SKU, and re-opening the cart
|
|
946
|
+
// would orphan the order. A throw before the order exists releases
|
|
947
|
+
// both the placed holds AND the checkout claim so the shopper can
|
|
948
|
+
// retry on the same cart. Both releases are atomic + self-targeting,
|
|
949
|
+
// so neither can disturb a genuinely completed checkout.
|
|
950
|
+
if (!rollbackCtx.orderCreated) {
|
|
951
|
+
if (rollbackCtx.stockHolds) {
|
|
952
|
+
try { await _releaseStockHolds(rollbackCtx.stockHolds); }
|
|
953
|
+
catch (_holdErr) { /* drop-silent — claim release below + original error are the actionable parts */ }
|
|
954
|
+
}
|
|
955
|
+
try { await cart.releaseCheckoutClaim(input.cart_id); }
|
|
956
|
+
catch (_releaseErr) { /* drop-silent — the original error below is the actionable one */ }
|
|
957
|
+
}
|
|
856
958
|
throw e;
|
|
857
959
|
}
|
|
858
960
|
},
|
|
@@ -867,10 +969,16 @@ function create(deps) {
|
|
|
867
969
|
// mutable flag the catch reads: set `orderCreated` true the moment a
|
|
868
970
|
// pending order exists so a LATER throw doesn't release holds the order
|
|
869
971
|
// now owns.
|
|
870
|
-
_confirmAfterHolds: async function (input, quote, email, stockHolds, rollbackCtx) {
|
|
871
|
-
//
|
|
872
|
-
//
|
|
873
|
-
|
|
972
|
+
_confirmAfterHolds: async function (input, quote, email, stockHolds, rollbackCtx, idempotencyKey) {
|
|
973
|
+
// The Stripe idempotency key is derived deterministically from the
|
|
974
|
+
// claimed cart by confirm() (one cart = one checkout), so a double-fire
|
|
975
|
+
// collapses onto a single PaymentIntent on Stripe's side. Defended for
|
|
976
|
+
// a direct call: fall back to the input key.
|
|
977
|
+
if (typeof idempotencyKey !== "string" || idempotencyKey.length < 8) {
|
|
978
|
+
idempotencyKey = (typeof input.idempotency_key === "string" && input.idempotency_key.length >= 8)
|
|
979
|
+
? input.idempotency_key
|
|
980
|
+
: ("checkout:" + input.cart_id);
|
|
981
|
+
}
|
|
874
982
|
// Resolve an optional gift-card credit BEFORE any charge so a bad
|
|
875
983
|
// code fails the checkout without touching Stripe. The credit
|
|
876
984
|
// reduces the amount due; the order still records the full grand
|
|
@@ -940,8 +1048,16 @@ function create(deps) {
|
|
|
940
1048
|
// The pending order now owns the holds — a throw from here on must
|
|
941
1049
|
// NOT blanket-release them (see the conditional rollback in confirm).
|
|
942
1050
|
if (rollbackCtx) rollbackCtx.orderCreated = true;
|
|
943
|
-
|
|
944
|
-
|
|
1051
|
+
// Same settlement posture as the PaymentIntent branch below: the
|
|
1052
|
+
// order exists and the credits priced it, so a redeem failure here
|
|
1053
|
+
// must not strand the cart un-converted — it is captured and
|
|
1054
|
+
// reconciled, never propagated.
|
|
1055
|
+
await _settleCreditPostCreate("giftcard", paidOrder.id, function () {
|
|
1056
|
+
return gc ? _redeemGiftCard(gc, paidOrder.id) : null;
|
|
1057
|
+
});
|
|
1058
|
+
await _settleCreditPostCreate("loyalty", paidOrder.id, function () {
|
|
1059
|
+
return loy ? _redeemLoyalty(loy, loyaltyCustomerId, paidOrder.id) : null;
|
|
1060
|
+
});
|
|
945
1061
|
// Best-effort: record the auto-discount redemptions against the
|
|
946
1062
|
// placed order. Runs post-commit so a recording failure can't
|
|
947
1063
|
// reverse an order that's already paid.
|
|
@@ -1003,11 +1119,20 @@ function create(deps) {
|
|
|
1003
1119
|
if (rollbackCtx) rollbackCtx.orderCreated = true;
|
|
1004
1120
|
|
|
1005
1121
|
// Burn the gift-card + loyalty credits against the created order.
|
|
1006
|
-
//
|
|
1007
|
-
//
|
|
1008
|
-
//
|
|
1009
|
-
|
|
1010
|
-
|
|
1122
|
+
// POST-COMMIT: the order row + its PaymentIntent already exist, so a
|
|
1123
|
+
// redeem failure must NOT throw out of confirm — that would leave the
|
|
1124
|
+
// cart un-converted (still `active`) while a live, charge-able
|
|
1125
|
+
// PaymentIntent and order sit orphaned, and the conditional rollback
|
|
1126
|
+
// above no longer fires (orderCreated is set). Each redeem is captured
|
|
1127
|
+
// and reconciled instead (see _settleCreditPostCreate): the order is
|
|
1128
|
+
// valid and the buyer pays the credit-reduced amount; a failed debit is
|
|
1129
|
+
// surfaced for the operator to reconcile, never a stranded cart.
|
|
1130
|
+
await _settleCreditPostCreate("giftcard", createdOrder.id, function () {
|
|
1131
|
+
return gc ? _redeemGiftCard(gc, createdOrder.id) : null;
|
|
1132
|
+
});
|
|
1133
|
+
await _settleCreditPostCreate("loyalty", createdOrder.id, function () {
|
|
1134
|
+
return loy ? _redeemLoyalty(loy, loyaltyCustomerId, createdOrder.id) : null;
|
|
1135
|
+
});
|
|
1011
1136
|
// Best-effort: record the auto-discount redemptions against the
|
|
1012
1137
|
// placed order. Runs post-commit so a recording failure can't
|
|
1013
1138
|
// reverse or fail an order whose PaymentIntent already exists.
|
|
@@ -1256,7 +1381,12 @@ function create(deps) {
|
|
|
1256
1381
|
lines: ppLines,
|
|
1257
1382
|
});
|
|
1258
1383
|
ppOrderCreated = true;
|
|
1259
|
-
|
|
1384
|
+
// Same settlement posture as the Stripe branches: the order exists,
|
|
1385
|
+
// so a redeem failure is captured and reconciled, never allowed to
|
|
1386
|
+
// strand the cart un-converted.
|
|
1387
|
+
await _settleCreditPostCreate("giftcard", paidOrder.id, function () {
|
|
1388
|
+
return _redeemGiftCard(gc, paidOrder.id);
|
|
1389
|
+
});
|
|
1260
1390
|
await cart.setStatus(quote.cart_id, "converted");
|
|
1261
1391
|
var settled = await order.transition(paidOrder.id, "mark_paid", { reason: "gift_card:full" });
|
|
1262
1392
|
return { order: settled, paypal_order_id: null, status: "PAID_BY_GIFT_CARD", gift_card: { applied_minor: gc.applied_minor, amount_due_minor: 0 } };
|
|
@@ -1285,7 +1415,14 @@ function create(deps) {
|
|
|
1285
1415
|
lines: ppLines,
|
|
1286
1416
|
});
|
|
1287
1417
|
ppOrderCreated = true;
|
|
1288
|
-
|
|
1418
|
+
// Post-commit gift-card burn — captured, never stranding. The order row
|
|
1419
|
+
// + the PayPal order already exist; a redeem throw must not bubble to
|
|
1420
|
+
// the outer catch (which re-throws and would leave the cart active with
|
|
1421
|
+
// an orphaned PayPal order). The buyer pays the credit-reduced PayPal
|
|
1422
|
+
// amount; a failed debit is surfaced for reconciliation.
|
|
1423
|
+
await _settleCreditPostCreate("giftcard", createdOrder.id, function () {
|
|
1424
|
+
return gc ? _redeemGiftCard(gc, createdOrder.id) : null;
|
|
1425
|
+
});
|
|
1289
1426
|
await cart.setStatus(quote.cart_id, "converted");
|
|
1290
1427
|
return { order: createdOrder, paypal_order_id: ppOrder.id, status: ppOrder.status, gift_card: gc ? { applied_minor: gc.applied_minor, amount_due_minor: amountDue } : null };
|
|
1291
1428
|
} catch (e) {
|
package/lib/click-and-collect.js
CHANGED
|
@@ -574,24 +574,39 @@ function create(opts) {
|
|
|
574
574
|
[input.picked_up_at, sigHash, proofKind, now, orderId],
|
|
575
575
|
);
|
|
576
576
|
|
|
577
|
-
// Drive the parent order FSM to delivered
|
|
578
|
-
//
|
|
579
|
-
//
|
|
580
|
-
//
|
|
581
|
-
//
|
|
582
|
-
//
|
|
577
|
+
// Drive the parent order FSM to `delivered` so the order really
|
|
578
|
+
// reaches its terminal state when the goods leave the store.
|
|
579
|
+
//
|
|
580
|
+
// A pickup order is normally `paid` (or `fulfilling`, if the picker
|
|
581
|
+
// started staging) when the customer collects — for which the order
|
|
582
|
+
// FSM's BOPIS edge `mark_picked_up` goes straight to `delivered` (an
|
|
583
|
+
// in-store collection has no carrier ship leg). If the operator
|
|
584
|
+
// already shipped the order through the standard flow, `mark_picked_up`
|
|
585
|
+
// is illegal from `shipped`, so we fall back to the standard
|
|
586
|
+
// `mark_delivered` edge. If BOTH are illegal the order is already
|
|
587
|
+
// `delivered` (or otherwise terminal) — a double markPickedUp is
|
|
588
|
+
// idempotent at the schedule layer, so the order-layer no-op is
|
|
589
|
+
// expected and swallowed. Any non-FSM-refusal error still surfaces.
|
|
590
|
+
var pickupMeta = {
|
|
591
|
+
reason: "click_and_collect_picked_up",
|
|
592
|
+
metadata: {
|
|
593
|
+
location_code: schedule.location_code,
|
|
594
|
+
picked_up_at: input.picked_up_at,
|
|
595
|
+
},
|
|
596
|
+
};
|
|
583
597
|
if (orderPrim) {
|
|
584
598
|
try {
|
|
585
|
-
await orderPrim.transition(orderId, "
|
|
586
|
-
reason: "click_and_collect_picked_up",
|
|
587
|
-
metadata: {
|
|
588
|
-
location_code: schedule.location_code,
|
|
589
|
-
picked_up_at: input.picked_up_at,
|
|
590
|
-
},
|
|
591
|
-
});
|
|
599
|
+
await orderPrim.transition(orderId, "mark_picked_up", pickupMeta);
|
|
592
600
|
} catch (e) {
|
|
593
|
-
|
|
594
|
-
|
|
601
|
+
if ((e && e.code) !== "fsm/illegal-transition") throw e;
|
|
602
|
+
// Not legal from the order's current state — try the standard
|
|
603
|
+
// ship→deliver edge (operator already shipped it), then treat an
|
|
604
|
+
// already-terminal order as the idempotent no-op it is.
|
|
605
|
+
try {
|
|
606
|
+
await orderPrim.transition(orderId, "mark_delivered", pickupMeta);
|
|
607
|
+
} catch (e2) {
|
|
608
|
+
if ((e2 && e2.code) !== "fsm/illegal-transition") throw e2;
|
|
609
|
+
}
|
|
595
610
|
}
|
|
596
611
|
}
|
|
597
612
|
return await _getScheduleByOrder(orderId);
|