Replace the last-slip field with cumulative spending since the latest top-up, falling back to the opening when no top-up exists. Update the management table to display the new amount and cover the calculation with server and UI tests.
391 lines
15 KiB
TypeScript
391 lines
15 KiB
TypeScript
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<string, unknown>;
|
|
|
|
interface Fake {
|
|
pb: PocketBase;
|
|
calls: { collection: string; method: string; options?: unknown }[];
|
|
rows: Record<string, Row[]>;
|
|
}
|
|
|
|
/**
|
|
* 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<string, Row[]> = {}): Fake {
|
|
const rows: Record<string, Row[]> = 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/);
|
|
});
|
|
});
|