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
@@ -0,0 +1,35 @@
-- How a transaction was paid for, so cash can be told apart from everything else.
--
-- The problem this solves: getPendingReconciliations treats every unreconciled
-- manual transaction as awaiting a matching statement row. A cash purchase never
-- appears on a statement, so it sits in the queue forever and is offered matches
-- within 3 days and 1% on amount. Accepting one is silently destructive --
-- reconciled manual rows are excluded from every query, so the cash spend
-- disappears while the card transaction it matched claims to be that same spend.
--
-- NULL means unknown, which is treated as reconcilable -- the existing behaviour
-- for every row already in the table.
--
-- Only 'cash' is excluded from reconciliation. A bank transfer DOES appear on a
-- statement now that transaction accounts are being imported, so it stays a
-- reconciliation candidate.
ALTER TABLE transactions
ADD COLUMN IF NOT EXISTS payment_method TEXT;
ALTER TABLE transactions DROP CONSTRAINT IF EXISTS transactions_payment_method_check;
ALTER TABLE transactions ADD CONSTRAINT transactions_payment_method_check
CHECK (payment_method IS NULL
OR payment_method IN ('card', 'cash', 'bank_transfer', 'other'));
-- Partial index: the reconciliation query filters on this, and cash is expected
-- to stay a small minority of rows.
CREATE INDEX IF NOT EXISTS idx_transactions_payment_method
ON transactions (payment_method) WHERE payment_method IS NOT NULL;
-- Backfill the one row that is unambiguously cash. Statement-linked rows are
-- left NULL: they came from a statement, so by definition they are not cash.
UPDATE transactions
SET payment_method = 'cash'
WHERE statement_id IS NULL
AND description ILIKE '%cash transaction%';
+1
View File
@@ -175,6 +175,7 @@ model transactions {
row_index Int?
merchant_normalized String?
amount_aud Decimal? @db.Decimal(12, 2)
payment_method String? // card | cash | bank_transfer | other; NULL = unknown (migration 0016)
owner_id Int?
reconciled_with_id Int?
principal_amount Decimal? @db.Decimal(12, 2)