type
Process
title
Money — payments, ledger, boxes, periods
description
How money moves: fee payments posting register movements per line, expenses, salaries, receipts, void-by-reversal, cash boxes, drawer counts, period close/reopen and transfers with the exchange flag.
tags
money, payments, ledger, register, cash-boxes, period-close
status
stable
stale_after
Fri Apr 30 2027 02:00:00 GMT+0200 (Central European Summer Time)
generated
techwriter/mimo-v2.6-flash · Sat Oct 31 2026 11:02:38 GMT+0100 (Central European Standard Time)
sources
svc → internal/application/money/service.go; methods → internal/application/money/methods.go; payhandler → internal/interfaces/http/payments_handler.go; routes → internal/interfaces/http/routes_money.go; numrepo → internal/infrastructure/sqlite/payment_repo.go; plugin → /plugins/re/register-plugin.md

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.

flowchart TD form["POST /students/:id/pay<br/>fee ids + amounts + method + date"] svc["MoneyService.RecordPaymentLines"] validate["validate all lines first<br/>amount > 0 · currency required ·<br/>unknown method refused · reference when tag demands it"] resolve["ResolveBox per line<br/>(currency, method) → default active re_box"] post["plugin svc.Post(movement, extension)<br/>one transaction per line"] mv[("re_movements + re_lines<br/>amount_minor integer · ref_type=payment · ref_id")] ext["extension, inside the same tx:<br/>payments header (first line only) ·<br/>payment_lines row (movement_id) ·<br/>student_fees ApplyPayment"] num["payments.number = IN-0000n / OUT-0000n<br/>per-prefix, from MAX(number), UNIQUE"] receipt["302 → printable receipt"] fail["failure on a later line → compensate:<br/>void posted movements · revert fee credits ·<br/>cancel the header — net effect zero, visible in the register"] form --> svc --> validate --> resolve --> post post --> mv post --> ext --> num post --> receipt post -->|"a later line fails"| fail

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:

flowchart TD cancel["POST /incomes/:id/cancel (or :id/delete)"] svc["MoneyService.CancelPayment"] guard["already cancelled? → ErrAlreadyCancelled"] void["plugin Void per movement:<br/>original line's movement → voided<br/>+ reversal line, opposite direction, linked by id"] revert["RevertPayment: subtract each line's credit<br/>from student_fees; fees fall back to unpaid"] mark["payments.status → cancelled (+ audit row)"] net["box balance nets to zero:<br/>every query sums status=posted only"] cancel --> svc --> guard --> void --> revert --> mark --> net

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:

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

Not determined

Rendered from simple-lms/money.md · OKF bundle · regenerate with npm run render