Route the game's `money.delta` events into the web banking system so a player's personal bank account tracks their in-game wallet. - New `processMoneyEvent` (src/lib/arma-bridge/money.ts) applies a parsed money delta to the player's personal account: debits go to the unit treasury (or leave if no treasury), refunds hit the treasury first and fall back to a plain deposit, and rewards/grants deposit directly. - Wire the handler into ArmaSyncEvents and add a `POST /api/arma/v1/balances` route (get_balances) that returns personal balances for Steam IDs, omitting unlinked IDs so the game keeps its own stored value. - Events dedupe by serverId+eventId so a retried delivery never double-charges, and the whole path never throws.
139 lines
5.1 KiB
TypeScript
139 lines
5.1 KiB
TypeScript
import { getPayload, Payload } from "payload";
|
|
import config from "@/payload.config";
|
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
import type { BankAccount } from "@/payload-types";
|
|
import { createAccount, ensurePersonalAccount, getTreasuryAccountId } from "@/lib/banking";
|
|
import { getBalancesBySteamId, parseMoneyDelta, processMoneyEvent } from "@/lib/arma-bridge/money";
|
|
|
|
let payload: Payload;
|
|
const RUN = `money-${Date.now().toString(36)}`;
|
|
const STEAM_ID = `${RUN}-steam-001`;
|
|
const UNLINKED = `${RUN}-steam-unlinked`;
|
|
|
|
async function balanceOf(id: number): Promise<number> {
|
|
const acc = (await payload.findByID({
|
|
collection: "bank-accounts",
|
|
id,
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
})) as unknown as BankAccount;
|
|
return acc.balance ?? 0;
|
|
}
|
|
|
|
let eventSeq = 0;
|
|
function moneyEvent(delta: number, reason: string, detail = "") {
|
|
eventSeq += 1;
|
|
return { eventId: `${RUN}:${eventSeq}`, payload: [STEAM_ID, delta, reason, detail] };
|
|
}
|
|
|
|
describe("Arma shop money sync", () => {
|
|
let userId: number;
|
|
let accountId: number;
|
|
let treasuryId: number;
|
|
let createdTreasury = false;
|
|
|
|
beforeAll(async () => {
|
|
const payloadConfig = await config;
|
|
payload = await getPayload({ config: payloadConfig });
|
|
|
|
const user = await payload.create({
|
|
collection: "users",
|
|
data: {
|
|
username: `${RUN}-player`,
|
|
discordUsername: `${RUN}-player-discord`,
|
|
displayName: `${RUN} Player`,
|
|
steamId: STEAM_ID,
|
|
password: "Test1234",
|
|
},
|
|
overrideAccess: true,
|
|
depth: 0,
|
|
});
|
|
userId = user.id;
|
|
|
|
const existingTreasury = await getTreasuryAccountId(payload);
|
|
if (existingTreasury) {
|
|
treasuryId = existingTreasury;
|
|
} else {
|
|
const treasury = await createAccount(payload, { name: `${RUN} Treasury`, accountType: "treasury" });
|
|
treasuryId = treasury.id;
|
|
createdTreasury = true;
|
|
}
|
|
});
|
|
|
|
afterAll(async () => {
|
|
if (accountId) {
|
|
await payload.delete({ collection: "bank-accounts", id: accountId, overrideAccess: true }).catch(() => {});
|
|
}
|
|
if (createdTreasury && treasuryId) {
|
|
await payload.delete({ collection: "bank-accounts", id: treasuryId, overrideAccess: true }).catch(() => {});
|
|
}
|
|
if (userId) {
|
|
const profiles = await payload.find({
|
|
collection: "profiles",
|
|
where: { user: { equals: userId } },
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
});
|
|
for (const profile of profiles.docs) {
|
|
await payload.delete({ collection: "profiles", id: profile.id, overrideAccess: true }).catch(() => {});
|
|
}
|
|
await payload.delete({ collection: "users", id: userId, overrideAccess: true }).catch(() => {});
|
|
}
|
|
});
|
|
|
|
describe("parseMoneyDelta", () => {
|
|
it("parses [steamId, delta, reason, detail] and rounds the delta", () => {
|
|
expect(parseMoneyDelta(["7656119", -250.4, "Purchase", "arifle_MX_F x1"])).toEqual({
|
|
steamId: "7656119",
|
|
delta: -250,
|
|
reason: "purchase",
|
|
detail: "arifle_MX_F x1",
|
|
});
|
|
});
|
|
|
|
it("rejects malformed or zero payloads", () => {
|
|
expect(parseMoneyDelta(null)).toBeNull();
|
|
expect(parseMoneyDelta(["7656119", 100])).toBeNull();
|
|
expect(parseMoneyDelta(["", 100, "reward"])).toBeNull();
|
|
expect(parseMoneyDelta(["7656119", 0, "reward"])).toBeNull();
|
|
expect(parseMoneyDelta(["7656119", "100", "reward"])).toBeNull();
|
|
expect(parseMoneyDelta(["7656119", 100, ""])).toBeNull();
|
|
});
|
|
});
|
|
|
|
it("creates reward money in the personal account and reports it by Steam ID", async () => {
|
|
await processMoneyEvent(payload, moneyEvent(500, "reward", "Dialogue test"));
|
|
const account = await ensurePersonalAccount(payload, userId);
|
|
accountId = account.id;
|
|
expect(await balanceOf(accountId)).toBe(500);
|
|
|
|
const balances = await getBalancesBySteamId(payload, [STEAM_ID, UNLINKED]);
|
|
expect(balances[STEAM_ID]).toBe(500);
|
|
expect(balances).not.toHaveProperty(UNLINKED);
|
|
});
|
|
|
|
it("pays purchases to the treasury and refunds them from it", async () => {
|
|
const treasuryBefore = await balanceOf(treasuryId);
|
|
|
|
await processMoneyEvent(payload, moneyEvent(-200, "purchase", "arifle_MX_F x1"));
|
|
expect(await balanceOf(accountId)).toBe(300);
|
|
expect(await balanceOf(treasuryId)).toBe(treasuryBefore + 200);
|
|
|
|
await processMoneyEvent(payload, moneyEvent(200, "refund", "arifle_MX_F x1"));
|
|
expect(await balanceOf(accountId)).toBe(500);
|
|
expect(await balanceOf(treasuryId)).toBe(treasuryBefore);
|
|
});
|
|
|
|
it("does not overdraw: a debit larger than the balance is skipped without throwing", async () => {
|
|
await expect(processMoneyEvent(payload, moneyEvent(-100000, "purchase"))).resolves.toBeUndefined();
|
|
expect(await balanceOf(accountId)).toBe(500);
|
|
});
|
|
|
|
it("skips unlinked players and malformed events without throwing", async () => {
|
|
await expect(
|
|
processMoneyEvent(payload, { eventId: `${RUN}:unlinked`, payload: [UNLINKED, 100, "reward"] }),
|
|
).resolves.toBeUndefined();
|
|
await expect(processMoneyEvent(payload, { eventId: `${RUN}:bad`, payload: "nope" })).resolves.toBeUndefined();
|
|
expect(await balanceOf(accountId)).toBe(500);
|
|
});
|
|
});
|