From dd11019fdf37bdd10a8d1b36ce017af53e2ea211 Mon Sep 17 00:00:00 2001 From: siddharthd Date: Tue, 10 Mar 2026 00:24:42 +1100 Subject: [PATCH] fix(transactions): editable transaction type, fee/interest counted as spend, fees category MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TypeBadge is now clickable — opens inline select to change debit/credit/fee/interest/etc. - PATCH /api/transactions/[id] now accepts transaction_type, updates transactions table directly - Analytics monthly query includes fee and interest types as spend (not just debit) - fee and interest amounts show red in transaction list (same as debit) - Add fees category to taxonomy --- src/app/api/analytics/monthly/route.ts | 2 +- src/app/api/transactions/[id]/route.ts | 23 +++++++++- src/app/transactions/page.tsx | 61 +++++++++++++++++++++----- src/lib/categories.ts | 1 + src/lib/hooks.ts | 1 + 5 files changed, 74 insertions(+), 14 deletions(-) diff --git a/src/app/api/analytics/monthly/route.ts b/src/app/api/analytics/monthly/route.ts index f72943d..d58886c 100644 --- a/src/app/api/analytics/monthly/route.ts +++ b/src/app/api/analytics/monthly/route.ts @@ -38,7 +38,7 @@ export async function GET(req: NextRequest) { LEFT JOIN transaction_splits ts ON ts.transaction_id = t.id AND ts.participant_id = $1 JOIN statements s ON s.id = t.statement_id WHERE s.owner_id = $1 - AND t.transaction_type = 'debit' + AND t.transaction_type IN ('debit', 'fee', 'interest') AND COALESCE(o.category_override, t.category) NOT IN ('transfers', 'investment') AND t.transaction_date >= $2 AND t.transaction_date < $3 diff --git a/src/app/api/transactions/[id]/route.ts b/src/app/api/transactions/[id]/route.ts index 7570dca..9e3e534 100644 --- a/src/app/api/transactions/[id]/route.ts +++ b/src/app/api/transactions/[id]/route.ts @@ -1,6 +1,9 @@ import { NextRequest, NextResponse } from "next/server"; import { getTransactionById } from "@/lib/queries"; import { prisma } from "@/lib/db"; +import { queryRaw } from "@/lib/db"; + +const VALID_TYPES = ["debit", "credit", "payment", "refund", "fee", "interest", "transfer"]; export async function GET( _req: NextRequest, @@ -20,12 +23,30 @@ export async function PATCH( const transactionId = Number(id); const body = await req.json(); - const { category, merchant_normalized, notes } = body as { + const { category, merchant_normalized, notes, transaction_type } = body as { category?: string; merchant_normalized?: string; notes?: string; + transaction_type?: string; }; + // transaction_type is a direct correction on the transactions table + if (transaction_type !== undefined) { + if (!VALID_TYPES.includes(transaction_type)) { + return NextResponse.json({ error: "Invalid transaction_type" }, { status: 400 }); + } + await queryRaw( + `UPDATE transactions SET transaction_type = $1 WHERE id = $2`, + [transaction_type, transactionId] + ); + } + + // category/merchant/notes go through the overrides table + const hasOverride = category !== undefined || merchant_normalized !== undefined || notes !== undefined; + if (!hasOverride) { + return NextResponse.json({ ok: true }); + } + const data: Record = { updated_at: new Date() }; if (category !== undefined) data.category_override = category; if (merchant_normalized !== undefined) data.merchant_normalized = merchant_normalized; diff --git a/src/app/transactions/page.tsx b/src/app/transactions/page.tsx index 7c3fba4..aacc346 100644 --- a/src/app/transactions/page.tsx +++ b/src/app/transactions/page.tsx @@ -15,30 +15,62 @@ function formatDate(d: string) { }); } +const SPEND_TYPES = new Set(["debit", "fee", "interest"]); + function formatAmount(amount: number, type: string) { const formatted = new Intl.NumberFormat("en-AU", { style: "currency", currency: "AUD", }).format(amount); - return type === "debit" ? formatted : `+${formatted}`; + return SPEND_TYPES.has(type) ? formatted : `+${formatted}`; } +const TYPE_COLORS: Record = { + debit: "bg-red-900/30 text-red-400", + credit: "bg-green-900/30 text-green-400", + payment: "bg-blue-900/30 text-blue-400", + refund: "bg-emerald-900/30 text-emerald-400", + fee: "bg-yellow-900/30 text-yellow-400", + interest: "bg-orange-900/30 text-orange-400", + transfer: "bg-zinc-800 text-zinc-400", +}; + +const TYPE_OPTIONS = [ + "debit", "credit", "payment", "refund", "fee", "interest", "transfer", +].map((t) => ({ value: t, label: t })); + function TypeBadge({ type }: { type: string }) { - const colors: Record = { - debit: "bg-red-900/30 text-red-400", - credit: "bg-green-900/30 text-green-400", - payment: "bg-blue-900/30 text-blue-400", - refund: "bg-emerald-900/30 text-emerald-400", - fee: "bg-yellow-900/30 text-yellow-400", - interest: "bg-orange-900/30 text-orange-400", - }; return ( - + {type} ); } +function EditableTypeBadge({ type, onSave }: { type: string; onSave: (t: string) => void }) { + const [editing, setEditing] = useState(false); + if (!editing) { + return ( + + ); + } + return ( + + ); +} + function InlineEdit({ value, onSave, @@ -386,11 +418,16 @@ function TransactionsContent() { {formatAmount(t.amount, t.transaction_type)} - + + updateTxn.mutate({ id: t.id, transaction_type: val })} + /> +
{ const res = await fetch(`/api/transactions/${id}`, { method: "PATCH",