feat(cash): mark how a transaction was paid, exclude cash from reconciliation
ci / lint-test (push) Successful in 41s
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:
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -35,6 +35,8 @@ export function AddTransactionModal({
|
||||
const [type, setType] = useState(prefill?.transaction_type ?? "debit");
|
||||
const [merchant, setMerchant] = useState(prefill?.merchant_normalized ?? "");
|
||||
const [category, setCategory] = useState(prefill?.category ?? "");
|
||||
// Cash is excluded from reconciliation — it will never appear on a statement.
|
||||
const [paymentMethod, setPaymentMethod] = useState("");
|
||||
const [selectedTagIds, setSelectedTagIds] = useState<number[]>([]);
|
||||
const [splits, setSplits] = useState<{ participant_id: number; share_percent: number }[]>(
|
||||
prefill?.splits ?? []
|
||||
@@ -68,6 +70,7 @@ export function AddTransactionModal({
|
||||
transaction_type: type,
|
||||
merchant_normalized: merchant || undefined,
|
||||
category: category || undefined,
|
||||
payment_method: paymentMethod || undefined,
|
||||
splits: splits.length ? splits : undefined,
|
||||
});
|
||||
if (selectedTagIds.length && result?.id) {
|
||||
@@ -159,6 +162,26 @@ export function AddTransactionModal({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-zinc-500 mb-1">Paid by</label>
|
||||
<select
|
||||
value={paymentMethod}
|
||||
onChange={(e) => setPaymentMethod(e.target.value)}
|
||||
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
|
||||
>
|
||||
<option value="">— unknown —</option>
|
||||
<option value="card">Card</option>
|
||||
<option value="cash">Cash</option>
|
||||
<option value="bank_transfer">Bank transfer</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
{paymentMethod === "cash" && (
|
||||
<p className="text-[11px] text-zinc-500 mt-1">
|
||||
Cash won't be offered for reconciliation — it never appears on a statement.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-zinc-500 mb-1">Merchant (optional)</label>
|
||||
<input
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user