fix: ein abgebrochener Checkout hinterlaesst keine Bestellung mehr
Bisher blieb nach einem Abbruch eine Bestellung im Status "STORNIERT" stehen. Im Admin-Bereich sah damit jeder weggeklickte Warenkorb aus wie ein echter, gescheiterter Verkauf — bei einem Shop, in dem die meisten Besuche nicht im Kauf enden, verdecken diese Einträge irgendwann die tatsächlichen Vorgänge. Ein nie bezahlter Checkout wird deshalb gelöscht statt storniert. Es gab kein Geschäft, keine Zahlung, keine Bestätigung und keine Rechnung, also auch nichts aufzubewahren; die Positionen gehen mit (onDelete: Cascade). Betroffen sind der Abbruch-Knopf, die abgelaufene Stripe-Sitzung und der Aufräumlauf. Eine Ausnahme bleibt bewusst bestehen: checkout.session.async_payment_failed. Dort wurde tatsächlich eine Zahlung versucht und ist gescheitert (SEPA-Lastschrift, teils Klarna) — die Kundschaft hat den Checkout abgeschlossen und rechnet mit einer Lieferung. Dieser Fall bleibt als "STORNIERT" nachvollziehbar und wird weiterhin per E-Mail beantwortet. Beim bloßen Abbruch entfällt die Mail: eine Nachricht über einen Vorgang, den man selbst abgebrochen hat, verwirrt mehr als sie hilft. releaseUnpaidOrder unterscheidet die beiden Fälle jetzt über einen Parameter. Beim Löschen ist deleteMany mit der Statusbedingung der atomare Zugriff — es entfernt entweder genau diese eine noch unbezahlte Bestellung oder nichts. Hat inzwischen eine eingehende Zahlung den Status auf "BEZAHLT" gesetzt, fängt count === 0 das ab: eine bezahlte Bestellung darf dieser Weg unter keinen Umständen entfernen. Die Positionen werden vor dem Löschen gelesen, sonst wäre der Bestand danach nicht mehr zurückzubuchen. Die Reservierungsdauer bleibt bei 32 Minuten. Kürzer geht nicht: Stripe lässt als Untergrenze 30 Minuten zu (die zwei Minuten sind Puffer gegen auseinanderlaufende Uhren). Eine kürzere Reservierung ließe sich nur bauen, indem die Sitzung vorzeitig per API geschlossen wird — das hieße, einer Kundschaft, die gerade eine 3-D-Secure-Abfrage beantwortet, die Zahlung unter den Händen wegzunehmen. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
1 parent
a3f91b83fc
commit
dda0a66c6c
5 files changed
+105
-50
No files matched your search
@@ -157,7 +157,7 @@ SEPA-Lastschrift und teils Klarna bestätigen die Zahlung **verzögert** (bei SE
|
||||
- `checkout.session.completed` markiert die Bestellung nur dann als bezahlt, wenn `payment_status` nicht `unpaid` ist (bei Kartenzahlung sofort der Fall).
|
||||
- `checkout.session.async_payment_succeeded` markiert die Bestellung nachträglich als bezahlt, sobald die verzögerte Zahlung tatsächlich eingegangen ist (löst Bestätigungs-E-Mail und Rechnung aus).
|
||||
- `checkout.session.async_payment_failed` storniert die Bestellung, bucht reservierten Bestand zurück und informiert die Kundschaft per E-Mail, falls der Zahlungseinzug fehlschlägt.
|
||||
- `checkout.session.expired` behandelt eine von der Kundschaft einfach verlassene (nicht bezahlte) Checkout-Sitzung genauso wie einen fehlgeschlagenen Zahlungseinzug — storniert die Bestellung und bucht den beim Checkout reservierten Bestand zurück, statt ihn dauerhaft zu blockieren.
|
||||
- `checkout.session.expired` behandelt eine von der Kundschaft einfach verlassene (nicht bezahlte) Checkout-Sitzung anders als einen fehlgeschlagenen Einzug: der reservierte Bestand geht zurück ins Regal und die Bestellung wird **gelöscht**, nicht storniert. Es gab keine Zahlung, keine Bestätigung und keine Rechnung — also auch nichts aufzubewahren. Eine stornierte Bestellung stehen zu lassen würde nur verwirren: im Admin-Bereich sähe jeder weggeklickte Warenkorb aus wie ein echter, gescheiterter Verkauf.
|
||||
|
||||
Für einen produktiven Webhook-Endpoint müssen alle vier Events abonniert werden; `stripe listen` leitet lokal ohnehin alle Events weiter.
|
||||
|
||||
@@ -169,11 +169,13 @@ Eine Bestellung entsteht **vor** der Zahlung und reserviert dabei sofort Lagerbe
|
||||
|
||||
| Wann | Was passiert |
|
||||
|------|--------------|
|
||||
| Klick auf „Abbrechen“ bei Stripe | `/checkout/cancel?ref=<Token>` schließt die Stripe-Sitzung und bucht den Bestand **sofort** zurück |
|
||||
| Checkout einfach verlassen | Die Sitzung läuft nach **32 Minuten** ab, Stripe schickt `checkout.session.expired`, der Webhook bucht zurück |
|
||||
| Klick auf „Abbrechen“ bei Stripe | `/checkout/cancel?ref=<Token>` schließt die Stripe-Sitzung, bucht den Bestand **sofort** zurück und löscht die Bestellung |
|
||||
| Checkout einfach verlassen | Die Sitzung läuft nach **32 Minuten** ab, Stripe schickt `checkout.session.expired`, der Webhook bucht zurück und löscht |
|
||||
| Webhook kommt nicht an | Der CronJob `wabenhain-release-stale-orders` prüft alle 30 Minuten bei Stripe nach und bucht zurück, was dort abgelaufen ist |
|
||||
|
||||
Die **32 Minuten** sind eine bewusste Abweichung von Stripes Voreinstellung (24 Stunden, `expires_at` in `api/checkout/route.ts`). Mit einem Tag Reservierungsdauer könnte jemand den Shop mit wenigen Klicks leerräumen, ohne einen Cent zu zahlen — Stripe lässt als Untergrenze 30 Minuten zu, die zwei Minuten Aufschlag sind Puffer gegen auseinanderlaufende Uhren.
|
||||
**Ein abgebrochener Checkout hinterlässt keine Bestellung.** Weder im Admin-Bereich noch im Kundenkonto bleibt etwas stehen — mit der Bestellung verschwinden auch ihre Positionen (`onDelete: Cascade`). Aufbewahrungspflichten entstehen dabei nicht: ohne Zahlung gibt es kein Geschäft und keine Rechnung. Die Ausnahme ist `checkout.session.async_payment_failed`: dort wurde tatsächlich eine Zahlung versucht und ist gescheitert, die Kundschaft rechnet mit einer Lieferung. Diese Bestellung bleibt als **STORNIERT** bestehen und die Kundschaft bekommt eine E-Mail dazu.
|
||||
|
||||
Die **32 Minuten** sind eine bewusste Abweichung von Stripes Voreinstellung (24 Stunden, `expires_at` in `api/checkout/route.ts`). Mit einem Tag Reservierungsdauer könnte jemand den Shop mit wenigen Klicks leerräumen, ohne einen Cent zu zahlen. Kürzer geht nicht: **Stripe lässt als Untergrenze 30 Minuten zu**, die zwei Minuten Aufschlag sind Puffer gegen auseinanderlaufende Uhren.
|
||||
|
||||
Der **Abbruch-Token** (`Order.cancelToken`, 32 Byte) steht ausschließlich in der `cancel_url` und wird nach Gebrauch aus der Adresszeile entfernt. Die Bestellnummer taugt dafür nicht: sie steht in jeder Bestätigungsmail und auf jeder Rechnung, mit ihr ließe sich also die noch offene Zahlung einer fremden Person abbrechen. Vor dem Zurückbuchen wird immer erst bei Stripe nachgesehen — ist die Sitzung dort `complete`, war der Aufruf kein Abbruch, sondern ein Zurück-Klick nach erfolgreicher Zahlung, und es passiert nichts.
|
||||
|
||||
@@ -394,7 +396,7 @@ Ein einzelnes Image unterstützt beide Datenbank-Provider gleichzeitig (siehe
|
||||
- **Gutschein-Einlösung race-sicher**: bei einem Nutzungslimit zählt die Prüfung beim Checkout zusätzlich zu `usedCount` auch bereits laufende, aber noch unbezahlte Bestellungen mit demselben Code — verhindert, dass mehrere parallele Checkout-Versuche einen `maxUses:1`-Code alle gleichzeitig als gültig durchgehen lassen.
|
||||
- **Rate-Limit-IP vertrauenswürdig ermittelt**: die Client-IP für alle Limits (Login, 2FA, Checkout, Registrierung, Passwort-Reset) kommt aus dem letzten, vom eigenen Reverse-Proxy gesetzten `X-Forwarded-For`-Eintrag, nicht dem ersten (vom Client frei wählbaren).
|
||||
- **Admin-Sperre invalidiert aktive Sessions sofort** statt erst beim natürlichen Cookie-Ablauf, und ein Passwort-Reset kann eine Sperre nicht mehr eigenständig umgehen.
|
||||
- **Zahlungsbestätigung transaktional und idempotent**: der Webhook-Handler markiert eine Bestellung nur aus dem Status "NEU" heraus als bezahlt (verhindert doppelte Gutschein-Zählung bei wiederholter Webhook-Zustellung und verhindert, dass eine bereits stornierte Bestellung durch eine verspätete Zahlungsbestätigung wiederbelebt wird) und behandelt eine abgelaufene, nie bezahlte Checkout-Sitzung (`checkout.session.expired`) wie einen fehlgeschlagenen Zahlungseinzug.
|
||||
- **Zahlungsbestätigung transaktional und idempotent**: der Webhook-Handler markiert eine Bestellung nur aus dem Status "NEU" heraus als bezahlt (verhindert doppelte Gutschein-Zählung bei wiederholter Webhook-Zustellung und verhindert, dass eine bereits stornierte Bestellung durch eine verspätete Zahlungsbestätigung wiederbelebt wird) und löst eine abgelaufene, nie bezahlte Checkout-Sitzung (`checkout.session.expired`) samt Bestandsreservierung wieder auf.
|
||||
- **Admin-Zugriff**: kein separates Passwort/Basic-Auth mehr — die Rolle des eingeloggten Kontos entscheidet (siehe „Admin-Zugang" oben). Zweistufig geprüft: Proxy auf Request-Ebene (JWT-Claim) und jede Admin-Server-Action zusätzlich autoritativ gegen die Datenbank.
|
||||
- **Checkout**: Lagerbestand wird transaktional geprüft und reserviert (kein Überverkauf bei parallelen Bestellungen); Stornierung im Admin bucht den Bestand zurück. Bestell-/Rechnungsnummern kommen aus `crypto.randomBytes`.
|
||||
- **Stripe-Webhook**: Signaturprüfung über `STRIPE_WEBHOOK_SECRET`.
|
||||
|
||||
@@ -95,11 +95,14 @@ async function main(): Promise<void> {
|
||||
continue;
|
||||
}
|
||||
|
||||
const result = await releaseUnpaidOrder(order.id, 0);
|
||||
// "delete": nie bezahlt, nie bestaetigt — die Vormerkung verschwindet
|
||||
// mitsamt der Bestellung, statt als "STORNIERT" im Admin-Bereich wie
|
||||
// ein gescheiterter Verkauf auszusehen.
|
||||
const result = await releaseUnpaidOrder(order.id, "delete");
|
||||
if (result) {
|
||||
released += 1;
|
||||
console.log(
|
||||
`[release-stale-orders] ${result.orderNumber}: Bestand freigegeben (Sitzung abgelaufen).`
|
||||
`[release-stale-orders] ${result.orderNumber}: Bestand freigegeben, Vormerkung entfernt (Sitzung abgelaufen).`
|
||||
);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { getStripeClient } from "@/lib/stripe";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { sendMail } from "@/lib/mail";
|
||||
import { orderConfirmationEmail, paymentFailedEmail } from "@/lib/email-templates";
|
||||
import { releaseUnpaidOrder } from "@/lib/order-release";
|
||||
import { releaseUnpaidOrder, type ReleaseMode } from "@/lib/order-release";
|
||||
import { getBaseUrl } from "@/lib/url";
|
||||
import { getInvoiceByNumber } from "@/lib/account-data";
|
||||
import { generateInvoicePdf } from "@/lib/invoice-pdf";
|
||||
@@ -113,12 +113,20 @@ export async function POST(request: Request) {
|
||||
|
||||
if (event.type === "checkout.session.async_payment_failed") {
|
||||
const session = event.data.object as Stripe.Checkout.Session;
|
||||
await failOrder(session);
|
||||
// Hier wurde tatsaechlich eine Zahlung versucht und ist gescheitert. Die
|
||||
// Kundschaft hat den Checkout abgeschlossen und rechnet mit einer
|
||||
// Lieferung — das gehoert nachvollziehbar festgehalten und beantwortet.
|
||||
await failOrder(session, "cancel");
|
||||
}
|
||||
|
||||
if (event.type === "checkout.session.expired") {
|
||||
const session = event.data.object as Stripe.Checkout.Session;
|
||||
await failOrder(session);
|
||||
// Hier wurde nie bezahlt und nie etwas bestaetigt: jemand hat den
|
||||
// Checkout stehen lassen. Die Bestellung verschwindet spurlos, statt als
|
||||
// "STORNIERT" im Admin-Bereich wie ein gescheiterter Verkauf auszusehen.
|
||||
// Ohne Zahlung gibt es keine Nachricht darueber: eine Mail zu einem
|
||||
// Vorgang, den man selbst abgebrochen hat, verwirrt mehr als sie hilft.
|
||||
await failOrder(session, "delete");
|
||||
}
|
||||
|
||||
return NextResponse.json({ received: true });
|
||||
@@ -313,19 +321,22 @@ async function fulfillOrder(session: Stripe.Checkout.Session): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function failOrder(session: Stripe.Checkout.Session): Promise<void> {
|
||||
async function failOrder(session: Stripe.Checkout.Session, mode: ReleaseMode): Promise<void> {
|
||||
const orderId = session.metadata?.orderId;
|
||||
if (!orderId) return;
|
||||
|
||||
// Stornieren, Bestand zurueckbuchen und Zahlung als fehlgeschlagen
|
||||
// vermerken liegt in lib/order-release.ts, weil derselbe Vorgang auch beim
|
||||
// Abbruch durch die Kundschaft (checkout/cancel) und im Aufraeumlauf
|
||||
// (scripts/release-stale-orders.ts) gebraucht wird. Kommt der Webhook
|
||||
// nach einem dieser beiden an, liefert der Helfer null — die Bestellung ist
|
||||
// dann nicht mehr "NEU" und der Bestand laengst zurueckgebucht.
|
||||
const result = await releaseUnpaidOrder(orderId, session.amount_total ?? 0);
|
||||
// Bestand zurueckbuchen und die Bestellung aufloesen liegt in
|
||||
// lib/order-release.ts, weil derselbe Vorgang auch beim Abbruch durch die
|
||||
// Kundschaft (checkout/cancel) und im Aufraeumlauf
|
||||
// (scripts/release-stale-orders.ts) gebraucht wird. Kommt der Webhook nach
|
||||
// einem dieser beiden an, liefert der Helfer null — die Bestellung ist dann
|
||||
// nicht mehr "NEU" und der Bestand laengst zurueckgebucht.
|
||||
const result = await releaseUnpaidOrder(orderId, mode, session.amount_total ?? 0);
|
||||
if (!result) return;
|
||||
|
||||
if (result) {
|
||||
// Nur beim tatsaechlich fehlgeschlagenen Einzug: dort wartet jemand auf
|
||||
// Ware und muss erfahren, dass daraus nichts wird.
|
||||
if (mode === "cancel") {
|
||||
const { subject, html, text } = await paymentFailedEmail({
|
||||
locale: isLocale(result.locale) ? result.locale : defaultLocale,
|
||||
orderNumber: result.orderNumber,
|
||||
|
||||
@@ -80,11 +80,17 @@ async function releaseCheckout(cancelToken: string): Promise<void> {
|
||||
// Uebrig bleiben "open" (gerade geschlossen) und "expired" — in beiden
|
||||
// Faellen kann nicht mehr bezahlt werden. Stripe schickt dazu ein
|
||||
// checkout.session.expired-Ereignis; der Webhook findet die Bestellung
|
||||
// dann nicht mehr im Status "NEU" und tut nichts weiter (siehe
|
||||
// lib/order-release.ts).
|
||||
const released = await releaseUnpaidOrder(order.id, 0);
|
||||
// dann nicht mehr und tut nichts weiter (siehe lib/order-release.ts).
|
||||
//
|
||||
// "delete": hier wurde nie bezahlt und nie etwas bestaetigt. Eine
|
||||
// stornierte Bestellung stehen zu lassen wuerde nur verwirren — im
|
||||
// Admin-Bereich saehe jeder weggeklickte Warenkorb aus wie ein echter,
|
||||
// gescheiterter Verkauf.
|
||||
const released = await releaseUnpaidOrder(order.id, "delete");
|
||||
if (released) {
|
||||
console.info(`[checkout-cancel] Bestellung ${released.orderNumber} abgebrochen, Bestand freigegeben.`);
|
||||
console.info(
|
||||
`[checkout-cancel] Bestellung ${released.orderNumber} abgebrochen, Bestand freigegeben und Vormerkung entfernt.`
|
||||
);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
// Nicht weiterwerfen: die Person hat gerade abgebrochen und soll die
|
||||
|
||||
+60
-27
@@ -9,43 +9,59 @@ export interface ReleasedOrder {
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt eine unbezahlte Bestellung wieder frei: Status auf "STORNIERT", der
|
||||
* beim Checkout reservierte Lagerbestand zurueck ins Regal, Zahlung als
|
||||
* "FAILED" vermerkt.
|
||||
* Was mit einer unbezahlten Bestellung geschehen soll.
|
||||
*
|
||||
* "delete" — die Bestellung wird spurlos entfernt. Richtig fuer alles, was nie
|
||||
* bezahlt und nie bestaetigt wurde: den Abbruch bei Stripe und die abgelaufene
|
||||
* Sitzung. Eine stornierte Bestellung stehen zu lassen stiftet nur Verwirrung
|
||||
* — im Admin-Bereich saehe jeder weggeklickte Warenkorb aus wie ein echter,
|
||||
* gescheiterter Verkauf. Aufbewahrungspflichten entstehen dadurch nicht: es
|
||||
* gab kein Geschaeft, keine Zahlung und keine Rechnung.
|
||||
*
|
||||
* "cancel" — die Bestellung bleibt als "STORNIERT" bestehen. Richtig, wenn
|
||||
* tatsaechlich eine Zahlung versucht wurde und fehlschlug (SEPA-Lastschrift,
|
||||
* teils Klarna: die Kundschaft hat den Checkout abgeschlossen und rechnet mit
|
||||
* einer Lieferung). Hier gibt es etwas nachzuvollziehen, und die Kundschaft
|
||||
* bekommt eine Nachricht dazu.
|
||||
*/
|
||||
export type ReleaseMode = "delete" | "cancel";
|
||||
|
||||
/**
|
||||
* Gibt den beim Checkout reservierten Lagerbestand wieder frei.
|
||||
*
|
||||
* Eine Bestellung entsteht BEVOR die Stripe-Sitzung geoeffnet wird, und sie
|
||||
* reserviert dabei sofort Bestand — sonst koennte zwischen Kaufentscheidung
|
||||
* und Zahlung jemand anders dasselbe Glas kaufen. Der Preis dafuer: jeder
|
||||
* nicht zu Ende gefuehrte Checkout haelt Ware fest, die niemand bezahlt hat.
|
||||
* Diese Funktion ist die Gegenbuchung dazu und wird von allen drei Wegen
|
||||
* benutzt, auf denen das auffallen kann: dem Abbruch durch die Kundschaft,
|
||||
* dem Ablauf der Stripe-Sitzung (Webhook) und dem Aufraeumlauf
|
||||
* (scripts/release-stale-orders.ts).
|
||||
* Diese Funktion ist die Gegenbuchung dazu und wird von allen Wegen benutzt,
|
||||
* auf denen das auffallen kann: dem Abbruch durch die Kundschaft, dem Ablauf
|
||||
* der Stripe-Sitzung (Webhook), dem fehlgeschlagenen Einzug und dem
|
||||
* Aufraeumlauf (scripts/release-stale-orders.ts).
|
||||
*
|
||||
* Der Statuswechsel ist ein einzelnes bedingtes UPDATE ("nur wenn noch NEU").
|
||||
* Ein getrenntes Lesen und Schreiben wuerde bei zwei gleichzeitigen Ausloesern
|
||||
* — etwa Abbruch-Klick und Webhook im selben Moment — beide Seiten noch "NEU"
|
||||
* lesen lassen, und der Bestand wuerde doppelt gutgeschrieben. Genau deshalb
|
||||
* gibt diese Funktion beim zweiten Aufruf null zurueck: nicht als Fehler,
|
||||
* sondern als Aussage "hat schon jemand anders erledigt".
|
||||
* Der Zugriff ist in beiden Betriebsarten ein einzelnes bedingtes Schreiben
|
||||
* ("nur wenn noch NEU"). Ein getrenntes Lesen und Schreiben wuerde bei zwei
|
||||
* gleichzeitigen Ausloesern — etwa Abbruch-Klick und Webhook im selben Moment
|
||||
* — beide Seiten noch "NEU" lesen lassen, und der Bestand wuerde doppelt
|
||||
* gutgeschrieben. Genau deshalb gibt diese Funktion beim zweiten Aufruf null
|
||||
* zurueck: nicht als Fehler, sondern als Aussage "hat schon jemand anders
|
||||
* erledigt".
|
||||
*
|
||||
* @returns die freigegebene Bestellung, oder null wenn sie nicht (mehr) im
|
||||
* Status "NEU" war und deshalb nichts zu tun ist.
|
||||
*/
|
||||
export async function releaseUnpaidOrder(
|
||||
orderId: string,
|
||||
paidAmountCents: number
|
||||
mode: ReleaseMode,
|
||||
paidAmountCents = 0
|
||||
): Promise<ReleasedOrder | null> {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const claim = await tx.order.updateMany({
|
||||
where: { id: orderId, status: "NEU" },
|
||||
data: { status: "STORNIERT" },
|
||||
});
|
||||
if (claim.count === 0) return null;
|
||||
|
||||
const previous = await tx.order.findUniqueOrThrow({
|
||||
// Vor dem Schreiben lesen, nicht danach: beim Loeschen verschwinden die
|
||||
// Positionen mit der Bestellung (onDelete: Cascade), und ohne sie liesse
|
||||
// sich der Bestand nicht mehr zurueckbuchen.
|
||||
const previous = await tx.order.findUnique({
|
||||
where: { id: orderId },
|
||||
select: {
|
||||
status: true,
|
||||
orderNumber: true,
|
||||
email: true,
|
||||
firstName: true,
|
||||
@@ -53,15 +69,32 @@ export async function releaseUnpaidOrder(
|
||||
items: { select: { productId: true, quantity: true } },
|
||||
},
|
||||
});
|
||||
if (!previous || previous.status !== "NEU") return null;
|
||||
|
||||
if (mode === "delete") {
|
||||
// deleteMany mit der Statusbedingung ist hier der atomare Zugriff: es
|
||||
// loescht entweder genau diese eine noch unbezahlte Bestellung oder
|
||||
// nichts. Hat inzwischen eine eingehende Zahlung den Status auf
|
||||
// "BEZAHLT" gesetzt, faengt count === 0 das ab — eine bezahlte
|
||||
// Bestellung darf dieser Weg unter keinen Umstaenden entfernen.
|
||||
const deleted = await tx.order.deleteMany({ where: { id: orderId, status: "NEU" } });
|
||||
if (deleted.count === 0) return null;
|
||||
} else {
|
||||
const claim = await tx.order.updateMany({
|
||||
where: { id: orderId, status: "NEU" },
|
||||
data: { status: "STORNIERT" },
|
||||
});
|
||||
if (claim.count === 0) return null;
|
||||
|
||||
await tx.payment.upsert({
|
||||
where: { orderId },
|
||||
create: { orderId, status: "FAILED", amountCents: paidAmountCents },
|
||||
update: { status: "FAILED" },
|
||||
});
|
||||
}
|
||||
|
||||
await restockOrderItems(tx, previous.items);
|
||||
|
||||
await tx.payment.upsert({
|
||||
where: { orderId },
|
||||
create: { orderId, status: "FAILED", amountCents: paidAmountCents },
|
||||
update: { status: "FAILED" },
|
||||
});
|
||||
|
||||
return {
|
||||
orderNumber: previous.orderNumber,
|
||||
email: previous.email,
|
||||
|
||||
Reference in new issue
Block a user