From 4e0acde16409a8be49df99b1d78af3fe721aaaf2 Mon Sep 17 00:00:00 2001 From: Ty Reynolds Date: Thu, 11 Jun 2026 09:36:01 -0400 Subject: [PATCH] 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)}`; } - } } });