Skip to content

Commit a43ec61

Browse files
(SP: 1) [Checkout] Add durable payment_attempts layer (unique/limits, bounded retries, audit trail)
1 parent 2fe1dc8 commit a43ec61

10 files changed

Lines changed: 3331 additions & 174 deletions

File tree

frontend/app/[locale]/shop/checkout/payment/StripePaymentClient.tsx

Lines changed: 30 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
1-
// frontend/app/[locale]/shop/checkout/payment/StripePaymentClient.tsx
21
'use client';
32

43
import { useMemo, useState } from 'react';
5-
import { Link } from '@/i18n/routing';
4+
import { Link, useRouter } from '@/i18n/routing';
65

7-
import { useRouter } from 'next/navigation';
86
import {
97
Elements,
108
PaymentElement,
@@ -50,14 +48,21 @@ function toCurrencyCode(
5048
: resolveCurrencyFromLocale(locale);
5149
}
5250

51+
function buildShopBase(locale: string) {
52+
return `/${locale}/shop`;
53+
}
54+
5355
function nextRouteForPaymentResult(params: {
5456
locale: string;
5557
orderId: string;
5658
status?: string | null;
5759
}) {
58-
const { orderId, status } = params;
59-
const success = `/shop/checkout/success?orderId=${orderId}`;
60-
const failure = `/shop/checkout/error?orderId=${orderId}`;
60+
const { locale, orderId, status } = params;
61+
const shopBase = buildShopBase(locale);
62+
const id = encodeURIComponent(orderId);
63+
64+
const success = `${shopBase}/checkout/success?orderId=${id}`;
65+
const failure = `${shopBase}/checkout/error?orderId=${id}`;
6166

6267
if (!status) return success;
6368
if (
@@ -75,6 +80,9 @@ function StripePaymentForm({ orderId, locale }: PaymentFormProps) {
7580
const stripe = useStripe();
7681
const elements = useElements();
7782
const router = useRouter();
83+
84+
const shopBase = useMemo(() => buildShopBase(locale), [locale]);
85+
7886
const [submitting, setSubmitting] = useState(false);
7987
const [errorMessage, setErrorMessage] = useState<string | null>(null);
8088

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

94102
try {
103+
const id = encodeURIComponent(orderId);
104+
95105
const { error, paymentIntent } = await stripe.confirmPayment({
96106
elements,
97107
redirect: 'if_required',
98108
confirmParams: {
99-
return_url: `${window.location.origin}/shop/checkout/success?orderId=${orderId}`,
109+
// Stripe redirect comes from outside Next.js routing — must include locale.
110+
return_url: `${window.location.origin}${shopBase}/checkout/success?orderId=${id}`,
100111
},
101112
});
102113

103114
if (error) {
104115
setErrorMessage(error.message ?? 'Unable to confirm payment.');
105-
router.push(`/shop/checkout/error?orderId=${orderId}`);
116+
router.push(`${shopBase}/checkout/error?orderId=${id}`);
106117
return;
107118
}
108119

@@ -111,11 +122,14 @@ function StripePaymentForm({ orderId, locale }: PaymentFormProps) {
111122
orderId,
112123
status: paymentIntent?.status ?? null,
113124
});
125+
114126
router.push(next);
115127
} catch (error) {
116128
logError('stripe_payment_confirm_failed', error, { orderId });
117129
setErrorMessage('We couldn’t confirm your payment. Please try again.');
118-
router.push(`/shop/checkout/error?orderId=${orderId}`);
130+
router.push(
131+
`${shopBase}/checkout/error?orderId=${encodeURIComponent(orderId)}`
132+
);
119133
} finally {
120134
setSubmitting(false);
121135
}
@@ -161,6 +175,8 @@ export default function StripePaymentClient({
161175
[currency, locale]
162176
);
163177

178+
const shopBase = useMemo(() => buildShopBase(locale), [locale]);
179+
164180
const stripePromise = useMemo(() => {
165181
if (!paymentsEnabled || !publishableKey) return null;
166182
return loadStripe(publishableKey);
@@ -183,13 +199,15 @@ export default function StripePaymentClient({
183199
<p>Payments are disabled in this environment.</p>
184200
<nav className="flex gap-3" aria-label="Next steps">
185201
<Link
186-
href={`/shop/checkout/success?orderId=${orderId}`}
202+
href={`${shopBase}/checkout/success?orderId=${encodeURIComponent(
203+
orderId
204+
)}`}
187205
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"
188206
>
189207
Continue
190208
</Link>
191209
<Link
192-
href="/shop/cart"
210+
href={`${shopBase}/cart`}
193211
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"
194212
>
195213
Back to cart
@@ -207,7 +225,7 @@ export default function StripePaymentClient({
207225
>
208226
<p>Payment cannot be initialized. Please try again later.</p>
209227
<Link
210-
href="/shop/cart"
228+
href={`${shopBase}/cart`}
211229
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"
212230
>
213231
Return to cart

frontend/app/[locale]/shop/checkout/payment/[orderId]/page.tsx

Lines changed: 62 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,8 @@ import { getOrderSummary } from '@/lib/services/orders';
66
import { OrderNotFoundError } from '@/lib/services/errors';
77
import { orderIdParamSchema } from '@/lib/validation/shop';
88
import { getStripeEnv } from '@/lib/env/stripe';
9-
import { createPaymentIntent, retrievePaymentIntent } from '@/lib/psp/stripe';
10-
import { setOrderPaymentIntent } from '@/lib/services/orders';
119
import { logError } from '@/lib/logging';
10+
import { ensureStripePaymentIntentForOrder } from '@/lib/services/orders/payment-attempts';
1211

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

9494
const orderId = getOrderId(params);
9595

@@ -101,13 +101,13 @@ export default async function PaymentPage(props: PaymentPageProps) {
101101
>
102102
<nav className="mt-6 flex justify-center gap-3" aria-label="Next steps">
103103
<Link
104-
href="/shop/cart"
104+
href={`${shopBase}/cart`}
105105
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"
106106
>
107107
Go to cart
108108
</Link>
109109
<Link
110-
href="/shop/products"
110+
href={`${shopBase}/products`}
111111
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"
112112
>
113113
Continue shopping
@@ -133,13 +133,13 @@ export default async function PaymentPage(props: PaymentPageProps) {
133133
aria-label="Next steps"
134134
>
135135
<Link
136-
href="/shop/cart"
136+
href={`${shopBase}/cart`}
137137
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"
138138
>
139139
Go to cart
140140
</Link>
141141
<Link
142-
href="/shop/products"
142+
href={`${shopBase}/products`}
143143
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"
144144
>
145145
Continue shopping
@@ -165,48 +165,74 @@ export default async function PaymentPage(props: PaymentPageProps) {
165165

166166
// Ensure we have a clientSecret even when URL doesn't include ?clientSecret=...
167167
// Source of truth for payment finality is webhook; this only initializes Elements.
168+
// if (
169+
// paymentsEnabled &&
170+
// publishableKey &&
171+
// (!clientSecret || !clientSecret.trim())
172+
// ) {
173+
// const existingPi = order.paymentIntentId?.trim() ?? '';
174+
// let phase:
175+
// | 'retrievePaymentIntent'
176+
// | 'createPaymentIntent'
177+
// | 'setOrderPaymentIntent'
178+
// | 'unknown' = 'unknown';
179+
180+
// try {
181+
// if (existingPi) {
182+
// phase = 'retrievePaymentIntent';
183+
// const retrieved = await retrievePaymentIntent(existingPi);
184+
// clientSecret = retrieved.clientSecret;
185+
// } else {
186+
// phase = 'createPaymentIntent';
187+
// const snapshot = await readStripePaymentIntentParams(order.id);
188+
// const created = await createPaymentIntent({
189+
// amount: snapshot.amountMinor,
190+
// currency: snapshot.currency,
191+
// orderId: order.id,
192+
// idempotencyKey: `pi:${order.id}`,
193+
// });
194+
195+
// phase = 'setOrderPaymentIntent';
196+
// await setOrderPaymentIntent({
197+
// orderId: order.id,
198+
// paymentIntentId: created.paymentIntentId,
199+
// });
200+
201+
// clientSecret = created.clientSecret;
202+
// }
203+
// } catch (error) {
204+
// logError('payment_page_failed', error, {
205+
// orderId: order.id,
206+
// existingPi,
207+
// phase,
208+
// });
209+
210+
// // Leave clientSecret empty -> UI shows "Payment cannot be initialized"
211+
// }
212+
// }
213+
168214
if (
169215
paymentsEnabled &&
170216
publishableKey &&
171217
(!clientSecret || !clientSecret.trim())
172218
) {
173219
const existingPi = order.paymentIntentId?.trim() ?? '';
174-
let phase:
175-
| 'retrievePaymentIntent'
176-
| 'createPaymentIntent'
177-
| 'setOrderPaymentIntent'
178-
| 'unknown' = 'unknown';
220+
let phase: 'ensureStripePaymentIntentForOrder' | 'unknown' = 'unknown';
179221

180222
try {
181-
if (existingPi) {
182-
phase = 'retrievePaymentIntent';
183-
const retrieved = await retrievePaymentIntent(existingPi);
184-
clientSecret = retrieved.clientSecret;
185-
} else {
186-
phase = 'createPaymentIntent';
187-
const created = await createPaymentIntent({
188-
amount: order.totalAmountMinor,
189-
currency: order.currency,
190-
orderId: order.id,
191-
idempotencyKey: `pi:${order.id}`,
192-
});
193-
194-
phase = 'setOrderPaymentIntent';
195-
await setOrderPaymentIntent({
196-
orderId: order.id,
197-
paymentIntentId: created.paymentIntentId,
198-
});
199-
200-
clientSecret = created.clientSecret;
201-
}
223+
phase = 'ensureStripePaymentIntentForOrder';
224+
const ensured = await ensureStripePaymentIntentForOrder({
225+
orderId: order.id,
226+
existingPaymentIntentId: existingPi || null,
227+
});
228+
229+
clientSecret = ensured.clientSecret;
202230
} catch (error) {
203231
logError('payment_page_failed', error, {
204232
orderId: order.id,
205233
existingPi,
206234
phase,
207235
});
208-
209-
// Leave clientSecret empty -> UI shows "Payment cannot be initialized"
210236
}
211237
}
212238

@@ -223,13 +249,13 @@ export default async function PaymentPage(props: PaymentPageProps) {
223249
aria-label="Next steps"
224250
>
225251
<Link
226-
href={`/shop/checkout/success?orderId=${order.id}${cc}`}
252+
href={`${shopBase}/checkout/success?orderId=${order.id}${cc}`}
227253
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"
228254
>
229255
View confirmation
230256
</Link>
231257
<Link
232-
href="/shop/products"
258+
href={`${shopBase}/products`}
233259
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"
234260
>
235261
Continue shopping

0 commit comments

Comments
 (0)