RFC-004: Finance — Foundation & F&A Masters 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 the Phase 00 (Foundation) and Phase 01 (F&A Masters) of the finance library. Together these establish the foundational data structures — company context, financial calendar, chart of accounts, cost centres, and financial party extensions — that every other finance module depends on. Because all other RFCs build on these, this phase must be implemented and stabilised first.
Motivation
Section titled “Motivation”Without a working Company, FiscalYear, AccountingPeriod, and Account hierarchy, no GL entry can be posted, no budget can be tracked, and no payment can be recorded. These masters are the roots of the entire F&A data model.
Detailed Design
Section titled “Detailed Design”1. Data Model Changes
Section titled “1. Data Model Changes”Timezone-Aware Dates (Decision 5 from RFC-003)
Section titled “Timezone-Aware Dates (Decision 5 from RFC-003)”All date/datetime fields on FiscalYear and AccountingPeriod must be migrated from the current naive str representation to timezone-aware datetime objects using Python’s datetime with timezone.utc.
# BEFORE (incorrect — naive string)start_date: str = Field(description="Start Date (YYYY-MM-DD)")
# AFTER (correct — timezone-aware datetime)from datetime import datetime, timezonestart_date: datetime = Field(description="Fiscal Year Start (timezone-aware)")All date comparisons, period overlap checks, and GL posting date validations must use timezone-aware datetimes throughout.
FinanceSettings DocType
Section titled “FinanceSettings DocType”A new singleton settings doctype to drive configurable behaviours:
class FinanceSettings(DocType): __doctype_name__ = "FinanceSettings"
# Budget enforcement: "warn" (default) or "block" budget_enforcement_mode: str = Field(default="warn")
# Depreciation cron schedule (cron expression) depreciation_cron_schedule: str = Field(default="0 1 * * *")
# Book-Keeper API base URL bookkeeper_api_url: str = Field(default="") bookkeeper_api_key: str | None = Field(default=None)2. Controller Implementation
Section titled “2. Controller Implementation”AccountController — m-ledger Sync
Section titled “AccountController — m-ledger Sync”The most significant logic in this phase is the Account → m-ledger sync:
class AccountController(Controller[Account]): doctype = Account
async def after_save(self, doc: Account) -> None: """Sync account to m-ledger Book-Keeper after every save.""" try: ledger_id = await bookkeeper_client.sync_account( name=doc.name, account_type=doc.account_type, company=doc.company, is_group=doc.is_group, parent_account=doc.parent_account, ) # Update ledger_account_id without triggering another save await self.db.set_value(Account, doc.name, "ledger_account_id", ledger_id) except BookKeeperSyncError as e: # Surface a non-blocking warning; the account is saved but not yet synced self.add_message( type="warning", message=f"Account saved but could not sync to Book-Keeper: {e}. " "Use 'Sync to Ledger' action to retry.", )A custom sync_to_ledger action endpoint must be registered on the Account doctype to allow manual retry.
FiscalYearController
Section titled “FiscalYearController”class FiscalYearController(Controller[FiscalYear]): async def before_save(self, doc: FiscalYear) -> None: """Validate that start_date < end_date and no overlap with existing years.""" if doc.start_date >= doc.end_date: raise ValidationError("Fiscal year start date must be before end date.") # Check for overlap with other fiscal years for the same company context await self._validate_no_overlap(doc)AccountingPeriodController
Section titled “AccountingPeriodController”class AccountingPeriodController(Controller[AccountingPeriod]): async def before_save(self, doc: AccountingPeriod) -> None: """Validate period is within fiscal year bounds and non-overlapping.""" fiscal_year = await self.db.get(FiscalYear, doc.fiscal_year) if doc.start_date < fiscal_year.start_date or doc.end_date > fiscal_year.end_date: raise ValidationError("Accounting period must fall within the fiscal year.") await self._validate_no_overlap(doc)3. Account Tree Validation
Section titled “3. Account Tree Validation”On before_save of Account, validate:
- A group account (
is_group=True) cannot be directly posted to (no GL entries) - A leaf account must belong to a
parent_accountthat is a group account_typeof a child must be consistent with its parent’saccount_type- Circular references in
parent_accountmust be rejected
4. FinanceSupplier / FinanceCustomer Uniqueness
Section titled “4. FinanceSupplier / FinanceCustomer Uniqueness”Each supplier/customer record in m-core should have at most one FinanceSupplier / FinanceCustomer record per company:
class FinanceSupplierController(Controller[FinanceSupplier]): async def before_save(self, doc: FinanceSupplier) -> None: existing = await self.db.get_value( FinanceSupplier, filters={"supplier": doc.supplier, "company": doc.company}, ) if existing and existing.name != doc.name: raise ValidationError( f"A FinanceSupplier record already exists for supplier " f"'{doc.supplier}' in company '{doc.company}'." )Example
Section titled “Example”# Creating the foundational master data programmatically
company = await Company.new({ "name": "Acme Corp", "abbr": "ACME", "default_currency": "INR",})
fy = await FiscalYear.new({ "name": "FY 2026-27", "start_date": datetime(2026, 4, 1, tzinfo=timezone.utc), "end_date": datetime(2027, 3, 31, 23, 59, 59, tzinfo=timezone.utc),})
account = await Account.new({ "name": "Bank Accounts", "account_type": "Asset", "is_group": True, "company": "Acme Corp",})# AccountController.after_save fires → syncs to m-ledger automaticallyDrawbacks
Section titled “Drawbacks”- The automatic m-ledger sync in
after_saveintroduces a network call on every save, which adds latency. Mitigation: the sync is non-blocking (warning on failure, not an error); the manual fallback ensures data consistency. - Date migration from
strtodatetimerequires a data migration script for any existing records.
Alternatives
Section titled “Alternatives”| Alternative | Pros | Cons | Why Not |
|---|---|---|---|
| Lazy sync (sync on first GL posting) | No extra latency on account save | ledger_account_id is absent until first GL use; breaks GL validation |
Deferred complexity |
| Event-driven sync (queue) | Decoupled, retryable | Requires queue infrastructure | Overkill for master data volume |
Migration
Section titled “Migration”- Existing
FiscalYear/AccountingPeriodrecords: Run a data migration script to parse thestrdate fields and re-save as timezone-awaredatetimeobjects. - No breaking API changes for consumers that already use ISO 8601 strings — the serialised form remains identical.
FinanceSettingsis a new singleton; no migration required for existing records.
Unresolved Questions
Section titled “Unresolved Questions”- Should
Accountsync to Book-Keeper support partial failures (some legs sync, some fail) in a batch scenario, or must all accounts in a batch succeed atomically? - Should
CostCenteralso sync to Book-Keeper (i.e., does Book-Keeper have a “cost centre” concept)?
Implementation Plan
Section titled “Implementation Plan”- Fix
FiscalYear/AccountingPerioddate fields to timezone-awaredatetime - Scaffold
FinanceSettingssingleton doctype - Implement
AccountController.after_savem-ledger sync - Implement
Account— customsync_to_ledgeraction endpoint - Implement
FiscalYearController.before_save— overlap validation - Implement
AccountingPeriodController.before_save— bounds + overlap validation - Implement
Accounttree validation (group/leaf, circular ref, type consistency) - Implement
FinanceSupplierController/FinanceCustomerControlleruniqueness guards - Write unit tests for all controllers
- Write integration tests for m-ledger sync (with mock Book-Keeper)
- Write API endpoint tests for CRUD +
sync_to_ledgeraction
Testing Strategy
Section titled “Testing Strategy”Unit Tests
Section titled “Unit Tests”| Test | Scenario |
|---|---|
test_fiscal_year_date_validation |
start ≥ end raises ValidationError |
test_fiscal_year_overlap |
Overlapping fiscal year raises ValidationError |
test_accounting_period_out_of_bounds |
Period outside fiscal year raises error |
test_account_circular_ref |
Circular parent_account raises error |
test_account_group_consistency |
Leaf account type mismatch raises error |
test_finance_supplier_duplicate |
Duplicate FinanceSupplier per company raises error |
test_account_sync_success |
Successful sync sets ledger_account_id |
test_account_sync_failure_warning |
Failed sync surfaces warning, does not block save |
Integration Tests
Section titled “Integration Tests”- Mock Book-Keeper API; verify
Account.after_savecalls correct endpoint with correct payload - Verify
sync_to_ledgercustom action retries and setsledger_account_idon success
References
Section titled “References”- RFC-003: Finance Library Architecture & Doctypes
- Framework M
Controllerlifecycle documentation - m-ledger Book-Keeper API specification