Assets — fixed-asset lifecycle & GL integration
The whole assets domain reduces to one idea:
A fixed
Assetis a thin master record that owns four GL touch-points — acquisition, depreciation, manual transactions, and disposal — and every money movement is a balanced set ofAccountTransactionlegs, 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:
- Acquire — create an asset (name, code, cost, life, method, accounts) and post the purchase entry (Dr asset account, Cr payment account, + tax legs).
- 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.
- Manual transactions — post ad-hoc legs against an asset (
PURCHASE/SALES/AAD) viaaddAssetTransaction, each a balanced two-leg pair. - 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. - 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/locationIdon 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, $lookupTransactionsForAsset — assets.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 |
accumulatedDepreciationis 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.onModuleInitrunsbackfillAccumulatedDepreciation()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'saccumulatedDepreciationcache is bumped byamount. - 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.valueresolves to 0 andcanDelete/canUpdatebecome 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 (addEntryfor depreciation & disposal,findByIdfor thecanDelete/canUpdate/last-date checks), account (getTypefor gain/loss + AAD direction,validateBalanced,findById/findOnefor account joins), taxation (resolveLineTaxesfor 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 theaccumulatedDepreciationcache (and the detail view ignores even that, re-summing the AAD credit legs byref2Id). - 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 Depreciationcategory 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_DEPRECIATIONentry, and every asset'sdepreciationEntryIdpoints 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/departmentIdwith 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.