Parent PRD
#317
What to build
An anonymous shopper completes a guest checkout end-to-end, paying with a non-3DS test card, and ends with an EP order marked paid and an empty cart.
The full vertical user journey:
- Anonymous shopper has an item in their cart.
- They navigate to the checkout page in the demo Plasmic project.
- The page renders an
EPCheckoutSessionProvider that auto-creates a checkout session (no cartId argument needed — server resolves it from the better-auth session).
- They fill customer info, shipping address, and billing address using the existing form components driven by Plasmic refActions on the session.
- They request shipping rates (
calculateShipping); rates appear in $ctx.checkoutSession.session.availableShippingRates and the designer's repeated UI shows them.
- They select a shipping rate.
- They enter card details into
EPStripePayment (Stripe <PaymentElement> with mode: 'payment').
- They click "Place Order".
- Browser captures a Stripe
confirmation_token via stripe.createConfirmationToken, calls placeOrder with it, and the server runs the single-shot flow: createCartPaymentIntent({ confirm: true, confirmation_token }) → checkoutApi → confirmOrder → cart cleanup.
- Response carries the completed session; the designer's success UI renders from
session.status === "complete" and session.order.id.
- The cart is empty when the user returns to the storefront.
This slice deliberately covers only: anonymous (no account), no subscription items, non-3DS cards. The further customer types (account, subscription gate, 3DS) ship in subsequent slices.
Cuts through every layer:
- Package — auth. Better-auth EP plugin gains a
client_credentials grant path. SessionHandlerContext gains shopperAccessToken and getClientCredentialsToken: () => Promise<string> (request-scoped, memoized via closure, never cached across requests). The unused clientSecret field on EPCredentials is removed.
- Package — adapter.
stripe-adapter is rewritten to call EP's createCartPaymentIntent with gateway: "elastic_path_payments_stripe", method: "purchase", confirm: true, and the supplied confirmation_token. The host-side stripe npm package dependency is dropped. Result mapped to existing PaymentAdapterResult union.
- Package — handler.
handlePay restructured to single-shot flow: cart-hash check → createCartPaymentIntent({confirm:true,...}) → on succeeded, checkoutApi → confirmOrder → cart cleanup → return complete session. On failed, session stays open for retry. (requires_action and the subscription/account branches arrive in slices 2–4.) handleUpdateSession and handleCalculateShipping thread shopperAccessToken for their EP SDK calls.
- Package — components.
EPStripePayment rewritten: <Elements> with mode: 'payment', deferred PaymentIntent, <PaymentElement> + <AddressElement>, on submit calls stripe.createConfirmationToken({ elements }) and hands the token to placeOrder. No client-side stripe.confirmPayment. New StripeProvider Plasmic global context exposes publishableKey to all EPStripePayment instances. EPCheckoutSessionProvider auto-creates the session on mount when none exists (configurable via autoCreate prop, default true). useCheckoutSession.createSession() no longer requires a cartId argument.
- Host — context.
examples/ep-commerce-app-router/lib/checkout-context.ts is a per-request factory: resolves shopper access token from epAuth.api.getSession(), builds a closure-memoized getClientCredentialsToken callable, conditionally registers the Stripe adapter when EP_CLIENT_SECRET is present.
- Host — routes. Five route files mount the existing handlers via thin
NextRequest → SessionRequest adapters: POST /api/checkout/sessions, GET /api/checkout/sessions/current, PATCH /api/checkout/sessions/current, POST /api/checkout/sessions/current/shipping, POST /api/checkout/sessions/current/pay. Each is ~6 lines of glue.
- Host — env.
.env.local.example adds EP_CLIENT_SECRET (server-only, with comment forbidding NEXT_PUBLIC_ prefix), adds NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY, removes STRIPE_SECRET_KEY. StripeProvider is registered in plasmic-register-components.ts with publishableKey from process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY.
- Tests. Unit tests for the deep modules:
Client Credentials Token Resolver, Cart Payment Intent Adapter (success + failed paths), Cart Cleanup Operation, Checkout Body Builder (guest path), Session State Transition (open → complete). Integration tests for each of the five routes against a mocked EP SDK; /pay integration test exercises the full happy path.
See parent PRD §Solution and §Implementation Decisions for full detail.
Acceptance criteria
Blocked by
None - can start immediately.
User stories addressed
Reference by number from the parent PRD:
- User story 1
- User story 2
- User story 3
- User story 4
- User story 5
- User story 6
- User story 7
- User story 12
- User story 13
- User story 14
- User story 15
- User story 16
- User story 17
- User story 19
- User story 20
- User story 25
- User story 26
- User story 27
Parent PRD
#317
What to build
An anonymous shopper completes a guest checkout end-to-end, paying with a non-3DS test card, and ends with an EP order marked paid and an empty cart.
The full vertical user journey:
EPCheckoutSessionProviderthat auto-creates a checkout session (nocartIdargument needed — server resolves it from the better-auth session).calculateShipping); rates appear in$ctx.checkoutSession.session.availableShippingRatesand the designer's repeated UI shows them.EPStripePayment(Stripe<PaymentElement>withmode: 'payment').confirmation_tokenviastripe.createConfirmationToken, callsplaceOrderwith it, and the server runs the single-shot flow:createCartPaymentIntent({ confirm: true, confirmation_token })→checkoutApi→confirmOrder→ cart cleanup.session.status === "complete"andsession.order.id.This slice deliberately covers only: anonymous (no account), no subscription items, non-3DS cards. The further customer types (account, subscription gate, 3DS) ship in subsequent slices.
Cuts through every layer:
client_credentialsgrant path.SessionHandlerContextgainsshopperAccessTokenandgetClientCredentialsToken: () => Promise<string>(request-scoped, memoized via closure, never cached across requests). The unusedclientSecretfield onEPCredentialsis removed.stripe-adapteris rewritten to call EP'screateCartPaymentIntentwithgateway: "elastic_path_payments_stripe",method: "purchase",confirm: true, and the suppliedconfirmation_token. The host-sidestripenpm package dependency is dropped. Result mapped to existingPaymentAdapterResultunion.handlePayrestructured to single-shot flow: cart-hash check →createCartPaymentIntent({confirm:true,...})→ onsucceeded,checkoutApi→confirmOrder→ cart cleanup → return complete session. Onfailed, session staysopenfor retry. (requires_actionand the subscription/account branches arrive in slices 2–4.)handleUpdateSessionandhandleCalculateShippingthreadshopperAccessTokenfor their EP SDK calls.EPStripePaymentrewritten:<Elements>withmode: 'payment', deferred PaymentIntent,<PaymentElement>+<AddressElement>, on submit callsstripe.createConfirmationToken({ elements })and hands the token toplaceOrder. No client-sidestripe.confirmPayment. NewStripeProviderPlasmic global context exposespublishableKeyto allEPStripePaymentinstances.EPCheckoutSessionProviderauto-creates the session on mount when none exists (configurable viaautoCreateprop, defaulttrue).useCheckoutSession.createSession()no longer requires acartIdargument.examples/ep-commerce-app-router/lib/checkout-context.tsis a per-request factory: resolves shopper access token fromepAuth.api.getSession(), builds a closure-memoizedgetClientCredentialsTokencallable, conditionally registers the Stripe adapter whenEP_CLIENT_SECRETis present.NextRequest→SessionRequestadapters:POST /api/checkout/sessions,GET /api/checkout/sessions/current,PATCH /api/checkout/sessions/current,POST /api/checkout/sessions/current/shipping,POST /api/checkout/sessions/current/pay. Each is ~6 lines of glue..env.local.exampleaddsEP_CLIENT_SECRET(server-only, with comment forbiddingNEXT_PUBLIC_prefix), addsNEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY, removesSTRIPE_SECRET_KEY.StripeProvideris registered inplasmic-register-components.tswithpublishableKeyfromprocess.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY.Client Credentials Token Resolver,Cart Payment Intent Adapter(success + failed paths),Cart Cleanup Operation,Checkout Body Builder(guest path),Session State Transition(open → complete). Integration tests for each of the five routes against a mocked EP SDK;/payintegration test exercises the full happy path.See parent PRD §Solution and §Implementation Decisions for full detail.
Acceptance criteria
client_credentialstoken via the EP/oauth/access_tokenendpoint.SessionHandlerContextcarriesshopperAccessToken: stringandgetClientCredentialsToken: () => Promise<string>. The token resolver mints once per request lifecycle and returns the same token on subsequent calls within that request. There is no shared cache across requests.stripe-adaptermakes no direct network calls to Stripe; all Stripe interaction goes through EP'screateCartPaymentIntent. Thestripenpm package is no longer a runtime dependency of the package.handlePayhappy path produces an EP order withpayment.status === "paid"(or EP equivalent) and a session withstatus === "complete"andpayment.status === "succeeded".openandpayment.status === "failed"; the customer can retry.epCartIdis cleared from the better-auth session. The next add-to-cart action lazily creates a fresh cart.EPCheckoutSessionProviderautomatically callscreateSession()on first render when no session exists andautoCreate !== false.EPStripePaymentrenders<PaymentElement>and<AddressElement>, captures aconfirmation_tokenon submit, and callsplaceOrder({ confirmation_token })exactly once.StripeProviderglobal context is registered in the package;EPStripePaymentfalls back to$ctx.stripe.publishableKeywhen itspublishableKeyprop is unset..env.local.exampledocumentsEP_CLIENT_SECRETwith explicit server-only comment; noSTRIPE_SECRET_KEYis referenced./payintegration test covers the full guest happy path.4242 4242 4242 4242, sees an EP order created and marked paid, returns to the storefront and finds the cart empty.Blocked by
None - can start immediately.
User stories addressed
Reference by number from the parent PRD: