Track cash float spend since top-up

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.
This commit is contained in:
DEls 2026-10-30 00:05:47 +02:00
parent be890df613
commit 9ff13b3ce6
5 changed files with 34 additions and 12 deletions

View file

@ -24,7 +24,7 @@ const card = (over: Record<string, unknown> = {}) => ({
limit: 100000,
balance: 234000,
spentThisMonth: 60000,
lastSlip: null,
spentSinceTopup: 0,
low: false,
hasOpening: true,
...over,

View file

@ -27,7 +27,7 @@ const card = (over: Partial<CashFloatCard> = {}): CashFloatCard => ({
limit: 100000,
balance: 234000,
spentThisMonth: 60000,
lastSlip: { date: "2026-10-20T08:00:00Z", supplier: "Build Supply" },
spentSinceTopup: 25000,
low: false,
hasOpening: true,
...over,

View file

@ -116,7 +116,7 @@ export function CashFloatsView({ cards, users }: { cards: CashFloatCard[]; users
<div className="mgmt-table-scroll">
<table className="mgmt-table mgmt-cash-float-table">
<caption className="sr-only">Cash float cards, their holders and their balances</caption>
<thead><tr><th>Card</th><th>Holder</th><th>Assigned</th><th>Balance</th><th>Spent this month</th><th>Last slip</th><th>Status</th><th>Actions</th></tr></thead>
<thead><tr><th>Card</th><th>Holder</th><th>Assigned</th><th>Balance</th><th>Spent this month</th><th>Spent since top-up</th><th>Status</th><th>Actions</th></tr></thead>
<tbody>
{!cards.length && <tr><td colSpan={8} className="text-left text-muted-foreground">No cards yet. Add one to get started.</td></tr>}
{visible.map(card => (
@ -131,7 +131,7 @@ export function CashFloatsView({ cards, users }: { cards: CashFloatCard[]; users
<td>{card.assignedOn || "—"}</td>
<td className={card.low ? "font-semibold text-destructive" : ""}>{formatCents(card.balance)}</td>
<td>{formatCents(card.spentThisMonth)}</td>
<td className="text-left">{card.lastSlip ? `${card.lastSlip.date.slice(0, 10)}${card.lastSlip.supplier ? ` · ${card.lastSlip.supplier}` : ""}` : "—"}</td>
<td>{formatCents(card.spentSinceTopup)}</td>
<td>
{card.low
? <span className="flex items-center justify-end gap-1 font-semibold text-destructive"><TriangleAlert className="h-3.5 w-3.5" />Under the limit</span>

View file

@ -138,7 +138,7 @@ describe("loadCashFloatCards", () => {
expect(cards[1].hasOpening).toBe(true);
});
it("counts what was spent this month and finds the last slip", async () => {
it("counts what was spent this month and since the last top-up", async () => {
const { pb } = fakePb({
users: [holder()],
cash_float_log: [
@ -149,7 +149,22 @@ describe("loadCashFloatCards", () => {
});
const [card] = await loadCashFloatCards(pb, new Date("2026-10-24T00:00:00Z"));
expect(card.spentThisMonth).toBe(60000);
expect(card.lastSlip).toEqual({ date: "2026-10-20T08:00:00Z", supplier: "Build Supply" });
// 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 () => {

View file

@ -39,7 +39,8 @@ export interface CashFloatCard {
balance: number;
/** What has been spent off the card this month, in cents, positive. */
spentThisMonth: number;
lastSlip: { date: string; supplier: string } | null;
/** What has been spent since the last top-up (or the opening, if never topped up), in cents, positive. */
spentSinceTopup: number;
low: boolean;
/** A card with no opening row has not been set up yet. */
hasOpening: boolean;
@ -140,7 +141,7 @@ export async function cardHeldBy(pb: PocketBase, userId: string): Promise<{ card
* Every card, lowest balance first.
*
* The balance of every card comes from the one log read; the per-card
* figures (spent this month, the last slip) are picked out of the same rows.
* figures (spent this month, spent since the last top-up) are picked out of the same rows.
*/
export async function loadCashFloatCards(pb: PocketBase, now: Date = new Date()): Promise<CashFloatCard[]> {
const [holders, logRows] = await Promise.all([loadHolders(pb), loadLogRows(pb)]);
@ -157,9 +158,15 @@ export async function loadCashFloatCards(pb: PocketBase, now: Date = new Date())
const limit = lowLimit(holder);
const slips = cardRows.filter((row) => row.kind === "slip");
const thisMonth = slips.filter((row) => rowMonth(row) === month);
const lastSlip = slips
.slice()
.sort((a, b) => (b.occurred_on ?? "").localeCompare(a.occurred_on ?? ""))[0];
// The reference point is the latest top-up; a card never topped up counts from its opening.
const latest = (kind: string) =>
cardRows
.filter((row) => row.kind === kind)
.map((row) => row.occurred_on ?? "")
.sort()
.pop();
const since = latest("topup") ?? latest("opening") ?? "";
const sinceTopup = slips.filter((row) => (row.occurred_on ?? "") >= since);
return {
userId: holder.id,
holder: holderName(holder),
@ -169,7 +176,7 @@ export async function loadCashFloatCards(pb: PocketBase, now: Date = new Date())
limit,
balance,
spentThisMonth: thisMonth.reduce((total, row) => total + Math.abs(row.amount), 0),
lastSlip: lastSlip ? { date: lastSlip.occurred_on ?? "", supplier: lastSlip.supplier_name ?? "" } : null,
spentSinceTopup: sinceTopup.reduce((total, row) => total + Math.abs(row.amount), 0),
low: isLow(balance, limit),
hasOpening: cardRows.some((row) => row.kind === "opening"),
};