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:
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user