import { describe, expect, it } from "vitest"; import type PocketBase from "pocketbase"; import { cardBalances, cardHeldBy, CashFloatError, correctBalance, createCard, handoverCard, loadCardLog, loadCashFloatCards, loadCashFloatHolders, lowCardCount, removeCard, topUpCard, updateCard, } from "./cash-float-server"; type Row = Record; interface Fake { pb: PocketBase; calls: { collection: string; method: string; options?: unknown }[]; rows: Record; } /** * A PocketBase stand-in. Only the calls the cash-float code makes are * supported; anything else throws so a surprise read shows up as a failure * rather than as an empty result. */ function fakePb(seed: Record = {}): Fake { const rows: Record = JSON.parse(JSON.stringify(seed)); const calls: Fake["calls"] = []; let counter = 0; const collection = (name: string) => { const list = () => rows[name] ?? []; return { async getFullList(options: { filter?: string; fields?: string; sort?: string } = {}) { calls.push({ collection: name, method: "getFullList", options }); return list().map((row) => ({ ...row })); }, async getOne(id: string, options: { fields?: string } = {}) { calls.push({ collection: name, method: "getOne", options }); const found = list().find((row) => row.id === id); if (!found) throw Object.assign(new Error("not found"), { status: 404 }); return { ...found }; }, async create(payload: Row) { calls.push({ collection: name, method: "create", options: payload }); counter += 1; const created = { id: `${name}${counter}`, ...payload }; rows[name] = [...list(), created]; return created; }, async update(id: string, payload: Row) { calls.push({ collection: name, method: "update", options: { id, ...payload } }); const found = list().find((row) => row.id === id); if (!found) throw Object.assign(new Error("not found"), { status: 404 }); Object.assign(found, payload); return { ...found }; }, async delete(id: string) { calls.push({ collection: name, method: "delete", options: { id } }); rows[name] = list().filter((row) => row.id !== id); return true; }, }; }; const pb = { collection, createBatch: () => { const queued: { collection: string; id: string; payload: Row }[] = []; return { collection: (name: string) => ({ update: (id: string, payload: Row) => { queued.push({ collection: name, id, payload }); return queued.length; }, }), send: async () => queued.map((entry) => { calls.push({ collection: entry.collection, method: "update", options: { id: entry.id, ...entry.payload } }); const found = (rows[entry.collection] ?? []).find((row) => row.id === entry.id); if (found) Object.assign(found, entry.payload); return { status: 200 }; }), }; }, } as unknown as PocketBase; return { pb, calls, rows }; } const holder = (over: Row = {}): Row => ({ id: "u1", name: "Sipho", email: "sipho@example.test", is_active: true, float_card: true, float_card_number: "07", float_card_last_digits: "12345", float_low_limit: 1000, float_assigned_on: "2026-10-01", ...over, }); const log = (over: Row = {}): Row => ({ id: "l1", card_number: "07", kind: "slip", amount: -600, occurred_on: "2026-10-20T08:00:00Z", supplier_name: "Build Supply", ...over, }); describe("loadCashFloatCards", () => { it("works out every balance from the log, lowest first", async () => { const { pb } = fakePb({ users: [ holder({ id: "u1", float_card_number: "07", float_low_limit: 800 }), holder({ id: "u2", name: "Ana", float_card_number: "09", float_low_limit: 500 }), ], cash_float_log: [ log({ id: "l1", card_number: "07", kind: "opening", amount: 1500 }), log({ id: "l2", card_number: "07", amount: -600 }), log({ id: "l3", card_number: "09", kind: "opening", amount: 200 }), ], }); const cards = await loadCashFloatCards(pb, new Date("2026-10-24T00:00:00Z")); expect(cards.map((card) => [card.cardNumber, card.balance, card.low])).toEqual([ ["09", 20000, true], ["07", 90000, false], ]); expect(cards[1].holder).toBe("Sipho"); expect(cards[1].hasOpening).toBe(true); }); it("counts what was spent this month and since the last top-up", async () => { const { pb } = fakePb({ users: [holder()], cash_float_log: [ log({ id: "l1", kind: "opening", amount: 1500, occurred_on: "2026-10-01T08:00:00Z" }), log({ id: "l2", amount: -600, occurred_on: "2026-10-20T08:00:00Z", supplier_name: "Build Supply" }), log({ id: "l3", amount: -100, occurred_on: "2026-09-30T08:00:00Z", supplier_name: "Old Shop" }), ], }); const [card] = await loadCashFloatCards(pb, new Date("2026-10-24T00:00:00Z")); expect(card.spentThisMonth).toBe(60000); // Never topped up: counts from the opening on the 1st. expect(card.spentSinceTopup).toBe(60000); }); it("counts only the slips after the latest top-up", async () => { const { pb } = fakePb({ users: [holder()], cash_float_log: [ log({ id: "l1", kind: "opening", amount: 1500, occurred_on: "2026-10-01T08:00:00Z" }), log({ id: "l2", amount: -600, occurred_on: "2026-10-05T08:00:00Z" }), log({ id: "l3", kind: "topup", amount: 1000, occurred_on: "2026-10-10T08:00:00Z" }), log({ id: "l4", amount: -250, occurred_on: "2026-10-20T08:00:00Z" }), ], }); const [card] = await loadCashFloatCards(pb, new Date("2026-10-24T00:00:00Z")); expect(card.spentSinceTopup).toBe(25000); }); it("leaves unticked users out", async () => { const { pb } = fakePb({ users: [holder({ id: "u2", float_card: false })], cash_float_log: [], }); expect(await loadCashFloatCards(pb)).toEqual([]); }); it("reads the log once for every card", async () => { const { pb, calls } = fakePb({ users: [holder()], cash_float_log: [log()] }); await loadCashFloatCards(pb); expect(calls.filter((call) => call.collection === "cash_float_log" && call.method === "getFullList")).toHaveLength(1); }); it("treats a PocketBase without the migration as no cards", async () => { const pb = { collection: () => ({ getFullList: async () => { throw Object.assign(new Error("not found"), { status: 404 }); }, }), } as unknown as PocketBase; expect(await loadCashFloatCards(pb)).toEqual([]); }); }); describe("lowCardCount", () => { it("counts the cards under their limit", async () => { const { pb } = fakePb({ users: [ holder({ id: "u1", float_card_number: "07", float_low_limit: 800 }), holder({ id: "u2", float_card_number: "09", float_low_limit: 1000 }), holder({ id: "u3", float_card_number: "11", float_low_limit: 500 }), ], cash_float_log: [ log({ id: "l1", card_number: "07", kind: "opening", amount: 1500 }), log({ id: "l2", card_number: "09", kind: "opening", amount: 100 }), log({ id: "l3", card_number: "11", kind: "opening", amount: 900 }), ], }); expect(await lowCardCount(pb)).toBe(1); }); }); describe("cardBalances and cardHeldBy", () => { it("groups the balances by card", async () => { const { pb } = fakePb({ cash_float_log: [ log({ id: "l1", card_number: "07", kind: "opening", amount: 1500 }), log({ id: "l2", card_number: "07", amount: -600 }), log({ id: "l3", card_number: "09", kind: "opening", amount: 200 }), ], }); const balances = await cardBalances(pb); expect(balances.get("07")).toBe(90000); expect(balances.get("09")).toBe(20000); }); it("finds the card a user holds", async () => { const { pb } = fakePb({ users: [holder()] }); expect(await cardHeldBy(pb, "u1")).toEqual({ cardNumber: "07", lastDigits: "12345" }); }); it("reports no card for a user without one", async () => { const { pb } = fakePb({ users: [holder({ float_card: false })] }); expect(await cardHeldBy(pb, "u1")).toBeNull(); }); }); describe("loadCashFloatHolders", () => { it("gives the users screen a balance per holder", async () => { const { pb } = fakePb({ users: [holder()], cash_float_log: [log({ id: "l1", kind: "opening", amount: 1500 })], }); const [row] = await loadCashFloatHolders(pb); expect(row.cardNumber).toBe("07"); expect(row.balance).toBe(150000); expect(row.low).toBe(false); }); }); describe("loadCardLog", () => { it("asks for one card and the month", async () => { const { pb, calls } = fakePb({ cash_float_log: [log()] }); await loadCardLog(pb, "07", "2026-10"); const call = calls.find((entry) => entry.method === "getFullList"); expect((call?.options as { filter: string }).filter).toContain('card_number = "07"'); expect((call?.options as { filter: string }).filter).toContain("2026-10"); }); }); describe("createCard", () => { it("ticks the user and writes an opening row", async () => { const { pb, calls } = fakePb({ users: [holder({ float_card: false, float_card_number: "" })] }); const result = await createCard(pb, { userId: "u1", cardNumber: "07", lowLimit: 1000, assignedOn: "2026-10-01", openingCents: 150000 }); expect(result.openingWritten).toBe(true); const created = calls.find((call) => call.method === "create"); expect((created?.options as Row).kind).toBe("opening"); expect((created?.options as Row).amount).toBe(1500); const updated = calls.find((call) => call.method === "update" && call.collection === "users"); expect((updated?.options as Row).float_card).toBe(true); }); it("writes an R 0 opening row so an empty card can still take slips", async () => { const { pb, calls } = fakePb({ users: [holder({ float_card: false, float_card_number: "" })] }); const result = await createCard(pb, { userId: "u1", cardNumber: "07", openingCents: 0 }); expect(result.openingWritten).toBe(true); const created = calls.find((call) => call.method === "create"); expect((created?.options as Row).kind).toBe("opening"); expect((created?.options as Row).amount).toBe(0); }); it("does not open a card whose number already has rows", async () => { const { pb, calls } = fakePb({ users: [holder({ float_card: false, float_card_number: "" })], cash_float_log: [log({ id: "old", kind: "opening", amount: 1500 })], }); const result = await createCard(pb, { userId: "u1", cardNumber: "07", openingCents: 150000 }); expect(result.openingWritten).toBe(false); expect(calls.some((call) => call.method === "create")).toBe(false); }); it("refuses a number that is on another card", async () => { const { pb } = fakePb({ users: [holder({ id: "u1" }), holder({ id: "u2", float_card_number: "07" })] }); await expect(createCard(pb, { userId: "u1", cardNumber: "07" })).rejects.toThrow(CashFloatError); }); it("refuses a user who already holds a card", async () => { const { pb } = fakePb({ users: [holder()] }); await expect(createCard(pb, { userId: "u1", cardNumber: "12" })).rejects.toThrow(/already holds a card/); }); }); describe("updateCard", () => { it("moves the history when the number changes", async () => { const { pb, calls } = fakePb({ users: [holder()], cash_float_log: [log({ id: "l1", card_number: "07" })], }); await updateCard(pb, "u1", { cardNumber: "12", rewriteHistory: true }); const moved = calls.find((call) => call.collection === "cash_float_log" && call.method === "update"); expect((moved?.options as Row).card_number).toBe("12"); }); it("refuses a number that is on another card", async () => { const { pb } = fakePb({ users: [holder({ id: "u1" }), holder({ id: "u2", float_card_number: "12" })] }); await expect(updateCard(pb, "u1", { cardNumber: "12" })).rejects.toThrow(/already held by someone else/); }); }); describe("removeCard", () => { it("unticks the user and keeps the log", async () => { const { pb, rows } = fakePb({ users: [holder()], cash_float_log: [log()] }); await removeCard(pb, "u1"); expect(pb.collection("users")).toBeTruthy(); expect(rows.users[0].float_card).toBe(false); expect(rows.cash_float_log).toHaveLength(1); }); }); describe("handoverCard", () => { it("moves the card and writes a handover row", async () => { const { pb, calls, rows } = fakePb({ users: [holder({ id: "u1" }), holder({ id: "u2", name: "Ana", float_card: false, float_card_number: "" })], cash_float_log: [], }); await handoverCard(pb, "u1", { toUserId: "u2", assignedOn: "2026-10-24" }); expect(rows.users[0].float_card).toBe(false); expect(rows.users[1].float_card).toBe(true); expect(rows.users[1].float_card_number).toBe("07"); // The physical card moves, so its last digits go with it. expect(rows.users[1].float_card_last_digits).toBe("12345"); expect(rows.users[0].float_card_last_digits).toBe(""); const created = calls.find((call) => call.method === "create"); expect((created?.options as Row).kind).toBe("handover"); expect((created?.options as Row).amount).toBe(0); expect((created?.options as Row).comment).toBe("From Sipho to Ana"); }); it("refuses a holder who already has a card", async () => { const { pb } = fakePb({ users: [holder({ id: "u1" }), holder({ id: "u2" })] }); await expect(handoverCard(pb, "u1", { toUserId: "u2" })).rejects.toThrow(/already holds a card/); }); }); describe("topUpCard", () => { it("writes a positive top-up row", async () => { const { pb, calls } = fakePb({ users: [holder()], cash_float_log: [] }); await topUpCard(pb, "u1", { amountCents: 200000, comment: "Office" }); const created = calls.find((call) => call.method === "create"); expect((created?.options as Row).kind).toBe("topup"); expect((created?.options as Row).amount).toBe(2000); }); it("refuses an amount of zero", async () => { const { pb } = fakePb({ users: [holder()], cash_float_log: [] }); await expect(topUpCard(pb, "u1", { amountCents: 0 })).rejects.toThrow(CashFloatError); }); }); describe("correctBalance", () => { it("writes the difference as a correction row", async () => { const { pb, calls } = fakePb({ users: [holder()], cash_float_log: [log({ id: "l1", kind: "opening", amount: 1500 })], }); const result = await correctBalance(pb, "u1", { actualBalanceCents: 100000 }); expect(result.differenceCents).toBe(-50000); const created = calls.find((call) => call.method === "create"); expect((created?.options as Row).kind).toBe("correction"); expect((created?.options as Row).amount).toBe(-500); }); it("refuses a correction that changes nothing", async () => { const { pb } = fakePb({ users: [holder()], cash_float_log: [log({ id: "l1", kind: "opening", amount: 1500 })], }); await expect(correctBalance(pb, "u1", { actualBalanceCents: 150000 })).rejects.toThrow(/already the balance/); }); });