Fixed Assets & Depreciation — register, methods, runs, and GL postings

The whole fixed-asset register reduces to one idea:

An Asset is a master row plus four GL touch-points (acquire, depreciate, manual-adjust, dispose), and depreciation is computed on demandperiod 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 balanced AccountTransaction legs (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 a accumulatedDepreciation cache 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

assetsAsset (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; < purchaseCostprocurementType = "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_accountsAccumulated Depreciation contra account (credited on depreciation).
depreciationAccountId ObjectId no FK → finance_accountsDepreciation 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 accountfinance_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:

  • expectedLifeSpan is in years; × 12 makes 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 stored depreciationValue as 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 BE getDepreciationAmount uses depreciationValue verbatim for REDUCING_BALANCE.)
  • scrapValue is 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 run

So 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):

  1. First use requiredif (!asset.firstUseDate) → "not in use, please update the Asset First Use Date before depreciate".
  2. Must move forwardif (depDate ≤ startDate) → "depreciation date must be after last depreciation date (…)".
  3. End of lifemonthsSinceFirstUse = depDate.diff(firstUseDate,"month") + 1; if expectedLifeSpan × 12 > 0 and monthsSinceFirstUse > totalLifeMonths → "has reached its end of life and cannot be depreciated further".
  4. Something to postif (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-asset aadAccountId/depreciationAccountId for the run default to the request values, falling back to asset.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 (unique number + ref per company) → normalizeAssetDates (purchase/warranty → UTC start-of-day) → fiscalPeriodSvc.validateTransactionDate(purchaseDate) → guard amount ≤ purchaseCost → (txn) super.create (sets documentDate = purchaseDate, ref = ASST…) → addPurchaseTransactionvalidateBalanced("(Add asset)") → upload images. Throws 403 if amount > purchaseCost.
  • Update (update): validateAsset → normalize → fiscal check → (txn) upload files → updatePurchaseTransaction (delete old AssetEntry+TaxEntry legs by refId, re-add) → super.update. Blocked client-side once depreciated/disposed (canUpdate false).
  • Delete (delete): (txn) transactionSvc.deleteMany({ refId }) + fileUploadSvc.deleteByRefId
    • assetRepo.delete (soft) → validateBalanced("(Delete asset)"). Server allows delete even when depreciated (legs removed, books self-correct); the UI gates it via canDelete.

6.2 Depreciation run (happy path)

  1. Admin opens Assets → Depreciate (gated assets/depreciate-asset) → AssetDepreciation grid (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 category DEPRECIATION EXPENSE), AAD Account (filtered to ACCUMULATED DEPRECIATION), Depreciation Amount (defaults to getDepreciationAmount), and Depreciation Date.
  2. User selects rows, sets accounts + amount + date. Client validation: ≥ 1 selected, each has a positive amount + a date + both accounts.
  3. Submit maps to assets:[{ assetId, depreciationDate, depreciationAmount, aadAccountId, depreciationAccountId }]depreciateAsset mutation → AssetService.depreciateAssets.
  4. Service builds assetsToProcess (resolving start date, months, total amount; running the §4.2 guards), then posts one ASSET_DEPRECIATION journal entry with all Dr/Cr pairs (§5.2), updates each asset's depreciationEntryId + accumulatedDepreciation, and runs validateBalanced.
  5. Side effects: N×2 legs in finance_account_transactions (one Dr/Cr pair per asset, all under the one entry); each asset's depreciationEntryId → the shared entry; audit STATUS_CHANGE.

6.3 Disposal (happy path)

  1. Admin opens the asset detail → DisposeDisposeAssetsModal (components/disposeAsset.tsx). Pre-fills assetAccount (disabled) and assetNetBookValue = summary.value (disabled); user picks disposal type/date, payment account + proceeds, and gain/loss account + amount.
  2. Submit → disposeAsset mutation → AssetService.disposeAsset → posts the ASSET_DISPOSAL entry (§5.3) → validateBalanced → stamps disposalEntryId + disposal fields. After this summary.value = 0 and canDelete/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)

  1. PreviewimportAssets({ file })AssetService.import parses 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; if expectedLifeSpan is missing but a straight-line depreciationValue% is present, derives life ≈ round(100 / value). Returns parsed rows (not persisted).
  2. ConfirmconfirmAssetImport({ assets })AssetService.confirmImport: validates each purchaseDate against fiscal periods, then per asset upserts by Asset Codeupdate if an asset with that number exists in the company, else create. Sets accumulatedDepreciation = depreciationAmount and createdAt = purchaseDate.

6.6 Unhappy paths

  • Depreciation guards (no first-use date / date not after last / end of life / nothing to post) → BAD_REQUEST with all messages joined; nothing posts.
  • amount > purchaseCost on create → 403 FORBIDDEN.
  • Duplicate Asset Code / refBAD_REQUEST "Asset code … already exist".
  • Dated into a locked fiscal period → rejected by validateTransactionDate.
  • Books don't balance after a writevalidateBalanced throws → 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 with ApDurationPicker (date range + XLSX/PDF download via ApDownloadButton2 hitting /assets/download), Depreciate button (gated depreciate-asset), New Asset (gated create), Import (gated import-assets). Filters: search, department, location, and an Include Disposed toggle (gated view-disposed-asset). Body: AssetsTable.
  • State: context.tsx (useAssetState) is the only consumer of useAssetQuery(). 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 to DEPRECIATION 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 under components/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:

  1. One journal entry per depreciation batch (Task 4). Previously depreciateAssets looped journalSvc.addEntry() once per asset, producing N separate journal entries. The fix flattens all per-asset Dr/Cr pairs into allTransactions = transactions.flat() and calls addEntry once with type: ASSET_DEPRECIATION, then stamps every asset's depreciationEntryId with that single entry._id. This is the code documented in §5.2 — present and current in assets.service.ts.
  2. Dr/Cr legs share the parent entry's ref (Task 2). journal.service.ts → addTransaction now passes ref: entry.ref to each AccountTransaction.create, so a depreciation entry's debit and credit carry the same JN… ref instead of two auto-generated ones. See journal §4.2.
  3. GL Excel download no longer fails silently (Task 3). app.base.ts → xlsDownloadResponse now 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 in assets.controller.ts.
  4. Sortable download date format (Task 1). AP_DATE_FORMAT changed "DD MMM YYYY""D-MMM-YYYY", affecting every report download (including all asset reports). The asset controller imports AP_DATE_FORMAT from src/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 × rate recursion — the BE returns depreciationValue verbatim for REDUCING_BALANCE. (§4.1)
  • expectedLifeSpan is in years; the straight-line formula divides by × 12 to 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. accumulatedDepreciation is 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/canUpdate false) 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.