Merge pull request #36458 from GursheenK/consolidated-financial-statements-roottype
test: balance sheet report
diff --git a/erpnext/accounts/doctype/gl_entry/gl_entry.py b/erpnext/accounts/doctype/gl_entry/gl_entry.py
index f07a4fa..7af40c4 100644
--- a/erpnext/accounts/doctype/gl_entry/gl_entry.py
+++ b/erpnext/accounts/doctype/gl_entry/gl_entry.py
@@ -58,6 +58,13 @@
validate_balance_type(self.account, adv_adj)
validate_frozen_account(self.account, adv_adj)
+ if (
+ self.voucher_type == "Journal Entry"
+ and frappe.get_cached_value("Journal Entry", self.voucher_no, "voucher_type")
+ == "Exchange Gain Or Loss"
+ ):
+ return
+
if frappe.get_cached_value("Account", self.account, "account_type") not in [
"Receivable",
"Payable",
diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.py b/erpnext/accounts/doctype/journal_entry/journal_entry.py
index ea4a2d4..1e1b3ba 100644
--- a/erpnext/accounts/doctype/journal_entry/journal_entry.py
+++ b/erpnext/accounts/doctype/journal_entry/journal_entry.py
@@ -18,6 +18,7 @@
)
from erpnext.accounts.party import get_party_account
from erpnext.accounts.utils import (
+ cancel_exchange_gain_loss_journal,
get_account_currency,
get_balance_on,
get_stock_accounts,
@@ -87,9 +88,8 @@
self.update_invoice_discounting()
def on_cancel(self):
- from erpnext.accounts.utils import unlink_ref_doc_from_payment_entries
-
- unlink_ref_doc_from_payment_entries(self)
+ # References for this Journal are removed on the `on_cancel` event in accounts_controller
+ super(JournalEntry, self).on_cancel()
self.ignore_linked_doctypes = (
"GL Entry",
"Stock Ledger Entry",
@@ -499,11 +499,12 @@
)
if not against_entries:
- frappe.throw(
- _(
- "Journal Entry {0} does not have account {1} or already matched against other voucher"
- ).format(d.reference_name, d.account)
- )
+ if self.voucher_type != "Exchange Gain Or Loss":
+ frappe.throw(
+ _(
+ "Journal Entry {0} does not have account {1} or already matched against other voucher"
+ ).format(d.reference_name, d.account)
+ )
else:
dr_or_cr = "debit" if d.credit > 0 else "credit"
valid = False
@@ -586,7 +587,9 @@
else:
party_account = against_voucher[1]
- if against_voucher[0] != cstr(d.party) or party_account != d.account:
+ if (
+ against_voucher[0] != cstr(d.party) or party_account != d.account
+ ) and self.voucher_type != "Exchange Gain Or Loss":
frappe.throw(
_("Row {0}: Party / Account does not match with {1} / {2} in {3} {4}").format(
d.idx,
@@ -768,18 +771,23 @@
)
):
- # Modified to include the posting date for which to retreive the exchange rate
- d.exchange_rate = get_exchange_rate(
- self.posting_date,
- d.account,
- d.account_currency,
- self.company,
- d.reference_type,
- d.reference_name,
- d.debit,
- d.credit,
- d.exchange_rate,
- )
+ ignore_exchange_rate = False
+ if self.get("flags") and self.flags.get("ignore_exchange_rate"):
+ ignore_exchange_rate = True
+
+ if not ignore_exchange_rate:
+ # Modified to include the posting date for which to retreive the exchange rate
+ d.exchange_rate = get_exchange_rate(
+ self.posting_date,
+ d.account,
+ d.account_currency,
+ self.company,
+ d.reference_type,
+ d.reference_name,
+ d.debit,
+ d.credit,
+ d.exchange_rate,
+ )
if not d.exchange_rate:
frappe.throw(_("Row {0}: Exchange Rate is mandatory").format(d.idx))
@@ -935,6 +943,8 @@
merge_entries=merge_entries,
update_outstanding=update_outstanding,
)
+ if cancel:
+ cancel_exchange_gain_loss_journal(frappe._dict(doctype=self.doctype, name=self.name))
@frappe.whitelist()
def get_balance(self, difference_account=None):
diff --git a/erpnext/accounts/doctype/journal_entry/test_journal_entry.py b/erpnext/accounts/doctype/journal_entry/test_journal_entry.py
index e7aca79..a6e920b 100644
--- a/erpnext/accounts/doctype/journal_entry/test_journal_entry.py
+++ b/erpnext/accounts/doctype/journal_entry/test_journal_entry.py
@@ -5,6 +5,7 @@
import unittest
import frappe
+from frappe.tests.utils import change_settings
from frappe.utils import flt, nowdate
from erpnext.accounts.doctype.account.test_account import get_inventory_account
@@ -13,6 +14,7 @@
class TestJournalEntry(unittest.TestCase):
+ @change_settings("Accounts Settings", {"unlink_payment_on_cancellation_of_invoice": 1})
def test_journal_entry_with_against_jv(self):
jv_invoice = frappe.copy_doc(test_records[2])
base_jv = frappe.copy_doc(test_records[0])
diff --git a/erpnext/accounts/doctype/journal_entry_account/journal_entry_account.json b/erpnext/accounts/doctype/journal_entry_account/journal_entry_account.json
index 47ad19e..3ba8cea 100644
--- a/erpnext/accounts/doctype/journal_entry_account/journal_entry_account.json
+++ b/erpnext/accounts/doctype/journal_entry_account/journal_entry_account.json
@@ -203,7 +203,7 @@
"fieldtype": "Select",
"label": "Reference Type",
"no_copy": 1,
- "options": "\nSales Invoice\nPurchase Invoice\nJournal Entry\nSales Order\nPurchase Order\nExpense Claim\nAsset\nLoan\nPayroll Entry\nEmployee Advance\nExchange Rate Revaluation\nInvoice Discounting\nFees\nFull and Final Statement"
+ "options": "\nSales Invoice\nPurchase Invoice\nJournal Entry\nSales Order\nPurchase Order\nExpense Claim\nAsset\nLoan\nPayroll Entry\nEmployee Advance\nExchange Rate Revaluation\nInvoice Discounting\nFees\nFull and Final Statement\nPayment Entry"
},
{
"fieldname": "reference_name",
@@ -284,7 +284,7 @@
"idx": 1,
"istable": 1,
"links": [],
- "modified": "2022-10-26 20:03:10.906259",
+ "modified": "2023-06-16 14:11:13.507807",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Journal Entry Account",
diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.js b/erpnext/accounts/doctype/payment_entry/payment_entry.js
index 44474d9..33f2634 100644
--- a/erpnext/accounts/doctype/payment_entry/payment_entry.js
+++ b/erpnext/accounts/doctype/payment_entry/payment_entry.js
@@ -9,7 +9,7 @@
frappe.ui.form.on('Payment Entry', {
onload: function(frm) {
- frm.ignore_doctypes_on_cancel_all = ['Sales Invoice', 'Purchase Invoice', "Repost Payment Ledger"];
+ frm.ignore_doctypes_on_cancel_all = ['Sales Invoice', 'Purchase Invoice', "Journal Entry", "Repost Payment Ledger"];
if(frm.doc.__islocal) {
if (!frm.doc.paid_from) frm.set_value("paid_from_account_currency", null);
diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py
index 29b5272..69ce19c 100644
--- a/erpnext/accounts/doctype/payment_entry/payment_entry.py
+++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py
@@ -28,7 +28,12 @@
process_gl_map,
)
from erpnext.accounts.party import get_party_account
-from erpnext.accounts.utils import get_account_currency, get_balance_on, get_outstanding_invoices
+from erpnext.accounts.utils import (
+ cancel_exchange_gain_loss_journal,
+ get_account_currency,
+ get_balance_on,
+ get_outstanding_invoices,
+)
from erpnext.controllers.accounts_controller import (
AccountsController,
get_supplier_block_status,
@@ -143,6 +148,7 @@
"Repost Payment Ledger",
"Repost Payment Ledger Items",
)
+ super(PaymentEntry, self).on_cancel()
self.make_gl_entries(cancel=1)
self.make_advance_gl_entries(cancel=1)
self.update_outstanding_amounts()
@@ -281,7 +287,8 @@
d.payment_term
and (
(flt(d.allocated_amount)) > 0
- and flt(d.allocated_amount) > flt(latest.payment_term_outstanding)
+ and latest.payment_term_outstanding
+ and (flt(d.allocated_amount) > flt(latest.payment_term_outstanding))
)
and self.term_based_allocation_enabled_for_reference(d.reference_doctype, d.reference_name)
):
@@ -403,7 +410,7 @@
else:
if ref_doc:
if self.paid_from_account_currency == ref_doc.currency:
- self.source_exchange_rate = ref_doc.get("exchange_rate")
+ self.source_exchange_rate = ref_doc.get("exchange_rate") or ref_doc.get("conversion_rate")
if not self.source_exchange_rate:
self.source_exchange_rate = get_exchange_rate(
@@ -416,7 +423,7 @@
elif self.paid_to and not self.target_exchange_rate:
if ref_doc:
if self.paid_to_account_currency == ref_doc.currency:
- self.target_exchange_rate = ref_doc.get("exchange_rate")
+ self.target_exchange_rate = ref_doc.get("exchange_rate") or ref_doc.get("conversion_rate")
if not self.target_exchange_rate:
self.target_exchange_rate = get_exchange_rate(
@@ -812,10 +819,25 @@
flt(d.allocated_amount) * flt(exchange_rate), self.precision("base_paid_amount")
)
else:
+
+ # Use source/target exchange rate, so no difference amount is calculated.
+ # then update exchange gain/loss amount in reference table
+ # if there is an exchange gain/loss amount in reference table, submit a JE for that
+
+ exchange_rate = 1
+ if self.payment_type == "Receive":
+ exchange_rate = self.source_exchange_rate
+ elif self.payment_type == "Pay":
+ exchange_rate = self.target_exchange_rate
+
base_allocated_amount += flt(
- flt(d.allocated_amount) * flt(d.exchange_rate), self.precision("base_paid_amount")
+ flt(d.allocated_amount) * flt(exchange_rate), self.precision("base_paid_amount")
)
+ allocated_amount_in_pe_exchange_rate = flt(
+ flt(d.allocated_amount) * flt(d.exchange_rate), self.precision("base_paid_amount")
+ )
+ d.exchange_gain_loss = base_allocated_amount - allocated_amount_in_pe_exchange_rate
return base_allocated_amount
def set_total_allocated_amount(self):
@@ -1006,6 +1028,10 @@
gl_entries = self.build_gl_map()
gl_entries = process_gl_map(gl_entries)
make_gl_entries(gl_entries, cancel=cancel, adv_adj=adv_adj)
+ if cancel:
+ cancel_exchange_gain_loss_journal(frappe._dict(doctype=self.doctype, name=self.name))
+ else:
+ self.make_exchange_gain_loss_journal()
def add_party_gl_entries(self, gl_entries):
if self.party_account:
@@ -1992,7 +2018,6 @@
payment_type=None,
reference_date=None,
):
- reference_doc = None
doc = frappe.get_doc(dt, dn)
over_billing_allowance = frappe.db.get_single_value("Accounts Settings", "over_billing_allowance")
if dt in ("Sales Order", "Purchase Order") and flt(doc.per_billed, 2) >= (
@@ -2132,7 +2157,7 @@
update_accounting_dimensions(pe, doc)
if party_account and bank:
- pe.set_exchange_rate(ref_doc=reference_doc)
+ pe.set_exchange_rate(ref_doc=doc)
pe.set_amounts()
if discount_amount:
diff --git a/erpnext/accounts/doctype/payment_entry/test_payment_entry.py b/erpnext/accounts/doctype/payment_entry/test_payment_entry.py
index dc44fc3..8f9f7ce 100644
--- a/erpnext/accounts/doctype/payment_entry/test_payment_entry.py
+++ b/erpnext/accounts/doctype/payment_entry/test_payment_entry.py
@@ -31,6 +31,16 @@
def tearDown(self):
frappe.db.rollback()
+ def get_journals_for(self, voucher_type: str, voucher_no: str) -> list:
+ journals = []
+ if voucher_type and voucher_no:
+ journals = frappe.db.get_all(
+ "Journal Entry Account",
+ filters={"reference_type": voucher_type, "reference_name": voucher_no, "docstatus": 1},
+ fields=["parent"],
+ )
+ return journals
+
def test_payment_entry_against_order(self):
so = make_sales_order()
pe = get_payment_entry("Sales Order", so.name, bank_account="_Test Cash - _TC")
@@ -591,21 +601,15 @@
pe.target_exchange_rate = 45.263
pe.reference_no = "1"
pe.reference_date = "2016-01-01"
-
- pe.append(
- "deductions",
- {
- "account": "_Test Exchange Gain/Loss - _TC",
- "cost_center": "_Test Cost Center - _TC",
- "amount": 94.80,
- },
- )
-
pe.save()
self.assertEqual(flt(pe.difference_amount, 2), 0.0)
self.assertEqual(flt(pe.unallocated_amount, 2), 0.0)
+ # the exchange gain/loss amount is captured in reference table and a separate Journal will be submitted for them
+ # payment entry will not be generating difference amount
+ self.assertEqual(flt(pe.references[0].exchange_gain_loss, 2), -94.74)
+
def test_payment_entry_retrieves_last_exchange_rate(self):
from erpnext.setup.doctype.currency_exchange.test_currency_exchange import (
save_new_records,
@@ -792,33 +796,28 @@
pe.reference_no = "1"
pe.reference_date = "2016-01-01"
pe.source_exchange_rate = 55
-
- pe.append(
- "deductions",
- {
- "account": "_Test Exchange Gain/Loss - _TC",
- "cost_center": "_Test Cost Center - _TC",
- "amount": -500,
- },
- )
pe.save()
self.assertEqual(pe.unallocated_amount, 0)
self.assertEqual(pe.difference_amount, 0)
-
+ self.assertEqual(pe.references[0].exchange_gain_loss, 500)
pe.submit()
expected_gle = dict(
(d[0], d)
for d in [
- ["_Test Receivable USD - _TC", 0, 5000, si.name],
+ ["_Test Receivable USD - _TC", 0, 5500, si.name],
["_Test Bank USD - _TC", 5500, 0, None],
- ["_Test Exchange Gain/Loss - _TC", 0, 500, None],
]
)
self.validate_gl_entries(pe.name, expected_gle)
+ # Exchange gain/loss should have been posted through a journal
+ exc_je_for_si = self.get_journals_for(si.doctype, si.name)
+ exc_je_for_pe = self.get_journals_for(pe.doctype, pe.name)
+
+ self.assertEqual(exc_je_for_si, exc_je_for_pe)
outstanding_amount = flt(frappe.db.get_value("Sales Invoice", si.name, "outstanding_amount"))
self.assertEqual(outstanding_amount, 0)
diff --git a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py
index 25d94c5..ea06e0e 100644
--- a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py
+++ b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py
@@ -14,6 +14,7 @@
)
from erpnext.accounts.utils import (
QueryPaymentLedger,
+ create_gain_loss_journal,
get_outstanding_invoices,
reconcile_against_document,
)
@@ -276,6 +277,11 @@
def calculate_difference_on_allocation_change(self, payment_entry, invoice, allocated_amount):
invoice_exchange_map = self.get_invoice_exchange_map(invoice, payment_entry)
invoice[0]["exchange_rate"] = invoice_exchange_map.get(invoice[0].get("invoice_number"))
+ if payment_entry[0].get("reference_type") in ["Sales Invoice", "Purchase Invoice"]:
+ payment_entry[0]["exchange_rate"] = invoice_exchange_map.get(
+ payment_entry[0].get("reference_name")
+ )
+
new_difference_amount = self.get_difference_amount(
payment_entry[0], invoice[0], allocated_amount
)
@@ -363,12 +369,6 @@
payment_details = self.get_payment_details(row, dr_or_cr)
reconciled_entry.append(payment_details)
- if payment_details.difference_amount and row.reference_type not in [
- "Sales Invoice",
- "Purchase Invoice",
- ]:
- self.make_difference_entry(payment_details)
-
if entry_list:
reconcile_against_document(entry_list, skip_ref_details_update_for_pe)
@@ -656,6 +656,7 @@
"reference_type": inv.against_voucher_type,
"reference_name": inv.against_voucher,
"cost_center": erpnext.get_default_cost_center(company),
+ "exchange_rate": inv.exchange_rate,
},
{
"account": inv.account,
@@ -669,13 +670,38 @@
"reference_type": inv.voucher_type,
"reference_name": inv.voucher_no,
"cost_center": erpnext.get_default_cost_center(company),
+ "exchange_rate": inv.exchange_rate,
},
],
}
)
- if difference_entry := get_difference_row(inv):
- jv.append("accounts", difference_entry)
-
jv.flags.ignore_mandatory = True
+ jv.flags.ignore_exchange_rate = True
jv.submit()
+
+ if inv.difference_amount != 0:
+ # make gain/loss journal
+ if inv.party_type == "Customer":
+ dr_or_cr = "credit" if inv.difference_amount < 0 else "debit"
+ else:
+ dr_or_cr = "debit" if inv.difference_amount < 0 else "credit"
+
+ reverse_dr_or_cr = "debit" if dr_or_cr == "credit" else "credit"
+
+ create_gain_loss_journal(
+ company,
+ inv.party_type,
+ inv.party,
+ inv.account,
+ inv.difference_account,
+ inv.difference_amount,
+ dr_or_cr,
+ reverse_dr_or_cr,
+ inv.voucher_type,
+ inv.voucher_no,
+ None,
+ inv.against_voucher_type,
+ inv.against_voucher,
+ None,
+ )
diff --git a/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py b/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py
index 2ac7df0..1d843ab 100644
--- a/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py
+++ b/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py
@@ -686,14 +686,24 @@
# Check if difference journal entry gets generated for difference amount after reconciliation
pr.reconcile()
- total_debit_amount = frappe.db.get_all(
+ total_credit_amount = frappe.db.get_all(
"Journal Entry Account",
{"account": self.debtors_eur, "docstatus": 1, "reference_name": si.name},
- "sum(debit) as amount",
+ "sum(credit) as amount",
group_by="reference_name",
)[0].amount
- self.assertEqual(flt(total_debit_amount, 2), -500)
+ # total credit includes the exchange gain/loss amount
+ self.assertEqual(flt(total_credit_amount, 2), 8500)
+
+ jea_parent = frappe.db.get_all(
+ "Journal Entry Account",
+ filters={"account": self.debtors_eur, "docstatus": 1, "reference_name": si.name, "credit": 500},
+ fields=["parent"],
+ )[0]
+ self.assertEqual(
+ frappe.db.get_value("Journal Entry", jea_parent.parent, "voucher_type"), "Exchange Gain Or Loss"
+ )
def test_difference_amount_via_payment_entry(self):
# Make Sale Invoice
diff --git a/erpnext/accounts/doctype/payment_request/test_payment_request.py b/erpnext/accounts/doctype/payment_request/test_payment_request.py
index e17a846..feb2fdf 100644
--- a/erpnext/accounts/doctype/payment_request/test_payment_request.py
+++ b/erpnext/accounts/doctype/payment_request/test_payment_request.py
@@ -144,8 +144,7 @@
(d[0], d)
for d in [
["_Test Receivable USD - _TC", 0, 5000, si_usd.name],
- [pr.payment_account, 6290.0, 0, None],
- ["_Test Exchange Gain/Loss - _TC", 0, 1290, None],
+ [pr.payment_account, 5000.0, 0, None],
]
)
diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py
index 96ba783..d175df5 100644
--- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py
+++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py
@@ -536,6 +536,7 @@
merge_entries=False,
from_repost=from_repost,
)
+ self.make_exchange_gain_loss_journal()
elif self.docstatus == 2:
provisional_entries = [a for a in gl_entries if a.voucher_type == "Purchase Receipt"]
make_reverse_gl_entries(voucher_type=self.doctype, voucher_no=self.name)
@@ -580,7 +581,6 @@
self.get_asset_gl_entry(gl_entries)
self.make_tax_gl_entries(gl_entries)
- self.make_exchange_gain_loss_gl_entries(gl_entries)
self.make_internal_transfer_gl_entries(gl_entries)
gl_entries = make_regional_gl_entries(gl_entries, self)
diff --git a/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py
index 486e01e..55d0203 100644
--- a/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py
+++ b/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py
@@ -1273,10 +1273,11 @@
pi.save()
pi.submit()
+ creditors_account = pi.credit_to
+
expected_gle = [
["_Test Account Cost for Goods Sold - _TC", 37500.0],
- ["_Test Payable USD - _TC", -35000.0],
- ["Exchange Gain/Loss - _TC", -2500.0],
+ ["_Test Payable USD - _TC", -37500.0],
]
gl_entries = frappe.db.sql(
@@ -1293,6 +1294,31 @@
self.assertEqual(expected_gle[i][0], gle.account)
self.assertEqual(expected_gle[i][1], gle.balance)
+ pi.reload()
+ self.assertEqual(pi.outstanding_amount, 0)
+
+ total_debit_amount = frappe.db.get_all(
+ "Journal Entry Account",
+ {"account": creditors_account, "docstatus": 1, "reference_name": pi.name},
+ "sum(debit) as amount",
+ group_by="reference_name",
+ )[0].amount
+ self.assertEqual(flt(total_debit_amount, 2), 2500)
+ jea_parent = frappe.db.get_all(
+ "Journal Entry Account",
+ filters={
+ "account": creditors_account,
+ "docstatus": 1,
+ "reference_name": pi.name,
+ "debit": 2500,
+ "debit_in_account_currency": 0,
+ },
+ fields=["parent"],
+ )[0]
+ self.assertEqual(
+ frappe.db.get_value("Journal Entry", jea_parent.parent, "voucher_type"), "Exchange Gain Or Loss"
+ )
+
pi_2 = make_purchase_invoice(
supplier="_Test Supplier USD",
currency="USD",
@@ -1317,10 +1343,12 @@
pi_2.save()
pi_2.submit()
+ pi_2.reload()
+ self.assertEqual(pi_2.outstanding_amount, 0)
+
expected_gle = [
["_Test Account Cost for Goods Sold - _TC", 36500.0],
- ["_Test Payable USD - _TC", -35000.0],
- ["Exchange Gain/Loss - _TC", -1500.0],
+ ["_Test Payable USD - _TC", -36500.0],
]
gl_entries = frappe.db.sql(
@@ -1351,12 +1379,39 @@
self.assertEqual(expected_gle[i][0], gle.account)
self.assertEqual(expected_gle[i][1], gle.balance)
+ total_debit_amount = frappe.db.get_all(
+ "Journal Entry Account",
+ {"account": creditors_account, "docstatus": 1, "reference_name": pi_2.name},
+ "sum(debit) as amount",
+ group_by="reference_name",
+ )[0].amount
+ self.assertEqual(flt(total_debit_amount, 2), 1500)
+ jea_parent_2 = frappe.db.get_all(
+ "Journal Entry Account",
+ filters={
+ "account": creditors_account,
+ "docstatus": 1,
+ "reference_name": pi_2.name,
+ "debit": 1500,
+ "debit_in_account_currency": 0,
+ },
+ fields=["parent"],
+ )[0]
+ self.assertEqual(
+ frappe.db.get_value("Journal Entry", jea_parent_2.parent, "voucher_type"),
+ "Exchange Gain Or Loss",
+ )
+
pi.reload()
pi.cancel()
+ self.assertEqual(frappe.db.get_value("Journal Entry", jea_parent.parent, "docstatus"), 2)
+
pi_2.reload()
pi_2.cancel()
+ self.assertEqual(frappe.db.get_value("Journal Entry", jea_parent_2.parent, "docstatus"), 2)
+
pay.reload()
pay.cancel()
diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py
index b0cc8ca..f08bf18 100644
--- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py
+++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py
@@ -23,7 +23,7 @@
)
from erpnext.accounts.general_ledger import get_round_off_account_and_cost_center
from erpnext.accounts.party import get_due_date, get_party_account, get_party_details
-from erpnext.accounts.utils import get_account_currency
+from erpnext.accounts.utils import cancel_exchange_gain_loss_journal, get_account_currency
from erpnext.assets.doctype.asset.depreciation import (
depreciate_asset,
get_disposal_account_and_cost_center,
@@ -1030,7 +1030,10 @@
merge_entries=False,
from_repost=from_repost,
)
+
+ self.make_exchange_gain_loss_journal()
elif self.docstatus == 2:
+ cancel_exchange_gain_loss_journal(frappe._dict(doctype=self.doctype, name=self.name))
make_reverse_gl_entries(voucher_type=self.doctype, voucher_no=self.name)
if update_outstanding == "No":
@@ -1055,7 +1058,6 @@
self.make_customer_gl_entry(gl_entries)
self.make_tax_gl_entries(gl_entries)
- self.make_exchange_gain_loss_gl_entries(gl_entries)
self.make_internal_transfer_gl_entries(gl_entries)
self.make_item_gl_entries(gl_entries)
diff --git a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py
index 41e5554..8816a8c 100644
--- a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py
+++ b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py
@@ -3213,15 +3213,10 @@
account.disabled = 0
account.save()
+ @change_settings("Accounts Settings", {"unlink_payment_on_cancellation_of_invoice": 1})
def test_gain_loss_with_advance_entry(self):
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
- unlink_enabled = frappe.db.get_value(
- "Accounts Settings", "Accounts Settings", "unlink_payment_on_cancel_of_invoice"
- )
-
- frappe.db.set_single_value("Accounts Settings", "unlink_payment_on_cancel_of_invoice", 1)
-
jv = make_journal_entry("_Test Receivable USD - _TC", "_Test Bank - _TC", -7000, save=False)
jv.accounts[0].exchange_rate = 70
@@ -3254,18 +3249,28 @@
)
si.save()
si.submit()
-
expected_gle = [
- ["_Test Exchange Gain/Loss - _TC", 500.0, 0.0, nowdate()],
["_Test Receivable USD - _TC", 7500.0, 0.0, nowdate()],
- ["_Test Receivable USD - _TC", 0.0, 500.0, nowdate()],
["Sales - _TC", 0.0, 7500.0, nowdate()],
]
-
check_gl_entries(self, si.name, expected_gle, nowdate())
- frappe.db.set_single_value(
- "Accounts Settings", "unlink_payment_on_cancel_of_invoice", unlink_enabled
+ si.reload()
+ self.assertEqual(si.outstanding_amount, 0)
+ journals = frappe.db.get_all(
+ "Journal Entry Account",
+ filters={"reference_type": "Sales Invoice", "reference_name": si.name, "docstatus": 1},
+ pluck="parent",
+ )
+ journals = [x for x in journals if x != jv.name]
+ self.assertEqual(len(journals), 1)
+ je_type = frappe.get_cached_value("Journal Entry", journals[0], "voucher_type")
+ self.assertEqual(je_type, "Exchange Gain Or Loss")
+ ledger_outstanding = frappe.db.get_all(
+ "Payment Ledger Entry",
+ filters={"against_voucher_no": si.name, "delinked": 0},
+ fields=["sum(amount), sum(amount_in_account_currency)"],
+ as_list=1,
)
def test_batch_expiry_for_sales_invoice_return(self):
@@ -3371,6 +3376,13 @@
set_advance_flag(company="_Test Company", flag=0, default_account="")
+ def test_sales_return_negative_rate(self):
+ si = create_sales_invoice(is_return=1, qty=-2, rate=-10, do_not_save=True)
+ self.assertRaises(frappe.ValidationError, si.save)
+
+ si.items[0].rate = 10
+ si.save()
+
def set_advance_flag(company, flag, default_account):
frappe.db.set_value(
diff --git a/erpnext/accounts/test/test_utils.py b/erpnext/accounts/test/test_utils.py
index 3aca60e..3cb5e42 100644
--- a/erpnext/accounts/test/test_utils.py
+++ b/erpnext/accounts/test/test_utils.py
@@ -80,18 +80,27 @@
item = make_item().name
purchase_invoice = make_purchase_invoice(
- item=item, supplier="_Test Supplier USD", currency="USD", conversion_rate=82.32
+ item=item, supplier="_Test Supplier USD", currency="USD", conversion_rate=82.32, do_not_submit=1
)
+ purchase_invoice.credit_to = "_Test Payable USD - _TC"
purchase_invoice.submit()
payment_entry = get_payment_entry(purchase_invoice.doctype, purchase_invoice.name)
- payment_entry.target_exchange_rate = 62.9
payment_entry.paid_amount = 15725
payment_entry.deductions = []
- payment_entry.insert()
+ payment_entry.save()
- self.assertEqual(payment_entry.difference_amount, -4855.00)
+ # below is the difference between base_received_amount and base_paid_amount
+ self.assertEqual(payment_entry.difference_amount, -4855.0)
+
+ payment_entry.target_exchange_rate = 62.9
+ payment_entry.save()
+
+ # below is due to change in exchange rate
+ self.assertEqual(payment_entry.references[0].exchange_gain_loss, -4855.0)
+
payment_entry.references = []
+ self.assertEqual(payment_entry.difference_amount, 0.0)
payment_entry.submit()
payment_reconciliation = frappe.new_doc("Payment Reconciliation")
diff --git a/erpnext/accounts/utils.py b/erpnext/accounts/utils.py
index ae0a279..961f41c 100644
--- a/erpnext/accounts/utils.py
+++ b/erpnext/accounts/utils.py
@@ -459,6 +459,9 @@
# update ref in advance entry
if voucher_type == "Journal Entry":
update_reference_in_journal_entry(entry, doc, do_not_save=True)
+ # advance section in sales/purchase invoice and reconciliation tool,both pass on exchange gain/loss
+ # amount and account in args
+ doc.make_exchange_gain_loss_journal(args)
else:
update_reference_in_payment_entry(
entry, doc, do_not_save=True, skip_ref_details_update_for_pe=skip_ref_details_update_for_pe
@@ -618,9 +621,7 @@
"total_amount": d.grand_total,
"outstanding_amount": d.outstanding_amount,
"allocated_amount": d.allocated_amount,
- "exchange_rate": d.exchange_rate
- if not d.exchange_gain_loss
- else payment_entry.get_exchange_rate(),
+ "exchange_rate": d.exchange_rate if d.exchange_gain_loss else payment_entry.get_exchange_rate(),
"exchange_gain_loss": d.exchange_gain_loss, # only populated from invoice in case of advance allocation
"account": d.account,
}
@@ -642,28 +643,48 @@
new_row.docstatus = 1
new_row.update(reference_details)
- if d.difference_amount and d.difference_account:
- account_details = {
- "account": d.difference_account,
- "cost_center": payment_entry.cost_center
- or frappe.get_cached_value("Company", payment_entry.company, "cost_center"),
- }
- if d.difference_amount:
- account_details["amount"] = d.difference_amount
-
- payment_entry.set_gain_or_loss(account_details=account_details)
-
payment_entry.flags.ignore_validate_update_after_submit = True
payment_entry.setup_party_account_field()
payment_entry.set_missing_values()
if not skip_ref_details_update_for_pe:
payment_entry.set_missing_ref_details()
payment_entry.set_amounts()
+ payment_entry.make_exchange_gain_loss_journal()
if not do_not_save:
payment_entry.save(ignore_permissions=True)
+def cancel_exchange_gain_loss_journal(parent_doc: dict | object) -> None:
+ """
+ Cancel Exchange Gain/Loss for Sales/Purchase Invoice, if they have any.
+ """
+ if parent_doc.doctype in ["Sales Invoice", "Purchase Invoice", "Payment Entry", "Journal Entry"]:
+ journals = frappe.db.get_all(
+ "Journal Entry Account",
+ filters={
+ "reference_type": parent_doc.doctype,
+ "reference_name": parent_doc.name,
+ "docstatus": 1,
+ },
+ fields=["parent"],
+ as_list=1,
+ )
+
+ if journals:
+ gain_loss_journals = frappe.db.get_all(
+ "Journal Entry",
+ filters={
+ "name": ["in", [x[0] for x in journals]],
+ "voucher_type": "Exchange Gain Or Loss",
+ "docstatus": 1,
+ },
+ as_list=1,
+ )
+ for doc in gain_loss_journals:
+ frappe.get_doc("Journal Entry", doc[0]).cancel()
+
+
def unlink_ref_doc_from_payment_entries(ref_doc):
remove_ref_doc_link_from_jv(ref_doc.doctype, ref_doc.name)
remove_ref_doc_link_from_pe(ref_doc.doctype, ref_doc.name)
@@ -1820,3 +1841,74 @@
self.query_for_outstanding()
return self.voucher_outstandings
+
+
+def create_gain_loss_journal(
+ company,
+ party_type,
+ party,
+ party_account,
+ gain_loss_account,
+ exc_gain_loss,
+ dr_or_cr,
+ reverse_dr_or_cr,
+ ref1_dt,
+ ref1_dn,
+ ref1_detail_no,
+ ref2_dt,
+ ref2_dn,
+ ref2_detail_no,
+) -> str:
+ journal_entry = frappe.new_doc("Journal Entry")
+ journal_entry.voucher_type = "Exchange Gain Or Loss"
+ journal_entry.company = company
+ journal_entry.posting_date = nowdate()
+ journal_entry.multi_currency = 1
+
+ party_account_currency = frappe.get_cached_value("Account", party_account, "account_currency")
+
+ if not gain_loss_account:
+ frappe.throw(_("Please set default Exchange Gain/Loss Account in Company {}").format(company))
+ gain_loss_account_currency = get_account_currency(gain_loss_account)
+ company_currency = frappe.get_cached_value("Company", company, "default_currency")
+
+ if gain_loss_account_currency != company_currency:
+ frappe.throw(_("Currency for {0} must be {1}").format(gain_loss_account, company_currency))
+
+ journal_account = frappe._dict(
+ {
+ "account": party_account,
+ "party_type": party_type,
+ "party": party,
+ "account_currency": party_account_currency,
+ "exchange_rate": 0,
+ "cost_center": erpnext.get_default_cost_center(company),
+ "reference_type": ref1_dt,
+ "reference_name": ref1_dn,
+ "reference_detail_no": ref1_detail_no,
+ dr_or_cr: abs(exc_gain_loss),
+ dr_or_cr + "_in_account_currency": 0,
+ }
+ )
+
+ journal_entry.append("accounts", journal_account)
+
+ journal_account = frappe._dict(
+ {
+ "account": gain_loss_account,
+ "account_currency": gain_loss_account_currency,
+ "exchange_rate": 1,
+ "cost_center": erpnext.get_default_cost_center(company),
+ "reference_type": ref2_dt,
+ "reference_name": ref2_dn,
+ "reference_detail_no": ref2_detail_no,
+ reverse_dr_or_cr + "_in_account_currency": 0,
+ reverse_dr_or_cr: abs(exc_gain_loss),
+ }
+ )
+
+ journal_entry.append("accounts", journal_account)
+
+ journal_entry.save()
+ journal_entry.submit()
+ return journal_entry.name
diff --git a/erpnext/assets/doctype/asset/asset.js b/erpnext/assets/doctype/asset/asset.js
index a97ea73..0a2f61d 100644
--- a/erpnext/assets/doctype/asset/asset.js
+++ b/erpnext/assets/doctype/asset/asset.js
@@ -207,34 +207,39 @@
},
render_depreciation_schedule_view: function(frm, depr_schedule) {
- var wrapper = $(frm.fields_dict["depreciation_schedule_view"].wrapper).empty();
+ let wrapper = $(frm.fields_dict["depreciation_schedule_view"].wrapper).empty();
- let table = $(`<table class="table table-bordered" style="margin-top:0px;">
- <thead>
- <tr>
- <td align="center">${__("No.")}</td>
- <td>${__("Schedule Date")}</td>
- <td align="right">${__("Depreciation Amount")}</td>
- <td align="right">${__("Accumulated Depreciation Amount")}</td>
- <td>${__("Journal Entry")}</td>
- </tr>
- </thead>
- <tbody></tbody>
- </table>`);
+ let data = [];
depr_schedule.forEach((sch) => {
- const row = $(`<tr>
- <td align="center">${sch['idx']}</td>
- <td><b>${frappe.format(sch['schedule_date'], { fieldtype: 'Date' })}</b></td>
- <td><b>${frappe.format(sch['depreciation_amount'], { fieldtype: 'Currency' })}</b></td>
- <td>${frappe.format(sch['accumulated_depreciation_amount'], { fieldtype: 'Currency' })}</td>
- <td><a href="/app/journal-entry/${sch['journal_entry'] || ''}">${sch['journal_entry'] || ''}</a></td>
- </tr>`);
- table.find("tbody").append(row);
+ const row = [
+ sch['idx'],
+ frappe.format(sch['schedule_date'], { fieldtype: 'Date' }),
+ frappe.format(sch['depreciation_amount'], { fieldtype: 'Currency' }),
+ frappe.format(sch['accumulated_depreciation_amount'], { fieldtype: 'Currency' }),
+ sch['journal_entry'] || ''
+ ];
+ data.push(row);
});
- wrapper.append(table);
+ let datatable = new frappe.DataTable(wrapper.get(0), {
+ columns: [
+ {name: __("No."), editable: false, resizable: false, format: value => value, width: 60},
+ {name: __("Schedule Date"), editable: false, resizable: false, width: 270},
+ {name: __("Depreciation Amount"), editable: false, resizable: false, width: 164},
+ {name: __("Accumulated Depreciation Amount"), editable: false, resizable: false, width: 164},
+ {name: __("Journal Entry"), editable: false, resizable: false, format: value => `<a href="/app/journal-entry/${value}">${value}</a>`, width: 312}
+ ],
+ data: data,
+ serialNoColumn: false,
+ checkboxColumn: true,
+ cellHeight: 35
+ });
+ datatable.style.setStyle(`.dt-scrollable`, {'font-size': '0.75rem', 'margin-bottom': '1rem'});
+ datatable.style.setStyle(`.dt-cell--col-1`, {'text-align': 'center'});
+ datatable.style.setStyle(`.dt-cell--col-2`, {'font-weight': 600});
+ datatable.style.setStyle(`.dt-cell--col-3`, {'font-weight': 600});
},
setup_chart_and_depr_schedule_view: async function(frm) {
diff --git a/erpnext/assets/doctype/asset/asset.py b/erpnext/assets/doctype/asset/asset.py
index 252a3dd..04ec7be 100644
--- a/erpnext/assets/doctype/asset/asset.py
+++ b/erpnext/assets/doctype/asset/asset.py
@@ -148,17 +148,33 @@
frappe.throw(_("Item {0} must be a non-stock item").format(self.item_code))
def validate_cost_center(self):
- if not self.cost_center:
- return
-
- cost_center_company = frappe.db.get_value("Cost Center", self.cost_center, "company")
- if cost_center_company != self.company:
- frappe.throw(
- _("Selected Cost Center {} doesn't belongs to {}").format(
- frappe.bold(self.cost_center), frappe.bold(self.company)
- ),
- title=_("Invalid Cost Center"),
+ if self.cost_center:
+ cost_center_company, cost_center_is_group = frappe.db.get_value(
+ "Cost Center", self.cost_center, ["company", "is_group"]
)
+ if cost_center_company != self.company:
+ frappe.throw(
+ _("Cost Center {} doesn't belong to Company {}").format(
+ frappe.bold(self.cost_center), frappe.bold(self.company)
+ ),
+ title=_("Invalid Cost Center"),
+ )
+ if cost_center_is_group:
+ frappe.throw(
+ _(
+ "Cost Center {} is a group cost center and group cost centers cannot be used in transactions"
+ ).format(frappe.bold(self.cost_center)),
+ title=_("Invalid Cost Center"),
+ )
+
+ else:
+ if not frappe.get_cached_value("Company", self.company, "depreciation_cost_center"):
+ frappe.throw(
+ _(
+ "Please set a Cost Center for the Asset or set an Asset Depreciation Cost Center for the Company {}"
+ ).format(frappe.bold(self.company)),
+ title=_("Missing Cost Center"),
+ )
def validate_in_use_date(self):
if not self.available_for_use_date:
diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py
index 7940489..37a18d8 100644
--- a/erpnext/controllers/accounts_controller.py
+++ b/erpnext/controllers/accounts_controller.py
@@ -5,7 +5,7 @@
import json
import frappe
-from frappe import _, bold, throw
+from frappe import _, bold, qb, throw
from frappe.model.workflow import get_workflow_name, is_transition_condition_satisfied
from frappe.query_builder.custom import ConstantColumn
from frappe.query_builder.functions import Abs, Sum
@@ -38,7 +38,12 @@
get_party_gle_currency,
validate_party_frozen_disabled,
)
-from erpnext.accounts.utils import get_account_currency, get_fiscal_years, validate_fiscal_year
+from erpnext.accounts.utils import (
+ create_gain_loss_journal,
+ get_account_currency,
+ get_fiscal_years,
+ validate_fiscal_year,
+)
from erpnext.buying.utils import update_last_purchase_rate
from erpnext.controllers.print_settings import (
set_print_templates_for_item_table,
@@ -968,67 +973,133 @@
d.exchange_gain_loss = difference
- def make_exchange_gain_loss_gl_entries(self, gl_entries):
- if self.get("doctype") in ["Purchase Invoice", "Sales Invoice"]:
- for d in self.get("advances"):
- if d.exchange_gain_loss:
- is_purchase_invoice = self.get("doctype") == "Purchase Invoice"
- party = self.supplier if is_purchase_invoice else self.customer
- party_account = self.credit_to if is_purchase_invoice else self.debit_to
- party_type = "Supplier" if is_purchase_invoice else "Customer"
+ def make_exchange_gain_loss_journal(self, args: dict = None) -> None:
+ """
+ Make Exchange Gain/Loss journal for Invoices and Payments
+ """
+ # Cancelling existing exchange gain/loss journals is handled during the `on_cancel` event.
+ # see accounts/utils.py:cancel_exchange_gain_loss_journal()
+ if self.docstatus == 1:
+ if self.get("doctype") == "Journal Entry":
+ # 'args' is populated with exchange gain/loss account and the amount to be booked.
+ # These are generated by Sales/Purchase Invoice during reconciliation and advance allocation.
+ # and below logic is only for such scenarios
+ if args:
+ for arg in args:
+ # Advance section uses `exchange_gain_loss` and reconciliation uses `difference_amount`
+ if (
+ arg.get("difference_amount", 0) != 0 or arg.get("exchange_gain_loss", 0) != 0
+ ) and arg.get("difference_account"):
- gain_loss_account = frappe.get_cached_value(
- "Company", self.company, "exchange_gain_loss_account"
- )
- if not gain_loss_account:
- frappe.throw(
- _("Please set default Exchange Gain/Loss Account in Company {}").format(self.get("company"))
- )
- account_currency = get_account_currency(gain_loss_account)
- if account_currency != self.company_currency:
- frappe.throw(
- _("Currency for {0} must be {1}").format(gain_loss_account, self.company_currency)
- )
+ party_account = arg.get("account")
+ gain_loss_account = arg.get("difference_account")
+ difference_amount = arg.get("difference_amount") or arg.get("exchange_gain_loss")
+ if difference_amount > 0:
+ dr_or_cr = "debit" if arg.get("party_type") == "Customer" else "credit"
+ else:
+ dr_or_cr = "credit" if arg.get("party_type") == "Customer" else "debit"
- # for purchase
- dr_or_cr = "debit" if d.exchange_gain_loss > 0 else "credit"
- if not is_purchase_invoice:
- # just reverse for sales?
- dr_or_cr = "debit" if dr_or_cr == "credit" else "credit"
+ reverse_dr_or_cr = "debit" if dr_or_cr == "credit" else "credit"
- gl_entries.append(
- self.get_gl_dict(
- {
- "account": gain_loss_account,
- "account_currency": account_currency,
- "against": party,
- dr_or_cr + "_in_account_currency": abs(d.exchange_gain_loss),
- dr_or_cr: abs(d.exchange_gain_loss),
- "cost_center": self.cost_center or erpnext.get_default_cost_center(self.company),
- "project": self.project,
- },
- item=d,
+ je = create_gain_loss_journal(
+ self.company,
+ arg.get("party_type"),
+ arg.get("party"),
+ party_account,
+ gain_loss_account,
+ difference_amount,
+ dr_or_cr,
+ reverse_dr_or_cr,
+ arg.get("against_voucher_type"),
+ arg.get("against_voucher"),
+ arg.get("idx"),
+ self.doctype,
+ self.name,
+ arg.get("idx"),
+ )
+ frappe.msgprint(
+ _("Exchange Gain/Loss amount has been booked through {0}").format(
+ get_link_to_form("Journal Entry", je)
+ )
+ )
+
+ if self.get("doctype") == "Payment Entry":
+ # For Payment Entry, exchange_gain_loss field in the `references` table is the trigger for journal creation
+ gain_loss_to_book = [x for x in self.references if x.exchange_gain_loss != 0]
+ booked = []
+ if gain_loss_to_book:
+ vtypes = [x.reference_doctype for x in gain_loss_to_book]
+ vnames = [x.reference_name for x in gain_loss_to_book]
+ je = qb.DocType("Journal Entry")
+ jea = qb.DocType("Journal Entry Account")
+ parents = (
+ qb.from_(jea)
+ .select(jea.parent)
+ .where(
+ (jea.reference_type == "Payment Entry")
+ & (jea.reference_name == self.name)
+ & (jea.docstatus == 1)
)
+ .run()
)
- dr_or_cr = "debit" if dr_or_cr == "credit" else "credit"
-
- gl_entries.append(
- self.get_gl_dict(
- {
- "account": party_account,
- "party_type": party_type,
- "party": party,
- "against": gain_loss_account,
- dr_or_cr + "_in_account_currency": flt(abs(d.exchange_gain_loss) / self.conversion_rate),
- dr_or_cr: abs(d.exchange_gain_loss),
- "cost_center": self.cost_center,
- "project": self.project,
- },
- self.party_account_currency,
- item=self,
+ booked = []
+ if parents:
+ booked = (
+ qb.from_(je)
+ .inner_join(jea)
+ .on(je.name == jea.parent)
+ .select(jea.reference_type, jea.reference_name, jea.reference_detail_no)
+ .where(
+ (je.docstatus == 1)
+ & (je.name.isin(parents))
+ & (je.voucher_type == "Exchange Gain or Loss")
+ )
+ .run()
)
- )
+
+ for d in gain_loss_to_book:
+ # Filter out References for which Gain/Loss is already booked
+ if d.exchange_gain_loss and (
+ (d.reference_doctype, d.reference_name, str(d.idx)) not in booked
+ ):
+ if self.payment_type == "Receive":
+ party_account = self.paid_from
+ elif self.payment_type == "Pay":
+ party_account = self.paid_to
+
+ dr_or_cr = "debit" if d.exchange_gain_loss > 0 else "credit"
+
+ if d.reference_doctype == "Purchase Invoice":
+ dr_or_cr = "debit" if dr_or_cr == "credit" else "credit"
+
+ reverse_dr_or_cr = "debit" if dr_or_cr == "credit" else "credit"
+
+ gain_loss_account = frappe.get_cached_value(
+ "Company", self.company, "exchange_gain_loss_account"
+ )
+
+ je = create_gain_loss_journal(
+ self.company,
+ self.party_type,
+ self.party,
+ party_account,
+ gain_loss_account,
+ d.exchange_gain_loss,
+ dr_or_cr,
+ reverse_dr_or_cr,
+ d.reference_doctype,
+ d.reference_name,
+ d.idx,
+ self.doctype,
+ self.name,
+ d.idx,
+ )
+ frappe.msgprint(
+ _("Exchange Gain/Loss amount has been booked through {0}").format(
+ get_link_to_form("Journal Entry", je)
+ )
+ )
def update_against_document_in_jv(self):
"""
@@ -1090,9 +1161,15 @@
reconcile_against_document(lst)
def on_cancel(self):
- from erpnext.accounts.utils import unlink_ref_doc_from_payment_entries
+ from erpnext.accounts.utils import (
+ cancel_exchange_gain_loss_journal,
+ unlink_ref_doc_from_payment_entries,
+ )
- if self.doctype in ["Sales Invoice", "Purchase Invoice"]:
+ if self.doctype in ["Sales Invoice", "Purchase Invoice", "Payment Entry", "Journal Entry"]:
+ # Cancel Exchange Gain/Loss Journal before unlinking
+ cancel_exchange_gain_loss_journal(self)
+
if frappe.db.get_single_value("Accounts Settings", "unlink_payment_on_cancellation_of_invoice"):
unlink_ref_doc_from_payment_entries(self)
diff --git a/erpnext/controllers/status_updater.py b/erpnext/controllers/status_updater.py
index 58cab14..a4bc4a9 100644
--- a/erpnext/controllers/status_updater.py
+++ b/erpnext/controllers/status_updater.py
@@ -233,6 +233,9 @@
if hasattr(d, "qty") and d.qty > 0 and self.get("is_return"):
frappe.throw(_("For an item {0}, quantity must be negative number").format(d.item_code))
+ if hasattr(d, "item_code") and hasattr(d, "rate") and d.rate < 0:
+ frappe.throw(_("For an item {0}, rate must be a positive number").format(d.item_code))
+
if d.doctype == args["source_dt"] and d.get(args["join_field"]):
args["name"] = d.get(args["join_field"])
diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py
index caf4b6f..d669abe 100644
--- a/erpnext/controllers/stock_controller.py
+++ b/erpnext/controllers/stock_controller.py
@@ -15,7 +15,7 @@
make_reverse_gl_entries,
process_gl_map,
)
-from erpnext.accounts.utils import get_fiscal_year
+from erpnext.accounts.utils import cancel_exchange_gain_loss_journal, get_fiscal_year
from erpnext.controllers.accounts_controller import AccountsController
from erpnext.stock import get_warehouse_account_map
from erpnext.stock.doctype.inventory_dimension.inventory_dimension import (
@@ -534,6 +534,7 @@
make_sl_entries(sl_entries, allow_negative_stock, via_landed_cost_voucher)
def make_gl_entries_on_cancel(self):
+ cancel_exchange_gain_loss_journal(frappe._dict(doctype=self.doctype, name=self.name))
if frappe.db.sql(
"""select name from `tabGL Entry` where voucher_type=%s
and voucher_no=%s""",
diff --git a/erpnext/controllers/tests/test_accounts_controller.py b/erpnext/controllers/tests/test_accounts_controller.py
new file mode 100644
index 0000000..8e5f813
--- /dev/null
+++ b/erpnext/controllers/tests/test_accounts_controller.py
@@ -0,0 +1,980 @@
+# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors
+# For license information, please see license.txt
+
+import unittest
+
+import frappe
+from frappe import qb
+from frappe.query_builder.functions import Sum
+from frappe.tests.utils import FrappeTestCase, change_settings
+from frappe.utils import add_days, flt, nowdate
+
+from erpnext import get_default_cost_center
+from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
+from erpnext.accounts.doctype.payment_entry.test_payment_entry import create_payment_entry
+from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
+from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
+from erpnext.accounts.party import get_party_account
+from erpnext.stock.doctype.item.test_item import create_item
+
+
+def make_customer(customer_name, currency=None):
+ if not frappe.db.exists("Customer", customer_name):
+ customer = frappe.new_doc("Customer")
+ customer.customer_name = customer_name
+ customer.customer_type = "Individual"
+
+ if currency:
+ customer.default_currency = currency
+ customer.save()
+ return customer.name
+ else:
+ return customer_name
+
+
+def make_supplier(supplier_name, currency=None):
+ if not frappe.db.exists("Supplier", supplier_name):
+ supplier = frappe.new_doc("Supplier")
+ supplier.supplier_name = supplier_name
+ supplier.supplier_type = "Individual"
+ supplier.supplier_group = "All Supplier Groups"
+
+ if currency:
+ supplier.default_currency = currency
+ supplier.save()
+ return supplier.name
+ else:
+ return supplier_name
+
+
+class TestAccountsController(FrappeTestCase):
+ """
+ Test Exchange Gain/Loss booking on various scenarios.
+ Test Cases are numbered for better organization
+
+ 10 series - Sales Invoice against Payment Entries
+ 20 series - Sales Invoice against Journals
+ 30 series - Sales Invoice against Credit Notes
+ """
+
+ def setUp(self):
+ self.create_company()
+ self.create_account()
+ self.create_item()
+ self.create_parties()
+ self.clear_old_entries()
+
+ def tearDown(self):
+ frappe.db.rollback()
+
+ def create_company(self):
+ company_name = "_Test Company"
+ self.company_abbr = abbr = "_TC"
+ if frappe.db.exists("Company", company_name):
+ company = frappe.get_doc("Company", company_name)
+ else:
+ company = frappe.get_doc(
+ {
+ "doctype": "Company",
+ "company_name": company_name,
+ "country": "India",
+ "default_currency": "INR",
+ "create_chart_of_accounts_based_on": "Standard Template",
+ "chart_of_accounts": "Standard",
+ }
+ )
+ company = company.save()
+
+ self.company = company.name
+ self.cost_center = company.cost_center
+ self.warehouse = "Stores - " + abbr
+ self.finished_warehouse = "Finished Goods - " + abbr
+ self.income_account = "Sales - " + abbr
+ self.expense_account = "Cost of Goods Sold - " + abbr
+ self.debit_to = "Debtors - " + abbr
+ self.debit_usd = "Debtors USD - " + abbr
+ self.cash = "Cash - " + abbr
+ self.creditors = "Creditors - " + abbr
+
+ def create_item(self):
+ item = create_item(
+ item_code="_Test Notebook", is_stock_item=0, company=self.company, warehouse=self.warehouse
+ )
+ self.item = item if isinstance(item, str) else item.item_code
+
+ def create_parties(self):
+ self.create_customer()
+ self.create_supplier()
+
+ def create_customer(self):
+ self.customer = make_customer("_Test MC Customer USD", "USD")
+
+ def create_supplier(self):
+ self.supplier = make_supplier("_Test MC Supplier USD", "USD")
+
+ def create_account(self):
+ account_name = "Debtors USD"
+ if not frappe.db.get_value(
+ "Account", filters={"account_name": account_name, "company": self.company}
+ ):
+ acc = frappe.new_doc("Account")
+ acc.account_name = account_name
+ acc.parent_account = "Accounts Receivable - " + self.company_abbr
+ acc.company = self.company
+ acc.account_currency = "USD"
+ acc.account_type = "Receivable"
+ acc.insert()
+ else:
+ name = frappe.db.get_value(
+ "Account",
+ filters={"account_name": account_name, "company": self.company},
+ fieldname="name",
+ pluck=True,
+ )
+ acc = frappe.get_doc("Account", name)
+ self.debtors_usd = acc.name
+
+ def create_sales_invoice(
+ self,
+ qty=1,
+ rate=1,
+ conversion_rate=80,
+ posting_date=nowdate(),
+ do_not_save=False,
+ do_not_submit=False,
+ ):
+ """
+ Helper function to populate default values in sales invoice
+ """
+ sinv = create_sales_invoice(
+ qty=qty,
+ rate=rate,
+ company=self.company,
+ customer=self.customer,
+ item_code=self.item,
+ item_name=self.item,
+ cost_center=self.cost_center,
+ warehouse=self.warehouse,
+ debit_to=self.debit_usd,
+ parent_cost_center=self.cost_center,
+ update_stock=0,
+ currency="USD",
+ conversion_rate=conversion_rate,
+ is_pos=0,
+ is_return=0,
+ return_against=None,
+ income_account=self.income_account,
+ expense_account=self.expense_account,
+ do_not_save=do_not_save,
+ do_not_submit=do_not_submit,
+ )
+ return sinv
+
+ def create_payment_entry(
+ self, amount=1, source_exc_rate=75, posting_date=nowdate(), customer=None
+ ):
+ """
+ Helper function to populate default values in payment entry
+ """
+ payment = create_payment_entry(
+ company=self.company,
+ payment_type="Receive",
+ party_type="Customer",
+ party=customer or self.customer,
+ paid_from=self.debit_usd,
+ paid_to=self.cash,
+ paid_amount=amount,
+ )
+ payment.source_exchange_rate = source_exc_rate
+ payment.received_amount = source_exc_rate * amount
+ payment.posting_date = posting_date
+ return payment
+
+ def clear_old_entries(self):
+ doctype_list = [
+ "GL Entry",
+ "Payment Ledger Entry",
+ "Sales Invoice",
+ "Purchase Invoice",
+ "Payment Entry",
+ "Journal Entry",
+ ]
+ for doctype in doctype_list:
+ qb.from_(qb.DocType(doctype)).delete().where(qb.DocType(doctype).company == self.company).run()
+
+ def create_payment_reconciliation(self):
+ pr = frappe.new_doc("Payment Reconciliation")
+ pr.company = self.company
+ pr.party_type = "Customer"
+ pr.party = self.customer
+ pr.receivable_payable_account = get_party_account(pr.party_type, pr.party, pr.company)
+ pr.from_invoice_date = pr.to_invoice_date = pr.from_payment_date = pr.to_payment_date = nowdate()
+ return pr
+
+ def create_journal_entry(
+ self,
+ acc1=None,
+ acc1_exc_rate=None,
+ acc2_exc_rate=None,
+ acc2=None,
+ acc1_amount=0,
+ acc2_amount=0,
+ posting_date=None,
+ cost_center=None,
+ ):
+ je = frappe.new_doc("Journal Entry")
+ je.posting_date = posting_date or nowdate()
+ je.company = self.company
+ je.user_remark = "test"
+ je.multi_currency = True
+ if not cost_center:
+ cost_center = self.cost_center
+ je.set(
+ "accounts",
+ [
+ {
+ "account": acc1,
+ "exchange_rate": acc1_exc_rate or 1,
+ "cost_center": cost_center,
+ "debit_in_account_currency": acc1_amount if acc1_amount > 0 else 0,
+ "credit_in_account_currency": abs(acc1_amount) if acc1_amount < 0 else 0,
+ "debit": acc1_amount * acc1_exc_rate if acc1_amount > 0 else 0,
+ "credit": abs(acc1_amount * acc1_exc_rate) if acc1_amount < 0 else 0,
+ },
+ {
+ "account": acc2,
+ "exchange_rate": acc2_exc_rate or 1,
+ "cost_center": cost_center,
+ "credit_in_account_currency": acc2_amount if acc2_amount > 0 else 0,
+ "debit_in_account_currency": abs(acc2_amount) if acc2_amount < 0 else 0,
+ "credit": acc2_amount * acc2_exc_rate if acc2_amount > 0 else 0,
+ "debit": abs(acc2_amount * acc2_exc_rate) if acc2_amount < 0 else 0,
+ },
+ ],
+ )
+ return je
+
+ def get_journals_for(self, voucher_type: str, voucher_no: str) -> list:
+ journals = []
+ if voucher_type and voucher_no:
+ journals = frappe.db.get_all(
+ "Journal Entry Account",
+ filters={"reference_type": voucher_type, "reference_name": voucher_no, "docstatus": 1},
+ fields=["parent"],
+ )
+ return journals
+
+ def assert_ledger_outstanding(
+ self,
+ voucher_type: str,
+ voucher_no: str,
+ outstanding: float,
+ outstanding_in_account_currency: float,
+ ) -> None:
+ """
+ Assert outstanding amount based on ledger on both company/base currency and account currency
+ """
+
+ ple = qb.DocType("Payment Ledger Entry")
+ current_outstanding = (
+ qb.from_(ple)
+ .select(
+ Sum(ple.amount).as_("outstanding"),
+ Sum(ple.amount_in_account_currency).as_("outstanding_in_account_currency"),
+ )
+ .where(
+ (ple.against_voucher_type == voucher_type)
+ & (ple.against_voucher_no == voucher_no)
+ & (ple.delinked == 0)
+ )
+ .run(as_dict=True)[0]
+ )
+ self.assertEqual(outstanding, current_outstanding.outstanding)
+ self.assertEqual(
+ outstanding_in_account_currency, current_outstanding.outstanding_in_account_currency
+ )
+
+ def test_10_payment_against_sales_invoice(self):
+ # Sales Invoice in Foreign Currency
+ rate = 80
+ rate_in_account_currency = 1
+
+ si = self.create_sales_invoice(qty=1, rate=rate_in_account_currency)
+
+ # Test payments with different exchange rates
+ for exc_rate in [75.9, 83.1, 80.01]:
+ with self.subTest(exc_rate=exc_rate):
+ pe = self.create_payment_entry(amount=1, source_exc_rate=exc_rate).save()
+ pe.append(
+ "references",
+ {"reference_doctype": si.doctype, "reference_name": si.name, "allocated_amount": 1},
+ )
+ pe = pe.save().submit()
+
+ # Outstanding in both currencies should be '0'
+ si.reload()
+ self.assertEqual(si.outstanding_amount, 0)
+ self.assert_ledger_outstanding(si.doctype, si.name, 0.0, 0.0)
+
+ # Exchange Gain/Loss Journal should've been created.
+ exc_je_for_si = self.get_journals_for(si.doctype, si.name)
+ exc_je_for_pe = self.get_journals_for(pe.doctype, pe.name)
+ self.assertNotEqual(exc_je_for_si, [])
+ self.assertEqual(len(exc_je_for_si), 1)
+ self.assertEqual(len(exc_je_for_pe), 1)
+ self.assertEqual(exc_je_for_si[0], exc_je_for_pe[0])
+
+ # Cancel Payment
+ pe.cancel()
+
+ # outstanding should be same as grand total
+ si.reload()
+ self.assertEqual(si.outstanding_amount, rate_in_account_currency)
+ self.assert_ledger_outstanding(si.doctype, si.name, rate, rate_in_account_currency)
+
+ # Exchange Gain/Loss Journal should've been cancelled
+ exc_je_for_si = self.get_journals_for(si.doctype, si.name)
+ exc_je_for_pe = self.get_journals_for(pe.doctype, pe.name)
+ self.assertEqual(exc_je_for_si, [])
+ self.assertEqual(exc_je_for_pe, [])
+
+ def test_11_advance_against_sales_invoice(self):
+ # Advance Payment
+ adv = self.create_payment_entry(amount=1, source_exc_rate=85).save().submit()
+ adv.reload()
+
+ # Sales Invoices in different exchange rates
+ for exc_rate in [75.9, 83.1, 80.01]:
+ with self.subTest(exc_rate=exc_rate):
+ si = self.create_sales_invoice(qty=1, conversion_rate=exc_rate, rate=1, do_not_submit=True)
+ si.append(
+ "advances",
+ {
+ "doctype": "Sales Invoice Advance",
+ "reference_type": adv.doctype,
+ "reference_name": adv.name,
+ "advance_amount": 1,
+ "allocated_amount": 1,
+ "ref_exchange_rate": 85,
+ "remarks": "Test",
+ },
+ )
+ si = si.save()
+ si = si.submit()
+
+ # Outstanding in both currencies should be '0'
+ adv.reload()
+ self.assertEqual(si.outstanding_amount, 0)
+ self.assert_ledger_outstanding(si.doctype, si.name, 0.0, 0.0)
+
+ # Exchange Gain/Loss Journal should've been created.
+ exc_je_for_si = self.get_journals_for(si.doctype, si.name)
+ exc_je_for_adv = self.get_journals_for(adv.doctype, adv.name)
+ self.assertNotEqual(exc_je_for_si, [])
+ self.assertEqual(len(exc_je_for_si), 1)
+ self.assertEqual(len(exc_je_for_adv), 1)
+ self.assertEqual(exc_je_for_si, exc_je_for_adv)
+
+ # Cancel Invoice
+ si.cancel()
+
+ # Exchange Gain/Loss Journal should've been cancelled
+ exc_je_for_si = self.get_journals_for(si.doctype, si.name)
+ exc_je_for_adv = self.get_journals_for(adv.doctype, adv.name)
+ self.assertEqual(exc_je_for_si, [])
+ self.assertEqual(exc_je_for_adv, [])
+
+ def test_12_partial_advance_and_payment_for_sales_invoice(self):
+ """
+ Sales invoice with partial advance payment, and a normal payment reconciled
+ """
+ # Partial Advance
+ adv = self.create_payment_entry(amount=1, source_exc_rate=85).save().submit()
+ adv.reload()
+
+ # sales invoice with advance(partial amount)
+ rate = 80
+ rate_in_account_currency = 1
+ si = self.create_sales_invoice(
+ qty=2, conversion_rate=80, rate=rate_in_account_currency, do_not_submit=True
+ )
+ si.append(
+ "advances",
+ {
+ "doctype": "Sales Invoice Advance",
+ "reference_type": adv.doctype,
+ "reference_name": adv.name,
+ "advance_amount": 1,
+ "allocated_amount": 1,
+ "ref_exchange_rate": 85,
+ "remarks": "Test",
+ },
+ )
+ si = si.save()
+ si = si.submit()
+
+ # Outstanding should be there in both currencies
+ si.reload()
+ self.assertEqual(si.outstanding_amount, 1) # account currency
+ self.assert_ledger_outstanding(si.doctype, si.name, 80.0, 1.0)
+
+ # Exchange Gain/Loss Journal should've been created for the partial advance
+ exc_je_for_si = self.get_journals_for(si.doctype, si.name)
+ exc_je_for_adv = self.get_journals_for(adv.doctype, adv.name)
+ self.assertNotEqual(exc_je_for_si, [])
+ self.assertEqual(len(exc_je_for_si), 1)
+ self.assertEqual(len(exc_je_for_adv), 1)
+ self.assertEqual(exc_je_for_si, exc_je_for_adv)
+
+ # Payment for remaining amount
+ pe = self.create_payment_entry(amount=1, source_exc_rate=75).save()
+ pe.append(
+ "references",
+ {"reference_doctype": si.doctype, "reference_name": si.name, "allocated_amount": 1},
+ )
+ pe = pe.save().submit()
+
+ # Outstanding in both currencies should be '0'
+ si.reload()
+ self.assertEqual(si.outstanding_amount, 0)
+ self.assert_ledger_outstanding(si.doctype, si.name, 0.0, 0.0)
+
+ # Exchange Gain/Loss Journal should've been created for the payment
+ exc_je_for_si = self.get_journals_for(si.doctype, si.name)
+ exc_je_for_pe = self.get_journals_for(pe.doctype, pe.name)
+ self.assertNotEqual(exc_je_for_si, [])
+ # There should be 2 JE's now. One for the advance and one for the payment
+ self.assertEqual(len(exc_je_for_si), 2)
+ self.assertEqual(len(exc_je_for_pe), 1)
+ self.assertEqual(exc_je_for_si, exc_je_for_pe + exc_je_for_adv)
+
+ # Cancel Invoice
+ si.reload()
+ si.cancel()
+
+ # Exchange Gain/Loss Journal should been cancelled
+ exc_je_for_si = self.get_journals_for(si.doctype, si.name)
+ exc_je_for_pe = self.get_journals_for(pe.doctype, pe.name)
+ exc_je_for_adv = self.get_journals_for(adv.doctype, adv.name)
+ self.assertEqual(exc_je_for_si, [])
+ self.assertEqual(exc_je_for_pe, [])
+ self.assertEqual(exc_je_for_adv, [])
+
+ def test_13_partial_advance_and_payment_for_invoice_with_cancellation(self):
+ """
+ Invoice with partial advance payment, and a normal payment. Then cancel advance and payment.
+ """
+ # Partial Advance
+ adv = self.create_payment_entry(amount=1, source_exc_rate=85).save().submit()
+ adv.reload()
+
+ # invoice with advance(partial amount)
+ si = self.create_sales_invoice(qty=2, conversion_rate=80, rate=1, do_not_submit=True)
+ si.append(
+ "advances",
+ {
+ "doctype": "Sales Invoice Advance",
+ "reference_type": adv.doctype,
+ "reference_name": adv.name,
+ "advance_amount": 1,
+ "allocated_amount": 1,
+ "ref_exchange_rate": 85,
+ "remarks": "Test",
+ },
+ )
+ si = si.save()
+ si = si.submit()
+
+ # Outstanding should be there in both currencies
+ si.reload()
+ self.assertEqual(si.outstanding_amount, 1) # account currency
+ self.assert_ledger_outstanding(si.doctype, si.name, 80.0, 1.0)
+
+ # Exchange Gain/Loss Journal should've been created for the partial advance
+ exc_je_for_si = self.get_journals_for(si.doctype, si.name)
+ exc_je_for_adv = self.get_journals_for(adv.doctype, adv.name)
+ self.assertNotEqual(exc_je_for_si, [])
+ self.assertEqual(len(exc_je_for_si), 1)
+ self.assertEqual(len(exc_je_for_adv), 1)
+ self.assertEqual(exc_je_for_si, exc_je_for_adv)
+
+ # Payment(remaining amount)
+ pe = self.create_payment_entry(amount=1, source_exc_rate=75).save()
+ pe.append(
+ "references",
+ {"reference_doctype": si.doctype, "reference_name": si.name, "allocated_amount": 1},
+ )
+ pe = pe.save().submit()
+
+ # Outstanding should be '0' in both currencies
+ si.reload()
+ self.assertEqual(si.outstanding_amount, 0)
+ self.assert_ledger_outstanding(si.doctype, si.name, 0.0, 0.0)
+
+ # Exchange Gain/Loss Journal should've been created for the payment
+ exc_je_for_si = self.get_journals_for(si.doctype, si.name)
+ exc_je_for_pe = self.get_journals_for(pe.doctype, pe.name)
+ self.assertNotEqual(exc_je_for_si, [])
+ # There should be 2 JE's now. One for the advance and one for the payment
+ self.assertEqual(len(exc_je_for_si), 2)
+ self.assertEqual(len(exc_je_for_pe), 1)
+ self.assertEqual(exc_je_for_si, exc_je_for_pe + exc_je_for_adv)
+
+ adv.reload()
+ adv.cancel()
+
+ # Outstanding should be there in both currencies, since advance is cancelled.
+ si.reload()
+ self.assertEqual(si.outstanding_amount, 1) # account currency
+ self.assert_ledger_outstanding(si.doctype, si.name, 80.0, 1.0)
+
+ exc_je_for_si = self.get_journals_for(si.doctype, si.name)
+ exc_je_for_pe = self.get_journals_for(pe.doctype, pe.name)
+ exc_je_for_adv = self.get_journals_for(adv.doctype, adv.name)
+ # Exchange Gain/Loss Journal for advance should been cancelled
+ self.assertEqual(len(exc_je_for_si), 1)
+ self.assertEqual(len(exc_je_for_pe), 1)
+ self.assertEqual(exc_je_for_adv, [])
+
+ def test_14_same_payment_split_against_invoice(self):
+ # Invoice in Foreign Currency
+ si = self.create_sales_invoice(qty=2, conversion_rate=80, rate=1)
+ # Payment
+ pe = self.create_payment_entry(amount=2, source_exc_rate=75).save()
+ pe.append(
+ "references",
+ {"reference_doctype": si.doctype, "reference_name": si.name, "allocated_amount": 1},
+ )
+ pe = pe.save().submit()
+
+ # There should be outstanding in both currencies
+ si.reload()
+ self.assertEqual(si.outstanding_amount, 1)
+ self.assert_ledger_outstanding(si.doctype, si.name, 80.0, 1.0)
+
+ # Exchange Gain/Loss Journal should've been created.
+ exc_je_for_si = self.get_journals_for(si.doctype, si.name)
+ exc_je_for_pe = self.get_journals_for(pe.doctype, pe.name)
+ self.assertNotEqual(exc_je_for_si, [])
+ self.assertEqual(len(exc_je_for_si), 1)
+ self.assertEqual(len(exc_je_for_pe), 1)
+ self.assertEqual(exc_je_for_si[0], exc_je_for_pe[0])
+
+ # Reconcile the remaining amount
+ pr = frappe.get_doc("Payment Reconciliation")
+ pr.company = self.company
+ pr.party_type = "Customer"
+ pr.party = self.customer
+ pr.receivable_payable_account = self.debit_usd
+ pr.get_unreconciled_entries()
+ self.assertEqual(len(pr.invoices), 1)
+ self.assertEqual(len(pr.payments), 1)
+ invoices = [x.as_dict() for x in pr.invoices]
+ payments = [x.as_dict() for x in pr.payments]
+ pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments}))
+ pr.reconcile()
+ self.assertEqual(len(pr.invoices), 0)
+ self.assertEqual(len(pr.payments), 0)
+
+ # Exc gain/loss journal should have been creaetd for the reconciled amount
+ exc_je_for_si = self.get_journals_for(si.doctype, si.name)
+ exc_je_for_pe = self.get_journals_for(pe.doctype, pe.name)
+ self.assertEqual(len(exc_je_for_si), 2)
+ self.assertEqual(len(exc_je_for_pe), 2)
+ self.assertEqual(exc_je_for_si, exc_je_for_pe)
+
+ # There should be no outstanding
+ si.reload()
+ self.assertEqual(si.outstanding_amount, 0)
+ self.assert_ledger_outstanding(si.doctype, si.name, 0.0, 0.0)
+
+ # Cancel Payment
+ pe.reload()
+ pe.cancel()
+
+ si.reload()
+ self.assertEqual(si.outstanding_amount, 2)
+ self.assert_ledger_outstanding(si.doctype, si.name, 160.0, 2.0)
+
+ # Exchange Gain/Loss Journal should've been cancelled
+ exc_je_for_si = self.get_journals_for(si.doctype, si.name)
+ exc_je_for_pe = self.get_journals_for(pe.doctype, pe.name)
+ self.assertEqual(exc_je_for_si, [])
+ self.assertEqual(exc_je_for_pe, [])
+
+ def test_20_journal_against_sales_invoice(self):
+ # Invoice in Foreign Currency
+ si = self.create_sales_invoice(qty=1, conversion_rate=80, rate=1)
+ # Payment
+ je = self.create_journal_entry(
+ acc1=self.debit_usd,
+ acc1_exc_rate=75,
+ acc2=self.cash,
+ acc1_amount=-1,
+ acc2_amount=-75,
+ acc2_exc_rate=1,
+ )
+ je.accounts[0].party_type = "Customer"
+ je.accounts[0].party = self.customer
+ je = je.save().submit()
+
+ # Reconcile the remaining amount
+ pr = self.create_payment_reconciliation()
+ # pr.receivable_payable_account = self.debit_usd
+ pr.get_unreconciled_entries()
+ self.assertEqual(len(pr.invoices), 1)
+ self.assertEqual(len(pr.payments), 1)
+ invoices = [x.as_dict() for x in pr.invoices]
+ payments = [x.as_dict() for x in pr.payments]
+ pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments}))
+ pr.reconcile()
+ self.assertEqual(len(pr.invoices), 0)
+ self.assertEqual(len(pr.payments), 0)
+
+ # There should be no outstanding in both currencies
+ si.reload()
+ self.assertEqual(si.outstanding_amount, 0)
+ self.assert_ledger_outstanding(si.doctype, si.name, 0.0, 0.0)
+
+ # Exchange Gain/Loss Journal should've been created.
+ exc_je_for_si = self.get_journals_for(si.doctype, si.name)
+ exc_je_for_je = self.get_journals_for(je.doctype, je.name)
+ self.assertNotEqual(exc_je_for_si, [])
+ self.assertEqual(
+ len(exc_je_for_si), 2
+ ) # payment also has reference. so, there are 2 journals referencing invoice
+ self.assertEqual(len(exc_je_for_je), 1)
+ self.assertIn(exc_je_for_je[0], exc_je_for_si)
+
+ # Cancel Payment
+ je.reload()
+ je.cancel()
+
+ si.reload()
+ self.assertEqual(si.outstanding_amount, 1)
+ self.assert_ledger_outstanding(si.doctype, si.name, 80.0, 1.0)
+
+ # Exchange Gain/Loss Journal should've been cancelled
+ exc_je_for_si = self.get_journals_for(si.doctype, si.name)
+ exc_je_for_je = self.get_journals_for(je.doctype, je.name)
+ self.assertEqual(exc_je_for_si, [])
+ self.assertEqual(exc_je_for_je, [])
+
+ def test_21_advance_journal_against_sales_invoice(self):
+ # Advance Payment
+ adv_exc_rate = 80
+ adv = self.create_journal_entry(
+ acc1=self.debit_usd,
+ acc1_exc_rate=adv_exc_rate,
+ acc2=self.cash,
+ acc1_amount=-1,
+ acc2_amount=adv_exc_rate * -1,
+ acc2_exc_rate=1,
+ )
+ adv.accounts[0].party_type = "Customer"
+ adv.accounts[0].party = self.customer
+ adv.accounts[0].is_advance = "Yes"
+ adv = adv.save().submit()
+ adv.reload()
+
+ # Sales Invoices in different exchange rates
+ for exc_rate in [75.9, 83.1, 80.01]:
+ with self.subTest(exc_rate=exc_rate):
+ si = self.create_sales_invoice(qty=1, conversion_rate=exc_rate, rate=1, do_not_submit=True)
+ si.append(
+ "advances",
+ {
+ "doctype": "Sales Invoice Advance",
+ "reference_type": adv.doctype,
+ "reference_name": adv.name,
+ "reference_row": adv.accounts[0].name,
+ "advance_amount": 1,
+ "allocated_amount": 1,
+ "ref_exchange_rate": adv_exc_rate,
+ "remarks": "Test",
+ },
+ )
+ si = si.save()
+ si = si.submit()
+
+ # Outstanding in both currencies should be '0'
+ adv.reload()
+ self.assertEqual(si.outstanding_amount, 0)
+ self.assert_ledger_outstanding(si.doctype, si.name, 0.0, 0.0)
+
+ # Exchange Gain/Loss Journal should've been created.
+ exc_je_for_si = [x for x in self.get_journals_for(si.doctype, si.name) if x.parent != adv.name]
+ exc_je_for_adv = self.get_journals_for(adv.doctype, adv.name)
+ self.assertNotEqual(exc_je_for_si, [])
+ self.assertEqual(len(exc_je_for_si), 1)
+ self.assertEqual(len(exc_je_for_adv), 1)
+ self.assertEqual(exc_je_for_si, exc_je_for_adv)
+
+ # Cancel Invoice
+ si.cancel()
+
+ # Exchange Gain/Loss Journal should've been cancelled
+ exc_je_for_si = self.get_journals_for(si.doctype, si.name)
+ exc_je_for_adv = self.get_journals_for(adv.doctype, adv.name)
+ self.assertEqual(exc_je_for_si, [])
+ self.assertEqual(exc_je_for_adv, [])
+
+ def test_22_partial_advance_and_payment_for_invoice_with_cancellation(self):
+ """
+ Invoice with partial advance payment as Journal, and a normal payment. Then cancel advance and payment.
+ """
+ # Partial Advance
+ adv_exc_rate = 75
+ adv = self.create_journal_entry(
+ acc1=self.debit_usd,
+ acc1_exc_rate=adv_exc_rate,
+ acc2=self.cash,
+ acc1_amount=-1,
+ acc2_amount=adv_exc_rate * -1,
+ acc2_exc_rate=1,
+ )
+ adv.accounts[0].party_type = "Customer"
+ adv.accounts[0].party = self.customer
+ adv.accounts[0].is_advance = "Yes"
+ adv = adv.save().submit()
+ adv.reload()
+
+ # invoice with advance(partial amount)
+ si = self.create_sales_invoice(qty=3, conversion_rate=80, rate=1, do_not_submit=True)
+ si.append(
+ "advances",
+ {
+ "doctype": "Sales Invoice Advance",
+ "reference_type": adv.doctype,
+ "reference_name": adv.name,
+ "reference_row": adv.accounts[0].name,
+ "advance_amount": 1,
+ "allocated_amount": 1,
+ "ref_exchange_rate": adv_exc_rate,
+ "remarks": "Test",
+ },
+ )
+ si = si.save()
+ si = si.submit()
+
+ # Outstanding should be there in both currencies
+ si.reload()
+ self.assertEqual(si.outstanding_amount, 2) # account currency
+ self.assert_ledger_outstanding(si.doctype, si.name, 160.0, 2.0)
+
+ # Exchange Gain/Loss Journal should've been created for the partial advance
+ exc_je_for_si = [x for x in self.get_journals_for(si.doctype, si.name) if x.parent != adv.name]
+ exc_je_for_adv = self.get_journals_for(adv.doctype, adv.name)
+ self.assertNotEqual(exc_je_for_si, [])
+ self.assertEqual(len(exc_je_for_si), 1)
+ self.assertEqual(len(exc_je_for_adv), 1)
+ self.assertEqual(exc_je_for_si, exc_je_for_adv)
+
+ # Payment
+ adv2_exc_rate = 83
+ pay = self.create_journal_entry(
+ acc1=self.debit_usd,
+ acc1_exc_rate=adv2_exc_rate,
+ acc2=self.cash,
+ acc1_amount=-2,
+ acc2_amount=adv2_exc_rate * -2,
+ acc2_exc_rate=1,
+ )
+ pay.accounts[0].party_type = "Customer"
+ pay.accounts[0].party = self.customer
+ pay.accounts[0].is_advance = "Yes"
+ pay = pay.save().submit()
+ pay.reload()
+
+ # Reconcile the remaining amount
+ pr = self.create_payment_reconciliation()
+ # pr.receivable_payable_account = self.debit_usd
+ pr.get_unreconciled_entries()
+ self.assertEqual(len(pr.invoices), 1)
+ self.assertEqual(len(pr.payments), 1)
+ invoices = [x.as_dict() for x in pr.invoices]
+ payments = [x.as_dict() for x in pr.payments]
+ pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments}))
+ pr.reconcile()
+ self.assertEqual(len(pr.invoices), 0)
+ self.assertEqual(len(pr.payments), 0)
+
+ # Outstanding should be '0' in both currencies
+ si.reload()
+ self.assertEqual(si.outstanding_amount, 0)
+ self.assert_ledger_outstanding(si.doctype, si.name, 0.0, 0.0)
+
+ # Exchange Gain/Loss Journal should've been created for the payment
+ exc_je_for_si = [
+ x
+ for x in self.get_journals_for(si.doctype, si.name)
+ if x.parent != adv.name and x.parent != pay.name
+ ]
+ exc_je_for_pe = self.get_journals_for(pay.doctype, pay.name)
+ self.assertNotEqual(exc_je_for_si, [])
+ # There should be 2 JE's now. One for the advance and one for the payment
+ self.assertEqual(len(exc_je_for_si), 2)
+ self.assertEqual(len(exc_je_for_pe), 1)
+ self.assertEqual(exc_je_for_si, exc_je_for_pe + exc_je_for_adv)
+
+ adv.reload()
+ adv.cancel()
+
+ # Outstanding should be there in both currencies, since advance is cancelled.
+ si.reload()
+ self.assertEqual(si.outstanding_amount, 1) # account currency
+ self.assert_ledger_outstanding(si.doctype, si.name, 80.0, 1.0)
+
+ exc_je_for_si = [
+ x
+ for x in self.get_journals_for(si.doctype, si.name)
+ if x.parent != adv.name and x.parent != pay.name
+ ]
+ exc_je_for_pe = self.get_journals_for(pay.doctype, pay.name)
+ exc_je_for_adv = self.get_journals_for(adv.doctype, adv.name)
+ # Exchange Gain/Loss Journal for advance should been cancelled
+ self.assertEqual(len(exc_je_for_si), 1)
+ self.assertEqual(len(exc_je_for_pe), 1)
+ self.assertEqual(exc_je_for_adv, [])
+
+ def test_23_same_journal_split_against_single_invoice(self):
+ # Invoice in Foreign Currency
+ si = self.create_sales_invoice(qty=2, conversion_rate=80, rate=1)
+ # Payment
+ je = self.create_journal_entry(
+ acc1=self.debit_usd,
+ acc1_exc_rate=75,
+ acc2=self.cash,
+ acc1_amount=-2,
+ acc2_amount=-150,
+ acc2_exc_rate=1,
+ )
+ je.accounts[0].party_type = "Customer"
+ je.accounts[0].party = self.customer
+ je = je.save().submit()
+
+ # Reconcile the first half
+ pr = self.create_payment_reconciliation()
+ pr.get_unreconciled_entries()
+ self.assertEqual(len(pr.invoices), 1)
+ self.assertEqual(len(pr.payments), 1)
+ invoices = [x.as_dict() for x in pr.invoices]
+ payments = [x.as_dict() for x in pr.payments]
+ pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments}))
+ difference_amount = pr.calculate_difference_on_allocation_change(
+ [x.as_dict() for x in pr.payments], [x.as_dict() for x in pr.invoices], 1
+ )
+ pr.allocation[0].allocated_amount = 1
+ pr.allocation[0].difference_amount = difference_amount
+ pr.reconcile()
+ self.assertEqual(len(pr.invoices), 1)
+ self.assertEqual(len(pr.payments), 1)
+
+ # There should be outstanding in both currencies
+ si.reload()
+ self.assertEqual(si.outstanding_amount, 1)
+ self.assert_ledger_outstanding(si.doctype, si.name, 80.0, 1.0)
+
+ # Exchange Gain/Loss Journal should've been created.
+ exc_je_for_si = [x for x in self.get_journals_for(si.doctype, si.name) if x.parent != je.name]
+ exc_je_for_je = self.get_journals_for(je.doctype, je.name)
+ self.assertNotEqual(exc_je_for_si, [])
+ self.assertEqual(len(exc_je_for_si), 1)
+ self.assertEqual(len(exc_je_for_je), 1)
+ self.assertIn(exc_je_for_je[0], exc_je_for_si)
+
+ # reconcile remaining half
+ pr.get_unreconciled_entries()
+ self.assertEqual(len(pr.invoices), 1)
+ self.assertEqual(len(pr.payments), 1)
+ invoices = [x.as_dict() for x in pr.invoices]
+ payments = [x.as_dict() for x in pr.payments]
+ pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments}))
+ pr.allocation[0].allocated_amount = 1
+ pr.allocation[0].difference_amount = difference_amount
+ pr.reconcile()
+ self.assertEqual(len(pr.invoices), 0)
+ self.assertEqual(len(pr.payments), 0)
+
+ # Exchange Gain/Loss Journal should've been created.
+ exc_je_for_si = [x for x in self.get_journals_for(si.doctype, si.name) if x.parent != je.name]
+ exc_je_for_je = self.get_journals_for(je.doctype, je.name)
+ self.assertNotEqual(exc_je_for_si, [])
+ self.assertEqual(len(exc_je_for_si), 2)
+ self.assertEqual(len(exc_je_for_je), 2)
+ self.assertIn(exc_je_for_je[0], exc_je_for_si)
+
+ si.reload()
+ self.assertEqual(si.outstanding_amount, 0)
+ self.assert_ledger_outstanding(si.doctype, si.name, 0.0, 0.0)
+
+ # Cancel Payment
+ je.reload()
+ je.cancel()
+
+ si.reload()
+ self.assertEqual(si.outstanding_amount, 2)
+ self.assert_ledger_outstanding(si.doctype, si.name, 160.0, 2.0)
+
+ # Exchange Gain/Loss Journal should've been cancelled
+ exc_je_for_si = self.get_journals_for(si.doctype, si.name)
+ exc_je_for_je = self.get_journals_for(je.doctype, je.name)
+ self.assertEqual(exc_je_for_si, [])
+ self.assertEqual(exc_je_for_je, [])
+
+ def test_30_cr_note_against_sales_invoice(self):
+ """
+ Reconciling Cr Note against Sales Invoice, both having different exchange rates
+ """
+ # Invoice in Foreign currency
+ si = self.create_sales_invoice(qty=2, conversion_rate=80, rate=1)
+
+ # Cr Note in Foreign currency of different exchange rate
+ cr_note = self.create_sales_invoice(qty=-2, conversion_rate=75, rate=1, do_not_save=True)
+ cr_note.is_return = 1
+ cr_note.save().submit()
+
+ # Reconcile the first half
+ pr = self.create_payment_reconciliation()
+ pr.get_unreconciled_entries()
+ self.assertEqual(len(pr.invoices), 1)
+ self.assertEqual(len(pr.payments), 1)
+ invoices = [x.as_dict() for x in pr.invoices]
+ payments = [x.as_dict() for x in pr.payments]
+ pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments}))
+ difference_amount = pr.calculate_difference_on_allocation_change(
+ [x.as_dict() for x in pr.payments], [x.as_dict() for x in pr.invoices], 1
+ )
+ pr.allocation[0].allocated_amount = 1
+ pr.allocation[0].difference_amount = difference_amount
+ pr.reconcile()
+ self.assertEqual(len(pr.invoices), 1)
+ self.assertEqual(len(pr.payments), 1)
+
+ # Exchange Gain/Loss Journal should've been created.
+ exc_je_for_si = self.get_journals_for(si.doctype, si.name)
+ exc_je_for_cr = self.get_journals_for(cr_note.doctype, cr_note.name)
+ self.assertNotEqual(exc_je_for_si, [])
+ self.assertEqual(len(exc_je_for_si), 2)
+ self.assertEqual(len(exc_je_for_cr), 2)
+ self.assertEqual(exc_je_for_cr, exc_je_for_si)
+
+ si.reload()
+ self.assertEqual(si.outstanding_amount, 1)
+ self.assert_ledger_outstanding(si.doctype, si.name, 80.0, 1.0)
+
+ cr_note.reload()
+ cr_note.cancel()
+
+ # Exchange Gain/Loss Journal should've been created.
+ exc_je_for_si = self.get_journals_for(si.doctype, si.name)
+ exc_je_for_cr = self.get_journals_for(cr_note.doctype, cr_note.name)
+ self.assertNotEqual(exc_je_for_si, [])
+ self.assertEqual(len(exc_je_for_si), 1)
+ self.assertEqual(len(exc_je_for_cr), 0)
+
+ # The Credit Note JE is still active and is referencing the sales invoice
+ # So, outstanding stays the same
+ si.reload()
+ self.assertEqual(si.outstanding_amount, 1)
+ self.assert_ledger_outstanding(si.doctype, si.name, 80.0, 1.0)
diff --git a/erpnext/patches.txt b/erpnext/patches.txt
index 0f4238c..641d755 100644
--- a/erpnext/patches.txt
+++ b/erpnext/patches.txt
@@ -320,6 +320,7 @@
erpnext.patches.v14_0.create_accounting_dimensions_for_closing_balance
erpnext.patches.v14_0.update_closing_balances #14-07-2023
execute:frappe.db.set_single_value("Accounts Settings", "merge_similar_account_heads", 0)
+erpnext.patches.v14_0.update_reference_type_in_journal_entry_accounts
# below migration patches should always run last
erpnext.patches.v14_0.migrate_gl_to_payment_ledger
execute:frappe.delete_doc_if_exists("Report", "Tax Detail")
diff --git a/erpnext/patches/v14_0/update_reference_type_in_journal_entry_accounts.py b/erpnext/patches/v14_0/update_reference_type_in_journal_entry_accounts.py
new file mode 100644
index 0000000..48b6bcf
--- /dev/null
+++ b/erpnext/patches/v14_0/update_reference_type_in_journal_entry_accounts.py
@@ -0,0 +1,22 @@
+import frappe
+
+
+def execute():
+ """
+ Update Propery Setters for Journal Entry with new 'Entry Type'
+ """
+ new_reference_type = "Payment Entry"
+ prop_setter = frappe.db.get_list(
+ "Property Setter",
+ filters={
+ "doc_type": "Journal Entry Account",
+ "field_name": "reference_type",
+ "property": "options",
+ },
+ )
+ if prop_setter:
+ property_setter_doc = frappe.get_doc("Property Setter", prop_setter[0].get("name"))
+
+ if new_reference_type not in property_setter_doc.value.split("\n"):
+ property_setter_doc.value += "\n" + new_reference_type
+ property_setter_doc.save()
diff --git a/erpnext/public/js/setup_wizard.js b/erpnext/public/js/setup_wizard.js
index a913844..934fd1f 100644
--- a/erpnext/public/js/setup_wizard.js
+++ b/erpnext/public/js/setup_wizard.js
@@ -24,12 +24,14 @@
fieldtype: 'Data',
reqd: 1
},
+ { fieldtype: "Column Break" },
{
fieldname: 'company_abbr',
label: __('Company Abbreviation'),
fieldtype: 'Data',
- hidden: 1
+ reqd: 1
},
+ { fieldtype: "Section Break" },
{
fieldname: 'chart_of_accounts', label: __('Chart of Accounts'),
options: "", fieldtype: 'Select'
@@ -134,18 +136,20 @@
me.charts_modal(slide, chart_template);
});
- slide.get_input("company_name").on("change", function () {
+ slide.get_input("company_name").on("input", function () {
let parts = slide.get_input("company_name").val().split(" ");
let abbr = $.map(parts, function (p) { return p ? p.substr(0, 1) : null }).join("");
slide.get_field("company_abbr").set_value(abbr.slice(0, 10).toUpperCase());
}).val(frappe.boot.sysdefaults.company_name || "").trigger("change");
slide.get_input("company_abbr").on("change", function () {
- if (slide.get_input("company_abbr").val().length > 10) {
+ let abbr = slide.get_input("company_abbr").val();
+ if (abbr.length > 10) {
frappe.msgprint(__("Company Abbreviation cannot have more than 5 characters"));
- slide.get_field("company_abbr").set_value("");
+ abbr = abbr.slice(0, 10);
}
- });
+ slide.get_field("company_abbr").set_value(abbr);
+ }).val(frappe.boot.sysdefaults.company_abbr || "").trigger("change");
},
charts_modal: function(slide, chart_template) {