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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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",
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user