CC-Guthaben wird vorher umgewandelt, nicht am CC eingesetzt

Ein Redesign wird nie aus dem CC-Topf bezahlt. Wer sein Guthaben dafuer
einsetzen will, tauscht es oben um und setzt es danach ein wie jedes
andere Redesign. Damit faellt die Wahl CC-Guthaben an den CC-Aktionen
weg; was ein Eintrag kostet, entscheidet seine Stelle in der Liste.

Die Bezahlart gilt jetzt je Nutzer: bei einem Gruppen-CC hat jeder seine
eigene Buchung, und umstellen liess sich bisher nur die des Erstellers.

Die Guthaben-Historie laesst sich nach Topf filtern.
This commit is contained in:
2026-09-22 12:42:48 +02:00
parent 1f4034b397
commit fee4a4f73a
7 changed files with 302 additions and 216 deletions
+4 -1
View File
@@ -200,10 +200,13 @@ export async function characterRoutes(app: FastifyInstance): Promise<void> {
const body = parseWith( const body = parseWith(
z.object({ z.object({
useKind: z.enum([CreditKind.CC, CreditKind.REDESIGN_PAID, CreditKind.REDESIGN_KULANZ]), useKind: z.enum([CreditKind.CC, CreditKind.REDESIGN_PAID, CreditKind.REDESIGN_KULANZ]),
/// Wessen Buchung gemeint ist - bei einem Gruppen-CC hat jeder
/// Nutzer seine eigene.
forPlayerId: z.number().int().positive().optional(),
}), }),
request.body, request.body,
); );
return changeFunding(id, body.useKind, contextOf(request)); return changeFunding(id, body.useKind, contextOf(request), body.forPlayerId);
}); });
/** /**
+22
View File
@@ -6,6 +6,7 @@ import {
bookAdjustment, bookAdjustment,
bookKulanz, bookKulanz,
bookRedesignPackage, bookRedesignPackage,
convertCcToRedesign,
grantCcSlot, grantCcSlot,
removeLastGrant, removeLastGrant,
reverseGroup, reverseGroup,
@@ -44,6 +45,27 @@ export async function creditRoutes(app: FastifyInstance): Promise<void> {
return bookKulanz(id, contextOf(request), { note: body.note }); return bookKulanz(id, contextOf(request), { note: body.note });
}); });
/**
* Wandelt ein CC-Guthaben in ein Redesign um.
*
* Ein Redesign wird nie aus dem CC-Topf bezahlt. Wer sein Guthaben dafür
* einsetzen will, tauscht es hier und setzt es danach wie jedes andere
* Redesign ein.
*/
app.post('/api/players/:id/credits/convert', { preHandler: requireEditor }, async (request) => {
const { id } = parseWith(idParam, request.params);
return convertCcToRedesign(id, contextOf(request));
});
app.post(
'/api/players/:id/credits/convert/remove',
{ preHandler: requireEditor },
async (request) => {
const { id } = parseWith(idParam, request.params);
return removeLastGrant(id, CreditSource.CONVERSION, contextOf(request));
},
);
/** /**
* Nimmt die zuletzt von Hand eingetragene Zugabe zurück. * Nimmt die zuletzt von Hand eingetragene Zugabe zurück.
* *
+59 -100
View File
@@ -10,7 +10,7 @@ import {
} from '@prisma/client'; } from '@prisma/client';
import { prisma } from '../db.js'; import { prisma } from '../db.js';
import { badRequest, conflict, notFound } from '../errors.js'; import { badRequest, conflict, notFound } from '../errors.js';
import { getBalances, wandleCcInRedesign, type Balances } from './credits.js'; import { getBalances, type Balances } from './credits.js';
import { recordAudit, type ActionContext } from './audit.js'; import { recordAudit, type ActionContext } from './audit.js';
export interface CharacterResult { export interface CharacterResult {
@@ -397,7 +397,7 @@ export async function reactivatePaid(
playerId: updated.playerId, playerId: updated.playerId,
characterId: updated.id, characterId: updated.id,
kind: useKind, kind: useKind,
redesign: updated.predecessorId !== null, redesign: null,
note: note ?? `Reaktivierung: ${updated.name}`, note: note ?? `Reaktivierung: ${updated.name}`,
actorId: ctx.actorId, actorId: ctx.actorId,
}); });
@@ -745,7 +745,7 @@ export async function reviveDeadCharacter(
playerId: updated.playerId, playerId: updated.playerId,
characterId: updated.id, characterId: updated.id,
kind: input.useKind, kind: input.useKind,
redesign: updated.predecessorId !== null, redesign: null,
note: `Rückkehr nach Tod: ${input.reason}`, note: `Rückkehr nach Tod: ${input.reason}`,
actorId: ctx.actorId, actorId: ctx.actorId,
}); });
@@ -777,13 +777,9 @@ export async function reviveDeadCharacter(
/** /**
* Stellt um, woraus ein Eintrag bezahlt wurde. * Stellt um, woraus ein Eintrag bezahlt wurde.
* *
* Zwei Dinge lassen sich hier entscheiden: ob ein Redesign aus dem bezahlten * Zu entscheiden bleibt eines: ob ein Redesign aus dem bezahlten Topf kommt
* Topf oder aus Kulanz kommt - und ob dafür ausnahmsweise ein CC-Guthaben * oder aus Kulanz. Ob ein Eintrag überhaupt ein Redesign ist, steht nicht
* eingesetzt wird. Das Letzte ist keine eigene Bezahlart, sondern eine * zur Wahl - das entscheidet seine Stelle in der Liste.
* Umwandlung: ein Platz weniger, ein Redesign mehr.
*
* Ob ein Eintrag überhaupt ein Redesign ist, steht nicht zur Wahl - das
* entscheidet seine Stelle in der Kette.
* *
* Geändert wird wie jede Korrektur im Kontobuch: die alte Zeile wird * Geändert wird wie jede Korrektur im Kontobuch: die alte Zeile wird
* gegengebucht, die neue kommt dazu. So bleibt sichtbar, dass hier etwas * gegengebucht, die neue kommt dazu. So bleibt sichtbar, dass hier etwas
@@ -793,19 +789,21 @@ export async function changeFunding(
characterId: number, characterId: number,
useKind: JoinKind, useKind: JoinKind,
ctx: ActionContext, ctx: ActionContext,
forPlayerId?: number,
): Promise<CharacterResult> { ): Promise<CharacterResult> {
const character = await loadCharacter(characterId); const character = await loadCharacter(characterId);
const spieler = forPlayerId ?? character.playerId;
if (!JOIN_KINDS.includes(useKind)) { if (!JOIN_KINDS.includes(useKind)) {
throw badRequest('INVALID_KIND', 'Ungültige Guthaben-Art'); 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 Spieler seinen Platz bezahlt hat. Bei einem
// mehr; offen ist immer höchstens eine. // Gruppen-CC hat jeder Nutzer seine eigene; stornierte zählen nicht mehr.
const buchung = await prisma.creditLedger.findFirst({ const buchung = await prisma.creditLedger.findFirst({
where: { where: {
characterId, characterId,
playerId: character.playerId, playerId: spieler,
source: CreditSource.CONSUME, source: CreditSource.CONSUME,
reversedBy: null, reversedBy: null,
}, },
@@ -814,64 +812,42 @@ export async function changeFunding(
if (!buchung) { if (!buchung) {
throw conflict( throw conflict(
'NO_BOOKING', 'NO_BOOKING',
'Für diesen CC ist nichts gebucht - hier gibt es nichts umzustellen', 'Für diesen Spieler ist an diesem CC nichts gebucht - hier gibt es nichts umzustellen. ' +
'Sein Platz hängt weiter unten in der Liste.',
); );
} }
const redesign = await istRedesignPosition(prisma, characterId, buchung.playerId); const redesign = await istRedesignPosition(prisma, characterId, spieler);
if (!redesign && useKind !== CreditKind.CC) { if (!redesign && useKind !== CreditKind.CC) {
throw conflict( throw conflict(
'CHAIN_START', 'CHAIN_START',
'Dieser Eintrag steht am Anfang einer Kette und bucht einen eigenen Platz. ' + 'Dieser Eintrag steht am Anfang einer Liste und bucht einen eigenen Platz. ' +
'Erst über einem anderen Eintrag wird daraus ein Redesign.', 'Erst über einem anderen Eintrag wird daraus ein Redesign.',
); );
} }
if (redesign && useKind === CreditKind.CC) {
// Ein CC-Guthaben für ein Redesign wird umgewandelt, nicht anders gebucht. throw conflict(
const willUmwandeln = redesign && useKind === CreditKind.CC; 'CONVERT_FIRST',
const zielArt = willUmwandeln ? CreditKind.REDESIGN_PAID : useKind; 'Ein Redesign wird nicht aus dem CC-Topf bezahlt. Wandle das CC-Guthaben oben in ein ' +
const hatUmwandlung = 'Redesign um, dann steht es hier zur Verfügung.',
(await prisma.creditLedger.count({ );
where: { }
characterId, if (buchung.kind === useKind) {
playerId: buchung.playerId,
source: CreditSource.CONVERSION,
reversedBy: null,
},
})) > 0;
if (buchung.kind === zielArt && willUmwandeln === hatUmwandlung) {
throw conflict('SAME_KIND', 'Dieser CC ist bereits so gebucht'); throw conflict('SAME_KIND', 'Dieser CC ist bereits so gebucht');
} }
const grund = willUmwandeln const grund =
? 'CC-Guthaben umgewandelt' useKind === CreditKind.REDESIGN_KULANZ
: useKind === CreditKind.CC ? 'Umgestellt auf Kulanz'
? 'Umgestellt auf CC-Paket' : 'Umgestellt auf bezahltes Redesign';
: useKind === CreditKind.REDESIGN_KULANZ
? 'Umgestellt auf Kulanz'
: 'Umgestellt auf bezahltes Redesign';
await prisma.$transaction(async (tx) => { await prisma.$transaction(async (tx) => {
await storniere(tx, buchung, `${grund}: ${character.name}`, ctx.actorId); await storniere(tx, buchung, `${grund}: ${character.name}`, ctx.actorId);
if (willUmwandeln && !hatUmwandlung) {
await wandleCcInRedesign(tx, {
playerId: buchung.playerId,
characterId,
actorId: ctx.actorId,
note: `CC-Guthaben als Redesign eingesetzt: ${character.name}`,
occurredAt: buchung.occurredAt,
});
}
if (!willUmwandeln && hatUmwandlung) {
await nimmUmwandlungZurueck(tx, characterId, buchung.playerId, ctx.actorId);
}
await tx.creditLedger.create({ await tx.creditLedger.create({
data: { data: {
playerId: buchung.playerId, playerId: spieler,
kind: zielArt, kind: useKind,
amount: buchung.amount, amount: buchung.amount,
source: CreditSource.CONSUME, source: CreditSource.CONSUME,
groupId: randomUUID(), groupId: randomUUID(),
@@ -887,11 +863,11 @@ export async function changeFunding(
action: 'character.funding_changed', action: 'character.funding_changed',
targetType: 'character', targetType: 'character',
targetId: characterId, targetId: characterId,
details: { name: character.name, from: buchung.kind, to: zielArt, umgewandelt: willUmwandeln }, details: { name: character.name, playerId: spieler, from: buchung.kind, to: useKind },
}); });
}); });
const balances = await getBalances(character.playerId); const balances = await getBalances(spieler);
return { character, balances, overdrawn: isOverdrawn(balances) }; return { character, balances, overdrawn: isOverdrawn(balances) };
} }
@@ -927,11 +903,11 @@ async function istRedesignPosition(
/** /**
* Bucht, was ein Eintrag kostet. * Bucht, was ein Eintrag kostet.
* *
* Ein Platz am Anfang einer Kette kommt aus dem CC-Topf, alles darüber aus * Am Anfang einer Liste steht ein gekaufter Platz, alles darüber ein
* einem Redesign-Topf. Wer sein CC-Guthaben für ein Redesign einsetzen will, * Redesign. Die Stelle entscheidet, nicht die Wahl - sonst ginge die
* wandelt es hier um: ein Platz weniger, ein bezahltes Redesign mehr. Der * Rechnung "eine Liste, ein CC-Paket" nicht mehr auf. Wer sein CC-Guthaben
* Eintrag selbst ist danach ein Redesign wie jedes andere - dadurch bleibt * für ein Redesign einsetzen will, wandelt es vorher um; danach ist es ein
* die Rechnung überall dieselbe, ohne Sonderfall. * Redesign wie jedes andere.
*/ */
async function bucheVerbrauch( async function bucheVerbrauch(
tx: Prisma.TransactionClient, tx: Prisma.TransactionClient,
@@ -939,30 +915,40 @@ async function bucheVerbrauch(
playerId: number; playerId: number;
characterId: number; characterId: number;
kind: JoinKind; kind: JoinKind;
/// Steht der Eintrag über einem Vorgänger? Dann ist es ein Redesign. /**
redesign: boolean; * Steht der Eintrag über einem Vorgänger? Dann ist es ein Redesign.
*
* `null` bei einer Buchung, die neben einer bestehenden steht - eine
* Rückkehr etwa kostet zusätzlich, und was sie kostet, entscheidet der
* Fall, nicht die Stelle in der Liste.
*/
redesign: boolean | null;
note: string; note: string;
actorId: number | null; actorId: number | null;
occurredAt?: Date | undefined; occurredAt?: Date | undefined;
}, },
): Promise<void> { ): Promise<void> {
const ausCcTopf = params.kind === CreditKind.CC; const ausCcTopf = params.kind === CreditKind.CC;
const umwandeln = ausCcTopf && params.redesign;
if (umwandeln) { if (params.redesign === true && ausCcTopf) {
await wandleCcInRedesign(tx, { throw conflict(
playerId: params.playerId, 'CONVERT_FIRST',
characterId: params.characterId, 'Ein Redesign wird nicht aus dem CC-Topf bezahlt. Wandle das CC-Guthaben oben in ein ' +
actorId: params.actorId, 'Redesign um, dann steht es hier zur Verfügung.',
note: `CC-Guthaben als Redesign eingesetzt: ${params.note}`, );
occurredAt: params.occurredAt ?? null, }
}); if (params.redesign === false && !ausCcTopf) {
throw conflict(
'CHAIN_START',
'Dieser Eintrag steht am Anfang einer Liste und bucht einen eigenen Platz. Dafür wird ein ' +
'CC-Guthaben gebraucht, kein Redesign.',
);
} }
await tx.creditLedger.create({ await tx.creditLedger.create({
data: { data: {
playerId: params.playerId, playerId: params.playerId,
kind: umwandeln ? CreditKind.REDESIGN_PAID : params.kind, kind: params.kind,
amount: -1, amount: -1,
source: CreditSource.CONSUME, source: CreditSource.CONSUME,
groupId: randomUUID(), groupId: randomUUID(),
@@ -1005,27 +991,6 @@ async function storniere(
}); });
} }
/**
* Nimmt die Umwandlung zurück, aus der ein Eintrag bezahlt wurde.
*
* Wer ein CC-Guthaben in ein Redesign getauscht hat und den Eintrag später
* an den Anfang einer Kette stellt, bekommt den Tausch zurück: der Eintrag
* bucht dann wieder einen eigenen Platz.
*/
async function nimmUmwandlungZurueck(
tx: Prisma.TransactionClient,
characterId: number,
playerId: number,
actorId: number | null,
): Promise<void> {
const zeilen = await tx.creditLedger.findMany({
where: { characterId, playerId, source: CreditSource.CONVERSION, reversedBy: null },
});
for (const zeile of zeilen) {
await storniere(tx, zeile, 'Umwandlung zurückgenommen: bucht wieder einen Platz', actorId);
}
}
/** /**
* Bucht ein Kettenglied auf die Art um, die seine Stelle in der Kette * Bucht ein Kettenglied auf die Art um, die seine Stelle in der Kette
* verlangt. * verlangt.
@@ -1084,12 +1049,6 @@ async function buchungFolgtPosition(
await storniere(tx, offen, `${grund} - Storno: ${glied.name}`, actorId); await storniere(tx, offen, `${grund} - Storno: ${glied.name}`, actorId);
if (sollCC) {
// Wer sein CC-Guthaben einmal für dieses Redesign umgewandelt hat,
// bekommt den Tausch zurück - hier bucht der Eintrag wieder einen Platz.
await nimmUmwandlungZurueck(tx, glied.id, offen.playerId, actorId);
}
await tx.creditLedger.create({ await tx.creditLedger.create({
data: { data: {
playerId: offen.playerId, playerId: offen.playerId,
@@ -2207,7 +2166,7 @@ export async function reactivateMember(
playerId, playerId,
characterId, characterId,
kind: input.useKind, kind: input.useKind,
redesign: mitglied.predecessorId !== null, redesign: null,
note: input.reason ?? `Rückkehr zu "${gruppe.name}"`, note: input.reason ?? `Rückkehr zu "${gruppe.name}"`,
actorId: ctx.actorId, actorId: ctx.actorId,
}); });
+30 -31
View File
@@ -192,44 +192,40 @@ export async function bookCcPackage(
* Wandelt ein CC-Guthaben in ein bezahltes Redesign um. * Wandelt ein CC-Guthaben in ein bezahltes Redesign um.
* *
* Ein Redesign wird nie aus dem CC-Topf bezahlt. Wer sein CC-Guthaben dafür * Ein Redesign wird nie aus dem CC-Topf bezahlt. Wer sein CC-Guthaben dafür
* einsetzen will, tauscht es vorher: ein Platz weniger, ein Redesign mehr. * einsetzen will, tauscht es vorher um: ein Platz weniger, ein Redesign mehr.
* Danach ist der Eintrag ein Redesign wie jedes andere - keine Ausnahme, die * Danach wird es eingesetzt wie jedes andere Redesign - dadurch bleibt die
* sich durch Slot-Prüfung, Kettenrechnung und Anzeige ziehen müsste. * Rechnung überall dieselbe, ohne Sonderfall an einzelnen CCs.
*
* Läuft immer in der Transaktion des Vorgangs mit, der die Umwandlung
* auslöst: beides gilt zusammen oder gar nicht.
*/ */
export async function wandleCcInRedesign( export async function convertCcToRedesign(
tx: Prisma.TransactionClient, playerId: number,
params: { ctx: ActionContext,
playerId: number; ): Promise<{ balances: Balances }> {
/// Der Eintrag, für den das Guthaben eingesetzt wird. Daran hängt die await requirePlayer(playerId);
/// Umwandlung, damit sie sich zurücknehmen lässt, wenn der Eintrag
/// später doch einen eigenen Platz bekommt.
characterId: number;
actorId: number | null;
note: string;
/// Wann der Vorgang war, zu dem die Umwandlung gehört.
occurredAt?: Date | null;
},
): Promise<void> {
const groupId = randomUUID(); const groupId = randomUUID();
const gemeinsam = { const gemeinsam = {
playerId: params.playerId, playerId,
characterId: params.characterId,
source: CreditSource.CONVERSION, source: CreditSource.CONVERSION,
groupId, groupId,
note: params.note, note: 'CC-Guthaben in ein Redesign umgewandelt',
createdById: params.actorId, createdById: ctx.actorId,
...(params.occurredAt ? { occurredAt: params.occurredAt } : {}),
}; };
await tx.creditLedger.create({ await prisma.$transaction(async (tx) => {
data: { ...gemeinsam, kind: CreditKind.CC, amount: -1 }, await tx.creditLedger.create({ data: { ...gemeinsam, kind: CreditKind.CC, amount: -1 } });
}); await tx.creditLedger.create({
await tx.creditLedger.create({ data: { ...gemeinsam, kind: CreditKind.REDESIGN_PAID, amount: 1 },
data: { ...gemeinsam, kind: CreditKind.REDESIGN_PAID, amount: 1 }, });
await recordAudit(tx, ctx, {
action: 'credit.converted',
targetType: 'player',
targetId: playerId,
details: { from: CreditKind.CC, to: CreditKind.REDESIGN_PAID },
});
}); });
return { balances: await getBalances(playerId) };
} }
/** /**
@@ -385,6 +381,7 @@ const HANDEINTRAEGE = [
CreditSource.CC_PACKAGE, CreditSource.CC_PACKAGE,
CreditSource.REDESIGN_PACKAGE, CreditSource.REDESIGN_PACKAGE,
CreditSource.KULANZ, CreditSource.KULANZ,
CreditSource.CONVERSION,
] as const; ] as const;
export type Handeintrag = (typeof HANDEINTRAEGE)[number]; export type Handeintrag = (typeof HANDEINTRAEGE)[number];
@@ -425,7 +422,9 @@ export async function removeLastGrant(
? 'CC-Paket' ? 'CC-Paket'
: source === CreditSource.REDESIGN_PACKAGE : source === CreditSource.REDESIGN_PACKAGE
? 'Redesign-Paket' ? 'Redesign-Paket'
: 'Kulanz'; : source === CreditSource.CONVERSION
? 'umgewandeltes CC-Guthaben'
: 'Kulanz';
throw conflict( throw conflict(
'NOTHING_TO_REMOVE', 'NOTHING_TO_REMOVE',
`Es gibt kein von Hand eingetragenes ${was}, das sich zurücknehmen liesse. Aus einem Import gebuchte Pakete werden über die Historie storniert.`, `Es gibt kein von Hand eingetragenes ${was}, das sich zurücknehmen liesse. Aus einem Import gebuchte Pakete werden über die Historie storniert.`,
+65 -56
View File
@@ -1067,72 +1067,81 @@ describe('Ein CC-Guthaben für ein Redesign einsetzen', () => {
return buchung?.kind; return buchung?.kind;
} }
it('wandelt es um, statt eine Ausnahme zu buchen', async () => { it('geht nur über eine Umwandlung, nicht direkt am CC', async () => {
const spieler = await spielerMitPaketen('Ausnahmefall', 2); const spieler = await spielerMitPaketen('Direktzahler', 2);
const eins = await characters.createCharacter(spieler, { name: 'Basis' }, ctx); const eins = await characters.createCharacter(spieler, { name: 'Basis' }, ctx);
const vorher = await credits.getBalances(spieler); await assert.rejects(
const zwei = await characters.redesignCharacter( () =>
eins.character.id, characters.redesignCharacter(eins.character.id, { name: 'Zweites', useKind: 'CC' }, ctx),
{ name: 'Aus dem CC-Topf', useKind: 'CC' }, (error: Error & { code?: string }) => error.code === 'CONVERT_FIRST',
ctx, 'ein Redesign wird nicht aus dem CC-Topf bezahlt',
); );
});
assert.equal(zwei.balances.CC, vorher.CC - 1, 'der Platz ist weg'); it('tauscht einen Platz gegen ein bezahltes Redesign', async () => {
const spieler = await spielerMitPaketen('Umwandler', 2);
const vorher = await credits.getBalances(spieler);
const danach = await credits.convertCcToRedesign(spieler, ctx);
assert.equal(danach.balances.CC, vorher.CC - 1, 'ein Platz weniger');
assert.equal(danach.balances.REDESIGN_PAID, vorher.REDESIGN_PAID + 1, 'ein Redesign mehr');
assert.equal( assert.equal(
zwei.balances.REDESIGN_PAID, danach.balances.granted.CC,
vorher.REDESIGN_PAID, vorher.granted.CC,
'das eingetauschte Redesign wird sofort wieder verbraucht', 'gekauft wurde deswegen nicht weniger - der Platz ist verbraucht',
); );
assert.equal( assert.equal(
await verbrauchtAls(zwei.character.id), danach.balances.granted.REDESIGN_PAID,
'REDESIGN_PAID',
'der Eintrag selbst ist ein Redesign wie jedes andere',
);
assert.equal(
zwei.balances.granted.REDESIGN_PAID,
vorher.granted.REDESIGN_PAID + 1, vorher.granted.REDESIGN_PAID + 1,
'und das umgewandelte Guthaben ist als erhalten ausgewiesen', 'und das eingetauschte Redesign ist erhalten worden',
); );
assert.equal(zwei.balances.granted.CC, vorher.granted.CC, 'die Käufe bleiben, wie sie waren');
}); });
it('bleibt beim Ausrichten an den Ketten stehen', async () => { it('setzt das umgewandelte Redesign ein wie jedes andere', async () => {
const spieler = await spielerMitPaketen('Bleibt so', 2); const spieler = await spielerMitPaketen('Einsetzer', 2);
const eins = await characters.createCharacter(spieler, { name: 'Erstes' }, ctx); const eins = await characters.createCharacter(spieler, { name: 'Erster' }, ctx);
// Beide Redesigns aus den Paketen erst verbrauchen.
const zwei = await characters.redesignCharacter( const zwei = await characters.redesignCharacter(
eins.character.id, eins.character.id,
{ name: 'Zweites', useKind: 'CC' }, { name: 'Zweiter', useKind: 'REDESIGN_PAID' },
ctx,
);
const drei = await characters.redesignCharacter(
zwei.character.id,
{ name: 'Dritter', useKind: 'REDESIGN_PAID' },
ctx,
);
assert.equal((await credits.getBalances(spieler)).REDESIGN_PAID, 0);
await credits.convertCcToRedesign(spieler, ctx);
const vier = await characters.redesignCharacter(
drei.character.id,
{ name: 'Vierter', useKind: 'REDESIGN_PAID' },
ctx, ctx,
); );
const vorher = await credits.getBalances(spieler); assert.equal(await verbrauchtAls(vier.character.id), 'REDESIGN_PAID');
const lauf = await characters.rebuildFunding(spieler, ctx);
assert.equal(lauf.umgebucht, 0, 'es passt bereits alles');
assert.equal(await verbrauchtAls(zwei.character.id), 'REDESIGN_PAID');
assert.deepEqual(lauf.balances, vorher, 'und am Guthaben ändert sich nichts');
});
it('gibt den Tausch zurück, wenn der Eintrag wieder allein steht', async () => {
const spieler = await spielerMitPaketen('Rueckzieher', 2);
const eins = await characters.createCharacter(spieler, { name: 'A' }, ctx);
const zwei = await characters.redesignCharacter(
eins.character.id,
{ name: 'B', useKind: 'CC' },
ctx,
);
await characters.detachFromChain(zwei.character.id, ctx);
assert.equal(await verbrauchtAls(zwei.character.id), 'CC', 'allein steht er für sich');
const stand = await credits.getBalances(spieler); const stand = await credits.getBalances(spieler);
assert.equal(stand.CC, 0, 'beide Plätze sind belegt'); assert.equal(stand.REDESIGN_PAID, 0, 'das umgewandelte ist aufgebraucht');
assert.equal(stand.REDESIGN_PAID, 2, 'die Redesigns sind wieder frei'); assert.equal(stand.CC, 0, 'und der Platz dafür weg');
}); });
it('lässt sich auch nachträglich einsetzen', async () => { it('lässt sich zurücknehmen wie jede andere Handeingabe', async () => {
const spieler = await spielerMitPaketen('Von Hand', 2); const spieler = await spielerMitPaketen('Reumuetig', 1);
const vorher = await credits.getBalances(spieler);
await credits.convertCcToRedesign(spieler, ctx);
const danach = await credits.removeLastGrant(spieler, 'CONVERSION', ctx);
assert.equal(danach.balances.CC, vorher.CC, 'der Platz ist wieder da');
assert.equal(danach.balances.REDESIGN_PAID, vorher.REDESIGN_PAID, 'das Redesign wieder weg');
});
it('lässt die Bezahlart eines Redesigns nicht auf den CC-Topf stellen', async () => {
const spieler = await spielerMitPaketen('Umsteller zwei', 1);
const eins = await characters.createCharacter(spieler, { name: 'A' }, ctx); const eins = await characters.createCharacter(spieler, { name: 'A' }, ctx);
const zwei = await characters.redesignCharacter( const zwei = await characters.redesignCharacter(
eins.character.id, eins.character.id,
@@ -1140,16 +1149,16 @@ describe('Ein CC-Guthaben für ein Redesign einsetzen', () => {
ctx, ctx,
); );
const danach = await characters.changeFunding(zwei.character.id, 'CC', ctx); await assert.rejects(
() => characters.changeFunding(zwei.character.id, 'CC', ctx),
(error: Error & { code?: string }) => error.code === 'CONVERT_FIRST',
);
assert.equal(await verbrauchtAls(zwei.character.id), 'REDESIGN_PAID'); // Zwischen bezahlt und Kulanz bleibt die Wahl.
assert.equal(danach.balances.CC, 0, 'der Platz ist eingetauscht'); await credits.bookKulanz(spieler, ctx);
assert.equal(danach.balances.REDESIGN_PAID, 2, 'das Redesign dafür wieder frei'); const umgestellt = await characters.changeFunding(zwei.character.id, 'REDESIGN_KULANZ', ctx);
assert.equal(await verbrauchtAls(zwei.character.id), 'REDESIGN_KULANZ');
// Und zurück: der Tausch wird rückgängig gemacht. assert.equal(umgestellt.balances.REDESIGN_KULANZ, 0, 'die Kulanz ist verbraucht');
const zurueck = await characters.changeFunding(zwei.character.id, 'REDESIGN_PAID', ctx);
assert.equal(zurueck.balances.CC, 1, 'der Platz kommt zurück');
assert.equal(zurueck.balances.REDESIGN_PAID, 1, 'und das Redesign wird wieder verbraucht');
}); });
}); });
+114 -28
View File
@@ -66,6 +66,8 @@ export default function PlayerDetail() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [flash, setFlash] = useState<string | null>(null); const [flash, setFlash] = useState<string | null>(null);
/// Welcher Topf in der Historie gezeigt wird; null heisst alle.
const [topf, setTopf] = useState<CreditKind | null>(null);
const [dialog, setDialog] = useState<DialogState>({ type: 'none' }); const [dialog, setDialog] = useState<DialogState>({ type: 'none' });
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
@@ -198,6 +200,19 @@ export default function PlayerDetail() {
); );
}; };
const umwandeln = async () => {
const ja = await bestaetigen({
titel: 'Ein CC-Guthaben in ein Redesign umwandeln?',
text: 'Das kostet einen CC-Platz und bringt ein bezahltes Redesign. Einsetzen lässt es sich danach wie jedes andere Redesign.',
knopf: 'Umwandeln',
});
if (!ja) return;
await run(
() => api(`/api/players/${playerId}/credits/convert`, { method: 'POST', body: {} }),
'CC-Guthaben in ein Redesign umgewandelt.',
);
};
// Der Abgleich bucht nichts, er legt eine Vorschau an. Das muss die Meldung // Der Abgleich bucht nichts, er legt eine Vorschau an. Das muss die Meldung
// auch sagen, sonst wirkt der Knopf wirkungslos. Gelesen werden alle Konten // auch sagen, sonst wirkt der Knopf wirkungslos. Gelesen werden alle Konten
// des Spielers, deshalb kann auch ein einzelner Lauf dauern. // des Spielers, deshalb kann auch ein einzelner Lauf dauern.
@@ -222,6 +237,8 @@ export default function PlayerDetail() {
const editor = can('EDITOR'); const editor = can('EDITOR');
const admin = can('ADMIN'); const admin = can('ADMIN');
const gezeigteBuchungen =
topf === null ? detail.ledger : detail.ledger.filter((entry) => entry.kind === topf);
const playerName = detail.player.displayName; const playerName = detail.player.displayName;
@@ -418,6 +435,30 @@ export default function PlayerDetail() {
</button> </button>
</span> </span>
)} )}
{/* Ein Redesign wird nie aus dem CC-Topf bezahlt. Wer sein Guthaben
dafür einsetzen will, tauscht es hier - danach ist es ein
Redesign wie jedes andere. */}
<span className="credit-pair">
<button
type="button"
className="btn"
disabled={busy}
title="Ein CC-Guthaben in ein bezahltes Redesign tauschen: ein Platz weniger, ein Redesign mehr"
onClick={() => void umwandeln()}
>
CC → Redesign
</button>
<button
type="button"
className="btn btn-ghost"
disabled={busy}
title="Die zuletzt vorgenommene Umwandlung zurücknehmen"
onClick={() => void zugabeZuruecknehmen('convert', 'umgewandelte CC-Guthaben')}
>
−
</button>
</span>
<button <button
type="button" type="button"
className="btn btn-primary" className="btn btn-primary"
@@ -505,6 +546,27 @@ export default function PlayerDetail() {
<section className="block"> <section className="block">
<h2>Guthaben-Historie</h2> <h2>Guthaben-Historie</h2>
{/* Wer wissen will, wo eine einzelne Kulanz geblieben ist, soll nicht
durch hundert Zeilen scrollen müssen. */}
<div className="filter-row">
{(
[
[null, 'Alle Töpfe'],
['CC', KIND_LABEL.CC],
['REDESIGN_PAID', KIND_LABEL.REDESIGN_PAID],
['REDESIGN_KULANZ', KIND_LABEL.REDESIGN_KULANZ],
] as const
).map(([wert, label]) => (
<button
key={label}
type="button"
className={`btn btn-tiny ${topf === wert ? 'btn-primary' : 'btn-ghost'}`}
onClick={() => setTopf(wert)}
>
{label}
</button>
))}
</div>
<div className="table-wrap"> <div className="table-wrap">
<table className="table"> <table className="table">
<thead> <thead>
@@ -519,7 +581,7 @@ export default function PlayerDetail() {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{detail.ledger.map((entry) => ( {gezeigteBuchungen.map((entry) => (
<tr key={entry.id} className={entry.reversedBy ? 'is-reversed' : undefined}> <tr key={entry.id} className={entry.reversedBy ? 'is-reversed' : undefined}>
<td <td
className="muted small" className="muted small"
@@ -558,10 +620,12 @@ export default function PlayerDetail() {
</td> </td>
</tr> </tr>
))} ))}
{detail.ledger.length === 0 && ( {gezeigteBuchungen.length === 0 && (
<tr> <tr>
<td colSpan={7} className="empty"> <td colSpan={7} className="empty">
Noch keine Buchungen. {topf === null
? 'Noch keine Buchungen.'
: `In diesem Topf gibt es keine Buchung.`}
</td> </td>
</tr> </tr>
)} )}
@@ -570,7 +634,7 @@ export default function PlayerDetail() {
</div> </div>
{detail.ledgerTotal > detail.ledger.length && ( {detail.ledgerTotal > detail.ledger.length && (
<p className="muted"> <p className="muted">
{detail.ledger.length} von {detail.ledgerTotal} Buchungen angezeigt. {detail.ledger.length} von {detail.ledgerTotal} Buchungen geladen.
</p> </p>
)} )}
</section> </section>
@@ -601,7 +665,7 @@ export default function PlayerDetail() {
submitLabel="Redesign durchführen" submitLabel="Redesign durchführen"
hint={`"${dialog.character.name}" wird inaktiv (redesignt), der neue CC tritt an seine Stelle.`} hint={`"${dialog.character.name}" wird inaktiv (redesignt), der neue CC tritt an seine Stelle.`}
busy={busy} busy={busy}
withKind={['REDESIGN_PAID', 'REDESIGN_KULANZ', 'CC']} withKind={['REDESIGN_PAID', 'REDESIGN_KULANZ']}
balances={detail.balances} balances={detail.balances}
onClose={() => setDialog({ type: 'none' })} onClose={() => setDialog({ type: 'none' })}
onSubmit={(values) => onSubmit={(values) =>
@@ -643,7 +707,7 @@ export default function PlayerDetail() {
() => () =>
api(`/api/characters/${dialog.character.id}/funding`, { api(`/api/characters/${dialog.character.id}/funding`, {
method: 'POST', method: 'POST',
body: { useKind }, body: { useKind, forPlayerId: playerId },
}), }),
`Bezahlart von "${dialog.character.name}" umgebucht.`, `Bezahlart von "${dialog.character.name}" umgebucht.`,
) )
@@ -666,7 +730,7 @@ export default function PlayerDetail() {
submitLabel="Redesign durchführen" submitLabel="Redesign durchführen"
hint={`${playerName} steigt aus "${dialog.character.name}" aus und bekommt dafür einen eigenen CC. Die übrigen Nutzer bleiben unberührt. Ein Redesign tauscht einen Platz, es kommt keiner hinzu.`} hint={`${playerName} steigt aus "${dialog.character.name}" aus und bekommt dafür einen eigenen CC. Die übrigen Nutzer bleiben unberührt. Ein Redesign tauscht einen Platz, es kommt keiner hinzu.`}
busy={busy} busy={busy}
withKind={['REDESIGN_PAID', 'REDESIGN_KULANZ', 'CC']} withKind={['REDESIGN_PAID', 'REDESIGN_KULANZ']}
balances={detail.balances} balances={detail.balances}
onClose={() => setDialog({ type: 'none' })} onClose={() => setDialog({ type: 'none' })}
onSubmit={(values) => onSubmit={(values) =>
@@ -724,8 +788,16 @@ export default function PlayerDetail() {
{dialog.type === 'reactivatePaid' && ( {dialog.type === 'reactivatePaid' && (
<KindChoiceModal <KindChoiceModal
title={`"${dialog.character.name}" reaktivieren`} title={`"${dialog.character.name}" reaktivieren`}
hint="Dieser CC wurde redesignt und existiert im Original nicht mehr. Die Reaktivierung kostet ein Guthaben." hint={
kinds={['REDESIGN_KULANZ', 'REDESIGN_PAID', 'CC']} dialog.character.predecessorId === null
? 'Dieser CC steht am Anfang einer Liste - seine Rückkehr kostet einen eigenen Platz.'
: 'Dieser CC wurde redesignt und existiert im Original nicht mehr. Die Rückkehr kostet ein Redesign.'
}
kinds={
dialog.character.predecessorId === null
? ['CC', 'REDESIGN_PAID', 'REDESIGN_KULANZ']
: ['REDESIGN_PAID', 'REDESIGN_KULANZ', 'CC']
}
balances={detail.balances} balances={detail.balances}
busy={busy} busy={busy}
onClose={() => setDialog({ type: 'none' })} onClose={() => setDialog({ type: 'none' })}
@@ -1069,12 +1141,6 @@ function CharacterFormModal({
</label> </label>
))} ))}
</div> </div>
{useKind === 'CC' && (
<p className="muted small">
Ein CC-Guthaben für ein Redesign wird umgewandelt: ein Platz weniger, ein
bezahltes Redesign mehr.
</p>
)}
</> </>
)} )}
@@ -1688,7 +1754,11 @@ function ReviveDeadModal({
}) { }) {
const [reason, setReason] = useState(''); const [reason, setReason] = useState('');
const [kostet, setKostet] = useState(false); const [kostet, setKostet] = useState(false);
const [useKind, setUseKind] = useState<CreditKind>('CC'); // Am Anfang einer Liste kostet die Rückkehr einen Platz, darüber ein
// Redesign - das ergibt sich aus der Stelle, nicht aus der Wahl.
const [useKind, setUseKind] = useState<CreditKind>(
character.predecessorId === null ? 'CC' : 'REDESIGN_PAID',
);
return ( return (
<Modal title={`"${character.name}" zurückholen`} onClose={onClose}> <Modal title={`"${character.name}" zurückholen`} onClose={onClose}>
@@ -1730,12 +1800,17 @@ function ReviveDeadModal({
{kostet && ( {kostet && (
<div className="radio-group"> <div className="radio-group">
{( {(character.predecessorId === null
[ ? ([
['CC', 'CC-Guthaben', balances.CC], ['CC', 'CC-Guthaben', balances.CC],
['REDESIGN_KULANZ', 'Kulanz-Redesign', balances.REDESIGN_KULANZ], ['REDESIGN_PAID', 'Bezahltes Redesign', balances.REDESIGN_PAID],
['REDESIGN_PAID', 'Bezahltes Redesign', balances.REDESIGN_PAID], ['REDESIGN_KULANZ', 'Kulanz-Redesign', balances.REDESIGN_KULANZ],
] as const ] as const)
: ([
['REDESIGN_PAID', 'Bezahltes Redesign', balances.REDESIGN_PAID],
['REDESIGN_KULANZ', 'Kulanz-Redesign', balances.REDESIGN_KULANZ],
['CC', 'CC-Guthaben', balances.CC],
] as const)
).map(([wert, label, stand]) => ( ).map(([wert, label, stand]) => (
<label key={wert} className="radio"> <label key={wert} className="radio">
<input <input
@@ -1878,22 +1953,33 @@ function RedesignKindModal({
onClose: () => void; onClose: () => void;
onSubmit: (useKind: CreditKind) => void; onSubmit: (useKind: CreditKind) => void;
}) { }) {
const [useKind, setUseKind] = useState<CreditKind>('REDESIGN_KULANZ'); const [useKind, setUseKind] = useState<CreditKind>('REDESIGN_PAID');
// Am Anfang einer Liste bucht ein Eintrag einen Platz; das ergibt sich aus
// der Stelle und steht nicht zur Wahl. Zu entscheiden bleibt, ob ein
// Redesign bezahlt wurde oder auf Kulanz lief.
const amAnfang = character.predecessorId === null;
return ( return (
<Modal title={`Bezahlart von "${character.name}"`} onClose={onClose}> <Modal title={`Bezahlart von "${character.name}"`} onClose={onClose}>
<Notice kind="info"> <Notice kind="info">
Woraus ein CC bezahlt wurde, stellt sich manchmal erst später heraus - etwa wenn eine Woraus ein Redesign bezahlt wurde, stellt sich manchmal erst später heraus - etwa wenn
Absprache auftaucht oder sich zeigt, dass ein Eintrag gar nicht der Anfang einer Kette eine Absprache auftaucht. Umgebucht wird über eine Gegenbuchung; in der Historie bleibt
war. Umgebucht wird über eine Gegenbuchung; in der Historie bleibt beides sichtbar. beides sichtbar.
</Notice> </Notice>
{amAnfang && (
<Notice kind="warn">
Dieser Eintrag steht am Anfang einer Liste und bucht damit einen eigenen Platz aus dem
CC-Guthaben. Erst über einem anderen Eintrag wird daraus ein Redesign.
</Notice>
)}
<div className="radio-group"> <div className="radio-group">
{( {(
[ [
['CC', 'CC-Paket (eigener Platz)', balances.CC],
['REDESIGN_KULANZ', 'Kulanz-Redesign', balances.REDESIGN_KULANZ],
['REDESIGN_PAID', 'Bezahltes Redesign', balances.REDESIGN_PAID], ['REDESIGN_PAID', 'Bezahltes Redesign', balances.REDESIGN_PAID],
['REDESIGN_KULANZ', 'Kulanz-Redesign', balances.REDESIGN_KULANZ],
] as const ] as const
).map(([wert, label, stand]) => ( ).map(([wert, label, stand]) => (
<label key={wert} className="radio"> <label key={wert} className="radio">
+8
View File
@@ -1327,3 +1327,11 @@ textarea {
border-color: var(--warn); border-color: var(--warn);
color: var(--warn); color: var(--warn);
} }
/* Knopfreihe über einer Tabelle, etwa zum Filtern der Historie. */
.filter-row {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin-bottom: 0.6rem;
}