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:
@@ -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."))
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user