Skip to content

[PRD] Plasmic Stripe checkout via EP-native payments + ConfirmationToken #317

@field123

Description

@field123

Problem Statement

A designer using our Plasmic-based EP commerce stack today cannot build a working Stripe checkout. The package (@elasticpath/plasmic-ep-commerce-elastic-path) ships a session model and Plasmic checkout components, but:

  • The host app (examples/ep-commerce-app-router) doesn't mount any of the session API routes the components call.
  • The current package handlers were architected around EP's manual gateway (host app talks to Stripe directly via STRIPE_SECRET_KEY). That model needs a host-side webhook backstop for production correctness, and forces the host to take ownership of the Stripe lifecycle.
  • The package's auth model is implicit-grant only; EP's native Stripe payment flow (createCartPaymentIntent) requires a client_credentials token, which is not currently obtainable through the package.
  • A parallel "legacy" checkout pipeline still ships in the package alongside the session pipeline, confusing what designers should reach for.
  • Designers have to thread cartId, Stripe publishable keys, and gateway-selection decisions through Plasmic interactions explicitly — too much wiring to get a working flow.

End result: zero-to-checkout time for a Plasmic project is high, the architectures available to follow are inconsistent, and any project that does ship is taking on payment-lifecycle correctness risk that EP's payments service was built to handle.

Solution

Adopt EP's native Stripe payments integration as the default, single supported path: gateway: "elastic_path_payments_stripe" via createCartPaymentIntent with confirm: true, using Stripe's modern Confirmation Token client pattern. The host app holds no Stripe secret key. EP holds the Stripe credentials (configured in Commerce Manager) and subscribes to Stripe webhooks; the host app never owns the lifecycle.

Concretely:

  1. The package's session handlers are restructured for a single-shot server flow: createCartPaymentIntent({ confirm: true, confirmation_token })checkoutApi (cart→order) → confirmOrder (sync PI status) → cart cleanup. No webhook needed for the happy path. 3DS is supported via an early-return on requires_action plus a resume endpoint.
  2. The package gains a client_credentials grant path inside its existing better-auth EP plugin, surfaced to handlers as a request-scoped, memoized callback on SessionHandlerContext. The token is minted server-side per request and never crosses the server/client boundary.
  3. The Stripe Plasmic component (EPStripePayment) is rewritten to use Stripe Elements with mode: 'payment' and stripe.createConfirmationToken. No client-side stripe.confirmPayment. No client manages the order lifecycle.
  4. A new StripeProvider Plasmic global context lets designers configure the Stripe publishable key once in their Plasmic project; the host app exposes it from a NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY env var.
  5. The host app gains a per-request checkout context factory that resolves the shopper's implicit access token from better-auth, sets up a request-scoped client_credentials minter, and conditionally registers payment adapters based on env-var presence.
  6. EPCheckoutSessionProvider auto-creates the checkout session on mount, resolving cartId server-side from the better-auth session. Designers drop the provider and it boots itself.
  7. Both account and guest checkout work transparently. When the cart contains subscription items and no account is signed in, /pay returns a typed ACCOUNT_REQUIRED error so the designer can render a sign-up prompt before retry.
  8. Cart cleanup after successful checkout: dissociate the cart from the account, delete the cart, clear epCartId on the better-auth session. The next add-to-cart action lazily creates a fresh cart.

The legacy pipeline (EPCheckoutProvider, useCheckout, the api/endpoints/checkout/* REST endpoints, the monolithic checkout components, EPPaymentElements, EPCheckoutButton, EPCheckoutStepIndicator) is left untouched in this PR but earmarked for deletion in an immediate follow-up.

User Stories

  1. As a Plasmic designer, I want to drop a single EPCheckoutSessionProvider onto a page so that the checkout session boots itself without any onMount wiring on my part.
  2. As a Plasmic designer, I want to bind form-field onChange handlers to setField() refActions on EPCustomerInfoFields, EPShippingAddressFields, and EPBillingAddressFields so that I can lay out my own input components and let the package manage the form state.
  3. As a Plasmic designer, I want to call $refs.session.updateSession({ customerInfo, shippingAddress }) from a "Continue" button so that the server merges my collected fields into the checkout session.
  4. As a Plasmic designer, I want shipping rates to appear automatically in $ctx.checkoutSession.session.availableShippingRates after a "Calculate Shipping" interaction so that I can repeat over them with my own UI.
  5. As a Plasmic designer, I want to drop EPStripePayment inside the provider so that the user sees a Stripe Payment Element styled by my Plasmic theme without any other component on my page knowing about Stripe.
  6. As a Plasmic designer, I want the publishable Stripe key configured once in a StripeProvider global context so that any number of payment pages or variants can reuse it.
  7. As a Plasmic designer, I want my "Place Order" button to call a single placeOrder() refAction that completes the entire payment + order flow so that I do not have to chain interactions for create-payment, confirm-payment, and capture.
  8. As a Plasmic designer, I want to render UI conditionally on $ctx.checkoutSession.error.code so that I can show "Sign up to continue" when an unauthenticated user tries to check out a cart containing a subscription.
  9. As a Plasmic designer, I want to render UI conditionally on $ctx.checkoutSession.session.payment.status so that I can show a "Verifying with your bank..." state during 3DS challenges.
  10. As a Plasmic designer, I want every checkout component to render meaningful design-time previews in Studio canvas so that I can style empty, filled, error, processing, and complete states without running the full backend.
  11. As a Plasmic designer, I want my checkout to keep working when I switch from Stripe to a different EP-supported gateway by replacing the payment component, without any code change to the host app.
  12. As a host-app developer, I want to mount the package's session handlers in five Next.js route files with no business logic in them so that the framework adapter layer stays trivially auditable.
  13. As a host-app developer, I want to set EP_CLIENT_ID, EP_CLIENT_SECRET, CHECKOUT_SESSION_SECRET, and NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY and have a working checkout, without any other configuration in code.
  14. As a host-app developer, I want zero STRIPE_SECRET_KEY on my host so that a code or config leak cannot let an attacker create charges directly against my Stripe account.
  15. As a host-app developer, I want gateway adapters registered conditionally on env-var presence so that adding or removing a gateway is one block of code.
  16. As a host-app developer, I want the client_credentials token to be minted per request and discarded with the request so that there is no shared cache that could leak between tenants in a future multi-tenant deployment.
  17. As a host-app developer, I want to know via type signature which handlers require admin auth and which only require shopper auth so that least-privilege is visible at the call site.
  18. As a host-app developer, I want a security regression test that asserts admin tokens never appear in any client-visible response body so that any future code change that breaks the boundary fails CI.
  19. As a host-app developer, I want a single command that builds the package, copies the dist into the host app, and starts the dev server so that I can iterate on package changes without yarn-link complications.
  20. As an end customer, I want my anonymous shopping cart to seamlessly carry into checkout so that I am not forced to create an account just to complete a one-time purchase.
  21. As an end customer with a subscription item in my cart, I want to be redirected to sign up before checkout so that my subscription has an account to bill against.
  22. As an end customer who has signed in mid-checkout because of a subscription gate, I want to land back on the checkout page with my cart intact so that I do not have to start over.
  23. As an end customer using a card that requires 3D Secure verification, I want the 3DS challenge to appear, complete, and continue the checkout without me having to retry from scratch.
  24. As an end customer who closes their browser tab mid-3DS challenge, I want either the order to complete based on what I already approved or my cart to be preserved so that I have not lost money or progress.
  25. As an end customer who completes payment, I want my cart to be empty when I return to the store so that there is no confusion about what I have purchased versus what is still pending.
  26. As an end customer, I want my card details to never touch the host app's server so that PCI scope is contained within Stripe and EP.
  27. As an EP merchant, I want my Stripe gateway configured once in Commerce Manager and have all my Plasmic storefronts use it without further setup so that key rotation, fraud rules, and webhook URLs are managed in one place.
  28. As a security reviewer, I want every code path that handles client_credentials tokens to be auditable in a single grep so that I can verify the boundary holds without reading the entire codebase.
  29. As a tester, I want every payment-handler decision branch to have a unit test using a stubbed getClientCredentialsToken and a mocked SDK so that adding a branch is provably covered.
  30. As a tester, I want one integration test per route that exercises the route adapter, the request-scoped context, and the handler against a mocked EP SDK so that wiring bugs cannot reach production.

Implementation Decisions

Auth model

  • The package's existing better-auth EP plugin gains a client_credentials grant path. The plugin already mints implicit tokens via grant_type: "implicit"; it learns to also mint grant_type: "client_credentials" using the host-supplied EP_CLIENT_SECRET.
  • SessionHandlerContext carries: epCredentials: { clientId, apiBaseUrl } (no secret), shopperAccessToken: string, getClientCredentialsToken: () => Promise<string> (request-scoped, memoized once per request via closure), and existing adapterRegistry, sessionStore, sessionTtlSeconds. The unused clientSecret field on EPCredentials is removed.
  • The route adapter in the host app (lib/checkout-context.ts) is a per-request factory, not a singleton. There is no process-level cache of admin tokens. The closure that holds the memoized client_credentials token is GC'd at request end.
  • Handlers explicitly choose their auth: read operations use shopperAccessToken; admin operations (createCartPaymentIntent, checkoutApi, confirmOrder, cart cleanup) use getClientCredentialsToken. Choice is visible at the call site.
  • EP_CLIENT_SECRET is documented in .env.local.example as server-only with an explicit comment forbidding NEXT_PUBLIC_ prefix.

Stripe integration

  • The package's stripe-adapter is rewritten to call EP's createCartPaymentIntent with gateway: "elastic_path_payments_stripe", method: "purchase", confirm: true, and the client-supplied confirmation_token. The Stripe SDK is no longer a host-side dependency.
  • The Stripe Plasmic component (EPStripePayment) uses <Elements> with mode: "payment" (deferred PaymentIntent), renders <PaymentElement> and <AddressElement>, and calls stripe.createConfirmationToken({ elements }) on submit. The token is passed through the session's placeOrder refAction.
  • A new StripeProvider Plasmic global context exposes publishableKey to all EPStripePayment instances. The component prop becomes a fallback override.
  • No stripe.confirmPayment on the client side. The server confirms via confirm: true on the cart payment intent.

Single-shot checkout flow

  • The handlePay handler runs: cart-hash check (existing) → subscription gate (new) → createCartPaymentIntent({ confirm: true, confirmation_token }) → branch on response status. No order is created until after Stripe returns succeeded.
  • On requires_action: action data is stored in session.payment.actionData, session.payment.status = "requires_action", session stays "open" for retry. Order is not created.
  • On succeeded: checkoutApi (cart→order) → confirmOrder (sync PI status to EP) → cart cleanup → session marked "complete".
  • A new handleResumePayment handler is added for the post-3DS path. The client calls it after stripe.handleNextAction succeeds; the server re-runs confirmOrder and the cart cleanup tail.
  • The legacy handleConfirm is either deleted or aliased to handleResumePayment for backwards compatibility (decision deferred to implementation).

Account vs guest checkout

  • The handlePay handler inspects the cart for items of type subscription_item.
  • If subscription items present and the better-auth session has no accountToken: return 400 { code: "ACCOUNT_REQUIRED" }. Designer renders a sign-up prompt.
  • If subscription items present and accountToken present: checkoutApi body uses account: { id, member_id } form.
  • If no subscription items: checkoutApi body uses customer: { name, email } (guest) when no account token, or account: ... when account token is present.

Cart cleanup

  • After confirmOrder returns success, handlePay (or handleResumePayment) calls EP deleteAccountCartAssociation (if account present), then deleteACart, then clears epCartId on the better-auth session.
  • A new cart is not pre-created. The next add-to-cart action goes through the existing cart-routes auto-create path.
  • Cleanup failures are logged but do not fail the response — the order is genuine, cleanup is housekeeping.

Plasmic designer DX

  • EPCheckoutSessionProvider auto-creates the session on first render when none exists (configurable via autoCreate prop, defaulting true).
  • useCheckoutSession.createSession() no longer requires a cartId argument — the route adapter resolves it from the better-auth session server-side.
  • EPCheckoutSessionProvider.placeOrder refAction accepts the confirmation token from the registered payment component (existing PaymentRegistrationContext plumbing).
  • EPStripePayment self-registers as gateway "stripe" (existing pattern). Its confirm callback returns { confirmation_token } derived from the Stripe Elements state.
  • A new error contract: error.code field on the session response carries machine-readable codes (ACCOUNT_REQUIRED, CART_MISMATCH, UNKNOWN_GATEWAY, etc.). Designers bind to $ctx.checkoutSession.error.code.

Adapter / gateway flexibility

  • lib/checkout-context.ts registers payment adapters conditionally on env-var presence. Stripe is registered when EP_CLIENT_SECRET is present. Future Clover, Adyen etc. are registered when their respective env vars are present.
  • A payment component on the page for an unregistered gateway hits the existing 400 UNKNOWN_GATEWAY path; the designer sees a clear error in $ctx.checkoutSession.error.

Routes

  • Five Next.js route files: POST /api/checkout/sessions (create), GET /api/checkout/sessions/current (read), PATCH /api/checkout/sessions/current (update), POST /api/checkout/sessions/current/shipping (calculate), POST /api/checkout/sessions/current/pay (place), POST /api/checkout/sessions/current/resume-payment (3DS post-action).
  • Each route file is a 6-line wrapper: build per-request context, adapt NextRequest to SessionRequest, call the handler, adapt SessionResponse to NextResponse.

Out-of-PR cleanup queued

  • A follow-up PR will delete the legacy checkout endpoints (api/endpoints/checkout/*), legacy hooks (use-checkout, use-stripe-payment), monolithic components (EPCheckoutForm, EPPaymentForm, EPOrderSummary, EPCheckoutConfirmation), legacy provider (EPCheckoutProvider), legacy EPPaymentElements, EPCheckoutButton, EPCheckoutStepIndicator, legacy types, and legacy mocks. The form components (EPCustomerInfoFields, EPShippingAddressFields, EPBillingAddressFields, EPOrderTotalsBreakdown, EPShippingMethodSelector) will have their checkoutData fallbacks stripped. COMPONENTS.md will be rewritten to document the session pipeline as the canonical path.

Testing Decisions

What makes a good test for this work

  • Tests assert external behavior: HTTP response shapes, session state transitions, what was sent to EP, what was returned to the client. They do not assert which functions were called internally or in what order.
  • Tests do not assert on log output, type names, or implementation details that can change without breaking the user contract.
  • Tests use real fixtures (sample cart responses, sample EP error shapes) where possible, lifted from the EP SDK type definitions or recorded against a sandbox.
  • Each branch of a payment-handler decision tree has at least one test. Branches without coverage block merge.

Modules with unit tests

  • Client Credentials Token Resolver — mocked fetch, asserts request body shape (grant_type, client_id, client_secret), asserts in-request memoization (one mint per resolver instance regardless of call count), asserts no caching across resolver instances.
  • Cart Payment Intent Adapter — mocked SDK, covers all four input/output status combinations (succeeded, requires_action, requires_payment_method, generic failure), asserts the EP body carries the correct gateway name, method, automatic_payment_methods, and confirmation_token.
  • Subscription Cart Detector — table test covering empty cart, no-subscription cart, subscription-only cart, mixed cart, and EP item-type variants.
  • Cart Cleanup Operation — mocked SDK, covers happy path, partial failure (dissociate fails, delete succeeds), full failure (logs but returns).
  • Checkout Body Builder — table test covering account checkout, guest checkout, both with and without optional fields (line_2, county, region), asserts EP-required empty-string defaults are populated.
  • Session State Transition — table test covering every (currentState × event) pair the state machine handles, asserts target state for each.
  • Stripe Confirmation Token Capture (hook) — React testing-library, mocked @stripe/react-stripe-js useStripe/useElements, covers ready / not-ready / submit-error / token-error / token-success cases.

Integration tests

  • One per route (5 total). Each builds a real SessionHandlerContext against a mocked EP SDK, sends a real NextRequest, and asserts on the NextResponse (status, body shape, Set-Cookie headers).
  • The integration test for /pay is the deepest: it verifies the full single-shot path on success, the 3DS branch, the subscription gate, and the cart-mismatch retry.

Security regression tests

  • One test per route: send a request, parse the response body and headers as JSON/text, assert that no token field, no client_credentials substring, no EP_CLIENT_SECRET substring, and no Stripe client_secret substring appears anywhere in the response. Run as part of the package test suite.

Prior art in the codebase

  • Existing handler tests (api/endpoints/checkout-session/__tests__/*.test.ts) follow a jest.mock("@epcc-sdk/sdks-shopper", () => ...) pattern; this PR's tests adapt to that pattern and additionally inject a stubbed getClientCredentialsToken via the test fixture.
  • The extract-ep-provider-config.test.ts and cart-hash.test.ts files are the model for pure-function table tests.
  • The EPStripePayment.test.tsx and EPCloverPayment.test.tsx files are the model for React-component tests with mocked Stripe SDKs.

What is not unit-tested

  • The Next.js route adapter wrappers (each is too thin for a unit test to add value over the integration test).
  • The host-app lib/checkout-context.ts factory (composes deep modules; covered by route integration tests).
  • EPCheckoutSessionProvider outer wiring (covered by integration tests that render the provider with a mocked context).

Out of Scope

  • Multi-tenant Stripe configuration. The architecture (per-request token resolution, conditional adapter registration, no host-level cache) is deliberately compatible with multi-tenant, but adding a tenant-resolver and per-tenant secret store is a separate effort.
  • Stripe Connect (stripeAccount parameter, application fees, connected-account onboarding). EP-native Stripe handles single-merchant Stripe; Connect would be a future package addition.
  • Non-Stripe gateways (Clover, Adyen, PayPal). Clover support exists in the package today but is not modified by this PR. Adding new gateways follows the same adapter pattern.
  • A Stripe webhook handler. The single-shot confirm: true flow plus EP's own webhook subscription remove the need for the host to handle Stripe webhooks. Should this assumption fail in production for some edge case, a webhook backstop is a small additive change.
  • Building a new demo Plasmic page. The existing demo Plasmic project is the design surface; this PR registers the new components into the existing project's component manifest.
  • Localised payment methods that require non-Stripe SDKs (Klarna direct integration, etc.). EP's Stripe gateway exposes whatever Stripe supports automatically via automatic_payment_methods.
  • The legacy rip-out. Deferred to an immediate follow-up PR for blast-radius reasons.
  • Refactoring the existing form components (EPCustomerInfoFields, etc.) to drop their checkoutData legacy fallbacks. Also deferred to the legacy rip-out follow-up.

Further Notes

  • The EP_CLIENT_SECRET env var is the same client_secret used by the EP API for the client_credentials OAuth grant. It is not the better-auth JWE encryption secret (CHECKOUT_SESSION_SECRET); the two are distinct and must not be conflated.
  • The gateway: "elastic_path_payments_stripe" identifier corresponds to EP's hosted Stripe integration configured in Commerce Manager. Merchants must complete that one-time configuration in EP admin before this code can transact; an empty or misconfigured EP-Stripe gateway will surface as an EP-side 4xx response in the /pay handler.
  • The confirm: true server-side confirmation is a 2024+ Stripe pattern requiring Confirmation Tokens. Older Stripe SDKs and older EP integrations do not support this flow; the package targets @stripe/stripe-js 2.4+ and @stripe/react-stripe-js 2.4+ (already present in package.json).
  • 3DS handling adds a requires_action branch to /pay and a new /resume-payment route. The code surface is small (~150 lines) but the test surface is non-trivial because Stripe's next_action shapes vary.
  • Cart cleanup runs on the success path inside the same HTTP request as the order creation. If cleanup fails, the order is preserved and a server-side log captures the inconsistency. Future work could add an out-of-band cleanup queue, but this is not in scope.
  • The package's EPCloverPayment and Clover adapter are not modified by this PR. They continue to use their existing flow. Future alignment of Clover with the EP-native gateway pattern (if EP exposes a Clover-equivalent) would be a separate effort.
  • The new client_credentials grant path in the better-auth EP plugin is server-only and does not change the existing implicit-grant behaviour for cart routes or other shopper-facing operations.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions