diff --git a/README.md b/README.md index e4a6cc9..412ecae 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/api/account/export/route.ts b/app/api/account/export/route.ts index 679791f..f233760 100644 --- a/app/api/account/export/route.ts +++ b/app/api/account/export/route.ts @@ -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 = { diff --git a/app/konto/bestellungen/[orderNumber]/page.tsx b/app/konto/bestellungen/[orderNumber]/page.tsx index 95f1161..d6cf50b 100644 --- a/app/konto/bestellungen/[orderNumber]/page.tsx +++ b/app/konto/bestellungen/[orderNumber]/page.tsx @@ -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 = diff --git a/app/lib/customerAuth.ts b/app/lib/customerAuth.ts index bdd9874..5290566 100644 --- a/app/lib/customerAuth.ts +++ b/app/lib/customerAuth.ts @@ -416,9 +416,33 @@ export type CustomerOrder = { productIds: number[]; }; -export async function getCustomerOrders(token: string, customerId: number): Promise { +// 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 { + 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 { 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 { +// `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 { const params = new URLSearchParams({ "where[orderNumber][equals]": orderNumber, "where[customer][equals]": String(customerId), + ...(excludeFailedPaymentAttempts ? excludeFailedPaymentAttemptsQuery() : {}), depth: "0", limit: "1", });