Ein Eintrag kostet genau einmal, die Rueckkehr ist ein weiteres Glied
An einem Eintrag konnten zwei Buchungen haengen - etwa Kulanz und ein gekauftes Redesign nebeneinander -, und aufloesen liess sich das nirgends. Jetzt raeumt sowohl das Nachrechnen als auch das Umstellen der Bezahlart auf: es bleibt die aelteste passende, der Rest wird storniert. Die Rueckkehr eines redesignten oder gestorbenen CCs bucht deshalb nicht mehr zusaetzlich, sondern erschafft ihn noch einmal: derselbe CC als weiteres Glied ueber dem alten, und das kostet ein Redesign wie jedes andere. Mit einem CC-Guthaben beginnt er stattdessen eine eigene Liste und kann neben seinem Nachfolger laufen. Ein entzogener Platz nimmt seine Buchung mit - sonst waere die Rueckkehr umsonst oder stuende als zweite Buchung am selben Eintrag.
This commit is contained in:
@@ -421,6 +421,112 @@ export async function reactivateFree(
|
||||
return { character, balances, overdrawn: isOverdrawn(balances) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Holt einen CC zurück, indem er noch einmal erschaffen wird.
|
||||
*
|
||||
* Ein redesignter oder gestorbener CC existiert im Original nicht mehr.
|
||||
* Seine Rückkehr ist deshalb ein weiteres Glied in der Liste: derselbe CC,
|
||||
* noch einmal - und das kostet wie jedes andere Glied. Der alte Eintrag
|
||||
* bleibt stehen, damit sichtbar bleibt, was war.
|
||||
*
|
||||
* Wer dafür ein CC-Guthaben einsetzt, bekommt keinen Nachfolger, sondern
|
||||
* einen zweiten Platz: der neue Eintrag beginnt eine eigene Liste und kann
|
||||
* damit neben seinem Nachfolger laufen.
|
||||
*/
|
||||
async function erschaffeNeu(
|
||||
tx: Prisma.TransactionClient,
|
||||
alt: Character,
|
||||
useKind: ReviveKind,
|
||||
ctx: ActionContext,
|
||||
note: string,
|
||||
): Promise<Character> {
|
||||
const eigenerPlatz = useKind === CreditKind.CC;
|
||||
|
||||
const bisherige = await tx.characterMember.findMany({
|
||||
where: { characterId: alt.id, leftAt: null },
|
||||
select: {
|
||||
playerId: true,
|
||||
paidWith: true,
|
||||
lineageId: true,
|
||||
chainPosition: true,
|
||||
},
|
||||
});
|
||||
|
||||
const eigeneListe = randomUUID();
|
||||
const platzVon = (mitglied: { lineageId: string; chainPosition: number }) =>
|
||||
eigenerPlatz
|
||||
? { lineageId: randomUUID(), chainPosition: 0 }
|
||||
: { lineageId: mitglied.lineageId, chainPosition: mitglied.chainPosition + 10 };
|
||||
|
||||
const besitzer = bisherige.find((m) => m.playerId === alt.playerId);
|
||||
const platz = besitzer
|
||||
? platzVon(besitzer)
|
||||
: { lineageId: eigeneListe, chainPosition: 0 };
|
||||
|
||||
const neu = await tx.character.create({
|
||||
data: {
|
||||
playerId: alt.playerId,
|
||||
name: alt.name,
|
||||
loreText: alt.loreText,
|
||||
status: CharacterStatus.ACTIVE,
|
||||
lineageId: platz.lineageId,
|
||||
chainPosition: platz.chainPosition,
|
||||
...(eigenerPlatz ? {} : { predecessorId: alt.id }),
|
||||
createdById: ctx.actorId,
|
||||
members: {
|
||||
create: bisherige.map((mitglied) => {
|
||||
const eigen = platzVon(mitglied);
|
||||
return {
|
||||
playerId: mitglied.playerId,
|
||||
paidWith: mitglied.paidWith,
|
||||
lineageId:
|
||||
mitglied.playerId === alt.playerId ? platz.lineageId : eigen.lineageId,
|
||||
chainPosition:
|
||||
mitglied.playerId === alt.playerId
|
||||
? platz.chainPosition
|
||||
: eigen.chainPosition,
|
||||
...(eigenerPlatz ? {} : { predecessorId: alt.id }),
|
||||
createdById: ctx.actorId,
|
||||
};
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Der alte Eintrag ist damit ersetzt - so wie bei jedem Redesign.
|
||||
await tx.character.update({
|
||||
where: { id: alt.id },
|
||||
data: {
|
||||
status: CharacterStatus.INACTIVE,
|
||||
inactiveReason: InactiveReason.REWORKED,
|
||||
},
|
||||
});
|
||||
|
||||
await bucheVerbrauch(tx, {
|
||||
playerId: alt.playerId,
|
||||
characterId: neu.id,
|
||||
kind: useKind,
|
||||
redesign: !eigenerPlatz,
|
||||
note,
|
||||
actorId: ctx.actorId,
|
||||
});
|
||||
|
||||
// Bei einem Gruppen-CC zählt der Eintrag für jeden Nutzer einzeln.
|
||||
for (const mitglied of bisherige) {
|
||||
if (mitglied.playerId === alt.playerId) continue;
|
||||
await bucheVerbrauch(tx, {
|
||||
playerId: mitglied.playerId,
|
||||
characterId: neu.id,
|
||||
kind: eigenerPlatz ? CreditKind.CC : CreditKind.REDESIGN_PAID,
|
||||
redesign: !eigenerPlatz,
|
||||
note,
|
||||
actorId: ctx.actorId,
|
||||
});
|
||||
}
|
||||
|
||||
return neu;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bezahlte Reaktivierung, ausschließlich für redesignte CCs: deren Original
|
||||
* existiert nicht mehr, es muss also neu erschaffen werden. Kostet ein
|
||||
@@ -461,39 +567,30 @@ export async function reactivatePaid(
|
||||
}
|
||||
|
||||
const character = await prisma.$transaction(async (tx) => {
|
||||
const updated = await tx.character.update({
|
||||
where: { id: characterId },
|
||||
data: {
|
||||
status: CharacterStatus.ACTIVE,
|
||||
inactiveReason: null,
|
||||
inactiveNote: null,
|
||||
},
|
||||
});
|
||||
const neu = await erschaffeNeu(
|
||||
tx,
|
||||
existing,
|
||||
useKind,
|
||||
ctx,
|
||||
note ?? `Rückkehr: ${existing.name}`,
|
||||
);
|
||||
|
||||
await bucheVerbrauch(tx, {
|
||||
playerId: updated.playerId,
|
||||
characterId: updated.id,
|
||||
kind: useKind,
|
||||
redesign: null,
|
||||
note: note ?? `Reaktivierung: ${updated.name}`,
|
||||
actorId: ctx.actorId,
|
||||
});
|
||||
|
||||
await assertSlotsReichen(tx, updated.playerId, "Die Rückkehr dieses CCs");
|
||||
await assertSlotsReichen(tx, neu.playerId, "Die Rückkehr dieses CCs");
|
||||
|
||||
await recordAudit(tx, ctx, {
|
||||
action: "character.reactivate_paid",
|
||||
targetType: "character",
|
||||
targetId: characterId,
|
||||
targetId: neu.id,
|
||||
details: {
|
||||
playerId: updated.playerId,
|
||||
name: updated.name,
|
||||
playerId: neu.playerId,
|
||||
name: neu.name,
|
||||
useKind,
|
||||
previousId: existing.id,
|
||||
previousReason: existing.inactiveReason,
|
||||
},
|
||||
});
|
||||
|
||||
return updated;
|
||||
return neu;
|
||||
});
|
||||
|
||||
const balances = await getBalances(character.playerId);
|
||||
@@ -838,37 +935,39 @@ export async function reviveDeadCharacter(
|
||||
}
|
||||
|
||||
const character = await prisma.$transaction(async (tx) => {
|
||||
const updated = await tx.character.update({
|
||||
// Kostet die Rückkehr etwas, ist sie ein weiteres Glied: derselbe CC,
|
||||
// noch einmal. Gratis kommt er dagegen unverändert zurück - dann hat
|
||||
// sich an der Liste nichts geändert.
|
||||
const updated =
|
||||
input.useKind === undefined
|
||||
? await tx.character.update({
|
||||
where: { id: characterId },
|
||||
data: {
|
||||
status: CharacterStatus.ACTIVE,
|
||||
inactiveReason: null,
|
||||
inactiveNote: null,
|
||||
},
|
||||
});
|
||||
|
||||
if (input.useKind !== undefined) {
|
||||
await bucheVerbrauch(tx, {
|
||||
playerId: updated.playerId,
|
||||
characterId: updated.id,
|
||||
kind: input.useKind,
|
||||
redesign: null,
|
||||
note: `Rückkehr nach Tod: ${input.reason}`,
|
||||
actorId: ctx.actorId,
|
||||
});
|
||||
}
|
||||
})
|
||||
: await erschaffeNeu(
|
||||
tx,
|
||||
existing,
|
||||
input.useKind,
|
||||
ctx,
|
||||
`Rückkehr nach Tod: ${input.reason}`,
|
||||
);
|
||||
|
||||
await assertSlotsReichen(tx, updated.playerId, "Die Rückkehr dieses CCs");
|
||||
|
||||
await recordAudit(tx, ctx, {
|
||||
action: "character.revive_dead",
|
||||
targetType: "character",
|
||||
targetId: characterId,
|
||||
targetId: updated.id,
|
||||
details: {
|
||||
playerId: updated.playerId,
|
||||
name: updated.name,
|
||||
reason: input.reason,
|
||||
useKind: input.useKind ?? null,
|
||||
previousId: existing.id,
|
||||
previousNote: existing.inactiveNote,
|
||||
},
|
||||
});
|
||||
@@ -906,7 +1005,7 @@ export async function changeFunding(
|
||||
|
||||
// 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({
|
||||
const offene = await prisma.creditLedger.findMany({
|
||||
where: {
|
||||
characterId,
|
||||
playerId: spieler,
|
||||
@@ -915,6 +1014,7 @@ export async function changeFunding(
|
||||
},
|
||||
orderBy: { id: "desc" },
|
||||
});
|
||||
const buchung = offene[0];
|
||||
if (!buchung) {
|
||||
throw conflict(
|
||||
"NO_BOOKING",
|
||||
@@ -938,7 +1038,9 @@ export async function changeFunding(
|
||||
"Redesign um, dann steht es hier zur Verfügung.",
|
||||
);
|
||||
}
|
||||
if (buchung.kind === useKind) {
|
||||
// Bei mehr als einer Buchung ist das Umstellen zugleich das Aufräumen:
|
||||
// ein Eintrag kostet genau einmal.
|
||||
if (offene.length === 1 && buchung.kind === useKind) {
|
||||
throw conflict("SAME_KIND", "Dieser CC ist bereits so gebucht");
|
||||
}
|
||||
|
||||
@@ -948,7 +1050,9 @@ export async function changeFunding(
|
||||
: "Umgestellt auf bezahltes Redesign";
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await storniere(tx, buchung, `${grund}: ${character.name}`, ctx.actorId);
|
||||
for (const eintrag of offene) {
|
||||
await storniere(tx, eintrag, `${grund}: ${character.name}`, ctx.actorId);
|
||||
}
|
||||
|
||||
await tx.creditLedger.create({
|
||||
data: {
|
||||
@@ -1041,6 +1145,21 @@ async function bucheVerbrauch(
|
||||
): Promise<void> {
|
||||
const ausCcTopf = params.kind === CreditKind.CC;
|
||||
|
||||
// Ein Eintrag kostet genau einmal. Steht seine Buchung schon da - etwa bei
|
||||
// der Rückkehr eines CCs, der einmal bezahlt wurde -, kommt keine zweite
|
||||
// dazu. Ob der Platz für zwei gleichzeitig aktive CCs reicht, entscheidet
|
||||
// die Slot-Prüfung.
|
||||
const schonBezahlt = await tx.creditLedger.findFirst({
|
||||
where: {
|
||||
characterId: params.characterId,
|
||||
playerId: params.playerId,
|
||||
source: CreditSource.CONSUME,
|
||||
reversedBy: null,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (schonBezahlt !== null) return;
|
||||
|
||||
if (params.redesign === true && ausCcTopf) {
|
||||
throw conflict(
|
||||
"CONVERT_FIRST",
|
||||
@@ -1165,18 +1284,38 @@ async function buchungFolgtPosition(
|
||||
return true;
|
||||
}
|
||||
|
||||
// Steht schon etwas da, das zur Stelle passt, bleibt alles, wie es ist.
|
||||
// An einem Eintrag kann mehr als eine Buchung hängen - eine Rückkehr
|
||||
// kostet zusätzlich, und das soll sie auch.
|
||||
if (offene.some((eintrag) => (eintrag.kind === CreditKind.CC) === sollCC))
|
||||
return false;
|
||||
// Ein Glied kostet genau einmal. Steht mehr als eine Buchung daran, ist
|
||||
// eine davon zu viel - die überzähligen werden zurückgenommen, sonst
|
||||
// zahlt jemand doppelt für denselben Eintrag.
|
||||
// Es bleibt die älteste passende: sie gehört zu dem Vorgang, aus dem der
|
||||
// Eintrag entstanden ist. Was später dazukam, ist die Dublette.
|
||||
const passend = offene
|
||||
.filter((eintrag) => (eintrag.kind === CreditKind.CC) === sollCC)
|
||||
.sort((a, b) => a.id - b.id);
|
||||
const behalten = passend[0] ?? null;
|
||||
|
||||
// Ein Eintrag, der in eine Kette rückt, braucht kein eigenes Paket mehr:
|
||||
// das Guthaben kommt zurück, dafür geht ein Redesign weg. Umgekehrt
|
||||
// genauso.
|
||||
let geaendert = false;
|
||||
for (const eintrag of offene) {
|
||||
// Die eine, die bleibt, und - wenn keine passt - die jüngste, die
|
||||
// gleich umgebucht wird.
|
||||
if (eintrag.id === (behalten?.id ?? offen.id)) continue;
|
||||
await storniere(
|
||||
tx,
|
||||
eintrag,
|
||||
`Doppelt gebucht - Storno: ${glied.name}`,
|
||||
actorId,
|
||||
);
|
||||
geaendert = true;
|
||||
}
|
||||
|
||||
if (behalten !== null) return geaendert;
|
||||
|
||||
// Keine passende dabei: Ein Eintrag, der in eine Liste rückt, braucht kein
|
||||
// eigenes Paket mehr - das Guthaben kommt zurück, dafür geht ein Redesign
|
||||
// weg. Umgekehrt genauso.
|
||||
const grund = sollCC
|
||||
? "Steht jetzt am Anfang der Kette: aus einem CC-Paket bezahlt"
|
||||
: "Steht jetzt in einer Kette: als Redesign gebucht";
|
||||
? "Steht jetzt am Anfang der Liste: aus einem CC-Paket bezahlt"
|
||||
: "Steht jetzt in einer Liste: als Redesign gebucht";
|
||||
|
||||
await storniere(tx, offen, `${grund} - Storno: ${glied.name}`, actorId);
|
||||
|
||||
@@ -2348,6 +2487,30 @@ export async function deactivateMember(
|
||||
},
|
||||
});
|
||||
|
||||
// Ein entzogener Platz ist weg - seine Buchung auch. Nur so kostet die
|
||||
// Rückkehr wieder etwas, ohne dass am selben Eintrag zwei Buchungen
|
||||
// stehen. Wer geputzt wurde oder im Rollenspiel gestorben ist, behält
|
||||
// seine Buchung: sein Platz ist bezahlt, die Rückkehr entscheidet ein
|
||||
// Administrator.
|
||||
if (reason === InactiveReason.NO_FREE_CLAIM) {
|
||||
const offene = await tx.creditLedger.findMany({
|
||||
where: {
|
||||
characterId,
|
||||
playerId,
|
||||
source: CreditSource.CONSUME,
|
||||
reversedBy: null,
|
||||
},
|
||||
});
|
||||
for (const eintrag of offene) {
|
||||
await storniere(
|
||||
tx,
|
||||
eintrag,
|
||||
`Platz nicht mehr genutzt - Storno: ${mitglied.character.name}`,
|
||||
ctx.actorId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await recordAudit(tx, ctx, {
|
||||
action: "character.member_deactivated",
|
||||
targetType: "character",
|
||||
@@ -2436,7 +2599,9 @@ export async function reactivateMember(
|
||||
playerId,
|
||||
characterId,
|
||||
kind: input.useKind,
|
||||
redesign: null,
|
||||
// Auch hier entscheidet die Stelle in seiner Liste: steht der CC
|
||||
// dort unten, braucht die Rückkehr einen Platz.
|
||||
redesign: mitglied.predecessorId !== null,
|
||||
note: input.reason ?? `Rückkehr zu "${gruppe.name}"`,
|
||||
actorId: ctx.actorId,
|
||||
});
|
||||
|
||||
@@ -682,14 +682,12 @@ describe('Einzelne Mitglieder fallen aus', () => {
|
||||
(error: Error & { code?: string }) => error.code === 'NEED_KIND',
|
||||
);
|
||||
|
||||
// Sein Platz war entzogen, also kostet die Rückkehr wieder einen - in
|
||||
// seiner Liste steht der CC ganz unten.
|
||||
const vorher = await credits.getBalances(partner);
|
||||
await characters.reactivateMember(character.id, partner, { useKind: 'REDESIGN_PAID' }, ctx);
|
||||
await characters.reactivateMember(character.id, partner, { useKind: 'CC' }, ctx);
|
||||
|
||||
assert.equal(
|
||||
(await credits.getBalances(partner)).REDESIGN_PAID,
|
||||
vorher.REDESIGN_PAID - 1,
|
||||
'bezahlt',
|
||||
);
|
||||
assert.equal((await credits.getBalances(partner)).CC, vorher.CC - 1, 'bezahlt');
|
||||
});
|
||||
|
||||
it('verlangt für die Rückkehr eines Gestorbenen eine Begründung', async () => {
|
||||
|
||||
@@ -1383,3 +1383,86 @@ describe('Die Umstellung auf Listen je Nutzer', () => {
|
||||
assert.ok(drei.character.id > 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Zwei Buchungen an einem Eintrag', () => {
|
||||
/// Die offenen Verbrauchsbuchungen eines Spielers an einem CC.
|
||||
async function offene(characterId: number, playerId: number): Promise<string[]> {
|
||||
const zeilen = await db.prisma.creditLedger.findMany({
|
||||
where: { characterId, playerId, source: 'CONSUME', reversedBy: null },
|
||||
orderBy: { id: 'asc' },
|
||||
select: { kind: true },
|
||||
});
|
||||
return zeilen.map((z) => z.kind);
|
||||
}
|
||||
|
||||
/** Der Fall aus dem Bestand: an einem Redesign hängen Kulanz und Kauf. */
|
||||
async function doppeltGebucht(name: string) {
|
||||
const spieler = await spielerMitPaketen(name, 1);
|
||||
const eins = await characters.createCharacter(spieler, { name: `${name}-Anfang` }, ctx);
|
||||
await credits.bookRedesignPackage(spieler, ctx);
|
||||
const zwei = await characters.redesignCharacter(
|
||||
eins.character.id,
|
||||
{ name: `${name}-Zweites`, useKind: 'REDESIGN_PAID' },
|
||||
ctx,
|
||||
);
|
||||
|
||||
await credits.bookKulanz(spieler, ctx);
|
||||
await db.prisma.creditLedger.create({
|
||||
data: {
|
||||
playerId: spieler,
|
||||
kind: 'REDESIGN_KULANZ',
|
||||
amount: -1,
|
||||
source: 'CONSUME',
|
||||
characterId: zwei.character.id,
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(await offene(zwei.character.id, spieler), [
|
||||
'REDESIGN_PAID',
|
||||
'REDESIGN_KULANZ',
|
||||
]);
|
||||
return { spieler, zwei: zwei.character.id };
|
||||
}
|
||||
|
||||
it('räumt das Nachrechnen wieder auf', async () => {
|
||||
const { spieler, zwei } = await doppeltGebucht('Doppelt');
|
||||
const vorher = await credits.getBalances(spieler);
|
||||
|
||||
const ergebnis = await characters.rebuildFunding(spieler, ctx);
|
||||
|
||||
assert.equal(ergebnis.umgebucht, 1, 'eine Buchung war zu viel');
|
||||
assert.deepEqual(
|
||||
await offene(zwei, spieler),
|
||||
['REDESIGN_PAID'],
|
||||
'ein Eintrag kostet genau einmal',
|
||||
);
|
||||
assert.equal(
|
||||
ergebnis.balances.REDESIGN_KULANZ,
|
||||
vorher.REDESIGN_KULANZ + 1,
|
||||
'die Kulanz ist wieder frei',
|
||||
);
|
||||
});
|
||||
|
||||
it('lässt sich auch über die Bezahlart auflösen', async () => {
|
||||
const { spieler, zwei } = await doppeltGebucht('Umstellen');
|
||||
|
||||
const vorher = await credits.getBalances(spieler);
|
||||
const danach = await characters.changeFunding(zwei, 'REDESIGN_KULANZ', ctx);
|
||||
|
||||
assert.deepEqual(
|
||||
await offene(zwei, spieler),
|
||||
['REDESIGN_KULANZ'],
|
||||
'umstellen ersetzt alles, was dort stand',
|
||||
);
|
||||
assert.equal(
|
||||
danach.balances.REDESIGN_PAID,
|
||||
vorher.REDESIGN_PAID + 1,
|
||||
'das bezahlte Redesign ist wieder frei',
|
||||
);
|
||||
assert.equal(
|
||||
danach.balances.REDESIGN_KULANZ,
|
||||
vorher.REDESIGN_KULANZ,
|
||||
'die Kulanz bleibt verbraucht - nur eben einmal',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user