Skip to content

Make invoice posting, cancellation and period locks atomic - #7

Merged
pallaoro merged 5 commits into
mainfrom
gobd-ledger
Sep 13, 2026
Merged

Make invoice posting, cancellation and period locks atomic#7
pallaoro merged 5 commits into
mainfrom
gobd-ledger

Conversation

@pallaoro

@pallaoro pallaoro commented Sep 10, 2026

Copy link
Copy Markdown
Member

Issuing, cancelling, or retrying an invoice could leave the document, journal and audit log out of sync. Repeating cancellation could restore revenue; simultaneous requests could post duplicate reversals; a failed audit write could leave a financial change committed without its record.

Invoice issuing, cancellation, status changes, draft deletion and period locking now commit their dependent writes in one SQLite statement through triggers. Issuing allocates the number and posts balanced lines together; failure leaves the invoice draft and preserves the sequence. Cancellation retains the original and one mirror entry, with actor-attributed audit records, and remains safe to retry. A correction to a locked month lands in the current open month.

Database guards validate dates, enforce closed periods, and freeze issued documents even when an edit started while they were drafts. Ordinary posting/backfill is idempotent; quotes allocate numbers without posting financial entries. Ledger refusals return 409 JSON, while existing HTTPException responses and unexpected-error behavior are preserved.

The schema changes are additive and verified against populated main data. Unique indexes refuse duplicate historical postings instead of silently repairing accounting data. New triggers are supported by both platform storage paths; future changes to installed trigger definitions require explicit migrations because the platform reconciler compares trigger names.

Validation: 56 committed regression tests, including an independently written public-HTTP suite; independent concurrency/fault tests on local Cloudflare D1 and SQLite Durable Objects; actual exported Worker smoke test; populated main-to-final schema reconciliation and repeat-apply convergence. Vite build passes. Typecheck has the same nine pre-existing client errors, with no new errors.

Scope is ledger integrity; country-specific filing and export requirements remain separate. Validation used local Cloudflare runtimes, without migrating production customer databases.

pallaoro and others added 2 commits September 10, 2026 17:02
A posted journal entry could be deleted outright. Cancelling an invoice
removed its entry, deleting an invoice removed both, and re-posting wiped
what was there first. Books you can rewrite are books an accountant has to
redo, so none of the export work on top of them is worth much.

A correction is now a second, mirror-image entry. The original and its
Storno both stay `posted` and net to zero, so the trial balance is right
without either leaving the record; the pair is linked by
reverses_entry_id / reversed_by_entry_id. There is deliberately no
'reversed' status, because dropping the original out of the three
`status = 'posted'` queries while adding a posted Storno subtracts the
same amount twice.

The document behind the entry is frozen too. The editor already hid its
controls on a non-draft invoice, but the API did not, and the API is what
an agent calls: an issued invoice could be silently rewritten while its
journal entry stood unchanged. addLine, updateLine, deleteLine and
updateInvoice now refuse on anything but a draft, and a posted invoice is
cancelled rather than deleted.

Months can be locked closed. Nothing posts into a locked period -- which
is what stops a back-dated draft being issued into a month already
reported -- and a reversal of an entry inside one is dated into the open
period instead, so a closed month keeps the numbers it was reported with.
Locking is one-way; an unlock would make every lock a suggestion.

Every change to the books is recorded with who made it, taken from the
platform's own caller identity, so the log separates a person from the
org's agent acting on its own.

Refusals come back as 409 with a message saying what to do instead, and
the UI surfaces it rather than failing silently.

Verified against an in-memory SQLite build of the real schema: 35 checks
covering the reversal arithmetic, the frozen-document guards, period
locking, reversal out of a locked period, and that a refused issue leaves
the invoice a draft with its number sequence untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Independent re-review of this branch before merge surfaced real bugs the
first pass's sqlite harness didn't exercise:

- setStatus had no restriction on the target status, so POST
  /api/invoices/:id/status could set a posted invoice back to 'draft',
  reopening every edit path assertEditable exists to close. Restrict it
  to the three legitimate transitions (sent/paid/cancelled) and refuse on
  a still-draft invoice.
- reverseEntry set the original's reversed_by_entry_id before flipping the
  Storno to 'posted'. A crash between those two writes left the Storno
  stuck pending (invisible to every report) while the original looked
  already reversed and unretryable -- a silent, permanent loss of the
  correction. Flip the Storno to posted first, and make the whole
  operation idempotent (reuse an already-posted Storno on retry) so a
  crash there can no longer double-post either.
- createFromInvoice reversed the superseded entry before checking the new
  one would balance, have any lines, or land in an open period. A refusal
  there left the original reversed with nothing posted to replace it.
  Move the reversal after every check that can still refuse the call.
- lockPeriod accepted the current or a future month. There is no unlock
  route, so locking a month before it's over would have permanently
  refused every invoice in it.

Also: listAudit's limit clamp let a non-numeric ?limit= reach the query
as `LIMIT NaN` and 500.

Verified via the node:sqlite domain harness from the original commit
(memory: template-sqlite-harness) -- all 35 existing checks still pass,
plus 13 new checks targeting these 5 bugs specifically, each confirmed to
fail against the pre-fix code first. tsc --noEmit and vite build both
clean (same 15 pre-existing tsc errors as baseline, all in client code
unrelated to this change).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@pallaoro

Copy link
Copy Markdown
Member Author

An independent second-opinion review (a separate agent, asked to specifically try to break this before it merges) found 4 real correctness bugs the original sqlite-harness verification didn't reach, plus one crash-on-bad-input:

  1. setStatus had no restriction on the target statusPOST /api/invoices/:id/status {"status":"draft"} could silently un-draft a posted invoice, reopening every edit path assertEditable exists to close.
  2. Reversal pointer written before the Storno was posted — a crash between those two writes left the Storno permanently stuck pending (invisible to every report) while the original looked already-reversed and unretryable. Silent, permanent loss of the correction.
  3. createFromInvoice reversed the old entry before validating the new one (balance check, empty-lines check, period-open check) — a refusal there left the original reversed with nothing posted to replace it.
  4. lockPeriod accepted the current or a future month — with no unlock route, that would have permanently refused every invoice in that month.
  5. (minor) GET /api/audit?limit=abc 500'd (LIMIT NaN).

Fixed in 66640a4, verified against all 4 by extending the same node:sqlite domain harness from the original commit: 13 new checks, each first confirmed to fail against the pre-fix code, all passing after. The existing 35 checks, tsc --noEmit (same 15 pre-existing baseline errors, all pre-existing client-side, none new), and vite build are all still clean.

Not merging this — that's still yours to do (gh pr merge), the auto-mode permission classifier here can't run it.

@pallaoro pallaoro changed the title Make the ledger audit-proof: reverse entries instead of deleting them Make invoice posting, cancellation and period locks atomic Sep 13, 2026
@pallaoro
pallaoro merged commit eb6425d into main Sep 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant