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 <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,7 @@ Run `npm run prisma:deploy` in the backend when deploying. This release adds a m
|
|||||||
### Added
|
### 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ function amountOrNull(value, label) {
|
|||||||
* that's gone (card fees, bank charges, a cash shortfall). Whatever is left —
|
* 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
|
* 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
|
* 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 {object} p
|
||||||
* @param {string} p.method - 'cash' | 'card' | 'eft' | 'other'
|
* @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;
|
let onHandAmount = method === 'cash' ? amountOrNull(input.onHandAmount, 'Cash on hand') : null;
|
||||||
const pendingAmount = method === 'cash' ? null : amountOrNull(input.pendingAmount, 'Pending');
|
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 : [])
|
const writeOffs = (Array.isArray(input.writeOffs) ? input.writeOffs : [])
|
||||||
.filter(w => w && w.amount !== undefined && w.amount !== null && w.amount !== '')
|
.filter(w => w && w.amount !== undefined && w.amount !== null && w.amount !== '')
|
||||||
.map(w => {
|
.map(w => {
|
||||||
@@ -59,6 +54,14 @@ function settleLine({ method, expected, counted = null, input = {} }) {
|
|||||||
});
|
});
|
||||||
const writeOffTotal = cents(sum(writeOffs));
|
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 parts = [bankAmount, onHandAmount, pendingAmount];
|
||||||
const entered = parts.some(v => v !== null) || writeOffs.length > 0;
|
const entered = parts.some(v => v !== null) || writeOffs.length > 0;
|
||||||
const actual = entered ? cents(parts.reduce((s, v) => s + (v || 0), 0)) : null;
|
const actual = entered ? cents(parts.reduce((s, v) => s + (v || 0), 0)) : null;
|
||||||
|
|||||||
@@ -40,6 +40,24 @@ describe('settleLine', () => {
|
|||||||
expect(s.unaccounted).toBe(0);
|
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', () => {
|
test('cash never has a pending amount', () => {
|
||||||
const s = settleLine({ method: 'cash', expected: 500, input: { onHandAmount: 500, pendingAmount: 200 } });
|
const s = settleLine({ method: 'cash', expected: 500, input: { onHandAmount: 500, pendingAmount: 200 } });
|
||||||
expect(s.pendingAmount).toBeNull();
|
expect(s.pendingAmount).toBeNull();
|
||||||
|
|||||||
@@ -664,7 +664,7 @@ const WRITE_OFF_LABELS: Record<CashupWriteOffCategory, string> = {
|
|||||||
const WRITE_OFF_CATEGORIES = Object.keys(WRITE_OFF_LABELS) as CashupWriteOffCategory[];
|
const WRITE_OFF_CATEGORIES = Object.keys(WRITE_OFF_LABELS) as CashupWriteOffCategory[];
|
||||||
const DEFAULT_WRITE_OFF: Record<CashupMethod, CashupWriteOffCategory> = { cash: "cash_shortfall", card: "card_fees", eft: "bank_charges", other: "other" };
|
const DEFAULT_WRITE_OFF: Record<CashupMethod, CashupWriteOffCategory> = { cash: "cash_shortfall", card: "card_fees", eft: "bank_charges", other: "other" };
|
||||||
const BANK_HELP: Record<CashupMethod, string> = {
|
const BANK_HELP: Record<CashupMethod, string> = {
|
||||||
cash: "Deposited into the bank",
|
cash: "What reached the bank, after any deposit fee",
|
||||||
card: "Settled into the bank account",
|
card: "Settled into the bank account",
|
||||||
eft: "Showing on the bank statement",
|
eft: "Showing on the bank statement",
|
||||||
other: "Received in the bank",
|
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) {
|
function settle(method: CashupMethod, expected: number, counted: number | null, input: SettlementInput) {
|
||||||
const num = (v: string) => (v.trim() === "" || isNaN(Number(v)) ? null : Number(v));
|
const num = (v: string) => (v.trim() === "" || isNaN(Number(v)) ? null : Number(v));
|
||||||
const bank = num(input.bankAmount);
|
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));
|
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 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;
|
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) };
|
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 }: {
|
|||||||
<input type="number" step="0.01" min={0} className="w-full border rounded px-2 py-1.5 text-sm" value={input.onHandAmount} onChange={e => set("onHandAmount", e.target.value)} />
|
<input type="number" step="0.01" min={0} className="w-full border rounded px-2 py-1.5 text-sm" value={input.onHandAmount} onChange={e => set("onHandAmount", e.target.value)} />
|
||||||
)}
|
)}
|
||||||
<p className="text-[10px] text-gray-400 mt-0.5">
|
<p className="text-[10px] text-gray-400 mt-0.5">
|
||||||
{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."}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export const dashboardAdminCashupDetailHelpContent: HelpContent = {
|
|||||||
<p>Each method shows what's still <strong>unaccounted</strong>. You can close with an unaccounted amount, but you'll be warned and it comes off profit.</p>
|
<p>Each method shows what's still <strong>unaccounted</strong>. You can close with an unaccounted amount, but you'll be warned and it comes off profit.</p>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<GuideItem icon={ClipboardCheck} title="Cash accountability" tone="amber">
|
<GuideItem icon={ClipboardCheck} title="Cash accountability" tone="amber">
|
||||||
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).
|
||||||
</GuideItem>
|
</GuideItem>
|
||||||
<GuideItem icon={Wallet} title="Pending money after closing" tone="blue">
|
<GuideItem icon={Wallet} title="Pending money after closing" tone="blue">
|
||||||
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.
|
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.
|
||||||
|
|||||||
Reference in New Issue
Block a user