Replace tag-based voiding with reversing entries (single mutation surface) #24
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Context
Libra's current correction model edits the source file in-place:
reject_pending_entry(views_api.py:2860) calls Fava'sPUT /api/sourceto append a#voidedtag to the original transaction's line. Balance computations then filter voided entries out viaif "voided" in entry.get("tags", []): continueat four sites.Two problems with this:
#voided-tagged transaction is, to any consumer that doesn't know Libra's tag convention (Fava's own balance computation,bean-query, an auditor's script, Tackler), a live transaction. The journal is no longer self-describing. Beancount has no native#voidedflag/tag — confirmed by inspectingbeancount/core/flags.py(only*,!, and system-generatedP/S/T/C/Mexist).The canonical Beancount approach to corrections is the same as traditional double-entry bookkeeping: post a reversing transaction. Original stands, reversal cancels it, both consumers and humans get the right answer with zero special knowledge.
Scope
This is one coherent change that touches several places. Don't split:
Construction
FavaClient.void_entry(entry_hash, sha256sum)method that:Transactionwith sign-flipped amountsreverses: <original_entry_hash>meta key for directional identificationlibra-<entry_id>link plus a distinctreversaltag for retrieval/groupingexpense-entryorincome-entrytags — these are the gate for lifetime-totals (models.py:93,fava_client.py:get_user_lifetime_totals_bql,get_user_contributions_bql). One helper enforces this; one test asserts it.add_entryso the existing_write_lockand idempotency machinery apply naturally.Deletion (close the capability surface)
FavaClient.update_entry_source(fava_client.py:1392) — dead code, no call sites.FavaClient.delete_entry(fava_client.py:1443) — dead code, no call sites./api/sourcehttpxcall inreject_pending_entry(views_api.py:2913-2954) — replace withawait fava.void_entry(...)._write_lock.Voided-tag filter removal
The
voided-tag exclusion is the old model's mechanism. Under reversing entries, both legs are supposed to be summed (they net to zero); leaving the exclusion in place would suppress the original while the reversal still subtracts, producing a phantom credit. Remove at all four sites:fava_client.py:325fava_client.py:471views_api.py:489-490views_api.py:782-783Backfill
Any pre-existing entries voided under the current model carry
#voidedon the original. After the exclusion filter is removed, those will start counting in balances with no reversal to cancel them. Options:#voidedoriginals, posts a synthetic reversal for each (action-dated to migration date, linked back to the original), then strips the#voidedtag from the originals.Check this before writing the refactor.
Date policy: action-date, not original-date
Reversals are dated to the action-date (today, when the void happens), not back-dated to the original. Rationale:
models.py:93carries the comment "original entries only; not net of reconciliation" — the lifetime-totals field deliberately treats originally-entered numbers as a stable historical fact. Original-dated reversals would silently change those numbers; action-dated ones preserve them.balance_assertionsis the natural foundation for a soft-close later (a passed assertion implicitly says "the books at date Z are these"). Original-dated reversals would silently invalidate already-passed assertions; action-dated ones touch only the current period.Tradeoff to document
Action-dating creates a cross-period question: a March transaction voided in June leaves the original in March and the reversal in June. As-of-date balance summation handles this correctly (
tasks.pyreconciliation watchdog), but per-period activity reporting (which doesn't exist today) would need to decide whether the reversal counts in its origin period or its action period.Add a docstring next to
models.py:93documenting both the action-date decision and this consequence, so future-you debugging "why doesn't June's activity net to zero" finds the tradeoff written down.Dependencies
add_entryonce the mutation surface is consolidated.Out of scope
UI changes to surface "this entry was voided by reversal X" pairings — file a follow-up when the data model lands. The data is sufficient (link +
reverses:meta) for the UI to do the join later.