)
) : preview ? (
diff --git a/src/modules/kunden/ui/views/kunden-view.tsx b/src/modules/kunden/ui/views/kunden-view.tsx
index 0a779a2..c567585 100644
--- a/src/modules/kunden/ui/views/kunden-view.tsx
+++ b/src/modules/kunden/ui/views/kunden-view.tsx
@@ -18,7 +18,7 @@ export type Kunde = KundeRow;
interface KundenViewProps {
initialKunden: KundeRow[];
issueOptions: PassIssueOption[];
- /** Vergebene Karten, deren Empfänger sich noch nicht registriert hat. */
+ /** Aktive Karten ohne Konto - vergeben vor der Registrierung oder Konto gelöscht. */
pendingPasses: PendingPass[];
}
@@ -68,7 +68,7 @@ const KundenView = ({ initialKunden, issueOptions, pendingPasses }: KundenViewPr
{pendingPasses.length > 0 && (
-
Vergebene Karten ohne Konto
+
Karten ohne Konto
Sobald sich jemand mit dieser E-Mail-Adresse registriert, erscheint die Karte in seinem
Konto.
@@ -83,8 +83,8 @@ const KundenView = ({ initialKunden, issueOptions, pendingPasses }: KundenViewPr
{pass.name} · {pass.creditsRemaining}{" "}
{pass.creditsRemaining === 1 ? "Einheit" : "Einheiten"}
- {pass.validUntil && ` · bis ${formatPassDate(new Date(pass.validUntil))}`} ·
- vergeben am {formatPassDate(new Date(pass.createdAt))}
+ {pass.validUntil && ` · bis ${formatPassDate(new Date(pass.validUntil))}`} · seit{" "}
+ {formatPassDate(new Date(pass.createdAt))}
))}
diff --git a/src/modules/products/actions/pass-admin-actions.ts b/src/modules/products/actions/pass-admin-actions.ts
index 8a99bc2..ee821d1 100644
--- a/src/modules/products/actions/pass-admin-actions.ts
+++ b/src/modules/products/actions/pass-admin-actions.ts
@@ -9,6 +9,7 @@ import { sendMail } from "@/src/lib/mail/resend";
import { generatePassConfirmationEmail } from "@/src/lib/mail/templates/pass-confirmation";
import { toEmailBranding } from "@/src/modules/settings/lib/to-email-branding";
import { berlinDayEnd } from "@/src/modules/courses/lib/course-days";
+import { markPassRefunded, restorePassAfterFailedRefund } from "../lib/pass-refund";
/**
* Was der Manager im Kunden-Detail mit Karten machen kann. Jede Änderung steht
@@ -176,7 +177,7 @@ export async function adjustPassCredits(input: {
manager.businesses.map((b) => b.id)
);
if (!pass) return { success: false, error: "Karte nicht gefunden." };
- if (pass.status === "refunded") {
+ if (pass.status === "refunded" || pass.status === "refunding") {
return { success: false, error: "Eine erstattete Karte lässt sich nicht mehr ändern." };
}
@@ -284,14 +285,20 @@ export async function endPass(input: { passId: string; reason: string }): Promis
);
if (!pass) return { success: false, error: "Karte nicht gefunden." };
- const { count } = await prisma.customerPass.updateMany({
- where: { id: pass.id, status: "active" },
- data: { status: "ended" },
- });
- if (count !== 1) return { success: false, error: "Die Karte ist nicht mehr aktiv." };
- await prisma.passAdjustment.create({
- data: { passId: pass.id, managerId: manager.id, type: "end", reason: reason.data || null },
+ // Status und Protokoll gemeinsam - eine beendete Karte ohne Eintrag im
+ // Verlauf wäre später nicht mehr nachvollziehbar.
+ const ended = await prisma.$transaction(async (tx) => {
+ const { count } = await tx.customerPass.updateMany({
+ where: { id: pass.id, status: "active" },
+ data: { status: "ended" },
+ });
+ if (count !== 1) return false;
+ await tx.passAdjustment.create({
+ data: { passId: pass.id, managerId: manager.id, type: "end", reason: reason.data || null },
+ });
+ return true;
});
+ if (!ended) return { success: false, error: "Die Karte ist nicht mehr aktiv." };
refreshCustomer(pass.userId);
return { success: true };
@@ -310,6 +317,13 @@ export async function endPass(input: { passId: string; reason: string }): Promis
* Der Kunde bekommt den vollen Betrag inklusive Servicegebühr zurück. Die
* Gebühr behält Kumasy, das Studio trägt sie - wie bei einer Kursabsage: wer
* zurückzahlt, trägt die Kosten (siehe stripe-refund.ts).
+ *
+ * Ablauf (siehe pass-refund.ts): Erst sperren und protokollieren, dann Stripe.
+ * Zurückgesetzt wird nur, wenn Stripe sicher abgelehnt hat. Bleibt der Ausgang
+ * offen, bleibt die Karte gesperrt, bis der Webhook die Erstattung bestätigt
+ * oder der Manager erneut prüft. Ein erneuter Aufruf ist sicher: Der
+ * Idempotency-Key verhindert eine zweite Erstattung, und eine schon erstattete
+ * Zahlung meldet Stripe als charge_already_refunded.
*/
export async function refundPass(input: { passId: string }): Promise {
const manager = await requireManager();
@@ -327,34 +341,50 @@ export async function refundPass(input: { passId: string }): Promise 0) {
- return {
- success: false,
- error: "Die Karte wurde schon genutzt und kann nicht mehr erstattet werden.",
- };
- }
+ // Beim ersten Versuch sperren - bei "refunding" lief schon einer, dessen
+ // Ausgang offen ist; dann nur erneut bei Stripe nachfragen.
+ if (pass.status !== "refunding") {
+ const used = await prisma.courseBooking.count({
+ where: { passId: pass.id, paymentStatus: { in: ["paid", "refunding", "cancelled"] } },
+ });
+ if (used > 0) {
+ return {
+ success: false,
+ error: "Die Karte wurde schon genutzt und kann nicht mehr erstattet werden.",
+ };
+ }
- // Vor dem Stripe-Aufruf sperren: ein Doppelklick erstattet nicht zweimal,
- // und eine gleichzeitige Buchung findet die Karte nicht mehr aktiv. Die
- // Bindung an den gelesenen Stand fängt eine Buchung ab, die zwischen der
- // Prüfung oben und hier Einheiten abgezogen hat.
- const { count } = await prisma.customerPass.updateMany({
- where: {
- id: pass.id,
- status: { in: ["active", "ended"] },
- creditsRemaining: pass.creditsRemaining,
- },
- data: { status: "refunded", creditsRemaining: 0 },
- });
- if (count !== 1) {
- return {
- success: false,
- error:
- "Die Karte hat sich gerade geändert. Bitte lade die Seite neu und versuche es erneut.",
- };
+ // Sperre und Protokoll gemeinsam: Danach ist die Karte nicht mehr
+ // einlösbar, egal was mit dem Stripe-Aufruf passiert. Die Bindung an den
+ // gelesenen Stand fängt eine Buchung ab, die zwischen der Prüfung oben
+ // und hier Einheiten abgezogen hat.
+ const locked = await prisma.$transaction(async (tx) => {
+ const { count } = await tx.customerPass.updateMany({
+ where: {
+ id: pass.id,
+ status: { in: ["active", "ended"] },
+ creditsRemaining: pass.creditsRemaining,
+ },
+ data: { status: "refunding", creditsRemaining: 0 },
+ });
+ if (count !== 1) return false;
+ await tx.passAdjustment.create({
+ data: {
+ passId: pass.id,
+ managerId: manager.id,
+ type: "refund",
+ creditsDelta: -pass.creditsRemaining,
+ },
+ });
+ return true;
+ });
+ if (!locked) {
+ return {
+ success: false,
+ error:
+ "Die Karte hat sich gerade geändert. Bitte lade die Seite neu und versuche es erneut.",
+ };
+ }
}
const refund = await refundStripePayment({
@@ -363,32 +393,28 @@ export async function refundPass(input: { passId: string }): Promise {
const manager = await requireManager();
const passes = await prisma.customerPass.findMany({
diff --git a/src/modules/products/lib/pass-refund.ts b/src/modules/products/lib/pass-refund.ts
new file mode 100644
index 0000000..c153b91
--- /dev/null
+++ b/src/modules/products/lib/pass-refund.ts
@@ -0,0 +1,78 @@
+import type Stripe from "stripe";
+import { prisma } from "@/src/lib/prisma";
+
+/**
+ * Abschluss einer Kartenerstattung. Eine Erstattung läuft in zwei Schritten:
+ * refundPass sperrt die Karte ("refunding", keine freien Einheiten) und
+ * protokolliert das, dann geht der Auftrag an Stripe. Erst wenn Stripe sie
+ * bestätigt - direkt in der Antwort oder später per Webhook - steht die Karte
+ * auf "refunded". Bleibt der Ausgang offen (Timeout, Netzwerk), bleibt sie
+ * gesperrt: Der Kunde hat dann vielleicht schon sein Geld und darf die
+ * Einheiten nicht mehr einlösen.
+ */
+
+/** Setzt eine gesperrte Karte auf erstattet. Wirkt nur einmal, auch bei doppelter Bestätigung. */
+export async function markPassRefunded(passId: string, amountRefundedCents: number) {
+ return prisma.customerPass.updateMany({
+ where: { id: passId, status: "refunding" },
+ data: { status: "refunded", refundedAmount: amountRefundedCents, refundedAt: new Date() },
+ });
+}
+
+/**
+ * Stripe hat die Erstattung sicher abgelehnt: Die Karte bekommt Status und
+ * Einheiten von vor der Sperre zurück. Beides steht im Protokoll - die Sperre
+ * hat die Einheiten als "refund" abgezogen, und "beendet" kann eine Karte nur
+ * über endPass werden (und nie zurück).
+ */
+export async function restorePassAfterFailedRefund(
+ passId: string,
+ managerId: string,
+ error: string
+): Promise {
+ await prisma.$transaction(async (tx) => {
+ const [lock, ended] = await Promise.all([
+ tx.passAdjustment.findFirst({
+ where: { passId, type: "refund" },
+ orderBy: { createdAt: "desc" },
+ select: { creditsDelta: true },
+ }),
+ tx.passAdjustment.count({ where: { passId, type: "end" } }),
+ ]);
+ const credits = -(lock?.creditsDelta ?? 0);
+
+ const { count } = await tx.customerPass.updateMany({
+ where: { id: passId, status: "refunding" },
+ data: { status: ended > 0 ? "ended" : "active", creditsRemaining: credits },
+ });
+ if (count !== 1) return;
+
+ await tx.passAdjustment.create({
+ data: {
+ passId,
+ managerId,
+ type: "refund_failed",
+ creditsDelta: credits,
+ reason: error.slice(0, 200),
+ },
+ });
+ });
+}
+
+/**
+ * Webhook charge.refunded: bestätigt eine Kartenerstattung, deren Ausgang beim
+ * Anstoßen offen blieb. Nur vollständig erstattete Zahlungen zählen - eine
+ * Karte wird nie teilweise erstattet.
+ */
+export async function reconcilePassRefund(charge: Stripe.Charge): Promise {
+ if (!charge.refunded) return;
+ const paymentIntentId =
+ typeof charge.payment_intent === "string" ? charge.payment_intent : charge.payment_intent?.id;
+ if (!paymentIntentId) return;
+
+ const pass = await prisma.customerPass.findFirst({
+ where: { stripePaymentIntentId: paymentIntentId, status: "refunding" },
+ select: { id: true },
+ });
+ if (pass) await markPassRefunded(pass.id, charge.amount_refunded);
+}
diff --git a/src/modules/products/lib/pass-rules.ts b/src/modules/products/lib/pass-rules.ts
index ea4d14c..3301368 100644
--- a/src/modules/products/lib/pass-rules.ts
+++ b/src/modules/products/lib/pass-rules.ts
@@ -31,11 +31,18 @@ export function canRedeemPass(pass: PassState, seats: number, now: Date): boolea
return isPassActive(pass, now) && pass.creditsRemaining >= seats;
}
-export type PassDisplayStatus = "active" | "used_up" | "expired" | "ended" | "refunded";
+export type PassDisplayStatus =
+ | "active"
+ | "used_up"
+ | "expired"
+ | "ended"
+ | "refunding"
+ | "refunded";
/** Ein Status für Badge und Sortierung. Erstattet und beendet gehen vor abgelaufen. */
export function passDisplayStatus(pass: PassState, now: Date): PassDisplayStatus {
if (pass.status === "refunded") return "refunded";
+ if (pass.status === "refunding") return "refunding";
if (pass.status === "ended") return "ended";
if (pass.validUntil !== null && pass.validUntil <= now) return "expired";
if (pass.creditsRemaining <= 0) return "used_up";
@@ -47,6 +54,7 @@ export const PASS_STATUS_LABELS: Record = {
used_up: "Aufgebraucht",
expired: "Abgelaufen",
ended: "Beendet",
+ refunding: "Erstattung läuft",
refunded: "Erstattet",
};
diff --git a/src/modules/products/ui/components/KundePassesSection.tsx b/src/modules/products/ui/components/KundePassesSection.tsx
index 089b9c3..fdeacbc 100644
--- a/src/modules/products/ui/components/KundePassesSection.tsx
+++ b/src/modules/products/ui/components/KundePassesSection.tsx
@@ -59,7 +59,9 @@ function describeAdjustment(adjustment: KundePassAdjustment): string {
case "end":
return "Beendet";
case "refund":
- return "Erstattet";
+ return "Erstattung angestoßen";
+ case "refund_failed":
+ return `Erstattung von Stripe abgelehnt, ${units(delta)} wieder frei`;
default:
return adjustment.type;
}
@@ -175,7 +177,7 @@ const KundePassesSection = ({ passes, customer, issueOptions }: KundePassesSecti
onClick={() => setActive({ pass, action: "refund" })}
>
- Erstatten
+ {pass.status === "refunding" ? "Erstattung prüfen" : "Erstatten"}
)}
{editable && (
@@ -310,7 +312,7 @@ const PassActionDialog = ({
credits: "Einheiten anpassen",
validity: "Ablaufdatum ändern",
end: "Karte beenden?",
- refund: "Karte erstatten?",
+ refund: pass.status === "refunding" ? "Erstattung prüfen" : "Karte erstatten?",
};
return (
@@ -398,18 +400,24 @@ const PassActionDialog = ({
)}
- {action === "refund" && (
-
- Der Kunde bekommt{" "}
-
- {pass.amountPaidCents !== null
- ? formatPrice(pass.amountPaidCents)
- : "den Kaufpreis"}
- {" "}
- vollständig zurück, die Karte wird ungültig. Die Servicegebühr trägst du, wie bei
- einer Kursabsage.
-
+ Stripe hat beim letzten Versuch nicht eindeutig geantwortet. Erneut prüfen ist
+ sicher: Stripe erstattet höchstens einmal.
+
+ ) : (
+
+ Der Kunde bekommt{" "}
+
+ {pass.amountPaidCents !== null
+ ? formatPrice(pass.amountPaidCents)
+ : "den Kaufpreis"}
+ {" "}
+ vollständig zurück, die Karte wird ungültig. Die Servicegebühr trägst du, wie bei
+ einer Kursabsage.
+