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", + ); + }); +});