Skip to content
Merged
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
66 changes: 59 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,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({
Expand All @@ -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({
Expand All @@ -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
)
})

Expand All @@ -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')
})
Expand All @@ -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')
})
Expand Down
13 changes: 10 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 Down Expand Up @@ -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' },
Expand All @@ -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' },
Expand Down
58 changes: 58 additions & 0 deletions src/services/core/external/medusa-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>).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<string, string>).Authorization
).toBeUndefined()
})

it('returns null on error', async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
Expand Down Expand Up @@ -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<string, string>).Authorization).toBe(
'Bearer jwt_abc'
)
})

it('returns null on error', async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
Expand Down
31 changes: 29 additions & 2 deletions src/services/core/external/medusa-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,16 @@ async function medusaFetch<T>(
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<string, string> {
return authToken ? { Authorization: `Bearer ${authToken}` } : {}
}

/**
* Get all published products
*/
Expand Down Expand Up @@ -350,16 +360,23 @@ 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<PaymentCollectionResponse['payment_collection'] | null> {
try {
const response = await medusaFetch<PaymentCollectionResponse>(
'/store/payment-collections',
{
method: 'POST',
body: JSON.stringify({ cart_id: cartId }),
headers: buildAuthHeaders(authToken),
}
)
return response.payment_collection
Expand All @@ -380,17 +397,27 @@ 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<PaymentSessionResult | null> {
try {
const response = await medusaFetch<PaymentCollectionResponse>(
`/store/payment-collections/${paymentCollectionId}/payment-sessions`,
{
method: 'POST',
body: JSON.stringify({ provider_id: providerId }),
headers: buildAuthHeaders(authToken),
}
)

Expand Down
Loading