Skip to content

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

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.

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.


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

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}/forfeit
async 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")
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)"
)
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_account
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
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
# Record EMD receipt
receipt = 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 EMD
refund = 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"

  • The forfeit action modifies EMDReceipt.status but does not create a separate audit document. For financial audits, this may be insufficient — an EMDForfeit document may be required in the future.
  • before_submit on EMDRefund does not check for previously issued partial refunds (partial refund tracking is not modelled in the current schema).
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
  • Add gl_entry_id to EMDReceipt and EMDRefund
  • Add emd_liability_account and emd_forfeiture_income_account to FinanceSettings
  • No data migration for existing records
  • 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_number link to a separate Contract or Bid doctype or remain a free-text cross-reference?
  • Should the forfeit action require a two-step confirmation (raise a workflow prompt) given its irreversible nature?
  • Add gl_entry_id field to EMDReceipt and EMDRefund
  • Add emd_liability_account and emd_forfeiture_income_account to FinanceSettings
  • Implement get_bank_gl_account utility
  • Implement EMDReceiptController (on_submit, on_cancel)
  • Implement EMDRefundController (on_submit, on_cancel)
  • Implement forfeit_emd custom action
  • Register forfeit and by_ref API endpoints
  • Unit tests for all controller methods
  • Unit tests for forfeit_emd
  • Integration tests for full EMD lifecycle (receipt → refund, receipt → forfeit)
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
  • Full receipt → refund flow with mock Book-Keeper
  • Full receipt → forfeit flow with mock Book-Keeper
  • RFC-003: Finance Library Architecture & Doctypes
  • RFC-004: Foundation & Masters (FinanceSettings, BankAccount GL linkage)
  • RFC-007: Bills & Payments (GL posting pattern)