From 8c4f1f753e351849ac89e3cfda73070daf0a1a3e Mon Sep 17 00:00:00 2001 From: Ty Reynolds Date: Fri, 15 May 2026 13:47:35 -0400 Subject: [PATCH] Finished sign up for auto pay feature from pay invoice form. Also, added a doc file for the payment flow. --- ns_app/api/payment_flow_documentation.md | 203 +++++++ ns_app/api/payments.py | 665 +++++++++++++++++------ ns_app/public/js/sales_invoice.js | 527 ++++++++++++++---- 3 files changed, 1107 insertions(+), 288 deletions(-) create mode 100644 ns_app/api/payment_flow_documentation.md diff --git a/ns_app/api/payment_flow_documentation.md b/ns_app/api/payment_flow_documentation.md new file mode 100644 index 0000000..5b6b251 --- /dev/null +++ b/ns_app/api/payment_flow_documentation.md @@ -0,0 +1,203 @@ +Author: Ty Reynolds +Description: Full flow of what happens now from the moment a user opens the payment form through vault creation and payment entry generation. + + +1. User Opens “Pay Invoice” Form + +Frontend JS runs: + +open_manual_payment_form(frm) + +This creates a custom Frappe dialog containing: + +Cardholder Name +Billing ZIP +Secure card fields from Collect.js +“Subscribe to Auto Pay” checkbox +Pay button + +At this point, no card data touches ERPNext, Collect.js owns the secure fields + +2. Collect.js Initializes Secure Fields + +These divs: + +
+
+
+ +are replaced by NMI-hosted iframe fields. + +This is important because card numbers never enter our JS, CVV never reaches ERPNext, PCI scope stays much lower and we don't have worry. + +3. User Enters Card Info + +User types: + +card number +expiration +CVV +ZIP +cardholder name + +Optionally checks: + +Subscribe to Auto Pay + + +4. User Clicks “Pay” + +JS gathers "const save_autopay = checkbox.checked ? 1 : 0;". Then Collect.js tokenizes the card. Instead of returning card data, NMI returns "payment_token". This token represents the card securely. + +5. Frontend Calls Backend API + +Frontend sends: + +frappe.call({ + method: "ns_app.api.payments.run_token_payment", + args: { + invoice, + token, + cardholder_name, + billing_zip, + save_autopay + } +}) + +ERPNext now receives: + +token +invoice +checkbox value +customer metadata + +But not the raw card data. + +6. Backend Starts run_token_payment() + +Python method begins: + +run_token_payment(...) + +First debug logs run: + +AUTOPAY DEBUG - START + +This is to confirm invoice arrived and checkbox value arrived + +7. Feature Flag Protection Runs + +This executes: + +if not frappe.conf.get("enable_autopay_signup"): + save_autopay = 0 + +Meaning checkbox can exist but vaulting can be globally disabled + +Then "save_autopay = int(save_autopay or 0)" normalizes the value safely. + +8. Invoice + Customer Are Loaded + +Backend loads: + +inv = frappe.get_doc("Sales Invoice", invoice) +customer = frappe.get_doc("Customer", inv.customer) + +Now the system knows exact invoice, ERP customer and outstanding balance + +9. Name + ZIP Are Prepared + +Fallback logic determines first_name, last_name and billing_zip + +This is used both for payment and vault creation + +10. SALE Transaction Runs + +Backend sends: + +sale_data = { + "type": "sale", + "payment_token": token, + ... +} + +to: + +https://secure.nmi.com/api/transact.php + +NMI processes the payment. + +11. NMI Returns Payment Response + +NMI returns query-string style data: +response=1 +transactionid=123456 + +Python then parses it. + +12. Payment Success Check + +If response == "1", then payment succeeded. + +If not: + +Error returned to frontend, payment entry NOT created + +13. ERPNext Payment Entry Is Created + +Code runs: + +create_payment_entry(...) + +This creates Payment Entry, allocates against invoice, submits it automatically and invoice balance updates immediately. + +14. Vault Logic Begins (Only If Checked) + +This section runs ONLY if save_autopay == 1, which means the box is checked, and feature flag enabled. + +Debug log: + +AUTOPAY DEBUG - VAULT ENTRY + +15. Vault Request Sent to NMI + +Backend sends customer_vault=add_customer and payment_token + +This tells NMI to store this payment method securely + +16. NMI Creates Customer Vault Entry + +NMI returns response=1 customer_vault_id=****** + +This vault ID is the reusable payment profile. + +No card data is stored in ERPNext. + +Only the vault reference. + +17. ERPNext Customer Is Updated + +If successful: + +customer.custom_auto_pay_id = vault_id and customer.custom_auto_pay_status = 1 + +Now the ERP Customer is marked as enrolled in autopay and linked to NMI vault profile + +18. Autopay Transaction Flow + +Autopay requests send: + +"type": "sale", +"customer_vault_id": vault_id + +NMI charges the saved method directly. + +19. Webhook Safety + +If NMI later posts payment confirmations crystalclear_webhook() can also create payment entries automatically. + +Security Model: + +Our system is currently designed to -> Never have ERPNext store card numbers. CVV never touches our backend. Collect.js handles PCI-sensitive data. NMI stores vault securely. ERPNext only stores vault ID + +root@erpnext:/home/norman/frappe-bench# \ No newline at end of file diff --git a/ns_app/api/payments.py b/ns_app/api/payments.py index 1c68b26..dfccc2b 100644 --- a/ns_app/api/payments.py +++ b/ns_app/api/payments.py @@ -1,22 +1,29 @@ import frappe import requests import urllib.parse + +from frappe import generate_hash from frappe.utils import nowdate -from frappe.utils import cint @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 + ) } @frappe.whitelist() def run_autopay_payment(invoice): + inv = frappe.get_doc("Sales Invoice", invoice) if inv.outstanding_amount <= 0: @@ -24,8 +31,13 @@ def run_autopay_payment(invoice): 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, @@ -36,70 +48,133 @@ def run_autopay_payment(invoice): response = call_payment_api(payload) if not response.get("success"): - frappe.throw(response.get("error", "Payment failed")) - payment_entry = create_payment_entry( - invoice=inv.name, - amount=payload["amount"], - transaction_id=response.get("transaction_id") - ) + frappe.throw( + response.get("error", "Payment failed") + ) return { + "success": True, "message": "AutoPay payment successful", - "payment_entry": payment_entry + "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") + 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": invoice + + "orderid": order_id } try: - response = requests.post(url, data=data, timeout=30) + + response = requests.post( + url, + data=data, + timeout=30 + ) + response.raise_for_status() + + log_response = response.text[:120] + frappe.logger("payments").info( - f"NMI PAYMENT | Invoice: {invoice} | Amount: {payload['amount']} | Response: {response.text}" + 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") - # Parse gateway response + 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] + 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 Payment API Error" + ) + + frappe.throw( + "Payment processor unreachable" + ) if success == "1": - # Detect payment mode 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 + } + create_payment_entry( invoice=invoice, amount=payload["amount"], @@ -116,202 +191,406 @@ def call_payment_api(payload): "success": False, "error": message } - + @frappe.whitelist() -def run_token_payment(invoice, token, cardholder_name=None, billing_zip=None, save_autopay=0): +def run_token_payment( + invoice, + token, + first_name=None, + last_name=None, + company=None, + billing_zip=None, + save_autopay=0 +): - if not frappe.conf.get("enable_autopay_signup"): + if not token: + + return { + "success": False, + "error": "Missing payment token" + } + + if not frappe.conf.get( + "enable_autopay_signup" + ): save_autopay = 0 - inv = frappe.get_doc("Sales Invoice", invoice) - customer = frappe.get_doc("Customer", inv.customer) + save_autopay = int(save_autopay or 0) + + inv = frappe.get_doc( + "Sales Invoice", + invoice + ) + + customer = frappe.get_doc( + "Customer", + inv.customer + ) url = "https://secure.nmi.com/api/transact.php" - # Fallback logic - customer_name = ( - cardholder_name - or inv.customer_name - or inv.customer - or "Customer" - ) - - billing_zip = ( - billing_zip - or inv.get("billing_zip") - or inv.get("pincode") - or "" - ) - - # Name split - parts = customer_name.strip().split(" ", 1) - first_name = parts[0] - last_name = parts[1] if len(parts) > 1 else "." - - - sale_data = { - "security_key": frappe.conf.get("nmi_security_key"), - "type": "sale", - "payment_token": token, - "amount": inv.outstanding_amount, - "orderid": inv.name, - - "first_name": first_name, - "last_name": last_name, - "email": inv.contact_email or "", - "zip": billing_zip, - } - - sale_response = requests.post(url, data=sale_data) - sale_result = urllib.parse.parse_qs(sale_response.text) - - frappe.logger("payments").info(f"NMI SALE RESPONSE: {sale_response.text}") - - success = sale_result.get("response", ["0"])[0] - transaction_id = sale_result.get("transactionid", [""])[0] - - if success != "1": - return { - "success": False, - "error": sale_result.get("responsetext", ["Error"])[0] - } - - # Create payment entry - create_payment_entry( - invoice=invoice, - amount=inv.outstanding_amount, - transaction_id=transaction_id, - mode_of_payment="Credit Card" - ) - - vault_id = None - - # Vault (if checked) - if cint(save_autopay): - vault_data = { - "security_key": frappe.conf.get("nmi_security_key"), - "type": "add_customer", - "payment_token": token, - - "first_name": first_name, - "last_name": last_name, - "zip": billing_zip, - - "customer_vault": "add_customer" - } - - try: - vault_response = requests.post(url, data=vault_data, timeout=30) - vault_result = urllib.parse.parse_qs(vault_response.text) - - frappe.logger("payments").info(f"NMI VAULT RESPONSE: {vault_response.text}") - - vault_success = vault_result.get("response", ["0"])[0] - vault_id = vault_result.get("customer_vault_id", [""])[0] - - if vault_success == "1" and vault_id: - customer.custom_auto_pay_id = vault_id - customer.custom_auto_pay_status = 1 - customer.save(ignore_permissions=True) - - except Exception: - frappe.log_error(frappe.get_traceback(), "Vault Creation Failed") - - return { - "success": True, - "vault_id": vault_id - } - -@frappe.whitelist() -def save_to_autopay(customer, token, cardholder_name=None, billing_zip=None): - - cust = frappe.get_doc("Customer", customer) - - # Priority: Form input → Customer record → fallback - final_name = ( - cardholder_name - or cust.customer_name - or cust.name + first_name = ( + first_name + or customer.customer_name or "Customer" ).strip() - # ZIP fallback chain - final_zip = ( + 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", + + "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, + } + + # Save to vault DURING sale transaction + if save_autopay: + + 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" + ) + + 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" + ) + + except Exception: + + 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] + + if success != "1": + + frappe.log_error( + sale_response.text[:120], + "AUTOPAY DEBUG - SALE FAILED" + ) + + return { + "success": False, + "error": sale_result.get( + "responsetext", + ["Error"] + )[0] + } + + existing_pe = frappe.db.exists( + "Payment Entry", + {"reference_no": transaction_id} + ) + + if not existing_pe: + + create_payment_entry( + invoice=invoice, + amount=inv.outstanding_amount, + transaction_id=transaction_id, + mode_of_payment="Credit Card" + ) + + # Save AutoPay info locally + if save_autopay and vault_id: + + 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" + ) + + 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 +): + + if not token: + + return { + "success": False, + "error": "Missing payment token" + } + + 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 "" ) - # Optional but recommended fields - email = cust.get("email_id") or "" - - # Safe name split - parts = final_name.split(" ", 1) - first_name = parts[0] - last_name = parts[1] if len(parts) > 1 else "." - data = { - "security_key": frappe.conf.get("nmi_security_key"), + + "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, - "email": email, + "company": company, - "zip": final_zip, + "zip": billing_zip, } try: + response = requests.post( "https://secure.nmi.com/api/transact.php", data=data, timeout=30 ) - result = urllib.parse.parse_qs(response.text) + result = urllib.parse.parse_qs( + response.text + ) - frappe.logger("payments").info({ - "vault_request": data, - "vault_response": response.text - }) + success = result.get( + "response", + ["0"] + )[0] - success = result.get("response", ["0"])[0] - vault_id = result.get("customer_vault_id", [""])[0] - message = result.get("responsetext", ["Failed"])[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"} - if success == "1" and vault_id: + frappe.log_error( + frappe.get_traceback(), + "NMI Vault Error" + ) + + return { + "success": False, + "error": "Vault request failed" + } + + if ( + success == "1" + or "duplicate" in message.lower() + ): + + cust.custom_auto_pay_id = ( + returned_vault_id + ) - # Save vault ID to ERP Customer - cust.custom_auto_pay_id = vault_id cust.custom_auto_pay_status = 1 - cust.custom_auto_pay_name = final_name - cust.custom_auto_pay_zip = final_zip + 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() + return { "success": True, - "vault_id": vault_id + "vault_id": returned_vault_id } return { "success": False, "error": message } - + @frappe.whitelist(allow_guest=True) def crystalclear_webhook(): + data = frappe.local.form_dict if data.get("response") != "1": @@ -337,20 +616,36 @@ def crystalclear_webhook(): return "ok" -def create_payment_entry(invoice, amount, transaction_id=None, mode_of_payment=None): +def create_payment_entry( + invoice, + amount, + transaction_id=None, + mode_of_payment=None +): - # Prevent duplicates - if transaction_id and frappe.db.exists( - "Payment Entry", {"reference_no": transaction_id} + if ( + transaction_id + and frappe.db.exists( + "Payment Entry", + {"reference_no": transaction_id} + ) ): return - inv = frappe.get_doc("Sales Invoice", invoice) + inv = frappe.get_doc( + "Sales Invoice", + invoice + ) + + if mode_of_payment in [ + "ACH", + "Credit Card" + ]: - # Account logic - if mode_of_payment in ["ACH", "Credit Card"]: paid_to = "ENB Bank Account - NIL" + else: + paid_to = frappe.db.get_value( "Company", inv.company, @@ -358,30 +653,42 @@ def create_payment_entry(invoice, amount, transaction_id=None, mode_of_payment=N ) if not paid_to: - frappe.throw("No receiving account configured") + + 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.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.append( + "references", + { + "reference_doctype": "Sales Invoice", + "reference_name": invoice, + "allocated_amount": amount + } + ) pe.insert(ignore_permissions=True) - pe.submit() - return pe.name \ No newline at end of file + pe.submit() \ 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 3ddb486..0b9c6ed 100644 --- a/ns_app/public/js/sales_invoice.js +++ b/ns_app/public/js/sales_invoice.js @@ -24,21 +24,35 @@ frappe.ui.form.on("Sales Invoice", { 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) { - frm.enable_save(); 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); + } } }); @@ -49,37 +63,53 @@ function run_autopay(frm) { frappe.confirm( `Run AutoPay for ${format_currency(frm.doc.outstanding_amount)}?`, + () => { - // Change button to processing 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 }, + 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) { - // Success UI - frm.remove_custom_button("Run Payment"); + frm.remove_custom_button( + "Run Payment" + ); - frm.add_custom_button("Paid ✓", () => {}) - .prop("disabled", true); + 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" }); @@ -87,26 +117,32 @@ function run_autopay(frm) { } 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) { - // Remove processing button frm.remove_custom_button("Processing..."); - // Add retry button - frm.add_custom_button("Retry Payment", () => { - run_payment_flow(frm); - }); + frm.add_custom_button( + "Retry Payment", + () => { + run_payment_flow(frm); + } + ); frappe.msgprint({ title: "Payment Failed", @@ -119,105 +155,223 @@ function show_payment_failed(frm, message) { function open_manual_payment_form(frm) { - const uid = Date.now(); // unique per open + const uid = Date.now(); + + window.ns_payment_processing = false; const dialog = new frappe.ui.Dialog({ title: "Secure Payment", + size: "large", + fields: [ { fieldtype: "HTML", + fieldname: "payment_form", + options: `
- - + + + +
+ +
+ + + +
+ +
+ + +
- + +
-
-
-
-
+
- +
` } ], - - // Destroy CollectJS instance if close button is clicked + primary_action_label: "Close", + primary_action() { - dialog.hide(); + dialog.hide(); } }); dialog.show(); - // Destroy CollectJS Instance if closed with the X button - dialog.$wrapper.on("hidden.bs.modal", function () { + dialog.$wrapper.on( + "hidden.bs.modal", + function () { - // Remove leftover backdrop (fix dark screen) - document.querySelectorAll(".modal-backdrop").forEach(el => el.remove()); + document.querySelectorAll( + ".modal-backdrop" + ).forEach(el => el.remove()); - // Restore body scroll + click - document.body.classList.remove("modal-open"); - document.body.style.overflow = ""; + document.body.classList.remove( + "modal-open" + ); - // safely remove dialog - dialog.$wrapper.remove(); + document.body.style.overflow = ""; - // WClean CollectJS - if (window.CollectJS) { - try { - delete window.CollectJS; - } catch (e) {} + dialog.$wrapper.remove(); + + window.ns_payment_processing = false; + + if (window.CollectJS) { + + try { + + delete window.CollectJS; + + } catch (e) {} + + } + + } + ); + + 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(" ") || ""; + } - // Prefill - setTimeout(() => { - const nameEl = document.getElementById(`cardholder_name_${uid}`); - const zipEl = document.getElementById(`billing_zip_${uid}`); + if (companyEl) { + companyEl.value = + frm.doc.customer || ""; + } + + if (zipEl) { + + zipEl.value = + frm.doc.billing_zip + || frm.doc.pincode + || ""; + + } - if (nameEl) nameEl.value = frm.doc.customer_name || ""; - if (zipEl) zipEl.value = frm.doc.billing_zip || frm.doc.pincode || ""; }, 50); - // Load CollectJS once function loadCollectJS(callback) { - // Remove existing script if it exists - const existingScript = document.querySelector('script[src*="Collect.js"]'); + const existingScript = document.querySelector( + 'script[src*="Collect.js"]' + ); + if (existingScript) { existingScript.remove(); } - // Reset global if (window.CollectJS) { - delete window.CollectJS; + + try { + + delete window.CollectJS; + + } catch (e) {} + } - // Create fresh script every instance - const script = document.createElement("script"); - script.src = "https://secure.nmi.com/token/Collect.js"; + const script = + document.createElement("script"); + + script.src = + "https://secure.nmi.com/token/Collect.js"; script.setAttribute( "data-tokenization-key", @@ -225,7 +379,11 @@ function open_manual_payment_form(frm) { ); script.onload = () => { - console.log("CollectJS loaded fresh"); + + console.log( + "CollectJS loaded fresh" + ); + callback(); }; @@ -234,114 +392,265 @@ function open_manual_payment_form(frm) { loadCollectJS(() => { + console.log("CollectJS ready"); setTimeout(() => { - CollectJS.configure({ + variant: "inline", + styleSniffer: true, fields: { + ccnumber: { - selector: `#cc_number_${uid}`, - placeholder: "Card Number" + selector: + `#cc_number_${uid}`, + + placeholder: + "Card Number" }, + ccexp: { - selector: `#cc_exp_${uid}`, - placeholder: "MM / YY" + selector: + `#cc_exp_${uid}`, + + placeholder: + "MM / YY" }, + cvv: { - selector: `#cc_cvv_${uid}`, - placeholder: "CVV" + selector: + `#cc_cvv_${uid}`, + + placeholder: + "CVV" } }, callback: function (response) { - if (response.token) { - // Get name and ZIP - const enteredName = document.getElementById(`cardholder_name_${uid}`)?.value; - const enteredZip = document.getElementById(`billing_zip_${uid}`)?.value; - - // Save name and ZIP - const finalName = enteredName || frm.doc.customer_name; - const finalZip = enteredZip || frm.doc.billing_zip || frm.doc.pincode; - - // Check autopay enrollment choice - const AUTOPAY_ENABLED = false; - const checkbox = document.getElementById(`save_autopay_${uid}`); - const save_autopay = AUTOPAY_ENABLED ? checkbox?.checked : 0; - - - run_token_payment(frm, response.token, dialog, { - cardholder_name: finalName, - billing_zip: finalZip, - save_autopay: save_autopay - }); - - } else { - frappe.msgprint("Payment failed to tokenize"); + 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" + ); + + return; + } + + const firstName = + document.getElementById( + `first_name_${uid}` + )?.value?.trim(); + + const lastName = + document.getElementById( + `last_name_${uid}` + )?.value?.trim(); + + const company = + document.getElementById( + `company_${uid}` + )?.value?.trim(); + + 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 + } + ); } }); - const btn = document.getElementById(`pay_btn_${uid}`); + const btn = + document.getElementById( + `pay_btn_${uid}` + ); if (!btn) { - console.error("Pay button not found"); + + console.error( + "Pay button not found" + ); + return; } btn.onclick = function () { + + if ( + window.ns_payment_processing + ) { + return; + } + + btn.disabled = true; + + btn.innerText = "Processing..."; + frappe.show_alert({ - message: "Processing payment...", + message: + "Processing payment...", + indicator: "blue" }); CollectJS.startPaymentRequest(); }; - }, 100); + }, 300); }); + } -function run_token_payment(frm, token, dialog, extra_data = {}) { - - const save_autopay = extra_data.save_autopay ? 1 : 0; +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, - cardholder_name: extra_data.cardholder_name, - billing_zip: extra_data.billing_zip, - save_autopay: save_autopay + + 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...", + callback(r) { + if (r.message?.success) { - if (save_autopay && r.message.vault_id) { + if ( + extra_data.save_autopay + && r.message.vault_id + ) { + frappe.show_alert({ - message: "Payment successful + AutoPay enabled", + message: + `Payment successful + AutoPay enabled (${r.message.vault_id})`, + indicator: "green" }); + } else { + frappe.show_alert({ - message: "Payment successful", + message: + "Payment successful", + indicator: "green" }); + } + window.ns_payment_processing = false; + dialog.hide(); + frm.reload_doc(); - } - else { - frappe.msgprint(r.message?.error || "Payment failed"); + + } else { + + window.ns_payment_processing = false; + + 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)}`; + + } + } } });