Phase 3: Platform customization
Status: built (2026-09-29); rulings taken as recommended, open to the owner’s change. The owner set
the goal “complete the phase 3”; every ruling below is the recommendation, recorded so it can
be overturned. Branch phase-3-import. Roadmap: Phase 3, the four items after import tooling.
Architecture spec §19 (Phase 3), §18.1, §18.4 (“a closed set of extension points, each
validated”).
The import lane is its own spec (2026-09-29-import-tooling-design.md). This one covers the
rest of Phase 3 in four lanes:
| Lane | What | Migrations |
|---|---|---|
| C1 | Per-tenant module install | 00670–00674 |
| C2 | User-defined fields | 00675–00684 |
| C3 | Custom report builder | 00685–00692 |
| C4 | Report and document layout designer | 00693–00699 |
What exists (surveyed 2026-09-29)
Section titled “What exists (surveyed 2026-09-29)”- Routes: every module’s
Registertakes the one authenticated/v1group (internal/app/app.go); per-route authorization ishttpx.Protected./v1/mereturnspermissions, which is all the web nav filters on. - Module code is interlinked (payables imports advances, einvoice and expenses; receivables imports einvoice …), so “not installed” cannot mean “not linked”.
field_schema(platform/payout/fields.go): typed fields with en/ms/zh labels, a schema validator and a value validator, string values only, errors as{key, reason}.- Reporting: trial balance, P&L, balance sheet, general ledger, budget vs actual, agings,
all on
movements()with aScopeof an account subtree and analytic accounts on one axis; exports are asyncreport_exportjobs rendered to CSV/XLSX/PDF byreporting/render. - Printing: only a sales invoice or credit note prints (
GET /receivables/invoices/{id}/pdf), drawn in Go throughrender.Writer(gopdf, A4, a QR slot, no logo, English labels).company_profile.logo_file_idexists but is unused.
C1. Per-tenant module install
Section titled “C1. Per-tenant module install”- K-1. Core and optional modules. Always on: identity, org structure, activity,
accounting, workflow, reports, search, imports, payout. Optional:
expenses(claims),advances,pettycash,payables,receivables,banking,einvoice,taxreturns. - K-2. Declared dependencies are functional, not the Go import graph: advances → expenses,
pettycash → expenses, einvoice → receivables, taxreturns → receivables. Enabling a module
whose dependency is off is refused (
module_dependency_missing); disabling one another enabled module depends on is refused (module_required_by). - K-3. Installed unless switched off.
module_install (org_id, module_key, enabled, updated_by, updated_at); no row means installed, so every existing organization is unchanged. Switching a module off hides it; it never deletes data, and switching it back on restores it as it was. - K-4. What “off” means. Its routes answer 403
module_not_installed(a middleware on the module’s route group,internal/app); its permission keys are left out of/v1/me’spermissions, so the nav, search and home hide it with no per-screen code;/v1/megainsmodules(the enabled optional keys). Background jobs keep running on data that exists, except the e-Invoice sweep, which skips an organization whoseeinvoiceis off. - K-5. Who.
identity.org.administer(the Owner), atGET /v1/org/modulesandPUT /v1/org/modules/{key}, every change audited. Web: Manage → Modules.
C2. User-defined fields
Section titled “C2. User-defined fields”- U-1. Entities in v1: customer, supplier, sales document (invoice and credit note), purchase document. The others (claims, advances, receipts, payments) follow the same mechanism when asked for.
- U-2. One mechanism.
custom_field (org_id, entity, key, type, label jsonb (en/ms/zh), required, options jsonb, position, active), validated likefield_schema: key^[a-z][a-z0-9_]{0,39}$, unique per entity, at most 20 active per entity. Types:text(140),number(a decimal),date,boolean,select(options with en/ms/zh labels). - U-3. Values live in a
custom_fields jsonbcolumn onparty_customer,party_supplier,sales_invoiceandpurchase_invoice, set through the same create and update functions and bodies (custom_fields: {key: value}, values as strings) and validated there against the active definitions: unknown key refused, required enforced (on create and update; an existing record keeps an old value), type checked and normalized, text is record text (English or Malay, §11A.3). Refusals are field errors namingcustom_fields.<key>. - U-4. Definitions change safely. Key and type are fixed once created; a field is retired
(
active = false), never deleted, so a value on an old document still shows with its label. A required field added later does not invalidate existing records. - U-5. Searchable: the customer, supplier and both invoice lists take
field.<key>=<value>(exact match on the stored, normalized value, by jsonb containment; a GIN index on eachcustom_fields). Exportable: the customer and supplier imports acceptcf.<key>columns, the lists’ CSV download (web) includes the fields, and a printed document can show them (C4). - U-6. Who. Defining fields is
org.custom_fields.manage(Owner); setting values follows the entity’s own edit permission. Web: Manage → Custom fields; the four forms render the fields; drawers show them. - As built (C2): definitions in
custom_field(00675; entity, key and type frozen by trigger, DELETE revoked), granted to existing Owners by00676.GET /v1/org/custom-fieldscarries no permission (every form reads it;api/public_routes.txt),POSTandPATCHneedorg.custom_fields.manage.customfield.Resolveruns insideCreateCustomer/UpdateCustomer,CreateSupplier/UpdateSupplier,CreateDocument/UpdateDocumentandCreateInvoice/UpdateInvoice; a refusal is 422custom_field_invalidwith each reason at/custom_fields/<key>, which the forms show beside the field. Required is enforced on create, and on update only for a value that was already set. Options may be added, never removed. The list CSV’scf.<key>columns are the ones the imports read back.
C3. Custom report builder
Section titled “C3. Custom report builder”- R-1. One configurable report,
account_balances, on the ledger engine: a period (from, to), an account subtree (optional root), analytic filters (any number of axes, AND across axes, descendants included), and columns chosen fromopening,debit,credit,movement,closing, optionallybudgetandvarianceagainst a budget version. Rows are accounts to a chosen depth (headers roll up), optionally split by one analytic axis. - R-2. Saved and shared.
saved_report (org_id, id, name, definition jsonb, owner, shared, revision): private to its owner unless shared, then visible to every holder ofreports.read; only the owner edits or deletes it. - R-3. Run and export.
POST /v1/reports/custom/runruns a definition (saved or not); export is the ordinary async export with reportcustomand the saved report’s id, so CSV, XLSX and PDF come fromreporting/renderunchanged. Needsreports.read(run) andreports.export(export), plus the analytic and budget read rules the fixed reports apply. - R-4. Web: Reports → Custom: a builder (period, account, filters, columns, depth, split), a live preview table, save / share, and the saved list.
- As built (C3):
reporting.Customonmovements(), the trial balance’s engine: columnsopening, debit, credit, movement, closing(opening, movement and closing net, debit positive),depth1–10 or every postable account. The analytic filter is the engine’s: one axis, descendants included (several axes ANDed, a split by axis and the budget columns are not in v1; budget vs actual remains its own report).saved_report(00686, operational) lives inplatform/savedreport, outside the read-only reporting tree. Routes:POST /v1/reports/custom/run,GET/POST /v1/reports/custom,PUT/DELETE /v1/reports/custom/{id}, allreports.read; export isPOST /v1/reports/exportswith reportcustomand the definition as its params (00685widens the CHECK), so an export is a snapshot of what was asked.
C4. Report and document layout designer
Section titled “C4. Report and document layout designer”- L-1. Layouts are configuration, not code. A layout is validated JSON applied by the Go renderer, not a template language: no tenant-supplied code runs (§18.4’s closed set of extension points). Statutory content is never optional: seller and buyer identity, TIN, SST registration, totals and tax, and the e-Invoice QR print whatever the layout says.
- L-2. Documents in v1: sales invoice and credit note (existing), customer statement (new PDF), supplier payment advice (new PDF), expense claim voucher (new PDF).
- L-3. What a layout sets per document kind: the title (en/ms), which optional header
lines print (trading name, mailing address, MSIC, phone, due date, payment terms, custom
fields), which optional table columns print and their order, extra footer notes (record
text), the language of the labels (English, Malay, or both), and whether the organization’s
logo prints.
document_layout (org_id, kind, layout jsonb, revision, updated_by, updated_at), one per kind; no row is the built-in default, which is today’s print. - L-4. Logo:
company_profile.logo_file_idfinally used: an image (PNG or JPEG) uploaded throughdocuments, set on the company profile, drawn in the PDF header. - L-5. Preview:
POST /v1/layouts/{kind}/previewrenders the draft layout against a sample document (or a named real one the caller may read) without saving; the designer shows it beside the form. Saving isPUT /v1/layouts/{kind}with If-Match. - L-6. Who.
org.layouts.manage(Owner). Web: Manage → Document layouts. - As built (C4):
platform/printingholds the layouts (document_layout,00693, revisioned and audited), the logo and the four renderers overreporting/render’s PDF writer (which gained a logo and footer notes). The logo is kept inorg_logo(PNG or JPEG, 256 KB) rather than throughdocumentsandcompany_profile.logo_file_id: every module that prints can then draw it without an object-store handle.StatutoryInvoiceFieldsis what an invoice prints under any layout; the drift test holds the most stripped layout to it and to the QR. Routes:GET /v1/org/layouts,PUT /v1/org/layouts/{kind},POST /v1/org/layouts/{kind}/preview(a PDF of a built-in sample; previewing against a real document is not in v1),GET/PUT/DELETE /v1/org/logo, granted to existing Owners by00694; and the new PDFsGET /v1/receivables/customers/{id}/statement/pdf,/v1/payables/payments/{id}/adviceand/v1/expenses/claims/{id}/voucher, each behind its record’s read permission. A statement exported through the report exports keeps its report format.
Drift tests these lanes add (presence first, each seen failing once)
Section titled “Drift tests these lanes add (presence first, each seen failing once)”- A disabled module’s routes refuse and its permissions leave
/v1/me; an enabled one’s do not (C1). - A custom field value is validated by the entity’s own save function on every path (screen and import) (C2).
- A report builder total equals the trial balance for the same scope (C3).
- A layout can hide no statutory line: an invoice printed under the most stripped layout still carries TIN, SST number, tax and the QR (C4).