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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
// frontend/app/[locale]/shop/checkout/payment/StripePaymentClient.tsx
'use client';

import { useMemo, useState } from 'react';
import { Link } from '@/i18n/routing';
import { Link, useRouter } from '@/i18n/routing';

import { useRouter } from 'next/navigation';
import {
Elements,
PaymentElement,
Expand Down Expand Up @@ -50,14 +48,21 @@ function toCurrencyCode(
: resolveCurrencyFromLocale(locale);
}

function buildShopBase(locale: string) {
return `/${locale}/shop`;
}

function nextRouteForPaymentResult(params: {
locale: string;
orderId: string;
status?: string | null;
}) {
const { orderId, status } = params;
const success = `/shop/checkout/success?orderId=${orderId}`;
const failure = `/shop/checkout/error?orderId=${orderId}`;
const { locale, orderId, status } = params;
const shopBase = buildShopBase(locale);
const id = encodeURIComponent(orderId);

const success = `${shopBase}/checkout/success?orderId=${id}`;
const failure = `${shopBase}/checkout/error?orderId=${id}`;

if (!status) return success;
if (
Expand All @@ -75,6 +80,9 @@ function StripePaymentForm({ orderId, locale }: PaymentFormProps) {
const stripe = useStripe();
const elements = useElements();
const router = useRouter();

const shopBase = useMemo(() => buildShopBase(locale), [locale]);

const [submitting, setSubmitting] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);

Expand All @@ -92,17 +100,20 @@ function StripePaymentForm({ orderId, locale }: PaymentFormProps) {
setSubmitting(true);

try {
const id = encodeURIComponent(orderId);

const { error, paymentIntent } = await stripe.confirmPayment({
elements,
redirect: 'if_required',
confirmParams: {
return_url: `${window.location.origin}/shop/checkout/success?orderId=${orderId}`,
// Stripe redirect comes from outside Next.js routing — must include locale.
return_url: `${window.location.origin}${shopBase}/checkout/success?orderId=${id}`,
},
});

if (error) {
setErrorMessage(error.message ?? 'Unable to confirm payment.');
router.push(`/shop/checkout/error?orderId=${orderId}`);
router.push(`${shopBase}/checkout/error?orderId=${id}`);
return;
}

Expand All @@ -111,11 +122,14 @@ function StripePaymentForm({ orderId, locale }: PaymentFormProps) {
orderId,
status: paymentIntent?.status ?? null,
});

router.push(next);
} catch (error) {
logError('stripe_payment_confirm_failed', error, { orderId });
setErrorMessage('We couldn’t confirm your payment. Please try again.');
router.push(`/shop/checkout/error?orderId=${orderId}`);
router.push(
`${shopBase}/checkout/error?orderId=${encodeURIComponent(orderId)}`
);
} finally {
setSubmitting(false);
}
Expand Down Expand Up @@ -161,6 +175,8 @@ export default function StripePaymentClient({
[currency, locale]
);

const shopBase = useMemo(() => buildShopBase(locale), [locale]);

const stripePromise = useMemo(() => {
if (!paymentsEnabled || !publishableKey) return null;
return loadStripe(publishableKey);
Expand All @@ -183,13 +199,15 @@ export default function StripePaymentClient({
<p>Payments are disabled in this environment.</p>
<nav className="flex gap-3" aria-label="Next steps">
<Link
href={`/shop/checkout/success?orderId=${orderId}`}
href={`${shopBase}/checkout/success?orderId=${encodeURIComponent(
orderId
)}`}
className="inline-flex items-center justify-center rounded-md bg-accent px-4 py-2 text-sm font-semibold uppercase tracking-wide text-accent-foreground hover:bg-accent/90"
>
Continue
</Link>
<Link
href="/shop/cart"
href={`${shopBase}/cart`}
className="inline-flex items-center justify-center rounded-md border border-border px-4 py-2 text-sm font-semibold uppercase tracking-wide text-foreground hover:bg-secondary"
>
Back to cart
Expand All @@ -207,7 +225,7 @@ export default function StripePaymentClient({
>
<p>Payment cannot be initialized. Please try again later.</p>
<Link
href="/shop/cart"
href={`${shopBase}/cart`}
className="inline-flex items-center justify-center rounded-md border border-border px-4 py-2 text-sm font-semibold uppercase tracking-wide text-foreground hover:bg-secondary"
>
Return to cart
Expand Down
52 changes: 15 additions & 37 deletions frontend/app/[locale]/shop/checkout/payment/[orderId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,8 @@ import { getOrderSummary } from '@/lib/services/orders';
import { OrderNotFoundError } from '@/lib/services/errors';
import { orderIdParamSchema } from '@/lib/validation/shop';
import { getStripeEnv } from '@/lib/env/stripe';
import { createPaymentIntent, retrievePaymentIntent } from '@/lib/psp/stripe';
import { setOrderPaymentIntent } from '@/lib/services/orders';
import { logError } from '@/lib/logging';
import { ensureStripePaymentIntentForOrder } from '@/lib/services/orders/payment-attempts';

export const dynamic = 'force-dynamic';
export const revalidate = 0;
Expand Down Expand Up @@ -90,6 +89,7 @@ export default async function PaymentPage(props: PaymentPageProps) {
const clearCart = shouldClearCart(searchParams);
const cc = clearCart ? '&clearCart=1' : '';
const { locale } = params;
const shopBase = `/${locale}/shop`;

const orderId = getOrderId(params);

Expand All @@ -101,13 +101,13 @@ export default async function PaymentPage(props: PaymentPageProps) {
>
<nav className="mt-6 flex justify-center gap-3" aria-label="Next steps">
<Link
href="/shop/cart"
href={`${shopBase}/cart`}
className="inline-flex items-center justify-center rounded-md border border-border px-4 py-2 text-sm font-semibold uppercase tracking-wide text-foreground hover:bg-secondary"
>
Go to cart
</Link>
<Link
href="/shop/products"
href={`${shopBase}/products`}
className="inline-flex items-center justify-center rounded-md bg-accent px-4 py-2 text-sm font-semibold uppercase tracking-wide text-accent-foreground hover:bg-accent/90"
>
Continue shopping
Expand All @@ -133,13 +133,13 @@ export default async function PaymentPage(props: PaymentPageProps) {
aria-label="Next steps"
>
<Link
href="/shop/cart"
href={`${shopBase}/cart`}
className="inline-flex items-center justify-center rounded-md border border-border px-4 py-2 text-sm font-semibold uppercase tracking-wide text-foreground hover:bg-secondary"
>
Go to cart
</Link>
<Link
href="/shop/products"
href={`${shopBase}/products`}
className="inline-flex items-center justify-center rounded-md bg-accent px-4 py-2 text-sm font-semibold uppercase tracking-wide text-accent-foreground hover:bg-accent/90"
>
Continue shopping
Expand All @@ -163,50 +163,28 @@ export default async function PaymentPage(props: PaymentPageProps) {
let clientSecret = resolveClientSecret(searchParams);
const publishableKey = paymentsEnabled ? stripeEnv.publishableKey : null;

// Ensure we have a clientSecret even when URL doesn't include ?clientSecret=...
// Source of truth for payment finality is webhook; this only initializes Elements.
if (
paymentsEnabled &&
publishableKey &&
(!clientSecret || !clientSecret.trim())
) {
const existingPi = order.paymentIntentId?.trim() ?? '';
let phase:
| 'retrievePaymentIntent'
| 'createPaymentIntent'
| 'setOrderPaymentIntent'
| 'unknown' = 'unknown';
let phase: 'ensureStripePaymentIntentForOrder' | 'unknown' = 'unknown';

try {
if (existingPi) {
phase = 'retrievePaymentIntent';
const retrieved = await retrievePaymentIntent(existingPi);
clientSecret = retrieved.clientSecret;
} else {
phase = 'createPaymentIntent';
const created = await createPaymentIntent({
amount: order.totalAmountMinor,
currency: order.currency,
orderId: order.id,
idempotencyKey: `pi:${order.id}`,
});

phase = 'setOrderPaymentIntent';
await setOrderPaymentIntent({
orderId: order.id,
paymentIntentId: created.paymentIntentId,
});
phase = 'ensureStripePaymentIntentForOrder';
const ensured = await ensureStripePaymentIntentForOrder({
orderId: order.id,
existingPaymentIntentId: existingPi || null,
});

clientSecret = created.clientSecret;
}
clientSecret = ensured.clientSecret;
} catch (error) {
logError('payment_page_failed', error, {
orderId: order.id,
existingPi,
phase,
});

// Leave clientSecret empty -> UI shows "Payment cannot be initialized"
}
}

Expand All @@ -223,13 +201,13 @@ export default async function PaymentPage(props: PaymentPageProps) {
aria-label="Next steps"
>
<Link
href={`/shop/checkout/success?orderId=${order.id}${cc}`}
href={`${shopBase}/checkout/success?orderId=${order.id}${cc}`}
className="inline-flex items-center justify-center rounded-md bg-accent px-4 py-2 text-sm font-semibold uppercase tracking-wide text-accent-foreground hover:bg-accent/90"
>
View confirmation
</Link>
<Link
href="/shop/products"
href={`${shopBase}/products`}
className="inline-flex items-center justify-center rounded-md border border-border px-4 py-2 text-sm font-semibold uppercase tracking-wide text-foreground hover:bg-secondary"
>
Continue shopping
Expand Down
19 changes: 18 additions & 1 deletion frontend/app/api/shop/admin/products/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,14 @@ import {
AdminUnauthorizedError,
requireAdminApi,
} from '@/lib/auth/admin';
import {
InvalidPayloadError,
SlugConflictError,
PriceConfigError,
} from '@/lib/services/errors';

import { parseAdminProductForm } from '@/lib/admin/parseAdminProductForm';
import { logError } from '@/lib/logging';
import { InvalidPayloadError, SlugConflictError } from '@/lib/services/errors';
import {
deleteProduct,
getAdminProductByIdWithPrices,
Expand Down Expand Up @@ -217,6 +221,19 @@ export async function PATCH(
} catch (error) {
logError('Failed to update product', error);

if (error instanceof PriceConfigError) {
return NextResponse.json(
{
error: error.message,
code: error.code, // PRICE_CONFIG_ERROR
productId: error.productId,
currency: error.currency,
field: 'prices',
},
{ status: 400 }
);
}

if (error instanceof InvalidPayloadError) {
const anyErr = error as any;
return NextResponse.json(
Expand Down
Loading