mirror of
https://github.com/Since-3/Kuma.git
synced 2026-10-12 20:51:18 +00:00
172 lines
5.7 KiB
TypeScript
172 lines
5.7 KiB
TypeScript
"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." };
|
||
}
|
||
}
|