feat(cash): mark how a transaction was paid, exclude cash from reconciliation
ci / lint-test (push) Successful in 41s

getPendingReconciliations treated every unreconciled manual transaction as
awaiting a matching statement row. Cash never appears on a statement, so a cash
entry sat in the queue indefinitely being offered matches within 3 days and 1%
on amount - and accepting one is silently destructive: reconciled manual rows
are filtered out of every query, so the cash spend disappears while the card
transaction it matched claims to be that same spend.

Migration 0016 adds transactions.payment_method (card | cash | bank_transfer |
other, NULL = unknown) with a CHECK constraint and a partial index. The notCash()
fragment excludes cash from both halves of the reconciliation query - the pending
list and the candidate match subquery, which aliases the manual row as m.

Only cash is excluded. Bank transfers do appear on a statement now that
transaction accounts are imported, and NULL means unknown, so both stay
candidates and every pre-existing row behaves exactly as before.

ATM withdrawals deliberately stay categorised as spend rather than transfers.
Treating them as transfers is only correct if every cash purchase is logged;
with partial logging it silently deletes the unlogged remainder from spend.
This commit is contained in:
2026-07-26 14:38:47 +10:00
parent d6b4ec84f6
commit 030490efa3
13 changed files with 318 additions and 4 deletions
+2
View File
@@ -104,6 +104,7 @@ export function useCreateTransaction() {
transaction_type?: string;
merchant_normalized?: string;
category?: string;
payment_method?: string;
splits?: { participant_id: number; share_percent: number }[];
}) => {
const res = await fetch("/api/transactions", {
@@ -140,6 +141,7 @@ export function useUpdateTransaction() {
amount?: number;
transaction_date?: string;
trip_id?: number | null;
payment_method?: string | null;
}) => {
const res = await fetch(`/api/transactions/${id}`, {
method: "PATCH",
+21
View File
@@ -25,6 +25,9 @@ export interface TransactionRow {
// loan repayment split — set only when the lender itemises it (migration 0014)
principal_amount: number | null;
interest_amount: number | null;
// How it was paid (migration 0016). NULL = unknown, treated as reconcilable.
// 'cash' is excluded from reconciliation — see notCash().
payment_method: string | null;
// override fields
category_override: string | null;
merchant_override: string | null;
@@ -517,6 +520,22 @@ export async function batchInsertCSVTransactions(
return txIds.length;
}
/**
* Excludes cash from reconciliation.
*
* Cash never appears on a statement, so a cash transaction would sit in the
* queue forever being offered matches within 3 days and 1% on amount. Accepting
* one is silently destructive: reconciled manual rows are filtered out of every
* query, so the cash spend vanishes while the card transaction it matched
* claims to be that same spend.
*
* Only cash is excluded. Bank transfers do appear on a statement now that
* transaction accounts are imported, and NULL means unknown — both stay
* candidates, which preserves the behaviour of every pre-existing row.
*/
export const notCash = (alias = "t") =>
`(${alias}.payment_method IS NULL OR ${alias}.payment_method <> 'cash')`;
export interface PotentialMatch {
id: number;
transaction_date: string;
@@ -559,6 +578,7 @@ export async function getPendingReconciliations(ownerId: number): Promise<Manual
WHERE ts.transaction_id = t.id
) txn_splits ON true
WHERE t.statement_id IS NULL AND t.owner_id = $1 AND t.reconciled_with_id IS NULL
AND ${notCash("t")}
ORDER BY t.transaction_date DESC, t.row_index ASC`,
[ownerId]
);
@@ -600,6 +620,7 @@ export async function getPendingReconciliations(ownerId: number): Promise<Manual
WHERE m.statement_id IS NULL
AND m.owner_id = $1
AND m.reconciled_with_id IS NULL
AND ${notCash("m")}
AND COALESCE(t.owner_id, s.owner_id) = $1
AND NOT EXISTS (
SELECT 1 FROM transactions mt WHERE mt.reconciled_with_id = t.id