diff --git a/docs/CUSTOMER_STATEMENTS.md b/docs/CUSTOMER_STATEMENTS.md index 1dbb260..091c36e 100644 --- a/docs/CUSTOMER_STATEMENTS.md +++ b/docs/CUSTOMER_STATEMENTS.md @@ -32,12 +32,26 @@ Each generation is recorded on the customer's timeline as an audit-trail entry. ## 2. Late payment fees ### How the fee is calculated -Interest uses ERPNext's own Dunning formula: +Interest uses ERPNext's own Dunning formula, accrued **from the last fee run** +rather than from each invoice's due date, so no period is ever billed twice: ``` -fee = Σ(invoice.outstanding × rate_of_interest/100/365 × days_overdue) + dunning_fee +accrue_from = max(invoice.due_date, last_billed_upto) +interest = Σ(invoice.outstanding × rate_of_interest/100/365 × days_since(accrue_from)) +charge = interest + dunning_fee # flat fee only when raising a new fee invoice + = interest # when topping up an existing one ``` +The Σ runs over **every** overdue invoice, unpaid late-fee invoices included — +they are receivables like any other and are charged on the same terms. + +`last_billed_upto` is stored on the fee invoice itself +(`custom_late_fee_billed_upto`). Fee invoices raised before that field existed +fall back to their posting date, which is when they were billed. + +The flat `dunning_fee` is a one-off charge for falling into collections, applied +when a fee invoice is first raised — not again on every top-up. + ### Where the settings live — ERPNext **Dunning Type** All fee configuration comes from the existing **Dunning Type** doctype (Accounting ▸ Dunning Type). Nothing is auto-created; generation stops with a @@ -47,7 +61,7 @@ the company is used. Fields consumed: | Dunning Type field | Purpose | |--------------------|---------| | `rate_of_interest` | Annual interest rate (%) | -| `dunning_fee` | Flat fee added per statement | +| `dunning_fee` | Flat fee, charged once when a fee invoice is raised | | `income_account` | Credited when the fee is billed | | `cost_center` | Cost center for the fee line (falls back to company default) | | `custom_late_fee_item` | **Late Fee Item** — the Item used to bill the fee (custom field added by this app) | @@ -71,18 +85,59 @@ sit **uncollectible** by those flows — hence the Sales Invoice. - **Never taxed.** A single zero-amount "Actual" tax line keeps ERPNext from auto-applying company/item tax templates, so the invoice total equals the computed fee exactly and nothing extra hits the ledger. -- **Idempotent** — at most one fee invoice per customer / company / calendar - month. Prior fee invoices are excluded from the interest base (no fee-on-fee). +- **Idempotent** — at most one charge per customer / company / calendar month. + An unpaid fee invoice accrues interest on the same terms as any other overdue + receivable (see below). On the statement the fee shows as a normal invoice line tagged **“late fee”**, folded into a single **Total Due** that equals the customer's balance. +### One fee invoice per collections episode +While an earlier fee invoice still carries a balance, the next run **amends it** +and appends the new period's interest as a further line item, instead of raising +a second invoice. The customer sees one growing charge, itemised by period. + +Amending means cancelling and re-raising, so the invoice number gains a suffix +(`LPF-2026-00001` → `LPF-2026-00001-1`). The original posting and due dates are +carried over deliberately: re-dating to today would reset the invoice to +*Current* in the statement's aging buckets and hide how long the balance has +been owed. + +**Partially paid fee invoices.** Cancelling unlinks any Payment Entries, leaving +the cash unallocated on them. After the amended invoice is submitted, each +payment is re-applied to it via `reconcile_against_document` — the same +primitive the Payment Reconciliation tool uses — so the outstanding amount and +the Payment Ledger reflect what the customer actually owes. (As with any +reconciliation, ERPNext clears `against_voucher` on the payment's **GL** rows and +tracks the allocation on the **Payment Ledger**; the AR reports read the latter.) + +**When it can't amend.** Cancelling is only reversible for links this app knows +how to restore. If the open fee invoice has a Journal Entry or credit note +applied, a negative payment allocation, or a posting date inside a frozen +accounting period, it is left alone, the charge goes onto a new invoice, and the +reason is recorded on the customer's timeline: + +> Late fee LPF-2026-00001 could not be amended (a Journal Entry is applied +> against it); the charge was billed on a new invoice. + +### Unpaid fee invoices accrue too +A late-fee invoice is an overdue receivable like any other, and once past its +due date its outstanding balance is part of the interest base. Because the +charge lands back on the invoice carrying that balance, **interest compounds**: +each run adds interest on the fee balance the previous runs built up. + +A customer whose only remaining overdue item is an unpaid fee invoice therefore +keeps accruing — the balance grows by `outstanding × rate/365 × days_since_last_run` +every run until it is paid. No new flat fee is raised while a fee invoice is +open, so the growth is interest alone. + --- ## 3. Configuration / prerequisites 1. **Migrate** the app (`bench --site migrate`) — creates the - `Late Fee Item` custom field on Dunning Type and registers the `LPF-` series. + `Late Fee Item` custom field on Dunning Type, the `Late Fee Billed Upto` + custom field on Sales Invoice, and registers the `LPF-` series. 2. Create an **Item** to represent the fee (a non-stock sales item, e.g. "Late Payment Fee"). 3. Create/complete a **Dunning Type** for the company with: rate of interest, @@ -97,7 +152,7 @@ error and posts nothing. Each generated statement adds an *Info* comment to the customer's timeline, e.g. -> Statement generated — Total Due $557.17 (late fee invoice LPF-2026-00001). +> Statement generated — Total Due $557.17 (late fee charged on LPF-2026-00001-1). The note reflects the outcome: the fee invoice raised, *no late fee*, or *late fee skipped* (when the fee checkbox was cleared). It is attributed to the @@ -112,7 +167,7 @@ generating user. | `ns_app/api/statements.py` | Overdue-customer query, statement builder, printable HTML, late-fee billing (Sales Invoice), audit-trail entry | | `ns_app/templates/statements/customer_statement.html` | Jinja template for one customer page (envelope windows + aging table) | | `ns_app/public/js/customer_statements.js` | List action + Customer-form button + selection/fee-toggle popups (loaded globally) | -| `ns_app/setup.py` | `after_migrate`: creates the Late Fee Item custom field, registers the `LPF-` naming series | +| `ns_app/setup.py` | `after_migrate`: creates the Late Fee Item / Late Fee Billed Upto custom fields, registers the `LPF-` naming series | | `ns_app/hooks.py` | Wires the JS (`app_include_js`) and `after_migrate` | ### Server API (`ns_app.api.statements`) diff --git a/ns_app/api/statements.py b/ns_app/api/statements.py index bfbed18..4db2b1c 100644 --- a/ns_app/api/statements.py +++ b/ns_app/api/statements.py @@ -16,6 +16,10 @@ from frappe.utils import flt, fmt_money, getdate, nowdate # Dedicated naming series so late-fee invoices are easy to spot and filter. LATE_FEE_NAMING_SERIES = "LPF-.YYYY.-" +# Custom field on Sales Invoice recording the date interest was last charged, so +# the next run accrues from there instead of re-charging from the due date. +BILLED_UPTO_FIELD = "custom_late_fee_billed_upto" + # Roles allowed to run collections/statement actions. ALLOWED_ROLES = [ "System Manager", @@ -264,6 +268,16 @@ def _wrap_document(pages): # (item -> Dunning Type income account) so it both hits the ledger and is # collectible by the app's existing payment flow (Run Payment / AutoPay / # multi-invoice), which settles Sales Invoices. +# +# A customer gets **one** fee invoice per collections episode, not one per +# month: while an earlier fee invoice still carries a balance, the next run +# amends it and appends the new period's interest as another line, so the +# customer sees a single growing charge instead of a stack of small ones. The +# flat dunning_fee is a one-off for falling into collections and is charged only +# when a fee invoice is first raised. Interest accrues from the last run +# (BILLED_UPTO_FIELD), not from each invoice's due date, so no period is billed +# twice. An unpaid fee invoice is itself an overdue receivable and accrues on +# the same terms as any other, so interest compounds onto the fee balance. DUNNING_TYPE_FIELDS = [ "name", @@ -308,26 +322,270 @@ def _late_fee_period(): def _get_fee_invoices(customer, company, fee_item): - """Return submitted late-fee Sales Invoices for a customer (by fee item).""" + """Submitted late-fee Sales Invoices for a customer/company, newest first. + + `distinct` matters: an amended fee invoice carries one item row per period + billed, so the join would otherwise return it several times. + """ if not fee_item: return [] return frappe.db.sql( """ - select si.name, si.posting_date + select distinct si.name, si.posting_date, si.due_date, si.debit_to, + si.outstanding_amount, si.creation, si.{billed_upto} as billed_upto from `tabSales Invoice` si inner join `tabSales Invoice Item` sii on sii.parent = si.name where si.customer = %s and si.company = %s and si.docstatus = 1 and sii.item_code = %s - """, + order by si.posting_date desc, si.creation desc + """.format(billed_upto=BILLED_UPTO_FIELD), (customer, company, fee_item), as_dict=True, ) -def _post_late_fee_invoice(customer, company, overdue_invoices, period): - """Bill a late fee as a submitted Sales Invoice (idempotent per month). +def _last_billed_upto(fee_invoices): + """Date late-fee interest was last charged, or None if it never has been. - Returns the fee invoice name, or None if nothing was billed. + Fee invoices raised before the marker field existed fall back to their + posting date, which is exactly when they were billed. + """ + dates = [getdate(fi.billed_upto or fi.posting_date) for fi in fee_invoices] + return max(dates) if dates else None + + +def _open_fee_invoice(fee_invoices): + """The most recent fee invoice still carrying a balance, or None.""" + for fi in fee_invoices: + if flt(fi.outstanding_amount) > 0: + return fi + return None + + +def _accrued_interest(overdue_invoices, rate_of_interest, last_billed_upto): + """Interest accrued since the last fee run (or since each invoice fell due). + + Charging on `days_overdue` every run would re-bill every period already + paid for, so each invoice accrues only from whichever is later: its due date + or the last time a fee was charged. + + An unpaid late-fee invoice is an overdue receivable like any other and is + charged on the same terms — its balance accrues interest too, which then + lands back on the invoice carrying it. + """ + today = getdate(nowdate()) + daily_interest = flt(rate_of_interest) / 100.0 / 365.0 + interest = 0.0 + for inv in overdue_invoices: + accrue_from = getdate(inv["due_date"]) + if last_billed_upto and last_billed_upto > accrue_from: + accrue_from = last_billed_upto + days = (today - accrue_from).days + if days > 0: + interest += flt(inv["outstanding_amount"]) * daily_interest * days + return interest + + +def _amend_blockers(fee_invoice): + """Reasons this fee invoice cannot safely be cancelled and re-raised. + + Amending means cancelling, and cancelling is only reversible for the links + we know how to restore (plain Payment Entry allocations). Anything else is + left alone and billed on a fresh invoice instead. + """ + reasons = [] + + frozen = frappe.db.get_single_value("Accounts Settings", "acc_frozen_upto") + if frozen and getdate(fee_invoice.posting_date) <= getdate(frozen): + reasons.append(_("its posting date falls in a frozen accounting period")) + + if frappe.db.exists( + "Journal Entry Account", + {"reference_type": "Sales Invoice", "reference_name": fee_invoice.name, "docstatus": 1}, + ): + reasons.append(_("a Journal Entry is applied against it")) + + if frappe.db.exists( + "Sales Invoice", {"return_against": fee_invoice.name, "docstatus": 1} + ): + reasons.append(_("a credit note is applied against it")) + + if frappe.db.exists( + "Payment Entry Reference", + { + "reference_doctype": "Sales Invoice", + "reference_name": fee_invoice.name, + "docstatus": 1, + "allocated_amount": ("<", 0), + }, + ): + reasons.append(_("a payment allocates a negative amount to it")) + + return reasons + + +def _payment_allocations(invoice_name): + """Submitted Payment Entry allocations against an invoice, one row per entry. + + Grouped per payment because re-linking consumes a payment's unallocated + balance in one go; two rows for the same entry would double-count it. + """ + return frappe.db.sql( + """ + select pe.name as payment_entry, pe.party_type, pe.party, + sum(per.allocated_amount) as allocated_amount, + max(per.account) as account + from `tabPayment Entry Reference` per + inner join `tabPayment Entry` pe on pe.name = per.parent + where per.reference_doctype = 'Sales Invoice' + and per.reference_name = %s + and per.docstatus = 1 and pe.docstatus = 1 + and per.allocated_amount > 0 + group by pe.name, pe.party_type, pe.party + """, + invoice_name, + as_dict=True, + ) + + +def _relink_payments(amended, allocations): + """Re-apply payments freed by the cancellation onto the amended invoice. + + Cancelling unlinks the payments, leaving them sitting as unallocated cash on + their Payment Entries; this puts them back so the amended invoice shows the + balance the customer actually owes. `reconcile_against_document` is the same + primitive the Payment Reconciliation tool uses, so the ledger and the + invoice's outstanding amount are reposted the standard way. + """ + from erpnext.accounts.utils import reconcile_against_document + + company_currency = frappe.get_cached_value("Company", amended.company, "default_currency") + in_company_currency = amended.party_account_currency == company_currency + remaining = flt(amended.outstanding_amount) + + args = [] + for alloc in allocations: + if remaining <= 0: + break + # The freed cash sits in unallocated_amount; that total is also what + # ERPNext validates the allocation against. + unallocated = flt( + frappe.db.get_value("Payment Entry", alloc.payment_entry, "unallocated_amount") + ) + amount = min(flt(alloc.allocated_amount), unallocated, remaining) + if amount <= 0: + continue + args.append( + frappe._dict( + { + "voucher_type": "Payment Entry", + "voucher_no": alloc.payment_entry, + "voucher_detail_no": None, + "against_voucher_type": "Sales Invoice", + "against_voucher": amended.name, + "account": alloc.account or amended.debit_to, + "party_type": alloc.party_type, + "party": alloc.party, + "is_advance": "No", + "dr_or_cr": "credit_in_account_currency", + "unadjusted_amount": unallocated, + "allocated_amount": amount, + "exchange_rate": 1 if in_company_currency else amended.conversion_rate, + "grand_total": ( + amended.base_grand_total if in_company_currency else amended.grand_total + ), + "outstanding_amount": remaining, + "difference_account": frappe.get_cached_value( + "Company", amended.company, "exchange_gain_loss_account" + ), + } + ) + ) + remaining -= amount + + if args: + reconcile_against_document(args) + + +def _amend_fee_invoice(fee_invoice, charge, billed_upto): + """Add a charge to an open fee invoice by amending it; return the new name. + + The whole sequence runs inside the caller's transaction, so a failure + anywhere rolls back the unlink and the cancellation with it. + """ + from erpnext.accounts.utils import unlink_ref_doc_from_payment_entries + + allocations = _payment_allocations(fee_invoice.name) + doc = frappe.get_doc("Sales Invoice", fee_invoice.name) + + # Unlinking and reconciling narrate themselves with msgprint dialogs. A run + # covering fifty customers would bury the user in them, and the timeline + # comment already records what happened. + muted = frappe.flags.mute_messages + frappe.flags.mute_messages = True + try: + if allocations: + unlink_ref_doc_from_payment_entries(doc) + doc.cancel() + + amended = frappe.copy_doc(doc, ignore_no_copy=False) + amended.amended_from = doc.name + amended.naming_series = LATE_FEE_NAMING_SERIES + # Same debt, so the original dates stand. Re-dating to today would + # reset the invoice to "Current" on the statement's aging buckets, which + # read from due_date, and hide how long the balance has been owed. + amended.set_posting_time = 1 + amended.posting_date = doc.posting_date + amended.posting_time = doc.posting_time + amended.due_date = doc.due_date + amended.set(BILLED_UPTO_FIELD, billed_upto) + amended.append("items", charge) + amended.insert(ignore_permissions=True) + amended.submit() + + if allocations: + amended.reload() + _relink_payments(amended, allocations) + finally: + frappe.flags.mute_messages = muted + + return amended.name + + +def _new_fee_invoice(customer, company, settings, charge, billed_upto): + """Raise a fresh late-fee Sales Invoice; return its name.""" + si = frappe.new_doc("Sales Invoice") + si.naming_series = LATE_FEE_NAMING_SERIES + si.customer = customer + si.company = company + si.posting_date = nowdate() + si.due_date = nowdate() + si.set(BILLED_UPTO_FIELD, billed_upto) + si.append("items", charge) + # Late fees are not taxed. A single zero "Actual" tax line keeps the taxes + # table non-empty, which stops ERPNext from auto-applying the company or + # item tax templates; being zero it posts nothing to the ledger. + si.taxes_and_charges = "" + si.append( + "taxes", + { + "charge_type": "Actual", + "account_head": settings.income_account, + "description": _("Late fees are not taxed"), + "tax_amount": 0, + "rate": 0, + }, + ) + si.insert(ignore_permissions=True) + si.submit() + return si.name + + +def _post_late_fee_invoice(customer, company, overdue_invoices, period): + """Bill late-payment interest for one customer/company (once per month). + + Tops up the customer's open fee invoice where there is one, otherwise raises + a new one. Returns the fee invoice name, or None if nothing was billed. """ if not overdue_invoices: return None @@ -348,64 +606,53 @@ def _post_late_fee_invoice(customer, company, overdue_invoices, period): ) fee_invoices = _get_fee_invoices(customer, company, fee_item) + last_billed = _last_billed_upto(fee_invoices) + open_invoice = _open_fee_invoice(fee_invoices) - # Idempotency: at most one fee invoice per (customer, company, month). + # Idempotency: at most one charge per (customer, company, month). month_start = getdate(period + "-01") - for fi in fee_invoices: - if getdate(fi.posting_date) >= month_start: - return fi.name + if last_billed and last_billed >= month_start: + return open_invoice.name if open_invoice else None - # Interest on overdue balances, excluding prior fee invoices (no fee-on-fee). - prior_fee_names = {fi.name for fi in fee_invoices} - daily_interest = flt(settings.rate_of_interest) / 100.0 / 365.0 - interest = sum( - flt(inv["outstanding_amount"]) * daily_interest * inv["days_overdue"] - for inv in overdue_invoices - if inv["name"] not in prior_fee_names - ) - fee = round(interest + flt(settings.dunning_fee), 2) - if fee <= 0: + today = getdate(nowdate()) + interest = _accrued_interest(overdue_invoices, settings.rate_of_interest, last_billed) + # The flat dunning fee is a one-off for falling into collections, charged + # when the fee invoice is raised — not again every time it is topped up. + amount = round(interest if open_invoice else interest + flt(settings.dunning_fee), 2) + if amount <= 0: return None - cost_center = settings.cost_center or frappe.get_cached_value( - "Company", company, "cost_center" - ) + charge = { + "item_code": fee_item, + "qty": 1, + "rate": amount, + "income_account": settings.income_account, + "cost_center": settings.cost_center + or frappe.get_cached_value("Company", company, "cost_center"), + "description": _("Late payment fee for statement period {0}").format(period), + } - si = frappe.new_doc("Sales Invoice") - si.naming_series = LATE_FEE_NAMING_SERIES - si.customer = customer - si.company = company - si.posting_date = nowdate() - si.due_date = nowdate() - si.append( - "items", - { - "item_code": fee_item, - "qty": 1, - "rate": fee, - "income_account": settings.income_account, - "cost_center": cost_center, - "description": _("Late payment fee for statement period {0}").format(period), - }, - ) - # Late fees are not taxed. A single zero "Actual" tax line keeps the taxes - # table non-empty, which stops ERPNext from auto-applying the company or - # item tax templates; being zero it posts nothing to the ledger. - si.taxes_and_charges = "" - si.append( - "taxes", - { - "charge_type": "Actual", - "account_head": settings.income_account, - "description": _("Late fees are not taxed"), - "tax_amount": 0, - "rate": 0, - }, - ) - si.insert(ignore_permissions=True) - si.submit() + if open_invoice: + blockers = _amend_blockers(open_invoice) + if not blockers: + name = _amend_fee_invoice(open_invoice, charge, today) + frappe.db.commit() + return name + _note_amend_skipped(customer, open_invoice.name, blockers) + + name = _new_fee_invoice(customer, company, settings, charge, today) frappe.db.commit() - return si.name + return name + + +def _note_amend_skipped(customer, fee_invoice, reasons): + """Record why an open fee invoice was left alone and a new one raised.""" + frappe.get_doc("Customer", customer).add_comment( + "Info", + _("Late fee {0} could not be amended ({1}); the charge was billed on a new invoice.").format( + fee_invoice, ", ".join(reasons) + ), + ) def _late_fee_invoice_names(customer): @@ -436,7 +683,7 @@ def _record_statement_activity(customer, data, fee_invoice_names, skip_late_fee) if skip_late_fee: fee_note = _("late fee skipped") elif fee_invoice_names: - fee_note = _("late fee invoice {0}").format(", ".join(fee_invoice_names)) + fee_note = _("late fee charged on {0}").format(", ".join(fee_invoice_names)) else: fee_note = _("no late fee") frappe.get_doc("Customer", customer).add_comment( @@ -448,9 +695,10 @@ def _record_statement_activity(customer, data, fee_invoice_names, skip_late_fee) def generate_statements(customers, skip_late_fee=0): """Render printable statements (one page per customer) for the selection. - Side effect (unless `skip_late_fee`): a late-payment fee is billed as a - Sales Invoice (once per customer per month) for each customer with overdue - invoices. Each generation is recorded on the customer's timeline. + Side effect (unless `skip_late_fee`): a late-payment fee is billed (once per + customer per month) for each customer with overdue invoices — added to their + open fee invoice if they have one, otherwise raised as a new Sales Invoice. + Each generation is recorded on the customer's timeline. `customers` may arrive as a JSON-encoded list from the client. """ diff --git a/ns_app/setup.py b/ns_app/setup.py index cd90c57..4535729 100644 --- a/ns_app/setup.py +++ b/ns_app/setup.py @@ -4,7 +4,7 @@ import frappe from frappe.custom.doctype.custom_field.custom_field import create_custom_fields from frappe.custom.doctype.property_setter.property_setter import make_property_setter -from ns_app.api.statements import LATE_FEE_NAMING_SERIES +from ns_app.api.statements import BILLED_UPTO_FIELD, LATE_FEE_NAMING_SERIES # Fee schedule/amounts live on ERPNext's Dunning Type; this adds the one thing # it lacks — the Item used to bill a late fee as a Sales Invoice. @@ -21,7 +21,25 @@ CUSTOM_FIELDS = { "customer statements are generated." ), } - ] + ], + "Sales Invoice": [ + { + "fieldname": BILLED_UPTO_FIELD, + "label": "Late Fee Billed Upto", + "fieldtype": "Date", + "insert_after": "due_date", + "read_only": 1, + "print_hide": 1, + # Must survive frappe.copy_doc() when the invoice is amended to add + # another period's interest — it is what stops that period being + # billed twice. + "no_copy": 0, + "description": ( + "On a late-fee invoice, the date interest was last charged. The " + "next statement run accrues interest from here." + ), + } + ], }