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>
747 lines
27 KiB
Python
747 lines
27 KiB
Python
"""Customer account statements.
|
|
|
|
Generates printable, one-customer-per-page account statements for customers with
|
|
overdue invoices, formatted for a standard double-window envelope. Statement
|
|
generation also books a late-payment fee to the ledger (see the late-fee helpers
|
|
added alongside the generator).
|
|
"""
|
|
|
|
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, 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",
|
|
"Sales User",
|
|
"Sales Manager",
|
|
"Accounts User",
|
|
"Accounts Manager",
|
|
]
|
|
|
|
|
|
@frappe.whitelist()
|
|
def get_customers_with_overdue_invoices():
|
|
"""Return one row per customer that has at least one overdue Sales Invoice.
|
|
|
|
A Sales Invoice is overdue when it is submitted, still has an outstanding
|
|
balance, and its due date is in the past.
|
|
"""
|
|
frappe.only_for(ALLOWED_ROLES)
|
|
|
|
today = nowdate()
|
|
rows = frappe.get_all(
|
|
"Sales Invoice",
|
|
filters={
|
|
"docstatus": 1,
|
|
"outstanding_amount": [">", 0],
|
|
"due_date": ["<", today],
|
|
},
|
|
fields=[
|
|
"customer",
|
|
"customer_name",
|
|
"count(name) as overdue_count",
|
|
"sum(outstanding_amount) as total_outstanding",
|
|
"min(due_date) as oldest_due_date",
|
|
],
|
|
group_by="customer, customer_name",
|
|
order_by="total_outstanding desc",
|
|
)
|
|
|
|
for row in rows:
|
|
row["max_days_overdue"] = (
|
|
(getdate(today) - getdate(row.oldest_due_date)).days
|
|
if row.oldest_due_date
|
|
else 0
|
|
)
|
|
|
|
return rows
|
|
|
|
|
|
def _aging_bucket(days_overdue):
|
|
"""Map days-overdue to a standard aging bucket label."""
|
|
if days_overdue <= 0:
|
|
return "Current"
|
|
if days_overdue <= 30:
|
|
return "1-30"
|
|
if days_overdue <= 60:
|
|
return "31-60"
|
|
if days_overdue <= 90:
|
|
return "61-90"
|
|
return "90+"
|
|
|
|
|
|
def _get_outstanding_invoices(customer):
|
|
"""Return all open (submitted, unpaid) Sales Invoices for a customer.
|
|
|
|
The statement lists the full open balance, so this includes not-yet-due
|
|
invoices; each row is annotated with days overdue, an overdue flag, and its
|
|
aging bucket.
|
|
"""
|
|
today = getdate(nowdate())
|
|
invoices = frappe.get_all(
|
|
"Sales Invoice",
|
|
filters={
|
|
"customer": customer,
|
|
"docstatus": 1,
|
|
"outstanding_amount": [">", 0],
|
|
},
|
|
fields=[
|
|
"name",
|
|
"posting_date",
|
|
"due_date",
|
|
"outstanding_amount",
|
|
"grand_total",
|
|
"company",
|
|
],
|
|
order_by="due_date asc",
|
|
)
|
|
|
|
for inv in invoices:
|
|
due = getdate(inv.due_date) if inv.due_date else None
|
|
days = (today - due).days if due else 0
|
|
inv["days_overdue"] = days if days > 0 else 0
|
|
inv["is_overdue"] = days > 0
|
|
inv["aging_bucket"] = _aging_bucket(inv["days_overdue"])
|
|
|
|
return invoices
|
|
|
|
|
|
def _address_display(doctype, name):
|
|
"""Return the formatted (HTML) default address for a party, or ''."""
|
|
address_name = get_default_address(doctype, name)
|
|
if not address_name:
|
|
return ""
|
|
return get_address_display(frappe.get_doc("Address", address_name).as_dict()) or ""
|
|
|
|
|
|
def _resolve_company(invoices):
|
|
"""Pick the company for the statement header/return address."""
|
|
if invoices:
|
|
return invoices[0].company
|
|
return frappe.defaults.get_user_default("Company") or frappe.db.get_single_value(
|
|
"Global Defaults", "default_company"
|
|
)
|
|
|
|
|
|
def get_statement_data(customer, invoices=None):
|
|
"""Assemble everything the statement template needs for one customer.
|
|
|
|
Late-fee charges are billed as Sales Invoices, so they appear in the invoice
|
|
list like any other open item (flagged `is_late_fee`); there is no separate
|
|
fee total to add.
|
|
"""
|
|
cust = frappe.get_doc("Customer", customer)
|
|
if invoices is None:
|
|
invoices = _get_outstanding_invoices(customer)
|
|
|
|
fee_names = _late_fee_invoice_names(customer)
|
|
|
|
company = _resolve_company(invoices)
|
|
company_doc = frappe.get_doc("Company", company) if company else None
|
|
|
|
aging = {"Current": 0.0, "1-30": 0.0, "31-60": 0.0, "61-90": 0.0, "90+": 0.0}
|
|
total_due = 0.0
|
|
for inv in invoices:
|
|
inv["is_late_fee"] = inv["name"] in fee_names
|
|
aging[inv["aging_bucket"]] += flt(inv["outstanding_amount"])
|
|
total_due += flt(inv["outstanding_amount"])
|
|
|
|
return {
|
|
"customer": cust.name,
|
|
"customer_name": cust.customer_name,
|
|
"customer_address": _address_display("Customer", cust.name),
|
|
"company": company,
|
|
"company_name": company_doc.company_name if company_doc else "",
|
|
"return_address": _address_display("Company", company) if company else "",
|
|
"currency": (company_doc.default_currency if company_doc else None)
|
|
or frappe.db.get_single_value("Global Defaults", "default_currency"),
|
|
"invoices": invoices,
|
|
"aging": aging,
|
|
"total_due": total_due,
|
|
"statement_date": nowdate(),
|
|
}
|
|
|
|
|
|
def _render_page(data):
|
|
path = frappe.get_app_path(
|
|
"ns_app", "templates", "statements", "customer_statement.html"
|
|
)
|
|
with open(path) as f:
|
|
template = f.read()
|
|
return frappe.render_template(template, {"s": data})
|
|
|
|
|
|
def _wrap_document(pages):
|
|
"""Wrap rendered per-customer pages in a printable HTML document."""
|
|
body = "\n".join(pages)
|
|
return f"""<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<title>Customer Statements</title>
|
|
<style>
|
|
@page {{ size: Letter; margin: 0; }}
|
|
* {{ box-sizing: border-box; }}
|
|
body {{ margin: 0; font-family: Helvetica, Arial, sans-serif; color: #333; }}
|
|
.toolbar {{ text-align: center; padding: 12px; background: #f5f5f5; }}
|
|
.toolbar button {{ font-size: 14px; padding: 8px 20px; cursor: pointer; }}
|
|
.statement-page {{
|
|
position: relative;
|
|
width: 8.5in;
|
|
min-height: 11in;
|
|
margin: 0 auto;
|
|
padding: 0;
|
|
page-break-after: always;
|
|
overflow: hidden;
|
|
}}
|
|
.statement-page:last-of-type {{ page-break-after: auto; }}
|
|
/* Window positions are field-tuned to the #9 (9x4) double-window envelope
|
|
(verified against a printed proof). */
|
|
.return-window {{
|
|
position: absolute; top: 0.8in; left: 0.6in;
|
|
width: 3.5in; font-size: 11px; line-height: 1.3;
|
|
}}
|
|
.doc-header {{
|
|
position: absolute; top: 0.8in; right: 0.6in;
|
|
width: 3in; text-align: right; font-size: 13px; line-height: 1.5;
|
|
}}
|
|
.doc-header .doc-title {{ font-size: 20px; font-weight: bold; letter-spacing: 1px; }}
|
|
.recipient-window {{
|
|
position: absolute; top: 2.5in; left: 1.125in;
|
|
width: 4.5in; height: 1.25in; font-size: 15px; line-height: 1.15em;
|
|
overflow: hidden;
|
|
}}
|
|
.statement-body {{ padding: 3.9in 0.6in 0.6in 0.6in; }}
|
|
.intro {{ font-size: 12px; margin-bottom: 12px; }}
|
|
table.items, table.aging {{ width: 100%; border-collapse: collapse; }}
|
|
table.items th, table.items td,
|
|
table.aging th, table.aging td {{
|
|
border: 1px solid #ccc; padding: 6px; font-size: 13px;
|
|
}}
|
|
table.items th, table.aging th {{ background: #f5f5f5; text-align: left; }}
|
|
.c {{ text-align: center; }}
|
|
.r {{ text-align: right; }}
|
|
tr.overdue td {{ color: #c62828; }}
|
|
.tag {{
|
|
display: inline-block; font-size: 10px; font-weight: bold; color: #fff;
|
|
background: #c62828; border-radius: 3px; padding: 1px 5px; vertical-align: middle;
|
|
}}
|
|
.totals {{ width: 45%; margin: 12px 0 12px auto; font-size: 14px; }}
|
|
.totals p {{ display: flex; justify-content: space-between; margin: 4px 0; }}
|
|
.totals p.grand {{
|
|
border-top: 2px solid #333; padding-top: 6px; font-weight: bold; font-size: 16px;
|
|
}}
|
|
table.aging {{ margin-top: 8px; }}
|
|
.footer {{
|
|
margin-top: 24px; font-size: 10px; color: #777; text-align: center;
|
|
white-space: pre-line;
|
|
}}
|
|
@media print {{ .toolbar {{ display: none; }} }}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="toolbar">
|
|
<button onclick="window.print()">Print Statements</button>
|
|
</div>
|
|
{body}
|
|
</body>
|
|
</html>"""
|
|
|
|
|
|
# ── Late-payment fee (billed as a Sales Invoice on generation) ───────────────
|
|
#
|
|
# Fee schedule/amounts come from ERPNext's existing **Dunning Type** settings
|
|
# (rate_of_interest is a yearly %, plus a flat dunning_fee), editable in the
|
|
# desk. Interest is computed with ERPNext's own Dunning formula so the numbers
|
|
# match a Dunning document. The fee is billed as a submitted **Sales Invoice**
|
|
# (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",
|
|
"rate_of_interest",
|
|
"dunning_fee",
|
|
"income_account",
|
|
"cost_center",
|
|
"company",
|
|
"custom_late_fee_item",
|
|
]
|
|
|
|
|
|
def _get_fee_settings(company):
|
|
"""Resolve the Dunning Type used for late fees for a company.
|
|
|
|
Nothing is auto-created: the user must configure a Dunning Type (rate of
|
|
interest, fee, income account) for the company in ERPNext. If none exists we
|
|
stop with a clear, actionable error rather than inventing default values.
|
|
"""
|
|
|
|
def first(filters):
|
|
rows = frappe.get_all(
|
|
"Dunning Type", filters=filters, fields=DUNNING_TYPE_FIELDS, limit=1
|
|
)
|
|
return rows[0] if rows else None
|
|
|
|
dt = first({"company": company, "is_default": 1}) or first({"company": company})
|
|
if not dt:
|
|
frappe.throw(
|
|
_(
|
|
"No Dunning Type is configured for {0}. Create one under "
|
|
"Accounting > Dunning Type — set the rate of interest, dunning "
|
|
"fee, and income account — before generating statements."
|
|
).format(company)
|
|
)
|
|
return dt
|
|
|
|
|
|
def _late_fee_period():
|
|
"""Statement period key used for idempotency (one fee per calendar month)."""
|
|
return getdate(nowdate()).strftime("%Y-%m")
|
|
|
|
|
|
def _get_fee_invoices(customer, company, 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 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 _last_billed_upto(fee_invoices):
|
|
"""Date late-fee interest was last charged, or None if it never has been.
|
|
|
|
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
|
|
|
|
settings = _get_fee_settings(company)
|
|
fee_item = settings.get("custom_late_fee_item")
|
|
if not fee_item:
|
|
frappe.throw(
|
|
_("Set a Late Fee Item on Dunning Type {0} before generating statements.").format(
|
|
settings.name
|
|
)
|
|
)
|
|
if not settings.income_account:
|
|
frappe.throw(
|
|
_("Set an Income Account on Dunning Type {0} before generating statements.").format(
|
|
settings.name
|
|
)
|
|
)
|
|
|
|
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 charge per (customer, company, month).
|
|
month_start = getdate(period + "-01")
|
|
if last_billed and last_billed >= month_start:
|
|
return open_invoice.name if open_invoice else None
|
|
|
|
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
|
|
|
|
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),
|
|
}
|
|
|
|
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 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):
|
|
"""Names of the customer's submitted late-fee Sales Invoices (any company)."""
|
|
fee_items = [
|
|
d.custom_late_fee_item
|
|
for d in frappe.get_all("Dunning Type", fields=["custom_late_fee_item"])
|
|
if d.custom_late_fee_item
|
|
]
|
|
if not fee_items:
|
|
return set()
|
|
rows = frappe.db.sql(
|
|
"""
|
|
select distinct sii.parent
|
|
from `tabSales Invoice Item` sii
|
|
inner join `tabSales Invoice` si on si.name = sii.parent
|
|
where si.customer = %s and si.docstatus = 1 and sii.item_code in %s
|
|
""",
|
|
(customer, tuple(fee_items)),
|
|
as_dict=True,
|
|
)
|
|
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 charged on {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, 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 (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.
|
|
"""
|
|
frappe.only_for(ALLOWED_ROLES)
|
|
|
|
if isinstance(customers, str):
|
|
try:
|
|
customers = json.loads(customers)
|
|
except (ValueError, TypeError):
|
|
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 = [], [], []
|
|
|
|
for customer in customers:
|
|
invoices = _get_outstanding_invoices(customer)
|
|
if not invoices:
|
|
skipped.append(customer)
|
|
continue
|
|
|
|
# Bill the late fee per company (on overdue invoices only).
|
|
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."))
|
|
|
|
return {"html": _wrap_document(pages), "rendered": rendered, "skipped": skipped}
|