Skip to content

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: —

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.

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.


Draft → [Fill accounts child table] → before_save validation → Saved
→ before_submit validation → after_submit (GL posted) → Submitted
→ after_cancel (GL reversed) → Cancelled
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.")

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_id field must be added to JournalEntry for the Book-Keeper batch reference.

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).

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."
)

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"
DocType Field Type Description
JournalEntry gl_batch_id str | None Book-Keeper batch reference
JournalEntry entity_id str Owning entity (org context)
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

# Manual inter-account transfer
je = 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=5000
await je.submit()
# before_submit: balanced ✓, open period ✓
# after_submit: GL batch posted to Book-Keeper

  • before_submit calls two external services (Book-Keeper trial balance in PeriodClosingVoucher, 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 a PeriodClosingVoucher must also re-open the fiscal year — this is a complex and potentially dangerous operation that should require elevated privilege.
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
  • Add gl_batch_id: str | None field to JournalEntry
  • No migration for existing records
  • Should cancellation of a PeriodClosingVoucher re-open the fiscal year? If so, what privilege is required?
  • Should Opening Balance journal entries be restricted to a specific entry window (before first operational transactions)?
  • How are system-generated JournalEntry records (from PaymentVoucher, DepreciationSchedule, etc.) differentiated from user-created ones? Should a is_system_generated: bool flag be added?
  • Add gl_batch_id field to JournalEntry
  • 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_entries action endpoint
  • Unit tests for all validation rules
  • Integration tests for GL batch posting (mock Book-Keeper)
  • Integration tests for period closing end-to-end
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
  • Full JE lifecycle: create → save (validation) → submit (GL post) → cancel (GL reverse)
  • PeriodClosingVoucher end-to-end with mock Book-Keeper trial balance
  • 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)