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:
@@ -122,9 +122,15 @@ 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.
|
||||
Auf der Spielerseite stehen drei Paare: **+ CC-Guthaben**, **+ 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.
|
||||
|
||||
**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
|
||||
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 $$;
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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,7 +368,9 @@ export async function removeLastGrant(
|
||||
|
||||
if (!juengste) {
|
||||
const was =
|
||||
source === CreditSource.CC_PACKAGE
|
||||
source === CreditSource.CC_GRANT
|
||||
? 'CC-Guthaben'
|
||||
: source === CreditSource.CC_PACKAGE
|
||||
? 'CC-Paket'
|
||||
: source === CreditSource.REDESIGN_PACKAGE
|
||||
? 'Redesign-Paket'
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,8 @@ export const KIND_LABEL: Record<CreditKind, string> = {
|
||||
export const SOURCE_LABEL: Record<CreditSource, string> = {
|
||||
CC_PACKAGE: 'CC-Paket',
|
||||
REDESIGN_PACKAGE: 'Redesign-Paket',
|
||||
/// Von Hand vergeben, ohne Kauf dahinter.
|
||||
CC_GRANT: 'CC-Guthaben (von Hand)',
|
||||
KULANZ: 'Kulanz',
|
||||
ADJUSTMENT: 'Korrektur',
|
||||
CONSUME: 'Verbrauch',
|
||||
|
||||
@@ -3,6 +3,7 @@ export type CreditKind = 'CC' | 'REDESIGN_PAID' | 'REDESIGN_KULANZ';
|
||||
export type CreditSource =
|
||||
| 'CC_PACKAGE'
|
||||
| 'REDESIGN_PACKAGE'
|
||||
| 'CC_GRANT'
|
||||
| 'KULANZ'
|
||||
| 'ADJUSTMENT'
|
||||
| 'CONSUME'
|
||||
|
||||
@@ -318,14 +318,14 @@ export default function PlayerDetail() {
|
||||
disabled={busy}
|
||||
onClick={() => setDialog({ type: 'addCredit', kind: 'cc-package' })}
|
||||
>
|
||||
+ CC-Paket
|
||||
+ CC-Guthaben
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
disabled={busy}
|
||||
title="Das zuletzt von Hand eingetragene CC-Paket zurücknehmen"
|
||||
onClick={() => void zugabeZuruecknehmen('cc-package', 'CC-Paket')}
|
||||
title="Das zuletzt von Hand eingetragene CC-Guthaben zurücknehmen"
|
||||
onClick={() => void zugabeZuruecknehmen('cc-package', 'CC-Guthaben')}
|
||||
>
|
||||
−
|
||||
</button>
|
||||
@@ -837,10 +837,11 @@ export default function PlayerDetail() {
|
||||
|
||||
const CREDIT_ACTIONS = {
|
||||
'cc-package': {
|
||||
titel: 'CC-Paket eintragen',
|
||||
wirkung: 'Schreibt +1 CC-Guthaben und +1 Redesign-Guthaben gut.',
|
||||
hinweis: 'Das Redesign ist bei jedem CC-Paket enthalten und wird immer mitgebucht.',
|
||||
flash: 'CC-Paket eingetragen: +1 CC und +1 Redesign.',
|
||||
titel: 'CC-Guthaben eintragen',
|
||||
wirkung: 'Schreibt +1 CC-Guthaben gut - einen Platz für einen aktiven CC.',
|
||||
hinweis:
|
||||
'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': {
|
||||
titel: 'Redesign-Paket eintragen',
|
||||
|
||||
Reference in New Issue
Block a user