Skip to content

Commit db6c9a7

Browse files
vibenedictclaude
andcommitted
feat(payments): explicit PayPal capture state handling (#377)
The PayPal capture path collapsed every non-COMPLETED status into a single generic error, so callers could not tell a declined card from a transient failure or a payment held for review. Handle each documented PayPal capture status explicitly: - COMPLETED -> success (guards against a missing capture id) - DECLINED / FAILED -> explicit permanent-failure error, including PayPal's status_details.reason when present - PENDING -> surfaced as requiresAction so it is persisted as pending and finalized by the webhook rather than reported as a hard success - unknown -> generic fallback error Adds status_details.reason to PayPalCaptureResponse and unit tests for the DECLINED, FAILED, and PENDING branches. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 10a9ba1 commit db6c9a7

3 files changed

Lines changed: 133 additions & 14 deletions

File tree

client/lib/__tests__/payment-service.test.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,89 @@ describe("PaymentService", () => {
149149
expect(result.requiresAction).toBeUndefined()
150150
})
151151

152+
it("should surface a DECLINED capture as an explicit failure", async () => {
153+
const { getPayPalService } = await import("../paypal-service")
154+
vi.mocked(getPayPalService).mockReturnValue({
155+
captureOrder: vi.fn().mockResolvedValue({
156+
id: "ORDER_ABC",
157+
status: "COMPLETED",
158+
purchase_units: [{
159+
payments: {
160+
captures: [{
161+
id: "CAPTURE_DECLINED",
162+
status: "DECLINED",
163+
status_details: { reason: "INSTRUMENT_DECLINED" },
164+
}],
165+
},
166+
}],
167+
}),
168+
createOrder: vi.fn(),
169+
refundCapture: vi.fn(),
170+
getOrder: vi.fn(),
171+
} as any)
172+
173+
const service = new PaymentService({ provider: "paypal" })
174+
const result = await service.processPayment(0, "usd", "order_ORDER_ABC")
175+
176+
expect(result.success).toBe(false)
177+
expect(result.error).toContain("declined")
178+
expect(result.error).toContain("INSTRUMENT_DECLINED")
179+
// Falls back to the capture ID so callers can reconcile the attempt.
180+
expect(result.transactionId).toBe("CAPTURE_DECLINED")
181+
})
182+
183+
it("should surface a FAILED capture as an explicit failure", async () => {
184+
const { getPayPalService } = await import("../paypal-service")
185+
vi.mocked(getPayPalService).mockReturnValue({
186+
captureOrder: vi.fn().mockResolvedValue({
187+
id: "ORDER_ABC",
188+
status: "COMPLETED",
189+
purchase_units: [{
190+
payments: { captures: [{ id: "CAPTURE_FAILED", status: "FAILED" }] },
191+
}],
192+
}),
193+
createOrder: vi.fn(),
194+
refundCapture: vi.fn(),
195+
getOrder: vi.fn(),
196+
} as any)
197+
198+
const service = new PaymentService({ provider: "paypal" })
199+
const result = await service.processPayment(0, "usd", "order_ORDER_ABC")
200+
201+
expect(result.success).toBe(false)
202+
expect(result.error).toContain("failed")
203+
})
204+
205+
it("should treat a PENDING capture as pending (requiresAction), not success", async () => {
206+
const { getPayPalService } = await import("../paypal-service")
207+
vi.mocked(getPayPalService).mockReturnValue({
208+
captureOrder: vi.fn().mockResolvedValue({
209+
id: "ORDER_ABC",
210+
status: "COMPLETED",
211+
purchase_units: [{
212+
payments: {
213+
captures: [{
214+
id: "CAPTURE_PENDING",
215+
status: "PENDING",
216+
status_details: { reason: "PENDING_REVIEW" },
217+
}],
218+
},
219+
}],
220+
}),
221+
createOrder: vi.fn(),
222+
refundCapture: vi.fn(),
223+
getOrder: vi.fn(),
224+
} as any)
225+
226+
const service = new PaymentService({ provider: "paypal" })
227+
const result = await service.processPayment(0, "usd", "order_ORDER_ABC")
228+
229+
expect(result.success).toBe(false)
230+
expect(result.requiresAction).toBe(true)
231+
expect(result.error).toContain("pending")
232+
expect(result.transactionId).toBe("CAPTURE_PENDING")
233+
})
234+
152235
it("should return error when PayPal is not configured", async () => {
153236
const { getPayPalService } = await import("../paypal-service")
154237
vi.mocked(getPayPalService).mockReturnValue(null)

client/lib/payment-service.ts

Lines changed: 47 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -156,20 +156,53 @@ export class PaymentService {
156156
const orderId = paymentMethodId.replace('order_', '')
157157
const capture = await paypalService.captureOrder(orderId)
158158

159-
const captureId = capture.purchase_units[0]?.payments?.captures[0]?.id
160-
const status = capture.purchase_units[0]?.payments?.captures[0]?.status
161-
162-
if (status === 'COMPLETED' && captureId) {
163-
return {
164-
success: true,
165-
transactionId: captureId,
166-
}
167-
} else {
168-
return {
169-
success: false,
170-
transactionId: orderId,
171-
error: `Payment capture failed with status: ${status}`,
172-
}
159+
const captureDetails = capture.purchase_units[0]?.payments?.captures[0]
160+
const captureId = captureDetails?.id
161+
const status = captureDetails?.status
162+
const reason = captureDetails?.status_details?.reason
163+
164+
// Handle every documented PayPal capture status explicitly so callers
165+
// can distinguish a completed payment from a declined, failed, or
166+
// pending-review one rather than collapsing them into one error.
167+
// @see https://developer.paypal.com/docs/api/orders/v2/#definition-capture_status
168+
switch (status) {
169+
case 'COMPLETED':
170+
if (!captureId) {
171+
return {
172+
success: false,
173+
transactionId: orderId,
174+
error: 'PayPal reported a completed capture but returned no capture ID',
175+
}
176+
}
177+
return {
178+
success: true,
179+
transactionId: captureId,
180+
}
181+
182+
case 'PENDING':
183+
// Authorized but held for review (e.g. risk/AVS). Not yet a success —
184+
// surface it and persist as pending so the webhook can finalize it.
185+
return {
186+
success: false,
187+
transactionId: captureId || orderId,
188+
requiresAction: true,
189+
error: `PayPal capture is pending review${reason ? `: ${reason}` : ''}`,
190+
}
191+
192+
case 'DECLINED':
193+
case 'FAILED':
194+
return {
195+
success: false,
196+
transactionId: captureId || orderId,
197+
error: `PayPal capture ${status.toLowerCase()}${reason ? `: ${reason}` : ''}`,
198+
}
199+
200+
default:
201+
return {
202+
success: false,
203+
transactionId: orderId,
204+
error: `Payment capture failed with status: ${status ?? 'UNKNOWN'}`,
205+
}
173206
}
174207
}
175208

client/lib/paypal-service.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,9 @@ export interface PayPalCaptureResponse {
5151
currency_code: string
5252
value: string
5353
}
54+
status_details?: {
55+
reason?: string
56+
}
5457
}>
5558
}
5659
}>

0 commit comments

Comments
 (0)