nx-register — the register plugin
The plugin id is re, so every table it owns starts with re_. It posts money,
voids it without deleting it, locks periods and reconciles drawer counts. The host
sees none of that — only Post, Void, Close, Count and the balances.
The money rules
| rule | why |
|---|---|
| one movement posts exactly one line | a partial posting is not a thing that can exist; the pair is the unit |
| a void flips the status and appends a reversal line | nothing is ever deleted, so the history stays auditable and the balance nets to zero |
every query sums status = posted only |
the naive sum over all movements overstates by exactly the voided amount |
| a movement carries a required date | an undated row silently lands outside every period report |
(ref_type, ref_id) is atomic and indexed |
a host joins its own documents on the pair; half a reference is a bug |
amount_minor is an integer |
no float ever touches money |
| a box's currency and method are frozen once it has lines | re-denominating a box rewrites history instead of correcting it |
Settings it actually has
type Settings struct {
LockEnforced bool // refuse a posting dated inside a locked period
VarianceCategory string // category used for a drawer-count difference
}
Two fields, and both are load-bearing: the first gates writes, the second decides
how a cash-count difference is booked. No method list — that vocabulary belongs to
the host, which passes its own tags to Service(ctx, tags); the plugin's
DefaultMethodCatalog is only the fallback when a host passes none.
What it refuses
- money with no date, or with a half-set reference pair;
- changing a box's currency or method after it has lines;
metaover the cap, or carrying a reserved key;- a second original line against the same movement;
ref_type = 'transfer'— reserved, because transfers are the host's shape.
Wiring traps worth remembering
- Handles come from the context (
rt.Engine(ctx)). Bun's*bun.DBtakes a fresh connection, and on a one-connection pool a write inside a transaction waits forever with no error. This has hung this codebase twice. Putinside a host transaction publishes nothing. The write joins via the context; values appear only after the host commits, so a rollback can't leave the process enforcing a value the table never stored.
Status — plugin migration complete
Done. simple-lms runs only nx-register; the pilot package go-register is
gone from go.mod, and its tables (movements, lines, boxes, cash_boxes,
drawer_closes, drawer_counts, register_schema_migrations) no longer exist in
a database created by the squashed baseline — only the plugin's re_* tables do.
Verified end to end on the deployed tenant (test-lms, image v20261030-71c4137)
through the browser, not through unit tests:
| flow | result |
|---|---|
| student fee payment | IN-00001, 75,000 SYP cash → re_movements receipt 7,500,000 minor, ref_type=payment, ref_id = payment id, meta carries payment_id/kind/student_id/student_fee_id |
| expense | OUT-00002, 5,000 SYP → expense movement, line out 500,000 |
| salary payment | OUT-00003, 20,193 SYP → salary movement, line out 2,019,300 |
| box balance | 75,000 − 5,000 − 20,193 = 49,807 SYP, matching Σ in − Σ out exactly |
| receipt | rendered with number, date, student, grade/class, amount, method, currency |
The host never touched a re_ table in any of those flows: each one went through
the money service into the plugin.
UI gaps — all five closed and re-verified
The first cashier run exposed five gaps; they were fixed in 9526ff5 and every one
was re-checked in the browser on the rebuilt tenant (v20261030-e8eb493):
| gap | fix | how it was verified |
|---|---|---|
pay form pre-filled 0, so a ticked fee paid nothing |
amount inputs pre-fill the fee's remaining balance | opened S-0425's pay screen: inputs read 10000 / 8000 / 12000, one submit → receipt IN-00001 |
/ledger defaulted to one box, so other currencies looked empty |
no box_id → all boxes, per-currency totals |
/ledger with no query string listed 4 movements with a box column |
| salary payment gave no confirmation | flash carrying the payment number | toast read "Salary paid · OUT-00003" |
first expense was OUT-00002 (shared sequence) |
per-prefix sequences from MAX(number) | first expense recorded as OUT-00001 |
| movement detail page unrouted | GET /ledger/movements/:id |
clicked a ledger row → fields + lines table rendered |
Acceptance run on the rebuilt tenant
Fresh database from the squashed baseline (001_schema.sql, 002_seed_settings.sql),
roles seeded from Go at boot (role-manager, role-accountant, role-teacher,
role-parent), business data copied back with users remapped by role code
(manager 1, accountant 1, teacher 20, parent 42, students 503, enrollments 550).
| step | result |
|---|---|
| login as admin | dashboard renders |
/roles |
4 built-in roles, catalogue matrix = 22 resources × actions |
pay fee (75,000 SYP, ref INV-9001) |
receipt IN-00001, reference printed |
| expense (5,000 SYP) | OUT-00001 |
| salary (20,193 SYP) | OUT-00002 + toast |
| cancel a salary payment | OUT-00003 → cancelled, movement voided, reversal line appended |
| ledger | 5 rows including the reversal, box column populated |
| balance | SYP cash 49,807 = posted-only sum, exactly |
The invariant holds after a void: Sums(lines) == Σ movements where status=posted.
Contract status: core flag
nx-register implements plugin.Core() from nx-common v0.3.0 (APIVersion 3) and
currently returns true, so a failed Register/Migrate/Init refuses to start the
host rather than hiding the money features.
That is a deliberate interim setting: the owner's position is that the register is
not core — a school must keep running students, attendance and fees when the
ledger is unavailable. Flipping it is one line (return false) plus the host's
already-tested degrade path; it is deferred rather than decided against.
Rendered from plugins/re/register-plugin.md · OKF bundle · regenerate with npm run render