Skip to main content
BlogERP

ERP Fixed Asset Module: Depreciation Schedules Done Right

How to design a fixed asset module for a custom ERP, from asset registers through straight-line and declining-balance depreciation to disposal and monthly GL posting.

Matthews Wong

Software Engineer

July 202610 min read
ERP Fixed Asset Module: Depreciation Schedules Done Right

Photo by Loco Steve on flickr

Key Takeaway

A fixed asset module looks like a CRUD screen for laptops and machinery until finance asks for a depreciation schedule that reconciles to the general ledger every period. Getting that right means the schedule is derived from the asset record rather than maintained beside it in a spreadsheet.

Every ERP eventually needs a fixed asset module, and most teams underestimate it. It looks like a CRUD screen for laptops and machinery until finance asks for a depreciation schedule that reconciles to the general ledger every single month, forever, without manual adjustment.

This post walks through the design I use for a fixed asset module inside a custom ERP: the asset register schema, how straight-line and declining-balance depreciation actually differ in code, how disposal closes out an asset cleanly, and how the whole thing posts to the GL as a repeatable monthly batch instead of a spreadsheet ritual.

The asset register is the foundation

Before any depreciation math happens, you need a register that captures the facts finance actually cares about at acquisition time. Get this wrong and every downstream calculation inherits the error. The register is not just a list of physical items; it is the system of record for cost basis, useful life assumptions, and the depreciation policy applied to each asset.

  • Acquisition cost, including freight and installation costs that are properly capitalized rather than expensed
  • Salvage value, the estimated residual worth at the end of useful life, which the book value should never fall below
  • Useful life expressed in months rather than years, since most depreciation runs are monthly
  • The depreciation method assigned to the asset, plus any rate override for declining-balance assets
FieldTypeNotes
acquisition_costNUMERIC(15,2)The capitalized cost basis used in every depreciation formula going forward
salvage_valueNUMERIC(15,2)Book value is never depreciated below this floor
useful_life_monthsINTEGERStored in months so partial-year acquisitions do not need special casing
depreciation_methodTEXTEnum-backed, drives which calculation function runs during the monthly batch

Straight-line versus declining-balance in practice

Finance teams pick a depreciation method for tax or reporting reasons, but the module has to implement both correctly and let each asset carry its own method. The two methods differ in what they are calculated against and how flat the expense curve is over time. Getting this abstraction right early matters more than it looks, because bolting a second method onto a module hard-coded for one is a painful migration once hundreds of assets already have posted history. In Indonesia the choice is constrained for tax purposes. UU No. 36/2008 Article 11 fixes both the useful life and the rate for each asset group: Group 1 is four years at 25% straight-line or 50% declining balance, through to Group 4 at twenty years and 5%. Buildings are straight-line only, twenty years at 5% when permanent and ten years at 10% when not.

Straight-line: predictable and boring, on purpose

Straight-line depreciation spreads the depreciable base, which is acquisition cost minus salvage value, evenly across the useful life. Every period gets the exact same expense until the asset reaches its salvage value. This is the default most companies use for office equipment and furniture because it is simple to audit and matches how the asset is actually used.

Declining-balance: front-loaded expense for assets that lose value fast

Declining-balance depreciation applies a fixed percentage rate against the asset's remaining book value rather than its original cost, so the expense is largest in early periods and shrinks over time. This models vehicles, computers, and machinery that lose most of their value in the first few years of use. The critical implementation detail is that book value must never be pushed below salvage value, which means every period's calculated expense has to be capped against the remaining depreciable amount.

// Straight-line: equal expense every period
function straightLineMonthly(asset: FixedAsset): number {
  const depreciableBase = asset.acquisitionCost - asset.salvageValue
  return depreciableBase / asset.usefulLifeMonths
}

// Declining-balance: fixed rate against the remaining book value
function decliningBalanceMonthly(asset: FixedAsset, bookValue: number): number {
  const annualRate = asset.decliningRate ?? 2 / (asset.usefulLifeMonths / 12) // double-declining default
  const monthlyRate = annualRate / 12
  const rawExpense = bookValue * monthlyRate

  // never depreciate below salvage value
  const floor = bookValue - asset.salvageValue
  return Math.min(rawExpense, Math.max(floor, 0))
}

function computeMonthlyDepreciation(asset: FixedAsset, bookValue: number): number {
  switch (asset.depreciationMethod) {
    case 'STRAIGHT_LINE':
      return straightLineMonthly(asset)
    case 'DECLINING_BALANCE':
      return decliningBalanceMonthly(asset, bookValue)
    default:
      throw new Error(`Unsupported method: ${asset.depreciationMethod}`)
  }
}

Store the depreciation method as an enum on the asset row, not as a global system setting. Real companies mix straight-line for furniture with declining-balance for vehicles inside the same asset register, and your monthly batch job needs to dispatch to the right formula per asset without a special case for every category.

Running the monthly close as a batch, not a spreadsheet

The part that actually earns its keep in production is the monthly posting run. Finance should be able to trigger one action that calculates every active asset's depreciation for the period, writes the schedule, and posts a single balanced batch of journal entries to the general ledger. The run has to be idempotent and auditable, because someone will eventually ask why an asset's book value changed in March. Treat the run identifier as a first-class record, not just a foreign key, because it is what lets finance reverse an entire period's postings as one unit if a mistake is discovered after the fact.

  1. Lock the period and pull every active asset whose useful life has not yet expired
  2. Calculate the period expense per asset using its assigned depreciation method
  3. Post one journal entry per asset, debiting depreciation expense and crediting accumulated depreciation, grouped under a single run identifier
  4. Mark the run as posted and update each asset's running book value so the next period's declining-balance calculation has the correct starting point
-- one journal entry per asset per period, batched into a single GL batch
CREATE TABLE depreciation_runs (
  id            UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  period_month  DATE NOT NULL,          -- first day of the accounting period
  status        TEXT NOT NULL DEFAULT 'DRAFT', -- DRAFT|POSTED|REVERSED
  posted_at     TIMESTAMPTZ,
  UNIQUE (period_month)
);

CREATE TABLE depreciation_entries (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  run_id          UUID NOT NULL REFERENCES depreciation_runs(id),
  asset_id        UUID NOT NULL REFERENCES fixed_assets(id),
  expense_amount  NUMERIC(15,2) NOT NULL,
  book_value_after NUMERIC(15,2) NOT NULL,
  gl_journal_id   UUID REFERENCES gl_journals(id)
);

// NestJS: close the period by posting one balanced journal per asset
async function postDepreciationRun(periodMonth: Date) {
  const run = await this.runRepo.create({ periodMonth, status: 'DRAFT' })
  const assets = await this.assetRepo.findActive()

  for (const asset of assets) {
    const expense = computeMonthlyDepreciation(asset, asset.currentBookValue)
    if (expense <= 0) continue

    const journal = await this.glService.postJournal({
      date: periodMonth,
      lines: [
        { account: 'DEPRECIATION_EXPENSE', debit: expense, credit: 0 },
        { account: 'ACCUMULATED_DEPRECIATION', debit: 0, credit: expense },
      ],
      memo: `Depreciation ${asset.code} ${periodMonth.toISOString().slice(0, 7)}`,
    })

    await this.entryRepo.create({
      runId: run.id,
      assetId: asset.id,
      expenseAmount: expense,
      bookValueAfter: asset.currentBookValue - expense,
      glJournalId: journal.id,
    })
    asset.currentBookValue -= expense
    await this.assetRepo.save(asset)
  }

  run.status = 'POSTED'
  run.postedAt = new Date()
  await this.runRepo.save(run)
}

Never let the monthly posting run recalculate from scratch using only the original acquisition cost. If the run reads a stale or recalculated book value instead of the value persisted from the last posted period, a single missed month silently corrupts every future declining-balance calculation for that asset, and the error is easy to miss until an auditor reconciles the accumulated depreciation account.

Disposal has to close the loop cleanly

Disposal is where fixed asset modules built as an afterthought fall apart. Selling, scrapping, or writing off an asset mid-period is not just a status flag; it is a financial event that needs its own journal entry recognizing any gain or loss between the disposal proceeds and the asset's remaining book value. If the module cannot represent a partial-period depreciation charge alongside a disposal entry in the same transaction, finance ends up doing the math by hand and pasting the adjustment in afterward, which defeats the entire point of automating the module in the first place.

  • Run a final partial-period depreciation entry up to the disposal date before calculating the disposal gain or loss
  • Post the disposal entry against accumulated depreciation and the asset's original cost account, recognizing the difference as a gain or loss on disposal
  • Flag the asset as disposed rather than deleting the row, so the register still supports historical depreciation reports and audits

Once disposal correctly zeroes out both the cost and accumulated depreciation accounts for that asset in the same journal entry, your fixed asset subledger will always tie back to the general ledger control accounts, which is the single most useful property a finance team can ask an ERP module for.

Reconciling the subledger against the general ledger

A fixed asset module is a subledger, and subledgers only earn trust when they reconcile. The sum of accumulated depreciation across every asset in the register must equal the balance of the accumulated depreciation control account in the general ledger at any point in time. Build a reconciliation report early, not as an afterthought, because it is the fastest way to catch a bug in the monthly posting run before finance does.

Ship a reconciliation view that compares the sum of asset book values in the register against the fixed asset control account balance in the general ledger, refreshed after every posting run. When the two numbers match automatically, finance stops manually cross-checking spreadsheets, and that is usually the moment they trust the module enough to stop asking for the old process as a backup.

What this buys the finance team

A properly designed fixed asset module turns depreciation from a monthly spreadsheet exercise into a repeatable, auditable batch process. Asset registers capture the right facts once, the calculation logic correctly dispatches between straight-line and declining-balance per asset, disposal closes out the accounting cleanly, and the general ledger always reflects the current state of every asset the company owns.

FAQ

Frequently Asked Questions

Short answers to what readers ask most about this topic.

What is the difference between straight-line and declining-balance depreciation?

Straight-line depreciation spreads the depreciable base, which is cost minus salvage value, evenly across the useful life so every period gets the same expense. Declining-balance depreciation applies a fixed rate against the asset's remaining book value instead, so the expense is largest early on and shrinks over time. Both methods must stop reducing book value once it reaches the asset's salvage value.

Why does a fixed asset module need its own asset register instead of a simple item list?

An asset register has to capture acquisition cost, salvage value, useful life, and depreciation method at the moment the asset is acquired, because every later calculation depends on those values staying accurate. A generic item list does not enforce this, which leads to depreciation schedules that silently drift from what finance expects. The register is effectively the system of record the monthly posting run reads from.

How should monthly depreciation post to the general ledger?

Each posting run should calculate the period expense per asset, then post one balanced journal entry debiting depreciation expense and crediting accumulated depreciation, grouped under a single run identifier. The run should be idempotent and store each asset's updated book value so the next period starts from the correct number. This keeps the fixed asset subledger reconciled with the general ledger at all times.

Can I mix straight-line and declining-balance depreciation for different assets in the same ERP?

Yes, and most real companies do exactly this, using straight-line for furniture and office equipment while applying declining-balance to vehicles and machinery. The depreciation method should be a field on each asset record rather than a global system setting, so the monthly batch job can dispatch to the correct calculation per asset without special-casing categories.

What happens to depreciation when an asset is disposed of mid-period?

Disposal should first trigger a final partial-period depreciation entry up to the disposal date, then a separate journal entry that zeroes out the asset's cost and accumulated depreciation accounts while recognizing any gain or loss versus the disposal proceeds. The asset row should be flagged as disposed rather than deleted so historical depreciation reports and audits still work correctly.