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
94 changes: 63 additions & 31 deletions nextjs_space/app/api/store/[slug]/orders/submit/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/<slug>" 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/<slug>" 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;
Comment on lines +214 to +237

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Validate payUrl before treating a mint attempt as successful.

createDirectCheckout's own implementation notes Dr Green responses are "sometimes wrapped in { data }, sometimes raw" — i.e. parsing ambiguity is a known risk. If a malformed/edge-case response resolves with an empty or missing payUrl, this loop still treats it as success (break), leaving paymentStartFailed = false. The storefront then falls through to setOrderResult(...); clearCart() (checkout/page.tsx data.order?.payUrl check fails, paymentStartFailed is falsy) — reproducing the exact incident this PR fixes: a placed-order confirmation and cleared cart with no way to pay.

🐛 Proposed fix
           const checkout = await createDirectCheckout({
             ...
           });
-          payUrl = checkout.payUrl;
+          if (!checkout.payUrl) {
+            throw new Error("createDirectCheckout returned no payUrl");
+          }
+          payUrl = checkout.payUrl;
           log("DIRECT_CHECKOUT", {
             hasPayUrl: !!payUrl,
             expiresAt: checkout.expiresAt,
             attempt,
           });
           break;

Consider adding a corresponding test case in direct-pay-no-phantom-order.test.ts (mint resolves with an empty payUrl) alongside the existing retry/failure cases.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const checkout = await createDirectCheckout({
drGreenOrderId: orderResponse.drGreenOrderId,
// Tenant hosts ({slug}.budstacks.io / custom domains) serve the
// store at root; the legacy "/store/<slug>" 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;
const checkout = await createDirectCheckout({
drGreenOrderId: orderResponse.drGreenOrderId,
// Tenant hosts ({slug}.budstacks.io / custom domains) serve the
// store at root; the legacy "/store/<slug>" 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),
});
if (!checkout.payUrl) {
throw new Error("createDirectCheckout returned no payUrl");
}
payUrl = checkout.payUrl;
log("DIRECT_CHECKOUT", {
hasPayUrl: !!payUrl,
expiresAt: checkout.expiresAt,
attempt,
});
break;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@nextjs_space/app/api/store/`[slug]/orders/submit/route.ts around lines 214 -
237, Validate checkout.payUrl immediately after createDirectCheckout returns and
only break the mint retry loop when it is non-empty; treat a missing or empty
value as a failed attempt so the existing retry/failure path sets
paymentStartFailed and prevents clearing the cart without a payment URL. Update
the relevant direct-pay test coverage to include a mint response with an empty
payUrl.

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

Expand Down Expand Up @@ -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 });
Expand Down
77 changes: 74 additions & 3 deletions nextjs_space/app/store/[slug]/checkout/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,47 @@ export default function CheckoutPage({ params }: { params: { slug: string } }) {
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [orderResult, setOrderResult] = useState<OrderResult | null>(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);
}
};
Comment on lines +42 to +74

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
fd -p 'orders/\[id\]|orders/\[orderId\]' nextjs_space/app/api/store
rg -n -A 20 'export (async )?function POST' nextjs_space/app/api/store/'[slug]'/orders/'[id]'/pay/route.ts 2>/dev/null
fd -e ts pay --full-path nextjs_space/app/api/store

Repository: AutomatosAI/budstack-saas

Length of output: 802


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== checkout file outline =="
ast-grep outline nextjs_space/app/store/[slug]/checkout/page.tsx --view expanded || true
echo "== checkout lines 1-120 =="
sed -n '1,120p' nextjs_space/app/store/[slug]/checkout/page.tsx
echo "== checkout lines 200-320 =="
sed -n '200,320p' nextjs_space/app/store/[slug]/checkout/page.tsx

echo "== pay route =="
sed -n '1,260p' nextjs_space/app/api/store/[slug]/orders/[orderId]/pay/route.ts

echo "== payment status route (for contract context maybe) =="
sed -n '1,220p' nextjs_space/app/api/store/[slug]/orders/[orderId]/payment-status/route.ts

echo "== all references to data.paid / payUrl / pendingPaymentOrderId =="
rg -n "data\.paid|payUrl|pendingPaymentOrderId|setOrderResult|OrderResult|items\.length" nextjs_space/app nextjs_space/app/api -S

Repository: AutomatosAI/budstack-saas

Length of output: 19458


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== submit post/payment status relevant sections =="
sed -n '220,300p' nextjs_space/app/api/store/[slug]/orders/submit/route.ts

echo "== return page relevant state/contracts =="
sed -n '1,160p' nextjs_space/app/store/[slug]/payment/return/[orderId]/page.tsx

Repository: AutomatosAI/budstack-saas

Length of output: 9751


Show the success state when retry reports a settled order.

POST /orders/{id}/pay returns { paid: true } only when the order is already PAID; clearing the cart leaves this checkout page rendering with items.length === 0 and no orderResult, so users see “Your cart is empty. Add items before checking out.” Persist the paid order details (or reuse the original order result) and render the confirmation instead. Also update submit/route.ts’s paymentStartFailed fallback so the contract for { paid } vs { payUrl }/submit error is not ambiguous.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@nextjs_space/app/store/`[slug]/checkout/page.tsx around lines 42 - 74, The
retryPayment flow must preserve the settled order’s details and render the
checkout success confirmation when the payment endpoint returns paid: true,
rather than clearing the cart and falling through to the empty-cart view; reuse
the existing orderResult state or persist equivalent paid-order data, while
leaving payUrl handling unchanged. Update submit/route.ts’s paymentStartFailed
fallback so responses clearly distinguish paid orders from payUrl responses and
submission errors.


// Saved address state
const [savedAddress, setSavedAddress] = useState<ShippingAddress | null>(null);
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -640,9 +694,26 @@ export default function CheckoutPage({ params }: { params: { slug: string } }) {
)}

{error && (
<div className="flex items-start gap-2 bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg text-sm">
<AlertCircle className="w-4 h-4 flex-shrink-0 mt-0.5" />
<span>{error}</span>
<div className="flex flex-col gap-3 bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg text-sm">
<div className="flex items-start gap-2">
<AlertCircle className="w-4 h-4 flex-shrink-0 mt-0.5" />
<span>{error}</span>
</div>
{pendingPaymentOrderId && (
<Button
type="button"
onClick={retryPayment}
disabled={isRetryingPayment}
className="w-full h-11 text-sm font-semibold text-white"
style={{
backgroundColor: "hsl(var(--tenant-color-primary))",
}}
>
{isRetryingPayment
? "Starting payment…"
: "Retry payment"}
</Button>
)}
</div>
)}

Expand Down
117 changes: 117 additions & 0 deletions nextjs_space/tests/unit/direct-pay-no-phantom-order.test.ts
Original file line number Diff line number Diff line change
@@ -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<void> = 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",
);
});
});
Loading