Skip to content

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
  • Routes: every module’s Register takes the one authenticated /v1 group (internal/app/app.go); per-route authorization is httpx.Protected. /v1/me returns permissions, 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 a Scope of an account subtree and analytic accounts on one axis; exports are async report_export jobs rendered to CSV/XLSX/PDF by reporting/render.
  • Printing: only a sales invoice or credit note prints (GET /receivables/invoices/{id}/pdf), drawn in Go through render.Writer (gopdf, A4, a QR slot, no logo, English labels). company_profile.logo_file_id exists but is unused.
  • 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’s permissions, so the nav, search and home hide it with no per-screen code; /v1/me gains modules (the enabled optional keys). Background jobs keep running on data that exists, except the e-Invoice sweep, which skips an organization whose einvoice is off.
  • K-5. Who. identity.org.administer (the Owner), at GET /v1/org/modules and PUT /v1/org/modules/{key}, every change audited. Web: Manage → Modules.
  • 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 like field_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 jsonb column on party_customer, party_supplier, sales_invoice and purchase_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 naming custom_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 each custom_fields). Exportable: the customer and supplier imports accept cf.<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 by 00676. GET /v1/org/custom-fields carries no permission (every form reads it; api/public_routes.txt), POST and PATCH need org.custom_fields.manage. customfield.Resolve runs inside CreateCustomer/UpdateCustomer, CreateSupplier/UpdateSupplier, CreateDocument/UpdateDocument and CreateInvoice/UpdateInvoice; a refusal is 422 custom_field_invalid with 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’s cf.<key> columns are the ones the imports read back.
  • 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 from opening, debit, credit, movement, closing, optionally budget and variance against 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 of reports.read; only the owner edits or deletes it.
  • R-3. Run and export. POST /v1/reports/custom/run runs a definition (saved or not); export is the ordinary async export with report custom and the saved report’s id, so CSV, XLSX and PDF come from reporting/render unchanged. Needs reports.read (run) and reports.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.Custom on movements(), the trial balance’s engine: columns opening, debit, credit, movement, closing (opening, movement and closing net, debit positive), depth 1–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 in platform/savedreport, outside the read-only reporting tree. Routes: POST /v1/reports/custom/run, GET/POST /v1/reports/custom, PUT/DELETE /v1/reports/custom/{id}, all reports.read; export is POST /v1/reports/exports with report custom and the definition as its params (00685 widens the CHECK), so an export is a snapshot of what was asked.
  • 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_id finally used: an image (PNG or JPEG) uploaded through documents, set on the company profile, drawn in the PDF header.
  • L-5. Preview: POST /v1/layouts/{kind}/preview renders 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 is PUT /v1/layouts/{kind} with If-Match.
  • L-6. Who. org.layouts.manage (Owner). Web: Manage → Document layouts.
  • As built (C4): platform/printing holds the layouts (document_layout, 00693, revisioned and audited), the logo and the four renderers over reporting/render’s PDF writer (which gained a logo and footer notes). The logo is kept in org_logo (PNG or JPEG, 256 KB) rather than through documents and company_profile.logo_file_id: every module that prints can then draw it without an object-store handle. StatutoryInvoiceFields is 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 by 00694; and the new PDFs GET /v1/receivables/customers/{id}/statement/pdf, /v1/payables/payments/{id}/advice and /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).