Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
154 changes: 147 additions & 7 deletions src/app/api/store/cart/payment-sessions/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@ 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()
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', () => ({
Expand Down Expand Up @@ -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',
Expand All @@ -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: [
Expand Down Expand Up @@ -114,10 +121,12 @@ 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,
undefined
)
expect(mockGetCart).toHaveBeenCalledWith('cart_1')
expect(json).toEqual({
Expand All @@ -128,6 +137,131 @@ 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',
undefined
)
})

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,
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
)
})

it('defaults non-string payment fields before Medusa calls', async () => {
mockCreatePaymentCollection.mockResolvedValueOnce({ id: 'paycol_1' })
mockCreatePaymentSession.mockResolvedValueOnce({
Expand All @@ -144,10 +278,12 @@ 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,
undefined
)
})

Expand All @@ -170,7 +306,9 @@ describe('POST /api/store/cart/payment-sessions', () => {
expect(mockCreatePaymentCollection).not.toHaveBeenCalled()
expect(mockCreatePaymentSession).toHaveBeenCalledWith(
'paycol_existing',
'pp_custom'
'pp_custom',
AUTH_TOKEN,
undefined
)
expect(json.paymentCollectionId).toBe('paycol_existing')
})
Expand All @@ -191,10 +329,12 @@ 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,
undefined
)
expect(json.paymentCollectionId).toBe('paycol_1')
})
Expand Down
33 changes: 30 additions & 3 deletions src/app/api/store/cart/payment-sessions/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 })
Expand All @@ -47,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(
Expand Down Expand Up @@ -77,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
Expand All @@ -96,7 +118,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' },
Expand All @@ -107,7 +129,12 @@ export async function POST(request: NextRequest) {
}

// Create session within the collection
const session = await createPaymentSession(collectionId, providerId)
const session = await createPaymentSession(
collectionId,
providerId,
authToken,
setupFutureUsage
)
if (!session) {
return NextResponse.json(
{ error: 'Failed to create payment session' },
Expand Down
Loading
Loading