From 7789d135840bf4b595b74c3563d67674ecfe5a62 Mon Sep 17 00:00:00 2001 From: Ty Reynolds Date: Wed, 27 May 2026 13:19:12 -0400 Subject: [PATCH 1/4] Added some fail safes for inconsequential errors. --- ns_app/api/payments.py | 193 ++++++++++++++++++++++++++++------------- 1 file changed, 133 insertions(+), 60 deletions(-) diff --git a/ns_app/api/payments.py b/ns_app/api/payments.py index dfccc2b..734706f 100644 --- a/ns_app/api/payments.py +++ b/ns_app/api/payments.py @@ -157,6 +157,17 @@ Response: if success == "1": + frappe.log_error( + f""" +PAYMENT SUCCESS FAILSAFE + +Invoice: {invoice} +Transaction ID: {transaction_id} +Amount: {payload['amount']} +""", + "PAYMENT SUCCESS FAILSAFE" + ) + if payment_type == "check": mode_of_payment = "ACH" else: @@ -175,12 +186,23 @@ Response: "duplicate": True } - create_payment_entry( - invoice=invoice, - amount=payload["amount"], - transaction_id=transaction_id, - mode_of_payment=mode_of_payment - ) + try: + + create_payment_entry( + invoice=invoice, + amount=payload["amount"], + transaction_id=transaction_id, + mode_of_payment=mode_of_payment + ) + + frappe.db.commit() + + except Exception: + + frappe.log_error( + frappe.get_traceback(), + "PAYMENT ENTRY FAILURE AFTER SUCCESSFUL CHARGE" + ) return { "success": True, @@ -379,6 +401,17 @@ Response: )[0] } + frappe.log_error( + f""" +PAYMENT SUCCESS FAILSAFE + +Invoice: {invoice} +Transaction ID: {transaction_id} +Amount: {inv.outstanding_amount} +""", + "PAYMENT SUCCESS FAILSAFE" + ) + existing_pe = frappe.db.exists( "Payment Entry", {"reference_no": transaction_id} @@ -386,54 +419,74 @@ Response: if not existing_pe: - create_payment_entry( - invoice=invoice, - amount=inv.outstanding_amount, - transaction_id=transaction_id, - mode_of_payment="Credit Card" - ) + try: + + create_payment_entry( + invoice=invoice, + amount=inv.outstanding_amount, + transaction_id=transaction_id, + mode_of_payment="Credit Card" + ) + + frappe.db.commit() + + except Exception: + + frappe.log_error( + frappe.get_traceback(), + "PAYMENT ENTRY FAILURE AFTER SUCCESSFUL CHARGE" + ) # Save AutoPay info locally if save_autopay and vault_id: - customer.custom_auto_pay_id = ( - vault_id - ) + try: - customer.custom_auto_pay_status = 1 + customer.custom_auto_pay_id = ( + vault_id + ) - customer.custom_auto_pay_first_name = ( - first_name - ) + customer.custom_auto_pay_status = 1 - customer.custom_auto_pay_last_name = ( - last_name - ) + customer.custom_auto_pay_first_name = ( + first_name + ) - customer.custom_auto_pay_company = ( - company - ) + customer.custom_auto_pay_last_name = ( + last_name + ) - customer.custom_auto_pay_zip = ( - billing_zip - ) + customer.custom_auto_pay_company = ( + company + ) - customer.save( - ignore_permissions=True - ) + customer.custom_auto_pay_zip = ( + billing_zip + ) - frappe.db.commit() + customer.save( + ignore_permissions=True + ) - frappe.log_error( - f""" + frappe.db.commit() + + frappe.log_error( + f""" Vault save complete customer={customer.name} vault_id={vault_id} """, - "AUTOPAY DEBUG - SAVE COMPLETE" - ) + "AUTOPAY DEBUG - SAVE COMPLETE" + ) + + except Exception: + + frappe.log_error( + frappe.get_traceback(), + "AUTOPAY CUSTOMER SAVE FAILED" + ) return { "success": True, @@ -551,31 +604,40 @@ def save_to_autopay( or "duplicate" in message.lower() ): - cust.custom_auto_pay_id = ( - returned_vault_id - ) + try: - cust.custom_auto_pay_status = 1 + cust.custom_auto_pay_id = ( + returned_vault_id + ) - cust.custom_auto_pay_first_name = ( - first_name - ) + cust.custom_auto_pay_status = 1 - cust.custom_auto_pay_last_name = ( - last_name - ) + cust.custom_auto_pay_first_name = ( + first_name + ) - cust.custom_auto_pay_company = ( - company - ) + cust.custom_auto_pay_last_name = ( + last_name + ) - cust.custom_auto_pay_zip = ( - billing_zip - ) + cust.custom_auto_pay_company = ( + company + ) - cust.save(ignore_permissions=True) + cust.custom_auto_pay_zip = ( + billing_zip + ) - frappe.db.commit() + cust.save(ignore_permissions=True) + + frappe.db.commit() + + except Exception: + + frappe.log_error( + frappe.get_traceback(), + "AUTOPAY CUSTOMER SAVE FAILED" + ) return { "success": True, @@ -606,12 +668,23 @@ def crystalclear_webhook(): else: mode_of_payment = "Credit Card" - create_payment_entry( - invoice=invoice, - amount=amount, - transaction_id=transaction_id, - mode_of_payment=mode_of_payment - ) + try: + + create_payment_entry( + invoice=invoice, + amount=amount, + transaction_id=transaction_id, + mode_of_payment=mode_of_payment + ) + + frappe.db.commit() + + except Exception: + + frappe.log_error( + frappe.get_traceback(), + "WEBHOOK PAYMENT ENTRY FAILURE" + ) return "ok" -- 2.39.5 From 5c5ff483a41dfa063a35b9d8c52611e77c6e9181 Mon Sep 17 00:00:00 2001 From: Ty Reynolds Date: Thu, 4 Jun 2026 10:01:33 -0400 Subject: [PATCH 2/4] auto fill customer after customer creation for every doctype. --- ns_app/public/js/customer_quick_entry.js | 646 +++++++++-------------- 1 file changed, 241 insertions(+), 405 deletions(-) diff --git a/ns_app/public/js/customer_quick_entry.js b/ns_app/public/js/customer_quick_entry.js index 857beb8..eadfaf8 100644 --- a/ns_app/public/js/customer_quick_entry.js +++ b/ns_app/public/js/customer_quick_entry.js @@ -1,317 +1,271 @@ frappe.provide("ns_app.customer"); -console.log("NS APP CUSTOMER JS LOADED"); +console.log("NS App: customer_quick_entry.js loaded"); -$(document).ready(() => { +// ─── Install override ──────────────────────────────────────────────────────── +// Poll until frappe.ui.form.make_quick_entry exists (ERPNext bundle settled), +// then wrap make_quick_entry so we re-assert our class at every Customer call. - setTimeout(() => { +(function () { + "use strict"; - const TargetClass = - frappe.ui.form.CustomerQuickEntryForm; + function install_override() { + const Base = frappe.ui.form.CustomerQuickEntryForm; + if (!Base) return false; + if (Base.__ns_patched) return true; - if (!TargetClass) { + frappe.ui.form.CustomerQuickEntryForm = class extends Base { - console.error( - "NS App: CustomerQuickEntryForm not found" - ); + // render_dialog is called by QuickEntryForm.setup() after the + // constructor runs. At this point this.after_insert is already + // set by the base constructor. We show our custom dialog instead + // of ERPNext's, but we PRESERVE this.after_insert so the link + // field callback chain stays intact. + render_dialog() { + console.log("NS App: render_dialog intercepted"); - return; - } + // Capture the typed customer name before anything mutates focus + const customer_name = this._get_typed_name(); - // Prevent duplicate patching - if (TargetClass.__ns_patched) { + console.log("NS App: prefill name =", customer_name); - console.log( - "NS App: already patched" - ); + // Open our custom dialog, passing: + // - the prefilled name + // - this.after_insert as the callback so ERPNext's link + // field gets notified when the customer is created + ns_app.customer.open_quick_entry({ + customer_name: customer_name, + after_insert: this.after_insert // ← this is the key + }); - return; - } - - console.log( - "NS App: patching CustomerQuickEntryForm" - ); - - frappe.ui.form.CustomerQuickEntryForm = - class extends TargetClass { - - render_dialog() { - - console.log( - "NS App: render_dialog intercepted" - ); - - let customer_name = ""; - - // Route option first - if (frappe.route_options?.name) { - - customer_name = - frappe.route_options.name; - } - - // Focused field fallback - if (!customer_name) { - - const active = - document.activeElement; - - if ( - active && - active.value - ) { - - customer_name = - active.value; - } - } - - // cur_frm fallback - if ( - !customer_name && - typeof cur_frm !== - "undefined" && - cur_frm - ) { - - customer_name = - cur_frm.doc.customer || - cur_frm.doc.party_name || - ""; - } - - console.log( - "NS App: Captured customer name:", - customer_name - ); - - // DO NOT call super.render_dialog() - // This restores the fully custom dialog - - ns_app.customer.open_quick_entry({ - customer_name: - customer_name, - - callback: - this.after_insert - }); + // We intentionally do NOT call super.render_dialog(). + // ERPNext's dialog is replaced entirely by ours. + // But we must call this.dialog = something so that + // QuickEntryForm.setup() doesn't crash on teardown. + // A minimal placeholder dialog satisfies that contract. + if (!this.dialog) { + this.dialog = { hide: () => {}, get_field: () => null }; } - }; + } - TargetClass.__ns_patched = true; + _get_typed_name() { + // 1. The link field control that triggered quick entry + if (frappe.ui.form.cur_field) { + const v = frappe.ui.form.cur_field.get_value?.(); + if (v) return v; + } + // 2. Whatever input had focus when the dialog opened + const el = document.activeElement; + if (el?.value) return el.value; + // 3. Current form doc + if (typeof cur_frm !== "undefined" && cur_frm?.doc) { + return cur_frm.doc.customer || cur_frm.doc.party_name || ""; + } + return ""; + } + }; - console.log( - "NS App: CustomerQuickEntryForm patched successfully" - ); + frappe.ui.form.CustomerQuickEntryForm.__ns_patched = true; + console.log("NS App: CustomerQuickEntryForm override installed ✓"); + return true; + } - }, 1000); + function patch_make_quick_entry() { + const orig = frappe.ui.form.make_quick_entry; + if (!orig || orig.__ns_patched) return; -}); + frappe.ui.form.make_quick_entry = function (doctype, after_insert, init_callback, doc, force) { + if (doctype === "Customer") { + install_override(); // re-assert in case anything clobbered it + } + return orig.apply(this, arguments); + }; + + frappe.ui.form.make_quick_entry.__ns_patched = true; + console.log("NS App: make_quick_entry patched ✓"); + } + + let attempts = 0; + const poller = setInterval(() => { + if (++attempts > 100) { + clearInterval(poller); + console.error("NS App: gave up waiting for frappe.ui.form.make_quick_entry"); + return; + } + if (frappe.ui.form?.make_quick_entry) { + clearInterval(poller); + install_override(); + patch_make_quick_entry(); + } + }, 100); + +})(); + + +// ─── Custom dialog ─────────────────────────────────────────────────────────── +// opts: +// customer_name {string} prefill value +// after_insert {function} ERPNext's link field callback — MUST be called +// with the new customer name on success ns_app.customer.open_quick_entry = function (opts = {}) { - console.log( - "NS App: Custom Customer Quick Entry OPENED" - ); + console.log("NS App: open_quick_entry called", opts); const d = new frappe.ui.Dialog({ - title: "New Customer", - - size: "large", - + size: "large", fields: [ - // ───────── CUSTOMER ───────── + // ── Customer ────────────────────────────────────────────────── + { fieldtype: "Section Break", label: "Customer Information" }, { - fieldtype: "Section Break", - label: "Customer Information" + fieldname: "customer_name", + label: "Customer Name", + fieldtype: "Data", + reqd: 1, + default: opts.customer_name || "", + description: "Enter the customer or company name" + }, + { + fieldname: "customer_type", + label: "Customer Type", + fieldtype: "Select", + options: "Company\nIndividual", + default: "Company", + reqd: 1, + description: "Select whether this customer is a company or individual" + }, + { + fieldname: "customer_group", + label: "Customer Group", + fieldtype: "Link", + options: "Customer Group", + default: "Commercial", + reqd: 1, + description: "Select the customer group" + }, + { + fieldname: "custom_send_via", + label: "Preferred Delivery Method", + fieldtype: "Select", + options: "mail\nemail\nfax", + description: "Choose how documents should be sent to the customer" }, + // ── Contact ─────────────────────────────────────────────────── + { fieldtype: "Section Break", label: "Primary Contact" }, + { - fieldname: "customer_name", - label: "Customer Name", - fieldtype: "Data", - reqd: 1, - default: - opts.customer_name || "", - description: - "Enter the customer or company name" + fieldname: "email_id", + label: "Email Address", + fieldtype: "Data", + options: "Email", + description: "Enter the customer's email address" + }, + { + fieldname: "mobile_no", + label: "Mobile Phone Number", + fieldtype: "Data", + reqd: 1, + description: "Enter the customer's mobile phone number" }, + // ── Address ─────────────────────────────────────────────────── + { fieldtype: "Section Break", label: "Address Information" }, + { - fieldname: "customer_type", - label: "Customer Type", - fieldtype: "Select", - options: - "Company\nIndividual", - default: "Company", - reqd: 1, - description: - "Select whether this customer is a company or individual" + fieldname: "address_line1", + label: "Address Line 1", + fieldtype: "Data", + reqd: 1, + description: "Enter the street address" }, - { - fieldname: "customer_group", - label: "Customer Group", - fieldtype: "Link", - options: "Customer Group", - default: "Commercial", - reqd: 1, - description: - "Select the customer group" + fieldname: "address_line2", + label: "Address Line 2", + fieldtype: "Data", + description: "Enter apartment, suite, or secondary address information" }, - { - fieldname: "custom_send_via", - label: - "Preferred Delivery Method", - fieldtype: "Select", - options: - "mail\nemail\nfax", - description: - "Choose how documents should be sent to the customer" + fieldname: "pincode", + label: "ZIP Code", + fieldtype: "Data", + reqd: 1, + description: "Enter the ZIP or postal code" }, - - // ───────── CONTACT ───────── - { - fieldtype: "Section Break", - label: "Primary Contact" + fieldname: "city", + label: "City", + fieldtype: "Data", + description: "Enter the city" }, - { - fieldname: "email_id", - label: "Email Address", - fieldtype: "Data", - options: "Email", - description: - "Enter the customer's email address" + fieldname: "state", + label: "State", + fieldtype: "Data", + description: "Enter the state" }, - { - fieldname: "mobile_no", - label: - "Mobile Phone Number", - fieldtype: "Data", - reqd: 1, - description: - "Enter the customer's mobile phone number" - }, - - // ───────── ADDRESS ───────── - - { - fieldtype: "Section Break", - label: - "Address Information" - }, - - { - fieldname: "address_line1", - label: - "Address Line 1", - fieldtype: "Data", - reqd: 1, - description: - "Enter the street address" - }, - - { - fieldname: "address_line2", - label: - "Address Line 2", - fieldtype: "Data", - description: - "Enter apartment, suite, or secondary address information" - }, - - { - fieldname: "pincode", - label: "ZIP Code", - fieldtype: "Data", - reqd: 1, - description: - "Enter the ZIP or postal code" - }, - - { - fieldname: "city", - label: "City", - fieldtype: "Data", - description: - "Enter the city" - }, - - { - fieldname: "state", - label: "State", - fieldtype: "Data", - description: - "Enter the state" - }, - - { - fieldname: "country", - label: "Country", - fieldtype: "Link", - options: "Country", - default: - "United States", - description: - "Select the country" + fieldname: "country", + label: "Country", + fieldtype: "Link", + options: "Country", + default: "United States", + description: "Select the country" } ], - primary_action_label: - "Create Customer", + primary_action_label: "Create Customer", primary_action(values) { - - console.log( - "NS App: Create Customer clicked", - values - ); - + console.log("NS App: submitting customer creation", values); d.disable_primary_action(); frappe.call({ - - method: - "ns_app.api.customer.create_customer_full", - - args: values, + method: "ns_app.api.customer.create_customer_full", + args: values, callback(r) { + if (!r.message) { + console.error("NS App: create_customer_full returned empty"); + d.enable_primary_action(); + return; + } - console.log( - "NS App: Customer created", - r.message - ); + const customer_name = r.message; + console.log("NS App: customer created →", customer_name); d.hide(); frappe.show_alert({ - - message: - "Customer created via NS App", - + message: `Customer "${customer_name}" created`, indicator: "green" }); - if ( - opts.callback - ) { - - opts.callback( - r.message - ); + // ── Hand control back to ERPNext ───────────────────── + // after_insert is ERPNext's link field callback. + // Calling it with the new customer name does everything: + // - populates the Customer field on the originating form + // - triggers the field's onchange/fetch logic + // - does NOT require any routing from our side + // This is the ONLY correct way to resume the originating + // document flow without racing the backend transaction. + if (typeof opts.after_insert === "function") { + console.log("NS App: calling after_insert with", customer_name); + // ERPNext's callback expects an object with a .name property, + // not a plain string — { name: "cu-00741" } + opts.after_insert({ name: customer_name }); + } else { + // Fallback: no callback was passed (e.g. dialog opened + // standalone). Just reload to a new Customer form. + console.warn("NS App: no after_insert callback — navigating to customer"); + frappe.set_route("Form", "Customer", customer_name); } }, always() { - d.enable_primary_action(); } }); @@ -320,166 +274,48 @@ ns_app.customer.open_quick_entry = function (opts = {}) { d.show(); - // Accessibility labels - + // ── Accessibility ──────────────────────────────────────────────────────── setTimeout(() => { - - d.fields.forEach(field => { - - const control = - d.get_field( - field.fieldname - ); - - if ( - !control || - !control.$input - ) { - return; - } - - control.$input.attr( - "aria-label", - field.label || - field.fieldname - ); - - control.$input.attr( - "title", - field.label || - field.fieldname - ); - - if (field.label) { - - control.$input.attr( - "placeholder", - field.label - ); + d.fields.forEach(f => { + const ctrl = d.get_field(f.fieldname); + if (!ctrl?.$input) return; + ctrl.$input + .attr("aria-label", f.label || f.fieldname) + .attr("title", f.label || f.fieldname); + if (f.label && !ctrl.$input.attr("placeholder")) { + ctrl.$input.attr("placeholder", f.label); } }); + }, 100); - console.log( - "NS App: accessibility applied" - ); + // ── ZIP autofill ───────────────────────────────────────────────────────── + d.fields_dict.pincode.df.onchange = () => { + const zip = d.get_value("pincode"); + if (!zip || zip.length < 5) return; - }, 300); + fetch(`https://api.zippopotam.us/us/${zip}`) + .then(r => r.ok ? r.json() : null) + .then(data => { + if (!data?.places?.length) return; + const p = data.places[0]; + d.set_value("city", p["place name"]); + d.set_value("state", p["state"]); + d.set_value("country", data.country); + console.log("NS App: ZIP autofill →", p["place name"], p["state"]); + }) + .catch(() => {}); + }; - // ZIP autofill + // ── Enter → next field ─────────────────────────────────────────────────── + d.$wrapper.on("keydown", "input, select, textarea", function (e) { + if (e.key !== "Enter") return; + if (document.activeElement?.classList.contains("btn-primary")) return; - d.fields_dict.pincode.df.onchange = - () => { - - const zip = - d.get_value( - "pincode" - ); - - if ( - !zip || - zip.length < 5 - ) { - return; - } - - console.log( - "NS App: ZIP lookup", - zip - ); - - fetch( - `https://api.zippopotam.us/us/${zip}` - ) - .then(r => - r.ok - ? r.json() - : null - ) - .then(data => { - - if ( - !data || - !data.places?.length - ) { - return; - } - - const p = - data.places[0]; - - d.set_value( - "city", - p["place name"] - ); - - d.set_value( - "state", - p["state"] - ); - - d.set_value( - "country", - data.country - ); - - console.log( - "NS App: ZIP autofill success" - ); - }) - .catch(() => {}); - }; - - // Enter navigation - - d.$wrapper.on( - "keydown", - "input, select, textarea", - function (e) { - - if ( - e.key === "Enter" - ) { - - const active = - document.activeElement; - - // Allow submit only - // on primary button - - if ( - active && - active.classList.contains( - "btn-primary" - ) - ) { - return; - } - - e.preventDefault(); - - const fields = - d.$wrapper - .find( - "input, select, textarea" - ) - .filter( - ":visible:not([disabled])" - ); - - const index = - fields.index(this); - - if ( - index > -1 && - index + 1 < - fields.length - ) { - - fields - .eq(index + 1) - .focus(); - } - } - } - ); + e.preventDefault(); + const fields = d.$wrapper + .find("input, select, textarea") + .filter(":visible:not([disabled])"); + const i = fields.index(this); + if (i > -1 && i + 1 < fields.length) fields.eq(i + 1).focus(); + }); }; \ No newline at end of file -- 2.39.5 From 4ed575c18f6cb25111b6d0d02f4caca2499e7a08 Mon Sep 17 00:00:00 2001 From: Ty Reynolds Date: Wed, 10 Jun 2026 08:20:17 -0400 Subject: [PATCH 3/4] New double window print formats. --- .../ns_dunning_double_window.html | 160 +++++++++++++++++ .../quotation_double_window.html | 153 ++++++++++++++++ .../sales_order_double_window.html | 163 ++++++++++++++++++ 3 files changed, 476 insertions(+) create mode 100644 ns_app/print_formats/print_formats/ns_dunning_double_window.html create mode 100644 ns_app/print_formats/print_formats/quotation_double_window.html create mode 100644 ns_app/print_formats/print_formats/sales_order_double_window.html diff --git a/ns_app/print_formats/print_formats/ns_dunning_double_window.html b/ns_app/print_formats/print_formats/ns_dunning_double_window.html new file mode 100644 index 0000000..55256ea --- /dev/null +++ b/ns_app/print_formats/print_formats/ns_dunning_double_window.html @@ -0,0 +1,160 @@ + + +
+ + {% set company_doc = frappe.get_doc("Company", company) %} + + + + + + + +
+

+ {{ company_doc.company_name }} +

+

+ 1063 Chestnut Level Road
+ Quarryville PA 17566
+ {% if company_doc.phone_no %}Phone: {{ company_doc.phone_no }}{% endif %} + {% if company_doc.email %} | Email: {{ company_doc.email }}{% endif %} +

+
+ Payment Reminder
+ {{ doc.name }}
+ Date: {{ frappe.utils.formatdate(doc.posting_date, "MM-dd-yyyy") }} +
+ + + + + + + +
+ Customer: {{ doc.customer }}
+ Amount Due: + ${{ doc.grand_total }} +
+ + +
+ {{ doc.customer_name }}
+ {{ doc.address_display or doc.customer_address }} +
+ + +
+ + +
+
+ PAYMENT REMINDER +
+
+ Our records show the following invoice(s) are past due. Please remit payment at your earliest convenience. +
+
+ + + + + + + + + + + + + + {% for row in doc.invoices %} + + + + + + + + {% endfor %} + +
InvoiceDue DateDays OverdueOutstandingInterest
{{ row.sales_invoice }} + {{ frappe.utils.formatdate(row.due_date, "MM-dd-yyyy") }} + + {{ row.overdue_days }} + + ${{ row.outstanding_amount }} + + ${{ row.interest }} +
+ + +
+

+ Total Outstanding:${{ doc.total_outstanding }} +

+ {% if doc.dunning_fee and doc.dunning_fee > 0 %} +

+ Dunning Fee:${{ doc.dunning_fee }} +

+ {% endif %} + {% if doc.total_interest and doc.total_interest > 0 %} +

+ Total Interest:${{ doc.total_interest }} +

+ {% endif %} +

+ Amount Due: + ${{ doc.grand_total }} +

+
+ +
+ + +
+

+We value your business and would like to resolve this promptly. Please contact us if you have any questions or if payment has already been sent. + +We accept payments by check or over the phone using a debit or credit card. + +Thank you for your prompt attention — NS Innovations +

+
+ +
diff --git a/ns_app/print_formats/print_formats/quotation_double_window.html b/ns_app/print_formats/print_formats/quotation_double_window.html new file mode 100644 index 0000000..601622b --- /dev/null +++ b/ns_app/print_formats/print_formats/quotation_double_window.html @@ -0,0 +1,153 @@ + + +
+ + {% set company_doc = frappe.get_doc("Company", company) %} + + + + + + + +
+

+ {{ company_doc.company_name }} +

+

+ 1063 Chestnut Level Road
+ Quarryville PA 17566
+ {% if company_doc.phone_no %}Phone: {{ company_doc.phone_no }}{% endif %} + {% if company_doc.email %} | Email: {{ company_doc.email }}{% endif %} +

+
+ Quotation: + {{ doc.name }}
+ Date: {{ frappe.utils.formatdate(doc.transaction_date, "MM-dd-yyyy") }}
+ Valid Till: + {{ frappe.utils.formatdate(doc.valid_till, "MM-dd-yyyy") if doc.valid_till else "" }} +
+ + + + + + + +
+ Terms: + {{ doc.payment_terms_template or "Net 30" }} +
+ + +
+ {{ doc.customer_name }}
+ {{ doc.address_display or doc.customer_address }} +
+ + +
+ + {% if doc.terms or doc.note %} + + + + +
+ {% if doc.note %} +
+ {{ doc.note }} +
+ {% endif %} + {% if doc.terms %} +
+ {{ doc.terms }} +
+ {% endif %} +
+ {% endif %} + + + + + + + + + + + + + + {% for row in doc.items %} + + + + + + + + {% endfor %} + +
ItemDescriptionQtyRateAmount
{{ row.item_code }}{{ row.item_name }}{{ row.qty }}{{ row.rate }}{{ row.amount }}
+ + +
+

+ Subtotal:{{ doc.total }} +

+ + {% for tax in doc.taxes %} +

+ {{ tax.account_head }} ({{ tax.rate }}%): + {{ tax.tax_amount }} +

+ {% endfor %} + +

+ Total:{{ doc.grand_total }} +

+
+ +
+ + +
+

+ This quotation is valid until the date listed above and subject to change thereafter. + + Pricing does not include applicable taxes unless otherwise stated. + + Thank you for considering NS Innovations. +

+
+ +
diff --git a/ns_app/print_formats/print_formats/sales_order_double_window.html b/ns_app/print_formats/print_formats/sales_order_double_window.html new file mode 100644 index 0000000..e7d79f7 --- /dev/null +++ b/ns_app/print_formats/print_formats/sales_order_double_window.html @@ -0,0 +1,163 @@ + + +
+ + {% set company_doc = frappe.get_doc("Company", company) %} + + + + + + + +
+

+ {{ company_doc.company_name }} +

+

+ 1063 Chestnut Level Road
+ Quarryville PA 17566
+ {% if company_doc.phone_no %}Phone: {{ company_doc.phone_no }}{% endif %} + {% if company_doc.email %} | Email: {{ company_doc.email }}{% endif %} +

+
+ Sales Order
+ {{ doc.name }}
+ Date: {{ frappe.utils.formatdate(doc.transaction_date, "MM-dd-yyyy") }} +
+ + + + + + + +
+ {% if doc.delivery_date %} + Delivery Date: + {{ frappe.utils.formatdate(doc.delivery_date, "MM-dd-yyyy") }}
+ {% endif %} + Terms: {{ doc.payment_terms_template or "Net 30" }} +
+ + +
+ {{ doc.customer_name }}
+ {{ doc.address_display or doc.customer_address }} +
+ +
+ + {% if doc.custom_subscription_data or doc.custom_invoice_notes %} + + + + +
+ {% if doc.custom_subscription_data %} +
+ {{ doc.custom_subscription_data }} +
+ {% endif %} + {% if doc.custom_invoice_notes %} +
+ {{ doc.custom_invoice_notes }} +
+ {% endif %} +
+ {% endif %} + + + + + + + + + + + + + + {% for row in doc.items %} + + + + + + + + {% endfor %} + +
ItemDescriptionQtyRateAmount
+ {{ row.item_code }} + + {{ row.item_name }} + + {{ row.qty }} + + {{ row.rate }} + + {{ row.amount }} +
+ + +
+

+ Subtotal:{{ doc.total }} +

+ + {% for tax in doc.taxes %} +

+ {{ tax.account_head }} ({{ tax.rate }}%): + {{ tax.tax_amount }} +

+ {% endfor %} + +

+ Total:{{ doc.grand_total }} +

+
+ + + +
+ + +
+

+This Sales Order is not an invoice. +Pricing and availability subject to confirmation. + +Thank you for your business. +

+
+ +
-- 2.39.5 From 4e0acde16409a8be49df99b1d78af3fe721aaaf2 Mon Sep 17 00:00:00 2001 From: Ty Reynolds Date: Thu, 11 Jun 2026 09:36:01 -0400 Subject: [PATCH 4/4] Added multiple invoice payment feature. Fixed autopay to run using security key. --- ns_app/api/payments.py | 877 +++++++++--------------------- ns_app/public/js/sales_invoice.js | 769 +++++++++----------------- 2 files changed, 510 insertions(+), 1136 deletions(-) diff --git a/ns_app/api/payments.py b/ns_app/api/payments.py index 734706f..fe763f3 100644 --- a/ns_app/api/payments.py +++ b/ns_app/api/payments.py @@ -1,6 +1,7 @@ import frappe import requests import urllib.parse +import json from frappe import generate_hash from frappe.utils import nowdate @@ -8,760 +9,382 @@ from frappe.utils import nowdate @frappe.whitelist() def check_autopay(customer): - cust = frappe.get_doc("Customer", customer) - return { "autopay_enabled": bool(cust.custom_auto_pay_status), - "autopay_id": ( - cust.custom_auto_pay_id - if cust.custom_auto_pay_status - else None - ) + "autopay_id": cust.custom_auto_pay_id if cust.custom_auto_pay_status else None } +# ── NEW: fetch all unpaid invoices for a customer ──────────────────────────── + +@frappe.whitelist() +def get_unpaid_invoices(customer): + """Return all submitted, unpaid Sales Invoices for this customer.""" + invoices = frappe.get_all( + "Sales Invoice", + filters={ + "customer": customer, + "docstatus": 1, + "outstanding_amount": [">", 0] + }, + fields=["name", "posting_date", "customer_name", "outstanding_amount"], + order_by="posting_date asc" + ) + return invoices + + +# ── AutoPay (unchanged) ─────────────────────────────────────────────────────── + @frappe.whitelist() def run_autopay_payment(invoice): - - inv = frappe.get_doc("Sales Invoice", invoice) - + inv = frappe.get_doc("Sales Invoice", invoice) if inv.outstanding_amount <= 0: frappe.throw("Invoice is already fully paid") cust = frappe.get_doc("Customer", inv.customer) - - if ( - not cust.custom_auto_pay_status - or not cust.custom_auto_pay_id - ): - frappe.throw( - "Customer does not have AutoPay enabled" - ) + if not cust.custom_auto_pay_status or not cust.custom_auto_pay_id: + frappe.throw("Customer does not have AutoPay enabled") payload = { "autopay_id": cust.custom_auto_pay_id, - "amount": float(inv.outstanding_amount), - "invoice": inv.name + "amount": float(inv.outstanding_amount), + "invoice": inv.name } response = call_payment_api(payload) - if not response.get("success"): - - frappe.throw( - response.get("error", "Payment failed") - ) + frappe.throw(response.get("error", "Payment failed")) return { - "success": True, - "message": "AutoPay payment successful", + "success": True, + "message": "AutoPay payment successful", "transaction_id": response.get("transaction_id") } -def call_payment_api(payload): - - url = "https://crystalclear.transactiongateway.com/api/transact.php" - - api_username = frappe.conf.get("nmi_username") - api_password = frappe.conf.get("nmi_password") - - if not api_username or not api_password: - frappe.throw( - "Payment gateway credentials not configured" - ) - - invoice = payload["invoice"] - - order_id = ( - f"{invoice}-{generate_hash(length=6)}" - ) - - data = { - "username": api_username, - "password": api_password, - - "type": "sale", - - "customer_vault_id": payload["autopay_id"], - - "amount": payload["amount"], - - "orderid": order_id - } - - try: - - response = requests.post( - url, - data=data, - timeout=30 - ) - - response.raise_for_status() - - log_response = response.text[:120] - - frappe.logger("payments").info( - f""" -NMI AUTOPAY RESPONSE - -Invoice: {invoice} -Order ID: {order_id} -Amount: {payload['amount']} - -Response: -{log_response} -""" - ) - - if not response.text: - - frappe.throw( - "Payment processor returned empty response" - ) - - result = urllib.parse.parse_qs(response.text) - - success = result.get( - "response", - ["0"] - )[0] - - transaction_id = result.get( - "transactionid", - [""] - )[0] - - message = result.get( - "responsetext", - ["Payment failed"] - )[0] - - payment_type = result.get( - "type", - [""] - )[0] - - except Exception: - - frappe.log_error( - frappe.get_traceback(), - "NMI Payment API Error" - ) - - frappe.throw( - "Payment processor unreachable" - ) - - if success == "1": - - frappe.log_error( - f""" -PAYMENT SUCCESS FAILSAFE - -Invoice: {invoice} -Transaction ID: {transaction_id} -Amount: {payload['amount']} -""", - "PAYMENT SUCCESS FAILSAFE" - ) - - if payment_type == "check": - mode_of_payment = "ACH" - else: - mode_of_payment = "Credit Card" - - existing_pe = frappe.db.exists( - "Payment Entry", - {"reference_no": transaction_id} - ) - - if existing_pe: - - return { - "success": True, - "transaction_id": transaction_id, - "duplicate": True - } - - try: - - create_payment_entry( - invoice=invoice, - amount=payload["amount"], - transaction_id=transaction_id, - mode_of_payment=mode_of_payment - ) - - frappe.db.commit() - - except Exception: - - frappe.log_error( - frappe.get_traceback(), - "PAYMENT ENTRY FAILURE AFTER SUCCESSFUL CHARGE" - ) - - return { - "success": True, - "transaction_id": transaction_id - } - - return { - "success": False, - "error": message - } - +# ── Token payment — now accepts invoice_names list ─────────────────────────── @frappe.whitelist() def run_token_payment( invoice, token, + invoice_names=None, first_name=None, last_name=None, company=None, billing_zip=None, save_autopay=0 ): - if not token: + return {"success": False, "error": "Missing payment token"} - return { - "success": False, - "error": "Missing payment token" - } + # invoice_names arrives as a JSON string from frappe.call args + if isinstance(invoice_names, str): + try: + invoice_names = json.loads(invoice_names) + except Exception: + invoice_names = [invoice] - if not frappe.conf.get( - "enable_autopay_signup" - ): + if not invoice_names: + invoice_names = [invoice] + + if not frappe.conf.get("enable_autopay_signup"): save_autopay = 0 - save_autopay = int(save_autopay or 0) - inv = frappe.get_doc( - "Sales Invoice", - invoice - ) + # Load all selected invoices and validate + invoices = [] + total_amount = 0 - customer = frappe.get_doc( - "Customer", - inv.customer - ) + for inv_name in invoice_names: + inv = frappe.get_doc("Sales Invoice", inv_name) + if inv.docstatus != 1: + frappe.throw(f"Invoice {inv_name} is not submitted") + if inv.outstanding_amount <= 0: + frappe.throw(f"Invoice {inv_name} is already fully paid") + invoices.append(inv) + total_amount += inv.outstanding_amount + + # Use customer from the primary invoice + primary_inv = frappe.get_doc("Sales Invoice", invoice) + customer = frappe.get_doc("Customer", primary_inv.customer) + + first_name = (first_name or customer.customer_name or "Customer").strip() + last_name = (last_name or ".").strip() + company = (company or "").strip() + billing_zip = (billing_zip or customer.get("billing_zip") or customer.get("pincode") or "") + + # Use a combined order ID referencing all invoices + inv_label = invoice if len(invoices) == 1 else f"{invoice}+{len(invoices)-1}more" + order_id = f"{inv_label}-{generate_hash(length=6)}" url = "https://secure.nmi.com/api/transact.php" - first_name = ( - first_name - or customer.customer_name - or "Customer" - ).strip() - - last_name = ( - last_name - or "." - ).strip() - - company = ( - company or "" - ).strip() - - billing_zip = ( - billing_zip - or customer.get("billing_zip") - or customer.get("pincode") - or "" - ) - - order_id = ( - f"{inv.name}-{generate_hash(length=6)}" - ) - sale_data = { - - "security_key": frappe.conf.get( - "nmi_security_key" - ), - - "type": "sale", - + "security_key": frappe.conf.get("nmi_security_key"), + "type": "sale", "payment_token": token, - - "amount": inv.outstanding_amount, - - "orderid": order_id, - - "first_name": first_name, - "last_name": last_name, - "company": company, - - "email": inv.contact_email or "", - - "zip": billing_zip, + "amount": total_amount, + "orderid": order_id, + "first_name": first_name, + "last_name": last_name, + "company": company, + "email": primary_inv.contact_email or "", + "zip": billing_zip, } - # Save to vault DURING sale transaction if save_autopay: - - sale_data["customer_vault"] = ( - "add_customer" - ) - - sale_data["customer_vault_id"] = ( - customer.name.upper() - ) + sale_data["customer_vault"] = "add_customer" + sale_data["customer_vault_id"] = customer.name.upper() frappe.log_error( - f""" -Sending SALE request - -invoice={inv.name} -order_id={order_id} - -customer={customer.name} - -save_autopay={save_autopay} - -amount={inv.outstanding_amount} -""", - "AUTOPAY DEBUG - SALE REQUEST" + f"invoice_names={invoice_names}\ntotal={total_amount}\norder_id={order_id}\nsave_autopay={save_autopay}", + "PAYMENT DEBUG - SALE REQUEST" ) try: - - sale_response = requests.post( - url, - data=sale_data, - timeout=30 - ) - - sale_result = urllib.parse.parse_qs( - sale_response.text - ) - - log_response = sale_response.text[:120] - - frappe.logger("payments").info( - f""" -NMI SALE RESPONSE - -Invoice: {inv.name} -Order ID: {order_id} - -Response: -{log_response} -""" - ) - - frappe.log_error( - log_response, - "AUTOPAY DEBUG - SALE RESPONSE" - ) - + sale_response = requests.post(url, data=sale_data, timeout=30) + sale_result = urllib.parse.parse_qs(sale_response.text) + frappe.log_error(sale_response.text[:120], "PAYMENT DEBUG - SALE RESPONSE") except Exception: + frappe.log_error(frappe.get_traceback(), "PAYMENT DEBUG - SALE EXCEPTION") + return {"success": False, "error": "Payment request failed"} - frappe.log_error( - frappe.get_traceback(), - "AUTOPAY DEBUG - SALE EXCEPTION" - ) - - return { - "success": False, - "error": "Payment request failed" - } - - success = sale_result.get( - "response", - ["0"] - )[0] - - transaction_id = sale_result.get( - "transactionid", - [""] - )[0] - - vault_id = sale_result.get( - "customer_vault_id", - [""] - )[0] + success = sale_result.get("response", ["0"])[0] + transaction_id = sale_result.get("transactionid", [""])[0] + vault_id = sale_result.get("customer_vault_id", [""])[0] if success != "1": - - frappe.log_error( - sale_response.text[:120], - "AUTOPAY DEBUG - SALE FAILED" - ) - + frappe.log_error(sale_response.text[:120], "PAYMENT DEBUG - SALE FAILED") return { "success": False, - "error": sale_result.get( - "responsetext", - ["Error"] - )[0] + "error": sale_result.get("responsetext", ["Error"])[0] } frappe.log_error( - f""" -PAYMENT SUCCESS FAILSAFE - -Invoice: {invoice} -Transaction ID: {transaction_id} -Amount: {inv.outstanding_amount} -""", - "PAYMENT SUCCESS FAILSAFE" + f"transaction_id={transaction_id}\ntotal={total_amount}\ninvoices={invoice_names}", + "PAYMENT SUCCESS" ) - existing_pe = frappe.db.exists( - "Payment Entry", - {"reference_no": transaction_id} - ) + # Duplicate check on transaction ID + if frappe.db.exists("Payment Entry", {"reference_no": transaction_id}): + return {"success": True, "transaction_id": transaction_id, "duplicate": True} - if not existing_pe: + try: + create_payment_entry( + invoices=invoices, + transaction_id=transaction_id, + mode_of_payment="Credit Card" + ) + frappe.db.commit() + except Exception: + frappe.log_error( + frappe.get_traceback(), + "PAYMENT ENTRY FAILURE AFTER SUCCESSFUL CHARGE" + ) - try: - - create_payment_entry( - invoice=invoice, - amount=inv.outstanding_amount, - transaction_id=transaction_id, - mode_of_payment="Credit Card" - ) - - frappe.db.commit() - - except Exception: - - frappe.log_error( - frappe.get_traceback(), - "PAYMENT ENTRY FAILURE AFTER SUCCESSFUL CHARGE" - ) - - # Save AutoPay info locally + # Save AutoPay info if save_autopay and vault_id: - try: - - customer.custom_auto_pay_id = ( - vault_id - ) - - customer.custom_auto_pay_status = 1 - - customer.custom_auto_pay_first_name = ( - first_name - ) - - customer.custom_auto_pay_last_name = ( - last_name - ) - - customer.custom_auto_pay_company = ( - company - ) - - customer.custom_auto_pay_zip = ( - billing_zip - ) - - customer.save( - ignore_permissions=True - ) - + customer.custom_auto_pay_id = vault_id + customer.custom_auto_pay_status = 1 + customer.custom_auto_pay_first_name = first_name + customer.custom_auto_pay_last_name = last_name + customer.custom_auto_pay_company = company + customer.custom_auto_pay_zip = billing_zip + customer.save(ignore_permissions=True) frappe.db.commit() - - frappe.log_error( - f""" -Vault save complete - -customer={customer.name} - -vault_id={vault_id} -""", - "AUTOPAY DEBUG - SAVE COMPLETE" - ) - except Exception: + frappe.log_error(frappe.get_traceback(), "AUTOPAY CUSTOMER SAVE FAILED") - frappe.log_error( - frappe.get_traceback(), - "AUTOPAY CUSTOMER SAVE FAILED" - ) - - return { - "success": True, - "transaction_id": transaction_id, - "vault_id": vault_id - } + return {"success": True, "transaction_id": transaction_id, "vault_id": vault_id} -@frappe.whitelist() -def save_to_autopay( - customer, - token, - first_name=None, - last_name=None, - company=None, - billing_zip=None -): +# ── Payment Entry — now takes a list of invoices ───────────────────────────── - if not token: +def create_payment_entry(invoices, transaction_id=None, mode_of_payment=None): + """ + Create a single Payment Entry covering one or more invoices. + `invoices` is a list of Sales Invoice document objects. + """ + if not invoices: + return - return { - "success": False, - "error": "Missing payment token" - } + if transaction_id and frappe.db.exists("Payment Entry", {"reference_no": transaction_id}): + return - cust = frappe.get_doc( - "Customer", - customer + primary_inv = invoices[0] + total_amount = sum(inv.outstanding_amount for inv in invoices) + + paid_to = ( + "ENB Bank Account - NIL" + if mode_of_payment in ["ACH", "Credit Card"] + else frappe.db.get_value("Company", primary_inv.company, "default_cash_account") ) - first_name = ( - first_name - or cust.customer_name - or "Customer" - ).strip() + if not paid_to: + frappe.throw("No receiving account configured") - last_name = ( - last_name - or "." - ).strip() + pe = frappe.new_doc("Payment Entry") + pe.payment_type = "Receive" + pe.party_type = "Customer" + pe.party = primary_inv.customer + pe.posting_date = nowdate() + pe.mode_of_payment = mode_of_payment or "Credit Card" + pe.paid_amount = total_amount + pe.received_amount = total_amount + pe.paid_to = paid_to + pe.reference_no = transaction_id + pe.reference_date = nowdate() - company = ( - company or "" - ).strip() + for inv in invoices: + pe.append("references", { + "reference_doctype": "Sales Invoice", + "reference_name": inv.name, + "allocated_amount": inv.outstanding_amount + }) - billing_zip = ( - billing_zip - or cust.get("billing_zip") - or cust.get("pincode") - or "" - ) + pe.insert(ignore_permissions=True) + pe.submit() + + +# ── AutoPay via vault (unchanged) ──────────────────────────────────────────── + +def call_payment_api(payload): + url = "https://secure.nmi.com/api/transact.php" + security_key = frappe.conf.get("nmi_security_key") + + if not security_key: + frappe.throw("Payment gateway credentials not configured") + + invoice = payload["invoice"] + order_id = f"{invoice}-{generate_hash(length=6)}" data = { - - "security_key": frappe.conf.get( - "nmi_security_key" - ), - - "type": "add_customer", - - "payment_token": token, - - "customer_vault": "add_customer", - - "customer_vault_id": cust.name.upper(), - - "first_name": first_name, - "last_name": last_name, - "company": company, - - "zip": billing_zip, + "security_key": security_key, + "type": "sale", + "customer_vault_id": payload["autopay_id"], + "amount": payload["amount"], + "orderid": order_id } try: - - response = requests.post( - "https://secure.nmi.com/api/transact.php", - data=data, - timeout=30 + response = requests.post(url, data=data, timeout=30) + frappe.logger("payments").info( + f"NMI AUTOPAY | Invoice: {invoice} | Order: {order_id} | {response.text[:120]}" ) + if not response.text: + frappe.throw("Payment processor returned empty response") - result = urllib.parse.parse_qs( - response.text - ) - - success = result.get( - "response", - ["0"] - )[0] - - returned_vault_id = result.get( - "customer_vault_id", - [cust.name.upper()] - )[0] - - message = result.get( - "responsetext", - ["Failed"] - )[0] + result = urllib.parse.parse_qs(response.text) + success = result.get("response", ["0"])[0] + transaction_id = result.get("transactionid", [""])[0] + message = result.get("responsetext", ["Payment failed"])[0] + payment_type = result.get("type", [""])[0] except Exception: + frappe.log_error(frappe.get_traceback(), "NMI Payment API Error") + frappe.throw("Payment processor unreachable") - frappe.log_error( - frappe.get_traceback(), - "NMI Vault Error" - ) + if success == "1": + mode_of_payment = "ACH" if payment_type == "check" else "Credit Card" - return { - "success": False, - "error": "Vault request failed" - } - - if ( - success == "1" - or "duplicate" in message.lower() - ): + if frappe.db.exists("Payment Entry", {"reference_no": transaction_id}): + return {"success": True, "transaction_id": transaction_id, "duplicate": True} try: - - cust.custom_auto_pay_id = ( - returned_vault_id + inv = frappe.get_doc("Sales Invoice", invoice) + create_payment_entry( + invoices=[inv], + transaction_id=transaction_id, + mode_of_payment=mode_of_payment ) - - cust.custom_auto_pay_status = 1 - - cust.custom_auto_pay_first_name = ( - first_name - ) - - cust.custom_auto_pay_last_name = ( - last_name - ) - - cust.custom_auto_pay_company = ( - company - ) - - cust.custom_auto_pay_zip = ( - billing_zip - ) - - cust.save(ignore_permissions=True) - frappe.db.commit() - except Exception: + frappe.log_error(frappe.get_traceback(), "PAYMENT ENTRY FAILURE AFTER SUCCESSFUL CHARGE") - frappe.log_error( - frappe.get_traceback(), - "AUTOPAY CUSTOMER SAVE FAILED" - ) + return {"success": True, "transaction_id": transaction_id} - return { - "success": True, - "vault_id": returned_vault_id - } + return {"success": False, "error": message} - return { - "success": False, - "error": message - } +# ── Webhook (unchanged except uses new create_payment_entry signature) ──────── @frappe.whitelist(allow_guest=True) def crystalclear_webhook(): - - data = frappe.local.form_dict - + data = frappe.local.form_dict if data.get("response") != "1": return "ignored" - invoice = data.get("orderid") - amount = data.get("amount") + invoice = data.get("orderid") + amount = data.get("amount") transaction_id = data.get("transactionid") - payment_type = data.get("type") - - if payment_type == "check": - mode_of_payment = "ACH" - else: - mode_of_payment = "Credit Card" + payment_type = data.get("type") + mode_of_payment = "ACH" if payment_type == "check" else "Credit Card" try: - + inv = frappe.get_doc("Sales Invoice", invoice) create_payment_entry( - invoice=invoice, - amount=amount, + invoices=[inv], transaction_id=transaction_id, mode_of_payment=mode_of_payment ) - frappe.db.commit() - except Exception: - - frappe.log_error( - frappe.get_traceback(), - "WEBHOOK PAYMENT ENTRY FAILURE" - ) + frappe.log_error(frappe.get_traceback(), "WEBHOOK PAYMENT ENTRY FAILURE") return "ok" -def create_payment_entry( - invoice, - amount, - transaction_id=None, - mode_of_payment=None -): +# ── save_to_autopay (unchanged) ─────────────────────────────────────────────── - if ( - transaction_id - and frappe.db.exists( - "Payment Entry", - {"reference_no": transaction_id} - ) - ): - return +@frappe.whitelist() +def save_to_autopay(customer, token, first_name=None, last_name=None, company=None, billing_zip=None): + if not token: + return {"success": False, "error": "Missing payment token"} - inv = frappe.get_doc( - "Sales Invoice", - invoice - ) + cust = frappe.get_doc("Customer", customer) + first_name = (first_name or cust.customer_name or "Customer").strip() + last_name = (last_name or ".").strip() + company = (company or "").strip() + billing_zip = (billing_zip or cust.get("billing_zip") or cust.get("pincode") or "") - if mode_of_payment in [ - "ACH", - "Credit Card" - ]: + data = { + "security_key": frappe.conf.get("nmi_security_key"), + "type": "add_customer", + "payment_token": token, + "customer_vault": "add_customer", + "customer_vault_id": cust.name.upper(), + "first_name": first_name, + "last_name": last_name, + "company": company, + "zip": billing_zip, + } - paid_to = "ENB Bank Account - NIL" + try: + response = requests.post("https://secure.nmi.com/api/transact.php", data=data, timeout=30) + result = urllib.parse.parse_qs(response.text) + success = result.get("response", ["0"])[0] + returned_vault_id = result.get("customer_vault_id", [cust.name.upper()])[0] + message = result.get("responsetext", ["Failed"])[0] + except Exception: + frappe.log_error(frappe.get_traceback(), "NMI Vault Error") + return {"success": False, "error": "Vault request failed"} - else: + if success == "1" or "duplicate" in message.lower(): + try: + cust.custom_auto_pay_id = returned_vault_id + cust.custom_auto_pay_status = 1 + cust.custom_auto_pay_first_name = first_name + cust.custom_auto_pay_last_name = last_name + cust.custom_auto_pay_company = company + cust.custom_auto_pay_zip = billing_zip + cust.save(ignore_permissions=True) + frappe.db.commit() + except Exception: + frappe.log_error(frappe.get_traceback(), "AUTOPAY CUSTOMER SAVE FAILED") - paid_to = frappe.db.get_value( - "Company", - inv.company, - "default_cash_account" - ) + return {"success": True, "vault_id": returned_vault_id} - if not paid_to: - - frappe.throw( - "No receiving account configured" - ) - - pe = frappe.new_doc("Payment Entry") - - pe.payment_type = "Receive" - - pe.party_type = "Customer" - pe.party = inv.customer - - pe.posting_date = nowdate() - - pe.mode_of_payment = ( - mode_of_payment - or "Credit Card" - ) - - pe.paid_amount = amount - pe.received_amount = amount - - pe.paid_to = paid_to - - pe.reference_no = transaction_id - pe.reference_date = nowdate() - - pe.append( - "references", - { - "reference_doctype": "Sales Invoice", - "reference_name": invoice, - "allocated_amount": amount - } - ) - - pe.insert(ignore_permissions=True) - - pe.submit() \ No newline at end of file + return {"success": False, "error": message} \ No newline at end of file diff --git a/ns_app/public/js/sales_invoice.js b/ns_app/public/js/sales_invoice.js index 0b9c6ed..662010d 100644 --- a/ns_app/public/js/sales_invoice.js +++ b/ns_app/public/js/sales_invoice.js @@ -2,11 +2,9 @@ frappe.ui.form.on("Sales Invoice", { refresh(frm) { frm.clear_custom_buttons(); - // Only on submitted invoices if (frm.doc.docstatus !== 1) return; if (!frm.doc.customer) return; - // Already paid if (frm.doc.outstanding_amount <= 0) { frm.dashboard.add_indicator("Paid", "green"); return; @@ -14,45 +12,27 @@ frappe.ui.form.on("Sales Invoice", { frm.dashboard.add_indicator("Unpaid", "red"); - if (frm.doc.outstanding_amount > 0 && frm.doc.docstatus === 1) { - frm.add_custom_button("Run Payment", () => { - run_payment_flow(frm); - }, "Actions"); - } + frm.add_custom_button("Run Payment", () => { + run_payment_flow(frm); + }, "Actions"); } }); function run_payment_flow(frm) { - frm.disable_save(); frappe.call({ method: "ns_app.api.payments.check_autopay", - - args: { - customer: frm.doc.customer - }, - + args: { customer: frm.doc.customer }, callback(r) { - frm.enable_save(); + if (!r.message) return; - if (!r.message) { - return; - } - - if ( - r.message.autopay_enabled - && r.message.autopay_id - ) { - + if (r.message.autopay_enabled && r.message.autopay_id) { run_autopay(frm); - } else { - open_manual_payment_form(frm); - } } }); @@ -60,597 +40,368 @@ function run_payment_flow(frm) { function run_autopay(frm) { - frappe.confirm( `Run AutoPay for ${format_currency(frm.doc.outstanding_amount)}?`, - () => { - frm.remove_custom_button("Run Payment"); - - frm.add_custom_button( - "Processing...", - () => {}, - null - ).prop("disabled", true); + frm.add_custom_button("Processing...", () => {}, null).prop("disabled", true); frappe.call({ method: "ns_app.api.payments.run_autopay_payment", - - args: { - invoice: frm.doc.name - }, - + args: { invoice: frm.doc.name }, freeze: true, freeze_message: "Processing payment...", - callback(r) { - if (!r.message) { - - show_payment_failed( - frm, - "No response from payment processor" - ); - + show_payment_failed(frm, "No response from payment processor"); return; } - if (r.message.success) { - - frm.remove_custom_button( - "Run Payment" - ); - - frm.add_custom_button( - "Paid ✓", - () => {} - ).prop("disabled", true); - + frm.remove_custom_button("Run Payment"); + frm.add_custom_button("Paid ✓", () => {}).prop("disabled", true); frappe.show_alert({ - message: - `Payment of ${format_currency(frm.doc.outstanding_amount)} received`, + message: `Payment of ${format_currency(frm.doc.outstanding_amount)} received`, indicator: "green" }); - frm.reload_doc(); - } else { - - show_payment_failed( - frm, - r.message.error || "Payment declined" - ); - + show_payment_failed(frm, r.message.error || "Payment declined"); } } }); - }, - () => {} ); } function show_payment_failed(frm, message) { - frm.remove_custom_button("Processing..."); - - frm.add_custom_button( - "Retry Payment", - () => { - run_payment_flow(frm); - } - ); - - frappe.msgprint({ - title: "Payment Failed", - indicator: "red", - message: message - }); - + frm.add_custom_button("Retry Payment", () => run_payment_flow(frm)); + frappe.msgprint({ title: "Payment Failed", indicator: "red", message }); } function open_manual_payment_form(frm) { - const uid = Date.now(); - window.ns_payment_processing = false; + let all_invoices = []; + let selected = new Set(); + const dialog = new frappe.ui.Dialog({ title: "Secure Payment", - size: "large", - fields: [ { fieldtype: "HTML", - fieldname: "payment_form", - options: ` -
- -
- - - -
- -
- - - -
- -
- - - -
- -
- - - -
- -
- -
- -
- -
- -
- - - +
+
+ +
- ` +
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+ +
+
+
+ +
` } ], - primary_action_label: "Close", - - primary_action() { - dialog.hide(); - } + primary_action() { dialog.hide(); } }); dialog.show(); - dialog.$wrapper.on( - "hidden.bs.modal", - function () { - - document.querySelectorAll( - ".modal-backdrop" - ).forEach(el => el.remove()); - - document.body.classList.remove( - "modal-open" - ); - - document.body.style.overflow = ""; - - dialog.$wrapper.remove(); - - window.ns_payment_processing = false; - - if (window.CollectJS) { - - try { - - delete window.CollectJS; - - } catch (e) {} - - } - - } - ); + // ── Cleanup on close ───────────────────────────────────────────────────── + dialog.$wrapper.on("hidden.bs.modal", function () { + document.querySelectorAll(".modal-backdrop").forEach(el => el.remove()); + document.body.classList.remove("modal-open"); + document.body.style.overflow = ""; + dialog.$wrapper.remove(); + window.ns_payment_processing = false; + if (window.CollectJS) { try { delete window.CollectJS; } catch(e) {} } + }); + // ── Prefill billing fields ──────────────────────────────────────────────── setTimeout(() => { - - const firstNameEl = - document.getElementById( - `first_name_${uid}` - ); - - const lastNameEl = - document.getElementById( - `last_name_${uid}` - ); - - const companyEl = - document.getElementById( - `company_${uid}` - ); - - const zipEl = - document.getElementById( - `billing_zip_${uid}` - ); - - const customerName = - frm.doc.customer_name || ""; - - const parts = - customerName.trim().split(" "); - - if (firstNameEl) { - firstNameEl.value = parts[0] || ""; - } - - if (lastNameEl) { - lastNameEl.value = - parts.slice(1).join(" ") || ""; - } - - if (companyEl) { - companyEl.value = - frm.doc.customer || ""; - } - - if (zipEl) { - - zipEl.value = - frm.doc.billing_zip - || frm.doc.pincode - || ""; - - } - + const parts = (frm.doc.customer_name || "").trim().split(" "); + const set = (id, val) => { const el = document.getElementById(id); if (el) el.value = val; }; + set(`first_name_${uid}`, parts[0] || ""); + set(`last_name_${uid}`, parts.slice(1).join(" ") || ""); + set(`company_${uid}`, frm.doc.customer || ""); + set(`billing_zip_${uid}`, frm.doc.billing_zip || frm.doc.pincode || ""); }, 50); + // ── Helpers ─────────────────────────────────────────────────────────────── + function get_selected_total() { + return all_invoices + .filter(inv => selected.has(inv.name)) + .reduce((sum, inv) => sum + inv.outstanding_amount, 0); + } + function update_pay_button() { + const total = selected.size > 0 ? get_selected_total() : frm.doc.outstanding_amount; + const btn = document.getElementById(`pay_btn_${uid}`); + if (btn && !btn.disabled) btn.innerText = `Pay ${format_currency(total)}`; + const totalEl = document.getElementById(`selected_total_${uid}`); + if (totalEl) totalEl.innerText = format_currency(total); + } + + function render_invoice_table(invoices) { + all_invoices = invoices; + + const tbody = document.getElementById(`invoice_tbody_${uid}`); + const table = document.getElementById(`invoice_table_${uid}`); + const loading = document.getElementById(`invoice_table_loading_${uid}`); + + if (!tbody) return; + + tbody.innerHTML = ""; + selected.clear(); + + invoices.forEach(inv => { + if (inv.name === frm.doc.name) selected.add(inv.name); + + const tr = document.createElement("tr"); + tr.setAttribute("data-invoice", inv.name); + tr.innerHTML = ` + + + + ${inv.name} + ${frappe.datetime.str_to_user(inv.posting_date)} + ${inv.customer_name || frm.doc.customer_name} + ${format_currency(inv.outstanding_amount)} + `; + tbody.appendChild(tr); + }); + + // Row checkbox events — use delegation on tbody + $(tbody).on("change", `.inv-check-${uid}`, function () { + if (this.checked) selected.add(this.dataset.name); + else selected.delete(this.dataset.name); + update_pay_button(); + + const all = tbody.querySelectorAll(`.inv-check-${uid}`); + const selectAll = document.getElementById(`select_all_${uid}`); + if (selectAll) selectAll.checked = [...all].every(c => c.checked); + }); + + // Select-all + $(dialog.$wrapper).on("change", `#select_all_${uid}`, function () { + tbody.querySelectorAll(`.inv-check-${uid}`).forEach(cb => { + cb.checked = this.checked; + if (this.checked) selected.add(cb.dataset.name); + else selected.delete(cb.dataset.name); + }); + update_pay_button(); + }); + + loading.style.display = "none"; + table.style.display = ""; + update_pay_button(); + } + + // ── Multi-invoice checkbox — delegated, no setTimeout needed ───────────── + dialog.$wrapper.on("change", `#multi_invoice_${uid}`, function () { + const tableWrap = document.getElementById(`invoice_table_wrap_${uid}`); + if (!tableWrap) return; + + if (!this.checked) { + tableWrap.style.display = "none"; + selected.clear(); + update_pay_button(); + return; + } + + tableWrap.style.display = ""; + + if (all_invoices.length > 0) { + render_invoice_table(all_invoices); + return; + } + + frappe.call({ + method: "ns_app.api.payments.get_unpaid_invoices", + args: { customer: frm.doc.customer }, + callback(r) { + if (r.message && r.message.length) { + render_invoice_table(r.message); + } else { + const loading = document.getElementById(`invoice_table_loading_${uid}`); + if (loading) loading.innerText = "No other unpaid invoices found."; + } + } + }); + }); + + // ── CollectJS ───────────────────────────────────────────────────────────── function loadCollectJS(callback) { + const existing = document.querySelector('script[src*="Collect.js"]'); + if (existing) existing.remove(); + if (window.CollectJS) { try { delete window.CollectJS; } catch(e) {} } - const existingScript = document.querySelector( - 'script[src*="Collect.js"]' - ); - - if (existingScript) { - existingScript.remove(); - } - - if (window.CollectJS) { - - try { - - delete window.CollectJS; - - } catch (e) {} - - } - - const script = - document.createElement("script"); - - script.src = - "https://secure.nmi.com/token/Collect.js"; - - script.setAttribute( - "data-tokenization-key", - "HKx4XR-G549wT-8bZ2YJ-3kbG28" - ); - - script.onload = () => { - - console.log( - "CollectJS loaded fresh" - ); - - callback(); - }; - + const script = document.createElement("script"); + script.src = "https://secure.nmi.com/token/Collect.js"; + script.setAttribute("data-tokenization-key", "HKx4XR-G549wT-8bZ2YJ-3kbG28"); + script.onload = () => { console.log("CollectJS loaded"); callback(); }; document.body.appendChild(script); } - loadCollectJS(() => { - - console.log("CollectJS ready"); - setTimeout(() => { - CollectJS.configure({ - variant: "inline", - styleSniffer: true, - fields: { - - ccnumber: { - selector: - `#cc_number_${uid}`, - - placeholder: - "Card Number" - }, - - ccexp: { - selector: - `#cc_exp_${uid}`, - - placeholder: - "MM / YY" - }, - - cvv: { - selector: - `#cc_cvv_${uid}`, - - placeholder: - "CVV" - } + ccnumber: { selector: `#cc_number_${uid}`, placeholder: "Card Number" }, + ccexp: { selector: `#cc_exp_${uid}`, placeholder: "MM / YY" }, + cvv: { selector: `#cc_cvv_${uid}`, placeholder: "CVV" } }, - - callback: function (response) { - - if ( - window.ns_payment_processing - ) { - return; - } - + callback(response) { + if (window.ns_payment_processing) return; window.ns_payment_processing = true; if (!response.token) { - window.ns_payment_processing = false; - - frappe.msgprint( - "Payment failed to tokenize" - ); - + frappe.msgprint("Payment failed to tokenize"); return; } - const firstName = - document.getElementById( - `first_name_${uid}` - )?.value?.trim(); + const get = id => document.getElementById(`${id}_${uid}`)?.value?.trim(); + const saveCb = document.getElementById(`save_autopay_${uid}`); + const multiCb = document.getElementById(`multi_invoice_${uid}`); - const lastName = - document.getElementById( - `last_name_${uid}` - )?.value?.trim(); + const invoice_names = (multiCb?.checked && selected.size > 0) + ? [...selected] + : [frm.doc.name]; - const company = - document.getElementById( - `company_${uid}` - )?.value?.trim(); + const payBtn = document.getElementById(`pay_btn_${uid}`); + if (payBtn) { payBtn.disabled = true; payBtn.innerText = "Processing..."; } - const billingZip = - document.getElementById( - `billing_zip_${uid}` - )?.value?.trim(); - - const checkbox = - document.getElementById( - `save_autopay_${uid}` - ); - - const save_autopay = - checkbox?.checked ? 1 : 0; - - console.log( - "AUTOPAY CHECKBOX:", - save_autopay - ); - - const payBtn = - document.getElementById( - `pay_btn_${uid}` - ); - - if (payBtn) { - - payBtn.disabled = true; - - payBtn.innerText = - "Processing..."; - - } - - run_token_payment( - frm, - response.token, - dialog, - { - first_name: firstName, - last_name: lastName, - company: company, - billing_zip: billingZip, - save_autopay: save_autopay - } - ); + run_token_payment(frm, response.token, dialog, { + first_name: get("first_name"), + last_name: get("last_name"), + company: get("company"), + billing_zip: get("billing_zip"), + save_autopay: saveCb?.checked ? 1 : 0, + invoice_names + }); } }); - const btn = - document.getElementById( - `pay_btn_${uid}` - ); - - if (!btn) { - - console.error( - "Pay button not found" - ); - - return; - } + const btn = document.getElementById(`pay_btn_${uid}`); + if (!btn) { console.error("Pay button not found"); return; } btn.onclick = function () { - - if ( - window.ns_payment_processing - ) { - return; - } - + if (window.ns_payment_processing) return; btn.disabled = true; - btn.innerText = "Processing..."; - - frappe.show_alert({ - message: - "Processing payment...", - - indicator: "blue" - }); - + frappe.show_alert({ message: "Processing payment...", indicator: "blue" }); CollectJS.startPaymentRequest(); }; - }, 300); - }); - } -function run_token_payment( - frm, - token, - dialog, - extra_data = {} -) { - +function run_token_payment(frm, token, dialog, extra_data = {}) { frappe.call({ - - method: - "ns_app.api.payments.run_token_payment", - + method: "ns_app.api.payments.run_token_payment", args: { - - invoice: frm.doc.name, - - token: token, - - first_name: - extra_data.first_name, - - last_name: - extra_data.last_name, - - company: - extra_data.company, - - billing_zip: - extra_data.billing_zip, - - save_autopay: - extra_data.save_autopay || 0 + invoice: frm.doc.name, + invoice_names: extra_data.invoice_names || [frm.doc.name], + token, + first_name: extra_data.first_name, + last_name: extra_data.last_name, + company: extra_data.company, + billing_zip: extra_data.billing_zip, + save_autopay: extra_data.save_autopay || 0 }, - freeze: true, - - freeze_message: - "Processing payment...", - + freeze_message: "Processing payment...", callback(r) { - if (r.message?.success) { - - if ( - extra_data.save_autopay - && r.message.vault_id - ) { - - frappe.show_alert({ - message: - `Payment successful + AutoPay enabled (${r.message.vault_id})`, - - indicator: "green" - }); - - } else { - - frappe.show_alert({ - message: - "Payment successful", - - indicator: "green" - }); - - } - + frappe.show_alert({ + message: extra_data.save_autopay && r.message.vault_id + ? `Payment successful + AutoPay enabled (${r.message.vault_id})` + : "Payment successful", + indicator: "green" + }); window.ns_payment_processing = false; - dialog.hide(); - frm.reload_doc(); - } else { - window.ns_payment_processing = false; - - frappe.msgprint( - r.message?.error - || "Payment failed" - ); - - const payBtn = - document.querySelector( - '[id^="pay_btn_"]' - ); - + frappe.msgprint(r.message?.error || "Payment failed"); + const payBtn = document.querySelector('[id^="pay_btn_"]'); if (payBtn) { - payBtn.disabled = false; - - payBtn.innerText = - `Pay ${format_currency(frm.doc.outstanding_amount)}`; - + payBtn.innerText = `Pay ${format_currency(frm.doc.outstanding_amount)}`; } - } } }); -- 2.39.5