feat(financial): let the academy decide where every collected pound lands
Where a collection went in the ledger was a `match` statement: five itemable
classes hardcoded to five revenue codes, identical for every client, and no way
to express tax at all. An accountant who said "no, five pounds of that is a
registration fee and the rest is training, and the kit carries 14% VAT" had no
answer but a deploy.
Revenue routing makes that answer data. Every kind of money the ERP can take is
a source with one rule: a tax treatment, and destination lines that take a flat
amount, a percentage, or the remainder. A rule can be narrowed to a single
product, programme or event, which beats the academy default for that item
alone. financial.revenue-routing edits them, with a preview that runs the
unsaved rule through the real allocation engine rather than a second
implementation that agrees with it until it doesn't.
Three things it will not do:
- Book tax as revenue. VAT collected is owed to the Authority, so it comes out
first and credits a liability; the service refuses a tax account that is not
one. Egypt's 14% is a per-source setting because some services are 10% and
some are exempt.
- Write an entry that does not balance. Integer piasters throughout, floors
everywhere, one line closes the rounding, and assertBalanced() throws before a
single row is written — a transaction is immutable, so a wrong one can only be
reversed, never corrected.
- Change anything on the day it ships. The migration seeds each academy the rule
that reproduces its current behaviour exactly, so nothing moves until someone
asks it to. The platform fee ships switched off for the same reason.
Refunds now reverse the collection's own ledger rows in proportion rather than
re-running today's rules — a rule edited last week would otherwise unwind money
into accounts it never touched.
LedgerAccountResolver::splitRevenue() and revenueCodeForItemable() are deleted
rather than deprecated: a second implementation of "where does this money go" is
one a caller would eventually reach for, and it would bypass every rule the
academy wrote.
Verified on a restored oc_sport tenant copy and on a database built from zero:
521 tests, 0 failures on both the Postgres and SQLite suites. PaymentLedgerTest
builds a schema without the routing tables, which pins the other half of the
safety property — an installation that has not received the migration keeps
taking money on the built-in behaviour instead of failing at the till.
Co-Authored-By:
Claude Opus 5 (1M context) <noreply@anthropic.com>
Showing
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
Please register or sign in to comment