@blamejs/blamejs-shop 0.1.15 → 0.1.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +2 -0
- package/README.md +3 -3
- package/lib/admin.js +2 -2
- package/lib/storefront.js +131 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.1.x
|
|
10
10
|
|
|
11
|
+
- v0.1.16 (2026-05-25) — **PayPal express checkout — the on-page button.** PayPal checkout is now usable from the storefront. When PayPal is configured, the checkout page shows a native PayPal button (distinct from PayPal-through-Stripe): it opens a PayPal order for the current cart, the buyer approves in the PayPal popup, and the order is captured and marked paid. A verified PayPal webhook is the asynchronous backstop. This completes the native PayPal integration on top of the adapter and checkout orchestration shipped in the previous two releases. Card / Stripe checkout is unchanged. **Added:** *PayPal button + create/capture routes on the storefront* — The checkout page renders a PayPal button when `PAYPAL_CLIENT_ID` is configured. Its create step posts to `POST /checkout/paypal/create` (prices the cart, opens a PayPal order, persists the local order pending) and its approve step posts to `POST /checkout/paypal/capture` (captures and advances the order to paid, then redirects to the order page). Both validate input and never 500 on a missing cart or id. The buttons collect the same shipping fields as the card form. · *PayPal webhook endpoint* — `POST /api/webhooks/paypal` is the asynchronous backstop for captures completed or refunded out of band. The container verifies each event server-to-server through PayPal's verify-webhook-signature API (no edge HMAC pre-check, unlike Stripe), then advances the order; re-deliveries are idempotent. Point a PayPal webhook at `/api/webhooks/paypal`. **Changed:** *PayPal listed as configurable; CSP note* — The integrations status page and README document PayPal as a first-class checkout option once configured. As with the Stripe pay page's `js.stripe.com`, operators must allow `www.paypal.com` in their Content-Security-Policy `script-src` / `frame-src` for the PayPal SDK and approval popup.
|
|
12
|
+
|
|
11
13
|
- v0.1.15 (2026-05-25) — **PayPal checkout orchestration.** Checkout can now run a PayPal order end to end, building on the adapter from the previous release. The orchestrator prices the cart, opens a PayPal order and persists the local order as pending, captures it after the buyer approves and advances the order to paid, and has a webhook backstop for captures completed or refunded out of band. Wired in when a PayPal app's credentials are present, and surfaced on the integrations status page. The storefront button and routes that drive this from the pay page come next; card / Stripe checkout is unchanged. **Added:** *checkout PayPal methods* — With a PayPal adapter wired (`paypal` dep), checkout gains three methods. `createPaypalOrder({ cart_id, ship_to, selected_shipping_id, customer, idempotency_key, return_url?, cancel_url? })` prices the cart, opens a PayPal Orders-v2 order, persists the local order in `pending` with the PayPal order id linked, and marks the cart converted. `capturePaypalOrder(paypalOrderId)` captures the approved order and advances the local order to `paid` (idempotent — a retry or a webhook that beat it won't double-transition). `handlePaypalEvent({ headers, rawBody })` is the asynchronous backstop: it verifies the event through PayPal, then maps `PAYMENT.CAPTURE.COMPLETED` → paid and `PAYMENT.CAPTURE.REFUNDED` → refunded, idempotent across re-deliveries. · *PayPal wired from configuration* — When `PAYPAL_CLIENT_ID` + `PAYPAL_SECRET` are set (with `PAYPAL_ENV` and `PAYPAL_WEBHOOK_ID`), the server builds the PayPal adapter and passes it to checkout; the integrations status page lists PayPal as action-needed once card checkout is also live. Off until configured; the existing checkout flow is untouched.
|
|
12
14
|
|
|
13
15
|
- v0.1.14 (2026-05-25) — **PayPal payment adapter (Orders v2).** `payment.create({ adapter: "paypal", … })` is a new native PayPal adapter alongside the Stripe one — a from-scratch Orders-v2 client over the framework's SSRF-gated HTTP client, with no PayPal SDK dependency. It exchanges an OAuth2 client-credentials token (cached until it nears expiry), creates and captures orders, fetches and refunds them, and verifies inbound webhooks through PayPal's verify-webhook-signature API. This ships the adapter only; wiring it into the checkout flow and a storefront button comes next. Card / Stripe checkout is unchanged. **Added:** *PayPal Orders-v2 adapter* — `payment.create({ adapter: "paypal", clientId, secret, sandbox?, webhookId?, apiBase? })` returns `{ createOrder, captureOrder, getOrder, refund, verifyWebhook }`. `createOrder({ amount_minor, currency, order_id?, return_url?, cancel_url? })` opens a CAPTURE-intent order (amounts converted to PayPal's decimal-string major units, including 0-decimal currencies); `captureOrder(id)` finalizes it; `refund({ capture_id, amount_minor?, currency? })` refunds full or partial; `getOrder(id)` reads status. Every call carries an OAuth2 bearer token exchanged once and cached until ~2 minutes before expiry, and a `PayPal-Request-Id` for idempotency (plus the shared idempotency cache when a `query` handle is wired). `verifyWebhook(headers, rawBody, { webhookId })` confirms an inbound event through PayPal's verify-webhook-signature API and returns `{ ok, event }`. Outbound HTTP goes through `b.httpClient` — no `paypal` npm dependency. Off until the operator supplies credentials; the Stripe adapter and existing checkout are unchanged.
|
package/README.md
CHANGED
|
@@ -57,9 +57,9 @@ Every primitive is composed on the vendored blamejs surface — no npm runtime d
|
|
|
57
57
|
| **`lib/pricing.js`** | Pure-function money math — `lineTotal`, `subtotal`, `totals`, `format`. Multi-currency refused, banker's-style rounding, locale-aware via `Intl.NumberFormat`. |
|
|
58
58
|
| **`lib/tax.js`** | Operator-table adapter. Country / state / postal_prefix → rate_bps. Most-specific-first match, banker's rounding. Pluggable adapter shape for future Stripe Tax / TaxJar / Avalara. |
|
|
59
59
|
| **`lib/shipping.js`** | Operator-table adapter. Services with zones (flat or per-gram + base + min/max), free-over-threshold, `digital_only` flag. |
|
|
60
|
-
| **`lib/payment.js`** |
|
|
60
|
+
| **`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). |
|
|
61
61
|
| **`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`. |
|
|
62
|
-
| **`lib/checkout.js`** | Orchestrator. `quote()` returns priced quote; `confirm()` creates PaymentIntent + persists order
|
|
62
|
+
| **`lib/checkout.js`** | Orchestrator. `quote()` returns priced quote; `confirm()` 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. |
|
|
63
63
|
| **`lib/email.js`** | Transactional templates — order receipt, ship notification, refund confirmation. Strict `{{var}}` renderer with HTML escape + refusal of unknown / unused placeholders. Composed on `b.mail` (DKIM/SPF/DMARC/BIMI upstream). |
|
|
64
64
|
| **`lib/storefront.js`** | Server-rendered HTML — utility bar + sticky header + dark hero with code-preview card + primitives marquee + featured-product callout + collections grid + framework feature band + designed catalog grid + newsletter band + four-column footer. Designed surfaces also for PDP, cart, checkout, pay, order, account login / register / dashboard, search results, `/admin` API landing, 404. Image-bearing cards on the home + search grids pull from `catalog.media`. The default theme stylesheet is external (R2-served `themes/default/assets/css/main.css`) and CSP-compliant — operators override by uploading a replacement at the same key, by passing `opts.theme_css` to renderers, or by registering a named theme through the `theme` primitive. |
|
|
65
65
|
| **`lib/customers.js`** | Customer accounts — passkey (WebAuthn) + **Sign in with Google / Apple** (OIDC). Email is stored hash-only (`b.crypto.namespaceHash` namespace `customer-email`); the raw address never lands in D1. Passkey credentials carry CBOR-encoded public keys, transport hints, and SHA3-512-fingerprinted attestation. `signInWithOIDC` keys federated accounts on the provider `(provider, subject)` and links an existing account only on a provider-verified email (never on an unverified one). `mintAppleClientSecret` produces Apple's required ES256 client-secret JWT from a Services-ID `.p8` key (the one classical signature the protocol mandates; the PQC default doesn't apply to an external IdP's wire format). Account routes (`/account/login`, `/account/register`, `/account`, `/account/login/google`, `/account/login/apple`) ship as designed cards on the storefront. |
|
|
@@ -163,10 +163,10 @@ variables. A signed-in operator can see the live on/off status of each at
|
|
|
163
163
|
| **Apple Pay & Google Pay** | One-tap wallet buttons (Express Checkout Element) on the pay page. | Stripe (above) **+** register each web domain: `POST /admin/payment-method-domains {"domain_name":"shop.example.com"}` | Stripe performs Apple merchant validation and hosts the association file — **no Apple Developer account needed**. Apex, `www`, and each subdomain register separately; a live-mode registration also covers sandbox. |
|
|
164
164
|
| **Sign in with Google** | A *Continue with Google* button on `/account/login` (OIDC). | `GOOGLE_OAUTH_CLIENT_ID`, `GOOGLE_OAUTH_CLIENT_SECRET`, `SHOP_ORIGIN` (e.g. `https://shop.example.com`) | Create a Google Cloud **OAuth 2.0 Web** client; add `<SHOP_ORIGIN>/account/auth/google/callback` as an Authorized redirect URI; consent-screen scopes `openid email profile`. The button appears only when all three are set. |
|
|
165
165
|
| **Sign in with Apple** | A *Continue with Apple* button on `/account/login` (OIDC). | `APPLE_TEAM_ID`, `APPLE_KEY_ID`, `APPLE_CLIENT_ID` (your **Services ID**), `APPLE_PRIVATE_KEY` (the `.p8` key contents), `SHOP_ORIGIN` | Needs an **Apple Developer Program** membership. Create a Services ID, enable Sign in with Apple, add `<SHOP_ORIGIN>/account/auth/apple/callback` as a Return URL, and create a Sign-in-with-Apple key (`.p8`). The shop mints Apple's ES256 client secret from the key at boot (re-minted each deploy, inside Apple's 6-month window). The button appears only when all five are set. |
|
|
166
|
+
| **PayPal checkout** | A native PayPal button on `/checkout` (PayPal Orders v2 — create / approve / capture), distinct from PayPal-through-Stripe. | `PAYPAL_CLIENT_ID`, `PAYPAL_SECRET` (a PayPal REST app), `PAYPAL_WEBHOOK_ID`, `PAYPAL_ENV` (`sandbox`\|`live`); Stripe checkout must also be live | The shop exchanges the OAuth2 token and creates / captures orders server-side; the button drives `/checkout/paypal/create` + `/checkout/paypal/capture`. Point a PayPal webhook at `/api/webhooks/paypal` (verified through PayPal's API). Allow `www.paypal.com` in your CSP `script-src` / `frame-src` (as you would `js.stripe.com`). |
|
|
166
167
|
|
|
167
168
|
**Planned / not available:**
|
|
168
169
|
|
|
169
|
-
- **PayPal** — a separate adapter (Orders v2 + its own webhook); planned.
|
|
170
170
|
- **Shop Pay / "Sign in with Shop"** — **not available** to a self-hosted,
|
|
171
171
|
non-Shopify store: the credentials only issue from a Shopify Admin and payment
|
|
172
172
|
flows through Shopify Payments. There is no path to enable it here.
|
package/lib/admin.js
CHANGED
|
@@ -1785,8 +1785,8 @@ var INTEGRATIONS_CATALOG = [
|
|
|
1785
1785
|
set: "GOOGLE_OAUTH_CLIENT_ID, GOOGLE_OAUTH_CLIENT_SECRET, SHOP_ORIGIN. Add <SHOP_ORIGIN>/account/auth/google/callback as a Google OAuth redirect URI." },
|
|
1786
1786
|
{ key: "apple_signin", name: "Sign in with Apple", enables: "A “Continue with Apple” button on the account login page.",
|
|
1787
1787
|
set: "APPLE_TEAM_ID, APPLE_KEY_ID, APPLE_CLIENT_ID (your Services ID), APPLE_PRIVATE_KEY (the .p8 key contents), SHOP_ORIGIN. Add <SHOP_ORIGIN>/account/auth/apple/callback as a Return URL on the Services ID. Requires an Apple Developer Program membership." },
|
|
1788
|
-
{ key: "paypal", name: "PayPal checkout", enables: "
|
|
1789
|
-
set: "PAYPAL_CLIENT_ID, PAYPAL_SECRET (a PayPal REST app), PAYPAL_WEBHOOK_ID, PAYPAL_ENV (sandbox|live). Card checkout (Stripe) must be live too." },
|
|
1788
|
+
{ key: "paypal", name: "PayPal checkout", enables: "A native PayPal button on the checkout page (create / capture via PayPal Orders v2) — distinct from PayPal-through-Stripe.",
|
|
1789
|
+
set: "PAYPAL_CLIENT_ID, PAYPAL_SECRET (a PayPal REST app), PAYPAL_WEBHOOK_ID, PAYPAL_ENV (sandbox|live). Card checkout (Stripe) must be live too. Point a PayPal webhook at /api/webhooks/paypal." },
|
|
1790
1790
|
];
|
|
1791
1791
|
|
|
1792
1792
|
function renderAdminIntegrations(opts) {
|
package/lib/storefront.js
CHANGED
|
@@ -1620,6 +1620,15 @@ function renderCheckoutForm(opts) {
|
|
|
1620
1620
|
});
|
|
1621
1621
|
}
|
|
1622
1622
|
var body = _render(CHECKOUT_PAGE, { subtotal: subtotal });
|
|
1623
|
+
// When PayPal is configured, append its button below the card form. The
|
|
1624
|
+
// block is built as raw HTML (appended after the strict render) so the SDK
|
|
1625
|
+
// script + handlers survive; the client-id is the only interpolation and is
|
|
1626
|
+
// attribute-escaped. The PayPal button's createOrder/onApprove drive the
|
|
1627
|
+
// /checkout/paypal/create + /capture routes. (Operators must allow
|
|
1628
|
+
// www.paypal.com in their CSP script-src/frame-src, as for js.stripe.com.)
|
|
1629
|
+
if (opts.paypal_client_id) {
|
|
1630
|
+
body += _paypalCheckoutBlock(opts.paypal_client_id, totals.currency);
|
|
1631
|
+
}
|
|
1623
1632
|
return _wrap({
|
|
1624
1633
|
title: "Checkout",
|
|
1625
1634
|
shop_name: shopName,
|
|
@@ -1629,6 +1638,31 @@ function renderCheckoutForm(opts) {
|
|
|
1629
1638
|
});
|
|
1630
1639
|
}
|
|
1631
1640
|
|
|
1641
|
+
function _paypalCheckoutBlock(clientId, currency) {
|
|
1642
|
+
var esc = b.template.escapeHtml;
|
|
1643
|
+
var cid = esc(String(clientId));
|
|
1644
|
+
var cur = esc(String(currency || "USD"));
|
|
1645
|
+
return "\n<div class=\"checkout-paypal\" style=\"max-width:32rem;margin:1.5rem auto 0;\">" +
|
|
1646
|
+
"<div class=\"pay-card__divider\"><span>or pay with PayPal</span></div>" +
|
|
1647
|
+
"<div id=\"paypal-button-container\"></div>" +
|
|
1648
|
+
"<p id=\"paypal-error\" class=\"auth-form__message auth-form__message--err\" hidden></p>" +
|
|
1649
|
+
"</div>" +
|
|
1650
|
+
"<script src=\"https://www.paypal.com/sdk/js?client-id=" + cid + "¤cy=" + cur + "&intent=capture\"></script>" +
|
|
1651
|
+
"<script>(function(){" +
|
|
1652
|
+
"if(!window.paypal){return;}" +
|
|
1653
|
+
"var form=document.querySelector('.checkout-page form');" +
|
|
1654
|
+
"var errEl=document.getElementById('paypal-error');" +
|
|
1655
|
+
"function vals(){var d={};['email','name','country','state','postal'].forEach(function(k){var el=form&&form.elements[k];if(el){d[k]=el.value;}});return d;}" +
|
|
1656
|
+
"function showErr(m){if(errEl){errEl.hidden=false;errEl.textContent=m||'PayPal checkout could not be completed.';}}" +
|
|
1657
|
+
"paypal.Buttons({" +
|
|
1658
|
+
"onClick:function(_d,actions){if(form&&form.reportValidity&&!form.reportValidity()){return actions.reject();}return actions.resolve();}," +
|
|
1659
|
+
"createOrder:function(){return fetch('/checkout/paypal/create',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(vals())}).then(function(r){return r.json();}).then(function(d){if(!d.id){throw new Error(d.error||'create failed');}return d.id;});}," +
|
|
1660
|
+
"onApprove:function(data){return fetch('/checkout/paypal/capture',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({paypal_order_id:data.orderID})}).then(function(r){return r.json();}).then(function(d){if(d.redirect){window.location.href=d.redirect;}else{showErr(d.error);}});}," +
|
|
1661
|
+
"onError:function(){showErr();}" +
|
|
1662
|
+
"}).render('#paypal-button-container');" +
|
|
1663
|
+
"})();</script>\n";
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1632
1666
|
// Stripe Elements payment page — embeds Stripe.js + a minimal
|
|
1633
1667
|
// mount block. The publishable key is operator-supplied (env
|
|
1634
1668
|
// `STRIPE_PUBLISHABLE_KEY` → forwarded into the rendered HTML).
|
|
@@ -2808,7 +2842,7 @@ function mount(router, deps) {
|
|
|
2808
2842
|
return res.end ? res.end() : res.send("");
|
|
2809
2843
|
}
|
|
2810
2844
|
var totals = pricing.totals(c, lines, {});
|
|
2811
|
-
_send(res, 200, renderCheckoutForm({ lines: lines, totals: totals, shop_name: shopName, theme: theme }));
|
|
2845
|
+
_send(res, 200, renderCheckoutForm({ lines: lines, totals: totals, shop_name: shopName, theme: theme, paypal_client_id: deps.paypal ? deps.paypal_client_id : null }));
|
|
2812
2846
|
});
|
|
2813
2847
|
|
|
2814
2848
|
router.post("/checkout", async function (req, res) {
|
|
@@ -2867,6 +2901,76 @@ function mount(router, deps) {
|
|
|
2867
2901
|
}
|
|
2868
2902
|
});
|
|
2869
2903
|
|
|
2904
|
+
// PayPal express checkout (Orders v2). Mounts when the PayPal adapter is
|
|
2905
|
+
// wired. The pay-page PayPal button drives two AJAX calls: `create` opens
|
|
2906
|
+
// a PayPal order for the current cart (returns its id for the SDK to
|
|
2907
|
+
// approve in the popup); `capture` finalizes after approval and advances
|
|
2908
|
+
// the local order to paid. Both read the session cart; the button posts
|
|
2909
|
+
// the same ship_to fields the card form collects.
|
|
2910
|
+
if (deps.paypal) {
|
|
2911
|
+
router.post("/checkout/paypal/create", async function (req, res) {
|
|
2912
|
+
function _json(status, obj) {
|
|
2913
|
+
res.status(status);
|
|
2914
|
+
res.setHeader && res.setHeader("content-type", "application/json; charset=utf-8");
|
|
2915
|
+
var s = JSON.stringify(obj);
|
|
2916
|
+
return res.end ? res.end(s) : res.send(s);
|
|
2917
|
+
}
|
|
2918
|
+
var body = req.body || {};
|
|
2919
|
+
var sid = _readSidCookie(req);
|
|
2920
|
+
if (!sid) return _json(400, { error: "no-session" });
|
|
2921
|
+
var c = await deps.cart.bySession(sid);
|
|
2922
|
+
if (!c || c.status !== "active") return _json(409, { error: "no-active-cart" });
|
|
2923
|
+
var shipTo = {
|
|
2924
|
+
country: (body.country || "").toUpperCase(),
|
|
2925
|
+
state: body.state ? String(body.state).toUpperCase() : undefined,
|
|
2926
|
+
postal: body.postal || undefined,
|
|
2927
|
+
};
|
|
2928
|
+
try {
|
|
2929
|
+
var defaultShipId = typeof deps.default_shipping_id === "function"
|
|
2930
|
+
? await deps.default_shipping_id() : deps.default_shipping_id;
|
|
2931
|
+
var created = await deps.checkout.createPaypalOrder({
|
|
2932
|
+
cart_id: c.id,
|
|
2933
|
+
ship_to: shipTo,
|
|
2934
|
+
selected_shipping_id: body.selected_shipping_id || defaultShipId || "std",
|
|
2935
|
+
customer: { email: body.email, name: body.name },
|
|
2936
|
+
idempotency_key: "paypal:" + c.id + ":" + b.uuid.v7(),
|
|
2937
|
+
return_url: body.return_url || undefined,
|
|
2938
|
+
cancel_url: body.cancel_url || undefined,
|
|
2939
|
+
});
|
|
2940
|
+
// The PayPal JS SDK's createOrder expects `{ id }`.
|
|
2941
|
+
return _json(200, { id: created.paypal_order_id, order_id: created.order.id });
|
|
2942
|
+
} catch (e) {
|
|
2943
|
+
return _json(e instanceof TypeError ? 400 : 502, { error: (e && e.message) || "paypal-create-failed" });
|
|
2944
|
+
}
|
|
2945
|
+
});
|
|
2946
|
+
|
|
2947
|
+
router.post("/checkout/paypal/capture", async function (req, res) {
|
|
2948
|
+
function _json(status, obj) {
|
|
2949
|
+
res.status(status);
|
|
2950
|
+
res.setHeader && res.setHeader("content-type", "application/json; charset=utf-8");
|
|
2951
|
+
var s = JSON.stringify(obj);
|
|
2952
|
+
return res.end ? res.end(s) : res.send(s);
|
|
2953
|
+
}
|
|
2954
|
+
var body = req.body || {};
|
|
2955
|
+
var paypalOrderId = body.paypal_order_id || body.orderID || body.orderId;
|
|
2956
|
+
if (typeof paypalOrderId !== "string" || !paypalOrderId.length) return _json(400, { error: "paypal_order_id required" });
|
|
2957
|
+
try {
|
|
2958
|
+
var result = await deps.checkout.capturePaypalOrder(paypalOrderId);
|
|
2959
|
+
if (!result.order) return _json(404, { error: "order-not-found" });
|
|
2960
|
+
// Only redirect to the order page when the capture actually
|
|
2961
|
+
// completed (or the order is already paid). A non-completing capture
|
|
2962
|
+
// leaves the order pending — surface it as an error so the client
|
|
2963
|
+
// doesn't show success for an unpaid order.
|
|
2964
|
+
if (!result.handled && result.order.status !== "paid") {
|
|
2965
|
+
return _json(502, { error: "capture-incomplete", status: result.order.status });
|
|
2966
|
+
}
|
|
2967
|
+
return _json(200, { order_id: result.order.id, status: result.order.status, redirect: "/orders/" + result.order.id });
|
|
2968
|
+
} catch (e) {
|
|
2969
|
+
return _json(502, { error: (e && e.message) || "paypal-capture-failed" });
|
|
2970
|
+
}
|
|
2971
|
+
});
|
|
2972
|
+
}
|
|
2973
|
+
|
|
2870
2974
|
router.get("/pay/:order_id", async function (req, res) {
|
|
2871
2975
|
var orderId = req.params && req.params.order_id;
|
|
2872
2976
|
if (!orderId) return _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme }));
|
|
@@ -2952,6 +3056,32 @@ function mount(router, deps) {
|
|
|
2952
3056
|
return res.end ? res.end("handler error") : res.send("");
|
|
2953
3057
|
}
|
|
2954
3058
|
});
|
|
3059
|
+
|
|
3060
|
+
// PayPal webhook — the async backstop for captures completed/refunded out
|
|
3061
|
+
// of band (the create/capture flow is primary). Verified server-to-server
|
|
3062
|
+
// through PayPal's API (handlePaypalEvent), so unlike Stripe there is no
|
|
3063
|
+
// edge HMAC pre-check; the raw body is captured upstream by
|
|
3064
|
+
// webhookRawBodyCapture. Mounts only when the PayPal adapter is wired.
|
|
3065
|
+
if (deps.paypal) {
|
|
3066
|
+
router.post("/api/webhooks/paypal", async function (req, res) {
|
|
3067
|
+
var raw = Buffer.isBuffer(req.body) ? req.body.toString("utf8")
|
|
3068
|
+
: (typeof req.body === "string" ? req.body : "");
|
|
3069
|
+
try {
|
|
3070
|
+
var result = await deps.checkout.handlePaypalEvent({ headers: req.headers || {}, rawBody: raw });
|
|
3071
|
+
res.status(200);
|
|
3072
|
+
res.setHeader && res.setHeader("content-type", "application/json; charset=utf-8");
|
|
3073
|
+
var payload = JSON.stringify({ ok: true, handled: !!(result && result.handled) });
|
|
3074
|
+
return res.end ? res.end(payload) : res.send(payload);
|
|
3075
|
+
} catch (e) {
|
|
3076
|
+
if (e && e.code === "WEBHOOK_INVALID") {
|
|
3077
|
+
res.status(400);
|
|
3078
|
+
return res.end ? res.end("invalid signature") : res.send("");
|
|
3079
|
+
}
|
|
3080
|
+
res.status(500);
|
|
3081
|
+
return res.end ? res.end("handler error") : res.send("");
|
|
3082
|
+
}
|
|
3083
|
+
});
|
|
3084
|
+
}
|
|
2955
3085
|
}
|
|
2956
3086
|
|
|
2957
3087
|
// ---- customer accounts (passkey-only) ------------------------------
|
package/package.json
CHANGED