Ketten mit Vorgeschichte: unterstes Glied ist nicht immer ein Kauf

Ein Spieler mit drei Paketen und drei Plaetzen stand bei "-1 von 3". Der
dritte Platz kam aus einem fremden Gruppen-CC: beigetreten mit einem
CC-Guthaben, spaeter per Redesign ausgestiegen und als eigener CC
weitergefuehrt. Damit beginnt zwar eine neue Kette, aber kein neuer Platz -
bezahlt ist er laengst, naemlich mit dem Beitritt.

Die Automatik sah nur "unterstes Glied" und buchte es auf CC-Paket um. Der
Platz stand damit zweimal im Konto, einmal fuer den Beitritt und einmal fuer
den eigenen CC. Jetzt zaehlt, ob der Kette etwas vorausging: Zeigt ihr erstes
Glied auf einen Vorgaenger ausserhalb, ist auch dieses Glied ein Redesign.

Dabei fiel ein zweiter Fehler auf: Das Neuverketten loeschte genau diesen
Verweis, weil es die Verkettung innerhalb der Linie normalisierte. Damit ging
die einzige Spur zur Herkunft verloren - und mit ihr der Eintrag "war dabei"
am Fuss der Kette. Ein Vorgaenger ausserhalb bleibt jetzt stehen.

Fuer die Korrektur bereits falsch gebuchter Eintraege kann "Bezahlart" jetzt
auch zwischen CC-Paket und Redesign wechseln, nicht nur zwischen bezahlt und
Kulanz. Der Knopf steht an jedem Eintrag, nicht nur an Redesigns.
This commit is contained in:
2026-09-21 19:07:29 +02:00
parent f7b48a9891
commit 1b643cb758
6 changed files with 180 additions and 56 deletions
+8 -3
View File
@@ -68,6 +68,11 @@ du mitten in eine Kette schiebst, ist von da an ein Redesign, und das CC-Guthabe
Ziehst du einen Eintrag an den Anfang oder löst ihn heraus, belegt er einen eigenen Platz und Ziehst du einen Eintrag an den Anfang oder löst ihn heraus, belegt er einen eigenen Platz und
kostet wieder ein CC-Paket. kostet wieder ein CC-Paket.
Eine Ausnahme gibt es: **Hat die Kette eine Herkunft außerhalb**, ist auch ihr unterstes Glied
kein Kauf. Wer aus einem fremden Gruppen-CC herausredesignt, beginnt zwar eine eigene Kette -
bezahlt hat er sie trotzdem mit einem Redesign. Sonst stünde derselbe Platz zweimal im Konto:
einmal für den Beitritt, einmal für den eigenen CC.
Umgebucht wird über eine Gegenbuchung, nicht durch Überschreiben - in der Historie bleibt Umgebucht wird über eine Gegenbuchung, nicht durch Überschreiben - in der Historie bleibt
sichtbar, was passiert ist. Reicht das Guthaben nicht, geht das Konto ins Minus und zeigt das an. sichtbar, was passiert ist. Reicht das Guthaben nicht, geht das Konto ins Minus und zeigt das an.
Zwischen bezahltem Redesign und Kulanz wird dabei **nicht** gewechselt: Das ist eine Zwischen bezahltem Redesign und Kulanz wird dabei **nicht** gewechselt: Das ist eine
@@ -81,9 +86,9 @@ woanders steht. Ein Platz entsteht dabei nicht aus dem Nichts: Wird ein CC dadur
muss er gedeckt sein wie jeder andere. muss er gedeckt sein wie jeder andere.
Über *Bearbeiten* lassen sich Name, Beschreibung und der verknüpfte Forums-Antrag nachtragen - Über *Bearbeiten* lassen sich Name, Beschreibung und der verknüpfte Forums-Antrag nachtragen -
die Thread-Nummer genügt, die volle Adresse geht auch. Über *Bezahlart* wird umgestellt, ob ein die Thread-Nummer genügt, die volle Adresse geht auch. Über *Bezahlart* wird umgestellt, woraus ein Eintrag
Redesign aus einem gekauften Paket oder aus Kulanz kam; umgebucht wird über eine Gegenbuchung, bezahlt wurde - aus einem CC-Paket, einem bezahlten Redesign oder aus Kulanz; umgebucht wird über
in der Historie bleibt beides sichtbar. eine Gegenbuchung, in der Historie bleibt beides sichtbar.
Bei einem **Gruppen-CC** gilt das für die eigene Buchung: Wer ihn in seine Kette einreiht, stellt Bei einem **Gruppen-CC** gilt das für die eigene Buchung: Wer ihn in seine Kette einreiht, stellt
damit seinen eigenen Beitritt um - die Buchung des Erstellers bleibt unberührt, für den ist es damit seinen eigenen Beitritt um - die Buchung des Erstellers bleibt unberührt, für den ist es
+7 -6
View File
@@ -24,7 +24,7 @@ import {
removeLink, removeLink,
moveInChain, moveInChain,
detachFromChain, detachFromChain,
changeRedesignKind, changeFunding,
addMemberToChain, addMemberToChain,
} from '../services/characters.js'; } from '../services/characters.js';
import { parseThreadId } from '../lib/forum.js'; import { parseThreadId } from '../lib/forum.js';
@@ -183,18 +183,19 @@ export async function characterRoutes(app: FastifyInstance): Promise<void> {
}); });
/** /**
* Nachträglich umstellen, woraus ein Redesign bezahlt wurde. Umgebucht * Nachträglich umstellen, woraus ein CC bezahlt wurde - aus einem Paket,
* wird im Kontobuch, nicht überschrieben. * einem bezahlten Redesign oder aus Kulanz. Umgebucht wird im Kontobuch,
* nicht überschrieben.
*/ */
app.post('/api/characters/:id/redesign-kind', { preHandler: requireEditor }, async (request) => { app.post('/api/characters/:id/funding', { preHandler: requireEditor }, async (request) => {
const { id } = parseWith(idParam, request.params); const { id } = parseWith(idParam, request.params);
const body = parseWith( const body = parseWith(
z.object({ z.object({
useKind: z.enum([CreditKind.REDESIGN_PAID, CreditKind.REDESIGN_KULANZ]), useKind: z.enum([CreditKind.CC, CreditKind.REDESIGN_PAID, CreditKind.REDESIGN_KULANZ]),
}), }),
request.body, request.body,
); );
return changeRedesignKind(id, body.useKind, contextOf(request)); return changeFunding(id, body.useKind, contextOf(request));
}); });
// --- Ketten --- // --- Ketten ---
+46 -21
View File
@@ -731,24 +731,26 @@ export async function reviveDeadCharacter(
/** /**
* Stellt nachträglich um, woraus ein Redesign bezahlt wurde. * Stellt nachträglich um, woraus ein CC bezahlt wurde.
* *
* Ob ein Redesign aus einem gekauften Paket oder aus Kulanz kam, stellt sich * Ob etwas aus einem gekauften Paket, einem bezahlten Redesign oder aus
* manchmal erst später heraus - etwa wenn eine Absprache auftaucht, von der * Kulanz kam, stellt sich manchmal erst später heraus - wenn eine Absprache
* beim Eintragen niemand wusste. Umgebucht wird nicht durch Überschreiben, * auftaucht, von der beim Eintragen niemand wusste, oder wenn sich zeigt,
* sondern wie jede Korrektur im Kontobuch: die alte Zeile wird * dass ein Eintrag gar nicht der Anfang einer Kette war.
* gegengebucht, die neue kommt dazu. So bleibt sichtbar, dass hier etwas *
* geändert wurde, und wann. * Umgebucht wird nicht durch Überschreiben, sondern wie jede Korrektur im
* Kontobuch: die alte Zeile wird gegengebucht, die neue kommt dazu. So
* bleibt sichtbar, dass hier etwas geändert wurde, und wann.
*/ */
export async function changeRedesignKind( export async function changeFunding(
characterId: number, characterId: number,
useKind: RedesignKind, useKind: JoinKind,
ctx: ActionContext, ctx: ActionContext,
): Promise<CharacterResult> { ): Promise<CharacterResult> {
const character = await loadCharacter(characterId); const character = await loadCharacter(characterId);
if (!REDESIGN_KINDS.includes(useKind)) { if (!JOIN_KINDS.includes(useKind)) {
throw badRequest('INVALID_KIND', 'Ein Redesign kostet bezahltes oder Kulanz-Redesign-Guthaben'); throw badRequest('INVALID_KIND', 'Ungültige Guthaben-Art');
} }
// Die Buchung, mit der dieser CC bezahlt wurde. Stornierte zählen nicht // Die Buchung, mit der dieser CC bezahlt wurde. Stornierte zählen nicht
@@ -756,24 +758,26 @@ export async function changeRedesignKind(
const buchung = await prisma.creditLedger.findFirst({ const buchung = await prisma.creditLedger.findFirst({
where: { where: {
characterId, characterId,
playerId: character.playerId,
source: CreditSource.CONSUME, source: CreditSource.CONSUME,
kind: { in: [...REDESIGN_KINDS] },
reversedBy: null, reversedBy: null,
}, },
orderBy: { id: 'desc' }, orderBy: { id: 'desc' },
}); });
if (!buchung) { if (!buchung) {
throw conflict( throw conflict(
'NO_REDESIGN_BOOKING', 'NO_BOOKING',
'Für diesen CC ist kein offenes Redesign gebucht - hier gibt es nichts umzustellen', 'Für diesen CC ist nichts gebucht - hier gibt es nichts umzustellen',
); );
} }
if (buchung.kind === useKind) { if (buchung.kind === useKind) {
throw conflict('SAME_KIND', 'Dieses Redesign ist bereits so gebucht'); throw conflict('SAME_KIND', 'Dieser CC ist bereits so gebucht');
} }
const grund = const grund =
useKind === CreditKind.REDESIGN_KULANZ useKind === CreditKind.CC
? 'Umgestellt auf CC-Paket'
: useKind === CreditKind.REDESIGN_KULANZ
? 'Umgestellt auf Kulanz' ? 'Umgestellt auf Kulanz'
: 'Umgestellt auf bezahltes Redesign'; : 'Umgestellt auf bezahltes Redesign';
@@ -809,7 +813,7 @@ export async function changeRedesignKind(
}); });
await recordAudit(tx, ctx, { await recordAudit(tx, ctx, {
action: 'character.redesign_kind_changed', action: 'character.funding_changed',
targetType: 'character', targetType: 'character',
targetId: characterId, targetId: characterId,
details: { name: character.name, from: buchung.kind, to: useKind }, details: { name: character.name, from: buchung.kind, to: useKind },
@@ -936,19 +940,40 @@ async function verketteNeu(
// Die Kette gehört dem, der unten steht: bei ihm fing sie an. // Die Kette gehört dem, der unten steht: bei ihm fing sie an.
const kettenBesitzer = glieder[0]?.playerId ?? null; const kettenBesitzer = glieder[0]?.playerId ?? null;
const inDieserLinie = new Set(glieder.map((g) => g.id));
/**
* Eine Herkunft ausserhalb dieser Kette.
*
* Wer aus einem fremden Gruppen-CC herausredesignt, beginnt eine eigene
* Kette - bezahlt hat er sie trotzdem mit einem Redesign, nicht mit einem
* Paket. Ihr unterstes Glied ist deshalb kein Kauf, und der Verweis auf
* die Herkunft bleibt stehen: er ist das Einzige, was noch zeigt, woher
* die Kette kommt.
*/
const herkunft = glieder[0]?.predecessorId ?? null;
const hatHerkunft = herkunft !== null && !inDieserLinie.has(herkunft);
let vorheriges: number | null = null; let vorheriges: number | null = null;
for (const [index, glied] of glieder.entries()) { for (const [index, glied] of glieder.entries()) {
const letztes = index === glieder.length - 1; const letztes = index === glieder.length - 1;
const erstes = index === 0;
const daten: Prisma.CharacterUpdateInput = {}; const daten: Prisma.CharacterUpdateInput = {};
// Das unterste Glied ist der gekaufte CC, alles darüber sein Redesign. // Das unterste Glied ist der gekaufte CC - es sei denn, ihm ging etwas
// voraus. Alles darüber sind seine Redesigns.
if (kettenBesitzer !== null) { if (kettenBesitzer !== null) {
await buchungFolgtPosition(tx, glied, kettenBesitzer, index === 0, actorId); await buchungFolgtPosition(tx, glied, kettenBesitzer, erstes && !hatHerkunft, actorId);
} }
if (glied.predecessorId !== vorheriges) { if (erstes) {
daten.predecessor = vorheriges === null ? { disconnect: true } : { connect: { id: vorheriges } }; // Am Anfang wird nur eine Verkettung gelöst, die innerhalb dieser
// Linie ins Leere zeigt. Eine Herkunft von aussen bleibt.
if (glied.predecessorId !== null && inDieserLinie.has(glied.predecessorId)) {
daten.predecessor = { disconnect: true };
}
} else if (glied.predecessorId !== vorheriges) {
daten.predecessor = { connect: { id: vorheriges as number } };
} }
// Was einen Nachfolger hat, wurde durch ihn ersetzt - anders ist eine // Was einen Nachfolger hat, wurde durch ihn ersetzt - anders ist eine
+102 -8
View File
@@ -448,7 +448,7 @@ describe('Redesign nachträglich umbuchen', () => {
assert.equal(zwei.balances.REDESIGN_PAID, 0, 'das bezahlte ist verbraucht'); assert.equal(zwei.balances.REDESIGN_PAID, 0, 'das bezahlte ist verbraucht');
await credits.bookKulanz(spieler, 1, 'Absprache aufgetaucht', ctx); await credits.bookKulanz(spieler, 1, 'Absprache aufgetaucht', ctx);
const danach = await characters.changeRedesignKind(zwei.character.id, 'REDESIGN_KULANZ', ctx); const danach = await characters.changeFunding(zwei.character.id, 'REDESIGN_KULANZ', ctx);
assert.equal(danach.balances.REDESIGN_PAID, 1, 'das bezahlte ist wieder frei'); assert.equal(danach.balances.REDESIGN_PAID, 1, 'das bezahlte ist wieder frei');
assert.equal(danach.balances.REDESIGN_KULANZ, 0, 'dafür ist die Kulanz verbraucht'); assert.equal(danach.balances.REDESIGN_KULANZ, 0, 'dafür ist die Kulanz verbraucht');
@@ -473,7 +473,7 @@ describe('Redesign nachträglich umbuchen', () => {
ctx, ctx,
); );
const danach = await characters.changeRedesignKind(zwei.character.id, 'REDESIGN_PAID', ctx); const danach = await characters.changeFunding(zwei.character.id, 'REDESIGN_PAID', ctx);
assert.equal(danach.balances.REDESIGN_KULANZ, 1); assert.equal(danach.balances.REDESIGN_KULANZ, 1);
assert.equal(danach.balances.REDESIGN_PAID, 0, 'das aus dem CC-Paket ist jetzt dran'); assert.equal(danach.balances.REDESIGN_PAID, 0, 'das aus dem CC-Paket ist jetzt dran');
}); });
@@ -488,19 +488,26 @@ describe('Redesign nachträglich umbuchen', () => {
); );
await assert.rejects( await assert.rejects(
() => characters.changeRedesignKind(zwei.character.id, 'REDESIGN_PAID', ctx), () => characters.changeFunding(zwei.character.id, 'REDESIGN_PAID', ctx),
(error: Error & { code?: string }) => error.code === 'SAME_KIND', (error: Error & { code?: string }) => error.code === 'SAME_KIND',
); );
}); });
it('weist einen CC ohne Redesign-Buchung ab', async () => { it('stellt einen gekauften CC auf Redesign um', async () => {
const spieler = await spielerMitPaketen('Ohne Redesign', 1); // Genau der Korrekturfall: ein Eintrag war gar nicht der Anfang einer
// Kette, sondern ging aus einem anderen hervor.
const spieler = await spielerMitPaketen('Umsteller', 1);
const eins = await characters.createCharacter(spieler, { name: 'Direkt aus dem Paket' }, ctx); const eins = await characters.createCharacter(spieler, { name: 'Direkt aus dem Paket' }, ctx);
await credits.bookRedesignPackage(spieler, ctx);
const danach = await characters.changeFunding(eins.character.id, 'REDESIGN_PAID', ctx);
assert.equal(danach.balances.CC, 1, 'das CC-Guthaben kommt zurück');
assert.equal(danach.balances.REDESIGN_PAID, 1, 'dafür geht ein Redesign weg');
await assert.rejects( await assert.rejects(
() => characters.changeRedesignKind(eins.character.id, 'REDESIGN_KULANZ', ctx), () => characters.changeFunding(eins.character.id, 'REDESIGN_PAID', ctx),
(error: Error & { code?: string }) => error.code === 'NO_REDESIGN_BOOKING', (error: Error & { code?: string }) => error.code === 'SAME_KIND',
'ein CC aus dem Paket ist kein Redesign', 'ein CC aus dem Paket ist schon als CC gebucht',
); );
}); });
}); });
@@ -718,3 +725,90 @@ describe('Die Stelle in der Kette bestimmt, was ein Eintrag kostet', () => {
assert.deepEqual(await credits.getBalances(spieler), vorher, 'nichts gebucht, nichts geändert'); assert.deepEqual(await credits.getBalances(spieler), vorher, 'nichts gebucht, nichts geändert');
}); });
}); });
describe('Eine Kette mit Vorgeschichte ausserhalb', () => {
/**
* Der Fall aus dem Betrieb: Ein Spieler tritt einem fremden Gruppen-CC
* bei, steigt später per Redesign aus und führt seinen eigenen CC weiter.
* Dessen Kette beginnt nicht bei null - sie hat eine Herkunft, die
* woanders liegt.
*/
async function ausgestiegen() {
const dargon = await spielerMitPaketen('Aussteiger', 3);
const wirt = await spielerMitPaketen('Gruppen-Wirt', 1);
await characters.createCharacter(dargon, { name: 'Platz eins' }, ctx);
await characters.createCharacter(dargon, { name: 'Platz zwei' }, ctx);
const fremd = await characters.createCharacter(wirt, { name: 'Fremde Gruppe' }, ctx);
await characters.addMember(fremd.character.id, dargon, { useKind: 'CC' }, ctx);
await credits.bookRedesignPackage(dargon, ctx);
const eigenes = await characters.redesignMembership(
fremd.character.id,
dargon,
{ name: 'Eigener Strang', useKind: 'REDESIGN_PAID' },
ctx,
);
await credits.bookRedesignPackage(dargon, ctx);
const weiter = await characters.redesignCharacter(
eigenes.character.id,
{ name: 'Und weiter', useKind: 'REDESIGN_PAID' },
ctx,
);
return { dargon, eigenes: eigenes.character, weiter: weiter.character };
}
it('zählt drei Plätze und drei verbrauchte Pakete', async () => {
const { dargon } = await ausgestiegen();
const stand = await credits.getBalances(dargon);
assert.equal(stand.granted.CC, 3, 'drei Pakete gekauft');
assert.equal(stand.CC, 0, 'drei Plätze belegt, keiner zu viel');
});
it('bucht das erste Glied einer hergeleiteten Kette nicht auf CC um', async () => {
// Es ist zwar das unterste seiner Kette, aber kein Kauf: es ging aus dem
// Gruppen-CC hervor. Wer das übersieht, bucht einen Platz doppelt.
const { dargon, eigenes, weiter } = await ausgestiegen();
const vorher = await credits.getBalances(dargon);
// Ein Verschieben innerhalb der Kette ordnet neu - und darf dabei nichts
// erfinden.
await characters.moveInChain(
weiter.id,
{ lineageId: eigenes.lineageId, afterId: eigenes.id, forPlayerId: dargon },
ctx,
);
assert.deepEqual(
await credits.getBalances(dargon),
vorher,
'am Konto ändert sich dabei nichts',
);
const buchung = await db.prisma.creditLedger.findFirst({
where: { characterId: eigenes.id, source: 'CONSUME', reversedBy: null },
orderBy: { id: 'desc' },
});
assert.equal(buchung?.kind, 'REDESIGN_PAID', 'es bleibt ein Redesign');
});
it('behält den Verweis auf die Herkunft', async () => {
const { dargon, eigenes, weiter } = await ausgestiegen();
await characters.moveInChain(
weiter.id,
{ lineageId: eigenes.lineageId, afterId: eigenes.id, forPlayerId: dargon },
ctx,
);
const danach = await db.prisma.character.findUniqueOrThrow({ where: { id: eigenes.id } });
assert.ok(
danach.predecessorId,
'sonst wäre nicht mehr zu sehen, woher die Kette kommt',
);
});
});
+1 -3
View File
@@ -527,16 +527,14 @@ function ChainEntry({
<button type="button" className="btn btn-tiny btn-ghost" onClick={() => onAction('edit', character)}> <button type="button" className="btn btn-tiny btn-ghost" onClick={() => onAction('edit', character)}>
Bearbeiten Bearbeiten
</button> </button>
{character.predecessorId !== null && (
<button <button
type="button" type="button"
className="btn btn-tiny btn-ghost" className="btn btn-tiny btn-ghost"
title="Bezahltes Redesign oder Kulanz? Lässt sich nachträglich umbuchen." title="Aus einem CC-Paket, einem bezahlten Redesign oder aus Kulanz? Lässt sich nachträglich umbuchen."
onClick={() => onAction('redesignKind', character)} onClick={() => onAction('redesignKind', character)}
> >
Bezahlart Bezahlart
</button> </button>
)}
<button type="button" className="btn btn-tiny btn-ghost" onClick={() => onAction('links', character)}> <button type="button" className="btn btn-tiny btn-ghost" onClick={() => onAction('links', character)}>
Verweise Verweise
</button> </button>
+7 -6
View File
@@ -538,7 +538,7 @@ export default function PlayerDetail() {
onSubmit={(useKind) => onSubmit={(useKind) =>
void run( void run(
() => () =>
api(`/api/characters/${dialog.character.id}/redesign-kind`, { api(`/api/characters/${dialog.character.id}/funding`, {
method: 'POST', method: 'POST',
body: { useKind }, body: { useKind },
}), }),
@@ -1749,7 +1749,7 @@ function ProblemModal({
} }
/** /**
* Stellt um, woraus ein Redesign bezahlt wurde. * Stellt um, woraus ein CC bezahlt wurde.
* *
* Umgebucht wird im Kontobuch: die alte Zeile wird gegengebucht, die neue * Umgebucht wird im Kontobuch: die alte Zeile wird gegengebucht, die neue
* kommt dazu. Überschrieben wird nichts - in der Historie bleibt sichtbar, * kommt dazu. Überschrieben wird nichts - in der Historie bleibt sichtbar,
@@ -1763,7 +1763,7 @@ function RedesignKindModal({
onSubmit, onSubmit,
}: { }: {
character: Character; character: Character;
balances: { REDESIGN_PAID: number; REDESIGN_KULANZ: number }; balances: { CC: number; REDESIGN_PAID: number; REDESIGN_KULANZ: number };
busy: boolean; busy: boolean;
onClose: () => void; onClose: () => void;
onSubmit: (useKind: CreditKind) => void; onSubmit: (useKind: CreditKind) => void;
@@ -1773,14 +1773,15 @@ function RedesignKindModal({
return ( return (
<Modal title={`Bezahlart von "${character.name}"`} onClose={onClose}> <Modal title={`Bezahlart von "${character.name}"`} onClose={onClose}>
<Notice kind="info"> <Notice kind="info">
Ob ein Redesign aus einem gekauften Paket oder aus Kulanz kam, stellt sich manchmal erst Woraus ein CC bezahlt wurde, stellt sich manchmal erst später heraus - etwa wenn eine
später heraus. Umgebucht wird über eine Gegenbuchung - in der Historie bleibt beides Absprache auftaucht oder sich zeigt, dass ein Eintrag gar nicht der Anfang einer Kette
sichtbar. war. Umgebucht wird über eine Gegenbuchung; in der Historie bleibt beides sichtbar.
</Notice> </Notice>
<div className="radio-group"> <div className="radio-group">
{( {(
[ [
['CC', 'CC-Paket (eigener Platz)', balances.CC],
['REDESIGN_KULANZ', 'Kulanz-Redesign', balances.REDESIGN_KULANZ], ['REDESIGN_KULANZ', 'Kulanz-Redesign', balances.REDESIGN_KULANZ],
['REDESIGN_PAID', 'Bezahltes Redesign', balances.REDESIGN_PAID], ['REDESIGN_PAID', 'Bezahltes Redesign', balances.REDESIGN_PAID],
] as const ] as const