Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 81 additions & 26 deletions frontend/app/[locale]/shop/cart/CartPageClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ type ShippingMethod = {
provider: 'nova_poshta';
methodCode: CheckoutDeliveryMethodCode;
title: string;
amountMinor: number;
quoteFingerprint: string;
};

type ShippingCity = {
Expand Down Expand Up @@ -161,6 +163,57 @@ function normalizeShippingCity(raw: unknown): ShippingCity | null {
nameUa,
};
}

function readTrimmedNonEmptyString(value: unknown): string | null {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}

function normalizeShippingMethod(raw: unknown): ShippingMethod | null {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
return null;
}

const item = raw as Record<string, unknown>;
if (item.provider !== 'nova_poshta') {
return null;
}

const methodCode = readTrimmedNonEmptyString(item.methodCode);
const title = readTrimmedNonEmptyString(item.title);
const quoteFingerprint = readTrimmedNonEmptyString(item.quoteFingerprint);
const amountMinor = item.amountMinor;

if (!methodCode || !isValidDeliveryMethodCode(methodCode)) {
return null;
}

if (!title) {
return null;
}

if (
typeof amountMinor !== 'number' ||
!Number.isInteger(amountMinor) ||
amountMinor < 0
) {
return null;
}

if (!quoteFingerprint || !/^[a-f0-9]{64}$/.test(quoteFingerprint)) {
return null;
}

return {
provider: 'nova_poshta',
methodCode,
title,
amountMinor,
quoteFingerprint,
};
}

function normalizeShippingWarehouse(raw: unknown): ShippingWarehouse | null {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
return null;
Expand Down Expand Up @@ -454,6 +507,13 @@ export default function CartPage({
shippingReasonCode === 'COUNTRY_NOT_SUPPORTED' ||
shippingReasonCode === 'CURRENCY_NOT_SUPPORTED' ||
shippingReasonCode === 'INTERNAL_ERROR';
const selectedShippingQuote =
shippingMethods.find(
method => method.methodCode === selectedShippingMethod
) ?? null;
const checkoutSummaryShippingMinor = selectedShippingQuote?.amountMinor ?? 0;
const checkoutSummaryTotalMinor =
cart.summary.totalAmountMinor + checkoutSummaryShippingMinor;

const isWarehouseSelectionMethod = isWarehouseMethod(selectedShippingMethod);

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

for (const item of methodsRaw) {
if (!item || typeof item !== 'object' || Array.isArray(item)) {
hardBlock();
return;
}

const m = item as Record<string, unknown>;
const method = normalizeShippingMethod(item);

const providerOk = m.provider === 'nova_poshta';
const methodCode =
typeof m.methodCode === 'string' ? m.methodCode.trim() : '';
const methodCodeOk = isValidDeliveryMethodCode(methodCode);
const titleOk =
typeof m.title === 'string' && m.title.trim().length > 0;

if (!providerOk || !methodCodeOk || !titleOk) {
if (!method) {
hardBlock();
return;
}

methods.push({
provider: 'nova_poshta',
methodCode,
title: String(m.title),
});
methods.push(method);
}

if (available === false && reasonCode == null) {
Expand Down Expand Up @@ -1150,6 +1194,15 @@ export default function CartPage({
body: JSON.stringify({
paymentProvider: selectedProvider,
paymentMethod: checkoutPaymentMethod,
...(cart.summary.pricingFingerprint
? { pricingFingerprint: cart.summary.pricingFingerprint }
: {}),
...(selectedShippingQuote?.quoteFingerprint
? {
shippingQuoteFingerprint:
selectedShippingQuote.quoteFingerprint,
}
: {}),
...(shippingPayloadResult?.ok
? {
shipping: shippingPayloadResult.shipping,
Expand Down Expand Up @@ -2238,9 +2291,15 @@ export default function CartPage({

<span
data-testid="checkout-summary-shipping"
className="text-muted-foreground text-right text-xs"
className="text-foreground font-medium"
>
{t('summary.shippingInformationalOnly')}
{selectedShippingQuote
? formatMoney(
checkoutSummaryShippingMinor,
cart.summary.currency,
locale
)
: t('summary.shippingCalc')}
</span>
</div>
</div>
Expand All @@ -2256,7 +2315,7 @@ export default function CartPage({
className="text-foreground text-2xl font-bold"
>
{formatMoney(
cart.summary.totalAmountMinor,
checkoutSummaryTotalMinor,
cart.summary.currency,
locale
)}
Expand Down Expand Up @@ -2309,10 +2368,6 @@ export default function CartPage({
</span>
</button>

<p className="text-muted-foreground mt-4 text-center text-xs">
{t('summary.shippingPayOnDeliveryNote')}
</p>

{recoveryHref && !checkoutError ? (
<div className="mt-3 flex justify-center">
{recoveryIsExternal ? (
Expand Down
67 changes: 65 additions & 2 deletions frontend/app/api/shop/checkout/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,14 +59,18 @@ type CheckoutRequestedProvider = 'stripe' | 'monobank';
const EXPECTED_BUSINESS_ERROR_CODES = new Set([
'IDEMPOTENCY_CONFLICT',
'INVALID_PAYLOAD',
'DISCOUNTS_NOT_SUPPORTED',
'INVALID_VARIANT',
'INSUFFICIENT_STOCK',
'CHECKOUT_PRICE_CHANGED',
'CHECKOUT_SHIPPING_CHANGED',
'PRICE_CONFIG_ERROR',
'PAYMENT_ATTEMPTS_EXHAUSTED',
'MISSING_SHIPPING_ADDRESS',
'INVALID_SHIPPING_ADDRESS',
'SHIPPING_METHOD_UNAVAILABLE',
'SHIPPING_CURRENCY_UNSUPPORTED',
'SHIPPING_AMOUNT_UNAVAILABLE',
'TERMS_NOT_ACCEPTED',
'PRIVACY_NOT_ACCEPTED',
]);
Expand All @@ -75,10 +79,13 @@ const DEFAULT_CHECKOUT_RATE_LIMIT_MAX = 10;
const DEFAULT_CHECKOUT_RATE_LIMIT_WINDOW_SECONDS = 300;

const SHIPPING_ERROR_STATUS_MAP: Record<string, number> = {
CHECKOUT_PRICE_CHANGED: 409,
CHECKOUT_SHIPPING_CHANGED: 409,
MISSING_SHIPPING_ADDRESS: 400,
INVALID_SHIPPING_ADDRESS: 400,
SHIPPING_METHOD_UNAVAILABLE: 422,
SHIPPING_CURRENCY_UNSUPPORTED: 422,
SHIPPING_AMOUNT_UNAVAILABLE: 422,
};

const STATUS_TOKEN_SCOPES_STATUS_ONLY: readonly StatusTokenScope[] = [
Expand All @@ -88,6 +95,15 @@ const STATUS_TOKEN_SCOPES_PAYMENT_INIT: readonly StatusTokenScope[] = [
'status_lite',
'order_payment_init',
];
const UNSUPPORTED_DISCOUNT_FIELDS = new Set([
'discountCode',
'couponCode',
'promoCode',
'discountAmount',
'discountAmountMinor',
'totalDiscountAmount',
'totalDiscountMinor',
]);

function resolveCheckoutTokenScopes(args: {
paymentProvider: PaymentProvider;
Expand Down Expand Up @@ -336,6 +352,18 @@ function errorResponse(
return res;
}

function collectUnsupportedDiscountFields(
value: unknown
): string[] {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return [];
}

return Object.keys(value).filter(field =>
UNSUPPORTED_DISCOUNT_FIELDS.has(field)
);
}

function getIdempotencyKey(request: NextRequest) {
const headerKey = request.headers.get('Idempotency-Key');
if (headerKey === null || headerKey === undefined) return null;
Expand Down Expand Up @@ -992,6 +1020,24 @@ export async function POST(request: NextRequest) {
};
}

const unsupportedDiscountFields =
collectUnsupportedDiscountFields(payloadForValidation);

if (unsupportedDiscountFields.length > 0) {
logWarn('checkout_discount_not_supported', {
...meta,
code: 'DISCOUNTS_NOT_SUPPORTED',
fields: unsupportedDiscountFields,
});

return errorResponse(
'DISCOUNTS_NOT_SUPPORTED',
'Discounts are not available at checkout.',
400,
{ fields: unsupportedDiscountFields }
);
}

const parsedPayload = checkoutPayloadSchema.safeParse(payloadForValidation);

if (!parsedPayload.success) {
Expand Down Expand Up @@ -1024,7 +1070,15 @@ export async function POST(request: NextRequest) {
);
}

const { items, userId, shipping, country, legalConsent } = parsedPayload.data;
const {
items,
userId,
shipping,
country,
legalConsent,
pricingFingerprint,
shippingQuoteFingerprint,
} = parsedPayload.data;
const itemCount = items.reduce((total, item) => total + item.quantity, 0);

let currentUser: unknown = null;
Expand Down Expand Up @@ -1149,6 +1203,10 @@ export async function POST(request: NextRequest) {
country: country ?? null,
shipping: shipping ?? null,
legalConsent: legalConsent ?? null,
pricingFingerprint,
shippingQuoteFingerprint,
requirePricingFingerprint: true,
requireShippingQuoteFingerprint: true,
paymentProvider: 'stripe',
paymentMethod: selectedMethod,
});
Expand All @@ -1173,6 +1231,10 @@ export async function POST(request: NextRequest) {
country: country ?? null,
shipping: shipping ?? null,
legalConsent: legalConsent ?? null,
pricingFingerprint,
shippingQuoteFingerprint,
requirePricingFingerprint: true,
requireShippingQuoteFingerprint: true,
paymentProvider: resolvedProvider,
paymentMethod: selectedMethod,
}));
Expand Down Expand Up @@ -1588,7 +1650,8 @@ export async function POST(request: NextRequest) {
return errorResponse(
error.code,
error.message || 'Invalid checkout payload',
customStatus ?? 400
customStatus ?? 400,
error.details
);
}

Expand Down
Loading
Loading