From aa7d2bca933bdee57dd2eb2f6bbafc832beec421 Mon Sep 17 00:00:00 2001 From: liudmylasovetovs Date: Wed, 14 Jan 2026 11:02:14 -0800 Subject: [PATCH 1/8] (SP: 1) [Backend] Stripe webhook: preserve refund history in pspMetadata (refunds[]), merge PSP metadata safely, and harden full-refund gating/partial-refund ignore --- frontend/app/[locale]/shop/shop-theme.css | 63 +--- .../shop/admin/orders/[id]/refund/route.ts | 4 +- .../app/api/shop/webhooks/stripe/route.ts | 328 +++++++++++++----- frontend/lib/psp/stripe.ts | 285 +++++++++------ frontend/lib/services/orders/refund.ts | 201 ++++++++--- frontend/project-structure.txt | 60 +++- 6 files changed, 639 insertions(+), 302 deletions(-) diff --git a/frontend/app/[locale]/shop/shop-theme.css b/frontend/app/[locale]/shop/shop-theme.css index 6b3c3f76..78923b73 100644 --- a/frontend/app/[locale]/shop/shop-theme.css +++ b/frontend/app/[locale]/shop/shop-theme.css @@ -1,89 +1,30 @@ .shop-scope { --radius: 0.5rem; - --background: #ffffff; --foreground: #111111; - --card: #ffffff; - --card-foreground: #111111; - - --popover: #ffffff; - --popover-foreground: #111111; - --primary: #111111; - --primary-foreground: #ffffff; - --secondary: #f5f5f5; - --secondary-foreground: #111111; - --muted: #f5f5f5; - --muted-foreground: #555555; - - --accent: #ff2d55; + --accent: #111111; --accent-foreground: #ffffff; - --destructive: oklch(0.577 0.245 27.325); - --border: #e5e5e5; --input: #e5e5e5; - --ring: #ff2d55; - - --chart-1: oklch(0.646 0.222 41.116); - --chart-2: oklch(0.6 0.118 184.704); - --chart-3: oklch(0.398 0.07 227.392); - --chart-4: oklch(0.828 0.189 84.429); - --chart-5: oklch(0.769 0.188 70.08); - - --sidebar: oklch(0.985 0 0); - --sidebar-foreground: oklch(0.145 0 0); - --sidebar-primary: oklch(0.205 0 0); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.97 0 0); - --sidebar-accent-foreground: oklch(0.205 0 0); - --sidebar-border: oklch(0.922 0 0); - --sidebar-ring: oklch(0.708 0 0); + --ring: #111111; } .dark .shop-scope { --background: #0a0a0a; --foreground: #ffffff; - --card: #0a0a0a; - --card-foreground: #ffffff; - - --popover: #0a0a0a; - --popover-foreground: #ffffff; - --primary: #ffffff; - --primary-foreground: #0a0a0a; - --secondary: #1c1c1e; - --secondary-foreground: #ffffff; - --muted: #1c1c1e; - --muted-foreground: #cccccc; - --accent: #ff2d55; --accent-foreground: #ffffff; - --destructive: oklch(0.396 0.141 25.723); - --border: #333333; --input: #333333; --ring: #ff2d55; - - --chart-1: oklch(0.488 0.243 264.376); - --chart-2: oklch(0.696 0.17 162.48); - --chart-3: oklch(0.769 0.188 70.08); - --chart-4: oklch(0.627 0.265 303.9); - --chart-5: oklch(0.645 0.246 16.439); - - --sidebar: oklch(0.205 0 0); - --sidebar-foreground: oklch(0.985 0 0); - --sidebar-primary: oklch(0.488 0.243 264.376); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.269 0 0); - --sidebar-accent-foreground: oklch(0.985 0 0); - --sidebar-border: oklch(0.269 0 0); - --sidebar-ring: oklch(0.439 0 0); } diff --git a/frontend/app/api/shop/admin/orders/[id]/refund/route.ts b/frontend/app/api/shop/admin/orders/[id]/refund/route.ts index 062846c9..5f402676 100644 --- a/frontend/app/api/shop/admin/orders/[id]/refund/route.ts +++ b/frontend/app/api/shop/admin/orders/[id]/refund/route.ts @@ -28,7 +28,9 @@ export async function POST( ); } - const order = await refundOrder(parsed.data.id); + // app/api/shop/admin/orders/[id]/refund/route.ts + const order = await refundOrder(parsed.data.id, { requestedBy: 'admin' }); + const orderSummary = orderSummarySchema.parse(order); return NextResponse.json({ diff --git a/frontend/app/api/shop/webhooks/stripe/route.ts b/frontend/app/api/shop/webhooks/stripe/route.ts index 85142670..e2d3f009 100644 --- a/frontend/app/api/shop/webhooks/stripe/route.ts +++ b/frontend/app/api/shop/webhooks/stripe/route.ts @@ -9,6 +9,82 @@ import { restockOrder } from '@/lib/services/orders'; import { guardedPaymentStatusUpdate } from '@/lib/services/orders/payment-state'; import { logError, logInfo, logWarn } from '@/lib/logging'; +type RefundMetaRecord = { + refundId: string; + idempotencyKey: string; + amountMinor: number; + currency: string; + createdAt: string; + createdBy: string; + status?: string | null; +}; + +function normalizeRefundsFromMeta( + meta: unknown, + fallback: { currency: string; createdAt: string } +): RefundMetaRecord[] { + const m = (meta ?? {}) as any; + + if (Array.isArray(m.refunds)) return m.refunds as RefundMetaRecord[]; + + const legacy = m.refund; + if (legacy?.id) { + return [ + { + refundId: String(legacy.id), + idempotencyKey: 'legacy:webhook', + amountMinor: Number(legacy.amount ?? 0), + currency: fallback.currency, + createdAt: fallback.createdAt, + createdBy: 'webhook', + status: legacy.status ?? null, + }, + ]; + } + + return []; +} + +function upsertRefundIntoMeta(params: { + prevMeta: unknown; + refund: { id: string; amount?: number | null; status?: string | null } | null; + eventId: string; + currency: string; + createdAtIso: string; +}): any { + const { prevMeta, refund, eventId, currency, createdAtIso } = params; + + const base = ((prevMeta ?? {}) as any) ?? {}; + + // якщо refund в payload нема — просто повертаємо base (але НЕ затираємо refunds) + if (!refund?.id) return base; + + const refunds = normalizeRefundsFromMeta(base, { + currency, + createdAt: createdAtIso, + }); + + const rec: RefundMetaRecord = { + refundId: refund.id, + idempotencyKey: `webhook:${eventId}`.slice(0, 128), + amountMinor: Number(refund.amount ?? 0), + currency, + createdAt: createdAtIso, + createdBy: 'webhook', + status: refund.status ?? null, + }; + + const exists = refunds.some( + r => r.refundId === rec.refundId || r.idempotencyKey === rec.idempotencyKey + ); + + return { + ...base, + refunds: exists ? refunds : [...refunds, rec], + refundInitiatedAt: base.refundInitiatedAt ?? createdAtIso, + }; +} + function warnRefundFullnessUndetermined(payload: { eventId: string; eventType: string; @@ -131,6 +207,52 @@ function buildPspMetadata(params: { }; } +function stripUndefined(obj: Record) { + const out: Record = {}; + for (const [k, v] of Object.entries(obj)) { + if (v !== undefined) out[k] = v; + } + return out; +} + +function mergePspMetadata(params: { + prevMeta: unknown; + delta: Record; + eventId: string; + currency: string; + createdAtIso: string; +}) { + const cleanedDelta = stripUndefined(params.delta); + + const refundForUpsert = (cleanedDelta as any)?.refund?.id + ? { + id: String((cleanedDelta as any).refund.id), + amount: + typeof (cleanedDelta as any).refund.amount === 'number' + ? (cleanedDelta as any).refund.amount + : null, + status: + typeof (cleanedDelta as any).refund.status === 'string' + ? (cleanedDelta as any).refund.status + : null, + } + : null; + + const metaWithRefunds = upsertRefundIntoMeta({ + prevMeta: params.prevMeta, + refund: refundForUpsert, + eventId: params.eventId, + currency: params.currency, + createdAtIso: params.createdAtIso, + }); + + // IMPORTANT: merge, not overwrite (preserves refunds[]) + return { + ...metaWithRefunds, + ...cleanedDelta, + }; +} + function shouldRestockFromWebhook(order: { stockRestored: boolean | null; inventoryStatus: string | null; @@ -342,6 +464,7 @@ export async function POST(request: NextRequest) { status: orders.status, stockRestored: orders.stockRestored, inventoryStatus: orders.inventoryStatus, + pspMetadata: orders.pspMetadata, }) .from(orders) .where(eq(orders.id, resolvedOrderId)) @@ -383,37 +506,42 @@ export async function POST(request: NextRequest) { : 'currency_mismatch'; const chargeForIntent = getLatestCharge(paymentIntent as any); + const createdAtIso = new Date().toISOString(); + const deltaMeta = buildPspMetadata({ + eventType, + paymentIntent, + charge: chargeForIntent, + extra: { + mismatch: { + reason: mismatchReason, + eventId: event.id, + expected: { + amountMinor: orderAmountMinor, + currency: order.currency, + }, + actual: { amountMinor: stripeAmount, currency: stripeCurrency }, + // keep old fields for backward-compat/debug grepping + stripeAmount, + orderAmountMinor, + stripeCurrency, + orderCurrency: order.currency, + }, + }, + }); + const nextMeta = mergePspMetadata({ + prevMeta: order.pspMetadata, + delta: deltaMeta as any, + eventId: event.id, + currency: order.currency, + createdAtIso, + }); await db .update(orders) .set({ updatedAt: new Date(), pspStatusReason: mismatchReason, - pspMetadata: buildPspMetadata({ - eventType, - paymentIntent, - charge: chargeForIntent, - extra: { - mismatch: { - reason: mismatchReason, - eventId: event.id, - expected: { - amountMinor: orderAmountMinor, - currency: order.currency, - }, - actual: { - amountMinor: stripeAmount, - currency: stripeCurrency, - }, - - // keep old fields for backward-compat/debug grepping - stripeAmount, - orderAmountMinor, - stripeCurrency, - orderCurrency: order.currency, - }, - }, - }), + pspMetadata: nextMeta, }) .where(eq(orders.id, order.id)); @@ -435,6 +563,19 @@ export async function POST(request: NextRequest) { const latestChargeId = getLatestChargeId(paymentIntent); const now = new Date(); + const createdAtIso = now.toISOString(); + const deltaMeta = buildPspMetadata({ + eventType, + paymentIntent, + charge: chargeForIntent ?? undefined, + }); + const nextMeta = mergePspMetadata({ + prevMeta: order.pspMetadata, + delta: deltaMeta as any, + eventId: event.id, + currency: order.currency, + createdAtIso, + }); await guardedPaymentStatusUpdate({ orderId: order.id, @@ -452,11 +593,7 @@ export async function POST(request: NextRequest) { chargeForIntent ), pspStatusReason: paymentIntent?.status ?? 'succeeded', - pspMetadata: buildPspMetadata({ - eventType, - paymentIntent, - charge: chargeForIntent ?? undefined, - }), + pspMetadata: nextMeta, }, extraWhere: and( eq(orders.stockRestored, false), @@ -504,7 +641,20 @@ export async function POST(request: NextRequest) { paymentIntent?.cancellation_reason ?? paymentIntent?.status ?? 'payment_failed'; - + const now = new Date(); + const createdAtIso = now.toISOString(); + const deltaMeta = buildPspMetadata({ + eventType, + paymentIntent, + charge: chargeForIntent, + }); + const nextMeta = mergePspMetadata({ + prevMeta: order.pspMetadata, + delta: deltaMeta as any, + eventId: event.id, + currency: order.currency, + createdAtIso, + }); await guardedPaymentStatusUpdate({ orderId: order.id, paymentProvider: 'stripe', @@ -513,18 +663,14 @@ export async function POST(request: NextRequest) { eventId: event.id, note: eventType, set: { - updatedAt: new Date(), + updatedAt: now, pspChargeId: chargeForIntent?.id ?? null, pspPaymentMethod: resolvePaymentMethod( paymentIntent, chargeForIntent ), pspStatusReason: failureReason, - pspMetadata: buildPspMetadata({ - eventType, - paymentIntent, - charge: chargeForIntent, - }), + pspMetadata: nextMeta, }, }); @@ -565,7 +711,20 @@ export async function POST(request: NextRequest) { paymentIntent?.cancellation_reason ?? paymentIntent?.status ?? 'canceled'; - + const now = new Date(); + const createdAtIso = now.toISOString(); + const deltaMeta = buildPspMetadata({ + eventType, + paymentIntent, + charge: chargeForIntent, + }); + const nextMeta = mergePspMetadata({ + prevMeta: order.pspMetadata, + delta: deltaMeta as any, + eventId: event.id, + currency: order.currency, + createdAtIso, + }); await guardedPaymentStatusUpdate({ orderId: order.id, paymentProvider: 'stripe', @@ -574,18 +733,14 @@ export async function POST(request: NextRequest) { eventId: event.id, note: eventType, set: { - updatedAt: new Date(), + updatedAt: now, pspChargeId: chargeForIntent?.id ?? null, pspPaymentMethod: resolvePaymentMethod( paymentIntent, chargeForIntent ), pspStatusReason: cancellationReason, - pspMetadata: buildPspMetadata({ - eventType, - paymentIntent, - charge: chargeForIntent, - }), + pspMetadata: nextMeta, }, }); @@ -620,8 +775,6 @@ export async function POST(request: NextRequest) { : null; // MVP: only FULL refund. - // - charge.refunded: amount_refunded === amount (or fallback sum(refunds)) - // - charge.refund.updated: compare cumulative refunded for the charge vs charge.amount let isFullRefund = false; if (eventType === 'charge.refunded') { @@ -637,8 +790,6 @@ export async function POST(request: NextRequest) { ? (effectiveCharge as any).amount_refunded : null; - // Fail-safe fallback: if amount_refunded missing, try refunds list; - // if list absent/empty -> UNDETERMINED (500 + retry), NOT "0 refunded". if (cumulativeRefunded == null) { const list = Array.isArray((effectiveCharge as any)?.refunds?.data) ? ((effectiveCharge as any).refunds.data as any[]) @@ -677,6 +828,7 @@ export async function POST(request: NextRequest) { sawNumericAmount = true; return sum + a; }, 0); + if (!sawNumericAmount && currentAmt == null) { warnRefundFullnessUndetermined({ eventId: event.id, @@ -701,13 +853,14 @@ export async function POST(request: NextRequest) { const hasCurrent = refund?.id && list.some(r => r?.id && r.id === refund.id); - cumulativeRefunded = sumFromList + (hasCurrent ? 0 : currentAmt ?? 0); } + if (amt == null || cumulativeRefunded == null) { const list = Array.isArray((effectiveCharge as any)?.refunds?.data) ? ((effectiveCharge as any).refunds.data as any[]) : null; + warnRefundFullnessUndetermined({ eventId: event.id, eventType, @@ -733,13 +886,11 @@ export async function POST(request: NextRequest) { isFullRefund = cumulativeRefunded === amt; } else if (eventType === 'charge.refund.updated' && refund) { - // Ensure we have the Charge to compute cumulative refunded correctly. let effectiveCharge: Stripe.Charge | undefined; if (typeof refund.charge === 'object' && refund.charge) { effectiveCharge = refund.charge as Stripe.Charge; } else if (typeof refund.charge === 'string' && refund.charge.trim()) { - // Fetch charge to get cumulative refunded / refunds list effectiveCharge = await retrieveCharge(refund.charge.trim()); } @@ -819,7 +970,6 @@ export async function POST(request: NextRequest) { } const hasCurrent = list.some(r => r?.id && r.id === refund.id); - cumulativeRefunded = sumFromList + (hasCurrent ? 0 : currentAmt ?? 0); } @@ -827,6 +977,7 @@ export async function POST(request: NextRequest) { const list = Array.isArray((effectiveCharge as any)?.refunds?.data) ? ((effectiveCharge as any).refunds.data as any[]) : null; + warnRefundFullnessUndetermined({ eventId: event.id, eventType, @@ -854,38 +1005,49 @@ export async function POST(request: NextRequest) { isFullRefund = cumulativeRefunded === amt; - // Prefer charge id from effectiveCharge for PSP fields if (effectiveCharge?.id) { charge = effectiveCharge; } } + const now = new Date(); + const createdAtIso = now.toISOString(); + if (!isFullRefund) { + const deltaMeta = buildPspMetadata({ + eventType, + paymentIntent, + charge: charge ?? undefined, + refund, + extra: { + refundGate: { + decision: 'ignored', + expectedAmountMinor: order.totalAmountMinor, + chargeAmount: (charge as any)?.amount ?? null, + chargeAmountRefunded: (charge as any)?.amount_refunded ?? null, + refundAmount: (refund as any)?.amount ?? null, + eventId: event.id, + }, + }, + }); + + const nextMeta = mergePspMetadata({ + prevMeta: order.pspMetadata, + delta: deltaMeta as any, + eventId: event.id, + currency: order.currency, + createdAtIso, + }); + await db .update(orders) .set({ - updatedAt: new Date(), + updatedAt: now, + pspMetadata: nextMeta, // do NOT change paymentStatus/status for partial refund pspChargeId: charge?.id ?? refundChargeId ?? null, pspPaymentMethod: resolvePaymentMethod(paymentIntent, charge), pspStatusReason: 'PARTIAL_REFUND_IGNORED', - pspMetadata: buildPspMetadata({ - eventType, - paymentIntent, - charge: charge ?? undefined, - refund, - extra: { - refundGate: { - decision: 'ignored', - expectedAmountMinor: order.totalAmountMinor, - chargeAmount: (charge as any)?.amount ?? null, - chargeAmountRefunded: - (charge as any)?.amount_refunded ?? null, - refundAmount: (refund as any)?.amount ?? null, - eventId: event.id, - }, - }, - }), }) .where(eq(orders.id, order.id)); @@ -898,6 +1060,21 @@ export async function POST(request: NextRequest) { return ack(); } + const deltaMeta = buildPspMetadata({ + eventType, + paymentIntent, + charge: charge ?? undefined, + refund, + }); + + const nextMeta = mergePspMetadata({ + prevMeta: order.pspMetadata, + delta: deltaMeta as any, + eventId: event.id, + currency: order.currency, + createdAtIso, + }); + const refundRes = await guardedPaymentStatusUpdate({ orderId: order.id, paymentProvider: 'stripe', @@ -906,17 +1083,12 @@ export async function POST(request: NextRequest) { eventId: event.id, note: eventType, set: { - updatedAt: new Date(), + updatedAt: now, status: 'CANCELED', // terminal in current enum pspChargeId: charge?.id ?? refundChargeId ?? null, pspPaymentMethod: resolvePaymentMethod(paymentIntent, charge), pspStatusReason: refund?.reason ?? refund?.status ?? 'refunded', - pspMetadata: buildPspMetadata({ - eventType, - paymentIntent, - charge: charge ?? undefined, - refund, - }), + pspMetadata: nextMeta, }, }); diff --git a/frontend/lib/psp/stripe.ts b/frontend/lib/psp/stripe.ts index 6f3fb8d4..666c67d1 100644 --- a/frontend/lib/psp/stripe.ts +++ b/frontend/lib/psp/stripe.ts @@ -1,146 +1,201 @@ -import Stripe from "stripe"; -import { getStripeEnv } from "@/lib/env/stripe"; -import { logError } from "@/lib/logging"; +import Stripe from 'stripe'; +import { getStripeEnv } from '@/lib/env/stripe'; +import { logError } from '@/lib/logging'; type CreatePaymentIntentInput = { - amount: number; - currency: string; - orderId: string; - idempotencyKey?: string; + amount: number; + currency: string; + orderId: string; + idempotencyKey?: string; +}; + +type CreateRefundInput = { + orderId: string; + paymentIntentId?: string | null; + chargeId?: string | null; + amountMinor?: number; // full refund: pass totalAmountMinor (recommended) + idempotencyKey?: string; }; let _stripe: Stripe | null = null; let _stripeKey: string | null = null; +export async function createRefund({ + orderId, + paymentIntentId, + chargeId, + amountMinor, + idempotencyKey, +}: CreateRefundInput): Promise<{ + refundId: string; + status: Stripe.Refund['status']; +}> { + const { paymentsEnabled, mode } = getStripeEnv(); + const stripe = getStripeClient(); + + if (!paymentsEnabled || !stripe) { + throw new Error('STRIPE_DISABLED'); + } + + const pi = paymentIntentId?.trim() ? paymentIntentId.trim() : null; + const ch = chargeId?.trim() ? chargeId.trim() : null; + + if (!pi && !ch) { + throw new Error('STRIPE_REFUND_MISSING_TARGET'); + } + + if (amountMinor !== undefined) { + if (!Number.isInteger(amountMinor) || amountMinor <= 0) { + throw new Error('STRIPE_INVALID_REFUND_AMOUNT'); + } + } + + try { + const refund = await stripe.refunds.create( + { + ...(pi ? { payment_intent: pi } : { charge: ch! }), + ...(amountMinor !== undefined ? { amount: amountMinor } : {}), + metadata: { orderId, mode: mode ?? 'test' }, + }, + idempotencyKey ? { idempotencyKey } : undefined + ); + + return { refundId: refund.id, status: refund.status }; + } catch (error) { + logError('Stripe refund creation failed', error); + throw new Error('STRIPE_REFUND_FAILED'); + } +} + function getStripeClient(): Stripe | null { - const { secretKey } = getStripeEnv(); - if (!secretKey) return null; + const { secretKey } = getStripeEnv(); + if (!secretKey) return null; - if (_stripe && _stripeKey === secretKey) return _stripe; - _stripeKey = secretKey; + if (_stripe && _stripeKey === secretKey) return _stripe; + _stripeKey = secretKey; - _stripe = new Stripe(secretKey, { - apiVersion: "2025-11-17.clover", - }); + _stripe = new Stripe(secretKey, { + apiVersion: '2025-11-17.clover', + }); - return _stripe; + return _stripe; } export async function createPaymentIntent({ - amount, - currency, - orderId, - idempotencyKey, + amount, + currency, + orderId, + idempotencyKey, }: CreatePaymentIntentInput): Promise<{ - clientSecret: string; - paymentIntentId: string; + clientSecret: string; + paymentIntentId: string; }> { - const { paymentsEnabled, mode } = getStripeEnv(); - const stripe = getStripeClient(); - - if (!paymentsEnabled || !stripe) { - throw new Error("STRIPE_DISABLED"); - } - - if (!Number.isFinite(amount) || amount <= 0) { - throw new Error("STRIPE_INVALID_AMOUNT"); - } - - try { - const intent = await stripe.paymentIntents.create( - { - amount, - currency: currency.toLowerCase(), - metadata: { orderId, mode: mode ?? "test" }, - automatic_payment_methods: { enabled: true }, - }, - idempotencyKey ? { idempotencyKey } : undefined, - ); - - if (!intent.client_secret) { - throw new Error("STRIPE_CLIENT_SECRET_MISSING"); - } - - return { clientSecret: intent.client_secret, paymentIntentId: intent.id }; - } catch (error) { - logError("Stripe payment intent creation failed", error); - throw new Error("STRIPE_PAYMENT_INTENT_FAILED"); - } + const { paymentsEnabled, mode } = getStripeEnv(); + const stripe = getStripeClient(); + + if (!paymentsEnabled || !stripe) { + throw new Error('STRIPE_DISABLED'); + } + + if (!Number.isFinite(amount) || amount <= 0) { + throw new Error('STRIPE_INVALID_AMOUNT'); + } + + try { + const intent = await stripe.paymentIntents.create( + { + amount, + currency: currency.toLowerCase(), + metadata: { orderId, mode: mode ?? 'test' }, + automatic_payment_methods: { enabled: true }, + }, + idempotencyKey ? { idempotencyKey } : undefined + ); + + if (!intent.client_secret) { + throw new Error('STRIPE_CLIENT_SECRET_MISSING'); + } + + return { clientSecret: intent.client_secret, paymentIntentId: intent.id }; + } catch (error) { + logError('Stripe payment intent creation failed', error); + throw new Error('STRIPE_PAYMENT_INTENT_FAILED'); + } } export async function retrievePaymentIntent(paymentIntentId: string): Promise<{ - clientSecret: string; - paymentIntentId: string; + clientSecret: string; + paymentIntentId: string; }> { - const { paymentsEnabled } = getStripeEnv(); - const stripe = getStripeClient(); - - if (!paymentsEnabled || !stripe) { - throw new Error("STRIPE_DISABLED"); - } - - if (!paymentIntentId || paymentIntentId.trim().length === 0) { - throw new Error("STRIPE_INVALID_PAYMENT_INTENT_ID"); - } - - try { - const intent = await stripe.paymentIntents.retrieve(paymentIntentId); - if (!intent.client_secret) throw new Error("STRIPE_CLIENT_SECRET_MISSING"); - return { clientSecret: intent.client_secret, paymentIntentId: intent.id }; - } catch (error) { - logError("Stripe payment intent retrieval failed", error); - throw new Error("STRIPE_PAYMENT_INTENT_FAILED"); - } + const { paymentsEnabled } = getStripeEnv(); + const stripe = getStripeClient(); + + if (!paymentsEnabled || !stripe) { + throw new Error('STRIPE_DISABLED'); + } + + if (!paymentIntentId || paymentIntentId.trim().length === 0) { + throw new Error('STRIPE_INVALID_PAYMENT_INTENT_ID'); + } + + try { + const intent = await stripe.paymentIntents.retrieve(paymentIntentId); + if (!intent.client_secret) throw new Error('STRIPE_CLIENT_SECRET_MISSING'); + return { clientSecret: intent.client_secret, paymentIntentId: intent.id }; + } catch (error) { + logError('Stripe payment intent retrieval failed', error); + throw new Error('STRIPE_PAYMENT_INTENT_FAILED'); + } } export async function retrieveCharge(chargeId: string): Promise { - const { paymentsEnabled } = getStripeEnv(); - const stripe = getStripeClient(); - - if (!paymentsEnabled || !stripe) { - throw new Error("STRIPE_DISABLED"); - } - - if (!chargeId || chargeId.trim().length === 0) { - throw new Error("STRIPE_INVALID_CHARGE_ID"); - } - - try { - return await stripe.charges.retrieve(chargeId); - } catch (error) { - logError("Stripe charge retrieval failed", error); - throw new Error("STRIPE_CHARGE_RETRIEVE_FAILED"); - } + const { paymentsEnabled } = getStripeEnv(); + const stripe = getStripeClient(); + + if (!paymentsEnabled || !stripe) { + throw new Error('STRIPE_DISABLED'); + } + + if (!chargeId || chargeId.trim().length === 0) { + throw new Error('STRIPE_INVALID_CHARGE_ID'); + } + + try { + return await stripe.charges.retrieve(chargeId); + } catch (error) { + logError('Stripe charge retrieval failed', error); + throw new Error('STRIPE_CHARGE_RETRIEVE_FAILED'); + } } type VerifyWebhookSignatureInput = { - rawBody: string; - signatureHeader: string | null; + rawBody: string; + signatureHeader: string | null; }; export function verifyWebhookSignature({ - rawBody, - signatureHeader, + rawBody, + signatureHeader, }: VerifyWebhookSignatureInput): Stripe.Event { - const { paymentsEnabled, webhookSecret } = getStripeEnv(); - const stripe = getStripeClient(); - - if (!paymentsEnabled || !stripe || !webhookSecret) { - throw new Error("STRIPE_WEBHOOK_DISABLED"); - } - - if (!signatureHeader) { - throw new Error("STRIPE_MISSING_SIGNATURE"); - } - - try { - return stripe.webhooks.constructEvent( - rawBody, - signatureHeader, - webhookSecret, - ); - } catch (error) { - logError("Stripe webhook signature verification failed", error); - throw new Error("STRIPE_INVALID_SIGNATURE"); - } + const { paymentsEnabled, webhookSecret } = getStripeEnv(); + const stripe = getStripeClient(); + + if (!paymentsEnabled || !stripe || !webhookSecret) { + throw new Error('STRIPE_WEBHOOK_DISABLED'); + } + + if (!signatureHeader) { + throw new Error('STRIPE_MISSING_SIGNATURE'); + } + + try { + return stripe.webhooks.constructEvent( + rawBody, + signatureHeader, + webhookSecret + ); + } catch (error) { + logError('Stripe webhook signature verification failed', error); + throw new Error('STRIPE_INVALID_SIGNATURE'); + } } diff --git a/frontend/lib/services/orders/refund.ts b/frontend/lib/services/orders/refund.ts index 190a4c5c..dfc23d98 100644 --- a/frontend/lib/services/orders/refund.ts +++ b/frontend/lib/services/orders/refund.ts @@ -2,66 +2,179 @@ import { eq } from 'drizzle-orm'; import { db } from '@/db'; import { orders } from '@/db/schema/shop'; -import { type OrderSummaryWithMinor } from '@/lib/types/shop'; +import { createRefund } from '@/lib/psp/stripe'; import { InvalidPayloadError, OrderNotFoundError } from '../errors'; -import { resolvePaymentProvider } from './_shared'; -import { restockOrder } from './restock'; -import { guardedPaymentStatusUpdate } from './payment-state'; import { getOrderById } from './summary'; +type RefundMetaRecord = { + refundId: string; + idempotencyKey: string; + amountMinor: number; + currency: string; + createdAt: string; + createdBy: string; + status?: string | null; +}; + +function invalid(code: string, message: string): InvalidPayloadError { + const err = new InvalidPayloadError(message); // 1 аргумент + (err as any).code = code; // зберігаємо стабільний code + return err; +} + +function normalizeRefunds( + meta: unknown, + fallback: { currency: string; createdAt: string } +): RefundMetaRecord[] { + const m = (meta ?? {}) as any; + + if (Array.isArray(m.refunds)) return m.refunds as RefundMetaRecord[]; + + const legacy = m.refund; + if (legacy?.id) { + // backward-compat: переносимо старий одиночний refund у refunds[] + return [ + { + refundId: String(legacy.id), + idempotencyKey: 'legacy:webhook', + amountMinor: Number(legacy.amount ?? 0), + currency: fallback.currency, + createdAt: fallback.createdAt, + createdBy: 'webhook', + status: legacy.status ?? null, + }, + ]; + } + + return []; +} + +function appendRefund(meta: unknown, rec: RefundMetaRecord) { + const base = ((meta ?? {}) as any) ?? {}; + const refunds = normalizeRefunds(base, { + currency: rec.currency, + createdAt: rec.createdAt, + }); + + // доменна ідемпотентність: не дублювати по idempotencyKey або refundId + const exists = refunds.some( + r => r.idempotencyKey === rec.idempotencyKey || r.refundId === rec.refundId + ); + const nextRefunds = exists ? refunds : [...refunds, rec]; + + return { + ...base, + refunds: nextRefunds, + refundInitiatedAt: rec.createdAt, // для UI-disable + }; +} + +function makeRefundIdempotencyKey( + orderId: string, + amountMinor: number, + currency: string +): string { + // тримай коротко; Stripe дозволяє довше, але це стабільно + return `refund:${orderId}:${amountMinor}:${currency}`.slice(0, 128); +} + export async function refundOrder( - orderId: string -): Promise { + orderId: string, + opts?: { requestedBy?: string } +) { + const requestedBy = opts?.requestedBy ?? 'admin'; + const [order] = await db - .select({ - id: orders.id, - paymentProvider: orders.paymentProvider, - paymentIntentId: orders.paymentIntentId, - paymentStatus: orders.paymentStatus, - stockRestored: orders.stockRestored, - }) + .select() .from(orders) .where(eq(orders.id, orderId)) .limit(1); - if (!order) throw new OrderNotFoundError('Order not found'); - const provider = resolvePaymentProvider(order); - if (provider !== 'stripe') { - throw new InvalidPayloadError( - 'Refunds are only supported for stripe orders.' + if (!order) throw new OrderNotFoundError(orderId); + + // Preconditions (fail-closed) + if (order.paymentProvider !== 'stripe') { + throw invalid( + 'REFUND_PROVIDER_NOT_STRIPE', + 'Refund is supported only for Stripe orders' ); } - const res = await guardedPaymentStatusUpdate({ + + if (order.paymentStatus !== 'paid') { + throw invalid( + 'REFUND_ORDER_NOT_PAID', + 'Order is not refundable in current state' + ); + } + + const currency = order.currency; + const amountMinor = order.totalAmountMinor; + + if (!currency || !Number.isInteger(amountMinor) || amountMinor <= 0) { + throw invalid( + 'REFUND_ORDER_MONEY_INVALID', + 'Invalid order amount/currency' + ); + } + + const paymentIntentId = order.paymentIntentId?.trim() + ? order.paymentIntentId.trim() + : null; + const chargeId = order.pspChargeId?.trim() ? order.pspChargeId.trim() : null; + + if (!paymentIntentId && !chargeId) { + throw invalid( + 'REFUND_MISSING_PSP_TARGET', + 'Missing Stripe identifiers (paymentIntentId/pspChargeId)' + ); + } + + const idempotencyKey = makeRefundIdempotencyKey( orderId, - paymentProvider: provider, // <-- замість order.paymentProvider - to: 'refunded', - source: 'admin', - note: 'refundOrder()', - set: { updatedAt: new Date(), status: 'CANCELED' }, + amountMinor, + currency + ); + + // Доменна ідемпотентність: якщо вже є запис — просто повертаємо summary + const existingRefunds = normalizeRefunds(order.pspMetadata, { + currency, + createdAt: order.createdAt.toISOString(), }); - if (!res.applied) { - if (res.reason === 'ALREADY_IN_STATE') { - // idempotent - } else if (res.reason === 'INVALID_TRANSITION') { - throw new InvalidPayloadError( - 'Order cannot be refunded from the current status.' - ); - } else if (res.reason === 'PROVIDER_MISMATCH') { - throw new InvalidPayloadError('Order payment provider mismatch.'); - } else if (res.reason === 'BLOCKED') { - throw new InvalidPayloadError('Refund blocked by safety gates.'); - } else { - throw new OrderNotFoundError('Order not found'); - } + const already = existingRefunds.find( + r => r.idempotencyKey === idempotencyKey + ); + if (already) { + return await getOrderById(orderId); } - const canRestock = - res.applied || (!res.applied && res.reason === 'ALREADY_IN_STATE'); + // Реальний Stripe call (idempotent на стороні Stripe) + const { refundId, status } = await createRefund({ + orderId, + paymentIntentId, + chargeId, + amountMinor, + idempotencyKey, + }); - if (canRestock && !order.stockRestored) { - await restockOrder(orderId, { reason: 'refunded' }); - } + const createdAtIso = new Date().toISOString(); + + const nextMeta = appendRefund(order.pspMetadata, { + refundId, + idempotencyKey, + amountMinor, + currency, + createdAt: createdAtIso, + createdBy: requestedBy, + status: status ?? null, + }); + + // Persist тільки metadata. payment_status НЕ чіпаємо (джерело істини — webhook) + await db + .update(orders) + .set({ pspMetadata: nextMeta }) + .where(eq(orders.id, orderId)); - return getOrderById(orderId); + // Повертаємо як і раніше: order summary для API + return await getOrderById(orderId); } diff --git a/frontend/project-structure.txt b/frontend/project-structure.txt index c910a3e3..9b874910 100644 --- a/frontend/project-structure.txt +++ b/frontend/project-structure.txt @@ -21,14 +21,24 @@ 📄 route.ts 📁 me 📄 route.ts + 📁 password-reset + 📁 confirm + 📄 route.ts + 📄 route.ts + 📁 resend-verification + 📄 route.ts 📁 signup 📄 route.ts + 📁 verify-email + 📄 route.ts 📁 questions 📁 [category] 📄 route.ts 📁 quiz 📁 guest-result 📄 route.ts + 📁 verify-answer + 📄 route.ts 📁 [slug] 📄 route.ts 📁 shop @@ -76,6 +86,8 @@ 📄 page.tsx 📁 dashboard 📄 page.tsx + 📁 forgot-password + 📄 page.tsx 📄 layout.tsx 📁 leaderboard 📄 page.tsx @@ -92,6 +104,8 @@ 📄 page.tsx 📁 quizzes 📄 page.tsx + 📁 reset-password + 📄 page.tsx 📁 shop 📁 admin 📄 layout.tsx @@ -178,7 +192,9 @@ 📄 AccordionList.tsx 📄 CodeBlock.tsx 📄 Pagination.tsx - 📄 TabsSection.tsx + 📄 QaSection.tsx + 📄 types.ts + 📄 useQaTabs.ts 📁 quiz 📄 CountdownTimer.tsx 📄 ExplanationRenderer.tsx @@ -211,6 +227,8 @@ 📄 shop-footer.tsx 📄 shop-hero.tsx 📄 theme-provider.tsx + 📁 tests + 📄 CookieBanner.test.tsx 📁 theme 📄 ThemeProvider.tsx 📄 ThemeToggle.tsx @@ -218,6 +236,7 @@ 📄 accordion.tsx 📄 badge.tsx 📄 button.tsx + 📄 confirm-modal.tsx 📄 radio-group.tsx 📄 tabs.tsx 📄 components.json @@ -236,7 +255,9 @@ 📄 users.ts 📁 schema 📄 categories.ts + 📄 emailVerificationTokens.ts 📄 index.ts + 📄 passwordResetTokens.ts 📄 points.ts 📄 questions.ts 📄 quiz.ts @@ -245,6 +266,8 @@ 📄 seed-categories.ts 📄 seed-demo-leaderboard.ts 📄 seed-questions.ts + 📄 seed-quiz-angular-advanced.ts + 📄 seed-quiz-angular.ts 📄 seed-quiz-css-advanced.ts 📄 seed-quiz-css.ts 📄 seed-quiz-from-json.ts @@ -253,12 +276,18 @@ 📄 seed-quiz-html.ts 📄 seed-quiz-javascript-advanced.ts 📄 seed-quiz-javascript.ts + 📄 seed-quiz-nodejs-advanced.ts + 📄 seed-quiz-nodejs.ts 📄 seed-quiz-react.ts 📄 seed-quiz-types.ts 📄 seed-quiz-typescript-advanced.ts 📄 seed-quiz-typescript.ts 📄 seed-quiz-verify.ts + 📄 seed-quiz-vue.ts 📄 seed-users.ts +📁 docs + 📁 payments + 📄 fondy.md 📁 drizzle 📄 0000_rich_magus.sql 📄 0001_black_random.sql @@ -275,7 +304,13 @@ 📄 0011_add_orders_sweep_claim_index.sql 📄 0012_inventory_moves_product_fk_restrict.sql 📄 0013_add_internal_job_state.sql + 📄 0013_brown_gamora.sql + 📄 0013_low_roughhouse.sql 📄 0014_add-stripe-events-processed-at.sql + 📄 0014_dapper_kang.sql + 📄 0014_steep_kabuki.sql + 📄 0015_dear_legion.sql + 📄 0015_glamorous_eternity.sql 📄 0015_warm_dexter_bennett.sql 📁 meta 📄 0000_snapshot.json @@ -292,6 +327,7 @@ 📄 0012_snapshot.json 📄 0013_snapshot.json 📄 0014_snapshot.json + 📄 0015_snapshot.json 📄 _journal.json 📄 relations.ts 📄 schema.ts @@ -300,6 +336,8 @@ 📁 hooks 📄 use-mounted.ts 📄 useAntiCheat.ts + 📄 useQuizGuards.ts + 📄 useQuizSession.ts 📁 i18n 📄 config.ts 📄 request.ts @@ -309,24 +347,38 @@ 📄 parseAdminProductForm.ts 📁 auth 📄 admin.ts + 📄 email-verification.ts 📄 internal-janitor.ts 📄 oauth-state.ts + 📄 password-reset.ts 📄 auth.ts 📄 cart.ts 📄 cloudinary.ts 📁 config 📄 catalog.ts + 📁 email + 📄 sendPasswordResetEmail.ts + 📄 sendVerificationEmail.ts + 📁 templates + 📄 base-layout.ts + 📄 reset-password.ts + 📄 verify-email.ts + 📄 transporter.ts 📁 env 📄 auth.ts 📄 cloudinary.ts 📄 index.ts 📄 stripe.ts - 📄 guest-quiz.ts 📄 logging.ts 📄 logout.ts 📄 navigation.ts 📁 psp 📄 stripe.ts + 📁 quiz + 📄 guest-quiz.ts + 📄 quiz-crypto.ts + 📄 quiz-session.ts + 📄 quiz-storage-keys.ts 📁 services 📄 errors.ts 📄 inventory.ts @@ -433,7 +485,9 @@ 📄 README.md 📄 save-structure.cjs 📁 scripts - 📄 guard-non-preview.ts + 📄 create-user.ts + 📄 debug-user.ts 📄 shop-janitor-restock-stale.mjs + 📄 verify-user.ts 📄 tsconfig.json 📄 vitest.config.ts \ No newline at end of file From 5284ab3d491eb85f7ee699a0c81d72555e070433 Mon Sep 17 00:00:00 2001 From: liudmylasovetovs Date: Wed, 14 Jan 2026 15:22:26 -0800 Subject: [PATCH 2/8] (SP: 1) [Backend] Enforce restock release invariants: fail-safe on release failure + regression test --- .../components/tests/CookieBanner.test.tsx | 3 + frontend/lib/services/orders/checkout.ts | 111 +++++---- frontend/lib/services/orders/restock.ts | 189 +++++++++------ .../restock-release-failure-invariant.test.ts | 219 ++++++++++++++++++ 4 files changed, 401 insertions(+), 121 deletions(-) create mode 100644 frontend/lib/tests/restock-release-failure-invariant.test.ts diff --git a/frontend/components/tests/CookieBanner.test.tsx b/frontend/components/tests/CookieBanner.test.tsx index cc747454..998509b5 100644 --- a/frontend/components/tests/CookieBanner.test.tsx +++ b/frontend/components/tests/CookieBanner.test.tsx @@ -1,3 +1,6 @@ +/** + * @vitest-environment jsdom + */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render, screen, act, fireEvent } from '@testing-library/react'; import { CookieBanner } from '@/components/shared/CookieBanner'; diff --git a/frontend/lib/services/orders/checkout.ts b/frontend/lib/services/orders/checkout.ts index 424ff82a..788271ae 100644 --- a/frontend/lib/services/orders/checkout.ts +++ b/frontend/lib/services/orders/checkout.ts @@ -1,6 +1,6 @@ import { and, eq, inArray, ne, sql } from 'drizzle-orm'; -import { applyReserveMove, applyReleaseMove } from '../inventory'; +import { applyReserveMove } from '../inventory'; import { logError, logWarn } from '@/lib/logging'; import { isPaymentsEnabled } from '@/lib/env/stripe'; import { db } from '@/db'; @@ -58,6 +58,8 @@ async function reconcileNoPaymentOrder( inventoryStatus: orders.inventoryStatus, stockRestored: orders.stockRestored, restockedAt: orders.restockedAt, + failureCode: orders.failureCode, + failureMessage: orders.failureMessage, }) .from(orders) .where(eq(orders.id, orderId)) @@ -89,6 +91,25 @@ async function reconcileNoPaymentOrder( return getOrderById(orderId); } + if (row.inventoryStatus === 'release_pending') { + // Do not attempt to reserve again while release is pending. + try { + await restockOrder(orderId, { + reason: 'failed', + workerId: 'reconcileNoPaymentOrder', + }); + } catch (restockErr) { + logError( + `[reconcileNoPaymentOrder] restock failed orderId=${orderId}`, + restockErr + ); + } + + throw new InsufficientStockError( + row.failureMessage ?? 'Order cannot be completed (release pending).' + ); + } + // If it was already released/restocked - treat as failed. if ( row.inventoryStatus === 'released' || @@ -168,49 +189,39 @@ async function reconcileNoPaymentOrder( return getOrderById(orderId); } catch (e) { const failAt = new Date(); + + // Mark as "release pending" only. Finalization must happen via restockOrder(). await db .update(orders) .set({ inventoryStatus: 'release_pending', updatedAt: failAt }) .where(eq(orders.id, orderId)); - for (const item of itemsToReserve) { - try { - await applyReleaseMove(orderId, item.productId, item.quantity); - } catch (releaseErr) { - logError( - `[reconcileNoPaymentOrder] release failed orderId=${orderId} productId=${item.productId} quantity=${item.quantity}`, - releaseErr - ); - } - } - const isOos = e instanceof InsufficientStockError; + await db .update(orders) .set({ status: 'INVENTORY_FAILED', - inventoryStatus: 'released', + inventoryStatus: 'release_pending', failureCode: isOos ? 'OUT_OF_STOCK' : 'INTERNAL_ERROR', failureMessage: isOos ? e.message : 'Checkout failed after reservation attempt.', - stockRestored: true, - restockedAt: failAt, + // IMPORTANT: do NOT set stockRestored/restockedAt here. updatedAt: failAt, }) .where(eq(orders.id, orderId)); - const payRes = await guardedPaymentStatusUpdate({ - orderId, - paymentProvider: 'none', - to: 'failed', - source: 'checkout', - }); - - if (!payRes.applied && payRes.reason !== 'ALREADY_IN_STATE') { + try { + await restockOrder(orderId, { + reason: 'failed', + workerId: 'reconcileNoPaymentOrder', + }); + } catch (restockErr) { + // If release fails, we must not lie in order state; leave it for sweeps/janitor. logError( - `[reconcileNoPaymentOrder] paymentStatus transition to failed blocked orderId=${orderId} reason=${payRes.reason}`, - new Error('payment_transition_blocked') + `[reconcileNoPaymentOrder] restock failed orderId=${orderId}`, + restockErr ); } @@ -396,8 +407,10 @@ export async function createOrderWithItems({ const paymentsEnabled = isPaymentsEnabled(); const paymentProvider: PaymentProvider = paymentsEnabled ? 'stripe' : 'none'; - // IMPORTANT: DB CHECK requires provider=none => payment_status in ('paid','failed') - const paymentStatus = paymentsEnabled ? 'requires_payment' : 'paid'; + // paymentStatus is initialized here only; ALL transitions must go via guardedPaymentStatusUpdate. + // IMPORTANT: DB CHECK requires provider='none' => payment_status in ('paid','failed') + const initialPaymentStatus: PaymentStatus = + paymentProvider === 'none' ? 'paid' : 'requires_payment'; const normalizedItems = mergeCheckoutItems( items @@ -584,7 +597,7 @@ export async function createOrderWithItems({ totalAmount: toDbMoney(orderTotalCents), currency, - paymentStatus, + paymentStatus: initialPaymentStatus, paymentProvider, paymentIntentId: null, @@ -712,9 +725,8 @@ export async function createOrderWithItems({ }) .where(eq(orders.id, orderId)); - const targetPaymentStatus: PaymentStatus = paymentsEnabled - ? 'pending' - : 'paid'; + const targetPaymentStatus: PaymentStatus = + paymentProvider === 'none' ? 'paid' : 'pending'; const payRes = await guardedPaymentStatusUpdate({ orderId, @@ -739,50 +751,37 @@ export async function createOrderWithItems({ } } catch (e) { const failAt = new Date(); + await db .update(orders) .set({ inventoryStatus: 'release_pending', updatedAt: failAt }) .where(eq(orders.id, orderId)); - // best-effort release - for (const it of itemsToReserve) { - try { - await applyReleaseMove(orderId, it.productId, it.quantity); - } catch (releaseErr) { - logError( - `[createOrderWithItems] release failed orderId=${orderId} productId=${it.productId} quantity=${it.quantity}`, - releaseErr - ); - } - } - const isOos = e instanceof InsufficientStockError; + await db .update(orders) .set({ status: 'INVENTORY_FAILED', - inventoryStatus: 'released', + inventoryStatus: 'release_pending', failureCode: isOos ? 'OUT_OF_STOCK' : 'INTERNAL_ERROR', failureMessage: isOos ? e.message : 'Checkout failed after reservation attempt.', - stockRestored: true, - restockedAt: failAt, + // IMPORTANT: do NOT set stockRestored/restockedAt here. updatedAt: failAt, }) .where(eq(orders.id, orderId)); - const payRes = await guardedPaymentStatusUpdate({ - orderId, - paymentProvider, - to: 'failed', - source: 'checkout', - }); - - if (!payRes.applied && payRes.reason !== 'ALREADY_IN_STATE') { + try { + await restockOrder(orderId, { + reason: 'failed', + workerId: 'createOrderWithItems', + }); + } catch (restockErr) { logError( - `[createOrderWithItems] paymentStatus transition to failed blocked orderId=${orderId} provider=${paymentProvider} reason=${payRes.reason}`, - new Error('payment_transition_blocked') + `[createOrderWithItems] restock failed orderId=${orderId}`, + restockErr ); } diff --git a/frontend/lib/services/orders/restock.ts b/frontend/lib/services/orders/restock.ts index 64971a63..785b85e8 100644 --- a/frontend/lib/services/orders/restock.ts +++ b/frontend/lib/services/orders/restock.ts @@ -1,4 +1,4 @@ -import crypto from 'crypto'; +import crypto from 'node:crypto'; import { and, eq, isNull, lt, ne, or } from 'drizzle-orm'; import { applyReleaseMove } from '../inventory'; @@ -103,77 +103,71 @@ export async function restockOrder( ) ); - if (!reservedMoves.length) { - // Nothing was reserved. For no-payments orders this is an "orphan" that must become terminal. + if (reservedMoves.length === 0) { + // safety: paid can only be terminalized via refund if ( - isNoPayment && - (reason === 'failed' || reason === 'canceled' || reason === 'stale') + !isNoPayment && + order.paymentStatus === 'paid' && + reason !== 'refunded' ) { - const now = new Date(); - - const [touched] = await db - .update(orders) - .set({ - status: 'INVENTORY_FAILED', - inventoryStatus: 'released', - failureCode: order.failureCode ?? 'STALE_ORPHAN', - failureMessage: - order.failureMessage ?? - 'Orphan order: no inventory reservation was recorded.', - stockRestored: true, - restockedAt: now, - updatedAt: now, - }) - .where(and(eq(orders.id, orderId), eq(orders.stockRestored, false))) - .returning({ id: orders.id }); - - if (!touched) return; - - // paymentStatus transition only via guard - await guardedPaymentStatusUpdate({ - orderId, - paymentProvider: provider, - to: 'failed', - source: transitionSource, - // tie to this exact finalize marker (prevents races) - extraWhere: eq(orders.restockedAt, now), - }); - - return; + throw new OrderStateInvalidError( + `Cannot terminalize an orphan paid order without refund reason.`, + { orderId, details: { reason, paymentStatus: order.paymentStatus } } + ); } - // Stripe (or any non-none provider): stale orphan must become terminal - if (reason === 'stale') { - const now = new Date(); - - const [touched] = await db - .update(orders) - .set({ - status: 'INVENTORY_FAILED', - inventoryStatus: 'released', - failureCode: order.failureCode ?? 'STALE_ORPHAN', - failureMessage: - order.failureMessage ?? - 'Orphan order: no inventory reservation was recorded.', - stockRestored: true, - restockedAt: now, - updatedAt: now, - }) - .where(and(eq(orders.id, orderId), eq(orders.stockRestored, false))) - .returning({ id: orders.id }); - - if (!touched) return; - - // paymentStatus transition only via guard (provider from DB) + // No inventory was reserved. If caller gave no reason, do nothing (fail-closed). + if (!reason) return; + + const now = new Date(); + const shouldCancel = reason === 'canceled'; + const shouldFail = reason === 'failed' || reason === 'stale'; + + const orphanFailureCode = + order.failureCode ?? + (reason === 'failed' + ? 'FAILED_ORPHAN' + : reason === 'canceled' + ? 'CANCELED_ORPHAN' + : 'STALE_ORPHAN'); + + const [touched] = await db + .update(orders) + .set({ + ...(shouldFail ? { status: 'INVENTORY_FAILED' } : {}), + ...(shouldCancel ? { status: 'CANCELED' } : {}), + inventoryStatus: 'released', + ...(shouldFail + ? { + failureCode: orphanFailureCode, + failureMessage: + order.failureMessage ?? + 'Orphan order: no inventory reservation was recorded.', + } + : {}), + stockRestored: true, + restockedAt: now, + updatedAt: now, + }) + .where(and(eq(orders.id, orderId), eq(orders.stockRestored, false))) + .returning({ id: orders.id }); + + if (!touched) return; + + let normalizedStatus: PaymentStatus | undefined; + if (reason === 'refunded' && !isNoPayment) normalizedStatus = 'refunded'; + else if (reason === 'failed' || reason === 'canceled' || reason === 'stale') + normalizedStatus = 'failed'; + + if (normalizedStatus) { await guardedPaymentStatusUpdate({ orderId, paymentProvider: provider, - to: 'failed', + to: normalizedStatus, source: transitionSource, + // bind to this exact finalize marker (prevents races) extraWhere: eq(orders.restockedAt, now), }); - - return; } return; @@ -208,8 +202,73 @@ export async function restockOrder( .set({ inventoryStatus: 'release_pending', updatedAt: now }) .where(and(eq(orders.id, orderId), ne(orders.inventoryStatus, 'released'))); - for (const item of reservedMoves) - await applyReleaseMove(orderId, item.productId, item.quantity); + // Apply release moves. IMPORTANT invariant: + // do NOT mark released/stockRestored/restockedAt unless all releases are CONFIRMED ok. + const releaseFailures: Array<{ productId: string; reason: string }> = []; + + for (const item of reservedMoves) { + try { + const res: unknown = await applyReleaseMove( + orderId, + item.productId, + item.quantity + ); + + // Support both styles: + // - void return (treat as success) + // - { ok: boolean, reason?: string } return (explicit fail if ok === false) + if ( + res && + typeof res === 'object' && + 'ok' in (res as any) && + (res as any).ok === false + ) { + releaseFailures.push({ + productId: item.productId, + reason: String((res as any).reason ?? 'unknown'), + }); + } + } catch (err) { + releaseFailures.push({ + productId: item.productId, + reason: err instanceof Error ? err.message : String(err), + }); + } + } + + if (releaseFailures.length > 0) { + const failAt = new Date(); + const details = releaseFailures + .slice(0, 3) + .map(f => `${f.productId}:${f.reason}`) + .join(', '); + + const msg = + `Release move not confirmed for ${releaseFailures.length} item(s): ` + + details + + (releaseFailures.length > 3 ? ', ...' : ''); + + // FAIL-SAFE: leave order in a state janitor can safely retry. + // Do NOT set: inventoryStatus='released' OR stockRestored=true OR restockedAt!=null. + const shouldSetFailureCode = reason === 'failed' || reason === 'stale'; + await db + .update(orders) + .set({ + inventoryStatus: 'release_pending', + stockRestored: false, + restockedAt: null, + ...(shouldSetFailureCode + ? { failureCode: order.failureCode ?? 'RESTOCK_RELEASE_FAILED' } + : {}), + failureMessage: order.failureMessage + ? `${order.failureMessage} | ${msg}` + : msg, + updatedAt: failAt, + }) + .where(eq(orders.id, orderId)); + + return; + } // FINALIZE ONCE: only one caller may flip stock_restored/restocked_at // If RETURNING is empty => already finalized by another worker (or previous attempt). const finalizedAt = new Date(); @@ -236,7 +295,7 @@ export async function restockOrder( .update(orders) .set({ inventoryStatus: 'released', - updatedAt: now, + updatedAt: finalizedAt, ...(shouldFail ? { status: 'INVENTORY_FAILED' } : {}), ...(shouldCancel ? { status: 'CANCELED' } : {}), }) diff --git a/frontend/lib/tests/restock-release-failure-invariant.test.ts b/frontend/lib/tests/restock-release-failure-invariant.test.ts new file mode 100644 index 00000000..fbd741a1 --- /dev/null +++ b/frontend/lib/tests/restock-release-failure-invariant.test.ts @@ -0,0 +1,219 @@ +import crypto from 'node:crypto'; +import { eq } from 'drizzle-orm'; +import { describe, it, expect, vi } from 'vitest'; + +import { db } from '@/db'; +import { + orders, + orderItems, + products, + productPrices, + inventoryMoves, +} from '@/db/schema/shop'; +import { toDbMoney } from '@/lib/shop/money'; +import { restockOrder } from '@/lib/services/orders/restock'; +import * as inventory from '@/lib/services/inventory'; + +describe('P0 Inventory release invariants', () => { + it('must NOT mark released/stockRestored/restockedAt when applyReleaseMove fails (leave safe for janitor)', async () => { + const productId = crypto.randomUUID(); + const orderId = crypto.randomUUID(); + const idemKey = `t_${crypto.randomUUID()}`; + const requestHash = crypto.randomBytes(16).toString('hex'); + + const slug = `t-${productId.slice(0, 8)}`; + const sku = `SKU-${productId.slice(0, 8)}`; + + // Keep references for cleanup + const now = new Date(); + + // Spy/mocking: force release to fail + // NOTE: if applyReleaseMove throws in your impl, mockRejectedValueOnce is also OK. + const releaseSpy = vi + .spyOn(inventory, 'applyReleaseMove' as any) + .mockResolvedValueOnce({ ok: false, reason: 'MOCK_FAIL' } as any); + + try { + // 1) Seed product + price + await db.insert(products).values({ + id: productId, + slug, + title: 'Test product', + description: 'Test description', + imageUrl: 'https://example.com/test.png', + imagePublicId: 'test-public-id', // додай одразу, щоб не впертись у NOT NULL, якщо він є + sku, + + // IMPORTANT: products.price is NOT NULL (legacy) + currency: 'USD', + price: toDbMoney(1000), // 10.00 + originalPrice: null, + + stock: 1, + isActive: true, + + createdAt: now, + updatedAt: now, + } as any); + + await db.insert(productPrices).values({ + productId, + currency: 'USD', + // canonical minor (int) + priceMinor: 1000, + // legacy fallback (numeric) + price: 10, + originalPrice: null, + } as any); + + // 2) Seed order + order item (minimal required fields) + await db.insert(orders).values({ + id: orderId, + totalAmountMinor: 1000, + totalAmount: toDbMoney(1000), + currency: 'USD', + + paymentProvider: 'stripe', + paymentIntentId: null, + paymentStatus: 'failed', // we are testing restock on "failed" path + + status: 'INVENTORY_FAILED', + inventoryStatus: 'reserving', // will reserve first, then switch to release_pending + failureCode: 'INTERNAL_ERROR', + failureMessage: 'fail before release', + + stockRestored: false, + restockedAt: null, + + idempotencyKey: idemKey, + idempotencyRequestHash: requestHash, + userId: null, + + createdAt: now, + updatedAt: now, + } as any); + + await db.insert(orderItems).values({ + orderId, + productId, + selectedSize: '', + selectedColor: '', + quantity: 1, + + unitPriceMinor: 1000, + lineTotalMinor: 1000, + + unitPrice: toDbMoney(1000), + lineTotal: toDbMoney(1000), + + productTitle: 'Test product', + productSlug: slug, + productSku: sku, + } as any); + + // 3) Create an ACTUAL reservation move (so “release” is реально потрібен) + // This should decrement product stock from 1 -> 0. + const reserveRes = await inventory.applyReserveMove( + orderId, + productId, + 1 + ); + expect(reserveRes?.ok).toBe(true); + + const [pAfterReserve] = await db + .select({ stock: products.stock }) + .from(products) + .where(eq(products.id, productId)) + .limit(1); + + expect(pAfterReserve?.stock).toBe(0); + + // 4) Put order into release_pending so restockOrder will attempt release + await db + .update(orders) + .set({ + inventoryStatus: 'release_pending', + status: 'INVENTORY_FAILED', + updatedAt: new Date(), + } as any) + .where(eq(orders.id, orderId)); + + // 5) Call restockOrder — release fails; function may throw OR no-op, but must NOT finalize release fields + try { + await restockOrder(orderId, { + reason: 'failed', + workerId: 'test', + } as any); + } catch { + // acceptable: some implementations throw for manual/admin path + } + + // 6) Assert invariants: order NOT finalized to released/stockRestored/restockedAt + const [o] = await db + .select({ + inventoryStatus: orders.inventoryStatus, + stockRestored: orders.stockRestored, + restockedAt: orders.restockedAt, + }) + .from(orders) + .where(eq(orders.id, orderId)) + .limit(1); + + expect(o).toBeTruthy(); + + // Key invariants: + expect(o!.inventoryStatus).not.toBe('released'); + expect(o!.stockRestored).toBe(false); + expect(o!.restockedAt).toBeNull(); + + // 7) Assert product stock DID NOT increment (release not confirmed) + const [pAfterFail] = await db + .select({ stock: products.stock }) + .from(products) + .where(eq(products.id, productId)) + .limit(1); + + expect(pAfterFail?.stock).toBe(0); + } finally { + releaseSpy.mockRestore(); + + // cleanup (best-effort) + try { + // delete ledger rows for this order if table exists in your schema + if (inventoryMoves) { + await db + .delete(inventoryMoves as any) + .where(eq((inventoryMoves as any).orderId, orderId)); + } + } catch { + // ignore + } + + try { + await db.delete(orderItems).where(eq(orderItems.orderId, orderId)); + } catch { + // ignore + } + + try { + await db.delete(orders).where(eq(orders.id, orderId)); + } catch { + // ignore + } + + try { + await db + .delete(productPrices) + .where(eq(productPrices.productId, productId)); + } catch { + // ignore + } + + try { + await db.delete(products).where(eq(products.id, productId)); + } catch { + // ignore + } + } + }); +}); From 60906946d0e1d340e5b36528fb972e5f51eb4e81 Mon Sep 17 00:00:00 2001 From: liudmylasovetovs Date: Wed, 14 Jan 2026 17:37:22 -0800 Subject: [PATCH 3/8] (SP: 1) [Shop/Backend] Checkout: enforce post-create Stripe failure semantics (502 STRIPE_ERROR, 409 CHECKOUT_CONFLICT) + contract tests --- frontend/app/[locale]/shop/cart/page.tsx | 6 +- frontend/app/[locale]/shop/products/page.tsx | 9 + frontend/app/api/shop/checkout/route.ts | 156 ++++++++++-------- frontend/lib/psp/stripe.ts | 5 +- frontend/lib/services/orders/checkout.ts | 7 +- .../lib/services/orders/payment-intent.ts | 2 + frontend/lib/services/orders/refund.ts | 9 +- .../lib/services/products/cart/rehydrate.ts | 30 ++-- ...set-payment-intent-reject-contract.test.ts | 134 +++++++++++++++ .../checkout-stripe-error-contract.test.ts | 112 +++++++++++++ 10 files changed, 375 insertions(+), 95 deletions(-) create mode 100644 frontend/lib/tests/checkout-set-payment-intent-reject-contract.test.ts create mode 100644 frontend/lib/tests/checkout-stripe-error-contract.test.ts diff --git a/frontend/app/[locale]/shop/cart/page.tsx b/frontend/app/[locale]/shop/cart/page.tsx index e6b7655c..a74a63dc 100644 --- a/frontend/app/[locale]/shop/cart/page.tsx +++ b/frontend/app/[locale]/shop/cart/page.tsx @@ -222,7 +222,7 @@ export default function CartPage() { - {formatMoney(item.lineTotal, item.currency, locale)} + {formatMoney(item.lineTotalMinor, item.currency, locale)} @@ -240,7 +240,7 @@ export default function CartPage() { Subtotal {formatMoney( - cart.summary.totalAmount, + cart.summary.totalAmountMinor, cart.summary.currency, locale )} @@ -259,7 +259,7 @@ export default function CartPage() { {formatMoney( - cart.summary.totalAmount, + cart.summary.totalAmountMinor, cart.summary.currency, locale )} diff --git a/frontend/app/[locale]/shop/products/page.tsx b/frontend/app/[locale]/shop/products/page.tsx index f4c369fb..0e5f0fd1 100644 --- a/frontend/app/[locale]/shop/products/page.tsx +++ b/frontend/app/[locale]/shop/products/page.tsx @@ -5,6 +5,7 @@ import { ProductCard } from '@/components/shop/product-card'; import { ProductFilters } from '@/components/shop/product-filters'; import { ProductSort } from '@/components/shop/product-sort'; import { CatalogLoadMore } from '@/components/shop/catalog-load-more'; +// import { Pagination } from '@/components/q&a/Pagination'; import { getCatalogProducts } from '@/lib/shop/data'; import { catalogQuerySchema } from '@/lib/validation/shop'; import { CATALOG_PAGE_SIZE } from '@/lib/config/catalog'; @@ -18,6 +19,7 @@ type RawSearchParams = { page?: string; }; + interface ProductsPageProps { searchParams: Promise; } @@ -84,6 +86,13 @@ export default async function ProductsPage({ hasMore={catalog.hasMore} nextPage={catalog.page + 1} /> + {/* {!isLoading && totalPages > 1 && ( + + )} */} )} diff --git a/frontend/app/api/shop/checkout/route.ts b/frontend/app/api/shop/checkout/route.ts index 0cd6181c..b5539c43 100644 --- a/frontend/app/api/shop/checkout/route.ts +++ b/frontend/app/api/shop/checkout/route.ts @@ -157,11 +157,7 @@ export async function POST(request: NextRequest) { logWarn('Failed to parse cart payload', { reason: error instanceof Error ? error.message : String(error), }); - return errorResponse( - 'INVALID_PAYLOAD', - 'Unable to process cart data.', - 400 - ); + return errorResponse('INVALID_PAYLOAD', 'Unable to process cart data.', 400); } const idempotencyKey = getIdempotencyKey(request); @@ -241,22 +237,13 @@ export async function POST(request: NextRequest) { const paymentsEnabled = isPaymentsEnabled(); if (!paymentsEnabled) { - // If the order already failed (inventory or other), return a stable conflict instead of 500. - if ( - order.paymentProvider === 'none' && - order.paymentStatus === 'failed' - ) { - return errorResponse( - 'CHECKOUT_FAILED', - 'Order could not be completed.', - 409, - { orderId: order.id } - ); + if (order.paymentProvider === 'none' && order.paymentStatus === 'failed') { + return errorResponse('CHECKOUT_FAILED', 'Order could not be completed.', 409, { + orderId: order.id, + }); } - if ( - order.paymentProvider === 'stripe' && - order.paymentStatus !== 'paid' - ) { + + if (order.paymentProvider === 'stripe' && order.paymentStatus !== 'paid') { return errorResponse( 'PAYMENTS_DISABLED', 'Payments are disabled. This order requires payment and cannot be processed.', @@ -266,16 +253,9 @@ export async function POST(request: NextRequest) { } if (order.paymentProvider === 'none') { - if ( - !['paid', 'failed'].includes(order.paymentStatus) || - order.paymentIntentId - ) { + if (!['paid', 'failed'].includes(order.paymentStatus) || order.paymentIntentId) { logError( - `Payments disabled but order is not paid/none. orderId=${ - order.id - } provider=${order.paymentProvider} status=${ - order.paymentStatus - } intent=${order.paymentIntentId ?? 'null'}`, + `Payments disabled but order is not paid/none. orderId=${order.id} provider=${order.paymentProvider} status=${order.paymentStatus} intent=${order.paymentIntentId ?? 'null'}`, new Error('ORDER_STATE_INVALID') ); return errorResponse( @@ -287,16 +267,16 @@ export async function POST(request: NextRequest) { } } - const stripePaymentFlow = - paymentsEnabled && order.paymentProvider === 'stripe'; + const stripePaymentFlow = paymentsEnabled && order.paymentProvider === 'stripe'; + // ========================= + // Existing order path + // ========================= if (!result.isNew) { + // Existing order already has PI: retrieve client_secret if (stripePaymentFlow && order.paymentIntentId) { try { - const paymentIntent = await retrievePaymentIntent( - order.paymentIntentId - ); - + const paymentIntent = await retrievePaymentIntent(order.paymentIntentId); return buildCheckoutResponse({ order: { id: order.id, @@ -312,23 +292,27 @@ export async function POST(request: NextRequest) { }); } catch (error) { logError('Checkout payment intent retrieval failed', error); - return errorResponse( - 'STRIPE_ERROR', - 'Unable to initiate payment.', - 400 - ); + return errorResponse('STRIPE_ERROR', 'Unable to initiate payment.', 502); } } + // Existing order without PI: create PI then attach (post-create => never 400) if (stripePaymentFlow && !order.paymentIntentId) { + let paymentIntent: { paymentIntentId: string; clientSecret: string }; + try { - const paymentIntent = await createPaymentIntent({ + paymentIntent = await createPaymentIntent({ amount: totalCents, currency: order.currency, orderId: order.id, idempotencyKey, }); + } catch (error) { + logError('Checkout payment intent creation failed', error); + return errorResponse('STRIPE_ERROR', 'Unable to initiate payment.', 502); + } + try { const updatedOrder = await setOrderPaymentIntent({ orderId: order.id, paymentIntentId: paymentIntent.paymentIntentId, @@ -348,15 +332,30 @@ export async function POST(request: NextRequest) { status: 200, }); } catch (error) { - logError('Checkout payment intent creation failed', error); - return errorResponse( - 'STRIPE_ERROR', - 'Unable to initiate payment.', - 400 - ); + logError('Checkout payment intent attach failed', error); + + // Post-create => conflict, not 400 + if (error instanceof InvalidPayloadError) { + return errorResponse( + 'CHECKOUT_CONFLICT', + 'Order state conflict while attaching payment intent. Retry with the same Idempotency-Key.', + 409, + { orderId: order.id } + ); + } + + if (error instanceof OrderStateInvalidError) { + return errorResponse(error.code, error.message, 500, { + orderId: error.orderId, + ...(error.details ? { details: error.details } : {}), + }); + } + + return errorResponse('INTERNAL_ERROR', 'Unable to process checkout.', 500); } } + // Not Stripe flow => return existing order as-is return buildCheckoutResponse({ order: { id: order.id, @@ -372,6 +371,9 @@ export async function POST(request: NextRequest) { }); } + // ========================= + // New order path + // ========================= if (!stripePaymentFlow) { return buildCheckoutResponse({ order: { @@ -388,14 +390,30 @@ export async function POST(request: NextRequest) { }); } + // Stripe new order: Phase 1 PSP call (if fails => restock best-effort, return 502) + let paymentIntent: { paymentIntentId: string; clientSecret: string }; + try { - const paymentIntent = await createPaymentIntent({ + paymentIntent = await createPaymentIntent({ amount: totalCents, currency: order.currency, orderId: order.id, idempotencyKey, }); + } catch (error) { + logError('Checkout payment intent creation failed', error); + try { + await restockOrder(order.id, { reason: 'failed' }); + } catch (restockError) { + logError('Restoring stock after payment intent failure failed', restockError); + } + + return errorResponse('STRIPE_ERROR', 'Unable to initiate payment.', 502); + } + + // Stripe new order: Phase 2 attach PI (post-create => never 400) + try { const updatedOrder = await setOrderPaymentIntent({ orderId: order.id, paymentIntentId: paymentIntent.paymentIntentId, @@ -415,36 +433,34 @@ export async function POST(request: NextRequest) { status: 201, }); } catch (error) { - logError('Checkout payment intent creation failed', error); + logError('Checkout payment intent attach failed', error); - try { - await restockOrder(order.id, { reason: 'failed' }); - } catch (restockError) { - logError( - 'Restoring stock after payment intent failure failed', - restockError + if (error instanceof InvalidPayloadError) { + // Conflict/race/state issue. Do NOT return 400. + // Leave inventory reserved; retry with same idempotency key or janitor will sweep. + return errorResponse( + 'CHECKOUT_CONFLICT', + 'Order state conflict while attaching payment intent. Retry with the same Idempotency-Key.', + 409, + { orderId: order.id } ); } - if (error instanceof Error && error.message.startsWith('STRIPE_')) { - return errorResponse( - 'STRIPE_ERROR', - 'Unable to initiate payment.', - 400 - ); + // For non-conflict attach failures: best-effort release to avoid stock lock + try { + await restockOrder(order.id, { reason: 'failed' }); + } catch (restockError) { + logError('Restoring stock after payment intent attach failure failed', restockError); } if (error instanceof OrderStateInvalidError) { return errorResponse(error.code, error.message, 500, { orderId: error.orderId, + ...(error.details ? { details: error.details } : {}), }); } - return errorResponse( - 'INTERNAL_ERROR', - 'Unable to process checkout.', - 500 - ); + return errorResponse('INTERNAL_ERROR', 'Unable to process checkout.', 500); } } catch (error) { if (isExpectedBusinessError(error)) { @@ -457,11 +473,7 @@ export async function POST(request: NextRequest) { } if (error instanceof InvalidPayloadError) { - return errorResponse( - error.code, - error.message || 'Invalid checkout payload', - 400 - ); + return errorResponse(error.code, error.message || 'Invalid checkout payload', 400); } if (error instanceof InvalidVariantError) { @@ -512,4 +524,4 @@ export async function POST(request: NextRequest) { return errorResponse('INTERNAL_ERROR', 'Unable to process checkout.', 500); } -} +} \ No newline at end of file diff --git a/frontend/lib/psp/stripe.ts b/frontend/lib/psp/stripe.ts index 666c67d1..ce60a0c9 100644 --- a/frontend/lib/psp/stripe.ts +++ b/frontend/lib/psp/stripe.ts @@ -45,7 +45,7 @@ export async function createRefund({ } if (amountMinor !== undefined) { - if (!Number.isInteger(amountMinor) || amountMinor <= 0) { + if (!Number.isSafeInteger(amountMinor) || amountMinor <= 0) { throw new Error('STRIPE_INVALID_REFUND_AMOUNT'); } } @@ -97,7 +97,8 @@ export async function createPaymentIntent({ throw new Error('STRIPE_DISABLED'); } - if (!Number.isFinite(amount) || amount <= 0) { + // Stripe amount must be an integer in minor units. Fail-closed on floats/NaN/huge values. + if (!Number.isSafeInteger(amount) || amount <= 0) { throw new Error('STRIPE_INVALID_AMOUNT'); } diff --git a/frontend/lib/services/orders/checkout.ts b/frontend/lib/services/orders/checkout.ts index 788271ae..71ef7683 100644 --- a/frontend/lib/services/orders/checkout.ts +++ b/frontend/lib/services/orders/checkout.ts @@ -46,6 +46,9 @@ import { getOrderById, getOrderByIdempotencyKey } from './summary'; import { restockOrder } from './restock'; import { guardedPaymentStatusUpdate } from './payment-state'; +// NOTE: PaymentStatus semantics for Stripe: +// pending (no PI yet) -> requires_payment (PI attached) -> paid/failed/refunded via provider events. + async function reconcileNoPaymentOrder( orderId: string ): Promise { @@ -409,8 +412,10 @@ export async function createOrderWithItems({ // paymentStatus is initialized here only; ALL transitions must go via guardedPaymentStatusUpdate. // IMPORTANT: DB CHECK requires provider='none' => payment_status in ('paid','failed') + // Avoid the cycle: requires_payment -> pending -> requires_payment. + // For Stripe, start at pending and switch to requires_payment only after PI is attached. const initialPaymentStatus: PaymentStatus = - paymentProvider === 'none' ? 'paid' : 'requires_payment'; + paymentProvider === 'none' ? 'paid' : 'pending'; const normalizedItems = mergeCheckoutItems( items diff --git a/frontend/lib/services/orders/payment-intent.ts b/frontend/lib/services/orders/payment-intent.ts index e5b38421..0041e84f 100644 --- a/frontend/lib/services/orders/payment-intent.ts +++ b/frontend/lib/services/orders/payment-intent.ts @@ -32,6 +32,8 @@ export async function setOrderPaymentIntent({ ); } + // New flow: pending -> requires_payment when attaching PI. + // Keep requires_payment only for backward-compat (old orders created before this change). const allowed: PaymentStatus[] = ['pending', 'requires_payment']; if (!allowed.includes(existing.paymentStatus as PaymentStatus)) { throw new InvalidPayloadError( diff --git a/frontend/lib/services/orders/refund.ts b/frontend/lib/services/orders/refund.ts index dfc23d98..6ab57c0c 100644 --- a/frontend/lib/services/orders/refund.ts +++ b/frontend/lib/services/orders/refund.ts @@ -157,7 +157,8 @@ export async function refundOrder( idempotencyKey, }); - const createdAtIso = new Date().toISOString(); + const now = new Date(); + const createdAtIso = now.toISOString(); const nextMeta = appendRefund(order.pspMetadata, { refundId, @@ -172,7 +173,11 @@ export async function refundOrder( // Persist тільки metadata. payment_status НЕ чіпаємо (джерело істини — webhook) await db .update(orders) - .set({ pspMetadata: nextMeta }) + .set({ + updatedAt: now, + pspStatusReason: 'REFUND_REQUESTED', + pspMetadata: nextMeta, + }) .where(eq(orders.id, orderId)); // Повертаємо як і раніше: order summary для API diff --git a/frontend/lib/services/products/cart/rehydrate.ts b/frontend/lib/services/products/cart/rehydrate.ts index c7c9aa8d..a6657ec7 100644 --- a/frontend/lib/services/products/cart/rehydrate.ts +++ b/frontend/lib/services/products/cart/rehydrate.ts @@ -64,7 +64,7 @@ export async function rehydrateCartItems( const rehydratedItems: CartRehydrateItem[] = []; const removed: CartRemovedItem[] = []; - let totalCents = 0; + let totalMinor = 0; for (const item of items) { const product = productMap.get(item.productId); @@ -100,7 +100,7 @@ export async function rehydrateCartItems( MAX_QUANTITY_PER_LINE ); - let unitPriceCents: number; + let unitPriceMinor: number; if ( typeof product.priceMinor === 'number' && @@ -123,10 +123,10 @@ export async function rehydrateCartItems( }); } - unitPriceCents = product.priceMinor; + unitPriceMinor = product.priceMinor; } else { // Fallback to legacy money column (string/decimal), still validated via coercePriceFromDb - unitPriceCents = toCents( + unitPriceMinor = toCents( coercePriceFromDb(product.price, { field: 'price', productId: product.id, @@ -134,19 +134,19 @@ export async function rehydrateCartItems( ); } // Safety: regardless of source (canonical priceMinor or legacy price), - // unitPriceCents must be a positive safe integer in minor units. - if (!Number.isSafeInteger(unitPriceCents) || unitPriceCents < 1) { + // unitPriceMinor must be a positive safe integer in minor units. + if (!Number.isSafeInteger(unitPriceMinor) || unitPriceMinor < 1) { throw new PriceConfigError('Invalid price in DB (out of range).', { productId: product.id, currency, }); } - const lineTotalCents = calculateLineTotal( - unitPriceCents, + const lineTotalMinor = calculateLineTotal( + unitPriceMinor, effectiveQuantity ); - totalCents += lineTotalCents; + totalMinor += lineTotalMinor; rehydratedItems.push({ productId: product.id, @@ -155,11 +155,11 @@ export async function rehydrateCartItems( quantity: effectiveQuantity, // canonical: - unitPriceMinor: unitPriceCents, - lineTotalMinor: lineTotalCents, + unitPriceMinor: unitPriceMinor, + lineTotalMinor: lineTotalMinor, // display: - unitPrice: fromCents(unitPriceCents), - lineTotal: fromCents(lineTotalCents), + unitPrice: fromCents(unitPriceMinor), + lineTotal: fromCents(lineTotalMinor), // policy: items currency should match resolved currency currency, @@ -180,9 +180,9 @@ export async function rehydrateCartItems( // IMPORTANT: MINOR units (integer) summary: { // canonical: - totalAmountMinor: totalCents, + totalAmountMinor: totalMinor, // display: - totalAmount: fromCents(totalCents), + totalAmount: fromCents(totalMinor), itemCount, currency, }, diff --git a/frontend/lib/tests/checkout-set-payment-intent-reject-contract.test.ts b/frontend/lib/tests/checkout-set-payment-intent-reject-contract.test.ts new file mode 100644 index 00000000..f33a9498 --- /dev/null +++ b/frontend/lib/tests/checkout-set-payment-intent-reject-contract.test.ts @@ -0,0 +1,134 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { NextRequest } from 'next/server'; + +import { InvalidPayloadError } from '@/lib/services/errors'; + +// Force payments enabled so route goes into Stripe flow +vi.mock('@/lib/env/stripe', () => ({ + isPaymentsEnabled: () => true, +})); + +// Avoid auth coupling +vi.mock('@/lib/auth', () => ({ + getCurrentUser: vi.fn().mockResolvedValue(null), +})); + +// Stripe: PI creation succeeds +vi.mock('@/lib/psp/stripe', () => ({ + createPaymentIntent: vi.fn(async () => ({ + paymentIntentId: 'pi_test_attach_reject', + clientSecret: 'cs_test_attach_reject', + })), + retrievePaymentIntent: vi.fn(), +})); + +// Mock order services +vi.mock('@/lib/services/orders', async () => { + const actual = await vi.importActual('@/lib/services/orders'); + return { + ...actual, + createOrderWithItems: vi.fn(), + setOrderPaymentIntent: vi.fn(), + restockOrder: vi.fn(), + }; +}); + +import { POST } from '@/app/api/shop/checkout/route'; +import { + createOrderWithItems, + setOrderPaymentIntent, + restockOrder, +} from '@/lib/services/orders'; + +type MockedFn = ReturnType; + +function makeReq(idempotencyKey: string) { + return new NextRequest('http://localhost/api/shop/checkout', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Idempotency-Key': idempotencyKey, + 'Accept-Language': 'en', + }, + body: JSON.stringify({ + items: [ + { + // Must be UUID to satisfy validation schema (avoid accidental 400). + productId: '11111111-1111-4111-8111-111111111111', + quantity: 1, + }, + ], + }), + }); +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('checkout: setOrderPaymentIntent rejection after order creation must not be 400', () => { + it('new order (isNew=true): attach rejection returns 409 CHECKOUT_CONFLICT (not 400)', async () => { + const co = createOrderWithItems as unknown as MockedFn; + const setPI = setOrderPaymentIntent as unknown as MockedFn; + const restock = restockOrder as unknown as MockedFn; + + co.mockResolvedValueOnce({ + order: { + id: 'order_test_new_attach_reject', + currency: 'USD', + totalAmount: 10, + paymentStatus: 'pending', + paymentProvider: 'stripe', + paymentIntentId: null, + }, + isNew: true, + totalCents: 1000, + }); + + setPI.mockRejectedValueOnce( + new InvalidPayloadError('Order cannot accept a payment intent from the current status.') + ); + + const res = await POST(makeReq('idem_key_test_new_attach_reject_0001')); + + expect(res.status).toBe(409); + + const json = await res.json(); + expect(json.code).toBe('CHECKOUT_CONFLICT'); + + // Policy: conflict should not trigger immediate restock here. + expect(restock).not.toHaveBeenCalled(); + }); + + it('existing order (isNew=false, no PI): attach rejection returns 409 CHECKOUT_CONFLICT (not 400)', async () => { + const co = createOrderWithItems as unknown as MockedFn; + const setPI = setOrderPaymentIntent as unknown as MockedFn; + const restock = restockOrder as unknown as MockedFn; + + co.mockResolvedValueOnce({ + order: { + id: 'order_test_existing_attach_reject', + currency: 'USD', + totalAmount: 10, + paymentStatus: 'pending', + paymentProvider: 'stripe', + paymentIntentId: null, + }, + isNew: false, + totalCents: 1000, + }); + + setPI.mockRejectedValueOnce( + new InvalidPayloadError('Order cannot accept a payment intent from the current status.') + ); + + const res = await POST(makeReq('idem_key_test_existing_attach_reject_0001')); + + expect(res.status).toBe(409); + + const json = await res.json(); + expect(json.code).toBe('CHECKOUT_CONFLICT'); + + expect(restock).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/lib/tests/checkout-stripe-error-contract.test.ts b/frontend/lib/tests/checkout-stripe-error-contract.test.ts new file mode 100644 index 00000000..70fb4762 --- /dev/null +++ b/frontend/lib/tests/checkout-stripe-error-contract.test.ts @@ -0,0 +1,112 @@ +// frontend/lib/tests/checkout-stripe-error-contract.test.ts +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { NextRequest } from 'next/server'; + +// 1) force payments enabled so route goes into Stripe flow +vi.mock('@/lib/env/stripe', () => ({ + isPaymentsEnabled: () => true, +})); + +// 2) avoid auth coupling +vi.mock('@/lib/auth', () => ({ + getCurrentUser: vi.fn().mockResolvedValue(null), +})); + +// 3) force Stripe PI creation to fail AFTER "DB writes" (simulated by createOrderWithItems resolving) +vi.mock('@/lib/psp/stripe', () => ({ + createPaymentIntent: vi.fn(async () => { + throw new Error('STRIPE_TEST_DOWN'); + }), + retrievePaymentIntent: vi.fn(), +})); + +// 4) mock orders services so we don't depend on DB schema/seed here +vi.mock('@/lib/services/orders', async () => { + const actual = await vi.importActual('@/lib/services/orders'); + return { + ...actual, + createOrderWithItems: vi.fn(), + setOrderPaymentIntent: vi.fn(), + restockOrder: vi.fn(), + }; +}); + +import { POST } from '@/app/api/shop/checkout/route'; +import { createOrderWithItems } from '@/lib/services/orders'; + +type MockedFn = ReturnType; + +function makeReq(idempotencyKey: string) { + return new NextRequest('http://localhost/api/shop/checkout', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Idempotency-Key': idempotencyKey, + 'Accept-Language': 'en', + }, + body: JSON.stringify({ + items: [ + { + productId: '11111111-1111-4111-8111-111111111111', + quantity: 1, + selectedSize: '', + selectedColor: '', + }, + ], + }), + }); +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('checkout: Stripe errors after order creation must not be 400', () => { + it('new order (isNew=true): Stripe PI creation failure returns 502 STRIPE_ERROR', async () => { + const co = createOrderWithItems as unknown as MockedFn; + + co.mockResolvedValueOnce({ + order: { + id: 'order_test_new', + currency: 'USD', + totalAmount: 10, + paymentStatus: 'pending', + paymentProvider: 'stripe', + paymentIntentId: null, + }, + isNew: true, + totalCents: 1000, + }); + + const res = await POST(makeReq('idem_key_test_new_0001')); + expect(res.status).toBe(502); + + const json = await res.json(); + expect(json.code).toBe('STRIPE_ERROR'); + expect(typeof json.message).toBe('string'); + }); + + it('existing order (isNew=false, no PI): Stripe PI creation failure returns 502 STRIPE_ERROR', async () => { + const co = createOrderWithItems as unknown as MockedFn; + + co.mockResolvedValueOnce({ + order: { + id: 'order_test_existing', + currency: 'USD', + totalAmount: 10, + paymentStatus: 'pending', + paymentProvider: 'stripe', + paymentIntentId: null, + }, + isNew: false, + totalCents: 1000, + }); + + const res = await POST(makeReq('idem_key_test_existing_0001')); + expect(res.status).toBe(502); + + const json = await res.json(); + expect(json.code).toBe('STRIPE_ERROR'); + expect(typeof json.message).toBe('string'); + }); +}); From 0456d5b495c27157b6d56d98eaf9ea5d22a082ae Mon Sep 17 00:00:00 2001 From: liudmylasovetovs Date: Wed, 14 Jan 2026 18:06:44 -0800 Subject: [PATCH 4/8] (SP: 1) [Frontend] Fix shop catalog pagination: client-side Load more appends items without navigation + canonicalize ?page= under locale --- frontend/app/[locale]/shop/products/page.tsx | 52 +++---- frontend/app/api/shop/catalog/route.ts | 39 ++++++ .../components/shop/catalog-load-more.tsx | 38 ++--- .../shop/catalog-products-client.tsx | 132 ++++++++++++++++++ 4 files changed, 211 insertions(+), 50 deletions(-) create mode 100644 frontend/app/api/shop/catalog/route.ts create mode 100644 frontend/components/shop/catalog-products-client.tsx diff --git a/frontend/app/[locale]/shop/products/page.tsx b/frontend/app/[locale]/shop/products/page.tsx index 0e5f0fd1..e2f4e22f 100644 --- a/frontend/app/[locale]/shop/products/page.tsx +++ b/frontend/app/[locale]/shop/products/page.tsx @@ -1,14 +1,13 @@ import { Suspense } from 'react'; import { Filter } from 'lucide-react'; -import { ProductCard } from '@/components/shop/product-card'; import { ProductFilters } from '@/components/shop/product-filters'; import { ProductSort } from '@/components/shop/product-sort'; -import { CatalogLoadMore } from '@/components/shop/catalog-load-more'; -// import { Pagination } from '@/components/q&a/Pagination'; +import { CatalogProductsClient } from '@/components/shop/catalog-products-client'; import { getCatalogProducts } from '@/lib/shop/data'; import { catalogQuerySchema } from '@/lib/validation/shop'; import { CATALOG_PAGE_SIZE } from '@/lib/config/catalog'; +import { redirect } from 'next/navigation'; type RawSearchParams = { category?: string; @@ -19,7 +18,6 @@ type RawSearchParams = { page?: string; }; - interface ProductsPageProps { searchParams: Promise; } @@ -30,13 +28,35 @@ export default async function ProductsPage({ }: ProductsPageProps & { params: Promise<{ locale: string }> }) { const { locale } = await params; const resolvedSearchParams = (await searchParams) ?? {}; + // canonicalize: infinite-load page should not be shareable as ?page=N + if (resolvedSearchParams.page) { + const qsParams = new URLSearchParams(); + + for (const [k, v] of Object.entries(resolvedSearchParams)) { + if (!v) continue; + if (k === 'page') continue; + qsParams.set(k, v); + } + + const qs = qsParams.toString(); + const basePath = `/${locale}/shop/products`; + + redirect(qs ? `${basePath}?${qs}` : basePath); + } const parsedParams = catalogQuerySchema.safeParse(resolvedSearchParams); - const filters = parsedParams.success + const parsed = parsedParams.success ? parsedParams.data : { page: 1, limit: CATALOG_PAGE_SIZE }; + // Для “Load more” UX: починаємо завжди з 1-ї сторінки (URL ?page=... ігноруємо). + const filters = { + ...parsed, + page: 1, + limit: parsed.limit ?? CATALOG_PAGE_SIZE, + }; + const catalog = await getCatalogProducts(filters, locale); return ( @@ -74,27 +94,7 @@ export default async function ProductsPage({

) : ( - <> -
- {catalog.products.map(product => ( - - ))} -
- -
- - {/* {!isLoading && totalPages > 1 && ( - - )} */} -
- + )} diff --git a/frontend/app/api/shop/catalog/route.ts b/frontend/app/api/shop/catalog/route.ts new file mode 100644 index 00000000..66fc37a9 --- /dev/null +++ b/frontend/app/api/shop/catalog/route.ts @@ -0,0 +1,39 @@ +import { NextRequest, NextResponse } from 'next/server'; + +import { getCatalogProducts } from '@/lib/shop/data'; +import { catalogQuerySchema } from '@/lib/validation/shop'; +import { CATALOG_PAGE_SIZE } from '@/lib/config/catalog'; + +export const dynamic = 'force-dynamic'; +export const revalidate = 0; + +type RawSearchParams = { + category?: string; + type?: string; + color?: string; + size?: string; + sort?: string; + page?: string; + limit?: string; + locale?: string; +}; + +export async function GET(req: NextRequest) { + const url = new URL(req.url); + const raw = Object.fromEntries(url.searchParams.entries()) as RawSearchParams; + + const { locale, ...rest } = raw; + const effectiveLocale = locale ?? 'en'; + + const parsed = catalogQuerySchema.safeParse(rest); + + const filters = parsed.success + ? parsed.data + : { page: 1, limit: CATALOG_PAGE_SIZE }; + + const catalog = await getCatalogProducts(filters, effectiveLocale); + + return NextResponse.json(catalog, { + headers: { 'Cache-Control': 'no-store' }, + }); +} diff --git a/frontend/components/shop/catalog-load-more.tsx b/frontend/components/shop/catalog-load-more.tsx index 6ad28caa..b4f1f01f 100644 --- a/frontend/components/shop/catalog-load-more.tsx +++ b/frontend/components/shop/catalog-load-more.tsx @@ -1,35 +1,25 @@ -"use client" - -import { useRouter, useSearchParams } from "next/navigation" -import { useTransition } from "react" +'use client'; interface CatalogLoadMoreProps { - hasMore: boolean - nextPage: number + hasMore: boolean; + isLoading: boolean; + onLoadMore: () => void; } -export function CatalogLoadMore({ hasMore, nextPage }: CatalogLoadMoreProps) { - const router = useRouter() - const searchParams = useSearchParams() - const [isPending, startTransition] = useTransition() - - if (!hasMore) return null - - const handleClick = () => { - startTransition(() => { - const params = new URLSearchParams(searchParams?.toString()) - params.set("page", nextPage.toString()) - router.push(`/shop/products?${params.toString()}`) - }) - } +export function CatalogLoadMore({ + hasMore, + isLoading, + onLoadMore, +}: CatalogLoadMoreProps) { + if (!hasMore) return null; return ( - ) + ); } diff --git a/frontend/components/shop/catalog-products-client.tsx b/frontend/components/shop/catalog-products-client.tsx new file mode 100644 index 00000000..b8df5200 --- /dev/null +++ b/frontend/components/shop/catalog-products-client.tsx @@ -0,0 +1,132 @@ +'use client'; + +import React from 'react'; +import { useSearchParams, type ReadonlyURLSearchParams } from 'next/navigation'; + +import { ProductCard } from '@/components/shop/product-card'; +import { CatalogLoadMore } from '@/components/shop/catalog-load-more'; + +type Product = React.ComponentProps['product'] & { + id: string; +}; + +type CatalogPayload = { + products: Product[]; + hasMore: boolean; + page: number; +}; + +function stripPageParam(sp: ReadonlyURLSearchParams | null): string { + const p = new URLSearchParams(sp?.toString() ?? ''); + p.delete('page'); + return p.toString(); +} + +export function CatalogProductsClient({ + locale, + initialCatalog, +}: { + locale: string; + initialCatalog: CatalogPayload; +}) { + const searchParams = useSearchParams(); + + const baseQuery = React.useMemo( + () => stripPageParam(searchParams), + [searchParams] + ); + + const [products, setProducts] = React.useState( + initialCatalog.products + ); + const [page, setPage] = React.useState(initialCatalog.page); + const [hasMore, setHasMore] = React.useState(initialCatalog.hasMore); + const [isLoadingMore, setIsLoadingMore] = React.useState(false); + const [error, setError] = React.useState(null); + + const activeQueryRef = React.useRef(`${baseQuery}|l=${locale}`); + + React.useEffect(() => { + activeQueryRef.current = `${baseQuery}|l=${locale}`; + setProducts(initialCatalog.products); + setPage(initialCatalog.page); + setHasMore(initialCatalog.hasMore); + setIsLoadingMore(false); + setError(null); + }, [ + baseQuery, + locale, + initialCatalog.products, + initialCatalog.page, + initialCatalog.hasMore, + ]); + + const onLoadMore = async () => { + if (!hasMore || isLoadingMore) return; + + setIsLoadingMore(true); + setError(null); + + const nextPage = page + 1; + + const query = new URLSearchParams(baseQuery); + query.set('page', String(nextPage)); + + const requestQueryKey = `${baseQuery}|l=${locale}`; + activeQueryRef.current = requestQueryKey; + query.set('locale', locale); + + try { + const res = await fetch(`/api/shop/catalog?${query.toString()}`, { + method: 'GET', + cache: 'no-store', + }); + + if (!res.ok) { + setError(`Failed to load more (HTTP ${res.status})`); + return; + } + + const data = (await res.json()) as CatalogPayload; + + // якщо фільтри/сорт змінились під час запиту — ігноруємо відповідь + if (activeQueryRef.current !== requestQueryKey) return; + + setProducts(prev => { + const seen = new Set(prev.map(p => p.id)); + const appended = data.products.filter(p => !seen.has(p.id)); + return [...prev, ...appended]; + }); + + setPage(data.page); + setHasMore(data.hasMore); + } catch { + setError('Failed to load more'); + } finally { + if (activeQueryRef.current === requestQueryKey) { + setIsLoadingMore(false); + } + } + }; + + return ( + <> +
+ {products.map(p => ( + + ))} +
+ +
+ + {error ? ( +

{error}

+ ) : null} +
+ + ); +} From 58bca6da9487249ff0f945e9898fc7af7946d3ee Mon Sep 17 00:00:00 2001 From: liudmylasovetovs Date: Wed, 14 Jan 2026 18:49:17 -0800 Subject: [PATCH 5/8] (SP: 1) [UI] Move shop theme tokens into global scoped .shop-scope overrides (remove shop-theme.css) --- frontend/app/[locale]/shop/layout.tsx | 3 +- frontend/app/[locale]/shop/shop-theme.css | 30 -------- frontend/app/globals.css | 37 +++++++++ frontend/project-structure.txt | 94 +++++++++++++---------- 4 files changed, 90 insertions(+), 74 deletions(-) delete mode 100644 frontend/app/[locale]/shop/shop-theme.css diff --git a/frontend/app/[locale]/shop/layout.tsx b/frontend/app/[locale]/shop/layout.tsx index 0315ed4e..9defb980 100644 --- a/frontend/app/[locale]/shop/layout.tsx +++ b/frontend/app/[locale]/shop/layout.tsx @@ -1,6 +1,5 @@ import type React from 'react'; -import './shop-theme.css'; export default function ShopLayout({ children }: { children: React.ReactNode }) { return
{children}
; -} +} \ No newline at end of file diff --git a/frontend/app/[locale]/shop/shop-theme.css b/frontend/app/[locale]/shop/shop-theme.css deleted file mode 100644 index 78923b73..00000000 --- a/frontend/app/[locale]/shop/shop-theme.css +++ /dev/null @@ -1,30 +0,0 @@ -.shop-scope { - --radius: 0.5rem; - --background: #ffffff; - --foreground: #111111; - --card: #ffffff; - --primary: #111111; - --secondary: #f5f5f5; - --muted: #f5f5f5; - --accent: #111111; - --accent-foreground: #ffffff; - --destructive: oklch(0.577 0.245 27.325); - --border: #e5e5e5; - --input: #e5e5e5; - --ring: #111111; -} - -.dark .shop-scope { - --background: #0a0a0a; - --foreground: #ffffff; - --card: #0a0a0a; - --primary: #ffffff; - --secondary: #1c1c1e; - --muted: #1c1c1e; - --accent: #ff2d55; - --accent-foreground: #ffffff; - --destructive: oklch(0.396 0.141 25.723); - --border: #333333; - --input: #333333; - --ring: #ff2d55; -} diff --git a/frontend/app/globals.css b/frontend/app/globals.css index b25f8169..1577945b 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -160,3 +160,40 @@ transform: scale(1.08); } } + + +/* Shop theme: scoped overrides (must not affect platform pages) */ +.shop-scope { + /* keep shop rounding slightly tighter than platform */ + --radius: calc(var(--radius) - 2px); + + /* light: shop accent = black */ + --accent: var(--foreground); + --accent-foreground: var(--background); + --ring: var(--foreground); + + /* IMPORTANT: override Tailwind v4 theme vars directly */ + --color-accent: var(--foreground); + --color-accent-foreground: var(--background); + --color-ring: var(--foreground); + + --card: var(--background); +} + +.dark .shop-scope { + /* dark: shop accent = magenta */ + --accent: var(--accent-primary); + --accent-foreground: var(--foreground); + --ring: var(--accent-primary); + + /* IMPORTANT: override Tailwind v4 theme vars directly */ + --color-accent: var(--accent-primary); + --color-accent-foreground: var(--foreground); + --color-ring: var(--accent-primary); + + --card: var(--background); + + /* keep borders closer to previous shop look, derived (no hex) */ + --border: color-mix(in oklab, var(--foreground) 18%, var(--background)); + --input: color-mix(in oklab, var(--foreground) 18%, var(--background)); +} diff --git a/frontend/project-structure.txt b/frontend/project-structure.txt index 9b874910..b647ee6c 100644 --- a/frontend/project-structure.txt +++ b/frontend/project-structure.txt @@ -60,6 +60,8 @@ 📁 cart 📁 rehydrate 📄 route.ts + 📁 catalog + 📄 route.ts 📁 checkout 📄 route.ts 📁 internal @@ -216,6 +218,7 @@ 📄 admin-product-status-toggle.tsx 📄 cart-provider.tsx 📄 catalog-load-more.tsx + 📄 catalog-products-client.tsx 📄 category-tile.tsx 📁 header 📄 cart-button.tsx @@ -244,6 +247,50 @@ 📄 category.ts 📁 db 📄 index.ts + 📁 legacy-migrations + 📁 drizzle_legacy + 📄 0000_rich_magus.sql + 📄 0001_black_random.sql + 📄 0002_yielding_purple_man.sql + 📄 0003_handy_cammi.sql + 📄 0004_tough_ultron.sql + 📄 0005_furry_warstar.sql + 📄 0006_minor_units_money.sql + 📄 0007_add-payment-intent-id-to-orders.sql + 📄 0008_dizzy_james_howlett.sql + 📄 0009_p0_inventory_workflow_baseline.sql + 📄 0009_unknown_nico_minoru.sql + 📄 0010_parallel_princess_powerful.sql + 📄 0011_add_orders_sweep_claim_index.sql + 📄 0012_inventory_moves_product_fk_restrict.sql + 📄 0013_add_internal_job_state.sql + 📄 0013_brown_gamora.sql + 📄 0013_low_roughhouse.sql + 📄 0014_add-stripe-events-processed-at.sql + 📄 0014_dapper_kang.sql + 📄 0014_steep_kabuki.sql + 📄 0015_dear_legion.sql + 📄 0015_glamorous_eternity.sql + 📄 0015_warm_dexter_bennett.sql + 📁 meta + 📄 0000_snapshot.json + 📄 0001_snapshot.json + 📄 0002_snapshot.json + 📄 0003_snapshot.json + 📄 0004_snapshot.json + 📄 0005_snapshot.json + 📄 0006_snapshot.json + 📄 0007_snapshot.json + 📄 0008_snapshot.json + 📄 0009_snapshot.json + 📄 0010_snapshot.json + 📄 0012_snapshot.json + 📄 0013_snapshot.json + 📄 0014_snapshot.json + 📄 0015_snapshot.json + 📄 _journal.json + 📄 relations.ts + 📄 schema.ts 📁 queries 📄 leaderboard.ts 📄 points.ts @@ -289,48 +336,10 @@ 📁 payments 📄 fondy.md 📁 drizzle - 📄 0000_rich_magus.sql - 📄 0001_black_random.sql - 📄 0002_yielding_purple_man.sql - 📄 0003_handy_cammi.sql - 📄 0004_tough_ultron.sql - 📄 0005_furry_warstar.sql - 📄 0006_minor_units_money.sql - 📄 0007_add-payment-intent-id-to-orders.sql - 📄 0008_dizzy_james_howlett.sql - 📄 0009_p0_inventory_workflow_baseline.sql - 📄 0009_unknown_nico_minoru.sql - 📄 0010_parallel_princess_powerful.sql - 📄 0011_add_orders_sweep_claim_index.sql - 📄 0012_inventory_moves_product_fk_restrict.sql - 📄 0013_add_internal_job_state.sql - 📄 0013_brown_gamora.sql - 📄 0013_low_roughhouse.sql - 📄 0014_add-stripe-events-processed-at.sql - 📄 0014_dapper_kang.sql - 📄 0014_steep_kabuki.sql - 📄 0015_dear_legion.sql - 📄 0015_glamorous_eternity.sql - 📄 0015_warm_dexter_bennett.sql + 📄 0000_dry_young_avengers.sql 📁 meta 📄 0000_snapshot.json - 📄 0001_snapshot.json - 📄 0002_snapshot.json - 📄 0003_snapshot.json - 📄 0004_snapshot.json - 📄 0005_snapshot.json - 📄 0006_snapshot.json - 📄 0007_snapshot.json - 📄 0008_snapshot.json - 📄 0009_snapshot.json - 📄 0010_snapshot.json - 📄 0012_snapshot.json - 📄 0013_snapshot.json - 📄 0014_snapshot.json - 📄 0015_snapshot.json 📄 _journal.json - 📄 relations.ts - 📄 schema.ts 📄 drizzle.config.ts 📄 eslint.config.mjs 📁 hooks @@ -422,6 +431,8 @@ 📄 checkout-concurrency-stock1.test.ts 📄 checkout-currency-policy.test.ts 📄 checkout-no-payments.test.ts + 📄 checkout-set-payment-intent-reject-contract.test.ts + 📄 checkout-stripe-error-contract.test.ts 📄 currency.test.ts 📄 format-money.test.ts 📄 order-items-snapshot-immutable.test.ts @@ -434,6 +445,7 @@ 📄 product-sale-invariant.test.ts 📄 public-product-visibility.test.ts 📄 restock-order-only-once.test.ts + 📄 restock-release-failure-invariant.test.ts 📄 restock-stale-claim-gate.test.ts 📄 restock-stale-stripe-orphan.test.ts 📄 restock-stuck-reserving-sweep.test.ts @@ -485,9 +497,7 @@ 📄 README.md 📄 save-structure.cjs 📁 scripts - 📄 create-user.ts - 📄 debug-user.ts 📄 shop-janitor-restock-stale.mjs - 📄 verify-user.ts 📄 tsconfig.json +📄 tsconfig.tsbuildinfo 📄 vitest.config.ts \ No newline at end of file From 613e346258901ee4c6f2c8c4612fd408e2cd4664 Mon Sep 17 00:00:00 2001 From: liudmylasovetovs Date: Wed, 14 Jan 2026 19:31:35 -0800 Subject: [PATCH 6/8] (SP: 1) [Shop/Admin] Restore AdminTopbar container padding after layout removal --- frontend/app/[locale]/shop/admin/layout.tsx | 66 ---- .../[locale]/shop/admin/orders/[id]/page.tsx | 330 +++++++++--------- .../app/[locale]/shop/admin/orders/page.tsx | 193 +++++----- frontend/app/[locale]/shop/admin/page.tsx | 62 ++-- .../shop/admin/products/[id]/edit/page.tsx | 46 +-- .../[locale]/shop/admin/products/new/page.tsx | 15 +- .../app/[locale]/shop/admin/products/page.tsx | 298 ++++++++-------- frontend/app/[locale]/shop/layout.tsx | 5 - .../shop/admin/shop-admin-topbar.tsx | 43 +++ frontend/lib/auth/guard-shop-admin-page.ts | 18 + 10 files changed, 575 insertions(+), 501 deletions(-) delete mode 100644 frontend/app/[locale]/shop/admin/layout.tsx delete mode 100644 frontend/app/[locale]/shop/layout.tsx create mode 100644 frontend/components/shop/admin/shop-admin-topbar.tsx create mode 100644 frontend/lib/auth/guard-shop-admin-page.ts diff --git a/frontend/app/[locale]/shop/admin/layout.tsx b/frontend/app/[locale]/shop/admin/layout.tsx deleted file mode 100644 index b1748c79..00000000 --- a/frontend/app/[locale]/shop/admin/layout.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import type React from 'react'; -import { Link } from '@/i18n/routing'; - -import { notFound, redirect } from 'next/navigation'; - -import { - AdminApiDisabledError, - AdminForbiddenError, - AdminUnauthorizedError, - requireAdminPage, -} from '@/lib/auth/admin'; - -export default async function ShopAdminLayout({ - children, -}: { - children: React.ReactNode; -}) { - try { - await requireAdminPage(); - } catch (err) { - if (err instanceof AdminApiDisabledError) notFound(); - if (err instanceof AdminUnauthorizedError) redirect('/login'); - if (err instanceof AdminForbiddenError) notFound(); - - throw err; - } - - return ( - <> -
-
-
- - Admin - - / - - Products - - - Orders - -
- - - Back to shop - -
-
- - {children} - - ); -} diff --git a/frontend/app/[locale]/shop/admin/orders/[id]/page.tsx b/frontend/app/[locale]/shop/admin/orders/[id]/page.tsx index 23af1bd1..ccb5f385 100644 --- a/frontend/app/[locale]/shop/admin/orders/[id]/page.tsx +++ b/frontend/app/[locale]/shop/admin/orders/[id]/page.tsx @@ -9,6 +9,8 @@ import { type CurrencyCode, } from '@/lib/shop/currency'; import { fromDbMoney } from '@/lib/shop/money'; +import { ShopAdminTopbar } from '@/components/shop/admin/shop-admin-topbar'; +import { guardShopAdminPage } from '@/lib/auth/guard-shop-admin-page'; export const dynamic = 'force-dynamic'; @@ -36,6 +38,7 @@ export default async function AdminOrderDetailPage({ }: { params: Promise<{ locale: string; id: string }>; }) { + await guardShopAdminPage(); const { locale, id } = await params; const order = await getAdminOrderDetail(id); if (!order) notFound(); @@ -46,180 +49,187 @@ export default async function AdminOrderDetailPage({ !!order.paymentIntentId; return ( -
-
-
-

Order

-

- {order.id} -

-
- -
- - Back - - - -
-
- -
-
-
Summary
-
-
-
Payment status
-
{order.paymentStatus}
-
- -
-
Total
-
- {(() => { - const c = orderCurrency(order, locale); - const totalMinor = pickMinor( - order?.totalAmountMinor, - order?.totalAmount - ); - return totalMinor === null - ? '-' - : formatMoney(totalMinor, c, locale); - })()} -
-
- -
-
Provider
-
{order.paymentProvider}
-
- -
-
Payment intent
-
- {order.paymentIntentId ?? '-'} -
-
+ <> + +
+
+
+

Order

+

+ {order.id} +

+
-
-
Idempotency key
-
- {order.idempotencyKey} -
-
-
-
+
+ + Back + -
-
- Stock / timestamps +
-
-
-
Created
-
- {formatDateTime(order.createdAt)} -
-
-
-
Updated
-
- {formatDateTime(order.updatedAt)} -
-
-
-
Stock restored
-
- {order.stockRestored ? 'Yes' : 'No'} -
-
-
-
Restocked at
-
- {formatDateTime(order.restockedAt)} -
-
-
-
-
- - - - - - - - - - - - {order.items.map(item => ( - - - - - - + + + +
+
Provider
+
{order.paymentProvider}
+
+ +
+
Payment intent
+
+ {order.paymentIntentId ?? '-'} +
+
+ +
+
Idempotency key
+
+ {order.idempotencyKey} +
+
+ + - - - ))} +
+
+ Stock / timestamps +
+
+
+
Created
+
+ {formatDateTime(order.createdAt)} +
+
+
+
Updated
+
+ {formatDateTime(order.updatedAt)} +
+
+
+
Stock restored
+
+ {order.stockRestored ? 'Yes' : 'No'} +
+
+
+
Restocked at
+
+ {formatDateTime(order.restockedAt)} +
+
+
+
+ - {order.items.length === 0 ? ( +
+
- Product - - Qty - - Unit - - Line total -
-
- {item.productTitle ?? '-'} -
-
- {item.productSlug ?? '-'} - {item.productSku ? · {item.productSku} : null} -
-
- {item.quantity} - +
+
+
Summary
+
+
+
Payment status
+
{order.paymentStatus}
+
+ +
+
Total
+
{(() => { const c = orderCurrency(order, locale); - const unitMinor = pickMinor( - item?.unitPriceMinor, - item?.unitPrice + const totalMinor = pickMinor( + order?.totalAmountMinor, + order?.totalAmount ); - return unitMinor === null + return totalMinor === null ? '-' - : formatMoney(unitMinor, c, locale); + : formatMoney(totalMinor, c, locale); })()} -
- {(() => { - const c = orderCurrency(order, locale); - const lineMinor = pickMinor( - item?.lineTotalMinor, - item?.lineTotal - ); - return lineMinor === null - ? '-' - : formatMoney(lineMinor, c, locale); - })()} -
+ - + + + + - ) : null} - -
- No items found for this order. - + Product + + Qty + + Unit + + Line total +
+ + + + {order.items.map(item => ( + + +
+ {item.productTitle ?? '-'} +
+
+ + {item.productSlug ?? '-'} + + {item.productSku ? ( + · {item.productSku} + ) : null} +
+ + + + {item.quantity} + + + + {(() => { + const c = orderCurrency(order, locale); + const unitMinor = pickMinor( + item?.unitPriceMinor, + item?.unitPrice + ); + return unitMinor === null + ? '-' + : formatMoney(unitMinor, c, locale); + })()} + + + + {(() => { + const c = orderCurrency(order, locale); + const lineMinor = pickMinor( + item?.lineTotalMinor, + item?.lineTotal + ); + return lineMinor === null + ? '-' + : formatMoney(lineMinor, c, locale); + })()} + + + ))} + + {order.items.length === 0 ? ( + + + No items found for this order. + + + ) : null} + + +
-
+ ); } diff --git a/frontend/app/[locale]/shop/admin/orders/page.tsx b/frontend/app/[locale]/shop/admin/orders/page.tsx index 014d703f..3f37bde6 100644 --- a/frontend/app/[locale]/shop/admin/orders/page.tsx +++ b/frontend/app/[locale]/shop/admin/orders/page.tsx @@ -1,13 +1,19 @@ import { Link } from '@/i18n/routing'; -import { getAdminOrdersPage } from "@/db/queries/shop/admin-orders"; -import { formatMoney, resolveCurrencyFromLocale, type CurrencyCode } from "@/lib/shop/currency"; -import { fromDbMoney } from "@/lib/shop/money"; - -export const dynamic = "force-dynamic"; +import { getAdminOrdersPage } from '@/db/queries/shop/admin-orders'; +import { + formatMoney, + resolveCurrencyFromLocale, + type CurrencyCode, +} from '@/lib/shop/currency'; +import { fromDbMoney } from '@/lib/shop/money'; +import { ShopAdminTopbar } from '@/components/shop/admin/shop-admin-topbar'; +import { guardShopAdminPage } from '@/lib/auth/guard-shop-admin-page'; + +export const dynamic = 'force-dynamic'; function pickMinor(minor: unknown, legacyMajor: unknown): number | null { - if (typeof minor === "number") return minor; + if (typeof minor === 'number') return minor; if (legacyMajor === null || legacyMajor === undefined) return null; return fromDbMoney(legacyMajor); } @@ -17,7 +23,7 @@ function orderCurrency(order: any, locale: string): CurrencyCode { } function formatDate(value: Date | null | undefined) { - if (!value) return "-"; + if (!value) return '-'; return value.toLocaleDateString(); } @@ -26,83 +32,114 @@ export default async function AdminOrdersPage({ }: { params: Promise<{ locale: string }>; }) { + await guardShopAdminPage(); const { locale } = await params; const { items } = await getAdminOrdersPage({ limit: 50, offset: 0 }); return ( -
-
-

Admin · Orders

- -
- -
-
- -
- - - - - - - - - - - - - - - {items.map((order) => ( - - - - - - - - - - - - - - - ))} - - {items.length === 0 ? ( + <> + +
+
+

Admin · Orders

+ +
+ + +
+ +
+
CreatedStatusTotalItemsProviderOrder IDActions
{formatDate(order.createdAt)} - - {order.paymentStatus} - - - {(() => { - const c = orderCurrency(order, locale); - const totalMinor = pickMinor(order?.totalAmountMinor, order?.totalAmount); - return totalMinor === null ? "-" : formatMoney(totalMinor, c, locale); - })()} - {order.itemCount}{order.paymentProvider}{order.id} - - View - -
+ - + + + + + + + - ) : null} - -
- No orders yet. - + Created + + Status + + Total + + Items + + Provider + + Order ID + + Actions +
+ + + + {items.map(order => ( + + + {formatDate(order.createdAt)} + + + + + {order.paymentStatus} + + + + + {(() => { + const c = orderCurrency(order, locale); + const totalMinor = pickMinor( + order?.totalAmountMinor, + order?.totalAmount + ); + return totalMinor === null + ? '-' + : formatMoney(totalMinor, c, locale); + })()} + + + + {order.itemCount} + + + {order.paymentProvider} + + + + {order.id} + + + + + View + + + + ))} + + {items.length === 0 ? ( + + + No orders yet. + + + ) : null} + + +
- + ); } diff --git a/frontend/app/[locale]/shop/admin/page.tsx b/frontend/app/[locale]/shop/admin/page.tsx index b9d3000d..aa508c77 100644 --- a/frontend/app/[locale]/shop/admin/page.tsx +++ b/frontend/app/[locale]/shop/admin/page.tsx @@ -1,32 +1,44 @@ import { Link } from '@/i18n/routing'; +import { ShopAdminTopbar } from '@/components/shop/admin/shop-admin-topbar'; +import { guardShopAdminPage } from '@/lib/auth/guard-shop-admin-page'; - -export default function ShopAdminHomePage() { +export default async function ShopAdminHomePage() { + await guardShopAdminPage(); return ( -
- -

Shop Admin

-

- Administrative tools for the merch shop. -

+ <> + +
+

Shop Admin

+

+ Administrative tools for the merch shop. +

-
- -
Products
-
Create, edit, activate, feature.
- +
+ +
+ Products +
+
+ Create, edit, activate, feature. +
+ - -
Orders
-
Review and manage orders.
- + +
+ Orders +
+
+ Review and manage orders. +
+ +
-
- ) + + ); } diff --git a/frontend/app/[locale]/shop/admin/products/[id]/edit/page.tsx b/frontend/app/[locale]/shop/admin/products/[id]/edit/page.tsx index 2c5b0ae2..686f1ab2 100644 --- a/frontend/app/[locale]/shop/admin/products/[id]/edit/page.tsx +++ b/frontend/app/[locale]/shop/admin/products/[id]/edit/page.tsx @@ -1,6 +1,8 @@ import { notFound } from 'next/navigation'; import { eq } from 'drizzle-orm'; import { z } from 'zod'; +import { guardShopAdminPage } from '@/lib/auth/guard-shop-admin-page'; +import { ShopAdminTopbar } from '@/components/shop/admin/shop-admin-topbar'; import { ProductForm } from '../../_components/product-form'; import { db } from '@/db'; @@ -24,6 +26,7 @@ export default async function EditProductPage({ }: { params: Promise<{ id: string }>; }) { + await guardShopAdminPage(); const rawParams = await params; const parsed = paramsSchema.safeParse(rawParams); if (!parsed.success) return notFound(); @@ -68,25 +71,28 @@ export default async function EditProductPage({ ]; return ( - + <> + + + ); } diff --git a/frontend/app/[locale]/shop/admin/products/new/page.tsx b/frontend/app/[locale]/shop/admin/products/new/page.tsx index 0fc79684..93904c53 100644 --- a/frontend/app/[locale]/shop/admin/products/new/page.tsx +++ b/frontend/app/[locale]/shop/admin/products/new/page.tsx @@ -1,5 +1,14 @@ -import { ProductForm } from "../_components/product-form" +import { ProductForm } from '../_components/product-form'; +import { ShopAdminTopbar } from '@/components/shop/admin/shop-admin-topbar'; +import { guardShopAdminPage } from '@/lib/auth/guard-shop-admin-page'; -export default function NewProductPage() { - return +export default async function NewProductPage() { + await guardShopAdminPage(); + + return ( + <> + + + + ); } diff --git a/frontend/app/[locale]/shop/admin/products/page.tsx b/frontend/app/[locale]/shop/admin/products/page.tsx index 9df8d86d..596c91d3 100644 --- a/frontend/app/[locale]/shop/admin/products/page.tsx +++ b/frontend/app/[locale]/shop/admin/products/page.tsx @@ -1,5 +1,7 @@ import { Link } from '@/i18n/routing'; import { and, desc, eq } from 'drizzle-orm'; +import { ShopAdminTopbar } from '@/components/shop/admin/shop-admin-topbar'; +import { guardShopAdminPage } from '@/lib/auth/guard-shop-admin-page'; import { AdminProductStatusToggle } from '@/components/shop/admin/admin-product-status-toggle'; import { db } from '@/db'; @@ -38,6 +40,7 @@ export default async function AdminProductsPage({ }: { params: Promise<{ locale: string }>; }) { + await guardShopAdminPage(); const { locale } = await params; // currency policy: derived from locale @@ -68,152 +71,159 @@ export default async function AdminProductsPage({ .orderBy(desc(products.createdAt)); return ( -
-
-

Admin · Products

- - New product - -
- -
- - - - - - - - - - - - - - - - - - - {rows.map(row => { - const priceMinor = safeFromDbMoney(row.price, { - productId: row.id, - currency: displayCurrency, - }); - - return ( - - - - - - - - - - - - - - - - - - - - - - + + + + + + ); + })} + +
- Title - - Slug - - Price - - Category - - Type - - Stock - - Badge - - Active - - Featured - - Created - - Actions -
-
- {row.title} -
-
-
- {row.slug} -
-
- {priceMinor === null - ? '-' - : formatMoney(priceMinor, displayCurrency, locale)} - -
- {row.category ?? '-'} -
-
-
- {row.type ?? '-'} -
-
- {row.stock} - - {row.badge === 'NONE' ? '-' : row.badge} - - - {row.isActive ? 'Yes' : 'No'} - - - - {row.isFeatured ? 'Yes' : 'No'} - - - {formatDate(row.createdAt, locale)} - -
- + +
+
+

+ Admin · Products +

+ + New product + +
+ +
+ + + + + + + + + + + + + + + + + + + {rows.map(row => { + const priceMinor = safeFromDbMoney(row.price, { + productId: row.id, + currency: displayCurrency, + }); + + return ( + + + + + + + + + + + + + + + + + + - - ); - })} - -
+ Title + + Slug + + Price + + Category + + Type + + Stock + + Badge + + Active + + Featured + + Created + + Actions +
+
+ {row.title} +
+
+
+ {row.slug} +
+
+ {priceMinor === null + ? '-' + : formatMoney(priceMinor, displayCurrency, locale)} + +
+ {row.category ?? '-'} +
+
+
+ {row.type ?? '-'} +
+
+ {row.stock} + + {row.badge === 'NONE' ? '-' : row.badge} + + - View - - + + - Edit - - - -
+ {row.isFeatured ? 'Yes' : 'No'} + +
+ {formatDate(row.createdAt, locale)} + +
+ + View + + + Edit + + +
+
+
-
+ ); } diff --git a/frontend/app/[locale]/shop/layout.tsx b/frontend/app/[locale]/shop/layout.tsx deleted file mode 100644 index 9defb980..00000000 --- a/frontend/app/[locale]/shop/layout.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import type React from 'react'; - -export default function ShopLayout({ children }: { children: React.ReactNode }) { - return
{children}
; -} \ No newline at end of file diff --git a/frontend/components/shop/admin/shop-admin-topbar.tsx b/frontend/components/shop/admin/shop-admin-topbar.tsx new file mode 100644 index 00000000..23673b6e --- /dev/null +++ b/frontend/components/shop/admin/shop-admin-topbar.tsx @@ -0,0 +1,43 @@ +import { Link } from '@/i18n/routing'; + +export function ShopAdminTopbar() { + return ( +
+
+
+
+ + Admin + + + / + + + Products + + + + Orders + +
+ + + Back to shop + +
+
+
+ ); +} diff --git a/frontend/lib/auth/guard-shop-admin-page.ts b/frontend/lib/auth/guard-shop-admin-page.ts new file mode 100644 index 00000000..05d426b3 --- /dev/null +++ b/frontend/lib/auth/guard-shop-admin-page.ts @@ -0,0 +1,18 @@ +import { notFound, redirect } from 'next/navigation'; +import { + AdminApiDisabledError, + AdminForbiddenError, + AdminUnauthorizedError, + requireAdminPage, +} from '@/lib/auth/admin'; + +export async function guardShopAdminPage(): Promise { + try { + await requireAdminPage(); + } catch (err) { + if (err instanceof AdminApiDisabledError) notFound(); + if (err instanceof AdminUnauthorizedError) redirect('/login'); + if (err instanceof AdminForbiddenError) notFound(); + throw err; + } +} From 8278c1b752aa67476aee4566eed767da4e902d5a Mon Sep 17 00:00:00 2001 From: liudmylasovetovs Date: Wed, 14 Jan 2026 20:08:50 -0800 Subject: [PATCH 7/8] (SP: 1) [UI] Shop header nav: add Home link + align hover/active styling with platform --- frontend/components/header/AppMobileMenu.tsx | 16 +++++++- frontend/components/header/UnifiedHeader.tsx | 24 +++++------ frontend/components/shop/header/nav-links.tsx | 41 ++++++++++++++++--- 3 files changed, 60 insertions(+), 21 deletions(-) diff --git a/frontend/components/header/AppMobileMenu.tsx b/frontend/components/header/AppMobileMenu.tsx index a2ab8aac..239cb3c9 100644 --- a/frontend/components/header/AppMobileMenu.tsx +++ b/frontend/components/header/AppMobileMenu.tsx @@ -16,7 +16,11 @@ type Props = { showAdminLink?: boolean; }; -export function AppMobileMenu({ variant, userExists, showAdminLink = false }: Props) { +export function AppMobileMenu({ + variant, + userExists, + showAdminLink = false, +}: Props) { const [open, setOpen] = useState(false); const close = () => setOpen(false); @@ -65,6 +69,16 @@ export function AppMobileMenu({ variant, userExists, showAdminLink = false }: Pr className="fixed left-0 right-0 top-16 z-50 border-t border-border bg-background px-4 py-4 md:hidden" >
+ {variant === 'shop' ? ( + + Home + + ) : null} + {links.map(link => (
-