Skip to content

Commit 6090694

Browse files
(SP: 1) [Shop/Backend] Checkout: enforce post-create Stripe failure semantics (502 STRIPE_ERROR, 409 CHECKOUT_CONFLICT) + contract tests
1 parent 5284ab3 commit 6090694

10 files changed

Lines changed: 375 additions & 95 deletions

File tree

frontend/app/[locale]/shop/cart/page.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -222,7 +222,7 @@ export default function CartPage() {
222222
</div>
223223

224224
<span className="text-sm font-semibold text-foreground">
225-
{formatMoney(item.lineTotal, item.currency, locale)}
225+
{formatMoney(item.lineTotalMinor, item.currency, locale)}
226226
</span>
227227
</div>
228228
</div>
@@ -240,7 +240,7 @@ export default function CartPage() {
240240
<span className="text-muted-foreground">Subtotal</span>
241241
<span className="font-medium text-foreground">
242242
{formatMoney(
243-
cart.summary.totalAmount,
243+
cart.summary.totalAmountMinor,
244244
cart.summary.currency,
245245
locale
246246
)}
@@ -259,7 +259,7 @@ export default function CartPage() {
259259
</span>
260260
<span className="text-lg font-bold text-foreground">
261261
{formatMoney(
262-
cart.summary.totalAmount,
262+
cart.summary.totalAmountMinor,
263263
cart.summary.currency,
264264
locale
265265
)}

frontend/app/[locale]/shop/products/page.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { ProductCard } from '@/components/shop/product-card';
55
import { ProductFilters } from '@/components/shop/product-filters';
66
import { ProductSort } from '@/components/shop/product-sort';
77
import { CatalogLoadMore } from '@/components/shop/catalog-load-more';
8+
// import { Pagination } from '@/components/q&a/Pagination';
89
import { getCatalogProducts } from '@/lib/shop/data';
910
import { catalogQuerySchema } from '@/lib/validation/shop';
1011
import { CATALOG_PAGE_SIZE } from '@/lib/config/catalog';
@@ -18,6 +19,7 @@ type RawSearchParams = {
1819
page?: string;
1920
};
2021

22+
2123
interface ProductsPageProps {
2224
searchParams: Promise<RawSearchParams>;
2325
}
@@ -84,6 +86,13 @@ export default async function ProductsPage({
8486
hasMore={catalog.hasMore}
8587
nextPage={catalog.page + 1}
8688
/>
89+
{/* {!isLoading && totalPages > 1 && (
90+
<Pagination
91+
currentPage={currentPage}
92+
totalPages={totalPages}
93+
onPageChange={handlePageChange}
94+
/>
95+
)} */}
8796
</div>
8897
</>
8998
)}

frontend/app/api/shop/checkout/route.ts

Lines changed: 84 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -157,11 +157,7 @@ export async function POST(request: NextRequest) {
157157
logWarn('Failed to parse cart payload', {
158158
reason: error instanceof Error ? error.message : String(error),
159159
});
160-
return errorResponse(
161-
'INVALID_PAYLOAD',
162-
'Unable to process cart data.',
163-
400
164-
);
160+
return errorResponse('INVALID_PAYLOAD', 'Unable to process cart data.', 400);
165161
}
166162

167163
const idempotencyKey = getIdempotencyKey(request);
@@ -241,22 +237,13 @@ export async function POST(request: NextRequest) {
241237
const paymentsEnabled = isPaymentsEnabled();
242238

243239
if (!paymentsEnabled) {
244-
// If the order already failed (inventory or other), return a stable conflict instead of 500.
245-
if (
246-
order.paymentProvider === 'none' &&
247-
order.paymentStatus === 'failed'
248-
) {
249-
return errorResponse(
250-
'CHECKOUT_FAILED',
251-
'Order could not be completed.',
252-
409,
253-
{ orderId: order.id }
254-
);
240+
if (order.paymentProvider === 'none' && order.paymentStatus === 'failed') {
241+
return errorResponse('CHECKOUT_FAILED', 'Order could not be completed.', 409, {
242+
orderId: order.id,
243+
});
255244
}
256-
if (
257-
order.paymentProvider === 'stripe' &&
258-
order.paymentStatus !== 'paid'
259-
) {
245+
246+
if (order.paymentProvider === 'stripe' && order.paymentStatus !== 'paid') {
260247
return errorResponse(
261248
'PAYMENTS_DISABLED',
262249
'Payments are disabled. This order requires payment and cannot be processed.',
@@ -266,16 +253,9 @@ export async function POST(request: NextRequest) {
266253
}
267254

268255
if (order.paymentProvider === 'none') {
269-
if (
270-
!['paid', 'failed'].includes(order.paymentStatus) ||
271-
order.paymentIntentId
272-
) {
256+
if (!['paid', 'failed'].includes(order.paymentStatus) || order.paymentIntentId) {
273257
logError(
274-
`Payments disabled but order is not paid/none. orderId=${
275-
order.id
276-
} provider=${order.paymentProvider} status=${
277-
order.paymentStatus
278-
} intent=${order.paymentIntentId ?? 'null'}`,
258+
`Payments disabled but order is not paid/none. orderId=${order.id} provider=${order.paymentProvider} status=${order.paymentStatus} intent=${order.paymentIntentId ?? 'null'}`,
279259
new Error('ORDER_STATE_INVALID')
280260
);
281261
return errorResponse(
@@ -287,16 +267,16 @@ export async function POST(request: NextRequest) {
287267
}
288268
}
289269

290-
const stripePaymentFlow =
291-
paymentsEnabled && order.paymentProvider === 'stripe';
270+
const stripePaymentFlow = paymentsEnabled && order.paymentProvider === 'stripe';
292271

272+
// =========================
273+
// Existing order path
274+
// =========================
293275
if (!result.isNew) {
276+
// Existing order already has PI: retrieve client_secret
294277
if (stripePaymentFlow && order.paymentIntentId) {
295278
try {
296-
const paymentIntent = await retrievePaymentIntent(
297-
order.paymentIntentId
298-
);
299-
279+
const paymentIntent = await retrievePaymentIntent(order.paymentIntentId);
300280
return buildCheckoutResponse({
301281
order: {
302282
id: order.id,
@@ -312,23 +292,27 @@ export async function POST(request: NextRequest) {
312292
});
313293
} catch (error) {
314294
logError('Checkout payment intent retrieval failed', error);
315-
return errorResponse(
316-
'STRIPE_ERROR',
317-
'Unable to initiate payment.',
318-
400
319-
);
295+
return errorResponse('STRIPE_ERROR', 'Unable to initiate payment.', 502);
320296
}
321297
}
322298

299+
// Existing order without PI: create PI then attach (post-create => never 400)
323300
if (stripePaymentFlow && !order.paymentIntentId) {
301+
let paymentIntent: { paymentIntentId: string; clientSecret: string };
302+
324303
try {
325-
const paymentIntent = await createPaymentIntent({
304+
paymentIntent = await createPaymentIntent({
326305
amount: totalCents,
327306
currency: order.currency,
328307
orderId: order.id,
329308
idempotencyKey,
330309
});
310+
} catch (error) {
311+
logError('Checkout payment intent creation failed', error);
312+
return errorResponse('STRIPE_ERROR', 'Unable to initiate payment.', 502);
313+
}
331314

315+
try {
332316
const updatedOrder = await setOrderPaymentIntent({
333317
orderId: order.id,
334318
paymentIntentId: paymentIntent.paymentIntentId,
@@ -348,15 +332,30 @@ export async function POST(request: NextRequest) {
348332
status: 200,
349333
});
350334
} catch (error) {
351-
logError('Checkout payment intent creation failed', error);
352-
return errorResponse(
353-
'STRIPE_ERROR',
354-
'Unable to initiate payment.',
355-
400
356-
);
335+
logError('Checkout payment intent attach failed', error);
336+
337+
// Post-create => conflict, not 400
338+
if (error instanceof InvalidPayloadError) {
339+
return errorResponse(
340+
'CHECKOUT_CONFLICT',
341+
'Order state conflict while attaching payment intent. Retry with the same Idempotency-Key.',
342+
409,
343+
{ orderId: order.id }
344+
);
345+
}
346+
347+
if (error instanceof OrderStateInvalidError) {
348+
return errorResponse(error.code, error.message, 500, {
349+
orderId: error.orderId,
350+
...(error.details ? { details: error.details } : {}),
351+
});
352+
}
353+
354+
return errorResponse('INTERNAL_ERROR', 'Unable to process checkout.', 500);
357355
}
358356
}
359357

358+
// Not Stripe flow => return existing order as-is
360359
return buildCheckoutResponse({
361360
order: {
362361
id: order.id,
@@ -372,6 +371,9 @@ export async function POST(request: NextRequest) {
372371
});
373372
}
374373

374+
// =========================
375+
// New order path
376+
// =========================
375377
if (!stripePaymentFlow) {
376378
return buildCheckoutResponse({
377379
order: {
@@ -388,14 +390,30 @@ export async function POST(request: NextRequest) {
388390
});
389391
}
390392

393+
// Stripe new order: Phase 1 PSP call (if fails => restock best-effort, return 502)
394+
let paymentIntent: { paymentIntentId: string; clientSecret: string };
395+
391396
try {
392-
const paymentIntent = await createPaymentIntent({
397+
paymentIntent = await createPaymentIntent({
393398
amount: totalCents,
394399
currency: order.currency,
395400
orderId: order.id,
396401
idempotencyKey,
397402
});
403+
} catch (error) {
404+
logError('Checkout payment intent creation failed', error);
398405

406+
try {
407+
await restockOrder(order.id, { reason: 'failed' });
408+
} catch (restockError) {
409+
logError('Restoring stock after payment intent failure failed', restockError);
410+
}
411+
412+
return errorResponse('STRIPE_ERROR', 'Unable to initiate payment.', 502);
413+
}
414+
415+
// Stripe new order: Phase 2 attach PI (post-create => never 400)
416+
try {
399417
const updatedOrder = await setOrderPaymentIntent({
400418
orderId: order.id,
401419
paymentIntentId: paymentIntent.paymentIntentId,
@@ -415,36 +433,34 @@ export async function POST(request: NextRequest) {
415433
status: 201,
416434
});
417435
} catch (error) {
418-
logError('Checkout payment intent creation failed', error);
436+
logError('Checkout payment intent attach failed', error);
419437

420-
try {
421-
await restockOrder(order.id, { reason: 'failed' });
422-
} catch (restockError) {
423-
logError(
424-
'Restoring stock after payment intent failure failed',
425-
restockError
438+
if (error instanceof InvalidPayloadError) {
439+
// Conflict/race/state issue. Do NOT return 400.
440+
// Leave inventory reserved; retry with same idempotency key or janitor will sweep.
441+
return errorResponse(
442+
'CHECKOUT_CONFLICT',
443+
'Order state conflict while attaching payment intent. Retry with the same Idempotency-Key.',
444+
409,
445+
{ orderId: order.id }
426446
);
427447
}
428448

429-
if (error instanceof Error && error.message.startsWith('STRIPE_')) {
430-
return errorResponse(
431-
'STRIPE_ERROR',
432-
'Unable to initiate payment.',
433-
400
434-
);
449+
// For non-conflict attach failures: best-effort release to avoid stock lock
450+
try {
451+
await restockOrder(order.id, { reason: 'failed' });
452+
} catch (restockError) {
453+
logError('Restoring stock after payment intent attach failure failed', restockError);
435454
}
436455

437456
if (error instanceof OrderStateInvalidError) {
438457
return errorResponse(error.code, error.message, 500, {
439458
orderId: error.orderId,
459+
...(error.details ? { details: error.details } : {}),
440460
});
441461
}
442462

443-
return errorResponse(
444-
'INTERNAL_ERROR',
445-
'Unable to process checkout.',
446-
500
447-
);
463+
return errorResponse('INTERNAL_ERROR', 'Unable to process checkout.', 500);
448464
}
449465
} catch (error) {
450466
if (isExpectedBusinessError(error)) {
@@ -457,11 +473,7 @@ export async function POST(request: NextRequest) {
457473
}
458474

459475
if (error instanceof InvalidPayloadError) {
460-
return errorResponse(
461-
error.code,
462-
error.message || 'Invalid checkout payload',
463-
400
464-
);
476+
return errorResponse(error.code, error.message || 'Invalid checkout payload', 400);
465477
}
466478

467479
if (error instanceof InvalidVariantError) {
@@ -512,4 +524,4 @@ export async function POST(request: NextRequest) {
512524

513525
return errorResponse('INTERNAL_ERROR', 'Unable to process checkout.', 500);
514526
}
515-
}
527+
}

frontend/lib/psp/stripe.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ export async function createRefund({
4545
}
4646

4747
if (amountMinor !== undefined) {
48-
if (!Number.isInteger(amountMinor) || amountMinor <= 0) {
48+
if (!Number.isSafeInteger(amountMinor) || amountMinor <= 0) {
4949
throw new Error('STRIPE_INVALID_REFUND_AMOUNT');
5050
}
5151
}
@@ -97,7 +97,8 @@ export async function createPaymentIntent({
9797
throw new Error('STRIPE_DISABLED');
9898
}
9999

100-
if (!Number.isFinite(amount) || amount <= 0) {
100+
// Stripe amount must be an integer in minor units. Fail-closed on floats/NaN/huge values.
101+
if (!Number.isSafeInteger(amount) || amount <= 0) {
101102
throw new Error('STRIPE_INVALID_AMOUNT');
102103
}
103104

frontend/lib/services/orders/checkout.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,9 @@ import { getOrderById, getOrderByIdempotencyKey } from './summary';
4646
import { restockOrder } from './restock';
4747
import { guardedPaymentStatusUpdate } from './payment-state';
4848

49+
// NOTE: PaymentStatus semantics for Stripe:
50+
// pending (no PI yet) -> requires_payment (PI attached) -> paid/failed/refunded via provider events.
51+
4952
async function reconcileNoPaymentOrder(
5053
orderId: string
5154
): Promise<OrderSummaryWithMinor> {
@@ -409,8 +412,10 @@ export async function createOrderWithItems({
409412

410413
// paymentStatus is initialized here only; ALL transitions must go via guardedPaymentStatusUpdate.
411414
// IMPORTANT: DB CHECK requires provider='none' => payment_status in ('paid','failed')
415+
// Avoid the cycle: requires_payment -> pending -> requires_payment.
416+
// For Stripe, start at pending and switch to requires_payment only after PI is attached.
412417
const initialPaymentStatus: PaymentStatus =
413-
paymentProvider === 'none' ? 'paid' : 'requires_payment';
418+
paymentProvider === 'none' ? 'paid' : 'pending';
414419

415420
const normalizedItems = mergeCheckoutItems(
416421
items

frontend/lib/services/orders/payment-intent.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ export async function setOrderPaymentIntent({
3232
);
3333
}
3434

35+
// New flow: pending -> requires_payment when attaching PI.
36+
// Keep requires_payment only for backward-compat (old orders created before this change).
3537
const allowed: PaymentStatus[] = ['pending', 'requires_payment'];
3638
if (!allowed.includes(existing.paymentStatus as PaymentStatus)) {
3739
throw new InvalidPayloadError(

frontend/lib/services/orders/refund.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,8 @@ export async function refundOrder(
157157
idempotencyKey,
158158
});
159159

160-
const createdAtIso = new Date().toISOString();
160+
const now = new Date();
161+
const createdAtIso = now.toISOString();
161162

162163
const nextMeta = appendRefund(order.pspMetadata, {
163164
refundId,
@@ -172,7 +173,11 @@ export async function refundOrder(
172173
// Persist тільки metadata. payment_status НЕ чіпаємо (джерело істини — webhook)
173174
await db
174175
.update(orders)
175-
.set({ pspMetadata: nextMeta })
176+
.set({
177+
updatedAt: now,
178+
pspStatusReason: 'REFUND_REQUESTED',
179+
pspMetadata: nextMeta,
180+
})
176181
.where(eq(orders.id, orderId));
177182

178183
// Повертаємо як і раніше: order summary для API

0 commit comments

Comments
 (0)