import frappe import requests import urllib.parse import json from frappe import generate_hash 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 } # ── 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) 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") payload = { "autopay_id": cust.custom_auto_pay_id, "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")) return { "success": True, "message": "AutoPay payment successful", "transaction_id": response.get("transaction_id") } # ── 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"} # 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 invoice_names: invoice_names = [invoice] if not frappe.conf.get("enable_autopay_signup"): save_autopay = 0 save_autopay = int(save_autopay or 0) # Load all selected invoices and validate invoices = [] total_amount = 0 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" sale_data = { "security_key": frappe.conf.get("nmi_security_key"), "type": "sale", "payment_token": token, "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, } if save_autopay: sale_data["customer_vault"] = "add_customer" sale_data["customer_vault_id"] = customer.name.upper() frappe.log_error( 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) 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"} 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], "PAYMENT DEBUG - SALE FAILED") return { "success": False, "error": sale_result.get("responsetext", ["Error"])[0] } frappe.log_error( f"transaction_id={transaction_id}\ntotal={total_amount}\ninvoices={invoice_names}", "PAYMENT SUCCESS" ) # Duplicate check on transaction ID if frappe.db.exists("Payment Entry", {"reference_no": transaction_id}): return {"success": True, "transaction_id": transaction_id, "duplicate": True} 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" ) # 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) frappe.db.commit() except Exception: frappe.log_error(frappe.get_traceback(), "AUTOPAY CUSTOMER SAVE FAILED") return {"success": True, "transaction_id": transaction_id, "vault_id": vault_id} # ── Payment Entry — now takes a list of invoices ───────────────────────────── 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 if transaction_id and frappe.db.exists("Payment Entry", {"reference_no": transaction_id}): return 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") ) 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 = 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() for inv in invoices: pe.append("references", { "reference_doctype": "Sales Invoice", "reference_name": inv.name, "allocated_amount": inv.outstanding_amount }) 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": security_key, "type": "sale", "customer_vault_id": payload["autopay_id"], "amount": payload["amount"], "orderid": order_id } try: 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] 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": mode_of_payment = "ACH" if payment_type == "check" else "Credit Card" if frappe.db.exists("Payment Entry", {"reference_no": transaction_id}): return {"success": True, "transaction_id": transaction_id, "duplicate": True} try: inv = frappe.get_doc("Sales Invoice", invoice) create_payment_entry( invoices=[inv], 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} # ── Webhook (unchanged except uses new create_payment_entry signature) ──────── @frappe.whitelist(allow_guest=True) def crystalclear_webhook(): data = frappe.local.form_dict 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") mode_of_payment = "ACH" if payment_type == "check" else "Credit Card" try: inv = frappe.get_doc("Sales Invoice", invoice) create_payment_entry( 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") return "ok" # ── save_to_autopay (unchanged) ─────────────────────────────────────────────── @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 "") 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, } 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"} 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") return {"success": True, "vault_id": returned_vault_id} return {"success": False, "error": message}