Statement generation raised a fresh LPF invoice every month, so a customer who never paid accumulated a pile of small invoices, each carrying the flat dunning_fee again. Interest was also recomputed from each invoice's due date every run, re-billing periods already charged for. Now a customer gets 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, rather than creating a second invoice. - Payment Entries allocated to a partly paid fee invoice are unlinked by the cancellation and re-applied to the amended invoice via reconcile_against_document (the primitive Payment Reconciliation uses), so the outstanding amount and Payment Ledger stay correct. - Original posting and due dates are carried over. 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. - Interest accrues from the last run, tracked by a new custom_late_fee_billed_upto field on Sales Invoice, so no period is billed twice. Fee invoices predating the field fall back to their posting date, which is when they were billed, so no migration patch is needed. - The flat dunning_fee is charged once, when a fee invoice is first raised, not again on every top-up. - Unpaid fee invoices are in the interest base on the same terms as any other overdue receivable, so interest compounds onto the fee balance. Amending means cancelling, which is only reversible for links we can restore. If the open fee invoice has a Journal Entry or credit note applied, a negative payment allocation, or a posting date in a frozen period, it is left alone, the charge goes on a new invoice, and the reason is recorded on the customer's timeline. Verified against nsi.local with two rolled-back integration probes covering the amend + re-link path (including two consecutive amendments) and the blocked-amend fallback. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
65 lines
2.2 KiB
Python
65 lines
2.2 KiB
Python
"""App setup: custom fields created/synced on migrate."""
|
|
|
|
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 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.
|
|
CUSTOM_FIELDS = {
|
|
"Dunning Type": [
|
|
{
|
|
"fieldname": "custom_late_fee_item",
|
|
"label": "Late Fee Item",
|
|
"fieldtype": "Link",
|
|
"options": "Item",
|
|
"insert_after": "income_account",
|
|
"description": (
|
|
"Item used to bill a late-payment fee as a Sales Invoice when "
|
|
"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."
|
|
),
|
|
}
|
|
],
|
|
}
|
|
|
|
|
|
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()
|