From 06237640771fb8a7b4f901d103a117544c1c5de8 Mon Sep 17 00:00:00 2001 From: Heather Kusmierz Date: Mon, 5 Jan 2026 16:26:50 -0500 Subject: [PATCH 1/3] test: add void payment entry workflow test --- check_run/check_run/custom/payment_entry.json | 61 ++++++ check_run/overrides/payment_entry.py | 16 +- .../public/js/custom/payment_entry_custom.js | 54 +++++ check_run/tests/setup.py | 46 ++-- check_run/tests/test_payment_entry.py | 203 ++++++++++++++++-- docs/index.md | 2 +- docs/permissions.md | 44 ++++ 7 files changed, 394 insertions(+), 32 deletions(-) diff --git a/check_run/check_run/custom/payment_entry.json b/check_run/check_run/custom/payment_entry.json index 3df21d4b..469b58ae 100644 --- a/check_run/check_run/custom/payment_entry.json +++ b/check_run/check_run/custom/payment_entry.json @@ -60,6 +60,67 @@ "translatable": 0, "unique": 0, "width": null + }, + { + "_assign": null, + "_comments": null, + "_liked_by": null, + "_user_tags": null, + "allow_in_quick_entry": 0, + "allow_on_submit": 1, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "creation": "2026-01-02 16:41:31.902368", + "default": null, + "depends_on": "eval:doc.status === \"Voided\"", + "description": null, + "docstatus": 0, + "dt": "Payment Entry", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "voided_date", + "fieldtype": "Date", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "idx": 80, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "status", + "label": "Voided Date", + "length": 0, + "mandatory_depends_on": null, + "modified": "2026-01-02 16:45:06.171693", + "modified_by": "Administrator", + "module": "Check Run", + "name": "Payment Entry-voided_date", + "no_copy": 0, + "non_negative": 0, + "options": null, + "owner": "Administrator", + "parent": null, + "parentfield": null, + "parenttype": null, + "permlevel": 0, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 1, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "translatable": 0, + "unique": 0, + "width": null } ], "custom_perms": [], diff --git a/check_run/overrides/payment_entry.py b/check_run/overrides/payment_entry.py index 4167823e..cf0760d1 100644 --- a/check_run/overrides/payment_entry.py +++ b/check_run/overrides/payment_entry.py @@ -31,8 +31,10 @@ def make_gl_entries(self, cancel=0, adv_adj=0): self.set_transaction_currency_and_rate() if self.status == "Voided": + # voided_date field is set via the dialog from the UI + voided_date = frappe.get_value(self.doctype, self.name, "voided_date") or getdate() original_posting_date = self.posting_date - self.voided_date = self.posting_date = getdate() + self.voided_date = self.posting_date = voided_date gl_entries = [] self.add_party_gl_entries(gl_entries) @@ -345,3 +347,15 @@ def get_image_base64_data(file_url): image, unused_filename, extn = get_local_image(file_url) file_content = file_doc.get_content() return f"data:image/{extn};base64,{safe_decode(base64.b64encode(file_content).decode('utf-8'))}" + + +@frappe.whitelist() +def set_voided_date(doctype, docname, voided_date): + voided_date = getdate(voided_date) + orig_posting_date = frappe.get_value(doctype, docname, "posting_date") + if voided_date < orig_posting_date: + frappe.throw( + msg=_("Void As Of Date cannot be before the Payment Entry's posting date."), + title=_("Invalid Void As Of Date"), + ) + frappe.db.set_value(doctype, docname, "voided_date", voided_date) diff --git a/check_run/public/js/custom/payment_entry_custom.js b/check_run/public/js/custom/payment_entry_custom.js index a630f7f9..7f2794f6 100644 --- a/check_run/public/js/custom/payment_entry_custom.js +++ b/check_run/public/js/custom/payment_entry_custom.js @@ -8,6 +8,12 @@ frappe.ui.form.on('Payment Entry', { onload: frm => { load_supplier_default_mode_of_payment(frm) }, + before_workflow_action: async frm => { + if (frm.selected_workflow_action == 'Void') { + await set_void_as_of_date(frm) + } + return + }, }) function get_next_check_number(frm) { @@ -47,3 +53,51 @@ function load_supplier_default_mode_of_payment(frm) { }) }) } + +async function set_void_as_of_date(frm) { + let values = await void_as_of_date_dialog(frm) + frm.set_value('voided_date', values.as_of_date) + cur_dialog.hide() + frappe + .xcall('check_run.overrides.payment_entry.set_voided_date', { + doctype: frm.doc.doctype, + docname: frm.doc.name, + voided_date: values.as_of_date, + }) + .then(r => { + frm.reload_doc() + }) +} + +function void_as_of_date_dialog(frm) { + return new Promise(resolve => { + let dialog = new frappe.ui.Dialog({ + title: __('Select the As-Of Date to Void Payment Entry'), + fields: [ + { + fieldtype: 'Date', + label: __('Void As-Of Date'), + fieldname: 'as_of_date', + default: moment(), + }, + ], + primary_action: function () { + let as_of_date = dialog.get_value('as_of_date') + if (!as_of_date) { + as_of_date = moment() + } + + if (as_of_date < frm.doc.posting_date) { + frappe.throw(__("Void As Of Date cannot be before the Payment Entry's posting date.")) + } + + resolve({ + as_of_date: as_of_date, + }) + }, + primary_action_label: __('Set Date'), + }) + dialog.show() + frappe.dom.unfreeze() + }) +} diff --git a/check_run/tests/setup.py b/check_run/tests/setup.py index 7f65ff2a..23f99931 100644 --- a/check_run/tests/setup.py +++ b/check_run/tests/setup.py @@ -44,7 +44,7 @@ def before_test(): def create_test_data(): today = frappe.utils.getdate() - setup_accounts() + setup_accounts_and_fiscal_years() settings = frappe._dict( { "day": today.replace(month=1, day=1), @@ -74,6 +74,33 @@ def create_test_data(): create_manual_payment_entry(settings) +def setup_accounts_and_fiscal_years(): + frappe.rename_doc( + "Account", "1000 - Application of Funds (Assets) - CFC", "1000 - Assets - CFC", force=True + ) + frappe.rename_doc( + "Account", "2000 - Source of Funds (Liabilities) - CFC", "2000 - Liabilities - CFC", force=True + ) + frappe.rename_doc( + "Account", "1310 - Debtors - CFC", "1310 - Accounts Receivable - CFC", force=True + ) + frappe.rename_doc( + "Account", "2110 - Creditors - CFC", "2110 - Accounts Payable - CFC", force=True + ) + update_account_number("1110 - Cash - CFC", "Petty Cash", account_number="1110") + update_account_number("Primary Checking - CFC", "Primary Checking", account_number="1201") + + company = frappe.defaults.get_defaults().company + today = frappe.utils.getdate() + for year in [today.year - 1, today.year + 1]: + fy = frappe.new_doc("Fiscal Year") + fy.year = year + fy.year_start_date = datetime.date(year, 1, 1) + fy.year_end_date = datetime.date(year, 12, 31) + fy.append("companies", {"company": company}) + fy.save() + + def create_company_address(settings): company_address = frappe.new_doc("Address") company_address.title = settings.company @@ -164,23 +191,6 @@ def create_bank_and_bank_account(settings): doc.submit() -def setup_accounts(): - frappe.rename_doc( - "Account", "1000 - Application of Funds (Assets) - CFC", "1000 - Assets - CFC", force=True - ) - frappe.rename_doc( - "Account", "2000 - Source of Funds (Liabilities) - CFC", "2000 - Liabilities - CFC", force=True - ) - frappe.rename_doc( - "Account", "1310 - Debtors - CFC", "1310 - Accounts Receivable - CFC", force=True - ) - frappe.rename_doc( - "Account", "2110 - Creditors - CFC", "2110 - Accounts Payable - CFC", force=True - ) - update_account_number("1110 - Cash - CFC", "Petty Cash", account_number="1110") - update_account_number("Primary Checking - CFC", "Primary Checking", account_number="1201") - - def create_payment_terms_templates(settings): if not frappe.db.exists("Payment Terms Template", "Net 30"): pt = frappe.new_doc("Payment Term") diff --git a/check_run/tests/test_payment_entry.py b/check_run/tests/test_payment_entry.py index e465b0c2..c58d9ef5 100644 --- a/check_run/tests/test_payment_entry.py +++ b/check_run/tests/test_payment_entry.py @@ -6,8 +6,12 @@ import frappe import pytest from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry +from frappe.exceptions import ValidationError +from frappe.model.workflow import apply_workflow +from frappe.utils import add_days, getdate from check_run.check_run.doctype.check_run.check_run import get_entries +from check_run.overrides.payment_entry import set_voided_date from check_run.tests.test_check_run import cr # noqa year = datetime.date.today().year @@ -15,13 +19,10 @@ @pytest.mark.order(30) def test_partial_payment_payment_entry_with_terms(): - pi_name = frappe.get_all( + pi_name = frappe.get_value( "Purchase Invoice", - {"supplier": "Exceptional Grid"}, - pluck="name", - order_by="posting_date ASC", - limit=1, - )[0] + {"supplier": "Exceptional Grid", "grand_total": 150, "posting_date": datetime.date(year, 1, 1)}, + ) pe0 = get_payment_entry("Purchase Invoice", pi_name) pe0.mode_of_payment = "Check" pe0.paid_amount = 30.00 @@ -144,13 +145,10 @@ def test_partial_payment_payment_entry_without_terms(): @pytest.mark.order(33) def test_outstanding_amount_in_check_run(cr): - pi_name = frappe.get_all( + pi_name = frappe.get_value( "Purchase Invoice", - {"supplier": "Mare Digitalis"}, - pluck="name", - order_by="posting_date ASC", - limit=1, - )[0] + {"supplier": "Mare Digitalis", "grand_total": 200, "posting_date": datetime.date(year, 1, 1)}, + ) pi = frappe.get_doc("Purchase Invoice", pi_name) assert pi.outstanding_amount == 200.00 assert pi.payment_schedule[0].outstanding == 200.00 @@ -191,3 +189,184 @@ def test_outstanding_amount_in_check_run(cr): t = list(filter(lambda x: x.get("name") == f"ACC-PINV-{year}-00004", transactions)) assert t[0].get("amount") == 200.00 + + +@pytest.mark.order(34) +def test_voided_payment_entry(cr): + """ + For illustrative purposes, the dates used for the following T-accounts assume + - current date is Jan-02 + - original PI created on Nov-01 (prior year) with Net 30 terms, due Dec-01 + - check payment sent and Payment Entry posting date set to Dec-01 (prior year) + - current date is Jan-02, but learned about lost check two days prior on Dec-31 + + Purchase Invoice: + | Date | Account | Party | Debit | Credit | + | :---- | :--------| :----: | -----: | ------: | + | Nov-01 | Accounts Payable | Cooperative Ag Finance | | $5,000 | + | Nov-01 | Inventory Received But Not Billed | | $5,000 | | + + Payment Entry: + | Date | Account | Party | Debit | Credit | + | :---- | :--------| :----: | -----: | ------: | + | Dec-01 | Accounts Payable | Cooperative Ag Finance | $5,000 | | + | Dec-01 | Primary Checking | | | $5,000 | + + Payment Entry Voided as of Dec-31: + | Date | Account | Party | Debit | Credit | + | :---- | :--------| :----: | -----: | ------: | + | Dec-31 | Accounts Payable | Cooperative Ag Finance | | $5,000 | + | Dec-31 | Primary Checking | | $5,000 | | + """ + # Activate Voidable Payment Entry workflow + vpe = frappe.get_doc("Workflow", "Voidable Payment Entry") + vpe.is_active = 1 + vpe.save() + + invoice_date = add_days(getdate(), -60) + payment_posting_date = add_days(getdate(), -30) + lost_check_date = add_days(getdate(), -2) + + # Create a 2-month-old Purchase Invoice + pi = frappe.new_doc("Purchase Invoice") + pi.supplier = "Cooperative Ag Finance" + pi.company = frappe.defaults.get_defaults().company + pi.set_posting_time = 1 + pi.posting_date = invoice_date + pi.append( + "items", + { + "item_code": "Financial Services", + "rate": 5000.00, + "qty": 1, + }, + ) + pi.payment_terms = "Net 30" + pi.save() + pi.submit() + + # Create a Payment Entry 1 month ago using Check + company = frappe.defaults.get_defaults().company + bank_account = frappe.get_value("Bank Account", {"company": company}) + gl_account = frappe.get_value("Bank Account", bank_account, "account") + gl_account_currency = frappe.db.get_value("Account", gl_account, "account_currency") + last_check_no = frappe.get_value("Bank Account", bank_account, "check_number") + total_amount = pi.outstanding_amount + + pe = frappe.new_doc("Payment Entry") + pe.payment_type = "Pay" + pe.posting_date = payment_posting_date + pe.mode_of_payment = "Check" + pe.company = company + pe.bank_account = bank_account + pe.paid_from = gl_account + pe.paid_to = frappe.get_value("Account", {"name": ["like", "%Accounts Payable%"]}) + pe.paid_to_account_currency = gl_account_currency + pe.paid_from_account_currency = pe.paid_to_account_currency + pe.party_type = "Supplier" + pe.party = pi.supplier + pe.reference_no = int(last_check_no) + 1 + pe.reference_date = pe.posting_date + pe.append( + "references", + { + "reference_doctype": pi.doctype, + "reference_name": pi.name, + "due_date": pi.get("due_date"), + "outstanding_amount": total_amount, + "allocated_amount": total_amount, + "total_amount": total_amount, + "payment_term": frappe.get_value("Payment Schedule", {"parent": pi.name}, "payment_term"), + }, + ) + pe.received_amount = total_amount + pe.base_received_amount = total_amount + pe.paid_amount = total_amount + pe.base_paid_amount = total_amount + pe.base_grand_total = total_amount + pe.save() + pe.submit() + + pi.reload() + assert pi.outstanding_amount == 0 + + # Check initial GL Entries + gl_count = len( + frappe.get_all("GL Entry", {"voucher_type": "Payment Entry", "voucher_no": pe.name}) + ) + assert gl_count == 2 + + payable_acct = frappe.get_value( + "Account", {"account_type": "Payable", "name": ["like", "%Accounts Payable%"]} + ) + checking_acct = frappe.get_value( + "Account", {"account_type": "Bank", "name": ["like", "%Primary Checking%"]} + ) + + gl_1 = frappe.get_doc( + "GL Entry", {"voucher_no": pe.name, "account": payable_acct, "party": pi.supplier} + ) + assert gl_1.against_voucher == pi.name + assert gl_1.debit == total_amount + assert gl_1.credit == 0 + assert gl_1.is_cancelled == 0 + assert gl_1.posting_date == payment_posting_date + + gl_2 = frappe.get_doc( + "GL Entry", {"voucher_no": pe.name, "account": checking_acct, "against": pi.supplier} + ) + assert gl_2.debit == 0 + assert gl_2.credit == total_amount + assert gl_2.is_cancelled == 0 + assert gl_2.posting_date == payment_posting_date + + # Learned check lost in mail (two days ago), apply Voidable Payment Entry workflow + # Note: set_voided_date would normally be called from the dialog in UI + + # Raise error if void date is before Payment Entry posting date + invalid_void_date = add_days(pe.posting_date, -5) + with pytest.raises(ValidationError) as exc_info: + set_voided_date(pe.doctype, pe.name, str(invalid_void_date)) + + assert ( + "Void As Of Date cannot be before the Payment Entry's posting date." in exc_info.value.args[0] + ) + + # Use appropriate void date + set_voided_date(pe.doctype, pe.name, str(lost_check_date)) + apply_workflow(pe, "Void") + pe.reload() + assert pe.status == "Voided" + assert pe.voided_date == lost_check_date + pi.reload() + assert pi.outstanding_amount == total_amount + + # Check voided GL Entries + gl_count = len( + frappe.get_all("GL Entry", {"voucher_type": "Payment Entry", "voucher_no": pe.name}) + ) + assert gl_count == 4 + + gl_1.reload() + assert gl_1.is_cancelled == 1 + gl_2.reload() + assert gl_2.is_cancelled == 1 + + gl_3 = frappe.get_doc( + "GL Entry", + {"voucher_no": pe.name, "account": payable_acct, "party": pi.supplier, "credit": [">", 0]}, + ) + assert gl_3.against_voucher == pi.name + assert gl_3.debit == 0 + assert gl_3.credit == total_amount + assert gl_3.is_cancelled == 1 + assert gl_3.posting_date == lost_check_date + + gl_4 = frappe.get_doc( + "GL Entry", + {"voucher_no": pe.name, "account": checking_acct, "against": pi.supplier, "debit": [">", 0]}, + ) + assert gl_4.debit == total_amount + assert gl_4.credit == 0 + assert gl_4.is_cancelled == 1 + assert gl_4.posting_date == lost_check_date diff --git a/docs/index.md b/docs/index.md index 56f0dd6f..f34286f8 100644 --- a/docs/index.md +++ b/docs/index.md @@ -11,7 +11,7 @@ There is some required prerequisite setup to get the Check Run application up an - [Installation Guide](./installationguide.md) - [Configuration: Bank Accounts, Mode of Payment for Employees and Suppliers](./configuration.md) - [Check Run Settings](./settings.md) -- [Default Permissions and Workflows](./permissions.md) +- [Default Permissions and Workflows](./permissions.md), including an optional Voidable Payment Entry Workflow available with the Check Run app and how to activate it ## Check Run Quick Start diff --git a/docs/permissions.md b/docs/permissions.md index 17b52d08..26cac563 100644 --- a/docs/permissions.md +++ b/docs/permissions.md @@ -12,3 +12,47 @@ Only one draft Check Run is allowed per payable/bank account combination. This i ## Role Permissions Out of the box, Check Run is permissioned the same as Payment Entry. For most small organizations this may be fine, but larger organizations with document approval policies and a desire to limit persons with access to printed checks will likely want to implement additional policies. Check Run print and ACH generation policies are based on permissions for Payment Entry, not on Check Run itself. + +## Voidable Payment Entry Workflow +Check Run ships with an optional Voidable Payment Entry Workflow that allows a user an additional option to void a Payment Entry, versus cancelling it. To activate this workflow, navigate to Workflow -> Voidable Payment Entry, and check the "Is Active" box. + +While the "Void" and "Cancel" Payment Entry workflows have similar accounting effects in ERPNext, they differ in the date used to reverse the Payment Entry's General Ledger entries. A normal "Cancel" workflow reverses the GL entries as of the original Payment Entry's posting date as if the payment were never made. The "Void" workflow uses the current date (by default), or the user can change to a specific date in the provided dialog. This helps to indicate a difference in the two scenarios and to preserve an audit trail of when the company learned about an issue with a sent payment. + +The distinction can be important in scenarios when the company sent a valid payment on time, but the recipient never received it. Examples include when a physical check gets lost or destroyed in the mail, or there's an error in the account number used for an electronic transfer. + +The following tables demonstrate the differences in the accounting entries between voiding a Payment Entry vs. cancelling it. The example assumes the Purchase Invoice posting date was November 1 with Net 30 terms (due on December 1): + +1. The company creates a Purchase Invoice to Cooperative Ag Finance for $5,000 on Nov-01 with Net 30 terms (posting date is Nov-01, due date is Dec-01) + +| Date | Account | Party | Debit | Credit | +| :---- | :--------| :----: | -----: | ------: | +| Nov-01 | Accounts Payable | Cooperative Ag Finance | | $5,000 | +| Nov-01 | Inventory Received But Not Billed | | $5,000 | | + +2. The company postmarks and mails a physical check for the amount due on Dec-01, and creates a Payment Entry against the Purchase Invoice + +| Date | Account | Party | Debit | Credit | +| :---- | :--------| :----: | -----: | ------: | +| Dec-01 | Accounts Payable | Cooperative Ag Finance | $5,000 | | +| Dec-01 | Primary Checking | | | $5,000 | + +3. On Dec-31, Cooperative Ag Finance notifies the company that they never received the check. The company's local bank is closed for a bank holiday, so they have to wait until Jan-02 to issue and confirm a stop payment on the check. + +Below are the General Ledger entries if the company **CANCELS** the Payment Entry - the ledger entries are reversed as of the original posting date, as if they payment were never made: + +**Payment Entry is Cancelled** + +| Date | Account | Party | Debit | Credit | +| :---- | :--------| :----: | -----: | ------: | +| Dec-01 | Accounts Payable | Cooperative Ag Finance | | $5,000 | +| Dec-01 | Primary Checking | | $5,000 | | + + +Below are the General Ledger entries if the company **VOIDS** the Payment Entry on Jan-02, but uses a voided date of Dec-31 (the day they learned of the lost check) - the ledger entries are reversed as of the voided date: + +**Payment Entry is Voided** + +| Date | Account | Party | Debit | Credit | +| :---- | :--------| :----: | -----: | ------: | +| Dec-31 | Accounts Payable | Cooperative Ag Finance | | $5,000 | +| Dec-31 | Primary Checking | | $5,000 | | From fcddca2bd12262b2784ba3db0f02bf76bb21da90 Mon Sep 17 00:00:00 2001 From: Heather Kusmierz Date: Thu, 8 Jan 2026 11:16:57 -0500 Subject: [PATCH 2/3] fix: async timing issue --- check_run/overrides/payment_entry.py | 6 ++--- .../public/js/custom/payment_entry_custom.js | 23 +++++++++---------- 2 files changed, 14 insertions(+), 15 deletions(-) diff --git a/check_run/overrides/payment_entry.py b/check_run/overrides/payment_entry.py index cf0760d1..45472790 100644 --- a/check_run/overrides/payment_entry.py +++ b/check_run/overrides/payment_entry.py @@ -351,11 +351,11 @@ def get_image_base64_data(file_url): @frappe.whitelist() def set_voided_date(doctype, docname, voided_date): + doc = frappe.get_doc(doctype, docname) voided_date = getdate(voided_date) - orig_posting_date = frappe.get_value(doctype, docname, "posting_date") - if voided_date < orig_posting_date: + if voided_date < doc.posting_date: frappe.throw( msg=_("Void As Of Date cannot be before the Payment Entry's posting date."), title=_("Invalid Void As Of Date"), ) - frappe.db.set_value(doctype, docname, "voided_date", voided_date) + frappe.db.set_value(doctype, docname, "voided_date", voided_date, update_modified=False) diff --git a/check_run/public/js/custom/payment_entry_custom.js b/check_run/public/js/custom/payment_entry_custom.js index 7f2794f6..8cf3cd19 100644 --- a/check_run/public/js/custom/payment_entry_custom.js +++ b/check_run/public/js/custom/payment_entry_custom.js @@ -58,15 +58,6 @@ async function set_void_as_of_date(frm) { let values = await void_as_of_date_dialog(frm) frm.set_value('voided_date', values.as_of_date) cur_dialog.hide() - frappe - .xcall('check_run.overrides.payment_entry.set_voided_date', { - doctype: frm.doc.doctype, - docname: frm.doc.name, - voided_date: values.as_of_date, - }) - .then(r => { - frm.reload_doc() - }) } function void_as_of_date_dialog(frm) { @@ -91,9 +82,17 @@ function void_as_of_date_dialog(frm) { frappe.throw(__("Void As Of Date cannot be before the Payment Entry's posting date.")) } - resolve({ - as_of_date: as_of_date, - }) + frappe + .xcall('check_run.overrides.payment_entry.set_voided_date', { + doctype: frm.doc.doctype, + docname: frm.doc.name, + voided_date: as_of_date, + }) + .then(r => { + resolve({ + as_of_date: as_of_date, + }) + }) }, primary_action_label: __('Set Date'), }) From 4fcd35bad4484f6ae2e7bb9fdf06d2fc6ff4a59e Mon Sep 17 00:00:00 2001 From: Heather Kusmierz Date: Fri, 9 Jan 2026 14:27:59 -0500 Subject: [PATCH 3/3] feat: adjust Payment Ledger Entry behavior on void --- .pre-commit-config.yaml | 1 + check_run/overrides/payment_entry.py | 272 ++++++++++++++++++++++++++- docs/permissions.md | 52 ++++- 3 files changed, 316 insertions(+), 9 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d34c77ef..5d6cd8d3 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -60,6 +60,7 @@ repos: - id: codespell additional_dependencies: - tomli + args: ['-L delink'] - repo: https://github.com/pre-commit/mirrors-mypy rev: v1.15.0 diff --git a/check_run/overrides/payment_entry.py b/check_run/overrides/payment_entry.py index 45472790..a60d0e28 100644 --- a/check_run/overrides/payment_entry.py +++ b/check_run/overrides/payment_entry.py @@ -2,6 +2,7 @@ # For license information, please see license.txt import base64 +import copy import frappe from erpnext.accounts.doctype.payment_entry.payment_entry import ( @@ -9,10 +10,21 @@ add_regional_gl_entries, get_outstanding_reference_documents, ) -from erpnext.accounts.general_ledger import make_gl_entries, process_gl_map +from erpnext.accounts.general_ledger import ( + check_freezing_date, + make_acc_dimensions_offsetting_entry, + make_entry, + process_gl_map, + save_entries, + set_as_cancel, + validate_accounting_period, + validate_against_pcv, + validate_disabled_accounts, +) +from erpnext.accounts.utils import get_payment_ledger_entries, is_immutable_ledger_enabled from frappe import _, safe_decode from frappe.core.doctype.file.utils import get_local_image -from frappe.utils import flt, get_link_to_form +from frappe.utils import flt, get_link_to_form, now from frappe.utils.data import getdate @@ -30,6 +42,7 @@ def make_gl_entries(self, cancel=0, adv_adj=0): self.setup_party_account_field() self.set_transaction_currency_and_rate() + voided_date = None if self.status == "Voided": # voided_date field is set via the dialog from the UI voided_date = frappe.get_value(self.doctype, self.name, "voided_date") or getdate() @@ -44,7 +57,7 @@ def make_gl_entries(self, cancel=0, adv_adj=0): add_regional_gl_entries(gl_entries, self) gl_entries = process_gl_map(gl_entries) - make_gl_entries(gl_entries, cancel=cancel, adv_adj=adv_adj) + make_gl_entries(gl_entries, cancel=cancel, adv_adj=adv_adj, voided_date=voided_date) if self.status == "Voided": self.posting_date = original_posting_date @@ -244,6 +257,259 @@ def validate_allocated_amount_with_latest_data(self): ) +def make_gl_entries( + gl_map, + cancel=False, + adv_adj=False, + merge_entries=True, + update_outstanding="Yes", + from_repost=False, + voided_date=None, # CUSTOM CODE +): + """ + HASH: a2b6e4a1c587ce2f7e017f39944899f76e3e2f7d + REPO: https://github.com/frappe/erpnext/ + PATH: erpnext/accounts/general_ledger.py + METHOD: make_gl_entries + """ + if gl_map: + if not cancel: + make_acc_dimensions_offsetting_entry(gl_map) + validate_accounting_period(gl_map) + validate_disabled_accounts(gl_map) + gl_map = process_gl_map(gl_map, merge_entries, from_repost=from_repost) + if gl_map and len(gl_map) > 1: + if gl_map[0].voucher_type != "Period Closing Voucher": + create_payment_ledger_entry( + gl_map, + cancel=0, + adv_adj=adv_adj, + update_outstanding=update_outstanding, + from_repost=from_repost, + voided_date=voided_date, # CUSTOM CODE + ) + save_entries(gl_map, adv_adj, update_outstanding, from_repost) + # Post GL Map process there may no be any GL Entries + elif gl_map: + frappe.throw( + _( + "Incorrect number of General Ledger Entries found. You might have selected a wrong Account in the transaction." + ) + ) + else: + make_reverse_gl_entries( + gl_map, adv_adj=adv_adj, update_outstanding=update_outstanding, voided_date=voided_date + ) # CUSTOM CODE + + +def make_reverse_gl_entries( + gl_entries=None, + voucher_type=None, + voucher_no=None, + adv_adj=False, + update_outstanding="Yes", + partial_cancel=False, + voided_date=None, # CUSTOM CODE +): + """ + HASH: a2b6e4a1c587ce2f7e017f39944899f76e3e2f7d + REPO: https://github.com/frappe/erpnext/ + PATH: erpnext/accounts/general_ledger.py + METHOD: make_reverse_gl_entries + + Get original gl entries of the voucher + and make reverse gl entries by swapping debit and credit + """ + + immutable_ledger_enabled = is_immutable_ledger_enabled() + + if not gl_entries: + gl_entry = frappe.qb.DocType("GL Entry") + gl_entries = ( + frappe.qb.from_(gl_entry) + .select("*") + .where(gl_entry.voucher_type == voucher_type) + .where(gl_entry.voucher_no == voucher_no) + .where(gl_entry.is_cancelled == 0) + .for_update() + ).run(as_dict=1) + + if gl_entries: + create_payment_ledger_entry( + gl_entries, + cancel=1, + adv_adj=adv_adj, + update_outstanding=update_outstanding, + partial_cancel=partial_cancel, + voided_date=voided_date, # CUSTOM CODE + ) + validate_accounting_period(gl_entries) + check_freezing_date(gl_entries[0]["posting_date"], adv_adj) + + is_opening = any(d.get("is_opening") == "Yes" for d in gl_entries) + validate_against_pcv(is_opening, gl_entries[0]["posting_date"], gl_entries[0]["company"]) + if partial_cancel: + # Partial cancel is only used by `Advance` in separate account feature. + # Only cancel GL entries for unlinked reference using `voucher_detail_no` + gle = frappe.qb.DocType("GL Entry") + for x in gl_entries: + query = ( + frappe.qb.update(gle) + .set(gle.modified, now()) + .set(gle.modified_by, frappe.session.user) + .where( + (gle.company == x.company) + & (gle.account == x.account) + & (gle.party_type == x.party_type) + & (gle.party == x.party) + & (gle.voucher_type == x.voucher_type) + & (gle.voucher_no == x.voucher_no) + & (gle.against_voucher_type == x.against_voucher_type) + & (gle.against_voucher == x.against_voucher) + & (gle.voucher_detail_no == x.voucher_detail_no) + ) + ) + + if not immutable_ledger_enabled: + query = query.set(gle.is_cancelled, True) + + query.run() + else: + if not immutable_ledger_enabled: + gle_names = [x.get("name") for x in gl_entries] + + # if names are available, cancel only that set of entries + if not all(gle_names): + set_as_cancel(gl_entries[0]["voucher_type"], gl_entries[0]["voucher_no"]) + else: + frappe.db.sql( + """UPDATE `tabGL Entry` SET is_cancelled = 1, + modified=%s, modified_by=%s + where name in %s and is_cancelled = 0""", + (now(), frappe.session.user, tuple(gle_names)), + ) + + for entry in gl_entries: + new_gle = copy.deepcopy(entry) + new_gle["name"] = None + debit = new_gle.get("debit", 0) + credit = new_gle.get("credit", 0) + + debit_in_account_currency = new_gle.get("debit_in_account_currency", 0) + credit_in_account_currency = new_gle.get("credit_in_account_currency", 0) + debit_in_transaction_currency = new_gle.get("debit_in_transaction_currency", 0) + credit_in_transaction_currency = new_gle.get("credit_in_transaction_currency", 0) + + new_gle["debit"] = credit + new_gle["credit"] = debit + new_gle["debit_in_account_currency"] = credit_in_account_currency + new_gle["credit_in_account_currency"] = debit_in_account_currency + new_gle["debit_in_transaction_currency"] = credit_in_transaction_currency + new_gle["credit_in_transaction_currency"] = debit_in_transaction_currency + + new_gle["remarks"] = "On cancellation of " + new_gle["voucher_no"] + new_gle["is_cancelled"] = 1 + + if immutable_ledger_enabled: + new_gle["is_cancelled"] = 0 + new_gle["posting_date"] = frappe.form_dict.get("posting_date") or getdate() + + if new_gle["debit"] or new_gle["credit"]: + make_entry(new_gle, adv_adj, "Yes") + + +def create_payment_ledger_entry( + gl_entries, + cancel=0, + adv_adj=0, + update_outstanding="Yes", + from_repost=0, + partial_cancel=False, + voided_date=None, # CUSTOM CODE +): + """ + HASH: f039bfe35a575272049534bac9aa771260691bde + REPO: https://github.com/frappe/erpnext/ + PATH: erpnext/accounts/utils.py + METHOD: create_payment_ledger_entry + """ + if gl_entries: + ple_map = get_payment_ledger_entries(gl_entries, cancel=cancel) + + for entry in ple_map: + ple = frappe.get_doc(entry) + + if cancel: + delink_original_entry(ple, partial_cancel=partial_cancel, voided_date=voided_date) + if is_immutable_ledger_enabled(): + ple.delinked = 0 + ple.posting_date = frappe.form_dict.get("posting_date") or getdate() + elif voided_date: + ple.delinked = 0 + ple.posting_date = voided_date + + ple.flags.ignore_permissions = 1 + ple.flags.adv_adj = adv_adj + ple.flags.from_repost = from_repost + ple.flags.update_outstanding = update_outstanding + ple.submit() + + +def delink_original_entry(pl_entry, partial_cancel=False, voided_date=None): # CUSTOM CODE + """ + HASH: f039bfe35a575272049534bac9aa771260691bde + REPO: https://github.com/frappe/erpnext/ + PATH: erpnext/accounts/utils.py + METHOD: delink_original_entry + """ + if not pl_entry: + return + + if pl_entry.doctype == "Advance Payment Ledger Entry": + adv = frappe.qb.DocType("Advance Payment Ledger Entry") + + ( + frappe.qb.update(adv) + .set(adv.delinked, 1) + .set(adv.event, "Cancel") + .set(adv.modified, now()) + .set(adv.modified_by, frappe.session.user) + .where(adv.voucher_type == pl_entry.voucher_type) + .where(adv.voucher_no == pl_entry.voucher_no) + .where(adv.against_voucher_type == pl_entry.against_voucher_type) + .where(adv.against_voucher_no == pl_entry.against_voucher_no) + .where(adv.event == pl_entry.event) + .run() + ) + + else: + ple = frappe.qb.DocType("Payment Ledger Entry") + query = ( + frappe.qb.update(ple) + .set(ple.modified, now()) + .set(ple.modified_by, frappe.session.user) + .where( + (ple.company == pl_entry.company) + & (ple.account_type == pl_entry.account_type) + & (ple.account == pl_entry.account) + & (ple.party_type == pl_entry.party_type) + & (ple.party == pl_entry.party) + & (ple.voucher_type == pl_entry.voucher_type) + & (ple.voucher_no == pl_entry.voucher_no) + & (ple.against_voucher_type == pl_entry.against_voucher_type) + & (ple.against_voucher_no == pl_entry.against_voucher_no) + ) + ) + + if partial_cancel: + query = query.where(ple.voucher_detail_no == pl_entry.voucher_detail_no) + + if not (is_immutable_ledger_enabled() or voided_date): # CUSTOM CODE + query = query.set(ple.delinked, True) + + query.run() + + @frappe.whitelist() def update_check_number(doc: PaymentEntry, method: str | None = None) -> None: mode_of_payment_type = frappe.db.get_value("Mode of Payment", doc.mode_of_payment, "type") diff --git a/docs/permissions.md b/docs/permissions.md index 26cac563..4cc0967b 100644 --- a/docs/permissions.md +++ b/docs/permissions.md @@ -16,13 +16,28 @@ Out of the box, Check Run is permissioned the same as Payment Entry. For most sm ## Voidable Payment Entry Workflow Check Run ships with an optional Voidable Payment Entry Workflow that allows a user an additional option to void a Payment Entry, versus cancelling it. To activate this workflow, navigate to Workflow -> Voidable Payment Entry, and check the "Is Active" box. -While the "Void" and "Cancel" Payment Entry workflows have similar accounting effects in ERPNext, they differ in the date used to reverse the Payment Entry's General Ledger entries. A normal "Cancel" workflow reverses the GL entries as of the original Payment Entry's posting date as if the payment were never made. The "Void" workflow uses the current date (by default), or the user can change to a specific date in the provided dialog. This helps to indicate a difference in the two scenarios and to preserve an audit trail of when the company learned about an issue with a sent payment. +While the "Void" and "Cancel" Payment Entry workflows have similar accounting effects in ERPNext, they differ in the date used to reverse the Payment Entry's General Ledger entries and how Payment Ledger entries are handled. -The distinction can be important in scenarios when the company sent a valid payment on time, but the recipient never received it. Examples include when a physical check gets lost or destroyed in the mail, or there's an error in the account number used for an electronic transfer. +- a normal "Cancel" workflow reverses the GL entries as of the original Payment Entry's posting date as if the payment were never made. Note that if the company has the immutable ledger feature enabled in Accounts Settings, then reverse GL entries are made as of the cancellation date +- the "Void" workflow provides a dialog for the user to specify the void date (which defaults to the current date) and reverses the GL entries as of that date -The following tables demonstrate the differences in the accounting entries between voiding a Payment Entry vs. cancelling it. The example assumes the Purchase Invoice posting date was November 1 with Net 30 terms (due on December 1): +This helps to indicate a difference in the two scenarios and to preserve an audit trail of when the company learned about an issue with a sent payment. -1. The company creates a Purchase Invoice to Cooperative Ag Finance for $5,000 on Nov-01 with Net 30 terms (posting date is Nov-01, due date is Dec-01) +These distinctions can be important in scenarios when the company sent a valid payment on time, but the recipient didn't received it. Examples include: + +- a physical check gets lost or destroyed in the mail +- a physical check arrives, but is then misplaced or damaged +- there's an error in the routing/account information given for an electronic transfer and the transfer goes to the wrong account + +For the Payment Ledger, the "Void" workflow follows abehavior is a hybrid between how ERPNext handles cancelled payments when the immutable ledger feature is enabled or not. + +- a "Cancel" workflow (without the immutable ledger feature enabled) will find the initial Payment Ledger Entry (PLE), use it to create an offsetting PLE with the same posting date, then "delink" both. Delinked PLEs don't show up in any ERPNext reports, so the Accounts Payable Report will show Invoice as outstanding for any report date between the original Invoice posting date and beyond, until another payment is recorded +- a "Cancel workflow" (with the immutable ledger feature enabled) will find the initial Payment Ledger Entry (PLE), use it to create an offsetting PLE with the the current date, then keep both PLEs linked. The Accounts Payable Report will show that the Invoice is paid using a report date up to the cancellation date (report dates after that show the Invoice as outstanding again with aging calculated against the Invoice's due date) +- a "Void" workflow follows the immutable ledger pattern where it doesn't delink the entries, except that the offsetting PLE uses the user-provided void date for its posting date. This maintains the historical record in the Accounts Payable report that the Invoice were presumed paid from original payment date up to the void date, then report dates after than show the Invoice as outstanding + +The following example demonstrates the differences in the accounting entries and the Accounts Payable report results between voiding a Payment Entry vs. cancelling it. It assumes the company doesn't have the immutable ledger feature enabled: + +1. On Nov-01, the company creates a Purchase Invoice to Cooperative Ag Finance for $5,000 with Net 30 terms (posting date is Nov-01, due date is Dec-01) | Date | Account | Party | Debit | Credit | | :---- | :--------| :----: | -----: | ------: | @@ -36,9 +51,17 @@ The following tables demonstrate the differences in the accounting entries betwe | Dec-01 | Accounts Payable | Cooperative Ag Finance | $5,000 | | | Dec-01 | Primary Checking | | | $5,000 | +On the due date (before payment), the Accounts Payable Report would show the Purchase Invoice as still outstanding and an Age (in days) of zero. + +| Posting Date | Party | Voucher Type | Due Date | O/S Amt | Age (Days) | +| :---- | :---- | :---- | :---- | :---- | ----: | :----: | +| Nov-01 | Cooperative Ag Finance | Purchase Invoice | Dec-01 | $5,000 | 0 | + +Once the company creates the Payment Entry, the row no longer shows in the Accounts Payable Report when the report's date is set to on or after the payment's posting date. + 3. On Dec-31, Cooperative Ag Finance notifies the company that they never received the check. The company's local bank is closed for a bank holiday, so they have to wait until Jan-02 to issue and confirm a stop payment on the check. -Below are the General Ledger entries if the company **CANCELS** the Payment Entry - the ledger entries are reversed as of the original posting date, as if they payment were never made: +a. Below are the General Ledger entries if the company **CANCELS** the Payment Entry - the ledger entries are reversed as of the original posting date, as if the payment were never made: **Payment Entry is Cancelled** @@ -47,8 +70,13 @@ Below are the General Ledger entries if the company **CANCELS** the Payment Entr | Dec-01 | Accounts Payable | Cooperative Ag Finance | | $5,000 | | Dec-01 | Primary Checking | | $5,000 | | +The Accounts Payable report again shows the original Purchase Invoice as outstanding again, and will do so using any report date on or after the original Purchase Invoice's posting date of Nov-01. Using a report date of Jan-02: + +| Report Date | Posting Date | Party | Voucher Type | Due Date | O/S Amt | Age (Days) | +| :----- | :---- | :---- | :---- | :---- | ----: | :----: | +| Jan-02 | Nov-01 | Cooperative Ag Finance | Purchase Invoice | Dec-01 | $5,000 | 32 | -Below are the General Ledger entries if the company **VOIDS** the Payment Entry on Jan-02, but uses a voided date of Dec-31 (the day they learned of the lost check) - the ledger entries are reversed as of the voided date: +b. Below are the General Ledger entries if the company **VOIDS** the Payment Entry on Jan-02, but uses a voided date of Dec-31 (the day they learned of the lost check) - the ledger entries are reversed as of the voided date: **Payment Entry is Voided** @@ -56,3 +84,15 @@ Below are the General Ledger entries if the company **VOIDS** the Payment Entry | :---- | :--------| :----: | -----: | ------: | | Dec-31 | Accounts Payable | Cooperative Ag Finance | | $5,000 | | Dec-31 | Primary Checking | | $5,000 | | + +The Accounts Payable Report again shows the original Purchase Invoice as outstanding and calculates the same age as the cancelled workflow when using a report date of Jan-02: + +| Report Date | Posting Date | Party | Voucher Type | Due Date | O/S Amt | Age (Days) | +| :----- | :---- | :---- | :---- | :---- | ----: | :----: | +| Jan-02 | Nov-01 | Cooperative Ag Finance | Purchase Invoice | Dec-01 | $5,000 | 32 | + +However, the Accounts Payable report won't include the Invoice in its results when the report date is between the original payment (Dec-01) and the day prior to voiding payment (Dec-30): + +| Report Date | Posting Date | Party | Voucher Type | Due Date | O/S Amt | Age (Days) | +| :----- | :---- | :---- | :---- | :---- | ----: | :----: | +| Dec-30 | | | | | | |