RFC-008: Finance — General Ledger 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 06 (General Ledger) of the finance library. It specifies the controller logic for JournalEntry (with child JournalEntryAccount) and PeriodClosingVoucher. Both documents have full submit/cancel lifecycles. JournalEntry is the backbone of the accounting system — all manual, adjustment, and system-generated GL entries flow through it.
Motivation
Section titled “Motivation”The JournalEntry is the most expressive and flexible document in the finance system. Supporting multi-leg debit/credit entries with per-line party, cost centre, and remark tracking enables manual adjustments, opening balance entry, and year-end closing operations. PeriodClosingVoucher automates the year-end P&L transfer to retained earnings by querying the live Book-Keeper balances, which avoids manual recomputation errors.
Detailed Design
Section titled “Detailed Design”1. JournalEntry — Submit Lifecycle
Section titled “1. JournalEntry — Submit Lifecycle”Draft → [Fill accounts child table] → before_save validation → Saved → before_submit validation → after_submit (GL posted) → Submitted → after_cancel (GL reversed) → Cancelledbefore_save — Balance Validation
Section titled “before_save — Balance Validation”class JournalEntryController(Controller[JournalEntry]):
async def before_save(self, doc: JournalEntry) -> None: """Recompute totals and validate balance on every save.""" total_debit = Decimal("0") total_credit = Decimal("0")
for leg in doc.accounts: if leg.debit < Decimal("0") or leg.credit < Decimal("0"): raise ValidationError("Debit and credit amounts cannot be negative.") if leg.debit > Decimal("0") and leg.credit > Decimal("0"): raise ValidationError( f"A single account leg cannot have both debit and credit. " f"See account '{leg.account}'." ) total_debit += leg.debit total_credit += leg.credit
doc.total_debit = total_debit doc.total_credit = total_credit
async def before_submit(self, doc: JournalEntry) -> None: """Enforce balanced entry before GL posting.""" if doc.total_debit != doc.total_credit: raise ValidationError( f"Journal entry is not balanced. " f"Total Debit: {doc.total_debit}, Total Credit: {doc.total_credit}." ) if doc.total_debit == Decimal("0"): raise ValidationError("Journal entry cannot have zero total amount.") if len(doc.accounts) < 2: raise ValidationError("A journal entry must have at least two account legs.")after_submit — Multi-Leg GL Posting
Section titled “after_submit — Multi-Leg GL Posting”Each JournalEntryAccount row is posted as a separate GL line to Book-Keeper, grouped under the same journal_entry_id batch reference:
async def after_submit(self, doc: JournalEntry) -> None: """Post all account legs to Book-Keeper as a single GL batch.""" gl_legs = [ { "account": leg.account, "debit": leg.debit, "credit": leg.credit, "party_type": leg.party_type, "party": leg.party, "cost_center": leg.cost_center, "remark": leg.user_remark, } for leg in doc.accounts ]
gl_batch_id = await bookkeeper_client.post_journal_entry( company=doc.company, posting_date=doc.posting_date, entry_type=doc.entry_type, narration=doc.narration, legs=gl_legs, ) doc.gl_batch_id = gl_batch_id doc.status = "Submitted"
async def after_cancel(self, doc: JournalEntry) -> None: """Reverse the entire GL batch.""" await bookkeeper_client.cancel_journal_entry(doc.gl_batch_id) doc.status = "Cancelled"Note: A
gl_batch_idfield must be added toJournalEntryfor the Book-Keeper batch reference.
2. JournalEntry — Entry Types
Section titled “2. JournalEntry — Entry Types”entry_type |
Use Case | Notes |
|---|---|---|
"Journal Entry" |
Manual adjustments, inter-account transfers | General purpose |
"Adjustment" |
Corrections to previous entries | Audit trail required |
"Opening Balance" |
Initial balances on system go-live | One-time, per account |
Each type may require different validation rules in future iterations (e.g., Opening Balance entries may only be allowed if the accounting period has no prior postings).
3. Accounting Period Lock
Section titled “3. Accounting Period Lock”Before submitting, check that the posting date falls within an open AccountingPeriod:
async def before_submit(self, doc: JournalEntry) -> None: # (balance checks above, then...) period = await db.get_value( AccountingPeriod, filters=[ ("fiscal_year", "=", await get_fiscal_year(doc.posting_date, doc.company)), ("start_date", "<=", doc.posting_date), ("end_date", ">=", doc.posting_date), ("is_closed", "=", False), ], ) if not period: raise ValidationError( f"No open accounting period found for posting date {doc.posting_date}. " "Please open an accounting period or use a valid date." )4. PeriodClosingVoucher Controller
Section titled “4. PeriodClosingVoucher Controller”Year-end closing transfers net P&L to retained earnings:
- Income accounts are credited (zero-out)
- Expense accounts are debited (zero-out)
- Net difference goes to
closing_account(Retained Earnings)
class PeriodClosingVoucherController(Controller[PeriodClosingVoucher]):
async def before_submit(self, doc: PeriodClosingVoucher) -> None: """Compute net P&L from Book-Keeper before closing.""" # Fetch trial balance from Book-Keeper for the fiscal year trial_balance = await bookkeeper_client.get_trial_balance( company=doc.company, fiscal_year=doc.fiscal_year, as_of_date=doc.period_year_end_date, ) income_total = sum( account.balance for account in trial_balance if account.type == "Income" ) expense_total = sum( account.balance for account in trial_balance if account.type == "Expense" ) doc.net_pl_amount = income_total - expense_total
# Validate no prior closing voucher exists for this fiscal year existing = await self.db.get_value( PeriodClosingVoucher, filters={ "fiscal_year": doc.fiscal_year, "company": doc.company, "status": "Submitted", }, ) if existing: raise ValidationError( f"A period closing voucher already exists for " f"fiscal year '{doc.fiscal_year}'." )
async def after_submit(self, doc: PeriodClosingVoucher) -> None: """Automatically close the fiscal year and mark AccountingPeriods closed.""" # Post the closing JE to Book-Keeper await bookkeeper_client.post_period_closing( company=doc.company, fiscal_year=doc.fiscal_year, closing_account=doc.closing_account, net_pl_amount=doc.net_pl_amount, posting_date=doc.posting_date, ) # Mark FiscalYear as closed await self.db.set_value(FiscalYear, doc.fiscal_year, "is_closed", True) doc.status = "Submitted"5. Additional Fields Required
Section titled “5. Additional Fields Required”| DocType | Field | Type | Description |
|---|---|---|---|
JournalEntry |
gl_batch_id |
str | None |
Book-Keeper batch reference |
JournalEntry |
entity_id |
str |
Owning entity (org context) |
6. API Endpoints
Section titled “6. API Endpoints”| Endpoint | Description |
|---|---|
| Standard CRUD + submit/cancel | JournalEntry, PeriodClosingVoucher |
GET /api/resource/JournalEntry/{id}/gl_entries |
Fetch all GL lines from Book-Keeper for this JE |
Example
Section titled “Example”# Manual inter-account transferje = await JournalEntry.new({ "entry_number": "JE-2026-001", "company": "Acme Corp", "posting_date": date(2026, 4, 20), "entry_type": "Journal Entry", "narration": "Transfer from petty cash to imprest account", "accounts": [ {"account": "Imprest Account", "debit": Decimal("5000"), "credit": Decimal("0")}, {"account": "Petty Cash", "debit": Decimal("0"), "credit": Decimal("5000")}, ],})# before_save: total_debit=5000, total_credit=5000await je.submit()# before_submit: balanced ✓, open period ✓# after_submit: GL batch posted to Book-KeeperDrawbacks
Section titled “Drawbacks”before_submitcalls two external services (Book-Keeper trial balance inPeriodClosingVoucher, period lookup). If either is slow or unavailable, the submission blocks.- The period closing voucher creates a side effect that cannot be simply rolled back (FiscalYear
is_closed=True). Cancellation of aPeriodClosingVouchermust also re-open the fiscal year — this is a complex and potentially dangerous operation that should require elevated privilege.
Alternatives
Section titled “Alternatives”| Alternative | Pros | Cons | Why Not |
|---|---|---|---|
| Store full GL detail on JournalEntry | Offline access | Duplicates Book-Keeper data | Violates single source of truth |
| Separate period-closing as a batch process | Decoupled | No user-facing document trail | Govt audit requires document artifact |
Migration
Section titled “Migration”- Add
gl_batch_id: str | Nonefield toJournalEntry - No migration for existing records
Unresolved Questions
Section titled “Unresolved Questions”- Should cancellation of a
PeriodClosingVoucherre-open the fiscal year? If so, what privilege is required? - Should
Opening Balancejournal entries be restricted to a specific entry window (before first operational transactions)? - How are system-generated
JournalEntryrecords (fromPaymentVoucher,DepreciationSchedule, etc.) differentiated from user-created ones? Should ais_system_generated: boolflag be added?
Implementation Plan
Section titled “Implementation Plan”- Add
gl_batch_idfield toJournalEntry - Implement
JournalEntryController.before_save— totals + per-leg validation - Implement
JournalEntryController.before_submit— balance check + period lock - Implement
JournalEntryController.after_submit— multi-leg GL batch posting - Implement
JournalEntryController.after_cancel— GL batch reversal - Implement
PeriodClosingVoucherController.before_submit— P&L computation + duplicate guard - Implement
PeriodClosingVoucherController.after_submit— Book-Keeper closing + FY close - Implement
get_gl_entriesaction endpoint - Unit tests for all validation rules
- Integration tests for GL batch posting (mock Book-Keeper)
- Integration tests for period closing end-to-end
Testing Strategy
Section titled “Testing Strategy”Unit Tests
Section titled “Unit Tests”| Test | Scenario |
|---|---|
test_je_totals_computed |
Totals auto-computed on save |
test_je_unbalanced_rejected |
Debit ≠ Credit → ValidationError |
test_je_zero_amount_rejected |
Zero totals → ValidationError |
test_je_single_leg_rejected |
Less than 2 legs → ValidationError |
test_je_negative_amount_rejected |
Negative amount → ValidationError |
test_je_debit_and_credit_same_leg |
Both debit+credit on one leg → ValidationError |
test_je_closed_period_rejected |
Posting date in closed period → ValidationError |
test_je_submit_gl_posted |
Balanced entry in open period → GL batch posted |
test_je_cancel_gl_reversed |
Cancel reverses GL batch |
test_pcv_duplicate_rejected |
Second PCV for same FY → ValidationError |
test_pcv_submit_closes_fiscal_year |
After submit, FY is_closed=True |
Integration Tests
Section titled “Integration Tests”- Full JE lifecycle: create → save (validation) → submit (GL post) → cancel (GL reverse)
PeriodClosingVoucherend-to-end with mock Book-Keeper trial balance
References
Section titled “References”- RFC-003: Finance Library Architecture (Decision 4 — JournalEntry is_submittable=True)
- RFC-004: Foundation & Masters (AccountingPeriod locking, get_fiscal_year)
- RFC-007: Bills & Payments (GL posting pattern reference)