Skip to content

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

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.

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.


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.

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_id must be added as a hidden field to PaymentVoucher to store the Book-Keeper reference.

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"

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"

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

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

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

# Supplier Payment
pv = 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 set

  • Storing gl_entry_id as 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_value for gl_entry_id fails, the GL entry exists with no local reference. A two-phase commit or idempotent upsert pattern in Book-Keeper should be considered.
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
  • Add gl_entry_id: str | None field to PaymentVoucher, PaymentReceipt, DebitNote, CreditNote
  • Add purchase_return_account and sales_return_account to FinanceSettings
  • No data migration for existing records (all new)
  • Should PaymentVoucher referencing a PurchaseInvoice automatically link the two? (Partially paid invoice tracking)
  • Should DebitNote/CreditNote update the outstanding balance on the linked invoice in m-invoice?
  • What is the retry behaviour if GL posting fails mid-transaction (network timeout)?
  • Add gl_entry_id field to all four doctypes
  • Add purchase_return_account / sales_return_account to FinanceSettings
  • Implement get_fiscal_year utility
  • 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
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
  • Mock Book-Keeper; verify full submit → cancel round-trip for each document type
  • Verify utilized_amount is updated correctly on PaymentVoucher submit and cancel
  • RFC-003: Finance Library Architecture
  • RFC-004: Foundation & Masters (FinanceSettings, Account hierarchy)
  • RFC-006: Budget Management (update_budget_utilization)