main #17

Merged
norman merged 4 commits from main into production 2026-06-15 14:14:35 +00:00
6 changed files with 1237 additions and 1478 deletions

View File

@@ -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()
return {"success": False, "error": message}

View File

@@ -0,0 +1,160 @@
<style>
@media print {
.window-address-space {
padding-top: 1in;
}
}
</style>
<div style="font-family: Helvetica, Arial, sans-serif; font-size: 10px; color: #333;">
{% set company_doc = frappe.get_doc("Company", company) %}
<!-- Header -->
<table style="width: 100%; border-bottom: 1px solid #ccc; margin-bottom: 15px;">
<tr>
<td style="text-align: center; position: relative; left: -100px;">
<h3 style="margin: 8px 0 0 0; font-size: 15px; font-weight: bold;">
{{ company_doc.company_name }}
</h3>
<p style="
margin: 10px 0 0 0;
font-family: Helvetica, Arial, sans-serif;
font-size: 10px;
line-height: 1.4;
text-align: center;
">
1063 Chestnut Level Road<br>
Quarryville PA 17566<br>
{% if company_doc.phone_no %}Phone: {{ company_doc.phone_no }}{% endif %}
{% if company_doc.email %} | Email: {{ company_doc.email }}{% endif %}
</p>
</td>
<td style="
text-align: right;
font-family: Helvetica, Arial, sans-serif;
font-size: 14px;
width: 240px;
vertical-align: top;
">
<strong>Payment Reminder</strong><br>
<span>{{ doc.name }}</span><br>
<strong>Date:</strong> {{ frappe.utils.formatdate(doc.posting_date, "MM-dd-yyyy") }}
</td>
</tr>
</table>
<!-- Due info row -->
<table style="width:100%; margin-top:-4px; margin-bottom:10px;">
<tr>
<td style="width:60%;"></td>
<td style="width:40%; text-align:right; font-size:15px; line-height:1.4;">
<strong>Customer:</strong> {{ doc.customer }}<br>
<strong>Amount Due:</strong>
<span style="color:#c62828; font-size:17px;">${{ doc.grand_total }}</span>
</td>
</tr>
</table>
<!-- WINDOW ADDRESS -->
<div style="
position:absolute;
top:1.9in;
left:1.125in;
width:4.5in;
height:1.25in;
font-size:15px;
line-height:1.15em;
overflow:hidden;
">
{{ doc.customer_name }}<br>
{{ doc.address_display or doc.customer_address }}
</div>
<!-- PRINT SPACE -->
<div class="window-address-space">
<!-- Prominent notice banner -->
<div style="
border: 2px solid #c62828;
border-radius: 4px;
padding: 10px 16px;
margin-bottom: 14px;
background-color: #fff8f8;
text-align: center;
">
<div style="font-size: 16px; font-weight: bold; color: #c62828; letter-spacing: 0.5px;">
PAYMENT REMINDER
</div>
<div style="font-size: 12px; color: #555; margin-top: 4px;">
Our records show the following invoice(s) are past due. Please remit payment at your earliest convenience.
</div>
</div>
<!-- Invoices Table -->
<table style="width:100%; border-collapse:collapse; margin-bottom:0;">
<thead>
<tr style="background-color:#f5f5f5;">
<th style="border:1px solid #ccc; padding:6px; text-align:left;">Invoice</th>
<th style="border:1px solid #ccc; padding:6px; text-align:center;">Due Date</th>
<th style="border:1px solid #ccc; padding:6px; text-align:center;">Days Overdue</th>
<th style="border:1px solid #ccc; padding:6px; text-align:right;">Outstanding</th>
<th style="border:1px solid #ccc; padding:6px; text-align:right;">Interest</th>
</tr>
</thead>
<tbody>
{% for row in doc.invoices %}
<tr>
<td style="border:1px solid #ccc; padding:6px; font-size:14px;">{{ row.sales_invoice }}</td>
<td style="border:1px solid #ccc; padding:6px; font-size:14px; text-align:center;">
{{ frappe.utils.formatdate(row.due_date, "MM-dd-yyyy") }}
</td>
<td style="border:1px solid #ccc; padding:6px; font-size:14px; text-align:center;">
{{ row.overdue_days }}
</td>
<td style="border:1px solid #ccc; padding:6px; font-size:14px; text-align:right;">
${{ row.outstanding_amount }}
</td>
<td style="border:1px solid #ccc; padding:6px; font-size:14px; text-align:right;">
${{ row.interest }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
<!-- Totals -->
<div style="font-family: Helvetica, Arial, sans-serif; font-size:15px; color:#333; margin-top:10px; width:40%; margin-left:auto;">
<p style="display:flex; justify-content:space-between;">
<strong>Total Outstanding:</strong><span>${{ doc.total_outstanding }}</span>
</p>
{% if doc.dunning_fee and doc.dunning_fee > 0 %}
<p style="display:flex; justify-content:space-between;">
<strong>Dunning Fee:</strong><span>${{ doc.dunning_fee }}</span>
</p>
{% endif %}
{% if doc.total_interest and doc.total_interest > 0 %}
<p style="display:flex; justify-content:space-between;">
<strong>Total Interest:</strong><span>${{ doc.total_interest }}</span>
</p>
{% endif %}
<p style="display:flex; justify-content:space-between; border-top:2px solid #c62828; padding-top:6px; margin-top:4px;">
<strong style="color:#c62828;">Amount Due:</strong>
<span style="color:#c62828; font-weight:bold;">${{ doc.grand_total }}</span>
</p>
</div>
</div> <!-- end print space -->
<!-- Footer -->
<div style="margin-top:23px; font-size:10px; color:#777; text-align:center;">
<p style="white-space:pre-line; margin:0;">
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
</p>
</div>
</div>

View File

@@ -0,0 +1,153 @@
<style>
/* Reserve space in PRINT/PDF so table does not overlap window address */
@media print {
.window-address-space {
padding-top: 1in;
}
}
</style>
<div style="font-family: Helvetica, Arial, sans-serif; font-size: 12px; color: #333;">
{% set company_doc = frappe.get_doc("Company", company) %}
<!-- Header -->
<table style="width: 100%; border-bottom: 1px solid #ccc; margin-bottom: 10px;">
<tr>
<td style="text-align: center; position: relative; left: -100px;">
<h3 style="margin: 8px 0 0 0; font-size: 15px; font-weight: bold;">
{{ company_doc.company_name }}
</h3>
<p style="
margin: 10px 0 0 0;
font-family: Helvetica, Arial, sans-serif;
font-size: 10px;
line-height: 1.4;
text-align: center;
">
1063 Chestnut Level Road<br>
Quarryville PA 17566<br>
{% if company_doc.phone_no %}Phone: {{ company_doc.phone_no }}{% endif %}
{% if company_doc.email %} | Email: {{ company_doc.email }}{% endif %}
</p>
</td>
<td style="
text-align: right;
font-family: Helvetica, Arial, sans-serif;
font-size: 14px;
width: 240px;
vertical-align: top;
">
<strong>Quotation:</strong>
<span>{{ doc.name }}</span><br>
<strong>Date:</strong> {{ frappe.utils.formatdate(doc.transaction_date, "MM-dd-yyyy") }}<br>
<strong>Valid Till:</strong>
{{ frappe.utils.formatdate(doc.valid_till, "MM-dd-yyyy") if doc.valid_till else "" }}
</td>
</tr>
</table>
<!-- Valid Till / Terms -->
<table style="width:100%; margin-top:-4px; margin-bottom:10px;">
<tr>
<td style="width:60%;"></td>
<td style="width:40%; text-align:right; font-size:15px; line-height:1.4;">
<strong>Terms:</strong>
{{ doc.payment_terms_template or "Net 30" }}
</td>
</tr>
</table>
<!-- WINDOW ADDRESS -->
<div style="
position:absolute;
top:1.5in;
left:1.125in;
width:4.5in;
height:1.25in;
font-size:15px;
line-height:1.15em;
overflow:hidden;
">
{{ doc.customer_name }}<br>
{{ doc.address_display or doc.customer_address }}
</div>
<!-- PRINT SPACE -->
<div class="window-address-space">
{% if doc.terms or doc.note %}
<table style="width:100%; margin:0 0 6px 0;">
<tr>
<td style="font-size:14px; line-height:1.3;">
{% if doc.note %}
<div style="font-weight:bold;">
{{ doc.note }}
</div>
{% endif %}
{% if doc.terms %}
<div style="white-space:pre-line;">
{{ doc.terms }}
</div>
{% endif %}
</td>
</tr>
</table>
{% endif %}
<!-- Items Table -->
<table style="width:100%; border-collapse:collapse; margin-bottom:0;">
<thead>
<tr style="background-color:#f5f5f5;">
<th style="border:1px solid #ccc; padding:6px;">Item</th>
<th style="border:1px solid #ccc; padding:6px;">Description</th>
<th style="border:1px solid #ccc; padding:6px; text-align:right;">Qty</th>
<th style="border:1px solid #ccc; padding:6px; text-align:right;">Rate</th>
<th style="border:1px solid #ccc; padding:6px; text-align:right;">Amount</th>
</tr>
</thead>
<tbody>
{% for row in doc.items %}
<tr>
<td style="border:1px solid #ccc; padding:6px; font-size:14px;">{{ row.item_code }}</td>
<td style="border:1px solid #ccc; padding:6px; font-size:14px;">{{ row.item_name }}</td>
<td style="border:1px solid #ccc; padding:6px; font-size:14px; text-align:right;">{{ row.qty }}</td>
<td style="border:1px solid #ccc; padding:6px; font-size:14px; text-align:right;">{{ row.rate }}</td>
<td style="border:1px solid #ccc; padding:6px; font-size:14px; text-align:right;">{{ row.amount }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<!-- Totals -->
<div style="font-size:15px; margin-top:10px; width:40%; margin-left:auto;">
<p style="display:flex; justify-content:space-between;">
<strong>Subtotal:</strong><span>{{ doc.total }}</span>
</p>
{% for tax in doc.taxes %}
<p style="display:flex; justify-content:space-between;">
<strong>{{ tax.account_head }} ({{ tax.rate }}%):</strong>
<span>{{ tax.tax_amount }}</span>
</p>
{% endfor %}
<p style="display:flex; justify-content:space-between;">
<strong>Total:</strong><span>{{ doc.grand_total }}</span>
</p>
</div>
</div> <!-- end print space -->
<!-- Footer -->
<div style="margin-top:23px; font-size:10px; color:#777; text-align:center;">
<p style="white-space:pre-line; margin:0;">
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.
</p>
</div>
</div>

View File

@@ -0,0 +1,163 @@
<style>
@media print {
.window-address-space {
padding-top: 1in;
}
}
</style>
<div style="font-family: Helvetica, Arial, sans-serif; font-size: 12px; color: #333;">
{% set company_doc = frappe.get_doc("Company", company) %}
<!-- Header -->
<table style="width: 100%; border-bottom: 1px solid #ccc; margin-bottom: 10px;">
<tr>
<td style="text-align: center; position: relative; left: -100px;">
<h3 style="margin: 8px 0 0 0; font-size: 15px; font-weight: bold;">
{{ company_doc.company_name }}
</h3>
<p style="
margin: 10px 0 0 0;
font-family: Helvetica, Arial, sans-serif;
font-size: 10px;
line-height: 1.4;
text-align: center;
">
1063 Chestnut Level Road<br>
Quarryville PA 17566<br>
{% if company_doc.phone_no %}Phone: {{ company_doc.phone_no }}{% endif %}
{% if company_doc.email %} | Email: {{ company_doc.email }}{% endif %}
</p>
</td>
<td style="
text-align: right;
font-family: Helvetica, Arial, sans-serif;
font-size: 14px;
width: 240px;
vertical-align: top;
">
<strong>Sales Order</strong><br>
<span>{{ doc.name }}</span><br>
Date: {{ frappe.utils.formatdate(doc.transaction_date, "MM-dd-yyyy") }}
</td>
</tr>
</table>
<!-- Delivery Date / Terms -->
<table style="width:100%; margin-top:-4px; margin-bottom:10px;">
<tr>
<td style="width:60%;"></td>
<td style="width:40%; text-align:right; font-size:15px; line-height:1.4;">
{% if doc.delivery_date %}
<strong>Delivery Date:</strong>
{{ frappe.utils.formatdate(doc.delivery_date, "MM-dd-yyyy") }}<br>
{% endif %}
<strong>Terms:</strong> {{ doc.payment_terms_template or "Net 30" }}
</td>
</tr>
</table>
<!-- WINDOW ADDRESS -->
<div style="
position:absolute;
top:1.5in;
left:1.125in;
width:4.5in;
height:1.25in;
font-size:15px;
line-height:1.15em;
overflow:hidden;
">
{{ doc.customer_name }}<br>
{{ doc.address_display or doc.customer_address }}
</div>
<div class="window-address-space">
{% if doc.custom_subscription_data or doc.custom_invoice_notes %}
<table style="width:100%; margin-bottom:6px;">
<tr>
<td style="font-size:14px; line-height:1.3;">
{% if doc.custom_subscription_data %}
<div style="font-weight:bold;">
{{ doc.custom_subscription_data }}
</div>
{% endif %}
{% if doc.custom_invoice_notes %}
<div style="white-space:pre-line;">
{{ doc.custom_invoice_notes }}
</div>
{% endif %}
</td>
</tr>
</table>
{% endif %}
<!-- Items -->
<table style="width:100%; border-collapse:collapse; margin-bottom:0;">
<thead>
<tr style="background:#f5f5f5;">
<th style="border:1px solid #ccc; padding:6px;">Item</th>
<th style="border:1px solid #ccc; padding:6px;">Description</th>
<th style="border:1px solid #ccc; padding:6px; text-align:right;">Qty</th>
<th style="border:1px solid #ccc; padding:6px; text-align:right;">Rate</th>
<th style="border:1px solid #ccc; padding:6px; text-align:right;">Amount</th>
</tr>
</thead>
<tbody>
{% for row in doc.items %}
<tr>
<td style="border:1px solid #ccc; padding:6px; font-size:14px;">
{{ row.item_code }}
</td>
<td style="border:1px solid #ccc; padding:6px; font-size:14px;">
{{ row.item_name }}
</td>
<td style="border:1px solid #ccc; padding:6px; font-size:14px; text-align:right;">
{{ row.qty }}
</td>
<td style="border:1px solid #ccc; padding:6px; font-size:14px; text-align:right;">
{{ row.rate }}
</td>
<td style="border:1px solid #ccc; padding:6px; font-size:14px; text-align:right;">
{{ row.amount }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
<!-- Totals -->
<div style="font-family: Helvetica, Arial, sans-serif; font-size: 15px; color: #333; margin-top: 10px; width: 40%; margin-left: auto;">
<p style="display:flex; justify-content:space-between;">
<strong>Subtotal:</strong><span>{{ doc.total }}</span>
</p>
{% for tax in doc.taxes %}
<p style="display:flex; justify-content:space-between;">
<strong>{{ tax.account_head }} ({{ tax.rate }}%):</strong>
<span>{{ tax.tax_amount }}</span>
</p>
{% endfor %}
<p style="display:flex; justify-content:space-between;">
<strong>Total:</strong><span>{{ doc.grand_total }}</span>
</p>
</div>
</div>
<!-- Footer -->
<div style="margin-top:23px; font-size:10px; color:#777; text-align:center;">
<p style="white-space:pre-line; margin:0;">
This Sales Order is not an invoice.
Pricing and availability subject to confirmation.
Thank you for your business.
</p>
</div>
</div>

View File

@@ -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();
});
};

View File

@@ -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: `
<div style="padding: 20px;">
<div>
<label>First Name</label>
<input
type="text"
id="first_name_${uid}"
class="form-control"
/>
</div>
<div class="mt-2">
<label>Last Name</label>
<input
type="text"
id="last_name_${uid}"
class="form-control"
/>
</div>
<div class="mt-2">
<label>Company (Optional)</label>
<input
type="text"
id="company_${uid}"
class="form-control"
/>
</div>
<div class="mt-2">
<label>Billing ZIP</label>
<input
type="text"
id="billing_zip_${uid}"
class="form-control"
/>
</div>
<div class="mt-3">
<label style="font-weight: bold;">
<input
type="checkbox"
id="save_autopay_${uid}"
/>
Save for Auto Pay
</label>
</div>
<div
id="cc_number_${uid}"
class="mt-3"
></div>
<div
id="cc_exp_${uid}"
class="mt-2"
></div>
<div
id="cc_cvv_${uid}"
class="mt-2"
></div>
<button
id="pay_btn_${uid}"
class="btn btn-primary mt-4"
>
Pay ${format_currency(frm.doc.outstanding_amount)}
</button>
<div style="padding:20px;">
<div>
<label>First Name</label>
<input type="text" id="first_name_${uid}" class="form-control"/>
</div>
`
<div class="mt-2">
<label>Last Name</label>
<input type="text" id="last_name_${uid}" class="form-control"/>
</div>
<div class="mt-2">
<label>Company (Optional)</label>
<input type="text" id="company_${uid}" class="form-control"/>
</div>
<div class="mt-2">
<label>Billing ZIP</label>
<input type="text" id="billing_zip_${uid}" class="form-control"/>
</div>
<div class="mt-3">
<label style="font-weight:bold;">
<input type="checkbox" id="save_autopay_${uid}"/>
Save for Auto Pay
</label>
</div>
<div class="mt-3" style="border-top:1px solid #eee; padding-top:12px;">
<label style="font-weight:bold;">
<input type="checkbox" id="multi_invoice_${uid}"/>
Pay Additional Invoices
</label>
</div>
<div id="invoice_table_wrap_${uid}" style="display:none; margin-top:12px;">
<div id="invoice_table_loading_${uid}" style="color:#888; font-size:13px;">
Loading invoices...
</div>
<table id="invoice_table_${uid}" class="table table-bordered table-sm"
style="display:none; font-size:13px;">
<thead style="background:#f5f5f5;">
<tr>
<th style="width:36px;">
<input type="checkbox" id="select_all_${uid}" title="Select all"/>
</th>
<th>Invoice #</th>
<th>Date</th>
<th>Customer</th>
<th style="text-align:right;">Amount Due</th>
</tr>
</thead>
<tbody id="invoice_tbody_${uid}"></tbody>
<tfoot>
<tr>
<td colspan="4" style="text-align:right; font-weight:bold;">
Selected Total
</td>
<td style="text-align:right; font-weight:bold;"
id="selected_total_${uid}">
${format_currency(frm.doc.outstanding_amount)}
</td>
</tr>
</tfoot>
</table>
</div>
<div id="cc_number_${uid}" class="mt-3"></div>
<div id="cc_exp_${uid}" class="mt-2"></div>
<div id="cc_cvv_${uid}" class="mt-2"></div>
<button id="pay_btn_${uid}" class="btn btn-primary mt-4">
Pay ${format_currency(frm.doc.outstanding_amount)}
</button>
</div>`
}
],
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 = `
<td style="text-align:center;">
<input type="checkbox"
class="inv-check-${uid}"
data-name="${inv.name}"
${inv.name === frm.doc.name ? "checked" : ""}/>
</td>
<td><a href="/app/sales-invoice/${inv.name}" target="_blank">${inv.name}</a></td>
<td>${frappe.datetime.str_to_user(inv.posting_date)}</td>
<td>${inv.customer_name || frm.doc.customer_name}</td>
<td style="text-align:right;">${format_currency(inv.outstanding_amount)}</td>
`;
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)}`;
}
}
}
});