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:
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TYPE_COLORS: Record<string, string> = {
|
||||||
|
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 }) {
|
function TypeBadge({ type }: { type: string }) {
|
||||||
const colors: Record<string, string> = {
|
|
||||||
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 (
|
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
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export const CATEGORIES = [
|
|||||||
"pets",
|
"pets",
|
||||||
"gifts",
|
"gifts",
|
||||||
"charity",
|
"charity",
|
||||||
|
"fees",
|
||||||
"other",
|
"other",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user