Hide failed-payment order attempts from the customer's own order history

A cancelled order with no invoiceNumber is a Stripe payment that never
succeeded (failed or timed out before ever reaching received/invoiced),
not a real cancellation of something that actually happened — from the
customer's point of view it was never really an order. Filtered out of
getCustomerOrders/getCustomerOrderDetail by default; the row stays in
Payload for admin/audit purposes (shown there as "Zahlung
fehlgeschlagen", see backend).

getCustomerOrderDetail's filter is opt-in via a new optional parameter,
not the default — /api/checkout/status/route.ts's post-payment polling
needs to keep seeing exactly this order to show the "Zahlung
fehlgeschlagen, bitte erneut versuchen" retry state. The GDPR export
route also opts out for the same reason a legal completeness export
can't silently drop rows.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Marco
2026-07-25 16:05:26 +00:00
parent 0e12ab1f1e
commit bae23775f2
4 changed files with 67 additions and 7 deletions
+2 -2
View File
@@ -10,9 +10,9 @@ export async function GET() {
if (!session) return NextResponse.json({ ok: false, reason: "Bitte zuerst einloggen." }, { status: 401 });
const profile = await getCustomerProfile(session.token);
const orderSummaries = await getCustomerOrders(session.token, session.customer.id);
const orderSummaries = await getCustomerOrders(session.token, session.customer.id, false);
const orders = await Promise.all(
orderSummaries.map((o) => getCustomerOrderDetail(session.token, session.customer.id, o.orderNumber)),
orderSummaries.map((o) => getCustomerOrderDetail(session.token, session.customer.id, o.orderNumber, false)),
);
const payload = {
@@ -33,7 +33,7 @@ export default async function KontoBestellungDetailPage({ params }: { params: Pr
const session = await getSessionCustomer();
if (!session) redirect("/konto/login");
const order = await getCustomerOrderDetail(session.token, session.customer.id, decodeURIComponent(orderNumber));
const order = await getCustomerOrderDetail(session.token, session.customer.id, decodeURIComponent(orderNumber), true);
if (!order) notFound();
const address =
+40 -3
View File
@@ -416,9 +416,33 @@ export type CustomerOrder = {
productIds: number[];
};
export async function getCustomerOrders(token: string, customerId: number): Promise<CustomerOrder[]> {
// Excludes orders that never actually happened from the customer's own
// point of view — a `pending_payment` order whose Stripe payment failed
// (or timed out, see the backend's expirePendingPayments job) transitions
// straight to `cancelled` without ever getting an `invoiceNumber`
// (deferred until payment confirms, see confirmPayment.ts). A *real*
// cancellation (Storno) is always of an already-`received`, already-
// invoiced order, so `invoiceNumber` is always present there. That
// distinction — `status: 'cancelled'` with no `invoiceNumber` — is what
// separates "a real order that got cancelled" (show it) from "a checkout
// attempt whose payment never went through" (nothing to show — the row
// stays in Payload for admin/audit purposes, just not surfaced here).
function excludeFailedPaymentAttemptsQuery(): Record<string, string> {
return {
"where[and][1][or][0][status][not_equals]": "cancelled",
"where[and][1][or][1][invoiceNumber][exists]": "true",
};
}
// `excludeFailedPaymentAttempts` defaults to true (list views) — the one
// exception is /api/account/export/route.ts's GDPR data export, which
// passes false: a legal completeness export must include every order
// row that exists about this customer, not just the ones normally shown
// in "Meine Bestellungen".
export async function getCustomerOrders(token: string, customerId: number, excludeFailedPaymentAttempts = true): Promise<CustomerOrder[]> {
const params = new URLSearchParams({
"where[customer][equals]": String(customerId),
"where[and][0][customer][equals]": String(customerId),
...(excludeFailedPaymentAttempts ? excludeFailedPaymentAttemptsQuery() : {}),
sort: "-createdAt",
depth: "0",
limit: "50",
@@ -508,10 +532,23 @@ export type CustomerOrderItem = {
// caller round-trip a full, valid items array back on a return request
// (Orders.ts's field-lock hook needs every required item field present,
// not just returnQuantity — see that hook's own comment).
export async function getCustomerOrderDetail(token: string, customerId: number, orderNumber: string): Promise<CustomerOrderDetail | null> {
// `excludeFailedPaymentAttempts` defaults to false because this function
// is shared with /api/checkout/status/route.ts's polling right after a
// Stripe payment fails — that flow needs to keep seeing the
// `cancelled`/no-`invoiceNumber` order (to show "Zahlung fehlgeschlagen,
// bitte erneut versuchen") for exactly the same order this flag would
// otherwise hide. Only /konto/bestellungen/[orderNumber] (a customer
// browsing their own history, not mid-checkout) opts in.
export async function getCustomerOrderDetail(
token: string,
customerId: number,
orderNumber: string,
excludeFailedPaymentAttempts = false,
): Promise<CustomerOrderDetail | null> {
const params = new URLSearchParams({
"where[orderNumber][equals]": orderNumber,
"where[customer][equals]": String(customerId),
...(excludeFailedPaymentAttempts ? excludeFailedPaymentAttemptsQuery() : {}),
depth: "0",
limit: "1",
});