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:
+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() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
@@ -95,6 +124,10 @@ export function useUpdateTransaction() {
|
||||
merchant_normalized?: string;
|
||||
notes?: string;
|
||||
transaction_type?: string;
|
||||
my_share_percent?: number | null;
|
||||
description?: string;
|
||||
amount?: number;
|
||||
transaction_date?: string;
|
||||
}) => {
|
||||
const res = await fetch(`/api/transactions/${id}`, {
|
||||
method: "PATCH",
|
||||
@@ -106,6 +139,7 @@ export function useUpdateTransaction() {
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["transactions"] });
|
||||
qc.invalidateQueries({ queryKey: ["transaction"] });
|
||||
qc.invalidateQueries({ queryKey: ["analytics"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -421,14 +455,54 @@ export function useDeleteRule() {
|
||||
export function useApplyRules() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await fetch("/api/rules/apply", { method: "POST" });
|
||||
mutationFn: async (splitFrom?: string) => {
|
||||
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");
|
||||
return res.json() as Promise<{ matched: number; transactions_affected: number }>;
|
||||
return res.json() as Promise<{ id: number; matched: number; transactions_affected: number }>;
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["transactions"] });
|
||||
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"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user