Rueckfragen im Stil der Anwendung, Kulanz-Ausgleich, Redesign aus dem CC-Topf

window.confirm und window.prompt sind raus - alle vierzehn Stellen. Sie sahen
nicht nur fremd aus: Wer sie im Browser abschaltet, bekommt stillschweigend
"nein" zurueck, und eine Aktion passiert einfach nicht, ohne dass jemand
erfaehrt warum. Stattdessen gibt es einen Dialog-Dienst mit drei Formen -
bestaetigen, abfragen, waehlen -, der sich wie der Rest der Anwendung
anfuehlt.

Die Wahl kam dazu, weil eine Stelle ein Ja/Nein fuer eine Auswahl
missbrauchte: "OK = Kulanz, Abbrechen = bezahltes Redesign". Das fuehrt jeden
in die Irre, der es zum ersten Mal sieht.

Neu: Steht der bezahlte Redesign-Topf im Minus, waehrend Kulanz uebrig ist,
erscheint "Minus mit Kulanz ausgleichen". Es bucht so viele Uebergaenge um,
wie beide Seiten hergeben - die aeltesten zuerst, damit das Ergebnis
wiederholbar ist. Unterm Strich aendert sich nichts, nur die Verteilung.

Ebenfalls neu: Ein Redesign laesst sich ausnahmsweise aus dem CC-Topf
bezahlen. Damit das Ausrichten an den Ketten so eine Entscheidung nicht beim
naechsten Umsortieren wieder einebnet, tragen von Hand gewaehlte Buchungen
jetzt einen Merker.

Dabei aufgefallen: Die Tests wurden gar nicht typgeprueft - tsconfig schloss
nur src ein. Sieben Aufrufe von bookKulanz hatten vertauschte Argumente und
liefen trotzdem durch, weil JavaScript das hinnimmt; sie pruefte damit etwas
anderes als gedacht. Tests haengen jetzt in der Typpruefung.
This commit is contained in:
2026-09-22 09:39:57 +02:00
parent 9b93844be2
commit 34819d44cc
19 changed files with 709 additions and 61 deletions
+1 -1
View File
@@ -12,7 +12,7 @@
"migrate": "prisma migrate deploy",
"migrate:dev": "prisma migrate dev",
"studio": "prisma studio",
"typecheck": "tsc -p tsconfig.json --noEmit",
"typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.test.json",
"test": "node --import tsx --test test/*.test.ts"
},
"dependencies": {
@@ -0,0 +1,4 @@
-- Eine von Hand gewaehlte Bezahlart soll bleiben. Ohne diese Markierung
-- wuerde das Ausrichten der Buchungen an den Ketten jede Ausnahme beim
-- naechsten Umsortieren wieder einebnen.
ALTER TABLE "CreditLedger" ADD COLUMN IF NOT EXISTS "fundingPinned" BOOLEAN NOT NULL DEFAULT false;
+5
View File
@@ -227,6 +227,11 @@ model CreditLedger {
/// hier eingetippt hat.
occurredAt DateTime @default(now())
/// Von Hand so entschieden. Das Ausrichten der Buchungen an den Ketten
/// lässt solche Zeilen in Ruhe - sonst würde jede Ausnahme beim nächsten
/// Umsortieren wieder eingeebnet.
fundingPinned Boolean @default(false)
@@index([playerId])
@@index([playerId, kind])
@@index([groupId])
+24 -2
View File
@@ -27,6 +27,7 @@ import {
changeFunding,
addMemberToChain,
rebuildFunding,
settleWithKulanz,
} from '../services/characters.js';
import { parseThreadId } from '../lib/forum.js';
import { linkThreadToCharacter } from '../services/forum.js';
@@ -49,7 +50,13 @@ async function linkThread(
await linkThreadToCharacter(threadId, characterId, classification, ctx);
}
const redesignKind = z.enum([CreditKind.REDESIGN_PAID, CreditKind.REDESIGN_KULANZ]);
/// Der Regelfall für ein Redesign sind die Redesign-Töpfe. Ein CC-Paket
/// geht ausnahmsweise auch - dann gilt die Wahl als von Hand getroffen.
const redesignKind = z.enum([
CreditKind.REDESIGN_PAID,
CreditKind.REDESIGN_KULANZ,
CreditKind.CC,
]);
const reviveKind = z.enum([CreditKind.CC, CreditKind.REDESIGN_PAID, CreditKind.REDESIGN_KULANZ]);
export async function characterRoutes(app: FastifyInstance): Promise<void> {
@@ -208,6 +215,15 @@ export async function characterRoutes(app: FastifyInstance): Promise<void> {
return rebuildFunding(id, contextOf(request));
});
/**
* Gleicht ein Minus im bezahlten Redesign-Topf mit vorhandener Kulanz aus,
* indem ebenso viele Übergänge auf Kulanz umgebucht werden.
*/
app.post('/api/players/:id/credits/settle', { preHandler: requireEditor }, async (request) => {
const { id } = parseWith(idParam, request.params);
return settleWithKulanz(id, contextOf(request));
});
// --- Ketten ---
/**
@@ -294,7 +310,13 @@ export async function characterRoutes(app: FastifyInstance): Promise<void> {
z.object({
name: z.string().trim().min(1).max(160),
loreText: z.string().trim().max(5000).optional(),
useKind: z.enum([CreditKind.REDESIGN_PAID, CreditKind.REDESIGN_KULANZ]),
/// Der Regelfall sind die Redesign-Töpfe; ein CC-Paket geht
/// ausnahmsweise auch.
useKind: z.enum([
CreditKind.REDESIGN_PAID,
CreditKind.REDESIGN_KULANZ,
CreditKind.CC,
]),
note: optionalNote,
}),
request.body,
+114 -6
View File
@@ -162,7 +162,7 @@ export async function redesignCharacter(
input: {
name: string;
loreText?: string | undefined;
useKind: RedesignKind;
useKind: JoinKind;
note?: string | undefined;
},
ctx: ActionContext,
@@ -172,9 +172,13 @@ export async function redesignCharacter(
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');
// Der Regelfall ist ein Redesign-Guthaben. Ausnahmsweise lässt sich auch
// ein CC-Paket dafür einsetzen - dann gilt die Entscheidung als von Hand
// getroffen und bleibt beim Umsortieren stehen.
if (!JOIN_KINDS.includes(input.useKind)) {
throw badRequest('INVALID_KIND', 'Ungültige Guthaben-Art für ein Redesign');
}
const ausnahme = input.useKind === CreditKind.CC;
const result = await prisma.$transaction(async (tx) => {
const predecessor = await tx.character.update({
@@ -221,6 +225,7 @@ export async function redesignCharacter(
characterId: created.id,
note: input.note ?? `Redesign: ${old.name} -> ${created.name}`,
createdById: ctx.actorId,
...(ausnahme ? { fundingPinned: true } : {}),
},
});
@@ -809,6 +814,8 @@ export async function changeFunding(
createdById: ctx.actorId,
// Bezahlt wurde damals, nicht heute.
occurredAt: buchung.occurredAt,
// Von Hand gewählt - das Ausrichten an den Ketten lässt es stehen.
fundingPinned: true,
},
});
@@ -879,6 +886,10 @@ async function buchungFolgtPosition(
});
if (!offen) return false;
// Wer die Bezahlart von Hand gewählt hat, meinte es so - etwa wenn ein
// Redesign ausnahmsweise aus einem CC-Paket bezahlt wurde.
if (offen.fundingPinned) return false;
const istCC = offen.kind === CreditKind.CC;
if (istCC === sollCC) return false;
@@ -1053,6 +1064,102 @@ export async function rebuildFunding(
};
}
/**
* Begleicht ein Minus im Redesign-Topf mit vorhandener Kulanz.
*
* Kommt vor, wenn nachträglich klar wird, dass ein Teil der Redesigns
* Kulanz war: Dann stehen dort Guthaben herum, während der bezahlte Topf im
* Minus hängt. Statt einzeln Übergang für Übergang umzustellen, wird hier so
* viel umgebucht, wie beide Seiten hergeben.
*
* Welche Übergänge es trifft, spielt inhaltlich keine Rolle - am Ende zählt,
* wie viel woraus bezahlt wurde. Genommen werden die ältesten zuerst, damit
* das Ergebnis wiederholbar ist und nicht vom Zufall abhängt.
*/
export async function settleWithKulanz(
playerId: number,
ctx: ActionContext,
): Promise<{ umgebucht: number; balances: Balances }> {
const stand = await getBalances(playerId);
const schulden = Math.max(0, -stand.REDESIGN_PAID);
const kulanz = Math.max(0, stand.REDESIGN_KULANZ);
const moeglich = Math.min(schulden, kulanz);
if (moeglich === 0) {
throw conflict(
'NOTHING_TO_SETTLE',
schulden === 0
? 'Im bezahlten Redesign-Topf steht kein Minus - hier gibt es nichts auszugleichen'
: 'Es ist keine Kulanz übrig, mit der sich das Minus ausgleichen liesse',
);
}
// Die ältesten bezahlten Redesigns zuerst: wer nachträglich Kulanz
// zugestanden bekommt, bekommt sie für das, was am längsten zurückliegt.
const kandidaten = await prisma.creditLedger.findMany({
where: {
playerId,
kind: CreditKind.REDESIGN_PAID,
amount: { lt: 0 },
source: CreditSource.CONSUME,
reversedBy: null,
},
orderBy: [{ occurredAt: 'asc' }, { id: 'asc' }],
take: moeglich,
include: { character: { select: { id: true, name: true } } },
});
if (kandidaten.length === 0) {
throw conflict(
'NOTHING_TO_SETTLE',
'Es gibt keine bezahlten Redesigns, die sich auf Kulanz umstellen liessen',
);
}
await prisma.$transaction(async (tx) => {
for (const buchung of kandidaten) {
await tx.creditLedger.create({
data: {
playerId,
kind: buchung.kind,
amount: -buchung.amount,
source: CreditSource.REVERSAL,
groupId: buchung.groupId,
characterId: buchung.characterId,
reversalOfId: buchung.id,
note: `Auf Kulanz umgestellt - Storno: ${buchung.character?.name ?? ''}`.trim(),
createdById: ctx.actorId,
occurredAt: buchung.occurredAt,
},
});
await tx.creditLedger.create({
data: {
playerId,
kind: CreditKind.REDESIGN_KULANZ,
amount: buchung.amount,
source: CreditSource.CONSUME,
groupId: randomUUID(),
characterId: buchung.characterId,
note: buchung.note ?? buchung.character?.name ?? null,
createdById: ctx.actorId,
occurredAt: buchung.occurredAt,
},
});
}
await recordAudit(tx, ctx, {
action: 'credit.settled_with_kulanz',
targetType: 'player',
targetId: playerId,
details: { umgebucht: kandidaten.length },
});
});
return { umgebucht: kandidaten.length, balances: await getBalances(playerId) };
}
/**
* Verschiebt einen CC innerhalb seiner Kette oder in eine andere.
*
@@ -1560,7 +1667,7 @@ export async function redesignMembership(
input: {
name: string;
loreText?: string | undefined;
useKind: RedesignKind;
useKind: JoinKind;
note?: string | undefined;
},
ctx: ActionContext,
@@ -1570,8 +1677,8 @@ export async function redesignMembership(
if (gruppe.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');
if (!JOIN_KINDS.includes(input.useKind)) {
throw badRequest('INVALID_KIND', 'Ungültige Guthaben-Art für ein Redesign');
}
const name = input.name.trim();
if (!name) throw badRequest('NO_NAME', 'Für den neuen CC wird ein Name gebraucht');
@@ -1630,6 +1737,7 @@ export async function redesignMembership(
characterId: erstellt.id,
note: input.note ?? `Redesign aus "${gruppe.name}" heraus: ${erstellt.name}`,
createdById: ctx.actorId,
...(input.useKind === CreditKind.CC ? { fundingPinned: true } : {}),
},
});
+137 -4
View File
@@ -447,7 +447,7 @@ describe('Redesign nachträglich umbuchen', () => {
);
assert.equal(zwei.balances.REDESIGN_PAID, 0, 'das bezahlte ist verbraucht');
await credits.bookKulanz(spieler, 1, 'Absprache aufgetaucht', ctx);
await credits.bookKulanz(spieler, ctx, { note: 'Absprache aufgetaucht' });
const danach = await characters.changeFunding(zwei.character.id, 'REDESIGN_KULANZ', ctx);
assert.equal(danach.balances.REDESIGN_PAID, 1, 'das bezahlte ist wieder frei');
@@ -466,7 +466,7 @@ describe('Redesign nachträglich umbuchen', () => {
it('und wieder zurück', async () => {
const spieler = await spielerMitPaketen('Hin und her', 1);
const eins = await characters.createCharacter(spieler, { name: 'A' }, ctx);
await credits.bookKulanz(spieler, 1, 'Erstmal Kulanz', ctx);
await credits.bookKulanz(spieler, ctx, { note: 'Erstmal Kulanz' });
const zwei = await characters.redesignCharacter(
eins.character.id,
{ name: 'B', useKind: 'REDESIGN_KULANZ' },
@@ -681,7 +681,7 @@ describe('Die Stelle in der Kette bestimmt, was ein Eintrag kostet', () => {
it('lässt eine Kulanz-Entscheidung unangetastet', async () => {
const spieler = await spielerMitPaketen('Kulanzfall', 2);
const eins = await characters.createCharacter(spieler, { name: 'Basis' }, ctx);
await credits.bookKulanz(spieler, 1, 'Absprache', ctx);
await credits.bookKulanz(spieler, ctx, { note: 'Absprache' });
const zwei = await characters.redesignCharacter(
eins.character.id,
{ name: 'Aus Kulanz', useKind: 'REDESIGN_KULANZ' },
@@ -951,7 +951,7 @@ describe('Die Rechnung geht auf', () => {
it('lässt eine Kulanz-Entscheidung auch beim Nachrechnen stehen', async () => {
const spieler = await spielerMitPaketen('Kulanz bleibt', 1);
const eins = await characters.createCharacter(spieler, { name: 'Start' }, ctx);
await credits.bookKulanz(spieler, 1, 'Absprache', ctx);
await credits.bookKulanz(spieler, ctx, { note: 'Absprache' });
const zwei = await characters.redesignCharacter(
eins.character.id,
{ name: 'Aus Kulanz', useKind: 'REDESIGN_KULANZ' },
@@ -967,3 +967,136 @@ describe('Die Rechnung geht auf', () => {
assert.equal(buchung?.kind, 'REDESIGN_KULANZ');
});
});
describe('Kulanz gegen ein Minus verrechnen', () => {
/** Ein Spieler mit Redesigns im Minus und Kulanz auf der Seite. */
async function schieflage() {
const spieler = await spielerMitPaketen('Schieflage', 1);
const erster = await characters.createCharacter(spieler, { name: 'Anfang' }, ctx);
// Vier Redesigns, aber nur eines bezahlt - der Topf geht ins Minus.
let letzter = erster.character.id;
for (let i = 2; i <= 5; i += 1) {
const naechster = await characters.redesignCharacter(
letzter,
{ name: `Stufe ${i}`, useKind: 'REDESIGN_PAID' },
ctx,
);
letzter = naechster.character.id;
}
const stand = await credits.getBalances(spieler);
assert.equal(stand.REDESIGN_PAID, -3, 'vier verbraucht, eines aus dem Paket');
return spieler;
}
it('gleicht so viel aus, wie beide Seiten hergeben', async () => {
const spieler = await schieflage();
for (let i = 0; i < 2; i += 1) {
await credits.bookKulanz(spieler, ctx, { note: 'Nachträglich zugestanden' });
}
const ergebnis = await characters.settleWithKulanz(spieler, ctx);
assert.equal(ergebnis.umgebucht, 2, 'zwei Kulanzen, zwei Übergänge');
assert.equal(ergebnis.balances.REDESIGN_PAID, -1, 'das Minus schrumpft um zwei');
assert.equal(ergebnis.balances.REDESIGN_KULANZ, 0, 'die Kulanz ist aufgebraucht');
assert.equal(
ergebnis.balances.redesignTotal,
-1,
'unterm Strich bleibt dasselbe - nur anders verteilt',
);
});
it('nimmt die ältesten Übergänge zuerst', async () => {
const spieler = await schieflage();
await credits.bookKulanz(spieler, ctx, { note: 'Eine Geste' });
await characters.settleWithKulanz(spieler, ctx);
const kulanzZeile = await db.prisma.creditLedger.findFirstOrThrow({
where: { playerId: spieler, kind: 'REDESIGN_KULANZ', amount: { lt: 0 } },
include: { character: { select: { name: true } } },
});
assert.equal(
kulanzZeile.character?.name,
'Stufe 2',
'wer nachträglich Kulanz bekommt, bekommt sie für das Älteste',
);
});
it('meldet sich, wenn es nichts auszugleichen gibt', async () => {
const spieler = await spielerMitPaketen('Im Plus', 1);
await credits.bookKulanz(spieler, ctx, { note: 'Übrig' });
await assert.rejects(
() => characters.settleWithKulanz(spieler, ctx),
(error: Error & { code?: string }) => error.code === 'NOTHING_TO_SETTLE',
);
});
it('meldet sich, wenn keine Kulanz da ist', async () => {
const spieler = await schieflage();
await assert.rejects(
() => characters.settleWithKulanz(spieler, ctx),
(error: Error & { code?: string }) => error.code === 'NOTHING_TO_SETTLE',
);
});
});
describe('Ein Redesign ausnahmsweise aus dem CC-Topf', () => {
it('nimmt ein CC-Guthaben an', async () => {
const spieler = await spielerMitPaketen('Ausnahmefall', 2);
const eins = await characters.createCharacter(spieler, { name: 'Basis' }, ctx);
const vorher = await credits.getBalances(spieler);
const zwei = await characters.redesignCharacter(
eins.character.id,
{ name: 'Aus dem CC-Topf', useKind: 'CC' },
ctx,
);
assert.equal(zwei.balances.CC, vorher.CC - 1, 'bezahlt aus dem CC-Topf');
assert.equal(zwei.balances.REDESIGN_PAID, vorher.REDESIGN_PAID, 'die Redesigns bleiben');
});
it('lässt sich vom Ausrichten an den Ketten nicht überschreiben', async () => {
// Ohne Merker würde die Automatik beim nächsten Umsortieren daraus
// wieder ein gewöhnliches Redesign machen.
const spieler = await spielerMitPaketen('Bleibt so', 2);
const eins = await characters.createCharacter(spieler, { name: 'Erstes' }, ctx);
const zwei = await characters.redesignCharacter(
eins.character.id,
{ name: 'Zweites', useKind: 'CC' },
ctx,
);
await characters.rebuildFunding(spieler, ctx);
const buchung = await db.prisma.creditLedger.findFirst({
where: { characterId: zwei.character.id, source: 'CONSUME', reversedBy: null },
orderBy: { id: 'desc' },
});
assert.equal(buchung?.kind, 'CC', 'die bewusste Entscheidung bleibt stehen');
});
it('merkt sich auch eine von Hand geänderte Bezahlart', async () => {
const spieler = await spielerMitPaketen('Von Hand', 2);
const eins = await characters.createCharacter(spieler, { name: 'A' }, ctx);
const zwei = await characters.redesignCharacter(
eins.character.id,
{ name: 'B', useKind: 'REDESIGN_PAID' },
ctx,
);
await characters.changeFunding(zwei.character.id, 'CC', ctx);
await characters.rebuildFunding(spieler, ctx);
const buchung = await db.prisma.creditLedger.findFirst({
where: { characterId: zwei.character.id, source: 'CONSUME', reversedBy: null },
orderBy: { id: 'desc' },
});
assert.equal(buchung?.kind, 'CC');
});
});
+11
View File
@@ -0,0 +1,11 @@
{
// Tests werden nicht gebaut, aber geprüft: Ein Aufruf mit vertauschten
// Argumenten läuft zur Laufzeit oft einfach durch und prüft dann etwas
// anderes als gedacht - ohne dass es auffällt.
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": true,
"rootDir": "."
},
"include": ["src/**/*.ts", "test/**/*.ts"]
}