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
53 changes: 53 additions & 0 deletions src/app/api/store/cart/payment-sessions/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const mockGetCustomer = vi.fn()
const mockGetAuthToken = vi.fn()
const mockCreatePaymentCollection = vi.fn()
const mockCreatePaymentSession = vi.fn()
const mockCreateCustomerSession = vi.fn()
const mockGetCart = vi.fn()
const mockGetProOfferCatalog = vi.fn()

Expand All @@ -23,6 +24,8 @@ vi.mock('@/services/core/external/medusa-client', () => ({
mockCreatePaymentCollection(...args),
createPaymentSession: (...args: unknown[]) =>
mockCreatePaymentSession(...args),
createCustomerSession: (...args: unknown[]) =>
mockCreateCustomerSession(...args),
getCart: (...args: unknown[]) => mockGetCart(...args),
}))

Expand Down Expand Up @@ -68,6 +71,7 @@ describe('POST /api/store/cart/payment-sessions', () => {
mockGetCustomer.mockResolvedValue({ id: 'cust_1' })
mockGetAuthToken.mockResolvedValue(AUTH_TOKEN)
mockGetCart.mockResolvedValue(validCart)
mockCreateCustomerSession.mockResolvedValue('cuss_secret_test')
mockGetProOfferCatalog.mockResolvedValue({
offers: [
{ planId: 'yearly', handle: 'wcpos-pro-yearly', variantId: 'variant_yearly_current' },
Expand Down Expand Up @@ -138,9 +142,58 @@ describe('POST /api/store/cart/payment-sessions', () => {
paymentCollectionId: 'paycol_1',
clientSecret: 'pi_secret',
paymentSessionId: 'payses_1',
customerSessionClientSecret: 'cuss_secret_test',
})
})

it('mints a CustomerSession for a yearly Stripe checkout', async () => {
mockCreatePaymentCollection.mockResolvedValueOnce({ id: 'paycol_1' })
mockCreatePaymentSession.mockResolvedValueOnce({
clientSecret: 'pi_secret',
paymentSessionId: 'payses_1',
})

const response = await POST(makeRequest({ cartId: 'cart_1' }))
const json = await response.json()

expect(mockCreateCustomerSession).toHaveBeenCalledWith('cart_1', AUTH_TOKEN)
expect(json.customerSessionClientSecret).toBe('cuss_secret_test')
})

it('does not mint a CustomerSession for a lifetime cart', 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' }))
const json = await response.json()

expect(mockCreateCustomerSession).not.toHaveBeenCalled()
expect(json.customerSessionClientSecret).toBeNull()
})

it('does not mint a CustomerSession for a non-Stripe provider', async () => {
mockCreatePaymentCollection.mockResolvedValueOnce({ id: 'paycol_1' })
mockCreatePaymentSession.mockResolvedValueOnce({
clientSecret: null,
paymentSessionId: 'payses_1',
})

const response = await POST(
makeRequest({ cartId: 'cart_1', provider_id: 'pp_paypal_paypal' })
)
const json = await response.json()

expect(mockCreateCustomerSession).not.toHaveBeenCalled()
expect(json.customerSessionClientSecret).toBeNull()
})

it('forwards the session JWT so Medusa can attach a Stripe Customer', async () => {
mockGetAuthToken.mockResolvedValueOnce('jwt_specific')
mockCreatePaymentCollection.mockResolvedValueOnce({ id: 'paycol_1' })
Expand Down
12 changes: 12 additions & 0 deletions src/app/api/store/cart/payment-sessions/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from 'next/server'
import {
createCustomerSession,
createPaymentCollection,
createPaymentSession,
getCart,
Expand Down Expand Up @@ -144,11 +145,22 @@ export async function POST(request: NextRequest) {
)
}

// For a yearly card checkout, mint a Stripe CustomerSession so the Payment
// Element can show its optional save-card checkbox. The payment session was
// created just above, so the customer's Stripe account holder now exists.
// Null for anything else (or on any error) → the storefront shows no
// checkbox; it never blocks checkout.
let customerSessionClientSecret: string | null = null
if (providerId.startsWith('pp_stripe') && selection.planId === 'yearly') {
customerSessionClientSecret = await createCustomerSession(cartId, authToken)
}

return NextResponse.json({
cart,
paymentCollectionId: collectionId,
clientSecret: session.clientSecret,
paymentSessionId: session.paymentSessionId,
customerSessionClientSecret,
})
} catch (error) {
storeLogger.error`Error with payment sessions: ${error}`
Expand Down
18 changes: 18 additions & 0 deletions src/components/pro/checkout-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@
cart: Cart
paymentCollectionId: string | null
clientSecret?: string | null
/** Stripe CustomerSession secret → optional save-card checkbox (yearly+card). */
customerSessionClientSecret?: string | null
}

const PRO_CHECKOUT_EXPERIMENT = 'pro_checkout_v1'
Expand Down Expand Up @@ -195,6 +197,8 @@
const [taxNumber, setTaxNumber] = useState<string | null>(null)
const [cart, setCart] = useState<Cart | null>(null)
const [clientSecret, setClientSecret] = useState<string | null>(null)
const [customerSessionClientSecret, setCustomerSessionClientSecret] =
useState<string | null>(null)
const [isProcessing, setIsProcessing] = useState(false)
// True while a provider confirmation may be charging the customer —
// billing Edit and method switching are locked for its duration.
Expand Down Expand Up @@ -340,6 +344,9 @@
if (paymentResult.clientSecret) {
setClientSecret(paymentResult.clientSecret)
}
setCustomerSessionClientSecret(
paymentResult.customerSessionClientSecret ?? null
)

cartReadyRef.current?.resolve({
cart: paymentResult.cart,
Expand All @@ -355,7 +362,7 @@
}

initializeCheckout()
}, [

Check warning on line 365 in src/components/pro/checkout-client.tsx

View workflow job for this annotation

GitHub Actions / Test

React Hook useEffect has missing dependencies: 'anyPaymentMethodEnabled' and 'defaultPaymentMethod'. Either include them or remove the dependency array
safetyRestored,
prerequisiteError,
blockedByProtectiveFailure,
Expand Down Expand Up @@ -389,6 +396,11 @@
if (method === 'stripe' && paymentResult.clientSecret) {
setClientSecret(paymentResult.clientSecret)
}
if (method === 'stripe') {
setCustomerSessionClientSecret(
paymentResult.customerSessionClientSecret ?? null
)
}
} catch (err) {
setFailure(
createPaymentFailure(METHOD_SWITCH_FAILED_MESSAGE, {
Expand Down Expand Up @@ -519,6 +531,11 @@
? paymentResult.clientSecret
: null
)
setCustomerSessionClientSecret(
paymentMethod === 'stripe'
? paymentResult.customerSessionClientSecret ?? null
: null
)
setBillingAddress(address)
setTaxNumber(extras.taxNumber)
setStep('payment')
Expand Down Expand Up @@ -694,6 +711,7 @@
<PaymentStep
cartId={cart.id}
clientSecret={clientSecret}
customerSessionClientSecret={customerSessionClientSecret}
paypalOrderId={paypalOrderId}
btcpayCheckoutLink={btcpayCheckoutLink}
method={paymentMethod}
Expand Down
4 changes: 4 additions & 0 deletions src/components/pro/checkout/payment-step.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ function MethodRow({
interface PaymentStepProps {
cartId: string
clientSecret: string | null
/** Stripe CustomerSession secret → enables the optional save-card checkbox. */
customerSessionClientSecret: string | null
paypalOrderId: string | null
btcpayCheckoutLink: string | null
method: PaymentMethod
Expand Down Expand Up @@ -116,6 +118,7 @@ function PreparingMethod() {
export function PaymentStep({
cartId,
clientSecret,
customerSessionClientSecret,
paypalOrderId,
btcpayCheckoutLink,
method,
Expand Down Expand Up @@ -240,6 +243,7 @@ export function PaymentStep({
return (
<StripeProvider
clientSecret={clientSecret}
customerSessionClientSecret={customerSessionClientSecret}
publishableKey={stripePublishableKey}
>
<ExpressCheckoutRow
Expand Down
12 changes: 12 additions & 0 deletions src/components/pro/stripe-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,22 @@ function getStripePromise(publishableKey: string): Promise<Stripe | null> {
interface StripeProviderProps {
children: ReactNode
clientSecret?: string
/**
* Stripe CustomerSession client secret. When present, the Payment Element
* renders Stripe's optional "save payment details" checkbox (with its
* card-network-compliant off-session mandate). Absent → no checkbox. Must be
* supplied at mount alongside clientSecret — Elements ignores option changes
* afterwards.
*/
customerSessionClientSecret?: string | null
/** Host-resolved public key; null renders the not-configured notice. */
publishableKey: string | null
}

export function StripeProvider({
children,
clientSecret,
customerSessionClientSecret,
publishableKey,
}: StripeProviderProps) {
if (!publishableKey) {
Expand All @@ -41,6 +50,9 @@ export function StripeProvider({
const options = clientSecret
? {
clientSecret,
...(customerSessionClientSecret
? { customerSessionClientSecret }
: {}),
appearance: {
theme: 'stripe' as const,
variables: {
Expand Down
39 changes: 39 additions & 0 deletions src/services/core/external/medusa-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import {
updateCart,
createPaymentCollection,
createPaymentSession,
createCustomerSession,
completeCart,
} from './medusa-client'

Expand Down Expand Up @@ -680,6 +681,44 @@ describe('medusaClient', () => {
})
})

describe('createCustomerSession', () => {
it('returns the customer session client secret with Bearer auth', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({ customer_session_client_secret: 'cuss_abc' }),
})

const result = await createCustomerSession('cart_123', 'jwt_tok')

expect(result).toBe('cuss_abc')
const [url, init] = mockFetch.mock.calls[0]
expect(url).toBe(
'https://test-store-api.wcpos.com/store/carts/cart_123/customer-session'
)
expect(init.method).toBe('POST')
expect((init.headers as Record<string, string>).Authorization).toBe(
'Bearer jwt_tok'
)
})

it('returns null when the backend returns no secret', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({ customer_session_client_secret: null }),
})

const result = await createCustomerSession('cart_123', 'jwt_tok')
expect(result).toBeNull()
})

it('returns null on error so checkout is never blocked', async () => {
mockFetch.mockResolvedValueOnce({ ok: false, status: 500 })

const result = await createCustomerSession('cart_123', 'jwt_tok')
expect(result).toBeNull()
})
})

describe('completeCart', () => {
it('completes cart and returns order', async () => {
mockFetch.mockResolvedValueOnce({
Expand Down
27 changes: 27 additions & 0 deletions src/services/core/external/medusa-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,33 @@ export async function createPaymentSession(
}
}

/**
* Mint a Stripe CustomerSession for the cart (Medusa custom route).
*
* Returns the `customer_session_client_secret` the storefront hands to
* `<Elements>` so Stripe renders its optional "save my card" checkbox. The
* backend only mints one for yearly carts with an attached Stripe customer, and
* returns null otherwise — a null here (or any failure) just means "no
* checkbox", never a checkout error.
*/
export async function createCustomerSession(
cartId: string,
authToken?: string | null
): Promise<string | null> {
try {
const response = await medusaFetch<{
customer_session_client_secret: string | null
}>(`/store/carts/${cartId}/customer-session`, {
method: 'POST',
headers: buildAuthHeaders(authToken),
})
return response.customer_session_client_secret ?? null
} catch (error) {
storeLogger.error`Failed to create customer session: ${error}`
return null
}
}

/**
* Complete a cart (finalize payment and create order)
*/
Expand Down
Loading