ERP Fixed Asset Module: Depreciation Schedules Done Right

Photo by Loco Steve on flickr
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.
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.
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.
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.
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.

Photo by Loco Steve on flickr
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.
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.
| Field | Type | Notes |
|---|---|---|
| acquisition_cost | NUMERIC(15,2) | The capitalized cost basis used in every depreciation formula going forward |
| salvage_value | NUMERIC(15,2) | Book value is never depreciated below this floor |
| useful_life_months | INTEGER | Stored in months so partial-year acquisitions do not need special casing |
| depreciation_method | TEXT | Enum-backed, drives which calculation function runs during the monthly batch |
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.
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 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.
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.
-- 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 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.
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.
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.
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.