From 7446388bfa4f6b3a6c909dbe4ad64f1033bbbf77 Mon Sep 17 00:00:00 2001 From: joshua Date: Wed, 21 Oct 2026 13:29:17 +0200 Subject: [PATCH] Take cash deposit fees out of the counted cash A bank charge on a cash deposit comes out of cash that was counted and banked, so cash on hand is now the count less what reached the bank and any bank-charge write-off. Before, a deposit fee showed as money "more than expected". Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + backend/src/utils/cashupUtils.js | 15 +++++++++------ backend/tests/cashupSettlement.test.js | 18 ++++++++++++++++++ .../app/dashboard/admin/cashup/[id]/page.tsx | 10 ++++++---- .../content/help/dashboard-admin-cashup.tsx | 2 +- 5 files changed, 35 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c8f184..94d6308 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ Run `npm run prisma:deploy` in the backend when deploying. This release adds a m ### Added - Cashups now record where each payment method's money ended up. For cash, card, EFT and other, the expected takings are split into what's **in the bank**, **cash on hand**, and **pending** (on the card machine or Yoco report, or with a proof of payment), with a reference for the pending amount. Card fees, bank charges, cash shortfalls and other losses are **written off** with a reason. Each method shows what's still **unaccounted**. Closing with an unaccounted amount is allowed after a warning, and the amount comes off profit, as variance did before. +- When staff have counted the cash, cash on hand is worked out for you: the count, less what reached the bank and any deposit fee written off as a bank charge. - Pending money on a closed event can be marked **received** or **written off** later from the cashup screen, without reopening the event. Each one is logged with who did it and when. - Reopening an event starts the next cashup from the last one, with any pending money settled since folded in. - Write-offs show as **Bank & card charges** on the cashup screen and in the Cashup, Finance and Profit reports. The Cashup report has new In bank, On hand, Pending, Written off and Unaccounted columns. diff --git a/backend/src/utils/cashupUtils.js b/backend/src/utils/cashupUtils.js index b50b087..d9313db 100644 --- a/backend/src/utils/cashupUtils.js +++ b/backend/src/utils/cashupUtils.js @@ -31,7 +31,7 @@ function amountOrNull(value, label) { * that's gone (card fees, bank charges, a cash shortfall). Whatever is left — * expected − actual − write-offs — is unaccounted for. Cash has no pending; when staff * counted it (per-person counts), the count is all the cash there is, so whatever wasn't - * deposited is on hand. + * deposited (or taken by the bank as a deposit fee) is on hand. * * @param {object} p * @param {string} p.method - 'cash' | 'card' | 'eft' | 'other' @@ -44,11 +44,6 @@ function settleLine({ method, expected, counted = null, input = {} }) { let onHandAmount = method === 'cash' ? amountOrNull(input.onHandAmount, 'Cash on hand') : null; const pendingAmount = method === 'cash' ? null : amountOrNull(input.pendingAmount, 'Pending'); - if (method === 'cash' && counted !== null && counted !== undefined) { - if ((bankAmount || 0) > counted + 0.005) throw new Error('More cash was deposited than was counted'); - onHandAmount = cents(counted - (bankAmount || 0)); - } - const writeOffs = (Array.isArray(input.writeOffs) ? input.writeOffs : []) .filter(w => w && w.amount !== undefined && w.amount !== null && w.amount !== '') .map(w => { @@ -59,6 +54,14 @@ function settleLine({ method, expected, counted = null, input = {} }) { }); const writeOffTotal = cents(sum(writeOffs)); + if (method === 'cash' && counted !== null && counted !== undefined) { + // A deposit fee comes out of cash that was counted and banked, so the count is what + // reached the bank + the fee + what's still on hand. + const depositFees = cents(sum(writeOffs.filter(w => w.category === 'bank_charges'))); + if ((bankAmount || 0) + depositFees > counted + 0.005) throw new Error('More cash was deposited than was counted'); + onHandAmount = cents(counted - (bankAmount || 0) - depositFees); + } + const parts = [bankAmount, onHandAmount, pendingAmount]; const entered = parts.some(v => v !== null) || writeOffs.length > 0; const actual = entered ? cents(parts.reduce((s, v) => s + (v || 0), 0)) : null; diff --git a/backend/tests/cashupSettlement.test.js b/backend/tests/cashupSettlement.test.js index 59e26d6..6d6dd24 100644 --- a/backend/tests/cashupSettlement.test.js +++ b/backend/tests/cashupSettlement.test.js @@ -40,6 +40,24 @@ describe('settleLine', () => { expect(s.unaccounted).toBe(0); }); + test('a deposit fee comes out of the counted cash, not on top of it', () => { + // Counted R1000; the bank credited R941.50 after a R58.50 deposit fee; nothing left on hand. + const s = settleLine({ method: 'cash', expected: 1000, counted: 1000, input: { bankAmount: 941.5, writeOffs: [{ category: 'bank_charges', amount: 58.5 }] } }); + expect(s.onHandAmount).toBe(0); + expect(s.actual).toBe(941.5); + expect(s.unaccounted).toBe(0); + }); + + test('a cash shortfall write-off does not change what is on hand', () => { + const s = settleLine({ method: 'cash', expected: 1000, counted: 980, input: { bankAmount: 480, writeOffs: [{ category: 'cash_shortfall', amount: 20 }] } }); + expect(s.onHandAmount).toBe(500); + expect(s.unaccounted).toBe(0); + }); + + test('banking more than was counted, counting the deposit fee, is rejected', () => { + expect(() => settleLine({ method: 'cash', expected: 1000, counted: 1000, input: { bankAmount: 1000, writeOffs: [{ category: 'bank_charges', amount: 58.5 }] } })).toThrow('More cash was deposited'); + }); + test('cash never has a pending amount', () => { const s = settleLine({ method: 'cash', expected: 500, input: { onHandAmount: 500, pendingAmount: 200 } }); expect(s.pendingAmount).toBeNull(); diff --git a/frontend/src/app/dashboard/admin/cashup/[id]/page.tsx b/frontend/src/app/dashboard/admin/cashup/[id]/page.tsx index 6d584b3..840406c 100644 --- a/frontend/src/app/dashboard/admin/cashup/[id]/page.tsx +++ b/frontend/src/app/dashboard/admin/cashup/[id]/page.tsx @@ -664,7 +664,7 @@ const WRITE_OFF_LABELS: Record = { const WRITE_OFF_CATEGORIES = Object.keys(WRITE_OFF_LABELS) as CashupWriteOffCategory[]; const DEFAULT_WRITE_OFF: Record = { cash: "cash_shortfall", card: "card_fees", eft: "bank_charges", other: "other" }; const BANK_HELP: Record = { - cash: "Deposited into the bank", + cash: "What reached the bank, after any deposit fee", card: "Settled into the bank account", eft: "Showing on the bank statement", other: "Received in the bank", @@ -684,9 +684,11 @@ const amountText = (v: string | number | null | undefined) => (v === null || v = function settle(method: CashupMethod, expected: number, counted: number | null, input: SettlementInput) { const num = (v: string) => (v.trim() === "" || isNaN(Number(v)) ? null : Number(v)); const bank = num(input.bankAmount); - const onHand = method === "cash" ? (counted !== null ? round2(counted - (bank || 0)) : num(input.onHandAmount)) : null; - const pending = method === "cash" ? null : num(input.pendingAmount); const writeOffTotal = round2(input.writeOffs.reduce((s, w) => s + (num(w.amount) || 0), 0)); + // A deposit fee comes out of the counted cash that was banked. + const depositFees = round2(input.writeOffs.filter(w => w.category === "bank_charges").reduce((s, w) => s + (num(w.amount) || 0), 0)); + const onHand = method === "cash" ? (counted !== null ? round2(counted - (bank || 0) - depositFees) : num(input.onHandAmount)) : null; + const pending = method === "cash" ? null : num(input.pendingAmount); const entered = [bank, onHand, pending].some(v => v !== null) || input.writeOffs.some(w => num(w.amount) !== null); const actual = entered ? round2((bank || 0) + (onHand || 0) + (pending || 0)) : null; return { bank, onHand, pending, writeOffTotal, actual, unaccounted: actual === null ? null : round2(expected - actual - writeOffTotal) }; @@ -741,7 +743,7 @@ function SettlementCard({ method, data, counted, input, onChange }: { set("onHandAmount", e.target.value)} /> )}

- {counted !== null ? `Counted ${money(counted)}, less what was deposited` : "Not deposited yet. Enter staff cash counts below to work this out."} + {counted !== null ? `Counted ${money(counted)}, less what was deposited and any deposit fee` : "Not deposited yet. Enter staff cash counts below to work this out."}

) : ( diff --git a/frontend/src/content/help/dashboard-admin-cashup.tsx b/frontend/src/content/help/dashboard-admin-cashup.tsx index 1cad907..44f6457 100644 --- a/frontend/src/content/help/dashboard-admin-cashup.tsx +++ b/frontend/src/content/help/dashboard-admin-cashup.tsx @@ -53,7 +53,7 @@ export const dashboardAdminCashupDetailHelpContent: HelpContent = {

Each method shows what's still unaccounted. You can close with an unaccounted amount, but you'll be warned and it comes off profit.

- Counted cash comes from the staff cash-count panel below, where each staff member enters their physical cash by denomination. When counts are entered, cash on hand is the count less what was deposited. + Counted cash comes from the staff cash-count panel below, where each staff member enters their physical cash by denomination. When counts are entered, cash on hand is the count less what reached the bank and any deposit fee (written off as a bank charge). Once the event is closed, pending money shows as outstanding. When it reaches the bank, use Mark received; if it never will, use Write off. You don't need to reopen the event.