RFC-007: Finance — Bills & Payments 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 05 (Bills & Payments) of the finance library. It defines the controller logic and GL posting rules for PaymentVoucher, PaymentReceipt, DebitNote, and CreditNote. Each of these documents, on submission, must produce a balanced GL entry via the m-ledger Book-Keeper and check budget utilisation.
Motivation
Section titled “Motivation”Payment processing is the highest-frequency financial operation in the F&A module. Supplier payments, customer receipts, and adjustment notes each need to result in accurate, balanced, and auditable GL postings. Errors in these flows have direct accounting implications. The implementation must be atomic: either the document submits and the GL is posted, or both are rolled back.
Detailed Design
Section titled “Detailed Design”1. GL Posting Pattern
Section titled “1. GL Posting Pattern”All four doctypes follow the same GL posting pattern on submit:
Document Submit └─► Controller.after_submit() └─► bookkeeper_client.post_gl_entry(debit_account, credit_account, amount) └─► On success: set document.status = "Submitted" └─► On failure: raise GlPostingError (document remains in "Draft")On cancel, the GL entry is reversed:
Document Cancel └─► Controller.after_cancel() └─► bookkeeper_client.cancel_gl_entry(gl_entry_id) └─► On success: set document.status = "Cancelled"Each document stores the gl_entry_id returned by Book-Keeper so the cancel can reference it.
2. PaymentVoucher Controller
Section titled “2. PaymentVoucher Controller”class PaymentVoucherController(Controller[PaymentVoucher]):
async def before_submit(self, doc: PaymentVoucher) -> None: """Budget check before GL posting.""" await update_budget_utilization( company=doc.company, account=doc.paid_from_account, cost_center=doc.cost_center, fiscal_year=await get_fiscal_year(doc.payment_date, doc.company), amount=doc.amount, )
async def after_submit(self, doc: PaymentVoucher) -> None: """Post GL: Debit paid_from_account, Credit paid_to_account.""" gl_entry_id = await bookkeeper_client.post_gl_entry( company=doc.company, posting_date=doc.payment_date, debit_account=doc.paid_from_account, credit_account=doc.paid_to_account, amount=doc.amount, narration=doc.narration or f"Payment Voucher {doc.voucher_number}", reference=doc.reference_number, cost_center=doc.cost_center, party_type=doc.party_type, party=doc.party, ) doc.gl_entry_id = gl_entry_id doc.status = "Submitted"
async def after_cancel(self, doc: PaymentVoucher) -> None: """Reverse GL entry and reverse budget utilization.""" await bookkeeper_client.cancel_gl_entry(doc.gl_entry_id) await update_budget_utilization( company=doc.company, account=doc.paid_from_account, cost_center=doc.cost_center, fiscal_year=await get_fiscal_year(doc.payment_date, doc.company), amount=doc.amount, reverse=True, ) doc.status = "Cancelled"Note:
gl_entry_idmust be added as a hidden field toPaymentVoucherto store the Book-Keeper reference.
3. PaymentReceipt Controller
Section titled “3. PaymentReceipt Controller”Mirror of PaymentVoucher for the receivable side:
class PaymentReceiptController(Controller[PaymentReceipt]):
async def after_submit(self, doc: PaymentReceipt) -> None: """Post GL: Debit received_in_account (Bank/Cash), Credit paid_from_account (Receivable).""" gl_entry_id = await bookkeeper_client.post_gl_entry( company=doc.company, posting_date=doc.receipt_date, debit_account=doc.received_in_account, credit_account=doc.paid_from_account, amount=doc.amount, narration=doc.narration or f"Payment Receipt {doc.receipt_number}", reference=doc.reference_number, cost_center=doc.cost_center, party_type=doc.party_type, party=doc.party, ) doc.gl_entry_id = gl_entry_id doc.status = "Submitted"
async def after_cancel(self, doc: PaymentReceipt) -> None: await bookkeeper_client.cancel_gl_entry(doc.gl_entry_id) doc.status = "Cancelled"4. DebitNote Controller
Section titled “4. DebitNote Controller”A debit note reduces the supplier payable balance. The accounting entry is:
- Debit:
account(Supplier Payable) — reduces payable - Credit: Purchase Return / Goods Return Account (derived from
FinanceSettings.purchase_return_account)
class DebitNoteController(Controller[DebitNote]):
async def after_submit(self, doc: DebitNote) -> None: settings = await FinanceSettings.get_single() gl_entry_id = await bookkeeper_client.post_gl_entry( company=doc.company, posting_date=doc.posting_date, debit_account=doc.account, # Supplier Payable (reduces it) credit_account=settings.purchase_return_account, amount=doc.amount, narration=f"Debit Note {doc.note_number}: {doc.reason}", party_type="Supplier", party=doc.supplier, ) doc.gl_entry_id = gl_entry_id doc.status = "Submitted"
async def after_cancel(self, doc: DebitNote) -> None: await bookkeeper_client.cancel_gl_entry(doc.gl_entry_id) doc.status = "Cancelled"5. CreditNote Controller
Section titled “5. CreditNote Controller”A credit note reduces the customer receivable balance:
- Debit: Sales Return / Goods Return Account
- Credit:
account(Customer Receivable) — reduces receivable
class CreditNoteController(Controller[CreditNote]):
async def after_submit(self, doc: CreditNote) -> None: settings = await FinanceSettings.get_single() gl_entry_id = await bookkeeper_client.post_gl_entry( company=doc.company, posting_date=doc.posting_date, debit_account=settings.sales_return_account, credit_account=doc.account, # Customer Receivable (reduces it) amount=doc.amount, narration=f"Credit Note {doc.note_number}: {doc.reason}", party_type="Customer", party=doc.customer, ) doc.gl_entry_id = gl_entry_id doc.status = "Submitted"
async def after_cancel(self, doc: CreditNote) -> None: await bookkeeper_client.cancel_gl_entry(doc.gl_entry_id) doc.status = "Cancelled"6. FinanceSettings additions (for this phase)
Section titled “6. FinanceSettings additions (for this phase)”purchase_return_account: str | None = Field(default=None, description="Default Purchase Return Account")sales_return_account: str | None = Field(default=None, description="Default Sales Return Account")7. Additional Field: gl_entry_id
Section titled “7. Additional Field: gl_entry_id”All four submittable doctypes need a gl_entry_id field added to store the Book-Keeper reference:
gl_entry_id: str | None = Field(default=None, description="Book-Keeper GL Entry ID")8. get_fiscal_year Utility
Section titled “8. get_fiscal_year Utility”A helper to resolve the active FiscalYear for a given date and company:
async def get_fiscal_year(posting_date: date, company: str) -> str: """Return the name of the active FiscalYear for the given date.""" fy = await db.get_value( FiscalYear, filters=[ ("start_date", "<=", posting_date), ("end_date", ">=", posting_date), ("is_closed", "=", False), ], ) if not fy: raise ValidationError(f"No open fiscal year found for date {posting_date}.") return fy.nameExample
Section titled “Example”# Supplier Paymentpv = await PaymentVoucher.new({ "voucher_number": "PV-2026-001", "company": "Acme Corp", "payment_date": date(2026, 4, 15), "party_type": "Supplier", "party": "SUPPLIER-001", "paid_from_account": "SBI Bank Account", "paid_to_account": "Creditors - National", "amount": Decimal("50000.00"), "bank_account": "SBI-CURRENT-001", "reference_number": "CHQ-20260415-001",})await pv.submit()# budget checked → GL posted: Dr SBI Bank A/c, Cr Creditors# pv.status == "Submitted", pv.gl_entry_id setDrawbacks
Section titled “Drawbacks”- Storing
gl_entry_idas a field means doc schema changes are needed for all four doctypes — this is a minor migration. - If GL posting succeeds but the subsequent
db.set_valueforgl_entry_idfails, the GL entry exists with no local reference. A two-phase commit or idempotent upsert pattern in Book-Keeper should be considered.
Alternatives
Section titled “Alternatives”| Alternative | Pros | Cons | Why Not |
|---|---|---|---|
| Store GL ref in a separate link table | Cleaner separation | Extra join for every cancel | Unnecessary complexity |
| Don’t store GL ref (re-query by doc number) | Simpler schema | Book-Keeper API must support lookup by ref | Depends on external API contract |
Migration
Section titled “Migration”- Add
gl_entry_id: str | Nonefield toPaymentVoucher,PaymentReceipt,DebitNote,CreditNote - Add
purchase_return_accountandsales_return_accounttoFinanceSettings - No data migration for existing records (all new)
Unresolved Questions
Section titled “Unresolved Questions”- Should
PaymentVoucherreferencing aPurchaseInvoiceautomatically link the two? (Partially paid invoice tracking) - Should
DebitNote/CreditNoteupdate the outstanding balance on the linked invoice inm-invoice? - What is the retry behaviour if GL posting fails mid-transaction (network timeout)?
Implementation Plan
Section titled “Implementation Plan”- Add
gl_entry_idfield to all four doctypes - Add
purchase_return_account/sales_return_accounttoFinanceSettings - Implement
get_fiscal_yearutility - Implement
PaymentVoucherController(before_submit, after_submit, after_cancel) - Implement
PaymentReceiptController(after_submit, after_cancel) - Implement
DebitNoteController(after_submit, after_cancel) - Implement
CreditNoteController(after_submit, after_cancel) - Unit tests for each controller method
- Integration tests with mock Book-Keeper for GL posting
- Integration tests for budget enforcement during payment submission
Testing Strategy
Section titled “Testing Strategy”Unit Tests
Section titled “Unit Tests”| Test | Scenario |
|---|---|
test_pv_submit_gl_posted |
GL entry created on submit; gl_entry_id stored |
test_pv_cancel_gl_reversed |
GL entry cancelled on cancel; budget decremented |
test_pv_budget_warn_mode |
Overshoot warns but doesn’t block |
test_pv_budget_block_mode |
Overshoot raises BudgetExceededError; GL not posted |
test_pr_submit_gl_posted |
PaymentReceipt GL: Dr received_in, Cr receivable |
test_debit_note_submit |
GL: Dr payable, Cr purchase return account |
test_credit_note_submit |
GL: Dr sales return account, Cr receivable |
test_gl_posting_failure_rollback |
If Book-Keeper returns error, doc stays Draft |
test_get_fiscal_year_found |
Correct FY returned for a given date |
test_get_fiscal_year_not_found |
No open FY → ValidationError |
Integration Tests
Section titled “Integration Tests”- Mock Book-Keeper; verify full submit → cancel round-trip for each document type
- Verify
utilized_amountis updated correctly onPaymentVouchersubmit and cancel
References
Section titled “References”- RFC-003: Finance Library Architecture
- RFC-004: Foundation & Masters (FinanceSettings, Account hierarchy)
- RFC-006: Budget Management (update_budget_utilization)