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
+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 } : {}),
},
});