diff --git a/ns_app/api/statements.py b/ns_app/api/statements.py index eb44593..e50f11a 100644 --- a/ns_app/api/statements.py +++ b/ns_app/api/statements.py @@ -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.")) diff --git a/ns_app/setup.py b/ns_app/setup.py index 0a37058..cd90c57 100644 --- a/ns_app/setup.py +++ b/ns_app/setup.py @@ -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()