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
@@ -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 {
/// Ein gekauftes CC-Paket: bringt einen Platz und ein Redesign mit.
CC_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
ADJUSTMENT
CONSUME
+8 -3
View File
@@ -4,9 +4,9 @@ import { CreditKind, CreditSource } from '@prisma/client';
import { requireAdmin, requireEditor } from '../auth/guard.js';
import {
bookAdjustment,
bookCcPackage,
bookKulanz,
bookRedesignPackage,
grantCcSlot,
removeLastGrant,
reverseGroup,
} from '../services/credits.js';
@@ -15,10 +15,15 @@ import { contextOf, idParam, optionalNote, parseWith } from './helpers.js';
const noteBody = z.object({ note: optionalNote });
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) => {
const { id } = parseWith(idParam, request.params);
const body = parseWith(noteBody, request.body ?? {});
return bookCcPackage(id, contextOf(request), { note: body.note });
return grantCcSlot(id, contextOf(request), { note: body.note });
});
app.post(
@@ -50,7 +55,7 @@ export async function creditRoutes(app: FastifyInstance): Promise<void> {
{ preHandler: requireEditor },
async (request) => {
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) };
}
/**
* 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(
playerId: number,
ctx: ActionContext,
@@ -289,6 +330,7 @@ export async function bookAdjustment(
*/
/// Was sich über die Knöpfe auf der Spielerseite eintragen lässt.
const HANDEINTRAEGE = [
CreditSource.CC_GRANT,
CreditSource.CC_PACKAGE,
CreditSource.REDESIGN_PACKAGE,
CreditSource.KULANZ,
@@ -326,11 +368,13 @@ export async function removeLastGrant(
if (!juengste) {
const was =
source === CreditSource.CC_PACKAGE
? 'CC-Paket'
: source === CreditSource.REDESIGN_PACKAGE
? 'Redesign-Paket'
: 'Kulanz';
source === CreditSource.CC_GRANT
? 'CC-Guthaben'
: 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.`,
+6 -5
View File
@@ -361,7 +361,7 @@ describe('Erstpasswort', () => {
});
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 created = await app.inject({
@@ -380,13 +380,14 @@ describe('Guthaben über die API', () => {
});
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, {
CC: 1,
REDESIGN_PAID: 1,
REDESIGN_PAID: 0,
REDESIGN_KULANZ: 0,
redesignTotal: 1,
// Was offen ist, und wovon: "1 von 1".
granted: { CC: 1, REDESIGN_PAID: 1, REDESIGN_KULANZ: 0 },
redesignTotal: 0,
granted: { CC: 1, REDESIGN_PAID: 0, 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');
});
});