diff --git a/erpnext/accounts/doctype/advance_payment_ledger_entry/__init__.py b/erpnext/accounts/doctype/advance_payment_ledger_entry/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/accounts/doctype/advance_payment_ledger_entry/advance_payment_ledger_entry.js b/erpnext/accounts/doctype/advance_payment_ledger_entry/advance_payment_ledger_entry.js new file mode 100644 index 00000000000..1a0dc1e7272 --- /dev/null +++ b/erpnext/accounts/doctype/advance_payment_ledger_entry/advance_payment_ledger_entry.js @@ -0,0 +1,8 @@ +// Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +// frappe.ui.form.on("Advance Payment Ledger Entry", { +// refresh(frm) { + +// }, +// }); diff --git a/erpnext/accounts/doctype/advance_payment_ledger_entry/advance_payment_ledger_entry.json b/erpnext/accounts/doctype/advance_payment_ledger_entry/advance_payment_ledger_entry.json new file mode 100644 index 00000000000..1d0a5b42a31 --- /dev/null +++ b/erpnext/accounts/doctype/advance_payment_ledger_entry/advance_payment_ledger_entry.json @@ -0,0 +1,97 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2024-10-16 16:57:12.085072", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "company", + "voucher_type", + "voucher_no", + "against_voucher_type", + "against_voucher_no", + "amount", + "currency", + "event" + ], + "fields": [ + { + "fieldname": "voucher_type", + "fieldtype": "Link", + "label": "Voucher Type", + "options": "DocType", + "read_only": 1 + }, + { + "fieldname": "voucher_no", + "fieldtype": "Dynamic Link", + "label": "Voucher No", + "options": "voucher_type", + "read_only": 1 + }, + { + "fieldname": "against_voucher_type", + "fieldtype": "Link", + "label": "Against Voucher Type", + "options": "DocType", + "read_only": 1 + }, + { + "fieldname": "against_voucher_no", + "fieldtype": "Dynamic Link", + "label": "Against Voucher No", + "options": "against_voucher_type", + "read_only": 1 + }, + { + "fieldname": "amount", + "fieldtype": "Currency", + "label": "Amount", + "read_only": 1 + }, + { + "fieldname": "currency", + "fieldtype": "Link", + "label": "Currency", + "options": "Currency", + "read_only": 1 + }, + { + "fieldname": "event", + "fieldtype": "Data", + "label": "Event", + "read_only": 1 + }, + { + "fieldname": "company", + "fieldtype": "Link", + "label": "Company", + "options": "Company", + "read_only": 1 + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2024-10-16 17:11:28.143979", + "modified_by": "Administrator", + "module": "Accounts", + "name": "Advance Payment Ledger Entry", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "sort_field": "creation", + "sort_order": "DESC", + "states": [] +} \ No newline at end of file diff --git a/erpnext/accounts/doctype/advance_payment_ledger_entry/advance_payment_ledger_entry.py b/erpnext/accounts/doctype/advance_payment_ledger_entry/advance_payment_ledger_entry.py new file mode 100644 index 00000000000..0ec2d411761 --- /dev/null +++ b/erpnext/accounts/doctype/advance_payment_ledger_entry/advance_payment_ledger_entry.py @@ -0,0 +1,27 @@ +# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class AdvancePaymentLedgerEntry(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + against_voucher_no: DF.DynamicLink | None + against_voucher_type: DF.Link | None + amount: DF.Currency + company: DF.Link | None + currency: DF.Link | None + event: DF.Data | None + voucher_no: DF.DynamicLink | None + voucher_type: DF.Link | None + # end: auto-generated types + + pass diff --git a/erpnext/accounts/doctype/advance_payment_ledger_entry/test_advance_payment_ledger_entry.py b/erpnext/accounts/doctype/advance_payment_ledger_entry/test_advance_payment_ledger_entry.py new file mode 100644 index 00000000000..2f578aed172 --- /dev/null +++ b/erpnext/accounts/doctype/advance_payment_ledger_entry/test_advance_payment_ledger_entry.py @@ -0,0 +1,222 @@ +# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +import frappe +from frappe.tests.utils import FrappeTestCase +from frappe.utils import nowdate, today + +from erpnext.accounts.doctype.payment_entry.test_payment_entry import get_payment_entry +from erpnext.accounts.test.accounts_mixin import AccountsTestMixin +from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order +from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order + + +class TestAdvancePaymentLedgerEntry(AccountsTestMixin, FrappeTestCase): + """ + Integration tests for AdvancePaymentLedgerEntry. + Use this class for testing interactions between multiple components. + """ + + def setUp(self): + self.create_company() + self.create_usd_receivable_account() + self.create_usd_payable_account() + self.create_item() + self.clear_old_entries() + + def tearDown(self): + frappe.db.rollback() + + def create_sales_order(self, qty=1, rate=100, currency="INR", do_not_submit=False): + """ + Helper method + """ + so = make_sales_order( + company=self.company, + customer=self.customer, + currency=currency, + item=self.item, + qty=qty, + rate=rate, + transaction_date=today(), + do_not_submit=do_not_submit, + ) + return so + + def create_purchase_order(self, qty=1, rate=100, currency="INR", do_not_submit=False): + """ + Helper method + """ + po = create_purchase_order( + company=self.company, + customer=self.supplier, + currency=currency, + item=self.item, + qty=qty, + rate=rate, + transaction_date=today(), + do_not_submit=do_not_submit, + ) + return po + + def test_so_advance_paid_and_currency_with_payment(self): + self.create_customer("_Test USD Customer", "USD") + + so = self.create_sales_order(currency="USD", do_not_submit=True) + so.conversion_rate = 80 + so.submit() + + pe_exchange_rate = 85 + pe = get_payment_entry(so.doctype, so.name, bank_account=self.cash) + pe.reference_no = "1" + pe.reference_date = nowdate() + pe.paid_from = self.debtors_usd + pe.paid_from_account_currency = "USD" + pe.source_exchange_rate = pe_exchange_rate + pe.paid_amount = so.grand_total + pe.received_amount = pe_exchange_rate * pe.paid_amount + pe.references[0].outstanding_amount = 100 + pe.references[0].total_amount = 100 + pe.references[0].allocated_amount = 100 + pe.save().submit() + + so.reload() + self.assertEqual(so.advance_paid, 100) + self.assertEqual(so.party_account_currency, "USD") + + # cancel advance payment + pe.reload() + pe.cancel() + + so.reload() + self.assertEqual(so.advance_paid, 0) + self.assertEqual(so.party_account_currency, "USD") + + def test_so_advance_paid_and_currency_with_journal(self): + self.create_customer("_Test USD Customer", "USD") + + so = self.create_sales_order(currency="USD", do_not_submit=True) + so.conversion_rate = 80 + so.submit() + + je_exchange_rate = 85 + je = frappe.get_doc( + { + "doctype": "Journal Entry", + "company": self.company, + "voucher_type": "Journal Entry", + "posting_date": so.transaction_date, + "multi_currency": True, + "accounts": [ + { + "account": self.debtors_usd, + "party_type": "Customer", + "party": so.customer, + "credit": 8500, + "credit_in_account_currency": 100, + "is_advance": "Yes", + "reference_type": so.doctype, + "reference_name": so.name, + "exchange_rate": je_exchange_rate, + }, + { + "account": self.cash, + "debit": 8500, + "debit_in_account_currency": 8500, + }, + ], + } + ) + je.save().submit() + so.reload() + self.assertEqual(so.advance_paid, 100) + self.assertEqual(so.party_account_currency, "USD") + + # cancel advance payment + je.reload() + je.cancel() + + so.reload() + self.assertEqual(so.advance_paid, 0) + self.assertEqual(so.party_account_currency, "USD") + + def test_po_advance_paid_and_currency_with_payment(self): + self.create_supplier("_Test USD Supplier", "USD") + + po = self.create_purchase_order(currency="USD", do_not_submit=True) + po.conversion_rate = 80 + po.submit() + + pe_exchange_rate = 85 + pe = get_payment_entry(po.doctype, po.name, bank_account=self.cash) + pe.reference_no = "1" + pe.reference_date = nowdate() + pe.paid_to = self.creditors_usd + pe.paid_to_account_currency = "USD" + pe.target_exchange_rate = pe_exchange_rate + pe.received_amount = po.grand_total + pe.paid_amount = pe_exchange_rate * pe.received_amount + pe.references[0].outstanding_amount = 100 + pe.references[0].total_amount = 100 + pe.references[0].allocated_amount = 100 + pe.save().submit() + + po.reload() + self.assertEqual(po.advance_paid, 100) + self.assertEqual(po.party_account_currency, "USD") + + # cancel advance payment + pe.reload() + pe.cancel() + + po.reload() + self.assertEqual(po.advance_paid, 0) + self.assertEqual(po.party_account_currency, "USD") + + def test_po_advance_paid_and_currency_with_journal(self): + self.create_supplier("_Test USD Supplier", "USD") + + po = self.create_purchase_order(currency="USD", do_not_submit=True) + po.conversion_rate = 80 + po.submit() + + je_exchange_rate = 85 + je = frappe.get_doc( + { + "doctype": "Journal Entry", + "company": self.company, + "voucher_type": "Journal Entry", + "posting_date": po.transaction_date, + "multi_currency": True, + "accounts": [ + { + "account": self.creditors_usd, + "party_type": "Supplier", + "party": po.supplier, + "debit": 8500, + "debit_in_account_currency": 100, + "is_advance": "Yes", + "reference_type": po.doctype, + "reference_name": po.name, + "exchange_rate": je_exchange_rate, + }, + { + "account": self.cash, + "credit": 8500, + "credit_in_account_currency": 8500, + }, + ], + } + ) + je.save().submit() + po.reload() + self.assertEqual(po.advance_paid, 100) + self.assertEqual(po.party_account_currency, "USD") + + # cancel advance payment + je.reload() + je.cancel() + + po.reload() + self.assertEqual(po.advance_paid, 0) + self.assertEqual(po.party_account_currency, "USD") diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.py b/erpnext/accounts/doctype/journal_entry/journal_entry.py index 47626492e84..fb5c563d790 100644 --- a/erpnext/accounts/doctype/journal_entry/journal_entry.py +++ b/erpnext/accounts/doctype/journal_entry/journal_entry.py @@ -188,6 +188,7 @@ class JournalEntry(AccountsController): self.validate_cheque_info() self.check_credit_limit() self.make_gl_entries() + self.make_advance_payment_ledger_entries() self.update_advance_paid() self.update_asset_value() self.update_inter_company_jv() @@ -218,8 +219,10 @@ class JournalEntry(AccountsController): "Repost Accounting Ledger Items", "Unreconcile Payment", "Unreconcile Payment Entries", + "Advance Payment Ledger Entry", ) self.make_gl_entries(1) + self.make_advance_payment_ledger_entries() self.update_advance_paid() self.unlink_advance_entry_reference() self.unlink_asset_reference() diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py index b645d92cb64..8832b87eec7 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py @@ -110,6 +110,7 @@ class PaymentEntry(AccountsController): self.update_outstanding_amounts() self.update_payment_schedule() self.update_payment_requests() + self.make_advance_payment_ledger_entries() self.update_advance_paid() # advance_paid_status depends on the payment request amount self.set_status() @@ -190,6 +191,7 @@ class PaymentEntry(AccountsController): "Repost Accounting Ledger Items", "Unreconcile Payment", "Unreconcile Payment Entries", + "Advance Payment Ledger Entry", ) super().on_cancel() self.make_gl_entries(cancel=1) @@ -197,6 +199,7 @@ class PaymentEntry(AccountsController): self.delink_advance_entry_references() self.update_payment_schedule(cancel=1) self.update_payment_requests(cancel=True) + self.make_advance_payment_ledger_entries() self.update_advance_paid() # advance_paid_status depends on the payment request amount self.set_status() diff --git a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py index 7ac0d34e671..d05da0dbf19 100644 --- a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py @@ -1995,7 +1995,7 @@ class TestSalesInvoice(FrappeTestCase): # Check if SO is unlinked/replaced by SI in PE & if SO advance paid is 0 self.assertEqual(pe.references[0].reference_name, si.name) - self.assertEqual(sales_order.advance_paid, 0.0) + self.assertEqual(sales_order.advance_paid, 300.0) # check outstanding after advance allocation self.assertEqual( diff --git a/erpnext/accounts/doctype/unreconcile_payment/test_unreconcile_payment.py b/erpnext/accounts/doctype/unreconcile_payment/test_unreconcile_payment.py index 13e5294aa78..3d222b22ff8 100644 --- a/erpnext/accounts/doctype/unreconcile_payment/test_unreconcile_payment.py +++ b/erpnext/accounts/doctype/unreconcile_payment/test_unreconcile_payment.py @@ -362,10 +362,14 @@ class TestUnreconcilePayment(AccountsTestMixin, FrappeTestCase): # Assert 'Advance Paid' so.reload() pe.reload() - self.assertEqual(so.advance_paid, 0) + self.assertEqual(so.advance_paid, 100) self.assertEqual(len(pe.references), 0) self.assertEqual(pe.unallocated_amount, 100) + pe.cancel() + so.reload() + self.assertEqual(so.advance_paid, 100) + def test_06_unreconcile_advance_from_payment_entry(self): self.enable_advance_as_liability() so1 = self.create_sales_order() @@ -411,7 +415,7 @@ class TestUnreconcilePayment(AccountsTestMixin, FrappeTestCase): so2.reload() pe.reload() self.assertEqual(so1.advance_paid, 150) - self.assertEqual(so2.advance_paid, 0) + self.assertEqual(so2.advance_paid, 110) self.assertEqual(len(pe.references), 1) self.assertEqual(pe.unallocated_amount, 110) @@ -459,6 +463,6 @@ class TestUnreconcilePayment(AccountsTestMixin, FrappeTestCase): # Assert 'Advance Paid' so.reload() - self.assertEqual(so.advance_paid, 0) + self.assertEqual(so.advance_paid, 1000) self.disable_advance_as_liability() diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index e6fb5d0d153..1ae6b5256a5 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -345,9 +345,21 @@ class AccountsController(TransactionBase): repost_doc.flags.ignore_links = True repost_doc.save(ignore_permissions=True) + def _remove_advance_payment_ledger_entries(self): + adv = qb.DocType("Advance Payment Ledger Entry") + qb.from_(adv).delete().where(adv.voucher_type.eq(self.doctype) & adv.voucher_no.eq(self.name)).run() + + advance_payment_doctypes = frappe.get_hooks("advance_payment_doctypes") + + if self.doctype in advance_payment_doctypes: + qb.from_(adv).delete().where( + adv.against_voucher_type.eq(self.doctype) & adv.against_voucher_no.eq(self.name) + ).run() + def on_trash(self): from erpnext.accounts.utils import delete_exchange_gain_loss_journal + self._remove_advance_payment_ledger_entries() self._remove_references_in_repost_doctypes() self._remove_references_in_unreconcile() self.remove_serial_and_batch_bundle() @@ -1935,21 +1947,23 @@ class AccountsController(TransactionBase): return stock_items - def set_total_advance_paid(self): - ple = frappe.qb.DocType("Payment Ledger Entry") - party = self.customer if self.doctype == "Sales Order" else self.supplier + def calculate_total_advance_from_ledger(self): + adv = frappe.qb.DocType("Advance Payment Ledger Entry") advance = ( - frappe.qb.from_(ple) - .select(ple.account_currency, Abs(Sum(ple.amount_in_account_currency)).as_("amount")) + frappe.qb.from_(adv) + .select(adv.currency.as_("account_currency"), Abs(Sum(adv.amount)).as_("amount")) .where( - (ple.against_voucher_type == self.doctype) - & (ple.against_voucher_no == self.name) - & (ple.party == party) - & (ple.delinked == 0) - & (ple.company == self.company) + (adv.against_voucher_type == self.doctype) + & (adv.against_voucher_no == self.name) + & (adv.company == self.company) ) .run(as_dict=True) ) + return advance + + def set_total_advance_paid(self): + advance = self.calculate_total_advance_from_ledger() + advance_paid, order_total = None, None if advance: advance = advance[0] @@ -2546,6 +2560,65 @@ class AccountsController(TransactionBase): repost_ledger.insert() repost_ledger.submit() + def get_advance_payment_doctypes(self) -> list: + return frappe.get_hooks("advance_payment_doctypes") + + def make_advance_payment_ledger_for_journal(self): + advance_payment_doctypes = self.get_advance_payment_doctypes() + advance_doctype_references = [ + x for x in self.accounts if x.reference_type in advance_payment_doctypes + ] + + for x in advance_doctype_references: + # Looking for payments + dr_or_cr = ( + "credit_in_account_currency" + if x.account_type == "Receivable" + else "debit_in_account_currency" + ) + + amount = x.get(dr_or_cr) + if amount > 0: + doc = frappe.new_doc("Advance Payment Ledger Entry") + doc.company = self.company + doc.voucher_type = self.doctype + doc.voucher_no = self.name + doc.against_voucher_type = x.reference_type + doc.against_voucher_no = x.reference_name + doc.amount = amount if self.docstatus == 1 else -1 * amount + doc.event = "Submit" if self.docstatus == 1 else "Cancel" + doc.currency = x.account_currency + doc.save() + + def make_advance_payment_ledger_for_payment(self): + advance_payment_doctypes = self.get_advance_payment_doctypes() + advance_doctype_references = [ + x for x in self.references if x.reference_doctype in advance_payment_doctypes + ] + currency = ( + self.paid_from_account_currency + if self.payment_type == "Receive" + else self.paid_to_account_currency + ) + for x in advance_doctype_references: + doc = frappe.new_doc("Advance Payment Ledger Entry") + doc.company = self.company + doc.voucher_type = self.doctype + doc.voucher_no = self.name + doc.against_voucher_type = x.reference_doctype + doc.against_voucher_no = x.reference_name + doc.amount = x.allocated_amount if self.docstatus == 1 else -1 * x.allocated_amount + doc.currency = currency + doc.event = "Submit" if self.docstatus == 1 else "Cancel" + doc.save() + + def make_advance_payment_ledger_entries(self): + if self.docstatus != 0: + if self.doctype == "Journal Entry": + self.make_advance_payment_ledger_for_journal() + elif self.doctype == "Payment Entry": + self.make_advance_payment_ledger_for_payment() + @frappe.whitelist() def get_tax_rate(account_head): diff --git a/erpnext/patches.txt b/erpnext/patches.txt index f48dc00fc8e..64c74f9d645 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -359,6 +359,7 @@ erpnext.patches.v15_0.allow_on_submit_dimensions_for_repostable_doctypes erpnext.patches.v14_0.update_flag_for_return_invoices #2024-03-22 erpnext.patches.v15_0.create_accounting_dimensions_in_payment_request erpnext.patches.v14_0.update_pos_return_ledger_entries #2024-08-16 +erpnext.patches.v15_0.create_advance_payment_ledger_records # below migration patch should always run last erpnext.patches.v14_0.migrate_gl_to_payment_ledger erpnext.stock.doctype.delivery_note.patches.drop_unused_return_against_index # 2023-12-20 diff --git a/erpnext/patches/v15_0/create_advance_payment_ledger_records.py b/erpnext/patches/v15_0/create_advance_payment_ledger_records.py new file mode 100644 index 00000000000..8d247885cab --- /dev/null +++ b/erpnext/patches/v15_0/create_advance_payment_ledger_records.py @@ -0,0 +1,73 @@ +import frappe +from frappe import qb +from frappe.query_builder.custom import ConstantColumn + + +def get_advance_doctypes() -> list: + return frappe.get_hooks("advance_payment_doctypes") + + +def get_payments_with_so_po_reference() -> list: + advance_payment_entries = [] + advance_doctypes = get_advance_doctypes() + per = qb.DocType("Payment Entry Reference") + payments_with_reference = ( + qb.from_(per) + .select(per.parent) + .distinct() + .where(per.reference_doctype.isin(advance_doctypes) & per.docstatus.eq(1)) + .run() + ) + if payments_with_reference: + pe = qb.DocType("Payment Entry") + advance_payment_entries = ( + qb.from_(pe) + .select(ConstantColumn("Payment Entry").as_("doctype")) + .select(pe.name) + .where(pe.name.isin(payments_with_reference) & pe.docstatus.eq(1)) + .run(as_dict=True) + ) + + return advance_payment_entries + + +def get_journals_with_so_po_reference() -> list: + advance_journal_entries = [] + advance_doctypes = get_advance_doctypes() + jea = qb.DocType("Journal Entry Account") + journals_with_reference = ( + qb.from_(jea) + .select(jea.parent) + .distinct() + .where(jea.reference_type.isin(advance_doctypes) & jea.docstatus.eq(1)) + .run() + ) + if journals_with_reference: + je = qb.DocType("Journal Entry") + advance_journal_entries = ( + qb.from_(je) + .select(ConstantColumn("Journal Entry").as_("doctype")) + .select(je.name) + .where(je.name.isin(journals_with_reference) & je.docstatus.eq(1)) + .run(as_dict=True) + ) + + return advance_journal_entries + + +def make_advance_ledger_entries(vouchers: list): + for x in vouchers: + frappe.get_doc(x.doctype, x.name).make_advance_payment_ledger_entries() + + +def execute(): + """ + Description: + Create Advance Payment Ledger Entry for all Payments made against Sales / Purchase Orders + """ + frappe.db.truncate("Advance Payment Ledger Entry") + payment_entries = get_payments_with_so_po_reference() + make_advance_ledger_entries(payment_entries) + + journals = get_journals_with_so_po_reference() + make_advance_ledger_entries(journals)