Skip to content

Commit 500e39d

Browse files
fix(plugin-ecommerce): verify PaymentIntent succeeded before creating… (#15902)
### what? Prevents stripe/confirm-order from creating an order unless the Stripe PaymentIntent is actually successful. ### Why? - Failed or canceled payments could still create an order, mark the cart as purchased, and set the transaction to succeeded. ### How? - After retrieving the PaymentIntent, check paymentIntent.status === 'succeeded'. If not, throw an error and stop before any order/cart/transaction updates. Fixes #15862 --------- Co-authored-by: Paul Popus <paul@payloadcms.com>
1 parent da17810 commit 500e39d

2 files changed

Lines changed: 161 additions & 1 deletion

File tree

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest'
2+
3+
const mockCustomersList = vi.fn()
4+
const mockCustomersCreate = vi.fn()
5+
const mockPaymentIntentsRetrieve = vi.fn()
6+
7+
vi.mock('stripe', () => {
8+
const MockStripe = function () {
9+
return {
10+
customers: {
11+
list: mockCustomersList,
12+
create: mockCustomersCreate,
13+
},
14+
paymentIntents: {
15+
retrieve: mockPaymentIntentsRetrieve,
16+
},
17+
}
18+
}
19+
20+
return { default: MockStripe }
21+
})
22+
23+
import { confirmOrder } from './confirmOrder'
24+
25+
const defaultCartItemsSnapshot = JSON.stringify([{ id: 'item-1', quantity: 1 }])
26+
27+
const createMockPaymentIntent = (status: string) => ({
28+
amount: 1000,
29+
currency: 'usd',
30+
metadata: {
31+
cartID: 'cart-123',
32+
cartItemsSnapshot: defaultCartItemsSnapshot,
33+
shippingAddress: JSON.stringify({ city: 'Test City' }),
34+
},
35+
status,
36+
})
37+
38+
const createMockPayload = () => ({
39+
create: vi.fn().mockResolvedValue({ id: 'order-123' }),
40+
find: vi.fn().mockResolvedValue({
41+
docs: [{ id: 'txn-123' }],
42+
totalDocs: 1,
43+
}),
44+
logger: { error: vi.fn() },
45+
update: vi.fn().mockResolvedValue({}),
46+
})
47+
48+
const createMockReq = (payload: ReturnType<typeof createMockPayload>) =>
49+
({
50+
payload,
51+
user: { id: 'user-123' },
52+
}) as any
53+
54+
describe('confirmOrder - payment status check', () => {
55+
const secretKey = 'sk_test_123'
56+
57+
beforeEach(() => {
58+
vi.clearAllMocks()
59+
60+
mockCustomersList.mockResolvedValue({ data: [{ id: 'cus-123' }] })
61+
mockCustomersCreate.mockResolvedValue({ id: 'cus-new' })
62+
})
63+
64+
it('should throw when paymentIntent status is requires_payment_method', async () => {
65+
mockPaymentIntentsRetrieve.mockResolvedValue(createMockPaymentIntent('requires_payment_method'))
66+
67+
const mockPayload = createMockPayload()
68+
const handler = confirmOrder({ secretKey })
69+
70+
await expect(
71+
handler({
72+
data: { customerEmail: 'test@test.com', paymentIntentID: 'pi_123' },
73+
req: createMockReq(mockPayload),
74+
}),
75+
).rejects.toThrow('Payment not completed.')
76+
77+
expect(mockPayload.create).not.toHaveBeenCalled()
78+
})
79+
80+
it('should throw when paymentIntent status is canceled', async () => {
81+
mockPaymentIntentsRetrieve.mockResolvedValue(createMockPaymentIntent('canceled'))
82+
83+
const mockPayload = createMockPayload()
84+
const handler = confirmOrder({ secretKey })
85+
86+
await expect(
87+
handler({
88+
data: { customerEmail: 'test@test.com', paymentIntentID: 'pi_123' },
89+
req: createMockReq(mockPayload),
90+
}),
91+
).rejects.toThrow('Payment not completed.')
92+
93+
expect(mockPayload.create).not.toHaveBeenCalled()
94+
})
95+
96+
it('should throw when paymentIntent status is processing', async () => {
97+
mockPaymentIntentsRetrieve.mockResolvedValue(createMockPaymentIntent('processing'))
98+
99+
const mockPayload = createMockPayload()
100+
const handler = confirmOrder({ secretKey })
101+
102+
await expect(
103+
handler({
104+
data: { customerEmail: 'test@test.com', paymentIntentID: 'pi_123' },
105+
req: createMockReq(mockPayload),
106+
}),
107+
).rejects.toThrow('Payment not completed.')
108+
109+
expect(mockPayload.create).not.toHaveBeenCalled()
110+
})
111+
112+
it('should not update cart or transaction when payment has not succeeded', async () => {
113+
mockPaymentIntentsRetrieve.mockResolvedValue(createMockPaymentIntent('requires_payment_method'))
114+
115+
const mockPayload = createMockPayload()
116+
const handler = confirmOrder({ secretKey })
117+
118+
await expect(
119+
handler({
120+
data: { customerEmail: 'test@test.com', paymentIntentID: 'pi_123' },
121+
req: createMockReq(mockPayload),
122+
}),
123+
).rejects.toThrow()
124+
125+
expect(mockPayload.update).not.toHaveBeenCalled()
126+
})
127+
128+
it('should create order when paymentIntent status is succeeded', async () => {
129+
mockPaymentIntentsRetrieve.mockResolvedValue(createMockPaymentIntent('succeeded'))
130+
131+
const mockPayload = createMockPayload()
132+
const handler = confirmOrder({ secretKey })
133+
134+
const result = await handler({
135+
data: { customerEmail: 'test@test.com', paymentIntentID: 'pi_123' },
136+
req: createMockReq(mockPayload),
137+
})
138+
139+
expect(mockPayload.create).toHaveBeenCalledWith(
140+
expect.objectContaining({
141+
collection: 'orders',
142+
data: expect.objectContaining({
143+
amount: 1000,
144+
currency: 'USD',
145+
status: 'processing',
146+
}),
147+
}),
148+
)
149+
150+
expect(result).toEqual(
151+
expect.objectContaining({
152+
orderID: 'order-123',
153+
transactionID: 'txn-123',
154+
}),
155+
)
156+
})
157+
})

packages/plugin-ecommerce/src/payments/adapters/stripe/confirmOrder.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,9 +74,12 @@ export const confirmOrder: (props: Props) => NonNullable<PaymentAdapter>['confir
7474
throw new Error('No transaction found for the provided PaymentIntent ID')
7575
}
7676

77-
// Verify the payment intent exists and retrieve it
7877
const paymentIntent = await stripe.paymentIntents.retrieve(paymentIntentID)
7978

79+
if (paymentIntent.status !== 'succeeded') {
80+
throw new Error(`Payment not completed.`)
81+
}
82+
8083
const cartID = paymentIntent.metadata.cartID
8184
const cartItemsSnapshot = paymentIntent.metadata.cartItemsSnapshot
8285
? JSON.parse(paymentIntent.metadata.cartItemsSnapshot)

0 commit comments

Comments
 (0)