@blamejs/blamejs-shop 0.4.27 → 0.4.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +4 -0
- package/README.md +2 -2
- package/SECURITY.md +22 -0
- package/lib/admin.js +197 -39
- package/lib/asset-manifest.json +3 -3
- package/lib/auto-discount.js +177 -6
- package/lib/checkout.js +550 -135
- package/lib/gift-card-ledger.js +208 -88
- package/lib/giftcards.js +56 -0
- package/lib/loyalty.js +61 -1
- package/lib/order.js +69 -3
- package/lib/payment.js +113 -7
- package/lib/refund-automation.js +50 -7
- package/lib/security-middleware.js +39 -0
- package/lib/store-credit.js +99 -79
- package/lib/storefront.js +9 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.4.x
|
|
10
10
|
|
|
11
|
+
- v0.4.29 (2026-06-11) — **A gift card can no longer pay for two orders at once — credits debit before any charge — and store-credit wallets, capped discounts, and the gift-card audit chain all hold under concurrency.** A money-integrity release closing five concurrency windows, each reproduced before fixing. The serious one: gift-card and loyalty credits were debited after the order existed, with failures captured for reconciliation — so two simultaneous checkouts presenting the same gift card both produced paid orders while the card was only debited once. Credits now debit before any charge: the database balance gate decides the race, the loser gets a clean re-quote, and a checkout that fails after the debit but before the order exists reverses the debit automatically. Store-credit wallets stop computing balances from a stale read — concurrent debits can no longer overdraw, and two grants landing in the same millisecond both count. Capped automatic discounts are reserved atomically before charging, so a last-redemption race refuses one buyer with a clear message instead of granting both. Every gift-card ledger entry — debits included — now participates in the per-card tamper-evidence hash chain, a uniqueness fence keeps concurrent writes from forking it, and a new verifyChain call recomputes a card's chain on demand. The payment idempotency cache absorbs same-key races instead of failing one of them. Upgrade applies two D1 migrations. **Fixed:** *Store-credit wallets hold under concurrent writes* — Wallet writes computed the new balance from a separately-read snapshot, so two concurrent debits could both fulfill against one balance — overdrawing the wallet — and two grants landing in the same millisecond could tie on their timestamp and silently drop one. Every wallet write now computes the live balance and a strictly-monotonic per-customer timestamp inside a single guarded insert: a debit that loses the race is refused as insufficient, both same-instant grants land and sum, and the scheduled expiry sweep keeps its degrade-gracefully cap. · *Capped automatic discounts are reserved before charging* — A rule's redemption caps were only read at quote time and counted after the order existed, so a single-use discount applied to every order that raced the last redemption. The applied rules are now claimed atomically before any charge — total cap and per-customer cap both enforced inside single guarded statements — and a refused claim fails the checkout closed with a clear message and re-quote, never a silently different price. A checkout that fails before its order exists releases its reservations, recording a redemption is idempotent per order, and a retried checkout reuses its own claim instead of double-reserving. · *Same-key payment calls absorb their race* — Two concurrent calls carrying the same idempotency key could both miss the replay cache and collide on its primary key, failing one of them with a constraint error. The cache claim is now conflict-aware: one call stores its response, the other defers to it and replays — and a same-key call carrying a different request body is still refused as a collision, racing or not. **Security:** *Gift-card and loyalty credits debit before any charge* — A checkout's gift-card and loyalty debits are now the first money movement, ahead of the payment intent and the order row, on the card-payment and PayPal paths alike. The database balance predicate is the cross-checkout double-spend gate: two carts presenting the same card race it directly, exactly one wins, and the loser's checkout rolls back cleanly — stock holds released, cart reusable, a clear message and re-quote, nothing charged. A checkout that dies between the debit and order creation reverses the debit (claim-guarded, exactly once). Once the order exists the debit is attached to it, so refunds and cancellations keep reversing credit proportionally exactly as before. · *Every gift-card ledger entry is chained, and the chain can't fork* — Debit rows — previously written outside the hash chain by the atomic overdraft guard — now carry the same parent and row hashes as credits and expirations, with the overdraft gate still enforced inside the insert. A per-card uniqueness fence (one child per chain tip) makes concurrent writes serialize instead of forking the chain or basing a balance on a stale snapshot; a writer that loses the race re-reads the tip and retries. A new verifyChain call recomputes a card's chain end to end and reports the first divergence, tolerating rows that predate the chain columns as a counted, unverifiable prefix.
|
|
12
|
+
|
|
13
|
+
- 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.
|
|
14
|
+
|
|
11
15
|
- 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
16
|
|
|
13
17
|
- 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). |
|
|
@@ -98,7 +98,7 @@ Every primitive is composed on the vendored blamejs surface — no npm runtime d
|
|
|
98
98
|
| **`lib/translations.js`** | Storefront localization. The UI chrome (nav, search, newsletter band, footer) renders in the visitor's locale, resolved identically at the edge and container: `?lang=`, then a first-party cookie, then `Accept-Language`, then the operator default. A footer locale switcher (languages shown by their autonyms) persists the choice and 303s back; `GET /locale` sets the cookie. Right-to-left languages set the document `dir`. Strings layer the operator's translation rows over a built-in English baseline — a missing key falls back to English, never a raw placeholder. Enable it by seeding a locale policy (default + supported locales) via `localeRouter`; with none seeded the storefront renders the English baseline and shows no switcher. `SHOP_DEFAULT_LOCALE` sets the edge default and `SHOP_LOCALES` (the supported list) lets the edge forward an `Accept-Language`-preferred non-default locale to the container instead of caching the default; an explicit cookie/`?lang=` choice is always container-served. Server-rendered (works with JS off), byte-identical edge/container. |
|
|
99
99
|
| **`lib/subscriptions.js`** | Stripe-backed recurring billing — `subscription_plans` (interval / amount / trial) + `subscriptions` (mirrors Stripe's object byte-for-byte). `subscriptions.create` POSTs to Stripe via the payment dep, then persists the returned object locally. `handleStripeEvent` replays `customer.subscription.*` events into the local row so the shop has an authoritative view without round-tripping. Customers view + cancel their own subscriptions at `/account/subscriptions` (ownership-checked; cancel mounts when the payment handle is wired). |
|
|
100
100
|
| **`lib/giftcards.js`** | Prepaid bearer gift cards. `issue({ amount_minor, currency })` generates a 16-char code (32-glyph alphabet, no ambiguous letters) via `b.crypto.generateBytes`, stores only its `namespaceHash` digest + a 4-char hint, and returns the plaintext code once. `balance(code)` / `lookup(code)` resolve a code to its live balance (constant-time hash compare); `redeem({ code, order_id, amount_minor })` decrements the balance with an atomic `balance >= amount` SQL guard so concurrent spends can't overdraw. Redeemed at checkout as a credit against the order grand total: the amount due drops by the applied balance (never below zero), the order still records the full total it owed, and the debit is recorded once per order — a card that fully covers the order is marked paid with no Stripe charge. Customers check a balance at `GET /gift-cards`; the page is not a code-existence oracle (unknown / malformed / expired all return the same generic not-found). |
|
|
101
|
-
| **`lib/gift-card-ledger.js`** | Append-only credit / debit / expire history per gift card, with a denormalized `balance_after_minor` snapshot for O(1) balance reads. `credit` / `debit` / `expire` write one row each; `history(id)` paginates a card's transactions; `transactionsForOrder(id)` lists a card's movements for one order. The audit trail behind the admin gift-card ledger console; overdraft is refused at the primitive layer. |
|
|
101
|
+
| **`lib/gift-card-ledger.js`** | Append-only credit / debit / expire history per gift card, with a denormalized `balance_after_minor` snapshot for O(1) balance reads. `credit` / `debit` / `expire` write one row each; `history(id)` paginates a card's transactions; `transactionsForOrder(id)` lists a card's movements for one order. Every row of every kind participates in a per-card SHA3-512 hash chain whose parent fence (one child per chain tip, enforced by a unique index) makes concurrent writes serialize instead of forking; `verifyChain(id)` recomputes a card's chain and reports the first divergence. The audit trail behind the admin gift-card ledger console; overdraft is refused at the primitive layer, inside the same guarded insert. |
|
|
102
102
|
| **`lib/newsletter.js`** | Operator-collected email broadcast list — `signup({ email, source })` composes `b.guardEmail` for shape validation, `b.crypto.namespaceHash` for the dedup key, and `INSERT OR IGNORE` for idempotency. Storefront POST `/newsletter` route renders a designed thank-you card with separate copy for the `new` vs `dedup` branches. |
|
|
103
103
|
| **`lib/admin.js`** | Bearer-token-gated CRUD over catalog + orders + refunds + bulk CSV import + subscription plans + review moderation + return moderation. Token compared via `b.crypto.timingSafeEqual`. Errors as RFC 9457 problem documents via `b.problemDetails`. Audit emission on every mutation. Also serves a **browser admin console**: sign in at `/admin` by pasting the API key (sealed `shop_admin` session cookie, SameSite=Strict, /admin-scoped), with a persistent nav across every signed-in page. A guided **setup wizard** at `/admin/setup` writes shop identity to config; **Products** (`/admin/products`) browses the catalog and creates / archives / restores, and each product opens a management screen that edits its fields, adds / edits / removes variants, sets a variant's price and shows its price history, and attaches / uploads / removes images — the full path to a sellable product; **Inventory** (`/admin/inventory`) lists stock per SKU (on-hand / held / available) with a low-stock filter, restocks, sets per-SKU thresholds, tracks new SKUs, and opens the low-stock alert history (`/admin/inventory/alerts`) — each alert row is written when a checkout decrement crosses a SKU's threshold, alongside an `inventory.low_stock` webhook to subscribed endpoints; **Stock locations** (`/admin/inventory/locations`) defines warehouse / retail / virtual locations with per-location stock levels (a single-location store needs no configuration — the default location stays implicit), **Receive stock** (`/admin/inventory/receive`) records reason-coded inbound stock against a location with a batched receipt history, **Transfers** (`/admin/inventory/transfers`) moves stock between locations through a dispatch → receive state machine — the source is debited at dispatch, the destination credited on receive, and a dispatch racing a checkout hold for the last unit has exactly one winner — and **Write-offs** (`/admin/inventory/writeoffs`) records reason-coded stock losses with an audit trail, refusing a write-off that would eat into stock already held for paid orders; **Orders** (`/admin/orders`) lists recent orders with status filters, opens an order's items, totals, and shipping address, and drives the lifecycle (mark paid → fulfil → ship → deliver, cancel — Refund goes through the payment provider) through the order FSM, with a rate-bounded resend of the order confirmation to an operator-supplied address (the buyer's email is stored only as a hash, so the operator types the recipient), and attaches a shipment (carrier + tracking number) with recorded shipment events that surface a public tracking link to the customer, plus a customer-service notes thread per order (internal or customer-visible, pinnable, resolvable); **Customers** (`/admin/customers`) is a read-only roster, newest first — display name, short id, join date, sign-in method (passkey count + linked OAuth providers), and order count, with the count and sign-in methods resolved by bounded aggregate queries so a page of customers costs no per-row trips (email addresses aren't stored in the clear, so they're not shown); each customer opens to an aggregated activity timeline (orders, loyalty, wishlist, reviews, support) read from the tables those primitives already populate; customer segments export their members as a streamed CSV — id, display name, join date, order count, deliberately no email column; **Returns** (`/admin/returns`) is the RMA moderation queue — filter by status, open a request's items and reason, and approve (with refund amount) → mark received → refund, or reject with a reason, over the return FSM; **Reviews** (`/admin/reviews`) is the review moderation queue — filter by status and publish, reject (with a reason), or take down each submission inline; **Q&A** (`/admin/questions`) is the question moderation queue — filter by status, open a question to its full answer thread, approve / reject the question, post the seller answer, and approve / reject / pin individual answers; **Subscriptions** (`/admin/subscription-plans`) is the recurring-offer catalog — filter active / archived, create a plan (Stripe price id, interval, amount, trial), and archive one, with archiving terminal because the mirrored Stripe price can go stale; **Collections** (`/admin/collections`) manages manual + smart product collections — filter active / archived, create a collection (manual or smart with a starter rule), and per collection edit title / description / sort strategy, manage manual members (add by product id, remove, reorder) or edit a smart collection's rule set with a live preview of the products the rules currently match, and archive; **Gift cards** (`/admin/gift-cards`) is the gift-card ledger — list issued cards (masked code, original + remaining balance, status, issued date) filtered by lifecycle status, issue a new card (the bearer code shown once, right after creation), open a card to see its full credit / debit / expire ledger, and void an active card through a confirmation step; **Webhooks** (`/admin/webhooks`) registers outbound endpoints (https:// only) with a one-time signing-secret reveal, enables / disables / deletes them, and opens an endpoint's delivery feed to retry a failed delivery — the signing secret is shown once on create and never in the list, and order transitions fan out signed deliveries to subscribed endpoints. **Operators** (`/admin/operators`) is the staff-account console — create operators with their own credential (Argon2id password and/or a per-operator API key shown once) and a least-privilege role (owner / manager / viewer), enforced at the single admin write chokepoint on every POST/PUT/DELETE rather than by hiding menu items; disable takes effect on the operator's next request, `ADMIN_API_KEY` stays the bootstrap / break-glass owner credential so an upgrade can never lock the store out, and every operator-management action plus every role-denied attempt is audited. **Email campaigns** (`/admin/campaigns`) is the consent-gated broadcast console — author a campaign (escape-by-default Markdown body), target a mailing audience, preview, test-send to an operator-supplied address, and send to the recipients who are actually reachable: the recipient set resolves at send time from the newsletter list (the only place a deliverable address exists — customer accounts keep only an email hash), every recipient is re-checked against the unsubscribe flag and the marketing suppression list at the send moment, every message carries RFC 8058 one-click unsubscribe headers plus an in-body link, and a per-recipient send ledger makes a resumed broadcast never re-mail. Sending drains in rate-bounded batches on the scheduled tick; per-campaign delivered / failed / skipped counts show on the detail screen. **Quotes** (`/admin/quotes`) is the RFQ response queue — open a request's lines and customer message, respond with per-line pricing and a validity window, or withdraw a responded quote; an accepted quote converts to an order through the storefront's normal checkout path, holds included. **Tax** (`/admin/tax-rates`), **Shipping** (`/admin/shipping`), and **Discounts** (`/admin/discounts`) configure tax rates per jurisdiction, shipping zones + rates, and automatic-discount rules — including code-unlocked rules a shopper redeems with a discount code on the cart page — + coupon-stacking policies — create / edit / archive each. **Audit** (`/admin/audit`) is a read-only activity log of every privileged action — filtered by outcome (success / failure / denied) and paginated — composed on the framework's tamper-evident `b.audit` chain; opening it is itself recorded as an `audit.read` event. **Errors** (`/admin/errors`) lists captured server-error detail — time, status, route, and a truncated message for scrubbed 500-class failures (checkout confirm, public API, admin actions) — newest-first, with the same path answering a bearer-token request with JSON so the log is one `curl` away. **Carts** (`/admin/carts`) lists abandoned carts — active, has items, idle past a tunable window (24h default) — with line counts, value at risk, and guest/signed-in attribution; a per-cart action mints a single-use, code-gated discount the operator shares through their own channel (recovery email is impossible by design: buyer addresses are stored only as hashes, and the screen says so). **Analytics** (`/admin/analytics`) is the pre-purchase view the sales report can't see — browse-to-buy funnel with conversion rate, top search terms, most-viewed products, units-ranked top SKUs, and a revenue-by-day sparkline — cross-linked with the Reports screen, read-only, every aggregate window- and limit-bounded. **Search suggestions** (`/admin/search-suggestions`) curates the storefront autocomplete — pin a featured link to a typed prefix, set its priority / status / active window, edit or remove it inline — and surfaces a read-only popular-searches report (each term's 30-day count, zero-result share, and last-seen) so an unmatched term flags a stock or naming gap. The Customers, Returns, Reviews, Q&A, Subscriptions, Collections, Gift cards, Webhooks, Tax, Shipping, Discounts, Delivery estimates, Analytics, Search suggestions, Carts, Errors, Stock locations, Receive stock, Transfers, Write-offs, Quotes, Email campaigns, and Operators links appear only when those primitives are wired. Each console path content-negotiates: a bearer-token client still gets the JSON API unchanged, a signed-in browser gets HTML. Reachable by the cookie or the bearer token. The console's styling is an external, integrity-pinned stylesheet (`themes/default/assets/css/admin.css`) with the same self-hosted typeface — no inline styles and no third-party font host, so it renders correctly under the strict `style-src 'self'` / `font-src 'self'` CSP that governs the route. |
|
|
104
104
|
| **`lib/catalog-import.js`** | Bulk CSV import — `POST /admin/catalog/import` accepts a `text/csv` body, parses via `b.csv`, content-safety-filters every cell through `b.guardCsv` (formula-injection / bidi / control / dangerous-function denylist), validates exact header order, de-dupes rows by `product_slug`, returns per-row errors without aborting. Default 1 MiB / 10000 rows caps. |
|
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`.
|
|
@@ -298,6 +311,15 @@ node -e "
|
|
|
298
311
|
(success / failure / denied) and paginated. Opening the log is itself
|
|
299
312
|
recorded (an `audit.read` row), so reviewing the audit trail leaves
|
|
300
313
|
its own forensic mark.
|
|
314
|
+
- **The gift-card ledger is hash-chained, fork-proof, and verifiable.**
|
|
315
|
+
Every ledger entry — credits, debits, and expirations alike — links to
|
|
316
|
+
its predecessor through a per-card SHA3-512 chain, so a direct edit or
|
|
317
|
+
deletion of a row breaks the linkage. A uniqueness fence (one child per
|
|
318
|
+
chain tip) makes concurrent writes serialize rather than fork the
|
|
319
|
+
chain, and `giftCardLedger.verifyChain(cardId)` recomputes a card's
|
|
320
|
+
chain end to end and reports the first divergence — run it whenever a
|
|
321
|
+
card's balance is disputed. The overdraft refusal stays inside the same
|
|
322
|
+
guarded insert, so the integrity device never weakens the balance gate.
|
|
301
323
|
- **Privacy exports hold the whole record; erasure states a basis per
|
|
302
324
|
domain.** A subject-access export walks every table that keys a row
|
|
303
325
|
by the customer — identity, orders, subscriptions, addresses, saved
|
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.29",
|
|
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",
|