@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 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; // refund endpoints disabled when absent
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 (payment && o.payment_intent_id) {
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 a payment
2257
- // provider is wired AND the order has a captured intent to refund.
2258
- can_refund: !!(payment && o.payment_intent_id),
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
- if (payment) {
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 refund = await payment.refund({
4020
- payment_intent: o.payment_intent_id,
4021
- amount_minor: body.amount_minor || undefined,
4022
- reason: body.reason || undefined,
4023
- metadata: { order_id: o.id },
4024
- }, refundIdempotencyKey);
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: { stripe_refund_id: refund.id, amount_minor: refund.amount },
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: refund, order: await order.get(o.id) };
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, "stripe-refund-failed", (e && e.message) || String(e));
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 refund = await payment.refund({
4157
- payment_intent: o.payment_intent_id,
4158
- amount_minor: minor,
4159
- reason: "requested_by_customer",
4160
- metadata: { order_id: o.id, partial: !clearsBalance },
4161
- }, idemKey);
4162
- var refundedMinor = Number(refund.amount) || minor;
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: { stripe_refund_id: refund.id, amount_minor: refundedMinor, partial: true },
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: { stripe_refund_id: refund.id },
4331
+ metadata: _refundLedgerMeta(result, { amount_minor: refundedMinor }),
4182
4332
  });
4183
4333
  }
4184
- return { refund: refund, amount_minor: refundedMinor, cleared: clearsBalance };
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" ? 422 : 400;
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, "stripe-refund-failed", (e && e.message) || String(e));
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
- ctx.canProviderRefund = !!(ctx.order && ctx.order.payment_intent_id);
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
- refund = await payment.refund({
5016
- payment_intent: order2.payment_intent_id,
5017
- amount_minor: (rma.refund_amount_minor != null && rma.refund_amount_minor > 0) ? rma.refund_amount_minor : undefined,
5018
- reason: "requested_by_customer",
5019
- metadata: { order_id: order2.id, rma_id: rma.id, rma_code: rma.rma_code || "" },
5020
- }, idem);
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
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.4.27",
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-LI6y/1z0Y9F8Kx8RhW4EwY2WqJPXLwJozCXqnhDT+dTckLHyvhly0SsRpH0bsdui",
50
- "fingerprinted": "js/paypal-checkout.b05ab5572cc3728f.js"
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
- // Gift card fully covers the order — no PayPal order (PayPal
1435
- // refuses a zero-amount order). Create + burn + mark paid, same
1436
- // as the Stripe full-coverage path.
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", { reason: "gift_card:full" });
1462
- return { order: settled, paypal_order_id: null, status: "PAID_BY_GIFT_CARD", gift_card: { applied_minor: gc.applied_minor, amount_due_minor: 0 } };
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. The order row
1489
- // + the PayPal order already exist; a redeem throw must not bubble to
1490
- // the outer catch (which re-throws and would leave the cart active with
1491
- // an orphaned PayPal order). The buyer pays the credit-reduced PayPal
1492
- // amount; a failed debit is surfaced for reconciliation.
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 { order: createdOrder, paypal_order_id: ppOrder.id, status: ppOrder.status, gift_card: gc ? { applied_minor: gc.applied_minor, amount_due_minor: amountDue } : null };
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
- var alreadyAdvanced = (
1566
- (fsmEvent === "mark_paid" && o.status !== "pending") ||
1567
- (fsmEvent === "refund" && o.status === "refunded")
1568
- );
1569
- if (alreadyAdvanced) return { handled: true, event_type: eventType, order: o, skipped: "already-advanced" };
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: { paypal_event_id: event.id, paypal_order_id: ppOrderId },
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, ?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, JSON.stringify(input.ship_to),
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
- async function _paypalCall(opts, state, method, path, bodyObj, requestId) {
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
- breaker: opts._breaker,
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
- try { res = await _paypalCall(opts, state, "POST", "/v1/notifications/verify-webhook-signature", verifyBody, null); }
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
  };
@@ -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 payment.refund when wired. The handle's exact shape
689
- // mirrors the framework's payment primitive (`{ payment_intent,
690
- // amount_minor, reason, metadata }`). Absent a handle the
691
- // primitive still records the decision so the operator can
692
- // drive the actual refund out-of-band.
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 (paymentHandle && typeof paymentHandle.refund === "function") {
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
- var gcErr = ecode.indexOf("GIFTCARD_") === 0;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.4.27",
3
+ "version": "0.4.28",
4
4
  "description": "Open-source framework built on blamejs. Vendored stack, zero npm runtime deps, PQC-first crypto, security-on by default.",
5
5
  "main": "lib/index.js",
6
6
  "scripts": {