Fachlogik fuer Guthaben, CC-Lebenszyklus und Import

Die beiden zentralen Regeln stehen jeweils an genau einer Stelle:

Ein CC-Paket erzeugt immer CC +1 und REDESIGN_PAID +1. Die Zeilen dafuer baut
buildPackageRows, das sowohl die manuelle Eingabe als auch der Import nutzen -
so koennen beide Wege nicht auseinanderlaufen.

Ein CC kommt nur dann gratis zurueck, wenn er wegen Spieler-Inaktivitaet
entfernt wurde. Ein reworkter CC existiert im Original nicht mehr und kostet
deshalb ein Redesign- oder CC-Guthaben.

Ueberzogene Toepfe werden zugelassen statt blockiert; die Aktion meldet das
ueber ein overdrawn-Flag zurueck. Storniert wird per Gegenbuchung, nie durch
Loeschen. Der Import berechnet den Plan vorab und dedupliziert ueber die
Bestell-ID, sowohl gegen bereits gebuchte als auch innerhalb einer Datei.
This commit is contained in:
2026-09-18 11:37:31 +02:00
parent 3d2b87297e
commit f3e31cdd17
5 changed files with 1379 additions and 0 deletions
+325
View File
@@ -0,0 +1,325 @@
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;
// Fuer ein Redesign zaehlen beide Redesign-Toepfe zusammen.
redesignTotal: number;
}
const EMPTY: Balances = { CC: 0, REDESIGN_PAID: 0, REDESIGN_KULANZ: 0, redesignTotal: 0 };
function assemble(sums: Partial<Record<CreditKind, number>>): 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<Balances> {
const rows = await prisma.creditLedger.groupBy({
by: ['kind'],
where: { playerId },
_sum: { amount: true },
});
const sums: Partial<Record<CreditKind, number>> = {};
for (const row of rows) sums[row.kind] = row._sum.amount ?? 0;
return assemble(sums);
}
export async function getBalancesForMany(playerIds: number[]): Promise<Map<number, Balances>> {
const result = new Map<number, Balances>();
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<number, Partial<Record<CreditKind, number>>>();
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<void> {
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;
}
/**
* Baut die Ledger-Zeilen eines gekauften Pakets. Die Regel, dass ein CC-Paket
* immer auch ein bezahltes Redesign enthaelt, steht nur hier, damit manuelle
* Eingabe und Import nicht auseinanderlaufen koennen.
*/
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,
};
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 zurueckholt.
*/
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 fuer 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 Loeschung: die Historie
* bleibt vollstaendig lesbar. Verbrauchszeilen sind ausgenommen, die werden
* ueber die zugehoerige CC-Aktion zurueckgenommen.
*/
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 ueber die zugehoerige CC-Aktion zurueckgenommen, 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: [{ createdAt: '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 };
}