diff --git a/README.md b/README.md index dd9b879..a9e7325 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,20 @@ An jedem Eintrag hängen beliebig viele Verweise - das Steam-Addon des Ersteller eine Absprache. Eingetragen wird ein **Name** und eine **Adresse**; in der Liste steht dann der Name als Link. Ändern und entfernen geht über denselben Knopf *Verweise*. +## Guthaben von Hand eintragen + +Auf der Spielerseite stehen drei Paare: **+ CC-Paket**, **+ Redesign-Paket**, **+ Kulanz** - und +daneben je ein **−**, das die zuletzt von Hand eingetragene Zugabe dieser Art zurücknimmt. Für +den vertippten oder doppelten Klick, ohne in der Historie nach der richtigen Zeile zu suchen. + +Zurückgenommen wird über eine Gegenbuchung; in der Historie bleibt beides stehen. Bei einem +CC-Paket gehen beide Zeilen mit zurück, das CC-Guthaben und das darin enthaltene Redesign. +**Aus einem Import gebuchte Pakete rührt das nicht an** - die gehören zu einer Bestellung und +werden dort storniert, wo dieser Zusammenhang sichtbar ist. + +In den Kästen oben steht neben dem offenen Stand, wie viel jemals hereinkam: *0 von 3* liest +sich anders als *0 von 0*. Das eine heißt aufgebraucht, das andere nie etwas gehabt. + ## Gruppen-CCs Manche CCs teilen sich mehrere Spieler. Für jeden gelten dabei dieselben Regeln wie für einen diff --git a/server/src/routes/credits.routes.ts b/server/src/routes/credits.routes.ts index c2761a2..463c2e3 100644 --- a/server/src/routes/credits.routes.ts +++ b/server/src/routes/credits.routes.ts @@ -1,12 +1,13 @@ import type { FastifyInstance } from 'fastify'; import { z } from 'zod'; -import { CreditKind } from '@prisma/client'; +import { CreditKind, CreditSource } from '@prisma/client'; import { requireAdmin, requireEditor } from '../auth/guard.js'; import { bookAdjustment, bookCcPackage, bookKulanz, bookRedesignPackage, + removeLastGrant, reverseGroup, } from '../services/credits.js'; import { contextOf, idParam, optionalNote, parseWith } from './helpers.js'; @@ -38,6 +39,39 @@ export async function creditRoutes(app: FastifyInstance): Promise { return bookKulanz(id, contextOf(request), { note: body.note }); }); + /** + * Nimmt die zuletzt von Hand eingetragene Zugabe zurück. + * + * Die Rechte folgen dem Eintragen: Wer ein Paket eintragen darf, darf es + * auch wieder zurücknehmen; bei Kulanz bleibt beides bei den Admins. + */ + app.post( + '/api/players/:id/credits/cc-package/remove', + { preHandler: requireEditor }, + async (request) => { + const { id } = parseWith(idParam, request.params); + return removeLastGrant(id, CreditSource.CC_PACKAGE, contextOf(request)); + }, + ); + + app.post( + '/api/players/:id/credits/redesign-package/remove', + { preHandler: requireEditor }, + async (request) => { + const { id } = parseWith(idParam, request.params); + return removeLastGrant(id, CreditSource.REDESIGN_PACKAGE, contextOf(request)); + }, + ); + + app.post( + '/api/players/:id/credits/kulanz/remove', + { preHandler: requireAdmin }, + async (request) => { + const { id } = parseWith(idParam, request.params); + return removeLastGrant(id, CreditSource.KULANZ, contextOf(request)); + }, + ); + app.post('/api/players/:id/credits/adjustment', { preHandler: requireAdmin }, async (request) => { const { id } = parseWith(idParam, request.params); const body = parseWith( diff --git a/server/src/services/credits.ts b/server/src/services/credits.ts index 32cf0c3..c0258ba 100644 --- a/server/src/services/credits.ts +++ b/server/src/services/credits.ts @@ -10,27 +10,71 @@ export interface Balances { REDESIGN_KULANZ: number; // Für ein Redesign zählen beide Redesign-Töpfe zusammen. redesignTotal: number; + /** + * Wie viel jemals in den Topf kam - Käufe, Kulanz, Korrekturen, abzüglich + * dessen, was davon storniert wurde. Der Stand oben sagt, was noch offen + * ist; das hier sagt, wovon. "0 von 3" liest sich anders als "0 von 0". + */ + granted: { CC: number; REDESIGN_PAID: number; REDESIGN_KULANZ: number }; } -const EMPTY: Balances = { CC: 0, REDESIGN_PAID: 0, REDESIGN_KULANZ: 0, redesignTotal: 0 }; +const EMPTY: Balances = { + CC: 0, + REDESIGN_PAID: 0, + REDESIGN_KULANZ: 0, + redesignTotal: 0, + granted: { CC: 0, REDESIGN_PAID: 0, REDESIGN_KULANZ: 0 }, +}; -function assemble(sums: Partial>): Balances { +function assemble( + sums: Partial>, + granted?: Partial>, +): Balances { const cc = sums.CC ?? 0; const paid = sums.REDESIGN_PAID ?? 0; const kulanz = sums.REDESIGN_KULANZ ?? 0; - return { CC: cc, REDESIGN_PAID: paid, REDESIGN_KULANZ: kulanz, redesignTotal: paid + kulanz }; + return { + CC: cc, + REDESIGN_PAID: paid, + REDESIGN_KULANZ: kulanz, + redesignTotal: paid + kulanz, + granted: { + CC: granted?.CC ?? 0, + REDESIGN_PAID: granted?.REDESIGN_PAID ?? 0, + REDESIGN_KULANZ: granted?.REDESIGN_KULANZ ?? 0, + }, + }; } export async function getBalances(playerId: number): Promise { - const rows = await prisma.creditLedger.groupBy({ - by: ['kind'], + // Für den Stand genügte eine Summe je Topf. Für "von wie vielen" muss + // jede Zeile angesehen werden: ein Storno zählt dorthin, wohin die Zeile + // zählte, die es zurücknimmt - sonst sähe die Rücknahme eines Verbrauchs + // wie ein zusätzliches Paket aus. + const eintraege = await prisma.creditLedger.findMany({ where: { playerId }, - _sum: { amount: true }, + select: { + kind: true, + amount: true, + source: true, + reversalOf: { select: { source: true } }, + }, }); const sums: Partial> = {}; - for (const row of rows) sums[row.kind] = row._sum.amount ?? 0; - return assemble(sums); + const granted: Partial> = {}; + + for (const eintrag of eintraege) { + sums[eintrag.kind] = (sums[eintrag.kind] ?? 0) + eintrag.amount; + + const zweck = + eintrag.source === CreditSource.REVERSAL ? eintrag.reversalOf?.source : eintrag.source; + if (zweck !== CreditSource.CONSUME) { + granted[eintrag.kind] = (granted[eintrag.kind] ?? 0) + eintrag.amount; + } + } + + return assemble(sums, granted); } export async function getBalancesForMany(playerIds: number[]): Promise> { @@ -243,6 +287,59 @@ export async function bookAdjustment( * bleibt vollständig lesbar. Verbrauchszeilen sind ausgenommen, die werden * über die zugehörige CC-Aktion zurückgenommen. */ +/// Was sich über die Knöpfe auf der Spielerseite eintragen lässt. +const HANDEINTRAEGE = [ + CreditSource.CC_PACKAGE, + CreditSource.REDESIGN_PACKAGE, + CreditSource.KULANZ, +] as const; +export type Handeintrag = (typeof HANDEINTRAEGE)[number]; + +/** + * Nimmt die zuletzt von Hand eingetragene Zugabe wieder zurück. + * + * Wer sich vertippt oder zweimal geklickt hat, soll das geradeziehen können, + * ohne in der Historie nach der richtigen Zeile zu suchen. Zurückgenommen + * wird die jüngste offene Zugabe dieser Art - über eine Gegenbuchung, wie + * jede Korrektur hier. Bei einem CC-Paket sind das beide Zeilen: das + * CC-Guthaben und das Redesign, das darin steckt. + * + * Eingetragen aus einem Import wird dabei ausgelassen. Ein gebuchtes Paket + * gehört zu einer Bestellung; wer das zurücknehmen will, tut es dort, wo der + * Zusammenhang sichtbar ist - in der Historie. + */ +export async function removeLastGrant( + playerId: number, + source: Handeintrag, + ctx: ActionContext, +): Promise<{ balances: Balances; reversed: number }> { + await requirePlayer(playerId); + + if (!HANDEINTRAEGE.includes(source)) { + throw badRequest('INVALID_SOURCE', 'Diese Art lässt sich nicht von Hand zurücknehmen'); + } + + const juengste = await prisma.creditLedger.findFirst({ + where: { playerId, source, importId: null, reversedBy: null }, + orderBy: { id: 'desc' }, + }); + + if (!juengste) { + const was = + source === CreditSource.CC_PACKAGE + ? 'CC-Paket' + : source === CreditSource.REDESIGN_PACKAGE + ? 'Redesign-Paket' + : '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.`, + ); + } + + return reverseGroup(juengste.id, 'Von Hand zurückgenommen', ctx); +} + export async function reverseGroup( ledgerId: number, reason: string, diff --git a/server/test/harness.ts b/server/test/harness.ts index 7cbd5e4..957d6d4 100644 --- a/server/test/harness.ts +++ b/server/test/harness.ts @@ -31,13 +31,35 @@ export async function startTestDatabase(port: number): Promise { await db.exec(readFileSync(file, 'utf8')); } - const server = new PGLiteSocketServer({ db, port, host: '127.0.0.1' }); - await server.start(); + // Die Testdateien laufen nebeneinander und bekommen feste Ports. Hängt aus + // einem vorherigen Lauf noch einer fest, wäre der Start sonst ohne Grund + // fehlgeschlagen - deshalb ein paar Versuche mit dem nächsten Port. + let server: PGLiteSocketServer | null = null; + let benutzt = port; + + for (let versuch = 0; versuch < 20; versuch += 1) { + const kandidat = new PGLiteSocketServer({ db, port: benutzt, host: '127.0.0.1' }); + try { + await kandidat.start(); + server = kandidat; + break; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== 'EADDRINUSE') throw error; + benutzt += 1; + } + } + + if (!server) { + throw new Error(`Kein freier Port ab ${port} für die Testdatenbank gefunden`); + } + + const belegterPort = benutzt; return { // connection_limit=1, weil die Testinstanz immer nur eine Verbindung // gleichzeitig bedient. Echtes Postgres braucht die Einschränkung nicht. - url: `postgresql://postgres:postgres@127.0.0.1:${port}/postgres?connection_limit=1`, + url: `postgresql://postgres:postgres@127.0.0.1:${belegterPort}/postgres?connection_limit=1`, stop: async () => { await server.stop(); await db.close(); diff --git a/server/test/http.test.ts b/server/test/http.test.ts index 7d9041d..d8ae015 100644 --- a/server/test/http.test.ts +++ b/server/test/http.test.ts @@ -385,6 +385,8 @@ describe('Guthaben über die API', () => { REDESIGN_PAID: 1, REDESIGN_KULANZ: 0, redesignTotal: 1, + // Was offen ist, und wovon: "1 von 1". + granted: { CC: 1, REDESIGN_PAID: 1, REDESIGN_KULANZ: 0 }, }); }); diff --git a/server/test/rules.test.ts b/server/test/rules.test.ts index 76bb4d7..dd8a6bd 100644 --- a/server/test/rules.test.ts +++ b/server/test/rules.test.ts @@ -314,3 +314,93 @@ describe('Korrekturen', () => { ); }); }); + +describe('Von Hand eingetragene Guthaben zurücknehmen', () => { + it('zählt, wie viel jemals in den Topf kam', async () => { + const playerId = await freshPlayer('Zaehler'); + await credits.bookCcPackage(playerId, ctx); + await credits.bookCcPackage(playerId, ctx); + await characters.createCharacter(playerId, { name: 'Verbraucht eines' }, ctx); + + const stand = await credits.getBalances(playerId); + assert.equal(stand.CC, 1, 'eines ist noch offen'); + assert.equal(stand.granted.CC, 2, 'zwei kamen jemals herein'); + assert.equal(stand.granted.REDESIGN_PAID, 2, 'jedes Paket bringt eines mit'); + }); + + it('zählt die Rücknahme eines Verbrauchs nicht als Zugang', async () => { + // Sonst sähe ein storniertes Redesign wie ein zusätzliches Paket aus. + const playerId = await freshPlayer('Stornierer'); + await credits.bookCcPackage(playerId, ctx); + const cc = await characters.createCharacter(playerId, { name: 'Wird zurückgenommen' }, ctx); + await characters.revertCharacter(cc.character.id, 'Fehleintrag', ctx); + + const stand = await credits.getBalances(playerId); + assert.equal(stand.CC, 1, 'das Guthaben ist zurück'); + assert.equal(stand.granted.CC, 1, 'hereingekommen ist trotzdem nur eines'); + }); + + it('nimmt das zuletzt eingetragene CC-Paket zurück, beide Zeilen', async () => { + const playerId = await freshPlayer('Vertipper'); + await credits.bookCcPackage(playerId, ctx); + await credits.bookCcPackage(playerId, ctx); + + const { balances } = await credits.removeLastGrant(playerId, 'CC_PACKAGE', ctx); + + assert.equal(balances.CC, 1); + assert.equal(balances.REDESIGN_PAID, 1, 'das mitgelieferte Redesign geht mit zurück'); + assert.equal(balances.granted.CC, 1, 'und zählt auch nicht mehr als erhalten'); + }); + + it('nimmt eine Kulanz zurück', async () => { + const playerId = await freshPlayer('Kulanzfall'); + await credits.bookKulanz(playerId, ctx, { note: 'War zu grosszügig' }); + + const { balances } = await credits.removeLastGrant(playerId, 'KULANZ', ctx); + assert.equal(balances.REDESIGN_KULANZ, 0); + assert.equal(balances.granted.REDESIGN_KULANZ, 0); + }); + + it('meldet sich, wenn es nichts zurückzunehmen gibt', async () => { + const playerId = await freshPlayer('Leerer Topf'); + + await assert.rejects( + () => credits.removeLastGrant(playerId, 'CC_PACKAGE', ctx), + (error: Error & { code?: string }) => error.code === 'NOTHING_TO_REMOVE', + ); + }); + + it('lässt gebuchte Import-Pakete in Ruhe', async () => { + // Ein Paket aus einer Bestellung gehört in die Historie, nicht unter + // einen Knopf, der "das letzte weg" bedeutet. + const playerId = await freshPlayer('Importiert'); + const importRecord = await db.prisma.import.create({ + data: { filename: 'shop.csv', status: 'COMMITTED', rowCount: 1 }, + }); + await db.prisma.creditLedger.create({ + data: { + playerId, + kind: 'CC', + amount: 1, + source: 'CC_PACKAGE', + importId: importRecord.id, + }, + }); + + await assert.rejects( + () => credits.removeLastGrant(playerId, 'CC_PACKAGE', ctx), + (error: Error & { code?: string }) => error.code === 'NOTHING_TO_REMOVE', + ); + }); + + it('nimmt dieselbe Zugabe nicht zweimal zurück', async () => { + const playerId = await freshPlayer('Doppelklicker'); + await credits.bookKulanz(playerId, ctx); + + await credits.removeLastGrant(playerId, 'KULANZ', ctx); + await assert.rejects( + () => credits.removeLastGrant(playerId, 'KULANZ', ctx), + (error: Error & { code?: string }) => error.code === 'NOTHING_TO_REMOVE', + ); + }); +}); diff --git a/server/test/zeitplan.test.ts b/server/test/zeitplan.test.ts index 487af75..1499c91 100644 --- a/server/test/zeitplan.test.ts +++ b/server/test/zeitplan.test.ts @@ -21,6 +21,10 @@ before(async () => { process.env.DATABASE_URL = database.url; process.env.SESSION_SECRET = 'test-secret-that-is-long-enough-000000'; process.env.NODE_ENV = 'test'; + // Der Sperr-Test stösst einen echten Abruf an. Er soll sofort scheitern + // statt in ein Zeitlimit zu laufen - und vor allem nicht wirklich bei + // GExtension anklopfen, nur weil hier Tests laufen. + process.env.GEXTENSION_BASE_URL = 'http://127.0.0.1:1'; db = await import('../src/db.js'); schedule = await import('../src/services/schedule.js'); diff --git a/web/src/components/BalanceCards.tsx b/web/src/components/BalanceCards.tsx index 6356e56..f03ab35 100644 --- a/web/src/components/BalanceCards.tsx +++ b/web/src/components/BalanceCards.tsx @@ -1,11 +1,19 @@ import type { Balances } from '../lib/types'; -function Card({ label, value, hint }: { label: string; value: number; hint?: string }) { +/** + * Ein Topf: was noch offen ist, und wovon. + * + * "0 von 3" liest sich anders als "0 von 0" - das eine heisst aufgebraucht, + * das andere nie etwas gehabt. Ohne die zweite Zahl sehen beide gleich aus. + */ +function Card({ label, value, granted }: { label: string; value: number; granted: number }) { return (
{label} - {value} - {hint && {hint}} + + {value} + {granted > 0 && von {granted}} +
); } @@ -13,13 +21,22 @@ function Card({ label, value, hint }: { label: string; value: number; hint?: str export default function BalanceCards({ balances }: { balances: Balances }) { const canCreate = balances.CC >= 1; const canRedesign = balances.redesignTotal >= 1; + const granted = balances.granted ?? { CC: 0, REDESIGN_PAID: 0, REDESIGN_KULANZ: 0 }; return (
- - - + + +
diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 91a3ea6..b13f570 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -29,6 +29,8 @@ export interface Balances { REDESIGN_PAID: number; REDESIGN_KULANZ: number; redesignTotal: number; + /// Wie viel jemals in den Topf kam, abzüglich stornierter Zugänge. + granted?: { CC: number; REDESIGN_PAID: number; REDESIGN_KULANZ: number }; } export interface PlayerSummary { diff --git a/web/src/pages/PlayerDetail.tsx b/web/src/pages/PlayerDetail.tsx index 86b18e1..ac9a725 100644 --- a/web/src/pages/PlayerDetail.tsx +++ b/web/src/pages/PlayerDetail.tsx @@ -101,6 +101,22 @@ export default function PlayerDetail() { } }; + /** + * Nimmt die zuletzt von Hand eingetragene Zugabe zurück. + * + * Zurückgenommen wird über eine Gegenbuchung - in der Historie bleibt + * beides stehen. Gebuchte Import-Pakete rührt das nicht an; die gehören zu + * einer Bestellung und werden dort storniert, wo der Zusammenhang steht. + */ + const zugabeZuruecknehmen = async (pfad: string, was: string) => { + if (!window.confirm(`Zuletzt eingetragenes ${was} zurücknehmen?`)) return; + await run( + () => + api(`/api/players/${playerId}/credits/${pfad}/remove`, { method: 'POST', body: {} }), + `${was} zurückgenommen.`, + ); + }; + // 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. @@ -259,32 +275,67 @@ export default function PlayerDetail() { {editor && (
- - - {/* Kulanz vergibt das Team, sie steht in keinem Kauf - daher nur Admins. */} - {can('ADMIN') && ( + + + + + + + + + + {/* Kulanz vergibt das Team, sie steht in keinem Kauf - daher nur Admins. */} + {can('ADMIN') && ( + + + + )}