Skip to content

Commit 2fe1dc8

Browse files
(SP: 1) [Checkout] Use DB-canonical totalAmountMinor/currency for Stripe PaymentIntent creation (remove in-memory totals)
1 parent 56ed8d5 commit 2fe1dc8

4 files changed

Lines changed: 143 additions & 26 deletions

File tree

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

Lines changed: 79 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
PriceConfigError,
1515
OrderStateInvalidError,
1616
} from '@/lib/services/errors';
17+
import { readStripePaymentIntentParams } from '@/lib/services/orders/payment-intent';
1718

1819
import {
1920
createOrderWithItems,
@@ -157,7 +158,11 @@ export async function POST(request: NextRequest) {
157158
logWarn('Failed to parse cart payload', {
158159
reason: error instanceof Error ? error.message : String(error),
159160
});
160-
return errorResponse('INVALID_PAYLOAD', 'Unable to process cart data.', 400);
161+
return errorResponse(
162+
'INVALID_PAYLOAD',
163+
'Unable to process cart data.',
164+
400
165+
);
161166
}
162167

163168
const idempotencyKey = getIdempotencyKey(request);
@@ -232,18 +237,29 @@ export async function POST(request: NextRequest) {
232237
locale,
233238
});
234239

235-
const { order, totalCents } = result;
240+
const { order } = result;
236241

237242
const paymentsEnabled = isPaymentsEnabled();
238243

239244
if (!paymentsEnabled) {
240-
if (order.paymentProvider === 'none' && order.paymentStatus === 'failed') {
241-
return errorResponse('CHECKOUT_FAILED', 'Order could not be completed.', 409, {
242-
orderId: order.id,
243-
});
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+
{
254+
orderId: order.id,
255+
}
256+
);
244257
}
245258

246-
if (order.paymentProvider === 'stripe' && order.paymentStatus !== 'paid') {
259+
if (
260+
order.paymentProvider === 'stripe' &&
261+
order.paymentStatus !== 'paid'
262+
) {
247263
return errorResponse(
248264
'PAYMENTS_DISABLED',
249265
'Payments are disabled. This order requires payment and cannot be processed.',
@@ -253,9 +269,16 @@ export async function POST(request: NextRequest) {
253269
}
254270

255271
if (order.paymentProvider === 'none') {
256-
if (!['paid', 'failed'].includes(order.paymentStatus) || order.paymentIntentId) {
272+
if (
273+
!['paid', 'failed'].includes(order.paymentStatus) ||
274+
order.paymentIntentId
275+
) {
257276
logError(
258-
`Payments disabled but order is not paid/none. orderId=${order.id} provider=${order.paymentProvider} status=${order.paymentStatus} intent=${order.paymentIntentId ?? 'null'}`,
277+
`Payments disabled but order is not paid/none. orderId=${
278+
order.id
279+
} provider=${order.paymentProvider} status=${
280+
order.paymentStatus
281+
} intent=${order.paymentIntentId ?? 'null'}`,
259282
new Error('ORDER_STATE_INVALID')
260283
);
261284
return errorResponse(
@@ -267,7 +290,8 @@ export async function POST(request: NextRequest) {
267290
}
268291
}
269292

270-
const stripePaymentFlow = paymentsEnabled && order.paymentProvider === 'stripe';
293+
const stripePaymentFlow =
294+
paymentsEnabled && order.paymentProvider === 'stripe';
271295

272296
// =========================
273297
// Existing order path
@@ -276,7 +300,9 @@ export async function POST(request: NextRequest) {
276300
// Existing order already has PI: retrieve client_secret
277301
if (stripePaymentFlow && order.paymentIntentId) {
278302
try {
279-
const paymentIntent = await retrievePaymentIntent(order.paymentIntentId);
303+
const paymentIntent = await retrievePaymentIntent(
304+
order.paymentIntentId
305+
);
280306
return buildCheckoutResponse({
281307
order: {
282308
id: order.id,
@@ -292,7 +318,11 @@ export async function POST(request: NextRequest) {
292318
});
293319
} catch (error) {
294320
logError('Checkout payment intent retrieval failed', error);
295-
return errorResponse('STRIPE_ERROR', 'Unable to initiate payment.', 502);
321+
return errorResponse(
322+
'STRIPE_ERROR',
323+
'Unable to initiate payment.',
324+
502
325+
);
296326
}
297327
}
298328

@@ -301,15 +331,21 @@ export async function POST(request: NextRequest) {
301331
let paymentIntent: { paymentIntentId: string; clientSecret: string };
302332

303333
try {
334+
const snapshot = await readStripePaymentIntentParams(order.id);
335+
304336
paymentIntent = await createPaymentIntent({
305-
amount: totalCents,
306-
currency: order.currency,
337+
amount: snapshot.amountMinor,
338+
currency: snapshot.currency,
307339
orderId: order.id,
308340
idempotencyKey,
309341
});
310342
} catch (error) {
311343
logError('Checkout payment intent creation failed', error);
312-
return errorResponse('STRIPE_ERROR', 'Unable to initiate payment.', 502);
344+
return errorResponse(
345+
'STRIPE_ERROR',
346+
'Unable to initiate payment.',
347+
502
348+
);
313349
}
314350

315351
try {
@@ -351,7 +387,11 @@ export async function POST(request: NextRequest) {
351387
});
352388
}
353389

354-
return errorResponse('INTERNAL_ERROR', 'Unable to process checkout.', 500);
390+
return errorResponse(
391+
'INTERNAL_ERROR',
392+
'Unable to process checkout.',
393+
500
394+
);
355395
}
356396
}
357397

@@ -394,9 +434,11 @@ export async function POST(request: NextRequest) {
394434
let paymentIntent: { paymentIntentId: string; clientSecret: string };
395435

396436
try {
437+
const snapshot = await readStripePaymentIntentParams(order.id);
438+
397439
paymentIntent = await createPaymentIntent({
398-
amount: totalCents,
399-
currency: order.currency,
440+
amount: snapshot.amountMinor,
441+
currency: snapshot.currency,
400442
orderId: order.id,
401443
idempotencyKey,
402444
});
@@ -406,7 +448,10 @@ export async function POST(request: NextRequest) {
406448
try {
407449
await restockOrder(order.id, { reason: 'failed' });
408450
} catch (restockError) {
409-
logError('Restoring stock after payment intent failure failed', restockError);
451+
logError(
452+
'Restoring stock after payment intent failure failed',
453+
restockError
454+
);
410455
}
411456

412457
return errorResponse('STRIPE_ERROR', 'Unable to initiate payment.', 502);
@@ -450,7 +495,10 @@ export async function POST(request: NextRequest) {
450495
try {
451496
await restockOrder(order.id, { reason: 'failed' });
452497
} catch (restockError) {
453-
logError('Restoring stock after payment intent attach failure failed', restockError);
498+
logError(
499+
'Restoring stock after payment intent attach failure failed',
500+
restockError
501+
);
454502
}
455503

456504
if (error instanceof OrderStateInvalidError) {
@@ -460,7 +508,11 @@ export async function POST(request: NextRequest) {
460508
});
461509
}
462510

463-
return errorResponse('INTERNAL_ERROR', 'Unable to process checkout.', 500);
511+
return errorResponse(
512+
'INTERNAL_ERROR',
513+
'Unable to process checkout.',
514+
500
515+
);
464516
}
465517
} catch (error) {
466518
if (isExpectedBusinessError(error)) {
@@ -473,7 +525,11 @@ export async function POST(request: NextRequest) {
473525
}
474526

475527
if (error instanceof InvalidPayloadError) {
476-
return errorResponse(error.code, error.message || 'Invalid checkout payload', 400);
528+
return errorResponse(
529+
error.code,
530+
error.message || 'Invalid checkout payload',
531+
400
532+
);
477533
}
478534

479535
if (error instanceof InvalidVariantError) {
@@ -524,4 +580,4 @@ export async function POST(request: NextRequest) {
524580

525581
return errorResponse('INTERNAL_ERROR', 'Unable to process checkout.', 500);
526582
}
527-
}
583+
}

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

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,13 @@ import { eq } from 'drizzle-orm';
22

33
import { db } from '@/db';
44
import { orders } from '@/db/schema/shop';
5-
import { type PaymentStatus } from '@/lib/shop/payments';
5+
import { type PaymentProvider, type PaymentStatus } from '@/lib/shop/payments';
66
import { type OrderSummaryWithMinor } from '@/lib/types/shop';
7-
8-
import { InvalidPayloadError, OrderNotFoundError } from '../errors';
7+
import {
8+
InvalidPayloadError,
9+
OrderNotFoundError,
10+
OrderStateInvalidError,
11+
} from '../errors';
912
import { resolvePaymentProvider } from './_shared';
1013
import { getOrderItems, parseOrderSummary } from './summary';
1114
import { guardedPaymentStatusUpdate } from './payment-state';
@@ -86,3 +89,45 @@ export async function setOrderPaymentIntent({
8689
const items = await getOrderItems(orderId);
8790
return parseOrderSummary(updated, items);
8891
}
92+
93+
export async function readStripePaymentIntentParams(orderId: string): Promise<{
94+
amountMinor: number;
95+
currency: (typeof orders.$inferSelect)['currency'];
96+
}> {
97+
const [existing] = await db
98+
.select()
99+
.from(orders)
100+
.where(eq(orders.id, orderId))
101+
.limit(1);
102+
103+
if (!existing) throw new OrderNotFoundError('Order not found');
104+
105+
const provider: PaymentProvider = resolvePaymentProvider(existing);
106+
107+
if (provider !== 'stripe') {
108+
throw new InvalidPayloadError(
109+
'Payment intent can only be created for stripe orders.'
110+
);
111+
}
112+
113+
const amountMinor = existing.totalAmountMinor;
114+
115+
// Canonical money source = DB minor units. Fail-closed on invalid totals.
116+
if (!Number.isSafeInteger(amountMinor) || amountMinor <= 0) {
117+
const err = new OrderStateInvalidError(
118+
'Invalid order total for Stripe payment intent creation.'
119+
);
120+
121+
// attach diagnostics for API handler (keeps existing errorResponse shape)
122+
(err as any).orderId = orderId;
123+
(err as any).field = 'totalAmountMinor';
124+
(err as any).rawValue = amountMinor;
125+
(err as any).details = {
126+
reason: 'Invalid order total for Stripe payment intent creation.',
127+
};
128+
129+
throw err;
130+
}
131+
132+
return { amountMinor, currency: existing.currency };
133+
}

frontend/lib/tests/checkout-set-payment-intent-reject-contract.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,14 @@ vi.mock('@/lib/psp/stripe', () => ({
2828
retrievePaymentIntent: vi.fn(),
2929
}));
3030

31+
// Avoid DB coupling introduced by #6 (DB-canonical PI amount/currency)
32+
vi.mock('@/lib/services/orders/payment-intent', () => ({
33+
readStripePaymentIntentParams: vi.fn(async () => ({
34+
amountMinor: 1000,
35+
currency: 'USD',
36+
})),
37+
}));
38+
3139
// Mock order services
3240
vi.mock('@/lib/services/orders', async () => {
3341
const actual = await vi.importActual<any>('@/lib/services/orders');

frontend/lib/tests/checkout-stripe-error-contract.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,14 @@ vi.mock('@/lib/psp/stripe', () => ({
2525
retrievePaymentIntent: vi.fn(),
2626
}));
2727

28+
// Avoid DB coupling introduced by #6 (DB-canonical PI amount/currency)
29+
vi.mock('@/lib/services/orders/payment-intent', () => ({
30+
readStripePaymentIntentParams: vi.fn(async () => ({
31+
amountMinor: 1000,
32+
currency: 'USD',
33+
})),
34+
}));
35+
2836
// 4) mock orders services so we don't depend on DB schema/seed here
2937
vi.mock('@/lib/services/orders', async () => {
3038
const actual = await vi.importActual<any>('@/lib/services/orders');

0 commit comments

Comments
 (0)