Skip to content

Commit 5422f34

Browse files
(SP: 1)[SHOP] enforce explicit no-discount checkout contract for launch
1 parent 64c2919 commit 5422f34

2 files changed

Lines changed: 85 additions & 0 deletions

File tree

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

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ type CheckoutRequestedProvider = 'stripe' | 'monobank';
5959
const EXPECTED_BUSINESS_ERROR_CODES = new Set([
6060
'IDEMPOTENCY_CONFLICT',
6161
'INVALID_PAYLOAD',
62+
'DISCOUNTS_NOT_SUPPORTED',
6263
'INVALID_VARIANT',
6364
'INSUFFICIENT_STOCK',
6465
'CHECKOUT_PRICE_CHANGED',
@@ -94,6 +95,15 @@ const STATUS_TOKEN_SCOPES_PAYMENT_INIT: readonly StatusTokenScope[] = [
9495
'status_lite',
9596
'order_payment_init',
9697
];
98+
const UNSUPPORTED_DISCOUNT_FIELDS = new Set([
99+
'discountCode',
100+
'couponCode',
101+
'promoCode',
102+
'discountAmount',
103+
'discountAmountMinor',
104+
'totalDiscountAmount',
105+
'totalDiscountMinor',
106+
]);
97107

98108
function resolveCheckoutTokenScopes(args: {
99109
paymentProvider: PaymentProvider;
@@ -342,6 +352,18 @@ function errorResponse(
342352
return res;
343353
}
344354

355+
function collectUnsupportedDiscountFields(
356+
value: unknown
357+
): string[] {
358+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
359+
return [];
360+
}
361+
362+
return Object.keys(value).filter(field =>
363+
UNSUPPORTED_DISCOUNT_FIELDS.has(field)
364+
);
365+
}
366+
345367
function getIdempotencyKey(request: NextRequest) {
346368
const headerKey = request.headers.get('Idempotency-Key');
347369
if (headerKey === null || headerKey === undefined) return null;
@@ -998,6 +1020,24 @@ export async function POST(request: NextRequest) {
9981020
};
9991021
}
10001022

1023+
const unsupportedDiscountFields =
1024+
collectUnsupportedDiscountFields(payloadForValidation);
1025+
1026+
if (unsupportedDiscountFields.length > 0) {
1027+
logWarn('checkout_discount_not_supported', {
1028+
...meta,
1029+
code: 'DISCOUNTS_NOT_SUPPORTED',
1030+
fields: unsupportedDiscountFields,
1031+
});
1032+
1033+
return errorResponse(
1034+
'DISCOUNTS_NOT_SUPPORTED',
1035+
'Discounts are not available at checkout.',
1036+
400,
1037+
{ fields: unsupportedDiscountFields }
1038+
);
1039+
}
1040+
10011041
const parsedPayload = checkoutPayloadSchema.safeParse(payloadForValidation);
10021042

10031043
if (!parsedPayload.success) {

frontend/lib/tests/shop/checkout-shipping-authoritative-total.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -480,4 +480,49 @@ describe('checkout authoritative shipping totals', () => {
480480

481481
expect(orderRow).toBeFalsy();
482482
});
483+
484+
it('rejects client-supplied discount input under the launch no-discount contract', async () => {
485+
const seed = await seedShippingCheckoutData();
486+
const quote = await rehydrateCartItems(
487+
[{ productId: seed.productId, quantity: 1 }],
488+
'UAH'
489+
);
490+
const warehouseMethod = await fetchWarehouseMethodQuote();
491+
const pricingFingerprint = quote.summary.pricingFingerprint;
492+
const idempotencyKey = crypto.randomUUID();
493+
494+
expect(typeof pricingFingerprint).toBe('string');
495+
expect(pricingFingerprint).toHaveLength(64);
496+
497+
const response = await POST(
498+
makeCheckoutRequest({
499+
idempotencyKey,
500+
productId: seed.productId,
501+
pricingFingerprint: pricingFingerprint!,
502+
cityRef: seed.cityRef,
503+
warehouseRef: seed.warehouseRef,
504+
shippingQuoteFingerprint: warehouseMethod.quoteFingerprint,
505+
extraBody: {
506+
discountCode: 'SPRING10',
507+
discountAmountMinor: 1000,
508+
},
509+
})
510+
);
511+
512+
expect(response.status).toBe(400);
513+
const json = await response.json();
514+
expect(json.code).toBe('DISCOUNTS_NOT_SUPPORTED');
515+
expect(json.message).toBe('Discounts are not available at checkout.');
516+
expect(json.details?.fields).toEqual(
517+
expect.arrayContaining(['discountCode', 'discountAmountMinor'])
518+
);
519+
520+
const [orderRow] = await db
521+
.select({ id: orders.id })
522+
.from(orders)
523+
.where(eq(orders.idempotencyKey, idempotencyKey))
524+
.limit(1);
525+
526+
expect(orderRow).toBeFalsy();
527+
});
483528
});

0 commit comments

Comments
 (0)