From cc185ba789e6dd6f85a51e09fcc4e10fe9548c42 Mon Sep 17 00:00:00 2001 From: Devansh Shetty Date: Tue, 16 Jun 2026 15:09:28 +0000 Subject: [PATCH] fix(accounts): store Dunning overdue outstanding in transaction currency --- .../accounts/doctype/dunning/test_dunning.py | 99 +++++++++++++++++++ .../accounts/doctype/sales_invoice/mapper.py | 14 ++- 2 files changed, 111 insertions(+), 2 deletions(-) diff --git a/erpnext/accounts/doctype/dunning/test_dunning.py b/erpnext/accounts/doctype/dunning/test_dunning.py index 0110877ce904..6c61fcad3bad 100644 --- a/erpnext/accounts/doctype/dunning/test_dunning.py +++ b/erpnext/accounts/doctype/dunning/test_dunning.py @@ -12,6 +12,7 @@ create_dunning as create_dunning_from_sales_invoice, ) from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import ( + create_sales_invoice, create_sales_invoice_against_cost_center, ) from erpnext.tests.utils import ERPNextTestSuite @@ -152,6 +153,104 @@ def test_dunning_resolution_from_credit_note(self): dunning.reload() self.assertEqual(dunning.status, "Unresolved") + @ERPNextTestSuite.change_settings( + "Accounts Settings", {"allow_multi_currency_invoices_against_single_party_account": 1} + ) + def test_dunning_outstanding_in_transaction_currency(self): + """ + When party_account_currency != currency (USD invoice against INR/company-currency receivable), + overdue_payments[0].outstanding must be the transaction-currency amount, not outstanding_amount + (which is stored in party account currency). Regression guard for the fix to #41817 extension. + """ + # USD invoice booked against the INR (company-currency) debtors account. + # party_account_currency=INR, currency=USD → outstanding_amount stored in INR. + si = create_sales_invoice( + currency="USD", + conversion_rate=50, + rate=100, + qty=1, + debit_to="Debtors - _TC", + posting_date=add_days(today(), -10), + ) + + dunning = create_dunning_from_sales_invoice(si.name) + + self.assertEqual(dunning.currency, "USD") + # outstanding on the dunning row must be in USD (transaction currency). + self.assertEqual( + round(dunning.overdue_payments[0].outstanding, 2), + round(si.payment_schedule[0].outstanding, 2), + ) + # Must NOT be the INR outstanding_amount painted as USD. + self.assertNotEqual( + round(dunning.overdue_payments[0].outstanding, 2), + round(si.outstanding_amount, 2), + ) + self.assertEqual( + round(dunning.total_outstanding, 2), + round(si.payment_schedule[0].outstanding, 2), + ) + + def test_dunning_outstanding_same_currency_no_regression(self): + """ + When party_account_currency == currency (same-currency invoice), outstanding_amount + is used directly — ensures no regression of the original #41817 fix. + """ + si = create_sales_invoice_against_cost_center(posting_date=add_days(today(), -10), qty=1, rate=100) + dunning = create_dunning_from_sales_invoice(si.name) + + self.assertEqual( + round(dunning.overdue_payments[0].outstanding, 2), + round(si.outstanding_amount, 2), + ) + + @ERPNextTestSuite.change_settings( + "Accounts Settings", {"allow_multi_currency_invoices_against_single_party_account": 1} + ) + def test_dunning_multi_installment_foreign_currency(self): + """ + Multi-row payment schedule + foreign currency: both overdue installments must carry + transaction-currency (USD) outstanding amounts, not company-currency values. + The postprocess block is skipped entirely for len(payment_schedule) > 1, so the + mapper-copied Payment Schedule.outstanding values (already in transaction currency) + must be preserved untouched. + """ + create_payment_terms_template_for_dunning() + # Post 11 days ago: term 1 (due day 5) and term 2 (due day 10) are both overdue. + # Set the template before insert so the payment schedule is split into two rows. + si = create_sales_invoice( + currency="USD", + conversion_rate=50, + rate=200, + qty=1, + debit_to="Debtors - _TC", + posting_date=add_days(today(), -11), + do_not_save=True, + ) + si.payment_terms_template = "_Test 50-50 for Dunning" + si.insert() + si.submit() + si.load_from_db() + + dunning = create_dunning_from_sales_invoice(si.name) + + self.assertEqual(dunning.currency, "USD") + self.assertEqual(len(dunning.overdue_payments), 2) + + # Build a lookup: payment_schedule row name → outstanding (USD) + ps_outstanding = {row.name: row.outstanding for row in si.payment_schedule} + + for op in dunning.overdue_payments: + expected_usd = round(ps_outstanding[op.payment_schedule], 2) + self.assertEqual(round(op.outstanding, 2), expected_usd) + # Must not be the INR outstanding_amount (10000) accidentally stored as USD. + self.assertNotEqual(round(op.outstanding, 2), round(si.outstanding_amount, 2)) + + self.assertEqual( + round(dunning.total_outstanding, 2), + round(sum(ps_outstanding.values()), 2), + ) + def test_dunning_not_affected_by_standalone_credit_note(self): """ Test that dunning is NOT resolved when a credit note has update_outstanding_for_self checked. diff --git a/erpnext/accounts/doctype/sales_invoice/mapper.py b/erpnext/accounts/doctype/sales_invoice/mapper.py index cd7d9830febb..83597dab796a 100644 --- a/erpnext/accounts/doctype/sales_invoice/mapper.py +++ b/erpnext/accounts/doctype/sales_invoice/mapper.py @@ -594,11 +594,21 @@ def postprocess_dunning(source, target): target.closing_text = letter_text.get("closing_text") target.language = letter_text.get("language") - # update outstanding from doc + # Update outstanding from the invoice. + # Sales Invoice.outstanding_amount is in the party account currency, which + # may differ from the transaction currency (e.g. a USD invoice booked + # against a company-currency receivable account). Overdue Payment.outstanding + # is in the transaction currency, so when the currencies differ we must use + # Payment Schedule.outstanding (already in transaction currency and kept + # current with payments) instead of the party-account outstanding_amount. if source.payment_schedule and len(source.payment_schedule) == 1: + if source.party_account_currency and source.party_account_currency != source.currency: + outstanding = flt(source.payment_schedule[0].outstanding) + else: + outstanding = source.get("outstanding_amount") for row in target.overdue_payments: if row.payment_schedule == source.payment_schedule[0].name: - row.outstanding = source.get("outstanding_amount") + row.outstanding = outstanding target.validate()