feat: CSV import and batch reconciliation UI
- Add reconciled_with_id column to transactions (links manual → statement tx) - CSV import wizard: 4-step modal (upload → map columns → review → done) - Handles any bank format via column mapping with localStorage presets - Single signed or separate debit/credit column modes - Editable preview table before committing - Auto-tags all imported rows with 'csv-import' - Batch reconcile page: shows all unreconciled manual transactions with potential statement matches (date ±3 days, amount ±1%) pre-fetched - Select matches across multiple rows, apply all at once - Copies overrides/tags/splits from manual → statement tx atomically - Manual tx marked reconciled (linked), hidden from main transactions view - Transactions with no matches shown separately - Import CSV button on transactions page - Reconcile nav item in sidebar
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE transactions ADD COLUMN reconciled_with_id INTEGER REFERENCES transactions(id) ON DELETE SET NULL;
|
||||||
|
CREATE INDEX idx_transactions_reconciled ON transactions(reconciled_with_id) WHERE reconciled_with_id IS NOT NULL;
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getCurrentUser } from "@/lib/auth";
|
||||||
|
import { ensureTag, batchInsertCSVTransactions } from "@/lib/queries";
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const user = await getCurrentUser(req);
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||||
|
|
||||||
|
const body = await req.json() as {
|
||||||
|
bank_name: string;
|
||||||
|
transactions: {
|
||||||
|
date: string;
|
||||||
|
description: string;
|
||||||
|
amount: number;
|
||||||
|
transaction_type: string;
|
||||||
|
merchant_name?: string;
|
||||||
|
foreign_currency_amount?: number;
|
||||||
|
foreign_currency_code?: string;
|
||||||
|
category?: string;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!Array.isArray(body.transactions) || body.transactions.length === 0) {
|
||||||
|
return NextResponse.json({ error: "No transactions provided" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const tagId = await ensureTag("csv-import", "#8b5cf6");
|
||||||
|
const inserted = await batchInsertCSVTransactions(user.id, body.transactions, tagId);
|
||||||
|
|
||||||
|
return NextResponse.json({ inserted }, { status: 201 });
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getCurrentUser } from "@/lib/auth";
|
||||||
|
import { getPendingReconciliations } from "@/lib/queries";
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const user = await getCurrentUser(req);
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||||
|
|
||||||
|
const data = await getPendingReconciliations(user.id);
|
||||||
|
return NextResponse.json(data);
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getCurrentUser } from "@/lib/auth";
|
||||||
|
import { prisma, queryRaw } from "@/lib/db";
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const user = await getCurrentUser(req);
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||||
|
|
||||||
|
const body = await req.json() as {
|
||||||
|
matches: { manual_id: number; statement_tx_id: number }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!Array.isArray(body.matches) || body.matches.length === 0) {
|
||||||
|
return NextResponse.json({ error: "No matches provided" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify all manual_ids belong to this user
|
||||||
|
const manualIds = body.matches.map((m) => m.manual_id);
|
||||||
|
const owned = await queryRaw<{ id: number }>(
|
||||||
|
`SELECT id FROM transactions WHERE id = ANY($1::int[]) AND statement_id IS NULL AND owner_id = $2`,
|
||||||
|
[manualIds, user.id]
|
||||||
|
);
|
||||||
|
if (owned.length !== manualIds.length) {
|
||||||
|
return NextResponse.json({ error: "One or more transactions not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let reconciled = 0;
|
||||||
|
|
||||||
|
for (const { manual_id, statement_tx_id } of body.matches) {
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
// Copy overrides: manual → statement tx
|
||||||
|
const override = await tx.transaction_overrides.findUnique({
|
||||||
|
where: { transaction_id: manual_id },
|
||||||
|
});
|
||||||
|
if (override) {
|
||||||
|
await tx.transaction_overrides.upsert({
|
||||||
|
where: { transaction_id: statement_tx_id },
|
||||||
|
update: {
|
||||||
|
category_override: override.category_override,
|
||||||
|
merchant_normalized: override.merchant_normalized,
|
||||||
|
notes: override.notes,
|
||||||
|
my_share_percent: override.my_share_percent,
|
||||||
|
updated_at: new Date(),
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
transaction_id: statement_tx_id,
|
||||||
|
category_override: override.category_override,
|
||||||
|
merchant_normalized: override.merchant_normalized,
|
||||||
|
notes: override.notes,
|
||||||
|
my_share_percent: override.my_share_percent,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy tags: manual → statement tx
|
||||||
|
const tags = await tx.transaction_tags.findMany({ where: { transaction_id: manual_id } });
|
||||||
|
if (tags.length) {
|
||||||
|
await tx.transaction_tags.createMany({
|
||||||
|
data: tags.map((t) => ({ transaction_id: statement_tx_id, tag_id: t.tag_id })),
|
||||||
|
skipDuplicates: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy splits: manual → statement tx
|
||||||
|
const splits = await tx.transaction_splits.findMany({ where: { transaction_id: manual_id } });
|
||||||
|
if (splits.length) {
|
||||||
|
await tx.transaction_splits.createMany({
|
||||||
|
data: splits.map((s) => ({
|
||||||
|
transaction_id: statement_tx_id,
|
||||||
|
participant_id: s.participant_id,
|
||||||
|
share_percent: s.share_percent,
|
||||||
|
})),
|
||||||
|
skipDuplicates: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark manual tx as reconciled (link to statement tx)
|
||||||
|
await tx.$executeRawUnsafe(
|
||||||
|
`UPDATE transactions SET reconciled_with_id = $1 WHERE id = $2`,
|
||||||
|
statement_tx_id,
|
||||||
|
manual_id
|
||||||
|
);
|
||||||
|
});
|
||||||
|
reconciled++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ reconciled });
|
||||||
|
}
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { usePendingReconciliations, useReconcile } from "@/lib/hooks";
|
||||||
|
import type { ManualTxWithMatches, PotentialMatch } from "@/lib/queries";
|
||||||
|
|
||||||
|
function formatDate(d: string | Date) {
|
||||||
|
return new Date(d).toLocaleDateString("en-AU", { day: "2-digit", month: "short", year: "numeric" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAmt(amount: number, type: string) {
|
||||||
|
const f = new Intl.NumberFormat("en-AU", { style: "currency", currency: "AUD" }).format(amount);
|
||||||
|
return ["debit", "fee", "interest"].includes(type) ? f : `+${f}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Selections: manual_id → statement_tx_id or null (skip)
|
||||||
|
type Selections = Record<number, number | null>;
|
||||||
|
|
||||||
|
export default function ReconcilePage() {
|
||||||
|
const { data: pending = [], isLoading, refetch } = usePendingReconciliations();
|
||||||
|
const reconcile = useReconcile();
|
||||||
|
|
||||||
|
const [selections, setSelections] = useState<Selections>({});
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [done, setDone] = useState<{ reconciled: number } | null>(null);
|
||||||
|
|
||||||
|
const withMatches = pending.filter((tx) => tx.matches.length > 0);
|
||||||
|
const noMatches = pending.filter((tx) => tx.matches.length === 0);
|
||||||
|
|
||||||
|
// Statement tx IDs already chosen in another row this session
|
||||||
|
const usedStatementIds = new Set(Object.values(selections).filter((v): v is number => v !== null));
|
||||||
|
|
||||||
|
function selectMatch(manualId: number, matchId: number) {
|
||||||
|
setSelections((prev) => {
|
||||||
|
// If this matchId was previously selected for a different manual tx, clear that
|
||||||
|
const updated: Selections = { ...prev };
|
||||||
|
for (const [k, v] of Object.entries(updated)) {
|
||||||
|
if (v === matchId && Number(k) !== manualId) delete updated[Number(k)];
|
||||||
|
}
|
||||||
|
updated[manualId] = matchId;
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function skipManual(manualId: number) {
|
||||||
|
setSelections((prev) => ({ ...prev, [manualId]: null }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmedMatches = Object.entries(selections)
|
||||||
|
.filter(([, v]) => v !== null)
|
||||||
|
.map(([k, v]) => ({ manual_id: Number(k), statement_tx_id: v as number }));
|
||||||
|
|
||||||
|
async function handleApply() {
|
||||||
|
if (!confirmedMatches.length) return;
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const result = await reconcile.mutateAsync(confirmedMatches);
|
||||||
|
setDone(result);
|
||||||
|
setSelections({});
|
||||||
|
refetch();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Reconcile failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <div className="p-6 text-zinc-500 text-sm">Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (done) {
|
||||||
|
return (
|
||||||
|
<div className="p-6 max-w-lg">
|
||||||
|
<div className="bg-emerald-900/20 border border-emerald-700/50 rounded-xl p-6 text-center space-y-2">
|
||||||
|
<p className="text-emerald-400 font-semibold text-lg">✓ {done.reconciled} transaction{done.reconciled !== 1 ? "s" : ""} reconciled</p>
|
||||||
|
<p className="text-zinc-400 text-sm">Overrides, tags, and splits copied to statement transactions.</p>
|
||||||
|
<button onClick={() => setDone(null)} className="mt-3 px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-lg text-sm">
|
||||||
|
Continue reconciling
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pending.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<h2 className="text-xl font-semibold mb-2">Reconcile</h2>
|
||||||
|
<p className="text-zinc-500 text-sm">No unreconciled manual transactions. Import a CSV to get started.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold">Reconcile</h2>
|
||||||
|
<p className="text-xs text-zinc-500 mt-0.5">
|
||||||
|
{pending.length} manual transaction{pending.length !== 1 ? "s" : ""} ·{" "}
|
||||||
|
{withMatches.length} with potential matches
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{confirmedMatches.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={handleApply}
|
||||||
|
disabled={reconcile.isPending}
|
||||||
|
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg text-sm font-medium disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{reconcile.isPending ? "Reconciling..." : `Apply ${confirmedMatches.length} match${confirmedMatches.length !== 1 ? "es" : ""}`}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-red-400 text-sm">{error}</p>}
|
||||||
|
|
||||||
|
{/* Transactions with matches */}
|
||||||
|
{withMatches.map((tx) => (
|
||||||
|
<ReconcileRow
|
||||||
|
key={tx.id}
|
||||||
|
tx={tx}
|
||||||
|
selection={selections[tx.id]}
|
||||||
|
usedStatementIds={usedStatementIds}
|
||||||
|
onSelect={(matchId) => selectMatch(tx.id, matchId)}
|
||||||
|
onSkip={() => skipManual(tx.id)}
|
||||||
|
onClear={() => setSelections((prev) => { const n = { ...prev }; delete n[tx.id]; return n; })}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Transactions with no matches */}
|
||||||
|
{noMatches.length > 0 && (
|
||||||
|
<div className="border border-zinc-800 rounded-xl overflow-hidden">
|
||||||
|
<div className="px-4 py-3 bg-zinc-900/60 border-b border-zinc-800">
|
||||||
|
<p className="text-sm font-medium text-zinc-400">No statement matches found ({noMatches.length})</p>
|
||||||
|
<p className="text-xs text-zinc-600 mt-0.5">These may not have hit a statement yet, or the statement hasn't been imported.</p>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-zinc-800">
|
||||||
|
{noMatches.map((tx) => (
|
||||||
|
<div key={tx.id} className="px-4 py-3 flex items-center gap-3">
|
||||||
|
<span className="text-zinc-500 text-xs w-20 flex-shrink-0">{formatDate(tx.transaction_date)}</span>
|
||||||
|
<span className="text-zinc-300 text-sm flex-1 truncate">{tx.effective_merchant || tx.description}</span>
|
||||||
|
<span className={`text-sm font-mono ${["debit","fee","interest"].includes(tx.transaction_type) ? "text-red-400" : "text-green-400"}`}>
|
||||||
|
{formatAmt(tx.amount, tx.transaction_type)}
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{(tx.tags as { id: number; name: string; color: string }[]).map((tag) => (
|
||||||
|
<span key={tag.id} className="w-2 h-2 rounded-full" style={{ backgroundColor: tag.color }} title={tag.name} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReconcileRow({
|
||||||
|
tx, selection, usedStatementIds, onSelect, onSkip, onClear,
|
||||||
|
}: {
|
||||||
|
tx: ManualTxWithMatches;
|
||||||
|
selection: number | null | undefined;
|
||||||
|
usedStatementIds: Set<number>;
|
||||||
|
onSelect: (matchId: number) => void;
|
||||||
|
onSkip: () => void;
|
||||||
|
onClear: () => void;
|
||||||
|
}) {
|
||||||
|
const isSkipped = selection === null;
|
||||||
|
const selectedMatchId = typeof selection === "number" ? selection : null;
|
||||||
|
const tags = tx.tags as { id: number; name: string; color: string }[];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`border rounded-xl overflow-hidden transition-colors ${
|
||||||
|
selectedMatchId ? "border-emerald-700/60" : isSkipped ? "border-zinc-700/40 opacity-60" : "border-zinc-700"
|
||||||
|
}`}>
|
||||||
|
{/* Manual tx header */}
|
||||||
|
<div className="px-4 py-3 bg-zinc-900/60 border-b border-zinc-800 flex items-center gap-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="text-zinc-500 text-xs flex-shrink-0">{formatDate(tx.transaction_date)}</span>
|
||||||
|
<span className="text-zinc-200 text-sm truncate">{tx.effective_merchant || tx.description}</span>
|
||||||
|
{tx.effective_merchant && (
|
||||||
|
<span className="text-zinc-600 text-xs truncate hidden sm:inline">{tx.description}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{tags.length > 0 && (
|
||||||
|
<div className="flex gap-1 mt-1">
|
||||||
|
{tags.map((tag) => (
|
||||||
|
<span key={tag.id} className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs"
|
||||||
|
style={{ backgroundColor: tag.color + "33", color: tag.color }}>
|
||||||
|
{tag.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className={`text-sm font-mono flex-shrink-0 ${["debit","fee","interest"].includes(tx.transaction_type) ? "text-red-400" : "text-green-400"}`}>
|
||||||
|
{formatAmt(tx.amount, tx.transaction_type)}
|
||||||
|
</span>
|
||||||
|
<span className={`px-2 py-0.5 rounded text-xs font-medium flex-shrink-0 ${TYPE_COLORS[tx.transaction_type] || "bg-zinc-800 text-zinc-400"}`}>
|
||||||
|
{tx.transaction_type}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Matches */}
|
||||||
|
<div className="divide-y divide-zinc-800/50">
|
||||||
|
{tx.matches.map((match) => {
|
||||||
|
const isSelected = selectedMatchId === match.id;
|
||||||
|
const isUsedElsewhere = !isSelected && usedStatementIds.has(match.id);
|
||||||
|
return (
|
||||||
|
<MatchRow
|
||||||
|
key={match.id}
|
||||||
|
match={match}
|
||||||
|
isSelected={isSelected}
|
||||||
|
isDisabled={isUsedElsewhere}
|
||||||
|
onSelect={() => onSelect(match.id)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Skip / status row */}
|
||||||
|
<div className="px-4 py-2.5 flex items-center justify-between bg-zinc-950/30">
|
||||||
|
{selectedMatchId ? (
|
||||||
|
<span className="text-emerald-400 text-xs font-medium">✓ Match selected</span>
|
||||||
|
) : isSkipped ? (
|
||||||
|
<span className="text-zinc-500 text-xs">Skipped — will stay as manual transaction</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-zinc-600 text-xs">Select a match above, or skip</span>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{(selectedMatchId || isSkipped) && (
|
||||||
|
<button onClick={onClear} className="text-xs text-zinc-500 hover:text-zinc-300 px-2 py-1 rounded hover:bg-zinc-800">
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{!isSkipped && (
|
||||||
|
<button onClick={onSkip} className="text-xs text-zinc-500 hover:text-zinc-300 px-2 py-1 rounded hover:bg-zinc-800">
|
||||||
|
Skip (no match)
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MatchRow({
|
||||||
|
match, isSelected, isDisabled, onSelect,
|
||||||
|
}: {
|
||||||
|
match: PotentialMatch;
|
||||||
|
isSelected: boolean;
|
||||||
|
isDisabled: boolean;
|
||||||
|
onSelect: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onSelect}
|
||||||
|
disabled={isDisabled}
|
||||||
|
className={`w-full px-4 py-2.5 flex items-center gap-3 text-left transition-colors ${
|
||||||
|
isSelected
|
||||||
|
? "bg-emerald-900/20 hover:bg-emerald-900/30"
|
||||||
|
: isDisabled
|
||||||
|
? "opacity-30 cursor-not-allowed"
|
||||||
|
: "hover:bg-zinc-800/50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className={`w-4 h-4 rounded-full border-2 flex-shrink-0 flex items-center justify-center ${
|
||||||
|
isSelected ? "border-emerald-500 bg-emerald-500" : "border-zinc-600"
|
||||||
|
}`}>
|
||||||
|
{isSelected && <div className="w-2 h-2 rounded-full bg-white" />}
|
||||||
|
</div>
|
||||||
|
<span className="text-zinc-500 text-xs w-20 flex-shrink-0">{formatDate(match.transaction_date)}</span>
|
||||||
|
<span className="flex-1 min-w-0">
|
||||||
|
<span className="text-zinc-300 text-sm truncate block">
|
||||||
|
{match.effective_merchant || match.description}
|
||||||
|
</span>
|
||||||
|
{match.effective_merchant && (
|
||||||
|
<span className="text-zinc-600 text-xs truncate block">{match.description}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="text-zinc-500 text-xs flex-shrink-0">{match.bank_name}</span>
|
||||||
|
<span className={`text-sm font-mono flex-shrink-0 ${["debit","fee","interest"].includes(match.transaction_type) ? "text-red-400" : "text-green-400"}`}>
|
||||||
|
{formatAmt(match.amount, match.transaction_type)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import { SplitModal } from "@/components/split-modal";
|
|||||||
import { TagPicker } from "@/components/tag-picker";
|
import { TagPicker } from "@/components/tag-picker";
|
||||||
import { AddTransactionModal } from "@/components/add-transaction-modal";
|
import { AddTransactionModal } from "@/components/add-transaction-modal";
|
||||||
import { EditTransactionModal } from "@/components/edit-transaction-modal";
|
import { EditTransactionModal } from "@/components/edit-transaction-modal";
|
||||||
|
import { CsvImportModal } from "@/components/csv-import-modal";
|
||||||
import type { TransactionRow } from "@/lib/queries";
|
import type { TransactionRow } from "@/lib/queries";
|
||||||
|
|
||||||
function formatDate(d: string) {
|
function formatDate(d: string) {
|
||||||
@@ -507,6 +508,7 @@ function TransactionsContent() {
|
|||||||
const [splitModal, setSplitModal] = useState<{ transactionId?: number; transactionIds?: number[]; amount?: number; description: string; merchant?: string } | null>(null);
|
const [splitModal, setSplitModal] = useState<{ transactionId?: number; transactionIds?: number[]; amount?: number; description: string; merchant?: string } | null>(null);
|
||||||
const [addModal, setAddModal] = useState<{ prefill?: Parameters<typeof AddTransactionModal>[0]["prefill"]; title?: string } | null>(null);
|
const [addModal, setAddModal] = useState<{ prefill?: Parameters<typeof AddTransactionModal>[0]["prefill"]; title?: string } | null>(null);
|
||||||
const [editModal, setEditModal] = useState<TransactionRow | null>(null);
|
const [editModal, setEditModal] = useState<TransactionRow | null>(null);
|
||||||
|
const [showImportModal, setShowImportModal] = useState(false);
|
||||||
const [paymentModal, setPaymentModal] = useState<TransactionRow | null>(null);
|
const [paymentModal, setPaymentModal] = useState<TransactionRow | null>(null);
|
||||||
const [rulePrompt, setRulePrompt] = useState<{
|
const [rulePrompt, setRulePrompt] = useState<{
|
||||||
tx: { id: number; effective_merchant: string; description: string; bank_name: string };
|
tx: { id: number; effective_merchant: string; description: string; bank_name: string };
|
||||||
@@ -562,12 +564,20 @@ function TransactionsContent() {
|
|||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h2 className="text-xl font-semibold">Transactions</h2>
|
<h2 className="text-xl font-semibold">Transactions</h2>
|
||||||
<button
|
<div className="flex gap-2">
|
||||||
onClick={() => setAddModal({})}
|
<button
|
||||||
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg text-sm font-medium"
|
onClick={() => setShowImportModal(true)}
|
||||||
>
|
className="px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-lg text-sm font-medium"
|
||||||
+ Add Transaction
|
>
|
||||||
</button>
|
Import CSV
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setAddModal({})}
|
||||||
|
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg text-sm font-medium"
|
||||||
|
>
|
||||||
|
+ Add Transaction
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Statement context banner */}
|
{/* Statement context banner */}
|
||||||
@@ -933,6 +943,8 @@ function TransactionsContent() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showImportModal && <CsvImportModal onClose={() => setShowImportModal(false)} />}
|
||||||
|
|
||||||
{addModal && (
|
{addModal && (
|
||||||
<AddTransactionModal
|
<AddTransactionModal
|
||||||
prefill={addModal.prefill}
|
prefill={addModal.prefill}
|
||||||
|
|||||||
@@ -0,0 +1,413 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useRef, useEffect } from "react";
|
||||||
|
import { CATEGORIES, formatCategory } from "@/lib/categories";
|
||||||
|
import { useImportCSV } from "@/lib/hooks";
|
||||||
|
import {
|
||||||
|
parseCSVRows, detectHasHeaders, getColumnLabels, getDataRows, applyMapping,
|
||||||
|
saveBankPreset, loadBankPresets,
|
||||||
|
type DateFormat, type ColumnMapping, type ParsedTransaction, type BankPreset,
|
||||||
|
} from "@/lib/csv-parser";
|
||||||
|
|
||||||
|
const DATE_FORMATS: DateFormat[] = ["DD/MM/YYYY", "YYYY-MM-DD", "MM/DD/YYYY", "M/D/YYYY"];
|
||||||
|
const TX_TYPES = ["debit", "credit", "payment", "refund", "fee", "interest", "transfer"];
|
||||||
|
|
||||||
|
type Step = "upload" | "map" | "review" | "done";
|
||||||
|
|
||||||
|
function ColSelect({
|
||||||
|
label, value, onChange, options, required,
|
||||||
|
}: {
|
||||||
|
label: string; value: string; onChange: (v: string) => void;
|
||||||
|
options: string[]; required?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-zinc-500 mb-1">{label}</label>
|
||||||
|
<select
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
|
||||||
|
>
|
||||||
|
{!required && <option value="">— none —</option>}
|
||||||
|
{options.map((o) => <option key={o} value={o}>{o}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CsvImportModal({ onClose }: { onClose: () => void }) {
|
||||||
|
const importCSV = useImportCSV();
|
||||||
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const [step, setStep] = useState<Step>("upload");
|
||||||
|
const [rawRows, setRawRows] = useState<string[][]>([]);
|
||||||
|
const [hasHeaders, setHasHeaders] = useState(false);
|
||||||
|
const [columnLabels, setColumnLabels] = useState<string[]>([]);
|
||||||
|
const [dataRows, setDataRows] = useState<string[][]>([]);
|
||||||
|
const [bankName, setBankName] = useState("");
|
||||||
|
const [dateFormat, setDateFormat] = useState<DateFormat>("DD/MM/YYYY");
|
||||||
|
const [mapping, setMapping] = useState<ColumnMapping>({
|
||||||
|
dateCol: "", descriptionCol: "", amountMode: "single", amountCol: "",
|
||||||
|
});
|
||||||
|
const [savePreset, setSavePreset] = useState(false);
|
||||||
|
const [editedRows, setEditedRows] = useState<ParsedTransaction[]>([]);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [presets, setPresets] = useState<BankPreset[]>([]);
|
||||||
|
const [insertedCount, setInsertedCount] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => { setPresets(loadBankPresets()); }, []);
|
||||||
|
|
||||||
|
function handleFile(file: File) {
|
||||||
|
setError("");
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
const text = e.target?.result as string;
|
||||||
|
const rows = parseCSVRows(text);
|
||||||
|
if (rows.length === 0) { setError("No data found in file"); return; }
|
||||||
|
setRawRows(rows);
|
||||||
|
const headers = detectHasHeaders(rows, dateFormat);
|
||||||
|
setHasHeaders(headers);
|
||||||
|
const labels = getColumnLabels(rows, headers);
|
||||||
|
setColumnLabels(labels);
|
||||||
|
setDataRows(getDataRows(rows, headers));
|
||||||
|
// auto-set first columns as defaults
|
||||||
|
setMapping((m) => ({
|
||||||
|
...m,
|
||||||
|
dateCol: labels[0] ?? "",
|
||||||
|
descriptionCol: labels[1] ?? "",
|
||||||
|
amountCol: labels[2] ?? "",
|
||||||
|
}));
|
||||||
|
setStep("map");
|
||||||
|
};
|
||||||
|
reader.readAsText(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPreset(preset: BankPreset) {
|
||||||
|
setBankName(preset.bankName);
|
||||||
|
setDateFormat(preset.dateFormat);
|
||||||
|
setMapping(preset.mapping);
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshColumns() {
|
||||||
|
if (!rawRows.length) return;
|
||||||
|
const headers = detectHasHeaders(rawRows, dateFormat);
|
||||||
|
setHasHeaders(headers);
|
||||||
|
const labels = getColumnLabels(rawRows, headers);
|
||||||
|
setColumnLabels(labels);
|
||||||
|
setDataRows(getDataRows(rawRows, headers));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleNext() {
|
||||||
|
setError("");
|
||||||
|
if (!bankName.trim()) { setError("Bank name is required"); return; }
|
||||||
|
if (!mapping.dateCol) { setError("Date column is required"); return; }
|
||||||
|
if (!mapping.descriptionCol) { setError("Description column is required"); return; }
|
||||||
|
if (mapping.amountMode === "single" && !mapping.amountCol) { setError("Amount column is required"); return; }
|
||||||
|
if (mapping.amountMode === "debit_credit" && !mapping.debitCol && !mapping.creditCol) {
|
||||||
|
setError("At least one of debit/credit columns is required"); return;
|
||||||
|
}
|
||||||
|
const parsed = applyMapping(dataRows, columnLabels, mapping, dateFormat);
|
||||||
|
if (parsed.length === 0) { setError("No valid transactions could be parsed — check your column mapping and date format"); return; }
|
||||||
|
if (savePreset) {
|
||||||
|
saveBankPreset({ bankName: bankName.trim(), mapping, dateFormat });
|
||||||
|
}
|
||||||
|
setEditedRows(parsed);
|
||||||
|
setStep("review");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleImport() {
|
||||||
|
setError("");
|
||||||
|
const valid = editedRows.filter((r) => r.date && r.amount > 0 && r.description);
|
||||||
|
if (!valid.length) { setError("No valid rows to import"); return; }
|
||||||
|
try {
|
||||||
|
const result = await importCSV.mutateAsync({ bank_name: bankName, transactions: valid });
|
||||||
|
setInsertedCount(result.inserted);
|
||||||
|
setStep("done");
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Import failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateRow(i: number, patch: Partial<ParsedTransaction>) {
|
||||||
|
setEditedRows((rows) => rows.map((r, idx) => idx === i ? { ...r, ...patch } : r));
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteRow(i: number) {
|
||||||
|
setEditedRows((rows) => rows.filter((_, idx) => idx !== i));
|
||||||
|
}
|
||||||
|
|
||||||
|
const modalWidth = step === "review" ? "max-w-5xl" : step === "map" ? "max-w-xl" : "max-w-md";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" onClick={onClose}>
|
||||||
|
<div
|
||||||
|
className={`bg-zinc-900 border border-zinc-700 rounded-xl shadow-2xl w-full ${modalWidth} flex flex-col max-h-[90vh]`}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-800 flex-shrink-0">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-sm text-zinc-200">Import CSV</h3>
|
||||||
|
<div className="flex gap-2 mt-1">
|
||||||
|
{(["upload", "map", "review", "done"] as Step[]).map((s, i) => (
|
||||||
|
<span
|
||||||
|
key={s}
|
||||||
|
className={`text-xs ${step === s ? "text-indigo-400 font-medium" : "text-zinc-600"}`}
|
||||||
|
>
|
||||||
|
{i + 1}. {s.charAt(0).toUpperCase() + s.slice(1)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={onClose} className="text-zinc-500 hover:text-zinc-300 text-xl leading-none">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="overflow-y-auto flex-1 px-6 py-5">
|
||||||
|
|
||||||
|
{/* Step 1: Upload */}
|
||||||
|
{step === "upload" && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{presets.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-zinc-500 mb-1">Load saved preset</label>
|
||||||
|
<select
|
||||||
|
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
|
||||||
|
defaultValue=""
|
||||||
|
onChange={(e) => {
|
||||||
|
const p = presets.find((x) => x.bankName === e.target.value);
|
||||||
|
if (p) applyPreset(p);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">— select preset —</option>
|
||||||
|
{presets.map((p) => <option key={p.bankName} value={p.bankName}>{p.bankName}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
ref={fileRef}
|
||||||
|
type="file"
|
||||||
|
accept=".csv,text/csv"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => { const f = e.target.files?.[0]; if (f) handleFile(f); }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => fileRef.current?.click()}
|
||||||
|
className="w-full border-2 border-dashed border-zinc-700 hover:border-indigo-500 rounded-xl py-12 text-center transition-colors"
|
||||||
|
onDragOver={(e) => e.preventDefault()}
|
||||||
|
onDrop={(e) => { e.preventDefault(); const f = e.dataTransfer.files?.[0]; if (f) handleFile(f); }}
|
||||||
|
>
|
||||||
|
<p className="text-zinc-400 text-sm">Drop a CSV file here, or click to browse</p>
|
||||||
|
<p className="text-zinc-600 text-xs mt-1">Westpac, ANZ, CBA, NAB and others</p>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-red-400 text-xs">{error}</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 2: Map */}
|
||||||
|
{step === "map" && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Raw preview */}
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-zinc-500 mb-2">First 3 rows from file:</p>
|
||||||
|
<div className="overflow-x-auto rounded border border-zinc-800">
|
||||||
|
<table className="text-xs text-zinc-400 w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-zinc-800">
|
||||||
|
{columnLabels.map((h) => (
|
||||||
|
<th key={h} className="px-2 py-1.5 text-left font-medium text-zinc-300 whitespace-nowrap">{h}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{dataRows.slice(0, 3).map((row, i) => (
|
||||||
|
<tr key={i} className="border-b border-zinc-800/50">
|
||||||
|
{columnLabels.map((_, ci) => (
|
||||||
|
<td key={ci} className="px-2 py-1 truncate max-w-[160px]">{row[ci] ?? ""}</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-zinc-500 mb-1">Bank Name</label>
|
||||||
|
<input
|
||||||
|
value={bankName}
|
||||||
|
onChange={(e) => setBankName(e.target.value)}
|
||||||
|
placeholder="e.g. Westpac"
|
||||||
|
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-zinc-500 mb-1">Date Format</label>
|
||||||
|
<select
|
||||||
|
value={dateFormat}
|
||||||
|
onChange={(e) => { setDateFormat(e.target.value as DateFormat); refreshColumns(); }}
|
||||||
|
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
|
||||||
|
>
|
||||||
|
{DATE_FORMATS.map((f) => <option key={f} value={f}>{f}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<ColSelect label="Date Column *" value={mapping.dateCol} onChange={(v) => setMapping((m) => ({ ...m, dateCol: v }))} options={columnLabels} required />
|
||||||
|
<ColSelect label="Description Column *" value={mapping.descriptionCol} onChange={(v) => setMapping((m) => ({ ...m, descriptionCol: v }))} options={columnLabels} required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-zinc-500 mb-2">Amount</label>
|
||||||
|
<div className="flex gap-4 mb-2">
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<input type="radio" name="amtmode" value="single" checked={mapping.amountMode === "single"} onChange={() => setMapping((m) => ({ ...m, amountMode: "single" }))} className="accent-indigo-500" />
|
||||||
|
Single signed column
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<input type="radio" name="amtmode" value="debit_credit" checked={mapping.amountMode === "debit_credit"} onChange={() => setMapping((m) => ({ ...m, amountMode: "debit_credit" }))} className="accent-indigo-500" />
|
||||||
|
Separate debit / credit columns
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{mapping.amountMode === "single" ? (
|
||||||
|
<ColSelect label="Amount Column *" value={mapping.amountCol ?? ""} onChange={(v) => setMapping((m) => ({ ...m, amountCol: v }))} options={columnLabels} required />
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<ColSelect label="Debit Column" value={mapping.debitCol ?? ""} onChange={(v) => setMapping((m) => ({ ...m, debitCol: v }))} options={columnLabels} />
|
||||||
|
<ColSelect label="Credit Column" value={mapping.creditCol ?? ""} onChange={(v) => setMapping((m) => ({ ...m, creditCol: v }))} options={columnLabels} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<ColSelect label="Merchant Column (optional)" value={mapping.merchantCol ?? ""} onChange={(v) => setMapping((m) => ({ ...m, merchantCol: v || undefined }))} options={columnLabels} />
|
||||||
|
<ColSelect label="Category Column (optional)" value={mapping.categoryCol ?? ""} onChange={(v) => setMapping((m) => ({ ...m, categoryCol: v || undefined }))} options={columnLabels} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer text-zinc-400">
|
||||||
|
<input type="checkbox" checked={savePreset} onChange={(e) => setSavePreset(e.target.checked)} className="accent-indigo-500" />
|
||||||
|
Save as preset for {bankName || "this bank"}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{error && <p className="text-red-400 text-xs">{error}</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 3: Review */}
|
||||||
|
{step === "review" && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-zinc-500 mb-3">
|
||||||
|
{editedRows.length} transactions parsed. Edit or remove rows before importing.
|
||||||
|
</p>
|
||||||
|
<div className="overflow-x-auto rounded border border-zinc-800">
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead className="border-b border-zinc-800">
|
||||||
|
<tr>
|
||||||
|
{["Date", "Description", "Amount", "Type", "Merchant", "Category", ""].map((h) => (
|
||||||
|
<th key={h} className="px-2 py-2 text-left text-zinc-400 font-medium whitespace-nowrap">{h}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{editedRows.map((row, i) => (
|
||||||
|
<tr key={i} className="border-b border-zinc-800/50 hover:bg-zinc-800/30">
|
||||||
|
<td className="px-2 py-1">
|
||||||
|
<input type="date" value={row.date} onChange={(e) => updateRow(i, { date: e.target.value })}
|
||||||
|
className="bg-transparent border-b border-zinc-700 text-zinc-300 text-xs w-28 focus:outline-none focus:border-indigo-500" />
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1">
|
||||||
|
<input value={row.description} onChange={(e) => updateRow(i, { description: e.target.value })}
|
||||||
|
className="bg-transparent border-b border-zinc-700 text-zinc-300 text-xs w-48 focus:outline-none focus:border-indigo-500" />
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1">
|
||||||
|
<input type="number" step="0.01" value={row.amount} onChange={(e) => updateRow(i, { amount: parseFloat(e.target.value) || 0 })}
|
||||||
|
className="bg-transparent border-b border-zinc-700 text-zinc-300 text-xs w-20 focus:outline-none focus:border-indigo-500" />
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1">
|
||||||
|
<select value={row.transaction_type} onChange={(e) => updateRow(i, { transaction_type: e.target.value })}
|
||||||
|
className="bg-zinc-800 border border-zinc-700 rounded px-1 py-0.5 text-xs">
|
||||||
|
{TX_TYPES.map((t) => <option key={t} value={t}>{t}</option>)}
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1">
|
||||||
|
<input value={row.merchant_name ?? ""} onChange={(e) => updateRow(i, { merchant_name: e.target.value || undefined })}
|
||||||
|
className="bg-transparent border-b border-zinc-700 text-zinc-300 text-xs w-28 focus:outline-none focus:border-indigo-500" />
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1">
|
||||||
|
<select value={row.category ?? ""} onChange={(e) => updateRow(i, { category: e.target.value || undefined })}
|
||||||
|
className="bg-zinc-800 border border-zinc-700 rounded px-1 py-0.5 text-xs">
|
||||||
|
<option value="">—</option>
|
||||||
|
{CATEGORIES.map((c) => <option key={c} value={c}>{formatCategory(c)}</option>)}
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1">
|
||||||
|
<button onClick={() => deleteRow(i)} className="text-zinc-600 hover:text-red-400 text-base leading-none">×</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-red-400 text-xs mt-2">{error}</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 4: Done */}
|
||||||
|
{step === "done" && (
|
||||||
|
<div className="text-center py-6 space-y-3">
|
||||||
|
<div className="text-4xl">✓</div>
|
||||||
|
<p className="text-zinc-200 font-medium">Imported {insertedCount} transactions</p>
|
||||||
|
<p className="text-zinc-500 text-sm">Tagged with <span className="text-indigo-400">csv-import</span></p>
|
||||||
|
<div className="flex gap-2 justify-center pt-2">
|
||||||
|
<a href="/transactions" className="px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-lg text-sm">
|
||||||
|
View Transactions
|
||||||
|
</a>
|
||||||
|
<a href="/reconcile" className="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg text-sm">
|
||||||
|
Reconcile
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
{step !== "done" && (
|
||||||
|
<div className="flex gap-2 px-6 py-4 border-t border-zinc-800 flex-shrink-0">
|
||||||
|
{step === "map" && (
|
||||||
|
<button onClick={() => setStep("upload")} className="px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-lg text-sm">
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{step === "review" && (
|
||||||
|
<button onClick={() => setStep("map")} className="px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-lg text-sm">
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div className="flex-1" />
|
||||||
|
<button onClick={onClose} className="px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-lg text-sm">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
{step === "map" && (
|
||||||
|
<button onClick={handleNext} className="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg text-sm font-medium">
|
||||||
|
Next →
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{step === "review" && (
|
||||||
|
<button
|
||||||
|
onClick={handleImport}
|
||||||
|
disabled={importCSV.isPending || editedRows.length === 0}
|
||||||
|
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg text-sm font-medium disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{importCSV.isPending ? "Importing..." : `Import ${editedRows.length} transactions`}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ const NAV_ITEMS = [
|
|||||||
{ href: "/merchants", label: "Merchants", icon: "store" },
|
{ href: "/merchants", label: "Merchants", icon: "store" },
|
||||||
{ href: "/tags", label: "Tags", icon: "tag" },
|
{ href: "/tags", label: "Tags", icon: "tag" },
|
||||||
{ href: "/rules", label: "Rules", icon: "settings" },
|
{ href: "/rules", label: "Rules", icon: "settings" },
|
||||||
|
{ href: "/reconcile", label: "Reconcile", icon: "git-merge" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const ICONS: Record<string, React.ReactNode> = {
|
const ICONS: Record<string, React.ReactNode> = {
|
||||||
@@ -52,6 +53,11 @@ const ICONS: Record<string, React.ReactNode> = {
|
|||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m1.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m1.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||||
</svg>
|
</svg>
|
||||||
),
|
),
|
||||||
|
"git-merge": (
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 3v12M18 9a3 3 0 100-6 3 3 0 000 6zm0 0v12M6 15a3 3 0 100 6 3 3 0 000-6zm0 0c0-4 3-6 6-6h6" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
store: (
|
store: (
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
export type DateFormat = "DD/MM/YYYY" | "YYYY-MM-DD" | "MM/DD/YYYY" | "M/D/YYYY";
|
||||||
|
|
||||||
|
export interface ColumnMapping {
|
||||||
|
dateCol: string;
|
||||||
|
descriptionCol: string;
|
||||||
|
amountMode: "single" | "debit_credit";
|
||||||
|
amountCol?: string;
|
||||||
|
debitCol?: string;
|
||||||
|
creditCol?: string;
|
||||||
|
merchantCol?: string;
|
||||||
|
categoryCol?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BankPreset {
|
||||||
|
bankName: string;
|
||||||
|
mapping: ColumnMapping;
|
||||||
|
dateFormat: DateFormat;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParsedTransaction {
|
||||||
|
date: string;
|
||||||
|
description: string;
|
||||||
|
amount: number;
|
||||||
|
transaction_type: string;
|
||||||
|
merchant_name?: string;
|
||||||
|
category?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseCSVRows(text: string): string[][] {
|
||||||
|
const rows: string[][] = [];
|
||||||
|
let row: string[] = [];
|
||||||
|
let field = "";
|
||||||
|
let inQuotes = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < text.length; i++) {
|
||||||
|
const c = text[i];
|
||||||
|
const next = text[i + 1];
|
||||||
|
if (inQuotes) {
|
||||||
|
if (c === '"' && next === '"') { field += '"'; i++; }
|
||||||
|
else if (c === '"') { inQuotes = false; }
|
||||||
|
else { field += c; }
|
||||||
|
} else {
|
||||||
|
if (c === '"') { inQuotes = true; }
|
||||||
|
else if (c === ',') { row.push(field.trim()); field = ""; }
|
||||||
|
else if (c === '\r' && next === '\n') {
|
||||||
|
row.push(field.trim());
|
||||||
|
if (row.some((f) => f !== "")) rows.push(row);
|
||||||
|
row = []; field = ""; i++;
|
||||||
|
} else if (c === '\n' || c === '\r') {
|
||||||
|
row.push(field.trim());
|
||||||
|
if (row.some((f) => f !== "")) rows.push(row);
|
||||||
|
row = []; field = "";
|
||||||
|
} else { field += c; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (field || row.length > 0) {
|
||||||
|
row.push(field.trim());
|
||||||
|
if (row.some((f) => f !== "")) rows.push(row);
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseDate(raw: string, format: DateFormat): string {
|
||||||
|
const s = raw.trim();
|
||||||
|
try {
|
||||||
|
if (format === "DD/MM/YYYY") {
|
||||||
|
const [d, m, y] = s.split("/");
|
||||||
|
if (!d || !m || !y || y.length !== 4) return "";
|
||||||
|
return `${y}-${m.padStart(2, "0")}-${d.padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
if (format === "YYYY-MM-DD") {
|
||||||
|
return /^\d{4}-\d{2}-\d{2}$/.test(s) ? s : "";
|
||||||
|
}
|
||||||
|
if (format === "MM/DD/YYYY" || format === "M/D/YYYY") {
|
||||||
|
const [m, d, y] = s.split("/");
|
||||||
|
if (!d || !m || !y || y.length !== 4) return "";
|
||||||
|
return `${y}-${m.padStart(2, "0")}-${d.padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
} catch { return ""; }
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function detectHasHeaders(rows: string[][], dateFormat: DateFormat): boolean {
|
||||||
|
if (rows.length === 0) return false;
|
||||||
|
return parseDate(rows[0][0] ?? "", dateFormat) === "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getColumnLabels(rows: string[][], hasHeaders: boolean): string[] {
|
||||||
|
if (hasHeaders && rows.length > 0) {
|
||||||
|
return rows[0].map((h, i) => h || `Column ${i + 1}`);
|
||||||
|
}
|
||||||
|
const maxCols = rows.reduce((m, r) => Math.max(m, r.length), 0);
|
||||||
|
return Array.from({ length: maxCols }, (_, i) => `Column ${i + 1}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDataRows(rows: string[][], hasHeaders: boolean): string[][] {
|
||||||
|
return hasHeaders ? rows.slice(1) : rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyMapping(
|
||||||
|
dataRows: string[][],
|
||||||
|
columnLabels: string[],
|
||||||
|
mapping: ColumnMapping,
|
||||||
|
dateFormat: DateFormat
|
||||||
|
): ParsedTransaction[] {
|
||||||
|
const idx = (name: string) => columnLabels.indexOf(name);
|
||||||
|
const dateIdx = idx(mapping.dateCol);
|
||||||
|
const descIdx = idx(mapping.descriptionCol);
|
||||||
|
const merchantIdx = mapping.merchantCol ? idx(mapping.merchantCol) : -1;
|
||||||
|
const categoryIdx = mapping.categoryCol ? idx(mapping.categoryCol) : -1;
|
||||||
|
|
||||||
|
const results: ParsedTransaction[] = [];
|
||||||
|
for (const row of dataRows) {
|
||||||
|
const date = parseDate(row[dateIdx] ?? "", dateFormat);
|
||||||
|
const description = (row[descIdx] ?? "").trim();
|
||||||
|
if (!date || !description) continue;
|
||||||
|
|
||||||
|
let amount = 0;
|
||||||
|
let transaction_type = "debit";
|
||||||
|
|
||||||
|
if (mapping.amountMode === "single" && mapping.amountCol) {
|
||||||
|
const raw = (row[idx(mapping.amountCol)] ?? "").replace(/[^\d.\-+]/g, "");
|
||||||
|
const val = parseFloat(raw);
|
||||||
|
if (isNaN(val) || val === 0) continue;
|
||||||
|
amount = Math.abs(val);
|
||||||
|
transaction_type = val < 0 ? "debit" : "credit";
|
||||||
|
} else if (mapping.amountMode === "debit_credit") {
|
||||||
|
const debitIdx = mapping.debitCol ? idx(mapping.debitCol) : -1;
|
||||||
|
const creditIdx = mapping.creditCol ? idx(mapping.creditCol) : -1;
|
||||||
|
const dVal = parseFloat((row[debitIdx] ?? "").replace(/[^\d.]/g, ""));
|
||||||
|
const cVal = parseFloat((row[creditIdx] ?? "").replace(/[^\d.]/g, ""));
|
||||||
|
if (!isNaN(dVal) && dVal > 0) { amount = dVal; transaction_type = "debit"; }
|
||||||
|
else if (!isNaN(cVal) && cVal > 0) { amount = cVal; transaction_type = "credit"; }
|
||||||
|
else continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (amount <= 0) continue;
|
||||||
|
const tx: ParsedTransaction = { date, description, amount, transaction_type };
|
||||||
|
if (merchantIdx >= 0 && row[merchantIdx]) tx.merchant_name = row[merchantIdx].trim();
|
||||||
|
if (categoryIdx >= 0 && row[categoryIdx]) tx.category = row[categoryIdx].trim();
|
||||||
|
results.push(tx);
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveBankPreset(preset: BankPreset): void {
|
||||||
|
try {
|
||||||
|
const existing = loadBankPresets().filter((p) => p.bankName !== preset.bankName);
|
||||||
|
localStorage.setItem("csv-presets", JSON.stringify([...existing, preset]));
|
||||||
|
} catch { /* localStorage unavailable */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadBankPresets(): BankPreset[] {
|
||||||
|
try { return JSON.parse(localStorage.getItem("csv-presets") || "[]"); }
|
||||||
|
catch { return []; }
|
||||||
|
}
|
||||||
@@ -727,6 +727,66 @@ export interface MerchantTxnRow {
|
|||||||
statement_id: number;
|
statement_id: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- CSV Import & Reconcile ---
|
||||||
|
|
||||||
|
export function useImportCSV() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (body: {
|
||||||
|
bank_name: string;
|
||||||
|
transactions: {
|
||||||
|
date: string; description: string; amount: number; transaction_type: string;
|
||||||
|
merchant_name?: string; foreign_currency_amount?: number; foreign_currency_code?: string; category?: string;
|
||||||
|
}[];
|
||||||
|
}) => {
|
||||||
|
const res = await fetch("/api/import/csv", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error((await res.json()).error || "Import failed");
|
||||||
|
return res.json() as Promise<{ inserted: number }>;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["transactions"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["tags"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["reconcile-pending"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
import type { ManualTxWithMatches } from "./queries";
|
||||||
|
|
||||||
|
export function usePendingReconciliations() {
|
||||||
|
return useQuery<ManualTxWithMatches[]>({
|
||||||
|
queryKey: ["reconcile-pending"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await fetch("/api/reconcile/pending");
|
||||||
|
if (!res.ok) throw new Error("Failed to fetch");
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useReconcile() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (matches: { manual_id: number; statement_tx_id: number }[]) => {
|
||||||
|
const res = await fetch("/api/transactions/reconcile", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ matches }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error((await res.json()).error || "Reconcile failed");
|
||||||
|
return res.json() as Promise<{ reconciled: number }>;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["transactions"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["reconcile-pending"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useMerchantTransactions(merchant: string | null) {
|
export function useMerchantTransactions(merchant: string | null) {
|
||||||
return useQuery<{ transactions: MerchantTxnRow[] }>({
|
return useQuery<{ transactions: MerchantTxnRow[] }>({
|
||||||
queryKey: ["analytics", "merchant-txns", merchant],
|
queryKey: ["analytics", "merchant-txns", merchant],
|
||||||
|
|||||||
+180
-1
@@ -85,7 +85,10 @@ interface TransactionFilters {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getTransactions(ownerId: number, filters: TransactionFilters) {
|
export async function getTransactions(ownerId: number, filters: TransactionFilters) {
|
||||||
const conditions: string[] = [`(COALESCE(t.owner_id, s.owner_id) = $1 OR EXISTS (SELECT 1 FROM transaction_splits ts_me WHERE ts_me.transaction_id = t.id AND ts_me.participant_id = $1))`];
|
const conditions: string[] = [
|
||||||
|
`(COALESCE(t.owner_id, s.owner_id) = $1 OR EXISTS (SELECT 1 FROM transaction_splits ts_me WHERE ts_me.transaction_id = t.id AND ts_me.participant_id = $1))`,
|
||||||
|
`NOT (t.statement_id IS NULL AND t.reconciled_with_id IS NOT NULL)`,
|
||||||
|
];
|
||||||
const params: unknown[] = [ownerId];
|
const params: unknown[] = [ownerId];
|
||||||
let paramIdx = 2;
|
let paramIdx = 2;
|
||||||
|
|
||||||
@@ -344,6 +347,182 @@ export interface SharedTransactionRow extends TransactionRow {
|
|||||||
splits: { participant_id: number; name: string; share_percent: number; settled: boolean }[];
|
splits: { participant_id: number; name: string; share_percent: number; settled: boolean }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function ensureTag(name: string, color: string): Promise<number> {
|
||||||
|
const rows = await queryRaw<{ id: number }>(
|
||||||
|
`INSERT INTO tags (name, color) VALUES ($1, $2)
|
||||||
|
ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name
|
||||||
|
RETURNING id`,
|
||||||
|
[name, color]
|
||||||
|
);
|
||||||
|
return rows[0].id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function batchInsertCSVTransactions(
|
||||||
|
ownerId: number,
|
||||||
|
rows: {
|
||||||
|
date: string;
|
||||||
|
description: string;
|
||||||
|
amount: number;
|
||||||
|
transaction_type: string;
|
||||||
|
merchant_name?: string;
|
||||||
|
foreign_currency_amount?: number;
|
||||||
|
foreign_currency_code?: string;
|
||||||
|
category?: string;
|
||||||
|
}[],
|
||||||
|
tagId: number
|
||||||
|
): Promise<number> {
|
||||||
|
if (rows.length === 0) return 0;
|
||||||
|
|
||||||
|
const baseRows = await queryRaw<{ base: number }>(
|
||||||
|
`SELECT COALESCE(MAX(row_index), -1) as base FROM transactions WHERE owner_id = $1 AND statement_id IS NULL`,
|
||||||
|
[ownerId]
|
||||||
|
);
|
||||||
|
const base = Number(baseRows[0].base);
|
||||||
|
|
||||||
|
const valueClauses: string[] = [];
|
||||||
|
const params: unknown[] = [ownerId];
|
||||||
|
let p = 2;
|
||||||
|
rows.forEach((r, i) => {
|
||||||
|
valueClauses.push(`(NULL, $1, $${p++}, $${p++}, $${p++}, $${p++}, $${p++}, $${p++}, $${p++}, ${base + 1 + i})`);
|
||||||
|
params.push(r.date, r.description, r.amount, r.transaction_type, r.merchant_name ?? null, r.foreign_currency_amount ?? null, r.foreign_currency_code ?? null);
|
||||||
|
});
|
||||||
|
|
||||||
|
const txIds = await queryRaw<{ id: number }>(
|
||||||
|
`INSERT INTO transactions (statement_id, owner_id, transaction_date, description, amount, transaction_type, merchant_name, foreign_currency_amount, foreign_currency_code, row_index)
|
||||||
|
VALUES ${valueClauses.join(", ")}
|
||||||
|
RETURNING id`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
if (txIds.length > 0) {
|
||||||
|
const tagValueClauses = txIds.map((_, i) => `($${i * 2 + 1}, $${i * 2 + 2})`);
|
||||||
|
const tagParams: unknown[] = txIds.flatMap((r) => [r.id, tagId]);
|
||||||
|
await queryRaw(
|
||||||
|
`INSERT INTO transaction_tags (transaction_id, tag_id) VALUES ${tagValueClauses.join(", ")} ON CONFLICT DO NOTHING`,
|
||||||
|
tagParams
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return txIds.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PotentialMatch {
|
||||||
|
id: number;
|
||||||
|
transaction_date: string;
|
||||||
|
description: string;
|
||||||
|
amount: number;
|
||||||
|
transaction_type: string;
|
||||||
|
effective_merchant: string;
|
||||||
|
effective_category: string;
|
||||||
|
bank_name: string;
|
||||||
|
billing_end_date: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ManualTxWithMatches extends TransactionRow {
|
||||||
|
matches: PotentialMatch[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPendingReconciliations(ownerId: number): Promise<ManualTxWithMatches[]> {
|
||||||
|
// Fetch all unreconciled manual transactions
|
||||||
|
const raw = await queryRaw<TransactionRow & { tags: string | TagRow[]; splits: string | TransactionRow["splits"] }>(
|
||||||
|
`SELECT t.*,
|
||||||
|
o.category_override, o.merchant_normalized as merchant_override, o.notes, o.my_share_percent,
|
||||||
|
COALESCE(o.category_override, t.category) as effective_category,
|
||||||
|
COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name) as effective_merchant,
|
||||||
|
'Manual' as bank_name,
|
||||||
|
t.owner_id,
|
||||||
|
p.name as owner_name,
|
||||||
|
txn_tags.tags,
|
||||||
|
txn_splits.splits
|
||||||
|
FROM transactions t
|
||||||
|
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
|
||||||
|
LEFT JOIN participants p ON p.id = t.owner_id
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT COALESCE(json_agg(json_build_object('id', tg.id, 'name', tg.name, 'color', tg.color) ORDER BY tg.name), '[]'::json) as tags
|
||||||
|
FROM transaction_tags tt JOIN tags tg ON tg.id = tt.tag_id
|
||||||
|
WHERE tt.transaction_id = t.id
|
||||||
|
) txn_tags ON true
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT COALESCE(json_agg(json_build_object('participant_id', ts.participant_id, 'name', sp.name, 'share_percent', ts.share_percent, 'settled', ts.settled) ORDER BY sp.name), '[]'::json) as splits
|
||||||
|
FROM transaction_splits ts JOIN participants sp ON sp.id = ts.participant_id
|
||||||
|
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
|
||||||
|
ORDER BY t.transaction_date DESC, t.row_index ASC`,
|
||||||
|
[ownerId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const manualTxs = raw.map((r) => ({
|
||||||
|
...r,
|
||||||
|
tags: typeof r.tags === "string" ? JSON.parse(r.tags) : (r.tags ?? []),
|
||||||
|
splits: typeof r.splits === "string" ? JSON.parse(r.splits) : (r.splits ?? []),
|
||||||
|
})) as TransactionRow[];
|
||||||
|
|
||||||
|
if (manualTxs.length === 0) return [];
|
||||||
|
|
||||||
|
// Fetch all potential matches in one query using window function
|
||||||
|
const matchRows = await queryRaw<PotentialMatch & { manual_id: number; rn: number }>(
|
||||||
|
`SELECT manual_id, id, transaction_date, description, amount, transaction_type,
|
||||||
|
effective_merchant, effective_category, bank_name, billing_end_date
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
|
m.id AS manual_id,
|
||||||
|
t.id,
|
||||||
|
t.transaction_date,
|
||||||
|
t.description,
|
||||||
|
t.amount,
|
||||||
|
t.transaction_type,
|
||||||
|
COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name, '') AS effective_merchant,
|
||||||
|
COALESCE(o.category_override, t.category, '') AS effective_category,
|
||||||
|
s.bank_name,
|
||||||
|
s.billing_end_date,
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY m.id
|
||||||
|
ORDER BY ABS(t.amount - m.amount), ABS(t.transaction_date - m.transaction_date)
|
||||||
|
) AS rn
|
||||||
|
FROM transactions m
|
||||||
|
JOIN transactions t ON t.statement_id IS NOT NULL
|
||||||
|
AND t.transaction_date BETWEEN m.transaction_date - 3 AND m.transaction_date + 3
|
||||||
|
AND t.amount BETWEEN m.amount * 0.99 AND m.amount * 1.01
|
||||||
|
JOIN statements s ON s.id = t.statement_id
|
||||||
|
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
|
||||||
|
WHERE m.statement_id IS NULL
|
||||||
|
AND m.owner_id = $1
|
||||||
|
AND m.reconciled_with_id IS NULL
|
||||||
|
AND COALESCE(t.owner_id, s.owner_id) = $1
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM transactions mt WHERE mt.reconciled_with_id = t.id
|
||||||
|
)
|
||||||
|
) sq
|
||||||
|
WHERE rn <= 5
|
||||||
|
ORDER BY manual_id, rn`,
|
||||||
|
[ownerId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Group matches by manual_id
|
||||||
|
const matchesByManualId = new Map<number, PotentialMatch[]>();
|
||||||
|
for (const row of matchRows) {
|
||||||
|
const list = matchesByManualId.get(row.manual_id) ?? [];
|
||||||
|
list.push({
|
||||||
|
id: row.id,
|
||||||
|
transaction_date: row.transaction_date,
|
||||||
|
description: row.description,
|
||||||
|
amount: row.amount,
|
||||||
|
transaction_type: row.transaction_type,
|
||||||
|
effective_merchant: row.effective_merchant,
|
||||||
|
effective_category: row.effective_category,
|
||||||
|
bank_name: row.bank_name,
|
||||||
|
billing_end_date: row.billing_end_date,
|
||||||
|
});
|
||||||
|
matchesByManualId.set(row.manual_id, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
return manualTxs.map((tx) => ({
|
||||||
|
...tx,
|
||||||
|
matches: matchesByManualId.get(tx.id) ?? [],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
export async function getTags() {
|
export async function getTags() {
|
||||||
return queryRaw<TagRow & { transaction_count: number }>(`
|
return queryRaw<TagRow & { transaction_count: number }>(`
|
||||||
SELECT tg.id, tg.name, tg.color,
|
SELECT tg.id, tg.name, tg.color,
|
||||||
|
|||||||
Reference in New Issue
Block a user