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:
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user