Money flows
The host owns what a payment means — numbers, kinds, student/employee links,
the payment_lines that settle a student_fees row. The nx-register plugin
owns the money facts — boxes, movements, lines, voids, counts, period locks.
Every write crosses that line through MoneyService, and the host never learns
a re_ table.
The flows
| Flow | Route(s) | Gate | What happens |
|---|---|---|---|
| Student fee payment | GET/POST /students/:id/pay |
payments write |
one payment, one line per ticked fee; each line posts its own movement (a movement carries one currency and one method, so a multi-currency payment is several movements); lines settle student_fees (amount_paid += , unpaid → partially_paid → paid) inside the posting transaction |
Incomes (type=in) |
/incomes* |
payments R/W/D |
receipts; kinds fees, income, other |
Expenses (type=out) |
/expenses* |
payments R/W/D |
disbursements; movement type expense |
| Salary payment | /salaries/:id/pay, /teachers/:id/salary-pay |
salaries write |
an out payment with kind=salary → movement type salary; a flash carries the payment number |
| Receipt / voucher | GET /incomes/:id/receipt, /expenses/:id/receipt, /payments/:id/receipt |
payments read |
printable page; parents (scope own) are denied for students they do not hold |
| Cancel | `POST /incomes | expenses/:id/cancel**and**/:id/delete` |
payments write |
| Ledger | GET /ledger, /ledger/movements/:id |
ledger read |
read-only projection of the plugin's lines; no box_id → all boxes with per-currency totals |
Movement type is derived from the payment: in → receipt, kind=salary →
salary, kind=adjustment → adjustment (an outgoing adjustment posts a
negative amount), everything else → expense. An empty method means cash;
an empty date means today. The reference column carries the document number a
method tag demands (bank id, cheque number) — the register refuses a posting
whose tag requires one when it is empty.
The void/reversal rule
Nothing money-shaped is ever deleted. Cancel flips the payment and lets the plugin reverse it:
The same rule covers a partially-failed multi-line payment: the host
compensates by voiding whatever already posted, so the register always shows
the attempt plus its reversal rather than a silent half-payment. The invariant
survives every void: Σ(re_lines) == Σ(re_movements where status = posted).
Cash boxes
A box is the register's re_boxes row — identity is the (currency, method)
pair, resolved by ResolveBox when a payment posts; the box's currency and
method are frozen once it has lines. The host's cash_boxes screen
(/cash-boxes*, gate cash_boxes) lists them with derived balances
(BoxBalance = opening + receipts − payments, from posted lines only; two
currencies are two boxes and are never summed into one figure).
Boxes are created by the first-login setup (/setup, one box per enabled
currency × configured method, each opening posted as an opening receipt) and
kept in step by the settings save: disabling a currency deactivates its boxes,
re-enabling activates them, and a newly added method gets a box per enabled
currency so its first payment cannot fail on a missing drawer. RequireSetup
redirects any settings-writing user to /setup until at least one box exists.
Period close, drawer counts, reopen
| Step | Route | What happens |
|---|---|---|
| Count + close | POST /period-close (cash_boxes write) |
ClosePeriod(closeDate, note, counts): each box's counted major units → minor with the box's own exponent; the plugin records the counts, posts a variance adjustment per box that disagrees with its lines, and locks the period — nothing dated on or before closeDate can be posted afterwards (when lock_enforced is on) |
| Inspect | GET /period-close (cash_boxes read) |
latest close; CountViews shows expected / counted / variance per box in major units |
| Reopen | POST /period-close/reopen (cash_boxes write) |
Reopen(closeID): voids the close's adjustment movements, leaves the counts untouched; idempotent on an already-unlocked close |
Close and reopen are both cash_boxes write, which the seeded accountant
does not hold — effectively manager-only, deliberately.
Transfers and the exchange flag
POST /cash-transfers (cash_boxes write) moves money between two boxes via
the plugin's transfer pair: transfer_out on the source and transfer_in on
the destination, one shared ref_id (reserved — the register refuses
ref_type=transfer from any other source). The host guards the currency rule
before posting:
exchange=false: the two boxes must share a currency; one amount.exchange=true: the two boxes must differ;amountis what arrives (in the destination's currency),originalis what leaves (in the source's), each converted with its own box's exponent. The ledger therefore stores two integer amounts and never an exchange rate; the implied rate is derived for display and the exchange detail rides in the leg'smeta.
ListTransfers groups the two legs back into one record per transfer
(ref_id), carrying source/destination boxes, both amounts, the implied rate
and the voided flag.
Numbers, editing, guard rails
- Numbers are
PREFIX-NNNNNwith independent per-prefix sequences derived fromMAX(number)inside the posting transaction (payments.numberUNIQUE is the concurrency guard): the first disbursement isOUT-00001no matter how many receipts exist. - Payment edit (
/incomes/:id/editetc.) cannot move money: financial fields are locked once posted — the e2e suite pins it. - Refusals come back as sentinel errors:
ErrInvalidAmount,ErrUnknownMethod,ErrMissingReference,ErrBoxNotFound/ErrBoxInactive,ErrAlreadyCancelled, and the exchange/currency mismatch errors. - Currency guard on settings: a currency still referenced by fees or salaries cannot be disabled — the settings save is blocked with a count.
Not determined
- The exact
metakeys the plugin writes on a transfer leg beyond{exchange, rate}are the plugin's business and were not read from its source for this pass (the host reads them for display only).
Rendered from simple-lms/money.md · OKF bundle · regenerate with npm run render