Kuma/src/modules/zahlungen/actions/zahlungen-actions.ts
2026-10-02 01:28:49 +02:00

172 lines
5.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use server";
import { prisma } from "@/src/lib/prisma";
import { requireManager } from "@/src/lib/auth/getUser";
import { buildZahlungenWhere } from "../lib/build-zahlungen-where";
import {
courseAmountCents,
payoutAmountCents,
refundedCourseCents,
} from "@/src/lib/booking-amounts";
export type ZahlungStatus =
| "paid"
| "pending"
| "refunded"
| "partially_refunded"
| "cancelled"
| "failed";
export type ZahlungenFilters = {
status?: ZahlungStatus;
dateFrom?: string; // ISO
dateTo?: string; // ISO
customerQuery?: string;
businessId?: string;
cursor?: string;
limit?: number;
};
export type ZahlungRow = {
id: string;
createdAt: string;
customerName: string;
customerEmail: string;
isGuest: boolean;
courseName: string;
/** Ursprünglich bezahlte Plätze - mehr als 1 bei einer Mehrfachbuchung. */
seats: number;
businessId: string;
businessName: string;
/**
* Kursbetrag in Cents, also der Umsatz des Studios vor Stripe-Gebühr. Bei
* `partially_refunded` der Nettobetrag (gezahlt − erstattet). Die Rechnung
* selbst bleibt davon unberührt (siehe invoice-actions.ts).
*/
amountPaid: number | null;
/**
* Stripe-Gebühr in Cents, die das Studio für diese Zahlung getragen hat.
* null = Stripe hat die Zahlung noch nicht abgerechnet (wird über
* charge.updated nachgetragen). Bei Altbuchungen trug die Plattform die
* Gebühr, dort steht 0.
*/
stripeFeeCents: number | null;
/** Was nach Plattform- und Stripe-Gebühr beim Studio ankommt, in Cents. */
payoutCents: number | null;
paymentStatus: string;
invoiceNumber: string | null;
};
const DEFAULT_LIMIT = 20;
export async function getZahlungen(
filters: ZahlungenFilters
): Promise<
| { success: true; zahlungen: ZahlungRow[]; nextCursor: string | null }
| { success: false; error: string }
> {
const manager = await requireManager();
const businessIds = manager.businesses.map((b) => b.id);
if (businessIds.length === 0) {
return { success: true, zahlungen: [], nextCursor: null };
}
const limit = filters.limit ?? DEFAULT_LIMIT;
const where = buildZahlungenWhere(filters, businessIds);
try {
const bookings = await prisma.courseBooking.findMany({
where,
take: limit + 1,
...(filters.cursor ? { cursor: { id: filters.cursor }, skip: 1 } : {}),
// id als Tiebreaker, da createdAt allein bei gleichem Zeitstempel keine
// stabile Reihenfolge garantiert - sonst können beim Cursor-Paging
// Zeilen übersprungen oder doppelt geliefert werden.
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
select: {
id: true,
createdAt: true,
paymentStatus: true,
amountPaid: true,
platformFee: true,
platformFeeOnTop: true,
stripeFee: true,
stripeAccountId: true,
refundedAmount: true,
refundedFeeAmount: true,
seatsPurchased: true,
invoiceNumber: true,
course: {
select: {
name: true,
business: { select: { id: true, name: true } },
},
},
user: { select: { name: true, email: true } },
guestLead: { select: { name: true, email: true } },
},
});
const hasMore = bookings.length > limit;
const page = hasMore ? bookings.slice(0, limit) : bookings;
const zahlungen: ZahlungRow[] = page.map((b) => {
const isGuest = !b.user;
const customerName =
b.user?.name ?? b.user?.email ?? b.guestLead?.name ?? b.guestLead?.email ?? "–";
const customerEmail = b.user?.email ?? b.guestLead?.email ?? "–";
// Bei Teilerstattung zeigt die Übersicht den Betrag, den das Business
// behält (netto) - das gilt auch für eine aktive Mehrfachbuchung, von der
// einzelne Plätze storniert wurden ("paid" mit refundedAmount). Voll
// erstattete / stornierte Zeilen behalten ihren Rohbetrag – dort trägt
// das Status-Badge die Bedeutung. Basis ist immer der Kursanteil: die
// Servicegebühr hat der Kunde an die Plattform gezahlt, sie war nie
// Umsatz des Studios.
const keepsMoney = b.paymentStatus === "paid" || b.paymentStatus === "partially_refunded";
const courseAmount = b.amountPaid !== null ? courseAmountCents(b) : null;
const netAmount =
keepsMoney && courseAmount !== null ? courseAmount - refundedCourseCents(b) : courseAmount;
// Direct Charges (stripeAccountId gesetzt): die Gebühr zieht Stripe beim
// Studio ab, null heißt "noch nicht abgerechnet". Altbuchungen: die
// Plattform trug sie, für das Studio also 0.
const stripeFeeCents = b.stripeAccountId ? b.stripeFee : (b.stripeFee ?? 0);
// Gleiche Konvention wie oben: nur Teilerstattungen werden abgezogen.
// Die Gebühren bleiben auch bei erstatteten Zeilen stehen - Stripe
// erstattet seine Gebühr nie, und die Plattformgebühr behält Kumasy bei
// einer Stornierung ein.
const payout =
b.amountPaid !== null
? payoutAmountCents(b) - (keepsMoney ? (b.refundedAmount ?? 0) : 0)
: null;
return {
id: b.id,
createdAt: b.createdAt.toISOString(),
customerName,
customerEmail,
isGuest,
courseName: b.course.name,
seats: b.seatsPurchased,
businessId: b.course.business?.id ?? "",
businessName: b.course.business?.name ?? "–",
amountPaid: netAmount,
stripeFeeCents,
payoutCents: payout,
paymentStatus: b.paymentStatus,
invoiceNumber: b.invoiceNumber,
};
});
return {
success: true,
zahlungen,
nextCursor: hasMore ? page[page.length - 1].id : null,
};
} catch {
return { success: false, error: "Zahlungen konnten nicht geladen werden." };
}
}