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: —
Summary
Section titled “Summary”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(withExpenseClaimDetailchild) and integrates with the Framework MWorkflowProtocolfor multi-level approval. - Phase 08 covers
AssetCategory,Asset,AssetMovement, andDepreciationSchedule, including cron-driven depreciation batch processing.
Motivation
Section titled “Motivation”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”1. Asset Submit — Schedule Generation
Section titled “1. Asset Submit — Schedule Generation”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 schedules2. Asset Disposal
Section titled “2. Asset Disposal”When Asset.is_active is set to False with a disposal_date:
- Any unprocessed
DepreciationSchedulerows must be cancelled - A GL entry is posted: debit
AssetCategory.disposal_account, creditAsset.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)3. AssetMovement — Update Asset
Section titled “3. AssetMovement — Update Asset”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)4. Depreciation Batch Processing (Cron)
Section titled “4. Depreciation Batch Processing (Cron)”The depreciation batch job is cron-driven, scheduled via FinanceSettings.depreciation_cron_schedule (default: daily at 01:00). On each run:
- Query all unprocessed
DepreciationSchedulerows whereschedule_date <= todayandis_depreciated=False - For each row:
- Create a
JournalEntry(entry_type:"Adjustment") with two legs:- Dr
AssetCategory.depreciation_account(expense) - Cr
AssetCategory.accumulated_depreciation_account
- Dr
- 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
- Create a
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”5. ExpenseClaim — Auto-Sum & Workflow
Section titled “5. ExpenseClaim — Auto-Sum & Workflow”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"}, ],}7. GL Posting on Payment
Section titled “7. GL Posting on Payment”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_id8. FinanceSettings Additions
Section titled “8. FinanceSettings Additions”default_expense_claim_account: str | None = Field(default=None)employee_payable_account: str | None = Field(default=None)Example
Section titled “Example”# Create and process an expense claimclaim = 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 5500Drawbacks
Section titled “Drawbacks”- 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
WorkflowProtocolimplementation in Framework M must be stable beforeExpenseClaimcan be fully implemented.
Alternatives
Section titled “Alternatives”| 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 |
Migration
Section titled “Migration”- No data migration for new doctypes
FinanceSettingsadditions (default_expense_claim_account,employee_payable_account) need to be populated post-deployment
Unresolved Questions
Section titled “Unresolved Questions”- Should partial disposal be supported (e.g., selling part of an asset batch)?
- Should
DepreciationSchedulerows be re-generated ifAssetCategoryrules change after submission? - How should failed depreciation batch rows be surfaced to the Finance team — as a
DepreciationBatchLogdoctype, email notification, or dashboard alert? - What happens to an
ExpenseClaimif theDept Headwho needs to approve it leaves the organisation (role reassignment)?
Implementation Plan
Section titled “Implementation Plan”Phase 08 — Fixed Asset Register:
- Implement
AssetController.before_submit— amount validations - Implement
AssetController.after_submit—DepreciationSchedulegeneration (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_batchcron 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_WORKFLOWwith Framework MWorkflowProtocol - Implement
on_workflow_transition— GL posting on"Mark as Paid" - Add
gl_entry_idfield toExpenseClaim - Add
default_expense_claim_accountandemployee_payable_accounttoFinanceSettings - Unit tests for claim total computation
- Integration tests for full workflow lifecycle (Draft → Pending → Approved → Paid)
Testing Strategy
Section titled “Testing Strategy”Unit Tests
Section titled “Unit Tests”| 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 |
References
Section titled “References”- RFC-003: Finance Library Architecture (Decision 6 — cron, Decision 7 — WorkflowProtocol)
- RFC-004: Foundation & Masters
- RFC-008: General Ledger (DepreciationSchedule creates JournalEntry)