This commit is contained in:
commit
59bb5a38a3
132 files changed
+19435
No files matched your search
@@ -0,0 +1,379 @@
|
||||
/**
|
||||
* Zustandsuebergaenge fuer Zahlungen.
|
||||
*
|
||||
* Grundsaetze:
|
||||
* - Jede Funktion laeuft in einer Transaktion und sperrt zuerst die
|
||||
* Buchungszeile. Doppelte, verspaetete oder ungeordnete Stripe-Events
|
||||
* treffen dadurch immer auf einen konsistenten Stand.
|
||||
* - Uebergaenge sind monoton: Eine bezahlte Rate wird nie wieder offen, ein
|
||||
* spaetes "expired" hebt keine bezahlte Buchung auf.
|
||||
* - Buchungsstatus (BookingStatus) und Zahlungsstatus (PaymentStatus) sind
|
||||
* getrennt; eine bezahlte erste Rate heisst bei Ratenzahlung PARTIALLY_PAID.
|
||||
* - Was nicht automatisch sicher entschieden werden kann (verspaetete Zahlung
|
||||
* ohne freien Platz, Doppelzahlung, Rueckbuchung), landet als
|
||||
* NEEDS_REVIEW mit Begruendung im Adminbereich — nie still verworfen.
|
||||
*/
|
||||
import type { Booking, Installment, PaymentStatus } from "@/generated/prisma/client";
|
||||
import { audit, type Actor } from "./audit";
|
||||
import { countOccupiedSeats, lockBooking, lockCamp, releaseBookingHold } from "./booking-service";
|
||||
import { getCamp } from "./camp";
|
||||
import type { Tx } from "./db";
|
||||
import { enqueueEmail } from "./email";
|
||||
import {
|
||||
adminReviewEmail,
|
||||
bookingConfirmedEmail,
|
||||
installmentActionEmail,
|
||||
installmentPaidEmail,
|
||||
paymentFailedEmail,
|
||||
paymentProcessingEmail,
|
||||
reviewCustomerEmail,
|
||||
} from "./email-templates";
|
||||
import { statusTokenFor } from "./tokens";
|
||||
import { countUsedRedemptions } from "./vouchers";
|
||||
|
||||
export const MAX_OFF_SESSION_ATTEMPTS = 3;
|
||||
export const RETRY_DELAY_DAYS = 3;
|
||||
|
||||
export type EventContext = { now: Date; secret: string; actor: Actor; adminEmail: string | null };
|
||||
|
||||
export type PaymentInfo = {
|
||||
paymentIntentId: string;
|
||||
chargeId: string | null;
|
||||
paymentMethodId: string | null;
|
||||
customerId: string | null;
|
||||
amountCents: number;
|
||||
};
|
||||
|
||||
type FullBooking = Booking & { installments: Installment[] };
|
||||
|
||||
async function loadLocked(tx: Tx, bookingId: string) {
|
||||
await lockBooking(tx, bookingId);
|
||||
return tx.booking.findUniqueOrThrow({
|
||||
where: { id: bookingId },
|
||||
include: { installments: { orderBy: { seq: "asc" } }, reservation: true, redemption: true },
|
||||
});
|
||||
}
|
||||
|
||||
function paymentStatusFor(paidCents: number, totalCents: number, refundedCents: number): PaymentStatus {
|
||||
if (refundedCents > 0) return refundedCents >= paidCents ? "REFUNDED" : "PARTIALLY_REFUNDED";
|
||||
if (paidCents <= 0) return "UNPAID";
|
||||
return paidCents >= totalCents ? "PAID" : "PARTIALLY_PAID";
|
||||
}
|
||||
|
||||
async function flagForReview(tx: Tx, booking: Booking, reason: string, ctx: EventContext): Promise<void> {
|
||||
await tx.booking.update({ where: { id: booking.id }, data: { status: "NEEDS_REVIEW", reviewReason: reason } });
|
||||
await audit(tx, ctx.actor, "booking.needs_review", "Booking", booking.id, { reason });
|
||||
if (ctx.adminEmail) await enqueueEmail(tx, adminReviewEmail(ctx.adminEmail, booking, reason));
|
||||
}
|
||||
|
||||
export type FirstPaymentOutcome = "confirmed" | "duplicate" | "needs_review";
|
||||
|
||||
/** Erste Rate (bzw. Einmalzahlung) ist bei Stripe erfolgreich eingegangen. */
|
||||
export async function applyFirstPaymentSucceeded(
|
||||
tx: Tx,
|
||||
bookingId: string,
|
||||
info: PaymentInfo,
|
||||
ctx: EventContext,
|
||||
): Promise<FirstPaymentOutcome> {
|
||||
const booking = await loadLocked(tx, bookingId);
|
||||
const first = booking.installments.find((i) => i.seq === 1)!;
|
||||
|
||||
if (first.status === "PAID") {
|
||||
if (first.stripePaymentIntentId && first.stripePaymentIntentId !== info.paymentIntentId) {
|
||||
await flagForReview(tx, booking, "Doppelzahlung der ersten Rate – bitte eine Zahlung erstatten.", ctx);
|
||||
return "needs_review";
|
||||
}
|
||||
return "duplicate";
|
||||
}
|
||||
|
||||
const problems: string[] = [];
|
||||
if (info.amountCents !== first.amountCents) {
|
||||
problems.push(`Betrag weicht ab (erwartet ${first.amountCents}, erhalten ${info.amountCents} Cent).`);
|
||||
}
|
||||
|
||||
// Platz: normalerweise noch reserviert. Wurde er inzwischen freigegeben
|
||||
// (Zahlung kam nach Ablauf an), wird er nur uebernommen, wenn noch frei.
|
||||
const reservation = booking.reservation!;
|
||||
if (reservation.status === "ACTIVE" || reservation.status === "HELD") {
|
||||
await tx.seatReservation.update({ where: { id: reservation.id }, data: { status: "CONVERTED" } });
|
||||
} else if (reservation.status === "RELEASED") {
|
||||
await lockCamp(tx);
|
||||
const camp = await getCamp(tx);
|
||||
const occupied = await countOccupiedSeats(tx, booking.livemode, ctx.now);
|
||||
if (occupied < camp.capacity) {
|
||||
await tx.seatReservation.update({
|
||||
where: { id: reservation.id },
|
||||
data: { status: "CONVERTED", releasedAt: null, releaseReason: null },
|
||||
});
|
||||
await audit(tx, ctx.actor, "seat.reacquired_after_late_payment", "Booking", booking.id, { occupiedBefore: occupied });
|
||||
} else {
|
||||
problems.push("Zahlung nach Ablauf der Reservierung eingegangen, inzwischen ist kein Platz mehr frei.");
|
||||
}
|
||||
}
|
||||
|
||||
const redemption = booking.redemption;
|
||||
if (redemption?.status === "RESERVED") {
|
||||
await tx.voucherRedemption.update({ where: { id: redemption.id }, data: { status: "COMPLETED" } });
|
||||
} else if (redemption?.status === "RELEASED") {
|
||||
await tx.$queryRaw`SELECT "id" FROM "Voucher" WHERE "id" = ${redemption.voucherId} FOR UPDATE`;
|
||||
const [voucher, camp] = await Promise.all([
|
||||
tx.voucher.findUniqueOrThrow({ where: { id: redemption.voucherId } }),
|
||||
getCamp(tx),
|
||||
]);
|
||||
const used = await countUsedRedemptions(tx, { livemode: booking.livemode, now: ctx.now, voucherId: voucher.id });
|
||||
const quotaUsed = await countUsedRedemptions(tx, { livemode: booking.livemode, now: ctx.now });
|
||||
if (used < voucher.maxRedemptions && quotaUsed < camp.stammmodelQuota) {
|
||||
await tx.voucherRedemption.update({ where: { id: redemption.id }, data: { status: "COMPLETED", releasedAt: null } });
|
||||
} else {
|
||||
problems.push("Gutscheincode war bei verspäteter Zahlung nicht mehr verfügbar.");
|
||||
}
|
||||
}
|
||||
|
||||
await tx.installment.update({
|
||||
where: { id: first.id },
|
||||
data: {
|
||||
status: "PAID",
|
||||
paidAt: ctx.now,
|
||||
stripePaymentIntentId: info.paymentIntentId,
|
||||
stripeChargeId: info.chargeId,
|
||||
},
|
||||
});
|
||||
|
||||
const paidCents = booking.paidCents + info.amountCents;
|
||||
const updated = await tx.booking.update({
|
||||
where: { id: booking.id },
|
||||
data: {
|
||||
status: problems.length > 0 ? "NEEDS_REVIEW" : "CONFIRMED",
|
||||
reviewReason: problems.length > 0 ? problems.join(" ") : null,
|
||||
paymentStatus: paymentStatusFor(paidCents, booking.totalCents, booking.refundedCents),
|
||||
paidCents,
|
||||
confirmedAt: problems.length > 0 ? null : ctx.now,
|
||||
stripeCustomerId: info.customerId ?? booking.stripeCustomerId,
|
||||
// Nur bei Ratenzahlung gespeichert; bei Einmalzahlung gibt es nichts
|
||||
// wiederzuverwenden.
|
||||
stripePaymentMethodId: booking.planType === "FULL" ? null : info.paymentMethodId,
|
||||
},
|
||||
include: { installments: { orderBy: { seq: "asc" } } },
|
||||
});
|
||||
|
||||
await audit(tx, ctx.actor, "payment.first_succeeded", "Booking", booking.id, {
|
||||
paymentIntentId: info.paymentIntentId,
|
||||
amountCents: info.amountCents,
|
||||
paidCents,
|
||||
previousStatus: booking.status,
|
||||
problems,
|
||||
});
|
||||
|
||||
const token = statusTokenFor(booking.id, ctx.secret);
|
||||
if (problems.length > 0) {
|
||||
if (ctx.adminEmail) await enqueueEmail(tx, adminReviewEmail(ctx.adminEmail, updated, problems.join(" ")));
|
||||
await enqueueEmail(tx, reviewCustomerEmail(updated, token));
|
||||
return "needs_review";
|
||||
}
|
||||
await enqueueEmail(tx, bookingConfirmedEmail(updated, token));
|
||||
return "confirmed";
|
||||
}
|
||||
|
||||
/** Checkout abgeschlossen, Zahlung aber noch unterwegs (z. B. SEPA-Lastschrift). */
|
||||
export async function applyFirstPaymentProcessing(
|
||||
tx: Tx,
|
||||
bookingId: string,
|
||||
info: { paymentIntentId: string | null; customerId: string | null },
|
||||
ctx: EventContext,
|
||||
): Promise<"processing" | "ignored"> {
|
||||
const booking = await loadLocked(tx, bookingId);
|
||||
const first = booking.installments.find((i) => i.seq === 1)!;
|
||||
if (booking.status !== "PENDING" || first.status !== "PENDING") return "ignored";
|
||||
|
||||
await tx.seatReservation.updateMany({ where: { bookingId, status: "ACTIVE" }, data: { status: "HELD" } });
|
||||
await tx.installment.update({
|
||||
where: { id: first.id },
|
||||
data: { status: "PROCESSING", stripePaymentIntentId: info.paymentIntentId },
|
||||
});
|
||||
const updated = await tx.booking.update({
|
||||
where: { id: bookingId },
|
||||
data: { paymentStatus: "PROCESSING", stripeCustomerId: info.customerId ?? booking.stripeCustomerId },
|
||||
});
|
||||
await audit(tx, ctx.actor, "payment.first_processing", "Booking", bookingId, { paymentIntentId: info.paymentIntentId });
|
||||
await enqueueEmail(tx, paymentProcessingEmail(updated, statusTokenFor(bookingId, ctx.secret)));
|
||||
return "processing";
|
||||
}
|
||||
|
||||
/** Verzoegerte erste Zahlung endgueltig fehlgeschlagen: Platz freigeben. */
|
||||
export async function applyFirstPaymentFailed(tx: Tx, bookingId: string, ctx: EventContext): Promise<"failed" | "ignored"> {
|
||||
const booking = await loadLocked(tx, bookingId);
|
||||
const first = booking.installments.find((i) => i.seq === 1)!;
|
||||
if (first.status === "PAID" || booking.status === "CANCELED") return "ignored";
|
||||
|
||||
await tx.installment.update({ where: { id: first.id }, data: { status: "FAILED", failureCode: "async_payment_failed" } });
|
||||
await releaseBookingHold(tx, bookingId, { status: "EXPIRED", reason: "payment_failed", now: ctx.now });
|
||||
const updated = await tx.booking.update({ where: { id: bookingId }, data: { paymentStatus: "FAILED" } });
|
||||
await audit(tx, ctx.actor, "payment.first_failed", "Booking", bookingId);
|
||||
await enqueueEmail(tx, paymentFailedEmail(updated));
|
||||
return "failed";
|
||||
}
|
||||
|
||||
/** Checkout-Session abgelaufen oder abgebrochen: nur unbezahlte Buchungen freigeben. */
|
||||
export async function applyCheckoutExpired(
|
||||
tx: Tx,
|
||||
bookingId: string,
|
||||
sessionId: string | null,
|
||||
ctx: EventContext & { reason?: string },
|
||||
): Promise<"released" | "ignored"> {
|
||||
const booking = await loadLocked(tx, bookingId);
|
||||
if (sessionId && booking.stripeCheckoutSessionId && booking.stripeCheckoutSessionId !== sessionId) return "ignored";
|
||||
if (booking.status !== "PENDING" || booking.paymentStatus !== "UNPAID") return "ignored";
|
||||
await releaseBookingHold(tx, bookingId, { status: "EXPIRED", reason: ctx.reason ?? "checkout_expired", now: ctx.now });
|
||||
await audit(tx, ctx.actor, "booking.expired", "Booking", bookingId, { sessionId, reason: ctx.reason ?? "checkout_expired" });
|
||||
return "released";
|
||||
}
|
||||
|
||||
export type InstallmentPaidOutcome = { outcome: "paid" | "duplicate" | "needs_review"; fullyPaid: boolean };
|
||||
|
||||
/** Folgerate bezahlt (Off-Session-Einzug oder selbst bezahlt per Link). */
|
||||
export async function applyInstallmentPaid(
|
||||
tx: Tx,
|
||||
installmentId: string,
|
||||
info: PaymentInfo,
|
||||
ctx: EventContext,
|
||||
): Promise<InstallmentPaidOutcome> {
|
||||
const target = await tx.installment.findUniqueOrThrow({ where: { id: installmentId } });
|
||||
const booking = await loadLocked(tx, target.bookingId);
|
||||
const installment = booking.installments.find((i) => i.id === installmentId)!;
|
||||
|
||||
if (installment.status === "PAID") {
|
||||
if (installment.stripePaymentIntentId !== info.paymentIntentId) {
|
||||
await flagForReview(tx, booking, `Rate ${installment.seq} wurde doppelt bezahlt – bitte eine Zahlung erstatten.`, ctx);
|
||||
return { outcome: "needs_review", fullyPaid: booking.paidCents >= booking.totalCents };
|
||||
}
|
||||
return { outcome: "duplicate", fullyPaid: booking.paidCents >= booking.totalCents };
|
||||
}
|
||||
|
||||
const paidCents = booking.paidCents + info.amountCents;
|
||||
await tx.installment.update({
|
||||
where: { id: installmentId },
|
||||
data: {
|
||||
status: "PAID",
|
||||
paidAt: ctx.now,
|
||||
stripePaymentIntentId: info.paymentIntentId,
|
||||
stripeChargeId: info.chargeId,
|
||||
nextAttemptAt: null,
|
||||
failureCode: null,
|
||||
},
|
||||
});
|
||||
const updated = await tx.booking.update({
|
||||
where: { id: booking.id },
|
||||
data: {
|
||||
paidCents,
|
||||
paymentStatus: paymentStatusFor(paidCents, booking.totalCents, booking.refundedCents),
|
||||
...(info.paymentMethodId && booking.planType !== "FULL" && !booking.paymentMethodDetachedAt
|
||||
? { stripePaymentMethodId: info.paymentMethodId }
|
||||
: {}),
|
||||
},
|
||||
include: { installments: { orderBy: { seq: "asc" } } },
|
||||
});
|
||||
await audit(tx, ctx.actor, "payment.installment_succeeded", "Installment", installmentId, {
|
||||
bookingId: booking.id,
|
||||
seq: installment.seq,
|
||||
paymentIntentId: info.paymentIntentId,
|
||||
amountCents: info.amountCents,
|
||||
paidCents,
|
||||
});
|
||||
|
||||
const problems: string[] = [];
|
||||
if (info.amountCents !== installment.amountCents) problems.push(`Betrag von Rate ${installment.seq} weicht ab.`);
|
||||
if (installment.status === "CANCELED" || booking.status === "CANCELED") problems.push(`Rate ${installment.seq} ging nach Stornierung ein.`);
|
||||
if (paidCents > booking.totalCents) problems.push("Es wurde mehr als der Gesamtpreis gezahlt.");
|
||||
if (problems.length > 0) {
|
||||
await flagForReview(tx, updated, problems.join(" "), ctx);
|
||||
return { outcome: "needs_review", fullyPaid: paidCents >= booking.totalCents };
|
||||
}
|
||||
|
||||
await enqueueEmail(tx, installmentPaidEmail(updated, { ...installment, status: "PAID" }, statusTokenFor(booking.id, ctx.secret)));
|
||||
return { outcome: "paid", fullyPaid: paidCents >= booking.totalCents };
|
||||
}
|
||||
|
||||
/**
|
||||
* Off-Session-Einzug fehlgeschlagen. Greift nur, solange die Rate vom Worker
|
||||
* beansprucht ist (PROCESSING) — so zaehlt ein Fehler nie doppelt, egal ob
|
||||
* ihn der Worker direkt oder der Webhook zuerst meldet.
|
||||
*/
|
||||
export async function applyInstallmentFailed(
|
||||
tx: Tx,
|
||||
installmentId: string,
|
||||
info: { paymentIntentId: string | null; failureCode: string | null; requiresAction: boolean },
|
||||
ctx: EventContext,
|
||||
): Promise<"recorded" | "ignored"> {
|
||||
const target = await tx.installment.findUniqueOrThrow({ where: { id: installmentId } });
|
||||
const booking = await loadLocked(tx, target.bookingId);
|
||||
const installment = booking.installments.find((i) => i.id === installmentId)!;
|
||||
if (installment.status !== "PROCESSING") return "ignored";
|
||||
|
||||
const attempt = installment.attemptCount + 1;
|
||||
const retry = !info.requiresAction && attempt < MAX_OFF_SESSION_ATTEMPTS;
|
||||
const updatedInstallment = await tx.installment.update({
|
||||
where: { id: installmentId },
|
||||
data: {
|
||||
status: info.requiresAction ? "REQUIRES_ACTION" : "FAILED",
|
||||
attemptCount: attempt,
|
||||
failureCode: info.failureCode,
|
||||
stripePaymentIntentId: info.paymentIntentId ?? installment.stripePaymentIntentId,
|
||||
nextAttemptAt: retry ? new Date(ctx.now.getTime() + RETRY_DELAY_DAYS * 86_400_000) : null,
|
||||
},
|
||||
});
|
||||
await audit(tx, ctx.actor, "payment.installment_failed", "Installment", installmentId, {
|
||||
bookingId: booking.id,
|
||||
seq: installment.seq,
|
||||
attempt,
|
||||
failureCode: info.failureCode,
|
||||
requiresAction: info.requiresAction,
|
||||
nextAttemptAt: updatedInstallment.nextAttemptAt?.toISOString() ?? null,
|
||||
});
|
||||
await enqueueEmail(
|
||||
tx,
|
||||
installmentActionEmail(booking, updatedInstallment, statusTokenFor(booking.id, ctx.secret), info.requiresAction),
|
||||
);
|
||||
if (!retry && !info.requiresAction) {
|
||||
await flagForReview(tx, booking, `Rate ${installment.seq} ist nach ${attempt} Versuchen nicht eingegangen.`, ctx);
|
||||
}
|
||||
return "recorded";
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstattungsstand einer Zahlung. Stripe liefert den kumulierten Betrag
|
||||
* (amount_refunded) — absolute Werte machen ungeordnete Events harmlos.
|
||||
*/
|
||||
export async function applyRefundState(
|
||||
tx: Tx,
|
||||
params: { paymentIntentId: string; amountRefundedCents: number },
|
||||
ctx: EventContext,
|
||||
): Promise<"updated" | "ignored"> {
|
||||
const target = await tx.installment.findUnique({ where: { stripePaymentIntentId: params.paymentIntentId } });
|
||||
if (!target) return "ignored";
|
||||
const booking = await loadLocked(tx, target.bookingId);
|
||||
await tx.installment.update({ where: { id: target.id }, data: { refundedCents: params.amountRefundedCents } });
|
||||
const refundedCents = booking.installments.reduce(
|
||||
(sum, i) => sum + (i.id === target.id ? params.amountRefundedCents : i.refundedCents),
|
||||
0,
|
||||
);
|
||||
await tx.booking.update({
|
||||
where: { id: booking.id },
|
||||
data: { refundedCents, paymentStatus: paymentStatusFor(booking.paidCents, booking.totalCents, refundedCents) },
|
||||
});
|
||||
await audit(tx, ctx.actor, "payment.refund_state", "Booking", booking.id, {
|
||||
paymentIntentId: params.paymentIntentId,
|
||||
installmentRefundedCents: params.amountRefundedCents,
|
||||
bookingRefundedCents: refundedCents,
|
||||
});
|
||||
return "updated";
|
||||
}
|
||||
|
||||
export async function applyDispute(tx: Tx, paymentIntentId: string, ctx: EventContext): Promise<"flagged" | "ignored"> {
|
||||
const target = await tx.installment.findUnique({ where: { stripePaymentIntentId: paymentIntentId } });
|
||||
if (!target) return "ignored";
|
||||
const booking = await loadLocked(tx, target.bookingId);
|
||||
await flagForReview(tx, booking, `Rückbuchung (Dispute) zu Rate ${target.seq} eröffnet.`, ctx);
|
||||
return "flagged";
|
||||
}
|
||||
|
||||
export type { FullBooking };
|
||||
Reference in new issue
Block a user