fix(reconcile): prevent split/tag double-counting on reconciled transactions

Move splits, tags and overrides from manual to statement side on reconcile
(delete from manual after copying) instead of just copying. Add read-time
filter to exclude reconciled manual transactions from balance and shared
transaction queries. Also adds participant filter to shared expenses page.
This commit is contained in:
2026-05-11 19:15:41 +10:00
parent ce67e38d77
commit b8cd1b0f89
5 changed files with 41 additions and 10 deletions
+3 -1
View File
@@ -10,6 +10,8 @@ export async function GET(req: NextRequest) {
const rawIds = tagParam ? tagParam.split(",").filter(Boolean) : []; const rawIds = tagParam ? tagParam.split(",").filter(Boolean) : [];
const noTags = rawIds.includes("untagged"); const noTags = rawIds.includes("untagged");
const tagIds = rawIds.filter((id) => id !== "untagged").map(Number).filter((n) => !isNaN(n)); const tagIds = rawIds.filter((id) => id !== "untagged").map(Number).filter((n) => !isNaN(n));
const transactions = await getSharedTransactions(user.id, tagIds.length ? tagIds : undefined, noTags); const participantParam = req.nextUrl.searchParams.get("participant_id");
const participantId = participantParam ? Number(participantParam) : undefined;
const transactions = await getSharedTransactions(user.id, tagIds.length ? tagIds : undefined, noTags, participantId);
return NextResponse.json(transactions); return NextResponse.json(transactions);
} }
+5 -2
View File
@@ -50,18 +50,20 @@ export async function POST(req: NextRequest) {
my_share_percent: override.my_share_percent, my_share_percent: override.my_share_percent,
}, },
}); });
await tx.transaction_overrides.deleteMany({ where: { transaction_id: manual_id } });
} }
// Copy tags: manual → statement tx // Move tags: manual → statement tx
const tags = await tx.transaction_tags.findMany({ where: { transaction_id: manual_id } }); const tags = await tx.transaction_tags.findMany({ where: { transaction_id: manual_id } });
if (tags.length) { if (tags.length) {
await tx.transaction_tags.createMany({ await tx.transaction_tags.createMany({
data: tags.map((t) => ({ transaction_id: statement_tx_id, tag_id: t.tag_id })), data: tags.map((t) => ({ transaction_id: statement_tx_id, tag_id: t.tag_id })),
skipDuplicates: true, skipDuplicates: true,
}); });
await tx.transaction_tags.deleteMany({ where: { transaction_id: manual_id } });
} }
// Copy splits: manual → statement tx // Move splits: manual → statement tx
const splits = await tx.transaction_splits.findMany({ where: { transaction_id: manual_id } }); const splits = await tx.transaction_splits.findMany({ where: { transaction_id: manual_id } });
if (splits.length) { if (splits.length) {
await tx.transaction_splits.createMany({ await tx.transaction_splits.createMany({
@@ -72,6 +74,7 @@ export async function POST(req: NextRequest) {
})), })),
skipDuplicates: true, skipDuplicates: true,
}); });
await tx.transaction_splits.deleteMany({ where: { transaction_id: manual_id } });
} }
// Mark manual tx as reconciled (link to statement tx) // Mark manual tx as reconciled (link to statement tx)
+15 -2
View File
@@ -4,6 +4,7 @@ import { useState, useRef, useEffect } from "react";
import { import {
useSharedTransactions, useSharedTransactions,
useParticipantBalances, useParticipantBalances,
useParticipants,
useCreateParticipant, useCreateParticipant,
useRecordPayment, useRecordPayment,
usePaymentHistory, usePaymentHistory,
@@ -274,10 +275,12 @@ type SortCol = "transaction_date" | "created_at" | "amount";
export default function SharedPage() { export default function SharedPage() {
const [tagIds, setTagIds] = useState<string[]>([]); const [tagIds, setTagIds] = useState<string[]>([]);
const [participantId, setParticipantId] = useState<number | undefined>(undefined);
const [sortCol, setSortCol] = useState<SortCol>("transaction_date"); const [sortCol, setSortCol] = useState<SortCol>("transaction_date");
const [sortDir, setSortDir] = useState<"asc" | "desc">("desc"); const [sortDir, setSortDir] = useState<"asc" | "desc">("desc");
const realTagIds = tagIds.filter((id) => id !== "untagged"); const realTagIds = tagIds.filter((id) => id !== "untagged");
const { data: rawTransactions = [], isLoading: txLoading } = useSharedTransactions(tagIds); const { data: participants = [] } = useParticipants();
const { data: rawTransactions = [], isLoading: txLoading } = useSharedTransactions(tagIds, participantId);
const transactions = [...rawTransactions].sort((a, b) => { const transactions = [...rawTransactions].sort((a, b) => {
const av = sortCol === "amount" ? Number(a.amount) : new Date(a[sortCol]).getTime(); const av = sortCol === "amount" ? Number(a.amount) : new Date(a[sortCol]).getTime();
@@ -305,7 +308,17 @@ export default function SharedPage() {
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<h2 className="text-xl font-semibold">Shared Expenses</h2> <h2 className="text-xl font-semibold">Shared Expenses</h2>
<div className="flex items-center gap-2 ml-auto"> <div className="flex items-center gap-2 ml-auto flex-wrap">
<select
value={participantId ?? ""}
onChange={(e) => setParticipantId(e.target.value ? Number(e.target.value) : undefined)}
className={`border rounded px-3 py-1.5 text-sm bg-zinc-900 ${participantId ? "border-indigo-500 text-white" : "border-zinc-700 text-zinc-400"}`}
>
<option value="">All People</option>
{participants.map((p: { id: number; name: string }) => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
<TagFilter value={tagIds} onChange={setTagIds} /> <TagFilter value={tagIds} onChange={setTagIds} />
{!addingParticipant && ( {!addingParticipant && (
<button onClick={() => setAddingParticipant(true)} <button onClick={() => setAddingParticipant(true)}
+7 -4
View File
@@ -210,12 +210,15 @@ export function useParticipantBalances(tagIds?: string[]) {
}); });
} }
export function useSharedTransactions(tagIds?: string[]) { export function useSharedTransactions(tagIds?: string[], participantId?: number) {
return useQuery({ return useQuery({
queryKey: ["shared-transactions", tagIds], queryKey: ["shared-transactions", tagIds, participantId],
queryFn: async () => { queryFn: async () => {
const params = tagIds?.length ? `?tag_ids=${tagIds.join(",")}` : ""; const sp = new URLSearchParams();
const res = await fetch(`/api/shared-transactions${params}`); if (tagIds?.length) sp.set("tag_ids", tagIds.join(","));
if (participantId) sp.set("participant_id", String(participantId));
const query = sp.toString() ? `?${sp.toString()}` : "";
const res = await fetch(`/api/shared-transactions${query}`);
return res.json(); return res.json();
}, },
}); });
+11 -1
View File
@@ -329,6 +329,7 @@ export async function getParticipantBalances(ownerId: number, tagIds?: number[])
JOIN transactions t ON t.id = ts.transaction_id 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 COALESCE(t.owner_id, s.owner_id) = $1 AND ts.participant_id != $1 WHERE COALESCE(t.owner_id, s.owner_id) = $1 AND ts.participant_id != $1
AND NOT (t.statement_id IS NULL AND t.reconciled_with_id IS NOT NULL)
${tagFilter} ${tagFilter}
UNION ALL UNION ALL
@@ -341,6 +342,7 @@ export async function getParticipantBalances(ownerId: number, tagIds?: number[])
JOIN transactions t ON t.id = ts.transaction_id 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 ts.participant_id = $1 AND COALESCE(t.owner_id, s.owner_id) != $1 WHERE ts.participant_id = $1 AND COALESCE(t.owner_id, s.owner_id) != $1
AND NOT (t.statement_id IS NULL AND t.reconciled_with_id IS NOT NULL)
${tagFilter} ${tagFilter}
) splits ON splits.pid = p.id ) splits ON splits.pid = p.id
${paymentsJoin} ${paymentsJoin}
@@ -542,7 +544,7 @@ export async function getTags() {
`); `);
} }
export async function getSharedTransactions(ownerId: number, tagIds?: number[], noTags?: boolean) { export async function getSharedTransactions(ownerId: number, tagIds?: number[], noTags?: boolean, participantId?: number) {
const params: unknown[] = [ownerId]; const params: unknown[] = [ownerId];
let tagClause = ""; let tagClause = "";
if (noTags) { if (noTags) {
@@ -552,6 +554,12 @@ export async function getSharedTransactions(ownerId: number, tagIds?: number[],
tagClause = `AND EXISTS (SELECT 1 FROM transaction_tags tt WHERE tt.transaction_id = t.id AND tt.tag_id = ANY($2::int[]))`; tagClause = `AND EXISTS (SELECT 1 FROM transaction_tags tt WHERE tt.transaction_id = t.id AND tt.tag_id = ANY($2::int[]))`;
} }
let participantClause = "";
if (participantId) {
params.push(participantId);
participantClause = `AND EXISTS (SELECT 1 FROM transaction_splits ts_p WHERE ts_p.transaction_id = t.id AND ts_p.participant_id = $${params.length})`;
}
const rows = await queryRaw<TransactionRow & { split_data: string }>(` const rows = await queryRaw<TransactionRow & { split_data: string }>(`
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,
@@ -584,7 +592,9 @@ export async function getSharedTransactions(ownerId: number, tagIds?: number[],
AND EXISTS (SELECT 1 FROM transaction_splits ts_me WHERE ts_me.transaction_id = t.id AND ts_me.participant_id = $1) AND EXISTS (SELECT 1 FROM transaction_splits ts_me WHERE ts_me.transaction_id = t.id AND ts_me.participant_id = $1)
) )
) )
AND NOT (t.statement_id IS NULL AND t.reconciled_with_id IS NOT NULL)
${tagClause} ${tagClause}
${participantClause}
GROUP BY t.id, o.category_override, o.merchant_normalized, o.notes, s.bank_name, s.owner_id, p_owner.name, src.created_at GROUP BY t.id, o.category_override, o.merchant_normalized, o.notes, s.bank_name, s.owner_id, p_owner.name, src.created_at
ORDER BY t.transaction_date DESC ORDER BY t.transaction_date DESC
`, params); `, params);