From e3d0d24b043ed9a100f3e355f17f867d6d7f23b9 Mon Sep 17 00:00:00 2001 From: Ty Reynolds Date: Wed, 21 Jan 2026 10:28:22 -0500 Subject: [PATCH] Added and updated autopay logic --- ns_app/api/payments.py | 67 +++++++++++++---------- ns_app/public/js/custom.js | 40 ++++++++------ ns_app/public/js/sales_invoice.js | 88 +++++++++++++++++++++---------- 3 files changed, 126 insertions(+), 69 deletions(-) diff --git a/ns_app/api/payments.py b/ns_app/api/payments.py index a8d5740..a9f017f 100644 --- a/ns_app/api/payments.py +++ b/ns_app/api/payments.py @@ -1,8 +1,8 @@ -import requests import frappe +import requests +from frappe.utils import nowdate -# Checks to see if customer has Autopay on @frappe.whitelist() def check_autopay(customer): cust = frappe.get_doc("Customer", customer) @@ -13,27 +13,32 @@ def check_autopay(customer): } - -# Creates payload to send through API, checks for success, then makes a call to create a payment entry. @frappe.whitelist() -def run_autopay_payment(invoice, autopay_id, amount): +def run_autopay_payment(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.auto_pay or not cust.auto_pay_id: + frappe.throw("Customer does not have AutoPay enabled") payload = { - "autopay_id": autopay_id, - "amount": amount, - "invoice": invoice + "autopay_id": cust.auto_pay_id, + "amount": float(inv.outstanding_amount), + "invoice": inv.name } response = call_payment_api(payload) - # Handle gateway failure clearly if not response.get("success"): frappe.throw(response.get("error", "Payment failed")) - # Create ERPNext payment record payment_entry = create_payment_entry( - invoice=invoice, - amount=amount, + invoice=inv.name, + amount=payload["amount"], transaction_id=response.get("transaction_id") ) @@ -43,14 +48,9 @@ def run_autopay_payment(invoice, autopay_id, amount): } - -# Call's Crystal CLear's API and Runs an AutoPay transaction using NMI / Crystal Clear using customer_vault_id (autopay_id) - def call_payment_api(payload): - url = "https://crystalclear.transactiongateway.com/api/transact.php" - # Store these in site_config.json or environment variables api_username = frappe.conf.get("nmi_username") api_password = frappe.conf.get("nmi_password") @@ -71,38 +71,54 @@ def call_payment_api(payload): response = requests.post(url, data=data, timeout=30) response.raise_for_status() result = response.json() - - except Exception as e: + except Exception: frappe.log_error(frappe.get_traceback(), "NMI Payment API Error") frappe.throw("Payment processor unreachable") - # NMI success condition if result.get("response") == "1": return { "success": True, "transaction_id": result.get("transactionid") } - # Failure case return { "success": False, "error": result.get("responsetext", "Payment failed") } +@frappe.whitelist() +def get_collect_checkout_url(invoice): + inv = frappe.get_doc("Sales Invoice", invoice) + + return ( + "https://crystalclear.transactiongateway.com/collect/checkout" + f"?amount={inv.outstanding_amount}&reference={inv.name}" + ) + -# Auto creates payment entry in ERP Next def create_payment_entry(invoice, amount, transaction_id=None): inv = frappe.get_doc("Sales Invoice", invoice) + paid_to = frappe.db.get_value( + "Company", + inv.company, + "default_cash_account" + ) + + if not paid_to: + frappe.throw("Default cash account not set for company") + pe = frappe.new_doc("Payment Entry") pe.payment_type = "Receive" pe.party_type = "Customer" pe.party = inv.customer + pe.posting_date = nowdate() pe.paid_amount = amount pe.received_amount = amount + pe.paid_to = paid_to pe.reference_no = transaction_id - pe.reference_date = frappe.utils.nowdate() + pe.reference_date = nowdate() pe.append("references", { "reference_doctype": "Sales Invoice", @@ -110,10 +126,7 @@ def create_payment_entry(invoice, amount, transaction_id=None): "allocated_amount": amount }) - pe.insert() + pe.insert(ignore_permissions=True) pe.submit() return pe.name - - - diff --git a/ns_app/public/js/custom.js b/ns_app/public/js/custom.js index 4d174f4..165ee8e 100644 --- a/ns_app/public/js/custom.js +++ b/ns_app/public/js/custom.js @@ -13,7 +13,7 @@ frappe.ready(() => { doctype, after_insert, function (doc) { - // Defaults + // Safe defaults doc.customer_group = "Commercial"; doc.territory = "United States"; @@ -25,50 +25,60 @@ frappe.ready(() => { }; }); + frappe.ui.form.on("Customer", { onload(frm) { if (!frm.is_quick_entry) return; console.log("NS App: Enhancing Customer Quick Entry"); - // Hide defaults you don't want + // Hide fields you don't want exposed frm.toggle_display("customer_group", false); - // Required fields + // Make required frm.set_df_property("mobile_no", "reqd", 1); - // Defaults + // Default territory frm.set_value("territory", "United States"); - // Add Company fields to Quick Entry - frm.add_custom_field({ - fieldname: "company_name", - label: "Company Name", - fieldtype: "Data", - insert_after: "customer_name", - reqd: 1 - }); + /** + * IMPORTANT: + * "company_name" MUST already exist as a Custom Field in erpnext + * (Customer → Custom Fields) + */ + frm.toggle_display("company_name", true); + + // Company name is OPTIONAL by default + frm.set_df_property("company_name", "reqd", 0); }, - // ZIP auto-fill + /** + * ZIP auto-fill + * NOTE: This only works if pincode/city/state/country + * are available in Quick Entry (customized Address block) + */ pincode(frm) { if (!frm.is_quick_entry) return; const zip = frm.doc.pincode; if (!zip || zip.length < 5) return; + // Only US ZIPs if (frm.doc.country && frm.doc.country !== "United States") return; fetch(`https://api.zippopotam.us/us/${zip}`) .then(res => res.ok ? res.json() : null) .then(data => { - if (!data?.places?.length) return; + if (!data || !data.places || !data.places.length) return; const place = data.places[0]; + frm.set_value("city", place["place name"]); frm.set_value("state", place["state"]); frm.set_value("country", data.country); }) - .catch(() => {}); + .catch(() => { + // Fail silently (never block entry) + }); } }); diff --git a/ns_app/public/js/sales_invoice.js b/ns_app/public/js/sales_invoice.js index 598837c..a8db35a 100644 --- a/ns_app/public/js/sales_invoice.js +++ b/ns_app/public/js/sales_invoice.js @@ -1,13 +1,32 @@ frappe.ui.form.on("Sales Invoice", { refresh(frm) { - if (frm.doc.docstatus !== 1) return; // submitted only + frm.clear_custom_buttons(); - frm.add_custom_button("Run Payment", () => { - run_payment_flow(frm); - }); + // Submitted invoices only + if (frm.doc.docstatus !== 1) return; + if (!frm.doc.customer) return; + + // Only show manual payment button if AutoPay is OFF + frappe.db.get_value( + "Customer", + frm.doc.customer, + "auto_pay", + (r) => { + if (!r) return; + + if (!r.auto_pay) { + frm.add_custom_button( + "Run Payment", + () => run_payment_flow(frm), + "Actions" + ); + } + } + ); } }); + function run_payment_flow(frm) { frappe.call({ method: "ns_app.api.payments.check_autopay", @@ -18,24 +37,23 @@ function run_payment_flow(frm) { if (!r.message) return; if (r.message.autopay_enabled) { - run_autopay(frm, r.message.autopay_id); + run_autopay(frm); } else { open_manual_payment_form(frm); } } }); +} -function run_autopay(frm, autopay_id) { +function run_autopay(frm) { frappe.confirm( - `Run AutoPay for $${frm.doc.grand_total}?`, + `Run AutoPay for $${frm.doc.outstanding_amount}?`, () => { frappe.call({ method: "ns_app.api.payments.run_autopay_payment", args: { - invoice: frm.doc.name, - autopay_id: autopay_id, - amount: frm.doc.grand_total + invoice: frm.doc.name }, callback(r) { frappe.msgprint(r.message || "Payment processed"); @@ -46,23 +64,39 @@ function run_autopay(frm, autopay_id) { ); } -// TODO: Source needs updated to a correct url. + +// Hosted checkout function open_manual_payment_form(frm) { - const dialog = new frappe.ui.Dialog({ - title: "Enter Payment", - fields: [ - { - fieldtype: "HTML", - fieldname: "payment_form", - options: `` - } - ] + frappe.call({ + method: "ns_app.api.payments.get_collect_checkout_url", + args: { + invoice: frm.doc.name + }, + callback(r) { + if (!r.message) { + frappe.msgprint("Unable to start payment"); + return; + } + + const dialog = new frappe.ui.Dialog({ + title: "Secure Payment", + size: "large", + fields: [ + { + fieldtype: "HTML", + fieldname: "payment_form", + options: ` + + ` + } + ] + }); + + dialog.show(); + } }); - - dialog.show(); -} - }