feat(statements): top up open late-fee invoices instead of stacking new ones #24
@@ -32,12 +32,26 @@ Each generation is recorded on the customer's timeline as an audit-trail entry.
|
||||
## 2. Late payment fees
|
||||
|
||||
### How the fee is calculated
|
||||
Interest uses ERPNext's own Dunning formula:
|
||||
Interest uses ERPNext's own Dunning formula, accrued **from the last fee run**
|
||||
rather than from each invoice's due date, so no period is ever billed twice:
|
||||
|
||||
```
|
||||
fee = Σ(invoice.outstanding × rate_of_interest/100/365 × days_overdue) + dunning_fee
|
||||
accrue_from = max(invoice.due_date, last_billed_upto)
|
||||
interest = Σ(invoice.outstanding × rate_of_interest/100/365 × days_since(accrue_from))
|
||||
charge = interest + dunning_fee # flat fee only when raising a new fee invoice
|
||||
= interest # when topping up an existing one
|
||||
```
|
||||
|
||||
The Σ runs over **every** overdue invoice, unpaid late-fee invoices included —
|
||||
they are receivables like any other and are charged on the same terms.
|
||||
|
||||
`last_billed_upto` is stored on the fee invoice itself
|
||||
(`custom_late_fee_billed_upto`). Fee invoices raised before that field existed
|
||||
fall back to their posting date, which is when they were billed.
|
||||
|
||||
The flat `dunning_fee` is a one-off charge for falling into collections, applied
|
||||
when a fee invoice is first raised — not again on every top-up.
|
||||
|
||||
### Where the settings live — ERPNext **Dunning Type**
|
||||
All fee configuration comes from the existing **Dunning Type** doctype
|
||||
(Accounting ▸ Dunning Type). Nothing is auto-created; generation stops with a
|
||||
@@ -47,7 +61,7 @@ the company is used. Fields consumed:
|
||||
| Dunning Type field | Purpose |
|
||||
|--------------------|---------|
|
||||
| `rate_of_interest` | Annual interest rate (%) |
|
||||
| `dunning_fee` | Flat fee added per statement |
|
||||
| `dunning_fee` | Flat fee, charged once when a fee invoice is raised |
|
||||
| `income_account` | Credited when the fee is billed |
|
||||
| `cost_center` | Cost center for the fee line (falls back to company default) |
|
||||
| `custom_late_fee_item` | **Late Fee Item** — the Item used to bill the fee (custom field added by this app) |
|
||||
@@ -71,18 +85,59 @@ sit **uncollectible** by those flows — hence the Sales Invoice.
|
||||
- **Never taxed.** A single zero-amount "Actual" tax line keeps ERPNext from
|
||||
auto-applying company/item tax templates, so the invoice total equals the
|
||||
computed fee exactly and nothing extra hits the ledger.
|
||||
- **Idempotent** — at most one fee invoice per customer / company / calendar
|
||||
month. Prior fee invoices are excluded from the interest base (no fee-on-fee).
|
||||
- **Idempotent** — at most one charge per customer / company / calendar month.
|
||||
An unpaid fee invoice accrues interest on the same terms as any other overdue
|
||||
receivable (see below).
|
||||
|
||||
On the statement the fee shows as a normal invoice line tagged **“late fee”**,
|
||||
folded into a single **Total Due** that equals the customer's balance.
|
||||
|
||||
### 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, instead of raising
|
||||
a second invoice. The customer sees one growing charge, itemised by period.
|
||||
|
||||
Amending means cancelling and re-raising, so the invoice number gains a suffix
|
||||
(`LPF-2026-00001` → `LPF-2026-00001-1`). The original posting and due dates are
|
||||
carried over deliberately: 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.
|
||||
|
||||
**Partially paid fee invoices.** Cancelling unlinks any Payment Entries, leaving
|
||||
the cash unallocated on them. After the amended invoice is submitted, each
|
||||
payment is re-applied to it via `reconcile_against_document` — the same
|
||||
primitive the Payment Reconciliation tool uses — so the outstanding amount and
|
||||
the Payment Ledger reflect what the customer actually owes. (As with any
|
||||
reconciliation, ERPNext clears `against_voucher` on the payment's **GL** rows and
|
||||
tracks the allocation on the **Payment Ledger**; the AR reports read the latter.)
|
||||
|
||||
**When it can't amend.** Cancelling is only reversible for links this app knows
|
||||
how to restore. If the open fee invoice has a Journal Entry or credit note
|
||||
applied, a negative payment allocation, or a posting date inside a frozen
|
||||
accounting period, it is left alone, the charge goes onto a new invoice, and the
|
||||
reason is recorded on the customer's timeline:
|
||||
|
||||
> Late fee LPF-2026-00001 could not be amended (a Journal Entry is applied
|
||||
> against it); the charge was billed on a new invoice.
|
||||
|
||||
### Unpaid fee invoices accrue too
|
||||
A late-fee invoice is an overdue receivable like any other, and once past its
|
||||
due date its outstanding balance is part of the interest base. Because the
|
||||
charge lands back on the invoice carrying that balance, **interest compounds**:
|
||||
each run adds interest on the fee balance the previous runs built up.
|
||||
|
||||
A customer whose only remaining overdue item is an unpaid fee invoice therefore
|
||||
keeps accruing — the balance grows by `outstanding × rate/365 × days_since_last_run`
|
||||
every run until it is paid. No new flat fee is raised while a fee invoice is
|
||||
open, so the growth is interest alone.
|
||||
|
||||
---
|
||||
|
||||
## 3. Configuration / prerequisites
|
||||
|
||||
1. **Migrate** the app (`bench --site <site> migrate`) — creates the
|
||||
`Late Fee Item` custom field on Dunning Type and registers the `LPF-` series.
|
||||
`Late Fee Item` custom field on Dunning Type, the `Late Fee Billed Upto`
|
||||
custom field on Sales Invoice, and registers the `LPF-` series.
|
||||
2. Create an **Item** to represent the fee (a non-stock sales item, e.g.
|
||||
"Late Payment Fee").
|
||||
3. Create/complete a **Dunning Type** for the company with: rate of interest,
|
||||
@@ -97,7 +152,7 @@ error and posts nothing.
|
||||
|
||||
Each generated statement adds an *Info* comment to the customer's timeline, e.g.
|
||||
|
||||
> Statement generated — Total Due $557.17 (late fee invoice LPF-2026-00001).
|
||||
> Statement generated — Total Due $557.17 (late fee charged on LPF-2026-00001-1).
|
||||
|
||||
The note reflects the outcome: the fee invoice raised, *no late fee*, or
|
||||
*late fee skipped* (when the fee checkbox was cleared). It is attributed to the
|
||||
@@ -112,7 +167,7 @@ generating user.
|
||||
| `ns_app/api/statements.py` | Overdue-customer query, statement builder, printable HTML, late-fee billing (Sales Invoice), audit-trail entry |
|
||||
| `ns_app/templates/statements/customer_statement.html` | Jinja template for one customer page (envelope windows + aging table) |
|
||||
| `ns_app/public/js/customer_statements.js` | List action + Customer-form button + selection/fee-toggle popups (loaded globally) |
|
||||
| `ns_app/setup.py` | `after_migrate`: creates the Late Fee Item custom field, registers the `LPF-` naming series |
|
||||
| `ns_app/setup.py` | `after_migrate`: creates the Late Fee Item / Late Fee Billed Upto custom fields, registers the `LPF-` naming series |
|
||||
| `ns_app/hooks.py` | Wires the JS (`app_include_js`) and `after_migrate` |
|
||||
|
||||
### Server API (`ns_app.api.statements`)
|
||||
|
||||
@@ -16,6 +16,10 @@ 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",
|
||||
@@ -264,6 +268,16 @@ def _wrap_document(pages):
|
||||
# (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",
|
||||
@@ -308,26 +322,270 @@ def _late_fee_period():
|
||||
|
||||
|
||||
def _get_fee_invoices(customer, company, fee_item):
|
||||
"""Return submitted late-fee Sales Invoices for a customer (by 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 si.name, si.posting_date
|
||||
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 _post_late_fee_invoice(customer, company, overdue_invoices, period):
|
||||
"""Bill a late fee as a submitted Sales Invoice (idempotent per month).
|
||||
def _last_billed_upto(fee_invoices):
|
||||
"""Date late-fee interest was last charged, or None if it never has been.
|
||||
|
||||
Returns the fee invoice name, or None if nothing was billed.
|
||||
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
|
||||
@@ -348,64 +606,53 @@ def _post_late_fee_invoice(customer, company, overdue_invoices, period):
|
||||
)
|
||||
|
||||
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 fee invoice per (customer, company, month).
|
||||
# Idempotency: at most one charge per (customer, company, month).
|
||||
month_start = getdate(period + "-01")
|
||||
for fi in fee_invoices:
|
||||
if getdate(fi.posting_date) >= month_start:
|
||||
return fi.name
|
||||
if last_billed and last_billed >= month_start:
|
||||
return open_invoice.name if open_invoice else None
|
||||
|
||||
# Interest on overdue balances, excluding prior fee invoices (no fee-on-fee).
|
||||
prior_fee_names = {fi.name for fi in fee_invoices}
|
||||
daily_interest = flt(settings.rate_of_interest) / 100.0 / 365.0
|
||||
interest = sum(
|
||||
flt(inv["outstanding_amount"]) * daily_interest * inv["days_overdue"]
|
||||
for inv in overdue_invoices
|
||||
if inv["name"] not in prior_fee_names
|
||||
)
|
||||
fee = round(interest + flt(settings.dunning_fee), 2)
|
||||
if fee <= 0:
|
||||
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
|
||||
|
||||
cost_center = settings.cost_center or frappe.get_cached_value(
|
||||
"Company", company, "cost_center"
|
||||
)
|
||||
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),
|
||||
}
|
||||
|
||||
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.append(
|
||||
"items",
|
||||
{
|
||||
"item_code": fee_item,
|
||||
"qty": 1,
|
||||
"rate": fee,
|
||||
"income_account": settings.income_account,
|
||||
"cost_center": cost_center,
|
||||
"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()
|
||||
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 si.name
|
||||
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):
|
||||
@@ -436,7 +683,7 @@ def _record_statement_activity(customer, data, fee_invoice_names, skip_late_fee)
|
||||
if skip_late_fee:
|
||||
fee_note = _("late fee skipped")
|
||||
elif fee_invoice_names:
|
||||
fee_note = _("late fee invoice {0}").format(", ".join(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(
|
||||
@@ -448,9 +695,10 @@ def _record_statement_activity(customer, data, fee_invoice_names, skip_late_fee)
|
||||
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 as a
|
||||
Sales Invoice (once per customer per month) for each customer with overdue
|
||||
invoices. Each generation is recorded on the customer's timeline.
|
||||
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.
|
||||
"""
|
||||
|
||||
@@ -4,7 +4,7 @@ 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
|
||||
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.
|
||||
@@ -21,7 +21,25 @@ CUSTOM_FIELDS = {
|
||||
"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."
|
||||
),
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user