RFC-010: Finance — Earnest Money Deposit (EMD) 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 09 (Earnest Money Deposit) of the finance library. It specifies the controller logic for EMDReceipt and EMDRefund, including GL posting rules, status lifecycle management, and the forfeit flow that does not require a separate refund document.
Motivation
Section titled “Motivation”Organisations frequently collect Earnest Money Deposits (EMD) from bidders and counterparties as security against contractual obligations. The finance module must properly track EMD receipts, their current status (Held | Refunded | Forfeited), and the corresponding GL postings for receipt, refund, and forfeiture. This is a compliance requirement for financial audits.
Detailed Design
Section titled “Detailed Design”1. EMD Receipt Flow
Section titled “1. EMD Receipt Flow”Party submits EMD (cheque / DD / NEFT) └─► EMDReceipt (status: Held) └─► GL: Dr Bank A/c, Cr EMD Liability A/c └─► [Contract/bid outcome] ──────────────────────┐ │ │ ▼ ▼ EMDRefund (partial/full) Forfeit Action status: Refunded status: Forfeited GL: Dr EMD Liability, Cr Bank GL: Dr EMD Liability, Cr Income2. EMDReceiptController
Section titled “2. EMDReceiptController”class EMDReceiptController(Controller[EMDReceipt]):
async def before_submit(self, doc: EMDReceipt) -> None: """Validate before posting GL.""" if doc.amount <= Decimal("0"): raise ValidationError("EMD amount must be greater than zero.") if not doc.bank_account: raise ValidationError("Bank Account is required to record EMD receipt.")
async def after_submit(self, doc: EMDReceipt) -> None: """Post GL: Dr Bank Account, Cr EMD Liability.""" settings = await FinanceSettings.get_single()
gl_entry_id = await bookkeeper_client.post_gl_entry( company=doc.company, posting_date=doc.receipt_date, debit_account=await get_bank_gl_account(doc.bank_account), credit_account=settings.emd_liability_account, amount=doc.amount, narration=( f"EMD Receipt {doc.receipt_number} from {doc.party}" + (f" against Ref {doc.ref_number}" if doc.ref_number else "") ), party_type=doc.party_type, party=doc.party, reference=doc.reference_number, ) doc.gl_entry_id = gl_entry_id doc.status = "Held"
async def after_cancel(self, doc: EMDReceipt) -> None: """Reverse GL entry if the receipt is cancelled.""" if doc.status in ("Refunded", "Forfeited"): raise ValidationError( "Cannot cancel an EMD Receipt that has already been refunded or forfeited." ) await bookkeeper_client.cancel_gl_entry(doc.gl_entry_id) doc.status = "Cancelled"3. EMDRefundController
Section titled “3. EMDRefundController”class EMDRefundController(Controller[EMDRefund]):
async def before_submit(self, doc: EMDRefund) -> None: """Validate refund amount and receipt status.""" receipt = await self.db.get(EMDReceipt, doc.emd_receipt)
if receipt.status != "Held": raise ValidationError( f"EMD Receipt '{doc.emd_receipt}' is not in 'Held' status. " f"Current status: {receipt.status}." ) if doc.refund_amount <= Decimal("0"): raise ValidationError("Refund amount must be greater than zero.") if doc.refund_amount > receipt.amount: raise ValidationError( f"Refund amount ({doc.refund_amount}) cannot exceed " f"the original EMD receipt amount ({receipt.amount})." )
async def after_submit(self, doc: EMDRefund) -> None: """Post GL: Dr EMD Liability, Cr Bank Account.""" settings = await FinanceSettings.get_single() receipt = await self.db.get(EMDReceipt, doc.emd_receipt)
gl_entry_id = await bookkeeper_client.post_gl_entry( company=receipt.company, posting_date=doc.refund_date, debit_account=settings.emd_liability_account, credit_account=await get_bank_gl_account(doc.bank_account), amount=doc.refund_amount, narration=f"EMD Refund {doc.refund_number} against Receipt {doc.emd_receipt}", party_type=receipt.party_type, party=receipt.party, reference=doc.reference_number, ) doc.gl_entry_id = gl_entry_id doc.status = "Submitted"
# Update EMDReceipt status await self.db.set_value(EMDReceipt, doc.emd_receipt, "status", "Refunded")
async def after_cancel(self, doc: EMDRefund) -> None: """Reverse refund GL and restore receipt status to Held.""" await bookkeeper_client.cancel_gl_entry(doc.gl_entry_id) await self.db.set_value(EMDReceipt, doc.emd_receipt, "status", "Held") doc.status = "Cancelled"4. EMD Forfeiture Flow
Section titled “4. EMD Forfeiture Flow”Forfeiture is handled as a custom action on EMDReceipt (not a separate document), since no refund is issued. The action posts a GL entry that transfers the liability to income:
# Custom action endpoint: POST /api/resource/EMDReceipt/{id}/forfeitasync def forfeit_emd(receipt_id: str, reason: str) -> None: receipt = await db.get(EMDReceipt, receipt_id)
if receipt.status != "Held": raise ValidationError( f"Can only forfeit a 'Held' EMD. Current status: {receipt.status}." )
settings = await FinanceSettings.get_single()
# GL: Dr EMD Liability (clears the liability), Cr Forfeited EMD Income await bookkeeper_client.post_gl_entry( company=receipt.company, posting_date=date.today(), debit_account=settings.emd_liability_account, credit_account=settings.emd_forfeiture_income_account, amount=receipt.amount, narration=f"EMD Forfeited: {receipt.receipt_number}. Reason: {reason}", party_type=receipt.party_type, party=receipt.party, ) await db.set_value(EMDReceipt, receipt_id, "status", "Forfeited")5. FinanceSettings Additions
Section titled “5. FinanceSettings Additions”emd_liability_account: str | None = Field( default=None, description="EMD Liability Account (Cr on receipt, Dr on refund/forfeit)")emd_forfeiture_income_account: str | None = Field( default=None, description="Forfeited EMD Income Account (Cr on forfeit)")6. get_bank_gl_account Utility
Section titled “6. get_bank_gl_account Utility”async def get_bank_gl_account(bank_account_id: str) -> str: """Return the GL Account linked to a BankAccount record.""" bank_account = await db.get(BankAccount, bank_account_id) if not bank_account.gl_account: raise ValidationError( f"Bank Account '{bank_account_id}' does not have a linked GL Account. " "Please configure the GL Account on the Bank Account before proceeding." ) return bank_account.gl_account7. Additional Fields Required
Section titled “7. Additional Fields Required”| DocType | Field | Type | Description |
|---|---|---|---|
EMDReceipt |
gl_entry_id |
str | None |
Book-Keeper GL entry reference |
EMDRefund |
gl_entry_id |
str | None |
Book-Keeper GL entry reference |
8. API Endpoints
Section titled “8. API Endpoints”| Endpoint | Description |
|---|---|
| Standard CRUD + submit/cancel | EMDReceipt, EMDRefund |
POST /api/resource/EMDReceipt/{id}/forfeit |
Forfeit an EMD (with reason) |
GET /api/resource/EMDReceipt/by_ref/{ref_number} |
List EMD receipts for a contract/bid reference |
Example
Section titled “Example”# Record EMD receiptreceipt = await EMDReceipt.new({ "receipt_number": "EMD-2026-001", "company": "Acme Corp", "receipt_date": date(2026, 4, 10), "party_type": "Supplier", "party": "SUPPLIER-ALPHA", "amount": Decimal("100000.00"), "bank_account": "HDFC-CURRENT-001", "reference_number": "DD-20260410-001", "ref_number": "BID-2026-Q2-04",})await receipt.submit()# GL: Dr Bank 1,00,000 | Cr EMD Liability 1,00,000# receipt.status = "Held"
# Counterparty is awarded contract — refund EMDrefund = await EMDRefund.new({ "refund_number": "EMDR-2026-001", "emd_receipt": receipt.name, "refund_date": date(2026, 5, 20), "refund_amount": Decimal("100000.00"), "bank_account": "HDFC-CURRENT-001", "reference_number": "NEFT-20260520-001",})await refund.submit()# GL: Dr EMD Liability 1,00,000 | Cr Bank 1,00,000# receipt.status = "Refunded"
# Alternatively, if supplier fails to execute contract:await forfeit_emd(receipt.name, reason="Supplier failed to execute contract")# GL: Dr EMD Liability 1,00,000 | Cr Forfeited EMD Income 1,00,000# receipt.status = "Forfeited"Drawbacks
Section titled “Drawbacks”- The forfeit action modifies
EMDReceipt.statusbut does not create a separate audit document. For financial audits, this may be insufficient — anEMDForfeitdocument may be required in the future. before_submitonEMDRefunddoes not check for previously issued partial refunds (partial refund tracking is not modelled in the current schema).
Alternatives
Section titled “Alternatives”| Alternative | Pros | Cons | Why Not |
|---|---|---|---|
EMDForfeit doctype for forfeiture |
Cleaner audit trail | Extra doctype overhead | Status field on EMDReceipt is sufficient for now; can be Added later |
| Partial refund support via amounts | More accurate tracking | Complex multi-refund aggregation | Not in scope for Phase 09 |
Migration
Section titled “Migration”- Add
gl_entry_idtoEMDReceiptandEMDRefund - Add
emd_liability_accountandemd_forfeiture_income_accounttoFinanceSettings - No data migration for existing records
Unresolved Questions
Section titled “Unresolved Questions”- Should partial EMD refunds (e.g., refunding 50% and forfeiting 50%) be supported now or deferred?
- What is the contract/bid reference lookup strategy? Should
ref_numberlink to a separateContractorBiddoctype or remain a free-text cross-reference? - Should the
forfeitaction require a two-step confirmation (raise a workflow prompt) given its irreversible nature?
Implementation Plan
Section titled “Implementation Plan”- Add
gl_entry_idfield toEMDReceiptandEMDRefund - Add
emd_liability_accountandemd_forfeiture_income_accounttoFinanceSettings - Implement
get_bank_gl_accountutility - Implement
EMDReceiptController(on_submit, on_cancel) - Implement
EMDRefundController(on_submit, on_cancel) - Implement
forfeit_emdcustom action - Register
forfeitandby_refAPI endpoints - Unit tests for all controller methods
- Unit tests for
forfeit_emd - Integration tests for full EMD lifecycle (receipt → refund, receipt → forfeit)
Testing Strategy
Section titled “Testing Strategy”Unit Tests
Section titled “Unit Tests”| Test | Scenario |
|---|---|
test_receipt_zero_amount |
Amount ≤ 0 → ValidationError |
test_receipt_no_bank_account |
Missing bank_account → ValidationError |
test_receipt_submit_gl_posted |
GL: Dr Bank, Cr EMD Liability |
test_receipt_cancel_after_refund |
Cancel on Refunded receipt → ValidationError |
test_refund_exceeds_receipt |
Refund > receipt amount → ValidationError |
test_refund_non_held_receipt |
Refund against non-Held receipt → ValidationError |
test_refund_submit_gl_posted |
GL: Dr EMD Liability, Cr Bank |
test_refund_submit_updates_receipt_status |
Receipt status → Refunded |
test_refund_cancel_restores_held |
Cancel refund → receipt back to Held |
test_forfeit_non_held_receipt |
Forfeit non-Held receipt → ValidationError |
test_forfeit_gl_posted |
GL: Dr EMD Liability, Cr Forfeiture Income |
test_forfeit_updates_status |
Receipt status → Forfeited |
test_get_bank_gl_account_no_gl |
BankAccount with no GL → ValidationError |
Integration Tests
Section titled “Integration Tests”- Full receipt → refund flow with mock Book-Keeper
- Full receipt → forfeit flow with mock Book-Keeper
References
Section titled “References”- RFC-003: Finance Library Architecture & Doctypes
- RFC-004: Foundation & Masters (FinanceSettings, BankAccount GL linkage)
- RFC-007: Bills & Payments (GL posting pattern)