Phase 4 O4 — Fixed Assets Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: A finance user defines asset classes, registers fixed assets from what already posted their cost (a posted purchase invoice line, a manual journal line, or an opening-balance import), runs monthly book depreciation (automatically on the first daily tick of each month, or by hand), disposes of or writes off an asset through an approved workflow that posts its effect, reads the register, a depreciation forecast, a cost/accumulated-depreciation roll-forward and a register-vs-ledger comparison, and can rely on a nightly integrity check that the register equals the class accounts’ ledger balances.
Architecture: One new platform package, internal/platform/assets, owns five tables (asset_class, asset, asset_disposal, depreciation_run, depreciation_line), the pure schedule maths, registration (which never posts), the depreciation run (one manual journal entry per run through ledger.Post, source depreciation_run — not a workflow event), the River periodic job, the asset_disposal workflow kind (seeded graph, guard, and a posting rule on the effect semantic (O2 Task 1’s, canonical) posted by accounting’s Poster; the sole-owner exception covers its approval), the reports and the asset_register_gl reconciliation check. A new HTTP module internal/modules/assets sits behind the optional-module switch assets. The import framework gains a fixed_asset kind that calls assets.RegisterOpening. The web gains web/src/features/assets (five pages).
Tech Stack: Go 1.26 · Gin · pgx v5 (hand-written SQL) · goose · River · oapi-codegen · testify · testcontainers (Postgres 17) · shopspring/decimal behind kernel/money · React 19 · TanStack Router/Query/Table · i18next (en/ms/zh) · Vitest + MSW
Spec: docs/superpowers/specs/2026-09-30-operations-design.md (§O4; owner decision OD-4; “Drift tests these lanes add”; “Roadmap and outside-authority additions”)
Global Constraints
Section titled “Global Constraints”Every task’s requirements implicitly include this section.
- Migrations
00770–00789only. This plan uses00770–00773. The{770, 789}range row ininternal/kernel/db/migrationranges_test.gois registered by O1 Task 1; this plan does not touch that file (should O4 ever merge before O1, add O1’s{770, 789, "Phase 4 O4 fixed assets …"}row in Task 2’s commit). effecthas one definition: O2 Task 1 is canonical; if O2 lands first, drop O4 Task 1 (and never create00770). O4 Task 1 is O2 Task 1 carried over with only the substitutions it lists, so either way the tree holds the sameposting.Effect,ledger.SemanticEffect, CHECK lists andPostsToLedgerbehaviour:effectshares the instance’saccrual_open, its entry names source and instance (journal_entry_workflow_source), and cancelling after it reverses.- Provisioning steps: this lane adds none. The
asset_disposalgraph is seeded by workflow’s existing definitions step throughRegisterSeededGraph, so nothing collides with O2’s orders 81 and 83 or with O1’s. - Money never float.
money.Amountin Go,NUMERIC(19,4)in Postgres, a string on the wire (the OpenAPIMoneyschema). Percentages aremoney.Ratein Go,NUMERIC(9,4)in Postgres, the OpenAPIRatestring on the wire. Compare withCmp, never==. Nofloat64anywhere ininternal/platform/assets,internal/modules/assetsorweb/src/features/assets(web/src/drift/nofloat.test.tsscans the web). Depreciation is rounded to the sen once, half away from zero, withmoney.Amount.MulDivCents(Ruling FA-11). - Every new table has
org_id UUID NOT NULL REFERENCES organization (id),ENABLEandFORCE ROW LEVEL SECURITY, a policy namedorg_isolationwithUSINGandWITH CHECKoncurrent_setting('app.current_org', true)::uuid, aCONSTRAINT <table>_tenant_id UNIQUE (org_id, id), and composite(org_id, x)foreign keys only. - Named constraints. Every
CHECK/UNIQUE/FOREIGN KEYisCONSTRAINT <table>_<rule>; no name ends in_check,_keyor_excl(TestEveryConstraintIsNamed). A trigger’s refusal isRAISE EXCEPTION … USING ERRCODE = '23514', CONSTRAINT = '<name>'. - Pinned functions. Every trigger function is
SET search_path = pg_catalog, public, pg_tempand schema-qualifies every table. NoSECURITY DEFINER. - Every SQL statement names
<alias>.org_id = $nfor each org-scoped table it touches, subqueries and joins included, and everyINSERTnamesorg_id(TestEveryTenantQueryCarriesAnExplicitOrgPredicate). Platform functions read the org fromtenancy.OrgFromContext; nothing takes an org argument from a caller. - Retention (
internal/kernel/db/retention_test.go):depreciation_run,depreciation_lineareappendOnly(REVOKE UPDATE, DELETE);asset_class,asset,asset_disposalareamendable(REVOKE DELETE).assetkeeps its UPDATE grant only soSELECT … FOR UPDATEworks; a trigger refuses every change (Ruling FA-26). - The ledger is append-only. Registration never posts. Corrections are reversals. Nothing in this plan updates or deletes a
journal_*row. - Accounting reacts to semantic tags, never state names. The disposal posts because its state’s semantic is
effect; no Go code compares a disposal state key. - Every user-facing name is a translation row. An asset class’s name is
translationrows (entity_kind = 'asset_class',field = 'name'),enandmsrequired,zhoptional. An asset’s description and a disposal’s reason are record text: one column, English or Bahasa Malaysia only (recordtext.Checkin Go, a!~ '[-ヿ㐀-䶿一-鿿가-]'CHECK in SQL). - A claimant never picks their own approver. A disposal’s approval authority comes from its workflow grant (the Owner seat), resolved by the engine; the preparer cannot approve their own disposal (the grant names the Owner seat for that state only, so a preparer’s own grant never counts). The one exception is the existing audited sole-owner exception (Ruling O-3a), which covers a one-member organization’s disposal approval (FA-17).
- Lock order (Ruling FA-27), no advisory locks: run —
assetrowsFOR UPDATEin id order → ledger →audit.Write; create disposal —assetrow → insert →workflow.Start→ audit; transition disposal —workflow.LockSoleOwner→asset_disposalrowFOR UPDATE→workflow.Transition(instance, then guard:assetrow) → Poster → audit, last. - No nested
db.InTx. Platform functions take(ctx, tx pgx.Tx, …); only handlers, the River worker and tests open transactions. - Business date is
clock.Today(ctx, c)(the organization’s day, O-1); timestamps arec.Now(); nevertime.Now()and neverclock.TodayUTC(TestNothingDatesABusinessFactInUTC). EveryDATEin Go is midnight UTC. - Errors are RFC 7807. Handlers
_ = c.Error(mapErr(err))and return. Platform code returns sentinel errors or*assets.FieldError, never*apperr.Problem. A field refusal is 422 with detailfield=<name> rule=<code>: <sentence>and.With("code", <rule>). - OpenAPI first. Every route goes through
httpx.Protectedwith its permission, is documented inapi/openapi.yamlin the same commit (TestTheOpenAPIDocumentSaysWhatTheRouterEnforces), andinternal/modules/assets/routes_test.gopins each route’s permission.api/public_routes.txtgains nothing. Runmake generateafter every migration, OpenAPI orconstants.yamlchange; commit the generated files;git statusis clean after it. - Vocabulary values are strings: method
straight_line | reducing_balance; sourcepurchase_invoice_line | journal_line | opening_import; run statusposted | empty | skipped; disposal kindsale | write_off; asset statusin_service | disposed; GL rolecost | accumulated. - i18n. Every UI string is a key in
web/src/lib/i18n/locales/{en,ms,zh}/assets.jsonor an existing namespace;web/src/drift/i18nparity.test.tsrequires identical keys and placeholders. Malay is genuine Malay — use the strings in this plan. - Presence before absence (
AGENTS.md). Every refusal test shows the accepted case first. Every guard (trigger, CHECK, Go rule, drift test) is watched failing once: each such task has a Sabotage step — break the guard, run the test, read the red message, restore, and record the message in the commit body. - Tests:
testdb.Start(throughpayablestest.New/accountingtest.New) per test; run only the touched packages while iterating; one fullgo test -p 2 -count=1 ./...andmake webin Task 12 only. A test that registers a Poster never runs in parallel. - Shared edit points with O1/O2/O3 (merge, never overwrite; a CHECK list is the list on the integration branch when this merges plus this lane’s value):
constants.yaml,00770’s two semantic lists,module_install_key_known(00772),import_job_kind_known(00773),moduleinstall.Catalog,internal/app/app.go,internal/app/routes_test.go,cmd/api/main.go,cmd/worker/{main,exports}.go,api/openapi.yaml,web/src/app/shell/nav.ts,web/src/app/routes.ts,web/src/lib/i18n/locales/*/{common,modules,imports,journal,workflowEditor}.json,web/src/features/imports/MigrationPage.tsx,docs/ROADMAP.md,docs/DEPLOY_NOTES.md. - Never bare
git stash. Use a WIP commit. - Git commits end with:
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Rulings
Section titled “Rulings”Places the spec is silent or leaves a choice to the plan. Format: Ruling: <decision> — <why>.
- FA-1 Ruling:
effectis O2 Task 1’s, verbatim (Global Constraints). O4 Task 1 exists only so O4 builds alone and is dropped if O2 lands first. Itsworkflow_state_semantic_knownlist is the integration branch’s list at merge pluseffect(withcommittedwhen O1 has landed). - FA-2 Ruling: A depreciation run is not a workflow event and its entry is not an
effectentry. It posts the way system-originated, non-workflow entries post today — M4’s go-live imports (internal/platform/imports/balances.go:ledger.PostwithSemantic: ledger.SemanticManual,SourceKind: ledger.SourceKindMigrationPeriod,SourceIDthe import job,PostedBythe member who ran it; bank-statement journals ininternal/platform/banking/journal.gofollow the same shape withSourceKindStatementLine): semanticmanual, source_kinddepreciation_run, source_id the run, posted_by the member who ran it.journal_entry_manual_posterrequires a poster and no entry today posts without one, so the scheduled job names the member holding the organization’s root seat (workflow.OwnerSeat, the seat the seeded grants name);depreciation_run.run_bystays NULL, so the run list still shows it was scheduled. Idempotency isdepreciation_run_once_per_period. Run entries are excluded from registration sources. A run’s manual entry could be reversed from the journal screen like any manual entry;asset_register_glthen reports it. A disposal’s entry is the Poster’s: journal semanticeffect, sourceasset_disposal, with its instance. - FA-3 Ruling: A posted disposal is final.
DisposalGraphhas no exit fromdisposed, and the disposal guard refuses any move out of aneffectstate of kindasset_disposal(disposal_final), so a customer-edited graph cannot add one either. This is a per-graph rule of this kind, not a difference in the semantic:effectitself (O2 Task 1) reverses on a cancel edge, as a stock adjustment’s does; the register cannot follow such a reversal, so this kind does not offer it. The DB also refuses cancelling a posted disposal (asset_disposal_posted_or_cancelled). - FA-4 Ruling: The optional-module key is
assets, notfixed_assets:moduleinstall.OwnsPermissionties a module to the first segment of its permission keys, and the spec’s keys areassets.*. The UI calls it “Fixed assets”. - FA-5 Ruling: A class names four existing accounts; nothing provisions a Depreciation or Disposal account (the MY pack has only 1110 PP&E and 1120 Accumulated depreciation). Types: cost and accumulated
asset, depreciationexpense, disposalincomeorexpense; all active, postable, with no control kind; cost ≠ accumulated and depreciation ≠ disposal. The class dialog says so. Upgrade: a provisioning step adding 6200/4300. - FA-6 Ruling: A class’s code, accounts, method, life, rate and residual % are fixed at creation (trigger
asset_class_terms_fixed); only its names and active flag change. A wrong class is retired and a new one created. Changing terms under existing assets would be an MFRS 116 ¶51/¶61 change of estimate, which this lane does not build. - FA-7 Ruling: Sources. A purchase invoice line is eligible when its invoice is a
purchase_invoice(not a credit note), the line is not withdrawn, the invoice’s accrual is open, and the line’s account is an active class’s cost account; its cost is the line’snet_base(what posted to the cost account). A journal line is eligible when it is a normal debit line of amanualentry that is not reversed and not an opening balance or migration period, on an active class’s cost account; its cost isbase_debit. Opening assets arrive only by thefixed_assetimport. Unique per source line in the database (asset_purchase_line_once,asset_journal_line_once). - FA-8 Ruling: A source reversed after registration (an AP invoice voided, a manual entry reversed) is not blocked —
purchase_invoice’s one workflow-guard slot belongs to e-Invoicing — it surfaces as anasset_register_glfinding. Upgrade: a guard chain inworkflow.RegisterGuard. - FA-9 Ruling: Residual value defaults to the class’s residual % × cost, to the sen; a caller may give one,
0 ≤ residual ≤ cost − opening accumulated. - FA-10 Ruling: Useful life is the class’s; there is no per-asset life.
- FA-11 Ruling:
depreciate_fromis the first day of the in-service month for a new asset and the first day of the go-live month (cut-over + 1 day) for an opening asset. Straight-line: remaining life R = life − months(in-service month → depreciate_from), at least 1; monthly = (cost − residual − opening accumulated) ÷ R to the sen; the Rth month takes the remainder (“rounding to the final month”). Reducing balance ignores life: monthly = NBV × rate ÷ 1200 to the sen, capped at NBV − residual. - FA-12 Ruling: Catch-up. A run posts, per asset, scheduled accumulated through the period − posted so far, never negative. So an asset registered months after it went into service, or a month skipped because its period was closed, is caught up in the next run that posts.
Schedulednever depends on what posted, so the maths is pure. - FA-13 Ruling: A run is for one fiscal period whose
starts_on ≤ today; runs go forward only (run_out_of_orderonce a later period has one); one per period (depreciation_run_once_per_period), whatever its status. A closed or locked period gets askippedrun and no entry — the flag, listed on the Depreciation page; the next posted run catches up (FA-12). A period with nothing due gets anemptyrun. An organization with no member on its root seat cannot be run by the scheduler (ErrNoPoster, logged per organization); a member can still run the month by hand. - FA-14 Ruling: The schedule is a River periodic job every 24 hours (not on start). For each organization with the module on and at least one asset, it runs the period containing the last day of the previous calendar month, unless that period or a later one already has a run. So “on the 1st” is the first tick of the month, and a missed day heals itself. No cron-string dependency.
- FA-15 Ruling: A run entry has two lines per asset — Dr the class’s depreciation account carrying the asset’s analytic shares, Cr its accumulated account with none — dated the period’s last day, on the misc journal, described
Depreciation <YYYY-MM>, each line’s memo the asset number. Shares ride on the depreciation line only; registration validates them withanalytic.Planagainst the depreciation account’s type, required axes enforced. An organization that makes an axis required on asset or income accounts gets a refused posting that names it. - FA-16 Ruling: A disposal depreciates through its disposal month (catch-up posted in the disposal entry). A disposal dated in a month before one that already has depreciation posted for the asset is refused (
disposal_month_depreciated). A run skips assets with an open disposal dated on or before the period’s end. One open disposal per asset (asset_disposal_one_open).kindissaleorwrite_off(a write-off is a disposal without proceeds; proceeds post separately through AR to the disposal account, per the spec). The entry is dated the disposal date, or the first open date after it (the Poster’sEntryDaterule). - FA-17 Ruling: The sole-owner exception (Ruling O-3a) covers the disposal’s approval.
workflow.AllowSoleOwnerException(KindDisposal, true)is called inplatform/assets’init(), O2 Task 5’s pattern: with O2 Task 1’ssoleOwnerEdge,issuecovers the edge into an effect state.TransitionDisposaltakesworkflow.LockSoleOwnerfirst and audits withworkflow.SoleOwnerAudit. The engine requires the reason (ErrSoleOwnerReasonRequired,*ReasonError→ 422). With a second active member the claimant rule stands. There is no void edge to cover (FA-3). - FA-18 Ruling: The four reports are JSON endpoints under
/assets/reports/*plus the register list itself (GET /assetswith totals); no CSV/XLSX/PDF export this lane. The schedule forecasts 1–60 months (default 12) from the month after the latest run, or the current month when there is none. The roll-forward is by class for[from, to]. Register vs GL compares current balances. Upgrade: export kinds onplatform/reporting/export. - FA-19 Ruling: The integrity check is per account: Σ cost of live (not disposed) assets per class cost account against that account’s ledger balance; Σ accumulated depreciation of live assets per accumulated account against minus its balance. One finding per account that differs. It registers into
reconcile.Default()asasset_register_gl, so it runs nightly into the operator console’s integrity table and before every period close — a close with an unregistered addition on a class cost account is refused unless closed unreconciled. - FA-20 Ruling: Permissions
assets.asset.read,assets.asset.manage(classes and registration),assets.depreciation.run,assets.disposal.create,assets.disposal.transition. Owner gets all five; Finance gets all five (authority to approve still comes from the workflow grant). Registered withmember.RegisterPermission, not added toconstants.yaml(the Phase 3 precedent:imports.run,org.layouts.manage). - FA-21 Ruling: Asset numbers are
FA-<calendar year of registration>-NNNNfrom numbering scopefixed_asset. - FA-22 Ruling: A class code is
^[A-Z0-9][A-Z0-9_-]{0,19}$(the import names the class by it). Descriptions and reasons are 1–500 characters; class names are 1–100. - FA-23 Ruling: The opening import requires the migration started and unfinished (
requireUnfinished, like the other go-live kinds),in_service_on ≤ cut-over, and accumulated ≤ cost − residual. Columns:class,description,cost,accumulated_depreciation,in_service_on,residual_value. No shares column: an organization with a required expense axis cannot import opening assets until it relaxes it. - FA-24 Ruling: No Playwright e2e this lane; Go HTTP tests and web unit tests cover the flows.
- FA-25 Ruling: Existing organizations get the
asset_disposalgraph by re-running provisioning (DEPLOY_NOTES) and the five permissions from migration00772. The module is on by default (nomodule_installrow). - FA-26 Ruling:
assetrows are immutable (triggerasset_fixed) but UPDATE stays granted, becauseFOR UPDATEneeds it and runs and disposals lock asset rows. - FA-27 Ruling: the lock order in Global Constraints. A run takes asset locks before reading disposals and posted sums, so a disposal committed while the run waited is seen, and a disposal approved while a run holds the lock waits and then sees the run’s lines.
File Structure
Section titled “File Structure”Migrations (db/migrations/)
Section titled “Migrations (db/migrations/)”| File | Task | Responsibility |
|---|---|---|
00770_effect_semantic.sql |
1 | O2 Task 1’s 00720 body, renumbered (FA-1). Dropped if O2 landed first. |
00771_fixed_assets.sql |
2 | The five tables, their guards, RLS and grants. |
00772_assets_module.sql |
9 | assets in module_install_key_known; Owner and Finance grants for existing organizations. |
00773_import_fixed_asset.sql |
8 | fixed_asset in import_job_kind_known. |
New Go code
Section titled “New Go code”| Path | Task(s) | Responsibility |
|---|---|---|
internal/platform/assets/schedule.go |
3 | Terms, Scheduled, Due, Forecast; month helpers; method constants. Pure. |
internal/platform/assets/doc.go |
4 | Package doc, constants, sentinel errors, FieldError, helpers. |
internal/platform/assets/class.go |
4 | CreateClass, UpdateClass, GetClass, ListClasses. |
internal/platform/assets/asset.go |
4, 8 | Register, Get, List, Lines, Candidates; RegisterOpening (8). |
internal/platform/assets/run.go |
5 | RunDepreciation, ListRuns, ScheduledRun. |
internal/platform/assets/job.go |
5 | River DepreciationArgs, DepreciationWorker, DepreciationPeriodic, RunScheduled. |
internal/platform/assets/disposal.go |
6 | Graph, guard, DisposalRule, CreateDisposal, GetDisposal, ListDisposals, TransitionDisposal, Available. |
internal/platform/assets/integrity.go |
7 | RegisterVsGL, the asset_register_gl check. |
internal/platform/assets/reports.go |
7 | ScheduleForecast, RollForward. |
internal/platform/imports/fixedassets.go |
8 | The fixed_asset import kind. |
internal/modules/assets/*.go |
9 | Permissions, boot, routes, http helpers, error mapping, handlers. |
web/src/features/assets/* |
10, 11 | api, routes, five pages, dialogs, drawers, tests. |
Modified (shared)
Section titled “Modified (shared)”| Path | Task | Change |
|---|---|---|
internal/kernel/db/retention_test.go |
2 | Five tables. |
internal/platform/imports/imports.go |
8 | kinds gains fixed_asset. |
internal/platform/moduleinstall/moduleinstall.go |
9 | Catalog gains assets. |
internal/app/app.go, internal/app/routes_test.go, cmd/api/main.go |
9 | Module wiring. |
cmd/worker/main.go, cmd/worker/exports.go |
9 | Link the module; the depreciation worker and periodic job. |
api/openapi.yaml (+ internal/gen/api, web/src/lib/api/schema.d.ts) |
1, 8, 9 | Semantic enums; import kind; assets paths and schemas. |
Every file O2 Task 1 lists (vocab, posting, accounting, ledger, workflow, OpenAPI Semantic/WorkflowSemantic, semanticTone.ts) |
1 | effect, exactly as O2 Task 1. |
api/openapi.yaml journal-entry semantic enums; web/src/features/workflow/graphModel.ts; locales/*/{journal,workflowEditor}.json |
9, 11 | effect where O2 Task 1 leaves it out (disposal entries carry it). |
web/src/app/shell/nav.ts, web/src/app/routes.ts, web/src/lib/api/types.ts, locales/*/{assets,common}.json |
10 | Nav, routes, aliases, strings. |
web/src/features/imports/MigrationPage.tsx, locales/*/{imports,modules}.json |
11 | Import kind and module labels. |
docs/ROADMAP.md, docs/DEPLOY_NOTES.md |
12 | Tick; outside-authority item; deploy notes. |
Tasks in one wave touch disjoint files and may run in parallel.
| Wave | Tasks | Why this order |
|---|---|---|
| 1 | T1 | effect first so it can be dropped cleanly. |
| 2 | T2 ∥ T3 | Schema vs pure maths; disjoint files. |
| 3 | T4 | Classes and registration need T2’s tables and T3’s helpers. |
| 4 | T5 ∥ T6 | Run (run.go, job.go) vs disposal (disposal.go); both on T4’s package, disjoint files. T6 edits world_test.go’s poster line only. |
| 5 | T7 ∥ T8 | Integrity/reports vs import; T8 touches asset.go (append) and imports. |
| 6 | T9 | HTTP module, OpenAPI, wiring; needs T4–T8. |
| 7 | T10 | Web part 1; needs T9’s generated schema. |
| 8 | T11 | Web part 2. |
| 9 | T12 | Docs, full suite. |
Task 1: The effect semantic — O2 Task 1, carried over (drop if O2 landed first)
Section titled “Task 1: The effect semantic — O2 Task 1, carried over (drop if O2 landed first)”O2 Task 1 is canonical; if O2 lands first, drop O4 Task 1. Check with git grep -n 'Effect Semantic = "effect"' internal/platform/posting/: a hit means O2 (or this task) already landed, so skip to Task 2.
This task exists only so O4 builds alone. It is O2’s Task 1: execute docs/superpowers/plans/2026-09-30-phase-4-o2-stock-costing.md → “Task 1: The effect semantic, end to end”, Steps 1–9, every file, test, code block, sabotage and run command exactly as written there, with these substitutions and no others:
| In O2 Task 1 | In O4 Task 1 |
|---|---|
db/migrations/00720_effect_semantic.sql |
db/migrations/00770_effect_semantic.sql. The body is the same, with the header comment’s first line reading -- Phase 4 O2.3, carried by O4 Task 1 because O4 merged first (O2 Task 1 is canonical). |
'committed' in both workflow_state_semantic_known lists; posting.Committed in TestConstantsMatchTheVocabulary and in validate.go’s semantics; “after - committed” in constants.yaml |
Unchanged if O1’s 00700 is on the integration branch. If it is not, leave committed/Committed out everywhere, put - effect after - custom, and write the Up’s first statement as ALTER TABLE workflow_state DROP CONSTRAINT IF EXISTS workflow_state_semantic_check, DROP CONSTRAINT IF EXISTS workflow_state_semantic_known, ADD CONSTRAINT workflow_state_semantic_known CHECK (…) (00030 left the check unnamed). |
“O1’s Task 1 already declared the 00720–00739 range” |
O1 Task 1 declared 00770–00789 too (Global Constraints). |
commit subject Phase 4 O2: the effect semantic |
O4 Task 1: the effect semantic (O2 Task 1, canonical) |
Interfaces:
- Produces, identical to O2 Task 1 and relied on by Tasks 6 and 9:
posting.Effect, withPostsToLedger() == true.ledger.SemanticEffect:validateHeaderrequires source kind, source and instance for it, andjournal_entry_workflow_sourcecovers it. This is why runs are not effect entries (FA-2).RuleSet.Registeraccepts(kind, effect).- The Poster posts nothing for an empty effect draft.
ledger.Unreversedincludes effect entries, so an effect posts once per instance.- Entering an effect state sets
accrual_open, and cancelling after it reverses. - The validator treats effect like an accrual with no control account (
effect_on_cycle, reversal flag required). reporterMayPostandFinalApprovalaccept effect.soleOwnerEdge’sissuecovers the edge into an effect state.- OpenAPI
Semantic/WorkflowSemanticincludeeffect, andsemanticTone.tshaseffect: 'green'.
- O4 adds nothing to
effectitself. The journal-entrysemanticenums, the workflow editor’sSEMANTICSand the journal/editor labels, which O2 Task 1 does not touch, are added by Tasks 9 and 11 only if still missing.
Task 2: Schema — classes, assets, disposals, runs and lines
Section titled “Task 2: Schema — classes, assets, disposals, runs and lines”Files:
- Create:
db/migrations/00771_fixed_assets.sql - Create:
internal/platform/assets/schema_test.go - Modify:
internal/kernel/db/retention_test.go
Interfaces:
-
Consumes:
account,fiscal_period,journal_entry,journal_line,purchase_invoice_line,import_job,workflow_instance,organization_member— each hasUNIQUE (org_id, id). -
Produces: tables
asset_class,asset,asset_disposal,depreciation_run,depreciation_line, and the constraint names Go maps:asset_class_code_once,asset_class_terms_fixed,asset_fixed,asset_purchase_line_once,asset_journal_line_once,asset_disposal_one_open(unique index),asset_disposal_fixed,asset_disposal_posted_or_cancelled,depreciation_run_once_per_period,depreciation_line_once_per_run,depreciation_line_one_origin. -
Step 1: Write the failing schema tests.
internal/platform/assets/schema_test.go(packageassets_test). These insert with raw SQL as the app role inside the org’s transaction (RLS in force): the test is about the database, not Go.
package assets_test
import ( "context" "testing"
"github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" "github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/kernel/money" "github.com/mcom/erp/internal/platform/accounting/accountingtest" "github.com/mcom/erp/internal/platform/accounting/chart" "github.com/mcom/erp/internal/platform/accounting/ledger")
// schemaWorld is accountingtest's books plus the four class accounts, one// class and one import job to hang opening assets on.type schemaWorld struct { e *accountingtest.Env b accountingtest.Books member uuid.UUID cost, accum, dep, disp uuid.UUID class, job uuid.UUID}
func newSchemaWorld(t *testing.T) *schemaWorld { t.Helper() e := accountingtest.New(t) w := &schemaWorld{e: e, b: e.SeedBooks(t), member: e.SeedMember(t, "Farid")} ch := w.b.Chart e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { mk := func(parent chart.Account, code, name, typ string) uuid.UUID { a, err := chart.CreateAccount(ctx, tx, e.Clock, chart.AccountInput{ParentID: &parent.ID, Code: code, Name: name, Type: typ}) require.NoError(t, err) return a.ID } w.cost = mk(ch.Assets, "1810", "Equipment", chart.TypeAsset) w.accum = mk(ch.Assets, "1820", "Accumulated depreciation", chart.TypeAsset) w.dep = mk(ch.Expenses, "6810", "Depreciation", chart.TypeExpense) w.disp = mk(ch.Income, "4810", "Disposal gain or loss", chart.TypeIncome) return nil }) w.class = w.insertClass(t, "EQUIP") e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { return tx.QueryRow(ctx, ` INSERT INTO import_job (id, org_id, kind, file_name, file_format, file_sha256, file_bytes, mapping, row_count, committed_by, committed_at) VALUES (gen_random_uuid(), $1, 'chart_of_accounts', 'assets.csv', 'csv', encode(sha256('x'::bytea), 'hex'), 'x'::bytea, '{}'::jsonb, 1, $2, now()) RETURNING id`, e.Org, w.member).Scan(&w.job) }) return w}
func (w *schemaWorld) insertClass(t *testing.T, code string) uuid.UUID { t.Helper() var id uuid.UUID w.e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { return tx.QueryRow(ctx, ` INSERT INTO asset_class (org_id, code, cost_account_id, accumulated_account_id, depreciation_account_id, disposal_account_id, method, life_months, residual_percent, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, 'straight_line', 60, 0, now(), now()) RETURNING id`, w.e.Org, code, w.cost, w.accum, w.dep, w.disp).Scan(&id) }) return id}
// insertOpening inserts an opening-import asset; number must be unique.func (w *schemaWorld) insertOpening(ctx context.Context, tx pgx.Tx, number string) (uuid.UUID, error) { var id uuid.UUID err := tx.QueryRow(ctx, ` INSERT INTO asset (org_id, number, class_id, description, cost, residual_value, opening_accumulated, in_service_on, depreciate_from, source_kind, import_job_id, registered_by, registered_at) VALUES ($1, $2, $3, 'Delivery van', 60000, 0, 24000, DATE '2023-01-01', DATE '2026-01-01', 'opening_import', $4, $5, now()) RETURNING id`, w.e.Org, number, w.class, w.job, w.member).Scan(&id) return id, err}
func requireConstraint(t *testing.T, err error, name string) { t.Helper() var pgErr *pgconn.PgError require.ErrorAs(t, err, &pgErr) require.Equal(t, name, pgErr.ConstraintName, pgErr.Message)}
func TestAClassesTermsAreFixed(t *testing.T) { w := newSchemaWorld(t) // PRESENCE: retiring a class is an allowed change. w.e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { _, err := tx.Exec(ctx, `UPDATE asset_class SET is_active = false, updated_at = now() WHERE org_id = $1 AND id = $2`, w.e.Org, w.class) return err }) // ABSENCE: its life cannot change. err := w.e.TxErr(func(ctx context.Context, tx pgx.Tx) error { _, err := tx.Exec(ctx, `UPDATE asset_class SET life_months = 24 WHERE org_id = $1 AND id = $2`, w.e.Org, w.class) return err }) requireConstraint(t, err, "asset_class_terms_fixed")}
func TestAClassNamesItsMethodsTerms(t *testing.T) { w := newSchemaWorld(t) insert := func(method string, life, rate any) error { return w.e.TxErr(func(ctx context.Context, tx pgx.Tx) error { _, err := tx.Exec(ctx, ` INSERT INTO asset_class (org_id, code, cost_account_id, accumulated_account_id, depreciation_account_id, disposal_account_id, method, life_months, rate_percent, residual_percent, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 0, now(), now())`, w.e.Org, "C"+uuid.NewString()[:6], w.cost, w.accum, w.dep, w.disp, method, life, rate) return err }) } // PRESENCE: each method with its own term inserts. require.NoError(t, insert("straight_line", 36, nil)) require.NoError(t, insert("reducing_balance", nil, "20")) // ABSENCE: the other method's term, or none, is refused. requireConstraint(t, insert("straight_line", 36, "20"), "asset_class_method_terms") requireConstraint(t, insert("reducing_balance", nil, nil), "asset_class_method_terms") requireConstraint(t, insert("straight_line", 0, nil), "asset_class_method_terms")}
func TestAnAssetIsFixed(t *testing.T) { w := newSchemaWorld(t) var id uuid.UUID w.e.Tx(t, func(ctx context.Context, tx pgx.Tx) (err error) { id, err = w.insertOpening(ctx, tx, "FA-2026-0001"); return }) // PRESENCE: the row can be locked, and a no-change update passes the trigger. w.e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { if _, err := tx.Exec(ctx, `SELECT 1 FROM asset WHERE org_id = $1 AND id = $2 FOR UPDATE`, w.e.Org, id); err != nil { return err } _, err := tx.Exec(ctx, `UPDATE asset SET description = description WHERE org_id = $1 AND id = $2`, w.e.Org, id) return err }) // ABSENCE: its cost cannot change. err := w.e.TxErr(func(ctx context.Context, tx pgx.Tx) error { _, err := tx.Exec(ctx, `UPDATE asset SET cost = 1 WHERE org_id = $1 AND id = $2`, w.e.Org, id) return err }) requireConstraint(t, err, "asset_fixed")}
func TestAnAssetStaysWithinItsCost(t *testing.T) { w := newSchemaWorld(t) insert := func(residual, opening string) error { return w.e.TxErr(func(ctx context.Context, tx pgx.Tx) error { _, err := tx.Exec(ctx, ` INSERT INTO asset (org_id, number, class_id, description, cost, residual_value, opening_accumulated, in_service_on, depreciate_from, source_kind, import_job_id, registered_by, registered_at) VALUES ($1, $2, $3, 'Forklift', 1000, $4, $5, DATE '2025-06-15', DATE '2026-01-01', 'opening_import', $6, $7, now())`, w.e.Org, "FA-"+uuid.NewString()[:8], w.class, residual, opening, w.job, w.member) return err }) } require.NoError(t, insert("100", "900"), "PRESENCE: residual + opening = cost is allowed") requireConstraint(t, insert("100", "900.01"), "asset_within_cost")}
func TestASourceLineRegistersOnce(t *testing.T) { w := newSchemaWorld(t) var line uuid.UUID w.e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { e, err := ledger.Post(ctx, tx, w.e.Clock, ledger.EntryInput{ JournalID: &w.b.Journals.Misc.ID, EntryDate: accountingtest.Date("2026-09-20"), Description: "Laptop bought for cash", Semantic: ledger.SemanticManual, PostedBy: &w.member, Lines: []ledger.Line{{AccountID: w.cost, Debit: money.MustParse("3000")}, {AccountID: w.b.Chart.Cash.ID, Credit: money.MustParse("3000")}}, }) line = e.Lines[0].ID return err }) insert := func(number string) error { return w.e.TxErr(func(ctx context.Context, tx pgx.Tx) error { _, err := tx.Exec(ctx, ` INSERT INTO asset (org_id, number, class_id, description, cost, residual_value, in_service_on, depreciate_from, source_kind, journal_line_id, registered_by, registered_at) VALUES ($1, $2, $3, 'Laptop', 3000, 0, DATE '2026-09-20', DATE '2026-09-01', 'journal_line', $4, $5, now())`, w.e.Org, number, w.class, line, w.member) return err }) } require.NoError(t, insert("FA-2026-0001"), "PRESENCE: the line registers once") requireConstraint(t, insert("FA-2026-0002"), "asset_journal_line_once") // Two opening assets share one import job: the job is not a line. w.e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { if _, err := w.insertOpening(ctx, tx, "FA-2026-0003"); err != nil { return err } _, err := w.insertOpening(ctx, tx, "FA-2026-0004") return err })}
func TestARunIsOncePerPeriodAndItsLinesOncePerAsset(t *testing.T) { w := newSchemaWorld(t) var asset uuid.UUID w.e.Tx(t, func(ctx context.Context, tx pgx.Tx) (err error) { asset, err = w.insertOpening(ctx, tx, "FA-2026-0001"); return }) run := func(period uuid.UUID) (uuid.UUID, error) { var id uuid.UUID err := w.e.TxErr(func(ctx context.Context, tx pgx.Tx) error { return tx.QueryRow(ctx, ` INSERT INTO depreciation_run (id, org_id, fiscal_period_id, status, asset_count, total, run_at) VALUES (gen_random_uuid(), $1, $2, 'empty', 0, 0, now()) RETURNING id`, w.e.Org, period).Scan(&id) }) return id, err } r, err := run(w.b.Periods[7].ID) require.NoError(t, err, "PRESENCE: the first run of a period inserts") _, err = run(w.b.Periods[7].ID) requireConstraint(t, err, "depreciation_run_once_per_period")
line := func(runID, disposalID any) error { return w.e.TxErr(func(ctx context.Context, tx pgx.Tx) error { _, err := tx.Exec(ctx, ` INSERT INTO depreciation_line (org_id, asset_id, run_id, disposal_id, amount, charged_on) VALUES ($1, $2, $3, $4, 1500, DATE '2026-08-31')`, w.e.Org, asset, runID, disposalID) return err }) } require.NoError(t, line(r, nil), "PRESENCE: one line per asset per run") requireConstraint(t, line(r, nil), "depreciation_line_once_per_run") requireConstraint(t, line(nil, nil), "depreciation_line_one_origin")
// Append-only: the app role cannot change a run. err = w.e.TxErr(func(ctx context.Context, tx pgx.Tx) error { _, err := tx.Exec(ctx, `UPDATE depreciation_run SET total = 1 WHERE org_id = $1 AND id = $2`, w.e.Org, r) return err }) require.ErrorContains(t, err, "permission denied")}(accountingtest.Books.Periods is FY2026’s twelve periods, index 7 = August; ch.Assets, ch.Expenses, ch.Income, ch.Cash are accountingtest.Chart fields.)
Run: go test ./internal/platform/assets/... -count=1
Expected: FAIL — relation "asset_class" does not exist.
- Step 2: The migration.
db/migrations/00771_fixed_assets.sql:
-- +goose Up
-- Phase 4 O4 fixed assets (docs/superpowers/specs/2026-09-30-operations-design.md §O4).-- Registration never posts: the source already posted the cost. Depreciation-- runs post manual entries (source depreciation_run); disposals post through-- the Poster on the effect semantic.
CREATE TABLE asset_class ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), org_id UUID NOT NULL REFERENCES organization (id), code TEXT NOT NULL CONSTRAINT asset_class_code_shape CHECK (code ~ '^[A-Z0-9][A-Z0-9_-]{0,19}$'), cost_account_id UUID NOT NULL, accumulated_account_id UUID NOT NULL, depreciation_account_id UUID NOT NULL, disposal_account_id UUID NOT NULL, method TEXT NOT NULL CONSTRAINT asset_class_method_known CHECK (method IN ('straight_line','reducing_balance')), life_months INT, rate_percent NUMERIC(9,4), residual_percent NUMERIC(9,4) NOT NULL DEFAULT 0 CONSTRAINT asset_class_residual_range CHECK (residual_percent >= 0 AND residual_percent < 100), is_active BOOLEAN NOT NULL DEFAULT true, created_at TIMESTAMPTZ NOT NULL, updated_at TIMESTAMPTZ NOT NULL, CONSTRAINT asset_class_tenant_id UNIQUE (org_id, id), CONSTRAINT asset_class_code_once UNIQUE (org_id, code), CONSTRAINT asset_class_method_terms CHECK ( (method = 'straight_line' AND life_months BETWEEN 1 AND 1200 AND rate_percent IS NULL) OR (method = 'reducing_balance' AND rate_percent > 0 AND rate_percent <= 100 AND life_months IS NULL)), CONSTRAINT asset_class_accounts_distinct CHECK (cost_account_id <> accumulated_account_id AND depreciation_account_id <> disposal_account_id), CONSTRAINT asset_class_cost_account_fk FOREIGN KEY (org_id, cost_account_id) REFERENCES account (org_id, id), CONSTRAINT asset_class_accumulated_account_fk FOREIGN KEY (org_id, accumulated_account_id) REFERENCES account (org_id, id), CONSTRAINT asset_class_depreciation_account_fk FOREIGN KEY (org_id, depreciation_account_id) REFERENCES account (org_id, id), CONSTRAINT asset_class_disposal_account_fk FOREIGN KEY (org_id, disposal_account_id) REFERENCES account (org_id, id));
CREATE TABLE asset ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), org_id UUID NOT NULL REFERENCES organization (id), number TEXT NOT NULL, class_id UUID NOT NULL, description TEXT NOT NULL CONSTRAINT asset_description_shape CHECK (btrim(description) <> '' AND length(description) <= 500), cost NUMERIC(19,4) NOT NULL CONSTRAINT asset_cost_positive CHECK (cost > 0), residual_value NUMERIC(19,4) NOT NULL CONSTRAINT asset_residual_nonnegative CHECK (residual_value >= 0), opening_accumulated NUMERIC(19,4) NOT NULL DEFAULT 0 CONSTRAINT asset_opening_nonnegative CHECK (opening_accumulated >= 0), in_service_on DATE NOT NULL, depreciate_from DATE NOT NULL, shares JSONB NOT NULL DEFAULT '[]'::jsonb CONSTRAINT asset_shares_array CHECK (jsonb_typeof(shares) = 'array'), source_kind TEXT NOT NULL CONSTRAINT asset_source_kind_known CHECK (source_kind IN ('purchase_invoice_line','journal_line','opening_import')), purchase_invoice_line_id UUID, journal_line_id UUID, import_job_id UUID, registered_by UUID NOT NULL, registered_at TIMESTAMPTZ NOT NULL, CONSTRAINT asset_tenant_id UNIQUE (org_id, id), CONSTRAINT asset_number_once UNIQUE (org_id, number), -- A source line registers once (spec O4). NULLs are distinct: many rows -- have no purchase line. CONSTRAINT asset_purchase_line_once UNIQUE (org_id, purchase_invoice_line_id), CONSTRAINT asset_journal_line_once UNIQUE (org_id, journal_line_id), CONSTRAINT asset_description_language CHECK (description !~ '[-ヿ㐀-䶿一-鿿가-]'), CONSTRAINT asset_within_cost CHECK (residual_value + opening_accumulated <= cost), CONSTRAINT asset_from_month_start CHECK (extract(day FROM depreciate_from) = 1 AND depreciate_from >= date_trunc('month', in_service_on)::date), CONSTRAINT asset_source_matches_kind CHECK ( (source_kind = 'purchase_invoice_line' AND purchase_invoice_line_id IS NOT NULL AND journal_line_id IS NULL AND import_job_id IS NULL) OR (source_kind = 'journal_line' AND journal_line_id IS NOT NULL AND purchase_invoice_line_id IS NULL AND import_job_id IS NULL) OR (source_kind = 'opening_import' AND import_job_id IS NOT NULL AND purchase_invoice_line_id IS NULL AND journal_line_id IS NULL)), CONSTRAINT asset_opening_only_imported CHECK (opening_accumulated = 0 OR source_kind = 'opening_import'), CONSTRAINT asset_class_fk FOREIGN KEY (org_id, class_id) REFERENCES asset_class (org_id, id), CONSTRAINT asset_purchase_line_fk FOREIGN KEY (org_id, purchase_invoice_line_id) REFERENCES purchase_invoice_line (org_id, id), CONSTRAINT asset_journal_line_fk FOREIGN KEY (org_id, journal_line_id) REFERENCES journal_line (org_id, id), CONSTRAINT asset_import_job_fk FOREIGN KEY (org_id, import_job_id) REFERENCES import_job (org_id, id), CONSTRAINT asset_registered_by_fk FOREIGN KEY (org_id, registered_by) REFERENCES organization_member (org_id, id));
CREATE TABLE asset_disposal ( id UUID PRIMARY KEY, org_id UUID NOT NULL REFERENCES organization (id), asset_id UUID NOT NULL, kind TEXT NOT NULL CONSTRAINT asset_disposal_kind_known CHECK (kind IN ('sale','write_off')), disposed_on DATE NOT NULL, reason TEXT NOT NULL CONSTRAINT asset_disposal_reason_shape CHECK (btrim(reason) <> '' AND length(reason) <= 500), instance_id UUID NOT NULL, prepared_by UUID NOT NULL, created_at TIMESTAMPTZ NOT NULL, cancelled_at TIMESTAMPTZ, -- Set by the disposal guard as the approval enters its effect state. posted_at TIMESTAMPTZ, catch_up NUMERIC(19,4), accumulated NUMERIC(19,4), nbv NUMERIC(19,4), CONSTRAINT asset_disposal_tenant_id UNIQUE (org_id, id), CONSTRAINT asset_disposal_asset_id UNIQUE (org_id, id, asset_id), CONSTRAINT asset_disposal_instance_once UNIQUE (org_id, instance_id), CONSTRAINT asset_disposal_reason_language CHECK (reason !~ '[-ヿ㐀-䶿一-鿿가-]'), CONSTRAINT asset_disposal_posted_figures CHECK ((posted_at IS NULL) = (catch_up IS NULL) AND (posted_at IS NULL) = (accumulated IS NULL) AND (posted_at IS NULL) = (nbv IS NULL)), CONSTRAINT asset_disposal_figures_nonnegative CHECK (catch_up >= 0 AND accumulated >= 0 AND nbv >= 0), CONSTRAINT asset_disposal_posted_or_cancelled CHECK (posted_at IS NULL OR cancelled_at IS NULL), CONSTRAINT asset_disposal_asset_fk FOREIGN KEY (org_id, asset_id) REFERENCES asset (org_id, id), CONSTRAINT asset_disposal_instance_fk FOREIGN KEY (org_id, instance_id) REFERENCES workflow_instance (org_id, id), CONSTRAINT asset_disposal_prepared_by_fk FOREIGN KEY (org_id, prepared_by) REFERENCES organization_member (org_id, id));-- One disposal in progress (or posted) per asset (Ruling FA-16).CREATE UNIQUE INDEX asset_disposal_one_open ON asset_disposal (org_id, asset_id) WHERE cancelled_at IS NULL;
CREATE TABLE depreciation_run ( id UUID PRIMARY KEY, org_id UUID NOT NULL REFERENCES organization (id), fiscal_period_id UUID NOT NULL, status TEXT NOT NULL CONSTRAINT depreciation_run_status_known CHECK (status IN ('posted','empty','skipped')), journal_entry_id UUID, asset_count INT NOT NULL CONSTRAINT depreciation_run_count_nonnegative CHECK (asset_count >= 0), total NUMERIC(19,4) NOT NULL CONSTRAINT depreciation_run_total_nonnegative CHECK (total >= 0), run_by UUID, run_at TIMESTAMPTZ NOT NULL, CONSTRAINT depreciation_run_tenant_id UNIQUE (org_id, id), -- Spec O4: unique on (org, period). A second run for a period posts nothing. CONSTRAINT depreciation_run_once_per_period UNIQUE (org_id, fiscal_period_id), CONSTRAINT depreciation_run_entry_matches_status CHECK ((status = 'posted') = (journal_entry_id IS NOT NULL) AND (status = 'posted') = (asset_count > 0)), CONSTRAINT depreciation_run_period_fk FOREIGN KEY (org_id, fiscal_period_id) REFERENCES fiscal_period (org_id, id), CONSTRAINT depreciation_run_entry_fk FOREIGN KEY (org_id, journal_entry_id) REFERENCES journal_entry (org_id, id), CONSTRAINT depreciation_run_run_by_fk FOREIGN KEY (org_id, run_by) REFERENCES organization_member (org_id, id));
CREATE TABLE depreciation_line ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), org_id UUID NOT NULL REFERENCES organization (id), asset_id UUID NOT NULL, run_id UUID, disposal_id UUID, amount NUMERIC(19,4) NOT NULL CONSTRAINT depreciation_line_amount_positive CHECK (amount > 0), charged_on DATE NOT NULL, CONSTRAINT depreciation_line_tenant_id UNIQUE (org_id, id), CONSTRAINT depreciation_line_one_origin CHECK ((run_id IS NULL) <> (disposal_id IS NULL)), CONSTRAINT depreciation_line_once_per_run UNIQUE (org_id, run_id, asset_id), CONSTRAINT depreciation_line_once_per_disposal UNIQUE (org_id, disposal_id), CONSTRAINT depreciation_line_asset_fk FOREIGN KEY (org_id, asset_id) REFERENCES asset (org_id, id), CONSTRAINT depreciation_line_run_fk FOREIGN KEY (org_id, run_id) REFERENCES depreciation_run (org_id, id), -- A disposal's catch-up line is for the disposal's own asset. CONSTRAINT depreciation_line_disposal_fk FOREIGN KEY (org_id, disposal_id, asset_id) REFERENCES asset_disposal (org_id, id, asset_id));CREATE INDEX depreciation_line_asset_idx ON depreciation_line (org_id, asset_id);
ALTER TABLE asset_class ENABLE ROW LEVEL SECURITY;ALTER TABLE asset_class FORCE ROW LEVEL SECURITY;CREATE POLICY org_isolation ON asset_class USING (org_id = current_setting('app.current_org', true)::uuid) WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);ALTER TABLE asset ENABLE ROW LEVEL SECURITY;ALTER TABLE asset FORCE ROW LEVEL SECURITY;CREATE POLICY org_isolation ON asset USING (org_id = current_setting('app.current_org', true)::uuid) WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);ALTER TABLE asset_disposal ENABLE ROW LEVEL SECURITY;ALTER TABLE asset_disposal FORCE ROW LEVEL SECURITY;CREATE POLICY org_isolation ON asset_disposal USING (org_id = current_setting('app.current_org', true)::uuid) WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);ALTER TABLE depreciation_run ENABLE ROW LEVEL SECURITY;ALTER TABLE depreciation_run FORCE ROW LEVEL SECURITY;CREATE POLICY org_isolation ON depreciation_run USING (org_id = current_setting('app.current_org', true)::uuid) WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);ALTER TABLE depreciation_line ENABLE ROW LEVEL SECURITY;ALTER TABLE depreciation_line FORCE ROW LEVEL SECURITY;CREATE POLICY org_isolation ON depreciation_line USING (org_id = current_setting('app.current_org', true)::uuid) WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);
-- Records: never deleted. Runs and lines never change. asset keeps UPDATE-- only so FOR UPDATE works; asset_fixed refuses every change (Ruling FA-26).REVOKE UPDATE, DELETE ON depreciation_run, depreciation_line FROM erp_app;REVOKE DELETE ON asset_class, asset, asset_disposal FROM erp_app;
-- +goose StatementBeginCREATE FUNCTION asset_class_terms_fixed() RETURNS trigger LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp AS $$BEGIN IF ROW(NEW.org_id, NEW.code, NEW.cost_account_id, NEW.accumulated_account_id, NEW.depreciation_account_id, NEW.disposal_account_id, NEW.method, NEW.life_months, NEW.rate_percent, NEW.residual_percent, NEW.created_at) IS DISTINCT FROM ROW(OLD.org_id, OLD.code, OLD.cost_account_id, OLD.accumulated_account_id, OLD.depreciation_account_id, OLD.disposal_account_id, OLD.method, OLD.life_months, OLD.rate_percent, OLD.residual_percent, OLD.created_at) THEN RAISE EXCEPTION 'asset class %: only its names and active flag change (Ruling FA-6)', OLD.id USING ERRCODE = '23514', CONSTRAINT = 'asset_class_terms_fixed'; END IF; RETURN NEW;END $$;-- +goose StatementEndCREATE TRIGGER asset_class_terms_fixed BEFORE UPDATE ON asset_class FOR EACH ROW EXECUTE FUNCTION asset_class_terms_fixed();
-- +goose StatementBeginCREATE FUNCTION asset_fixed() RETURNS trigger LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp AS $$BEGIN IF NEW IS DISTINCT FROM OLD THEN RAISE EXCEPTION 'asset % is fixed once registered (Ruling FA-26)', OLD.id USING ERRCODE = '23514', CONSTRAINT = 'asset_fixed'; END IF; RETURN NEW;END $$;-- +goose StatementEndCREATE TRIGGER asset_fixed BEFORE UPDATE ON asset FOR EACH ROW EXECUTE FUNCTION asset_fixed();
-- +goose StatementBeginCREATE FUNCTION asset_disposal_fixed() RETURNS trigger LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp AS $$BEGIN IF ROW(NEW.org_id, NEW.asset_id, NEW.kind, NEW.disposed_on, NEW.reason, NEW.instance_id, NEW.prepared_by, NEW.created_at) IS DISTINCT FROM ROW(OLD.org_id, OLD.asset_id, OLD.kind, OLD.disposed_on, OLD.reason, OLD.instance_id, OLD.prepared_by, OLD.created_at) OR (OLD.cancelled_at IS NOT NULL AND NEW.cancelled_at IS DISTINCT FROM OLD.cancelled_at) OR (OLD.posted_at IS NOT NULL AND ROW(NEW.posted_at, NEW.catch_up, NEW.accumulated, NEW.nbv) IS DISTINCT FROM ROW(OLD.posted_at, OLD.catch_up, OLD.accumulated, OLD.nbv)) THEN RAISE EXCEPTION 'asset disposal %: only its cancellation or its posting is recorded, once', OLD.id USING ERRCODE = '23514', CONSTRAINT = 'asset_disposal_fixed'; END IF; RETURN NEW;END $$;-- +goose StatementEndCREATE TRIGGER asset_disposal_fixed BEFORE UPDATE ON asset_disposal FOR EACH ROW EXECUTE FUNCTION asset_disposal_fixed();
-- +goose DownDROP TABLE depreciation_line;DROP TABLE depreciation_run;DROP TABLE asset_disposal;DROP TABLE asset;DROP TABLE asset_class;DROP FUNCTION asset_disposal_fixed();DROP FUNCTION asset_fixed();DROP FUNCTION asset_class_terms_fixed();-
Step 3: Retention. In
internal/kernel/db/retention_test.go:"depreciation_run", "depreciation_line"intoappendOnlywith the comment// Phase 4 O4 fixed assets: a run and its lines are what posted; never changed.;"asset_class", "asset", "asset_disposal"intoamendablewith// Phase 4 O4 fixed assets: a class retires, a disposal records its outcome; asset keeps UPDATE only for FOR UPDATE (asset_fixed). Never deleted. -
Step 4: Run.
make generate(nothing new generated) thengo test ./internal/platform/assets/... ./internal/kernel/db/... ./internal/kernel/layering/... -count=1→ PASS. Confirm the RLS drift test (internal/kernel/db/rlscoverage_test.go) saw the five tables: run it with-v -run RLSand read that its checked-table count rose by 5. -
Step 5: Sabotage. (a) Remove
method,from bothROW(...)s ofasset_class_terms_fixedandlife_months; runTestAClassesTermsAreFixed; read the red (the update succeeds); restore. (b) Dropasset_journal_line_once; runTestASourceLineRegistersOnce; read the red; restore. (c) Remove"depreciation_line"fromappendOnly; runTestOnlyOperationalTablesGrantDeleteToTheAppRole(or the retention test that fails — read its name from the output); read the “no list names” failure; restore. Record the three messages in the commit body. -
Step 6: Commit.
git add -A && git commit -m "O4 Task 2: fixed asset schema and its guards" -m "<sabotage messages>" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
Task 3: The depreciation schedule (pure maths)
Section titled “Task 3: The depreciation schedule (pure maths)”Files:
- Create:
internal/platform/assets/schedule.go - Create:
internal/platform/assets/schedule_test.go
Interfaces:
- Consumes:
money.Amount.MulDivCents,money.Rate. - Produces (used by Tasks 4–7):
const MethodStraightLine = "straight_line"const MethodReducingBalance = "reducing_balance"
type Terms struct { Method string Cost money.Amount Residual money.Amount Opening money.Amount // accumulated depreciation brought in by an opening import InService time.Time From time.Time // first day of the first month this system charges LifeMonths int // straight_line RatePercent money.Rate // reducing_balance, annual, percent}func Scheduled(t Terms, through time.Time) money.Amountfunc Due(t Terms, through time.Time, posted money.Amount) money.Amountfunc Forecast(t Terms, start time.Time, n int, posted money.Amount) []money.Amountfunc monthStart(d time.Time) time.Time // unexported, package-widefunc monthEnd(d time.Time) time.Timefunc months(from, to time.Time) int- Step 1: Write the failing test.
internal/platform/assets/schedule_test.go:
package assets_test
import ( "testing" "time"
"github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/kernel/money" "github.com/mcom/erp/internal/platform/assets")
func day(y int, m time.Month, d int) time.Time { return time.Date(y, m, d, 0, 0, 0, 0, time.UTC) }func amt(s string) money.Amount { return money.MustParse(s) }
func requireAmount(t *testing.T, want string, got money.Amount, msg string) { t.Helper() require.Zero(t, amt(want).Cmp(got), "%s: want %s, got %s", msg, want, got)}
// sl12k is 12,000 over 12 months, in service mid-January: 1,000 a month.var sl12k = assets.Terms{Method: assets.MethodStraightLine, Cost: amt("12000"), Residual: amt("0"), Opening: amt("0"), InService: day(2026, 1, 15), From: day(2026, 1, 1), LifeMonths: 12}
func TestStraightLineChargesWholeMonthsFromTheInServiceMonth(t *testing.T) { requireAmount(t, "0", assets.Scheduled(sl12k, day(2025, 12, 31)), "before the in-service month") requireAmount(t, "1000", assets.Scheduled(sl12k, day(2026, 1, 31)), "the in-service month counts in full") requireAmount(t, "3000", assets.Scheduled(sl12k, day(2026, 3, 10)), "any day of March is through March") requireAmount(t, "12000", assets.Scheduled(sl12k, day(2026, 12, 31)), "the last month") requireAmount(t, "12000", assets.Scheduled(sl12k, day(2027, 6, 30)), "never past cost less residual")}
func TestStraightLineRoundsToTheFinalMonth(t *testing.T) { x := assets.Terms{Method: assets.MethodStraightLine, Cost: amt("1000"), Residual: amt("0"), Opening: amt("0"), InService: day(2026, 1, 1), From: day(2026, 1, 1), LifeMonths: 3} requireAmount(t, "333.33", assets.Scheduled(x, day(2026, 1, 31)), "month 1") requireAmount(t, "666.66", assets.Scheduled(x, day(2026, 2, 28)), "month 2") requireAmount(t, "1000", assets.Scheduled(x, day(2026, 3, 31)), "month 3 takes the remainder, 333.34") x.Residual = amt("100") requireAmount(t, "300", assets.Scheduled(x, day(2026, 1, 31)), "the residual is never depreciated")}
func TestStraightLineSpreadsAnOpeningAssetOverItsRemainingLife(t *testing.T) { // In service Jan 2025, 24-month life, 6,000 already depreciated before go-live Jan 2026: // 12 months remain, 6,000 left, 500 a month. x := assets.Terms{Method: assets.MethodStraightLine, Cost: amt("12000"), Residual: amt("0"), Opening: amt("6000"), InService: day(2025, 1, 10), From: day(2026, 1, 1), LifeMonths: 24} requireAmount(t, "500", assets.Scheduled(x, day(2026, 1, 31)), "month 1 of the remaining 12") requireAmount(t, "6000", assets.Scheduled(x, day(2026, 12, 31)), "all that remained") // Past its life at go-live: what remains goes in the first month. y := assets.Terms{Method: assets.MethodStraightLine, Cost: amt("1200"), Residual: amt("0"), Opening: amt("1000"), InService: day(2020, 1, 1), From: day(2026, 1, 1), LifeMonths: 12} requireAmount(t, "200", assets.Scheduled(y, day(2026, 1, 31)), "the remainder at once")}
func TestReducingBalanceChargesNBVTimesRateOverTwelve(t *testing.T) { x := assets.Terms{Method: assets.MethodReducingBalance, Cost: amt("10000"), Residual: amt("0"), Opening: amt("0"), InService: day(2026, 1, 1), From: day(2026, 1, 1), RatePercent: money.MustParseRate("20")} requireAmount(t, "166.67", assets.Scheduled(x, day(2026, 1, 31)), "10,000 × 20% ÷ 12") requireAmount(t, "330.56", assets.Scheduled(x, day(2026, 2, 28)), "+ 9,833.33 × 20% ÷ 12 = 163.89")}
func TestReducingBalanceStopsAtTheResidual(t *testing.T) { x := assets.Terms{Method: assets.MethodReducingBalance, Cost: amt("1000"), Residual: amt("990"), Opening: amt("0"), InService: day(2026, 1, 1), From: day(2026, 1, 1), RatePercent: money.MustParseRate("50")} requireAmount(t, "10", assets.Scheduled(x, day(2026, 1, 31)), "41.67 capped at the 10 above residual") requireAmount(t, "10", assets.Scheduled(x, day(2026, 3, 31)), "nothing after")}
func TestDueIsScheduledLessPostedNeverNegative(t *testing.T) { requireAmount(t, "2000", assets.Due(sl12k, day(2026, 3, 31), amt("1000")), "catch-up") requireAmount(t, "0", assets.Due(sl12k, day(2026, 1, 31), amt("3000")), "never negative")}
func TestForecastGivesEachMonthsCharge(t *testing.T) { got := assets.Forecast(sl12k, day(2026, 10, 1), 4, amt("9000")) require.Len(t, got, 4) for i, want := range []string{"1000", "1000", "1000", "0"} { requireAmount(t, want, got[i], "month") } // Nothing posted yet: the first month carries the catch-up. got = assets.Forecast(sl12k, day(2026, 3, 1), 2, amt("0")) requireAmount(t, "3000", got[0], "Jan–Mar") requireAmount(t, "1000", got[1], "Apr")}Run: go test ./internal/platform/assets/... -count=1 -run 'Straight|Reducing|Due|Forecast'
Expected: FAIL — undefined: assets.Terms.
- Step 2: Implement.
internal/platform/assets/schedule.go:
package assets
import ( "strconv" "time"
"github.com/mcom/erp/internal/kernel/money")
// The depreciation methods an asset class may use (spec O4).const ( MethodStraightLine = "straight_line" MethodReducingBalance = "reducing_balance")
// Terms is everything an asset's depreciation schedule depends on. Every// date is midnight UTC.type Terms struct { Method string Cost money.Amount Residual money.Amount Opening money.Amount // accumulated depreciation brought in by an opening import; zero otherwise InService time.Time From time.Time // first day of the first month this system charges (Ruling FA-11) LifeMonths int // straight_line RatePercent money.Rate // reducing_balance: the annual rate, in percent}
var one, twelveHundred = money.MustParse("1"), money.MustParse("1200")
func monthStart(d time.Time) time.Time { return time.Date(d.Year(), d.Month(), 1, 0, 0, 0, 0, time.UTC) }func monthEnd(d time.Time) time.Time { return monthStart(d).AddDate(0, 1, -1) }
// months is how many month boundaries lie between from's month and to's.func months(from, to time.Time) int { return (to.Year()-from.Year())*12 + int(to.Month()) - int(from.Month())}
func count(n int) money.Amount { return money.MustParse(strconv.Itoa(n)) }
// Scheduled is the depreciation this system charges on t from t.From through// the month containing through: whole months only, the in-service month in// full. It never includes t.Opening and never depends on what posted, so a// run and a forecast compute the same figure (Ruling FA-12).func Scheduled(t Terms, through time.Time) money.Amount { if monthStart(through).Before(t.From) { return money.Zero() } n := months(t.From, through) + 1 if t.Method == MethodReducingBalance { return reducing(t, n) } return straight(t, n)}
// straight: (cost − residual − opening) over the remaining life, to the sen;// the last month takes the remainder (Ruling FA-11).func straight(t Terms, n int) money.Amount { d := t.Cost.Sub(t.Residual).Sub(t.Opening) if !d.IsPositive() { return money.Zero() } r := t.LifeMonths - months(monthStart(t.InService), t.From) if r < 1 { r = 1 } if n >= r { return d } monthly, _ := d.MulDivCents(one, count(r)) // r >= 1: never a zero divisor s, _ := monthly.MulDivCents(count(n), one) if s.Cmp(d) > 0 { return d } return s}
// reducing: each month NBV × rate ÷ 1200 to the sen, never below the residual.// ponytail: one iteration per month since From; fine for decades of months.func reducing(t Terms, n int) money.Amount { rate := money.MustParse(t.RatePercent.String()) nbv, sum := t.Cost.Sub(t.Opening), money.Zero() for i := 0; i < n; i++ { room := nbv.Sub(t.Residual) if !room.IsPositive() { break } c, _ := nbv.MulDivCents(rate, twelveHundred) if c.Cmp(room) > 0 { c = room } if !c.IsPositive() { break } nbv, sum = nbv.Sub(c), sum.Add(c) } return sum}
// Due is what a posting through the month containing through adds to posted:// Scheduled less posted, never negative.func Due(t Terms, through time.Time, posted money.Amount) money.Amount { d := Scheduled(t, through).Sub(posted) if d.IsNegative() { return money.Zero() } return d}
// Forecast is each of n months' charge from the month containing start, given// what has posted: the depreciation schedule report (Ruling FA-18).func Forecast(t Terms, start time.Time, n int, posted money.Amount) []money.Amount { out := make([]money.Amount, n) for i := range out { s := Scheduled(t, monthStart(start).AddDate(0, i, 0)) if s.Cmp(posted) > 0 { out[i], posted = s.Sub(posted), s } else { out[i] = money.Zero() } } return out}-
Step 3: Run.
go test ./internal/platform/assets/... -count=1 -run 'Straight|Reducing|Due|Forecast'→ PASS. -
Step 4: Sabotage. Change
if n >= r {toif n > r {; runTestStraightLineRoundsToTheFinalMonth; read the red (month 3 … got 999.99); restore. Record it in the commit body. -
Step 5: Commit.
git add internal/platform/assets/schedule.go internal/platform/assets/schedule_test.go && git commit -m "O4 Task 3: straight-line and reducing-balance schedule" -m "<sabotage message>" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
Task 4: Classes and registration
Section titled “Task 4: Classes and registration”Files:
- Create:
internal/platform/assets/doc.go,internal/platform/assets/class.go,internal/platform/assets/asset.go - Create:
internal/platform/assets/world_test.go,internal/platform/assets/class_test.go,internal/platform/assets/asset_test.go
Interfaces:
- Consumes: Task 2’s tables; Task 3’s
Terms,monthStart;chart.GetAccount,chart.ErrNotFound,analytic.Plan,numbering.Allocate/Format,recordtext.Check,audit.Write,payablestest(tests). - Produces:
const (SourcePurchaseInvoiceLine, SourceJournalLine, SourceOpeningImport = "purchase_invoice_line", "journal_line", "opening_import")const (StatusInService, StatusDisposed = "in_service", "disposed")const (RunPosted, RunEmpty, RunSkipped = "posted", "empty", "skipped")const (DisposalSale, DisposalWriteOff = "sale", "write_off")const KindDisposal = "asset_disposal"; const SourceKindRun = "depreciation_run"; const ModuleKey = "assets"var ErrNotFound, ErrSourceNotEligible, ErrSourceRegistered, ErrClassCodeTaken, ErrRunExists, ErrRunOutOfOrder, ErrPeriodNotStarted, ErrDisposed, ErrDisposalOpen, ErrDisposalMonthDepreciated, ErrDisposalFinal, ErrNoPoster errortype FieldError struct{ Field, Rule, Detail string }
// class.gotype Class struct { ID uuid.UUID; Code string; Names map[string]string; CostAccountID, AccumulatedAccountID, DepreciationAccountID, DisposalAccountID uuid.UUID; Method string; LifeMonths *int; RatePercent *money.Rate; ResidualPercent money.Rate; IsActive bool; CreatedAt, UpdatedAt time.Time }type ClassInput struct { Code string; Names map[string]string; CostAccountID, AccumulatedAccountID, DepreciationAccountID, DisposalAccountID uuid.UUID; Method string; LifeMonths *int; RatePercent *money.Rate; ResidualPercent money.Rate }type ClassUpdate struct { Names map[string]string; IsActive bool }func CreateClass(ctx context.Context, tx pgx.Tx, c clock.Clock, in ClassInput) (Class, error)func UpdateClass(ctx context.Context, tx pgx.Tx, c clock.Clock, id uuid.UUID, in ClassUpdate) (Class, error)func GetClass(ctx context.Context, tx pgx.Tx, id uuid.UUID) (Class, error)func ListClasses(ctx context.Context, tx pgx.Tx) ([]Class, error)
// asset.gotype Asset struct { ID uuid.UUID; Number string; ClassID uuid.UUID; Description string; Cost, ResidualValue, OpeningAccumulated money.Amount; InServiceOn, DepreciateFrom time.Time; Shares []analytic.Share; SourceKind string; SourceID uuid.UUID; RegisteredBy uuid.UUID; RegisteredAt time.Time; Accumulated, NBV money.Amount; Status string; DisposedOn *time.Time }type RegisterInput struct { SourceKind string; SourceID, ClassID uuid.UUID; Description string; InServiceOn time.Time; ResidualValue *money.Amount; Shares []analytic.Share /* nil: the source line's */ }type Filter struct { Status string /* "" or "all": every asset */; ClassID *uuid.UUID }type Line struct { ChargedOn time.Time; Amount money.Amount; RunID, DisposalID *uuid.UUID }type Candidate struct { SourceKind string; SourceID uuid.UUID; Date time.Time; Reference, Description string; AccountID uuid.UUID; Amount money.Amount }func Register(ctx context.Context, tx pgx.Tx, c clock.Clock, by uuid.UUID, in RegisterInput) (Asset, error)func Get(ctx context.Context, tx pgx.Tx, id uuid.UUID) (Asset, error)func List(ctx context.Context, tx pgx.Tx, f Filter) ([]Asset, error)func Lines(ctx context.Context, tx pgx.Tx, assetID uuid.UUID) ([]Line, error)func Candidates(ctx context.Context, tx pgx.Tx) ([]Candidate, error)// unexported, used by later tasks:func orgOf(ctx context.Context) (uuid.UUID, error)func fieldErr(field, rule, detail string) errorfunc isConstraint(err error, name string) boolfunc lockAsset(ctx context.Context, tx pgx.Tx, org, id uuid.UUID) (Asset, error)func termsOf(a Asset, k Class) Termsfunc (a Asset) posted() money.Amounttype newAsset struct{...}; func insertAsset(ctx context.Context, tx pgx.Tx, c clock.Clock, org uuid.UUID, n newAsset) (Asset, error)func classByCode(ctx context.Context, tx pgx.Tx, code string) (Class, error)- Step 1: The test world.
internal/platform/assets/world_test.go:
package assets_test
import ( "context" "testing" "time"
"github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/kernel/money" "github.com/mcom/erp/internal/platform/accounting/chart" "github.com/mcom/erp/internal/platform/accounting/fiscal" "github.com/mcom/erp/internal/platform/accounting/ledger" "github.com/mcom/erp/internal/platform/assets" "github.com/mcom/erp/internal/platform/payables" "github.com/mcom/erp/internal/platform/payables/payablestest")
// world is payablestest's provisioned Malaysian organization (MY pack: 1110// PP&E, 1120 Accumulated depreciation, 3100 Share capital; Staff prepares,// Owner holds the OWNER seat) plus a Depreciation (6200) and a Disposal// (4300) account and one straight-line, 12-month class EQUIP. The clock is// 2026-09-21.type world struct { *payablestest.Env PPE, Accum, Dep, Disposal uuid.UUID Class assets.Class}
var jan15 = day(2026, 1, 15)
func newWorld(t *testing.T) *world { t.Helper() e := payablestest.New(t) e.UsePoster(t, payables.Rules()...) w := &world{Env: e, PPE: e.Account(t, "1110"), Accum: e.Account(t, "1120")} e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { exp, err := chart.GetAccountByCode(ctx, tx, "6000") if err != nil { return err } inc, err := chart.GetAccountByCode(ctx, tx, "4000") if err != nil { return err } dep, err := chart.CreateAccount(ctx, tx, e.Clock, chart.AccountInput{ParentID: &exp.ID, Code: "6200", Name: "Depreciation", Type: chart.TypeExpense}) if err != nil { return err } disp, err := chart.CreateAccount(ctx, tx, e.Clock, chart.AccountInput{ParentID: &inc.ID, Code: "4300", Name: "Gain or loss on disposal of assets", Type: chart.TypeIncome}) w.Dep, w.Disposal = dep.ID, disp.ID return err }) w.Class = w.class(t, "EQUIP", assets.MethodStraightLine, 12, "") return w}
// class creates a class on the world's four accounts: life for straight// line, rate for reducing balance.func (w *world) class(t *testing.T, code, method string, life int, rate string) assets.Class { t.Helper() in := assets.ClassInput{Code: code, Names: map[string]string{"en": code + " class", "ms": "Kelas " + code}, CostAccountID: w.PPE, AccumulatedAccountID: w.Accum, DepreciationAccountID: w.Dep, DisposalAccountID: w.Disposal, Method: method, ResidualPercent: money.MustParseRate("0")} if method == assets.MethodStraightLine { in.LifeMonths = &life } else { r := money.MustParseRate(rate) in.RatePercent = &r } var k assets.Class w.Tx(t, func(ctx context.Context, tx pgx.Tx) (err error) { k, err = assets.CreateClass(ctx, tx, w.Clock, in); return }) return k}
// manualCost posts Dr account / Cr share capital (3100), dated on, as the// Owner; the debit is the entry's first line.func (w *world) manualCost(t *testing.T, account uuid.UUID, cost string, on time.Time) ledger.Entry { t.Helper() capital := w.Account(t, "3100") var e ledger.Entry w.Tx(t, func(ctx context.Context, tx pgx.Tx) (err error) { e, err = ledger.Post(ctx, tx, w.Clock, ledger.EntryInput{JournalType: chart.JournalMisc, EntryDate: on, Description: "Equipment contributed by a shareholder", Semantic: ledger.SemanticManual, PostedBy: &w.Owner, Lines: []ledger.Line{{AccountID: account, Debit: money.MustParse(cost)}, {AccountID: capital, Credit: money.MustParse(cost)}}}) return }) return e}
func (w *world) register(t *testing.T, in assets.RegisterInput) (assets.Asset, error) { t.Helper() var a assets.Asset err := w.DB.InTx(w.As(w.Staff), func(ctx context.Context, tx pgx.Tx) (err error) { a, err = assets.Register(ctx, tx, w.Clock, w.Staff, in) return }) return a, err}
// journalAsset registers a manual cost line as an asset of class k, in// service on.func (w *world) journalAsset(t *testing.T, k assets.Class, cost string, on time.Time) assets.Asset { t.Helper() e := w.manualCost(t, k.CostAccountID, cost, on) a, err := w.register(t, assets.RegisterInput{SourceKind: assets.SourceJournalLine, SourceID: e.Lines[0].ID, ClassID: k.ID, Description: "Delivery van", InServiceOn: on}) require.NoError(t, err) return a}
// period is FY2026's period for month m.func (w *world) period(t *testing.T, m time.Month) fiscal.Period { t.Helper() var p fiscal.Period w.Tx(t, func(ctx context.Context, tx pgx.Tx) error { y, err := fiscal.YearContaining(ctx, tx, day(2026, m, 1)) if err != nil { return err } ps, err := fiscal.ListPeriods(ctx, tx, y.ID) if err == nil { p = ps[int(m)-1] } return err }) return p}
func (w *world) get(t *testing.T, id uuid.UUID) assets.Asset { t.Helper() var a assets.Asset w.Tx(t, func(ctx context.Context, tx pgx.Tx) (err error) { a, err = assets.Get(ctx, tx, id); return }) return a}- Step 2: Write the failing tests.
internal/platform/assets/class_test.go:
package assets_test
import ( "context" "testing"
"github.com/jackc/pgx/v5" "github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/kernel/money" "github.com/mcom/erp/internal/platform/assets")
func TestAClassNamesFourAccountsOfTheRightTypes(t *testing.T) { w := newWorld(t) require.Equal(t, "EQUIP", w.Class.Code, "PRESENCE: the world's class was created") require.Equal(t, "Kelas EQUIP", w.Class.Names["ms"])
base := func() assets.ClassInput { life := 60 return assets.ClassInput{Code: "VEH", Names: map[string]string{"en": "Vehicles", "ms": "Kenderaan"}, CostAccountID: w.PPE, AccumulatedAccountID: w.Accum, DepreciationAccountID: w.Dep, DisposalAccountID: w.Disposal, Method: assets.MethodStraightLine, LifeMonths: &life, ResidualPercent: money.MustParseRate("10")} } create := func(in assets.ClassInput) error { return w.TxErr(func(ctx context.Context, tx pgx.Tx) error { _, err := assets.CreateClass(ctx, tx, w.Clock, in); return err }) } for _, c := range []struct { name string mutate func(*assets.ClassInput) rule string }{ {"depreciation on an asset account", func(in *assets.ClassInput) { in.DepreciationAccountID = w.PPE }, "account_type"}, {"cost on an expense account", func(in *assets.ClassInput) { in.CostAccountID = w.Dep }, "account_type"}, {"accumulated is the cost account", func(in *assets.ClassInput) { in.AccumulatedAccountID = w.PPE }, "accounts_distinct"}, {"no Malay name", func(in *assets.ClassInput) { in.Names = map[string]string{"en": "Vehicles"} }, "names_required"}, {"straight line with a rate", func(in *assets.ClassInput) { r := money.MustParseRate("20"); in.RatePercent = &r }, "method_terms"}, {"a code with an accent", func(in *assets.ClassInput) { in.Code = "vé" }, "code_shape"}, {"residual of 100%", func(in *assets.ClassInput) { in.ResidualPercent = money.MustParseRate("100") }, "residual_range"}, } { in := base() c.mutate(&in) var fe *assets.FieldError require.ErrorAs(t, create(in), &fe, c.name) require.Equal(t, c.rule, fe.Rule, c.name) } require.NoError(t, create(base()), "PRESENCE: the unmutated input is accepted") require.ErrorIs(t, create(base()), assets.ErrClassCodeTaken)}
func TestAClassRetiresAndIsRenamed(t *testing.T) { w := newWorld(t) var k assets.Class w.Tx(t, func(ctx context.Context, tx pgx.Tx) (err error) { k, err = assets.UpdateClass(ctx, tx, w.Clock, w.Class.ID, assets.ClassUpdate{ Names: map[string]string{"en": "Office equipment", "ms": "Peralatan pejabat", "zh": "办公设备"}, IsActive: false}) return }) require.False(t, k.IsActive) require.Equal(t, "办公设备", k.Names["zh"]) require.Equal(t, *w.Class.LifeMonths, *k.LifeMonths, "terms unchanged")}internal/platform/assets/asset_test.go:
package assets_test
import ( "context" "testing"
"github.com/jackc/pgx/v5" "github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/platform/accounting/ledger" "github.com/mcom/erp/internal/platform/assets" "github.com/mcom/erp/internal/platform/payables" "github.com/mcom/erp/internal/platform/payables/payablestest")
func TestAPostedPurchaseLineRegistersOnceAndPostsNothing(t *testing.T) { w := newWorld(t) inv := w.InvoiceOn(t, payables.KindInvoice, w.Supplier(t, "DELL"), "INV-9", jan15, payablestest.Line(w.PPE, "12000")) in := assets.RegisterInput{SourceKind: assets.SourcePurchaseInvoiceLine, SourceID: inv.Lines[0].ID, ClassID: w.Class.ID, Description: "Laptops for finance", InServiceOn: jan15}
_, err := w.register(t, in) require.ErrorIs(t, err, assets.ErrSourceNotEligible, "a draft invoice has posted nothing")
w.Post(t, inv) entries := w.Count(t, `SELECT count(*) FROM journal_entry WHERE org_id = $1`, w.Org) a, err := w.register(t, in) require.NoError(t, err, "PRESENCE: the posted line registers") require.Equal(t, "FA-2026-0001", a.Number) requireAmount(t, "12000", a.Cost, "the line's net base") requireAmount(t, "0", a.ResidualValue, "the class's 0% residual") require.Equal(t, day(2026, 1, 1), a.DepreciateFrom) require.Equal(t, assets.StatusInService, a.Status) require.Equal(t, entries, w.Count(t, `SELECT count(*) FROM journal_entry WHERE org_id = $1`, w.Org), "registration never posts")
_, err = w.register(t, in) require.ErrorIs(t, err, assets.ErrSourceRegistered)}
func TestAManualLineIsACandidateUntilRegistered(t *testing.T) { w := newWorld(t) e := w.manualCost(t, w.PPE, "5000", jan15) other := w.manualCost(t, w.Dep, "70", jan15) // an expense: never a candidate candidates := func() map[string]bool { out := map[string]bool{} w.Tx(t, func(ctx context.Context, tx pgx.Tx) error { cs, err := assets.Candidates(ctx, tx) for _, c := range cs { out[c.SourceID.String()] = true } return err }) return out } require.True(t, candidates()[e.Lines[0].ID.String()], "PRESENCE: the cost line is offered") require.False(t, candidates()[other.Lines[0].ID.String()])
_, err := w.register(t, assets.RegisterInput{SourceKind: assets.SourceJournalLine, SourceID: other.Lines[0].ID, ClassID: w.Class.ID, Description: "Not an asset", InServiceOn: jan15}) require.ErrorIs(t, err, assets.ErrSourceNotEligible, "the line is not on the class's cost account")
_, err = w.register(t, assets.RegisterInput{SourceKind: assets.SourceJournalLine, SourceID: e.Lines[0].ID, ClassID: w.Class.ID, Description: "Forklift", InServiceOn: jan15}) require.NoError(t, err) require.False(t, candidates()[e.Lines[0].ID.String()], "a registered line is no longer offered")}
func TestAReversedManualLineIsNotEligible(t *testing.T) { w := newWorld(t) e := w.manualCost(t, w.PPE, "5000", jan15) w.Tx(t, func(ctx context.Context, tx pgx.Tx) error { _, err := ledger.Reverse(ctx, tx, w.Clock, ledger.ReverseInput{EntryID: e.ID, EntryDate: jan15, Description: "Entered twice", PostedBy: &w.Owner}) return err }) _, err := w.register(t, assets.RegisterInput{SourceKind: assets.SourceJournalLine, SourceID: e.Lines[0].ID, ClassID: w.Class.ID, Description: "Forklift", InServiceOn: jan15}) require.ErrorIs(t, err, assets.ErrSourceNotEligible)}
func TestADescriptionIsEnglishOrMalay(t *testing.T) { w := newWorld(t) e := w.manualCost(t, w.PPE, "5000", jan15) in := assets.RegisterInput{SourceKind: assets.SourceJournalLine, SourceID: e.Lines[0].ID, ClassID: w.Class.ID, Description: "叉车", InServiceOn: jan15} _, err := w.register(t, in) var fe *assets.FieldError require.ErrorAs(t, err, &fe) require.Equal(t, "description", fe.Field) in.Description = "Kereta angkat barang" // Malay _, err = w.register(t, in) require.NoError(t, err, "PRESENCE: Malay is record text")}Run: go test ./internal/platform/assets/... -count=1 -run 'Class|Register|Candidate|Reversed|Description'
Expected: FAIL — undefined: assets.ClassInput.
- Step 3:
doc.go.
// Package assets is the fixed-asset register (Phase 4 operations design// O4): asset classes, assets registered from what already posted their cost,// monthly depreciation runs, disposals, reports, and the register-to-ledger// integrity check.//// Registration never posts: the source already posted the cost. A run posts// one manual journal entry through ledger.Post (source depreciation_run, the// M4 import's pattern; Ruling FA-2). A disposal posts through accounting's// Poster when its workflow enters an effect state; this package's guard// settles its figures in the same transition, and DisposalRule builds the// lines. Book depreciation only (owner decision OD-4): capital allowance is// the tax agent's.package assets
import ( "context" "errors" "fmt"
"github.com/google/uuid" "github.com/jackc/pgx/v5/pgconn"
"github.com/mcom/erp/internal/kernel/tenancy")
const ( SourcePurchaseInvoiceLine = "purchase_invoice_line" SourceJournalLine = "journal_line" SourceOpeningImport = "opening_import"
StatusInService = "in_service" StatusDisposed = "disposed"
RunPosted = "posted" RunEmpty = "empty" RunSkipped = "skipped"
DisposalSale = "sale" DisposalWriteOff = "write_off"
// KindDisposal is a disposal's workflow kind, and so its entry's source_kind. KindDisposal = "asset_disposal" // SourceKindRun is a depreciation run entry's source_kind (Ruling FA-2). SourceKindRun = "depreciation_run" // ModuleKey is the optional-module key (Ruling FA-4). ModuleKey = "assets"
numberingScope = "fixed_asset" entityClass = "asset_class" entityAsset = "asset" entityRun = "depreciation_run" entityDisposal = "asset_disposal")
var ( ErrNotFound = errors.New("assets: no such record in this organization") ErrSourceNotEligible = errors.New("assets: the source is not a posted cost on an active class's cost account") ErrSourceRegistered = errors.New("assets: that source line is already registered") ErrClassCodeTaken = errors.New("assets: another class has that code") ErrRunExists = errors.New("assets: that period already has a depreciation run") ErrRunOutOfOrder = errors.New("assets: a later period already has a depreciation run") ErrPeriodNotStarted = errors.New("assets: the period has not started") ErrDisposed = errors.New("assets: the asset is disposed") ErrDisposalOpen = errors.New("assets: the asset already has a disposal in progress") ErrDisposalMonthDepreciated = errors.New("assets: depreciation is posted for a month after the disposal date") ErrDisposalFinal = errors.New("assets: a posted disposal is final") ErrNoPoster = errors.New("assets: no member holds the organization's root seat to post a scheduled run"))
// FieldError refuses one input field. Rule is the stable code the wire// carries.type FieldError struct{ Field, Rule, Detail string }
func (e *FieldError) Error() string { return fmt.Sprintf("field=%s rule=%s: %s", e.Field, e.Rule, e.Detail)}
func fieldErr(field, rule, detail string) error { return &FieldError{Field: field, Rule: rule, Detail: detail} }
func orgOf(ctx context.Context) (uuid.UUID, error) { org, ok := tenancy.OrgFromContext(ctx) if !ok { return uuid.Nil, fmt.Errorf("assets: %w", tenancy.ErrNoOrg) } return org, nil}
// isConstraint reports whether err is Postgres refusing on constraint name.func isConstraint(err error, name string) bool { var pg *pgconn.PgError return errors.As(err, &pg) && pg.ConstraintName == name}- Step 4:
class.go.
package assets
import ( "context" "errors" "fmt" "regexp" "slices" "strings" "time"
"github.com/google/uuid" "github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/kernel/audit" "github.com/mcom/erp/internal/kernel/clock" "github.com/mcom/erp/internal/kernel/locale" "github.com/mcom/erp/internal/kernel/money" "github.com/mcom/erp/internal/platform/accounting/chart")
// Class is one asset class (Rulings FA-5, FA-6). Names holds every locale// the class is named in.type Class struct { ID uuid.UUID Code string Names map[string]string CostAccountID uuid.UUID AccumulatedAccountID uuid.UUID DepreciationAccountID uuid.UUID DisposalAccountID uuid.UUID Method string LifeMonths *int RatePercent *money.Rate ResidualPercent money.Rate IsActive bool CreatedAt, UpdatedAt time.Time}
type ClassInput struct { Code string Names map[string]string CostAccountID, AccumulatedAccountID, DepreciationAccountID, DisposalAccountID uuid.UUID Method string LifeMonths *int RatePercent *money.Rate ResidualPercent money.Rate}
// ClassUpdate is all that changes on a class (Ruling FA-6).type ClassUpdate struct { Names map[string]string IsActive bool}
var ( classCode = regexp.MustCompile(`^[A-Z0-9][A-Z0-9_-]{0,19}$`) zeroRate = money.MustParseRate("0") hundredRate = money.MustParseRate("100"))
const classColumns = `k.id, k.code, k.cost_account_id, k.accumulated_account_id, k.depreciation_account_id, k.disposal_account_id, k.method, k.life_months, k.rate_percent, k.residual_percent, k.is_active, k.created_at, k.updated_at`
func scanClass(row pgx.Row) (Class, error) { var k Class err := row.Scan(&k.ID, &k.Code, &k.CostAccountID, &k.AccumulatedAccountID, &k.DepreciationAccountID, &k.DisposalAccountID, &k.Method, &k.LifeMonths, &k.RatePercent, &k.ResidualPercent, &k.IsActive, &k.CreatedAt, &k.UpdatedAt) return k, err}
// checkNames: en and ms required (record-bearing locales), zh optional, each// at most 100 characters (Ruling FA-22).func checkNames(names map[string]string) (map[string]string, error) { out := map[string]string{} for loc, v := range names { v = strings.TrimSpace(v) if !slices.Contains(locale.Supported, loc) { return nil, fieldErr("names", "names_locale", "names are given in en, ms or zh") } if len([]rune(v)) > 100 { return nil, fieldErr("names", "names_length", "a name is at most 100 characters") } if v != "" { out[loc] = v } } if out["en"] == "" || out["ms"] == "" { return nil, fieldErr("names", "names_required", "an English and a Malay name are required") } return out, nil}
func checkAccount(ctx context.Context, tx pgx.Tx, field string, id uuid.UUID, types ...string) error { a, err := chart.GetAccount(ctx, tx, id) switch { case errors.Is(err, chart.ErrNotFound): return fieldErr(field, "account_type", "no such account") case err != nil: return err case !a.IsActive || !a.IsPostable || a.ControlKind != nil || !slices.Contains(types, a.Type): return fieldErr(field, "account_type", fmt.Sprintf("an active, postable %s account with no control kind", strings.Join(types, " or "))) } return nil}
// CreateClass validates and inserts a class, its names and its audit row.func CreateClass(ctx context.Context, tx pgx.Tx, c clock.Clock, in ClassInput) (Class, error) { org, err := orgOf(ctx) if err != nil { return Class{}, err } code := strings.ToUpper(strings.TrimSpace(in.Code)) if !classCode.MatchString(code) { return Class{}, fieldErr("code", "code_shape", "1 to 20 capital letters, digits, dashes or underscores") } names, err := checkNames(in.Names) if err != nil { return Class{}, err } for _, a := range []struct { field string id uuid.UUID types []string }{ {"cost_account_id", in.CostAccountID, []string{chart.TypeAsset}}, {"accumulated_account_id", in.AccumulatedAccountID, []string{chart.TypeAsset}}, {"depreciation_account_id", in.DepreciationAccountID, []string{chart.TypeExpense}}, {"disposal_account_id", in.DisposalAccountID, []string{chart.TypeIncome, chart.TypeExpense}}, } { if err := checkAccount(ctx, tx, a.field, a.id, a.types...); err != nil { return Class{}, err } } switch { case in.CostAccountID == in.AccumulatedAccountID: return Class{}, fieldErr("accumulated_account_id", "accounts_distinct", "accumulated depreciation has its own account") case in.DepreciationAccountID == in.DisposalAccountID: return Class{}, fieldErr("disposal_account_id", "accounts_distinct", "disposal gain or loss has its own account") case in.Method == MethodStraightLine: if in.LifeMonths == nil || *in.LifeMonths < 1 || *in.LifeMonths > 1200 || in.RatePercent != nil { return Class{}, fieldErr("life_months", "method_terms", "straight line needs a useful life of 1 to 1200 months and no rate") } case in.Method == MethodReducingBalance: if in.RatePercent == nil || !in.RatePercent.IsPositive() || in.RatePercent.Cmp(hundredRate) > 0 || in.LifeMonths != nil { return Class{}, fieldErr("rate_percent", "method_terms", "reducing balance needs an annual rate above 0 and at most 100 percent, and no life") } default: return Class{}, fieldErr("method", "method_known", "the method is straight_line or reducing_balance") } if in.ResidualPercent.Cmp(zeroRate) < 0 || in.ResidualPercent.Cmp(hundredRate) >= 0 { return Class{}, fieldErr("residual_percent", "residual_range", "the residual is at least 0 and under 100 percent") } var taken bool if err := tx.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM asset_class k WHERE k.org_id = $1 AND k.code = $2)`, org, code).Scan(&taken); err != nil { return Class{}, fmt.Errorf("assets: create class: %w", err) } if taken { return Class{}, ErrClassCodeTaken } now := c.Now() var id uuid.UUID err = tx.QueryRow(ctx, ` INSERT INTO asset_class (org_id, code, cost_account_id, accumulated_account_id, depreciation_account_id, disposal_account_id, method, life_months, rate_percent, residual_percent, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $11) RETURNING id`, org, code, in.CostAccountID, in.AccumulatedAccountID, in.DepreciationAccountID, in.DisposalAccountID, in.Method, in.LifeMonths, in.RatePercent, in.ResidualPercent, now).Scan(&id) if isConstraint(err, "asset_class_code_once") { return Class{}, ErrClassCodeTaken } if err != nil { return Class{}, fmt.Errorf("assets: create class: %w", err) } if err := writeNames(ctx, tx, org, id, names); err != nil { return Class{}, err } k, err := GetClass(ctx, tx, id) if err != nil { return Class{}, err } return k, audit.Write(ctx, tx, c, audit.Entry{EntityKind: entityClass, EntityID: id, Action: "create", After: k})}
// UpdateClass renames a class and retires or restores it; its terms are fixed.func UpdateClass(ctx context.Context, tx pgx.Tx, c clock.Clock, id uuid.UUID, in ClassUpdate) (Class, error) { org, err := orgOf(ctx) if err != nil { return Class{}, err } before, err := GetClass(ctx, tx, id) if err != nil { return Class{}, err } names, err := checkNames(in.Names) if err != nil { return Class{}, err } if _, err := tx.Exec(ctx, `UPDATE asset_class SET is_active = $3, updated_at = $4 WHERE org_id = $1 AND id = $2`, org, id, in.IsActive, c.Now()); err != nil { return Class{}, fmt.Errorf("assets: update class: %w", err) } if err := writeNames(ctx, tx, org, id, names); err != nil { return Class{}, err } after, err := GetClass(ctx, tx, id) if err != nil { return Class{}, err } return after, audit.Write(ctx, tx, c, audit.Entry{EntityKind: entityClass, EntityID: id, Action: "update", Before: before, After: after})}
// writeNames upserts one translation row per locale given.func writeNames(ctx context.Context, tx pgx.Tx, org, id uuid.UUID, names map[string]string) error { for loc, v := range names { if _, err := tx.Exec(ctx, ` INSERT INTO translation (org_id, entity_kind, entity_id, field, locale, value) VALUES ($1, 'asset_class', $2, 'name', $3, $4) ON CONFLICT (org_id, entity_kind, entity_id, field, locale) WHERE org_id IS NOT NULL DO UPDATE SET value = EXCLUDED.value`, org, id, loc, v); err != nil { return fmt.Errorf("assets: class names: %w", err) } } return nil}
func loadNames(ctx context.Context, tx pgx.Tx, org uuid.UUID, ids []uuid.UUID) (map[uuid.UUID]map[string]string, error) { rows, err := tx.Query(ctx, ` SELECT t.entity_id, t.locale, t.value FROM translation t WHERE t.org_id = $1 AND t.entity_kind = 'asset_class' AND t.field = 'name' AND t.entity_id = ANY($2)`, org, ids) if err != nil { return nil, fmt.Errorf("assets: class names: %w", err) } defer rows.Close() out := map[uuid.UUID]map[string]string{} for rows.Next() { var id uuid.UUID var loc, v string if err := rows.Scan(&id, &loc, &v); err != nil { return nil, err } if out[id] == nil { out[id] = map[string]string{} } out[id][loc] = v } return out, rows.Err()}
func listClasses(ctx context.Context, tx pgx.Tx, where string, args ...any) ([]Class, error) { org, err := orgOf(ctx) if err != nil { return nil, err } rows, err := tx.Query(ctx, `SELECT `+classColumns+` FROM asset_class k WHERE k.org_id = $1 `+where+` ORDER BY k.code`, append([]any{org}, args...)...) if err != nil { return nil, fmt.Errorf("assets: classes: %w", err) } out, err := pgx.CollectRows(rows, func(r pgx.CollectableRow) (Class, error) { return scanClass(r) }) if err != nil { return nil, fmt.Errorf("assets: classes: %w", err) } ids := make([]uuid.UUID, len(out)) for i := range out { ids[i] = out[i].ID } names, err := loadNames(ctx, tx, org, ids) for i := range out { out[i].Names = names[out[i].ID] } return out, err}
func GetClass(ctx context.Context, tx pgx.Tx, id uuid.UUID) (Class, error) { ks, err := listClasses(ctx, tx, `AND k.id = $2`, id) if err != nil { return Class{}, err } if len(ks) == 0 { return Class{}, ErrNotFound } return ks[0], nil}
func ListClasses(ctx context.Context, tx pgx.Tx) ([]Class, error) { return listClasses(ctx, tx, ``) }
// classByCode is the import's lookup (Task 8).func classByCode(ctx context.Context, tx pgx.Tx, code string) (Class, error) { ks, err := listClasses(ctx, tx, `AND k.code = $2`, code) if err != nil { return Class{}, err } if len(ks) == 0 { return Class{}, ErrNotFound } return ks[0], nil}- Step 5:
asset.go.
package assets
import ( "context" "encoding/json" "errors" "fmt" "strings" "time"
"github.com/google/uuid" "github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/kernel/audit" "github.com/mcom/erp/internal/kernel/clock" "github.com/mcom/erp/internal/kernel/money" "github.com/mcom/erp/internal/kernel/recordtext" "github.com/mcom/erp/internal/platform/accounting/analytic" "github.com/mcom/erp/internal/platform/accounting/chart" "github.com/mcom/erp/internal/platform/numbering")
// Asset is one registered fixed asset with its running figures.type Asset struct { ID uuid.UUID Number string ClassID uuid.UUID Description string Cost money.Amount ResidualValue money.Amount OpeningAccumulated money.Amount InServiceOn time.Time DepreciateFrom time.Time Shares []analytic.Share SourceKind string SourceID uuid.UUID // the purchase invoice line, journal line or import job RegisteredBy uuid.UUID RegisteredAt time.Time Accumulated money.Amount // opening + every depreciation line NBV money.Amount // cost − accumulated; zero once disposed Status string // in_service | disposed DisposedOn *time.Time}
// posted is what this system has charged: accumulated less the opening balance.func (a Asset) posted() money.Amount { return a.Accumulated.Sub(a.OpeningAccumulated) }
func termsOf(a Asset, k Class) Terms { t := Terms{Method: k.Method, Cost: a.Cost, Residual: a.ResidualValue, Opening: a.OpeningAccumulated, InService: a.InServiceOn, From: a.DepreciateFrom} if k.LifeMonths != nil { t.LifeMonths = *k.LifeMonths } if k.RatePercent != nil { t.RatePercent = *k.RatePercent } return t}
// shareJSON is the stored shape of a share (payables' own, copied).type shareJSON struct { AnalyticAccountID uuid.UUID `json:"analytic_account_id"` Percentage money.Rate `json:"percentage"`}
func parseShares(raw []byte) ([]analytic.Share, error) { var stored []shareJSON if err := json.Unmarshal(raw, &stored); err != nil { return nil, fmt.Errorf("assets: shares: %w", err) } var out []analytic.Share for _, s := range stored { out = append(out, analytic.Share{AnalyticAccountID: s.AnalyticAccountID, Percentage: s.Percentage}) } return out, nil}
func sharesJSON(shares []analytic.Share) []byte { stored := make([]shareJSON, 0, len(shares)) for _, s := range shares { stored = append(stored, shareJSON{AnalyticAccountID: s.AnalyticAccountID, Percentage: s.Percentage}) } raw, _ := json.Marshal(stored) return raw}
const assetSelect = ` SELECT a.id, a.number, a.class_id, a.description, a.cost, a.residual_value, a.opening_accumulated, a.in_service_on, a.depreciate_from, a.shares, a.source_kind, coalesce(a.purchase_invoice_line_id, a.journal_line_id, a.import_job_id), a.registered_by, a.registered_at, a.opening_accumulated + coalesce((SELECT sum(l.amount) FROM depreciation_line l WHERE l.org_id = $1 AND l.asset_id = a.id), 0), (SELECT d.disposed_on FROM asset_disposal d WHERE d.org_id = $1 AND d.asset_id = a.id AND d.posted_at IS NOT NULL) FROM asset a WHERE a.org_id = $1`
func scanAsset(row pgx.Row) (Asset, error) { var a Asset var raw []byte if err := row.Scan(&a.ID, &a.Number, &a.ClassID, &a.Description, &a.Cost, &a.ResidualValue, &a.OpeningAccumulated, &a.InServiceOn, &a.DepreciateFrom, &raw, &a.SourceKind, &a.SourceID, &a.RegisteredBy, &a.RegisteredAt, &a.Accumulated, &a.DisposedOn); err != nil { return a, err } shares, err := parseShares(raw) a.Shares = shares a.InServiceOn, a.DepreciateFrom = a.InServiceOn.UTC(), a.DepreciateFrom.UTC() a.Status, a.NBV = StatusInService, a.Cost.Sub(a.Accumulated) if a.DisposedOn != nil { d := a.DisposedOn.UTC() a.DisposedOn, a.Status, a.NBV = &d, StatusDisposed, money.Zero() } return a, err}
func Get(ctx context.Context, tx pgx.Tx, id uuid.UUID) (Asset, error) { org, err := orgOf(ctx) if err != nil { return Asset{}, err } a, err := scanAsset(tx.QueryRow(ctx, assetSelect+` AND a.id = $2`, org, id)) if errors.Is(err, pgx.ErrNoRows) { return Asset{}, ErrNotFound } if err != nil { return Asset{}, fmt.Errorf("assets: get: %w", err) } return a, nil}
// lockAsset takes the asset row FOR UPDATE (Ruling FA-27), then reads it.func lockAsset(ctx context.Context, tx pgx.Tx, org, id uuid.UUID) (Asset, error) { if _, err := tx.Exec(ctx, `SELECT 1 FROM asset WHERE org_id = $1 AND id = $2 FOR UPDATE`, org, id); err != nil { return Asset{}, fmt.Errorf("assets: lock: %w", err) } return Get(ctx, tx, id)}
// Filter narrows List. Status "" or "all" is every asset.type Filter struct { Status string ClassID *uuid.UUID}
// List is the register, by number (the register report, Ruling FA-18).func List(ctx context.Context, tx pgx.Tx, f Filter) ([]Asset, error) { org, err := orgOf(ctx) if err != nil { return nil, err } rows, err := tx.Query(ctx, assetSelect+` AND ($2::uuid IS NULL OR a.class_id = $2) ORDER BY a.number`, org, f.ClassID) if err != nil { return nil, fmt.Errorf("assets: list: %w", err) } all, err := pgx.CollectRows(rows, func(r pgx.CollectableRow) (Asset, error) { return scanAsset(r) }) if err != nil { return nil, fmt.Errorf("assets: list: %w", err) } if f.Status == "" || f.Status == "all" { return all, nil } out := []Asset{} for _, a := range all { if a.Status == f.Status { out = append(out, a) } } return out, nil}
// Line is one depreciation charge on an asset: a run's, or a disposal's catch-up.type Line struct { ChargedOn time.Time Amount money.Amount RunID *uuid.UUID DisposalID *uuid.UUID}
func Lines(ctx context.Context, tx pgx.Tx, assetID uuid.UUID) ([]Line, error) { org, err := orgOf(ctx) if err != nil { return nil, err } rows, err := tx.Query(ctx, ` SELECT l.charged_on, l.amount, l.run_id, l.disposal_id FROM depreciation_line l WHERE l.org_id = $1 AND l.asset_id = $2 ORDER BY l.charged_on, l.id`, org, assetID) if err != nil { return nil, fmt.Errorf("assets: lines: %w", err) } return pgx.CollectRows(rows, func(r pgx.CollectableRow) (Line, error) { var l Line err := r.Scan(&l.ChargedOn, &l.Amount, &l.RunID, &l.DisposalID) l.ChargedOn = l.ChargedOn.UTC() return l, err })}
// RegisterInput registers an asset from a posted cost (Ruling FA-7).type RegisterInput struct { SourceKind string // purchase_invoice_line | journal_line SourceID uuid.UUID ClassID uuid.UUID Description string InServiceOn time.Time ResidualValue *money.Amount // nil: the class's residual % of cost Shares []analytic.Share // nil: the source line's}
type source struct { accountID uuid.UUID cost money.Amount shares []analytic.Share}
func readSource(ctx context.Context, tx pgx.Tx, org uuid.UUID, kind string, id uuid.UUID) (source, error) { var s source var err error switch kind { case SourcePurchaseInvoiceLine: var raw []byte err = tx.QueryRow(ctx, ` SELECT l.account_id, l.net_base, l.shares FROM purchase_invoice_line l JOIN purchase_invoice i ON i.org_id = l.org_id AND i.id = l.invoice_id JOIN workflow_instance w ON w.org_id = i.org_id AND w.id = i.instance_id WHERE l.org_id = $1 AND i.org_id = $1 AND w.org_id = $1 AND l.id = $2 AND i.kind = 'purchase_invoice' AND l.withdrawn_at IS NULL AND w.accrual_open`, org, id).Scan(&s.accountID, &s.cost, &raw) if err == nil { s.shares, err = parseShares(raw) } case SourceJournalLine: err = tx.QueryRow(ctx, ` SELECT jl.account_id, jl.base_debit FROM journal_line jl JOIN journal_entry e ON e.org_id = jl.org_id AND e.id = jl.entry_id WHERE jl.org_id = $1 AND e.org_id = $1 AND jl.id = $2 AND jl.kind = 'normal' AND jl.base_debit > 0 AND e.semantic = 'manual' AND coalesce(e.source_kind, '') NOT IN ('opening_balance', 'migration_period', 'depreciation_run') AND NOT EXISTS (SELECT 1 FROM journal_entry r WHERE r.org_id = $1 AND r.reversal_of_id = e.id)`, org, id).Scan(&s.accountID, &s.cost) if err == nil { s.shares, err = distributionShares(ctx, tx, org, id) } default: return s, fieldErr("source_kind", "source_kind_known", "the source is a purchase invoice line or a journal line") } if errors.Is(err, pgx.ErrNoRows) { return s, ErrSourceNotEligible } return s, err}
func distributionShares(ctx context.Context, tx pgx.Tx, org, line uuid.UUID) ([]analytic.Share, error) { rows, err := tx.Query(ctx, ` SELECT ad.analytic_account_id, ad.percentage FROM analytic_distribution ad WHERE ad.org_id = $1 AND ad.journal_line_id = $2 ORDER BY ad.analytic_account_id`, org, line) if err != nil { return nil, fmt.Errorf("assets: line shares: %w", err) } return pgx.CollectRows(rows, func(r pgx.CollectableRow) (analytic.Share, error) { var s analytic.Share err := r.Scan(&s.AnalyticAccountID, &s.Percentage) return s, err })}
// Register registers an asset from a posted cost line. It never posts.func Register(ctx context.Context, tx pgx.Tx, c clock.Clock, by uuid.UUID, in RegisterInput) (Asset, error) { org, err := orgOf(ctx) if err != nil { return Asset{}, err } k, err := GetClass(ctx, tx, in.ClassID) if errors.Is(err, ErrNotFound) || (err == nil && !k.IsActive) { return Asset{}, fieldErr("class_id", "class_active", "choose an active asset class") } if err != nil { return Asset{}, err } src, err := readSource(ctx, tx, org, in.SourceKind, in.SourceID) if err != nil { return Asset{}, err } if src.accountID != k.CostAccountID { return Asset{}, ErrSourceNotEligible } var taken bool if err := tx.QueryRow(ctx, ` SELECT EXISTS (SELECT 1 FROM asset a WHERE a.org_id = $1 AND (a.purchase_invoice_line_id = $2 OR a.journal_line_id = $2))`, org, in.SourceID).Scan(&taken); err != nil { return Asset{}, fmt.Errorf("assets: register: %w", err) } if taken { return Asset{}, ErrSourceRegistered } shares := in.Shares if shares == nil { shares = src.shares } return insertAsset(ctx, tx, c, org, newAsset{class: k, description: in.Description, cost: src.cost, opening: money.Zero(), residual: in.ResidualValue, inService: in.InServiceOn, from: monthStart(in.InServiceOn), shares: shares, sourceKind: in.SourceKind, sourceID: in.SourceID, by: by})}
type newAsset struct { class Class description string cost, opening money.Amount residual *money.Amount inService time.Time from time.Time shares []analytic.Share sourceKind string sourceID uuid.UUID by uuid.UUID}
// insertAsset is every registration's one write path (Register, RegisterOpening).func insertAsset(ctx context.Context, tx pgx.Tx, c clock.Clock, org uuid.UUID, n newAsset) (Asset, error) { today := clock.Today(ctx, c) desc := strings.TrimSpace(n.description) if desc == "" || len([]rune(desc)) > 500 || recordtext.Check("description", desc) != nil { return Asset{}, fieldErr("description", "required", "a description of up to 500 characters, in English or Bahasa Malaysia, is required") } if n.inService.IsZero() || n.inService.After(today) { return Asset{}, fieldErr("in_service_on", "not_future", "the in-service date is today or earlier") } residual := n.cost.PercentCents(n.class.ResidualPercent) if n.residual != nil { residual = *n.residual } if residual.IsNegative() || residual.Add(n.opening).Cmp(n.cost) > 0 { return Asset{}, fieldErr("residual_value", "within_cost", "the residual value and accumulated depreciation cannot exceed the cost") } dep, err := chart.GetAccount(ctx, tx, n.class.DepreciationAccountID) if err != nil { return Asset{}, err } // Ruling FA-15: the shares ride on the depreciation line; check them now, // with required axes enforced, so a run never fails on them later. if _, err := analytic.Plan(ctx, tx, dep.Type, n.cost, n.shares, true); err != nil { return Asset{}, fieldErr("shares", "shares_valid", err.Error()) } year := today.Year() seq, err := numbering.Allocate(ctx, tx, numberingScope, year) if err != nil { return Asset{}, err } var pil, jl, job *uuid.UUID src := n.sourceID switch n.sourceKind { case SourcePurchaseInvoiceLine: pil = &src case SourceJournalLine: jl = &src case SourceOpeningImport: job = &src } id := uuid.New() _, err = tx.Exec(ctx, ` INSERT INTO asset (id, org_id, number, class_id, description, cost, residual_value, opening_accumulated, in_service_on, depreciate_from, shares, source_kind, purchase_invoice_line_id, journal_line_id, import_job_id, registered_by, registered_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)`, id, org, numbering.Format("FA", year, seq), n.class.ID, desc, n.cost, residual, n.opening, n.inService, n.from, sharesJSON(n.shares), n.sourceKind, pil, jl, job, n.by, c.Now()) if isConstraint(err, "asset_purchase_line_once") || isConstraint(err, "asset_journal_line_once") { return Asset{}, ErrSourceRegistered } if err != nil { return Asset{}, fmt.Errorf("assets: register: %w", err) } a, err := Get(ctx, tx, id) if err != nil { return Asset{}, err } return a, audit.Write(ctx, tx, c, audit.Entry{EntityKind: entityAsset, EntityID: id, Action: "register", After: a})}
// Candidate is a posted cost on an active class's cost account that is not// yet registered: what the Register dialog offers.type Candidate struct { SourceKind string SourceID uuid.UUID Date time.Time Reference string Description string AccountID uuid.UUID Amount money.Amount}
// Candidates lists them, oldest first, at most 500.func Candidates(ctx context.Context, tx pgx.Tx) ([]Candidate, error) { org, err := orgOf(ctx) if err != nil { return nil, err } rows, err := tx.Query(ctx, ` SELECT 'purchase_invoice_line', l.id, i.invoice_date, i.reference, l.description, l.account_id, l.net_base FROM purchase_invoice_line l JOIN purchase_invoice i ON i.org_id = l.org_id AND i.id = l.invoice_id JOIN workflow_instance w ON w.org_id = i.org_id AND w.id = i.instance_id WHERE l.org_id = $1 AND i.org_id = $1 AND w.org_id = $1 AND i.kind = 'purchase_invoice' AND l.withdrawn_at IS NULL AND w.accrual_open AND l.account_id IN (SELECT k.cost_account_id FROM asset_class k WHERE k.org_id = $1 AND k.is_active) AND NOT EXISTS (SELECT 1 FROM asset a WHERE a.org_id = $1 AND a.purchase_invoice_line_id = l.id) UNION ALL SELECT 'journal_line', jl.id, e.entry_date, e.entry_no, coalesce(nullif(jl.memo, ''), e.description), jl.account_id, jl.base_debit FROM journal_line jl JOIN journal_entry e ON e.org_id = jl.org_id AND e.id = jl.entry_id WHERE jl.org_id = $1 AND e.org_id = $1 AND jl.kind = 'normal' AND jl.base_debit > 0 AND e.semantic = 'manual' AND coalesce(e.source_kind, '') NOT IN ('opening_balance', 'migration_period', 'depreciation_run') AND NOT EXISTS (SELECT 1 FROM journal_entry r WHERE r.org_id = $1 AND r.reversal_of_id = e.id) AND jl.account_id IN (SELECT k2.cost_account_id FROM asset_class k2 WHERE k2.org_id = $1 AND k2.is_active) AND NOT EXISTS (SELECT 1 FROM asset a2 WHERE a2.org_id = $1 AND a2.journal_line_id = jl.id) ORDER BY 3, 4 LIMIT 500`, org) if err != nil { return nil, fmt.Errorf("assets: candidates: %w", err) } return pgx.CollectRows(rows, func(r pgx.CollectableRow) (Candidate, error) { var c Candidate err := r.Scan(&c.SourceKind, &c.SourceID, &c.Date, &c.Reference, &c.Description, &c.AccountID, &c.Amount) c.Date = c.Date.UTC() return c, err })}-
Step 6: Run.
go test ./internal/platform/assets/... ./internal/kernel/layering/... -count=1→ PASS (the tenant-predicate guard reads every new statement). -
Step 7: Sabotage. (a) Delete
AND w.accrual_openfromreadSource; runTestAPostedPurchaseLineRegistersOnceAndPostsNothing; read the red (the draft registers); restore. (b) Delete theNOT EXISTS (… reversal_of_id …)clause fromreadSource; runTestAReversedManualLineIsNotEligible; read the red; restore. (c) Replacer.org_id = $1 ANDin that clause with nothing; rungo test ./internal/kernel/layering/ -run TestEveryTenantQueryCarriesAnExplicitOrgPredicate; read the red namingasset.go; restore. Record the three messages. -
Step 8: Commit.
git add internal/platform/assets && git commit -m "O4 Task 4: asset classes and registration from posted costs" -m "<sabotage messages>" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
Task 5: Depreciation runs and the scheduled job
Section titled “Task 5: Depreciation runs and the scheduled job”Files:
- Create:
internal/platform/assets/run.go,internal/platform/assets/job.go - Create:
internal/platform/assets/run_test.go,internal/platform/assets/job_test.go
Interfaces:
- Consumes: Task 4’s
List,ListClasses,termsOf,Asset.posted,orgOf,isConstraint; Task 3’sDue,monthStart;fiscal.GetPeriod,fiscal.StatusOpen;ledger.Postwithledger.SemanticManual(FA-2);workflow.OwnerSeat;moduleinstall.Enabled;jobs.QueueMaintenance. - Produces:
type Run struct { ID, PeriodID uuid.UUID; PeriodStart, PeriodEnd time.Time; Status string; JournalEntryID *uuid.UUID; AssetCount int; Total money.Amount; RunBy *uuid.UUID; RunAt time.Time }func RunDepreciation(ctx context.Context, tx pgx.Tx, c clock.Clock, periodID uuid.UUID, by *uuid.UUID) (Run, error) // by nil: scheduled; the entry's poster is the root seat's holderfunc ListRuns(ctx context.Context, tx pgx.Tx) ([]Run, error) // newest period firstfunc ScheduledRun(ctx context.Context, tx pgx.Tx, c clock.Clock) (*Run, error) // nil: nothing to dotype DepreciationArgs struct{} // Kind() "assets_depreciation", queue maintenancefunc DepreciationPeriodic() *river.PeriodicJobtype DepreciationWorker struct { river.WorkerDefaults[DepreciationArgs]; DB *db.DB; Clock clock.Clock; Logger *slog.Logger }func RunScheduled(ctx context.Context, d *db.DB, c clock.Clock, org uuid.UUID) error// test helpers in run_test.go, used by Tasks 6 and 7:func (w *world) run(t *testing.T, m time.Month) (assets.Run, error)func (w *world) balance(t *testing.T, account uuid.UUID) money.Amount // base debit − credit- Step 1: Write the failing tests.
internal/platform/assets/run_test.go:
package assets_test
import ( "context" "testing" "time"
"github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/kernel/money" "github.com/mcom/erp/internal/platform/accounting/fiscal" "github.com/mcom/erp/internal/platform/assets")
// run runs depreciation for FY2026 month m as the Owner.func (w *world) run(t *testing.T, m time.Month) (assets.Run, error) { t.Helper() p := w.period(t, m) var r assets.Run err := w.DB.InTx(w.As(w.Owner), func(ctx context.Context, tx pgx.Tx) (err error) { r, err = assets.RunDepreciation(ctx, tx, w.Clock, p.ID, &w.Owner) return }) return r, err}
func (w *world) balance(t *testing.T, account uuid.UUID) money.Amount { t.Helper() b := money.Zero() require.NoError(t, w.H.Admin.QueryRow(context.Background(), `SELECT coalesce(sum(base_debit - base_credit), 0) FROM journal_line WHERE org_id = $1 AND account_id = $2`, w.Org, account).Scan(&b)) return b}
func (w *world) runEntries(t *testing.T) int { t.Helper() return w.Count(t, `SELECT count(*) FROM journal_entry WHERE org_id = $1 AND source_kind = 'depreciation_run'`, w.Org)}
// The spec's drift test: a second depreciation run for the same period posts nothing.func TestASecondRunForAPeriodPostsNothing(t *testing.T) { w := newWorld(t) a := w.journalAsset(t, w.Class, "12000", jan15)
r, err := w.run(t, time.August) require.NoError(t, err) // PRESENCE: the first run posts January to August, 1,000 a month. require.Equal(t, assets.RunPosted, r.Status) require.Equal(t, 1, r.AssetCount) requireAmount(t, "8000", r.Total, "Jan–Aug caught up") require.Equal(t, 1, w.runEntries(t)) require.Equal(t, 1, w.Count(t, `SELECT count(*) FROM journal_entry WHERE org_id = $1 AND source_kind = 'depreciation_run' AND semantic = 'manual' AND posted_by = $2`, w.Org, w.Owner), "a manual entry naming who ran it (FA-2)") requireAmount(t, "8000", w.balance(t, w.Dep), "Dr depreciation") requireAmount(t, "-8000", w.balance(t, w.Accum), "Cr accumulated depreciation") got := w.get(t, a.ID) requireAmount(t, "8000", got.Accumulated, "the register follows") requireAmount(t, "4000", got.NBV, "net book value")
// ABSENCE: the second run for August is refused and posts nothing. _, err = w.run(t, time.August) require.ErrorIs(t, err, assets.ErrRunExists) require.Equal(t, 1, w.runEntries(t)) requireAmount(t, "8000", w.balance(t, w.Dep), "unchanged")}
func TestAClosedPeriodIsSkippedFlaggedAndCaughtUp(t *testing.T) { w := newWorld(t) w.journalAsset(t, w.Class, "12000", jan15) r, err := w.run(t, time.July) require.NoError(t, err) requireAmount(t, "7000", r.Total, "PRESENCE: an open period posts")
aug := w.period(t, time.August) w.Tx(t, func(ctx context.Context, tx pgx.Tx) error { return fiscal.SetPeriodStatus(ctx, tx, w.Clock, aug.ID, fiscal.StatusClosed, &w.Owner) }) r, err = w.run(t, time.August) require.NoError(t, err) require.Equal(t, assets.RunSkipped, r.Status, "the flag") require.Nil(t, r.JournalEntryID, "never posted into a closed period") require.Equal(t, 1, w.runEntries(t))
r, err = w.run(t, time.September) require.NoError(t, err) requireAmount(t, "2000", r.Total, "August caught up in September") requireAmount(t, "9000", w.balance(t, w.Dep), "Jan–Sep")}
func TestRunsGoForwardAndNotIntoTheFuture(t *testing.T) { w := newWorld(t) w.journalAsset(t, w.Class, "12000", jan15) _, err := w.run(t, time.September) require.NoError(t, err, "PRESENCE: the current month runs (clock 2026-09-21)") _, err = w.run(t, time.August) require.ErrorIs(t, err, assets.ErrRunOutOfOrder) _, err = w.run(t, time.October) require.ErrorIs(t, err, assets.ErrPeriodNotStarted)}
func TestAPeriodWithNothingDueIsEmpty(t *testing.T) { w := newWorld(t) w.journalAsset(t, w.Class, "12000", day(2026, 9, 10)) r, err := w.run(t, time.August) require.NoError(t, err) require.Equal(t, assets.RunEmpty, r.Status, "in service only from September") require.Equal(t, 0, w.runEntries(t)) r, err = w.run(t, time.September) require.NoError(t, err) require.Equal(t, assets.RunPosted, r.Status, "PRESENCE: September charges it")}
func TestListRunsIsNewestFirst(t *testing.T) { w := newWorld(t) w.journalAsset(t, w.Class, "12000", jan15) _, err := w.run(t, time.July) require.NoError(t, err) _, err = w.run(t, time.August) require.NoError(t, err) var runs []assets.Run w.Tx(t, func(ctx context.Context, tx pgx.Tx) (err error) { runs, err = assets.ListRuns(ctx, tx); return }) require.Len(t, runs, 2) require.Equal(t, day(2026, 8, 1), runs[0].PeriodStart)}internal/platform/assets/job_test.go:
package assets_test
import ( "context" "testing"
"github.com/google/uuid" "github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/platform/assets")
func (w *world) runs(t *testing.T) int { t.Helper() return w.Count(t, `SELECT count(*) FROM depreciation_run WHERE org_id = $1`, w.Org)}
// Ruling FA-14: the daily tick runs the previous month once.func TestTheScheduleRunsThePreviousMonthOnce(t *testing.T) { w := newWorld(t) require.NoError(t, assets.RunScheduled(context.Background(), w.DB, w.Clock, w.Org)) require.Equal(t, 0, w.runs(t), "no asset, no run")
w.journalAsset(t, w.Class, "12000", jan15) require.NoError(t, assets.RunScheduled(context.Background(), w.DB, w.Clock, w.Org)) require.Equal(t, 1, w.runs(t), "PRESENCE: August (the clock is 2026-09-21)") requireAmount(t, "8000", w.balance(t, w.Dep), "Jan–Aug") var sem string var poster uuid.UUID require.NoError(t, w.H.Admin.QueryRow(context.Background(), ` SELECT semantic, posted_by FROM journal_entry WHERE org_id = $1 AND source_kind = 'depreciation_run'`, w.Org).Scan(&sem, &poster)) require.Equal(t, "manual", sem, "a run is not a workflow event (FA-2)") require.Equal(t, w.Owner, poster, "the scheduler posts as the root seat's holder")
require.NoError(t, assets.RunScheduled(context.Background(), w.DB, w.Clock, w.Org)) require.Equal(t, 1, w.runs(t), "the next tick does nothing")}Run: go test ./internal/platform/assets/... -count=1 -run 'Run|Schedule|Period'
Expected: FAIL — undefined: assets.RunDepreciation.
- Step 2:
run.go.
package assets
import ( "context" "errors" "fmt" "time"
"github.com/google/uuid" "github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/kernel/audit" "github.com/mcom/erp/internal/kernel/clock" "github.com/mcom/erp/internal/kernel/money" "github.com/mcom/erp/internal/platform/accounting/chart" "github.com/mcom/erp/internal/platform/accounting/fiscal" "github.com/mcom/erp/internal/platform/accounting/ledger" "github.com/mcom/erp/internal/platform/workflow")
// Run is one period's depreciation run (Ruling FA-13).type Run struct { ID uuid.UUID PeriodID uuid.UUID PeriodStart time.Time PeriodEnd time.Time Status string // posted | empty | skipped JournalEntryID *uuid.UUID AssetCount int Total money.Amount RunBy *uuid.UUID // nil: the scheduled job RunAt time.Time}
const runSelect = ` SELECT r.id, r.fiscal_period_id, p.starts_on, p.ends_on, r.status, r.journal_entry_id, r.asset_count, r.total, r.run_by, r.run_at FROM depreciation_run r JOIN fiscal_period p ON p.org_id = r.org_id AND p.id = r.fiscal_period_id WHERE r.org_id = $1 AND p.org_id = $1`
func ListRuns(ctx context.Context, tx pgx.Tx) ([]Run, error) { org, err := orgOf(ctx) if err != nil { return nil, err } rows, err := tx.Query(ctx, runSelect+` ORDER BY p.starts_on DESC`, org) if err != nil { return nil, fmt.Errorf("assets: runs: %w", err) } return pgx.CollectRows(rows, func(r pgx.CollectableRow) (Run, error) { var x Run err := r.Scan(&x.ID, &x.PeriodID, &x.PeriodStart, &x.PeriodEnd, &x.Status, &x.JournalEntryID, &x.AssetCount, &x.Total, &x.RunBy, &x.RunAt) x.PeriodStart, x.PeriodEnd = x.PeriodStart.UTC(), x.PeriodEnd.UTC() return x, err })}
type due struct { asset Asset class Class amount money.Amount}
// dueThrough locks every asset in service by end, in id order, and only then// reads disposals and posted sums (Ruling FA-27), so a disposal committed// while this waited is seen.func dueThrough(ctx context.Context, tx pgx.Tx, org uuid.UUID, end time.Time) ([]due, error) { if _, err := tx.Exec(ctx, `SELECT 1 FROM asset a WHERE a.org_id = $1 AND a.in_service_on <= $2 ORDER BY a.id FOR UPDATE`, org, end); err != nil { return nil, fmt.Errorf("assets: lock assets: %w", err) } rows, err := tx.Query(ctx, ` SELECT d.asset_id FROM asset_disposal d WHERE d.org_id = $1 AND d.cancelled_at IS NULL AND d.disposed_on <= $2`, org, end) if err != nil { return nil, fmt.Errorf("assets: disposals: %w", err) } held, err := pgx.CollectRows(rows, pgx.RowTo[uuid.UUID]) if err != nil { return nil, fmt.Errorf("assets: disposals: %w", err) } skip := map[uuid.UUID]bool{} for _, id := range held { skip[id] = true // Ruling FA-16: the disposal depreciates it to its month } list, err := List(ctx, tx, Filter{Status: StatusInService}) if err != nil { return nil, err } ks, err := ListClasses(ctx, tx) if err != nil { return nil, err } classes := map[uuid.UUID]Class{} for _, k := range ks { classes[k.ID] = k } var out []due for _, a := range list { if a.InServiceOn.After(end) || skip[a.ID] { continue } k := classes[a.ClassID] if amount := Due(termsOf(a, k), end, a.posted()); amount.IsPositive() { out = append(out, due{asset: a, class: k, amount: amount}) } } return out, nil}
// RunDepreciation runs one period (Rulings FA-12, FA-13, FA-15): one manual// entry (source depreciation_run, FA-2), two lines per asset, dated the// period's last day. by is nil for the scheduled job: the run records no// runner, and the entry names the root seat's holder as its poster.func RunDepreciation(ctx context.Context, tx pgx.Tx, c clock.Clock, periodID uuid.UUID, by *uuid.UUID) (Run, error) { org, err := orgOf(ctx) if err != nil { return Run{}, err } p, err := fiscal.GetPeriod(ctx, tx, periodID) if err != nil { return Run{}, err } if p.StartsOn.After(clock.Today(ctx, c)) { return Run{}, ErrPeriodNotStarted } var exists, later bool if err := tx.QueryRow(ctx, ` SELECT EXISTS (SELECT 1 FROM depreciation_run r WHERE r.org_id = $1 AND r.fiscal_period_id = $2), EXISTS (SELECT 1 FROM depreciation_run r2 JOIN fiscal_period p2 ON p2.org_id = r2.org_id AND p2.id = r2.fiscal_period_id WHERE r2.org_id = $1 AND p2.org_id = $1 AND p2.starts_on > $3)`, org, p.ID, p.StartsOn).Scan(&exists, &later); err != nil { return Run{}, fmt.Errorf("assets: run: %w", err) } switch { case exists: return Run{}, ErrRunExists case later: return Run{}, ErrRunOutOfOrder }
run := Run{ID: uuid.New(), PeriodID: p.ID, PeriodStart: p.StartsOn.UTC(), PeriodEnd: p.EndsOn.UTC(), Status: RunSkipped, Total: money.Zero(), RunBy: by, RunAt: c.Now()} var dues []due if p.Status == fiscal.StatusOpen { if dues, err = dueThrough(ctx, tx, org, run.PeriodEnd); err != nil { return Run{}, err } run.Status = RunEmpty } if len(dues) > 0 { poster := by if poster == nil { m, err := seatHolder(ctx, tx, org, clock.Today(ctx, c)) if err != nil { return Run{}, err } poster = &m } lines := make([]ledger.Line, 0, 2*len(dues)) for _, d := range dues { lines = append(lines, ledger.Line{AccountID: d.class.DepreciationAccountID, Debit: d.amount, Credit: money.Zero(), Memo: d.asset.Number, Distribution: d.asset.Shares}, ledger.Line{AccountID: d.class.AccumulatedAccountID, Debit: money.Zero(), Credit: d.amount, Memo: d.asset.Number}) run.Total = run.Total.Add(d.amount) } entry, err := ledger.Post(ctx, tx, c, ledger.EntryInput{JournalType: chart.JournalMisc, EntryDate: run.PeriodEnd, Description: "Depreciation for " + run.PeriodStart.Format("2006-01"), Semantic: ledger.SemanticManual, SourceKind: SourceKindRun, SourceID: &run.ID, PostedBy: poster, Lines: lines}) if err != nil { return Run{}, err } run.Status, run.JournalEntryID, run.AssetCount = RunPosted, &entry.ID, len(dues) } _, err = tx.Exec(ctx, ` INSERT INTO depreciation_run (id, org_id, fiscal_period_id, status, journal_entry_id, asset_count, total, run_by, run_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, run.ID, org, run.PeriodID, run.Status, run.JournalEntryID, run.AssetCount, run.Total, run.RunBy, run.RunAt) if isConstraint(err, "depreciation_run_once_per_period") { return Run{}, ErrRunExists } if err != nil { return Run{}, fmt.Errorf("assets: run: %w", err) } for _, d := range dues { if _, err := tx.Exec(ctx, ` INSERT INTO depreciation_line (org_id, asset_id, run_id, amount, charged_on) VALUES ($1, $2, $3, $4, $5)`, org, d.asset.ID, run.ID, d.amount, run.PeriodEnd); err != nil { return Run{}, fmt.Errorf("assets: run line: %w", err) } } return run, audit.Write(ctx, tx, c, audit.Entry{EntityKind: entityRun, EntityID: run.ID, Action: run.Status, ActorID: by, After: run})}
// seatHolder is the active member holding the organization's root seat on// day (workflow.OwnerSeat, the seat the seeded grants name): the poster a// manual entry must name when the scheduler runs (Ruling FA-2).func seatHolder(ctx context.Context, tx pgx.Tx, org uuid.UUID, day time.Time) (uuid.UUID, error) { seat, err := workflow.OwnerSeat(ctx, tx) if err != nil { return uuid.Nil, err } var m uuid.UUID err = tx.QueryRow(ctx, ` SELECT mp.member_id FROM member_position mp JOIN organization_member om ON om.org_id = mp.org_id AND om.id = mp.member_id WHERE mp.org_id = $1 AND om.org_id = $1 AND mp.position_id = $2 AND om.active AND mp.withdrawn_at IS NULL AND mp.valid_from <= $3 AND (mp.valid_to IS NULL OR mp.valid_to > $3) ORDER BY mp.is_primary DESC, mp.valid_from, mp.member_id LIMIT 1`, org, seat, day).Scan(&m) if errors.Is(err, pgx.ErrNoRows) { return uuid.Nil, ErrNoPoster } return m, err}
// ScheduledRun is the job's one organization (Ruling FA-14): the period// holding the last day of the previous calendar month, when the organization// has an asset and no run for that period or a later one. nil: nothing to do.func ScheduledRun(ctx context.Context, tx pgx.Tx, c clock.Clock) (*Run, error) { org, err := orgOf(ctx) if err != nil { return nil, err } target := monthStart(clock.Today(ctx, c)).AddDate(0, 0, -1) var periodID uuid.UUID err = tx.QueryRow(ctx, ` SELECT p.id FROM fiscal_period p WHERE p.org_id = $1 AND p.starts_on <= $2 AND p.ends_on >= $2 AND EXISTS (SELECT 1 FROM asset a WHERE a.org_id = $1)`, org, target).Scan(&periodID) if errors.Is(err, pgx.ErrNoRows) { return nil, nil } if err != nil { return nil, fmt.Errorf("assets: scheduled run: %w", err) } r, err := RunDepreciation(ctx, tx, c, periodID, nil) if errors.Is(err, ErrRunExists) || errors.Is(err, ErrRunOutOfOrder) { return nil, nil } if err != nil { return nil, err } return &r, nil}- Step 3:
job.go.
package assets
import ( "context" "fmt" "log/slog" "time"
"github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/riverqueue/river"
"github.com/mcom/erp/internal/kernel/clock" "github.com/mcom/erp/internal/kernel/db" "github.com/mcom/erp/internal/kernel/jobs" "github.com/mcom/erp/internal/kernel/tenancy" "github.com/mcom/erp/internal/platform/moduleinstall")
// DepreciationArgs is the daily depreciation tick (Ruling FA-14).type DepreciationArgs struct{}
func (DepreciationArgs) Kind() string { return "assets_depreciation" }
func (DepreciationArgs) InsertOpts() river.InsertOpts { return river.InsertOpts{Queue: jobs.QueueMaintenance}}
// DepreciationPeriodic ticks every 24 hours; not on start, so a worker// restart does not re-run every organization.func DepreciationPeriodic() *river.PeriodicJob { return river.NewPeriodicJob(river.PeriodicInterval(24*time.Hour), func() (river.JobArgs, *river.InsertOpts) { return DepreciationArgs{}, nil }, nil)}
type DepreciationWorker struct { river.WorkerDefaults[DepreciationArgs] DB *db.DB Clock clock.Clock Logger *slog.Logger}
func (w *DepreciationWorker) Timeout(*river.Job[DepreciationArgs]) time.Duration { return time.Hour }
// Work runs every organization in its own transaction. One organization's// failure is logged and does not stop the others, and the job succeeds: a// retry would rerun them all to redo one (opsjobs' reconcile precedent).func (w *DepreciationWorker) Work(ctx context.Context, _ *river.Job[DepreciationArgs]) error { rows, err := w.DB.Pool().Query(ctx, `SELECT org_id FROM ops.org_ids()`) if err != nil { return fmt.Errorf("assets_depreciation: org ids: %w", err) } orgs, err := pgx.CollectRows(rows, pgx.RowTo[uuid.UUID]) if err != nil { return fmt.Errorf("assets_depreciation: org ids: %w", err) } for _, org := range orgs { if err := RunScheduled(ctx, w.DB, w.Clock, org); err != nil { w.Logger.Error("assets_depreciation: organization failed", "org_id", org, "err", err) } } return nil}
// RunScheduled is one organization's tick, in its own tenant transaction.func RunScheduled(ctx context.Context, d *db.DB, c clock.Clock, org uuid.UUID) error { return d.InTx(tenancy.WithOrg(ctx, org), func(ctx context.Context, tx pgx.Tx) error { on, err := moduleinstall.Enabled(ctx, tx, ModuleKey) if err != nil || !on { return err } _, err = ScheduledRun(ctx, tx, c) return err })}-
Step 4: Run.
go test ./internal/platform/assets/... ./internal/kernel/layering/... ./internal/kernel/clock/... -count=1→ PASS. -
Step 5: Sabotage. (a) Replace
case exists: return Run{}, ErrRunExistswith nothing and dropdepreciation_run_once_per_periodfrom00771; runTestASecondRunForAPeriodPostsNothing; read the red (two entries); restore both. (b) Replacea.posted()indueThroughwithmoney.Zero(); runTestAClosedPeriodIsSkippedFlaggedAndCaughtUp; read the red (want 2000, got 9000); restore. Record both. -
Step 6: Commit.
git add internal/platform/assets && git commit -m "O4 Task 5: monthly depreciation runs and the daily job" -m "<sabotage messages>" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
Task 6: Disposals — workflow, guard and effect posting
Section titled “Task 6: Disposals — workflow, guard and effect posting”Files:
- Create:
internal/platform/assets/disposal.go,internal/platform/assets/disposal_test.go - Modify:
internal/platform/assets/world_test.go(the poster line)
Interfaces:
- Consumes:
posting.Effectand the engine’s effect handling (O2 Task 1 / O4 Task 1);workflow.AllowSoleOwnerException; Task 4’slockAsset,Get,GetClass,termsOf,fieldErr; Task 3’sDue,monthEnd;workflow.RegisterSeededGraph,RegisterGuard,Start,Transition,LockSoleOwner,AvailableTransitions,SoleOwnerAudit;accounting.PostingRule,accounting.Draft. - Produces:
type Disposal struct { ID, AssetID uuid.UUID; AssetNumber, Kind string; DisposedOn time.Time; Reason string; InstanceID, PreparedBy uuid.UUID; CreatedAt time.Time; CancelledAt, PostedAt *time.Time; CatchUp, Accumulated, NBV *money.Amount; State string; Semantic posting.Semantic }type DisposalInput struct { AssetID uuid.UUID; Kind string; DisposedOn time.Time; Reason string }type TransitionInput struct { ID uuid.UUID; From, To string; Actor uuid.UUID; Reason string }func CreateDisposal(ctx context.Context, tx pgx.Tx, c clock.Clock, by uuid.UUID, in DisposalInput) (Disposal, error)func GetDisposal(ctx context.Context, tx pgx.Tx, id uuid.UUID) (Disposal, error)func ListDisposals(ctx context.Context, tx pgx.Tx, assetID *uuid.UUID) ([]Disposal, error)func TransitionDisposal(ctx context.Context, tx pgx.Tx, c clock.Clock, in TransitionInput) (Disposal, error)func Available(ctx context.Context, tx pgx.Tx, c clock.Clock, instanceID, actor uuid.UUID) ([]workflow.Available, error)func DisposalGraph(ownerSeat uuid.UUID) workflow.Graphtype DisposalRule struct{} // Kind asset_disposal, Semantic effectfunc Rules() []accounting.PostingRule // {DisposalRule{}}// test helpers in disposal_test.go, used by Task 7:func (w *world) dispose(t *testing.T, a assets.Asset, kind string, on time.Time) (assets.Disposal, error)func (w *world) move(t *testing.T, d assets.Disposal, by uuid.UUID, from, to string) (assets.Disposal, error)func (w *world) disposeAndApprove(t *testing.T, a assets.Asset, kind string, on time.Time) assets.Disposal-
Step 1: Poster. In
world_test.goreplacee.UsePoster(t, payables.Rules()...)withe.UsePoster(t, append(payables.Rules(), assets.Rules()...)...). -
Step 2: Write the failing tests.
internal/platform/assets/disposal_test.go:
package assets_test
import ( "context" "testing" "time"
"github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/platform/assets" "github.com/mcom/erp/internal/platform/posting" "github.com/mcom/erp/internal/platform/workflow" "github.com/mcom/erp/internal/platform/workflow/workflowtest")
func (w *world) dispose(t *testing.T, a assets.Asset, kind string, on time.Time) (assets.Disposal, error) { t.Helper() var d assets.Disposal err := w.DB.InTx(w.As(w.Staff), func(ctx context.Context, tx pgx.Tx) (err error) { d, err = assets.CreateDisposal(ctx, tx, w.Clock, w.Staff, assets.DisposalInput{AssetID: a.ID, Kind: kind, DisposedOn: on, Reason: "Sold to a scrap dealer"}) return }) return d, err}
func (w *world) move(t *testing.T, d assets.Disposal, by uuid.UUID, from, to string) (assets.Disposal, error) { t.Helper() var out assets.Disposal err := w.DB.InTx(w.As(by), func(ctx context.Context, tx pgx.Tx) (err error) { out, err = assets.TransitionDisposal(ctx, tx, w.Clock, assets.TransitionInput{ID: d.ID, From: from, To: to, Actor: by, Reason: "test"}) return }) return out, err}
// disposeAndApprove: Staff prepares and submits, the Owner approves.func (w *world) disposeAndApprove(t *testing.T, a assets.Asset, kind string, on time.Time) assets.Disposal { t.Helper() d, err := w.dispose(t, a, kind, on) require.NoError(t, err) d, err = w.move(t, d, w.Staff, "draft", "pending_approval") require.NoError(t, err) d, err = w.move(t, d, w.Owner, "pending_approval", "disposed") require.NoError(t, err) return d}
func TestTheDisposalGraphIsValidAndPostsOnEffect(t *testing.T) { workflowtest.RequireSeededGraphValid(t, assets.KindDisposal) g := assets.DisposalGraph(uuid.New()) s, ok := g.State("disposed") require.True(t, ok) require.Equal(t, posting.Effect, s.Semantic) require.True(t, s.IsTerminal, "a posted disposal is final (Ruling FA-3)") for _, st := range g.States { require.Len(t, st.Labels, 3, "en, ms and zh: %s", st.Key) }}
func TestADisposalDepreciatesThroughItsMonthThenClearsTheAsset(t *testing.T) { w := newWorld(t) a := w.journalAsset(t, w.Class, "12000", jan15) _, err := w.run(t, time.August) require.NoError(t, err)
d, err := w.dispose(t, a, assets.DisposalSale, day(2026, 9, 10)) require.NoError(t, err) d, err = w.move(t, d, w.Staff, "draft", "pending_approval") require.NoError(t, err)
// A claimant never approves their own: the preparer is refused ... _, err = w.move(t, d, w.Staff, "pending_approval", "disposed") var refused *workflow.RefusedError require.ErrorAs(t, err, &refused) // ... and the Owner, who holds the grant, approves. d, err = w.move(t, d, w.Owner, "pending_approval", "disposed") require.NoError(t, err) require.Equal(t, "disposed", d.State) requireAmount(t, "1000", *d.CatchUp, "September's depreciation") requireAmount(t, "9000", *d.Accumulated, "Jan–Sep") requireAmount(t, "3000", *d.NBV, "to the disposal account")
requireAmount(t, "0", w.balance(t, w.PPE), "cost cleared") requireAmount(t, "0", w.balance(t, w.Accum), "accumulated depreciation cleared") requireAmount(t, "9000", w.balance(t, w.Dep), "depreciation through the disposal month") requireAmount(t, "3000", w.balance(t, w.Disposal), "net book value written off (Dr)") require.Equal(t, assets.StatusDisposed, w.get(t, a.ID).Status)
r, err := w.run(t, time.September) require.NoError(t, err) require.Equal(t, assets.RunEmpty, r.Status, "a disposed asset is not depreciated again")
// The database: a posted disposal cannot be cancelled. err = w.TxErr(func(ctx context.Context, tx pgx.Tx) error { _, err := tx.Exec(ctx, `UPDATE asset_disposal SET cancelled_at = now() WHERE org_id = $1 AND id = $2`, w.Org, d.ID) return err }) requireConstraint(t, err, "asset_disposal_posted_or_cancelled")}
func TestOneDisposalInProgressPerAsset(t *testing.T) { w := newWorld(t) a := w.journalAsset(t, w.Class, "12000", jan15) d, err := w.dispose(t, a, assets.DisposalSale, day(2026, 9, 10)) require.NoError(t, err, "PRESENCE: the first disposal is created") _, err = w.dispose(t, a, assets.DisposalWriteOff, day(2026, 9, 11)) require.ErrorIs(t, err, assets.ErrDisposalOpen)
d, err = w.move(t, d, w.Staff, "draft", "cancelled") require.NoError(t, err) require.NotNil(t, d.CancelledAt, "the guard records the withdrawal") _, err = w.dispose(t, a, assets.DisposalWriteOff, day(2026, 9, 11)) require.NoError(t, err, "a withdrawn disposal frees the asset")}
func TestADisposalBeforeADepreciatedMonthIsRefused(t *testing.T) { w := newWorld(t) a := w.journalAsset(t, w.Class, "12000", jan15) _, err := w.run(t, time.August) require.NoError(t, err) _, err = w.dispose(t, a, assets.DisposalSale, day(2026, 7, 20)) require.ErrorIs(t, err, assets.ErrDisposalMonthDepreciated) _, err = w.dispose(t, a, assets.DisposalSale, day(2026, 8, 5)) require.NoError(t, err, "PRESENCE: August itself is depreciated, not after")}
func TestARunSkipsAnAssetWhoseDisposalIsInProgress(t *testing.T) { w := newWorld(t) a := w.journalAsset(t, w.Class, "12000", jan15) d, err := w.dispose(t, a, assets.DisposalSale, day(2026, 8, 10)) require.NoError(t, err) r, err := w.run(t, time.August) require.NoError(t, err) require.Equal(t, assets.RunEmpty, r.Status, "the disposal depreciates it to its month") d, err = w.move(t, d, w.Staff, "draft", "pending_approval") require.NoError(t, err) d, err = w.move(t, d, w.Owner, "pending_approval", "disposed") require.NoError(t, err) requireAmount(t, "8000", *d.CatchUp, "Jan–Aug in the disposal entry") requireAmount(t, "4000", *d.NBV, "")}
func TestAWriteOffOfAFullyDepreciatedAssetHasNoGainOrLoss(t *testing.T) { w := newWorld(t) short := w.class(t, "SHORT", assets.MethodStraightLine, 3, "") a := w.journalAsset(t, short, "3000", jan15) _, err := w.run(t, time.August) require.NoError(t, err) d := w.disposeAndApprove(t, a, assets.DisposalWriteOff, day(2026, 9, 1)) requireAmount(t, "0", *d.CatchUp, "") requireAmount(t, "0", *d.NBV, "") requireAmount(t, "0", w.balance(t, w.Disposal), "no gain or loss line") require.Equal(t, 2, w.Count(t, ` SELECT count(*) FROM journal_line l JOIN journal_entry e ON e.org_id = l.org_id AND e.id = l.entry_id WHERE e.org_id = $1 AND e.source_kind = 'asset_disposal' AND e.source_id = $2`, w.Org, d.ID), "Dr accumulated, Cr cost")}
// Ruling FA-17: the claimant rule stands with two members; a one-member// organization's Owner approves their own disposal with a reason.func TestASoleOwnerApprovesTheirOwnDisposalWithAReason(t *testing.T) { w := newWorld(t) a := w.journalAsset(t, w.Class, "12000", jan15) var d assets.Disposal require.NoError(t, w.DB.InTx(w.As(w.Owner), func(ctx context.Context, tx pgx.Tx) (err error) { d, err = assets.CreateDisposal(ctx, tx, w.Clock, w.Owner, assets.DisposalInput{AssetID: a.ID, Kind: assets.DisposalWriteOff, DisposedOn: day(2026, 9, 10), Reason: "Destroyed in a flood"}) return })) d, err := w.move(t, d, w.Owner, "draft", "pending_approval") require.NoError(t, err) // PRESENCE: with Staff active, the Owner-as-preparer is refused. _, err = w.move(t, d, w.Owner, "pending_approval", "disposed") var refused *workflow.RefusedError require.ErrorAs(t, err, &refused)
_, err = w.H.Admin.Exec(context.Background(), `UPDATE organization_member SET active = false WHERE org_id = $1 AND id = $2`, w.Org, w.Staff) require.NoError(t, err) d, err = w.move(t, d, w.Owner, "pending_approval", "disposed") // move() sends reason "test" require.NoError(t, err, "one active member: the audited sole-owner exception") require.Equal(t, "disposed", d.State) require.Equal(t, 1, w.Count(t, `SELECT count(*) FROM audit_log WHERE org_id = $1 AND entity_kind = 'asset_disposal' AND action = 'transition' AND after->>'sole_owner_exception' = 'true'`, w.Org))}(requireConstraint is schema_test.go’s.)
Run: go test ./internal/platform/assets/... -count=1 -run Dispos
Expected: FAIL — undefined: assets.Rules.
- Step 3:
disposal.go.
package assets
import ( "context" "errors" "fmt" "strings" "time"
"github.com/google/uuid" "github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/kernel/audit" "github.com/mcom/erp/internal/kernel/clock" "github.com/mcom/erp/internal/kernel/money" "github.com/mcom/erp/internal/kernel/recordtext" "github.com/mcom/erp/internal/platform/accounting" "github.com/mcom/erp/internal/platform/accounting/chart" "github.com/mcom/erp/internal/platform/accounting/ledger" "github.com/mcom/erp/internal/platform/posting" "github.com/mcom/erp/internal/platform/workflow")
func init() { workflow.RegisterSeededGraph(KindDisposal, DisposalGraph) workflow.RegisterGuard(KindDisposal, disposalGuard) // Ruling FA-17 (O2 Task 5's pattern): a one-member organization's Owner // approves their own disposal, with a reason, audited. issue covers the // edge into the effect state; the kind has no void edge (FA-3). workflow.AllowSoleOwnerException(KindDisposal, true)}
var errInstanceMismatch = errors.New("assets: the event's instance is not this disposal's")
// Disposal is one disposal or write-off (spec O4, Ruling FA-16). CatchUp,// Accumulated and NBV are set when it posts.type Disposal struct { ID uuid.UUID AssetID uuid.UUID AssetNumber string Kind string // sale | write_off DisposedOn time.Time Reason string InstanceID uuid.UUID PreparedBy uuid.UUID CreatedAt time.Time CancelledAt *time.Time PostedAt *time.Time CatchUp *money.Amount Accumulated *money.Amount NBV *money.Amount State string Semantic posting.Semantic}
type DisposalInput struct { AssetID uuid.UUID Kind string DisposedOn time.Time Reason string}
const disposalSelect = ` SELECT d.id, d.asset_id, a.number, d.kind, d.disposed_on, d.reason, d.instance_id, d.prepared_by, d.created_at, d.cancelled_at, d.posted_at, d.catch_up, d.accumulated, d.nbv, w.state_key, s.semantic FROM asset_disposal d JOIN asset a ON a.org_id = d.org_id AND a.id = d.asset_id JOIN workflow_instance w ON w.org_id = d.org_id AND w.id = d.instance_id JOIN workflow_state s ON s.org_id = w.org_id AND s.definition_id = w.definition_id AND s.state_key = w.state_key WHERE d.org_id = $1 AND a.org_id = $1 AND w.org_id = $1 AND s.org_id = $1`
func scanDisposal(row pgx.Row) (Disposal, error) { var d Disposal var sem string err := row.Scan(&d.ID, &d.AssetID, &d.AssetNumber, &d.Kind, &d.DisposedOn, &d.Reason, &d.InstanceID, &d.PreparedBy, &d.CreatedAt, &d.CancelledAt, &d.PostedAt, &d.CatchUp, &d.Accumulated, &d.NBV, &d.State, &sem) d.DisposedOn, d.Semantic = d.DisposedOn.UTC(), posting.Semantic(sem) return d, err}
func GetDisposal(ctx context.Context, tx pgx.Tx, id uuid.UUID) (Disposal, error) { org, err := orgOf(ctx) if err != nil { return Disposal{}, err } d, err := scanDisposal(tx.QueryRow(ctx, disposalSelect+` AND d.id = $2`, org, id)) if errors.Is(err, pgx.ErrNoRows) { return Disposal{}, ErrNotFound } if err != nil { return Disposal{}, fmt.Errorf("assets: disposal: %w", err) } return d, nil}
// ListDisposals is every disposal, newest first; of one asset when assetID is set.func ListDisposals(ctx context.Context, tx pgx.Tx, assetID *uuid.UUID) ([]Disposal, error) { org, err := orgOf(ctx) if err != nil { return nil, err } rows, err := tx.Query(ctx, disposalSelect+` AND ($2::uuid IS NULL OR d.asset_id = $2) ORDER BY d.created_at DESC`, org, assetID) if err != nil { return nil, fmt.Errorf("assets: disposals: %w", err) } return pgx.CollectRows(rows, func(r pgx.CollectableRow) (Disposal, error) { return scanDisposal(r) })}
// checkDisposal: the asset is in service, the date falls between its// in-service date and today, and no depreciation is posted for a month after// the disposal month (Ruling FA-16).func checkDisposal(ctx context.Context, tx pgx.Tx, c clock.Clock, org uuid.UUID, a Asset, on time.Time) error { if a.Status == StatusDisposed { return ErrDisposed } if on.IsZero() || on.After(clock.Today(ctx, c)) || on.Before(a.InServiceOn) { return fieldErr("disposed_on", "within_service", "the disposal date is on or after the in-service date and not after today") } var later bool if err := tx.QueryRow(ctx, ` SELECT EXISTS (SELECT 1 FROM depreciation_line l WHERE l.org_id = $1 AND l.asset_id = $2 AND l.charged_on > $3)`, org, a.ID, monthEnd(on)).Scan(&later); err != nil { return fmt.Errorf("assets: disposal date: %w", err) } if later { return ErrDisposalMonthDepreciated } return nil}
// CreateDisposal prepares a disposal and starts its workflow.func CreateDisposal(ctx context.Context, tx pgx.Tx, c clock.Clock, by uuid.UUID, in DisposalInput) (Disposal, error) { org, err := orgOf(ctx) if err != nil { return Disposal{}, err } if in.Kind != DisposalSale && in.Kind != DisposalWriteOff { return Disposal{}, fieldErr("kind", "kind_known", "the kind is sale or write_off") } reason := strings.TrimSpace(in.Reason) if reason == "" || len([]rune(reason)) > 500 || recordtext.Check("reason", reason) != nil { return Disposal{}, fieldErr("reason", "required", "a reason of up to 500 characters, in English or Bahasa Malaysia, is required") } a, err := lockAsset(ctx, tx, org, in.AssetID) if err != nil { return Disposal{}, err } if err := checkDisposal(ctx, tx, c, org, a, in.DisposedOn); err != nil { return Disposal{}, err } var open bool if err := tx.QueryRow(ctx, ` SELECT EXISTS (SELECT 1 FROM asset_disposal d WHERE d.org_id = $1 AND d.asset_id = $2 AND d.cancelled_at IS NULL)`, org, a.ID).Scan(&open); err != nil { return Disposal{}, fmt.Errorf("assets: create disposal: %w", err) } if open { return Disposal{}, ErrDisposalOpen } id := uuid.New() inst, err := workflow.Start(ctx, tx, c, workflow.StartInput{Kind: KindDisposal, SourceID: id, Reporter: by, Amount: a.Cost}) if err != nil { return Disposal{}, err } _, err = tx.Exec(ctx, ` INSERT INTO asset_disposal (id, org_id, asset_id, kind, disposed_on, reason, instance_id, prepared_by, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, id, org, a.ID, in.Kind, in.DisposedOn, reason, inst.ID, by, c.Now()) if isConstraint(err, "asset_disposal_one_open") { return Disposal{}, ErrDisposalOpen } if err != nil { return Disposal{}, fmt.Errorf("assets: create disposal: %w", err) } d, err := GetDisposal(ctx, tx, id) if err != nil { return Disposal{}, err } return d, audit.Write(ctx, tx, c, audit.Entry{EntityKind: entityDisposal, EntityID: id, Action: "create", After: d})}
type TransitionInput struct { ID uuid.UUID From, To string Actor uuid.UUID Reason string}
// TransitionDisposal moves a disposal. ORDER (Ruling FA-27): sole-owner lock// → disposal row → workflow.Transition (instance, then the guard's asset// row, then the Poster) → audit.func TransitionDisposal(ctx context.Context, tx pgx.Tx, c clock.Clock, in TransitionInput) (Disposal, error) { if err := workflow.LockSoleOwner(ctx, tx); err != nil { return Disposal{}, err } org, err := orgOf(ctx) if err != nil { return Disposal{}, err } var instanceID uuid.UUID err = tx.QueryRow(ctx, `SELECT d.instance_id FROM asset_disposal d WHERE d.org_id = $1 AND d.id = $2 FOR UPDATE`, org, in.ID).Scan(&instanceID) if errors.Is(err, pgx.ErrNoRows) { return Disposal{}, ErrNotFound } if err != nil { return Disposal{}, fmt.Errorf("assets: lock disposal: %w", err) } before, err := GetDisposal(ctx, tx, in.ID) if err != nil { return Disposal{}, err } dec, err := workflow.Transition(ctx, tx, c, workflow.TransitionInput{InstanceID: instanceID, From: in.From, To: in.To, Actor: in.Actor, Reason: in.Reason}) if err != nil { return Disposal{}, err } after, err := GetDisposal(ctx, tx, in.ID) if err != nil { return Disposal{}, err } return after, audit.Write(ctx, tx, c, audit.Entry{EntityKind: entityDisposal, EntityID: in.ID, ActorID: &in.Actor, Action: "transition", Before: map[string]string{"state": before.State}, After: workflow.SoleOwnerAudit(after.State, dec)})}
func Available(ctx context.Context, tx pgx.Tx, c clock.Clock, instanceID, actor uuid.UUID) ([]workflow.Available, error) { return workflow.AvailableTransitions(ctx, tx, c, instanceID, actor)}
// disposalGuard runs inside the transition, before the instance moves and// before anything posts. Entering effect it settles the disposal's figures// (and its catch-up line); entering cancelled it records the withdrawal. It// never writes audit (the Guard contract).func disposalGuard(ctx context.Context, tx pgx.Tx, c clock.Clock, m workflow.Move) error { if m.From.Semantic == posting.Effect { // Ruling FA-3: a per-graph rule of this kind, not of the semantic — // effect reverses on cancel elsewhere; a disposal never leaves it. return ErrDisposalFinal } org, err := orgOf(ctx) if err != nil { return err } switch m.To.Semantic { case posting.Cancelled: _, err := tx.Exec(ctx, `UPDATE asset_disposal SET cancelled_at = $3 WHERE org_id = $1 AND id = $2`, org, m.SourceID, c.Now()) return err case posting.Effect: return settle(ctx, tx, c, org, m.SourceID) } return nil}
// settle depreciates the asset through the disposal month (Ruling FA-16)// and records what the entry will clear.func settle(ctx context.Context, tx pgx.Tx, c clock.Clock, org, id uuid.UUID) error { d, err := GetDisposal(ctx, tx, id) if err != nil { return err } a, err := lockAsset(ctx, tx, org, d.AssetID) if err != nil { return err } if err := checkDisposal(ctx, tx, c, org, a, d.DisposedOn); err != nil { return err } k, err := GetClass(ctx, tx, a.ClassID) if err != nil { return err } catchUp := Due(termsOf(a, k), monthEnd(d.DisposedOn), a.posted()) if catchUp.IsPositive() { if _, err := tx.Exec(ctx, ` INSERT INTO depreciation_line (org_id, asset_id, disposal_id, amount, charged_on) VALUES ($1, $2, $3, $4, $5)`, org, a.ID, d.ID, catchUp, d.DisposedOn); err != nil { return fmt.Errorf("assets: disposal catch-up: %w", err) } } accumulated := a.Accumulated.Add(catchUp) _, err = tx.Exec(ctx, ` UPDATE asset_disposal SET posted_at = $3, catch_up = $4, accumulated = $5, nbv = $6 WHERE org_id = $1 AND id = $2`, org, d.ID, c.Now(), catchUp, accumulated, a.Cost.Sub(accumulated)) return err}
// DisposalRule posts a disposal on the effect semantic: the catch-up, then// Dr accumulated depreciation, Cr cost, and the net book value to the// disposal account (spec O4). Proceeds post separately through AR.type DisposalRule struct{}
var _ accounting.PostingRule = DisposalRule{}
func (DisposalRule) Kind() string { return KindDisposal }func (DisposalRule) Semantic() posting.Semantic { return posting.Effect }
func (DisposalRule) Build(ctx context.Context, tx pgx.Tx, src accounting.Source) (accounting.Draft, error) { d, err := GetDisposal(ctx, tx, src.Event.SourceID) if err != nil { return accounting.Draft{}, err } if d.InstanceID != src.Event.InstanceID { return accounting.Draft{}, errInstanceMismatch } if d.PostedAt == nil { return accounting.Draft{}, fmt.Errorf("assets: disposal %s was not settled by its guard", d.ID) } a, err := Get(ctx, tx, d.AssetID) if err != nil { return accounting.Draft{}, err } k, err := GetClass(ctx, tx, a.ClassID) if err != nil { return accounting.Draft{}, err } z := money.Zero() var lines []ledger.Line if d.CatchUp.IsPositive() { lines = append(lines, ledger.Line{AccountID: k.DepreciationAccountID, Debit: *d.CatchUp, Credit: z, Memo: a.Number, Distribution: a.Shares}, ledger.Line{AccountID: k.AccumulatedAccountID, Debit: z, Credit: *d.CatchUp, Memo: a.Number}) } if d.Accumulated.IsPositive() { lines = append(lines, ledger.Line{AccountID: k.AccumulatedAccountID, Debit: *d.Accumulated, Credit: z, Memo: a.Number}) } lines = append(lines, ledger.Line{AccountID: k.CostAccountID, Debit: z, Credit: a.Cost, Memo: a.Number}) if d.NBV.IsPositive() { lines = append(lines, ledger.Line{AccountID: k.DisposalAccountID, Debit: *d.NBV, Credit: z, Memo: a.Number}) } verb := "Disposal" if d.Kind == DisposalWriteOff { verb = "Write-off" } return accounting.Draft{JournalType: chart.JournalMisc, Currency: src.BaseCurrency, Lines: lines, Description: fmt.Sprintf("%s of %s", verb, a.Number), EntryDate: d.DisposedOn}, nil}
// Rules is every posting rule this package owns.func Rules() []accounting.PostingRule { return []accounting.PostingRule{DisposalRule{}} }
// ls, st, ed and why are taxreturn's graph helpers, copied (AP P25).func ls(en, zh, ms string) workflow.Labels { return workflow.Labels{"en": en, "zh": zh, "ms": ms} }
func st(order int, key string, sem posting.Semantic, terminal bool, l workflow.Labels) workflow.StateSpec { return workflow.StateSpec{Key: key, Semantic: sem, DisplayOrder: order, IsTerminal: terminal, Labels: l}}
func ed(order int, from, to string, gate workflow.Gate, action string, l workflow.Labels) workflow.EdgeSpec { return workflow.EdgeSpec{From: from, To: to, Gate: gate, ActionKey: action, DisplayOrder: order, Labels: l}}
func why(e workflow.EdgeSpec) workflow.EdgeSpec { e.RequiresReason = true return e}
// DisposalGraph://// draft → pending_approval → disposed (effect, terminal)// │ └─→ rejected → draft// └─→ cancelled (withdrawn)//// The Owner seat approves, through a grant on that one state, so a preparer's// own grant never counts; a one-member organization approves under the// sole-owner exception (FA-17).func DisposalGraph(ownerSeat uuid.UUID) workflow.Graph { return workflow.Graph{ Kind: KindDisposal, PostingOrder: workflow.AccrualFirst, States: []workflow.StateSpec{ st(1, "draft", posting.Draft, false, ls("Draft", "草稿", "Draf")), st(2, "pending_approval", posting.Pending, false, ls("Pending approval", "待批准", "Menunggu kelulusan")), st(3, "disposed", posting.Effect, true, ls("Disposed", "已处置", "Dilupuskan")), st(4, "rejected", posting.Rejected, false, ls("Rejected", "已拒绝", "Ditolak")), st(5, "cancelled", posting.Cancelled, true, ls("Withdrawn", "已撤回", "Ditarik balik")), }, Edges: []workflow.EdgeSpec{ ed(1, "draft", "pending_approval", workflow.GateReporter, "submit", ls("Submit", "提交", "Hantar")), ed(2, "pending_approval", "disposed", workflow.GateTarget, "approve_disposal", ls("Approve and post", "批准并过账", "Luluskan dan pos")), why(ed(3, "pending_approval", "rejected", workflow.GateAdvance, "reject", ls("Reject", "拒绝", "Tolak"))), ed(4, "rejected", "draft", workflow.GateReporter, "revise", ls("Revise", "修改", "Semak semula")), why(ed(5, "draft", "cancelled", workflow.GateReporter, "withdraw", ls("Withdraw", "撤回", "Tarik balik"))), }, Grants: []workflow.GrantSpec{{StateKey: "disposed", GranteeKind: workflow.GranteePosition, PositionID: &ownerSeat}}, }.Normalized()}-
Step 4: Run.
go test ./internal/platform/assets/... ./internal/kernel/layering/... -count=1→ PASS. -
Step 5: Sabotage. (a) In
settle, changemonthEnd(d.DisposedOn)tomonthEnd(d.DisposedOn).AddDate(0, -1, 0)(through the month before); runTestADisposalDepreciatesThroughItsMonthThenClearsTheAsset; read the red (catch-up want 1000, got 0); restore. (b) Remove theskip[a.ID]test inrun.go’sdueThrough; runTestARunSkipsAnAssetWhoseDisposalIsInProgress; read the red; restore. (c) Change the grant toGranteeKind: workflow.GranteeSuperior, PositionID: nil; run the claimant test; read the red or the validator refusal; restore. Record the three messages. -
Step 6: Commit.
git add internal/platform/assets && git commit -m "O4 Task 6: asset disposal workflow posting on effect" -m "<sabotage messages>" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
Task 7: Register-vs-ledger integrity and the reports
Section titled “Task 7: Register-vs-ledger integrity and the reports”Files:
- Create:
internal/platform/assets/integrity.go,internal/platform/assets/reports.go - Create:
internal/platform/assets/integrity_test.go,internal/platform/assets/reports_test.go
Interfaces:
- Consumes: Tasks 4–6 (
List,ListClasses,termsOf,Forecast, run and disposal helpers in tests);reconcile.Register,reconcile.Check,reconcile.Finding,reconcile.NewSet. - Produces:
const CheckRegisterGL = "asset_register_gl"type GLRow struct { AccountID uuid.UUID; Role string /* cost | accumulated */; Register, Ledger, Difference money.Amount }func RegisterVsGL(ctx context.Context, tx pgx.Tx) ([]GLRow, error)func Checks() []reconcile.Checktype ForecastRow struct { Asset Asset; Charges []money.Amount }type ForecastReport struct { Months []time.Time; Rows []ForecastRow; Totals []money.Amount }func ScheduleForecast(ctx context.Context, tx pgx.Tx, c clock.Clock, n int) (ForecastReport, error)type RollForwardRow struct { ClassID uuid.UUID; ClassCode string; CostOpening, Additions, CostDisposals, CostClosing, AccumOpening, AccumAcquired, Charge, AccumDisposals, AccumClosing money.Amount }func RollForward(ctx context.Context, tx pgx.Tx, from, to time.Time) ([]RollForwardRow, RollForwardRow, error)- Step 1: Write the failing tests.
internal/platform/assets/integrity_test.go:
package assets_test
import ( "context" "testing" "time"
"github.com/jackc/pgx/v5" "github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/platform/accounting/reconcile" "github.com/mcom/erp/internal/platform/assets")
func (w *world) findings(t *testing.T) []reconcile.Finding { t.Helper() set := reconcile.NewSet() for _, c := range assets.Checks() { set.Register(c) } var res reconcile.Result w.Tx(t, func(ctx context.Context, tx pgx.Tx) (err error) { res, err = set.Run(ctx, tx); return }) require.Len(t, res.Checks, 1) require.Equal(t, assets.CheckRegisterGL, res.Checks[0].Key) return res.Checks[0].Findings}
// The spec's drift test: register totals equal the class accounts' GL balances.func TestTheRegisterEqualsTheClassAccountsLedgerBalances(t *testing.T) { w := newWorld(t) e := w.manualCost(t, w.PPE, "12000", jan15)
// PRESENCE: a cost on the class's cost account that is not registered is a finding. f := w.findings(t) require.Len(t, f, 1) require.Equal(t, w.PPE, f[0].Ref)
// ABSENCE: registered, the register and the ledger agree ... a, err := w.register(t, assets.RegisterInput{SourceKind: assets.SourceJournalLine, SourceID: e.Lines[0].ID, ClassID: w.Class.ID, Description: "Delivery van", InServiceOn: jan15}) require.NoError(t, err) require.Empty(t, w.findings(t)) // ... after a run ... _, err = w.run(t, time.August) require.NoError(t, err) require.Empty(t, w.findings(t)) // ... and after a disposal. w.disposeAndApprove(t, a, assets.DisposalSale, day(2026, 9, 10)) require.Empty(t, w.findings(t))
var rows []assets.GLRow w.Tx(t, func(ctx context.Context, tx pgx.Tx) (err error) { rows, err = assets.RegisterVsGL(ctx, tx); return }) require.Len(t, rows, 2, "one cost and one accumulated account") require.Equal(t, "cost", rows[0].Role) for _, r := range rows { requireAmount(t, "0", r.Difference, r.Role) }}
// The check is registered where the worker and the period close read it.func TestTheCheckIsInTheDefaultSet(t *testing.T) { w := newWorld(t) var res reconcile.Result w.Tx(t, func(ctx context.Context, tx pgx.Tx) (err error) { res, err = reconcile.Default().Run(ctx, tx); return }) keys := []string{} for _, c := range res.Checks { keys = append(keys, c.Key) } require.Contains(t, keys, assets.CheckRegisterGL)}internal/platform/assets/reports_test.go:
package assets_test
import ( "context" "testing" "time"
"github.com/jackc/pgx/v5" "github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/platform/assets")
// A (12,000, Jan) depreciated to August, disposed 10 September; B (6,000,// 1 September) depreciated in September's run.func rollForwardWorld(t *testing.T) *world { t.Helper() w := newWorld(t) a := w.journalAsset(t, w.Class, "12000", jan15) _, err := w.run(t, time.August) require.NoError(t, err) w.disposeAndApprove(t, a, assets.DisposalSale, day(2026, 9, 10)) w.journalAsset(t, w.Class, "6000", day(2026, 9, 1)) _, err = w.run(t, time.September) require.NoError(t, err) return w}
func TestTheRollForwardBalances(t *testing.T) { w := rollForwardWorld(t) var rows []assets.RollForwardRow var total assets.RollForwardRow w.Tx(t, func(ctx context.Context, tx pgx.Tx) (err error) { rows, total, err = assets.RollForward(ctx, tx, day(2026, 9, 1), day(2026, 9, 30)) return }) require.Len(t, rows, 1) r := rows[0] requireAmount(t, "12000", r.CostOpening, "A at 31 August") requireAmount(t, "6000", r.Additions, "B") requireAmount(t, "12000", r.CostDisposals, "A") requireAmount(t, "6000", r.CostClosing, "B") requireAmount(t, "8000", r.AccumOpening, "A to August") requireAmount(t, "1500", r.Charge, "A's September catch-up 1,000 + B's 500") requireAmount(t, "9000", r.AccumDisposals, "A to September") requireAmount(t, "500", r.AccumClosing, "B") requireAmount(t, "0", r.CostOpening.Add(r.Additions).Sub(r.CostDisposals).Sub(r.CostClosing), "cost identity") requireAmount(t, "0", r.AccumOpening.Add(r.AccumAcquired).Add(r.Charge).Sub(r.AccumDisposals).Sub(r.AccumClosing), "accumulated identity") requireAmount(t, "6000", total.CostClosing, "the total row")
var fe *assets.FieldError err := w.TxErr(func(ctx context.Context, tx pgx.Tx) error { _, _, err := assets.RollForward(ctx, tx, day(2026, 9, 30), day(2026, 9, 1)) return err }) require.ErrorAs(t, err, &fe)}
func TestTheForecastStartsAfterTheLatestRun(t *testing.T) { w := newWorld(t) w.journalAsset(t, w.Class, "12000", jan15) _, err := w.run(t, time.August) require.NoError(t, err) var f assets.ForecastReport w.Tx(t, func(ctx context.Context, tx pgx.Tx) (err error) { f, err = assets.ScheduleForecast(ctx, tx, w.Clock, 5); return }) require.Equal(t, day(2026, 9, 1), f.Months[0]) require.Len(t, f.Rows, 1) for i, want := range []string{"1000", "1000", "1000", "1000", "0"} { requireAmount(t, want, f.Totals[i], f.Months[i].Format("2006-01")) } err = w.TxErr(func(ctx context.Context, tx pgx.Tx) error { _, err := assets.ScheduleForecast(ctx, tx, w.Clock, 61); return err }) var fe *assets.FieldError require.ErrorAs(t, err, &fe)}Run: go test ./internal/platform/assets/... -count=1 -run 'Register|Check|Roll|Forecast'
Expected: FAIL — undefined: assets.Checks.
- Step 2:
integrity.go.
package assets
import ( "context" "fmt"
"github.com/google/uuid" "github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/kernel/money" "github.com/mcom/erp/internal/platform/accounting/reconcile")
// CheckRegisterGL is the integrity check (spec O4, Ruling FA-19). It runs in// the nightly reconciliation (the operator console's integrity table) and// before every period close.const CheckRegisterGL = "asset_register_gl"
func init() { for _, c := range Checks() { reconcile.Register(c) }}
// Checks is this package's checks, for a caller building its own Set.func Checks() []reconcile.Check { return []reconcile.Check{registerGLCheck{}} }
// GLRow is one class account: what the register says it should hold, what// the ledger holds, and the difference. For an accumulated depreciation// account both are credit balances, shown positive.type GLRow struct { AccountID uuid.UUID Role string // cost | accumulated Register money.Amount Ledger money.Amount Difference money.Amount // register − ledger}
// RegisterVsGL compares, per account any class names, the live (not// disposed) assets' cost or accumulated depreciation with the ledger's// balance. Cost rows first, then by account id.func RegisterVsGL(ctx context.Context, tx pgx.Tx) ([]GLRow, error) { org, err := orgOf(ctx) if err != nil { return nil, err } rows, err := tx.Query(ctx, ` WITH live AS ( SELECT a.cost, k.cost_account_id, k.accumulated_account_id, a.opening_accumulated + coalesce((SELECT sum(l.amount) FROM depreciation_line l WHERE l.org_id = $1 AND l.asset_id = a.id), 0) AS accumulated FROM asset a JOIN asset_class k ON k.org_id = a.org_id AND k.id = a.class_id WHERE a.org_id = $1 AND k.org_id = $1 AND NOT EXISTS (SELECT 1 FROM asset_disposal d WHERE d.org_id = $1 AND d.asset_id = a.id AND d.posted_at IS NOT NULL)), roles AS ( SELECT c.cost_account_id AS account_id, 'cost' AS role FROM asset_class c WHERE c.org_id = $1 UNION SELECT c2.accumulated_account_id, 'accumulated' FROM asset_class c2 WHERE c2.org_id = $1) SELECT x.account_id, x.role, CASE x.role WHEN 'cost' THEN coalesce((SELECT sum(v.cost) FROM live v WHERE v.cost_account_id = x.account_id), 0) ELSE coalesce((SELECT sum(v.accumulated) FROM live v WHERE v.accumulated_account_id = x.account_id), 0) END, CASE x.role WHEN 'cost' THEN 1 ELSE -1 END * coalesce((SELECT sum(jl.base_debit - jl.base_credit) FROM journal_line jl WHERE jl.org_id = $1 AND jl.account_id = x.account_id), 0) FROM roles x ORDER BY x.role DESC, x.account_id`, org) if err != nil { return nil, fmt.Errorf("assets: register vs ledger: %w", err) } return pgx.CollectRows(rows, func(r pgx.CollectableRow) (GLRow, error) { var g GLRow err := r.Scan(&g.AccountID, &g.Role, &g.Register, &g.Ledger) g.Difference = g.Register.Sub(g.Ledger) return g, err })}
type registerGLCheck struct{}
func (registerGLCheck) Key() string { return CheckRegisterGL }
// Run: one finding per account whose register and ledger differ. A// cost-account finding usually means a posted cost not yet registered, or a// registered source since reversed (Ruling FA-8).func (registerGLCheck) Run(ctx context.Context, tx pgx.Tx) ([]reconcile.Finding, error) { rows, err := RegisterVsGL(ctx, tx) if err != nil { return nil, err } var out []reconcile.Finding for _, r := range rows { if !r.Difference.IsZero() { out = append(out, reconcile.Finding{Check: CheckRegisterGL, Ref: r.AccountID, Detail: fmt.Sprintf( "the fixed asset register's %s on this account is %s; the ledger's is %s", r.Role, r.Register, r.Ledger)}) } } return out, nil}- Step 3:
reports.go.
package assets
import ( "context" "fmt" "time"
"github.com/google/uuid" "github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/kernel/clock" "github.com/mcom/erp/internal/kernel/money")
// ForecastRow is one in-service asset's charge for each forecast month.type ForecastRow struct { Asset Asset Charges []money.Amount}
// ForecastReport is the depreciation schedule (Ruling FA-18).type ForecastReport struct { Months []time.Time // first day of each month Rows []ForecastRow Totals []money.Amount}
// ScheduleForecast forecasts n months (1–60) from the month after the// latest run, or from the current month when there is none.func ScheduleForecast(ctx context.Context, tx pgx.Tx, c clock.Clock, n int) (ForecastReport, error) { if n < 1 || n > 60 { return ForecastReport{}, fieldErr("months", "months_range", "forecast 1 to 60 months") } org, err := orgOf(ctx) if err != nil { return ForecastReport{}, err } start := monthStart(clock.Today(ctx, c)) var last *time.Time if err := tx.QueryRow(ctx, ` SELECT max(p.ends_on) FROM depreciation_run r JOIN fiscal_period p ON p.org_id = r.org_id AND p.id = r.fiscal_period_id WHERE r.org_id = $1 AND p.org_id = $1`, org).Scan(&last); err != nil { return ForecastReport{}, fmt.Errorf("assets: forecast: %w", err) } if last != nil { start = last.UTC().AddDate(0, 0, 1) } list, err := List(ctx, tx, Filter{Status: StatusInService}) if err != nil { return ForecastReport{}, err } classes, err := classMap(ctx, tx) if err != nil { return ForecastReport{}, err } out := ForecastReport{Months: make([]time.Time, n), Totals: make([]money.Amount, n)} for i := range out.Months { out.Months[i], out.Totals[i] = start.AddDate(0, i, 0), money.Zero() } for _, a := range list { charges := Forecast(termsOf(a, classes[a.ClassID]), start, n, a.posted()) for i, ch := range charges { out.Totals[i] = out.Totals[i].Add(ch) } out.Rows = append(out.Rows, ForecastRow{Asset: a, Charges: charges}) } return out, nil}
func classMap(ctx context.Context, tx pgx.Tx) (map[uuid.UUID]Class, error) { ks, err := ListClasses(ctx, tx) out := map[uuid.UUID]Class{} for _, k := range ks { out[k.ID] = k } return out, err}
// RollForwardRow is one class's cost and accumulated depreciation over// [from, to]. Closing = opening + additions − disposals, for both;// AccumAcquired is accumulated depreciation that arrived with an opening// asset placed in service inside the range.type RollForwardRow struct { ClassID uuid.UUID ClassCode string CostOpening, Additions, CostDisposals, CostClosing money.Amount AccumOpening, AccumAcquired, Charge, AccumDisposals, AccumClosing money.Amount}
func zeroRow(id uuid.UUID, code string) RollForwardRow { z := money.Zero() return RollForwardRow{ClassID: id, ClassCode: code, CostOpening: z, Additions: z, CostDisposals: z, CostClosing: z, AccumOpening: z, AccumAcquired: z, Charge: z, AccumDisposals: z, AccumClosing: z}}
func (r *RollForwardRow) add(o RollForwardRow) { r.CostOpening, r.Additions = r.CostOpening.Add(o.CostOpening), r.Additions.Add(o.Additions) r.CostDisposals, r.CostClosing = r.CostDisposals.Add(o.CostDisposals), r.CostClosing.Add(o.CostClosing) r.AccumOpening, r.AccumAcquired = r.AccumOpening.Add(o.AccumOpening), r.AccumAcquired.Add(o.AccumAcquired) r.Charge, r.AccumDisposals = r.Charge.Add(o.Charge), r.AccumDisposals.Add(o.AccumDisposals) r.AccumClosing = r.AccumClosing.Add(o.AccumClosing)}
// RollForward is the cost and accumulated depreciation roll-forward by class,// in class-code order, with a total row (ClassID uuid.Nil).// ponytail: reads every asset and line into memory; an SME register is small.// Upgrade to per-class SQL sums if a register passes tens of thousands.func RollForward(ctx context.Context, tx pgx.Tx, from, to time.Time) ([]RollForwardRow, RollForwardRow, error) { if to.Before(from) { return nil, RollForwardRow{}, fieldErr("to", "range", "to is on or after from") } org, err := orgOf(ctx) if err != nil { return nil, RollForwardRow{}, err } list, err := List(ctx, tx, Filter{}) if err != nil { return nil, RollForwardRow{}, err } ks, err := ListClasses(ctx, tx) if err != nil { return nil, RollForwardRow{}, err } type charge struct { on time.Time amount money.Amount } lines := map[uuid.UUID][]charge{} rows, err := tx.Query(ctx, `SELECT l.asset_id, l.charged_on, l.amount FROM depreciation_line l WHERE l.org_id = $1`, org) if err != nil { return nil, RollForwardRow{}, fmt.Errorf("assets: roll-forward: %w", err) } for rows.Next() { var id uuid.UUID var ch charge if err := rows.Scan(&id, &ch.on, &ch.amount); err != nil { rows.Close() return nil, RollForwardRow{}, err } ch.on = ch.on.UTC() lines[id] = append(lines[id], ch) } rows.Close() disposedAccum := map[uuid.UUID]money.Amount{} rows, err = tx.Query(ctx, ` SELECT d.asset_id, d.accumulated FROM asset_disposal d WHERE d.org_id = $1 AND d.posted_at IS NOT NULL`, org) if err != nil { return nil, RollForwardRow{}, fmt.Errorf("assets: roll-forward: %w", err) } for rows.Next() { var id uuid.UUID var acc money.Amount if err := rows.Scan(&id, &acc); err != nil { rows.Close() return nil, RollForwardRow{}, err } disposedAccum[id] = acc } rows.Close()
opening := from.AddDate(0, 0, -1) within := func(d time.Time) bool { return !d.Before(from) && !d.After(to) } live := func(a Asset, on time.Time) bool { return !a.InServiceOn.After(on) && (a.DisposedOn == nil || a.DisposedOn.After(on)) } accumAt := func(a Asset, on time.Time) money.Amount { if !live(a, on) { return money.Zero() } s := a.OpeningAccumulated for _, l := range lines[a.ID] { if !l.on.After(on) { s = s.Add(l.amount) } } return s } out := make([]RollForwardRow, len(ks)) idx := map[uuid.UUID]int{} for i, k := range ks { out[i], idx[k.ID] = zeroRow(k.ID, k.Code), i } for _, a := range list { r := &out[idx[a.ClassID]] if live(a, opening) { r.CostOpening = r.CostOpening.Add(a.Cost) } if live(a, to) { r.CostClosing = r.CostClosing.Add(a.Cost) } if within(a.InServiceOn) { r.Additions, r.AccumAcquired = r.Additions.Add(a.Cost), r.AccumAcquired.Add(a.OpeningAccumulated) } if a.DisposedOn != nil && within(*a.DisposedOn) { r.CostDisposals, r.AccumDisposals = r.CostDisposals.Add(a.Cost), r.AccumDisposals.Add(disposedAccum[a.ID]) } for _, l := range lines[a.ID] { if within(l.on) { r.Charge = r.Charge.Add(l.amount) } } r.AccumOpening, r.AccumClosing = r.AccumOpening.Add(accumAt(a, opening)), r.AccumClosing.Add(accumAt(a, to)) } total := zeroRow(uuid.Nil, "") for _, r := range out { total.add(r) } return out, total, nil}-
Step 4: Run.
go test ./internal/platform/assets/... ./internal/kernel/layering/... -count=1→ PASS. -
Step 5: Sabotage. (a) In
run.goskip theINSERT INTO depreciation_lineloop; runTestTheRegisterEqualsTheClassAccountsLedgerBalances; read the red (a finding on the accumulated account after the run); restore. (b) Delete theinit()inintegrity.go; runTestTheCheckIsInTheDefaultSet; read the red; restore. Record them. -
Step 6: Commit.
git add internal/platform/assets && git commit -m "O4 Task 7: register-vs-ledger check and the fixed asset reports" -m "<sabotage messages>" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
Task 8: Opening fixed assets import
Section titled “Task 8: Opening fixed assets import”Files:
- Create:
db/migrations/00773_import_fixed_asset.sql - Create:
internal/platform/imports/fixedassets.go,internal/platform/imports/fixedassets_test.go - Modify:
internal/platform/assets/asset.go(appendOpeningInput,RegisterOpening) - Modify:
internal/platform/imports/imports.go(thekindsmap) - Modify:
api/openapi.yaml(the import request’skindenum and its mapping/counts descriptions) +make generate
Interfaces:
- Consumes: Task 4’s
insertAsset,classByCode,monthStart;imports.requireUnfinished,amountCell,dateCell,Migration.CutOverOn,Migration.GoLiveOn. - Produces:
// assetstype OpeningInput struct { ClassCode, Description string; Cost, Accumulated money.Amount; ResidualValue *money.Amount; InServiceOn, GoLiveOn time.Time; ImportJobID, By uuid.UUID }func RegisterOpening(ctx context.Context, tx pgx.Tx, c clock.Clock, in OpeningInput) (Asset, error)// importsconst KindFixedAssets = "fixed_asset" // fields: class, description, cost, accumulated_depreciation, in_service_on, residual_value- Step 1: Write the failing test.
internal/platform/imports/fixedassets_test.go:
package imports_test
import ( "context" "errors" "testing" "time"
"github.com/jackc/pgx/v5" "github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/kernel/money" "github.com/mcom/erp/internal/platform/accounting/chart" "github.com/mcom/erp/internal/platform/assets" "github.com/mcom/erp/internal/platform/imports")
// assetClassWorld is partyWorld with a Depreciation and a Disposal account and// a 60-month straight-line class EQUIP on the MY pack's 1110/1120, cut over// on 2025-12-31 (go-live 2026-01-01, the first day of FY2026).func assetClassWorld(t *testing.T) *partyWorld { t.Helper() w := newPartyWorld(t) w.tx(t, func(ctx context.Context, tx pgx.Tx) error { acct := func(code string) chart.Account { a, err := chart.GetAccountByCode(ctx, tx, code); require.NoError(t, err); return a } exp, inc := acct("6000"), acct("4000") dep, err := chart.CreateAccount(ctx, tx, w.e.Clock, chart.AccountInput{ParentID: &exp.ID, Code: "6200", Name: "Depreciation", Type: chart.TypeExpense}) require.NoError(t, err) disp, err := chart.CreateAccount(ctx, tx, w.e.Clock, chart.AccountInput{ParentID: &inc.ID, Code: "4300", Name: "Disposal gain or loss", Type: chart.TypeIncome}) require.NoError(t, err) life := 60 _, err = assets.CreateClass(ctx, tx, w.e.Clock, assets.ClassInput{Code: "EQUIP", Names: map[string]string{"en": "Equipment", "ms": "Peralatan"}, CostAccountID: acct("1110").ID, AccumulatedAccountID: acct("1120").ID, DepreciationAccountID: dep.ID, DisposalAccountID: disp.ID, Method: assets.MethodStraightLine, LifeMonths: &life, ResidualPercent: money.MustParseRate("0")}) return err }) require.NoError(t, w.setCutOver(t, time.Date(2025, 12, 31, 0, 0, 0, 0, time.UTC))) return w}
const openingAssetsCSV = "class,description,cost,accumulated_depreciation,in_service_on\n" + "EQUIP,Delivery van,\"60,000.00\",\"24,000.00\",2023-01-01\n" + "equip,Forklift,12000,,15/06/2025\n"
func TestOpeningFixedAssetsRegisterWithoutPosting(t *testing.T) { w := assetClassWorld(t) entries := w.e.Count(t, `SELECT count(*) FROM journal_entry WHERE org_id = $1`, w.e.Org) res, err := w.run(t, imports.KindFixedAssets, openingAssetsCSV) require.NoError(t, err) require.Equal(t, 2, res.Counts["assets"]) require.Equal(t, entries, w.e.Count(t, `SELECT count(*) FROM journal_entry WHERE org_id = $1`, w.e.Org), "registering never posts: the opening trial balance carried the balances (M4)")
var list []assets.Asset w.tx(t, func(ctx context.Context, tx pgx.Tx) (err error) { list, err = assets.List(ctx, tx, assets.Filter{}); return }) require.Len(t, list, 2) van := list[0] require.Equal(t, "Delivery van", van.Description) require.Equal(t, assets.SourceOpeningImport, van.SourceKind) require.Equal(t, res.JobID, van.SourceID) require.Zero(t, money.MustParse("24000").Cmp(van.OpeningAccumulated)) require.Zero(t, money.MustParse("24000").Cmp(van.Accumulated)) require.Equal(t, time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), van.DepreciateFrom, "the go-live month")}
func TestAnOpeningAssetsProblemsNameTheirColumns(t *testing.T) { w := assetClassWorld(t) for _, c := range []struct{ csv, column string }{ {"class,description,cost,in_service_on\nEQUIP,Van,1000,2026-02-01\n", "in_service_on"}, // after the cut-over {"class,description,cost,in_service_on\nNOPE,Van,1000,2025-02-01\n", "class"}, {"class,description,cost,accumulated_depreciation,in_service_on\nEQUIP,Van,1000,1000.01,2025-02-01\n", "residual_value"}, } { _, err := w.run(t, imports.KindFixedAssets, c.csv) var pe *imports.ProblemsError require.True(t, errors.As(err, &pe), "%s: %v", c.column, err) require.Equal(t, c.column, pe.Problems[0].Column) }}Run: go test ./internal/platform/imports/... -count=1 -run Opening.*Asset
Expected: FAIL — undefined: imports.KindFixedAssets.
- Step 2:
RegisterOpening. Append tointernal/platform/assets/asset.go:
// OpeningInput is one asset brought in by the fixed_asset import (Ruling// FA-23): its accumulated depreciation to the cut-over date.type OpeningInput struct { ClassCode string Description string Cost money.Amount Accumulated money.Amount ResidualValue *money.Amount InServiceOn time.Time GoLiveOn time.Time // depreciation starts in this month (Ruling FA-11) ImportJobID uuid.UUID By uuid.UUID}
// RegisterOpening registers an opening asset. It never posts: the opening// trial balance carried its cost and accumulated depreciation.func RegisterOpening(ctx context.Context, tx pgx.Tx, c clock.Clock, in OpeningInput) (Asset, error) { org, err := orgOf(ctx) if err != nil { return Asset{}, err } k, err := classByCode(ctx, tx, strings.ToUpper(strings.TrimSpace(in.ClassCode))) if errors.Is(err, ErrNotFound) || (err == nil && !k.IsActive) { return Asset{}, fieldErr("class", "class_active", "no active asset class has that code") } if err != nil { return Asset{}, err } if !in.Cost.IsPositive() { return Asset{}, fieldErr("cost", "positive", "the cost is a positive amount") } if in.Accumulated.IsNegative() { return Asset{}, fieldErr("accumulated_depreciation", "nonnegative", "accumulated depreciation is never negative") } return insertAsset(ctx, tx, c, org, newAsset{class: k, description: in.Description, cost: in.Cost, opening: in.Accumulated, residual: in.ResidualValue, inService: in.InServiceOn, from: monthStart(in.GoLiveOn), sourceKind: SourceOpeningImport, sourceID: in.ImportJobID, by: in.By})}- Step 3: The import kind.
internal/platform/imports/fixedassets.go:
package imports
import ( "context" "errors"
"github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/kernel/clock" "github.com/mcom/erp/internal/kernel/money" "github.com/mcom/erp/internal/platform/assets")
// KindFixedAssets is the opening fixed asset register (operations design O4,// Ruling FA-23): one row per asset in service at the cut-over date, with its// accumulated depreciation to that date. Registration never posts.const KindFixedAssets = "fixed_asset"
var fixedAssetsKind = kind{ fields: []string{"class", "description", "cost", "accumulated_depreciation", "in_service_on", "residual_value"}, required: []string{"class", "description", "cost", "in_service_on"}, save: saveFixedAssets,}
func saveFixedAssets(ctx context.Context, tx pgx.Tx, c clock.Clock, job jobRef, rows []row) (outcome, error) { m, problems, err := requireUnfinished(ctx, tx) if err != nil || len(problems) > 0 { return outcome{problems: problems}, err } if m.CutOverOn == nil { return outcome{problems: []Problem{{Reason: "set the cut-over date first"}}}, nil } out := outcome{counts: map[string]int{"assets": 0}} for _, r := range rows { bad := func(col, reason string) { out.problems = append(out.problems, Problem{Line: r.line, Column: col, Reason: reason}) } cost, err := amountCell(r.cells["cost"]) if err != nil { bad("cost", err.Error()) continue } accum, err := amountCell(r.cells["accumulated_depreciation"]) if err != nil { bad("accumulated_depreciation", err.Error()) continue } var residual *money.Amount if s := r.cells["residual_value"]; s != "" { v, err := amountCell(s) if err != nil { bad("residual_value", err.Error()) continue } residual = &v } on, err := dateCell(r.cells["in_service_on"]) if err != nil { bad("in_service_on", err.Error()) continue } if on.After(*m.CutOverOn) { bad("in_service_on", "an opening asset is in service by the cut-over date; register a later one from its purchase") continue } _, err = assets.RegisterOpening(ctx, tx, c, assets.OpeningInput{ClassCode: r.cells["class"], Description: r.cells["description"], Cost: cost, Accumulated: accum, ResidualValue: residual, InServiceOn: on, GoLiveOn: *m.GoLiveOn, ImportJobID: job.id, By: job.by}) var fe *assets.FieldError switch { case errors.As(err, &fe): bad(fe.Field, fe.Detail) continue case err != nil: return outcome{}, err } out.counts["assets"]++ } return out, nil}In internal/platform/imports/imports.go, the kinds map gains KindFixedAssets: fixedAssetsKind, after KindArchivePurchaseDocuments: archivePurchaseKind,.
- Step 4: The migration.
db/migrations/00773_import_fixed_asset.sql:
-- +goose Up-- Phase 4 O4 (Ruling FA-23): the fixed_asset import kind.-- MERGE NOTE: the list is the integration branch's list when this merges, plus-- 'fixed_asset' (lane O1 adds 'item').ALTER TABLE import_job DROP CONSTRAINT import_job_kind_known;ALTER TABLE import_job ADD CONSTRAINT import_job_kind_known CHECK (kind IN ('chart_of_accounts', 'customers', 'suppliers', 'opening_trial_balance', 'period_movements', 'opening_sales_documents', 'opening_purchase_documents', 'archive_sales_documents', 'archive_purchase_documents', 'fixed_asset'));
-- +goose Down-- Refused while a fixed_asset import exists: import_job rows are evidence.ALTER TABLE import_job DROP CONSTRAINT import_job_kind_known;ALTER TABLE import_job ADD CONSTRAINT import_job_kind_known CHECK (kind IN ('chart_of_accounts', 'customers', 'suppliers', 'opening_trial_balance', 'period_movements', 'opening_sales_documents', 'opening_purchase_documents', 'archive_sales_documents', 'archive_purchase_documents'));-
Step 5: OpenAPI. In
api/openapi.yaml, the import request’skindenum (the line readingkind: { type: string, enum: [chart_of_accounts, …, archive_purchase_documents], description: What the file holds. Each Phase 3 lane adds kinds. }) gainsfixed_assetlast; itsmappingdescription gains the sentencefixed_asset: class (an asset class code), description, cost, accumulated_depreciation (to the cut-over date; blank is zero), in_service_on (on or before the cut-over date), residual_value (blank: the class's residual percentage). Registered without posting; depreciation starts in the go-live month.; the result’scountsdescription gainsfixed_asset: assets.Runmake generate. -
Step 6: Run.
go test ./internal/platform/imports/... ./internal/platform/assets/... ./internal/kernel/db/... -count=1→ PASS (TestTheImportPackageWritesNoBusinessTableOfItsOwnstill passes: the kind writes throughassets.RegisterOpening). -
Step 7: Sabotage. Delete the
if on.After(*m.CutOverOn)block; runTestAnOpeningAssetsProblemsNameTheirColumns; read the red (the first case imports, or fails onasset_from_month_start); restore. Record it. -
Step 8: Commit.
git add -A && git commit -m "O4 Task 8: fixed_asset opening import" -m "<sabotage message>" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
Task 9: The HTTP module, OpenAPI, module switch and wiring
Section titled “Task 9: The HTTP module, OpenAPI, module switch and wiring”Files:
- Create:
db/migrations/00772_assets_module.sql - Create:
internal/modules/assets/{permissions.go,boot.go,routes.go,http.go,errors.go,handlers.go,disposal_handlers.go} - Create:
internal/modules/assets/{routes_test.go,env_test.go,http_test.go} - Modify:
internal/platform/moduleinstall/moduleinstall.go(Catalog),internal/platform/assets/job_test.go(module-off test) - Modify:
internal/app/app.go,internal/app/routes_test.go,cmd/api/main.go - Modify:
cmd/worker/main.go,cmd/worker/exports.go,cmd/worker/wiring_test.go - Modify:
api/openapi.yaml+make generate(internal/gen/api/api.gen.go,web/src/lib/api/schema.d.ts)
Interfaces:
- Consumes: every exported function of Tasks 4–8.
- Produces:
assets.Deps{DB *db.DB; Clock clock.Clock},assets.Register(g *gin.RouterGroup, deps Deps), permission constantsPermissionAssetRead = "assets.asset.read",PermissionAssetManage = "assets.asset.manage",PermissionDepreciationRun = "assets.depreciation.run",PermissionDisposalCreate = "assets.disposal.create",PermissionDisposalTransition = "assets.disposal.transition"; the sixteen routes below; OpenAPI schemasAssetClass,AssetClassInput,AssetClassUpdate,Asset,AssetList,AssetRegisterInput,AssetView,AssetCandidate,DepreciationRun,DepreciationRunInput,AssetDisposal,AssetDisposalInput,AssetDisposalView,AssetScheduleReport,AssetRollForwardRow,AssetRollForwardReport,AssetRegisterVsGL(the web’s types in Tasks 10–11).
| Route | Permission |
|---|---|
GET /assets/classes, GET /assets, GET /assets/candidates, GET /assets/:id, GET /assets/depreciation-runs, GET /assets/disposals, GET /assets/disposals/:id, GET /assets/reports/schedule, GET /assets/reports/roll-forward, GET /assets/reports/register-vs-gl |
assets.asset.read |
POST /assets/classes, PUT /assets/classes/:id, POST /assets |
assets.asset.manage |
POST /assets/depreciation-runs |
assets.depreciation.run |
POST /assets/disposals |
assets.disposal.create |
POST /assets/disposals/:id/transitions |
assets.disposal.transition |
- Step 1: Write the failing tests.
internal/modules/assets/routes_test.go:
package assets_test
import ( "strings" "testing"
"github.com/gin-gonic/gin" "github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/kernel/httpx" "github.com/mcom/erp/internal/modules/assets")
func TestEveryRouteRequiresItsExactPermission(t *testing.T) { gin.SetMode(gin.TestMode) httpx.ResetAuthorizedRoutes() r, api := httpx.NewRouter(httpx.Deps{}) assets.Register(api, assets.Deps{})
read, manage, run := assets.PermissionAssetRead, assets.PermissionAssetManage, assets.PermissionDepreciationRun create, transition := assets.PermissionDisposalCreate, assets.PermissionDisposalTransition want := map[string]string{ "GET /v1/assets/classes": read, "POST /v1/assets/classes": manage, "PUT /v1/assets/classes/:id": manage, "GET /v1/assets/candidates": read, "GET /v1/assets": read, "POST /v1/assets": manage, "GET /v1/assets/:id": read, "GET /v1/assets/depreciation-runs": read, "POST /v1/assets/depreciation-runs": run, "GET /v1/assets/disposals": read, "POST /v1/assets/disposals": create, "GET /v1/assets/disposals/:id": read, "POST /v1/assets/disposals/:id/transitions": transition, "GET /v1/assets/reports/schedule": read, "GET /v1/assets/reports/roll-forward": read, "GET /v1/assets/reports/register-vs-gl": read, } require.Equal(t, want, httpx.AuthorizedRoutes())
require.Equal(t, "assets.asset.read", assets.PermissionAssetRead) require.Equal(t, "assets.asset.manage", assets.PermissionAssetManage) require.Equal(t, "assets.depreciation.run", assets.PermissionDepreciationRun) require.Equal(t, "assets.disposal.create", assets.PermissionDisposalCreate) require.Equal(t, "assets.disposal.transition", assets.PermissionDisposalTransition)
var registered []string for _, ri := range r.Routes() { if strings.HasPrefix(ri.Path, "/v1/assets") { registered = append(registered, ri.Method+" "+ri.Path) } } require.Len(t, registered, len(want), "routes: %v", registered)}internal/modules/assets/env_test.go:
package assets_test
import ( "bytes" "context" "encoding/json" "log/slog" "net/http/httptest" "testing" "time"
"github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/kernel/authn" "github.com/mcom/erp/internal/kernel/clock" "github.com/mcom/erp/internal/kernel/httpx" "github.com/mcom/erp/internal/kernel/money" "github.com/mcom/erp/internal/modules/assets" "github.com/mcom/erp/internal/platform/accounting/chart" "github.com/mcom/erp/internal/platform/accounting/fiscal" "github.com/mcom/erp/internal/platform/accounting/ledger" pa "github.com/mcom/erp/internal/platform/assets" "github.com/mcom/erp/internal/platform/identity/token" "github.com/mcom/erp/internal/platform/payables" "github.com/mcom/erp/internal/platform/payables/payablestest")
type alwaysLive struct{}
func (alwaysLive) Check(context.Context, authn.Claims) error { return nil }
type discard struct{}
func (discard) Write(p []byte) (int, error) { return len(p), nil }
// env is payablestest's organization with this module alone on a router:// Staff (preparer) and Owner (approver) hold all five keys, a new Reader// only assets.asset.read. Accounts 6200 Depreciation and 4300 Disposal are added.type env struct { pt *payablestest.Env r *gin.Engine signer *token.Signer ppe, accum, dep, disp uuid.UUID preparerTok, approverTok, readerTok string}
func newEnv(t *testing.T) *env { t.Helper() gin.SetMode(gin.TestMode) pt := payablestest.New(t) pt.UsePoster(t, append(payables.Rules(), pa.Rules()...)...) signer, err := token.NewSigner(map[string][]byte{"k1": []byte("01234567890123456789012345678901")}, "k1", clock.Fixed(pt.Clock.Now())) require.NoError(t, err) r, api := httpx.NewRouter(httpx.Deps{DB: pt.DB, Logger: slog.New(slog.NewTextHandler(discard{}, nil)), Version: "test", TokenVerifier: signer, Liveness: alwaysLive{}}) assets.Register(api, assets.Deps{DB: pt.DB, Clock: pt.Clock})
e := &env{pt: pt, r: r, signer: signer, ppe: pt.Account(t, "1110"), accum: pt.Account(t, "1120")} pt.Tx(t, func(ctx context.Context, tx pgx.Tx) error { exp, err := chart.GetAccountByCode(ctx, tx, "6000") require.NoError(t, err) inc, err := chart.GetAccountByCode(ctx, tx, "4000") require.NoError(t, err) dep, err := chart.CreateAccount(ctx, tx, pt.Clock, chart.AccountInput{ParentID: &exp.ID, Code: "6200", Name: "Depreciation", Type: chart.TypeExpense}) require.NoError(t, err) disp, err := chart.CreateAccount(ctx, tx, pt.Clock, chart.AccountInput{ParentID: &inc.ID, Code: "4300", Name: "Disposal gain or loss", Type: chart.TypeIncome}) e.dep, e.disp = dep.ID, disp.ID return err }) reader, _ := pt.Member(t, "Reader", &pt.OwnerSeat) all := []string{assets.PermissionAssetRead, assets.PermissionAssetManage, assets.PermissionDepreciationRun, assets.PermissionDisposalCreate, assets.PermissionDisposalTransition} e.grant(t, pt.Staff, all...) e.grant(t, pt.Owner, all...) e.grant(t, reader, assets.PermissionAssetRead) e.preparerTok, e.approverTok, e.readerTok = e.mint(t, pt.Staff), e.mint(t, pt.Owner), e.mint(t, reader) return e}
func (e *env) grant(t *testing.T, memberID uuid.UUID, keys ...string) { t.Helper() ctx := context.Background() var roleID uuid.UUID require.NoError(t, e.pt.H.Admin.QueryRow(ctx, `INSERT INTO role (org_id, name, system) VALUES ($1,$2,true) RETURNING id`, e.pt.Org, "R-"+uuid.NewString()).Scan(&roleID)) for _, k := range keys { _, err := e.pt.H.Admin.Exec(ctx, `INSERT INTO role_permission (org_id, role_id, permission_key) VALUES ($1,$2,$3)`, e.pt.Org, roleID, k) require.NoError(t, err) } _, err := e.pt.H.Admin.Exec(ctx, `INSERT INTO member_role (org_id, member_id, role_id) VALUES ($1,$2,$3)`, e.pt.Org, memberID, roleID) require.NoError(t, err)}
func (e *env) mint(t *testing.T, memberID uuid.UUID) string { t.Helper() tok, err := e.signer.Mint(authn.Claims{UserID: uuid.New(), OrgID: e.pt.Org, MemberID: memberID, SessionID: uuid.New()}) require.NoError(t, err) return tok}
func (e *env) do(t *testing.T, method, path, tok string, body any) *httptest.ResponseRecorder { t.Helper() var raw []byte if body != nil { var err error raw, err = json.Marshal(body) require.NoError(t, err) } req := httptest.NewRequest(method, path, bytes.NewReader(raw)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+tok) w := httptest.NewRecorder() e.r.ServeHTTP(w, req) return w}
func decode[T any](t *testing.T, rec *httptest.ResponseRecorder, want int) T { t.Helper() require.Equal(t, want, rec.Code, "%s", rec.Body.String()) var v T require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &v)) return v}
// manualCost posts Dr 1110 / Cr 3100 on 2026-01-15 and returns the debit line.func (e *env) manualCost(t *testing.T, cost string) uuid.UUID { t.Helper() capital := e.pt.Account(t, "3100") var line uuid.UUID e.pt.Tx(t, func(ctx context.Context, tx pgx.Tx) error { en, err := ledger.Post(ctx, tx, e.pt.Clock, ledger.EntryInput{JournalType: chart.JournalMisc, EntryDate: time.Date(2026, 1, 15, 0, 0, 0, 0, time.UTC), Description: "Van contributed by a shareholder", Semantic: ledger.SemanticManual, PostedBy: &e.pt.Owner, Lines: []ledger.Line{{AccountID: e.ppe, Debit: money.MustParse(cost)}, {AccountID: capital, Credit: money.MustParse(cost)}}}) if err == nil { line = en.Lines[0].ID } return err }) return line}
func (e *env) period(t *testing.T, m time.Month) uuid.UUID { t.Helper() var id uuid.UUID e.pt.Tx(t, func(ctx context.Context, tx pgx.Tx) error { y, err := fiscal.YearContaining(ctx, tx, time.Date(2026, m, 1, 0, 0, 0, 0, time.UTC)) if err != nil { return err } ps, err := fiscal.ListPeriods(ctx, tx, y.ID) if err == nil { id = ps[int(m)-1].ID } return err }) return id}internal/modules/assets/http_test.go:
package assets_test
import ( "net/http" "net/http/httptest" "testing" "time"
"github.com/google/uuid" "github.com/stretchr/testify/require")
func TestAClassAnAssetARunAndADisposalOverHTTP(t *testing.T) { e := newEnv(t) class := map[string]any{"code": "EQUIP", "names": map[string]string{"en": "Equipment", "ms": "Peralatan"}, "cost_account_id": e.ppe, "accumulated_account_id": e.accum, "depreciation_account_id": e.dep, "disposal_account_id": e.disp, "method": "straight_line", "life_months": 12, "residual_percent": "0"} require.Equal(t, http.StatusForbidden, e.do(t, "POST", "/v1/assets/classes", e.readerTok, class).Code, "a reader cannot create a class") k := decode[struct { ID uuid.UUID `json:"id"` }](t, e.do(t, "POST", "/v1/assets/classes", e.preparerTok, class), http.StatusCreated) rec := e.do(t, "POST", "/v1/assets/classes", e.preparerTok, class) require.Equal(t, http.StatusConflict, rec.Code) require.Contains(t, rec.Body.String(), `"class_code_taken"`)
line := e.manualCost(t, "12000") cands := decode[struct { Candidates []struct { SourceID uuid.UUID `json:"source_id"` Amount string `json:"amount"` } `json:"candidates"` }](t, e.do(t, "GET", "/v1/assets/candidates", e.readerTok, nil), http.StatusOK) require.Len(t, cands.Candidates, 1) require.Equal(t, line, cands.Candidates[0].SourceID) require.Equal(t, "12000.0000", cands.Candidates[0].Amount, "money is a string on the wire")
a := decode[struct { ID uuid.UUID `json:"id"` Number string `json:"number"` }](t, e.do(t, "POST", "/v1/assets", e.preparerTok, map[string]any{"source_kind": "journal_line", "source_id": line, "class_id": k.ID, "description": "Delivery van", "in_service_on": "2026-01-15"}), http.StatusCreated) require.Equal(t, "FA-2026-0001", a.Number) list := decode[struct { Totals struct { Cost string `json:"cost"` } `json:"totals"` }](t, e.do(t, "GET", "/v1/assets", e.readerTok, nil), http.StatusOK) require.Equal(t, "12000.0000", list.Totals.Cost)
aug := map[string]any{"period_id": e.period(t, time.August)} run := decode[struct { Status string `json:"status"` Total string `json:"total"` }](t, e.do(t, "POST", "/v1/assets/depreciation-runs", e.approverTok, aug), http.StatusCreated) require.Equal(t, "posted", run.Status) require.Equal(t, "8000.0000", run.Total) rec = e.do(t, "POST", "/v1/assets/depreciation-runs", e.approverTok, aug) require.Equal(t, http.StatusConflict, rec.Code) require.Contains(t, rec.Body.String(), `"run_exists"`)
type view struct { Disposal struct { ID uuid.UUID `json:"id"` State string `json:"state"` NBV *string `json:"nbv"` } `json:"disposal"` } d := decode[view](t, e.do(t, "POST", "/v1/assets/disposals", e.preparerTok, map[string]any{"asset_id": a.ID, "kind": "sale", "disposed_on": "2026-09-10", "reason": "Sold to a scrap dealer"}), http.StatusCreated) require.Equal(t, "draft", d.Disposal.State) move := func(tok, from, to string) *httptest.ResponseRecorder { return e.do(t, "POST", "/v1/assets/disposals/"+d.Disposal.ID.String()+"/transitions", tok, map[string]string{"from": from, "to": to, "reason": "Sold"}) } decode[view](t, move(e.preparerTok, "draft", "pending_approval"), http.StatusOK) require.Equal(t, http.StatusForbidden, move(e.preparerTok, "pending_approval", "disposed").Code, "the preparer cannot approve their own disposal") done := decode[view](t, move(e.approverTok, "pending_approval", "disposed"), http.StatusOK) require.Equal(t, "disposed", done.Disposal.State) require.Equal(t, "3000.0000", *done.Disposal.NBV)
gl := decode[struct { Rows []struct { Difference string `json:"difference"` } `json:"rows"` }](t, e.do(t, "GET", "/v1/assets/reports/register-vs-gl", e.readerTok, nil), http.StatusOK) require.Len(t, gl.Rows, 2) for _, r := range gl.Rows { require.Equal(t, "0.0000", r.Difference) }}Append to internal/platform/assets/job_test.go:
func TestTheScheduleSkipsAnOrganizationWithTheModuleOff(t *testing.T) { w := newWorld(t) w.journalAsset(t, w.Class, "12000", jan15) set := func(on bool) { w.Tx(t, func(ctx context.Context, tx pgx.Tx) error { _, err := moduleinstall.Set(ctx, tx, w.Clock, assets.ModuleKey, on, w.Owner) return err }) } set(false) require.NoError(t, assets.RunScheduled(context.Background(), w.DB, w.Clock, w.Org)) require.Equal(t, 0, w.runs(t), "switched off: nothing runs") set(true) require.NoError(t, assets.RunScheduled(context.Background(), w.DB, w.Clock, w.Org)) require.Equal(t, 1, w.runs(t), "PRESENCE: the same organization, switched on, runs")}(add "github.com/jackc/pgx/v5" and "github.com/mcom/erp/internal/platform/moduleinstall" to its imports.)
Append to cmd/worker/wiring_test.go:
// The worker posts no disposal itself today, but a transition it ever runs// on an asset_disposal must find the effect rule (Ruling R31's reason).func TestWorkerLinksTheAssetsModule(t *testing.T) { _, ok := accounting.DefaultRules().Lookup("asset_disposal", posting.Effect) require.True(t, ok, "cmd/worker must blank-import internal/modules/assets")}Run: go test ./internal/modules/assets/... ./internal/platform/assets/... ./cmd/worker/... -count=1
Expected: FAIL — no non-test Go files in internal/modules/assets.
- Step 2: The module switch and grants.
internal/platform/moduleinstall/moduleinstall.goCataloggains{Key: "assets"},after{Key: "taxreturns", …}.db/migrations/00772_assets_module.sql:
-- +goose Up-- Phase 4 O4 (Rulings FA-4, FA-20, FA-25): the optional module `assets`,-- on by default (no row), and its five keys for existing Owner and Finance-- roles (a new organization's roles are seeded by provisioning).-- MERGE NOTE: the module list is the integration branch's list when this-- merges, plus 'assets' (lane O1 adds inventory, procurement and sales).ALTER TABLE module_install DROP CONSTRAINT module_install_key_known;ALTER TABLE module_install ADD CONSTRAINT module_install_key_known CHECK (module_key IN ( 'expenses', 'advances', 'pettycash', 'payables', 'receivables', 'banking', 'einvoice', 'taxreturns', 'assets'));
INSERT INTO role_permission (org_id, role_id, permission_key)SELECT r.org_id, r.id, k FROM role r, unnest(ARRAY['assets.asset.read','assets.asset.manage','assets.depreciation.run', 'assets.disposal.create','assets.disposal.transition']) AS k WHERE r.system AND r.name IN ('Owner','Finance')ON CONFLICT (role_id, permission_key) DO NOTHING;
-- member.BumpPermVersion's contract (see 00170).UPDATE organization o SET perm_version = o.perm_version + 1 WHERE EXISTS (SELECT 1 FROM role r WHERE r.org_id = o.id AND r.system AND r.name IN ('Owner','Finance'));
-- +goose DownDELETE FROM role_permission rp USING role r WHERE r.org_id = rp.org_id AND r.id = rp.role_id AND r.system AND r.name IN ('Owner','Finance') AND rp.permission_key LIKE 'assets.%';UPDATE organization o SET perm_version = o.perm_version + 1 WHERE EXISTS (SELECT 1 FROM role r WHERE r.org_id = o.id AND r.system AND r.name IN ('Owner','Finance'));-- Refused while an organization has a module_install row for assets.ALTER TABLE module_install DROP CONSTRAINT module_install_key_known;ALTER TABLE module_install ADD CONSTRAINT module_install_key_known CHECK (module_key IN ( 'expenses', 'advances', 'pettycash', 'payables', 'receivables', 'banking', 'einvoice', 'taxreturns'));- Step 3:
permissions.goandboot.go.
package assets
import ( "github.com/mcom/erp/internal/platform/identity/member" "github.com/mcom/erp/internal/platform/provisioning")
// The five keys (Ruling FA-20). Authority to approve a disposal still comes// from its workflow's grants; disposal.transition only opens the endpoint.const ( PermissionAssetRead = "assets.asset.read" PermissionAssetManage = "assets.asset.manage" PermissionDepreciationRun = "assets.depreciation.run" PermissionDisposalCreate = "assets.disposal.create" PermissionDisposalTransition = "assets.disposal.transition")
func init() { member.RegisterPermission(PermissionAssetRead, "Read the fixed asset register, classes, depreciation runs, disposals and their reports") member.RegisterPermission(PermissionAssetManage, "Create and retire asset classes, and register assets from posted costs") member.RegisterPermission(PermissionDepreciationRun, "Run a period's depreciation") member.RegisterPermission(PermissionDisposalCreate, "Prepare an asset disposal or write-off") member.RegisterPermission(PermissionDisposalTransition, "Use the disposal transition endpoint; authority to approve still comes from the workflow's grants") provisioning.RegisterRolePermissions("Finance", PermissionAssetRead, PermissionAssetManage, PermissionDepreciationRun, PermissionDisposalCreate, PermissionDisposalTransition)}package assets
import ( "github.com/mcom/erp/internal/platform/accounting" pa "github.com/mcom/erp/internal/platform/assets")
// One loop over Rules(): registering only some would let a disposal commit a// state change it cannot post.func init() { for _, r := range pa.Rules() { accounting.RegisterPostingRule(r) }}- Step 4:
http.go. Copy the taxreturns module’s request plumbing verbatim, then rename the package:
sed -e 's/^package taxreturns$/package assets/' \ -e 's#^// Package taxreturns is the HTTP surface of the periodic tax return engine:#// Package assets is the HTTP surface of the fixed asset register (Phase 4 O4):#' \ internal/modules/taxreturns/http.go > internal/modules/assets/http.goThen replace the rest of the package doc comment (up to package assets) with:
// Package assets is the HTTP surface of the fixed asset register (Phase 4// O4): internal/platform/assets owns every rule and audit row; this package// parses requests, runs one transaction per request (reads included) and// maps refusals to RFC 7807. Linking it registers the disposal's posting// rule (boot.go) and the five permissions (permissions.go).//// decodeStrict, parseID, parseDate, actor, mustActor, ifMatch, setETag and// firstErr are COPIES of internal/modules/taxreturns's (a module does not// import another module).Delete ifMatch, ifMatchOrZero, parseETag and setETag from the copy (this module has no revisioned writes), then remove whatever imports the compiler reports unused (go build ./internal/modules/assets/ names them).
- Step 5:
routes.goanderrors.go.
package assets
import ( "context" "fmt"
"github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/kernel/clock" "github.com/mcom/erp/internal/kernel/db" apperr "github.com/mcom/erp/internal/kernel/errors" "github.com/mcom/erp/internal/kernel/httpx" "github.com/mcom/erp/internal/platform/identity/member")
type Deps struct { DB *db.DB Clock clock.Clock}
// Register hangs the routes off the authenticated, tenant-scoped group// (behind the optional-module switch in internal/app). routes_test.go pins// each route's permission.func Register(g *gin.RouterGroup, deps Deps) { h := handlers{deps: deps} pl := permissionLoader{db: deps.DB} httpx.Protected(g, pl, "GET", "/assets/classes", PermissionAssetRead, h.listClasses) httpx.Protected(g, pl, "POST", "/assets/classes", PermissionAssetManage, h.createClass) httpx.Protected(g, pl, "PUT", "/assets/classes/:id", PermissionAssetManage, h.updateClass) httpx.Protected(g, pl, "GET", "/assets/candidates", PermissionAssetRead, h.listCandidates) httpx.Protected(g, pl, "GET", "/assets", PermissionAssetRead, h.listAssets) httpx.Protected(g, pl, "POST", "/assets", PermissionAssetManage, h.registerAsset) httpx.Protected(g, pl, "GET", "/assets/:id", PermissionAssetRead, h.getAsset) httpx.Protected(g, pl, "GET", "/assets/depreciation-runs", PermissionAssetRead, h.listRuns) httpx.Protected(g, pl, "POST", "/assets/depreciation-runs", PermissionDepreciationRun, h.runDepreciation) httpx.Protected(g, pl, "GET", "/assets/disposals", PermissionAssetRead, h.listDisposals) httpx.Protected(g, pl, "POST", "/assets/disposals", PermissionDisposalCreate, h.createDisposal) httpx.Protected(g, pl, "GET", "/assets/disposals/:id", PermissionAssetRead, h.getDisposal) httpx.Protected(g, pl, "POST", "/assets/disposals/:id/transitions", PermissionDisposalTransition, h.transitionDisposal) httpx.Protected(g, pl, "GET", "/assets/reports/schedule", PermissionAssetRead, h.scheduleReport) httpx.Protected(g, pl, "GET", "/assets/reports/roll-forward", PermissionAssetRead, h.rollForwardReport) httpx.Protected(g, pl, "GET", "/assets/reports/register-vs-gl", PermissionAssetRead, h.registerVsGLReport)}
type handlers struct{ deps Deps }
// permissionLoader is taxreturns's, copied.type permissionLoader struct{ db *db.DB }
var _ httpx.PermissionLoader = permissionLoader{}
func (p permissionLoader) Permissions(ctx context.Context, memberID uuid.UUID) (map[string]struct{}, error) { if p.db == nil { return nil, apperr.New(apperr.KindInternal, "authorization is not configured") } var out map[string]struct{} err := p.db.InTx(ctx, func(ctx context.Context, tx pgx.Tx) error { var err error out, err = member.Permissions(ctx, tx, memberID) return err }) if err != nil { return nil, fmt.Errorf("assets: load permissions: %w", err) } return out, nil}package assets
import ( "errors" "fmt"
apperr "github.com/mcom/erp/internal/kernel/errors" "github.com/mcom/erp/internal/platform/accounting" "github.com/mcom/erp/internal/platform/accounting/fiscal" pa "github.com/mcom/erp/internal/platform/assets" "github.com/mcom/erp/internal/platform/workflow")
func coded(kind apperr.Kind, code, detail string) error { return apperr.New(kind, detail).With("code", code) }
// mapErr: 404 no such record; 422 wrong whatever the data holds; 409 the same// request would succeed against the records in another state (payables' split).func mapErr(err error) error { var ( field *pa.FieldError refused *workflow.RefusedError unroutable *workflow.SubmissionRefusedError period *fiscal.PeriodNotOpenError noRule *accounting.NoPostingRuleError ) switch { case errors.Is(err, pa.ErrNotFound), errors.Is(err, fiscal.ErrNotFound): return apperr.New(apperr.KindNotFound, "no such record in this organization")
case errors.As(err, &field): return coded(apperr.KindValidation, field.Rule, field.Error()) case errors.Is(err, pa.ErrSourceNotEligible): return coded(apperr.KindValidation, "source_not_eligible", "the source is not a posted cost on this class's cost account") case errors.Is(err, pa.ErrPeriodNotStarted): return coded(apperr.KindValidation, "period_not_started", "the period has not started") case errors.Is(err, pa.ErrDisposalMonthDepreciated): return coded(apperr.KindValidation, "disposal_month_depreciated", "depreciation is posted for a month after the disposal date; date the disposal in or after that month")
case errors.Is(err, pa.ErrSourceRegistered): return coded(apperr.KindConflict, "source_registered", "that line is already registered as an asset") case errors.Is(err, pa.ErrClassCodeTaken): return coded(apperr.KindConflict, "class_code_taken", "another asset class has that code") case errors.Is(err, pa.ErrRunExists): return coded(apperr.KindConflict, "run_exists", "that period already has a depreciation run") case errors.Is(err, pa.ErrRunOutOfOrder): return coded(apperr.KindConflict, "run_out_of_order", "a later period already has a depreciation run") case errors.Is(err, pa.ErrDisposed): return coded(apperr.KindConflict, "asset_disposed", "the asset is disposed") case errors.Is(err, pa.ErrDisposalOpen): return coded(apperr.KindConflict, "disposal_open", "the asset already has a disposal in progress") case errors.Is(err, pa.ErrDisposalFinal): return coded(apperr.KindConflict, "disposal_final", "a posted disposal is final")
case errors.Is(err, workflow.ErrStaleState): return apperr.New(apperr.KindConflict, "the document is no longer in the state you sent as from; reload it and retry") case errors.Is(err, workflow.ErrNoSuchEdge): return apperr.New(apperr.KindValidation, "to names no transition out of the current state") case errors.As(err, new(*workflow.ReasonError)): // the sole-owner exception's reason (FA-17) return apperr.New(apperr.KindValidation, err.Error()).With("field", "reason") case errors.Is(err, workflow.ErrSoleOwnerReasonRequired): return coded(apperr.KindValidation, "sole_owner_reason_required", "field=reason rule=required: as the organization's only member you may do this yourself, with a reason") case errors.Is(err, workflow.ErrReasonRequired): return apperr.New(apperr.KindValidation, "reason is required for this transition") case errors.As(err, &refused): return coded(apperr.KindForbidden, refused.Refusal.Code, "refused: "+refused.Refusal.Code+": "+refused.Refusal.Detail) case errors.As(err, &unroutable): return apperr.New(apperr.KindValidation, err.Error()) case errors.As(err, &period): return coded(apperr.KindConflict, "period_not_open", fmt.Sprintf("the fiscal period this would post into (%s) is %s, not open", period.PeriodID, period.Status)) case errors.Is(err, pa.ErrNoPoster): return coded(apperr.KindConflict, "no_poster", "no member holds the organization's root seat") case errors.Is(err, fiscal.ErrNoFiscalPeriod): return coded(apperr.KindConflict, "no_fiscal_period", "no open fiscal period covers that date") case errors.As(err, &noRule): return apperr.New(apperr.KindInternal, "this document cannot be posted; contact the operator") } return err}- Step 6:
handlers.go(classes, assets, runs, reports).
package assets
import ( "context" "net/http" "strconv" "time"
"github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/jackc/pgx/v5"
apperr "github.com/mcom/erp/internal/kernel/errors" "github.com/mcom/erp/internal/kernel/money" "github.com/mcom/erp/internal/platform/accounting/analytic" "github.com/mcom/erp/internal/platform/accounting/ledger" pa "github.com/mcom/erp/internal/platform/assets")
// inTx runs fn in one transaction as the calling member; false means the// (mapped) error is recorded and the handler returns.func (h handlers) inTx(c *gin.Context, fn func(ctx context.Context, tx pgx.Tx, who uuid.UUID) error) bool { who, err := mustActor(c.Request.Context()) if err != nil { _ = c.Error(err) return false } err = h.deps.DB.InTx(c.Request.Context(), func(ctx context.Context, tx pgx.Tx) error { return fn(ctx, tx, who) }) if err = ledger.MapError(err); err != nil { _ = c.Error(mapErr(err)) return false } return true}
func wireDate(t time.Time) string { return t.UTC().Format(dateLayout) }
func dateField(field, s string) (time.Time, error) { d, err := parseDate(s) if err != nil { return time.Time{}, apperr.New(apperr.KindValidation, "field="+field+" rule=date: a date YYYY-MM-DD").With("code", "date") } return d, nil}
// --- classes ---
type classDTO struct { ID uuid.UUID `json:"id"` Code string `json:"code"` Names map[string]string `json:"names"` CostAccountID uuid.UUID `json:"cost_account_id"` AccumulatedAccountID uuid.UUID `json:"accumulated_account_id"` DepreciationAccountID uuid.UUID `json:"depreciation_account_id"` DisposalAccountID uuid.UUID `json:"disposal_account_id"` Method string `json:"method"` LifeMonths *int `json:"life_months"` RatePercent *money.Rate `json:"rate_percent"` ResidualPercent money.Rate `json:"residual_percent"` IsActive bool `json:"is_active"`}
func toClassDTO(k pa.Class) classDTO { names := k.Names if names == nil { names = map[string]string{} } return classDTO{ID: k.ID, Code: k.Code, Names: names, CostAccountID: k.CostAccountID, AccumulatedAccountID: k.AccumulatedAccountID, DepreciationAccountID: k.DepreciationAccountID, DisposalAccountID: k.DisposalAccountID, Method: k.Method, LifeMonths: k.LifeMonths, RatePercent: k.RatePercent, ResidualPercent: k.ResidualPercent, IsActive: k.IsActive}}
type classBody struct { Code string `json:"code"` Names map[string]string `json:"names"` CostAccountID uuid.UUID `json:"cost_account_id"` AccumulatedAccountID uuid.UUID `json:"accumulated_account_id"` DepreciationAccountID uuid.UUID `json:"depreciation_account_id"` DisposalAccountID uuid.UUID `json:"disposal_account_id"` Method string `json:"method"` LifeMonths *int `json:"life_months"` RatePercent *money.Rate `json:"rate_percent"` ResidualPercent money.Rate `json:"residual_percent"`}
type classUpdateBody struct { Names map[string]string `json:"names"` IsActive *bool `json:"is_active"`}
func (h handlers) listClasses(c *gin.Context) { out := []classDTO{} if !h.inTx(c, func(ctx context.Context, tx pgx.Tx, _ uuid.UUID) error { ks, err := pa.ListClasses(ctx, tx) for _, k := range ks { out = append(out, toClassDTO(k)) } return err }) { return } c.JSON(http.StatusOK, gin.H{"classes": out})}
func (h handlers) createClass(c *gin.Context) { var b classBody if err := decodeStrict(c, &b); err != nil { _ = c.Error(err) return } var out classDTO if !h.inTx(c, func(ctx context.Context, tx pgx.Tx, _ uuid.UUID) error { k, err := pa.CreateClass(ctx, tx, h.deps.Clock, pa.ClassInput{Code: b.Code, Names: b.Names, CostAccountID: b.CostAccountID, AccumulatedAccountID: b.AccumulatedAccountID, DepreciationAccountID: b.DepreciationAccountID, DisposalAccountID: b.DisposalAccountID, Method: b.Method, LifeMonths: b.LifeMonths, RatePercent: b.RatePercent, ResidualPercent: b.ResidualPercent}) out = toClassDTO(k) return err }) { return } c.JSON(http.StatusCreated, out)}
func (h handlers) updateClass(c *gin.Context) { id, err := parseID(c, "id") var b classUpdateBody if err := firstErr(err, decodeStrict(c, &b)); err != nil { _ = c.Error(err) return } if b.IsActive == nil { _ = c.Error(apperr.New(apperr.KindValidation, "field=is_active rule=required: is_active is required").With("code", "required")) return } var out classDTO if !h.inTx(c, func(ctx context.Context, tx pgx.Tx, _ uuid.UUID) error { k, err := pa.UpdateClass(ctx, tx, h.deps.Clock, id, pa.ClassUpdate{Names: b.Names, IsActive: *b.IsActive}) out = toClassDTO(k) return err }) { return } c.JSON(http.StatusOK, out)}
// --- assets ---
type shareDTO struct { AnalyticAccountID uuid.UUID `json:"analytic_account_id"` Percentage money.Rate `json:"percentage"`}
type assetDTO struct { ID uuid.UUID `json:"id"` Number string `json:"number"` ClassID uuid.UUID `json:"class_id"` Description string `json:"description"` Cost money.Amount `json:"cost"` ResidualValue money.Amount `json:"residual_value"` OpeningAccumulated money.Amount `json:"opening_accumulated"` Accumulated money.Amount `json:"accumulated"` NBV money.Amount `json:"nbv"` InServiceOn string `json:"in_service_on"` DepreciateFrom string `json:"depreciate_from"` Status string `json:"status"` DisposedOn *string `json:"disposed_on"` SourceKind string `json:"source_kind"` SourceID uuid.UUID `json:"source_id"` Shares []shareDTO `json:"shares"` RegisteredAt time.Time `json:"registered_at"`}
func toAssetDTO(a pa.Asset) assetDTO { shares := []shareDTO{} for _, s := range a.Shares { shares = append(shares, shareDTO{AnalyticAccountID: s.AnalyticAccountID, Percentage: s.Percentage}) } var disposed *string if a.DisposedOn != nil { d := wireDate(*a.DisposedOn) disposed = &d } return assetDTO{ID: a.ID, Number: a.Number, ClassID: a.ClassID, Description: a.Description, Cost: a.Cost, ResidualValue: a.ResidualValue, OpeningAccumulated: a.OpeningAccumulated, Accumulated: a.Accumulated, NBV: a.NBV, InServiceOn: wireDate(a.InServiceOn), DepreciateFrom: wireDate(a.DepreciateFrom), Status: a.Status, DisposedOn: disposed, SourceKind: a.SourceKind, SourceID: a.SourceID, Shares: shares, RegisteredAt: a.RegisteredAt.UTC()}}
func (h handlers) listAssets(c *gin.Context) { f := pa.Filter{Status: c.Query("status")} switch f.Status { case "", "all", pa.StatusInService, pa.StatusDisposed: default: _ = c.Error(apperr.New(apperr.KindValidation, "field=status rule=status_known: in_service, disposed or all").With("code", "status_known")) return } if s := c.Query("class_id"); s != "" { id, err := uuid.Parse(s) if err != nil { _ = c.Error(apperr.New(apperr.KindValidation, "class_id must be a UUID")) return } f.ClassID = &id } out := []assetDTO{} cost, accum, nbv := money.Zero(), money.Zero(), money.Zero() if !h.inTx(c, func(ctx context.Context, tx pgx.Tx, _ uuid.UUID) error { list, err := pa.List(ctx, tx, f) for _, a := range list { out = append(out, toAssetDTO(a)) cost, accum, nbv = cost.Add(a.Cost), accum.Add(a.Accumulated), nbv.Add(a.NBV) } return err }) { return } c.JSON(http.StatusOK, gin.H{"assets": out, "totals": gin.H{"cost": cost, "accumulated": accum, "nbv": nbv}})}
type registerBody struct { SourceKind string `json:"source_kind"` SourceID uuid.UUID `json:"source_id"` ClassID uuid.UUID `json:"class_id"` Description string `json:"description"` InServiceOn string `json:"in_service_on"` ResidualValue *money.Amount `json:"residual_value"` Shares []shareDTO `json:"shares"`}
func (h handlers) registerAsset(c *gin.Context) { var b registerBody if err := decodeStrict(c, &b); err != nil { _ = c.Error(err) return } on, err := dateField("in_service_on", b.InServiceOn) if err != nil { _ = c.Error(err) return } var shares []analytic.Share // nil: the source line's if b.Shares != nil { shares = []analytic.Share{} for _, s := range b.Shares { shares = append(shares, analytic.Share{AnalyticAccountID: s.AnalyticAccountID, Percentage: s.Percentage}) } } var out assetDTO if !h.inTx(c, func(ctx context.Context, tx pgx.Tx, who uuid.UUID) error { a, err := pa.Register(ctx, tx, h.deps.Clock, who, pa.RegisterInput{SourceKind: b.SourceKind, SourceID: b.SourceID, ClassID: b.ClassID, Description: b.Description, InServiceOn: on, ResidualValue: b.ResidualValue, Shares: shares}) out = toAssetDTO(a) return err }) { return } c.JSON(http.StatusCreated, out)}
type lineDTO struct { ChargedOn string `json:"charged_on"` Amount money.Amount `json:"amount"` RunID *uuid.UUID `json:"run_id"` DisposalID *uuid.UUID `json:"disposal_id"`}
func (h handlers) getAsset(c *gin.Context) { id, err := parseID(c, "id") if err != nil { _ = c.Error(err) return } var out struct { Asset assetDTO `json:"asset"` Lines []lineDTO `json:"lines"` } out.Lines = []lineDTO{} if !h.inTx(c, func(ctx context.Context, tx pgx.Tx, _ uuid.UUID) error { a, err := pa.Get(ctx, tx, id) if err != nil { return err } out.Asset = toAssetDTO(a) ls, err := pa.Lines(ctx, tx, id) for _, l := range ls { out.Lines = append(out.Lines, lineDTO{ChargedOn: wireDate(l.ChargedOn), Amount: l.Amount, RunID: l.RunID, DisposalID: l.DisposalID}) } return err }) { return } c.JSON(http.StatusOK, out)}
type candidateDTO struct { SourceKind string `json:"source_kind"` SourceID uuid.UUID `json:"source_id"` Date string `json:"date"` Reference string `json:"reference"` Description string `json:"description"` AccountID uuid.UUID `json:"account_id"` Amount money.Amount `json:"amount"`}
func (h handlers) listCandidates(c *gin.Context) { out := []candidateDTO{} if !h.inTx(c, func(ctx context.Context, tx pgx.Tx, _ uuid.UUID) error { cs, err := pa.Candidates(ctx, tx) for _, x := range cs { out = append(out, candidateDTO{SourceKind: x.SourceKind, SourceID: x.SourceID, Date: wireDate(x.Date), Reference: x.Reference, Description: x.Description, AccountID: x.AccountID, Amount: x.Amount}) } return err }) { return } c.JSON(http.StatusOK, gin.H{"candidates": out})}
// --- depreciation runs ---
type runDTO struct { ID uuid.UUID `json:"id"` PeriodID uuid.UUID `json:"period_id"` PeriodStart string `json:"period_start"` PeriodEnd string `json:"period_end"` Status string `json:"status"` JournalEntryID *uuid.UUID `json:"journal_entry_id"` AssetCount int `json:"asset_count"` Total money.Amount `json:"total"` RunBy *uuid.UUID `json:"run_by"` RunAt time.Time `json:"run_at"`}
func toRunDTO(r pa.Run) runDTO { return runDTO{ID: r.ID, PeriodID: r.PeriodID, PeriodStart: wireDate(r.PeriodStart), PeriodEnd: wireDate(r.PeriodEnd), Status: r.Status, JournalEntryID: r.JournalEntryID, AssetCount: r.AssetCount, Total: r.Total, RunBy: r.RunBy, RunAt: r.RunAt.UTC()}}
func (h handlers) listRuns(c *gin.Context) { out := []runDTO{} if !h.inTx(c, func(ctx context.Context, tx pgx.Tx, _ uuid.UUID) error { rs, err := pa.ListRuns(ctx, tx) for _, r := range rs { out = append(out, toRunDTO(r)) } return err }) { return } c.JSON(http.StatusOK, gin.H{"runs": out})}
func (h handlers) runDepreciation(c *gin.Context) { var b struct { PeriodID uuid.UUID `json:"period_id"` } if err := decodeStrict(c, &b); err != nil { _ = c.Error(err) return } var out runDTO if !h.inTx(c, func(ctx context.Context, tx pgx.Tx, who uuid.UUID) error { r, err := pa.RunDepreciation(ctx, tx, h.deps.Clock, b.PeriodID, &who) out = toRunDTO(r) return err }) { return } c.JSON(http.StatusCreated, out)}
// --- reports (Ruling FA-18) ---
func (h handlers) scheduleReport(c *gin.Context) { n := 12 if s := c.Query("months"); s != "" { v, err := strconv.Atoi(s) if err != nil { _ = c.Error(apperr.New(apperr.KindValidation, "field=months rule=months_range: forecast 1 to 60 months").With("code", "months_range")) return } n = v } type rowDTO struct { AssetID uuid.UUID `json:"asset_id"` Number string `json:"number"` Description string `json:"description"` ClassID uuid.UUID `json:"class_id"` Charges []money.Amount `json:"charges"` } out := struct { Months []string `json:"months"` Rows []rowDTO `json:"rows"` Totals []money.Amount `json:"totals"` }{Months: []string{}, Rows: []rowDTO{}} if !h.inTx(c, func(ctx context.Context, tx pgx.Tx, _ uuid.UUID) error { f, err := pa.ScheduleForecast(ctx, tx, h.deps.Clock, n) for _, m := range f.Months { out.Months = append(out.Months, m.Format("2006-01")) } for _, r := range f.Rows { out.Rows = append(out.Rows, rowDTO{AssetID: r.Asset.ID, Number: r.Asset.Number, Description: r.Asset.Description, ClassID: r.Asset.ClassID, Charges: r.Charges}) } out.Totals = f.Totals return err }) { return } c.JSON(http.StatusOK, out)}
type rollForwardDTO struct { ClassID *uuid.UUID `json:"class_id"` ClassCode string `json:"class_code"` CostOpening money.Amount `json:"cost_opening"` Additions money.Amount `json:"additions"` CostDisposals money.Amount `json:"cost_disposals"` CostClosing money.Amount `json:"cost_closing"` AccumOpening money.Amount `json:"accum_opening"` AccumAcquired money.Amount `json:"accum_acquired"` Charge money.Amount `json:"charge"` AccumDisposals money.Amount `json:"accum_disposals"` AccumClosing money.Amount `json:"accum_closing"`}
func toRollForwardDTO(r pa.RollForwardRow) rollForwardDTO { out := rollForwardDTO{ClassCode: r.ClassCode, CostOpening: r.CostOpening, Additions: r.Additions, CostDisposals: r.CostDisposals, CostClosing: r.CostClosing, AccumOpening: r.AccumOpening, AccumAcquired: r.AccumAcquired, Charge: r.Charge, AccumDisposals: r.AccumDisposals, AccumClosing: r.AccumClosing} if r.ClassID != uuid.Nil { id := r.ClassID out.ClassID = &id } return out}
func (h handlers) rollForwardReport(c *gin.Context) { from, err := dateField("from", c.Query("from")) to, err2 := dateField("to", c.Query("to")) if err := firstErr(err, err2); err != nil { _ = c.Error(err) return } out := struct { From string `json:"from"` To string `json:"to"` Rows []rollForwardDTO `json:"rows"` Total rollForwardDTO `json:"total"` }{From: wireDate(from), To: wireDate(to), Rows: []rollForwardDTO{}} if !h.inTx(c, func(ctx context.Context, tx pgx.Tx, _ uuid.UUID) error { rows, total, err := pa.RollForward(ctx, tx, from, to) for _, r := range rows { out.Rows = append(out.Rows, toRollForwardDTO(r)) } out.Total = toRollForwardDTO(total) return err }) { return } c.JSON(http.StatusOK, out)}
func (h handlers) registerVsGLReport(c *gin.Context) { type rowDTO struct { AccountID uuid.UUID `json:"account_id"` Role string `json:"role"` Register money.Amount `json:"register"` Ledger money.Amount `json:"ledger"` Difference money.Amount `json:"difference"` } out := []rowDTO{} if !h.inTx(c, func(ctx context.Context, tx pgx.Tx, _ uuid.UUID) error { rows, err := pa.RegisterVsGL(ctx, tx) for _, r := range rows { out = append(out, rowDTO{AccountID: r.AccountID, Role: r.Role, Register: r.Register, Ledger: r.Ledger, Difference: r.Difference}) } return err }) { return } c.JSON(http.StatusOK, gin.H{"rows": out})}- Step 7:
disposal_handlers.go.
package assets
import ( "context" "net/http" "time"
"github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/jackc/pgx/v5"
apperr "github.com/mcom/erp/internal/kernel/errors" "github.com/mcom/erp/internal/kernel/money" pa "github.com/mcom/erp/internal/platform/assets" "github.com/mcom/erp/internal/platform/workflow")
type disposalDTO struct { ID uuid.UUID `json:"id"` AssetID uuid.UUID `json:"asset_id"` AssetNumber string `json:"asset_number"` Kind string `json:"kind"` DisposedOn string `json:"disposed_on"` Reason string `json:"reason"` InstanceID uuid.UUID `json:"instance_id"` PreparedBy uuid.UUID `json:"prepared_by"` State string `json:"state"` Semantic string `json:"semantic"` CatchUp *money.Amount `json:"catch_up"` Accumulated *money.Amount `json:"accumulated"` NBV *money.Amount `json:"nbv"` CreatedAt time.Time `json:"created_at"`}
func toDisposalDTO(d pa.Disposal) disposalDTO { return disposalDTO{ID: d.ID, AssetID: d.AssetID, AssetNumber: d.AssetNumber, Kind: d.Kind, DisposedOn: wireDate(d.DisposedOn), Reason: d.Reason, InstanceID: d.InstanceID, PreparedBy: d.PreparedBy, State: d.State, Semantic: string(d.Semantic), CatchUp: d.CatchUp, Accumulated: d.Accumulated, NBV: d.NBV, CreatedAt: d.CreatedAt.UTC()}}
// decisionDTO is the shape web/src/components/workflow/DecisionTimeline reads// (taxreturns's, copied).type decisionDTO struct { From string `json:"from"` To string `json:"to"` ActionKey string `json:"action_key"` ActorMemberID uuid.UUID `json:"actor_member_id"` Reason string `json:"reason"` DecidedAt time.Time `json:"decided_at"`}
type disposalViewDTO struct { Disposal disposalDTO `json:"disposal"` AvailableTransitions []workflow.Available `json:"available_transitions"` Decisions []decisionDTO `json:"decisions"`}
func (h handlers) disposalView(ctx context.Context, tx pgx.Tx, d pa.Disposal, who uuid.UUID) (disposalViewDTO, error) { out := disposalViewDTO{Disposal: toDisposalDTO(d), Decisions: []decisionDTO{}} var err error if out.AvailableTransitions, err = pa.Available(ctx, tx, h.deps.Clock, d.InstanceID, who); err != nil { return out, err } ds, err := workflow.Decisions(ctx, tx, d.InstanceID) for _, x := range ds { out.Decisions = append(out.Decisions, decisionDTO{From: x.From, To: x.To, ActionKey: x.ActionKey, ActorMemberID: x.Actor, Reason: x.Reason, DecidedAt: x.DecidedAt.UTC()}) } return out, err}
func (h handlers) disposalTx(c *gin.Context, status int, fn func(ctx context.Context, tx pgx.Tx, who uuid.UUID) (pa.Disposal, error)) { var out disposalViewDTO if !h.inTx(c, func(ctx context.Context, tx pgx.Tx, who uuid.UUID) error { d, err := fn(ctx, tx, who) if err != nil { return err } out, err = h.disposalView(ctx, tx, d, who) return err }) { return } c.JSON(status, out)}
func (h handlers) createDisposal(c *gin.Context) { var b struct { AssetID uuid.UUID `json:"asset_id"` Kind string `json:"kind"` DisposedOn string `json:"disposed_on"` Reason string `json:"reason"` } if err := decodeStrict(c, &b); err != nil { _ = c.Error(err) return } on, err := dateField("disposed_on", b.DisposedOn) if err != nil { _ = c.Error(err) return } h.disposalTx(c, http.StatusCreated, func(ctx context.Context, tx pgx.Tx, who uuid.UUID) (pa.Disposal, error) { return pa.CreateDisposal(ctx, tx, h.deps.Clock, who, pa.DisposalInput{AssetID: b.AssetID, Kind: b.Kind, DisposedOn: on, Reason: b.Reason}) })}
func (h handlers) getDisposal(c *gin.Context) { id, err := parseID(c, "id") if err != nil { _ = c.Error(err) return } h.disposalTx(c, http.StatusOK, func(ctx context.Context, tx pgx.Tx, _ uuid.UUID) (pa.Disposal, error) { return pa.GetDisposal(ctx, tx, id) })}
func (h handlers) transitionDisposal(c *gin.Context) { id, err := parseID(c, "id") var b struct { From string `json:"from"` To string `json:"to"` Reason string `json:"reason"` } if err := firstErr(err, decodeStrict(c, &b)); err != nil { _ = c.Error(err) return } h.disposalTx(c, http.StatusOK, func(ctx context.Context, tx pgx.Tx, who uuid.UUID) (pa.Disposal, error) { return pa.TransitionDisposal(ctx, tx, h.deps.Clock, pa.TransitionInput{ID: id, From: b.From, To: b.To, Actor: who, Reason: b.Reason}) })}
func (h handlers) listDisposals(c *gin.Context) { var assetID *uuid.UUID if s := c.Query("asset_id"); s != "" { id, err := uuid.Parse(s) if err != nil { _ = c.Error(apperr.New(apperr.KindValidation, "asset_id must be a UUID")) return } assetID = &id } out := []disposalDTO{} if !h.inTx(c, func(ctx context.Context, tx pgx.Tx, _ uuid.UUID) error { ds, err := pa.ListDisposals(ctx, tx, assetID) for _, d := range ds { out = append(out, toDisposalDTO(d)) } return err }) { return } c.JSON(http.StatusOK, gin.H{"disposals": out})}- Step 8: OpenAPI. In
api/openapi.yaml:
(a) Under components: → responses:, directly after the responses: line, add:
AssetsForbidden: description: The caller does not hold the permission this operation requires, or the workflow refuses them this transition. content: application/problem+json: schema: { $ref: '#/components/schemas/Problem' } AssetsNotFound: description: No such record in this organization. content: application/problem+json: schema: { $ref: '#/components/schemas/Problem' } AssetsConflict: description: The same request would succeed against the records in another state (code names which). content: application/problem+json: schema: { $ref: '#/components/schemas/Problem' } AssetsInvalid: description: The request is well-formed but refused (code names the rule). content: application/problem+json: schema: { $ref: '#/components/schemas/Problem' }(b) Directly above the components: line, add the paths:
# --- fixed assets (Phase 4 O4) --- /assets/classes: get: operationId: listAssetClasses tags: [Assets] summary: Asset classes, by code description: Requires assets.asset.read. responses: '200': { description: The classes, content: { application/json: { schema: { type: object, required: [classes], properties: { classes: { type: array, items: { $ref: '#/components/schemas/AssetClass' } } } } } } } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/AssetsForbidden' } post: operationId: createAssetClass tags: [Assets] summary: Create an asset class description: > Four existing accounts — cost and accumulated depreciation (asset), depreciation (expense), disposal gain or loss (income or expense), each active, postable and with no control kind — a method with its term (straight_line: life_months 1–1200; reducing_balance: rate_percent above 0 and at most 100) and a residual percentage under 100. Fixed at creation except its names and active flag. 422 codes account_type, accounts_distinct, method_terms, method_known, residual_range, names_required, names_locale, names_length, code_shape; 409 class_code_taken. Requires assets.asset.manage. requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/AssetClassInput' } } } } responses: '201': { description: The class, content: { application/json: { schema: { $ref: '#/components/schemas/AssetClass' } } } } '400': { $ref: '#/components/responses/BadRequestBody' } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/AssetsForbidden' } '409': { $ref: '#/components/responses/AssetsConflict' } '422': { $ref: '#/components/responses/AssetsInvalid' } /assets/classes/{id}: put: operationId: updateAssetClass tags: [Assets] summary: Rename, retire or restore an asset class description: Its accounts and terms never change. Requires assets.asset.manage. parameters: - { name: id, in: path, required: true, schema: { type: string, format: uuid } } requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/AssetClassUpdate' } } } } responses: '200': { description: The class, content: { application/json: { schema: { $ref: '#/components/schemas/AssetClass' } } } } '400': { $ref: '#/components/responses/BadRequestBody' } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/AssetsForbidden' } '404': { $ref: '#/components/responses/AssetsNotFound' } '422': { $ref: '#/components/responses/AssetsInvalid' } /assets: get: operationId: listAssets tags: [Assets] summary: The fixed asset register, by number, with totals description: status in_service (default when the web asks), disposed or all (the default here). Requires assets.asset.read. parameters: - { name: status, in: query, required: false, schema: { type: string, enum: [in_service, disposed, all] } } - { name: class_id, in: query, required: false, schema: { type: string, format: uuid } } responses: '200': { description: The register, content: { application/json: { schema: { $ref: '#/components/schemas/AssetList' } } } } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/AssetsForbidden' } '422': { $ref: '#/components/responses/AssetsInvalid' } post: operationId: registerAsset tags: [Assets] summary: Register an asset from a posted cost description: > The source is a posted purchase invoice line or a manual journal debit line on an active class's cost account, not yet registered; its amount is the cost. Registration never posts. 422 source_not_eligible, class_active, description (required), in_service_on (not_future), residual_value (within_cost), shares (shares_valid); 409 source_registered. Requires assets.asset.manage. requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/AssetRegisterInput' } } } } responses: '201': { description: The asset, content: { application/json: { schema: { $ref: '#/components/schemas/Asset' } } } } '400': { $ref: '#/components/responses/BadRequestBody' } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/AssetsForbidden' } '409': { $ref: '#/components/responses/AssetsConflict' } '422': { $ref: '#/components/responses/AssetsInvalid' } /assets/candidates: get: operationId: listAssetCandidates tags: [Assets] summary: Posted costs on class cost accounts not yet registered description: Oldest first, at most 500. Requires assets.asset.read. responses: '200': { description: The candidates, content: { application/json: { schema: { type: object, required: [candidates], properties: { candidates: { type: array, items: { $ref: '#/components/schemas/AssetCandidate' } } } } } } } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/AssetsForbidden' } /assets/{id}: get: operationId: getAsset tags: [Assets] summary: An asset with its depreciation lines description: Requires assets.asset.read. parameters: - { name: id, in: path, required: true, schema: { type: string, format: uuid } } responses: '200': { description: The asset, content: { application/json: { schema: { $ref: '#/components/schemas/AssetView' } } } } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/AssetsForbidden' } '404': { $ref: '#/components/responses/AssetsNotFound' } /assets/depreciation-runs: get: operationId: listDepreciationRuns tags: [Assets] summary: Depreciation runs, newest period first description: A skipped run is the flag of a period that was closed when its run came due. Requires assets.asset.read. responses: '200': { description: The runs, content: { application/json: { schema: { type: object, required: [runs], properties: { runs: { type: array, items: { $ref: '#/components/schemas/DepreciationRun' } } } } } } } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/AssetsForbidden' } post: operationId: runDepreciation tags: [Assets] summary: Run a period's depreciation description: > One entry for the period, dated its last day, two lines per asset; assets registered late or a skipped month are caught up. A closed period records a skipped run and posts nothing. 409 run_exists (a second run for a period posts nothing), run_out_of_order; 422 period_not_started. Requires assets.depreciation.run. requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/DepreciationRunInput' } } } } responses: '201': { description: The run, content: { application/json: { schema: { $ref: '#/components/schemas/DepreciationRun' } } } } '400': { $ref: '#/components/responses/BadRequestBody' } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/AssetsForbidden' } '404': { $ref: '#/components/responses/AssetsNotFound' } '409': { $ref: '#/components/responses/AssetsConflict' } '422': { $ref: '#/components/responses/AssetsInvalid' } /assets/disposals: get: operationId: listAssetDisposals tags: [Assets] summary: Disposals and write-offs, newest first description: Requires assets.asset.read. parameters: - { name: asset_id, in: query, required: false, schema: { type: string, format: uuid } } responses: '200': { description: The disposals, content: { application/json: { schema: { type: object, required: [disposals], properties: { disposals: { type: array, items: { $ref: '#/components/schemas/AssetDisposal' } } } } } } } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/AssetsForbidden' } '422': { $ref: '#/components/responses/AssetsInvalid' } post: operationId: createAssetDisposal tags: [Assets] summary: Prepare a disposal or write-off description: > Depreciates the asset through the disposal month when approved, then posts Dr accumulated depreciation, Cr cost and the net book value to the class's disposal account. Proceeds post separately through a sales invoice or receipt. 409 asset_disposed, disposal_open; 422 disposal_month_depreciated, kind, reason, disposed_on (within_service). Requires assets.disposal.create. requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/AssetDisposalInput' } } } } responses: '201': { description: The disposal, content: { application/json: { schema: { $ref: '#/components/schemas/AssetDisposalView' } } } } '400': { $ref: '#/components/responses/BadRequestBody' } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/AssetsForbidden' } '404': { $ref: '#/components/responses/AssetsNotFound' } '409': { $ref: '#/components/responses/AssetsConflict' } '422': { $ref: '#/components/responses/AssetsInvalid' } /assets/disposals/{id}: get: operationId: getAssetDisposal tags: [Assets] summary: A disposal with its transitions and decisions description: Requires assets.asset.read. parameters: - { name: id, in: path, required: true, schema: { type: string, format: uuid } } responses: '200': { description: The disposal, content: { application/json: { schema: { $ref: '#/components/schemas/AssetDisposalView' } } } } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/AssetsForbidden' } '404': { $ref: '#/components/responses/AssetsNotFound' } /assets/disposals/{id}/transitions: post: operationId: transitionAssetDisposal tags: [Assets] summary: Move a disposal along its workflow description: > from must be the state the caller last read. The preparer cannot approve their own disposal (403). 409 disposal_final. Requires assets.disposal.transition; authority to decide still comes from the workflow's own grants. parameters: - { name: id, in: path, required: true, schema: { type: string, format: uuid } } requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/DocumentTransition' } } } } responses: '200': { description: The disposal in its new state, content: { application/json: { schema: { $ref: '#/components/schemas/AssetDisposalView' } } } } '400': { $ref: '#/components/responses/BadRequestBody' } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/AssetsForbidden' } '404': { $ref: '#/components/responses/AssetsNotFound' } '409': { $ref: '#/components/responses/AssetsConflict' } '422': { $ref: '#/components/responses/AssetsInvalid' } /assets/reports/schedule: get: operationId: getAssetScheduleReport tags: [Assets] summary: Depreciation schedule forecast description: 1–60 months (default 12) from the month after the latest run. 422 months_range. Requires assets.asset.read. parameters: - { name: months, in: query, required: false, schema: { type: integer, minimum: 1, maximum: 60 } } responses: '200': { description: The forecast, content: { application/json: { schema: { $ref: '#/components/schemas/AssetScheduleReport' } } } } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/AssetsForbidden' } '422': { $ref: '#/components/responses/AssetsInvalid' } /assets/reports/roll-forward: get: operationId: getAssetRollForward tags: [Assets] summary: Cost and accumulated depreciation roll-forward by class description: 422 date, range. Requires assets.asset.read. parameters: - { name: from, in: query, required: true, schema: { $ref: '#/components/schemas/Date' } } - { name: to, in: query, required: true, schema: { $ref: '#/components/schemas/Date' } } responses: '200': { description: The roll-forward, content: { application/json: { schema: { $ref: '#/components/schemas/AssetRollForwardReport' } } } } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/AssetsForbidden' } '422': { $ref: '#/components/responses/AssetsInvalid' } /assets/reports/register-vs-gl: get: operationId: getAssetRegisterVsGL tags: [Assets] summary: The register against the class accounts' ledger balances description: The asset_register_gl integrity check's figures. Requires assets.asset.read. responses: '200': { description: The comparison, content: { application/json: { schema: { $ref: '#/components/schemas/AssetRegisterVsGL' } } } } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/AssetsForbidden' }(c) At the end of the file (the end of components.schemas), add:
AssetClassNames: type: object description: Locale to name. en and ms are required, zh is optional; each at most 100 characters. additionalProperties: { type: string, maxLength: 100 } AssetClass: type: object required: [id, code, names, cost_account_id, accumulated_account_id, depreciation_account_id, disposal_account_id, method, life_months, rate_percent, residual_percent, is_active] properties: id: { type: string, format: uuid } code: { type: string } names: { $ref: '#/components/schemas/AssetClassNames' } cost_account_id: { type: string, format: uuid } accumulated_account_id: { type: string, format: uuid } depreciation_account_id: { type: string, format: uuid } disposal_account_id: { type: string, format: uuid } method: { type: string, enum: [straight_line, reducing_balance] } life_months: { type: integer, nullable: true } rate_percent: { allOf: [{ $ref: '#/components/schemas/Rate' }], nullable: true } residual_percent: { $ref: '#/components/schemas/Rate' } is_active: { type: boolean } AssetClassInput: type: object additionalProperties: false required: [code, names, cost_account_id, accumulated_account_id, depreciation_account_id, disposal_account_id, method, residual_percent] properties: code: { type: string, pattern: '^[A-Za-z0-9][A-Za-z0-9_-]{0,19}$' } names: { $ref: '#/components/schemas/AssetClassNames' } cost_account_id: { type: string, format: uuid } accumulated_account_id: { type: string, format: uuid } depreciation_account_id: { type: string, format: uuid } disposal_account_id: { type: string, format: uuid } method: { type: string, enum: [straight_line, reducing_balance] } life_months: { type: integer, nullable: true } rate_percent: { allOf: [{ $ref: '#/components/schemas/Rate' }], nullable: true } residual_percent: { $ref: '#/components/schemas/Rate' } AssetClassUpdate: type: object additionalProperties: false required: [names, is_active] properties: names: { $ref: '#/components/schemas/AssetClassNames' } is_active: { type: boolean } AssetShare: type: object additionalProperties: false required: [analytic_account_id, percentage] properties: analytic_account_id: { type: string, format: uuid } percentage: { $ref: '#/components/schemas/Rate' } Asset: type: object required: [id, number, class_id, description, cost, residual_value, opening_accumulated, accumulated, nbv, in_service_on, depreciate_from, status, disposed_on, source_kind, source_id, shares, registered_at] properties: id: { type: string, format: uuid } number: { type: string } class_id: { type: string, format: uuid } description: { type: string } cost: { $ref: '#/components/schemas/Money' } residual_value: { $ref: '#/components/schemas/Money' } opening_accumulated: { $ref: '#/components/schemas/Money' } accumulated: { $ref: '#/components/schemas/Money' } nbv: { $ref: '#/components/schemas/Money' } in_service_on: { $ref: '#/components/schemas/Date' } depreciate_from: { $ref: '#/components/schemas/Date' } status: { type: string, enum: [in_service, disposed] } disposed_on: { allOf: [{ $ref: '#/components/schemas/Date' }], nullable: true } source_kind: { type: string, enum: [purchase_invoice_line, journal_line, opening_import] } source_id: { type: string, format: uuid } shares: { type: array, items: { $ref: '#/components/schemas/AssetShare' } } registered_at: { type: string, format: date-time } AssetList: type: object required: [assets, totals] properties: assets: { type: array, items: { $ref: '#/components/schemas/Asset' } } totals: type: object required: [cost, accumulated, nbv] properties: cost: { $ref: '#/components/schemas/Money' } accumulated: { $ref: '#/components/schemas/Money' } nbv: { $ref: '#/components/schemas/Money' } AssetRegisterInput: type: object additionalProperties: false required: [source_kind, source_id, class_id, description, in_service_on] properties: source_kind: { type: string, enum: [purchase_invoice_line, journal_line] } source_id: { type: string, format: uuid } class_id: { type: string, format: uuid } description: { type: string, maxLength: 500 } in_service_on: { $ref: '#/components/schemas/Date' } residual_value: { allOf: [{ $ref: '#/components/schemas/Money' }], nullable: true } shares: { type: array, items: { $ref: '#/components/schemas/AssetShare' }, description: Omitted, the source line's shares. } AssetLine: type: object required: [charged_on, amount, run_id, disposal_id] properties: charged_on: { $ref: '#/components/schemas/Date' } amount: { $ref: '#/components/schemas/Money' } run_id: { type: string, format: uuid, nullable: true } disposal_id: { type: string, format: uuid, nullable: true } AssetView: type: object required: [asset, lines] properties: asset: { $ref: '#/components/schemas/Asset' } lines: { type: array, items: { $ref: '#/components/schemas/AssetLine' } } AssetCandidate: type: object required: [source_kind, source_id, date, reference, description, account_id, amount] properties: source_kind: { type: string, enum: [purchase_invoice_line, journal_line] } source_id: { type: string, format: uuid } date: { $ref: '#/components/schemas/Date' } reference: { type: string } description: { type: string } account_id: { type: string, format: uuid } amount: { $ref: '#/components/schemas/Money' } DepreciationRun: type: object required: [id, period_id, period_start, period_end, status, journal_entry_id, asset_count, total, run_by, run_at] properties: id: { type: string, format: uuid } period_id: { type: string, format: uuid } period_start: { $ref: '#/components/schemas/Date' } period_end: { $ref: '#/components/schemas/Date' } status: { type: string, enum: [posted, empty, skipped] } journal_entry_id: { type: string, format: uuid, nullable: true } asset_count: { type: integer } total: { $ref: '#/components/schemas/Money' } run_by: { type: string, format: uuid, nullable: true, description: Null for the scheduled job. } run_at: { type: string, format: date-time } DepreciationRunInput: type: object additionalProperties: false required: [period_id] properties: period_id: { type: string, format: uuid } AssetDisposal: type: object required: [id, asset_id, asset_number, kind, disposed_on, reason, instance_id, prepared_by, state, semantic, catch_up, accumulated, nbv, created_at] properties: id: { type: string, format: uuid } asset_id: { type: string, format: uuid } asset_number: { type: string } kind: { type: string, enum: [sale, write_off] } disposed_on: { $ref: '#/components/schemas/Date' } reason: { type: string } instance_id: { type: string, format: uuid } prepared_by: { type: string, format: uuid } state: { type: string } semantic: { $ref: '#/components/schemas/WorkflowSemantic' } catch_up: { allOf: [{ $ref: '#/components/schemas/Money' }], nullable: true } accumulated: { allOf: [{ $ref: '#/components/schemas/Money' }], nullable: true } nbv: { allOf: [{ $ref: '#/components/schemas/Money' }], nullable: true } created_at: { type: string, format: date-time } AssetDisposalInput: type: object additionalProperties: false required: [asset_id, kind, disposed_on, reason] properties: asset_id: { type: string, format: uuid } kind: { type: string, enum: [sale, write_off] } disposed_on: { $ref: '#/components/schemas/Date' } reason: { type: string, maxLength: 500 } AssetDisposalView: type: object required: [disposal, available_transitions, decisions] properties: disposal: { $ref: '#/components/schemas/AssetDisposal' } available_transitions: { type: array, items: { $ref: '#/components/schemas/WorkflowAvailableTransition' } } decisions: { type: array, items: { $ref: '#/components/schemas/DocumentDecision' } } AssetScheduleReport: type: object required: [months, rows, totals] properties: months: { type: array, items: { type: string, description: YYYY-MM } } rows: type: array items: type: object required: [asset_id, number, description, class_id, charges] properties: asset_id: { type: string, format: uuid } number: { type: string } description: { type: string } class_id: { type: string, format: uuid } charges: { type: array, items: { $ref: '#/components/schemas/Money' } } totals: { type: array, items: { $ref: '#/components/schemas/Money' } } AssetRollForwardRow: type: object required: [class_id, class_code, cost_opening, additions, cost_disposals, cost_closing, accum_opening, accum_acquired, charge, accum_disposals, accum_closing] properties: class_id: { type: string, format: uuid, nullable: true, description: Null on the total row. } class_code: { type: string } cost_opening: { $ref: '#/components/schemas/Money' } additions: { $ref: '#/components/schemas/Money' } cost_disposals: { $ref: '#/components/schemas/Money' } cost_closing: { $ref: '#/components/schemas/Money' } accum_opening: { $ref: '#/components/schemas/Money' } accum_acquired: { $ref: '#/components/schemas/Money' } charge: { $ref: '#/components/schemas/Money' } accum_disposals: { $ref: '#/components/schemas/Money' } accum_closing: { $ref: '#/components/schemas/Money' } AssetRollForwardReport: type: object required: [from, to, rows, total] properties: from: { $ref: '#/components/schemas/Date' } to: { $ref: '#/components/schemas/Date' } rows: { type: array, items: { $ref: '#/components/schemas/AssetRollForwardRow' } } total: { $ref: '#/components/schemas/AssetRollForwardRow' } AssetRegisterVsGL: type: object required: [rows] properties: rows: type: array items: type: object required: [account_id, role, register, ledger, difference] properties: account_id: { type: string, format: uuid } role: { type: string, enum: [cost, accumulated] } register: { $ref: '#/components/schemas/Money' } ledger: { $ref: '#/components/schemas/Money' } difference: { $ref: '#/components/schemas/Money' }(d) Disposal entries carry journal semantic effect. If O2 Task 1 has not already done so, replace each semantic: { type: string, enum: [accrual, payout, reversal, manual, year_end_close] } (the two journal-entry schemas) with semantic: { type: string, enum: [accrual, payout, reversal, manual, year_end_close, effect] }.
Run make generate.
- Step 9: Wiring.
internal/app/app.go: import "github.com/mcom/erp/internal/modules/assets"; Deps gains Assets assets.Deps; Router gains assets.Register(optional("assets"), deps.Assets) after taxreturns.Register(…).
internal/app/routes_test.go composedModules gains:
"assets": {assets.PermissionAssetRead, assets.PermissionAssetManage, assets.PermissionDepreciationRun, assets.PermissionDisposalCreate, assets.PermissionDisposalTransition},(and the import).
cmd/api/main.go: import the module and add Assets: assets.Deps{DB: database, Clock: clock.System()}, to app.Deps next to TaxReturns.
cmd/worker/main.go: add _ "github.com/mcom/erp/internal/modules/assets" to the blank imports (with the comment // the disposal's effect posting rule and the asset permissions), import "github.com/mcom/erp/internal/platform/assets", and add assets.DepreciationPeriodic() to PeriodicJobs after opsjobs.DispatchPeriodic().
cmd/worker/exports.go startExportWorker: add river.AddWorker(workers, &assets.DepreciationWorker{DB: database, Clock: clock.System(), Logger: cfg.Logger}) after the opsjobs.ReconcileWorker line (import "github.com/mcom/erp/internal/platform/assets").
-
Step 10: Run.
go test ./internal/modules/assets/... ./internal/platform/assets/... ./internal/platform/moduleinstall/... ./internal/app/... ./cmd/... ./internal/gen/... ./internal/kernel/... -count=1→ PASS.TestTheOpenAPIDocumentSaysWhatTheRouterEnforces,TestEveryRouteIsAuthorized,TestEveryModuleContributesRoutesAndPermissionsandTestEverySeededGraphPassesTheValidatorcover the wiring. Verify the grants on a migrated dev database:SELECT r.name, count(*) FROM role_permission rp JOIN role r ON r.org_id = rp.org_id AND r.id = rp.role_id WHERE rp.permission_key LIKE 'assets.%' GROUP BY r.name;→ Owner and Finance with 5 each per organization. -
Step 11: Sabotage. (a) Register
POST /assets/disposals/:id/transitionsunderPermissionAssetRead; runTestEveryRouteRequiresItsExactPermission; read the red; restore. (b) Removeassets.Register(optional("assets"), …)fromapp.go; rungo test ./internal/app/ -run TestEveryModuleContributesRoutesAndPermissions; read the red; restore. (c) Remove the blank import fromcmd/worker/main.go; runTestWorkerLinksTheAssetsModule; read the red; restore. Record them. -
Step 12: Commit.
git add -A && git commit -m "O4 Task 9: fixed assets HTTP module, OpenAPI and wiring" -m "<sabotage messages>" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
Task 10: Web — register, classes, registration and the asset drawer
Section titled “Task 10: Web — register, classes, registration and the asset drawer”Files:
- Create:
web/src/features/assets/{api.ts,routes.tsx,AssetsPage.tsx,RegisterDialog.tsx,AssetDrawer.tsx,DisposalDialog.tsx,ClassesPage.tsx,ClassDialog.tsx} - Create:
web/src/features/assets/__tests__/{AssetsPage.test.tsx,ClassesPage.test.tsx} - Create:
web/src/lib/i18n/locales/{en,ms,zh}/assets.json - Modify:
web/src/app/routes.ts,web/src/app/shell/nav.ts,web/src/lib/i18n/locales/{en,ms,zh}/common.json(refusals)
Interfaces:
-
Consumes: Task 9’s generated
Schemas['AssetClass' | 'AssetClassInput' | 'AssetClassUpdate' | 'Asset' | 'AssetCandidate' | 'AssetRegisterInput' | 'DepreciationRun' | 'AssetDisposal' | 'AssetDisposalInput' | 'DocumentTransition']and paths;useAccounts/accountOptions(features/accounting/queries),useMe,useCan,RequirePermission,pickLabel,useLocale,formatDate,todayISO,formatMoney,problemMessage, UI kit. -
Produces (used by Task 11):
api.tshooksuseAssetClasses,useCreateClass,useUpdateClass(id),useAssets(status),useAsset(id),useCandidates(enabled),useRegisterAsset,useRuns,useRunDepreciation,useDisposals,useDisposal(id),useCreateDisposal,useDisposalTransition(id),useScheduleReport(months),useRollForward(from, to),useRegisterVsGL; typesAssetClass,Asset,AssetCandidate,DepreciationRun,AssetDisposal,AssetRollForwardRow;routes.tsxexportingroutes(Task 11 appends to it); theassetsi18n namespace. -
Step 1: Strings.
web/src/lib/i18n/locales/en/assets.json:
{ "nav": { "register": "Fixed assets", "classes": "Asset classes", "depreciation": "Depreciation", "disposals": "Asset disposals", "reports": "Asset reports" }, "page": { "register": "Fixed asset register", "registerSubtitle": "Assets registered from posted purchases and journals. Registering never posts: the cost is already in the ledger.", "classes": "Asset classes", "classesSubtitle": "Each class names its four accounts and how it depreciates. Create the depreciation and disposal accounts in the chart first.", "depreciation": "Depreciation runs", "depreciationSubtitle": "One run a month posts every asset's depreciation. It runs by itself early each month; you can also run a month here.", "disposals": "Disposals and write-offs", "disposalsSubtitle": "An approved disposal takes the asset out of the books. Record any sale proceeds on a sales invoice or receipt.", "reports": "Fixed asset reports" }, "action": { "register": "Register asset", "newClass": "New class", "run": "Run depreciation", "dispose": "Dispose or write off" }, "status": { "in_service": "In service", "disposed": "Disposed", "all": "All" }, "asset": { "number": "Number", "description": "Description", "class": "Class", "inServiceOn": "In service", "cost": "Cost", "accumulated": "Accumulated depreciation", "nbv": "Net book value", "residual": "Residual value", "residualHint": "Leave blank to use the class's residual percentage.", "status": "Status", "source": "Source", "depreciateFrom": "Depreciated from", "lines": "Depreciation charged", "chargedOn": "Date", "amount": "Amount", "totals": "Totals", "noLines": "Nothing charged yet." }, "source": { "purchase_invoice_line": "Purchase invoice", "journal_line": "Journal entry", "opening_import": "Opening import" }, "register": { "title": "Register an asset", "candidate": "Posted cost", "pickCandidate": "Choose a posted cost", "noCandidates": "No posted cost on a class's cost account is waiting to be registered.", "done": "{{number}} registered" }, "class": { "code": "Code", "nameEn": "Name (English)", "nameMs": "Name (Malay)", "nameZh": "Name (Chinese)", "cost": "Cost account", "accumulated": "Accumulated depreciation account", "depreciation": "Depreciation expense account", "disposal": "Disposal gain or loss account", "method": "Method", "life": "Useful life (months)", "rate": "Annual rate (%)", "residual": "Residual value (%)", "active": "Active", "retired": "Retired", "newTitle": "New asset class", "editTitle": "Edit {{code}}", "fixedHint": "A class's accounts and terms cannot change once created. Retire it and create another instead.", "saved": "{{code}} saved" }, "method": { "straight_line": "Straight line", "reducing_balance": "Reducing balance" }, "run": { "period": "Month", "status": "Result", "assets": "Assets", "total": "Depreciation", "runAt": "Run at", "scheduled": "Scheduled", "title": "Run depreciation", "done": "Depreciation for {{period}} run" }, "runStatus": { "posted": "Posted", "empty": "Nothing due", "skipped": "Skipped: period closed" }, "disposal": { "asset": "Asset", "kind": "Kind", "date": "Disposal date", "reason": "Reason", "state": "Status", "catchUp": "Depreciation to the disposal month", "accumulated": "Accumulated depreciation cleared", "nbv": "Net book value written off", "title": "Dispose or write off", "detailTitle": "Disposal", "prepared": "Disposal prepared. Submit it from Asset disposals." }, "kind": { "sale": "Sale", "write_off": "Write-off" }, "report": { "schedule": "Depreciation schedule", "rollForward": "Roll-forward", "registerVsGl": "Register vs ledger", "months": "Months", "from": "From", "to": "To", "total": "Total", "costOpening": "Cost b/f", "additions": "Additions", "costDisposals": "Disposals", "costClosing": "Cost c/f", "accumOpening": "Depreciation b/f", "accumAcquired": "Brought in", "charge": "Charge", "accumDisposals": "On disposals", "accumClosing": "Depreciation c/f", "account": "Account", "role": "Holds", "register": "Register", "ledger": "Ledger", "difference": "Difference", "agree": "The register agrees with the ledger.", "disagree": "The register and the ledger differ. Register every posted cost, and check for reversed sources." }, "role": { "cost": "Cost", "accumulated": "Accumulated depreciation" }, "empty": { "assets": "No assets yet", "classes": "No asset classes yet", "runs": "No depreciation runs yet", "disposals": "No disposals yet" }}web/src/lib/i18n/locales/ms/assets.json:
{ "nav": { "register": "Aset tetap", "classes": "Kelas aset", "depreciation": "Susut nilai", "disposals": "Pelupusan aset", "reports": "Laporan aset" }, "page": { "register": "Daftar aset tetap", "registerSubtitle": "Aset yang didaftarkan daripada belian dan jurnal yang telah dipos. Pendaftaran tidak mengepos apa-apa: kosnya sudah ada dalam lejar.", "classes": "Kelas aset", "classesSubtitle": "Setiap kelas menamakan empat akaunnya dan cara ia disusut nilai. Cipta akaun susut nilai dan pelupusan dalam carta akaun dahulu.", "depreciation": "Larian susut nilai", "depreciationSubtitle": "Satu larian sebulan mengepos susut nilai setiap aset. Ia berjalan sendiri pada awal setiap bulan; anda juga boleh menjalankan satu bulan di sini.", "disposals": "Pelupusan dan hapus kira", "disposalsSubtitle": "Pelupusan yang diluluskan mengeluarkan aset daripada buku. Rekodkan hasil jualan pada invois jualan atau resit.", "reports": "Laporan aset tetap" }, "action": { "register": "Daftar aset", "newClass": "Kelas baharu", "run": "Jalankan susut nilai", "dispose": "Lupus atau hapus kira" }, "status": { "in_service": "Dalam perkhidmatan", "disposed": "Dilupuskan", "all": "Semua" }, "asset": { "number": "Nombor", "description": "Keterangan", "class": "Kelas", "inServiceOn": "Mula digunakan", "cost": "Kos", "accumulated": "Susut nilai terkumpul", "nbv": "Nilai buku bersih", "residual": "Nilai sisa", "residualHint": "Biarkan kosong untuk menggunakan peratusan nilai sisa kelas.", "status": "Status", "source": "Sumber", "depreciateFrom": "Disusut nilai dari", "lines": "Susut nilai dicaj", "chargedOn": "Tarikh", "amount": "Amaun", "totals": "Jumlah", "noLines": "Belum ada caj." }, "source": { "purchase_invoice_line": "Invois belian", "journal_line": "Catatan jurnal", "opening_import": "Import pembukaan" }, "register": { "title": "Daftar aset", "candidate": "Kos yang dipos", "pickCandidate": "Pilih kos yang dipos", "noCandidates": "Tiada kos yang dipos pada akaun kos kelas menunggu untuk didaftarkan.", "done": "{{number}} didaftarkan" }, "class": { "code": "Kod", "nameEn": "Nama (Inggeris)", "nameMs": "Nama (Melayu)", "nameZh": "Nama (Cina)", "cost": "Akaun kos", "accumulated": "Akaun susut nilai terkumpul", "depreciation": "Akaun perbelanjaan susut nilai", "disposal": "Akaun untung atau rugi pelupusan", "method": "Kaedah", "life": "Hayat berguna (bulan)", "rate": "Kadar tahunan (%)", "residual": "Nilai sisa (%)", "active": "Aktif", "retired": "Dihentikan", "newTitle": "Kelas aset baharu", "editTitle": "Sunting {{code}}", "fixedHint": "Akaun dan terma kelas tidak boleh diubah selepas dicipta. Hentikannya dan cipta kelas lain.", "saved": "{{code}} disimpan" }, "method": { "straight_line": "Garis lurus", "reducing_balance": "Baki berkurangan" }, "run": { "period": "Bulan", "status": "Keputusan", "assets": "Aset", "total": "Susut nilai", "runAt": "Dijalankan pada", "scheduled": "Berjadual", "title": "Jalankan susut nilai", "done": "Susut nilai bagi {{period}} dijalankan" }, "runStatus": { "posted": "Dipos", "empty": "Tiada yang perlu dicaj", "skipped": "Dilangkau: tempoh ditutup" }, "disposal": { "asset": "Aset", "kind": "Jenis", "date": "Tarikh pelupusan", "reason": "Sebab", "state": "Status", "catchUp": "Susut nilai hingga bulan pelupusan", "accumulated": "Susut nilai terkumpul dikosongkan", "nbv": "Nilai buku bersih dihapus kira", "title": "Lupus atau hapus kira", "detailTitle": "Pelupusan", "prepared": "Pelupusan disediakan. Hantarkannya daripada Pelupusan aset." }, "kind": { "sale": "Jualan", "write_off": "Hapus kira" }, "report": { "schedule": "Jadual susut nilai", "rollForward": "Pergerakan", "registerVsGl": "Daftar lawan lejar", "months": "Bulan", "from": "Dari", "to": "Hingga", "total": "Jumlah", "costOpening": "Kos b/b", "additions": "Tambahan", "costDisposals": "Pelupusan", "costClosing": "Kos h/b", "accumOpening": "Susut nilai b/b", "accumAcquired": "Dibawa masuk", "charge": "Caj", "accumDisposals": "Atas pelupusan", "accumClosing": "Susut nilai h/b", "account": "Akaun", "role": "Memegang", "register": "Daftar", "ledger": "Lejar", "difference": "Perbezaan", "agree": "Daftar sepadan dengan lejar.", "disagree": "Daftar dan lejar berbeza. Daftarkan setiap kos yang dipos, dan semak sumber yang dibalikkan." }, "role": { "cost": "Kos", "accumulated": "Susut nilai terkumpul" }, "empty": { "assets": "Belum ada aset", "classes": "Belum ada kelas aset", "runs": "Belum ada larian susut nilai", "disposals": "Belum ada pelupusan" }}web/src/lib/i18n/locales/zh/assets.json:
{ "nav": { "register": "固定资产", "classes": "资产类别", "depreciation": "折旧", "disposals": "资产处置", "reports": "资产报表" }, "page": { "register": "固定资产登记册", "registerSubtitle": "根据已过账的采购和日记账登记的资产。登记不会过账:成本已在总账中。", "classes": "资产类别", "classesSubtitle": "每个类别指定其四个科目及折旧方式。请先在科目表中创建折旧和处置科目。", "depreciation": "折旧运行", "depreciationSubtitle": "每月一次运行,为每项资产过账折旧。它在每月初自动运行;您也可以在此运行某个月份。", "disposals": "处置与核销", "disposalsSubtitle": "已批准的处置会将资产从账簿中移除。出售所得请以销售发票或收据记录。", "reports": "固定资产报表" }, "action": { "register": "登记资产", "newClass": "新建类别", "run": "运行折旧", "dispose": "处置或核销" }, "status": { "in_service": "使用中", "disposed": "已处置", "all": "全部" }, "asset": { "number": "编号", "description": "说明", "class": "类别", "inServiceOn": "启用日期", "cost": "成本", "accumulated": "累计折旧", "nbv": "账面净值", "residual": "残值", "residualHint": "留空则使用类别的残值百分比。", "status": "状态", "source": "来源", "depreciateFrom": "折旧起始", "lines": "已计提折旧", "chargedOn": "日期", "amount": "金额", "totals": "合计", "noLines": "尚未计提。" }, "source": { "purchase_invoice_line": "采购发票", "journal_line": "日记账分录", "opening_import": "期初导入" }, "register": { "title": "登记资产", "candidate": "已过账成本", "pickCandidate": "选择已过账成本", "noCandidates": "没有待登记的类别成本科目已过账成本。", "done": "{{number}} 已登记" }, "class": { "code": "代码", "nameEn": "名称(英文)", "nameMs": "名称(马来文)", "nameZh": "名称(中文)", "cost": "成本科目", "accumulated": "累计折旧科目", "depreciation": "折旧费用科目", "disposal": "处置损益科目", "method": "方法", "life": "使用年限(月)", "rate": "年折旧率(%)", "residual": "残值(%)", "active": "启用", "retired": "已停用", "newTitle": "新建资产类别", "editTitle": "编辑 {{code}}", "fixedHint": "类别创建后其科目和条款不可更改。请停用并另建类别。", "saved": "{{code}} 已保存" }, "method": { "straight_line": "直线法", "reducing_balance": "余额递减法" }, "run": { "period": "月份", "status": "结果", "assets": "资产数", "total": "折旧", "runAt": "运行时间", "scheduled": "定时", "title": "运行折旧", "done": "{{period}} 折旧已运行" }, "runStatus": { "posted": "已过账", "empty": "无需计提", "skipped": "已跳过:期间已关闭" }, "disposal": { "asset": "资产", "kind": "类型", "date": "处置日期", "reason": "原因", "state": "状态", "catchUp": "至处置月的折旧", "accumulated": "冲销的累计折旧", "nbv": "核销的账面净值", "title": "处置或核销", "detailTitle": "处置", "prepared": "处置已准备。请在资产处置中提交。" }, "kind": { "sale": "出售", "write_off": "核销" }, "report": { "schedule": "折旧计划", "rollForward": "变动表", "registerVsGl": "登记册与总账对照", "months": "月数", "from": "从", "to": "至", "total": "合计", "costOpening": "期初成本", "additions": "增加", "costDisposals": "处置", "costClosing": "期末成本", "accumOpening": "期初折旧", "accumAcquired": "转入", "charge": "本期计提", "accumDisposals": "处置转出", "accumClosing": "期末折旧", "account": "科目", "role": "内容", "register": "登记册", "ledger": "总账", "difference": "差额", "agree": "登记册与总账一致。", "disagree": "登记册与总账不一致。请登记每项已过账成本,并检查已冲销的来源。" }, "role": { "cost": "成本", "accumulated": "累计折旧" }, "empty": { "assets": "暂无资产", "classes": "暂无资产类别", "runs": "暂无折旧运行", "disposals": "暂无处置" }}In each common.json, the refusals object gains (en / ms / zh):
| Key | en | ms | zh |
|---|---|---|---|
source_not_eligible |
That cost is not a posted cost on the class’s cost account. | Kos itu bukan kos yang dipos pada akaun kos kelas. | 该成本不是类别成本科目上的已过账成本。 |
source_registered |
That line is already registered as an asset. | Baris itu sudah didaftarkan sebagai aset. | 该行已登记为资产。 |
class_code_taken |
Another asset class has that code. | Kelas aset lain sudah menggunakan kod itu. | 已有其他资产类别使用该代码。 |
run_exists |
That month already has a depreciation run. | Bulan itu sudah ada larian susut nilai. | 该月份已运行过折旧。 |
run_out_of_order |
A later month already has a depreciation run. | Bulan yang lebih lewat sudah ada larian susut nilai. | 之后的月份已运行过折旧。 |
period_not_started |
That month has not started. | Bulan itu belum bermula. | 该月份尚未开始。 |
asset_disposed |
The asset is disposed. | Aset itu telah dilupuskan. | 该资产已处置。 |
disposal_open |
The asset already has a disposal in progress. | Aset itu sudah mempunyai pelupusan yang sedang berjalan. | 该资产已有处理中的处置。 |
disposal_month_depreciated |
Depreciation is posted after that date. Date the disposal in or after the last depreciated month. | Susut nilai telah dipos selepas tarikh itu. Tarikhkan pelupusan dalam atau selepas bulan terakhir yang disusut nilai. | 该日期之后已过账折旧。请将处置日期定在最后计提月份或之后。 |
disposal_final |
A posted disposal is final. | Pelupusan yang telah dipos adalah muktamad. | 已过账的处置不可更改。 |
- Step 2: Write the failing tests.
web/src/features/assets/__tests__/AssetsPage.test.tsx:
import { describe, expect, it } from 'vitest';import { screen, waitFor } from '@testing-library/react';import { http, HttpResponse } from 'msw';import { apiPath } from '@/test/msw/handlers';import { server } from '@/test/msw/server';import { renderWithApp } from '@/test/renderWithApp';
export const klass = { id: 'k1', code: 'EQUIP', names: { en: 'Equipment', ms: 'Peralatan' }, cost_account_id: 'a1110', accumulated_account_id: 'a1120', depreciation_account_id: 'a6200', disposal_account_id: 'a4300', method: 'straight_line', life_months: 12, rate_percent: null, residual_percent: '0.0000000000', is_active: true,};export const asset = { id: 'as1', number: 'FA-2026-0001', class_id: 'k1', description: 'Delivery van', cost: '12000.0000', residual_value: '0.0000', opening_accumulated: '0.0000', accumulated: '8000.0000', nbv: '4000.0000', in_service_on: '2026-01-15', depreciate_from: '2026-01-01', status: 'in_service', disposed_on: null, source_kind: 'journal_line', source_id: 'l1', shares: [], registered_at: '2026-09-21T01:00:00Z',};
function mockApi(posted: unknown[] = []) { server.use( http.get(apiPath('/assets/classes'), () => HttpResponse.json({ classes: [klass] })), http.get(apiPath('/assets/candidates'), () => HttpResponse.json({ candidates: [{ source_kind: 'journal_line', source_id: 'l2', date: '2026-09-01', reference: 'MSC-2026-0007', description: 'Forklift', account_id: 'a1110', amount: '6000.0000' }], }), ), http.get(apiPath('/assets'), () => HttpResponse.json({ assets: [asset], totals: { cost: '12000.0000', accumulated: '8000.0000', nbv: '4000.0000' } }), ), http.post(apiPath('/assets'), async ({ request }) => { posted.push(await request.json()); return HttpResponse.json({ ...asset, id: 'as2', number: 'FA-2026-0002' }, { status: 201 }); }), http.get(apiPath('/assets/:id'), () => HttpResponse.json({ asset, lines: [] })), );}
describe('AssetsPage', () => { it('lists the register with its totals, and hides Register from a reader', async () => { mockApi(); await renderWithApp({ path: '/assets', permissions: ['assets.asset.read'] }); expect(await screen.findByText('FA-2026-0001')).toBeInTheDocument(); expect(screen.getByText('Delivery van')).toBeInTheDocument(); expect(screen.getAllByText('RM 4,000.00').length).toBeGreaterThan(0); expect(screen.queryByRole('button', { name: 'Register asset' })).not.toBeInTheDocument(); });
it('registers a posted cost under the class on its account', async () => { const posted: unknown[] = []; mockApi(posted); const { user } = await renderWithApp({ path: '/assets', permissions: ['assets.asset.read', 'assets.asset.manage'] }); await user.click(await screen.findByRole('button', { name: 'Register asset' })); await user.click(await screen.findByRole('combobox', { name: 'Posted cost' })); await user.click(await screen.findByRole('option', { name: /Forklift/ })); await user.click(screen.getByRole('button', { name: 'Save' })); await waitFor(() => expect(posted).toHaveLength(1)); expect(posted[0]).toEqual({ source_kind: 'journal_line', source_id: 'l2', class_id: 'k1', description: 'Forklift', in_service_on: '2026-09-01', }); });});web/src/features/assets/__tests__/ClassesPage.test.tsx:
import { describe, expect, it } from 'vitest';import { screen, waitFor } from '@testing-library/react';import { http, HttpResponse } from 'msw';import { apiPath } from '@/test/msw/handlers';import { server } from '@/test/msw/server';import { renderWithApp } from '@/test/renderWithApp';import { klass } from './AssetsPage.test';
const acct = (id: string, code: string, label: string, type: string) => ({ id, code, label, type, control_kind: null, postable: true, active: true, currency: null });
describe('ClassesPage', () => { it('creates a class, every figure a string', async () => { const posted: unknown[] = []; server.use( http.get(apiPath('/assets/classes'), () => HttpResponse.json({ classes: [] })), http.get(apiPath('/accounting/accounts'), () => HttpResponse.json({ accounts: [acct('a1110', '1110', 'Property, plant and equipment', 'asset'), acct('a1120', '1120', 'Accumulated depreciation', 'asset'), acct('a6200', '6200', 'Depreciation', 'expense'), acct('a4300', '4300', 'Disposal gain or loss', 'income')], }), ), http.post(apiPath('/assets/classes'), async ({ request }) => { posted.push(await request.json()); return HttpResponse.json(klass, { status: 201 }); }), ); const { user } = await renderWithApp({ path: '/assets/classes', permissions: ['assets.asset.read', 'assets.asset.manage', 'accounting.ledger.read'] }); await user.click(await screen.findByRole('button', { name: 'New class' })); await user.type(screen.getByLabelText('Code'), 'EQUIP'); await user.type(screen.getByLabelText('Name (English)'), 'Equipment'); await user.type(screen.getByLabelText('Name (Malay)'), 'Peralatan'); for (const [name, option] of [ ['Cost account', /1110/], ['Accumulated depreciation account', /1120/], ['Depreciation expense account', /6200/], ['Disposal gain or loss account', /4300/], ] as const) { await user.click(screen.getByRole('combobox', { name })); await user.click(await screen.findByRole('option', { name: option })); } await user.type(screen.getByLabelText('Useful life (months)'), '60'); await user.click(screen.getByRole('button', { name: 'Save' })); await waitFor(() => expect(posted).toHaveLength(1)); expect(posted[0]).toEqual({ code: 'EQUIP', names: { en: 'Equipment', ms: 'Peralatan' }, cost_account_id: 'a1110', accumulated_account_id: 'a1120', depreciation_account_id: 'a6200', disposal_account_id: 'a4300', method: 'straight_line', life_months: 60, residual_percent: '0', }); });});Run: pnpm -C web test -- features/assets
Expected: FAIL — cannot resolve @/features/assets/... / routes not found.
- Step 3:
api.ts.
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';import { api } from '@/lib/api/client';import { unwrap } from '@/lib/api/problem';import type { Schemas } from '@/lib/api/types';
export type AssetClass = Schemas['AssetClass'];export type AssetClassInput = Schemas['AssetClassInput'];export type Asset = Schemas['Asset'];export type AssetCandidate = Schemas['AssetCandidate'];export type DepreciationRun = Schemas['DepreciationRun'];export type AssetDisposal = Schemas['AssetDisposal'];export type AssetRollForwardRow = Schemas['AssetRollForwardRow'];export type AssetStatus = 'in_service' | 'disposed' | 'all';
export const assetsKeys = { all: ['assets'] as const, classes: ['assets', 'classes'] as const, list: (status: AssetStatus) => ['assets', 'list', status] as const, asset: (id: string) => ['assets', 'asset', id] as const, candidates: ['assets', 'candidates'] as const, runs: ['assets', 'runs'] as const, disposals: ['assets', 'disposals'] as const, disposal: (id: string) => ['assets', 'disposal', id] as const, schedule: (months: number) => ['assets', 'schedule', months] as const, rollForward: (from: string, to: string) => ['assets', 'roll-forward', from, to] as const, registerVsGL: ['assets', 'register-vs-gl'] as const,};
/** A write can move the register, the runs and every report: refetch them all. */function useInvalidateAll() { const qc = useQueryClient(); return () => void qc.invalidateQueries({ queryKey: assetsKeys.all });}
export function useAssetClasses() { return useQuery({ queryKey: assetsKeys.classes, queryFn: async ({ signal }) => unwrap(await api.GET('/assets/classes', { signal })).classes, });}export function useCreateClass() { const done = useInvalidateAll(); return useMutation({ mutationFn: async (body: AssetClassInput) => unwrap(await api.POST('/assets/classes', { body })), onSuccess: done, });}export function useUpdateClass(id: string) { const done = useInvalidateAll(); return useMutation({ mutationFn: async (body: Schemas['AssetClassUpdate']) => unwrap(await api.PUT('/assets/classes/{id}', { params: { path: { id } }, body })), onSuccess: done, });}export function useAssets(status: AssetStatus) { return useQuery({ queryKey: assetsKeys.list(status), queryFn: async ({ signal }) => unwrap(await api.GET('/assets', { params: { query: { status } }, signal })), });}export function useAsset(id: string) { return useQuery({ queryKey: assetsKeys.asset(id), queryFn: async ({ signal }) => unwrap(await api.GET('/assets/{id}', { params: { path: { id } }, signal })), });}export function useCandidates(enabled: boolean) { return useQuery({ queryKey: assetsKeys.candidates, enabled, queryFn: async ({ signal }) => unwrap(await api.GET('/assets/candidates', { signal })).candidates, });}export function useRegisterAsset() { const done = useInvalidateAll(); return useMutation({ mutationFn: async (body: Schemas['AssetRegisterInput']) => unwrap(await api.POST('/assets', { body })), onSuccess: done, });}export function useRuns() { return useQuery({ queryKey: assetsKeys.runs, queryFn: async ({ signal }) => unwrap(await api.GET('/assets/depreciation-runs', { signal })).runs, });}export function useRunDepreciation() { const done = useInvalidateAll(); return useMutation({ mutationFn: async (period_id: string) => unwrap(await api.POST('/assets/depreciation-runs', { body: { period_id } })), onSuccess: done, });}export function useDisposals() { return useQuery({ queryKey: assetsKeys.disposals, queryFn: async ({ signal }) => unwrap(await api.GET('/assets/disposals', { signal })).disposals, });}export function useDisposal(id: string) { return useQuery({ queryKey: assetsKeys.disposal(id), queryFn: async ({ signal }) => unwrap(await api.GET('/assets/disposals/{id}', { params: { path: { id } }, signal })), });}export function useCreateDisposal() { const done = useInvalidateAll(); return useMutation({ mutationFn: async (body: Schemas['AssetDisposalInput']) => unwrap(await api.POST('/assets/disposals', { body })), onSuccess: done, });}export function useDisposalTransition(id: string) { const done = useInvalidateAll(); return useMutation({ mutationFn: async (body: Schemas['DocumentTransition']) => unwrap(await api.POST('/assets/disposals/{id}/transitions', { params: { path: { id } }, body })), onSuccess: done, });}export function useScheduleReport(months: number) { return useQuery({ queryKey: assetsKeys.schedule(months), queryFn: async ({ signal }) => unwrap(await api.GET('/assets/reports/schedule', { params: { query: { months } }, signal })), });}export function useRollForward(from: string, to: string) { return useQuery({ queryKey: assetsKeys.rollForward(from, to), queryFn: async ({ signal }) => unwrap(await api.GET('/assets/reports/roll-forward', { params: { query: { from, to } }, signal })), });}export function useRegisterVsGL() { return useQuery({ queryKey: assetsKeys.registerVsGL, queryFn: async ({ signal }) => unwrap(await api.GET('/assets/reports/register-vs-gl', { signal })).rows, });}- Step 4:
AssetsPage.tsx.
import { useMemo, useState } from 'react';import { useTranslation } from 'react-i18next';import { Button } from '@/components/ui/Button';import { DataTable, type ColumnDef } from '@/components/ui/DataTable';import { EmptyState } from '@/components/ui/EmptyState';import { PageHeader } from '@/components/ui/PageHeader';import { Select } from '@/components/ui/Select';import { MoneyText } from '@/components/money/MoneyText';import { useCan, useMe } from '@/features/auth/session';import { formatDate } from '@/lib/date';import { pickLabel, useLocale } from '@/lib/i18n';import { useAssetClasses, useAssets, type Asset, type AssetStatus } from './api';import { RegisterDialog } from './RegisterDialog';
export function AssetsPage({ onOpen }: { onOpen(id: string): void }) { const { t } = useTranslation('assets'); const [locale] = useLocale(); const base = useMe().data?.organization.base_currency ?? 'MYR'; const canManage = useCan('assets.asset.manage'); const [status, setStatus] = useState<AssetStatus>('in_service'); const [registering, setRegistering] = useState(false); const list = useAssets(status); const classes = useAssetClasses(); const columns = useMemo<ColumnDef<Asset>[]>(() => { const className = (id: string) => { const k = classes.data?.find((c) => c.id === id); return k ? `${k.code} · ${pickLabel(k.names, locale)}` : ''; }; const money = (v: string) => <MoneyText amount={v} currency={base} />; return [ { id: 'number', meta: { required: true }, header: t('asset.number'), cell: ({ row }) => <span className="font-mono text-caption">{row.original.number}</span>, }, { id: 'description', header: t('asset.description'), cell: ({ row }) => row.original.description }, { id: 'class', header: t('asset.class'), cell: ({ row }) => className(row.original.class_id) }, { id: 'inService', header: t('asset.inServiceOn'), cell: ({ row }) => formatDate(row.original.in_service_on, locale), }, { id: 'cost', meta: { required: true }, header: t('asset.cost'), cell: ({ row }) => money(row.original.cost) }, { id: 'accumulated', header: t('asset.accumulated'), cell: ({ row }) => money(row.original.accumulated) }, { id: 'nbv', header: t('asset.nbv'), cell: ({ row }) => money(row.original.nbv) }, ]; }, [t, locale, base, classes.data]); const totals = list.data?.totals; return ( <div className="flex flex-col gap-4"> <PageHeader title={t('page.register')} subtitle={t('page.registerSubtitle')} actions={canManage && <Button onClick={() => setRegistering(true)}>{t('action.register')}</Button>} /> <Select aria-label={t('asset.status')} value={status} onValueChange={(v: string) => setStatus(v as AssetStatus)} options={(['in_service', 'disposed', 'all'] as const).map((s) => ({ value: s, label: t(`status.${s}`) }))} /> <DataTable tableId="assets.register" columns={columns} data={list.data?.assets ?? []} loading={list.isLoading} getRowId={(a: Asset) => a.id} onRowClick={(a: Asset) => onOpen(a.id)} empty={<EmptyState title={t('empty.assets')} />} /> {totals && ( <dl className="grid grid-cols-3 gap-4 rounded-card bg-bg-subtle p-4" aria-label={t('asset.totals')}> {(['cost', 'accumulated', 'nbv'] as const).map((k) => ( <div key={k}> <dt className="text-caption text-fg-muted">{t(`asset.${k}`)}</dt> <dd className="font-bold"> <MoneyText amount={totals[k]} currency={base} /> </dd> </div> ))} </dl> )} {registering && ( <RegisterDialog onClose={() => setRegistering(false)} onDone={(id) => { setRegistering(false); onOpen(id); }} /> )} </div> );}- Step 5:
RegisterDialog.tsx.
import { useState, type ChangeEvent } from 'react';import { useTranslation } from 'react-i18next';import { Button } from '@/components/ui/Button';import { DatePicker } from '@/components/ui/DatePicker';import { Dialog } from '@/components/ui/Dialog';import { EmptyState } from '@/components/ui/EmptyState';import { Field } from '@/components/ui/Field';import { Input } from '@/components/ui/Input';import { Select } from '@/components/ui/Select';import { MoneyInput } from '@/components/money/MoneyInput';import { useMe } from '@/features/auth/session';import { RecordLanguageHint } from '@/features/settings/RecordLanguageHint';import { problemMessage } from '@/lib/api/problem';import { formatDate, todayISO } from '@/lib/date';import { pickLabel, useLocale } from '@/lib/i18n';import { formatMoney } from '@/lib/money';import { useAssetClasses, useCandidates, useRegisterAsset } from './api';
/** Registers a posted cost (Ruling FA-7). The class list is only the classes whose cost account * the chosen line posted to: the server refuses any other. */export function RegisterDialog({ onClose, onDone }: { onClose(): void; onDone(id: string): void }) { const { t } = useTranslation('assets'); const { t: tc } = useTranslation(); const [locale] = useLocale(); const base = useMe().data?.organization.base_currency ?? 'MYR'; const candidates = useCandidates(true); const classes = useAssetClasses(); const register = useRegisterAsset(); const [pick, setPick] = useState<string>(); const [v, setV] = useState({ class_id: '', description: '', in_service_on: todayISO(), residual_value: '' }); const [error, setError] = useState(''); const chosen = candidates.data?.find((c) => c.source_id === pick); const eligible = (classes.data ?? []).filter((k) => k.is_active && k.cost_account_id === chosen?.account_id);
function choose(id: string) { const c = candidates.data?.find((x) => x.source_id === id); const k = (classes.data ?? []).find((x) => x.is_active && x.cost_account_id === c?.account_id); setPick(id); setV((s) => ({ ...s, class_id: k?.id ?? '', description: c?.description ?? '', in_service_on: c?.date ?? todayISO() })); }
async function submit() { if (!chosen) return; setError(''); try { const a = await register.mutateAsync({ source_kind: chosen.source_kind, source_id: chosen.source_id, class_id: v.class_id, description: v.description, in_service_on: v.in_service_on, ...(v.residual_value ? { residual_value: v.residual_value } : {}), }); onDone(a.id); } catch (e) { setError(problemMessage(e, tc)); } }
return ( <Dialog open size="lg" onOpenChange={(o: boolean) => !o && onClose()} title={t('register.title')} footer={ <Button onClick={() => void submit()} loading={register.isPending} disabled={!chosen}> {tc('actions.save')} </Button> } > {candidates.data?.length === 0 ? ( <EmptyState title={t('register.noCandidates')} /> ) : ( <div className="grid gap-4 md:grid-cols-2"> <Field label={t('register.candidate')} id="fa-source"> <Select id="fa-source" aria-label={t('register.candidate')} value={pick} placeholder={t('register.pickCandidate')} onValueChange={choose} options={(candidates.data ?? []).map((c) => ({ value: c.source_id, label: `${formatDate(c.date, locale)} · ${c.reference} · ${c.description} · ${formatMoney(c.amount, base, locale)}`, }))} /> </Field> <Field label={t('asset.class')} id="fa-class"> <Select id="fa-class" aria-label={t('asset.class')} value={v.class_id || undefined} onValueChange={(x: string) => setV((s) => ({ ...s, class_id: x }))} options={eligible.map((k) => ({ value: k.id, label: `${k.code} · ${pickLabel(k.names, locale)}` }))} /> </Field> <Field label={t('asset.description')} id="fa-description" hint={<RecordLanguageHint />}> <Input id="fa-description" maxLength={500} value={v.description} onChange={(e: ChangeEvent<HTMLInputElement>) => setV((s) => ({ ...s, description: e.target.value }))} /> </Field> <Field label={t('asset.inServiceOn')} id="fa-in-service"> <DatePicker id="fa-in-service" value={v.in_service_on} onChange={(x?: string) => setV((s) => ({ ...s, in_service_on: x ?? todayISO() }))} /> </Field> <Field label={t('asset.residual')} id="fa-residual" hint={t('asset.residualHint')} optional> <MoneyInput id="fa-residual" currency={base} value={v.residual_value} onChange={(x) => setV((s) => ({ ...s, residual_value: x }))} /> </Field> </div> )} {error && ( <p role="alert" className="mt-3 text-caption text-tone-red-fg"> {error} </p> )} </Dialog> );}- Step 6:
AssetDrawer.tsxandDisposalDialog.tsx.
import { useState, type ReactNode } from 'react';import { useTranslation } from 'react-i18next';import { Button } from '@/components/ui/Button';import { Drawer } from '@/components/ui/Drawer';import { Skeleton } from '@/components/ui/Skeleton';import { Tag } from '@/components/ui/Tag';import { MoneyText } from '@/components/money/MoneyText';import { useCan, useMe } from '@/features/auth/session';import { formatDate } from '@/lib/date';import { useLocale } from '@/lib/i18n';import { useAsset } from './api';import { DisposalDialog } from './DisposalDialog';
export function AssetDrawer({ id, onClose }: { id: string; onClose(): void }) { const { t } = useTranslation('assets'); const [locale] = useLocale(); const base = useMe().data?.organization.base_currency ?? 'MYR'; const canDispose = useCan('assets.disposal.create'); const q = useAsset(id); const [disposing, setDisposing] = useState(false); const a = q.data?.asset; const row = (label: string, value: ReactNode) => ( <div className="flex justify-between gap-4"> <dt className="text-caption text-fg-muted">{label}</dt> <dd className="text-right">{value}</dd> </div> ); return ( <Drawer open onOpenChange={(o: boolean) => !o && onClose()} title={a ? `${a.number} · ${a.description}` : ''}> {!q.data || !a ? ( <Skeleton className="h-96 w-full" /> ) : ( <div className="flex flex-col gap-4"> <Tag tone={a.status === 'disposed' ? 'muted' : 'green'}>{t(`status.${a.status}`)}</Tag> <dl className="flex flex-col gap-2"> {row(t('asset.cost'), <MoneyText amount={a.cost} currency={base} />)} {row(t('asset.accumulated'), <MoneyText amount={a.accumulated} currency={base} />)} {row(t('asset.nbv'), <MoneyText amount={a.nbv} currency={base} />)} {row(t('asset.residual'), <MoneyText amount={a.residual_value} currency={base} />)} {row(t('asset.inServiceOn'), formatDate(a.in_service_on, locale))} {row(t('asset.depreciateFrom'), formatDate(a.depreciate_from, locale))} {row(t('asset.source'), t(`source.${a.source_kind}`))} </dl> <h3 className="text-body font-bold">{t('asset.lines')}</h3> {q.data.lines.length === 0 ? ( <p className="text-caption text-fg-muted">{t('asset.noLines')}</p> ) : ( <dl className="flex flex-col gap-1"> {q.data.lines.map((l, i) => ( <div key={i}>{row(formatDate(l.charged_on, locale), <MoneyText amount={l.amount} currency={base} />)}</div> ))} </dl> )} {canDispose && a.status === 'in_service' && ( <Button variant="secondary" className="self-start" onClick={() => setDisposing(true)}> {t('action.dispose')} </Button> )} {disposing && <DisposalDialog assetId={a.id} onClose={() => setDisposing(false)} />} </div> )} </Drawer> );}import { useState, type ChangeEvent } from 'react';import { useTranslation } from 'react-i18next';import { Button } from '@/components/ui/Button';import { DatePicker } from '@/components/ui/DatePicker';import { Dialog } from '@/components/ui/Dialog';import { Field } from '@/components/ui/Field';import { Select } from '@/components/ui/Select';import { Textarea } from '@/components/ui/Textarea';import { toast } from '@/components/ui/Toaster';import { RecordLanguageHint } from '@/features/settings/RecordLanguageHint';import { problemMessage } from '@/lib/api/problem';import { todayISO } from '@/lib/date';import { useCreateDisposal } from './api';
export function DisposalDialog({ assetId, onClose }: { assetId: string; onClose(): void }) { const { t } = useTranslation('assets'); const { t: tc } = useTranslation(); const create = useCreateDisposal(); const [v, setV] = useState<{ kind: 'sale' | 'write_off'; disposed_on: string; reason: string }>({ kind: 'sale', disposed_on: todayISO(), reason: '', }); const [error, setError] = useState(''); async function submit() { setError(''); try { await create.mutateAsync({ asset_id: assetId, ...v }); toast.success(t('disposal.prepared')); onClose(); } catch (e) { setError(problemMessage(e, tc)); } } return ( <Dialog open onOpenChange={(o: boolean) => !o && onClose()} title={t('disposal.title')} footer={ <Button onClick={() => void submit()} loading={create.isPending}> {tc('actions.save')} </Button> } > <div className="grid gap-4"> <Field label={t('disposal.kind')} id="fd-kind"> <Select id="fd-kind" aria-label={t('disposal.kind')} value={v.kind} onValueChange={(x: string) => setV((s) => ({ ...s, kind: x as 'sale' | 'write_off' }))} options={(['sale', 'write_off'] as const).map((k) => ({ value: k, label: t(`kind.${k}`) }))} /> </Field> <Field label={t('disposal.date')} id="fd-date"> <DatePicker id="fd-date" value={v.disposed_on} onChange={(x?: string) => setV((s) => ({ ...s, disposed_on: x ?? todayISO() }))} /> </Field> <Field label={t('disposal.reason')} id="fd-reason" hint={<RecordLanguageHint />}> <Textarea id="fd-reason" maxLength={500} value={v.reason} onChange={(e: ChangeEvent<HTMLTextAreaElement>) => setV((s) => ({ ...s, reason: e.target.value }))} /> </Field> </div> {error && ( <p role="alert" className="mt-3 text-caption text-tone-red-fg"> {error} </p> )} </Dialog> );}- Step 7:
ClassesPage.tsxandClassDialog.tsx.
import { useMemo, useState } from 'react';import { useTranslation } from 'react-i18next';import { Button } from '@/components/ui/Button';import { DataTable, type ColumnDef } from '@/components/ui/DataTable';import { EmptyState } from '@/components/ui/EmptyState';import { PageHeader } from '@/components/ui/PageHeader';import { Tag } from '@/components/ui/Tag';import { useCan } from '@/features/auth/session';import { pickLabel, useLocale } from '@/lib/i18n';import { useAssetClasses, type AssetClass } from './api';import { ClassDialog } from './ClassDialog';
export function ClassesPage() { const { t } = useTranslation('assets'); const [locale] = useLocale(); const canManage = useCan('assets.asset.manage'); const classes = useAssetClasses(); const [editing, setEditing] = useState<AssetClass | 'new'>(); const columns = useMemo<ColumnDef<AssetClass>[]>( () => [ { id: 'code', meta: { required: true }, header: t('class.code'), cell: ({ row }) => row.original.code }, { id: 'name', header: t('class.nameEn'), cell: ({ row }) => pickLabel(row.original.names, locale) }, { id: 'method', header: t('class.method'), cell: ({ row }) => t(`method.${row.original.method}`) }, { id: 'term', header: `${t('class.life')} / ${t('class.rate')}`, cell: ({ row }) => row.original.life_months ?? row.original.rate_percent ?? '', }, { id: 'active', header: t('class.active'), cell: ({ row }) => ( <Tag tone={row.original.is_active ? 'green' : 'muted'}> {t(row.original.is_active ? 'class.active' : 'class.retired')} </Tag> ), }, ], [t, locale], ); return ( <div className="flex flex-col gap-4"> <PageHeader title={t('page.classes')} subtitle={t('page.classesSubtitle')} actions={canManage && <Button onClick={() => setEditing('new')}>{t('action.newClass')}</Button>} /> <DataTable tableId="assets.classes" columns={columns} data={classes.data ?? []} loading={classes.isLoading} getRowId={(k: AssetClass) => k.id} onRowClick={canManage ? (k: AssetClass) => setEditing(k) : undefined} empty={<EmptyState title={t('empty.classes')} />} /> {editing && <ClassDialog klass={editing === 'new' ? undefined : editing} onClose={() => setEditing(undefined)} />} </div> );}import { useState, type ChangeEvent } from 'react';import { useTranslation } from 'react-i18next';import { Button } from '@/components/ui/Button';import { Dialog } from '@/components/ui/Dialog';import { Field } from '@/components/ui/Field';import { Input } from '@/components/ui/Input';import { Select } from '@/components/ui/Select';import { toast } from '@/components/ui/Toaster';import { accountOptions, useAccounts } from '@/features/accounting/queries';import { problemMessage } from '@/lib/api/problem';import { useCreateClass, useUpdateClass, type AssetClass } from './api';
type Method = 'straight_line' | 'reducing_balance';const usable = (a: { postable: boolean; active: boolean; control_kind: string | null }) => a.postable && a.active && a.control_kind === null;
/** New: every field. Existing: names and active only (Ruling FA-6). Figures stay strings; the * life is a count of months, not money. */export function ClassDialog({ klass, onClose }: { klass?: AssetClass; onClose(): void }) { const { t } = useTranslation('assets'); const { t: tc } = useTranslation(); const accounts = useAccounts(); const create = useCreateClass(); const update = useUpdateClass(klass?.id ?? ''); const [names, setNames] = useState<Record<string, string>>(klass?.names ?? {}); const [active, setActive] = useState(klass?.is_active ?? true); const [v, setV] = useState({ code: '', cost: '', accumulated: '', depreciation: '', disposal: '', method: 'straight_line' as Method, life: '', rate: '', residual: '0', }); const [error, setError] = useState(''); const set = (k: keyof typeof v) => (x: string) => setV((s) => ({ ...s, [k]: x })); const text = (k: keyof typeof v) => (e: ChangeEvent<HTMLInputElement>) => set(k)(e.target.value); const name = (loc: string) => (e: ChangeEvent<HTMLInputElement>) => setNames((s) => ({ ...s, [loc]: e.target.value })); const cleanNames = () => Object.fromEntries(Object.entries(names).filter(([, x]) => x.trim() !== ''));
async function submit() { setError(''); try { if (klass) { await update.mutateAsync({ names: cleanNames(), is_active: active }); } else { await create.mutateAsync({ code: v.code, names: cleanNames(), cost_account_id: v.cost, accumulated_account_id: v.accumulated, depreciation_account_id: v.depreciation, disposal_account_id: v.disposal, method: v.method, ...(v.method === 'straight_line' ? { life_months: Number.parseInt(v.life, 10) } : { rate_percent: v.rate }), residual_percent: v.residual, }); } toast.success(t('class.saved', { code: klass?.code ?? v.code })); onClose(); } catch (e) { setError(problemMessage(e, tc)); } }
const account = (key: 'cost' | 'accumulated' | 'depreciation' | 'disposal', types: string[]) => ( <Field label={t(`class.${key}`)} id={`fc-${key}`}> <Select id={`fc-${key}`} aria-label={t(`class.${key}`)} value={v[key] || undefined} onValueChange={set(key)} options={accountOptions(accounts.data, (a) => usable(a) && types.includes(a.type))} /> </Field> );
return ( <Dialog open size="lg" onOpenChange={(o: boolean) => !o && onClose()} title={klass ? t('class.editTitle', { code: klass.code }) : t('class.newTitle')} description={t('class.fixedHint')} footer={ <Button onClick={() => void submit()} loading={create.isPending || update.isPending}> {tc('actions.save')} </Button> } > <div className="grid gap-4 md:grid-cols-2"> {!klass && ( <Field label={t('class.code')} id="fc-code"> <Input id="fc-code" maxLength={20} value={v.code} onChange={text('code')} /> </Field> )} <Field label={t('class.nameEn')} id="fc-en"> <Input id="fc-en" maxLength={100} value={names.en ?? ''} onChange={name('en')} /> </Field> <Field label={t('class.nameMs')} id="fc-ms"> <Input id="fc-ms" maxLength={100} value={names.ms ?? ''} onChange={name('ms')} /> </Field> <Field label={t('class.nameZh')} id="fc-zh" optional> <Input id="fc-zh" maxLength={100} value={names.zh ?? ''} onChange={name('zh')} /> </Field> {klass ? ( <Field label={t('class.active')} id="fc-active"> <Select id="fc-active" aria-label={t('class.active')} value={active ? 'active' : 'retired'} onValueChange={(x: string) => setActive(x === 'active')} options={[ { value: 'active', label: t('class.active') }, { value: 'retired', label: t('class.retired') }, ]} /> </Field> ) : ( <> {account('cost', ['asset'])} {account('accumulated', ['asset'])} {account('depreciation', ['expense'])} {account('disposal', ['income', 'expense'])} <Field label={t('class.method')} id="fc-method"> <Select id="fc-method" aria-label={t('class.method')} value={v.method} onValueChange={(x: string) => setV((s) => ({ ...s, method: x as Method }))} options={(['straight_line', 'reducing_balance'] as const).map((m) => ({ value: m, label: t(`method.${m}`) }))} /> </Field> {v.method === 'straight_line' ? ( <Field label={t('class.life')} id="fc-life"> <Input id="fc-life" inputMode="numeric" value={v.life} onChange={text('life')} /> </Field> ) : ( <Field label={t('class.rate')} id="fc-rate"> <Input id="fc-rate" inputMode="decimal" value={v.rate} onChange={text('rate')} /> </Field> )} <Field label={t('class.residual')} id="fc-residual"> <Input id="fc-residual" inputMode="decimal" value={v.residual} onChange={text('residual')} /> </Field> </> )} </div> {error && ( <p role="alert" className="mt-3 text-caption text-tone-red-fg"> {error} </p> )} </Dialog> );}(Number.parseInt makes an integer month count, never money; nofloat.test.ts scans money-named identifiers and life is not one — confirm the scan passes in Step 9.)
- Step 8:
routes.tsx, routes and nav.
import { createRoute } from '@tanstack/react-router';import { z } from 'zod';import { appRoute } from '@/app/layouts';import { RequirePermission } from '@/features/auth/session';import { AssetDrawer } from './AssetDrawer';import { AssetsPage } from './AssetsPage';import { ClassesPage } from './ClassesPage';
const READ = 'assets.asset.read';
const assetSearch = z.object({ asset: z.string().optional().catch(undefined) });
const assetsRoute = createRoute({ getParentRoute: () => appRoute, path: 'assets', validateSearch: (s: Record<string, unknown>) => assetSearch.parse(s), component: function R() { const s = assetsRoute.useSearch(); const navigate = assetsRoute.useNavigate(); const open = (asset?: string) => void navigate({ search: { asset } }); return ( <RequirePermission permission={READ}> <AssetsPage onOpen={open} /> {s.asset && <AssetDrawer id={s.asset} onClose={() => open(undefined)} />} </RequirePermission> ); },});
const classesRoute = createRoute({ getParentRoute: () => appRoute, path: 'assets/classes', component: () => ( <RequirePermission permission={READ}> <ClassesPage /> </RequirePermission> ),});
// APPEND-ONLY: Task 11 adds the depreciation, disposals and reports routes.export const routes = [assetsRoute, classesRoute];web/src/app/routes.ts: import { routes as assetsRoutes } from '@/features/assets/routes'; and ...assetsRoutes, after ...printingRoutes,. web/src/app/shell/nav.ts: import Building2, Shapes from lucide-react and append to navItems:
{ group: 'accounting', to: '/assets', labelKey: 'assets:nav.register', icon: Building2, permission: 'assets.asset.read', }, { group: 'accounting', to: '/assets/classes', labelKey: 'assets:nav.classes', icon: Shapes, permission: 'assets.asset.read', },-
Step 9: Run.
pnpm -C web typecheck && pnpm -C web lint && pnpm -C web test -- features/assets drift i18n→ PASS. -
Step 10: Sabotage. Change
eligible’s filter to(classes.data ?? [])(every class) and pre-select nothing inchoose; runAssetsPage.test.tsx; read the red (class_id: ''); restore. Record it. -
Step 11: Commit.
git add -A && git commit -m "O4 Task 10: web fixed asset register, classes and registration" -m "<sabotage message>" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
Task 11: Web — depreciation, disposals, reports, import and module labels
Section titled “Task 11: Web — depreciation, disposals, reports, import and module labels”Files:
- Create:
web/src/features/assets/{DepreciationPage.tsx,DisposalsPage.tsx,DisposalDrawer.tsx,AssetReportsPage.tsx} - Create:
web/src/features/assets/__tests__/{DepreciationPage.test.tsx,DisposalDrawer.test.tsx,AssetReportsPage.test.tsx} - Modify:
web/src/features/assets/routes.tsx,web/src/app/shell/nav.ts - Modify:
web/src/features/imports/MigrationPage.tsx(STEPS),web/src/lib/i18n/locales/{en,ms,zh}/{imports,modules}.json
Interfaces:
-
Consumes: Task 10’s
api.tshooks and types;useFiscalYears(features/accounting/queries);StatusChip,TransitionBar,DecisionTimeline,Tabs. -
Produces: routes
/assets/depreciation,/assets/disposals(searchdisposal),/assets/reports(searchtab). -
Step 1: Write the failing tests.
web/src/features/assets/__tests__/DepreciationPage.test.tsx:
import { describe, expect, it } from 'vitest';import { screen, waitFor } from '@testing-library/react';import { http, HttpResponse } from 'msw';import { apiPath } from '@/test/msw/handlers';import { server } from '@/test/msw/server';import { renderWithApp } from '@/test/renderWithApp';
const period = (seq: number, starts: string, ends: string) => ({ id: `p${seq}`, seq, starts_on: starts, ends_on: ends, status: 'open', closed_at: null, closed_by: null });
describe('DepreciationPage', () => { it('lists runs with the skipped flag and runs a month, showing the refusal', async () => { const posted: unknown[] = []; server.use( http.get(apiPath('/assets/depreciation-runs'), () => HttpResponse.json({ runs: [{ id: 'r1', period_id: 'p8', period_start: '2026-08-01', period_end: '2026-08-31', status: 'skipped', journal_entry_id: null, asset_count: 0, total: '0.0000', run_by: null, run_at: '2026-09-01T00:10:00Z' }], }), ), http.get(apiPath('/accounting/fiscal-years'), () => HttpResponse.json({ years: [{ id: 'y', name: 'FY2026', starts_on: '2026-01-01', ends_on: '2026-12-31', end_year: 2026, status: 'open', closed_at: null, closed_by: null, periods: [period(8, '2026-08-01', '2026-08-31'), period(9, '2026-09-01', '2026-09-30')] }], }), ), http.post(apiPath('/assets/depreciation-runs'), async ({ request }) => { posted.push(await request.json()); return HttpResponse.json({ type: 'about:blank', title: 'Conflict', status: 409, code: 'run_exists', detail: 'that period already has a depreciation run' }, { status: 409 }); }), ); const { user } = await renderWithApp({ path: '/assets/depreciation', permissions: ['assets.asset.read', 'assets.depreciation.run', 'accounting.ledger.read'] }); expect(await screen.findByText('Skipped: period closed')).toBeInTheDocument(); await user.click(screen.getByRole('button', { name: 'Run depreciation' })); await user.click(await screen.findByRole('combobox', { name: 'Month' })); await user.click(await screen.findByRole('option', { name: /Sep/ })); await user.click(screen.getByRole('button', { name: 'Save' })); await waitFor(() => expect(posted).toEqual([{ period_id: 'p9' }])); expect(await screen.findByRole('alert')).toHaveTextContent('That month already has a depreciation run.'); });});web/src/features/assets/__tests__/DisposalDrawer.test.tsx:
import { describe, expect, it } from 'vitest';import { screen } from '@testing-library/react';import { http, HttpResponse } from 'msw';import { apiPath } from '@/test/msw/handlers';import { server } from '@/test/msw/server';import { renderWithApp } from '@/test/renderWithApp';
const disposal = { id: 'd1', asset_id: 'as1', asset_number: 'FA-2026-0001', kind: 'sale', disposed_on: '2026-09-10', reason: 'Sold to a scrap dealer', instance_id: 'i1', prepared_by: 'm1', state: 'disposed', semantic: 'effect', catch_up: '1000.0000', accumulated: '9000.0000', nbv: '3000.0000', created_at: '2026-09-21T01:00:00Z',};
describe('DisposalDrawer', () => { it('shows what the approved disposal posted', async () => { server.use( http.get(apiPath('/assets/disposals'), () => HttpResponse.json({ disposals: [disposal] })), http.get(apiPath('/assets/disposals/:id'), () => HttpResponse.json({ disposal, available_transitions: [], decisions: [] })), ); await renderWithApp({ path: '/assets/disposals?disposal=d1', permissions: ['assets.asset.read'] }); expect(await screen.findByText('Net book value written off')).toBeInTheDocument(); expect(screen.getAllByText('RM 3,000.00').length).toBeGreaterThan(0); expect(screen.getAllByText('RM 1,000.00').length).toBeGreaterThan(0); });});web/src/features/assets/__tests__/AssetReportsPage.test.tsx:
import { describe, expect, it } from 'vitest';import { screen } from '@testing-library/react';import { http, HttpResponse } from 'msw';import { apiPath } from '@/test/msw/handlers';import { server } from '@/test/msw/server';import { renderWithApp } from '@/test/renderWithApp';
describe('AssetReportsPage', () => { it('flags a register that disagrees with the ledger', async () => { server.use( http.get(apiPath('/assets/reports/register-vs-gl'), () => HttpResponse.json({ rows: [{ account_id: 'a1110', role: 'cost', register: '0.0000', ledger: '5000.0000', difference: '-5000.0000' }], })), http.get(apiPath('/accounting/accounts'), () => HttpResponse.json({ accounts: [] })), ); await renderWithApp({ path: '/assets/reports?tab=registerVsGl', permissions: ['assets.asset.read'] }); expect(await screen.findByText(/The register and the ledger differ/)).toBeInTheDocument(); expect(screen.getByText('-RM 5,000.00')).toBeInTheDocument(); });});Run: pnpm -C web test -- features/assets
Expected: FAIL — no route /assets/depreciation.
- Step 2:
DepreciationPage.tsx.
import { useMemo, useState } from 'react';import { useTranslation } from 'react-i18next';import { Button } from '@/components/ui/Button';import { DataTable, type ColumnDef } from '@/components/ui/DataTable';import { Dialog } from '@/components/ui/Dialog';import { EmptyState } from '@/components/ui/EmptyState';import { Field } from '@/components/ui/Field';import { PageHeader } from '@/components/ui/PageHeader';import { Select } from '@/components/ui/Select';import { Tag, type TagTone } from '@/components/ui/Tag';import { toast } from '@/components/ui/Toaster';import { MoneyText } from '@/components/money/MoneyText';import { useFiscalYears } from '@/features/accounting/queries';import { useCan, useMe } from '@/features/auth/session';import { problemMessage } from '@/lib/api/problem';import { formatDate, formatDateTime, todayISO } from '@/lib/date';import { useLocale } from '@/lib/i18n';import { useRunDepreciation, useRuns, type DepreciationRun } from './api';
const TONE: Record<DepreciationRun['status'], TagTone> = { posted: 'green', empty: 'neutral', skipped: 'amber' };const monthLabel = (iso: string, locale: string) => formatDate(iso, locale, { month: 'short', year: 'numeric' });
export function DepreciationPage() { const { t } = useTranslation('assets'); const [locale] = useLocale(); const base = useMe().data?.organization.base_currency ?? 'MYR'; const canRun = useCan('assets.depreciation.run'); const runs = useRuns(); const [running, setRunning] = useState(false); const columns = useMemo<ColumnDef<DepreciationRun>[]>( () => [ { id: 'period', meta: { required: true }, header: t('run.period'), cell: ({ row }) => monthLabel(row.original.period_start, locale) }, { id: 'status', header: t('run.status'), cell: ({ row }) => <Tag tone={TONE[row.original.status]}>{t(`runStatus.${row.original.status}`)}</Tag>, }, { id: 'assets', header: t('run.assets'), cell: ({ row }) => row.original.asset_count }, { id: 'total', header: t('run.total'), cell: ({ row }) => <MoneyText amount={row.original.total} currency={base} /> }, { id: 'runAt', header: t('run.runAt'), cell: ({ row }) => `${formatDateTime(row.original.run_at, locale)}${row.original.run_by ? '' : ` · ${t('run.scheduled')}`}`, }, ], [t, locale, base], ); return ( <div className="flex flex-col gap-4"> <PageHeader title={t('page.depreciation')} subtitle={t('page.depreciationSubtitle')} actions={canRun && <Button onClick={() => setRunning(true)}>{t('action.run')}</Button>} /> <DataTable tableId="assets.runs" columns={columns} data={runs.data ?? []} loading={runs.isLoading} getRowId={(r: DepreciationRun) => r.id} empty={<EmptyState title={t('empty.runs')} />} /> {running && <RunDialog onClose={() => setRunning(false)} />} </div> );}
function RunDialog({ onClose }: { onClose(): void }) { const { t } = useTranslation('assets'); const { t: tc } = useTranslation(); const [locale] = useLocale(); const years = useFiscalYears(); const run = useRunDepreciation(); const [period, setPeriod] = useState<string>(); const [error, setError] = useState(''); const today = todayISO(); const periods = (years.data ?? []).flatMap((y) => y.periods).filter((p) => p.starts_on <= today); async function submit() { if (!period) return; setError(''); try { const r = await run.mutateAsync(period); toast.success(t('run.done', { period: monthLabel(r.period_start, locale) })); onClose(); } catch (e) { setError(problemMessage(e, tc)); } } return ( <Dialog open onOpenChange={(o: boolean) => !o && onClose()} title={t('run.title')} footer={ <Button onClick={() => void submit()} loading={run.isPending} disabled={!period}> {tc('actions.save')} </Button> } > <Field label={t('run.period')} id="fr-period"> <Select id="fr-period" aria-label={t('run.period')} value={period} onValueChange={setPeriod} options={periods.map((p) => ({ value: p.id, label: monthLabel(p.starts_on, locale) }))} /> </Field> {error && ( <p role="alert" className="mt-3 text-caption text-tone-red-fg"> {error} </p> )} </Dialog> );}(formatDate’s third argument takes { month, year, day }; comparing ISO YYYY-MM-DD strings with <= is a date comparison, not arithmetic.)
- Step 3:
DisposalsPage.tsxandDisposalDrawer.tsx.
import { useMemo } from 'react';import { useTranslation } from 'react-i18next';import { DataTable, type ColumnDef } from '@/components/ui/DataTable';import { EmptyState } from '@/components/ui/EmptyState';import { PageHeader } from '@/components/ui/PageHeader';import { MoneyText } from '@/components/money/MoneyText';import { StatusChip } from '@/components/workflow/StatusChip';import { useMe } from '@/features/auth/session';import { formatDate } from '@/lib/date';import { useLocale } from '@/lib/i18n';import { useDisposals, type AssetDisposal } from './api';
export function DisposalsPage({ onOpen }: { onOpen(id: string): void }) { const { t } = useTranslation('assets'); const [locale] = useLocale(); const base = useMe().data?.organization.base_currency ?? 'MYR'; const list = useDisposals(); const columns = useMemo<ColumnDef<AssetDisposal>[]>( () => [ { id: 'asset', meta: { required: true }, header: t('disposal.asset'), cell: ({ row }) => <span className="font-mono text-caption">{row.original.asset_number}</span>, }, { id: 'kind', header: t('disposal.kind'), cell: ({ row }) => t(`kind.${row.original.kind}`) }, { id: 'date', header: t('disposal.date'), cell: ({ row }) => formatDate(row.original.disposed_on, locale) }, { id: 'state', header: t('disposal.state'), cell: ({ row }) => ( <StatusChip kind="asset_disposal" state={row.original.state} semantic={row.original.semantic} /> ), }, { id: 'nbv', header: t('disposal.nbv'), cell: ({ row }) => (row.original.nbv ? <MoneyText amount={row.original.nbv} currency={base} /> : ''), }, ], [t, locale, base], ); return ( <div className="flex flex-col gap-4"> <PageHeader title={t('page.disposals')} subtitle={t('page.disposalsSubtitle')} /> <DataTable tableId="assets.disposals" columns={columns} data={list.data ?? []} loading={list.isLoading} getRowId={(d: AssetDisposal) => d.id} onRowClick={(d: AssetDisposal) => onOpen(d.id)} empty={<EmptyState title={t('empty.disposals')} />} /> </div> );}import { useTranslation } from 'react-i18next';import { Drawer } from '@/components/ui/Drawer';import { Skeleton } from '@/components/ui/Skeleton';import { MoneyText } from '@/components/money/MoneyText';import { DecisionTimeline } from '@/components/workflow/DecisionTimeline';import { StatusChip } from '@/components/workflow/StatusChip';import { TransitionBar } from '@/components/workflow/TransitionBar';import { useCan, useMe } from '@/features/auth/session';import { formatDate } from '@/lib/date';import { useLocale } from '@/lib/i18n';import { useDisposal, useDisposalTransition } from './api';
export function DisposalDrawer({ id, onClose }: { id: string; onClose(): void }) { const { t } = useTranslation('assets'); const [locale] = useLocale(); const base = useMe().data?.organization.base_currency ?? 'MYR'; const q = useDisposal(id); const move = useDisposalTransition(id); const canTransition = useCan('assets.disposal.transition'); const d = q.data?.disposal; const figure = (label: string, v: string | null | undefined) => v ? ( <div className="flex justify-between gap-4"> <dt className="text-caption text-fg-muted">{label}</dt> <dd> <MoneyText amount={v} currency={base} /> </dd> </div> ) : null; return ( <Drawer open onOpenChange={(o: boolean) => !o && onClose()} title={t('disposal.detailTitle')}> {!q.data || !d ? ( <Skeleton className="h-96 w-full" /> ) : ( <div className="flex flex-col gap-4"> <div className="flex items-start justify-between gap-2"> <h3 className="text-subtitle font-bold text-fg"> {d.asset_number} · {t(`kind.${d.kind}`)} </h3> <StatusChip kind="asset_disposal" state={d.state} semantic={d.semantic} /> </div> <p className="text-caption text-fg-muted"> {formatDate(d.disposed_on, locale)} · {d.reason} </p> <dl className="flex flex-col gap-2 rounded-card bg-bg-subtle p-4"> {figure(t('disposal.catchUp'), d.catch_up)} {figure(t('disposal.accumulated'), d.accumulated)} {figure(t('disposal.nbv'), d.nbv)} </dl> {canTransition && ( <TransitionBar kind="asset_disposal" transitions={q.data.available_transitions} onTake={(tr, reason) => move.mutateAsync({ from: d.state, to: tr.to, ...(reason ? { reason } : {}) })} /> )} <DecisionTimeline kind="asset_disposal" currentState={d.state} decisions={q.data.decisions} /> </div> )} </Drawer> );}- Step 4:
AssetReportsPage.tsx.
import { useState } from 'react';import { useTranslation } from 'react-i18next';import { DatePicker } from '@/components/ui/DatePicker';import { Field } from '@/components/ui/Field';import { PageHeader } from '@/components/ui/PageHeader';import { Select } from '@/components/ui/Select';import { Skeleton } from '@/components/ui/Skeleton';import { Tabs } from '@/components/ui/Tabs';import { MoneyText } from '@/components/money/MoneyText';import { useAccounts } from '@/features/accounting/queries';import { useMe } from '@/features/auth/session';import { todayISO } from '@/lib/date';import { isZeroMoney } from '@/lib/money';import { useAssetClasses, useRegisterVsGL, useRollForward, useScheduleReport, type AssetRollForwardRow } from './api';import { pickLabel, useLocale } from '@/lib/i18n';
export type AssetReportTab = 'schedule' | 'rollForward' | 'registerVsGl';
const th = 'px-2 py-1 text-left text-caption text-fg-muted';const td = 'px-2 py-1 text-right';
export function AssetReportsPage({ tab, onTab }: { tab: AssetReportTab; onTab(t: AssetReportTab): void }) { const { t } = useTranslation('assets'); return ( <div className="flex flex-col gap-4"> <PageHeader title={t('page.reports')} /> <Tabs value={tab} onValueChange={(v: string) => onTab(v as AssetReportTab)} items={(['schedule', 'rollForward', 'registerVsGl'] as const).map((v) => ({ value: v, label: t(`report.${v}`) }))} /> {tab === 'schedule' && <Schedule />} {tab === 'rollForward' && <RollForward />} {tab === 'registerVsGl' && <RegisterVsGL />} </div> );}
function useBase() { return useMe().data?.organization.base_currency ?? 'MYR';}
function Schedule() { const { t } = useTranslation('assets'); const base = useBase(); const [months, setMonths] = useState(12); const q = useScheduleReport(months); return ( <div className="flex flex-col gap-3"> <Select aria-label={t('report.months')} value={String(months)} onValueChange={(v: string) => setMonths(Number.parseInt(v, 10))} options={[12, 24, 36, 60].map((n) => ({ value: String(n), label: String(n) }))} /> {!q.data ? ( <Skeleton className="h-48 w-full" /> ) : ( <div className="overflow-x-auto"> <table className="w-full text-body"> <thead> <tr> <th className={th}>{t('asset.number')}</th> {q.data.months.map((m) => ( <th key={m} className={th}>{m}</th> ))} </tr> </thead> <tbody> {q.data.rows.map((r) => ( <tr key={r.asset_id}> <td className="px-2 py-1 font-mono text-caption">{r.number}</td> {r.charges.map((c, i) => ( <td key={i} className={td}><MoneyText amount={c} currency={base} /></td> ))} </tr> ))} <tr className="font-bold"> <td className="px-2 py-1">{t('report.total')}</td> {q.data.totals.map((c, i) => ( <td key={i} className={td}><MoneyText amount={c} currency={base} /></td> ))} </tr> </tbody> </table> </div> )} </div> );}
const RF_COLUMNS = ['costOpening', 'additions', 'costDisposals', 'costClosing', 'accumOpening', 'accumAcquired', 'charge', 'accumDisposals', 'accumClosing'] as const;const RF_FIELD: Record<(typeof RF_COLUMNS)[number], keyof AssetRollForwardRow> = { costOpening: 'cost_opening', additions: 'additions', costDisposals: 'cost_disposals', costClosing: 'cost_closing', accumOpening: 'accum_opening', accumAcquired: 'accum_acquired', charge: 'charge', accumDisposals: 'accum_disposals', accumClosing: 'accum_closing',};
function RollForward() { const { t } = useTranslation('assets'); const [locale] = useLocale(); const base = useBase(); const classes = useAssetClasses(); const today = todayISO(); const [from, setFrom] = useState(`${today.slice(0, 4)}-01-01`); const [to, setTo] = useState(today); const q = useRollForward(from, to); const label = (r: AssetRollForwardRow) => { const k = classes.data?.find((c) => c.id === r.class_id); return k ? `${k.code} · ${pickLabel(k.names, locale)}` : r.class_code; }; const row = (r: AssetRollForwardRow, name: string, bold = false) => ( <tr key={name} className={bold ? 'font-bold' : undefined}> <td className="px-2 py-1">{name}</td> {RF_COLUMNS.map((c) => ( <td key={c} className={td}><MoneyText amount={r[RF_FIELD[c]] as string} currency={base} /></td> ))} </tr> ); return ( <div className="flex flex-col gap-3"> <div className="flex gap-4"> <Field label={t('report.from')} id="rf-from"> <DatePicker id="rf-from" value={from} onChange={(x?: string) => setFrom(x ?? from)} /> </Field> <Field label={t('report.to')} id="rf-to"> <DatePicker id="rf-to" value={to} onChange={(x?: string) => setTo(x ?? to)} /> </Field> </div> {!q.data ? ( <Skeleton className="h-48 w-full" /> ) : ( <div className="overflow-x-auto"> <table className="w-full text-body"> <thead> <tr> <th className={th}>{t('asset.class')}</th> {RF_COLUMNS.map((c) => ( <th key={c} className={th}>{t(`report.${c}`)}</th> ))} </tr> </thead> <tbody> {q.data.rows.map((r) => row(r, label(r)))} {row(q.data.total, t('report.total'), true)} </tbody> </table> </div> )} </div> );}
function RegisterVsGL() { const { t } = useTranslation('assets'); const base = useBase(); const q = useRegisterVsGL(); const accounts = useAccounts(); if (!q.data) return <Skeleton className="h-48 w-full" />; const agree = q.data.every((r) => isZeroMoney(r.difference)); const name = (id: string) => { const a = accounts.data?.find((x) => x.id === id); return a ? `${a.code} · ${a.label}` : ''; }; return ( <div className="flex flex-col gap-3"> <p role="status" className={agree ? 'text-tone-green-fg' : 'text-tone-red-fg'}> {t(agree ? 'report.agree' : 'report.disagree')} </p> <table className="w-full text-body"> <thead> <tr> <th className={th}>{t('report.account')}</th> <th className={th}>{t('report.role')}</th> <th className={th}>{t('report.register')}</th> <th className={th}>{t('report.ledger')}</th> <th className={th}>{t('report.difference')}</th> </tr> </thead> <tbody> {q.data.map((r) => ( <tr key={`${r.account_id}-${r.role}`}> <td className="px-2 py-1">{name(r.account_id)}</td> <td className="px-2 py-1">{t(`role.${r.role}`)}</td> <td className={td}><MoneyText amount={r.register} currency={base} /></td> <td className={td}><MoneyText amount={r.ledger} currency={base} /></td> <td className={td}><MoneyText amount={r.difference} currency={base} /></td> </tr> ))} </tbody> </table> </div> );}(Number.parseInt of a month count is not money.)
- Step 5: Routes and nav. In
web/src/features/assets/routes.tsxadd the importsDepreciationPage,DisposalsPage,DisposalDrawer,AssetReportsPage, type AssetReportTab, and beforeexport const routes:
const depreciationRoute = createRoute({ getParentRoute: () => appRoute, path: 'assets/depreciation', component: () => ( <RequirePermission permission={READ}> <DepreciationPage /> </RequirePermission> ),});
const disposalSearch = z.object({ disposal: z.string().optional().catch(undefined) });const disposalsRoute = createRoute({ getParentRoute: () => appRoute, path: 'assets/disposals', validateSearch: (s: Record<string, unknown>) => disposalSearch.parse(s), component: function R() { const s = disposalsRoute.useSearch(); const navigate = disposalsRoute.useNavigate(); const open = (disposal?: string) => void navigate({ search: { disposal } }); return ( <RequirePermission permission={READ}> <DisposalsPage onOpen={open} /> {s.disposal && <DisposalDrawer id={s.disposal} onClose={() => open(undefined)} />} </RequirePermission> ); },});
const reportSearch = z.object({ tab: z.enum(['schedule', 'rollForward', 'registerVsGl']).catch('schedule'),});const reportsRoute = createRoute({ getParentRoute: () => appRoute, path: 'assets/reports', validateSearch: (s: Record<string, unknown>) => reportSearch.parse(s), component: function R() { const s = reportsRoute.useSearch(); const navigate = reportsRoute.useNavigate(); return ( <RequirePermission permission={READ}> <AssetReportsPage tab={s.tab} onTab={(tab: AssetReportTab) => void navigate({ search: { tab } })} /> </RequirePermission> ); },});and export const routes = [assetsRoute, classesRoute, depreciationRoute, disposalsRoute, reportsRoute];. In nav.ts import TrendingDown, PackageMinus, FileSpreadsheet and append:
{ group: 'accounting', to: '/assets/depreciation', labelKey: 'assets:nav.depreciation', icon: TrendingDown, permission: 'assets.asset.read', }, { group: 'accounting', to: '/assets/disposals', labelKey: 'assets:nav.disposals', icon: PackageMinus, permission: 'assets.asset.read', }, { group: 'accounting', to: '/assets/reports', labelKey: 'assets:nav.reports', icon: FileSpreadsheet, permission: 'assets.asset.read', },- Step 6: Import and module labels.
web/src/features/imports/MigrationPage.tsxSTEPSgains, afterdocuments:{ key: 'assets', kinds: ['fixed_asset'], optional: true },. Locale keys:
| File | Path | en | ms | zh |
|---|---|---|---|---|
imports.json |
kinds.fixed_asset |
Opening fixed assets | Aset tetap pembukaan | 期初固定资产 |
imports.json |
migration.steps.assets |
Fixed asset register | Daftar aset tetap | 固定资产登记册 |
modules.json |
names.assets |
Fixed assets | Aset tetap | 固定资产 |
modules.json |
descriptions.assets |
Asset register, monthly depreciation and disposals. | Daftar aset, susut nilai bulanan dan pelupusan. | 资产登记册、月度折旧与处置。 |
Where O2 Task 1 left them out (check each first), also add effect: 'effect' last in web/src/features/workflow/graphModel.ts SEMANTICS, and these keys:
| File | Object | Key | en | ms | zh |
|---|---|---|---|---|---|
journal.json |
semantic |
effect |
Effect | Kesan | 生效 |
workflowEditor.json |
semantic |
effect |
Effect (posts with no control account) | Kesan (mengepos tanpa akaun kawalan) | 生效(不经控制账户过账) |
-
Step 7: Run.
pnpm -C web typecheck && pnpm -C web lint && pnpm -C web test→ PASS (whole web suite: nav, i18n parity, nofloat, MigrationPage). -
Step 8: Sabotage. In
RegisterVsGL, changeeverytosome; runAssetReportsPage.test.tsx; read the red; restore. Record it. -
Step 9: Commit.
git add -A && git commit -m "O4 Task 11: web depreciation, disposals and fixed asset reports" -m "<sabotage message>" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
Task 12: Roadmap, deploy notes and the full suite
Section titled “Task 12: Roadmap, deploy notes and the full suite”Files:
- Modify:
docs/ROADMAP.md,docs/DEPLOY_NOTES.md
Interfaces:
-
Consumes: Tasks 1–11 merged.
-
Produces: nothing new in code.
-
Step 1: ROADMAP. Under
## Phase 4 — Operations, replace- [ ] Fixed assets: register, depreciation schedules, disposalwith:
- [x] Fixed assets — operations design [`2026-09-30-operations-design.md`](superpowers/specs/2026-09-30-operations-design.md) §O4, plan [`2026-09-30-phase-4-o4-fixed-assets.md`](superpowers/plans/2026-09-30-phase-4-o4-fixed-assets.md) (`00770`–`00773`): asset classes (four accounts, straight line or reducing balance, fixed once created); assets registered from a posted purchase invoice line, a manual journal line or the `fixed_asset` opening import, never posting; a monthly depreciation run (one manual entry, source `depreciation_run`, unique per period, caught up after a late registration or a closed month, which is skipped and flagged) run daily by River for the previous month and by hand at Accounting → Depreciation; disposals and write-offs through the `asset_disposal` workflow, depreciated through the disposal month and posted on `effect`; reports (register, schedule forecast, roll-forward, register vs ledger); the `asset_register_gl` integrity check (nightly, and before every close); module `assets`. Book depreciation only (OD-4)(If O2 has not merged, also note on the Inventory line that the effect semantic landed with O4.)
Under ### Needs an outside authority, append after item 13:
14. **Capital allowance (ITA 1967 Schedule 3)** — the tax agent computes initial and annual allowances, balancing adjustments on disposal and the qualifying-expenditure classes (OD-4). Until then: the system keeps book depreciation only (MFRS 116), the fixed asset register and its disposals give the agent cost, in-service and disposal dates and proceeds, and no tax computation depends on it.- Step 2: DEPLOY_NOTES. Insert directly after the first
---line (newest first):
## Phase 4 O4 — fixed assets (`00770`–`00773`)
- **Migrations.** `00770` adds the `effect` workflow semantic (only if lane O2's `00720` has not already); `00771` the five fixed-asset tables; `00772` switches on the optional module `assets` and grants its five keys to every existing Owner and Finance role (`perm_version` bumps, so members pick them up at their next token refresh); `00773` the `fixed_asset` import kind.- **Re-run provisioning** (`POST /v1/org/provision`, once per organization, after migrating): it seeds the `asset_disposal` workflow. Until then creating a disposal answers "no published definition".- **New River job `assets_depreciation`** on the `maintenance` queue, every 24 hours (not on start): for each organization with the module on and at least one asset, it runs the previous month's depreciation once. Nothing to configure. A month whose period is closed gets a `skipped` run and the next run catches it up — reopening the period is not needed. A scheduled run's journal entry is a manual entry naming the member on the organization's root (Owner) seat as its poster; an organization with nobody on that seat is logged (`no member holds the organization's root seat`) and must run the month by hand.- **Period close gains a check.** `asset_register_gl` compares the register with the class accounts. An organization whose classes name an account that already carries costs not registered as assets will see its next close refused until those costs are registered (Fixed assets → Register asset) or the close is made unreconciled. It also appears in the operator console's integrity table.- **Accounts.** Nothing is provisioned: an organization creates a Depreciation expense and a Disposal gain or loss account in its chart before its first asset class.-
Step 3: The full suite.
make generate && git status --short(nothing),make lint,go test -p 2 -count=1 ./...,make web→ all PASS. Read the counts: the RLS coverage, retention, constraint-name, tenant-predicate and OpenAPI drift tests each name the new tables or routes in their-voutput. -
Step 4: Commit.
git add docs && git commit -m "O4 Task 12: roadmap, outside authority and deploy notes for fixed assets" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
Self-review
Section titled “Self-review”Spec coverage (§O4 and the lane’s share of the shared sections):
| Spec item | Task |
|---|---|
asset_class: names as translation rows, four accounts, method, life, rate, residual % |
2, 4 |
asset: own number series, class, en/ms description, cost, residual, in-service date, shares, source (AP line / journal line / opening import), registration never posts, one registration per source line |
2, 4, 8 |
depreciation_run unique (org, period); River periodic job for the previous month and a manual trigger; one entry per run, one line per asset with its shares; closed period skipped and flagged; whole months from the in-service month; straight line with final-month rounding; reducing balance floored at residual |
2, 3, 5, 9 |
Disposal / write-off: workflow kind asset_disposal, approved, depreciates to the disposal month, posts effect (Dr accumulated, Cr cost, NBV to gain/loss); proceeds through AR; write-off without proceeds |
1, 6 |
fixed_asset import kind (cost, accumulated to date, in-service date), registering without posting |
8 |
| Reports: register, schedule forecast, roll-forward, register vs GL | 4 (register list), 7, 9, 11 |
| Integrity: register = class accounts’ GL — test and operator console check | 7 |
Permissions assets.asset.*, assets.depreciation.run, assets.disposal.* |
9 |
effect semantic (“approved, posts with no control account”) |
1 (O2 Task 1, canonical) |
| Drift tests: a second run posts nothing; register totals = class GL balances; every new table tenant-isolated | 5, 7, 2 |
| OD-4 book depreciation only; capital allowance on “Needs an outside authority” | 12 |
| Module install entry, web screens, en/ms/zh | 9, 10, 11 |
| Roadmap tick | 12 |
Placeholder scan: every code step carries its code; helper signatures outside this lane (ledger.ReverseInput, toast, Button variants, formatDate, RecordLanguageHint, money.MustParseRate) were read from the tree while writing this plan.
Type consistency: Terms/Scheduled/Due/Forecast (Task 3) are used with those names in 5–7; termsOf, Asset.posted, lockAsset, insertAsset, classByCode(ctx, tx, code) (Task 4) match their uses in 5, 6, 8; RunDepreciation(ctx, tx, c, periodID, by *uuid.UUID) and ScheduledRun/RunScheduled (Task 5) match Task 9’s handler and job test; TransitionInput{ID, From, To, Actor, Reason} (Task 6) matches the handler; RollForward returns ([]RollForwardRow, RollForwardRow, error) in Tasks 7 and 9; the wire names in Task 9’s DTOs match its OpenAPI schemas and Tasks 10–11’s fixtures.