type
Process
title
Settings — host document and plugin tabs
description
The single-row settings document the host owns, the per-plugin tabs rendered from the settings contract, and the one-transaction save that keeps boxes and plugins in step.
tags
settings, plugin, contract, currencies, methods
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
domain → internal/domain/settings/settings.go; svc → internal/application/settings/service.go; handler → internal/interfaces/http/settings_handler.go; pluginhandler → internal/interfaces/http/plugin_settings_handler.go; setup → internal/interfaces/http/setup_handler.go; contract → /plugins/contract/settings-contract.md

Settings

Two owners, one screen. The host document — one JSON row in settings — holds the school's identity, currencies, methods and calendar. Each enabled plugin contributes a tab rendered from its own contract value. The Save button commits both, or neither.

flowchart TD form["GET /settings (settings read)<br/>host form + one tab per enabled plugin"] render["tab built from Fields() + GetAll()<br/>inputs named plugin_<id>_<key>"] save["POST /settings (settings write)"] guard["validate: decimal places · method codes [a-z0-9]+ ·<br/>default currency ∈ enabled ·<br/>currency-disable blocker (fees/salaries still reference it)"] tx["money.RunPluginTx — ONE transaction"] doc["settings.Save: the host JSON row"] boxcur["sync boxes ↔ currencies<br/>disable deactivates · re-enable activates"] boxmet["sync boxes ↔ methods<br/>new method → a box per enabled currency"] plug["plugin panels: Put (whole patch) → Update (re-read)<br/>re_settings; before/after audit row"] refresh["RefreshSettings per plugin — after commit,<br/>peers re-read within 5s"] form --> render --> save --> guard --> tx tx --> doc --> boxcur --> boxmet --> plug plug --> refresh

The host document

settings.data is one JSON row decoded into SettingsData (internal/domain/settings/settings.go):

Block Contents Defaults
school name, address, phone, email, logo empty
currencies default + enabled list default USD, enabled from SUPPORTED_CURRENCIES (USD,SYP,TRY,EUR)
features multi_currency flag on; the form does not edit it, and the save preserves it explicitly so a zero value cannot wipe it
decimal_places display precision 2
description_types fee-description labels {en, ar} tuition, registration, school books, uniform, transport, activities, exams, lab
payment_methods {code, en, ar, requires_reference} per method cash (no reference), shamcash (requires one)
holidays YYYY-MM-DD dates empty
weekend_days lowercase weekday names friday

The settings service caches the decoded document in memory (the money formatter calls it per table cell) and invalidates on save; a missing row yields the defaults without persisting them, and legacy documents get description-types/payment-methods/weekend-days backfilled on read.

payment_methods doubles as the register's method catalog: each entry's code becomes a MethodTag with its requires_reference flag (application/money/methods.go), asked for on every write — so adding a method is a settings edit, not a code change, and a malformed row (blank or duplicate code) is skipped rather than allowed to take every posting down.

Host-owned vs plugin-owned

Host document Plugin tab (register)
Storage settings row, JSON re_settings key/value rows, via the plugin's Put/Update
Schema the Go struct the plugin's Fields() — labels translated by the host under plugin_<id>_<key> (fallback: the plugin's own label)
Tab visibility always (settings read) only for plugins that registered and initialised — a failed Init shows no tab, the same rule that hides its features
Values today everything above the register's own fields, incl. lock_enforced (refuse postings dated inside a locked period) and variance_category (how a drawer-count difference is booked) — see nx-register
Refresh settings service cache invalidated on save RefreshSettings after commit in this process; peers re-read within the plugin host's 5 s TTL

The host never sees the plugin's struct or its table name; the plugin never sees the host document. Full rules: settings contract.

The save, precisely

  1. Validate everything up front: decimal places positive; method codes match ^[a-z0-9]+$ (lowercase alphanumeric); the default currency must be in the enabled list (ErrDefaultCurrencyNotEnabled); a currency being dropped from the enabled list is refused while any student fee or salary still references it — the message counts both.
  2. One transaction (money.RunPluginTx → db.RunInTx + runtime.WithTx): settings.Save → box/currency sync → box/method sync → every plugin panel's Put/Update with its audit row. Every writer resolves its handle from the transaction's context, so they all join it; a failure rolls back all of it.
  3. After commit: each plugin refreshes its in-memory settings so the change applies without a restart (a failure here is a delay, not a loss — peers re-read on the TTL).

First-boot setup

/setup (gates settings R/W) is the fresh-install form: pick the currencies to accept and an opening balance per (currency, method); the save creates one box per pair, posts each opening as that box's opening receipt, records an audit row per box, and persists the enabled/default currencies. RequireSetup runs on every protected route: a user who may write settings is redirected to /setup until at least one box exists (the /setup routes themselves and settings-read-only users pass through).

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