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