From a76edf1a298860a96f8c1654755745424ba4aa1b Mon Sep 17 00:00:00 2001 From: Paul Kilmurray Date: Sun, 5 Jul 2026 22:47:47 +0200 Subject: [PATCH 1/3] feat(checkout): attach a persistent Stripe Customer at payment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Forward the signed-in customer's Medusa JWT on the payment-collection and payment-session calls so Medusa resolves `auth_context.actor_id` to the customer and creates/links a Stripe account holder (cus_...) on the PaymentIntent — instead of the throwaway "Guest" that Stripe invents for publishable-key-only requests. The store payment-sessions workflow only creates an account holder `when("customer-id-exists")`, and `customer_id` is derived solely from the request's auth context; today the storefront's medusaFetch sends only the publishable key, so actor_id is empty and every sale shows as Guest. getCustomer() in the route already validates the token against /store/customers/me (which rejects an empty actor_id), so the forwarded token is guaranteed to resolve to a real customer. A missing token degrades to the previous guest behaviour rather than breaking checkout. Scope: attach-customer only (bookkeeping/reconciliation). Saving the card for off-session renewals is a separate follow-up. --- .../store/cart/payment-sessions/route.test.ts | 66 +++++++++++++++++-- .../api/store/cart/payment-sessions/route.ts | 13 +++- .../core/external/medusa-client.test.ts | 58 ++++++++++++++++ src/services/core/external/medusa-client.ts | 25 ++++++- 4 files changed, 150 insertions(+), 12 deletions(-) diff --git a/src/app/api/store/cart/payment-sessions/route.test.ts b/src/app/api/store/cart/payment-sessions/route.test.ts index 43122ca4..0ce8f18a 100644 --- a/src/app/api/store/cart/payment-sessions/route.test.ts +++ b/src/app/api/store/cart/payment-sessions/route.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { NextRequest } from 'next/server' const mockGetCustomer = vi.fn() +const mockGetAuthToken = vi.fn() const mockCreatePaymentCollection = vi.fn() const mockCreatePaymentSession = vi.fn() const mockGetCart = vi.fn() @@ -9,6 +10,7 @@ const mockGetProOfferCatalog = vi.fn() vi.mock('@/lib/medusa-auth', () => ({ getCustomer: (...args: unknown[]) => mockGetCustomer(...args), + getAuthToken: (...args: unknown[]) => mockGetAuthToken(...args), })) vi.mock('@/services/core/external/medusa-client', () => ({ @@ -40,6 +42,10 @@ const validCart = { items: [{ variant_id: 'variant_yearly_current', quantity: 1 }], } +// The route reads the session JWT via getAuthToken and forwards it to the +// medusa client so Medusa attaches a persistent Stripe Customer to the intent. +const AUTH_TOKEN = 'jwt_customer_token' + function makeRequest(body: unknown) { return new NextRequest( 'http://localhost:3000/api/store/cart/payment-sessions', @@ -55,6 +61,7 @@ describe('POST /api/store/cart/payment-sessions', () => { beforeEach(() => { vi.clearAllMocks() mockGetCustomer.mockResolvedValue({ id: 'cust_1' }) + mockGetAuthToken.mockResolvedValue(AUTH_TOKEN) mockGetCart.mockResolvedValue(validCart) mockGetProOfferCatalog.mockResolvedValue({ offers: [ @@ -114,10 +121,11 @@ describe('POST /api/store/cart/payment-sessions', () => { const json = await response.json() expect(response.status).toBe(200) - expect(mockCreatePaymentCollection).toHaveBeenCalledWith('cart_1') + expect(mockCreatePaymentCollection).toHaveBeenCalledWith('cart_1', AUTH_TOKEN) expect(mockCreatePaymentSession).toHaveBeenCalledWith( 'paycol_1', - 'pp_stripe_stripe' + 'pp_stripe_stripe', + AUTH_TOKEN ) expect(mockGetCart).toHaveBeenCalledWith('cart_1') expect(json).toEqual({ @@ -128,6 +136,47 @@ describe('POST /api/store/cart/payment-sessions', () => { }) }) + it('forwards the session JWT so Medusa can attach a Stripe Customer', async () => { + mockGetAuthToken.mockResolvedValueOnce('jwt_specific') + mockCreatePaymentCollection.mockResolvedValueOnce({ id: 'paycol_1' }) + mockCreatePaymentSession.mockResolvedValueOnce({ + clientSecret: 'pi_secret', + paymentSessionId: 'payses_1', + }) + + const response = await POST(makeRequest({ cartId: 'cart_1' })) + + expect(response.status).toBe(200) + expect(mockCreatePaymentCollection).toHaveBeenCalledWith('cart_1', 'jwt_specific') + expect(mockCreatePaymentSession).toHaveBeenCalledWith( + 'paycol_1', + 'pp_stripe_stripe', + 'jwt_specific' + ) + }) + + it('degrades gracefully when no session token is present', async () => { + // getCustomer succeeded but the cookie is somehow absent: the client + // treats a null token as "publishable key only" rather than breaking + // checkout. The purchase still completes (as a Stripe "Guest"). + mockGetAuthToken.mockResolvedValueOnce(null) + mockCreatePaymentCollection.mockResolvedValueOnce({ id: 'paycol_1' }) + mockCreatePaymentSession.mockResolvedValueOnce({ + clientSecret: 'pi_secret', + paymentSessionId: 'payses_1', + }) + + const response = await POST(makeRequest({ cartId: 'cart_1' })) + + expect(response.status).toBe(200) + expect(mockCreatePaymentCollection).toHaveBeenCalledWith('cart_1', null) + expect(mockCreatePaymentSession).toHaveBeenCalledWith( + 'paycol_1', + 'pp_stripe_stripe', + null + ) + }) + it('defaults non-string payment fields before Medusa calls', async () => { mockCreatePaymentCollection.mockResolvedValueOnce({ id: 'paycol_1' }) mockCreatePaymentSession.mockResolvedValueOnce({ @@ -144,10 +193,11 @@ describe('POST /api/store/cart/payment-sessions', () => { ) expect(response.status).toBe(200) - expect(mockCreatePaymentCollection).toHaveBeenCalledWith('cart_1') + expect(mockCreatePaymentCollection).toHaveBeenCalledWith('cart_1', AUTH_TOKEN) expect(mockCreatePaymentSession).toHaveBeenCalledWith( 'paycol_1', - 'pp_stripe_stripe' + 'pp_stripe_stripe', + AUTH_TOKEN ) }) @@ -170,7 +220,8 @@ describe('POST /api/store/cart/payment-sessions', () => { expect(mockCreatePaymentCollection).not.toHaveBeenCalled() expect(mockCreatePaymentSession).toHaveBeenCalledWith( 'paycol_existing', - 'pp_custom' + 'pp_custom', + AUTH_TOKEN ) expect(json.paymentCollectionId).toBe('paycol_existing') }) @@ -191,10 +242,11 @@ describe('POST /api/store/cart/payment-sessions', () => { const json = await response.json() expect(response.status).toBe(200) - expect(mockCreatePaymentCollection).toHaveBeenCalledWith('cart_1') + expect(mockCreatePaymentCollection).toHaveBeenCalledWith('cart_1', AUTH_TOKEN) expect(mockCreatePaymentSession).toHaveBeenCalledWith( 'paycol_1', - 'pp_stripe_stripe' + 'pp_stripe_stripe', + AUTH_TOKEN ) expect(json.paymentCollectionId).toBe('paycol_1') }) diff --git a/src/app/api/store/cart/payment-sessions/route.ts b/src/app/api/store/cart/payment-sessions/route.ts index e7f4f739..4a96ac9a 100644 --- a/src/app/api/store/cart/payment-sessions/route.ts +++ b/src/app/api/store/cart/payment-sessions/route.ts @@ -4,7 +4,7 @@ import { createPaymentSession, getCart, } from '@/services/core/external/medusa-client' -import { getCustomer } from '@/lib/medusa-auth' +import { getAuthToken, getCustomer } from '@/lib/medusa-auth' import { storeLogger } from '@/lib/logger' import { getProOfferCatalog, @@ -31,6 +31,13 @@ export async function POST(request: NextRequest) { ) } + // getCustomer() above already validated this token against + // /store/customers/me, which rejects a JWT with an empty actor_id — so a + // non-null customer guarantees a token that resolves to a real customer. + // Forwarding it downstream is what makes Medusa attach a persistent Stripe + // Customer (cus_...) to the PaymentIntent instead of a "Guest". + const authToken = await getAuthToken() + const body = await request.json().catch(() => null) if (!body || typeof body !== 'object' || Array.isArray(body)) { return NextResponse.json({ error: 'Invalid request body' }, { status: 400 }) @@ -96,7 +103,7 @@ export async function POST(request: NextRequest) { // Prefer the cart's own collection; create one only when none exists. let collectionId = cartCollectionId ?? existingCollectionId if (!collectionId) { - const collection = await createPaymentCollection(cartId) + const collection = await createPaymentCollection(cartId, authToken) if (!collection) { return NextResponse.json( { error: 'Failed to create payment collection' }, @@ -107,7 +114,7 @@ export async function POST(request: NextRequest) { } // Create session within the collection - const session = await createPaymentSession(collectionId, providerId) + const session = await createPaymentSession(collectionId, providerId, authToken) if (!session) { return NextResponse.json( { error: 'Failed to create payment session' }, diff --git a/src/services/core/external/medusa-client.test.ts b/src/services/core/external/medusa-client.test.ts index da19f115..44185d8d 100644 --- a/src/services/core/external/medusa-client.test.ts +++ b/src/services/core/external/medusa-client.test.ts @@ -512,6 +512,38 @@ describe('medusaClient', () => { ) }) + it('forwards the customer JWT as Bearer auth when provided', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + payment_collection: { id: 'pay_col_123' }, + }), + }) + + await createPaymentCollection('cart_123', 'jwt_abc') + + const [, init] = mockFetch.mock.calls[0] + expect((init.headers as Record).Authorization).toBe( + 'Bearer jwt_abc' + ) + }) + + it('omits Authorization when no token is provided', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + payment_collection: { id: 'pay_col_123' }, + }), + }) + + await createPaymentCollection('cart_123') + + const [, init] = mockFetch.mock.calls[0] + expect( + (init.headers as Record).Authorization + ).toBeUndefined() + }) + it('returns null on error', async () => { mockFetch.mockResolvedValueOnce({ ok: false, @@ -611,6 +643,32 @@ describe('medusaClient', () => { expect(result?.clientSecret).toBeNull() }) + it('forwards the customer JWT as Bearer auth when provided', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + payment_collection: { + id: 'pay_col_123', + payment_sessions: [ + { + id: 'payses_123', + provider_id: 'pp_stripe_stripe', + status: 'pending', + data: { client_secret: 'pi_secret_123' }, + }, + ], + }, + }), + }) + + await createPaymentSession('pay_col_123', 'pp_stripe_stripe', 'jwt_abc') + + const [, init] = mockFetch.mock.calls[0] + expect((init.headers as Record).Authorization).toBe( + 'Bearer jwt_abc' + ) + }) + it('returns null on error', async () => { mockFetch.mockResolvedValueOnce({ ok: false, diff --git a/src/services/core/external/medusa-client.ts b/src/services/core/external/medusa-client.ts index 52216619..0924f1e0 100644 --- a/src/services/core/external/medusa-client.ts +++ b/src/services/core/external/medusa-client.ts @@ -350,9 +350,15 @@ interface PaymentCollectionResponse { /** * Create a payment collection for a cart (Medusa v2) * Called once during checkout initialization. + * + * `authToken` is the caller's Medusa customer JWT. When present it is forwarded + * as Bearer auth so Medusa resolves `auth_context.actor_id` to the signed-in + * customer — the same reason it matters on `createPaymentSession` (see there). + * Optional so anonymous/mock callers keep working with the publishable key only. */ export async function createPaymentCollection( - cartId: string + cartId: string, + authToken?: string | null ): Promise { try { const response = await medusaFetch( @@ -360,6 +366,9 @@ export async function createPaymentCollection( { method: 'POST', body: JSON.stringify({ cart_id: cartId }), + ...(authToken + ? { headers: { Authorization: `Bearer ${authToken}` } } + : {}), } ) return response.payment_collection @@ -380,10 +389,19 @@ export interface PaymentSessionResult { /** * Create a payment session within an existing collection (Medusa v2) * Called on init and when switching payment provider. + * + * `authToken` is the caller's Medusa customer JWT. Forwarding it as Bearer auth + * is what makes Medusa attach a persistent Stripe Customer to the PaymentIntent + * instead of a throwaway "Guest": the store payment-sessions route derives + * `customer_id` from `auth_context.actor_id`, and Medusa's create-payment-session + * workflow only creates/links a Stripe account holder `when("customer-id-exists")`. + * Without the token the request is publishable-key-only, `actor_id` is empty, and + * no `cus_...` is attached. Optional so anonymous/mock callers still work. */ export async function createPaymentSession( paymentCollectionId: string, - providerId: string + providerId: string, + authToken?: string | null ): Promise { try { const response = await medusaFetch( @@ -391,6 +409,9 @@ export async function createPaymentSession( { method: 'POST', body: JSON.stringify({ provider_id: providerId }), + ...(authToken + ? { headers: { Authorization: `Bearer ${authToken}` } } + : {}), } ) From 639e21b7769a6a81e7d8b6f6563832fbf548824e Mon Sep 17 00:00:00 2001 From: Paul Kilmurray Date: Mon, 6 Jul 2026 11:39:31 +0200 Subject: [PATCH 2/3] refactor(checkout): extract buildAuthHeaders helper De-duplicate the authToken -> Authorization header spread shared by createPaymentCollection and createPaymentSession into one buildAuthHeaders() helper. Behaviour-preserving (returns the same Bearer header, or {} when no token); addresses a review nitpick. --- src/services/core/external/medusa-client.ts | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/services/core/external/medusa-client.ts b/src/services/core/external/medusa-client.ts index 0924f1e0..6edf5423 100644 --- a/src/services/core/external/medusa-client.ts +++ b/src/services/core/external/medusa-client.ts @@ -76,6 +76,16 @@ async function medusaFetch( return response.json() } +/** + * Bearer-auth headers for a Medusa customer JWT, or `{}` when there is no token. + * Spread into a request's `headers` so authenticated store calls resolve + * `auth_context.actor_id` to the signed-in customer; anonymous/mock callers fall + * back to publishable-key-only. Keeps the header construction in one place. + */ +function buildAuthHeaders(authToken?: string | null): Record { + return authToken ? { Authorization: `Bearer ${authToken}` } : {} +} + /** * Get all published products */ @@ -366,9 +376,7 @@ export async function createPaymentCollection( { method: 'POST', body: JSON.stringify({ cart_id: cartId }), - ...(authToken - ? { headers: { Authorization: `Bearer ${authToken}` } } - : {}), + headers: buildAuthHeaders(authToken), } ) return response.payment_collection @@ -409,9 +417,7 @@ export async function createPaymentSession( { method: 'POST', body: JSON.stringify({ provider_id: providerId }), - ...(authToken - ? { headers: { Authorization: `Bearer ${authToken}` } } - : {}), + headers: buildAuthHeaders(authToken), } ) From 9ac68ca1733a1a811b1d7b2bbf95edc82c716961 Mon Sep 17 00:00:00 2001 From: Paul Kilmurray Date: Sun, 5 Jul 2026 22:55:04 +0200 Subject: [PATCH 3/3] feat(checkout): plumb off-session card saving for yearly renewals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the engineering seam that saves a buyer's card as a reusable, off-session Stripe payment method — the prerequisite for a future "click to renew" charge on the yearly plan. createPaymentSession now accepts an optional setup_future_usage, forwarded as the session `data`; Medusa's store payment-sessions route passes `data` into the Stripe provider's initiatePayment, where it lands on the PaymentIntent (verified against @medusajs/payment-stripe normalizePaymentIntentParameters). The route sets 'off_session' only when the buyer consented (saveCard), the plan is yearly (lifetime never renews), and it's the Stripe card provider (PayPal/BTCPay have no off-session-card concept). A saved card is only chargeable because the customer is attached (previous commit). DORMANT until a consent UI is added: no client sets saveCard yet, so today this changes nothing on the wire. The consent checkbox + mandate copy is a deliberate owner/legal decision and is intentionally NOT included here. --- .../store/cart/payment-sessions/route.test.ts | 100 ++++++++++++++++-- .../api/store/cart/payment-sessions/route.ts | 22 +++- .../core/external/medusa-client.test.ts | 55 ++++++++++ src/services/core/external/medusa-client.ts | 17 ++- 4 files changed, 185 insertions(+), 9 deletions(-) diff --git a/src/app/api/store/cart/payment-sessions/route.test.ts b/src/app/api/store/cart/payment-sessions/route.test.ts index 0ce8f18a..845cedb7 100644 --- a/src/app/api/store/cart/payment-sessions/route.test.ts +++ b/src/app/api/store/cart/payment-sessions/route.test.ts @@ -125,7 +125,8 @@ describe('POST /api/store/cart/payment-sessions', () => { expect(mockCreatePaymentSession).toHaveBeenCalledWith( 'paycol_1', 'pp_stripe_stripe', - AUTH_TOKEN + AUTH_TOKEN, + undefined ) expect(mockGetCart).toHaveBeenCalledWith('cart_1') expect(json).toEqual({ @@ -151,7 +152,8 @@ describe('POST /api/store/cart/payment-sessions', () => { expect(mockCreatePaymentSession).toHaveBeenCalledWith( 'paycol_1', 'pp_stripe_stripe', - 'jwt_specific' + 'jwt_specific', + undefined ) }) @@ -173,7 +175,90 @@ describe('POST /api/store/cart/payment-sessions', () => { expect(mockCreatePaymentSession).toHaveBeenCalledWith( 'paycol_1', 'pp_stripe_stripe', - null + null, + undefined + ) + }) + + it('saves the card off-session for a consenting yearly Stripe purchase', async () => { + mockCreatePaymentCollection.mockResolvedValueOnce({ id: 'paycol_1' }) + mockCreatePaymentSession.mockResolvedValueOnce({ + clientSecret: 'pi_secret', + paymentSessionId: 'payses_1', + }) + + const response = await POST(makeRequest({ cartId: 'cart_1', saveCard: true })) + + expect(response.status).toBe(200) + expect(mockCreatePaymentSession).toHaveBeenCalledWith( + 'paycol_1', + 'pp_stripe_stripe', + AUTH_TOKEN, + 'off_session' + ) + }) + + it('does not save the card for a lifetime purchase even with consent', async () => { + mockGetCart.mockResolvedValue({ + id: 'cart_1', + items: [{ variant_id: 'variant_lifetime_current', quantity: 1 }], + }) + mockCreatePaymentCollection.mockResolvedValueOnce({ id: 'paycol_1' }) + mockCreatePaymentSession.mockResolvedValueOnce({ + clientSecret: 'pi_secret', + paymentSessionId: 'payses_1', + }) + + const response = await POST(makeRequest({ cartId: 'cart_1', saveCard: true })) + + expect(response.status).toBe(200) + expect(mockCreatePaymentSession).toHaveBeenCalledWith( + 'paycol_1', + 'pp_stripe_stripe', + AUTH_TOKEN, + undefined + ) + }) + + it('does not save the card for a non-Stripe provider even with consent', async () => { + mockCreatePaymentCollection.mockResolvedValueOnce({ id: 'paycol_1' }) + mockCreatePaymentSession.mockResolvedValueOnce({ + clientSecret: null, + paymentSessionId: 'payses_1', + }) + + const response = await POST( + makeRequest({ + cartId: 'cart_1', + saveCard: true, + provider_id: 'pp_paypal_paypal', + }) + ) + + expect(response.status).toBe(200) + expect(mockCreatePaymentSession).toHaveBeenCalledWith( + 'paycol_1', + 'pp_paypal_paypal', + AUTH_TOKEN, + undefined + ) + }) + + it('does not save the card when consent is absent', async () => { + mockCreatePaymentCollection.mockResolvedValueOnce({ id: 'paycol_1' }) + mockCreatePaymentSession.mockResolvedValueOnce({ + clientSecret: 'pi_secret', + paymentSessionId: 'payses_1', + }) + + const response = await POST(makeRequest({ cartId: 'cart_1', saveCard: false })) + + expect(response.status).toBe(200) + expect(mockCreatePaymentSession).toHaveBeenCalledWith( + 'paycol_1', + 'pp_stripe_stripe', + AUTH_TOKEN, + undefined ) }) @@ -197,7 +282,8 @@ describe('POST /api/store/cart/payment-sessions', () => { expect(mockCreatePaymentSession).toHaveBeenCalledWith( 'paycol_1', 'pp_stripe_stripe', - AUTH_TOKEN + AUTH_TOKEN, + undefined ) }) @@ -221,7 +307,8 @@ describe('POST /api/store/cart/payment-sessions', () => { expect(mockCreatePaymentSession).toHaveBeenCalledWith( 'paycol_existing', 'pp_custom', - AUTH_TOKEN + AUTH_TOKEN, + undefined ) expect(json.paymentCollectionId).toBe('paycol_existing') }) @@ -246,7 +333,8 @@ describe('POST /api/store/cart/payment-sessions', () => { expect(mockCreatePaymentSession).toHaveBeenCalledWith( 'paycol_1', 'pp_stripe_stripe', - AUTH_TOKEN + AUTH_TOKEN, + undefined ) expect(json.paymentCollectionId).toBe('paycol_1') }) diff --git a/src/app/api/store/cart/payment-sessions/route.ts b/src/app/api/store/cart/payment-sessions/route.ts index 4a96ac9a..13a678c4 100644 --- a/src/app/api/store/cart/payment-sessions/route.ts +++ b/src/app/api/store/cart/payment-sessions/route.ts @@ -54,6 +54,10 @@ export async function POST(request: NextRequest) { payload.paymentCollectionId.trim() ? payload.paymentCollectionId.trim() : undefined + // Opt-in consent to store the card for a future off-session renewal charge. + // No UI sets this yet — the consent copy/mandate is an owner decision — so + // in practice this is currently always false and every card stays one-shot. + const saveCard = payload.saveCard === true if (!cartId) { return NextResponse.json( @@ -84,6 +88,17 @@ export async function POST(request: NextRequest) { ) } + // Only save a card when it can actually be reused: the yearly plan renews + // (lifetime never does), the buyer consented, and it's the Stripe card + // provider (PayPal/BTCPay have no off-session-card concept). A saved card + // is only chargeable later because Phase 1 attaches a Stripe Customer. + const setupFutureUsage = + saveCard && + selection.planId === 'yearly' && + providerId.startsWith('pp_stripe') + ? ('off_session' as const) + : undefined + // The cart is the source of truth for its payment collection: a // caller-supplied id that doesn't match the cart's own collection (stale // tab, replaced session, or a different cart's id) must not attach @@ -114,7 +129,12 @@ export async function POST(request: NextRequest) { } // Create session within the collection - const session = await createPaymentSession(collectionId, providerId, authToken) + const session = await createPaymentSession( + collectionId, + providerId, + authToken, + setupFutureUsage + ) if (!session) { return NextResponse.json( { error: 'Failed to create payment session' }, diff --git a/src/services/core/external/medusa-client.test.ts b/src/services/core/external/medusa-client.test.ts index 44185d8d..cf73fb43 100644 --- a/src/services/core/external/medusa-client.test.ts +++ b/src/services/core/external/medusa-client.test.ts @@ -669,6 +669,61 @@ describe('medusaClient', () => { ) }) + it('threads setup_future_usage into the session data when provided', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + payment_collection: { + id: 'pay_col_123', + payment_sessions: [ + { + id: 'payses_123', + provider_id: 'pp_stripe_stripe', + status: 'pending', + data: { client_secret: 'pi_secret_123' }, + }, + ], + }, + }), + }) + + await createPaymentSession( + 'pay_col_123', + 'pp_stripe_stripe', + 'jwt_abc', + 'off_session' + ) + + const [, init] = mockFetch.mock.calls[0] + const parsed = JSON.parse(init.body as string) + expect(parsed.data).toEqual({ setup_future_usage: 'off_session' }) + }) + + it('omits the data payload when setup_future_usage is not requested', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + payment_collection: { + id: 'pay_col_123', + payment_sessions: [ + { + id: 'payses_123', + provider_id: 'pp_stripe_stripe', + status: 'pending', + data: { client_secret: 'pi_secret_123' }, + }, + ], + }, + }), + }) + + await createPaymentSession('pay_col_123', 'pp_stripe_stripe', 'jwt_abc') + + const [, init] = mockFetch.mock.calls[0] + const parsed = JSON.parse(init.body as string) + expect(parsed.data).toBeUndefined() + }) + it('returns null on error', async () => { mockFetch.mockResolvedValueOnce({ ok: false, diff --git a/src/services/core/external/medusa-client.ts b/src/services/core/external/medusa-client.ts index 6edf5423..c0346751 100644 --- a/src/services/core/external/medusa-client.ts +++ b/src/services/core/external/medusa-client.ts @@ -405,18 +405,31 @@ export interface PaymentSessionResult { * workflow only creates/links a Stripe account holder `when("customer-id-exists")`. * Without the token the request is publishable-key-only, `actor_id` is empty, and * no `cus_...` is attached. Optional so anonymous/mock callers still work. + * + * `setupFutureUsage` (e.g. `'off_session'`) is forwarded verbatim as the session + * `data`: Medusa's store payment-sessions route passes `data` straight into the + * Stripe provider's `initiatePayment`, where `normalizePaymentIntentParameters` + * copies `setup_future_usage` onto the PaymentIntent — saving the card as a + * reusable off-session payment method against the attached Stripe Customer. + * Only meaningful with a Customer attached (i.e. alongside `authToken`). */ export async function createPaymentSession( paymentCollectionId: string, providerId: string, - authToken?: string | null + authToken?: string | null, + setupFutureUsage?: 'off_session' | null ): Promise { try { const response = await medusaFetch( `/store/payment-collections/${paymentCollectionId}/payment-sessions`, { method: 'POST', - body: JSON.stringify({ provider_id: providerId }), + body: JSON.stringify({ + provider_id: providerId, + ...(setupFutureUsage + ? { data: { setup_future_usage: setupFutureUsage } } + : {}), + }), headers: buildAuthHeaders(authToken), } )