fix(transactions): supersede rows imported twice instead of deleting them
ci / lint-test (push) Successful in 48s
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:
@@ -608,3 +608,61 @@ describe("getStatements — overlapping billing periods", () => {
|
||||
expect(rows.every((r) => r.overlaps.length === 0)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// A statement imported twice puts every transaction in the overlap in the
|
||||
// ledger twice. The duplicate is superseded rather than deleted, because every
|
||||
// child of `transactions` cascades on delete.
|
||||
describe("superseded duplicates are excluded but kept", () => {
|
||||
it("hides a superseded row from the transaction list", async () => {
|
||||
const { ownerId } = await seedParticipants(pool);
|
||||
const keep = await insertTransaction(pool, ownerId, { description: "RAIZ INVESTMENT", amount: 1500 });
|
||||
const dup = await insertTransaction(pool, ownerId, { description: "RAIZ INVESTMENT", amount: 1500 });
|
||||
await pool.query(`UPDATE transactions SET superseded_by_id = $1 WHERE id = $2`, [keep, dup]);
|
||||
|
||||
const { data, total } = await getTransactions(ownerId, { limit: 50, offset: 0 });
|
||||
expect(total).toBe(1);
|
||||
expect(data.map((t) => t.id)).toEqual([keep]);
|
||||
});
|
||||
|
||||
it("keeps the superseded row and its children in the database", async () => {
|
||||
const { ownerId, otherId } = await seedParticipants(pool);
|
||||
const keep = await insertTransaction(pool, ownerId, { amount: 100 });
|
||||
const dup = await insertTransaction(pool, ownerId, { amount: 100 });
|
||||
await pool.query(
|
||||
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent) VALUES ($1, $2, 50)`,
|
||||
[dup, otherId]
|
||||
);
|
||||
await pool.query(`UPDATE transactions SET superseded_by_id = $1 WHERE id = $2`, [keep, dup]);
|
||||
|
||||
const rows = await pool.query(`SELECT superseded_by_id FROM transactions WHERE id = $1`, [dup]);
|
||||
expect(rows.rows[0].superseded_by_id).toBe(keep);
|
||||
const kids = await pool.query(`SELECT count(*)::int AS n FROM transaction_splits WHERE transaction_id = $1`, [dup]);
|
||||
expect(kids.rows[0].n).toBe(1);
|
||||
});
|
||||
|
||||
// The point of excluding it: a split on a duplicate must not be owed twice.
|
||||
it("does not count a superseded row towards what someone owes", async () => {
|
||||
const { ownerId, otherId } = await seedParticipants(pool);
|
||||
const keep = await insertTransaction(pool, ownerId, { amount: 100 });
|
||||
const dup = await insertTransaction(pool, ownerId, { amount: 100 });
|
||||
for (const id of [keep, dup]) {
|
||||
await pool.query(
|
||||
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent) VALUES ($1, $2, 50)`,
|
||||
[id, otherId]
|
||||
);
|
||||
}
|
||||
await pool.query(`UPDATE transactions SET superseded_by_id = $1 WHERE id = $2`, [keep, dup]);
|
||||
|
||||
const balances = await getParticipantBalances(ownerId);
|
||||
const bob = balances.find((b) => b.id === otherId);
|
||||
expect(Number(bob!.total_owed)).toBeCloseTo(50);
|
||||
});
|
||||
|
||||
it("refuses to let a row supersede itself", async () => {
|
||||
const { ownerId } = await seedParticipants(pool);
|
||||
const id = await insertTransaction(pool, ownerId);
|
||||
await expect(
|
||||
pool.query(`UPDATE transactions SET superseded_by_id = $1 WHERE id = $1`, [id])
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user