fix(transactions): editable transaction type, fee/interest counted as spend, fees category

- 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
This commit is contained in:
2026-03-10 00:24:42 +11:00
parent 714c5a9b25
commit dd11019fdf
5 changed files with 74 additions and 14 deletions
+1 -1
View File
@@ -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 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 JOIN statements s ON s.id = t.statement_id
WHERE s.owner_id = $1 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 COALESCE(o.category_override, t.category) NOT IN ('transfers', 'investment')
AND t.transaction_date >= $2 AND t.transaction_date >= $2
AND t.transaction_date < $3 AND t.transaction_date < $3
+22 -1
View File
@@ -1,6 +1,9 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { getTransactionById } from "@/lib/queries"; import { getTransactionById } from "@/lib/queries";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { queryRaw } from "@/lib/db";
const VALID_TYPES = ["debit", "credit", "payment", "refund", "fee", "interest", "transfer"];
export async function GET( export async function GET(
_req: NextRequest, _req: NextRequest,
@@ -20,12 +23,30 @@ export async function PATCH(
const transactionId = Number(id); const transactionId = Number(id);
const body = await req.json(); const body = await req.json();
const { category, merchant_normalized, notes } = body as { const { category, merchant_normalized, notes, transaction_type } = body as {
category?: string; category?: string;
merchant_normalized?: string; merchant_normalized?: string;
notes?: 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<string, unknown> = { updated_at: new Date() }; const data: Record<string, unknown> = { updated_at: new Date() };
if (category !== undefined) data.category_override = category; if (category !== undefined) data.category_override = category;
if (merchant_normalized !== undefined) data.merchant_normalized = merchant_normalized; if (merchant_normalized !== undefined) data.merchant_normalized = merchant_normalized;
+44 -7
View File
@@ -15,30 +15,62 @@ function formatDate(d: string) {
}); });
} }
const SPEND_TYPES = new Set(["debit", "fee", "interest"]);
function formatAmount(amount: number, type: string) { function formatAmount(amount: number, type: string) {
const formatted = new Intl.NumberFormat("en-AU", { const formatted = new Intl.NumberFormat("en-AU", {
style: "currency", style: "currency",
currency: "AUD", currency: "AUD",
}).format(amount); }).format(amount);
return type === "debit" ? formatted : `+${formatted}`; return SPEND_TYPES.has(type) ? formatted : `+${formatted}`;
} }
function TypeBadge({ type }: { type: string }) { const TYPE_COLORS: Record<string, string> = {
const colors: Record<string, string> = {
debit: "bg-red-900/30 text-red-400", debit: "bg-red-900/30 text-red-400",
credit: "bg-green-900/30 text-green-400", credit: "bg-green-900/30 text-green-400",
payment: "bg-blue-900/30 text-blue-400", payment: "bg-blue-900/30 text-blue-400",
refund: "bg-emerald-900/30 text-emerald-400", refund: "bg-emerald-900/30 text-emerald-400",
fee: "bg-yellow-900/30 text-yellow-400", fee: "bg-yellow-900/30 text-yellow-400",
interest: "bg-orange-900/30 text-orange-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 }) {
return ( return (
<span className={`px-2 py-0.5 rounded text-xs font-medium ${colors[type] || "bg-zinc-800 text-zinc-400"}`}> <span className={`px-2 py-0.5 rounded text-xs font-medium ${TYPE_COLORS[type] || "bg-zinc-800 text-zinc-400"}`}>
{type} {type}
</span> </span>
); );
} }
function EditableTypeBadge({ type, onSave }: { type: string; onSave: (t: string) => void }) {
const [editing, setEditing] = useState(false);
if (!editing) {
return (
<button onClick={() => setEditing(true)} title="Click to change type">
<TypeBadge type={type} />
</button>
);
}
return (
<select
autoFocus
defaultValue={type}
onBlur={(e) => { onSave(e.target.value); setEditing(false); }}
onChange={(e) => { onSave(e.target.value); setEditing(false); }}
className="bg-zinc-800 border border-zinc-600 rounded px-1 py-0.5 text-xs"
>
{TYPE_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
);
}
function InlineEdit({ function InlineEdit({
value, value,
onSave, onSave,
@@ -386,11 +418,16 @@ function TransactionsContent() {
</div> </div>
</td> </td>
<td className={`p-2 text-right whitespace-nowrap font-mono ${ <td className={`p-2 text-right whitespace-nowrap font-mono ${
t.transaction_type === "debit" ? "text-red-400" : "text-green-400" SPEND_TYPES.has(t.transaction_type) ? "text-red-400" : "text-green-400"
}`}> }`}>
{formatAmount(t.amount, t.transaction_type)} {formatAmount(t.amount, t.transaction_type)}
</td> </td>
<td className="p-2"><TypeBadge type={t.transaction_type} /></td> <td className="p-2">
<EditableTypeBadge
type={t.transaction_type}
onSave={(val) => updateTxn.mutate({ id: t.id, transaction_type: val })}
/>
</td>
<td className="p-2 max-w-[140px]"> <td className="p-2 max-w-[140px]">
<div className="relative"> <div className="relative">
<InlineEdit <InlineEdit
+1
View File
@@ -23,6 +23,7 @@ export const CATEGORIES = [
"pets", "pets",
"gifts", "gifts",
"charity", "charity",
"fees",
"other", "other",
] as const; ] as const;
+1
View File
@@ -94,6 +94,7 @@ export function useUpdateTransaction() {
category?: string; category?: string;
merchant_normalized?: string; merchant_normalized?: string;
notes?: string; notes?: string;
transaction_type?: string;
}) => { }) => {
const res = await fetch(`/api/transactions/${id}`, { const res = await fetch(`/api/transactions/${id}`, {
method: "PATCH", method: "PATCH",