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
+15 -1
View File
@@ -34,11 +34,12 @@ export async function PATCH(
}
const body = await req.json();
const { category, merchant_normalized, notes, transaction_type, my_share_percent, description, amount, transaction_date, trip_id } = body as {
const { category, merchant_normalized, notes, transaction_type, my_share_percent, description, amount, transaction_date, trip_id, payment_method } = body as {
category?: string;
merchant_normalized?: string;
notes?: string;
transaction_type?: string;
payment_method?: string | null;
my_share_percent?: number | null;
description?: string;
amount?: number;
@@ -84,6 +85,19 @@ export async function PATCH(
);
}
// payment_method is a property of the transaction itself, not a user override
// of extracted data, so it lives on the transactions table.
if (payment_method !== undefined) {
const VALID_METHODS = ["card", "cash", "bank_transfer", "other"];
if (payment_method !== null && !VALID_METHODS.includes(payment_method)) {
return NextResponse.json({ error: "Invalid payment_method" }, { status: 400 });
}
await queryRaw(
`UPDATE transactions SET payment_method = $1 WHERE id = $2`,
[payment_method, transactionId]
);
}
// category/merchant/notes/my_share_percent/trip_id go through the overrides table
const hasOverride = category !== undefined || merchant_normalized !== undefined || notes !== undefined || my_share_percent !== undefined || trip_id !== undefined;
if (!hasOverride) {
+4 -2
View File
@@ -42,6 +42,7 @@ export async function POST(req: NextRequest) {
transaction_type?: string;
merchant_normalized?: string;
category?: string;
payment_method?: string;
splits?: { participant_id: number; share_percent: number }[];
};
@@ -51,8 +52,8 @@ export async function POST(req: NextRequest) {
// Insert manual transaction with no statement (statement_id = NULL, owner_id set directly)
const txRows = await queryRaw<{ id: number }>(
`INSERT INTO transactions (statement_id, owner_id, transaction_date, description, amount, transaction_type, merchant_normalized, category, row_index)
VALUES (NULL, $1, $2, $3, $4, $5, $6, $7, (
`INSERT INTO transactions (statement_id, owner_id, transaction_date, description, amount, transaction_type, merchant_normalized, category, payment_method, row_index)
VALUES (NULL, $1, $2, $3, $4, $5, $6, $7, $8, (
SELECT COALESCE(MAX(row_index), -1) + 1 FROM transactions WHERE owner_id = $1 AND statement_id IS NULL
))
RETURNING id`,
@@ -64,6 +65,7 @@ export async function POST(req: NextRequest) {
body.transaction_type || "debit",
body.merchant_normalized || null,
body.category || null,
body.payment_method || null,
]
);
const transactionId = txRows[0].id;