Fixed Assets & Depreciation — register, methods, runs, and GL postings
The whole fixed-asset register reduces to one idea:
An
Assetis a master row plus four GL touch-points (acquire, depreciate, manual-adjust, dispose), and depreciation is computed on demand —period amount × months elapsed since the last run— never read from a stored schedule. Net book value =purchaseCost − Σ(accumulated depreciation credit legs). Each touch-point writes balancedAccountTransactionlegs (delegating the depreciation/disposal header to the journal module), and the whole-company books are re-asserted after every write. The asset stores no balance — only aaccumulatedDepreciationcache for fast list rendering.
Source: BE src/modules/assets · Admin src/modules/assets + src/pages/assets
See _overview.md for the lifecycle and entity map, account for the auto-created Accumulated-Depreciation accounts, journal for the balanced header that groups depreciation/disposal legs, and transaction for the leg itself and getTransactionType.
1. Purpose & scope
This module owns the fixed-asset register and its accounting:
- Asset CRUD with a posted acquisition entry (Dr asset / Cr payment + tax legs).
- Depreciation by two methods (straight-line, reducing-balance), computed on demand and posted Dr expense / Cr accumulated-depreciation, merged into one journal entry per batch.
- Manual asset transactions (
PURCHASE/SALES/AAD) — balanced two-leg pairs. - Disposal (DISPOSE / SALE / SCRAP) — Dr proceeds, Cr net book value, Dr/Cr gain-or-loss.
- SAVED ↔︎ POSTED status across the asset and all its legs.
- XLSX import (preview → confirm, upsert by Asset Code) and a family of XLSX report downloads.
It does NOT own the ledger, build the balanced header for depreciation/disposal (delegated to journal addEntry), auto-create the Accumulated-Depreciation account (finance does, see account §"Auto-children"), revalue assets, or store a forward depreciation schedule.
2. Data model
assets — Asset (assets.schema.ts)
One row = one fixed asset, scoped to a company. Extends BaseSchema (so _id, key, companyId, branchId, ref, documentCode, documentDate, createdAt/By, updatedAt/By, canView/Update/ Delete/Post). Soft-delete via mongoose-delete.
| field | type | required? | description |
|---|---|---|---|
ref |
string | yes (generated) | document reference, generated { prefix: "ASST" } in assets.repository.ts → create(). |
name |
string | yes (GraphQL) | display name. Default "". |
number |
string | yes (GraphQL) | Asset Code — unique per company (enforced in validateAsset). Default "". |
description / note / puchaseDescription |
string | no | free text (puchaseDescription is the typo in source). |
branchId |
ObjectId | no | branch/warehouse; coerced via BaseSchema.toObjectId. |
purchaseDate |
number (unix ms) | yes (GraphQL) | acquisition date; floored to UTC start-of-day (normalizeAssetDates); drives documentDate and fiscal locks. Default 0. |
warrantyExpiryDate |
number | no | floored to UTC day. Default 0. |
firstUseDate |
number | no | depreciation start date. Depreciation cannot run until this is set. Default 0. |
purchaseCost |
number | yes (GraphQL) | gross cost basis for depreciation. Default 0. |
amount |
number | no | amount actually paid at acquisition; < purchaseCost ⇒ procurementType = "Credit". Must be ≤ purchaseCost. Default 0. |
expectedLifeSpan |
number | no | useful life in years (× 12 = months). Default 0. |
scrapValue |
number | no | residual/salvage value (subtracted in straight-line). Default 0. |
accountId |
ObjectId | yes (GraphQL) | FK → finance_accounts — the asset / PP&E account (debited on acquire, credited at NBV on disposal). |
paymentAccountId |
ObjectId | no | FK → finance_accounts — cash/bank/payable (credited on acquire). |
depreciationMethod |
DepreciationMethodTypes |
no | STRAIGHT_LINE (default) or REDUCING_BALANCE. |
depreciationValue |
number | no | for REDUCING_BALANCE: the flat per-month depreciation figure. (On the admin form, a % capped at 100 for straight-line; the stored per-period amount derives from cost/life.) Default 0. |
accumulatedDepreciation |
number | no | denormalised cache of total depreciation credited; backfilled at boot. Default 0. |
aadAccountId |
ObjectId | no | FK → finance_accounts — Accumulated Depreciation contra account (credited on depreciation). |
depreciationAccountId |
ObjectId | no | FK → finance_accounts — Depreciation Expense account (debited on depreciation). |
depreciationEntryId |
ObjectId | no | FK → finance_journal_entries — the last depreciation batch's journal entry (presence ⇒ canDelete/canUpdate false). |
disposalEntryId |
ObjectId | no | FK → finance_journal_entries — the disposal entry (presence ⇒ disposed). |
disposalType |
DisposalTypes |
no | DISPOSE (default) / SALE / SCRAP. |
disposalDate / disposalAmount / disposalDescription |
number / number / string | no | set on disposal (disposalAmount = proceeds). |
taxId |
ObjectId | no | first tax applied to the purchase (mirrors taxIds[0]). |
taxIds |
ObjectId[] | no | multi-tax on the purchase. |
vendorId |
ObjectId | no | FK → users (the supplier). |
locationId / departmentId |
ObjectId | no | FK → masters. |
imageIds |
ObjectId[] | no | uploaded image file ids. |
status |
AccountTransactionStatus |
no | SAVED (default) / POSTED — kept in lockstep with the legs. |
Indexes: { companyId, deleted }, { companyId, accountId }, { companyId, depreciationEntryId }, { companyId, disposalEntryId }, { companyId, purchaseDate: -1 }. Virtual account → finance_accounts.
// assets.schema.ts
export enum DepreciationMethodTypes { STRAIGHT_LINE = "STRAIGHT_LINE", REDUCING_BALANCE = "REDUCING_BALANCE" }
export enum DisposalTypes { DISPOSE = "DISPOSE", SALE = "SALE", SCRAP = "SCRAP" }
@ApSchema()
export class Asset extends BaseSchema {
@Prop({ default: "" }) name: string;
@Prop({ default: "" }) number: string; // Asset Code (unique per company)
@Prop({ default: 0 }) purchaseDate: number;
@Prop({ default: 0 }) firstUseDate: number; // depreciation start
@Prop({ default: 0 }) purchaseCost: number;
@Prop({ default: 0 }) amount: number;
@Prop({ default: 0 }) expectedLifeSpan: number; // YEARS
@Prop({ default: 0 }) scrapValue: number;
@Prop({ set: v => BaseSchema.toObjectId(v) }) accountId: Types.ObjectId;
@Prop({ set: v => BaseSchema.toObjectId(v) }) paymentAccountId: Types.ObjectId;
@Prop({ type: String, enum: DepreciationMethodTypes, default: DepreciationMethodTypes.STRAIGHT_LINE })
depreciationMethod: string;
@Prop({ default: 0 }) depreciationValue: number;
@Prop({ default: 0 }) accumulatedDepreciation: number; // denormalised cache
@Prop({ set: v => BaseSchema.toObjectId(v) }) aadAccountId: Types.ObjectId; // Acc. Depreciation
@Prop({ set: v => BaseSchema.toObjectId(v) }) depreciationAccountId: Types.ObjectId; // Depreciation Expense
@Prop({ set: v => BaseSchema.toObjectId(v) }) depreciationEntryId: Types.ObjectId; // last batch journal
@Prop({ set: v => BaseSchema.toObjectId(v) }) disposalEntryId: Types.ObjectId;
@Prop({ type: String, enum: DisposalTypes, default: DisposalTypes.DISPOSE }) disposalType: DisposalTypes;
@Prop({ default: 0 }) disposalDate: number;
@Prop({ default: 0 }) disposalAmount: number;
@Prop({ type: String, enum: AccountTransactionStatus, default: AccountTransactionStatus.SAVED })
status: AccountTransactionStatus;
}GL legs (in finance_account_transactions)
Asset legs are stamped kind = AccountTransactionKind.AssetEntry (acquire/depreciate/dispose/manual) or TaxEntry (purchase tax), keyed by refId = asset._id. Depreciation legs additionally set ref2Id = asset._id — the AAD lookups join CREDIT legs by ref2Id so accumulated depreciation is captured regardless of which AAD account was used at run time ($lookupAadTransactionsForAsset, assets.schema.ts). Full leg schema: transaction.
3. API surface
All GraphQL resolvers @ApGqlAuthorize(); resolver extends ApBaseResolver<Asset>. Source: assets.resolver.ts.
| Operation | Type | Input | Returns | Permission (audit) |
|---|---|---|---|---|
assetPage |
query | AssetPageInput (skip/take/sort + filters) |
AssetPageResult { totalRecords, data:[Asset] } |
@ApGqlAuthorize |
findOneAsset |
query | AssetQueryInput |
Asset |
@ApGqlAuthorize |
createAsset |
mutation | asset: CreateAssetInput |
Asset |
audit CREATE |
updateAsset |
mutation | id, asset: UpdateAssetInput |
Asset |
audit UPDATE |
deleteAsset |
mutation | id |
Boolean |
audit DELETE |
deleteManyAsset |
mutation | input: DeleteManyAssetInput { ids } |
Boolean |
audit DELETE |
depreciateAsset |
mutation | depreciate: DepreciateAssetInput { assets:[AssetDepreciationInput] } |
Boolean |
audit STATUS_CHANGE |
disposeAsset |
mutation | dispose: DisposeAssetInput |
Boolean |
audit STATUS_CHANGE |
addAssetTransaction |
mutation | transaction: AssetTransactionInput |
Asset |
audit CREATE |
postAssetTransactions |
mutation | assetId |
Boolean |
audit STATUS_CHANGE |
postManyAsset |
mutation | ids: [String] |
Boolean |
audit STATUS_CHANGE |
saveManyAsset |
mutation | ids: [String] |
Boolean |
audit STATUS_CHANGE |
importAssets |
mutation | import: AssetImportInput { file } |
[AssetImport] (parsed preview, not persisted) |
audit CREATE |
confirmAssetImport |
mutation | import: ConfirmAssetImportInput { assets } |
Boolean |
audit CREATE |
Resolve-fields on Asset (selected): summary (NBV + accumulated depreciation + disposal, see §2 derived), account/aadAccount/depreciationAccount/paymentAccount (joined or looked up), amountWithTax (resolveLineTaxes(taxIds, purchaseCost).lineTotal), depreciationAmount (per-period formula, §4), profitOnDisposal, procurementType, lastDepreciationDate, canDelete/canUpdate (false if disposed or depreciated), canPost (status !== POSTED), images.
# src/schema.gql (trimmed)
input AssetDepreciationInput {
assetId: String! depreciationAmount: Float depreciationDate: Float!
aadAccountId: String depreciationAccountId: String
}
input DepreciateAssetInput { assets: [AssetDepreciationInput!] }
input DisposeAssetInput {
assetId: String! disposalDate: Float! disposalType: DisposalTypes! disposalDescription: String
paymentAccountId: String! paymentAmount: Float! # sale proceeds
assetAccountId: String! assetNetBookValue: Float! # credited to remove the asset
gainOrLossAccountId: String! gainOrLossAmount: Float! # direction by account type
}
input AssetTransactionInput { # extends CreateAccountTransactionInput
accountId: String! amount: Float! type: AccountTransactionTypes documentDate: Float
assetId: String! transactionType: AssetTransactionTypes! # PURCHASE | SALES | AAD
}REST (assets.controller.ts, base api/assets, @ApiAuthorize())
| Method | Route | Report |
|---|---|---|
| GET | api/assets/download?downloadType=xlsx |
Asset report (import-template columns). |
| GET | api/assets/download/export |
Importable export (re-uploadable). |
| GET | api/assets/download/acquisition |
Acquisition report (+ Net Book Value). |
| GET | api/assets/download/register |
Register report (+ NBV, total acc. depreciation, method, scrap, procurement type/date). |
| GET | api/assets/download/depreciation |
Depreciation report (+ Last Dep. Date). |
| GET | api/assets/download/disposal |
Disposal report (+ proceeds, profit on disposal, disposal date/type). |
All downloads accept keyword/fromDate/toDate/departmentId/locationId (+ includeDisposed on the base). Date cells use AP_DATE_FORMAT (changed to "D-MMM-YYYY" by the GL-fixes plan; see §9). The register/acquisition/depreciation/disposal reports lead each row with the import-template columns so a downloaded report can be re-imported (importCompatible).
4. Depreciation methods & formulas
4.1 Per-period amount — getDepreciationAmount(asset)
// assets.service.ts → getDepreciationAmount
public getDepreciationAmount(asset: Asset): number {
if (asset.depreciationMethod === DepreciationMethodTypes.REDUCING_BALANCE) {
return asset.depreciationValue ?? 0; // flat per-month figure
}
if (asset.depreciationMethod === DepreciationMethodTypes.STRAIGHT_LINE) {
const depreciationAount =
((asset.purchaseCost - asset?.scrapValue) / (asset.expectedLifeSpan * 12)).toFixed(2);
return Number(depreciationAount ?? 0); // per-MONTH, rounded to 2dp
}
return 0;
}So the per-month depreciation is:
STRAIGHT_LINE period = (purchaseCost − scrapValue) / (expectedLifeSpan × 12) → .toFixed(2)
REDUCING_BALANCE period = depreciationValue (stored flat amount)
Notes for a rebuild:
expectedLifeSpanis in years;× 12makes it months. The straight-line period amount is a monthly figure (not annual).- "Reducing balance" here is not the classic declining-balance
NBV × rate%recursion — the code returns a single storeddepreciationValueas a flat per-month amount. There is no per-period recomputation against a shrinking book value. (The admin form labels the field as a % capped at 100 for straight-line, but the BEgetDepreciationAmountusesdepreciationValueverbatim forREDUCING_BALANCE.)scrapValueis the salvage floor, subtracted from cost in straight-line only.
4.2 The run amount — months elapsed since the last run
A depreciation run does not post one period; it posts a catch-up for every month since the last depreciation. From depreciateAssets (assets.service.ts):
const depreciationAmount = ast.depreciationAmount ?? this.getDepreciationAmount(asset); // per-month
// start = firstUseDate, OR the documentDate of the last depreciation journal entry if any
let startDate = asset.firstUseDate;
if (asset.depreciationEntryId) {
const lastEntry = await this.journalSvc.findById(asset.depreciationEntryId.toString());
if (lastEntry?.documentDate) startDate = lastEntry.documentDate;
}
const monthsToDepreciate = dayjs(ast.depreciationDate).diff(dayjs(startDate), "month") + 1;
const totalDepreciationAmount = depreciationAmount * monthsToDepreciate; // ← posted this runSo run amount = per-month amount × (depreciationDate − lastRun/firstUse) in whole months + 1. The + 1 makes the run inclusive of the boundary month.
Guards (collected into errors[], all-or-nothing — any error aborts the whole batch):
- First use required —
if (!asset.firstUseDate)→ "not in use, please update the Asset First Use Date before depreciate". - Must move forward —
if (depDate ≤ startDate)→ "depreciation date must be after last depreciation date (…)". - End of life —
monthsSinceFirstUse = depDate.diff(firstUseDate,"month") + 1; ifexpectedLifeSpan × 12 > 0andmonthsSinceFirstUse > totalLifeMonths→ "has reached its end of life and cannot be depreciated further". - Something to post —
if (monthsToDepreciate ≤ 0)→ "no months to depreciate".
If any asset in the request produced an error, depreciateAssets throws HttpException(errors.join("\n"), BAD_REQUEST) before posting anything.
There is no stored schedule table. The "schedule" is implicit: each run reads the last entry's
documentDate, computes the gap in months, and posts that many periods in one lump. The per-assetaadAccountId/depreciationAccountIdfor the run default to the request values, falling back toasset.accountId(AAD) /asset.depreciationAccountId(expense) respectively.
5. Depreciation & disposal GL postings (the legs)
5.1 Acquisition legs — addPurchaseTransaction
| Leg | Account | Amount | Dr/Cr | kind |
|---|---|---|---|---|
| Asset | accountId (PP&E) |
purchaseCost (net) |
DEBIT | AssetEntry |
| Payment | paymentAccountId |
comp.lineTotal (gross = net + additive − deductive tax) |
CREDIT | AssetEntry |
| Tax (per applied tax) | tax accountId |
t.amount |
DEBIT if additive, CREDIT if deductive | TaxEntry |
comp = taxSvc.resolveLineTaxes(taxIds, purchaseCost, false). All legs share one relationId, documentDate = purchaseDate, exchangeRate = 1. See taxation for the additive/deductive split.
5.2 Depreciation legs — depreciateAssets
Per asset, two legs (refId = ref2Id = asset._id, kind = AssetEntry):
| Leg | Account | Amount | Dr/Cr |
|---|---|---|---|
| Accumulated Depreciation (contra) | aadAccountId |
totalDepreciationAmount |
CREDIT |
| Depreciation Expense | depreciationAccountId |
totalDepreciationAmount |
DEBIT |
// assets.service.ts → depreciateAssets (per asset)
[
{ refId: asset._id, ref2Id: asset._id, credit: totalDepreciationAmount,
accountId: asset.aadAccountId, kind: AccountTransactionKind.AssetEntry,
type: AccountTransactionTypes.CREDIT, documentDate: depreciationDate },
{ refId: asset._id, ref2Id: asset._id, debit: totalDepreciationAmount,
accountId: asset.depreciationAccountId, kind: AccountTransactionKind.AssetEntry,
type: AccountTransactionTypes.DEBIT, documentDate: depreciationDate }
]All assets' pairs are flattened and posted as one journal entry:
const allTransactions = transactions.flat();
const batchDocumentDate = assetsToProcess[0].depreciationDate;
const entry = await this.journalSvc.addEntry({
transactions: allTransactions,
type: JournalEntryTypes.ASSET_DEPRECIATION,
description: `Asset Depreciation`,
documentDate: batchDocumentDate
});
// then per asset: depreciationEntryId = entry._id; accumulatedDepreciation += totalDepreciationAmount
await this.accountSvc.validateBalanced(`(Depreciate asset)`);journalSvc.addEntry builds the balanced ASSET_DEPRECIATION header and writes the legs (deriving each leg's DEBIT/CREDIT from debit/credit); the batch nets to zero (Σ debits = Σ credits = Σ all assets' depreciation). See journal §4.2.
5.3 Disposal legs — disposeAsset
| Leg | Account | Amount | Dr/Cr |
|---|---|---|---|
| Proceeds | paymentAccountId |
paymentAmount |
DEBIT |
| Asset removal (at NBV) | asset.accountId |
assetNetBookValue |
CREDIT |
| Gain or loss | gainOrLossAccountId |
gainOrLossAmount |
DEBIT if account is EXPENSE (loss), else CREDIT (gain) |
// assets.service.ts → disposeAsset
const gainOrLossTransactionType = await this.accountSvc.getType(dispose.gainOrLossAccountId);
const entry = await this.journalSvc.addEntry({
transactions: [
{ accountId: dispose.paymentAccountId, debit: dispose.paymentAmount, type: DEBIT },
{ accountId: asset.accountId, credit: dispose.assetNetBookValue, type: CREDIT },
{ accountId: dispose.gainOrLossAccountId,
[gainOrLossTransactionType?.type === "EXPENSE" ? "debit" : "credit"]: dispose.gainOrLossAmount,
type: gainOrLossTransactionType?.type === "EXPENSE" ? DEBIT : CREDIT }
],
type: JournalEntryTypes.ASSET_DISPOSAL,
description: `Disposal of asset ${dispose.assetId}`,
documentDate: dispose.disposalDate
});
await this.accountSvc.validateBalanced(`(Disposal of asset)`);
await this.assetRepo.update(dispose.assetId, {
disposalDate, disposalDescription, disposalType,
disposalAmount: dispose.paymentAmount, disposalEntryId: entry._id
});The gain/loss direction comes from the account's type via accountSvc.getType (normal balance from the category): a Loss on Asset Disposal account is an EXPENSE → DEBIT; a Gain on Asset Disposal account is income → CREDIT (finance maps these two category names explicitly in finance/finance.model.ts). The amount is operator-entered; the module surfaces profitOnDisposal = disposalAmount − summary.value separately for reporting. The whole entry balances: paymentAmount (Dr) + loss (Dr) = assetNetBookValue (Cr) for a loss, or paymentAmount (Dr) = assetNetBookValue (Cr) + gain (Cr) for a gain.
5.4 Manual transaction legs — addAssetTransaction
Two balanced legs sharing a relationId. The asset-account leg direction is derived for "DECREASE" when transactionType === AAD (credits the asset's normal balance) else "INCREASE"; the partner leg takes the opposite direction. For AAD, the accumulatedDepreciation cache is bumped by amount. validateBalanced("(Add asset transaction)") runs after.
6. Business rules & flows
6.1 Create / update / delete
- Create (
AssetService.create):validateAsset(uniquenumber+refper company) →normalizeAssetDates(purchase/warranty → UTC start-of-day) →fiscalPeriodSvc.validateTransactionDate(purchaseDate)→ guardamount ≤ purchaseCost→ (txn)super.create(setsdocumentDate = purchaseDate,ref = ASST…) →addPurchaseTransaction→validateBalanced("(Add asset)")→ upload images. Throws403ifamount > purchaseCost. - Update (
update):validateAsset→ normalize → fiscal check → (txn) upload files →updatePurchaseTransaction(delete old AssetEntry+TaxEntry legs byrefId, re-add) →super.update. Blocked client-side once depreciated/disposed (canUpdatefalse). - Delete (
delete): (txn)transactionSvc.deleteMany({ refId })+fileUploadSvc.deleteByRefIdassetRepo.delete(soft) →validateBalanced("(Delete asset)"). Server allows delete even when depreciated (legs removed, books self-correct); the UI gates it viacanDelete.
6.2 Depreciation run (happy path)
- Admin opens Assets → Depreciate (gated
assets/depreciate-asset) →AssetDepreciationgrid (components/depreciation.tsx). The table lists assets with First Use Date, Last Dep. Date, asset value, and editable per-selected-row: Depreciation Expense Account (filtered to categoryDEPRECIATION EXPENSE), AAD Account (filtered toACCUMULATED DEPRECIATION), Depreciation Amount (defaults togetDepreciationAmount), and Depreciation Date. - User selects rows, sets accounts + amount + date. Client validation: ≥ 1 selected, each has a positive amount + a date + both accounts.
- Submit maps to
assets:[{ assetId, depreciationDate, depreciationAmount, aadAccountId, depreciationAccountId }]→depreciateAssetmutation →AssetService.depreciateAssets. - Service builds
assetsToProcess(resolving start date, months, total amount; running the §4.2 guards), then posts oneASSET_DEPRECIATIONjournal entry with all Dr/Cr pairs (§5.2), updates each asset'sdepreciationEntryId+accumulatedDepreciation, and runsvalidateBalanced. - Side effects: N×2 legs in
finance_account_transactions(one Dr/Cr pair per asset, all under the one entry); each asset'sdepreciationEntryId→ the shared entry; audit STATUS_CHANGE.
6.3 Disposal (happy path)
- Admin opens the asset detail → Dispose →
DisposeAssetsModal(components/disposeAsset.tsx). Pre-fillsassetAccount(disabled) andassetNetBookValue = summary.value(disabled); user picks disposal type/date, payment account + proceeds, and gain/loss account + amount. - Submit →
disposeAssetmutation →AssetService.disposeAsset→ posts theASSET_DISPOSALentry (§5.3) →validateBalanced→ stampsdisposalEntryId+ disposal fields. After thissummary.value= 0 andcanDelete/canUpdate= false.
6.4 Status (post / save)
createAsset (status = SAVED | POSTED from input, default SAVED)
│ postAssetTransactions / postManyAsset
▼ ────────────────────────────────────────▶ POSTED
SAVED ◀──────────────────────────────────────── (final)
saveManyAsset
postTransactions(assetId) / saveTransactions load all legs { refId: assetId }, flip each leg's status, then set the asset's status — inside withRetryTransaction. postManyAsset / saveManyAsset loop the single-id version.
6.5 Import (two-phase)
- Preview —
importAssets({ file })→AssetService.importparses XLSX (flexible column aliases: Asset Name/Code, Asset/Depreciation/Payment/ACC Depreciation Account, Method, Lifespan, Scrap, Depreciation Value/Rate, Purchase Cost/Date, Expiry, Accumulated Depreciation Amount, Location, Department). Resolves accounts by name and master location/department; ifexpectedLifeSpanis missing but a straight-linedepreciationValue%is present, derives life≈ round(100 / value). Returns parsed rows (not persisted). - Confirm —
confirmAssetImport({ assets })→AssetService.confirmImport: validates eachpurchaseDateagainst fiscal periods, then per asset upserts by Asset Code —updateif an asset with thatnumberexists in the company, elsecreate. SetsaccumulatedDepreciation = depreciationAmountandcreatedAt = purchaseDate.
6.6 Unhappy paths
- Depreciation guards (no first-use date / date not after last / end of life / nothing to post) →
BAD_REQUESTwith all messages joined; nothing posts. amount > purchaseCoston create →403 FORBIDDEN.- Duplicate Asset Code / ref →
BAD_REQUEST"Asset code … already exist". - Dated into a locked fiscal period → rejected by
validateTransactionDate. - Books don't balance after a write →
validateBalancedthrows → transaction rolls back.
7. Permissions
Module ASSETS (assets); actions view, create, update, delete, depreciate-asset, view-disposed-asset, view-asset-details, import-assets (zerp-admin/src/constants/UserAccess.ts). All mutations @AuditMeta({ module: "asset", collection: "assets", … }); depreciate/dispose/post/save audit STATUS_CHANGE, create/import audit CREATE, update UPDATE, delete DELETE. REST reports @ApiAuthorize(). See _overview.md §6 and ../../platform/permissions-access.md.
8. Admin UI
Source: zerp-admin/src/modules/assets/.
- Page:
AssetsPage(page.tsx) — header withApDurationPicker(date range + XLSX/PDF download viaApDownloadButton2hitting/assets/download), Depreciate button (gateddepreciate-asset), New Asset (gatedcreate), Import (gatedimport-assets). Filters: search, department, location, and an Include Disposed toggle (gatedview-disposed-asset). Body:AssetsTable. - State:
context.tsx(useAssetState) is the only consumer ofuseAssetQuery(). Methods:fetchAssetPage,findOneAsset,saveAsset(→createAsset/updateAsset),deleteAsset,deleteManyAsset,depreciateAssets,saveDisposeAsset,addTransaction,postAssetTransactions,postManyAsset,saveManyAsset,importAsset,confirmAssetImport. After mutations it refetches the page. - Create form:
components/create.tsx— Formik (CreateFormSchema,validations/create-schema.ts): requires name, Asset Code, scrap value (≥ 10), asset/AAD/depreciation/payment accounts, purchase date + cost (> 0), depreciation method, depreciation value (≥ 5; ≤ 100 for straight-line), expected life (1–100 yrs, integer), ≤ 4 files. Includes First Use Date and the method/value inputs. - Depreciation grid:
components/depreciation.tsx(DepreciationSchema) — per-row account selectors filtered toDEPRECIATION EXPENSE/ACCUMULATED DEPRECIATION, amount, date; footer totals; submit gated on selection. - Dispose modal:
components/disposeAsset.tsx(DisposalAssetFormSchema) — disposal type, date (≤ today), disabled asset account + NBV, payment account + amount, gain/loss account + amount. - Other components:
table.tsx,detail.tsx/detail-modal.tsx,summary.tsx,transactionTable.tsx,addTransaction.tsx,depreciationTable.tsx,import.tsx/confirmImport.tsx,report-table.tsx, and report column sets undercomponents/columns/.
9. The depreciation-GL fixes
Source: docs/superpowers/plans/2026-04-16-depreciation-gl-fixes.md. Four fixes; two are now visible in the assets code, two are upstream in finance/base:
- One journal entry per depreciation batch (Task 4). Previously
depreciateAssetsloopedjournalSvc.addEntry()once per asset, producing N separate journal entries. The fix flattens all per-asset Dr/Cr pairs intoallTransactions = transactions.flat()and callsaddEntryonce withtype: ASSET_DEPRECIATION, then stamps every asset'sdepreciationEntryIdwith that singleentry._id. This is the code documented in §5.2 — present and current inassets.service.ts. - Dr/Cr legs share the parent entry's ref (Task 2).
journal.service.ts → addTransactionnow passesref: entry.refto eachAccountTransaction.create, so a depreciation entry's debit and credit carry the sameJN…ref instead of two auto-generated ones. See journal §4.2. - GL Excel download no longer fails silently (Task 3).
app.base.ts → xlsDownloadResponsenow re-throws after logging instead of swallowing the error, so a bad download surfaces an HTTP 500 rather than hanging the client. This is the base class behind every asset report download inassets.controller.ts. - Sortable download date format (Task 1).
AP_DATE_FORMATchanged"DD MMM YYYY"→"D-MMM-YYYY", affecting every report download (including all asset reports). The asset controller importsAP_DATE_FORMATfromsrc/constant.
Issues explicitly excluded from the plan: frontend tabs (issue 2) and embedding the asset code in the journal narration (issue 3).
10. Gotchas & project-specific rules
- Depreciation is on-demand catch-up, not a schedule. A run posts
per-month × months elapsed since the last run (+1)in one lump. No forward schedule table exists. (§4.2) - "Reducing balance" is a flat stored amount, not the classic
NBV × raterecursion — the BE returnsdepreciationValueverbatim forREDUCING_BALANCE. (§4.1) expectedLifeSpanis in years; the straight-line formula divides by× 12to get a monthly amount.- One journal entry per batch; every asset in the run shares its
depreciationEntryId. (§9) - AAD credit legs are matched by
ref2Id, so accumulated depreciation is captured even if the AAD account changes between runs.accumulatedDepreciationis a denormalised cache, backfilled at boot. - Disposal gain/loss direction is account-type driven (EXPENSE → Dr loss, income → Cr gain), and the amount is operator-entered, not auto-computed. (§5.3)
- No revaluation, no transfer document — relocation is a field edit with no GL impact.
- Disposed/depreciated assets are locked in the UI (
canDelete/canUpdatefalse) but the server delete still works by removing the legs (books self-correct). - All dated writes floor to UTC start-of-day and pass the fiscal-period lock check.
puchaseDescription(sic) is the actual field name in code.