Assets — fixed-asset lifecycle & GL integration

The whole assets domain reduces to one idea:

A fixed Asset is a thin master record that owns four GL touch-points — acquisition, depreciation, manual transactions, and disposal — and every money movement is a balanced set of AccountTransaction legs, never a stored balance on the asset. The asset row carries only its identity (name, code, cost, life, method) plus FK pointers to the accounts it posts against (accountId, paymentAccountId, aadAccountId, depreciationAccountId) and to the journal entries it produced (depreciationEntryId, disposalEntryId). Net book value, accumulated depreciation, and "can I dispose this" are all derived by aggregating the ledger — with one denormalised cache (accumulatedDepreciation) for fast list rendering. Delete the asset → its legs are deleted → the books self-correct.

Source: BE src/modules/assets · Admin src/modules/assets + src/pages/assets

This domain is a single backend module (assets) with no sub-modules. It is documented in two files:

  • this overview — the lifecycle, entity map, GL wiring, and shared enums/permissions;
  • fixed-assets-depreciation.md — the register, depreciation formulas, the depreciation run, and every depreciation/disposal GL leg (the rebuild-grade detail).

The assets module does not own the ledger. It calls into the finance domain for every posting: transaction (the leg writer), journal (the balanced header that groups depreciation/disposal legs), and account (the chart of accounts + the whole-book validateBalanced assertion run after every asset write).


1. What the domain is responsible for

The assets module manages fixed / capital assets end-to-end:

  1. Acquire — create an asset (name, code, cost, life, method, accounts) and post the purchase entry (Dr asset account, Cr payment account, + tax legs).
  2. Depreciate — run periodic depreciation (straight-line or reducing-balance) across one or many assets, posting Dr depreciation expense / Cr accumulated depreciation per asset, merged into one journal entry per batch.
  3. Manual transactions — post ad-hoc legs against an asset (PURCHASE/SALES/AAD) via addAssetTransaction, each a balanced two-leg pair.
  4. Dispose — retire an asset (DISPOSE / SALE / SCRAP), posting the disposal entry (Dr proceeds, Cr asset net book value, Dr/Cr gain-or-loss) and stamping disposalEntryId.
  5. Report / import / export — XLSX import (preview → confirm, upsert by Asset Code), and a family of XLSX report downloads (register, acquisition, depreciation, disposal).

It explicitly does NOT:

  • Store balances or own debit/credit legs — that is transaction.
  • Build the balanced depreciation/disposal header itself — it delegates to journal addEntry, which validates Σdebit == Σcredit.
  • Revalue assets to a new fair value, or run a multi-period future depreciation schedule table. Depreciation is computed on demand for the months elapsed since the last run (see §3.2 and fixed-assets-depreciation.md §4). There is no stored forward schedule, and no transfer/revaluation document — "transfer" between branches/locations is just editing branchId / locationId on the asset (no GL movement).

2. Entity map

                         ┌──────────────────────────────────────────────┐
                         │  Asset  (collection: assets)                  │
                         │  identity + cost + life + method + FK pointers│
                         └──────────────────────────────────────────────┘
   FK pointers to accounts ──┬── accountId             → finance_accounts (the asset / PP&E account)
   (chart of accounts)       ├── paymentAccountId      → finance_accounts (cash/bank/payable)
                             ├── aadAccountId           → finance_accounts (Accumulated Depreciation — contra)
                             └── depreciationAccountId  → finance_accounts (Depreciation Expense)

   FK pointers to journals ──┬── depreciationEntryId   → finance_journal_entries (last depreciation batch)
   (produced documents)      └── disposalEntryId       → finance_journal_entries (disposal entry)

   master / people refs ─────┬── locationId, departmentId → masters
                             ├── taxId, taxIds            → finance_taxation
                             └── vendorId                 → users

   GL legs produced ─────────── finance_account_transactions  (refId = asset._id, ref2Id = asset._id)
        (acquisition, depreciation, manual, disposal — all kind = AssetEntry; tax legs = TaxEntry)

Each asset produces ledger legs in finance_account_transactions keyed by refId = asset._id (and ref2Id = asset._id for depreciation legs). The asset never embeds those legs; the resolver joins them at read time via aggregation lookups ($lookupAadTransactionsForAsset, $lookupTransactionsForAssetassets.schema.ts).

Derived (computed, never stored except the cache)

Derived field How it is computed Where
summary.depreciation (accumulated) detail: Σ amount of CREDIT legs where ref2Id = asset._id; list: the denormalised accumulatedDepreciation assets.resolver.ts → summary
summary.value (net book value) purchaseCost − accumulatedDepreciation (0 if disposed) assets.resolver.ts → summary
lastDepreciationDate documentDate of the last AAD credit leg assets.service.ts → getLastDepreciationDate
depreciationAmount (per-period) the formula in §3.2 / fixed-assets-depreciation.md §4 assets.service.ts → getDepreciationAmount
profitOnDisposal disposalAmount − summary.value assets.service.ts → calculateProfitOnDisposal
procurementType "Cash" if purchaseCost === amount, else "Credit" assets.service.ts → getProcurementType
canDelete / canUpdate false if a disposalEntryId or depreciationEntryId exists resolver + assets.service.ts
accumulatedDepreciation (cache) denormalised; bumped on each depreciation/AAD; backfilled at boot from the AAD credit legs assets.service.ts, assets.repository.ts → backfillAccumulatedDepreciation

accumulatedDepreciation is the one denormalised number in the domain. It exists so list/page views can show net book value without aggregating the ledger per row. The detail view ignores it and re-sums the AAD credit legs. onModuleInit runs backfillAccumulatedDepreciation() once at startup to populate the cache for legacy assets that have depreciation legs but a zero cache.


3. The four GL touch-points (end-to-end flows)

All four run inside withRetryTransaction(...) (one Mongo session) and end with accountSvc.validateBalanced(...) — the whole-company books must still balance or the transaction rolls back. See account §"Whole-book balance assertion".

3.1 Acquire — createAsset

admin CreateAsset form ─▶ createAsset mutation ─▶ AssetService.create
  validateAsset (unique number + ref per company)
  normalizeAssetDates (purchaseDate/warrantyExpiryDate → UTC start-of-day)
  fiscalPeriodSvc.validateTransactionDate(purchaseDate)        ← reject locked period
  guard: amount ≤ purchaseCost
  (txn) super.create(asset, documentDate = purchaseDate)
        addPurchaseTransaction(asset):
            Dr  accountId          purchaseCost            (kind AssetEntry)
            Cr  paymentAccountId   comp.lineTotal (gross)  (kind AssetEntry)
            +   per tax: Dr/Cr tax accountId  t.amount     (kind TaxEntry)
        validateBalanced("(Add asset)")
        upload files if any

comp = taxSvc.resolveLineTaxes(taxIds, purchaseCost) — the payment leg carries the gross (net + additive − deductive tax); the asset leg carries the net cost. Deductive taxes post as a CREDIT, additive as a DEBIT. Update re-writes the purchase legs: updatePurchaseTransaction deletes the old AssetEntry+TaxEntry legs for the asset, then re-runs addPurchaseTransaction.

3.2 Depreciate — depreciateAsset

Per asset the period amount is getDepreciationAmount:

REDUCING_BALANCE → depreciationValue            (a flat per-month figure stored on the asset)
STRAIGHT_LINE    → (purchaseCost − scrapValue) / (expectedLifeSpan × 12)   ← per MONTH, .toFixed(2)

The run multiplies by months elapsed since the last depreciation (monthsToDepreciate), posts Dr Depreciation Expense / Cr Accumulated Depreciation per asset, and merges all assets' legs into one ASSET_DEPRECIATION journal entry. Full formulas, the month math, the end-of-life guard, and the schedule/run mechanics are in fixed-assets-depreciation.md §4 and §6.2.

3.3 Manual transaction — addAssetTransaction

addTransaction(transaction) posts a balanced two-leg pair against the asset (used for ad-hoc adjustments). transactionType{ PURCHASE, SALES, AAD }:

  • For AAD, the asset account leg direction is derived for a "DECREASE" (so it credits the asset's normal balance) and the partner leg flips; the asset's accumulatedDepreciation cache is bumped by amount.
  • For others, the asset account leg is derived for "INCREASE".

getTransactionType(accountId, "INCREASE"|"DECREASE") reads the account's normal balance from its category and returns DEBIT or CREDIT accordingly (see transaction getTransactionType and account §balance). Both legs share one relationId; validateBalanced runs after.

3.4 Dispose — disposeAsset

admin DisposeAsset form ─▶ disposeAsset mutation ─▶ AssetService.disposeAsset
  (txn) journalSvc.addEntry(type = ASSET_DISPOSAL):
            Dr  paymentAccountId      paymentAmount        (sale proceeds)
            Cr  asset.accountId        assetNetBookValue   (remove the asset at NBV)
            Dr/Cr gainOrLossAccountId  gainOrLossAmount    (direction by account type — see below)
        validateBalanced("(Disposal of asset)")
        assetRepo.update(disposalDate, disposalType, disposalAmount = paymentAmount, disposalEntryId)

Gain/loss leg direction is resolved from the chosen account's type (accountSvc.getType(gainOrLossAccountId)): if the account is an EXPENSE (a Loss on Disposal account) the leg is a DEBIT; otherwise (an income Gain on Disposal account) it is a CREDIT. The finance category map has dedicated LOSS ON ASSET DISPOSAL (→ EXPENSE) and GAIN ON ASSET DISPOSAL (→ income) categories (finance/finance.model.ts). The amount itself is entered by the operator on the form — the module does not auto-compute it.

After disposal summary.value resolves to 0 and canDelete/canUpdate become false. There is no un-dispose mutation — reverse by deleting the asset (removes its legs) or posting a manual correcting journal.


4. Accumulated-Depreciation accounts are auto-created by finance

This is the key cross-domain wiring: the AAD (Accumulated Depreciation) contra account an asset depreciates into is not created by the assets module. When an account is created under the category Property, Plant and Equipment (PP&E), the account service automatically creates a child contra account ACC.Depr <name> under category Accumulated Depreciation, with isAad: true, isContra: true, and parentId = the new PP&E account (AccountService.createAadAccount; intangibles get ACC.Amort … via createAaaAccount). See account §"Auto-children".

The depreciation run credits this AAD account (aadAccountId) and debits a Depreciation Expense account (depreciationAccountId); the admin depreciation grid filters the AAD selector to category ACCUMULATED DEPRECIATION and the expense selector to DEPRECIATION EXPENSE (ACCOUNT_CATEGORIES in zerp-admin/.../finance/account/model.ts). Net book value on the balance sheet is therefore PP&E account balance − its Accumulated Depreciation contra balance, both derived from the ledger.


5. Shared enums

// assets.schema.ts
export enum DepreciationMethodTypes {
  STRAIGHT_LINE    = "STRAIGHT_LINE",     // (cost − scrap) / (life × 12) per month  ← default
  REDUCING_BALANCE = "REDUCING_BALANCE"   // flat depreciationValue per month
}

export enum DisposalTypes {
  DISPOSE = "DISPOSE",   // default
  SALE    = "SALE",
  SCRAP   = "SCRAP"
}

// assets.interface.ts — manual asset-transaction direction discriminator
export enum AssetTransactionTypes {
  PURCHASE = "PURCHASE",
  SALES    = "SALES",
  AAD      = "AAD"        // accumulated-depreciation adjustment
}

The asset posting lifecycle reuses the shared finance posting enum AccountTransactionStatus { SAVED, POSTED } (from finance/transaction/transaction.schema.ts) on both the asset header and its legs — postAssetTransactions / saveManyAsset flip all legs + the asset in lockstep. Auto-journals are stamped JournalEntryTypes.ASSET_DEPRECIATION / ASSET_DISPOSAL (see journal §2); the legs themselves carry AccountTransactionKind.AssetEntry (purchase/depreciation/disposal/manual) or TaxEntry (purchase tax legs).


6. Permissions

Module ASSETS (assets), from zerp-admin/src/constants/UserAccess.ts:

Action value grants
VIEW view see the asset list/detail
CREATE create New Asset button + create mutation
UPDATE update edit an asset
DELETE delete delete an asset
DEPRECIATE_ASSET depreciate-asset the Depreciate button / run
VIEW_DISPOSED_ASSET view-disposed-asset the Include Disposed toggle
VIEW_ASSET_DETAILS view-asset-details open the detail view
IMPORT_ASSETS import-assets the Import button / import flow

All GraphQL resolvers are @ApGqlAuthorize(); every mutation carries @AuditMeta({ module: "asset", collection: "assets", … }) (CREATE on create/import; STATUS_CHANGE on depreciate/dispose/post/save; UPDATE on update; DELETE on delete). REST report endpoints are @ApiAuthorize(). Fiscal-period locks are enforced on every dated write via fiscalPeriodSvc.validateTransactionDate (create, update, manual transaction, import confirm). Full RBAC: ../../platform/permissions-access.md. Locked periods: ../../platform/workflow-approval-engine.md.


7. Dependencies & integrations

  • Calls into transaction (create/deleteMany/updateMany/find — every leg), journal (addEntry for depreciation & disposal, findById for the canDelete/canUpdate/last-date checks), account (getType for gain/loss + AAD direction, validateBalanced, findById/findOne for account joins), taxation (resolveLineTaxes for purchase tax legs), master (location/department lookups + import resolution), fiscal (validateTransactionDate), user (vendor), upload (asset images).
  • Called by: nothing creates assets programmatically — assets is a leaf domain. It produces journal entries that show up in finance reports (GL, trial balance, balance sheet via the PP&E / Accumulated Depreciation accounts).
  • Lifecycle hook: onModuleInit → backfillAccumulatedDepreciation() (one-time cache backfill at boot). No cron, no events.

8. Gotchas

  • No stored balances — net book value and accumulated depreciation are aggregations of finance_account_transactions; the only denormalised number is the accumulatedDepreciation cache (and the detail view ignores even that, re-summing the AAD credit legs by ref2Id).
  • AAD account is finance-owned — it is auto-created when a PP&E account is created, not by the assets module (§4). A rebuild must seed/auto-create the Accumulated Depreciation category first.
  • Depreciation is on-demand, not scheduled — there is no forward schedule table; each run computes months elapsed since the last run and posts that catch-up. See fixed-assets-depreciation.md §4.
  • One journal entry per depreciation batch — all assets in a run share a single ASSET_DEPRECIATION entry, and every asset's depreciationEntryId points at it (the depreciation-GL fix; see fixed-assets-depreciation.md §9).
  • "Transfer" is just a field edit — moving an asset between locations/branches/departments is an update of locationId/branchId/departmentId with no GL impact.
  • Disposal is irreversible by mutation — no un-dispose; reverse by delete or a correcting journal.
  • All dated writes anchor to UTC start-of-day and pass through the fiscal-period lock check.