diff --git a/server/src/routes/characters.routes.ts b/server/src/routes/characters.routes.ts index a9d256c..c2e55fd 100644 --- a/server/src/routes/characters.routes.ts +++ b/server/src/routes/characters.routes.ts @@ -200,10 +200,13 @@ export async function characterRoutes(app: FastifyInstance): Promise { const body = parseWith( z.object({ 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, ); - return changeFunding(id, body.useKind, contextOf(request)); + return changeFunding(id, body.useKind, contextOf(request), body.forPlayerId); }); /** diff --git a/server/src/routes/credits.routes.ts b/server/src/routes/credits.routes.ts index bb7d305..8719e7f 100644 --- a/server/src/routes/credits.routes.ts +++ b/server/src/routes/credits.routes.ts @@ -6,6 +6,7 @@ import { bookAdjustment, bookKulanz, bookRedesignPackage, + convertCcToRedesign, grantCcSlot, removeLastGrant, reverseGroup, @@ -44,6 +45,27 @@ export async function creditRoutes(app: FastifyInstance): Promise { 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. * diff --git a/server/src/services/characters.ts b/server/src/services/characters.ts index 832cc1e..3555df0 100644 --- a/server/src/services/characters.ts +++ b/server/src/services/characters.ts @@ -10,7 +10,7 @@ import { } from '@prisma/client'; import { prisma } from '../db.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'; export interface CharacterResult { @@ -397,7 +397,7 @@ export async function reactivatePaid( playerId: updated.playerId, characterId: updated.id, kind: useKind, - redesign: updated.predecessorId !== null, + redesign: null, note: note ?? `Reaktivierung: ${updated.name}`, actorId: ctx.actorId, }); @@ -745,7 +745,7 @@ export async function reviveDeadCharacter( playerId: updated.playerId, characterId: updated.id, kind: input.useKind, - redesign: updated.predecessorId !== null, + redesign: null, note: `Rückkehr nach Tod: ${input.reason}`, actorId: ctx.actorId, }); @@ -777,13 +777,9 @@ export async function reviveDeadCharacter( /** * Stellt um, woraus ein Eintrag bezahlt wurde. * - * Zwei Dinge lassen sich hier entscheiden: ob ein Redesign aus dem bezahlten - * Topf oder aus Kulanz kommt - und ob dafür ausnahmsweise ein CC-Guthaben - * eingesetzt wird. Das Letzte ist keine eigene Bezahlart, sondern eine - * 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. + * Zu entscheiden bleibt eines: ob ein Redesign aus dem bezahlten Topf kommt + * oder aus Kulanz. Ob ein Eintrag überhaupt ein Redesign ist, steht nicht + * zur Wahl - das entscheidet seine Stelle in der Liste. * * Geändert wird wie jede Korrektur im Kontobuch: die alte Zeile wird * gegengebucht, die neue kommt dazu. So bleibt sichtbar, dass hier etwas @@ -793,19 +789,21 @@ export async function changeFunding( characterId: number, useKind: JoinKind, ctx: ActionContext, + forPlayerId?: number, ): Promise { const character = await loadCharacter(characterId); + const spieler = forPlayerId ?? character.playerId; if (!JOIN_KINDS.includes(useKind)) { throw badRequest('INVALID_KIND', 'Ungültige Guthaben-Art'); } - // Die Buchung, mit der dieser CC bezahlt wurde. Stornierte zählen nicht - // mehr; offen ist immer höchstens eine. + // Die Buchung, mit der dieser Spieler seinen Platz bezahlt hat. Bei einem + // Gruppen-CC hat jeder Nutzer seine eigene; stornierte zählen nicht mehr. const buchung = await prisma.creditLedger.findFirst({ where: { characterId, - playerId: character.playerId, + playerId: spieler, source: CreditSource.CONSUME, reversedBy: null, }, @@ -814,64 +812,42 @@ export async function changeFunding( if (!buchung) { throw conflict( '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) { throw conflict( '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.', ); } - - // Ein CC-Guthaben für ein Redesign wird umgewandelt, nicht anders gebucht. - const willUmwandeln = redesign && useKind === CreditKind.CC; - const zielArt = willUmwandeln ? CreditKind.REDESIGN_PAID : useKind; - const hatUmwandlung = - (await prisma.creditLedger.count({ - where: { - characterId, - playerId: buchung.playerId, - source: CreditSource.CONVERSION, - reversedBy: null, - }, - })) > 0; - - if (buchung.kind === zielArt && willUmwandeln === hatUmwandlung) { + if (redesign && useKind === CreditKind.CC) { + throw conflict( + 'CONVERT_FIRST', + 'Ein Redesign wird nicht aus dem CC-Topf bezahlt. Wandle das CC-Guthaben oben in ein ' + + 'Redesign um, dann steht es hier zur Verfügung.', + ); + } + if (buchung.kind === useKind) { throw conflict('SAME_KIND', 'Dieser CC ist bereits so gebucht'); } - const grund = willUmwandeln - ? 'CC-Guthaben umgewandelt' - : useKind === CreditKind.CC - ? 'Umgestellt auf CC-Paket' - : useKind === CreditKind.REDESIGN_KULANZ - ? 'Umgestellt auf Kulanz' - : 'Umgestellt auf bezahltes Redesign'; + const grund = + useKind === CreditKind.REDESIGN_KULANZ + ? 'Umgestellt auf Kulanz' + : 'Umgestellt auf bezahltes Redesign'; await prisma.$transaction(async (tx) => { 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({ data: { - playerId: buchung.playerId, - kind: zielArt, + playerId: spieler, + kind: useKind, amount: buchung.amount, source: CreditSource.CONSUME, groupId: randomUUID(), @@ -887,11 +863,11 @@ export async function changeFunding( action: 'character.funding_changed', targetType: 'character', 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) }; } @@ -927,11 +903,11 @@ async function istRedesignPosition( /** * Bucht, was ein Eintrag kostet. * - * Ein Platz am Anfang einer Kette kommt aus dem CC-Topf, alles darüber aus - * einem Redesign-Topf. Wer sein CC-Guthaben für ein Redesign einsetzen will, - * wandelt es hier um: ein Platz weniger, ein bezahltes Redesign mehr. Der - * Eintrag selbst ist danach ein Redesign wie jedes andere - dadurch bleibt - * die Rechnung überall dieselbe, ohne Sonderfall. + * Am Anfang einer Liste steht ein gekaufter Platz, alles darüber ein + * Redesign. Die Stelle entscheidet, nicht die Wahl - sonst ginge die + * Rechnung "eine Liste, ein CC-Paket" nicht mehr auf. Wer sein CC-Guthaben + * für ein Redesign einsetzen will, wandelt es vorher um; danach ist es ein + * Redesign wie jedes andere. */ async function bucheVerbrauch( tx: Prisma.TransactionClient, @@ -939,30 +915,40 @@ async function bucheVerbrauch( playerId: number; characterId: number; 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; actorId: number | null; occurredAt?: Date | undefined; }, ): Promise { const ausCcTopf = params.kind === CreditKind.CC; - const umwandeln = ausCcTopf && params.redesign; - if (umwandeln) { - await wandleCcInRedesign(tx, { - playerId: params.playerId, - characterId: params.characterId, - actorId: params.actorId, - note: `CC-Guthaben als Redesign eingesetzt: ${params.note}`, - occurredAt: params.occurredAt ?? null, - }); + if (params.redesign === true && ausCcTopf) { + throw conflict( + 'CONVERT_FIRST', + 'Ein Redesign wird nicht aus dem CC-Topf bezahlt. Wandle das CC-Guthaben oben in ein ' + + 'Redesign um, dann steht es hier zur Verfügung.', + ); + } + 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({ data: { playerId: params.playerId, - kind: umwandeln ? CreditKind.REDESIGN_PAID : params.kind, + kind: params.kind, amount: -1, source: CreditSource.CONSUME, 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 { - 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 * verlangt. @@ -1084,12 +1049,6 @@ async function buchungFolgtPosition( 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({ data: { playerId: offen.playerId, @@ -2207,7 +2166,7 @@ export async function reactivateMember( playerId, characterId, kind: input.useKind, - redesign: mitglied.predecessorId !== null, + redesign: null, note: input.reason ?? `Rückkehr zu "${gruppe.name}"`, actorId: ctx.actorId, }); diff --git a/server/src/services/credits.ts b/server/src/services/credits.ts index 75a45ae..2c1eb93 100644 --- a/server/src/services/credits.ts +++ b/server/src/services/credits.ts @@ -192,44 +192,40 @@ export async function bookCcPackage( * Wandelt ein CC-Guthaben in ein bezahltes Redesign um. * * 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. - * Danach ist der Eintrag ein Redesign wie jedes andere - keine Ausnahme, die - * sich durch Slot-Prüfung, Kettenrechnung und Anzeige ziehen müsste. - * - * Läuft immer in der Transaktion des Vorgangs mit, der die Umwandlung - * auslöst: beides gilt zusammen oder gar nicht. + * einsetzen will, tauscht es vorher um: ein Platz weniger, ein Redesign mehr. + * Danach wird es eingesetzt wie jedes andere Redesign - dadurch bleibt die + * Rechnung überall dieselbe, ohne Sonderfall an einzelnen CCs. */ -export async function wandleCcInRedesign( - tx: Prisma.TransactionClient, - params: { - playerId: number; - /// Der Eintrag, für den das Guthaben eingesetzt wird. Daran hängt die - /// 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 { +export async function convertCcToRedesign( + playerId: number, + ctx: ActionContext, +): Promise<{ balances: Balances }> { + await requirePlayer(playerId); + const groupId = randomUUID(); const gemeinsam = { - playerId: params.playerId, - characterId: params.characterId, + playerId, source: CreditSource.CONVERSION, groupId, - note: params.note, - createdById: params.actorId, - ...(params.occurredAt ? { occurredAt: params.occurredAt } : {}), + note: 'CC-Guthaben in ein Redesign umgewandelt', + createdById: ctx.actorId, }; - await tx.creditLedger.create({ - data: { ...gemeinsam, kind: CreditKind.CC, amount: -1 }, - }); - await tx.creditLedger.create({ - data: { ...gemeinsam, kind: CreditKind.REDESIGN_PAID, amount: 1 }, + await prisma.$transaction(async (tx) => { + await tx.creditLedger.create({ data: { ...gemeinsam, kind: CreditKind.CC, amount: -1 } }); + await tx.creditLedger.create({ + 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.REDESIGN_PACKAGE, CreditSource.KULANZ, + CreditSource.CONVERSION, ] as const; export type Handeintrag = (typeof HANDEINTRAEGE)[number]; @@ -425,7 +422,9 @@ export async function removeLastGrant( ? 'CC-Paket' : source === CreditSource.REDESIGN_PACKAGE ? 'Redesign-Paket' - : 'Kulanz'; + : source === CreditSource.CONVERSION + ? 'umgewandeltes CC-Guthaben' + : 'Kulanz'; throw conflict( '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.`, diff --git a/server/test/ketten.test.ts b/server/test/ketten.test.ts index a421062..a949db7 100644 --- a/server/test/ketten.test.ts +++ b/server/test/ketten.test.ts @@ -1067,72 +1067,81 @@ describe('Ein CC-Guthaben für ein Redesign einsetzen', () => { return buchung?.kind; } - it('wandelt es um, statt eine Ausnahme zu buchen', async () => { - const spieler = await spielerMitPaketen('Ausnahmefall', 2); + it('geht nur über eine Umwandlung, nicht direkt am CC', async () => { + const spieler = await spielerMitPaketen('Direktzahler', 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, + await assert.rejects( + () => + characters.redesignCharacter(eins.character.id, { name: 'Zweites', useKind: 'CC' }, ctx), + (error: Error & { code?: string }) => error.code === 'CONVERT_FIRST', + '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( - zwei.balances.REDESIGN_PAID, - vorher.REDESIGN_PAID, - 'das eingetauschte Redesign wird sofort wieder verbraucht', + danach.balances.granted.CC, + vorher.granted.CC, + 'gekauft wurde deswegen nicht weniger - der Platz ist verbraucht', ); assert.equal( - await verbrauchtAls(zwei.character.id), - 'REDESIGN_PAID', - 'der Eintrag selbst ist ein Redesign wie jedes andere', - ); - assert.equal( - zwei.balances.granted.REDESIGN_PAID, + danach.balances.granted.REDESIGN_PAID, 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 () => { - const spieler = await spielerMitPaketen('Bleibt so', 2); - const eins = await characters.createCharacter(spieler, { name: 'Erstes' }, ctx); + it('setzt das umgewandelte Redesign ein wie jedes andere', async () => { + const spieler = await spielerMitPaketen('Einsetzer', 2); + const eins = await characters.createCharacter(spieler, { name: 'Erster' }, ctx); + + // Beide Redesigns aus den Paketen erst verbrauchen. const zwei = await characters.redesignCharacter( 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, ); - const vorher = await credits.getBalances(spieler); - 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'); + assert.equal(await verbrauchtAls(vier.character.id), 'REDESIGN_PAID'); const stand = await credits.getBalances(spieler); - assert.equal(stand.CC, 0, 'beide Plätze sind belegt'); - assert.equal(stand.REDESIGN_PAID, 2, 'die Redesigns sind wieder frei'); + assert.equal(stand.REDESIGN_PAID, 0, 'das umgewandelte ist aufgebraucht'); + assert.equal(stand.CC, 0, 'und der Platz dafür weg'); }); - it('lässt sich auch nachträglich einsetzen', async () => { - const spieler = await spielerMitPaketen('Von Hand', 2); + it('lässt sich zurücknehmen wie jede andere Handeingabe', async () => { + 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 zwei = await characters.redesignCharacter( eins.character.id, @@ -1140,16 +1149,16 @@ describe('Ein CC-Guthaben für ein Redesign einsetzen', () => { 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'); - assert.equal(danach.balances.CC, 0, 'der Platz ist eingetauscht'); - assert.equal(danach.balances.REDESIGN_PAID, 2, 'das Redesign dafür wieder frei'); - - // Und zurück: der Tausch wird rückgängig gemacht. - 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'); + // Zwischen bezahlt und Kulanz bleibt die Wahl. + await credits.bookKulanz(spieler, ctx); + const umgestellt = await characters.changeFunding(zwei.character.id, 'REDESIGN_KULANZ', ctx); + assert.equal(await verbrauchtAls(zwei.character.id), 'REDESIGN_KULANZ'); + assert.equal(umgestellt.balances.REDESIGN_KULANZ, 0, 'die Kulanz ist verbraucht'); }); }); diff --git a/web/src/pages/PlayerDetail.tsx b/web/src/pages/PlayerDetail.tsx index d1061f7..ba0fcf9 100644 --- a/web/src/pages/PlayerDetail.tsx +++ b/web/src/pages/PlayerDetail.tsx @@ -66,6 +66,8 @@ export default function PlayerDetail() { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [flash, setFlash] = useState(null); + /// Welcher Topf in der Historie gezeigt wird; null heisst alle. + const [topf, setTopf] = useState(null); const [dialog, setDialog] = useState({ type: 'none' }); 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 // auch sagen, sonst wirkt der Knopf wirkungslos. Gelesen werden alle Konten // des Spielers, deshalb kann auch ein einzelner Lauf dauern. @@ -222,6 +237,8 @@ export default function PlayerDetail() { const editor = can('EDITOR'); const admin = can('ADMIN'); + const gezeigteBuchungen = + topf === null ? detail.ledger : detail.ledger.filter((entry) => entry.kind === topf); const playerName = detail.player.displayName; @@ -418,6 +435,30 @@ export default function PlayerDetail() { )} + {/* 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. */} + + + + + + ))} +
@@ -519,7 +581,7 @@ export default function PlayerDetail() { - {detail.ledger.map((entry) => ( + {gezeigteBuchungen.map((entry) => ( ))} - {detail.ledger.length === 0 && ( + {gezeigteBuchungen.length === 0 && ( )} @@ -570,7 +634,7 @@ export default function PlayerDetail() { {detail.ledgerTotal > detail.ledger.length && (

- {detail.ledger.length} von {detail.ledgerTotal} Buchungen angezeigt. + {detail.ledger.length} von {detail.ledgerTotal} Buchungen geladen.

)} @@ -601,7 +665,7 @@ export default function PlayerDetail() { submitLabel="Redesign durchführen" hint={`"${dialog.character.name}" wird inaktiv (redesignt), der neue CC tritt an seine Stelle.`} busy={busy} - withKind={['REDESIGN_PAID', 'REDESIGN_KULANZ', 'CC']} + withKind={['REDESIGN_PAID', 'REDESIGN_KULANZ']} balances={detail.balances} onClose={() => setDialog({ type: 'none' })} onSubmit={(values) => @@ -643,7 +707,7 @@ export default function PlayerDetail() { () => api(`/api/characters/${dialog.character.id}/funding`, { method: 'POST', - body: { useKind }, + body: { useKind, forPlayerId: playerId }, }), `Bezahlart von "${dialog.character.name}" umgebucht.`, ) @@ -666,7 +730,7 @@ export default function PlayerDetail() { 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.`} busy={busy} - withKind={['REDESIGN_PAID', 'REDESIGN_KULANZ', 'CC']} + withKind={['REDESIGN_PAID', 'REDESIGN_KULANZ']} balances={detail.balances} onClose={() => setDialog({ type: 'none' })} onSubmit={(values) => @@ -724,8 +788,16 @@ export default function PlayerDetail() { {dialog.type === 'reactivatePaid' && ( setDialog({ type: 'none' })} @@ -1069,12 +1141,6 @@ function CharacterFormModal({ ))} - {useKind === 'CC' && ( -

- Ein CC-Guthaben für ein Redesign wird umgewandelt: ein Platz weniger, ein - bezahltes Redesign mehr. -

- )} )} @@ -1688,7 +1754,11 @@ function ReviveDeadModal({ }) { const [reason, setReason] = useState(''); const [kostet, setKostet] = useState(false); - const [useKind, setUseKind] = useState('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( + character.predecessorId === null ? 'CC' : 'REDESIGN_PAID', + ); return ( @@ -1730,12 +1800,17 @@ function ReviveDeadModal({ {kostet && (
- {( - [ - ['CC', 'CC-Guthaben', balances.CC], - ['REDESIGN_KULANZ', 'Kulanz-Redesign', balances.REDESIGN_KULANZ], - ['REDESIGN_PAID', 'Bezahltes Redesign', balances.REDESIGN_PAID], - ] as const + {(character.predecessorId === null + ? ([ + ['CC', 'CC-Guthaben', balances.CC], + ['REDESIGN_PAID', 'Bezahltes Redesign', balances.REDESIGN_PAID], + ['REDESIGN_KULANZ', 'Kulanz-Redesign', balances.REDESIGN_KULANZ], + ] 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]) => (
- Noch keine Buchungen. + {topf === null + ? 'Noch keine Buchungen.' + : `In diesem Topf gibt es keine Buchung.`}