Skip to content

RFC-009: Finance — Fixed Asset Register & Expense Claims Implementation

  • Status: Draft
  • Author(s): Aditya Lohumi
  • Created: 2026-02-26
  • Updated: 2026-02-26
  • TSC Decision: Pending
  • Supersedes: —
  • Superseded by: —

This RFC covers the implementation of Phase 07 (Employee Expense Claims) and Phase 08 (Fixed Asset Register) of the finance library. These two phases are grouped in a single RFC because they share similar patterns: both involve a parent document with auto-generated detail rows, both have multi-step approval or processing workflows, and both produce GL entries at a specific lifecycle stage.

  • Phase 07 covers ExpenseClaim (with ExpenseClaimDetail child) and integrates with the Framework M WorkflowProtocol for multi-level approval.
  • Phase 08 covers AssetCategory, Asset, AssetMovement, and DepreciationSchedule, including cron-driven depreciation batch processing.

Fixed asset tracking (Fixed Asset Register) and employee reimbursement claims are both critical organisational compliance requirements. Assets must be tracked throughout their lifecycle (purchase → depreciation → disposal), and expense claims must pass through a multi-level approval chain before payment.


Detailed Design — Phase 08: Fixed Asset Register

Section titled “Detailed Design — Phase 08: Fixed Asset Register”

On submit of an Asset, the controller auto-generates the full DepreciationSchedule for the asset’s life based on the linked AssetCategory:

class AssetController(Controller[Asset]):
async def before_submit(self, doc: Asset) -> None:
"""Validate asset before activating."""
if doc.gross_purchase_amount <= Decimal("0"):
raise ValidationError("Gross purchase amount must be greater than zero.")
if doc.salvage_value >= doc.gross_purchase_amount:
raise ValidationError("Salvage value must be less than gross purchase amount.")
async def after_submit(self, doc: Asset) -> None:
"""Generate depreciation schedule based on AssetCategory rules."""
category = await self.db.get(AssetCategory, doc.asset_category)
doc.current_value = doc.gross_purchase_amount
schedules = self._generate_schedule(doc, category)
for schedule in schedules:
await DepreciationSchedule.new(schedule)
def _generate_schedule(self, doc: Asset, category: AssetCategory) -> list[dict]:
"""Generate depreciation rows for the full asset life."""
schedules = []
depreciable_amount = doc.gross_purchase_amount - doc.salvage_value
n_periods = (category.total_useful_life * 12) // category.frequency_of_depreciation
current_value = doc.gross_purchase_amount
accumulated = Decimal("0")
schedule_date = doc.purchase_date
for i in range(n_periods):
schedule_date = _add_months(schedule_date, category.frequency_of_depreciation)
if category.depreciation_method == "SLM":
# Straight Line: equal amount each period
depreciation = (depreciable_amount / n_periods).quantize(Decimal("0.01"))
elif category.depreciation_method == "WDV":
# Written Down Value: % of current book value
wdv_rate = Decimal("1") - (
(doc.salvage_value / doc.gross_purchase_amount)
** (Decimal("1") / n_periods)
)
depreciation = (current_value * wdv_rate).quantize(Decimal("0.01"))
# Ensure we don't depreciate below salvage value
if current_value - depreciation < doc.salvage_value:
depreciation = current_value - doc.salvage_value
accumulated += depreciation
current_value -= depreciation
schedules.append({
"asset": doc.name,
"schedule_date": schedule_date,
"depreciation_amount": depreciation,
"accumulated_depreciation": accumulated,
"book_value_after": current_value,
"is_depreciated": False,
})
if current_value <= doc.salvage_value:
break
return schedules

When Asset.is_active is set to False with a disposal_date:

  1. Any unprocessed DepreciationSchedule rows must be cancelled
  2. A GL entry is posted: debit AssetCategory.disposal_account, credit Asset.current_value
async def before_save(self, doc: Asset) -> None:
"""Handle disposal when is_active transitions to False."""
if not doc.is_active and doc.disposal_date:
await self._handle_disposal(doc)

On submit of an AssetMovement, update the parent Asset’s location and custodian:

class AssetMovementController(Controller[AssetMovement]):
async def after_submit(self, doc: AssetMovement) -> None:
await self.db.set_value(Asset, doc.asset, "location", doc.to_location)
if doc.to_custodian:
await self.db.set_value(Asset, doc.asset, "custodian", doc.to_custodian)
async def after_cancel(self, doc: AssetMovement) -> None:
"""Restore previous location and custodian."""
await self.db.set_value(Asset, doc.asset, "location", doc.from_location)
if doc.from_custodian:
await self.db.set_value(Asset, doc.asset, "custodian", doc.from_custodian)

The depreciation batch job is cron-driven, scheduled via FinanceSettings.depreciation_cron_schedule (default: daily at 01:00). On each run:

  1. Query all unprocessed DepreciationSchedule rows where schedule_date <= today and is_depreciated=False
  2. For each row:
    • Create a JournalEntry (entry_type: "Adjustment") with two legs:
      • Dr AssetCategory.depreciation_account (expense)
      • Cr AssetCategory.accumulated_depreciation_account
    • Submit the JE (triggers GL posting to Book-Keeper)
    • Set DepreciationSchedule.is_depreciated = True
    • Set DepreciationSchedule.journal_entry = je.name
    • Update Asset.current_value -= depreciation_amount
async def run_depreciation_batch() -> DepreciationBatchResult:
"""
Cron-triggered batch. Processes due depreciation rows.
Returns a summary result for logging.
"""
due_schedules = await db.get_all(
DepreciationSchedule,
filters=[
("schedule_date", "<=", date.today()),
("is_depreciated", "=", False),
],
)
processed = []
failed = []
for schedule in due_schedules:
try:
asset = await db.get(Asset, schedule.asset)
category = await db.get(AssetCategory, asset.asset_category)
je = await JournalEntry.new({
"company": asset.company,
"posting_date": schedule.schedule_date,
"entry_type": "Adjustment",
"narration": f"Depreciation for {asset.asset_name} ({schedule.schedule_date})",
"accounts": [
{"account": category.depreciation_account, "debit": schedule.depreciation_amount, "credit": Decimal("0")},
{"account": category.accumulated_depreciation_account, "debit": Decimal("0"), "credit": schedule.depreciation_amount},
],
})
await je.submit()
await db.set_value(DepreciationSchedule, schedule.name, {
"is_depreciated": True,
"journal_entry": je.name,
})
await db.set_value(Asset, asset.name, "current_value", schedule.book_value_after)
processed.append(schedule.name)
except Exception as e:
failed.append({"schedule": schedule.name, "error": str(e)})
return DepreciationBatchResult(processed=processed, failed=failed)

Detailed Design — Phase 07: Expense Claims

Section titled “Detailed Design — Phase 07: Expense Claims”
class ExpenseClaimController(Controller[ExpenseClaim]):
async def before_save(self, doc: ExpenseClaim) -> None:
"""Recompute claim totals from expense lines."""
doc.total_claimed_amount = sum(line.amount for line in doc.expenses)
doc.total_approved_amount = sum(
line.approved_amount for line in doc.expenses if line.is_approved
)

6. Multi-Level Approval via WorkflowProtocol

Section titled “6. Multi-Level Approval via WorkflowProtocol”

Multi-level approval (Department Head → Finance) is delegated to the Framework M WorkflowProtocol. The controller does not implement a custom state machine. Instead, a workflow definition is registered:

# Workflow definition (registered with Framework M)
EXPENSE_CLAIM_WORKFLOW = {
"name": "Expense Claim Approval",
"doctype": "ExpenseClaim",
"states": [
{"state": "Draft", "allow_edit": ["Employee"]},
{"state": "Pending", "allow_edit": ["Dept Head"]},
{"state": "Approved", "allow_edit": ["Finance"]},
{"state": "Rejected", "allow_edit": []},
{"state": "Paid", "allow_edit": ["Finance"]},
],
"transitions": [
{"from": "Draft", "to": "Pending", "action": "Submit for Approval", "role": "Employee"},
{"from": "Pending", "to": "Approved", "action": "Approve", "role": "Dept Head"},
{"from": "Pending", "to": "Rejected", "action": "Reject", "role": "Dept Head"},
{"from": "Approved", "to": "Paid", "action": "Mark as Paid", "role": "Finance"},
],
}

When the claim transitions to "Paid" via the workflow:

async def on_workflow_transition(self, doc: ExpenseClaim, transition: str) -> None:
if transition == "Mark as Paid":
await self._post_expense_gl(doc)
async def _post_expense_gl(self, doc: ExpenseClaim) -> None:
"""Post GL: Dr Expense Account, Cr Employee Payable."""
settings = await FinanceSettings.get_single()
expense_account = settings.default_expense_claim_account
gl_entry_id = await bookkeeper_client.post_gl_entry(
company=doc.company,
posting_date=date.today(),
debit_account=expense_account,
credit_account=settings.employee_payable_account,
amount=doc.total_approved_amount,
narration=f"Expense Claim {doc.claim_number}{doc.claim_type}",
party_type="Employee",
party=doc.employee,
cost_center=doc.cost_center,
)
doc.gl_entry_id = gl_entry_id
default_expense_claim_account: str | None = Field(default=None)
employee_payable_account: str | None = Field(default=None)

# Create and process an expense claim
claim = await ExpenseClaim.new({
"claim_number": "EC-2026-001",
"company": "Acme Corp",
"employee": "EMP-001",
"claim_date": date(2026, 4, 20),
"claim_type": "Travel",
"expenses": [
{"expense_date": date(2026, 4, 18), "description": "Flight ticket to client site", "expense_type": "Travel", "amount": Decimal("3500")},
{"expense_date": date(2026, 4, 19), "description": "Hotel accommodation", "expense_type": "Lodging", "amount": Decimal("2000")},
],
})
# before_save: total_claimed_amount = 5500
# Workflow transitions: Draft → Pending → Approved → Paid
# On "Mark as Paid": GL posted Dr Expense A/c 5500, Cr Employee Payable 5500

  • SLM/WDV rounding: Depreciation amounts may not sum exactly to the depreciable amount due to decimal rounding. The last period must include a rounding adjustment.
  • Cron failures: If the depreciation batch partially fails (some rows succeed, some fail), the batch result must be logged and monitored. Failed rows must be retried on the next run without double-processing.
  • Workflow dependency: The WorkflowProtocol implementation in Framework M must be stable before ExpenseClaim can be fully implemented.
Alternative Pros Cons Why Not
Manual depreciation entry Simple Error-prone, labour-intensive Defeats the purpose of an asset register
Custom approval state machine in the controller No external dep Duplicates workflow framework logic Against RFC-003 Decision 7
  • No data migration for new doctypes
  • FinanceSettings additions (default_expense_claim_account, employee_payable_account) need to be populated post-deployment
  • Should partial disposal be supported (e.g., selling part of an asset batch)?
  • Should DepreciationSchedule rows be re-generated if AssetCategory rules change after submission?
  • How should failed depreciation batch rows be surfaced to the Finance team — as a DepreciationBatchLog doctype, email notification, or dashboard alert?
  • What happens to an ExpenseClaim if the Dept Head who needs to approve it leaves the organisation (role reassignment)?

Phase 08 — Fixed Asset Register:

  • Implement AssetController.before_submit — amount validations
  • Implement AssetController.after_submitDepreciationSchedule generation (SLM + WDV)
  • Implement AssetController.before_save — disposal handling
  • Implement AssetMovementController.after_submit — update Asset location/custodian
  • Implement AssetMovementController.after_cancel — restore previous values
  • Implement run_depreciation_batch cron task
  • Register cron schedule from FinanceSettings.depreciation_cron_schedule
  • Unit tests for SLM schedule generation
  • Unit tests for WDV schedule generation
  • Unit tests for batch processor (success, partial failure, retry safety)
  • Integration tests for full asset lifecycle (submit → depreciation → disposal)

Phase 07 — Expense Claims:

  • Implement ExpenseClaimController.before_save — totals auto-sum
  • Register EXPENSE_CLAIM_WORKFLOW with Framework M WorkflowProtocol
  • Implement on_workflow_transition — GL posting on "Mark as Paid"
  • Add gl_entry_id field to ExpenseClaim
  • Add default_expense_claim_account and employee_payable_account to FinanceSettings
  • Unit tests for claim total computation
  • Integration tests for full workflow lifecycle (Draft → Pending → Approved → Paid)
Test Scenario
test_asset_zero_amount_rejected gross_purchase_amount ≤ 0 → error
test_asset_salvage_above_gross Salvage ≥ gross → error
test_slm_schedule_generation Correct equal-amount rows over useful life
test_wdv_schedule_generation Correct decreasing-amount rows over useful life
test_schedule_no_below_salvage Final row does not go below salvage value
test_movement_updates_asset After submit, Asset.location updated
test_movement_cancel_restores After cancel, previous location restored
test_depreciation_batch_processes_due Due rows are processed with correct JE
test_depreciation_batch_skips_future Future-dated rows are not processed
test_depreciation_batch_idempotent Already processed rows are not double-processed
test_expense_claim_totals Sum computed correctly on save
test_expense_claim_workflow_gl GL posted on “Mark as Paid” transition
  • RFC-003: Finance Library Architecture (Decision 6 — cron, Decision 7 — WorkflowProtocol)
  • RFC-004: Foundation & Masters
  • RFC-008: General Ledger (DepreciationSchedule creates JournalEntry)