import { randomUUID } from 'node:crypto'; import { CreditKind, CreditSource, type Prisma } from '@prisma/client'; import { prisma } from '../db.js'; import { badRequest, conflict, notFound } from '../errors.js'; import { recordAudit, type ActionContext } from './audit.js'; export interface Balances { CC: number; REDESIGN_PAID: number; REDESIGN_KULANZ: number; // Für ein Redesign zählen beide Redesign-Töpfe zusammen. redesignTotal: number; } const EMPTY: Balances = { CC: 0, REDESIGN_PAID: 0, REDESIGN_KULANZ: 0, redesignTotal: 0 }; function assemble(sums: Partial>): Balances { const cc = sums.CC ?? 0; const paid = sums.REDESIGN_PAID ?? 0; const kulanz = sums.REDESIGN_KULANZ ?? 0; return { CC: cc, REDESIGN_PAID: paid, REDESIGN_KULANZ: kulanz, redesignTotal: paid + kulanz }; } export async function getBalances(playerId: number): Promise { const rows = await prisma.creditLedger.groupBy({ by: ['kind'], where: { playerId }, _sum: { amount: true }, }); const sums: Partial> = {}; for (const row of rows) sums[row.kind] = row._sum.amount ?? 0; return assemble(sums); } export async function getBalancesForMany(playerIds: number[]): Promise> { const result = new Map(); if (playerIds.length === 0) return result; const rows = await prisma.creditLedger.groupBy({ by: ['playerId', 'kind'], where: { playerId: { in: playerIds } }, _sum: { amount: true }, }); const grouped = new Map>>(); for (const row of rows) { const entry = grouped.get(row.playerId) ?? {}; entry[row.kind] = row._sum.amount ?? 0; grouped.set(row.playerId, entry); } for (const id of playerIds) { const sums = grouped.get(id); result.set(id, sums ? assemble(sums) : { ...EMPTY }); } return result; } async function requirePlayer(playerId: number): Promise { const exists = await prisma.player.findUnique({ where: { id: playerId }, select: { id: true } }); if (!exists) throw notFound('Spieler'); } export interface PackageRowParams { playerId: number; groupId: string; note?: string | null; importId?: number | null; actorId: number | null; /// Wann gekauft wurde. Ohne Angabe gilt der Zeitpunkt der Eingabe. occurredAt?: Date | null; } /** * Baut die Ledger-Zeilen eines gekauften Pakets. Die Regel, dass ein CC-Paket * immer auch ein bezahltes Redesign enthält, steht nur hier, damit manuelle * Eingabe und Import nicht auseinanderlaufen können. */ export function buildPackageRows( target: 'CC_PACKAGE' | 'REDESIGN_PACKAGE', params: PackageRowParams, ): Prisma.CreditLedgerCreateManyInput[] { const base = { playerId: params.playerId, groupId: params.groupId, note: params.note ?? null, importId: params.importId ?? null, createdById: params.actorId, ...(params.occurredAt ? { occurredAt: params.occurredAt } : {}), }; if (target === 'REDESIGN_PACKAGE') { return [ { ...base, kind: CreditKind.REDESIGN_PAID, amount: 1, source: CreditSource.REDESIGN_PACKAGE }, ]; } return [ { ...base, kind: CreditKind.CC, amount: 1, source: CreditSource.CC_PACKAGE }, { ...base, kind: CreditKind.REDESIGN_PAID, amount: 1, source: CreditSource.CC_PACKAGE }, ]; } /** * Ein CC-Paket erzeugt immer beide Guthaben: einen CC und ein bezahltes * Redesign. Beide Zeilen teilen sich eine groupId, damit ein Storno sie * gemeinsam zurückholt. */ export async function bookCcPackage( playerId: number, ctx: ActionContext, options: { note?: string | undefined; importId?: number | undefined; groupId?: string } = {}, ): Promise<{ groupId: string; balances: Balances }> { await requirePlayer(playerId); const groupId = options.groupId ?? randomUUID(); await prisma.$transaction(async (tx) => { await tx.creditLedger.createMany({ data: buildPackageRows('CC_PACKAGE', { playerId, groupId, note: options.note ?? null, importId: options.importId ?? null, actorId: ctx.actorId, }), }); await recordAudit(tx, ctx, { action: 'credit.cc_package', targetType: 'player', targetId: playerId, details: { groupId, note: options.note ?? null, importId: options.importId ?? null }, }); }); return { groupId, balances: await getBalances(playerId) }; } export async function bookRedesignPackage( playerId: number, ctx: ActionContext, options: { note?: string | undefined; importId?: number | undefined; groupId?: string } = {}, ): Promise<{ groupId: string; balances: Balances }> { await requirePlayer(playerId); const groupId = options.groupId ?? randomUUID(); await prisma.$transaction(async (tx) => { await tx.creditLedger.createMany({ data: buildPackageRows('REDESIGN_PACKAGE', { playerId, groupId, note: options.note ?? null, importId: options.importId ?? null, actorId: ctx.actorId, }), }); await recordAudit(tx, ctx, { action: 'credit.redesign_package', targetType: 'player', targetId: playerId, details: { groupId, note: options.note ?? null, importId: options.importId ?? null }, }); }); return { groupId, balances: await getBalances(playerId) }; } export async function bookKulanz( playerId: number, ctx: ActionContext, options: { note?: string | undefined } = {}, ): Promise<{ groupId: string; balances: Balances }> { await requirePlayer(playerId); const groupId = randomUUID(); await prisma.$transaction(async (tx) => { await tx.creditLedger.create({ data: { playerId, kind: CreditKind.REDESIGN_KULANZ, amount: 1, source: CreditSource.KULANZ, groupId, note: options.note ?? null, createdById: ctx.actorId, }, }); await recordAudit(tx, ctx, { action: 'credit.kulanz', targetType: 'player', targetId: playerId, details: { groupId, note: options.note ?? null }, }); }); return { groupId, balances: await getBalances(playerId) }; } /** Freie Korrekturbuchung. Vorbehalten für Administratoren. */ export async function bookAdjustment( playerId: number, kind: CreditKind, amount: number, note: string, ctx: ActionContext, ): Promise<{ groupId: string; balances: Balances }> { await requirePlayer(playerId); if (!Number.isInteger(amount) || amount === 0) { throw badRequest('INVALID_AMOUNT', 'Korrekturbetrag muss eine ganze Zahl ungleich 0 sein'); } const groupId = randomUUID(); await prisma.$transaction(async (tx) => { await tx.creditLedger.create({ data: { playerId, kind, amount, source: CreditSource.ADJUSTMENT, groupId, note, createdById: ctx.actorId, }, }); await recordAudit(tx, ctx, { action: 'credit.adjustment', targetType: 'player', targetId: playerId, details: { groupId, kind, amount, note }, }); }); return { groupId, balances: await getBalances(playerId) }; } /** * Storniert eine Buchung als Gegenbuchung, nicht als Löschung: die Historie * bleibt vollständig lesbar. Verbrauchszeilen sind ausgenommen, die werden * über die zugehörige CC-Aktion zurückgenommen. */ export async function reverseGroup( ledgerId: number, reason: string, ctx: ActionContext, ): Promise<{ balances: Balances; reversed: number }> { const anchor = await prisma.creditLedger.findUnique({ where: { id: ledgerId } }); if (!anchor) throw notFound('Buchung'); if (anchor.source === CreditSource.CONSUME) { throw conflict( 'CONSUME_NOT_REVERSIBLE', 'Verbrauchsbuchungen werden über die zugehörige CC-Aktion zurückgenommen, nicht direkt storniert', ); } if (anchor.source === CreditSource.REVERSAL) { throw conflict('ALREADY_REVERSAL', 'Eine Stornobuchung kann nicht storniert werden'); } const entries = await prisma.creditLedger.findMany({ where: { groupId: anchor.groupId, source: { not: CreditSource.REVERSAL } }, include: { reversedBy: { select: { id: true } } }, }); const open = entries.filter((e) => e.reversedBy === null); if (open.length === 0) { throw conflict('ALREADY_REVERSED', 'Diese Buchung wurde bereits storniert'); } await prisma.$transaction(async (tx) => { for (const entry of open) { await tx.creditLedger.create({ data: { playerId: entry.playerId, kind: entry.kind, amount: -entry.amount, source: CreditSource.REVERSAL, groupId: entry.groupId, note: reason, reversalOfId: entry.id, createdById: ctx.actorId, }, }); } await recordAudit(tx, ctx, { action: 'credit.reverse', targetType: 'player', targetId: anchor.playerId, details: { groupId: anchor.groupId, reversedIds: open.map((e) => e.id), reason }, }); }); return { balances: await getBalances(anchor.playerId), reversed: open.length }; } export interface LedgerFilter { playerId: number; kind?: CreditKind; take?: number; skip?: number; } export async function listLedger(filter: LedgerFilter) { const where: Prisma.CreditLedgerWhereInput = { playerId: filter.playerId }; if (filter.kind) where.kind = filter.kind; const [items, total] = await Promise.all([ prisma.creditLedger.findMany({ where, orderBy: [{ occurredAt: 'desc' }, { id: 'desc' }], take: filter.take ?? 100, skip: filter.skip ?? 0, include: { createdBy: { select: { username: true } }, character: { select: { id: true, name: true } }, reversedBy: { select: { id: true } }, }, }), prisma.creditLedger.count({ where }), ]); return { items, total }; }