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
+24 -1
View File
@@ -374,7 +374,12 @@ exactly as before: no gateway involved, order goes straight to `received`.
(`app/lib/payments/confirmPaymentEmail.ts`, only when the response isn't
`alreadyProcessed: true` — a repeat webhook delivery must never resend
it), mirroring exactly what the checkout route already does inline for
a manual/Überweisung order.
a manual/Überweisung order. Product photos in that snapshot's
`items[].imageUrl` need no frontend change to work —
`ConfirmPaymentOrderSnapshot`/`OrderConfirmationItem` already typed the
field; the backend just wasn't populating it (fixed there, see its own
README — needed `depth: 2` so `item.product.image` resolves to a real
`Media` doc).
- **`/checkout/verarbeitung`** (`VerarbeitungContent.tsx`) is the
`return_url` target. Neither a client-side `confirmPayment()` success nor
landing back from a PayPal redirect is trusted as proof of payment on its
@@ -1172,6 +1177,24 @@ ref isn't attached to anything yet at that exact synchronous point.
`cancelled`/`return_requested`/`returned`) is maintained by hand in the
Payload admin for the shipping states — no shipping-carrier API
integration.
- **A `cancelled` Stripe order with no `invoiceNumber` never shows up
here** — that's a `pending_payment` order whose payment failed or timed
out (see the backend's `expirePendingPayments`/`confirmPayment.ts`), not
a real Storno (which is always of an already-`received`, already-
invoiced order, so it always has an `invoiceNumber`). From the
customer's point of view a payment that never went through was never
really an order, so `getCustomerOrders`/`getCustomerOrderDetail`
(`app/lib/customerAuth.ts`) filter these out by default — the row still
exists in Payload for admin/audit purposes (shown there as "Zahlung
fehlgeschlagen", see the backend's own README), just not surfaced to
the customer. `getCustomerOrderDetail` takes this as an **optional**
4th param, defaulting `false``/api/checkout/status/route.ts`'s
post-payment polling deliberately calls it unfiltered, since that flow
needs to keep seeing exactly this order (to show "Zahlung
fehlgeschlagen, bitte erneut versuchen") for the one case this filter
would otherwise hide. `/api/account/export/route.ts`'s GDPR export also
opts out (`false`) — a legal completeness export can't silently drop
rows.
- **`Navbar.tsx`'s `AccountLink`** (account icon, always visible in the
header itself — not duplicated inside the mobile fullscreen menu, see
"Mobile navigation" below) is the only *always*-reachable way into
+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",
});