diff --git a/check_run/check_run/custom/employee.json b/check_run/check_run/custom/employee.json index ebed6781..8f30d8fa 100644 --- a/check_run/check_run/custom/employee.json +++ b/check_run/check_run/custom/employee.json @@ -177,8 +177,194 @@ "reqd": 0, "search_index": 0, "translatable": 0, + "unique": 0 + }, + { + "name": "Employee-account_details_validated", + "owner": "Administrator", + "creation": "2025-05-12 14:26:20.659548", + "modified": "2025-05-12 14:27:34.572960", + "modified_by": "Administrator", + "docstatus": 0, + "idx": 64, + "is_system_generated": 0, + "dt": "Employee", + "module": "Check Run", + "label": "Account Details Validated", + "fieldname": "account_details_validated", + "insert_after": "bank_account", + "length": 0, + "fieldtype": "Date", + "precision": "", + "hide_seconds": 0, + "hide_days": 0, + "sort_options": 0, + "fetch_if_empty": 0, + "collapsible": 0, + "non_negative": 0, + "reqd": 0, "unique": 0, - "width": null + "is_virtual": 0, + "read_only": 1, + "ignore_user_permissions": 0, + "hidden": 0, + "print_hide": 0, + "print_hide_if_no_value": 0, + "no_copy": 0, + "allow_on_submit": 0, + "in_list_view": 0, + "in_standard_filter": 0, + "in_global_search": 0, + "in_preview": 0, + "bold": 0, + "report_hide": 0, + "search_index": 0, + "allow_in_quick_entry": 0, + "ignore_xss_filter": 0, + "translatable": 0, + "hide_border": 0, + "permlevel": 0, + "columns": 0 + }, + { + "name": "Employee-ach_last_used", + "owner": "Administrator", + "creation": "2025-05-12 14:26:20.798988", + "modified": "2025-05-12 14:27:29.667182", + "modified_by": "Administrator", + "docstatus": 0, + "is_system_generated": 0, + "dt": "Employee", + "module": "Check Run", + "label": "ACH Last Used", + "fieldname": "ach_last_used", + "insert_after": "account_details_validated", + "length": 0, + "fieldtype": "Date", + "precision": "", + "hide_seconds": 0, + "hide_days": 0, + "sort_options": 0, + "fetch_if_empty": 0, + "collapsible": 0, + "non_negative": 0, + "reqd": 0, + "unique": 0, + "is_virtual": 0, + "read_only": 1, + "ignore_user_permissions": 0, + "hidden": 0, + "print_hide": 0, + "print_hide_if_no_value": 0, + "no_copy": 0, + "allow_on_submit": 0, + "in_list_view": 0, + "in_standard_filter": 0, + "in_global_search": 0, + "in_preview": 0, + "bold": 0, + "report_hide": 0, + "search_index": 0, + "allow_in_quick_entry": 0, + "ignore_xss_filter": 0, + "translatable": 0, + "hide_border": 0, + "permlevel": 0, + "columns": 0 + }, + { + "name": "Employee-ach_account_type", + "owner": "Administrator", + "creation": "2025-05-12 14:26:20.798988", + "modified": "2025-05-12 14:27:29.667182", + "modified_by": "Administrator", + "docstatus": 0, + "is_system_generated": 0, + "dt": "Employee", + "module": "Check Run", + "label": "ACH Account Type", + "fieldname": "ach_account_type", + "insert_after": "ach_last_used", + "length": 0, + "fieldtype": "Select", + "options": "\nChecking\nSavings", + "precision": "", + "hide_seconds": 0, + "hide_days": 0, + "sort_options": 0, + "fetch_if_empty": 0, + "collapsible": 0, + "non_negative": 0, + "reqd": 0, + "unique": 0, + "is_virtual": 0, + "read_only": 0, + "ignore_user_permissions": 0, + "hidden": 0, + "print_hide": 0, + "print_hide_if_no_value": 0, + "no_copy": 0, + "allow_on_submit": 0, + "in_list_view": 0, + "in_standard_filter": 0, + "in_global_search": 0, + "in_preview": 0, + "bold": 0, + "report_hide": 0, + "search_index": 0, + "allow_in_quick_entry": 0, + "ignore_xss_filter": 0, + "translatable": 0, + "hide_border": 0, + "permlevel": 0, + "columns": 0 + }, + { + "name": "Employee-ach_prenote_date", + "owner": "Administrator", + "creation": "2025-05-12 14:26:20.798988", + "modified": "2025-05-12 14:27:29.667182", + "modified_by": "Administrator", + "docstatus": 0, + "description": "This field is set by generating a NACHA file in the ACH Prenote report", + "is_system_generated": 0, + "dt": "Employee", + "module": "Check Run", + "label": "ACH Prenote Date", + "fieldname": "ach_prenote_date", + "insert_after": "ach_last_used", + "length": 0, + "fieldtype": "Date", + "precision": "", + "hide_seconds": 0, + "hide_days": 0, + "sort_options": 0, + "fetch_if_empty": 0, + "collapsible": 0, + "non_negative": 0, + "reqd": 0, + "unique": 0, + "is_virtual": 0, + "read_only": 1, + "ignore_user_permissions": 0, + "hidden": 0, + "print_hide": 0, + "print_hide_if_no_value": 0, + "no_copy": 0, + "allow_on_submit": 0, + "in_list_view": 0, + "in_standard_filter": 0, + "in_global_search": 0, + "in_preview": 0, + "bold": 0, + "report_hide": 0, + "search_index": 0, + "allow_in_quick_entry": 0, + "ignore_xss_filter": 0, + "translatable": 0, + "hide_border": 0, + "permlevel": 0, + "columns": 0 } ], "custom_perms": [], diff --git a/check_run/check_run/custom/supplier.json b/check_run/check_run/custom/supplier.json index 81e193ed..e32020e1 100644 --- a/check_run/check_run/custom/supplier.json +++ b/check_run/check_run/custom/supplier.json @@ -241,34 +241,199 @@ "search_index": 0, "sort_options": 0, "translatable": 0, + "unique": 0 + }, + { + "name": "Supplier-account_details_validated", + "owner": "Administrator", + "creation": "2025-05-12 14:26:20.659548", + "modified": "2025-05-12 14:27:34.572960", + "modified_by": "Administrator", + "docstatus": 0, + "idx": 64, + "is_system_generated": 0, + "dt": "Supplier", + "module": "Check Run", + "label": "Account Details Validated", + "fieldname": "account_details_validated", + "insert_after": "bank", + "length": 0, + "fieldtype": "Date", + "precision": "", + "hide_seconds": 0, + "hide_days": 0, + "sort_options": 0, + "fetch_if_empty": 0, + "collapsible": 0, + "non_negative": 0, + "reqd": 0, "unique": 0, - "width": null - } - ], - "custom_perms": [], - "doctype": "Supplier", - "links": [ + "is_virtual": 0, + "read_only": 1, + "ignore_user_permissions": 0, + "hidden": 0, + "print_hide": 0, + "print_hide_if_no_value": 0, + "no_copy": 0, + "allow_on_submit": 0, + "in_list_view": 0, + "in_standard_filter": 0, + "in_global_search": 0, + "in_preview": 0, + "bold": 0, + "report_hide": 0, + "search_index": 0, + "allow_in_quick_entry": 0, + "ignore_xss_filter": 0, + "translatable": 0, + "hide_border": 0, + "permlevel": 0, + "columns": 0 + }, { - "creation": "2013-01-10 16:34:11", - "custom": 0, + "name": "Supplier-ach_last_used", + "owner": "Administrator", + "creation": "2025-05-12 14:26:20.798988", + "modified": "2025-05-12 14:27:29.667182", + "modified_by": "Administrator", "docstatus": 0, - "group": "Allowed Items", + "is_system_generated": 0, + "dt": "Supplier", + "module": "Check Run", + "label": "ACH Last Used", + "fieldname": "ach_last_used", + "insert_after": "account_details_validated", + "length": 0, + "fieldtype": "Date", + "precision": "", + "hide_seconds": 0, + "hide_days": 0, + "sort_options": 0, + "fetch_if_empty": 0, + "collapsible": 0, + "non_negative": 0, + "reqd": 0, + "unique": 0, + "is_virtual": 0, + "read_only": 1, + "ignore_user_permissions": 0, "hidden": 0, - "idx": 1, - "is_child_table": 0, - "link_doctype": "Party Specific Item", - "link_fieldname": "party", - "modified": "2023-09-14 19:51:06.084944", + "print_hide": 0, + "print_hide_if_no_value": 0, + "no_copy": 0, + "allow_on_submit": 0, + "in_list_view": 0, + "in_standard_filter": 0, + "in_global_search": 0, + "in_preview": 0, + "bold": 0, + "report_hide": 0, + "search_index": 0, + "allow_in_quick_entry": 0, + "ignore_xss_filter": 0, + "translatable": 0, + "hide_border": 0, + "permlevel": 0, + "columns": 0 + }, + { + "name": "Supplier-ach_account_type", + "owner": "Administrator", + "creation": "2025-05-12 14:26:20.798988", + "modified": "2025-05-12 14:27:29.667182", "modified_by": "Administrator", - "name": "22db288d07", + "docstatus": 0, + "is_system_generated": 0, + "dt": "Supplier", + "module": "Check Run", + "label": "ACH Account Type", + "fieldname": "ach_account_type", + "insert_after": "ach_last_used", + "length": 0, + "fieldtype": "Select", + "options": "\nChecking\nSavings", + "precision": "", + "hide_seconds": 0, + "hide_days": 0, + "sort_options": 0, + "fetch_if_empty": 0, + "collapsible": 0, + "non_negative": 0, + "reqd": 0, + "unique": 0, + "is_virtual": 0, + "read_only": 0, + "ignore_user_permissions": 0, + "hidden": 0, + "print_hide": 0, + "print_hide_if_no_value": 0, + "no_copy": 0, + "allow_on_submit": 0, + "in_list_view": 0, + "in_standard_filter": 0, + "in_global_search": 0, + "in_preview": 0, + "bold": 0, + "report_hide": 0, + "search_index": 0, + "allow_in_quick_entry": 0, + "ignore_xss_filter": 0, + "translatable": 0, + "hide_border": 0, + "permlevel": 0, + "columns": 0 + }, + { + "name": "Supplier-ach_prenote_date", "owner": "Administrator", - "parent": "Supplier", - "parent_doctype": null, - "parentfield": "links", - "parenttype": "DocType", - "table_fieldname": null + "creation": "2025-05-12 14:26:20.798988", + "modified": "2025-05-12 14:27:29.667182", + "modified_by": "Administrator", + "docstatus": 0, + "is_system_generated": 0, + "dt": "Supplier", + "description": "This field is set by generating a NACHA file in the ACH Prenote report", + "module": "Check Run", + "label": "ACH Prenote Date", + "fieldname": "ach_prenote_date", + "insert_after": "ach_last_used", + "length": 0, + "fieldtype": "Date", + "precision": "", + "hide_seconds": 0, + "hide_days": 0, + "sort_options": 0, + "fetch_if_empty": 0, + "collapsible": 0, + "non_negative": 0, + "reqd": 0, + "unique": 0, + "is_virtual": 0, + "read_only": 1, + "ignore_user_permissions": 0, + "hidden": 0, + "print_hide": 0, + "print_hide_if_no_value": 0, + "no_copy": 0, + "allow_on_submit": 0, + "in_list_view": 0, + "in_standard_filter": 0, + "in_global_search": 0, + "in_preview": 0, + "bold": 0, + "report_hide": 0, + "search_index": 0, + "allow_in_quick_entry": 0, + "ignore_xss_filter": 0, + "translatable": 0, + "hide_border": 0, + "permlevel": 0, + "columns": 0 } ], + "custom_perms": [], + "doctype": "Supplier", + "links": [], "property_setters": [], "sync_on_migrate": 1 } diff --git a/check_run/check_run/doctype/check_run/check_run.js b/check_run/check_run/doctype/check_run/check_run.js index 51e89006..10c21454 100644 --- a/check_run/check_run/doctype/check_run/check_run.js +++ b/check_run/check_run/doctype/check_run/check_run.js @@ -415,18 +415,11 @@ function ach_only(frm) { } } if (!r.print_checks_only) { - if (frm.doc.docstatus == 1 && frm.doc.ach_file_generated == 1) { + if (frm.doc.docstatus == 1) { if (frappe.perm.has_perm('Check Run', 0, 'print')) { - frappe - .xcall('check_run.check_run.doctype.check_run.check_run.get_authorized_role_for_ach', { doc: frm.doc }) - .then(r => { - if (frappe.user.has_role(r)) { - add_download_nacha_button(frm) - } - }) - } - if (frm.doc.docstatus == 1 && frm.doc.ach_file_generated == 0) { - add_download_nacha_button(frm) + frm.add_custom_button(__('Download NACHA File'), () => { + download_nacha(frm) + }) } } } diff --git a/check_run/check_run/doctype/check_run/check_run.py b/check_run/check_run/doctype/check_run/check_run.py index c7794ee0..2ef3c461 100644 --- a/check_run/check_run/doctype/check_run/check_run.py +++ b/check_run/check_run/doctype/check_run/check_run.py @@ -18,6 +18,7 @@ from frappe.permissions import has_permission from frappe.query_builder.custom import ConstantColumn from frappe.query_builder.functions import Coalesce, NullIf, Sum +from frappe.utils import get_link_to_form from frappe.utils.data import flt, get_datetime, getdate, now, nowdate from frappe.utils.file_manager import remove_all, save_file from frappe.utils.password import get_decrypted_password @@ -828,11 +829,70 @@ def download_checks(docname: str) -> str: @frappe.whitelist() -def download_nacha(docname: str) -> None: - has_permission("Payment Entry", ptype="print", user=frappe.session.user, raise_exception=True) +def validate_for_nacha_file_generation(docname: str) -> list[str]: + doc = frappe.get_doc("Check Run", docname) + exceptions = [] + company_bank = frappe.db.get_value("Bank Account", doc.bank_account, "bank") + + if not company_bank: + company_link = get_link_to_form("Company", doc.company) + exceptions.append(f"Company Bank missing for {company_link}") + + if company_bank: + company_bank_aba_number = frappe.db.get_value("Bank", company_bank, "aba_number") + company_bank_account_no = frappe.db.get_value( + "Bank Account", doc.bank_account, "bank_account_no" + ) + company_ach_id = frappe.db.get_value("Bank Account", doc.bank_account, "company_ach_id") + + company_bank_link = get_link_to_form("Bank", company_bank) + bank_account_link = get_link_to_form("Bank Account", doc.bank_account) + + if not company_bank_aba_number: + exceptions.append(f"Company Bank ABA Number missing for {company_bank_link}") + if not company_bank_account_no: + exceptions.append(f"Company Bank Account Number missing for {bank_account_link}") + if not company_ach_id: + exceptions.append(f"Company Bank ACH ID missing for {bank_account_link}") + + payment_entries = doc.get_ach_payment_entries() + for pe in payment_entries: + party_link = get_link_to_form(pe.party_type, pe.party, label=pe.party_name) + + party_bank_account = get_decrypted_password( + pe.party_type, pe.party, fieldname="bank_account", raise_exception=False + ) + if not party_bank_account: + exceptions.append(f"{pe.party_type} Bank Account missing for {party_link}") + + party_bank = frappe.db.get_value(pe.party_type, pe.party, "bank") + if not party_bank: + exceptions.append(f"{pe.party_type} Bank missing for {party_link}") + + if party_bank: + party_bank_routing_number = frappe.db.get_value("Bank", party_bank, "aba_number") + if not party_bank_routing_number: + exceptions.append(f"{pe.party_type} Bank Routing Number missing for {party_link}") + + return exceptions + + +@frappe.whitelist() +def download_nacha(docname: str, validate: bool = False) -> None: + has_permission( + "Payment Entry", ptype="print", verbose=False, user=frappe.session.user, raise_exception=True + ) doc = frappe.get_doc("Check Run", docname) + + if validate: + exceptions = validate_for_nacha_file_generation(doc.name) + if exceptions: + frappe.throw("
".join(exceptions), title="Validation Errors") + settings = get_check_run_settings(doc) - ach_file = doc.build_nacha_file(settings) + payment_entries = doc.get_ach_payment_entries() + ach_file = build_nacha_file_from_payment_entries(doc, payment_entries, settings) + if settings.custom_post_processing_hook: ach_file = frappe.call(settings.custom_post_processing_hook, doc, settings, ach_file) else: @@ -852,44 +912,27 @@ def download_nacha(docname: str) -> None: comment.content = f"{frappe.session.user} created a NACHA file on {now()}" comment.flags.ignore_permissions = True comment.save() + for party, _pes in groupby(payment_entries, lambda x: x.get("party")): + pes = list(_pes) + frappe.db.set_value(pes[0].party_type, party, "ach_last_used", getdate()) frappe.db.commit() - if doc.ach_file_generated: - frappe.db.set_value("Check Run", doc.name, "ach_file_generated", 1) def build_nacha_file_from_payment_entries( doc: CheckRun, payment_entries: list[PaymentEntry], settings: CheckRunSettings ) -> NACHAFile: ach_entries = [] - exceptions = [] company_bank = frappe.db.get_value("Bank Account", doc.bank_account, "bank") - if not company_bank: - exceptions.append(f"Company Bank missing for {doc.company}") - if company_bank: - company_bank_aba_number = frappe.db.get_value("Bank", company_bank, "aba_number") - company_bank_account_no = frappe.db.get_value( - "Bank Account", doc.bank_account, "bank_account_no" - ) - company_ach_id = frappe.db.get_value("Bank Account", doc.bank_account, "company_ach_id") - if company_bank and not company_bank_aba_number: - exceptions.append(f"Company Bank ABA Number missing for {doc.bank_account}") - if company_bank and not company_bank_account_no: - exceptions.append(f"Company Bank Account Number missing for {doc.bank_account}") - if company_bank and not company_ach_id: - exceptions.append(f"Company Bank ACH ID missing for {doc.bank_account}") + company_bank_aba_number = frappe.db.get_value("Bank", company_bank, "aba_number") + company_ach_id = frappe.db.get_value("Bank Account", doc.bank_account, "company_ach_id") + for pe in payment_entries: party_bank_account = get_decrypted_password( pe.party_type, pe.party, fieldname="bank_account", raise_exception=False ) - if not party_bank_account: - exceptions.append(f"{pe.party_type} Bank Account missing for {pe.party_name}") party_bank = frappe.db.get_value(pe.party_type, pe.party, "bank") - if not party_bank: - exceptions.append(f"{pe.party_type} Bank missing for {pe.party_name}") - if party_bank: - party_bank_routing_number = frappe.db.get_value("Bank", party_bank, "aba_number") - if not party_bank_routing_number: - exceptions.append(f"{pe.party_type} Bank Routing Number missing for {pe.party_name}") + party_bank_routing_number = frappe.db.get_value("Bank", party_bank, "aba_number") + ach_entry = ACHEntry( transaction_code=22, # checking account receiving_dfi_identification=party_bank_routing_number, @@ -902,8 +945,6 @@ def build_nacha_file_from_payment_entries( ) ach_entries.append(ach_entry) - if exceptions: - frappe.throw("
".join(e for e in exceptions)) company_discretionary_data = ( doc.get("company_discretionary_data") if doc.get("company_discretionary_data") diff --git a/check_run/check_run/report/ach_prenote/__init__.py b/check_run/check_run/report/ach_prenote/__init__.py new file mode 100644 index 00000000..b1279b72 --- /dev/null +++ b/check_run/check_run/report/ach_prenote/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) 2025, AgriTheory and contributors +# For license information, please see license.txt diff --git a/check_run/check_run/report/ach_prenote/ach_prenote.js b/check_run/check_run/report/ach_prenote/ach_prenote.js new file mode 100644 index 00000000..d33a0a47 --- /dev/null +++ b/check_run/check_run/report/ach_prenote/ach_prenote.js @@ -0,0 +1,188 @@ +// Copyright (c) 2025, AgriTheory and contributors +// For license information, please see license.txt +/* eslint-disable */ + +let changedItems = {} + +frappe.query_reports['ACH Prenote'] = { + filters: [ + { + fieldname: 'validated_date', + label: __('Validated Date Before'), + fieldtype: 'Date', + }, + { + fieldname: 'last_used_date', + label: __('Last Used Date Before'), + fieldtype: 'Date', + }, + { + fieldname: 'ach_account_type', + label: __('ACH Account Type'), + fieldtype: 'Select', + options: ['', 'Checking', 'Savings'], + }, + ], + onload: reportview => { + frappe.query_report.page.add_button( + 'Generate File', + () => { + generate_ach_prenote() + }, + { btn_class: 'btn-success' } + ) + }, + saveButton: null, + get_datatable_options(options) { + return Object.assign(options, { + getEditor: this.get_editing_object.bind(this), + }) + }, + get_editing_object(colIndex, rowIndex, value, parent) { + const control = this.render_editing_input(colIndex, value, parent) + if (!control) return false + control.df.change = event => { + frappe.query_report.page.set_indicator('Unsaved', 'orange') + changedItems[rowIndex] = event?.currentTarget.value || null + this.add_save_button(frappe.query_report) + control.set_focus() + } + try { + return { + initValue: async value => { + return control.set_value(value) + }, + setValue: value => { + if (!value) { + return control.get_value() + } else { + return control.set_value(value) + } + }, + getValue: async () => { + return control.get_value() + }, + } + } catch (error) { + console.log(error) + } + }, + render_editing_input(colIndex, value, parent) { + const col = frappe.query_report.datatable.getColumn(colIndex) + let control = null + control = frappe.ui.form.make_control({ + df: col, + parent: parent, + render_input: true, + }) + control.set_value(value || '') + control.toggle_label(false) + control.toggle_description(false) + return control + }, + formatter: (value, row, column, data, default_formatter) => { + value = default_formatter(value, row, column, data) + if (data && column.fieldname == 'party_name') { + value = `${data.party_name}` + } + return value + }, + add_save_button: reportview => { + if (reportview.saveButton) { + return + } + reportview.saveButton = frappe.query_report.page.add_button( + __('Save Changes'), + () => { + let data = [] + let row + for (const [rowIndex, updated_date] of Object.entries(changedItems)) { + row = frappe.query_report.data[rowIndex] + row.account_details_validated = updated_date + data.push(row) + } + frappe + .xcall('check_run.check_run.report.ach_prenote.ach_prenote.update_validated_dates', { data: data }) + .then(r => { + reportview.saveButton.remove() + reportview.saveButton = null + changedItems = {} + reportview.data = [] + frappe.query_report.page.set_indicator('', '') + // this.get_datatable_options(frappe.query_report.datatable.options) + reportview.refresh() + frappe.query_report.page.set_indicator('', '') + frappe.show_alert(__('Updated Validated Dates'), 5) + }) + }, + { btn_class: 'btn-primary' } + ) + }, +} + +function generate_ach_prenote() { + return new Promise(resolve => { + let dialog = new frappe.ui.Dialog({ + title: __('Please provide additional details'), + fields: [ + { + label: 'Check Run Settings', + fieldname: 'check_run_settings', + fieldtype: 'Link', + options: 'Check Run Settings', + reqd: 1, + }, + { + fieldtype: 'Currency', + label: __('ACH Amount'), + fieldname: 'ach_amount', + default: 0.05, + reqd: 1, + }, + { + fieldtype: 'Date', + label: __('Date'), + fieldname: 'date', + default: moment().date(0).format(), + reqd: 1, + }, + ], + primary_action: () => { + let values = dialog.get_values() + dialog.hide() + frappe + .xcall('check_run.check_run.report.ach_prenote.ach_prenote.prepare_ach_prenote', { + check_run_settings: values.check_run_settings, + ach_amount: values.ach_amount, + date: values.date, + data: frappe.query_report.data, + }) + .then(r => { + if (r && r.success) { + let params = new URLSearchParams({ + check_run_settings: values.check_run_settings, + ach_amount: values.ach_amount, + date: values.date, + request_id: r.request_id || '', + }).toString() + + window.open( + `/api/method/check_run.check_run.report.ach_prenote.ach_prenote.download_ach_prenote?${params}` + ) + + setTimeout(() => { + resolve() + frappe.query_report.refresh_report() + }, 1000) + } else { + frappe.msgprint(__('Error preparing ACH prenote file')) + resolve() + } + }) + }, + primary_action_label: __('Generate File'), + }) + dialog.show() + dialog.get_close_btn() + }) +} diff --git a/check_run/check_run/report/ach_prenote/ach_prenote.json b/check_run/check_run/report/ach_prenote/ach_prenote.json new file mode 100644 index 00000000..226a3d8b --- /dev/null +++ b/check_run/check_run/report/ach_prenote/ach_prenote.json @@ -0,0 +1,29 @@ +{ + "add_total_row": 0, + "columns": [], + "creation": "2025-05-12 14:23:36.811868", + "disable_prepared_report": 0, + "disabled": 0, + "docstatus": 0, + "doctype": "Report", + "filters": [], + "idx": 0, + "is_standard": "Yes", + "modified": "2025-05-12 14:24:14.138591", + "modified_by": "Administrator", + "module": "Check Run", + "name": "ACH Prenote", + "owner": "Administrator", + "prepared_report": 0, + "ref_doctype": "Check Run Settings", + "report_name": "ACH Prenote", + "report_type": "Script Report", + "roles": [ + { + "role": "System Manager" + }, + { + "role": "Accounts Manager" + } + ] +} diff --git a/check_run/check_run/report/ach_prenote/ach_prenote.py b/check_run/check_run/report/ach_prenote/ach_prenote.py new file mode 100644 index 00000000..9331b044 --- /dev/null +++ b/check_run/check_run/report/ach_prenote/ach_prenote.py @@ -0,0 +1,294 @@ +# Copyright (c) 2025, AgriTheory and contributors +# For license information, please see license.txt + +import json +from io import StringIO + +import frappe +from atnacha import ACHBatch, ACHEntry, NACHAFile +from frappe.permissions import has_permission +from frappe.query_builder.custom import ConstantColumn +from frappe.query_builder.functions import Coalesce +from frappe.utils.data import flt, get_datetime, getdate +from frappe.utils.password import get_decrypted_password + +from check_run.check_run.doctype.check_run_settings.check_run_settings import CheckRunSettings + + +def execute(filters=None): + return get_columns(filters), get_data(filters) + + +def get_columns(filters): + return [ + { + "label": frappe._("Party Type"), + "fieldname": "party_type", + "fieldtype": "Data", + "width": "150px", + }, + { + "label": frappe._("Party"), + "fieldname": "party", + "fieldtype": "Data", + "width": "300px", + "hidden": True, + }, + { + "label": frappe._("Party Name"), + "fieldname": "party_name", + "fieldtype": "Data", + "width": "300px", + }, + { + "label": frappe._("Bank"), + "fieldname": "bank", + "fieldtype": "Link", + "width": "200px", + "options": "Bank", + }, + { + "label": frappe._("Account Type"), + "fieldname": "ach_account_type", + "fieldtype": "Data", + "width": "150px", + }, + { + "label": frappe._("ACH Prenote Date"), + "fieldname": "ach_prenote_date", + "fieldtype": "Date", + "width": "150px", + }, + { + "label": frappe._("Last Used Date"), + "fieldname": "ach_last_used", + "fieldtype": "Date", + "width": "150px", + }, + { + "label": frappe._("Validated Date"), + "fieldname": "account_details_validated", + "fieldtype": "Date", + "width": "200px", + "editable": True, + }, + ] + + +def get_data(filters): + Supplier = frappe.qb.DocType("Supplier") + Employee = frappe.qb.DocType("Employee") + supplier_query = ( + frappe.qb.from_(Supplier) + .select( + Supplier.ach_last_used, + Supplier.account_details_validated, + Supplier.bank, + Supplier.ach_account_type, + Supplier.ach_prenote_date, + (Supplier.supplier_name).as_("party_name"), + (Supplier.name).as_("party"), + ConstantColumn("Supplier").as_("party_type"), + ) + .where(Supplier.bank.isnotnull()) + .where(Coalesce(Supplier.ach_last_used, "1900-1-1") <= getdate(filters.last_used_date)) + .where( + Coalesce(Supplier.account_details_validated, "1900-1-1") <= getdate(filters.validated_date) + ) + .orderby( + Supplier.ach_last_used, + Supplier.account_details_validated, + ) + ) + if filters.ach_account_type == "Checking": + supplier_query = supplier_query.where(Supplier.ach_account_type == "Checking") + elif filters.ach_account_type == "Savings": + supplier_query = supplier_query.where(Supplier.ach_account_type == "Savings") + suppliers = supplier_query.run(as_dict=True) + employee_query = ( + frappe.qb.from_(Employee) + .select( + Employee.ach_last_used, + Employee.account_details_validated, + Employee.bank, + Employee.ach_account_type, + Employee.ach_prenote_date, + (Employee.employee_name).as_("party_name"), + (Employee.name).as_("party"), + ConstantColumn("Employee").as_("party_type"), + ) + .where(Employee.bank.isnotnull()) + .where(Coalesce(Employee.ach_last_used, "1900-1-1") <= getdate(filters.last_used_date)) + .where( + Coalesce(Employee.account_details_validated, "1900-1-1") <= getdate(filters.validated_date) + ) + .orderby( + Employee.ach_last_used, + Employee.account_details_validated, + ) + ) + if filters.ach_account_type == "Checking": + employee_query = employee_query.where(Employee.ach_account_type == "Checking") + elif filters.ach_account_type == "Savings": + employee_query = employee_query.where(Employee.ach_account_type == "Savings") + employees = employee_query.run(as_dict=True) + + results = sorted( + suppliers + employees, + key=lambda x: tuple( + [ + min( + x.get("account_details_validated") or getdate("1900-1-1"), + x.get("ach_last_used") or getdate("1900-1-1"), + ), + x.get("party_name"), + ] + ), + ) + return results + + +@frappe.whitelist() +def update_validated_dates(data): + data = json.loads(data) if isinstance(data, str) else data + for row in data: + frappe.db.set_value( + row.get("party_type"), + row.get("party"), + "account_details_validated", + getdate(row.get("account_details_validated")), + ) + + +def build_nacha_file(ach_amount, date, data, settings: CheckRunSettings) -> NACHAFile: + ach_entries = [] + company_bank = frappe.db.get_value("Bank Account", settings.bank_account, "bank") + company_bank_aba_number = frappe.db.get_value("Bank", company_bank, "aba_number") + company_ach_id = frappe.db.get_value("Bank Account", settings.bank_account, "company_ach_id") + + for row in data: + row = frappe._dict(row) + party_bank_account = get_decrypted_password( + row.party_type, row.party, fieldname="bank_account", raise_exception=False + ) + party_bank = frappe.db.get_value(row.party_type, row.party, "bank") + party_bank_routing_number = frappe.db.get_value("Bank", party_bank, "aba_number") + print(row.party_type, row.party, "ach_prenote_date", getdate()) + frappe.db.set_value(str(row.party_type), str(row.party), "ach_prenote_date", str(getdate())) + ach_entry = ACHEntry( + transaction_code=23, # checking prenote + receiving_dfi_identification=party_bank_routing_number, + dfi_account_number=party_bank_account, + amount=int(ach_amount * 100), + individual_id_number="", + individual_name=row.party_name, + discretionary_data="", + addenda_record_indicator=0, + ) + ach_entries.append(ach_entry) + + company_discretionary_data = settings.get("company_discretionary_data") or "" + ach_description = settings.get("ach_description") or "" + batch = ACHBatch( + service_class_code=settings.ach_service_class_code, + company_name=settings.company, + company_discretionary_data=company_discretionary_data[:20], + company_id=company_ach_id, + standard_class_code=settings.ach_standard_class_code, + company_entry_description=ach_description[:10] or "", + company_descriptive_date=None, + effective_entry_date=date, + settlement_date=None, + originator_status_code=1, + originating_dfi_id=company_bank_aba_number, + entries=ach_entries, + ) + nacha_file = NACHAFile( + priority_code=1, + immediate_destination=company_bank_aba_number, + immediate_origin=settings.immediate_origin or "", + file_creation_date=getdate(), + file_creation_time=get_datetime(), + file_id_modifier="0", + blocking_factor=10, + format_code=1, + immediate_destination_name=company_bank, + immediate_origin_name=settings.company, + reference_code="", + batches=[batch], + ) + return nacha_file + + +@frappe.whitelist() +def prepare_ach_prenote(check_run_settings, ach_amount, date, data): + try: + data = json.loads(data) if isinstance(data, str) else data + errors = [] + if not data or len(data) == 0: + errors.append("No data found to generate ACH prenote") + + if errors: + return {"success": False, "errors": errors} + + request_id = frappe.generate_hash(length=16) + frappe.cache().set_value( + f"ach_prenote_data_{request_id}", + { + "check_run_settings": check_run_settings, + "ach_amount": ach_amount, + "date": date, + "data": data, + }, + expires_in_sec=300, # Cache for 5 minutes + ) + + return {"success": True, "request_id": request_id} + except Exception as e: + frappe.log_error(f"Error preparing ACH prenote: {str(e)}", "ACH Prenote Generation") + return {"success": False, "errors": [str(e)]} + + +@frappe.whitelist() +def download_ach_prenote(): + try: + check_run_settings = frappe.form_dict.get("check_run_settings") + ach_amount = frappe.form_dict.get("ach_amount") + date = frappe.form_dict.get("date") + request_id = frappe.form_dict.get("request_id") + + if request_id: + cached_data = frappe.cache().get_value(f"ach_prenote_data_{request_id}") + if cached_data: + check_run_settings = cached_data.get("check_run_settings") + ach_amount = cached_data.get("ach_amount") + date = cached_data.get("date") + data = cached_data.get("data") + else: + frappe.throw("Download session expired. Please try again.") + + date = getdate(date) + ach_amount = flt(ach_amount, 2) + + has_permission( + "Payment Entry", ptype="print", verbose=False, user=frappe.session.user, raise_exception=True + ) + + settings = frappe.get_doc("Check Run Settings", check_run_settings) + ach_file = build_nacha_file(ach_amount, date, data, settings) + ach_file = ach_file() + ach_file = StringIO(ach_file) + ach_file.seek(0) + file_ext = settings.ach_file_extension if settings and settings.ach_file_extension else "ach" + + frappe.response["filename"] = f"ach_prenote.{file_ext}" + frappe.response["filecontent"] = ach_file.read() + frappe.response["type"] = "download" + frappe.response["content_type"] = "text/plain" + + if request_id: + frappe.cache().delete_key(f"ach_prenote_data_{request_id}") + frappe.db.commit() + except Exception as e: + frappe.log_error(f"Error generating ACH prenote file: {str(e)}", "ACH Prenote Generation") + frappe.throw(str(e)) diff --git a/check_run/tests/setup.py b/check_run/tests/setup.py index 3edd50fb..e4a5258f 100644 --- a/check_run/tests/setup.py +++ b/check_run/tests/setup.py @@ -1,3 +1,6 @@ +# Copyright (c) 2025, AgriTheory and contributors +# For license information, please see license.txt + import datetime import types @@ -25,7 +28,7 @@ def before_test(): "country": "United States", "fy_start_date": today.replace(month=1, day=1).isoformat(), "fy_end_date": today.replace(month=12, day=31).isoformat(), - "language": "english", + "language": "English-US", "company_tagline": "Chelsea Fruit Co", "email": "support@agritheory.dev", "password": "admin", @@ -40,6 +43,7 @@ def before_test(): for modu in frappe.get_all("Module Onboarding"): frappe.db.set_value("Module Onboarding", modu, "is_complete", 1) frappe.set_value("Website Settings", "Website Settings", "home_page", "login") + frappe.db.commit() def create_test_data(): @@ -59,7 +63,6 @@ def create_test_data(): ), } ) - create_company_address(settings) create_bank_and_bank_account(settings) create_payment_terms_templates(settings) create_suppliers(settings) @@ -69,24 +72,11 @@ def create_test_data(): create_employees(settings) create_expense_claim(settings) for month in range(1, 13): - settings.day = settings.day.replace(month=month) create_payroll_journal_entry(settings) + settings.day = settings.day.replace(month=month) create_manual_payment_entry(settings) -def create_company_address(settings): - company_address = frappe.new_doc("Address") - company_address.title = settings.company - company_address.address_type = "Office" - company_address.address_line1 = "67C Sweeny Street" - company_address.city = "Chelsea" - company_address.state = "MA" - company_address.pincode = "89077" - company_address.is_your_company_address = True - company_address.append("links", {"link_doctype": "Company", "link_name": settings.company}) - company_address.save() - - def create_bank_and_bank_account(settings): if not frappe.db.exists("Mode of Payment", "ACH/EFT"): mop = frappe.new_doc("Mode of Payment") @@ -165,6 +155,7 @@ def create_bank_and_bank_account(settings): def setup_accounts(): + frappe.flags.in_test = True frappe.rename_doc( "Account", "1000 - Application of Funds (Assets) - CFC", "1000 - Assets - CFC", force=True ) @@ -273,6 +264,7 @@ def create_suppliers(settings): if biz.supplier_default_mode_of_payment == "ACH/EFT": biz.bank = "Local Bank" biz.bank_account = "123456789" + biz.ach_account_type = "Checking" biz.currency = "USD" biz.default_price_list = "Standard Buying" biz.payment_terms = supplier[4] @@ -548,6 +540,7 @@ def create_employees(settings): if emp.mode_of_payment == "ACH/EFT": emp.bank = "Local Bank" emp.bank_account = f"{employee_number}12345" + emp.ach_account_type = "Checking" emp.save() @@ -634,7 +627,8 @@ def create_payroll_journal_entry(settings): je = frappe.new_doc("Journal Entry") je.entry_type = "Journal Entry" je.company = settings.company - je.due_date = je.posting_date = settings.day + je.posting_date = settings.day + je.due_date = settings.day total_payroll = 0.0 for idx, emp in enumerate(emps): employee_name = frappe.get_value( diff --git a/docs/achgeneration.md b/docs/achgeneration.md index dbbbf8d1..a6f529af 100644 --- a/docs/achgeneration.md +++ b/docs/achgeneration.md @@ -1,7 +1,12 @@ -# ACH Generation + + +# ACH Generation and Prenote For electronic bank transfers, banking institutions require specifically-formatted plain-text files to encode all necessary information. This includes data about the type of payment, the parties, their bank accounts, and payment amounts. These files conform to Automated Clearing House (ACH) standards, which is an electronic-funds transfer system run by the National Automated Clearing House Association (NACHA). ACH files are intended to represent electronic inter-bank transactions. +## Standard ACH Files + A Check Run will automatically generate this on demand, but only if the run includes payments using an "Electronic" Mode of Payment. See the [configuration page](./configuration.md) for details on how to set the `Mode of Payment` `type` field to mark it as an electronic bank transfer. The system defaults to using the "ach" file extension, but you can change this as needed in [Check Run Settings](./settings.md). The settings page also includes options to set two other mandatory fields in an ACH file: @@ -17,4 +22,44 @@ Other fields available to help configure your ACH generation include: The 'Custom Post Processing Hook' is a read-only field and not intended to be set by non-technical users. The RBC example noted above can be set by entering the following into the browser console: `cur_frm.set_value('custom_post_processing_hook','check_run.test_setup.example_post_processing_hook')`. Provide the dotted path to your function with a signature matching that of the example. -![Example ACH file data with properly-formatted header and batch entries.](./assets/ACHFile.png) \ No newline at end of file +![Example ACH file data with properly-formatted header and batch entries.](./assets/ACHFile.png) + +## ACH Prenote + +Before processing actual payments, banks often require ACH prenote files to validate the recipient bank account information. The ACH Prenote report allows you to generate these validation files with minimal transaction amounts (typically $0.00 to $0.50) to test the payment pathways before actual transfers occur. + +### Transaction Codes for Prenotes + +ACH prenotes use specific transaction codes to indicate they are test transactions for account verification: + +| Account Type | Prenote Credit Code | Regular Credit Code | Prenote Debit Code | Regular Debit Code | +|-------------|---------------------|---------------------|---------------------|---------------------| +| Checking | 23 | 22 | 28 | 27 | +| Savings | 33 | 32 | 38 | 37 | + +For suppliers receiving payments (credits), you would typically use code **23** for checking accounts or **33** for savings accounts during the prenote process. + +### Using the ACH Prenote Report + +1. Navigate to the ACH Prenote report in the Reports menu +2. The report displays eligible recipients for ACH prenote testing +3. Use the report filters to narrow down recipients by supplier group, payment terms, or other criteria +4. Click the "Generate ACH Prenote" button to create the prenote file + +### Generating the Prenote File + +When generating an ACH prenote file, you'll be prompted for the following information: + +- **Check Run Settings**: Select the appropriate settings profile for your bank +- **ACH Amount**: Enter the test amount, your bank should give you advice on the correct amount +- **Date**: The effective date for the prenote transactions + +After submitting this information, the system will generate and download an ACH prenote file that you can submit to your bank. + +### Editing Bank Information from the Prenote Report + +After submitting the prenote NACHA file to your bank, you may receive feedback requiring corrections. The ACH Prenote report allows you to: + +1. Update recipient bank account information directly in the report view +2. Edit effective dates or prenote amounts as needed +3. Re-generate the prenote file with the corrected information