feat(statements): untaxed late-fee series, skip-fee option, audit trail

- Bill late-fee invoices under a dedicated naming series (LPF-.YYYY.-),
  registered on Sales Invoice via after_migrate, so they are easy to spot
  and filter.
- Late fees are never taxed: a zero 'Actual' tax line keeps the taxes
  table non-empty so ERPNext skips auto-applying company/item tax
  templates (posts nothing to the ledger). Fee total == computed fee.
- generate_statements(skip_late_fee=...) generates a statement without
  billing a fee.
- Record every generation on the customer's timeline (add_comment) as an
  audit trail, noting Total Due and the fee invoice raised / skipped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-08 19:52:03 -04:00
parent acd7df1129
commit c20dd18287
2 changed files with 69 additions and 11 deletions

View File

@@ -11,7 +11,10 @@ import json
import frappe
from frappe import _
from frappe.contacts.doctype.address.address import get_address_display, get_default_address
from frappe.utils import flt, getdate, nowdate
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.-"
# Roles allowed to run collections/statement actions.
ALLOWED_ROLES = [
@@ -367,6 +370,7 @@ def _post_late_fee_invoice(customer, company, overdue_invoices, period):
)
si = frappe.new_doc("Sales Invoice")
si.naming_series = LATE_FEE_NAMING_SERIES
si.customer = customer
si.company = company
si.posting_date = nowdate()
@@ -382,6 +386,20 @@ def _post_late_fee_invoice(customer, company, overdue_invoices, period):
"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()
frappe.db.commit()
@@ -410,12 +428,27 @@ def _late_fee_invoice_names(customer):
return {r.parent for r in rows}
def _record_statement_activity(customer, data, fee_invoice_names, skip_late_fee):
"""Log statement generation on the customer's timeline (audit trail)."""
total = fmt_money(data["total_due"], currency=data["currency"])
if skip_late_fee:
fee_note = _("late fee skipped")
elif fee_invoice_names:
fee_note = _("late fee invoice {0}").format(", ".join(fee_invoice_names))
else:
fee_note = _("no late fee")
frappe.get_doc("Customer", customer).add_comment(
"Info", _("Statement generated — Total Due {0} ({1}).").format(total, fee_note)
)
@frappe.whitelist()
def generate_statements(customers):
def generate_statements(customers, skip_late_fee=0):
"""Render printable statements (one page per customer) for the selection.
Booking side effect: a late-payment fee is posted to the ledger (once per
customer per month) for each customer with overdue invoices.
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.
`customers` may arrive as a JSON-encoded list from the client.
"""
@@ -428,6 +461,7 @@ def generate_statements(customers):
customers = [customers]
if not customers:
frappe.throw(_("No customers selected"))
skip_late_fee = int(skip_late_fee or 0)
period = _late_fee_period()
pages, rendered, skipped = [], [], []
@@ -439,18 +473,22 @@ def generate_statements(customers):
continue
# Bill the late fee per company (on overdue invoices only).
overdue_by_company = {}
for inv in invoices:
if inv["is_overdue"]:
overdue_by_company.setdefault(inv["company"], []).append(inv)
for comp, invs in overdue_by_company.items():
_post_late_fee_invoice(customer, comp, invs, period)
fee_invoice_names = []
if not skip_late_fee:
overdue_by_company = {}
for inv in invoices:
if inv["is_overdue"]:
overdue_by_company.setdefault(inv["company"], []).append(inv)
for comp, invs in overdue_by_company.items():
name = _post_late_fee_invoice(customer, comp, invs, period)
if name:
fee_invoice_names.append(name)
# Re-fetch so the statement includes the freshly billed fee invoice(s).
data = get_statement_data(customer)
pages.append(_render_page(data))
rendered.append(customer)
_record_statement_activity(customer, data, fee_invoice_names, skip_late_fee)
if not pages:
frappe.throw(_("None of the selected customers have an outstanding balance."))

View File

@@ -2,6 +2,9 @@
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
# 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.
@@ -22,5 +25,22 @@ CUSTOM_FIELDS = {
}
def _register_late_fee_naming_series():
"""Add the late-fee series to Sales Invoice's naming_series options."""
field = frappe.get_meta("Sales Invoice").get_field("naming_series")
options = [o for o in (field.options or "").split("\n")] if field else []
if LATE_FEE_NAMING_SERIES not in options:
options.append(LATE_FEE_NAMING_SERIES)
make_property_setter(
"Sales Invoice",
"naming_series",
"options",
"\n".join(options),
"Text",
validate_fields_for_doctype=False,
)
def after_migrate():
create_custom_fields(CUSTOM_FIELDS)
_register_late_fee_naming_series()