Ein CC-Paket ist ein Platz fuer einen gleichzeitig aktiven CC - ein Redesign tauscht einen CC aus, statt einen Platz hinzuzufuegen. Beim Gruppen-Beitritt galt das seit dem letzten Stand, an anderer Stelle aber noch nicht: ein redesignter CC liess sich mit einem Redesign-Guthaben zurueckholen, waehrend sein Nachfolger aktiv blieb. Zwei aktive CCs auf ein einziges Paket. Geprueft wird das jetzt an einer Stelle statt an jedem Weg einzeln, fuer Erschaffen, Redesign, Reaktivierung, Rueckkehr und Beitritt. Der Massstab ist die Deckung, nicht der Kontostand: jeder aktive CC braucht eine eigene Buchung im CC-Topf. Ein bewusst ueberzogenes Konto bleibt damit moeglich - die Buchung ist da, nur das Guthaben fehlt, und das Minus steht sichtbar auf der Spielerseite. Beide Abgleiche laufen jetzt von allein: die Kaeufe alle zwei Stunden, bei Spielern ohne Kauf in den letzten drei Jahren alle sechs, das Forum einmal nachts. Das spart den Grossteil der Anfragen, denn ruhende Konten sind mit der Zeit die Mehrheit. Gebucht wird dabei nichts - neue Pakete landen in der Vorschau, neue Antraege in der Liste. Der Stand steht in der Datenbank, damit ein Neustart nicht alles erneut anstoesst. Unter Verwaltung steht, wann zuletzt gelaufen wurde; auf der Uebersicht, wie viele Pakete auf eine Buchung warten und bei wem. Ausserdem: - Die Guthaben-Historie zeigt bei Importen den Kauf im Shop, nicht den Zeitpunkt der Eingabe hier. Bereits gebuchte Importe werden nachgetragen. - Forums-Antraege tragen ihr Datum, im Forum ausgelesen statt geraten. - Die Hinweisbox ueber den Antraegen beim Spieler faellt weg. - Die Aktionsspalte der Nutzerverwaltung war per display:flex aus dem Tabellenlayout gefallen, daher die versetzte Trennlinie. - Die Zeitzone steht jetzt im Compose-Stack, sonst laege "nachts um vier" nach UTC und im Sommer zwei Stunden daneben.
329 lines
9.7 KiB
TypeScript
329 lines
9.7 KiB
TypeScript
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<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;
|
|
/// 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 };
|
|
}
|