feat(transactions): manual transaction support and multi-owner query infrastructure
- Add POST /api/transactions to create manual transactions (statement_id=NULL, owner_id set directly) - Queries switch from JOIN to LEFT JOIN statements so manual transactions are visible - COALESCE(t.owner_id, s.owner_id) throughout for owner resolution - Add "Manual" bank filter option in getTransactions - Search extended to include merchant_normalized override - Split data fetched via lateral subquery on every transaction row - getParticipantBalances rewritten as UNION for bidirectional net balances (credits/refunds negate, split from either side of the relationship) - getSharedTransactions: remove my_share_percent from SELECT (fixes GROUP BY error), WHERE rewritten as two distinct cases (owner with others split vs participant on others' txn) - getTransactions: OR EXISTS condition so split participants see shared transactions - add-transaction-modal component for creating manual transactions with splits - 0008_my_share_percent migration adds my_share_percent to transaction_overrides
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "transaction_overrides" ADD COLUMN "my_share_percent" DECIMAL(5,2);
|
||||||
@@ -13,6 +13,7 @@ model transaction_overrides {
|
|||||||
merchant_normalized String?
|
merchant_normalized String?
|
||||||
category_override String?
|
category_override String?
|
||||||
notes String?
|
notes String?
|
||||||
|
my_share_percent Decimal? @db.Decimal(5, 2)
|
||||||
updated_at DateTime @default(now()) @updatedAt
|
updated_at DateTime @default(now()) @updatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { getCurrentUser } from "@/lib/auth";
|
import { getCurrentUser } from "@/lib/auth";
|
||||||
import { getTransactions } from "@/lib/queries";
|
import { getTransactions } from "@/lib/queries";
|
||||||
|
import { queryRaw } from "@/lib/db";
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
const user = await getCurrentUser(req);
|
const user = await getCurrentUser(req);
|
||||||
@@ -23,3 +24,54 @@ export async function GET(req: NextRequest) {
|
|||||||
|
|
||||||
return NextResponse.json(result);
|
return NextResponse.json(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
date: string;
|
||||||
|
description: string;
|
||||||
|
amount: number;
|
||||||
|
transaction_type?: string;
|
||||||
|
merchant_normalized?: string;
|
||||||
|
category?: string;
|
||||||
|
splits?: { participant_id: number; share_percent: number }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!body.date || !body.description || body.amount == null) {
|
||||||
|
return NextResponse.json({ error: "date, description, amount are required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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, (
|
||||||
|
SELECT COALESCE(MAX(row_index), -1) + 1 FROM transactions WHERE owner_id = $1 AND statement_id IS NULL
|
||||||
|
))
|
||||||
|
RETURNING id`,
|
||||||
|
[
|
||||||
|
user.id,
|
||||||
|
body.date,
|
||||||
|
body.description,
|
||||||
|
body.amount,
|
||||||
|
body.transaction_type || "debit",
|
||||||
|
body.merchant_normalized || null,
|
||||||
|
body.category || null,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
const transactionId = txRows[0].id;
|
||||||
|
|
||||||
|
// Insert splits if provided
|
||||||
|
if (body.splits?.length) {
|
||||||
|
for (const s of body.splits) {
|
||||||
|
await queryRaw(
|
||||||
|
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent)
|
||||||
|
VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`,
|
||||||
|
[transactionId, s.participant_id, s.share_percent]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ id: transactionId }, { status: 201 });
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,215 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useCreateTransaction, useParticipants } from "@/lib/hooks";
|
||||||
|
import { CATEGORIES, formatCategory } from "@/lib/categories";
|
||||||
|
|
||||||
|
const TRANSACTION_TYPES = ["debit", "credit", "payment", "refund", "fee", "interest", "transfer"];
|
||||||
|
|
||||||
|
interface Prefill {
|
||||||
|
date?: string;
|
||||||
|
description?: string;
|
||||||
|
amount?: number;
|
||||||
|
transaction_type?: string;
|
||||||
|
merchant_normalized?: string;
|
||||||
|
category?: string;
|
||||||
|
splits?: { participant_id: number; share_percent: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AddTransactionModal({
|
||||||
|
prefill,
|
||||||
|
title,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
prefill?: Prefill;
|
||||||
|
title?: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const createTransaction = useCreateTransaction();
|
||||||
|
const { data: participants = [] } = useParticipants();
|
||||||
|
|
||||||
|
const [date, setDate] = useState(prefill?.date ?? new Date().toISOString().slice(0, 10));
|
||||||
|
const [description, setDescription] = useState(prefill?.description ?? "");
|
||||||
|
const [amount, setAmount] = useState(prefill?.amount != null ? String(prefill.amount) : "");
|
||||||
|
const [type, setType] = useState(prefill?.transaction_type ?? "debit");
|
||||||
|
const [merchant, setMerchant] = useState(prefill?.merchant_normalized ?? "");
|
||||||
|
const [category, setCategory] = useState(prefill?.category ?? "");
|
||||||
|
const [splits, setSplits] = useState<{ participant_id: number; share_percent: number }[]>(
|
||||||
|
prefill?.splits ?? []
|
||||||
|
);
|
||||||
|
|
||||||
|
function addSplit() {
|
||||||
|
if (!participants.length) return;
|
||||||
|
setSplits([...splits, { participant_id: participants[0].id, share_percent: 50 }]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSplit(i: number, patch: Partial<{ participant_id: number; share_percent: number }>) {
|
||||||
|
setSplits(splits.map((s, idx) => (idx === i ? { ...s, ...patch } : s)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeSplit(i: number) {
|
||||||
|
setSplits(splits.filter((_, idx) => idx !== i));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
await createTransaction.mutateAsync({
|
||||||
|
date,
|
||||||
|
description,
|
||||||
|
amount: parseFloat(amount),
|
||||||
|
transaction_type: type,
|
||||||
|
merchant_normalized: merchant || undefined,
|
||||||
|
category: category || undefined,
|
||||||
|
splits: splits.length ? splits : undefined,
|
||||||
|
});
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
const splitTotal = splits.reduce((s, e) => s + (e.share_percent || 0), 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={onClose}>
|
||||||
|
<div
|
||||||
|
className="bg-zinc-900 border border-zinc-700 rounded-xl p-6 w-full max-w-md shadow-2xl space-y-4"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h3 className="font-semibold text-sm text-zinc-300">{title ?? "Add Transaction"}</h3>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-3">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-zinc-500 mb-1">Date</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
required
|
||||||
|
value={date}
|
||||||
|
onChange={(e) => setDate(e.target.value)}
|
||||||
|
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">Amount</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
required
|
||||||
|
value={amount}
|
||||||
|
onChange={(e) => setAmount(e.target.value)}
|
||||||
|
placeholder="0.00"
|
||||||
|
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-zinc-500 mb-1">Description</label>
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder="e.g. Coles Wyndham Vale"
|
||||||
|
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-zinc-500 mb-1">Type</label>
|
||||||
|
<select
|
||||||
|
value={type}
|
||||||
|
onChange={(e) => setType(e.target.value)}
|
||||||
|
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
|
||||||
|
>
|
||||||
|
{TRANSACTION_TYPES.map((t) => (
|
||||||
|
<option key={t} value={t}>{t}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-zinc-500 mb-1">Category</label>
|
||||||
|
<select
|
||||||
|
value={category}
|
||||||
|
onChange={(e) => setCategory(e.target.value)}
|
||||||
|
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
|
||||||
|
>
|
||||||
|
<option value="">— none —</option>
|
||||||
|
{CATEGORIES.map((c) => (
|
||||||
|
<option key={c} value={c}>{formatCategory(c)}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-zinc-500 mb-1">Merchant (optional)</label>
|
||||||
|
<input
|
||||||
|
value={merchant}
|
||||||
|
onChange={(e) => setMerchant(e.target.value)}
|
||||||
|
placeholder="Normalized merchant name"
|
||||||
|
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Splits */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<label className="text-xs text-zinc-500">
|
||||||
|
Splits (optional)
|
||||||
|
{splits.length > 0 && (
|
||||||
|
<span className={`ml-2 ${splitTotal === 100 ? "text-emerald-400" : "text-amber-400"}`}>
|
||||||
|
{splitTotal}%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
{participants.length > 0 && (
|
||||||
|
<button type="button" onClick={addSplit} className="text-xs text-indigo-400 hover:text-indigo-300">
|
||||||
|
+ Add
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{splits.map((s, i) => (
|
||||||
|
<div key={i} className="flex gap-2 mb-1.5 items-center">
|
||||||
|
<select
|
||||||
|
value={s.participant_id}
|
||||||
|
onChange={(e) => updateSplit(i, { participant_id: Number(e.target.value) })}
|
||||||
|
className="flex-1 bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
|
||||||
|
>
|
||||||
|
{participants.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>{p.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
value={s.share_percent}
|
||||||
|
onChange={(e) => updateSplit(i, { share_percent: Number(e.target.value) })}
|
||||||
|
className="w-16 bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-zinc-500">%</span>
|
||||||
|
<button type="button" onClick={() => removeSplit(i)} className="text-zinc-500 hover:text-red-400 text-lg leading-none">×</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 pt-1">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={createTransaction.isPending}
|
||||||
|
className="flex-1 px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg text-sm font-medium disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{createTransaction.isPending ? "Saving..." : "Save Transaction"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-lg text-sm"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+77
-3
@@ -83,6 +83,35 @@ export function useBanks() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useCreateTransaction() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (data: {
|
||||||
|
date: string;
|
||||||
|
description: string;
|
||||||
|
amount: number;
|
||||||
|
transaction_type?: string;
|
||||||
|
merchant_normalized?: string;
|
||||||
|
category?: string;
|
||||||
|
splits?: { participant_id: number; share_percent: number }[];
|
||||||
|
}) => {
|
||||||
|
const res = await fetch("/api/transactions", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error((await res.json()).error || "Failed to create transaction");
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["transactions"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["splits"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["shared-transactions"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["participant-balances"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useUpdateTransaction() {
|
export function useUpdateTransaction() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
@@ -95,6 +124,10 @@ export function useUpdateTransaction() {
|
|||||||
merchant_normalized?: string;
|
merchant_normalized?: string;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
transaction_type?: string;
|
transaction_type?: string;
|
||||||
|
my_share_percent?: number | null;
|
||||||
|
description?: string;
|
||||||
|
amount?: number;
|
||||||
|
transaction_date?: string;
|
||||||
}) => {
|
}) => {
|
||||||
const res = await fetch(`/api/transactions/${id}`, {
|
const res = await fetch(`/api/transactions/${id}`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
@@ -106,6 +139,7 @@ export function useUpdateTransaction() {
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ["transactions"] });
|
qc.invalidateQueries({ queryKey: ["transactions"] });
|
||||||
qc.invalidateQueries({ queryKey: ["transaction"] });
|
qc.invalidateQueries({ queryKey: ["transaction"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["analytics"] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -421,14 +455,54 @@ export function useDeleteRule() {
|
|||||||
export function useApplyRules() {
|
export function useApplyRules() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async (splitFrom?: string) => {
|
||||||
const res = await fetch("/api/rules/apply", { method: "POST" });
|
const res = await fetch("/api/rules/apply", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ splitFrom: splitFrom || null }),
|
||||||
|
});
|
||||||
if (!res.ok) throw new Error("Failed to apply rules");
|
if (!res.ok) throw new Error("Failed to apply rules");
|
||||||
return res.json() as Promise<{ matched: number; transactions_affected: number }>;
|
return res.json() as Promise<{ id: number; matched: number; transactions_affected: number }>;
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ["transactions"] });
|
qc.invalidateQueries({ queryKey: ["transactions"] });
|
||||||
qc.invalidateQueries({ queryKey: ["rules"] });
|
qc.invalidateQueries({ queryKey: ["rules"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["rule-runs"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuleRun {
|
||||||
|
id: number;
|
||||||
|
applied_at: string;
|
||||||
|
split_from: string | null;
|
||||||
|
matched: number;
|
||||||
|
transactions_affected: number;
|
||||||
|
reverted_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRuleRuns() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["rule-runs"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await fetch("/api/rules/apply");
|
||||||
|
if (!res.ok) throw new Error("Failed to fetch rule runs");
|
||||||
|
return res.json() as Promise<RuleRun[]>;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRevertRuleRun() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (runId: number) => {
|
||||||
|
const res = await fetch(`/api/rules/apply/${runId}/revert`, { method: "POST" });
|
||||||
|
if (!res.ok) throw new Error("Failed to revert run");
|
||||||
|
return res.json() as Promise<{ reverted: number }>;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["transactions"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["rule-runs"] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+78
-30
@@ -8,7 +8,7 @@ export interface TagRow {
|
|||||||
|
|
||||||
export interface TransactionRow {
|
export interface TransactionRow {
|
||||||
id: number;
|
id: number;
|
||||||
statement_id: number;
|
statement_id: number | null;
|
||||||
transaction_date: string;
|
transaction_date: string;
|
||||||
description: string;
|
description: string;
|
||||||
amount: number;
|
amount: number;
|
||||||
@@ -26,14 +26,17 @@ export interface TransactionRow {
|
|||||||
category_override: string | null;
|
category_override: string | null;
|
||||||
merchant_override: string | null;
|
merchant_override: string | null;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
|
my_share_percent: number | null;
|
||||||
effective_category: string;
|
effective_category: string;
|
||||||
effective_merchant: string;
|
effective_merchant: string;
|
||||||
// statement context
|
// statement context (null for manual transactions)
|
||||||
bank_name: string;
|
bank_name: string;
|
||||||
owner_id: number;
|
owner_id: number;
|
||||||
owner_name: string;
|
owner_name: string;
|
||||||
// tags
|
// tags
|
||||||
tags: TagRow[];
|
tags: TagRow[];
|
||||||
|
// splits
|
||||||
|
splits: { participant_id: number; name: string; share_percent: number; settled: boolean }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StatementRow {
|
export interface StatementRow {
|
||||||
@@ -79,7 +82,7 @@ interface TransactionFilters {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getTransactions(ownerId: number, filters: TransactionFilters) {
|
export async function getTransactions(ownerId: number, filters: TransactionFilters) {
|
||||||
const conditions: string[] = [`s.owner_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))`];
|
||||||
const params: unknown[] = [ownerId];
|
const params: unknown[] = [ownerId];
|
||||||
let paramIdx = 2;
|
let paramIdx = 2;
|
||||||
|
|
||||||
@@ -96,11 +99,15 @@ export async function getTransactions(ownerId: number, filters: TransactionFilte
|
|||||||
params.push(filters.category);
|
params.push(filters.category);
|
||||||
}
|
}
|
||||||
if (filters.bank_name) {
|
if (filters.bank_name) {
|
||||||
|
if (filters.bank_name === "Manual") {
|
||||||
|
conditions.push(`t.statement_id IS NULL`);
|
||||||
|
} else {
|
||||||
conditions.push(`s.bank_name = $${paramIdx++}`);
|
conditions.push(`s.bank_name = $${paramIdx++}`);
|
||||||
params.push(filters.bank_name);
|
params.push(filters.bank_name);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if (filters.search) {
|
if (filters.search) {
|
||||||
conditions.push(`(t.description ILIKE $${paramIdx} OR t.merchant_name ILIKE $${paramIdx})`);
|
conditions.push(`(t.description ILIKE $${paramIdx} OR t.merchant_name ILIKE $${paramIdx} OR COALESCE(o.merchant_normalized, t.merchant_normalized) ILIKE $${paramIdx})`);
|
||||||
params.push(`%${filters.search}%`);
|
params.push(`%${filters.search}%`);
|
||||||
paramIdx++;
|
paramIdx++;
|
||||||
}
|
}
|
||||||
@@ -124,7 +131,7 @@ export async function getTransactions(ownerId: number, filters: TransactionFilte
|
|||||||
SELECT COUNT(*)::int as total
|
SELECT COUNT(*)::int as total
|
||||||
FROM transactions t
|
FROM transactions t
|
||||||
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
|
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
|
||||||
JOIN statements s ON s.id = t.statement_id
|
LEFT JOIN statements s ON s.id = t.statement_id
|
||||||
${where}
|
${where}
|
||||||
`;
|
`;
|
||||||
const countResult = await queryRaw<{ total: number }>(countSql, params);
|
const countResult = await queryRaw<{ total: number }>(countSql, params);
|
||||||
@@ -132,32 +139,41 @@ export async function getTransactions(ownerId: number, filters: TransactionFilte
|
|||||||
|
|
||||||
const dataSql = `
|
const dataSql = `
|
||||||
SELECT t.*,
|
SELECT t.*,
|
||||||
o.category_override, o.merchant_normalized as merchant_override, o.notes,
|
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.category_override, t.category) as effective_category,
|
||||||
COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name) as effective_merchant,
|
COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name) as effective_merchant,
|
||||||
s.bank_name, s.owner_id,
|
COALESCE(s.bank_name, 'Manual') as bank_name,
|
||||||
|
COALESCE(t.owner_id, s.owner_id) as owner_id,
|
||||||
p.name as owner_name,
|
p.name as owner_name,
|
||||||
txn_tags.tags
|
txn_tags.tags,
|
||||||
|
txn_splits.splits
|
||||||
FROM transactions t
|
FROM transactions t
|
||||||
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
|
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
|
||||||
JOIN statements s ON s.id = t.statement_id
|
LEFT JOIN statements s ON s.id = t.statement_id
|
||||||
LEFT JOIN participants p ON p.id = s.owner_id
|
LEFT JOIN participants p ON p.id = COALESCE(t.owner_id, s.owner_id)
|
||||||
LEFT JOIN LATERAL (
|
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
|
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
|
FROM transaction_tags tt
|
||||||
JOIN tags tg ON tg.id = tt.tag_id
|
JOIN tags tg ON tg.id = tt.tag_id
|
||||||
WHERE tt.transaction_id = t.id
|
WHERE tt.transaction_id = t.id
|
||||||
) txn_tags ON true
|
) 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}
|
${where}
|
||||||
ORDER BY ${sortCol} ${sortDir}, t.row_index ASC
|
ORDER BY ${sortCol} ${sortDir}, t.row_index ASC
|
||||||
LIMIT $${paramIdx++} OFFSET $${paramIdx++}
|
LIMIT $${paramIdx++} OFFSET $${paramIdx++}
|
||||||
`;
|
`;
|
||||||
params.push(limit, offset);
|
params.push(limit, offset);
|
||||||
|
|
||||||
const raw = await queryRaw<TransactionRow & { tags: string | TagRow[] }>(dataSql, params);
|
const raw = await queryRaw<TransactionRow & { tags: string | TagRow[]; splits: string | TransactionRow["splits"] }>(dataSql, params);
|
||||||
const data = raw.map((r) => ({
|
const data = raw.map((r) => ({
|
||||||
...r,
|
...r,
|
||||||
tags: typeof r.tags === "string" ? JSON.parse(r.tags) : (r.tags ?? []),
|
tags: typeof r.tags === "string" ? JSON.parse(r.tags) : (r.tags ?? []),
|
||||||
|
splits: typeof r.splits === "string" ? JSON.parse(r.splits) : (r.splits ?? []),
|
||||||
})) as TransactionRow[];
|
})) as TransactionRow[];
|
||||||
|
|
||||||
return { data, total, limit, offset };
|
return { data, total, limit, offset };
|
||||||
@@ -166,15 +182,16 @@ export async function getTransactions(ownerId: number, filters: TransactionFilte
|
|||||||
export async function getTransactionById(id: number) {
|
export async function getTransactionById(id: number) {
|
||||||
const sql = `
|
const sql = `
|
||||||
SELECT t.*,
|
SELECT t.*,
|
||||||
o.category_override, o.merchant_normalized as merchant_override, o.notes,
|
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.category_override, t.category) as effective_category,
|
||||||
COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name) as effective_merchant,
|
COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name) as effective_merchant,
|
||||||
s.bank_name, s.owner_id,
|
COALESCE(s.bank_name, 'Manual') as bank_name,
|
||||||
|
COALESCE(t.owner_id, s.owner_id) as owner_id,
|
||||||
p.name as owner_name
|
p.name as owner_name
|
||||||
FROM transactions t
|
FROM transactions t
|
||||||
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
|
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
|
||||||
JOIN statements s ON s.id = t.statement_id
|
LEFT JOIN statements s ON s.id = t.statement_id
|
||||||
LEFT JOIN participants p ON p.id = s.owner_id
|
LEFT JOIN participants p ON p.id = COALESCE(t.owner_id, s.owner_id)
|
||||||
WHERE t.id = $1
|
WHERE t.id = $1
|
||||||
`;
|
`;
|
||||||
const rows = await queryRaw<TransactionRow>(sql, [id]);
|
const rows = await queryRaw<TransactionRow>(sql, [id]);
|
||||||
@@ -220,8 +237,13 @@ export async function getMerchantSuggestions(search: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getBankNames() {
|
export async function getBankNames() {
|
||||||
const sql = `SELECT DISTINCT bank_name FROM statements ORDER BY bank_name`;
|
const [bankRows, manualCount] = await Promise.all([
|
||||||
return queryRaw<{ bank_name: string }>(sql);
|
queryRaw<{ bank_name: string }>(`SELECT DISTINCT bank_name FROM statements ORDER BY bank_name`),
|
||||||
|
queryRaw<{ count: number }>(`SELECT COUNT(*)::int as count FROM transactions WHERE statement_id IS NULL`),
|
||||||
|
]);
|
||||||
|
const banks = bankRows.map((r) => r.bank_name);
|
||||||
|
if (manualCount[0]?.count > 0) banks.push("Manual");
|
||||||
|
return banks;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ParticipantBalance {
|
export interface ParticipantBalance {
|
||||||
@@ -234,13 +256,35 @@ export interface ParticipantBalance {
|
|||||||
export async function getParticipantBalances(ownerId: number) {
|
export async function getParticipantBalances(ownerId: number) {
|
||||||
return queryRaw<ParticipantBalance>(`
|
return queryRaw<ParticipantBalance>(`
|
||||||
SELECT p.id, p.name,
|
SELECT p.id, p.name,
|
||||||
COALESCE(SUM(CASE WHEN ts.settled = false THEN t.amount * ts.share_percent / 100 ELSE 0 END), 0)::numeric(12,2) as total_owed,
|
COALESCE(SUM(combined.signed_amount), 0)::numeric(12,2) as total_owed,
|
||||||
COUNT(CASE WHEN ts.settled = false THEN 1 END)::int as unsettled_count
|
COALESCE(SUM(combined.unsettled_count), 0)::int as unsettled_count
|
||||||
FROM participants p
|
FROM participants p
|
||||||
LEFT JOIN transaction_splits ts ON ts.participant_id = p.id
|
LEFT JOIN (
|
||||||
LEFT JOIN transactions t ON t.id = ts.transaction_id
|
-- They owe me: their splits on transactions I own
|
||||||
|
SELECT ts.participant_id AS pid,
|
||||||
|
CASE WHEN ts.settled = false
|
||||||
|
THEN (CASE WHEN t.transaction_type IN ('debit', 'fee', 'interest') THEN t.amount ELSE -t.amount END) * ts.share_percent / 100
|
||||||
|
ELSE 0 END AS signed_amount,
|
||||||
|
CASE WHEN ts.settled = false AND t.transaction_type IN ('debit', 'fee', 'interest') THEN 1 ELSE 0 END AS unsettled_count
|
||||||
|
FROM transaction_splits ts
|
||||||
|
JOIN transactions t ON t.id = ts.transaction_id
|
||||||
LEFT JOIN statements s ON s.id = t.statement_id
|
LEFT JOIN statements s ON s.id = t.statement_id
|
||||||
WHERE (s.owner_id = $1 OR s.id IS NULL)
|
WHERE COALESCE(t.owner_id, s.owner_id) = $1 AND ts.participant_id != $1
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
-- I owe them: my splits on transactions they own
|
||||||
|
SELECT COALESCE(t.owner_id, s.owner_id) AS pid,
|
||||||
|
CASE WHEN ts.settled = false
|
||||||
|
THEN -(CASE WHEN t.transaction_type IN ('debit', 'fee', 'interest') THEN t.amount ELSE -t.amount END) * ts.share_percent / 100
|
||||||
|
ELSE 0 END AS signed_amount,
|
||||||
|
0 AS unsettled_count
|
||||||
|
FROM transaction_splits ts
|
||||||
|
JOIN transactions t ON t.id = ts.transaction_id
|
||||||
|
LEFT JOIN statements s ON s.id = t.statement_id
|
||||||
|
WHERE ts.participant_id = $1 AND COALESCE(t.owner_id, s.owner_id) != $1
|
||||||
|
) combined ON combined.pid = p.id
|
||||||
|
WHERE p.id != $1
|
||||||
GROUP BY p.id, p.name
|
GROUP BY p.id, p.name
|
||||||
ORDER BY p.name
|
ORDER BY p.name
|
||||||
`, [ownerId]);
|
`, [ownerId]);
|
||||||
@@ -267,7 +311,8 @@ export async function getSharedTransactions(ownerId: number) {
|
|||||||
o.category_override, o.merchant_normalized as merchant_override, o.notes,
|
o.category_override, o.merchant_normalized as merchant_override, o.notes,
|
||||||
COALESCE(o.category_override, t.category) as effective_category,
|
COALESCE(o.category_override, t.category) as effective_category,
|
||||||
COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name) as effective_merchant,
|
COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name) as effective_merchant,
|
||||||
s.bank_name, s.owner_id,
|
COALESCE(s.bank_name, 'Manual') as bank_name,
|
||||||
|
COALESCE(t.owner_id, s.owner_id) as owner_id,
|
||||||
p_owner.name as owner_name,
|
p_owner.name as owner_name,
|
||||||
json_agg(json_build_object(
|
json_agg(json_build_object(
|
||||||
'split_id', ts.id,
|
'split_id', ts.id,
|
||||||
@@ -280,13 +325,16 @@ export async function getSharedTransactions(ownerId: number) {
|
|||||||
JOIN transaction_splits ts ON ts.transaction_id = t.id
|
JOIN transaction_splits ts ON ts.transaction_id = t.id
|
||||||
JOIN participants p ON p.id = ts.participant_id
|
JOIN participants p ON p.id = ts.participant_id
|
||||||
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
|
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
|
||||||
JOIN statements s ON s.id = t.statement_id
|
LEFT JOIN statements s ON s.id = t.statement_id
|
||||||
LEFT JOIN participants p_owner ON p_owner.id = s.owner_id
|
LEFT JOIN participants p_owner ON p_owner.id = COALESCE(t.owner_id, s.owner_id)
|
||||||
WHERE s.owner_id = $1
|
WHERE (
|
||||||
AND EXISTS (
|
-- I own this transaction and at least one other person has a split
|
||||||
SELECT 1 FROM transaction_splits ts2
|
COALESCE(t.owner_id, s.owner_id) = $1
|
||||||
JOIN participants p2 ON p2.id = ts2.participant_id
|
AND EXISTS (SELECT 1 FROM transaction_splits ts2 WHERE ts2.transaction_id = t.id AND ts2.participant_id != $1)
|
||||||
WHERE ts2.transaction_id = t.id AND p2.name != 'Me'
|
) OR (
|
||||||
|
-- Someone else owns this transaction and I have a split on it
|
||||||
|
COALESCE(t.owner_id, s.owner_id) != $1
|
||||||
|
AND EXISTS (SELECT 1 FROM transaction_splits ts_me WHERE ts_me.transaction_id = t.id AND ts_me.participant_id = $1)
|
||||||
)
|
)
|
||||||
GROUP BY t.id, o.category_override, o.merchant_normalized, o.notes, s.bank_name, s.owner_id, p_owner.name
|
GROUP BY t.id, o.category_override, o.merchant_normalized, o.notes, s.bank_name, s.owner_id, p_owner.name
|
||||||
ORDER BY t.transaction_date DESC
|
ORDER BY t.transaction_date DESC
|
||||||
|
|||||||
Reference in New Issue
Block a user