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
+427
View File
@@ -0,0 +1,427 @@
import { randomUUID } from 'node:crypto';
import {
CharacterStatus,
CreditKind,
CreditSource,
InactiveReason,
type Character,
} from '@prisma/client';
import { prisma } from '../db.js';
import { badRequest, conflict, notFound } from '../errors.js';
import { getBalances, type Balances } from './credits.js';
import { recordAudit, type ActionContext } from './audit.js';
export interface CharacterResult {
character: Character;
balances: Balances;
// Zeigt an, dass ein Topf nach der Buchung im Minus steht. Die Aktion wird
// trotzdem ausgefuehrt, das Ergebnis gehoert aber sichtbar gemacht.
overdrawn: boolean;
}
// Fuer ein Redesign zaehlen nur die beiden Redesign-Toepfe.
const REDESIGN_KINDS = [CreditKind.REDESIGN_PAID, CreditKind.REDESIGN_KULANZ] as const;
// Beim Wiederbeleben eines reworkten CCs darf zusaetzlich CC-Guthaben gezogen werden.
const REVIVE_KINDS = [CreditKind.CC, ...REDESIGN_KINDS] as const;
export type RedesignKind = (typeof REDESIGN_KINDS)[number];
export type ReviveKind = (typeof REVIVE_KINDS)[number];
function isOverdrawn(balances: Balances): boolean {
return balances.CC < 0 || balances.REDESIGN_PAID < 0 || balances.REDESIGN_KULANZ < 0;
}
async function loadCharacter(id: number): Promise<Character> {
const character = await prisma.character.findUnique({ where: { id } });
if (!character) throw notFound('CC');
return character;
}
/** Neuer CC. Kostet ein CC-Guthaben, Kulanz ist dafuer nicht einsetzbar. */
export async function createCharacter(
playerId: number,
input: { name: string; loreText?: string | undefined; note?: string | undefined },
ctx: ActionContext,
): Promise<CharacterResult> {
const player = await prisma.player.findUnique({ where: { id: playerId }, select: { id: true } });
if (!player) throw notFound('Spieler');
const character = await prisma.$transaction(async (tx) => {
const created = await tx.character.create({
data: {
playerId,
name: input.name,
loreText: input.loreText ?? null,
status: CharacterStatus.ACTIVE,
lineageId: randomUUID(),
createdById: ctx.actorId,
},
});
await tx.creditLedger.create({
data: {
playerId,
kind: CreditKind.CC,
amount: -1,
source: CreditSource.CONSUME,
groupId: randomUUID(),
characterId: created.id,
note: input.note ?? `Neuer CC: ${created.name}`,
createdById: ctx.actorId,
},
});
await recordAudit(tx, ctx, {
action: 'character.create',
targetType: 'character',
targetId: created.id,
details: { playerId, name: created.name },
});
return created;
});
const balances = await getBalances(playerId);
return { character, balances, overdrawn: isOverdrawn(balances) };
}
/**
* Redesign als Lineage: der alte CC geht auf INACTIVE/REWORKED, der neue
* entsteht als eigener Datensatz in derselben Kette. Damit greift spaeter
* automatisch die Regel, dass ein reworkter CC nur bezahlt zurueckkommt.
*/
export async function redesignCharacter(
characterId: number,
input: {
name: string;
loreText?: string | undefined;
useKind: RedesignKind;
note?: string | undefined;
},
ctx: ActionContext,
): Promise<CharacterResult & { predecessor: Character }> {
const old = await loadCharacter(characterId);
if (old.status !== CharacterStatus.ACTIVE) {
throw conflict('NOT_ACTIVE', 'Nur ein aktiver CC kann redesignt werden');
}
if (!REDESIGN_KINDS.includes(input.useKind)) {
throw badRequest('INVALID_KIND', 'Ein Redesign kostet bezahltes oder Kulanz-Redesign-Guthaben');
}
const result = await prisma.$transaction(async (tx) => {
const predecessor = await tx.character.update({
where: { id: old.id },
data: { status: CharacterStatus.INACTIVE, inactiveReason: InactiveReason.REWORKED },
});
const created = await tx.character.create({
data: {
playerId: old.playerId,
name: input.name,
loreText: input.loreText ?? null,
status: CharacterStatus.ACTIVE,
lineageId: old.lineageId,
predecessorId: old.id,
createdById: ctx.actorId,
},
});
await tx.creditLedger.create({
data: {
playerId: old.playerId,
kind: input.useKind,
amount: -1,
source: CreditSource.CONSUME,
groupId: randomUUID(),
characterId: created.id,
note: input.note ?? `Redesign: ${old.name} -> ${created.name}`,
createdById: ctx.actorId,
},
});
await recordAudit(tx, ctx, {
action: 'character.redesign',
targetType: 'character',
targetId: created.id,
details: {
playerId: old.playerId,
predecessorId: old.id,
from: old.name,
to: created.name,
useKind: input.useKind,
},
});
return { created, predecessor };
});
const balances = await getBalances(old.playerId);
return {
character: result.created,
predecessor: result.predecessor,
balances,
overdrawn: isOverdrawn(balances),
};
}
/** Deaktivierung ohne Guthaben-Effekt, etwa beim Serverputz wegen Inaktivitaet. */
export async function deactivateCharacter(
characterId: number,
reason: InactiveReason,
ctx: ActionContext,
note?: string,
): Promise<CharacterResult> {
const existing = await loadCharacter(characterId);
if (existing.status === CharacterStatus.INACTIVE) {
throw conflict('ALREADY_INACTIVE', 'Dieser CC ist bereits inaktiv');
}
if (reason === InactiveReason.REWORKED) {
throw badRequest(
'USE_REDESIGN',
'Ein Rework entsteht ueber die Redesign-Aktion, nicht ueber eine Deaktivierung',
);
}
const character = await prisma.$transaction(async (tx) => {
const updated = await tx.character.update({
where: { id: characterId },
data: { status: CharacterStatus.INACTIVE, inactiveReason: reason },
});
await recordAudit(tx, ctx, {
action: 'character.deactivate',
targetType: 'character',
targetId: characterId,
details: { reason, note: note ?? null, name: updated.name },
});
return updated;
});
const balances = await getBalances(character.playerId);
return { character, balances, overdrawn: isOverdrawn(balances) };
}
/**
* Gratis-Reaktivierung. Greift nur, wenn der CC ausschliesslich wegen
* Spieler-Inaktivitaet vom Server geputzt wurde: er existiert unveraendert
* weiter und kostet deshalb kein Guthaben.
*/
export async function reactivateFree(
characterId: number,
ctx: ActionContext,
): Promise<CharacterResult> {
const existing = await loadCharacter(characterId);
if (existing.status === CharacterStatus.ACTIVE) {
throw conflict('ALREADY_ACTIVE', 'Dieser CC ist bereits aktiv');
}
if (existing.inactiveReason !== InactiveReason.PLAYER_INACTIVITY) {
throw conflict(
'NOT_FREE',
'Nur ein wegen Spieler-Inaktivitaet entfernter CC kommt gratis zurueck. Dieser CC braucht eine bezahlte Reaktivierung.',
);
}
const character = await prisma.$transaction(async (tx) => {
const updated = await tx.character.update({
where: { id: characterId },
data: { status: CharacterStatus.ACTIVE, inactiveReason: null },
});
await recordAudit(tx, ctx, {
action: 'character.reactivate_free',
targetType: 'character',
targetId: characterId,
details: { playerId: updated.playerId, name: updated.name },
});
return updated;
});
const balances = await getBalances(character.playerId);
return { character, balances, overdrawn: isOverdrawn(balances) };
}
/**
* Bezahlte Reaktivierung fuer CCs, die im Original nicht mehr existieren
* (reworkt oder aus sonstigem Grund inaktiv). Kostet ein Redesign- oder
* ein CC-Guthaben, der Nutzer entscheidet welches.
*/
export async function reactivatePaid(
characterId: number,
useKind: ReviveKind,
ctx: ActionContext,
note?: string,
): Promise<CharacterResult> {
const existing = await loadCharacter(characterId);
if (existing.status === CharacterStatus.ACTIVE) {
throw conflict('ALREADY_ACTIVE', 'Dieser CC ist bereits aktiv');
}
if (existing.inactiveReason === InactiveReason.PLAYER_INACTIVITY) {
throw conflict(
'IS_FREE',
'Dieser CC kommt gratis zurueck, eine Guthaben-Buchung waere falsch',
);
}
if (!REVIVE_KINDS.includes(useKind)) {
throw badRequest('INVALID_KIND', 'Ungueltige Guthaben-Art fuer eine Reaktivierung');
}
const character = await prisma.$transaction(async (tx) => {
const updated = await tx.character.update({
where: { id: characterId },
data: { status: CharacterStatus.ACTIVE, inactiveReason: null },
});
await tx.creditLedger.create({
data: {
playerId: updated.playerId,
kind: useKind,
amount: -1,
source: CreditSource.CONSUME,
groupId: randomUUID(),
characterId: updated.id,
note: note ?? `Reaktivierung: ${updated.name}`,
createdById: ctx.actorId,
},
});
await recordAudit(tx, ctx, {
action: 'character.reactivate_paid',
targetType: 'character',
targetId: characterId,
details: {
playerId: updated.playerId,
name: updated.name,
useKind,
previousReason: existing.inactiveReason,
},
});
return updated;
});
const balances = await getBalances(character.playerId);
return { character, balances, overdrawn: isOverdrawn(balances) };
}
export async function updateCharacter(
characterId: number,
input: { name?: string; loreText?: string | null },
ctx: ActionContext,
): Promise<Character> {
await loadCharacter(characterId);
return prisma.$transaction(async (tx) => {
const updated = await tx.character.update({
where: { id: characterId },
data: {
...(input.name !== undefined ? { name: input.name } : {}),
...(input.loreText !== undefined ? { loreText: input.loreText } : {}),
},
});
await recordAudit(tx, ctx, {
action: 'character.update',
targetType: 'character',
targetId: characterId,
details: { name: updated.name },
});
return updated;
});
}
/**
* Nimmt einen irrtuemlich angelegten CC komplett zurueck: Verbrauchsbuchungen
* werden gegengebucht und ein durch das Redesign deaktivierter Vorgaenger
* kehrt in den aktiven Zustand zurueck.
*/
export async function revertCharacter(
characterId: number,
reason: string,
ctx: ActionContext,
): Promise<{ balances: Balances; restoredPredecessorId: number | null }> {
const existing = await prisma.character.findUnique({
where: { id: characterId },
include: { successors: { select: { id: true } }, predecessor: true },
});
if (!existing) throw notFound('CC');
if (existing.successors.length > 0) {
throw conflict(
'HAS_SUCCESSORS',
'Dieser CC hat Nachfolger in der Redesign-Kette. Bitte zuerst den juengsten CC zuruecknehmen.',
);
}
const consumed = await prisma.creditLedger.findMany({
where: { characterId, source: CreditSource.CONSUME },
include: { reversedBy: { select: { id: true } } },
});
const open = consumed.filter((entry) => entry.reversedBy === null);
const predecessor = existing.predecessor;
const restorePredecessor =
predecessor !== null &&
predecessor.status === CharacterStatus.INACTIVE &&
predecessor.inactiveReason === InactiveReason.REWORKED;
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: `Ruecknahme "${existing.name}": ${reason}`,
reversalOfId: entry.id,
createdById: ctx.actorId,
},
});
}
if (restorePredecessor && predecessor) {
await tx.character.update({
where: { id: predecessor.id },
data: { status: CharacterStatus.ACTIVE, inactiveReason: null },
});
}
await tx.character.delete({ where: { id: characterId } });
await recordAudit(tx, ctx, {
action: 'character.revert',
targetType: 'character',
targetId: characterId,
details: {
playerId: existing.playerId,
name: existing.name,
reason,
reversedLedgerIds: open.map((e) => e.id),
restoredPredecessorId: restorePredecessor ? predecessor?.id ?? null : null,
},
});
});
return {
balances: await getBalances(existing.playerId),
restoredPredecessorId: restorePredecessor ? predecessor?.id ?? null : null,
};
}
export async function listCharacters(playerId: number) {
return prisma.character.findMany({
where: { playerId },
orderBy: [{ status: 'asc' }, { createdAt: 'desc' }],
include: {
predecessor: { select: { id: true, name: true } },
successors: { select: { id: true, name: true } },
},
});
}