From 57271bd0fd4b2ea7661b02c718469d074ed00d2b Mon Sep 17 00:00:00 2001 From: Gerard Kavanagh Date: Wed, 29 Jul 2026 15:28:38 +0100 Subject: [PATCH] fix(checkout): never confirm a pay-upfront order whose payment never started MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2026-07-29, LekkerWeed: a production backend task exited mid-request, the ALB returned a 502 in 87ms, and the PayCloud mint threw. The catch around it swallowed the error, execution continued to log('SUCCESS'), and the storefront — seeing no payUrl — fell through to the order-confirmation screen AND called clearCart(). The customer was shown a placed order, lost her basket, and no payment was ever taken. Dr Green order ccdeea34 sat PENDING with stock committed. The swallow was deliberate ('never fail the order if minting fails — still payable via the email-link flow on admin approval') and is correct for email-link stores, where an order with no mint is the normal state. It is wrong for DIRECT stores, which are pay-upfront: there, no mint means no checkout happened. Server (orders/submit): - retry the mint 3× with backoff. The failure was transient — the replacement task was healthy ~2 minutes later — so one blip should never strand a pay-upfront order. - on final failure with directPayEnabled, return paymentStartFailed:true. The Dr Green order is deliberately kept: it stays payable via /orders/{id}/pay or the email-link fallback, and the abandoned-order sweep releases its stock if nobody pays. Flag is always false on email-link stores. Storefront (checkout page): - paymentStartFailed no longer shows the confirmation and no longer clears the cart. It says nothing has been charged and offers Retry payment, which re-mints against the EXISTING order via /orders/{id}/pay rather than creating a second one. Tests pin both halves: retry-then-succeed, give-up-and-flag, email-link stores never flagged, and the storefront branch that used to confirm a phantom order. --- .../api/store/[slug]/orders/submit/route.ts | 94 +++++++++----- .../app/store/[slug]/checkout/page.tsx | 77 +++++++++++- .../unit/direct-pay-no-phantom-order.test.ts | 117 ++++++++++++++++++ 3 files changed, 254 insertions(+), 34 deletions(-) create mode 100644 nextjs_space/tests/unit/direct-pay-no-phantom-order.test.ts diff --git a/nextjs_space/app/api/store/[slug]/orders/submit/route.ts b/nextjs_space/app/api/store/[slug]/orders/submit/route.ts index 94313582..534aa0c3 100644 --- a/nextjs_space/app/api/store/[slug]/orders/submit/route.ts +++ b/nextjs_space/app/api/store/[slug]/orders/submit/route.ts @@ -194,39 +194,69 @@ export const POST = withAuth(async (request, { user }, { slug }) => { // markets fall back to the email-link flow. Per-tenant opt-out via // settings.directPayments===false; global kill via DIRECT_PAY_DISABLED. let payUrl: string | undefined; + // DIRECT stores are pay-upfront: an order with no minted checkout is a + // FAILED checkout, not a placed order. Reported to the caller so the + // storefront can say so instead of showing a confirmation. Always false on + // email-link stores, where an unminted order is the normal, correct state. + let paymentStartFailed = false; if (directPayEnabled && orderResponse.drGreenOrderId) { const host = request.headers.get("host") || ""; const origin = request.headers.get("origin") || (host ? `https://${host}` : ""); - try { - const checkout = await createDirectCheckout({ - drGreenOrderId: orderResponse.drGreenOrderId, - // Tenant hosts ({slug}.budstacks.io / custom domains) serve the store - // at root; the legacy "/store/" path 404s there. - returnUrl: storefrontUrl( - origin, - host, - slug, - `/payment/return/${orderResponse.orderId}`, - ), - apiKey: drGreenConfig.apiKey, - secretKey: drGreenConfig.secretKey, - apiUrl: drGreenConfig.apiUrl, - // US-008: the shopper's IP becomes PayCloud's term_ip fraud hint — - // without it the transaction is attributed to this server's egress. - customerIp: getPublicClientIp(request.headers), - }); - payUrl = checkout.payUrl; - log('DIRECT_CHECKOUT', { - hasPayUrl: !!payUrl, - expiresAt: checkout.expiresAt, - }); - } catch (e) { - // Never fail the order if minting fails — the order exists and remains - // payable via the email-link flow on admin approval (fallback). - log('DIRECT_CHECKOUT_FAILED', { - error: e instanceof Error ? e.message : String(e), - }); + // Retry the mint. A single backend blip must not strand a pay-upfront + // order: on 2026-07-29 a production task exited mid-request, the ALB + // returned a 502 in 87ms, and one LekkerWeed order was created with no + // payment ever started. Transient by nature — the replacement task was + // healthy ~2 minutes later. + const MINT_ATTEMPTS = 3; + for (let attempt = 1; attempt <= MINT_ATTEMPTS; attempt++) { + try { + const checkout = await createDirectCheckout({ + drGreenOrderId: orderResponse.drGreenOrderId, + // Tenant hosts ({slug}.budstacks.io / custom domains) serve the + // store at root; the legacy "/store/" path 404s there. + returnUrl: storefrontUrl( + origin, + host, + slug, + `/payment/return/${orderResponse.orderId}`, + ), + apiKey: drGreenConfig.apiKey, + secretKey: drGreenConfig.secretKey, + apiUrl: drGreenConfig.apiUrl, + // US-008: the shopper's IP becomes PayCloud's term_ip fraud hint — + // without it the transaction is attributed to this server's egress. + customerIp: getPublicClientIp(request.headers), + }); + payUrl = checkout.payUrl; + log("DIRECT_CHECKOUT", { + hasPayUrl: !!payUrl, + expiresAt: checkout.expiresAt, + attempt, + }); + break; + } catch (e) { + const lastAttempt = attempt === MINT_ATTEMPTS; + log("DIRECT_CHECKOUT_ATTEMPT_FAILED", { + attempt, + lastAttempt, + error: e instanceof Error ? e.message : String(e), + }); + if (!lastAttempt) { + await new Promise((r) => setTimeout(r, 400 * attempt)); + continue; + } + // Out of attempts on a pay-upfront store. The Dr Green order stays: + // it is still payable by retrying via /orders/{id}/pay, or through + // the email-link flow on admin approval. But the customer must NOT + // be told the order is placed. The abandoned-order sweep releases + // its stock if nobody ever pays. + paymentStartFailed = true; + log("DIRECT_CHECKOUT_FAILED", { + error: e instanceof Error ? e.message : String(e), + attempts: MINT_ATTEMPTS, + }); + } } } @@ -255,8 +285,10 @@ export const POST = withAuth(async (request, { user }, { slug }) => { }, }); - log('SUCCESS'); - return NextResponse.json({ order: { ...orderResponse, payUrl } }); + log(paymentStartFailed ? 'ORDER_CREATED_PAYMENT_NOT_STARTED' : 'SUCCESS'); + return NextResponse.json({ + order: { ...orderResponse, payUrl, paymentStartFailed }, + }); } catch (error) { const msg = error instanceof Error ? error.message : String(error); log('UNHANDLED_ERROR', { message: msg }); diff --git a/nextjs_space/app/store/[slug]/checkout/page.tsx b/nextjs_space/app/store/[slug]/checkout/page.tsx index 7870fe8d..dd4a12df 100644 --- a/nextjs_space/app/store/[slug]/checkout/page.tsx +++ b/nextjs_space/app/store/[slug]/checkout/page.tsx @@ -31,6 +31,47 @@ export default function CheckoutPage({ params }: { params: { slug: string } }) { const [isSubmitting, setIsSubmitting] = useState(false); const [error, setError] = useState(null); const [orderResult, setOrderResult] = useState(null); + // Set when a pay-upfront order was created but its checkout could not be + // minted. Drives the retry below, which re-mints against the EXISTING order + // rather than creating a second one. + const [pendingPaymentOrderId, setPendingPaymentOrderId] = useState< + string | null + >(null); + const [isRetryingPayment, setIsRetryingPayment] = useState(false); + + const retryPayment = async () => { + if (!pendingPaymentOrderId) return; + setIsRetryingPayment(true); + setError(null); + try { + const res = await fetch( + `/api/store/${params.slug}/orders/${pendingPaymentOrderId}/pay`, + { method: "POST" }, + ); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || "Could not start payment"); + if (data.payUrl) { + window.location.href = data.payUrl; + return; + } + if (data.paid) { + // Already settled (e.g. the first mint actually succeeded upstream). + setPendingPaymentOrderId(null); + setError(null); + clearCart(); + return; + } + throw new Error("Could not start payment"); + } catch (e) { + setError( + e instanceof Error + ? `${e.message}. Nothing has been charged.` + : "Could not start payment. Nothing has been charged.", + ); + } finally { + setIsRetryingPayment(false); + } + }; // Saved address state const [savedAddress, setSavedAddress] = useState(null); @@ -144,6 +185,19 @@ export default function CheckoutPage({ params }: { params: { slug: string } }) { return; } + // Pay-upfront store where the checkout could not be minted. The order + // exists but NOTHING has been paid, so the confirmation screen below + // would tell the customer they had ordered — and clearCart() would take + // their basket with it. That is exactly what happened on LekkerWeed on + // 2026-07-29. Keep the cart, say what happened, offer the retry. + if (data.order?.paymentStartFailed) { + setPendingPaymentOrderId(data.order.orderId ?? null); + setError( + "We couldn't start the payment for your order. Nothing has been charged — please try again.", + ); + return; + } + setOrderResult(data.order); clearCart(); } catch (err) { @@ -640,9 +694,26 @@ export default function CheckoutPage({ params }: { params: { slug: string } }) { )} {error && ( -
- - {error} +
+
+ + {error} +
+ {pendingPaymentOrderId && ( + + )}
)} diff --git a/nextjs_space/tests/unit/direct-pay-no-phantom-order.test.ts b/nextjs_space/tests/unit/direct-pay-no-phantom-order.test.ts new file mode 100644 index 00000000..a0e1c3db --- /dev/null +++ b/nextjs_space/tests/unit/direct-pay-no-phantom-order.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it, vi } from "vitest"; + +/** + * A pay-upfront (DIRECT) order whose checkout could not be minted is a FAILED + * checkout, not a placed order. + * + * 2026-07-29, LekkerWeed: a production backend task exited mid-request, the ALB + * returned a 502 in 87ms, and the mint threw. The catch swallowed it, execution + * continued to log('SUCCESS'), and the storefront — seeing no payUrl — showed + * the order-confirmation screen AND called clearCart(). The customer was told + * her order was placed, lost her basket, and no payment was ever taken. Dr Green + * order ccdeea34 sat PENDING with stock committed. + * + * These tests pin the two halves of the contract: + * - transient mint failures are retried rather than stranding the order + * - an unminted DIRECT order reports paymentStartFailed so the storefront can + * tell the truth. On email-link stores the same shape is normal and the + * flag must stay false. + */ + +// Mirrors the retry/report logic in +// app/api/store/[slug]/orders/submit/route.ts so the behaviour is pinned +// without standing up the full Next route (DB, Clerk, tenant resolution). +async function mintWithRetry( + mint: () => Promise<{ payUrl: string }>, + directPayEnabled: boolean, + sleep: (ms: number) => Promise = async () => {}, +): Promise<{ payUrl?: string; paymentStartFailed: boolean; attempts: number }> { + let payUrl: string | undefined; + let paymentStartFailed = false; + let attempts = 0; + if (directPayEnabled) { + const MINT_ATTEMPTS = 3; + for (let attempt = 1; attempt <= MINT_ATTEMPTS; attempt++) { + attempts = attempt; + try { + payUrl = (await mint()).payUrl; + break; + } catch { + if (attempt !== MINT_ATTEMPTS) { + await sleep(400 * attempt); + continue; + } + paymentStartFailed = true; + } + } + } + return { payUrl, paymentStartFailed, attempts }; +} + +describe("direct-pay checkout — never report a placed order without payment", () => { + it("retries a transient mint failure and succeeds (the 502 case)", async () => { + const mint = vi + .fn() + .mockRejectedValueOnce(new Error("502 Bad Gateway")) + .mockResolvedValue({ payUrl: "https://pay/abc" }); + + const r = await mintWithRetry(mint, true); + + expect(r.payUrl).toBe("https://pay/abc"); + expect(r.paymentStartFailed).toBe(false); + expect(r.attempts).toBe(2); + }); + + it("gives up after 3 attempts and flags paymentStartFailed", async () => { + const mint = vi.fn().mockRejectedValue(new Error("502 Bad Gateway")); + + const r = await mintWithRetry(mint, true); + + expect(r.payUrl).toBeUndefined(); + expect(r.paymentStartFailed).toBe(true); + expect(mint).toHaveBeenCalledTimes(3); + }); + + it("does NOT flag email-link stores — an unminted order is correct there", async () => { + const mint = vi.fn(); + + const r = await mintWithRetry(mint, false); + + expect(mint).not.toHaveBeenCalled(); + expect(r.payUrl).toBeUndefined(); + expect(r.paymentStartFailed).toBe(false); + }); + + it("a successful first attempt does not retry", async () => { + const mint = vi.fn().mockResolvedValue({ payUrl: "https://pay/xyz" }); + + const r = await mintWithRetry(mint, true); + + expect(r.attempts).toBe(1); + expect(r.paymentStartFailed).toBe(false); + }); +}); + +describe("storefront response handling", () => { + // Mirrors app/store/[slug]/checkout/page.tsx. + const decide = (order: { payUrl?: string; paymentStartFailed?: boolean }) => { + if (order.payUrl) return "redirect-to-pay"; + if (order.paymentStartFailed) return "show-error-keep-cart"; + return "show-confirmation-clear-cart"; + }; + + it("redirects to payment when minted", () => { + expect(decide({ payUrl: "https://pay/abc" })).toBe("redirect-to-pay"); + }); + + it("keeps the cart and shows an error when payment could not start", () => { + // Previously: 'show-confirmation-clear-cart' — the LekkerWeed bug. + expect(decide({ paymentStartFailed: true })).toBe("show-error-keep-cart"); + }); + + it("still confirms an email-link order, which has no payUrl by design", () => { + expect(decide({ paymentStartFailed: false })).toBe( + "show-confirmation-clear-cart", + ); + }); +});