fix(transactions): supersede rows imported twice instead of deleting them
ci / lint-test (push) Successful in 48s

Statements 107, 142 and 143 bill overlapping periods on one ANZ account, so 31
transactions -- $42,040.68 -- are in the ledger twice.

They are marked superseded, not deleted. Every child of transactions is ON
DELETE CASCADE (splits, tags, overrides, expense_metadata, order_reviews), so
deleting "the duplicate" destroys whatever curation sits on it, and which
member of a pair holds that curation is an accident of import order: here 1
pair carries splits and 6 carry overrides, all on the surviving side, but
nothing guarantees that. Superseding keeps the row, keeps its children, and
makes a mistake one UPDATE to undo rather than a restore from backup.

reconciled_with_id could not be reused. Its predicate is scoped to
statement_id IS NULL on purpose -- a statement line pointing at something else
is the survivor, not the duplicate -- and here both rows are statement lines.

The exclusion goes into EXCLUDE_RECONCILED_SOURCE rather than into a new
fragment, so every query already asking "count each purchase once" gets it
without being edited. The trip cost queries did not use that fragment at all
and now do; verified a no-op on current data (0 trip-tagged rows are either
reconciled sources or duplicates), but they were one import away from
double-counting.

Most of the $42k is transfers and investments, which spend already excludes.
The damage was elsewhere: duplicated rows in the list, and rules re-splitting a
duplicate -- txn 3807 is one of these 31 and was a candidate for splitting
earlier today.

Balances are unchanged: no duplicate carried a split.
This commit is contained in:
2026-07-28 11:54:25 +10:00
parent d5589b2980
commit dbfbd5196d
5 changed files with 125 additions and 2 deletions
+5
View File
@@ -897,6 +897,7 @@ export interface TripAnalytics {
// alias the shared fragments assume.
const TRIP_TOTAL_SPEND = `COALESCE(SUM(
CASE WHEN ${NET_SPEND_ROWS}
AND ${EXCLUDE_RECONCILED_SOURCE}
AND COALESCE(o.category_override, t.category, 'other') NOT IN ('transfers', 'investment')
THEN ${SPEND_SIGNED} ELSE 0 END
), 0)::float AS total_spend`;
@@ -964,6 +965,7 @@ export async function getTripAnalytics(tripId: number, ownerId: number): Promise
JOIN transactions t ON t.id = o.transaction_id
WHERE o.trip_id = $1
AND ${NET_SPEND_ROWS}
AND ${EXCLUDE_RECONCILED_SOURCE}
AND COALESCE(o.category_override, t.category, 'other') NOT IN ('transfers', 'investment')
GROUP BY 1
ORDER BY 2 DESC
@@ -977,6 +979,7 @@ export async function getTripAnalytics(tripId: number, ownerId: number): Promise
JOIN transactions t ON t.id = o.transaction_id
WHERE o.trip_id = $1
AND ${NET_SPEND_ROWS}
AND ${EXCLUDE_RECONCILED_SOURCE}
AND COALESCE(o.category_override, t.category, 'other') NOT IN ('transfers', 'investment')
GROUP BY 1
ORDER BY 1
@@ -991,6 +994,7 @@ export async function getTripAnalytics(tripId: number, ownerId: number): Promise
JOIN transactions t ON t.id = o.transaction_id
WHERE o.trip_id = $1
AND ${NET_SPEND_ROWS}
AND ${EXCLUDE_RECONCILED_SOURCE}
AND COALESCE(o.category_override, t.category, 'other') NOT IN ('transfers', 'investment')
GROUP BY 1
ORDER BY 2 DESC
@@ -1008,6 +1012,7 @@ export async function getTripAnalytics(tripId: number, ownerId: number): Promise
JOIN tags tg ON tg.id = tt.tag_id
WHERE o.trip_id = $1
AND ${NET_SPEND_ROWS}
AND ${EXCLUDE_RECONCILED_SOURCE}
AND COALESCE(o.category_override, t.category, 'other') NOT IN ('transfers', 'investment')
GROUP BY tg.id
ORDER BY 4 DESC