Von Hand vergebenes CC-Guthaben ohne Redesign

Der Knopf trug bisher die Regel des Shops: ein CC-Paket ist ein Platz plus
ein Redesign. Fuer Einzelfaelle - jemand bekommt einen Platz als Ausgleich,
aus einer Absprache, weil etwas schiefging - war das zu viel.

"+ CC-Guthaben" schreibt jetzt nur den Platz gut. Wer ein volles Paket
nachtragen will, klickt zusaetzlich auf "+ Redesign-Paket". Der Abgleich mit
dem Shop bleibt unberuehrt: dort ist ein Paket weiterhin beides, und die
Regel dafuer steht unveraendert an einer Stelle.

In der Historie steht "CC-Guthaben (von Hand)" statt "CC-Paket" - eine eigene
Quelle, damit zu sehen ist, dass kein Kauf dahintersteht. Das Minus daneben
nimmt entsprechend diese Zugabe zurueck.
This commit is contained in:
2026-09-21 23:43:26 +02:00
parent e57bfbabc7
commit ae5c67eea6
10 changed files with 145 additions and 23 deletions
+9 -3
View File
@@ -122,9 +122,15 @@ Name als Link. Ändern und entfernen geht über denselben Knopf *Verweise*.
## Guthaben von Hand eintragen ## Guthaben von Hand eintragen
Auf der Spielerseite stehen drei Paare: **+ CC-Paket**, **+ Redesign-Paket**, **+ Kulanz** - und Auf der Spielerseite stehen drei Paare: **+ CC-Guthaben**, **+ Redesign-Paket**, **+ Kulanz** -
daneben je ein **−**, das die zuletzt von Hand eingetragene Zugabe dieser Art zurücknimmt. Für und daneben je ein **−**, das die zuletzt von Hand eingetragene Zugabe dieser Art zurücknimmt.
den vertippten oder doppelten Klick, ohne in der Historie nach der richtigen Zeile zu suchen. Für den vertippten oder doppelten Klick, ohne in der Historie nach der richtigen Zeile zu suchen.
**Von Hand vergeben heißt nur der Platz.** Ein im Shop gekauftes Paket bringt ein Redesign mit,
und so bucht es der Abgleich auch - hier geht es um Einzelfälle, in denen jemand einen Platz
bekommt, ohne dafür gekauft zu haben. Wer ein volles Paket nachtragen will, klickt zusätzlich
auf *+ Redesign-Paket*. In der Historie steht *CC-Guthaben (von Hand)*, damit zu sehen ist, dass
kein Kauf dahintersteht.
Zurückgenommen wird über eine Gegenbuchung; in der Historie bleibt beides stehen. Bei einem 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. CC-Paket gehen beide Zeilen mit zurück, das CC-Guthaben und das darin enthaltene Redesign.
@@ -0,0 +1,9 @@
-- Ein von Hand vergebener CC-Platz, ohne das Redesign, das in einem gekauften
-- Paket steckt. Fuer Einzelfaelle; der Abgleich mit dem Shop bleibt, wie er
-- ist - dort ist ein Paket weiterhin beides.
DO $$
BEGIN
ALTER TYPE "CreditSource" ADD VALUE IF NOT EXISTS 'CC_GRANT';
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
+5
View File
@@ -21,8 +21,13 @@ enum CreditKind {
} }
enum CreditSource { enum CreditSource {
/// Ein gekauftes CC-Paket: bringt einen Platz und ein Redesign mit.
CC_PACKAGE CC_PACKAGE
REDESIGN_PACKAGE REDESIGN_PACKAGE
/// Ein von Hand vergebener Platz, ohne das Redesign aus einem Paket.
/// Für Einzelfälle, in denen jemand einen CC bekommt, ohne dafür gekauft
/// zu haben - der Abgleich mit dem Shop bleibt davon unberührt.
CC_GRANT
KULANZ KULANZ
ADJUSTMENT ADJUSTMENT
CONSUME CONSUME
+8 -3
View File
@@ -4,9 +4,9 @@ import { CreditKind, CreditSource } from '@prisma/client';
import { requireAdmin, requireEditor } from '../auth/guard.js'; import { requireAdmin, requireEditor } from '../auth/guard.js';
import { import {
bookAdjustment, bookAdjustment,
bookCcPackage,
bookKulanz, bookKulanz,
bookRedesignPackage, bookRedesignPackage,
grantCcSlot,
removeLastGrant, removeLastGrant,
reverseGroup, reverseGroup,
} from '../services/credits.js'; } from '../services/credits.js';
@@ -15,10 +15,15 @@ import { contextOf, idParam, optionalNote, parseWith } from './helpers.js';
const noteBody = z.object({ note: optionalNote }); const noteBody = z.object({ note: optionalNote });
export async function creditRoutes(app: FastifyInstance): Promise<void> { export async function creditRoutes(app: FastifyInstance): Promise<void> {
/**
* Von Hand einen CC-Platz vergeben - ohne das Redesign, das in einem
* gekauften Paket steckt. Der Abgleich mit dem Shop bleibt davon
* unberührt: dort ist ein Paket weiterhin beides.
*/
app.post('/api/players/:id/credits/cc-package', { preHandler: requireEditor }, async (request) => { app.post('/api/players/:id/credits/cc-package', { preHandler: requireEditor }, async (request) => {
const { id } = parseWith(idParam, request.params); const { id } = parseWith(idParam, request.params);
const body = parseWith(noteBody, request.body ?? {}); const body = parseWith(noteBody, request.body ?? {});
return bookCcPackage(id, contextOf(request), { note: body.note }); return grantCcSlot(id, contextOf(request), { note: body.note });
}); });
app.post( app.post(
@@ -50,7 +55,7 @@ export async function creditRoutes(app: FastifyInstance): Promise<void> {
{ preHandler: requireEditor }, { preHandler: requireEditor },
async (request) => { async (request) => {
const { id } = parseWith(idParam, request.params); const { id } = parseWith(idParam, request.params);
return removeLastGrant(id, CreditSource.CC_PACKAGE, contextOf(request)); return removeLastGrant(id, CreditSource.CC_GRANT, contextOf(request));
}, },
); );
+49 -5
View File
@@ -181,6 +181,47 @@ export async function bookCcPackage(
return { groupId, balances: await getBalances(playerId) }; return { groupId, balances: await getBalances(playerId) };
} }
/**
* Vergibt einen CC-Platz von Hand, ohne das Redesign aus einem Paket.
*
* Ein gekauftes CC-Paket bringt beides mit - so ist es im Shop, und so
* bucht es der Abgleich. Von Hand gibt es aber Einzelfälle, in denen jemand
* nur einen Platz bekommen soll: als Ausgleich, aus einer Absprache, weil
* etwas schiefging. Dafür ist das hier da, und deshalb trägt es eine eigene
* Quelle - in der Historie soll zu sehen sein, dass kein Kauf dahintersteht.
*/
export async function grantCcSlot(
playerId: number,
ctx: ActionContext,
options: { note?: string | undefined } = {},
): Promise<{ groupId: string; balances: Balances }> {
await requirePlayer(playerId);
const groupId = randomUUID();
await prisma.$transaction(async (tx) => {
await tx.creditLedger.create({
data: {
playerId,
kind: CreditKind.CC,
amount: 1,
source: CreditSource.CC_GRANT,
groupId,
note: options.note ?? null,
createdById: ctx.actorId,
},
});
await recordAudit(tx, ctx, {
action: 'credit.cc_grant',
targetType: 'player',
targetId: playerId,
details: { groupId, note: options.note ?? null },
});
});
return { groupId, balances: await getBalances(playerId) };
}
export async function bookRedesignPackage( export async function bookRedesignPackage(
playerId: number, playerId: number,
ctx: ActionContext, ctx: ActionContext,
@@ -289,6 +330,7 @@ export async function bookAdjustment(
*/ */
/// Was sich über die Knöpfe auf der Spielerseite eintragen lässt. /// Was sich über die Knöpfe auf der Spielerseite eintragen lässt.
const HANDEINTRAEGE = [ const HANDEINTRAEGE = [
CreditSource.CC_GRANT,
CreditSource.CC_PACKAGE, CreditSource.CC_PACKAGE,
CreditSource.REDESIGN_PACKAGE, CreditSource.REDESIGN_PACKAGE,
CreditSource.KULANZ, CreditSource.KULANZ,
@@ -326,11 +368,13 @@ export async function removeLastGrant(
if (!juengste) { if (!juengste) {
const was = const was =
source === CreditSource.CC_PACKAGE source === CreditSource.CC_GRANT
? 'CC-Paket' ? 'CC-Guthaben'
: source === CreditSource.REDESIGN_PACKAGE : source === CreditSource.CC_PACKAGE
? 'Redesign-Paket' ? 'CC-Paket'
: 'Kulanz'; : source === CreditSource.REDESIGN_PACKAGE
? 'Redesign-Paket'
: '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.`,
+6 -5
View File
@@ -361,7 +361,7 @@ describe('Erstpasswort', () => {
}); });
describe('Guthaben über die API', () => { describe('Guthaben über die API', () => {
it('ein CC-Paket schlägt in beiden Töpfen auf', async () => { it('von Hand vergeben schreibt nur das CC-Guthaben gut', async () => {
const credentials = await login('team'); const credentials = await login('team');
const created = await app.inject({ const created = await app.inject({
@@ -380,13 +380,14 @@ describe('Guthaben über die API', () => {
}); });
assert.equal(booked.statusCode, 200); assert.equal(booked.statusCode, 200);
// Ein im Shop gekauftes Paket bringt ein Redesign mit; hier geht es um
// den Einzelfall, in dem jemand nur einen Platz bekommt.
assert.deepEqual(booked.json().balances, { assert.deepEqual(booked.json().balances, {
CC: 1, CC: 1,
REDESIGN_PAID: 1, REDESIGN_PAID: 0,
REDESIGN_KULANZ: 0, REDESIGN_KULANZ: 0,
redesignTotal: 1, redesignTotal: 0,
// Was offen ist, und wovon: "1 von 1". granted: { CC: 1, REDESIGN_PAID: 0, REDESIGN_KULANZ: 0 },
granted: { CC: 1, REDESIGN_PAID: 1, REDESIGN_KULANZ: 0 },
}); });
}); });
+48
View File
@@ -404,3 +404,51 @@ describe('Von Hand eingetragene Guthaben zurücknehmen', () => {
); );
}); });
}); });
describe('CC-Guthaben von Hand vergeben', () => {
it('schreibt nur das CC-Guthaben gut, kein Redesign', async () => {
// Ein gekauftes Paket bringt beides mit. Von Hand gibt es Einzelfälle,
// in denen jemand nur einen Platz bekommen soll.
const playerId = await freshPlayer('Einzelfall');
const { balances } = await credits.grantCcSlot(playerId, ctx, { note: 'Ausgleich' });
assert.equal(balances.CC, 1);
assert.equal(balances.REDESIGN_PAID, 0, 'kein Redesign obendrauf');
assert.equal(balances.granted.CC, 1);
});
it('macht in der Historie sichtbar, dass kein Kauf dahintersteht', async () => {
const playerId = await freshPlayer('Nachvollziehbar');
await credits.grantCcSlot(playerId, ctx);
const zeile = await db.prisma.creditLedger.findFirstOrThrow({ where: { playerId } });
assert.equal(zeile.source, 'CC_GRANT');
});
it('lässt den Abgleich mit dem Shop unberührt', async () => {
// Ein Paket aus dem Shop bleibt, was es ist: Platz plus Redesign.
const playerId = await freshPlayer('Aus dem Shop');
const { balances } = await credits.bookCcPackage(playerId, ctx);
assert.equal(balances.CC, 1);
assert.equal(balances.REDESIGN_PAID, 1, 'das Redesign gehört zum Paket');
});
it('nimmt einen von Hand vergebenen Platz wieder zurück', async () => {
const playerId = await freshPlayer('Doch nicht');
await credits.grantCcSlot(playerId, ctx);
const { balances } = await credits.removeLastGrant(playerId, 'CC_GRANT', ctx);
assert.equal(balances.CC, 0);
assert.equal(balances.granted.CC, 0);
});
it('reicht für einen neuen CC', async () => {
const playerId = await freshPlayer('Baut damit');
await credits.grantCcSlot(playerId, ctx);
const ergebnis = await characters.createCharacter(playerId, { name: 'Daraus gebaut' }, ctx);
assert.equal(ergebnis.balances.CC, 0);
assert.equal(ergebnis.overdrawn, false, 'der Platz war gedeckt');
});
});
+2
View File
@@ -9,6 +9,8 @@ export const KIND_LABEL: Record<CreditKind, string> = {
export const SOURCE_LABEL: Record<CreditSource, string> = { export const SOURCE_LABEL: Record<CreditSource, string> = {
CC_PACKAGE: 'CC-Paket', CC_PACKAGE: 'CC-Paket',
REDESIGN_PACKAGE: 'Redesign-Paket', REDESIGN_PACKAGE: 'Redesign-Paket',
/// Von Hand vergeben, ohne Kauf dahinter.
CC_GRANT: 'CC-Guthaben (von Hand)',
KULANZ: 'Kulanz', KULANZ: 'Kulanz',
ADJUSTMENT: 'Korrektur', ADJUSTMENT: 'Korrektur',
CONSUME: 'Verbrauch', CONSUME: 'Verbrauch',
+1
View File
@@ -3,6 +3,7 @@ export type CreditKind = 'CC' | 'REDESIGN_PAID' | 'REDESIGN_KULANZ';
export type CreditSource = export type CreditSource =
| 'CC_PACKAGE' | 'CC_PACKAGE'
| 'REDESIGN_PACKAGE' | 'REDESIGN_PACKAGE'
| 'CC_GRANT'
| 'KULANZ' | 'KULANZ'
| 'ADJUSTMENT' | 'ADJUSTMENT'
| 'CONSUME' | 'CONSUME'
+8 -7
View File
@@ -318,14 +318,14 @@ export default function PlayerDetail() {
disabled={busy} disabled={busy}
onClick={() => setDialog({ type: 'addCredit', kind: 'cc-package' })} onClick={() => setDialog({ type: 'addCredit', kind: 'cc-package' })}
> >
+ CC-Paket + CC-Guthaben
</button> </button>
<button <button
type="button" type="button"
className="btn btn-ghost" className="btn btn-ghost"
disabled={busy} disabled={busy}
title="Das zuletzt von Hand eingetragene CC-Paket zurücknehmen" title="Das zuletzt von Hand eingetragene CC-Guthaben zurücknehmen"
onClick={() => void zugabeZuruecknehmen('cc-package', 'CC-Paket')} onClick={() => void zugabeZuruecknehmen('cc-package', 'CC-Guthaben')}
> >
− −
</button> </button>
@@ -837,10 +837,11 @@ export default function PlayerDetail() {
const CREDIT_ACTIONS = { const CREDIT_ACTIONS = {
'cc-package': { 'cc-package': {
titel: 'CC-Paket eintragen', titel: 'CC-Guthaben eintragen',
wirkung: 'Schreibt +1 CC-Guthaben und +1 Redesign-Guthaben gut.', wirkung: 'Schreibt +1 CC-Guthaben gut - einen Platz für einen aktiven CC.',
hinweis: 'Das Redesign ist bei jedem CC-Paket enthalten und wird immer mitgebucht.', hinweis:
flash: 'CC-Paket eingetragen: +1 CC und +1 Redesign.', 'Ohne Redesign. Ein im Shop gekauftes Paket bringt beides mit, und so bucht es der Abgleich auch - hier geht es um Einzelfälle, in denen jemand nur einen Platz bekommt. Wer ein volles Paket nachtragen will, klickt zusätzlich auf „+ Redesign-Paket".',
flash: 'CC-Guthaben eingetragen: +1 CC.',
}, },
'redesign-package': { 'redesign-package': {
titel: 'Redesign-Paket eintragen', titel: 'Redesign-Paket eintragen',