diff --git a/ns_app/api/payments.py b/ns_app/api/payments.py index 5b314ef..61669c2 100644 --- a/ns_app/api/payments.py +++ b/ns_app/api/payments.py @@ -1,5 +1,6 @@ import frappe import requests +import urllib.parse from frappe.utils import nowdate @@ -9,7 +10,7 @@ def check_autopay(customer): return { "autopay_enabled": bool(cust.custom_auto_pay_status), - "autopay_id": cust.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 } @@ -22,11 +23,11 @@ def run_autopay_payment(invoice): cust = frappe.get_doc("Customer", inv.customer) - if not cust.custom_auto_pay_status or not cust.auto_pay_id: + 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.auto_pay_id, + "autopay_id": cust.custom_auto_pay_id, "amount": float(inv.outstanding_amount), "invoice": inv.name } @@ -57,33 +58,62 @@ def call_payment_api(payload): if not api_username or not api_password: frappe.throw("Payment gateway credentials not configured") + invoice = payload["invoice"] + data = { "username": api_username, "password": api_password, "type": "sale", "customer_vault_id": payload["autopay_id"], "amount": payload["amount"], - "orderid": payload["invoice"], - "response": "json" + "orderid": invoice } try: response = requests.post(url, data=data, timeout=30) response.raise_for_status() - result = response.json() + frappe.logger("payments").info( + f"NMI PAYMENT | Invoice: {invoice} | Amount: {payload['amount']} | Response: {response.text}" + ) + + if not response.text: + frappe.throw("Payment processor returned empty response") + + # Parse gateway 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 result.get("response") == "1": + if success == "1": + + # Detect payment mode + if payment_type == "check": + mode_of_payment = "ACH" + else: + mode_of_payment = "Credit Card" + + create_payment_entry( + invoice=invoice, + amount=payload["amount"], + transaction_id=transaction_id, + mode_of_payment=mode_of_payment + ) + return { "success": True, - "transaction_id": result.get("transactionid") + "transaction_id": transaction_id } return { "success": False, - "error": result.get("responsetext", "Payment failed") + "error": message } @@ -101,47 +131,64 @@ def get_collect_checkout_url(invoice): def crystalclear_webhook(): data = frappe.local.form_dict - # Validate success if data.get("response") != "1": return "ignored" invoice = data.get("orderid") amount = data.get("amount") transaction_id = data.get("transactionid") + payment_type = data.get("type") - # Prevent duplicates - if frappe.db.exists("Payment Entry", {"reference_no": transaction_id}): - return "duplicate" + if payment_type == "check": + mode_of_payment = "ACH" + else: + mode_of_payment = "Credit Card" create_payment_entry( invoice=invoice, amount=amount, - transaction_id=transaction_id + transaction_id=transaction_id, + mode_of_payment=mode_of_payment ) return "ok" -def create_payment_entry(invoice, amount, transaction_id=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} + ): + return + inv = frappe.get_doc("Sales Invoice", invoice) - paid_to = frappe.db.get_value( - "Company", - inv.company, - "default_cash_account" - ) + # 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, + "default_cash_account" + ) if not paid_to: - frappe.throw("Default cash account not set for company") + 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() @@ -154,4 +201,4 @@ def create_payment_entry(invoice, amount, transaction_id=None): pe.insert(ignore_permissions=True) pe.submit() - return pe.name + return pe.name \ No newline at end of file diff --git a/ns_app/print_formats/print_formats/sales_invoice_ns.html b/ns_app/print_formats/print_formats/sales_invoice_ns.html index cfd20e4..a898654 100644 --- a/ns_app/print_formats/print_formats/sales_invoice_ns.html +++ b/ns_app/print_formats/print_formats/sales_invoice_ns.html @@ -37,8 +37,10 @@ + Customer Number: {{doc.customer}}
Due Date: {{ frappe.utils.formatdate(doc.due_date, "MM-dd-yyyy") }}
- Terms: {{ doc.payment_terms_template or "Net 30" }} + Terms: {{ doc.payment_terms_template or "Net 30" }}
+ **Please include invoice
number with check**
@@ -158,6 +160,7 @@

Prompt payment is always appreciated. We accept payments by check or over the phone using a debit or credit card. Automatic payment setup is also available upon request. + Invoices over 30 days may be charged a late payment fee of 1.5% per month with a minimum fee of $5. diff --git a/ns_app/public/js/sales_invoice.js b/ns_app/public/js/sales_invoice.js index a2daa8c..dc76cf4 100644 --- a/ns_app/public/js/sales_invoice.js +++ b/ns_app/public/js/sales_invoice.js @@ -14,9 +14,11 @@ frappe.ui.form.on("Sales Invoice", { frm.dashboard.add_indicator("Unpaid", "red"); - frm.add_custom_button("Run Payment", () => { - run_payment_flow(frm); - }, "Actions"); + if (frm.doc.outstanding_amount > 0 && frm.doc.docstatus === 1) { + frm.add_custom_button("Run Payment", () => { + run_payment_flow(frm); + }, "Actions"); + } } }); @@ -44,26 +46,76 @@ function run_payment_flow(frm) { function run_autopay(frm) { + frappe.confirm( - `Run AutoPay for $${frm.doc.outstanding_amount}?`, + `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); + frappe.call({ method: "ns_app.api.payments.run_autopay_payment", args: { invoice: frm.doc.name }, + freeze: true, + freeze_message: "Processing payment...", + callback(r) { - frappe.msgprint("Payment successful"); - frm.reload_doc(); + + if (!r.message) { + show_payment_failed(frm, "No response from payment processor"); + return; + } + + if (r.message.success) { + + // Success UI + 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`, + indicator: "green" + }); + + frm.reload_doc(); + + } else { + + show_payment_failed(frm, r.message.error || "Payment declined"); + + } } }); + }, - () => { - frm.enable_save(); - } + () => {} ); } +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); + }); + + frappe.msgprint({ + title: "Payment Failed", + indicator: "red", + message: message + }); + +} + function open_manual_payment_form(frm) { const dialog = new frappe.ui.Dialog({