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 noTags = rawIds.includes("untagged");
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);
}
+5 -2
View File
@@ -50,18 +50,20 @@ export async function POST(req: NextRequest) {
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 } });
if (tags.length) {
await tx.transaction_tags.createMany({
data: tags.map((t) => ({ transaction_id: statement_tx_id, tag_id: t.tag_id })),
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 } });
if (splits.length) {
await tx.transaction_splits.createMany({
@@ -72,6 +74,7 @@ export async function POST(req: NextRequest) {
})),
skipDuplicates: true,
});
await tx.transaction_splits.deleteMany({ where: { transaction_id: manual_id } });
}
// Mark manual tx as reconciled (link to statement tx)
+15 -2
View File
@@ -4,6 +4,7 @@ import { useState, useRef, useEffect } from "react";
import {
useSharedTransactions,
useParticipantBalances,
useParticipants,
useCreateParticipant,
useRecordPayment,
usePaymentHistory,
@@ -274,10 +275,12 @@ type SortCol = "transaction_date" | "created_at" | "amount";
export default function SharedPage() {
const [tagIds, setTagIds] = useState<string[]>([]);
const [participantId, setParticipantId] = useState<number | undefined>(undefined);
const [sortCol, setSortCol] = useState<SortCol>("transaction_date");
const [sortDir, setSortDir] = useState<"asc" | "desc">("desc");
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 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="flex items-center justify-between gap-3">
<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} />
{!addingParticipant && (
<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({
queryKey: ["shared-transactions", tagIds],
queryKey: ["shared-transactions", tagIds, participantId],
queryFn: async () => {
const params = tagIds?.length ? `?tag_ids=${tagIds.join(",")}` : "";
const res = await fetch(`/api/shared-transactions${params}`);
const sp = new URLSearchParams();
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();
},
});
+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
LEFT JOIN statements s ON s.id = t.statement_id
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}
UNION ALL
@@ -341,6 +342,7 @@ export async function getParticipantBalances(ownerId: number, tagIds?: number[])
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
AND NOT (t.statement_id IS NULL AND t.reconciled_with_id IS NOT NULL)
${tagFilter}
) splits ON splits.pid = p.id
${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];
let tagClause = "";
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[]))`;
}
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 }>(`
SELECT t.*,
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 NOT (t.statement_id IS NULL AND t.reconciled_with_id IS NOT NULL)
${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
ORDER BY t.transaction_date DESC
`, params);