diff --git a/ns_app/api/payments.py b/ns_app/api/payments.py
index dfccc2b..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,687 +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":
-
- 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"],
- transaction_id=transaction_id,
- mode_of_payment=mode_of_payment
- )
-
- 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]
}
- existing_pe = frappe.db.exists(
- "Payment Entry",
- {"reference_no": transaction_id}
+ frappe.log_error(
+ f"transaction_id={transaction_id}\ntotal={total_amount}\ninvoices={invoice_names}",
+ "PAYMENT SUCCESS"
)
- if not existing_pe:
+ # 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(
- invoice=invoice,
- amount=inv.outstanding_amount,
+ invoices=invoices,
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()
-
+ except Exception:
frappe.log_error(
- f"""
-Vault save complete
-
-customer={customer.name}
-
-vault_id={vault_id}
-""",
- "AUTOPAY DEBUG - SAVE COMPLETE"
+ frappe.get_traceback(),
+ "PAYMENT ENTRY FAILURE AFTER SUCCESSFUL CHARGE"
)
- return {
- "success": True,
- "transaction_id": transaction_id,
- "vault_id": vault_id
- }
+ # 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}
-@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 frappe.db.exists("Payment Entry", {"reference_no": transaction_id}):
+ return {"success": True, "transaction_id": transaction_id, "duplicate": True}
- if (
- success == "1"
- or "duplicate" in message.lower()
- ):
+ 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")
- cust.custom_auto_pay_id = (
- returned_vault_id
- )
+ return {"success": True, "transaction_id": transaction_id}
- cust.custom_auto_pay_status = 1
+ return {"success": False, "error": message}
- 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": returned_vault_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
-
+ 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")
+ payment_type = data.get("type")
+ mode_of_payment = "ACH" if payment_type == "check" else "Credit Card"
- 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,
- mode_of_payment=mode_of_payment
- )
+ 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"
-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/print_formats/print_formats/ns_dunning_double_window.html b/ns_app/print_formats/print_formats/ns_dunning_double_window.html
new file mode 100644
index 0000000..55256ea
--- /dev/null
+++ b/ns_app/print_formats/print_formats/ns_dunning_double_window.html
@@ -0,0 +1,160 @@
+
+
+
+
+ {% set company_doc = frappe.get_doc("Company", company) %}
+
+
+
+
+
+
+ {{ company_doc.company_name }}
+
+
+ 1063 Chestnut Level Road
+ Quarryville PA 17566
+ {% if company_doc.phone_no %}Phone: {{ company_doc.phone_no }}{% endif %}
+ {% if company_doc.email %} | Email: {{ company_doc.email }}{% endif %}
+
+
+
+ Payment Reminder
+ {{ doc.name }}
+ Date: {{ frappe.utils.formatdate(doc.posting_date, "MM-dd-yyyy") }}
+
+
+
+
+
+
+
+
+
+ Customer: {{ doc.customer }}
+ Amount Due:
+ ${{ doc.grand_total }}
+
+
+
+
+
+
+ {{ doc.customer_name }}
+ {{ doc.address_display or doc.customer_address }}
+
+
+
+
+
+
+
+
+ PAYMENT REMINDER
+
+
+ Our records show the following invoice(s) are past due. Please remit payment at your earliest convenience.
+
+
+
+
+
+
+
+ Invoice
+ Due Date
+ Days Overdue
+ Outstanding
+ Interest
+
+
+
+ {% for row in doc.invoices %}
+
+ {{ row.sales_invoice }}
+
+ {{ frappe.utils.formatdate(row.due_date, "MM-dd-yyyy") }}
+
+
+ {{ row.overdue_days }}
+
+
+ ${{ row.outstanding_amount }}
+
+
+ ${{ row.interest }}
+
+
+ {% endfor %}
+
+
+
+
+
+
+ Total Outstanding: ${{ doc.total_outstanding }}
+
+ {% if doc.dunning_fee and doc.dunning_fee > 0 %}
+
+ Dunning Fee: ${{ doc.dunning_fee }}
+
+ {% endif %}
+ {% if doc.total_interest and doc.total_interest > 0 %}
+
+ Total Interest: ${{ doc.total_interest }}
+
+ {% endif %}
+
+ Amount Due:
+ ${{ doc.grand_total }}
+
+
+
+
+
+
+
+
+We value your business and would like to resolve this promptly. Please contact us if you have any questions or if payment has already been sent.
+
+We accept payments by check or over the phone using a debit or credit card.
+
+Thank you for your prompt attention — NS Innovations
+
+
+
+
diff --git a/ns_app/print_formats/print_formats/quotation_double_window.html b/ns_app/print_formats/print_formats/quotation_double_window.html
new file mode 100644
index 0000000..601622b
--- /dev/null
+++ b/ns_app/print_formats/print_formats/quotation_double_window.html
@@ -0,0 +1,153 @@
+
+
+
+
+ {% set company_doc = frappe.get_doc("Company", company) %}
+
+
+
+
+
+
+ {{ company_doc.company_name }}
+
+
+ 1063 Chestnut Level Road
+ Quarryville PA 17566
+ {% if company_doc.phone_no %}Phone: {{ company_doc.phone_no }}{% endif %}
+ {% if company_doc.email %} | Email: {{ company_doc.email }}{% endif %}
+
+
+
+ Quotation:
+ {{ doc.name }}
+ Date: {{ frappe.utils.formatdate(doc.transaction_date, "MM-dd-yyyy") }}
+ Valid Till:
+ {{ frappe.utils.formatdate(doc.valid_till, "MM-dd-yyyy") if doc.valid_till else "" }}
+
+
+
+
+
+
+
+
+
+ Terms:
+ {{ doc.payment_terms_template or "Net 30" }}
+
+
+
+
+
+
+ {{ doc.customer_name }}
+ {{ doc.address_display or doc.customer_address }}
+
+
+
+
+
+ {% if doc.terms or doc.note %}
+
+
+
+ {% if doc.note %}
+
+ {{ doc.note }}
+
+ {% endif %}
+ {% if doc.terms %}
+
+ {{ doc.terms }}
+
+ {% endif %}
+
+
+
+ {% endif %}
+
+
+
+
+
+ Item
+ Description
+ Qty
+ Rate
+ Amount
+
+
+
+ {% for row in doc.items %}
+
+ {{ row.item_code }}
+ {{ row.item_name }}
+ {{ row.qty }}
+ {{ row.rate }}
+ {{ row.amount }}
+
+ {% endfor %}
+
+
+
+
+
+
+ Subtotal: {{ doc.total }}
+
+
+ {% for tax in doc.taxes %}
+
+ {{ tax.account_head }} ({{ tax.rate }}%):
+ {{ tax.tax_amount }}
+
+ {% endfor %}
+
+
+ Total: {{ doc.grand_total }}
+
+
+
+
+
+
+
+
+ This quotation is valid until the date listed above and subject to change thereafter.
+
+ Pricing does not include applicable taxes unless otherwise stated.
+
+ Thank you for considering NS Innovations.
+
+
+
+
diff --git a/ns_app/print_formats/print_formats/sales_order_double_window.html b/ns_app/print_formats/print_formats/sales_order_double_window.html
new file mode 100644
index 0000000..e7d79f7
--- /dev/null
+++ b/ns_app/print_formats/print_formats/sales_order_double_window.html
@@ -0,0 +1,163 @@
+
+
+
+
+ {% set company_doc = frappe.get_doc("Company", company) %}
+
+
+
+
+
+
+ {{ company_doc.company_name }}
+
+
+ 1063 Chestnut Level Road
+ Quarryville PA 17566
+ {% if company_doc.phone_no %}Phone: {{ company_doc.phone_no }}{% endif %}
+ {% if company_doc.email %} | Email: {{ company_doc.email }}{% endif %}
+
+
+
+ Sales Order
+ {{ doc.name }}
+ Date: {{ frappe.utils.formatdate(doc.transaction_date, "MM-dd-yyyy") }}
+
+
+
+
+
+
+
+
+
+ {% if doc.delivery_date %}
+ Delivery Date:
+ {{ frappe.utils.formatdate(doc.delivery_date, "MM-dd-yyyy") }}
+ {% endif %}
+ Terms: {{ doc.payment_terms_template or "Net 30" }}
+
+
+
+
+
+
+ {{ doc.customer_name }}
+ {{ doc.address_display or doc.customer_address }}
+
+
+
+
+ {% if doc.custom_subscription_data or doc.custom_invoice_notes %}
+
+
+
+ {% if doc.custom_subscription_data %}
+
+ {{ doc.custom_subscription_data }}
+
+ {% endif %}
+ {% if doc.custom_invoice_notes %}
+
+ {{ doc.custom_invoice_notes }}
+
+ {% endif %}
+
+
+
+ {% endif %}
+
+
+
+
+
+ Item
+ Description
+ Qty
+ Rate
+ Amount
+
+
+
+ {% for row in doc.items %}
+
+
+ {{ row.item_code }}
+
+
+ {{ row.item_name }}
+
+
+ {{ row.qty }}
+
+
+ {{ row.rate }}
+
+
+ {{ row.amount }}
+
+
+ {% endfor %}
+
+
+
+
+
+
+ Subtotal: {{ doc.total }}
+
+
+ {% for tax in doc.taxes %}
+
+ {{ tax.account_head }} ({{ tax.rate }}%):
+ {{ tax.tax_amount }}
+
+ {% endfor %}
+
+
+ Total: {{ doc.grand_total }}
+
+
+
+
+
+
+
+
+
+
+This Sales Order is not an invoice.
+Pricing and availability subject to confirmation.
+
+Thank you for your business.
+
+
+
+
diff --git a/ns_app/public/js/customer_quick_entry.js b/ns_app/public/js/customer_quick_entry.js
index 857beb8..eadfaf8 100644
--- a/ns_app/public/js/customer_quick_entry.js
+++ b/ns_app/public/js/customer_quick_entry.js
@@ -1,317 +1,271 @@
frappe.provide("ns_app.customer");
-console.log("NS APP CUSTOMER JS LOADED");
+console.log("NS App: customer_quick_entry.js loaded");
-$(document).ready(() => {
+// ─── Install override ────────────────────────────────────────────────────────
+// Poll until frappe.ui.form.make_quick_entry exists (ERPNext bundle settled),
+// then wrap make_quick_entry so we re-assert our class at every Customer call.
- setTimeout(() => {
+(function () {
+ "use strict";
- const TargetClass =
- frappe.ui.form.CustomerQuickEntryForm;
+ function install_override() {
+ const Base = frappe.ui.form.CustomerQuickEntryForm;
+ if (!Base) return false;
+ if (Base.__ns_patched) return true;
- if (!TargetClass) {
+ frappe.ui.form.CustomerQuickEntryForm = class extends Base {
- console.error(
- "NS App: CustomerQuickEntryForm not found"
- );
+ // render_dialog is called by QuickEntryForm.setup() after the
+ // constructor runs. At this point this.after_insert is already
+ // set by the base constructor. We show our custom dialog instead
+ // of ERPNext's, but we PRESERVE this.after_insert so the link
+ // field callback chain stays intact.
+ render_dialog() {
+ console.log("NS App: render_dialog intercepted");
- return;
- }
+ // Capture the typed customer name before anything mutates focus
+ const customer_name = this._get_typed_name();
- // Prevent duplicate patching
- if (TargetClass.__ns_patched) {
+ console.log("NS App: prefill name =", customer_name);
- console.log(
- "NS App: already patched"
- );
+ // Open our custom dialog, passing:
+ // - the prefilled name
+ // - this.after_insert as the callback so ERPNext's link
+ // field gets notified when the customer is created
+ ns_app.customer.open_quick_entry({
+ customer_name: customer_name,
+ after_insert: this.after_insert // ← this is the key
+ });
- return;
- }
-
- console.log(
- "NS App: patching CustomerQuickEntryForm"
- );
-
- frappe.ui.form.CustomerQuickEntryForm =
- class extends TargetClass {
-
- render_dialog() {
-
- console.log(
- "NS App: render_dialog intercepted"
- );
-
- let customer_name = "";
-
- // Route option first
- if (frappe.route_options?.name) {
-
- customer_name =
- frappe.route_options.name;
- }
-
- // Focused field fallback
- if (!customer_name) {
-
- const active =
- document.activeElement;
-
- if (
- active &&
- active.value
- ) {
-
- customer_name =
- active.value;
- }
- }
-
- // cur_frm fallback
- if (
- !customer_name &&
- typeof cur_frm !==
- "undefined" &&
- cur_frm
- ) {
-
- customer_name =
- cur_frm.doc.customer ||
- cur_frm.doc.party_name ||
- "";
- }
-
- console.log(
- "NS App: Captured customer name:",
- customer_name
- );
-
- // DO NOT call super.render_dialog()
- // This restores the fully custom dialog
-
- ns_app.customer.open_quick_entry({
- customer_name:
- customer_name,
-
- callback:
- this.after_insert
- });
+ // We intentionally do NOT call super.render_dialog().
+ // ERPNext's dialog is replaced entirely by ours.
+ // But we must call this.dialog = something so that
+ // QuickEntryForm.setup() doesn't crash on teardown.
+ // A minimal placeholder dialog satisfies that contract.
+ if (!this.dialog) {
+ this.dialog = { hide: () => {}, get_field: () => null };
}
- };
+ }
- TargetClass.__ns_patched = true;
+ _get_typed_name() {
+ // 1. The link field control that triggered quick entry
+ if (frappe.ui.form.cur_field) {
+ const v = frappe.ui.form.cur_field.get_value?.();
+ if (v) return v;
+ }
+ // 2. Whatever input had focus when the dialog opened
+ const el = document.activeElement;
+ if (el?.value) return el.value;
+ // 3. Current form doc
+ if (typeof cur_frm !== "undefined" && cur_frm?.doc) {
+ return cur_frm.doc.customer || cur_frm.doc.party_name || "";
+ }
+ return "";
+ }
+ };
- console.log(
- "NS App: CustomerQuickEntryForm patched successfully"
- );
+ frappe.ui.form.CustomerQuickEntryForm.__ns_patched = true;
+ console.log("NS App: CustomerQuickEntryForm override installed ✓");
+ return true;
+ }
- }, 1000);
+ function patch_make_quick_entry() {
+ const orig = frappe.ui.form.make_quick_entry;
+ if (!orig || orig.__ns_patched) return;
-});
+ frappe.ui.form.make_quick_entry = function (doctype, after_insert, init_callback, doc, force) {
+ if (doctype === "Customer") {
+ install_override(); // re-assert in case anything clobbered it
+ }
+ return orig.apply(this, arguments);
+ };
+
+ frappe.ui.form.make_quick_entry.__ns_patched = true;
+ console.log("NS App: make_quick_entry patched ✓");
+ }
+
+ let attempts = 0;
+ const poller = setInterval(() => {
+ if (++attempts > 100) {
+ clearInterval(poller);
+ console.error("NS App: gave up waiting for frappe.ui.form.make_quick_entry");
+ return;
+ }
+ if (frappe.ui.form?.make_quick_entry) {
+ clearInterval(poller);
+ install_override();
+ patch_make_quick_entry();
+ }
+ }, 100);
+
+})();
+
+
+// ─── Custom dialog ───────────────────────────────────────────────────────────
+// opts:
+// customer_name {string} prefill value
+// after_insert {function} ERPNext's link field callback — MUST be called
+// with the new customer name on success
ns_app.customer.open_quick_entry = function (opts = {}) {
- console.log(
- "NS App: Custom Customer Quick Entry OPENED"
- );
+ console.log("NS App: open_quick_entry called", opts);
const d = new frappe.ui.Dialog({
-
title: "New Customer",
-
- size: "large",
-
+ size: "large",
fields: [
- // ───────── CUSTOMER ─────────
+ // ── Customer ──────────────────────────────────────────────────
+ { fieldtype: "Section Break", label: "Customer Information" },
{
- fieldtype: "Section Break",
- label: "Customer Information"
+ fieldname: "customer_name",
+ label: "Customer Name",
+ fieldtype: "Data",
+ reqd: 1,
+ default: opts.customer_name || "",
+ description: "Enter the customer or company name"
+ },
+ {
+ fieldname: "customer_type",
+ label: "Customer Type",
+ fieldtype: "Select",
+ options: "Company\nIndividual",
+ default: "Company",
+ reqd: 1,
+ description: "Select whether this customer is a company or individual"
+ },
+ {
+ fieldname: "customer_group",
+ label: "Customer Group",
+ fieldtype: "Link",
+ options: "Customer Group",
+ default: "Commercial",
+ reqd: 1,
+ description: "Select the customer group"
+ },
+ {
+ fieldname: "custom_send_via",
+ label: "Preferred Delivery Method",
+ fieldtype: "Select",
+ options: "mail\nemail\nfax",
+ description: "Choose how documents should be sent to the customer"
},
+ // ── Contact ───────────────────────────────────────────────────
+ { fieldtype: "Section Break", label: "Primary Contact" },
+
{
- fieldname: "customer_name",
- label: "Customer Name",
- fieldtype: "Data",
- reqd: 1,
- default:
- opts.customer_name || "",
- description:
- "Enter the customer or company name"
+ fieldname: "email_id",
+ label: "Email Address",
+ fieldtype: "Data",
+ options: "Email",
+ description: "Enter the customer's email address"
+ },
+ {
+ fieldname: "mobile_no",
+ label: "Mobile Phone Number",
+ fieldtype: "Data",
+ reqd: 1,
+ description: "Enter the customer's mobile phone number"
},
+ // ── Address ───────────────────────────────────────────────────
+ { fieldtype: "Section Break", label: "Address Information" },
+
{
- fieldname: "customer_type",
- label: "Customer Type",
- fieldtype: "Select",
- options:
- "Company\nIndividual",
- default: "Company",
- reqd: 1,
- description:
- "Select whether this customer is a company or individual"
+ fieldname: "address_line1",
+ label: "Address Line 1",
+ fieldtype: "Data",
+ reqd: 1,
+ description: "Enter the street address"
},
-
{
- fieldname: "customer_group",
- label: "Customer Group",
- fieldtype: "Link",
- options: "Customer Group",
- default: "Commercial",
- reqd: 1,
- description:
- "Select the customer group"
+ fieldname: "address_line2",
+ label: "Address Line 2",
+ fieldtype: "Data",
+ description: "Enter apartment, suite, or secondary address information"
},
-
{
- fieldname: "custom_send_via",
- label:
- "Preferred Delivery Method",
- fieldtype: "Select",
- options:
- "mail\nemail\nfax",
- description:
- "Choose how documents should be sent to the customer"
+ fieldname: "pincode",
+ label: "ZIP Code",
+ fieldtype: "Data",
+ reqd: 1,
+ description: "Enter the ZIP or postal code"
},
-
- // ───────── CONTACT ─────────
-
{
- fieldtype: "Section Break",
- label: "Primary Contact"
+ fieldname: "city",
+ label: "City",
+ fieldtype: "Data",
+ description: "Enter the city"
},
-
{
- fieldname: "email_id",
- label: "Email Address",
- fieldtype: "Data",
- options: "Email",
- description:
- "Enter the customer's email address"
+ fieldname: "state",
+ label: "State",
+ fieldtype: "Data",
+ description: "Enter the state"
},
-
{
- fieldname: "mobile_no",
- label:
- "Mobile Phone Number",
- fieldtype: "Data",
- reqd: 1,
- description:
- "Enter the customer's mobile phone number"
- },
-
- // ───────── ADDRESS ─────────
-
- {
- fieldtype: "Section Break",
- label:
- "Address Information"
- },
-
- {
- fieldname: "address_line1",
- label:
- "Address Line 1",
- fieldtype: "Data",
- reqd: 1,
- description:
- "Enter the street address"
- },
-
- {
- fieldname: "address_line2",
- label:
- "Address Line 2",
- fieldtype: "Data",
- description:
- "Enter apartment, suite, or secondary address information"
- },
-
- {
- fieldname: "pincode",
- label: "ZIP Code",
- fieldtype: "Data",
- reqd: 1,
- description:
- "Enter the ZIP or postal code"
- },
-
- {
- fieldname: "city",
- label: "City",
- fieldtype: "Data",
- description:
- "Enter the city"
- },
-
- {
- fieldname: "state",
- label: "State",
- fieldtype: "Data",
- description:
- "Enter the state"
- },
-
- {
- fieldname: "country",
- label: "Country",
- fieldtype: "Link",
- options: "Country",
- default:
- "United States",
- description:
- "Select the country"
+ fieldname: "country",
+ label: "Country",
+ fieldtype: "Link",
+ options: "Country",
+ default: "United States",
+ description: "Select the country"
}
],
- primary_action_label:
- "Create Customer",
+ primary_action_label: "Create Customer",
primary_action(values) {
-
- console.log(
- "NS App: Create Customer clicked",
- values
- );
-
+ console.log("NS App: submitting customer creation", values);
d.disable_primary_action();
frappe.call({
-
- method:
- "ns_app.api.customer.create_customer_full",
-
- args: values,
+ method: "ns_app.api.customer.create_customer_full",
+ args: values,
callback(r) {
+ if (!r.message) {
+ console.error("NS App: create_customer_full returned empty");
+ d.enable_primary_action();
+ return;
+ }
- console.log(
- "NS App: Customer created",
- r.message
- );
+ const customer_name = r.message;
+ console.log("NS App: customer created →", customer_name);
d.hide();
frappe.show_alert({
-
- message:
- "Customer created via NS App",
-
+ message: `Customer "${customer_name}" created`,
indicator: "green"
});
- if (
- opts.callback
- ) {
-
- opts.callback(
- r.message
- );
+ // ── Hand control back to ERPNext ─────────────────────
+ // after_insert is ERPNext's link field callback.
+ // Calling it with the new customer name does everything:
+ // - populates the Customer field on the originating form
+ // - triggers the field's onchange/fetch logic
+ // - does NOT require any routing from our side
+ // This is the ONLY correct way to resume the originating
+ // document flow without racing the backend transaction.
+ if (typeof opts.after_insert === "function") {
+ console.log("NS App: calling after_insert with", customer_name);
+ // ERPNext's callback expects an object with a .name property,
+ // not a plain string — { name: "cu-00741" }
+ opts.after_insert({ name: customer_name });
+ } else {
+ // Fallback: no callback was passed (e.g. dialog opened
+ // standalone). Just reload to a new Customer form.
+ console.warn("NS App: no after_insert callback — navigating to customer");
+ frappe.set_route("Form", "Customer", customer_name);
}
},
always() {
-
d.enable_primary_action();
}
});
@@ -320,166 +274,48 @@ ns_app.customer.open_quick_entry = function (opts = {}) {
d.show();
- // Accessibility labels
-
+ // ── Accessibility ────────────────────────────────────────────────────────
setTimeout(() => {
-
- d.fields.forEach(field => {
-
- const control =
- d.get_field(
- field.fieldname
- );
-
- if (
- !control ||
- !control.$input
- ) {
- return;
- }
-
- control.$input.attr(
- "aria-label",
- field.label ||
- field.fieldname
- );
-
- control.$input.attr(
- "title",
- field.label ||
- field.fieldname
- );
-
- if (field.label) {
-
- control.$input.attr(
- "placeholder",
- field.label
- );
+ d.fields.forEach(f => {
+ const ctrl = d.get_field(f.fieldname);
+ if (!ctrl?.$input) return;
+ ctrl.$input
+ .attr("aria-label", f.label || f.fieldname)
+ .attr("title", f.label || f.fieldname);
+ if (f.label && !ctrl.$input.attr("placeholder")) {
+ ctrl.$input.attr("placeholder", f.label);
}
});
+ }, 100);
- console.log(
- "NS App: accessibility applied"
- );
+ // ── ZIP autofill ─────────────────────────────────────────────────────────
+ d.fields_dict.pincode.df.onchange = () => {
+ const zip = d.get_value("pincode");
+ if (!zip || zip.length < 5) return;
- }, 300);
+ fetch(`https://api.zippopotam.us/us/${zip}`)
+ .then(r => r.ok ? r.json() : null)
+ .then(data => {
+ if (!data?.places?.length) return;
+ const p = data.places[0];
+ d.set_value("city", p["place name"]);
+ d.set_value("state", p["state"]);
+ d.set_value("country", data.country);
+ console.log("NS App: ZIP autofill →", p["place name"], p["state"]);
+ })
+ .catch(() => {});
+ };
- // ZIP autofill
+ // ── Enter → next field ───────────────────────────────────────────────────
+ d.$wrapper.on("keydown", "input, select, textarea", function (e) {
+ if (e.key !== "Enter") return;
+ if (document.activeElement?.classList.contains("btn-primary")) return;
- d.fields_dict.pincode.df.onchange =
- () => {
-
- const zip =
- d.get_value(
- "pincode"
- );
-
- if (
- !zip ||
- zip.length < 5
- ) {
- return;
- }
-
- console.log(
- "NS App: ZIP lookup",
- zip
- );
-
- fetch(
- `https://api.zippopotam.us/us/${zip}`
- )
- .then(r =>
- r.ok
- ? r.json()
- : null
- )
- .then(data => {
-
- if (
- !data ||
- !data.places?.length
- ) {
- return;
- }
-
- const p =
- data.places[0];
-
- d.set_value(
- "city",
- p["place name"]
- );
-
- d.set_value(
- "state",
- p["state"]
- );
-
- d.set_value(
- "country",
- data.country
- );
-
- console.log(
- "NS App: ZIP autofill success"
- );
- })
- .catch(() => {});
- };
-
- // Enter navigation
-
- d.$wrapper.on(
- "keydown",
- "input, select, textarea",
- function (e) {
-
- if (
- e.key === "Enter"
- ) {
-
- const active =
- document.activeElement;
-
- // Allow submit only
- // on primary button
-
- if (
- active &&
- active.classList.contains(
- "btn-primary"
- )
- ) {
- return;
- }
-
- e.preventDefault();
-
- const fields =
- d.$wrapper
- .find(
- "input, select, textarea"
- )
- .filter(
- ":visible:not([disabled])"
- );
-
- const index =
- fields.index(this);
-
- if (
- index > -1 &&
- index + 1 <
- fields.length
- ) {
-
- fields
- .eq(index + 1)
- .focus();
- }
- }
- }
- );
+ e.preventDefault();
+ const fields = d.$wrapper
+ .find("input, select, textarea")
+ .filter(":visible:not([disabled])");
+ const i = fields.index(this);
+ if (i > -1 && i + 1 < fields.length) fields.eq(i + 1).focus();
+ });
};
\ 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: `
-
-
-
- First Name
-
-
-
-
-
- Last Name
-
-
-
-
-
- Company (Optional)
-
-
-
-
-
- Billing ZIP
-
-
-
-
-
-
-
-
-
- Save for Auto Pay
-
-
-
-
-
-
-
-
-
-
-
- Pay ${format_currency(frm.doc.outstanding_amount)}
-
-
+
+
+ First Name
+
- `
+
+ Last Name
+
+
+
+ Company (Optional)
+
+
+
+ Billing ZIP
+
+
+
+
+
+ Save for Auto Pay
+
+
+
+
+
+ Pay Additional Invoices
+
+
+
+
+ Loading invoices...
+
+
+
+
+
+
+
+ Pay ${format_currency(frm.doc.outstanding_amount)}
+
+
`
}
],
-
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)}`;
}
-
}
}
});