@blamejs/blamejs-shop 0.4.27 → 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 +2 -0
- package/README.md +1 -1
- package/SECURITY.md +13 -0
- package/lib/admin.js +197 -39
- 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/refund-automation.js +50 -7
- package/lib/security-middleware.js +39 -0
- package/lib/storefront.js +8 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.4.x
|
|
10
10
|
|
|
11
|
+
- v0.4.28 (2026-06-11) — **Console refunds reach PayPal, refund webhooks apply their stated amount instead of reversing everything, and gift cards and loyalty points now ride the PayPal button.** A payment-lifecycle release for stores taking PayPal. Refunding a PayPal-paid order from the console — full, partial, or through the returns flow — now dials PayPal; previously every console refund dialed the card processor with a PayPal id and failed, leaving the PayPal dashboard as the only way to refund. Refund webhooks from both processors now apply the amount they state: a partial refund issued from the processor's dashboard reverses gift-card and loyalty credit proportionally, where before it triggered the full terminal reversal and could hand a customer the entire credited value back for a five-dollar refund. PayPal webhook deliveries are now claimed in a replay store after signature verification, the verification call runs on its own circuit breaker behind a per-IP budget so a forged-delivery flood can't fast-fail live checkouts, and a buyer paying with the PayPal button can now apply a gift card or spend loyalty points like any other checkout. A deployment with PayPal credentials but no webhook id gets a boot warning naming the missing variable. Upgrade applies two D1 migrations. **Fixed:** *Console refunds route by the order's payment provider* — Orders persist which processor took the payment, and every refund surface — the order console's full and partial refund, the returns console's provider refund, and the refund-automation library — routes to that processor. PayPal refunds dial the capture (recovered from the order record, the payment transition's metadata, or the PayPal API, in that order), and the operator's idempotency key flows through as the PayPal request id so a retried partial refund deduplicates while distinct partial refunds execute distinctly. Orders that predate the provider column fall back to the payment-id shape. The refund button now reflects provider reality: it offers a refund only when the processor that took the payment is configured, and refuses with a specific reason — provider not configured, no capture on record — instead of a generic failure. · *Refund webhooks apply their stated amount* — A refund event arriving from the processor now reads the refunded amount instead of unconditionally driving the order to the terminal refunded state. A partial refund reverses gift-card and loyalty credit proportionally through the same accounting the console's partial refund uses; only a balance-clearing refund transitions the order to refunded. Both processors are covered: PayPal events carry a per-refund amount and deduplicate on the refund id, card-processor events carry a cumulative total and apply the delta against the local ledger. An event with a missing or unparseable amount is refused so the processor redelivers it — the handler never guesses a full refund. · *Gift cards and loyalty points apply to PayPal button payments* — The PayPal button now sends the gift-card code and loyalty-points fields from the pay form, matching card checkout. When a gift card covers the whole total, the page completes the order and redirects without opening the PayPal popup — the previous behavior surfaced a payment error to the buyer after the order had already been created, paid, and the card debited. **Security:** *PayPal webhook deliveries are claimed once and verified in isolation* — Each verified PayPal event id is claimed in a replay store before any state transition — matching the card-processor webhook discipline — so a replayed or re-delivered event is absorbed exactly once across the redelivery window. The signature-verification call to PayPal runs on its own circuit breaker, and the webhook path carries a per-IP request budget, so a flood of forged deliveries can neither trip the breaker that live checkout dials ride nor crowd out legitimate redeliveries. · *Boot warning when the PayPal webhook id is missing* — A deployment with PayPal client credentials but no PAYPAL_WEBHOOK_ID logs a warning at boot naming the variable. Verification itself remains mandatory and fails closed — without the id every webhook delivery is refused, which keeps forged events out but also means dashboard-issued refunds never mirror locally and PayPal will eventually disable the webhook endpoint; the warning makes that state visible instead of silent.
|
|
12
|
+
|
|
11
13
|
- v0.4.27 (2026-06-11) — **Stale quotes expire on schedule, operators can reprice an open quote or convert a verbally-approved one to an order, and customers see the operator's per-line notes and the validity window.** A quote-lifecycle release. Quotes whose validity window has elapsed now transition to expired on a scheduled sweep instead of lingering as open work — the accept-time guard already refused stale prices; now the console's queue and the customer's account list reflect that reality, and the admin list gains an expired filter. Operators can reprice a quote that is awaiting the customer's answer (the customer's existing link immediately shows the new pricing) and can convert a quote the customer approved outside the site — by phone or email — directly to an order, with a required reason recorded in the audit log. The customer-facing quote view now renders the per-line notes the operator wrote alongside each price and shows the validity date in the account list. Upgrade applies one D1 migration. **Added:** *Quotes past their validity window expire on a scheduled sweep* — A scheduled tick transitions responded quotes whose valid-until date has elapsed to expired, in one bounded pass per fire. The transition is race-safe: each row re-checks its status and validity inside a conditional update, so a customer accepting at the same moment wins, a reprice that extends validity rescues the quote, and overlapping ticks never double-transition. The sweep rides the same shared-secret internal endpoint discipline as the other scheduled tasks — secret-checked at the edge and again in the container. The per-pass batch size is tunable with QUOTE_EXPIRY_BATCH (validated at boot). · *Operators can reprice a quote awaiting the customer's answer* — A responded quote that the customer has not yet accepted can be repriced from the console — new per-line pricing, shipping, tax, and validity window through the same validation as the original response, with a version counter recording each revision. The customer's existing quote link keeps working and renders the new pricing; no new email is sent, so the link the customer already holds stays valid. Repricing a quote in any other state is refused with a conflict. · *Operators can convert a verbally-approved quote to an order* — When a customer approves a quote outside the site — by phone or email — the operator can convert it to an order directly from the console. The action requires a written reason, records an audit row with the operator, the reason, and the minted order id, and runs through the same conversion path customer acceptance uses: inventory holds are placed first and released if order creation fails, and the accept-time expiry guard still refuses a quote past its validity window. The action requires order-write permission. **Changed:** *The customer quote view shows per-line notes and the validity window* — Notes the operator writes against individual quote lines now render on the customer's quote page under the line they describe, and the account quote list shows the validity date for open quotes — so the customer sees the full offer, not just the numbers, and knows how long it stands. · *The admin quote list gains an expired filter* — The console's quote list accepts a status filter and ships an Expired view, so quotes the sweep transitioned are reviewable rather than invisible. An unrecognized status value falls back to the default queue.
|
|
12
14
|
|
|
13
15
|
- v0.4.26 (2026-06-06) — **Guest orders attach to an account on verified sign-in; privacy exports and erasures now cover suggestions, saved-for-later, and store credit; HSTS ships on container responses behind the CDN; and internal cron POSTs are secret-checked at the edge.** A guest-order-claim, privacy-completeness, and edge-hardening release. When a shopper who checked out as a guest later signs in or registers with a verified email that matches the order, those orders now attach to their account and appear in their order history. A subject-access export now includes the customer's suggestion-box submissions, their save-for-later list, and their store-credit ledger, and an erasure anonymizes the suggestions, deletes the saved list, and retains the store-credit ledger under its accounting basis — a domain whose reader isn't wired still shows in the export manifest rather than being silently dropped. Strict-Transport-Security now ships on responses served directly by the container behind the Cloudflare proxy, not only on edge-rendered pages. The worker now verifies the shared secret on internal cron and event POSTs before forwarding them to the container. Upgrade applies one D1 migration. **Added:** *Guest orders reconcile to an account on verified sign-in* — An order placed as a guest carries the buyer's email as a one-way hash. When that buyer later signs in or registers — including through Sign in with Google — and their verified email hashes to the same value, the matching guest orders attach to their account and show up under their order history. Attachment is driven only by verified-email ownership, never by unauthenticated email knowledge, and is idempotent: re-signing-in attaches nothing new, a non-matching email attaches nothing, and the placing-browser cookie and emailed-access-token routes to a guest order keep working unchanged. **Changed:** *Privacy export and erasure cover suggestions, saved-for-later, and store credit* — A full subject-access export now includes three more customer-keyed domains: the customer's suggestion-box submissions (product ideas and complaints, matched on the account id or the hashed email the submission carried), their save-for-later list, and their store-credit balance and ledger history. Erasure anonymizes each suggestion in place — severing both identity keys so the row can no longer be traced to the person while leaving the de-identified roadmap signal — deletes the save-for-later list outright, and retains the store-credit ledger under the same accounting / legal-obligation basis the loyalty ledger and gift cards already use. Each domain reports its effect in the request's completeness manifest, so a domain whose reader isn't wired is visible as absent rather than silently omitted. **Security:** *HSTS ships on container responses behind the proxy* — Strict-Transport-Security is emitted on responses served directly by the application container, not only on pages rendered at the edge. Behind the Cloudflare proxy the container connection is plain HTTP and the real scheme arrives in the forwarded-proto header; the security-headers middleware now trusts that header, so a direct-to-container or edge-render-off response carries the same two-year, includeSubDomains, preload HSTS value the edge sends. Plain-HTTP local and direct connections still omit the header, which user agents ignore over HTTP anyway. · *Internal cron and event POSTs are secret-checked at the edge* — The worker now verifies the shared secret on its internal cron and event POSTs (cart-recovery, stock-alert and wishlist sweeps, the stale-order reap, portal-session expiry, and campaign sends) before forwarding them to the container, refusing a forged public request at the edge instead of relying solely on the container's own check. The check is skipped when the secret isn't configured, so a deployment that hasn't set it still forwards rather than refusing every scheduled task.
|
package/README.md
CHANGED
|
@@ -68,7 +68,7 @@ Every primitive is composed on the vendored blamejs surface — no npm runtime d
|
|
|
68
68
|
| **`lib/shipping.js`** | Operator-table adapter. Services with zones (flat or per-gram + base + min/max), free-over-threshold, `digital_only` flag. |
|
|
69
69
|
| **`lib/delivery-estimate.js`** | "Get it by <date>" promises. Operators define carrier transit times, warehouse cutoffs, holidays, and postal-prefix zones at `/admin/delivery-estimates`; the product and cart pages then show a signed-in customer a delivery date computed against their saved shipping address and the configured origin (`SHOP_ESTIMATE_ORIGIN` or the `shop.estimate_origin` config row). Anonymous, edge-cached pages deliberately render no date — estimates are destination-specific and never bake into the shared cache. Unconfigured stores render nothing, never an error. |
|
|
70
70
|
| **`lib/promo-banners.js`** | Placement-targeted marketing banners (top strip, homepage hero, PDP-side, cart-side, empty-search, footer) with schedule windows, audience targeting, themes, priorities, and click/impression counts. Authored at `/admin/promo-banners`; rendered on both substrates with edge-cache-safe resolution. |
|
|
71
|
-
| **`lib/payment.js`** | Payment adapters — **Stripe** (verify webhook HMAC-SHA256 via upstream `b.webhook.verify`, create / retrieve / confirm / cancel PaymentIntent, refund, register / list payment-method domains for Apple/Google Pay) and **PayPal** (`adapter: "paypal"` — OAuth2 client-credentials token, create / capture / get / refund Orders v2, webhook verify via PayPal's verify-webhook-signature API). No `stripe` / `paypal` npm dep — outbound through `b.httpClient` (SSRF-gated, retried, circuit-broken). |
|
|
71
|
+
| **`lib/payment.js`** | Payment adapters — **Stripe** (verify webhook HMAC-SHA256 via upstream `b.webhook.verify`, create / retrieve / confirm / cancel PaymentIntent, refund, register / list payment-method domains for Apple/Google Pay) and **PayPal** (`adapter: "paypal"` — OAuth2 client-credentials token, create / capture / get / refund Orders v2, webhook verify via PayPal's verify-webhook-signature API). Console refunds route by the order's payment provider — full, partial, and RMA refunds reach the processor that took the payment — and processor-side refund webhooks apply their stated amount, reversing gift-card / loyalty credit proportionally on a partial. Gift cards and loyalty points apply to PayPal button payments the same as card checkout. No `stripe` / `paypal` npm dep — outbound through `b.httpClient` (SSRF-gated, retried, circuit-broken). |
|
|
72
72
|
| **`lib/order.js`** | FSM-driven post-checkout record via upstream `b.fsm`. States: pending → paid → fulfilling → shipped → delivered (+ refunded / cancelled). Every transition appends to `order_transitions`. Guest orders carry the buyer's email as a one-way hash and attach to a customer account when a verified sign-in (passkey / Google / Apple) proves ownership of the same address — never from email knowledge alone — after which they appear in the account's order history. |
|
|
73
73
|
| **`lib/checkout.js`** | Orchestrator. `quote()` returns priced quote; `confirm()` validates the ship-to address (real ISO 3166-1 country; US/CA state + ZIP/postal formats; lenient elsewhere) and the customer email shape, then creates a Stripe PaymentIntent + persists order pending; `handleStripeEvent()` verifies webhook + fires the FSM transition. PayPal path: `createPaypalOrder()` opens a PayPal order + persists pending, `capturePaypalOrder()` captures → paid, `handlePaypalEvent()` is the webhook backstop. All idempotent on re-delivery. |
|
|
74
74
|
| **`lib/email.js`** | Transactional templates — order receipt, ship notification, refund confirmation, wishlist price-drop, abandoned-cart, review request, back-in-stock, **wishlist digest** (the periodic saved-items rollup, rendered per-line from the structured digest so every title / price is independently escaped), and **email magic-link sign-in**. Strict `{{var}}` renderer with HTML escape + refusal of unknown / unused placeholders. Composed on `b.mail` (DKIM/SPF/DMARC/BIMI upstream). |
|
package/SECURITY.md
CHANGED
|
@@ -170,6 +170,19 @@ node -e "
|
|
|
170
170
|
`hmac-sha256-stripe`) inside `lib/payment.js` before any FSM
|
|
171
171
|
transition runs. An unsigned or out-of-window delivery never
|
|
172
172
|
touches origin resources.
|
|
173
|
+
- **PayPal webhook signature + replay refusal.** Inbound `POST` to
|
|
174
|
+
`/api/webhooks/paypal` is verified server-to-server against PayPal's
|
|
175
|
+
verify-webhook-signature API using `PAYPAL_WEBHOOK_ID` — set it
|
|
176
|
+
whenever the PayPal client credentials are set, or every delivery is
|
|
177
|
+
refused (verification fails closed; the boot log warns when the id is
|
|
178
|
+
missing). Each verified event id is claimed in a replay store before
|
|
179
|
+
any state transition, so a re-delivered or replayed event is absorbed
|
|
180
|
+
exactly once, and refund events apply their stated amount — a partial
|
|
181
|
+
refund issued from the PayPal dashboard reverses gift-card and
|
|
182
|
+
loyalty credit proportionally, never in full. The verify call runs on
|
|
183
|
+
its own circuit breaker and the path carries a per-IP budget, so a
|
|
184
|
+
forged-delivery flood can neither open the checkout breaker nor
|
|
185
|
+
drown legitimate redeliveries.
|
|
173
186
|
- **Apple Pay domain-association file.** Apple verifies the domain
|
|
174
187
|
before it will render the Apple Pay wallet button, and it verifies by
|
|
175
188
|
fetching `/.well-known/apple-developer-merchantid-domain-association`.
|
package/lib/admin.js
CHANGED
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
*/
|
|
32
32
|
|
|
33
33
|
var pricing = require("./pricing");
|
|
34
|
+
var paymentModule = require("./payment"); // _decimalToMinor — normalize a PayPal refund response's decimal amount to minor units
|
|
34
35
|
var collectionsModule = require("./collections");
|
|
35
36
|
var quantityDiscountsModule = require("./quantity-discounts");
|
|
36
37
|
var loyaltyEarnRulesModule = require("./loyalty-earn-rules");
|
|
@@ -843,7 +844,8 @@ function mount(router, deps) {
|
|
|
843
844
|
var catalog = deps.catalog;
|
|
844
845
|
var order = deps.order;
|
|
845
846
|
var cart = deps.cart || null; // abandoned-cart visibility console (/admin/carts) disabled when absent
|
|
846
|
-
var payment = deps.payment || null; //
|
|
847
|
+
var payment = deps.payment || null; // Stripe handle — Stripe-paid orders refund through this
|
|
848
|
+
var paypal = deps.paypal || null; // PayPal handle — PayPal-paid orders refund through this; refund endpoints disabled when BOTH are absent
|
|
847
849
|
var mailer = deps.mailer || null; // transactional email factory (lib/email.js) — resend-confirmation disabled when absent
|
|
848
850
|
var _checkout = deps.checkout || null; // reserved — future webhook handler wiring
|
|
849
851
|
var r2 = deps.r2_bridge || null; // media-upload endpoint disabled when absent
|
|
@@ -2244,7 +2246,7 @@ function mount(router, deps) {
|
|
|
2244
2246
|
// a read failure leaves the panel to treat the order as un-refunded
|
|
2245
2247
|
// (the route re-validates the cap server-side before moving money).
|
|
2246
2248
|
var refundedMinor = 0;
|
|
2247
|
-
if (
|
|
2249
|
+
if (o.payment_intent_id && _refundHandleFor(o)) {
|
|
2248
2250
|
try { refundedMinor = await order.refundedTotalMinor(o.id); }
|
|
2249
2251
|
catch (_re) { refundedMinor = 0; }
|
|
2250
2252
|
}
|
|
@@ -2253,9 +2255,12 @@ function mount(router, deps) {
|
|
|
2253
2255
|
nav_available: navAvailable,
|
|
2254
2256
|
order: o,
|
|
2255
2257
|
transitions: order.transitionsFrom(o.status),
|
|
2256
|
-
// Refund moves money, so the console only offers it when
|
|
2257
|
-
//
|
|
2258
|
-
|
|
2258
|
+
// Refund moves money, so the console only offers it when the order
|
|
2259
|
+
// has a captured intent AND the provider that captured it is wired —
|
|
2260
|
+
// a Stripe handle can't refund a PayPal-paid order (and vice versa),
|
|
2261
|
+
// so the gate routes by the order's provider, not by "some payment
|
|
2262
|
+
// handle exists".
|
|
2263
|
+
can_refund: !!(o.payment_intent_id && _refundHandleFor(o)),
|
|
2259
2264
|
// Partial-refund panel inputs. `refunded_minor` is the running
|
|
2260
2265
|
// total already refunded; `refundable_minor` is what's left of the
|
|
2261
2266
|
// order's grand total. The panel renders a decimal-amount form
|
|
@@ -4008,27 +4013,162 @@ function mount(router, deps) {
|
|
|
4008
4013
|
}
|
|
4009
4014
|
|
|
4010
4015
|
// ---- refunds --------------------------------------------------------
|
|
4016
|
+
//
|
|
4017
|
+
// PROVIDER ROUTING. An order's payment_intent_id is provider-opaque: the
|
|
4018
|
+
// Stripe flow stores a `pi_...` PaymentIntent id, the PayPal flow stores
|
|
4019
|
+
// the PayPal order id. A refund must dial the provider that captured the
|
|
4020
|
+
// charge — sending a PayPal order id into Stripe's refund API (or vice
|
|
4021
|
+
// versa) is a guaranteed upstream 4xx with the operator left reading a
|
|
4022
|
+
// misleading provider error. Every refund surface below (full, partial,
|
|
4023
|
+
// RMA) resolves the order's provider first and routes through
|
|
4024
|
+
// `_issueProviderRefund`, which normalizes the two providers' shapes:
|
|
4025
|
+
//
|
|
4026
|
+
// stripe → payment.refund({ payment_intent, amount_minor?, reason })
|
|
4027
|
+
// paypal → paypal.refund({ capture_id, amount_minor?, currency })
|
|
4028
|
+
//
|
|
4029
|
+
// The PayPal capture id (the refund target — NOT the order id) resolves
|
|
4030
|
+
// from the persisted orders.paypal_capture_id column, falling back to the
|
|
4031
|
+
// mark_paid transition metadata for orders captured before the column
|
|
4032
|
+
// existed, then to a remote getOrder read — and heals the column so the
|
|
4033
|
+
// recovery runs once per order.
|
|
4034
|
+
|
|
4035
|
+
// Which provider captured this order's charge. The persisted column is
|
|
4036
|
+
// authoritative; legacy rows (placed before the column existed) fall back
|
|
4037
|
+
// to the payment_intent_id shape — a Stripe PaymentIntent id always
|
|
4038
|
+
// carries the `pi_` prefix, a PayPal order id never does. Returns
|
|
4039
|
+
// "stripe" | "paypal" | null (no provider charge — gift-card / loyalty
|
|
4040
|
+
// fully-covered orders).
|
|
4041
|
+
function _orderPaymentProvider(o) {
|
|
4042
|
+
if (!o) return null;
|
|
4043
|
+
if (o.payment_provider === "paypal" || o.payment_provider === "stripe") return o.payment_provider;
|
|
4044
|
+
if (!o.payment_intent_id) return null;
|
|
4045
|
+
return /^pi_/.test(o.payment_intent_id) ? "stripe" : "paypal";
|
|
4046
|
+
}
|
|
4047
|
+
|
|
4048
|
+
// The wired adapter that can refund this order, or null when the order's
|
|
4049
|
+
// provider isn't configured. This is what the console's refund affordances
|
|
4050
|
+
// gate on — provider REALITY, not merely "some payment handle exists"
|
|
4051
|
+
// (offering a Stripe-backed Refund button on a PayPal order moves no
|
|
4052
|
+
// money and 502s).
|
|
4053
|
+
function _refundHandleFor(o) {
|
|
4054
|
+
var provider = _orderPaymentProvider(o);
|
|
4055
|
+
if (provider === "stripe") return payment;
|
|
4056
|
+
if (provider === "paypal") return paypal;
|
|
4057
|
+
return null;
|
|
4058
|
+
}
|
|
4059
|
+
|
|
4060
|
+
// Resolve the PayPal CAPTURE id a refund runs against. Local resolution
|
|
4061
|
+
// first (column, then transition-metadata recovery — order.paypalCaptureId
|
|
4062
|
+
// heals the column), then a remote getOrder read for pre-existing orders
|
|
4063
|
+
// whose capture id never reached the local ledger. Throws a coded
|
|
4064
|
+
// TypeError (clean 422, nothing dialed for the refund) when no capture
|
|
4065
|
+
// can be found — refunding the ORDER id instead would 404 at PayPal.
|
|
4066
|
+
async function _paypalCaptureIdFor(o) {
|
|
4067
|
+
var local = await order.paypalCaptureId(o.id);
|
|
4068
|
+
if (local) return local;
|
|
4069
|
+
try {
|
|
4070
|
+
var remote = await paypal.getOrder(o.payment_intent_id);
|
|
4071
|
+
var recovered = remote.purchase_units[0].payments.captures[0].id;
|
|
4072
|
+
if (typeof recovered === "string" && recovered.length) {
|
|
4073
|
+
try { await order.setPaypalCapture(o.id, recovered); }
|
|
4074
|
+
catch (_e) { /* drop-silent — healing the column is best-effort; the refund proceeds on the recovered id */ }
|
|
4075
|
+
return recovered;
|
|
4076
|
+
}
|
|
4077
|
+
} catch (_e2) { /* fall through to the coded refusal below */ }
|
|
4078
|
+
var missing = new TypeError("This PayPal order has no recorded capture to refund against — the payment may not have been captured.");
|
|
4079
|
+
missing._refundCode = "no-paypal-capture";
|
|
4080
|
+
throw missing;
|
|
4081
|
+
}
|
|
4082
|
+
|
|
4083
|
+
// Issue the provider refund for an order, routed by provider, normalized
|
|
4084
|
+
// to `{ provider, id, amount_minor, raw }`. `opts2.amount_minor` absent →
|
|
4085
|
+
// a FULL refund of the charge's remaining balance (both providers
|
|
4086
|
+
// implement that natively). `opts2.idempotency_key` is REQUIRED by the
|
|
4087
|
+
// callers' double-refund discipline: Stripe dedupes on Idempotency-Key;
|
|
4088
|
+
// the PayPal adapter folds it into the PayPal-Request-Id, so two distinct
|
|
4089
|
+
// partial slices on the SAME capture carry distinct request ids (PayPal
|
|
4090
|
+
// silently REPLAYS the first refund when the id repeats — it never
|
|
4091
|
+
// executes a second one) while a retry of the same slice stays
|
|
4092
|
+
// deduplicated.
|
|
4093
|
+
async function _issueProviderRefund(o, opts2) {
|
|
4094
|
+
var provider = _orderPaymentProvider(o);
|
|
4095
|
+
var handle = _refundHandleFor(o);
|
|
4096
|
+
if (!handle) {
|
|
4097
|
+
var unwired = new TypeError(provider === "paypal"
|
|
4098
|
+
? "This order was paid through PayPal, but PayPal credentials are not configured — set PAYPAL_CLIENT_ID / PAYPAL_SECRET to refund it."
|
|
4099
|
+
: "No payment provider is configured for this order's charge.");
|
|
4100
|
+
unwired._refundCode = "provider-not-configured";
|
|
4101
|
+
throw unwired;
|
|
4102
|
+
}
|
|
4103
|
+
var raw, refundedMinor = null;
|
|
4104
|
+
if (provider === "paypal") {
|
|
4105
|
+
var captureId = await _paypalCaptureIdFor(o);
|
|
4106
|
+
var ppInput = { capture_id: captureId };
|
|
4107
|
+
if (opts2.amount_minor != null) {
|
|
4108
|
+
ppInput.amount_minor = opts2.amount_minor;
|
|
4109
|
+
// A PayPal partial refund names its amount in the CAPTURE currency —
|
|
4110
|
+
// the order's charge currency.
|
|
4111
|
+
ppInput.currency = String(o.currency || "").toUpperCase();
|
|
4112
|
+
}
|
|
4113
|
+
raw = await handle.refund(ppInput, opts2.idempotency_key);
|
|
4114
|
+
// The refund resource echoes its amount as a decimal string; parse it
|
|
4115
|
+
// exactly, falling back to the requested amount when the response
|
|
4116
|
+
// omits it. Stays null on an unparseable full-refund response — the
|
|
4117
|
+
// ledger row then records no amount rather than a guessed one.
|
|
4118
|
+
if (raw && raw.amount && typeof raw.amount.value === "string") {
|
|
4119
|
+
try {
|
|
4120
|
+
refundedMinor = paymentModule._decimalToMinor(
|
|
4121
|
+
raw.amount.value, String(raw.amount.currency_code || o.currency || "").toUpperCase());
|
|
4122
|
+
} catch (_e) { refundedMinor = null; }
|
|
4123
|
+
}
|
|
4124
|
+
if (refundedMinor == null && opts2.amount_minor != null) refundedMinor = opts2.amount_minor;
|
|
4125
|
+
} else {
|
|
4126
|
+
raw = await handle.refund({
|
|
4127
|
+
payment_intent: o.payment_intent_id,
|
|
4128
|
+
amount_minor: opts2.amount_minor != null ? opts2.amount_minor : undefined,
|
|
4129
|
+
reason: opts2.reason || undefined,
|
|
4130
|
+
metadata: opts2.metadata || undefined,
|
|
4131
|
+
}, opts2.idempotency_key);
|
|
4132
|
+
refundedMinor = Number.isInteger(raw && raw.amount) ? raw.amount
|
|
4133
|
+
: (opts2.amount_minor != null ? opts2.amount_minor : null);
|
|
4134
|
+
}
|
|
4135
|
+
return { provider: provider, id: raw && raw.id, amount_minor: refundedMinor, raw: raw };
|
|
4136
|
+
}
|
|
4011
4137
|
|
|
4012
|
-
|
|
4138
|
+
// Ledger metadata for a provider refund: the provider's refund id under
|
|
4139
|
+
// its provider-named key plus the refunded amount. The PayPal key
|
|
4140
|
+
// (`paypal_refund_id`) is ALSO the dedupe identity the webhook mirror
|
|
4141
|
+
// checks — PayPal echoes every refund back as a PAYMENT.CAPTURE.REFUNDED
|
|
4142
|
+
// event whose resource.id is this refund id, and the mirror skips a row
|
|
4143
|
+
// it already finds in the ledger, so an admin-issued refund is never
|
|
4144
|
+
// double-applied when its own webhook arrives.
|
|
4145
|
+
function _refundLedgerMeta(result, extra) {
|
|
4146
|
+
var meta = Object.assign({}, extra || {});
|
|
4147
|
+
meta[result.provider === "paypal" ? "paypal_refund_id" : "stripe_refund_id"] = result.id;
|
|
4148
|
+
if (Number.isInteger(result.amount_minor) && result.amount_minor > 0) meta.amount_minor = result.amount_minor;
|
|
4149
|
+
return meta;
|
|
4150
|
+
}
|
|
4151
|
+
|
|
4152
|
+
if (payment || paypal) {
|
|
4013
4153
|
// Issue the actual payment-provider refund, then advance the order
|
|
4014
4154
|
// FSM. Shared by the JSON API and the browser console so a console
|
|
4015
4155
|
// "Refund" moves the money first (never a bare state change — that
|
|
4016
4156
|
// would mark an order refunded with the customer never paid back).
|
|
4017
4157
|
async function _refundOrder(o, body) {
|
|
4018
4158
|
var refundIdempotencyKey = "refund:" + o.id + ":" + (body.idempotency_suffix || b.uuid.v7());
|
|
4019
|
-
var
|
|
4020
|
-
|
|
4021
|
-
|
|
4022
|
-
|
|
4023
|
-
|
|
4024
|
-
}
|
|
4159
|
+
var result = await _issueProviderRefund(o, {
|
|
4160
|
+
amount_minor: body.amount_minor || undefined,
|
|
4161
|
+
reason: body.reason || undefined,
|
|
4162
|
+
metadata: { order_id: o.id },
|
|
4163
|
+
idempotency_key: refundIdempotencyKey,
|
|
4164
|
+
});
|
|
4025
4165
|
try {
|
|
4026
4166
|
await order.transition(o.id, "refund", {
|
|
4027
4167
|
reason: "admin:refund:" + (body.reason || "requested_by_customer"),
|
|
4028
|
-
metadata:
|
|
4168
|
+
metadata: _refundLedgerMeta(result),
|
|
4029
4169
|
});
|
|
4030
4170
|
} catch (_e) { /* refund succeeded at the provider; transition refusal logged, surfaced via re-fetch */ }
|
|
4031
|
-
return { refund:
|
|
4171
|
+
return { refund: result.raw, order: await order.get(o.id) };
|
|
4032
4172
|
}
|
|
4033
4173
|
|
|
4034
4174
|
// Browser confirmation interstitial for the full refund — it moves
|
|
@@ -4044,7 +4184,7 @@ function mount(router, deps) {
|
|
|
4044
4184
|
var o;
|
|
4045
4185
|
try { o = await order.get(id); }
|
|
4046
4186
|
catch (e) { if (!(e instanceof TypeError)) throw e; o = null; }
|
|
4047
|
-
if (!o || !o.payment_intent_id) {
|
|
4187
|
+
if (!o || !o.payment_intent_id || !_refundHandleFor(o)) {
|
|
4048
4188
|
return _redirect(res, "/admin/orders/" + encodeURIComponent(id) + "?err=1");
|
|
4049
4189
|
}
|
|
4050
4190
|
var amount = pricing.format(o.grand_total_minor, o.currency);
|
|
@@ -4069,8 +4209,14 @@ function mount(router, deps) {
|
|
|
4069
4209
|
try {
|
|
4070
4210
|
result = await _refundOrder(o, req.body || {});
|
|
4071
4211
|
} catch (e) {
|
|
4212
|
+
// A coded refusal (the order's provider isn't configured / the
|
|
4213
|
+
// PayPal capture can't be resolved) is a clean 422 — nothing was
|
|
4214
|
+
// dialed, the operator gets the actionable message.
|
|
4215
|
+
if (e instanceof TypeError && e._refundCode) {
|
|
4216
|
+
return _problem(res, 422, e._refundCode, e.message);
|
|
4217
|
+
}
|
|
4072
4218
|
// allow:admin-5xx-echoes-raw-error-message — 502 surfaces the PAYMENT PROVIDER's refund-failure reason (e.g. "charge already refunded"), an operator-actionable upstream message, not a server/storage internal.
|
|
4073
|
-
return _problem(res, 502, "
|
|
4219
|
+
return _problem(res, 502, _orderPaymentProvider(o) + "-refund-failed", (e && e.message) || String(e));
|
|
4074
4220
|
}
|
|
4075
4221
|
_json(res, 200, result);
|
|
4076
4222
|
return { id: o.id };
|
|
@@ -4083,7 +4229,7 @@ function mount(router, deps) {
|
|
|
4083
4229
|
var o;
|
|
4084
4230
|
try { o = await order.get(id); }
|
|
4085
4231
|
catch (e) { if (!(e instanceof TypeError)) throw e; o = null; }
|
|
4086
|
-
if (!o || !o.payment_intent_id) {
|
|
4232
|
+
if (!o || !o.payment_intent_id || !_refundHandleFor(o)) {
|
|
4087
4233
|
return _redirect(res, "/admin/orders/" + encodeURIComponent(id) + "?err=1");
|
|
4088
4234
|
}
|
|
4089
4235
|
try {
|
|
@@ -4151,15 +4297,19 @@ function mount(router, deps) {
|
|
|
4151
4297
|
}
|
|
4152
4298
|
var clearsBalance = (minor === remainingMinor);
|
|
4153
4299
|
// Idempotency key folds in the refunded-total seen, so two submits of
|
|
4154
|
-
// the same slice (a double-click, a retry) reuse one provider refund
|
|
4300
|
+
// the same slice (a double-click, a retry) reuse one provider refund —
|
|
4301
|
+
// and, equally load-bearing on the PayPal side, two DIFFERENT slices
|
|
4302
|
+
// on the same capture carry DIFFERENT keys: the adapter folds this key
|
|
4303
|
+
// into the PayPal-Request-Id, and PayPal silently replays the first
|
|
4304
|
+
// refund (it never executes a second) when the request id repeats.
|
|
4155
4305
|
var idemKey = "refund:" + o.id + ":partial:" + alreadyMinor + ":" + minor;
|
|
4156
|
-
var
|
|
4157
|
-
|
|
4158
|
-
|
|
4159
|
-
|
|
4160
|
-
|
|
4161
|
-
}
|
|
4162
|
-
var refundedMinor = Number(
|
|
4306
|
+
var result = await _issueProviderRefund(o, {
|
|
4307
|
+
amount_minor: minor,
|
|
4308
|
+
reason: "requested_by_customer",
|
|
4309
|
+
metadata: { order_id: o.id, partial: !clearsBalance },
|
|
4310
|
+
idempotency_key: idemKey,
|
|
4311
|
+
});
|
|
4312
|
+
var refundedMinor = Number.isInteger(result.amount_minor) && result.amount_minor > 0 ? result.amount_minor : minor;
|
|
4163
4313
|
if (clearsBalance) {
|
|
4164
4314
|
// The slice clears the remaining balance — drive the terminal FSM
|
|
4165
4315
|
// edge so the order moves to `refunded` (and the gift-card / loyalty
|
|
@@ -4169,7 +4319,7 @@ function mount(router, deps) {
|
|
|
4169
4319
|
try {
|
|
4170
4320
|
await order.transition(o.id, "refund", {
|
|
4171
4321
|
reason: "admin:refund:partial-final",
|
|
4172
|
-
metadata: {
|
|
4322
|
+
metadata: _refundLedgerMeta(result, { amount_minor: refundedMinor, partial: true }),
|
|
4173
4323
|
});
|
|
4174
4324
|
} catch (_te) { /* provider refund persisted; FSM refusal surfaced via re-fetch */ }
|
|
4175
4325
|
} else {
|
|
@@ -4178,10 +4328,10 @@ function mount(router, deps) {
|
|
|
4178
4328
|
await order.recordPartialRefund(o.id, {
|
|
4179
4329
|
amount_minor: refundedMinor,
|
|
4180
4330
|
reason: "admin:refund:partial",
|
|
4181
|
-
metadata: {
|
|
4331
|
+
metadata: _refundLedgerMeta(result, { amount_minor: refundedMinor }),
|
|
4182
4332
|
});
|
|
4183
4333
|
}
|
|
4184
|
-
return { refund:
|
|
4334
|
+
return { refund: result.raw, amount_minor: refundedMinor, cleared: clearsBalance };
|
|
4185
4335
|
}
|
|
4186
4336
|
|
|
4187
4337
|
router.post("/admin/orders/:id/refund/partial", _pageOrApi(false,
|
|
@@ -4195,11 +4345,12 @@ function mount(router, deps) {
|
|
|
4195
4345
|
result = await _partialRefund(o, body.amount);
|
|
4196
4346
|
} catch (e) {
|
|
4197
4347
|
if (e instanceof TypeError) {
|
|
4198
|
-
var status = e._refundCode === "over-refund" || e._refundCode === "nothing-remaining"
|
|
4348
|
+
var status = e._refundCode === "over-refund" || e._refundCode === "nothing-remaining" ||
|
|
4349
|
+
e._refundCode === "provider-not-configured" || e._refundCode === "no-paypal-capture" ? 422 : 400;
|
|
4199
4350
|
return _problem(res, status, e._refundCode || "bad-request", e.message);
|
|
4200
4351
|
}
|
|
4201
4352
|
// allow:admin-5xx-echoes-raw-error-message — 502 surfaces the PAYMENT PROVIDER's refund-failure reason, an operator-actionable upstream message, not a server/storage internal.
|
|
4202
|
-
return _problem(res, 502, "
|
|
4353
|
+
return _problem(res, 502, _orderPaymentProvider(o) + "-refund-failed", (e && e.message) || String(e));
|
|
4203
4354
|
}
|
|
4204
4355
|
_json(res, 200, result);
|
|
4205
4356
|
return { id: o.id };
|
|
@@ -4209,7 +4360,7 @@ function mount(router, deps) {
|
|
|
4209
4360
|
var o;
|
|
4210
4361
|
try { o = await order.get(id); }
|
|
4211
4362
|
catch (e) { if (!(e instanceof TypeError)) throw e; o = null; }
|
|
4212
|
-
if (!o || !o.payment_intent_id) {
|
|
4363
|
+
if (!o || !o.payment_intent_id || !_refundHandleFor(o)) {
|
|
4213
4364
|
return _redirect(res, "/admin/orders/" + encodeURIComponent(id) + "?err=1");
|
|
4214
4365
|
}
|
|
4215
4366
|
var enc = encodeURIComponent(id);
|
|
@@ -4710,10 +4861,13 @@ function mount(router, deps) {
|
|
|
4710
4861
|
// degrades to "record-only" rather than throwing.
|
|
4711
4862
|
async function _rmaProviderContext(rma) {
|
|
4712
4863
|
var ctx = { order: null, canProviderRefund: false };
|
|
4713
|
-
if (!payment || !rma || !rma.order_id) return ctx;
|
|
4864
|
+
if ((!payment && !paypal) || !rma || !rma.order_id) return ctx;
|
|
4714
4865
|
try { ctx.order = await order.get(rma.order_id); }
|
|
4715
4866
|
catch (_e) { ctx.order = null; }
|
|
4716
|
-
|
|
4867
|
+
// Provider reality, not mere handle presence: the linked order must
|
|
4868
|
+
// carry a captured intent AND the provider that captured it must be
|
|
4869
|
+
// the one that's wired (see _refundHandleFor).
|
|
4870
|
+
ctx.canProviderRefund = !!(ctx.order && ctx.order.payment_intent_id && _refundHandleFor(ctx.order));
|
|
4717
4871
|
return ctx;
|
|
4718
4872
|
}
|
|
4719
4873
|
|
|
@@ -5012,12 +5166,16 @@ function mount(router, deps) {
|
|
|
5012
5166
|
var idem = "rma-refund:" + rma.id;
|
|
5013
5167
|
var refund;
|
|
5014
5168
|
try {
|
|
5015
|
-
|
|
5016
|
-
|
|
5017
|
-
|
|
5018
|
-
|
|
5019
|
-
|
|
5020
|
-
|
|
5169
|
+
// Routed by the linked order's provider (Stripe payment_intent vs
|
|
5170
|
+
// PayPal capture) — see _issueProviderRefund. The deterministic key
|
|
5171
|
+
// keeps a retry of the SAME RMA refund deduplicated at either
|
|
5172
|
+
// provider (Stripe Idempotency-Key / PayPal-Request-Id).
|
|
5173
|
+
refund = (await _issueProviderRefund(order2, {
|
|
5174
|
+
amount_minor: (rma.refund_amount_minor != null && rma.refund_amount_minor > 0) ? rma.refund_amount_minor : undefined,
|
|
5175
|
+
reason: "requested_by_customer",
|
|
5176
|
+
metadata: { order_id: order2.id, rma_id: rma.id, rma_code: rma.rma_code || "" },
|
|
5177
|
+
idempotency_key: idem,
|
|
5178
|
+
})).raw;
|
|
5021
5179
|
} catch (e) {
|
|
5022
5180
|
// Provider call failed — release the claim so the operator can retry
|
|
5023
5181
|
// a transient failure. A release failure can't be recovered here, so
|
package/lib/asset-manifest.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.4.
|
|
2
|
+
"version": "0.4.28",
|
|
3
3
|
"assets": {
|
|
4
4
|
"css/admin.css": {
|
|
5
5
|
"integrity": "sha384-imfe0otYErcB8rr2h6KLSGTtStirysptpXETSPY4zLv3bZoIT75Lo1dOvkOav+xL",
|
|
@@ -46,8 +46,8 @@
|
|
|
46
46
|
"fingerprinted": "js/pay.683a905563e54a47.js"
|
|
47
47
|
},
|
|
48
48
|
"js/paypal-checkout.js": {
|
|
49
|
-
"integrity": "sha384-
|
|
50
|
-
"fingerprinted": "js/paypal-checkout.
|
|
49
|
+
"integrity": "sha384-Fxi5GnmvGA/ZoOC7Vomdo2ng37kkp+G9OgjnRiMSQWtl+fmzdUd5p4Yc+fmPQBrt",
|
|
50
|
+
"fingerprinted": "js/paypal-checkout.7ee687882cc1b588.js"
|
|
51
51
|
},
|
|
52
52
|
"js/saved-card.js": {
|
|
53
53
|
"integrity": "sha384-Kaj6n+Any4rwCH2lyREHoq30MrAZtEd/fTa+tDnIrMJ4zO01YWRhW5TTujcYyuVn",
|
package/lib/checkout.js
CHANGED
|
@@ -36,6 +36,10 @@
|
|
|
36
36
|
*/
|
|
37
37
|
|
|
38
38
|
var b = require("./vendor/blamejs");
|
|
39
|
+
// Shared decimal↔minor conversion (zero-decimal-currency aware) — the
|
|
40
|
+
// PAYMENT.CAPTURE.REFUNDED mirror parses the webhook's decimal amount with
|
|
41
|
+
// the same table the adapter encodes outbound amounts from.
|
|
42
|
+
var paymentLib = require("./payment");
|
|
39
43
|
|
|
40
44
|
function _uuid(s, label) {
|
|
41
45
|
try { return b.guardUuid.sanitize(s, { profile: "strict" }); }
|
|
@@ -262,6 +266,14 @@ function create(deps) {
|
|
|
262
266
|
var webhookReplayQuery = (typeof deps.webhookReplayQuery === "function")
|
|
263
267
|
? deps.webhookReplayQuery : null;
|
|
264
268
|
var STRIPE_REPLAY_TTL_MS = b.constants.TIME.minutes(5); // matches the signature tolerance window
|
|
269
|
+
// PayPal claims live in the same store but keep a much longer window:
|
|
270
|
+
// PayPal's verify-webhook-signature API has no timestamp tolerance of ours
|
|
271
|
+
// to lean on, and PayPal redelivers an unacknowledged event for ~3 days
|
|
272
|
+
// (up to ~25 attempts) — a claim that expired before the last legitimate
|
|
273
|
+
// redelivery would let a captured payload re-apply. Claimed ids are
|
|
274
|
+
// namespaced ("paypal:<event-id>") so the two providers can never collide
|
|
275
|
+
// in the shared table.
|
|
276
|
+
var PAYPAL_REPLAY_TTL_MS = b.constants.TIME.days(3);
|
|
265
277
|
var _stripeReplayStore = null;
|
|
266
278
|
function _stripeReplay() {
|
|
267
279
|
if (!webhookReplayQuery) return null;
|
|
@@ -300,6 +312,147 @@ function create(deps) {
|
|
|
300
312
|
return _stripeReplayStore;
|
|
301
313
|
}
|
|
302
314
|
|
|
315
|
+
// Has a provider refund with this id already been mirrored into the
|
|
316
|
+
// order's ledger? Scans the hydrated transition rows for a `refund` row
|
|
317
|
+
// whose metadata carries the same provider refund id. This is what makes
|
|
318
|
+
// the webhook refund mirror idempotent across BOTH paths a refund reaches
|
|
319
|
+
// us twice: the admin console issues the refund (stamping the provider
|
|
320
|
+
// refund id on its ledger row) and the provider then mirrors the same
|
|
321
|
+
// refund back as a webhook; or the provider redelivers the same event
|
|
322
|
+
// under a fresh delivery attempt after the replay claim's TTL.
|
|
323
|
+
function _refundAlreadyRecorded(o, metaKey, refundId) {
|
|
324
|
+
if (!refundId) return false;
|
|
325
|
+
var rows = (o && o.transitions) || [];
|
|
326
|
+
for (var i = 0; i < rows.length; i += 1) {
|
|
327
|
+
if (rows[i].on_event !== "refund") continue;
|
|
328
|
+
var meta;
|
|
329
|
+
try { meta = JSON.parse(rows[i].metadata_json || "{}"); }
|
|
330
|
+
catch (_e) { meta = {}; }
|
|
331
|
+
if (meta && meta[metaKey] === refundId) return true;
|
|
332
|
+
}
|
|
333
|
+
return false;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Mirror a PayPal PAYMENT.CAPTURE.REFUNDED event into the order ledger,
|
|
337
|
+
// AMOUNT-AWARE. The resource is the refund object itself: its `amount` is
|
|
338
|
+
// that single refund's value (NOT a cumulative figure), so a $5 dashboard
|
|
339
|
+
// refund on a $50 order must append a $5 partial-refund row — never drive
|
|
340
|
+
// the terminal refund edge, which re-credits every gift-card/loyalty
|
|
341
|
+
// credit on the order. Only a slice that clears the remaining balance is
|
|
342
|
+
// terminal. A missing/garbled/currency-mismatched amount THROWS a coded
|
|
343
|
+
// error the webhook route maps to a 5xx so PayPal redelivers — guessing
|
|
344
|
+
// "full refund" here is customer-influenceable value creation.
|
|
345
|
+
async function _mirrorPaypalRefund(o, event, ppOrderId) {
|
|
346
|
+
var eventType = event.event_type;
|
|
347
|
+
var resource = event.resource || {};
|
|
348
|
+
var refundId = (typeof resource.id === "string" && resource.id.length) ? resource.id : null;
|
|
349
|
+
if (o.status === "refunded") {
|
|
350
|
+
return { handled: true, event_type: eventType, order: o, skipped: "already-advanced" };
|
|
351
|
+
}
|
|
352
|
+
if (_refundAlreadyRecorded(o, "paypal_refund_id", refundId)) {
|
|
353
|
+
return { handled: true, event_type: eventType, order: o, skipped: "already-recorded", paypal_refund_id: refundId };
|
|
354
|
+
}
|
|
355
|
+
var amount = resource.amount || {};
|
|
356
|
+
var amountMinor;
|
|
357
|
+
try {
|
|
358
|
+
var ccy = typeof amount.currency_code === "string" ? amount.currency_code.toUpperCase() : "";
|
|
359
|
+
if (ccy !== String(o.currency || "").toUpperCase()) {
|
|
360
|
+
throw new TypeError("refund currency " + JSON.stringify(amount.currency_code) +
|
|
361
|
+
" does not match order currency " + JSON.stringify(o.currency));
|
|
362
|
+
}
|
|
363
|
+
amountMinor = paymentLib._decimalToMinor(amount.value, ccy);
|
|
364
|
+
} catch (e) {
|
|
365
|
+
var bad = new Error("checkout: PAYMENT.CAPTURE.REFUNDED resource.amount is missing or unparseable — " +
|
|
366
|
+
((e && e.message) || String(e)));
|
|
367
|
+
bad.code = "PAYPAL_REFUND_AMOUNT_INVALID";
|
|
368
|
+
throw bad;
|
|
369
|
+
}
|
|
370
|
+
if (amountMinor <= 0) {
|
|
371
|
+
var zero = new Error("checkout: PAYMENT.CAPTURE.REFUNDED resource.amount must be positive");
|
|
372
|
+
zero.code = "PAYPAL_REFUND_AMOUNT_INVALID";
|
|
373
|
+
throw zero;
|
|
374
|
+
}
|
|
375
|
+
var refundedSoFar = await order.refundedTotalMinor(o.id);
|
|
376
|
+
var grand = Number(o.grand_total_minor) || 0;
|
|
377
|
+
var remaining = grand - refundedSoFar;
|
|
378
|
+
if (remaining <= 0) {
|
|
379
|
+
return { handled: true, event_type: eventType, order: o, skipped: "nothing-remaining" };
|
|
380
|
+
}
|
|
381
|
+
var meta = { paypal_event_id: event.id, paypal_order_id: ppOrderId, paypal_refund_id: refundId };
|
|
382
|
+
if (amountMinor >= remaining) {
|
|
383
|
+
// Balance-clearing — drive the terminal refund edge (full credit
|
|
384
|
+
// reversal). The stamped amount is CAPPED at the remaining balance so
|
|
385
|
+
// refundedTotalMinor converges exactly on the grand total.
|
|
386
|
+
var updated = await order.transition(o.id, "refund", {
|
|
387
|
+
reason: "paypal:" + eventType,
|
|
388
|
+
metadata: Object.assign({}, meta, { amount_minor: remaining }),
|
|
389
|
+
});
|
|
390
|
+
return { handled: true, event_type: eventType, order: updated, amount_minor: remaining };
|
|
391
|
+
}
|
|
392
|
+
// Partial — append the same-state ledger row; recordPartialRefund runs
|
|
393
|
+
// the proportional gift-card / loyalty reversal against the cumulative
|
|
394
|
+
// refunded total.
|
|
395
|
+
var updatedPartial = await order.recordPartialRefund(o.id, {
|
|
396
|
+
amount_minor: amountMinor,
|
|
397
|
+
reason: "paypal:" + eventType,
|
|
398
|
+
metadata: meta,
|
|
399
|
+
});
|
|
400
|
+
return { handled: true, event_type: eventType, order: updatedPartial, partial: true, amount_minor: amountMinor };
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Mirror a Stripe charge.refunded event into the order ledger,
|
|
404
|
+
// AMOUNT-AWARE — same discipline as the PayPal mirror above, adapted to
|
|
405
|
+
// Stripe's shape: charge.refunded fires on EVERY refund (partial
|
|
406
|
+
// included); `amount_refunded` is the CUMULATIVE minor-unit total refunded
|
|
407
|
+
// on the charge and `refunded` is true only when the charge is fully
|
|
408
|
+
// refunded. The mirrored slice is the DELTA between Stripe's cumulative
|
|
409
|
+
// figure and the local ledger, which makes the mirror naturally
|
|
410
|
+
// idempotent against the console's own refunds (the console records
|
|
411
|
+
// first; the event's delta is then zero). A missing/garbled
|
|
412
|
+
// amount_refunded throws (5xx → Stripe redelivers) — never guess full.
|
|
413
|
+
async function _mirrorStripeRefund(o, event) {
|
|
414
|
+
var eventType = event.type;
|
|
415
|
+
if (o.status === "refunded") {
|
|
416
|
+
return { handled: true, event_type: eventType, order: o, skipped: "already-advanced" };
|
|
417
|
+
}
|
|
418
|
+
var charge = (event.data && event.data.object) || {};
|
|
419
|
+
var cumulative = charge.amount_refunded;
|
|
420
|
+
if (!Number.isInteger(cumulative) || cumulative < 0) {
|
|
421
|
+
var bad = new Error("checkout: charge.refunded carries no integer amount_refunded — refusing to guess a refund amount");
|
|
422
|
+
bad.code = "STRIPE_REFUND_AMOUNT_INVALID";
|
|
423
|
+
throw bad;
|
|
424
|
+
}
|
|
425
|
+
var refundedSoFar = await order.refundedTotalMinor(o.id);
|
|
426
|
+
var grand = Number(o.grand_total_minor) || 0;
|
|
427
|
+
var remaining = grand - refundedSoFar;
|
|
428
|
+
var delta = cumulative - refundedSoFar;
|
|
429
|
+
var meta = { stripe_event_id: event.id };
|
|
430
|
+
if (charge.refunded === true || cumulative >= grand) {
|
|
431
|
+
// Charge fully refunded — terminal edge. (On a split-tender order the
|
|
432
|
+
// charge covers only the provider-paid share; a full charge refund
|
|
433
|
+
// still voids the order, and the terminal edge re-credits the
|
|
434
|
+
// gift-card / loyalty share — same semantics the admin full-refund
|
|
435
|
+
// console applies.) Stamp the capped remaining balance so the ledger
|
|
436
|
+
// converges on the grand total.
|
|
437
|
+
var updated = await order.transition(o.id, "refund", {
|
|
438
|
+
reason: "stripe:" + eventType,
|
|
439
|
+
metadata: remaining > 0 ? Object.assign({}, meta, { amount_minor: remaining }) : meta,
|
|
440
|
+
});
|
|
441
|
+
return { handled: true, event_type: eventType, order: updated };
|
|
442
|
+
}
|
|
443
|
+
if (delta <= 0) {
|
|
444
|
+
// Ledger already at (or past) Stripe's cumulative figure — the
|
|
445
|
+
// console mirrored this refund when it issued it.
|
|
446
|
+
return { handled: true, event_type: eventType, order: o, skipped: "already-recorded" };
|
|
447
|
+
}
|
|
448
|
+
var updatedPartial = await order.recordPartialRefund(o.id, {
|
|
449
|
+
amount_minor: delta,
|
|
450
|
+
reason: "stripe:" + eventType,
|
|
451
|
+
metadata: meta,
|
|
452
|
+
});
|
|
453
|
+
return { handled: true, event_type: eventType, order: updatedPartial, partial: true, amount_minor: delta };
|
|
454
|
+
}
|
|
455
|
+
|
|
303
456
|
// Reprice a list of cart lines through the quantity-discount engine.
|
|
304
457
|
// Returns a shallow copy with `unit_amount_minor` overwritten by the
|
|
305
458
|
// discounted unit for each line's SKU at its quantity. A line whose
|
|
@@ -1095,6 +1248,7 @@ function create(deps) {
|
|
|
1095
1248
|
shipping_minor: quote.totals.shipping_minor,
|
|
1096
1249
|
grand_total_minor: quote.totals.grand_total_minor,
|
|
1097
1250
|
payment_intent_id: null,
|
|
1251
|
+
payment_provider: null, // credits covered the whole total — no provider charge to refund
|
|
1098
1252
|
ship_to: input.ship_to,
|
|
1099
1253
|
customer_email_hash: emailHash,
|
|
1100
1254
|
lines: orderLines,
|
|
@@ -1162,6 +1316,7 @@ function create(deps) {
|
|
|
1162
1316
|
shipping_minor: quote.totals.shipping_minor,
|
|
1163
1317
|
grand_total_minor: quote.totals.grand_total_minor,
|
|
1164
1318
|
payment_intent_id: pi.id,
|
|
1319
|
+
payment_provider: "stripe", // refund surfaces route the refund dial by this
|
|
1165
1320
|
ship_to: input.ship_to,
|
|
1166
1321
|
customer_email_hash: emailHash,
|
|
1167
1322
|
lines: orderLines,
|
|
@@ -1358,6 +1513,13 @@ function create(deps) {
|
|
|
1358
1513
|
var o = await order.byPaymentIntent(pi);
|
|
1359
1514
|
if (!o) return { handled: false, event_type: eventType, reason: "order-not-found" };
|
|
1360
1515
|
|
|
1516
|
+
// Refund events are AMOUNT-AWARE — a partial dashboard refund must
|
|
1517
|
+
// append a partial ledger row, never drive the terminal refund edge
|
|
1518
|
+
// (which re-credits every gift-card/loyalty credit on the order).
|
|
1519
|
+
if (eventType === "charge.refunded") {
|
|
1520
|
+
return await _mirrorStripeRefund(o, event);
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1361
1523
|
// Idempotency: if the order is already in a state the event
|
|
1362
1524
|
// would advance to, skip the transition (re-deliveries from
|
|
1363
1525
|
// Stripe are common).
|
|
@@ -1424,16 +1586,31 @@ function create(deps) {
|
|
|
1424
1586
|
// Resolve an optional gift-card credit before opening the PayPal
|
|
1425
1587
|
// order so a bad code fails without a remote round-trip.
|
|
1426
1588
|
var gc = await _resolveGiftCard(input.gift_card_code, quote);
|
|
1427
|
-
var amountDue = quote.totals.grand_total_minor - (gc ? gc.applied_minor : 0);
|
|
1428
1589
|
var cartRow = await cart.get(quote.cart_id);
|
|
1590
|
+
// Loyalty points credit stacks on top of any gift-card credit — same
|
|
1591
|
+
// resolution + residual re-cap discipline as the Stripe confirm path
|
|
1592
|
+
// (_confirmAfterHolds), so the two payment buttons honor identical
|
|
1593
|
+
// credits. Requires a signed-in customer; the resolver refuses a
|
|
1594
|
+
// points request on a guest cart with a clean coded error.
|
|
1595
|
+
var ppLoyaltyCustomerId = cartRow ? (cartRow.customer_id || null) : null;
|
|
1596
|
+
var loy = await _resolveLoyaltyCredit(input.loyalty_redeem_points, ppLoyaltyCustomerId, quote);
|
|
1597
|
+
var afterGiftCard = quote.totals.grand_total_minor - (gc ? gc.applied_minor : 0);
|
|
1598
|
+
if (loy && loy.applied_minor > afterGiftCard) {
|
|
1599
|
+
loy.applied_minor = afterGiftCard < 0 ? 0 : afterGiftCard;
|
|
1600
|
+
var ppPerUsd = loyalty.REDEMPTION_POINTS_PER_USD;
|
|
1601
|
+
loy.points = Math.ceil((loy.applied_minor * ppPerUsd) / 100);
|
|
1602
|
+
if (loy.applied_minor <= 0) loy = null;
|
|
1603
|
+
}
|
|
1604
|
+
var amountDue = afterGiftCard - (loy ? loy.applied_minor : 0);
|
|
1429
1605
|
var emailHash = customers ? customers.hashEmail(email) : null;
|
|
1430
1606
|
var ppLines = quote.lines.map(function (l) {
|
|
1431
1607
|
return { variant_id: l.variant_id, sku: l.sku, qty: l.qty, unit_amount_minor: l.unit_amount_minor, unit_currency: l.unit_currency, stock_held_qty: l._held_qty || 0 };
|
|
1432
1608
|
});
|
|
1433
1609
|
|
|
1434
|
-
//
|
|
1435
|
-
//
|
|
1436
|
-
//
|
|
1610
|
+
// Credits fully cover the order — no PayPal order (PayPal refuses a
|
|
1611
|
+
// zero-amount order). Create + burn + mark paid, same as the Stripe
|
|
1612
|
+
// full-coverage path. No provider charge → payment_provider stays
|
|
1613
|
+
// null (there is nothing a provider could refund).
|
|
1437
1614
|
if (amountDue === 0) {
|
|
1438
1615
|
var paidOrder = await order.createFromCart({
|
|
1439
1616
|
cart_id: quote.cart_id,
|
|
@@ -1446,6 +1623,7 @@ function create(deps) {
|
|
|
1446
1623
|
shipping_minor: quote.totals.shipping_minor,
|
|
1447
1624
|
grand_total_minor: quote.totals.grand_total_minor,
|
|
1448
1625
|
payment_intent_id: null,
|
|
1626
|
+
payment_provider: null,
|
|
1449
1627
|
ship_to: input.ship_to,
|
|
1450
1628
|
customer_email_hash: emailHash,
|
|
1451
1629
|
lines: ppLines,
|
|
@@ -1455,11 +1633,20 @@ function create(deps) {
|
|
|
1455
1633
|
// so a redeem failure is captured and reconciled, never allowed to
|
|
1456
1634
|
// strand the cart un-converted.
|
|
1457
1635
|
await _settleCreditPostCreate("giftcard", paidOrder.id, function () {
|
|
1458
|
-
return _redeemGiftCard(gc, paidOrder.id);
|
|
1636
|
+
return gc ? _redeemGiftCard(gc, paidOrder.id) : null;
|
|
1637
|
+
});
|
|
1638
|
+
await _settleCreditPostCreate("loyalty", paidOrder.id, function () {
|
|
1639
|
+
return loy ? _redeemLoyalty(loy, ppLoyaltyCustomerId, paidOrder.id) : null;
|
|
1459
1640
|
});
|
|
1460
1641
|
await cart.setStatus(quote.cart_id, "converted");
|
|
1461
|
-
var settled = await order.transition(paidOrder.id, "mark_paid", {
|
|
1462
|
-
|
|
1642
|
+
var settled = await order.transition(paidOrder.id, "mark_paid", {
|
|
1643
|
+
reason: loy && !gc ? "loyalty:full" : "gift_card:full",
|
|
1644
|
+
});
|
|
1645
|
+
return {
|
|
1646
|
+
order: settled, paypal_order_id: null, status: "PAID_BY_GIFT_CARD",
|
|
1647
|
+
gift_card: gc ? { applied_minor: gc.applied_minor, amount_due_minor: 0 } : null,
|
|
1648
|
+
loyalty: loy ? { points: loy.points, applied_minor: loy.applied_minor, amount_due_minor: 0 } : null,
|
|
1649
|
+
};
|
|
1463
1650
|
}
|
|
1464
1651
|
|
|
1465
1652
|
var ppOrder = await paypal.createOrder({
|
|
@@ -1480,21 +1667,30 @@ function create(deps) {
|
|
|
1480
1667
|
shipping_minor: quote.totals.shipping_minor,
|
|
1481
1668
|
grand_total_minor: quote.totals.grand_total_minor,
|
|
1482
1669
|
payment_intent_id: ppOrder.id, // the PayPal order id (opaque); links the webhook + capture
|
|
1670
|
+
payment_provider: "paypal", // refund surfaces route the refund dial by this
|
|
1483
1671
|
ship_to: input.ship_to,
|
|
1484
1672
|
customer_email_hash: emailHash,
|
|
1485
1673
|
lines: ppLines,
|
|
1486
1674
|
});
|
|
1487
1675
|
ppOrderCreated = true;
|
|
1488
|
-
// Post-commit gift-card burn — captured, never stranding.
|
|
1489
|
-
// + the PayPal order already exist; a redeem throw must
|
|
1490
|
-
// the outer catch (which re-throws and would leave the
|
|
1491
|
-
// an orphaned PayPal order). The buyer pays the
|
|
1492
|
-
// amount; a failed debit is surfaced for
|
|
1676
|
+
// Post-commit gift-card + loyalty burn — captured, never stranding.
|
|
1677
|
+
// The order row + the PayPal order already exist; a redeem throw must
|
|
1678
|
+
// not bubble to the outer catch (which re-throws and would leave the
|
|
1679
|
+
// cart active with an orphaned PayPal order). The buyer pays the
|
|
1680
|
+
// credit-reduced PayPal amount; a failed debit is surfaced for
|
|
1681
|
+
// reconciliation.
|
|
1493
1682
|
await _settleCreditPostCreate("giftcard", createdOrder.id, function () {
|
|
1494
1683
|
return gc ? _redeemGiftCard(gc, createdOrder.id) : null;
|
|
1495
1684
|
});
|
|
1685
|
+
await _settleCreditPostCreate("loyalty", createdOrder.id, function () {
|
|
1686
|
+
return loy ? _redeemLoyalty(loy, ppLoyaltyCustomerId, createdOrder.id) : null;
|
|
1687
|
+
});
|
|
1496
1688
|
await cart.setStatus(quote.cart_id, "converted");
|
|
1497
|
-
return {
|
|
1689
|
+
return {
|
|
1690
|
+
order: createdOrder, paypal_order_id: ppOrder.id, status: ppOrder.status,
|
|
1691
|
+
gift_card: gc ? { applied_minor: gc.applied_minor, amount_due_minor: amountDue } : null,
|
|
1692
|
+
loyalty: loy ? { points: loy.points, applied_minor: loy.applied_minor, amount_due_minor: amountDue } : null,
|
|
1693
|
+
};
|
|
1498
1694
|
} catch (e) {
|
|
1499
1695
|
// A throw BEFORE the order row commits (PayPal open failure,
|
|
1500
1696
|
// gift-card error) releases the holds so PayPal can't strand stock.
|
|
@@ -1527,6 +1723,16 @@ function create(deps) {
|
|
|
1527
1723
|
var completed = cap && (cap.status === "COMPLETED" ||
|
|
1528
1724
|
(captureId && cap.purchase_units[0].payments.captures[0].status === "COMPLETED"));
|
|
1529
1725
|
if (completed && o.status === "pending") {
|
|
1726
|
+
// Persist the capture id on the order row — refunds dial
|
|
1727
|
+
// /v2/payments/captures/<capture-id>/refund, NOT the PayPal order id
|
|
1728
|
+
// stored in payment_intent_id. Best-effort (drop-silent — by
|
|
1729
|
+
// design): the metadata stamp on the transition below remains the
|
|
1730
|
+
// recoverable source, and a persistence refusal must never block
|
|
1731
|
+
// settling a real payment.
|
|
1732
|
+
if (captureId) {
|
|
1733
|
+
try { await order.setPaypalCapture(o.id, captureId); }
|
|
1734
|
+
catch (_e2) { /* drop-silent — recoverable from the transition metadata */ }
|
|
1735
|
+
}
|
|
1530
1736
|
await order.transition(o.id, "mark_paid", {
|
|
1531
1737
|
reason: "paypal:capture",
|
|
1532
1738
|
metadata: { paypal_order_id: paypalOrderId, paypal_capture_id: captureId },
|
|
@@ -1554,6 +1760,24 @@ function create(deps) {
|
|
|
1554
1760
|
if (!eventType || !Object.prototype.hasOwnProperty.call(PAYPAL_EVENT_MAP, eventType)) {
|
|
1555
1761
|
return { handled: false, event_type: eventType || null };
|
|
1556
1762
|
}
|
|
1763
|
+
|
|
1764
|
+
// Replay defense — atomically claim this verified event id BEFORE any
|
|
1765
|
+
// transition or ledger write, same ordering as the Stripe handler.
|
|
1766
|
+
// Load-bearing for the refund mirror below: recordPartialRefund is an
|
|
1767
|
+
// append (not state-idempotent), so a re-delivered partial REFUNDED
|
|
1768
|
+
// event that raced past state checks would otherwise double-append.
|
|
1769
|
+
// A store error fails CLOSED inside the nonceStore (not-fresh), so a
|
|
1770
|
+
// wiped/unreachable store refuses rather than re-applies. No-op when
|
|
1771
|
+
// the store isn't wired (the refund-id dedupe in the mirror still
|
|
1772
|
+
// covers sequential re-delivery).
|
|
1773
|
+
var replay = _stripeReplay();
|
|
1774
|
+
if (replay && typeof event.id === "string" && event.id.length > 0) {
|
|
1775
|
+
var fresh = await replay.checkAndInsert("paypal:" + event.id, Date.now() + PAYPAL_REPLAY_TTL_MS);
|
|
1776
|
+
if (!fresh) {
|
|
1777
|
+
return { handled: true, event_type: eventType, skipped: "replay", event_id: event.id };
|
|
1778
|
+
}
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1557
1781
|
var fsmEvent = PAYPAL_EVENT_MAP[eventType];
|
|
1558
1782
|
if (!fsmEvent) return { handled: false, event_type: eventType, reason: "no-state-change" };
|
|
1559
1783
|
// The PayPal order id lives in the capture resource's related ids.
|
|
@@ -1562,14 +1786,34 @@ function create(deps) {
|
|
|
1562
1786
|
if (!ppOrderId) return { handled: false, event_type: eventType, reason: "no-order-id" };
|
|
1563
1787
|
var o = await order.byPaymentIntent(ppOrderId);
|
|
1564
1788
|
if (!o) return { handled: false, event_type: eventType, reason: "order-not-found" };
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
if (
|
|
1789
|
+
|
|
1790
|
+
// Refund events are AMOUNT-AWARE — see _mirrorPaypalRefund: a partial
|
|
1791
|
+
// dashboard refund appends a partial ledger row; only a
|
|
1792
|
+
// balance-clearing slice drives the terminal refund edge.
|
|
1793
|
+
if (eventType === "PAYMENT.CAPTURE.REFUNDED") {
|
|
1794
|
+
return await _mirrorPaypalRefund(o, event, ppOrderId);
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
if (fsmEvent === "mark_paid" && o.status !== "pending") {
|
|
1798
|
+
return { handled: true, event_type: eventType, order: o, skipped: "already-advanced" };
|
|
1799
|
+
}
|
|
1800
|
+
// The COMPLETED resource is the capture itself — persist its id so
|
|
1801
|
+
// refunds can run against the capture without re-dialing PayPal. The
|
|
1802
|
+
// write is best-effort here (the metadata stamp below remains the
|
|
1803
|
+
// recoverable source): a refused write must never block settling a
|
|
1804
|
+
// real payment.
|
|
1805
|
+
var ppCaptureId = (eventType === "PAYMENT.CAPTURE.COMPLETED" && event.resource &&
|
|
1806
|
+
typeof event.resource.id === "string" && event.resource.id.length)
|
|
1807
|
+
? event.resource.id : null;
|
|
1808
|
+
if (ppCaptureId) {
|
|
1809
|
+
try { await order.setPaypalCapture(o.id, ppCaptureId); }
|
|
1810
|
+
catch (_e) { /* drop-silent — by design: capture-id persistence must not block mark_paid; the transition metadata keeps it recoverable */ }
|
|
1811
|
+
}
|
|
1570
1812
|
var updated = await order.transition(o.id, fsmEvent, {
|
|
1571
1813
|
reason: "paypal:" + eventType,
|
|
1572
|
-
metadata:
|
|
1814
|
+
metadata: ppCaptureId
|
|
1815
|
+
? { paypal_event_id: event.id, paypal_order_id: ppOrderId, paypal_capture_id: ppCaptureId }
|
|
1816
|
+
: { paypal_event_id: event.id, paypal_order_id: ppOrderId },
|
|
1573
1817
|
});
|
|
1574
1818
|
return { handled: true, event_type: eventType, order: updated };
|
|
1575
1819
|
},
|
package/lib/order.js
CHANGED
|
@@ -443,19 +443,28 @@ function create(opts) {
|
|
|
443
443
|
_nonNegInt(input.shipping_minor, "shipping_minor");
|
|
444
444
|
_nonNegInt(input.grand_total_minor, "grand_total_minor");
|
|
445
445
|
_shipTo(input.ship_to);
|
|
446
|
+
// Which payment provider captured (or will capture) this order's
|
|
447
|
+
// charge — refund surfaces route the refund dial by this column.
|
|
448
|
+
// Optional: a fully-credited order (gift card / loyalty cover the
|
|
449
|
+
// whole total) carries no provider charge and stays NULL.
|
|
450
|
+
if (input.payment_provider != null &&
|
|
451
|
+
input.payment_provider !== "stripe" && input.payment_provider !== "paypal") {
|
|
452
|
+
throw new TypeError("order.createFromCart: payment_provider must be 'stripe', 'paypal', or null");
|
|
453
|
+
}
|
|
446
454
|
|
|
447
455
|
var id = b.uuid.v7();
|
|
448
456
|
var ts = _now();
|
|
449
457
|
await query(
|
|
450
458
|
"INSERT INTO orders (id, cart_id, customer_id, session_id, status, currency, " +
|
|
451
459
|
"subtotal_minor, discount_minor, tax_minor, shipping_minor, grand_total_minor, " +
|
|
452
|
-
"payment_intent_id, ship_to_json, customer_email_hash, created_at, updated_at) " +
|
|
453
|
-
"VALUES (?1, ?2, ?3, ?4, 'pending', ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?
|
|
460
|
+
"payment_intent_id, payment_provider, ship_to_json, customer_email_hash, created_at, updated_at) " +
|
|
461
|
+
"VALUES (?1, ?2, ?3, ?4, 'pending', ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?15)",
|
|
454
462
|
[
|
|
455
463
|
id, input.cart_id, input.customer_id || null, input.session_id,
|
|
456
464
|
input.currency, input.subtotal_minor, input.discount_minor,
|
|
457
465
|
input.tax_minor, input.shipping_minor, input.grand_total_minor,
|
|
458
|
-
input.payment_intent_id || null,
|
|
466
|
+
input.payment_intent_id || null, input.payment_provider || null,
|
|
467
|
+
JSON.stringify(input.ship_to),
|
|
459
468
|
input.customer_email_hash || null, ts,
|
|
460
469
|
],
|
|
461
470
|
);
|
|
@@ -518,6 +527,63 @@ function create(opts) {
|
|
|
518
527
|
return await this.get(rows[0].id);
|
|
519
528
|
},
|
|
520
529
|
|
|
530
|
+
// Persist the PayPal CAPTURE id on the order (and stamp the provider) —
|
|
531
|
+
// PayPal refunds run against the capture, not the order id stored in
|
|
532
|
+
// payment_intent_id. Called by the capture flow and the
|
|
533
|
+
// PAYMENT.CAPTURE.COMPLETED webhook backstop. Write-once: the first
|
|
534
|
+
// capture id recorded wins; a different id for an already-captured order
|
|
535
|
+
// is refused with a coded error (an order has exactly one capture in
|
|
536
|
+
// this flow — a mismatch means crossed wires, never something to
|
|
537
|
+
// overwrite silently). Re-recording the SAME id is an idempotent no-op.
|
|
538
|
+
setPaypalCapture: async function (orderId, captureId) {
|
|
539
|
+
_uuid(orderId, "order id");
|
|
540
|
+
if (typeof captureId !== "string" || !captureId.length || captureId.length > 64 ||
|
|
541
|
+
/[\u0000-\u001f\u007f]/.test(captureId)) {
|
|
542
|
+
throw new TypeError("order.setPaypalCapture: captureId must be a non-empty string <= 64 chars without control bytes");
|
|
543
|
+
}
|
|
544
|
+
var upd = await query(
|
|
545
|
+
"UPDATE orders SET paypal_capture_id = ?1, payment_provider = 'paypal' " +
|
|
546
|
+
"WHERE id = ?2 AND (paypal_capture_id IS NULL OR paypal_capture_id = ?1)",
|
|
547
|
+
[captureId, orderId],
|
|
548
|
+
);
|
|
549
|
+
if (Number(upd.rowCount || 0) > 0) return true;
|
|
550
|
+
var row = (await query("SELECT id, paypal_capture_id FROM orders WHERE id = ?1", [orderId])).rows[0];
|
|
551
|
+
if (!row) throw new TypeError("order.setPaypalCapture: order " + orderId + " not found");
|
|
552
|
+
var err = new Error("order.setPaypalCapture: order " + orderId + " already carries capture " +
|
|
553
|
+
row.paypal_capture_id + " — refusing to overwrite with " + captureId);
|
|
554
|
+
err.code = "PAYPAL_CAPTURE_MISMATCH";
|
|
555
|
+
throw err;
|
|
556
|
+
},
|
|
557
|
+
|
|
558
|
+
// Resolve the PayPal capture id for an order: the persisted column when
|
|
559
|
+
// present, else recovered from the order's transition metadata (the
|
|
560
|
+
// capture flow stamps `paypal_capture_id` on the mark_paid transition —
|
|
561
|
+
// the only place pre-existing orders recorded it) and healed back onto
|
|
562
|
+
// the column so the scan runs at most once per order. Returns the
|
|
563
|
+
// capture id string or null (a Stripe / uncaptured order). Callers with
|
|
564
|
+
// a PayPal handle may fall back to a remote getOrder read when this
|
|
565
|
+
// local resolution returns null.
|
|
566
|
+
paypalCaptureId: async function (orderId) {
|
|
567
|
+
_uuid(orderId, "order id");
|
|
568
|
+
var row = (await query("SELECT paypal_capture_id FROM orders WHERE id = ?1", [orderId])).rows[0];
|
|
569
|
+
if (!row) return null;
|
|
570
|
+
if (row.paypal_capture_id) return row.paypal_capture_id;
|
|
571
|
+
var transitions = (await query(
|
|
572
|
+
"SELECT metadata_json FROM order_transitions WHERE order_id = ?1 ORDER BY occurred_at ASC",
|
|
573
|
+
[orderId],
|
|
574
|
+
)).rows;
|
|
575
|
+
for (var i = 0; i < transitions.length; i += 1) {
|
|
576
|
+
var meta;
|
|
577
|
+
try { meta = JSON.parse(transitions[i].metadata_json || "{}"); }
|
|
578
|
+
catch (_e) { meta = {}; }
|
|
579
|
+
if (meta && typeof meta.paypal_capture_id === "string" && meta.paypal_capture_id.length) {
|
|
580
|
+
await this.setPaypalCapture(orderId, meta.paypal_capture_id);
|
|
581
|
+
return meta.paypal_capture_id;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
return null;
|
|
585
|
+
},
|
|
586
|
+
|
|
521
587
|
// Fire a transition. Replays the FSM from the current state +
|
|
522
588
|
// history, dispatches the event, and persists the new state on
|
|
523
589
|
// the orders row + appends an order_transitions row.
|
package/lib/payment.js
CHANGED
|
@@ -784,6 +784,32 @@ function _minorToDecimalString(minor, currency) {
|
|
|
784
784
|
return (neg ? "-" : "") + s.slice(0, s.length - dec) + "." + s.slice(s.length - dec);
|
|
785
785
|
}
|
|
786
786
|
|
|
787
|
+
// Inverse of _minorToDecimalString: parse a PayPal decimal amount string
|
|
788
|
+
// (e.g. webhook `resource.amount.value`) into exact integer minor units,
|
|
789
|
+
// using the same zero-decimal currency table. STRICT — money parsed off an
|
|
790
|
+
// inbound webhook decides refund accounting, so a malformed shape throws a
|
|
791
|
+
// TypeError rather than guessing (the caller maps that to a 5xx so the
|
|
792
|
+
// processor re-delivers; a guessed amount would silently mis-credit). Pure
|
|
793
|
+
// digit-string arithmetic — the value never passes through a float.
|
|
794
|
+
function _decimalToMinor(value, currency) {
|
|
795
|
+
if (typeof currency !== "string" || !/^[A-Z]{3}$/.test(currency)) {
|
|
796
|
+
throw new TypeError("payment: decimal amount currency must be a 3-letter uppercase ISO 4217 code");
|
|
797
|
+
}
|
|
798
|
+
if (typeof value !== "string" || !/^\d{1,15}(\.\d{1,2})?$/.test(value)) {
|
|
799
|
+
throw new TypeError("payment: decimal amount must be a plain non-negative decimal string (got " + JSON.stringify(value) + ")");
|
|
800
|
+
}
|
|
801
|
+
var dec = PAYPAL_ZERO_DECIMAL[currency] ? 0 : 2;
|
|
802
|
+
var parts = value.split(".");
|
|
803
|
+
var frac = parts[1] || "";
|
|
804
|
+
if (frac.length > dec) {
|
|
805
|
+
// More fractional digits than the currency carries (e.g. "100.50" JPY)
|
|
806
|
+
// is a garbled amount, not a roundable one — refuse, never round money.
|
|
807
|
+
throw new TypeError("payment: decimal amount " + JSON.stringify(value) + " has more fractional digits than " + currency + " allows");
|
|
808
|
+
}
|
|
809
|
+
while (frac.length < dec) frac += "0";
|
|
810
|
+
return parseInt(parts[0] + frac, 10);
|
|
811
|
+
}
|
|
812
|
+
|
|
787
813
|
function _headerCI(headers, name) {
|
|
788
814
|
if (!headers) return undefined;
|
|
789
815
|
if (headers[name] != null) return headers[name];
|
|
@@ -833,7 +859,13 @@ async function _paypalToken(opts, state) {
|
|
|
833
859
|
return state.token;
|
|
834
860
|
}
|
|
835
861
|
|
|
836
|
-
|
|
862
|
+
// `breaker` selects which circuit the dial rides — every payment call rides
|
|
863
|
+
// the adapter's main `opts._breaker`; the webhook-verification dial rides its
|
|
864
|
+
// own (see verifyWebhook) so attacker-shaped verification traffic can't trip
|
|
865
|
+
// the circuit live checkouts depend on. The token exchange inside always
|
|
866
|
+
// rides the main breaker: its failures are credential/PayPal-health signals,
|
|
867
|
+
// not attacker-controllable per-request outcomes.
|
|
868
|
+
async function _paypalCall(opts, state, method, path, bodyObj, requestId, breaker) {
|
|
837
869
|
var token = await _paypalToken(opts, state);
|
|
838
870
|
var headers = {
|
|
839
871
|
"authorization": "Bearer " + token,
|
|
@@ -855,7 +887,7 @@ async function _paypalCall(opts, state, method, path, bodyObj, requestId) {
|
|
|
855
887
|
// same id rides every retry attempt within one call). A keyless write
|
|
856
888
|
// rides the breaker but not the retry.
|
|
857
889
|
var idempotent = method === "GET" || !!requestId;
|
|
858
|
-
var json = await _dial(opts._breaker, idempotent, async function () {
|
|
890
|
+
var json = await _dial(breaker === undefined ? opts._breaker : breaker, idempotent, async function () {
|
|
859
891
|
var res = await httpClient.request({
|
|
860
892
|
method: method,
|
|
861
893
|
url: _paypalApiBase(opts) + path,
|
|
@@ -898,6 +930,17 @@ function paypal(opts) {
|
|
|
898
930
|
if (opts._breaker === undefined) {
|
|
899
931
|
opts._breaker = opts.breaker === false ? null : _makeBreaker("psp-paypal");
|
|
900
932
|
}
|
|
933
|
+
// SEPARATE breaker for the webhook-verification dial. The verify call's
|
|
934
|
+
// failure rate is attacker-influenceable: any header-complete spam POST to
|
|
935
|
+
// the (necessarily unauthenticated) webhook route triggers a
|
|
936
|
+
// verify-webhook-signature dial whose 4xx counts as a breaker failure —
|
|
937
|
+
// five consecutive spam posts would otherwise open the SAME circuit live
|
|
938
|
+
// checkout's createOrder/captureOrder ride and fast-fail real payments for
|
|
939
|
+
// the cooldown window. Verification failures say nothing about PayPal's
|
|
940
|
+
// health as a payments peer, so they account against their own circuit.
|
|
941
|
+
if (opts._verifyBreaker === undefined) {
|
|
942
|
+
opts._verifyBreaker = opts.breaker === false ? null : _makeBreaker("psp-paypal-verify");
|
|
943
|
+
}
|
|
901
944
|
|
|
902
945
|
var state = {
|
|
903
946
|
query: opts.query || null,
|
|
@@ -923,8 +966,11 @@ function paypal(opts) {
|
|
|
923
966
|
name: "paypal",
|
|
924
967
|
|
|
925
968
|
// The per-adapter circuit breaker (or null when disabled). Same
|
|
926
|
-
// operator-dashboard surface as the Stripe adapter's.
|
|
927
|
-
|
|
969
|
+
// operator-dashboard surface as the Stripe adapter's. `verifyBreaker`
|
|
970
|
+
// is the webhook-verification dial's own circuit — kept separate so
|
|
971
|
+
// spam against the webhook route can't open the payment circuit.
|
|
972
|
+
breaker: opts._breaker,
|
|
973
|
+
verifyBreaker: opts._verifyBreaker,
|
|
928
974
|
|
|
929
975
|
// Create an Orders-v2 order (intent CAPTURE). The returned `id` is the
|
|
930
976
|
// PayPal order id the buyer approves; `captureOrder` finalizes it.
|
|
@@ -1020,7 +1066,10 @@ function paypal(opts) {
|
|
|
1020
1066
|
webhook_event: event,
|
|
1021
1067
|
};
|
|
1022
1068
|
var res;
|
|
1023
|
-
|
|
1069
|
+
// Rides the verify-only breaker (opts._verifyBreaker), never the main
|
|
1070
|
+
// payment circuit — see the factory comment: verification traffic is
|
|
1071
|
+
// attacker-shaped and must not be able to fast-fail live checkouts.
|
|
1072
|
+
try { res = await _paypalCall(opts, state, "POST", "/v1/notifications/verify-webhook-signature", verifyBody, null, opts._verifyBreaker); }
|
|
1024
1073
|
catch (e) { return { ok: false, reason: "verify-call-failed", error: e && e.message }; }
|
|
1025
1074
|
if (res && res.verification_status === "SUCCESS") return { ok: true, event: event };
|
|
1026
1075
|
return { ok: false, reason: "verification-status-" + ((res && res.verification_status) || "unknown") };
|
|
@@ -1036,14 +1085,40 @@ function create(opts) {
|
|
|
1036
1085
|
throw new TypeError("payment.create: unknown adapter " + JSON.stringify(opts.adapter) + " — 'stripe' and 'paypal' are supported");
|
|
1037
1086
|
}
|
|
1038
1087
|
|
|
1088
|
+
// Boot-time PayPal configuration lint, called by the server entry point so
|
|
1089
|
+
// an incomplete env surfaces in the boot log instead of as a silent feature
|
|
1090
|
+
// gap. Returns an array of operator-actionable warning strings (empty when
|
|
1091
|
+
// nothing is wrong). Pure read of the supplied env map — never throws, never
|
|
1092
|
+
// changes behavior: webhook verification stays MANDATORY and fails closed
|
|
1093
|
+
// whether or not the operator saw the warning.
|
|
1094
|
+
function paypalConfigWarnings(env) {
|
|
1095
|
+
env = env && typeof env === "object" ? env : {};
|
|
1096
|
+
var warnings = [];
|
|
1097
|
+
if (env.PAYPAL_CLIENT_ID && env.PAYPAL_SECRET && !env.PAYPAL_WEBHOOK_ID) {
|
|
1098
|
+
warnings.push(
|
|
1099
|
+
"PAYPAL_WEBHOOK_ID is not set: PayPal checkout is configured, but every " +
|
|
1100
|
+
"/api/webhooks/paypal delivery will be refused (verification fails closed " +
|
|
1101
|
+
"without the webhook id), so out-of-band captures and refunds will not " +
|
|
1102
|
+
"reach the order ledger. Set PAYPAL_WEBHOOK_ID to the webhook id from the " +
|
|
1103
|
+
"PayPal developer dashboard.");
|
|
1104
|
+
}
|
|
1105
|
+
return warnings;
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1039
1108
|
module.exports = {
|
|
1040
1109
|
create: create,
|
|
1041
1110
|
stripe: stripe,
|
|
1042
1111
|
paypal: paypal,
|
|
1112
|
+
paypalConfigWarnings: paypalConfigWarnings,
|
|
1043
1113
|
STRIPE_WEBHOOK_TOLERANCE: STRIPE_WEBHOOK_TOLERANCE,
|
|
1044
1114
|
IDEMPOTENCY_TTL_MS: IDEMPOTENCY_TTL_MS,
|
|
1045
1115
|
// Exposed for tests + Worker to share form-encoding shape.
|
|
1046
1116
|
_formEncode: _formEncode,
|
|
1047
1117
|
_verifyWebhook: _verifyWebhook,
|
|
1048
1118
|
_canonicalHash: _canonicalHash,
|
|
1119
|
+
// Exposed for the checkout webhook mirror + admin refund normalization —
|
|
1120
|
+
// exact decimal-string ↔ minor-unit conversion sharing one zero-decimal
|
|
1121
|
+
// currency table.
|
|
1122
|
+
_decimalToMinor: _decimalToMinor,
|
|
1123
|
+
_minorToDecimalString: _minorToDecimalString,
|
|
1049
1124
|
};
|
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
|
|
@@ -745,6 +753,36 @@ function mountRouteGuards(r) {
|
|
|
745
753
|
return clientKey(req) + "|" + (req.pathname || req.url || "/");
|
|
746
754
|
},
|
|
747
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
|
+
|
|
748
786
|
r.use(function tightRateGuard(req, res, next) {
|
|
749
787
|
var pathname = req.pathname || req.url || "/";
|
|
750
788
|
// Never throttle the webhook or health paths.
|
|
@@ -781,6 +819,7 @@ module.exports = {
|
|
|
781
819
|
CSP_HOSTS: CSP_HOSTS,
|
|
782
820
|
WEBHOOK_PATHS: WEBHOOK_PATHS,
|
|
783
821
|
HEALTH_PATH: HEALTH_PATH,
|
|
822
|
+
PAYPAL_WEBHOOK_BUDGET_PER_MINUTE: PAYPAL_WEBHOOK_BUDGET_PER_MINUTE,
|
|
784
823
|
TIGHT_PREFIXES: TIGHT_PREFIXES,
|
|
785
824
|
EDGE_POST_PATHS: EDGE_POST_PATHS,
|
|
786
825
|
PUBLIC_WELL_KNOWN_PATHS: PUBLIC_WELL_KNOWN_PATHS,
|
package/lib/storefront.js
CHANGED
|
@@ -14976,6 +14976,10 @@ function mount(router, deps) {
|
|
|
14976
14976
|
selected_shipping_id: body.selected_shipping_id || defaultShipId || "std",
|
|
14977
14977
|
customer: { email: body.email, name: body.name },
|
|
14978
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),
|
|
14979
14983
|
codes: ppCodes.length ? ppCodes : undefined,
|
|
14980
14984
|
idempotency_key: "paypal:" + c.id + ":" + b.uuid.v7(),
|
|
14981
14985
|
return_url: body.return_url || undefined,
|
|
@@ -14991,7 +14995,10 @@ function mount(router, deps) {
|
|
|
14991
14995
|
return _json(200, { id: created.paypal_order_id, order_id: created.order.id });
|
|
14992
14996
|
} catch (e) {
|
|
14993
14997
|
var ecode = (e && typeof e.code === "string") ? e.code : "";
|
|
14994
|
-
|
|
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;
|
|
14995
15002
|
// Out-of-stock is a 409 (conflict) carrying the friendly per-line
|
|
14996
15003
|
// message so the PayPal button surfaces it; nothing was charged
|
|
14997
15004
|
// and the mid-confirm holds were already released.
|
package/package.json
CHANGED