Skip to content

Commit 64c2919

Browse files
(SP: 2)[SHOP] make checkout totals include authoritative shipping fail-closed
1 parent 8fc48fb commit 64c2919

11 files changed

Lines changed: 861 additions & 203 deletions

File tree

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

Lines changed: 78 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@ type ShippingMethod = {
6363
provider: 'nova_poshta';
6464
methodCode: CheckoutDeliveryMethodCode;
6565
title: string;
66+
amountMinor: number;
67+
quoteFingerprint: string;
6668
};
6769

6870
type ShippingCity = {
@@ -161,6 +163,57 @@ function normalizeShippingCity(raw: unknown): ShippingCity | null {
161163
nameUa,
162164
};
163165
}
166+
167+
function readTrimmedNonEmptyString(value: unknown): string | null {
168+
if (typeof value !== 'string') return null;
169+
const trimmed = value.trim();
170+
return trimmed.length > 0 ? trimmed : null;
171+
}
172+
173+
function normalizeShippingMethod(raw: unknown): ShippingMethod | null {
174+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
175+
return null;
176+
}
177+
178+
const item = raw as Record<string, unknown>;
179+
if (item.provider !== 'nova_poshta') {
180+
return null;
181+
}
182+
183+
const methodCode = readTrimmedNonEmptyString(item.methodCode);
184+
const title = readTrimmedNonEmptyString(item.title);
185+
const quoteFingerprint = readTrimmedNonEmptyString(item.quoteFingerprint);
186+
const amountMinor = item.amountMinor;
187+
188+
if (!methodCode || !isValidDeliveryMethodCode(methodCode)) {
189+
return null;
190+
}
191+
192+
if (!title) {
193+
return null;
194+
}
195+
196+
if (
197+
typeof amountMinor !== 'number' ||
198+
!Number.isInteger(amountMinor) ||
199+
amountMinor < 0
200+
) {
201+
return null;
202+
}
203+
204+
if (!quoteFingerprint || !/^[a-f0-9]{64}$/.test(quoteFingerprint)) {
205+
return null;
206+
}
207+
208+
return {
209+
provider: 'nova_poshta',
210+
methodCode,
211+
title,
212+
amountMinor,
213+
quoteFingerprint,
214+
};
215+
}
216+
164217
function normalizeShippingWarehouse(raw: unknown): ShippingWarehouse | null {
165218
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
166219
return null;
@@ -454,6 +507,13 @@ export default function CartPage({
454507
shippingReasonCode === 'COUNTRY_NOT_SUPPORTED' ||
455508
shippingReasonCode === 'CURRENCY_NOT_SUPPORTED' ||
456509
shippingReasonCode === 'INTERNAL_ERROR';
510+
const selectedShippingQuote =
511+
shippingMethods.find(
512+
method => method.methodCode === selectedShippingMethod
513+
) ?? null;
514+
const checkoutSummaryShippingMinor = selectedShippingQuote?.amountMinor ?? 0;
515+
const checkoutSummaryTotalMinor =
516+
cart.summary.totalAmountMinor + checkoutSummaryShippingMinor;
457517

458518
const isWarehouseSelectionMethod = isWarehouseMethod(selectedShippingMethod);
459519

@@ -636,30 +696,14 @@ export default function CartPage({
636696
const methods: ShippingMethod[] = [];
637697

638698
for (const item of methodsRaw) {
639-
if (!item || typeof item !== 'object' || Array.isArray(item)) {
640-
hardBlock();
641-
return;
642-
}
699+
const method = normalizeShippingMethod(item);
643700

644-
const m = item as Record<string, unknown>;
645-
646-
const providerOk = m.provider === 'nova_poshta';
647-
const methodCode =
648-
typeof m.methodCode === 'string' ? m.methodCode.trim() : '';
649-
const methodCodeOk = isValidDeliveryMethodCode(methodCode);
650-
const titleOk =
651-
typeof m.title === 'string' && m.title.trim().length > 0;
652-
653-
if (!providerOk || !methodCodeOk || !titleOk) {
701+
if (!method) {
654702
hardBlock();
655703
return;
656704
}
657705

658-
methods.push({
659-
provider: 'nova_poshta',
660-
methodCode,
661-
title: String(m.title),
662-
});
706+
methods.push(method);
663707
}
664708

665709
if (available === false && reasonCode == null) {
@@ -1153,6 +1197,12 @@ export default function CartPage({
11531197
...(cart.summary.pricingFingerprint
11541198
? { pricingFingerprint: cart.summary.pricingFingerprint }
11551199
: {}),
1200+
...(selectedShippingQuote?.quoteFingerprint
1201+
? {
1202+
shippingQuoteFingerprint:
1203+
selectedShippingQuote.quoteFingerprint,
1204+
}
1205+
: {}),
11561206
...(shippingPayloadResult?.ok
11571207
? {
11581208
shipping: shippingPayloadResult.shipping,
@@ -2241,9 +2291,15 @@ export default function CartPage({
22412291

22422292
<span
22432293
data-testid="checkout-summary-shipping"
2244-
className="text-muted-foreground text-right text-xs"
2294+
className="text-foreground font-medium"
22452295
>
2246-
{t('summary.shippingInformationalOnly')}
2296+
{selectedShippingQuote
2297+
? formatMoney(
2298+
checkoutSummaryShippingMinor,
2299+
cart.summary.currency,
2300+
locale
2301+
)
2302+
: t('summary.shippingCalc')}
22472303
</span>
22482304
</div>
22492305
</div>
@@ -2259,7 +2315,7 @@ export default function CartPage({
22592315
className="text-foreground text-2xl font-bold"
22602316
>
22612317
{formatMoney(
2262-
cart.summary.totalAmountMinor,
2318+
checkoutSummaryTotalMinor,
22632319
cart.summary.currency,
22642320
locale
22652321
)}
@@ -2312,10 +2368,6 @@ export default function CartPage({
23122368
</span>
23132369
</button>
23142370

2315-
<p className="text-muted-foreground mt-4 text-center text-xs">
2316-
{t('summary.shippingPayOnDeliveryNote')}
2317-
</p>
2318-
23192371
{recoveryHref && !checkoutError ? (
23202372
<div className="mt-3 flex justify-center">
23212373
{recoveryIsExternal ? (

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,12 +62,14 @@ const EXPECTED_BUSINESS_ERROR_CODES = new Set([
6262
'INVALID_VARIANT',
6363
'INSUFFICIENT_STOCK',
6464
'CHECKOUT_PRICE_CHANGED',
65+
'CHECKOUT_SHIPPING_CHANGED',
6566
'PRICE_CONFIG_ERROR',
6667
'PAYMENT_ATTEMPTS_EXHAUSTED',
6768
'MISSING_SHIPPING_ADDRESS',
6869
'INVALID_SHIPPING_ADDRESS',
6970
'SHIPPING_METHOD_UNAVAILABLE',
7071
'SHIPPING_CURRENCY_UNSUPPORTED',
72+
'SHIPPING_AMOUNT_UNAVAILABLE',
7173
'TERMS_NOT_ACCEPTED',
7274
'PRIVACY_NOT_ACCEPTED',
7375
]);
@@ -77,10 +79,12 @@ const DEFAULT_CHECKOUT_RATE_LIMIT_WINDOW_SECONDS = 300;
7779

7880
const SHIPPING_ERROR_STATUS_MAP: Record<string, number> = {
7981
CHECKOUT_PRICE_CHANGED: 409,
82+
CHECKOUT_SHIPPING_CHANGED: 409,
8083
MISSING_SHIPPING_ADDRESS: 400,
8184
INVALID_SHIPPING_ADDRESS: 400,
8285
SHIPPING_METHOD_UNAVAILABLE: 422,
8386
SHIPPING_CURRENCY_UNSUPPORTED: 422,
87+
SHIPPING_AMOUNT_UNAVAILABLE: 422,
8488
};
8589

8690
const STATUS_TOKEN_SCOPES_STATUS_ONLY: readonly StatusTokenScope[] = [
@@ -1033,6 +1037,7 @@ export async function POST(request: NextRequest) {
10331037
country,
10341038
legalConsent,
10351039
pricingFingerprint,
1040+
shippingQuoteFingerprint,
10361041
} = parsedPayload.data;
10371042
const itemCount = items.reduce((total, item) => total + item.quantity, 0);
10381043

@@ -1159,7 +1164,9 @@ export async function POST(request: NextRequest) {
11591164
shipping: shipping ?? null,
11601165
legalConsent: legalConsent ?? null,
11611166
pricingFingerprint,
1167+
shippingQuoteFingerprint,
11621168
requirePricingFingerprint: true,
1169+
requireShippingQuoteFingerprint: true,
11631170
paymentProvider: 'stripe',
11641171
paymentMethod: selectedMethod,
11651172
});
@@ -1185,7 +1192,9 @@ export async function POST(request: NextRequest) {
11851192
shipping: shipping ?? null,
11861193
legalConsent: legalConsent ?? null,
11871194
pricingFingerprint,
1195+
shippingQuoteFingerprint,
11881196
requirePricingFingerprint: true,
1197+
requireShippingQuoteFingerprint: true,
11891198
paymentProvider: resolvedProvider,
11901199
paymentMethod: selectedMethod,
11911200
}));

frontend/app/api/shop/shipping/methods/route.ts

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ import {
1111
rateLimitResponse,
1212
} from '@/lib/security/rate-limit';
1313
import { resolveShippingAvailability } from '@/lib/services/shop/shipping/availability';
14+
import {
15+
isCheckoutShippingQuoteCurrency,
16+
resolveCheckoutShippingQuote,
17+
} from '@/lib/services/shop/shipping/checkout-quote';
1418
import {
1519
sanitizeShippingErrorForLog,
1620
sanitizeShippingLogMeta,
@@ -27,6 +31,8 @@ type ShippingMethod = {
2731
provider: 'nova_poshta';
2832
methodCode: 'NP_WAREHOUSE' | 'NP_LOCKER' | 'NP_COURIER';
2933
title: string;
34+
amountMinor: number;
35+
quoteFingerprint: string;
3036
requiredFields: Array<
3137
| 'cityRef'
3238
| 'warehouseRef'
@@ -58,12 +64,27 @@ function parseQuery(request: NextRequest) {
5864
return shippingMethodsQuerySchema.safeParse(raw);
5965
}
6066

61-
function getMethods(): ShippingMethod[] {
67+
function getMethods(currency: 'UAH'): ShippingMethod[] {
68+
const warehouseQuote = resolveCheckoutShippingQuote({
69+
methodCode: 'NP_WAREHOUSE',
70+
currency,
71+
});
72+
const lockerQuote = resolveCheckoutShippingQuote({
73+
methodCode: 'NP_LOCKER',
74+
currency,
75+
});
76+
const courierQuote = resolveCheckoutShippingQuote({
77+
methodCode: 'NP_COURIER',
78+
currency,
79+
});
80+
6281
return [
6382
{
6483
provider: 'nova_poshta',
6584
methodCode: 'NP_WAREHOUSE',
6685
title: 'Nova Poshta warehouse',
86+
amountMinor: warehouseQuote.amountMinor,
87+
quoteFingerprint: warehouseQuote.quoteFingerprint,
6788
requiredFields: [
6889
'cityRef',
6990
'warehouseRef',
@@ -75,6 +96,8 @@ function getMethods(): ShippingMethod[] {
7596
provider: 'nova_poshta',
7697
methodCode: 'NP_LOCKER',
7798
title: 'Nova Poshta parcel locker',
99+
amountMinor: lockerQuote.amountMinor,
100+
quoteFingerprint: lockerQuote.quoteFingerprint,
78101
requiredFields: [
79102
'cityRef',
80103
'warehouseRef',
@@ -86,6 +109,8 @@ function getMethods(): ShippingMethod[] {
86109
provider: 'nova_poshta',
87110
methodCode: 'NP_COURIER',
88111
title: 'Nova Poshta courier',
112+
amountMinor: courierQuote.amountMinor,
113+
quoteFingerprint: courierQuote.quoteFingerprint,
89114
requiredFields: [
90115
'cityRef',
91116
'addressLine1',
@@ -168,6 +193,21 @@ export async function GET(request: NextRequest) {
168193
);
169194
}
170195

196+
if (!isCheckoutShippingQuoteCurrency(availability.normalized.currency)) {
197+
return cachedJson(
198+
{
199+
success: true,
200+
available: false,
201+
reasonCode: 'CURRENCY_NOT_SUPPORTED',
202+
locale: availability.normalized.locale,
203+
country: availability.normalized.country,
204+
currency: availability.normalized.currency,
205+
methods: [],
206+
},
207+
requestId
208+
);
209+
}
210+
171211
return cachedJson(
172212
{
173213
success: true,
@@ -176,7 +216,7 @@ export async function GET(request: NextRequest) {
176216
locale: availability.normalized.locale,
177217
country: availability.normalized.country,
178218
currency: availability.normalized.currency,
179-
methods: getMethods(),
219+
methods: getMethods(availability.normalized.currency),
180220
},
181221
requestId
182222
);

0 commit comments

Comments
 (0)