Merge pull request 'add autopay and fix customer entry form.' (#13) from main into production

Reviewed-on: #13
This commit was merged in pull request #13.
This commit is contained in:
2026-05-18 15:27:57 +00:00
5 changed files with 1614 additions and 394 deletions

View File

@@ -0,0 +1,203 @@
Author: Ty Reynolds
Description: Full flow of what happens now from the moment a user opens the payment form through vault creation and payment entry generation.
1. User Opens “Pay Invoice” Form
Frontend JS runs:
open_manual_payment_form(frm)
This creates a custom Frappe dialog containing:
Cardholder Name
Billing ZIP
Secure card fields from Collect.js
“Subscribe to Auto Pay” checkbox
Pay button
At this point, no card data touches ERPNext, Collect.js owns the secure fields
2. Collect.js Initializes Secure Fields
These divs:
<div id="cc_number_x"></div>
<div id="cc_exp_x"></div>
<div id="cc_cvv_x"></div>
are replaced by NMI-hosted iframe fields.
This is important because card numbers never enter our JS, CVV never reaches ERPNext, PCI scope stays much lower and we don't have worry.
3. User Enters Card Info
User types:
card number
expiration
CVV
ZIP
cardholder name
Optionally checks:
Subscribe to Auto Pay
4. User Clicks “Pay”
JS gathers "const save_autopay = checkbox.checked ? 1 : 0;". Then Collect.js tokenizes the card. Instead of returning card data, NMI returns "payment_token". This token represents the card securely.
5. Frontend Calls Backend API
Frontend sends:
frappe.call({
method: "ns_app.api.payments.run_token_payment",
args: {
invoice,
token,
cardholder_name,
billing_zip,
save_autopay
}
})
ERPNext now receives:
token
invoice
checkbox value
customer metadata
But not the raw card data.
6. Backend Starts run_token_payment()
Python method begins:
run_token_payment(...)
First debug logs run:
AUTOPAY DEBUG - START
This is to confirm invoice arrived and checkbox value arrived
7. Feature Flag Protection Runs
This executes:
if not frappe.conf.get("enable_autopay_signup"):
save_autopay = 0
Meaning checkbox can exist but vaulting can be globally disabled
Then "save_autopay = int(save_autopay or 0)" normalizes the value safely.
8. Invoice + Customer Are Loaded
Backend loads:
inv = frappe.get_doc("Sales Invoice", invoice)
customer = frappe.get_doc("Customer", inv.customer)
Now the system knows exact invoice, ERP customer and outstanding balance
9. Name + ZIP Are Prepared
Fallback logic determines first_name, last_name and billing_zip
This is used both for payment and vault creation
10. SALE Transaction Runs
Backend sends:
sale_data = {
"type": "sale",
"payment_token": token,
...
}
to:
https://secure.nmi.com/api/transact.php
NMI processes the payment.
11. NMI Returns Payment Response
NMI returns query-string style data:
response=1
transactionid=123456
Python then parses it.
12. Payment Success Check
If response == "1", then payment succeeded.
If not:
Error returned to frontend, payment entry NOT created
13. ERPNext Payment Entry Is Created
Code runs:
create_payment_entry(...)
This creates Payment Entry, allocates against invoice, submits it automatically and invoice balance updates immediately.
14. Vault Logic Begins (Only If Checked)
This section runs ONLY if save_autopay == 1, which means the box is checked, and feature flag enabled.
Debug log:
AUTOPAY DEBUG - VAULT ENTRY
15. Vault Request Sent to NMI
Backend sends customer_vault=add_customer and payment_token
This tells NMI to store this payment method securely
16. NMI Creates Customer Vault Entry
NMI returns response=1 customer_vault_id=******
This vault ID is the reusable payment profile.
No card data is stored in ERPNext.
Only the vault reference.
17. ERPNext Customer Is Updated
If successful:
customer.custom_auto_pay_id = vault_id and customer.custom_auto_pay_status = 1
Now the ERP Customer is marked as enrolled in autopay and linked to NMI vault profile
18. Autopay Transaction Flow
Autopay requests send:
"type": "sale",
"customer_vault_id": vault_id
NMI charges the saved method directly.
19. Webhook Safety
If NMI later posts payment confirmations crystalclear_webhook() can also create payment entries automatically.
Security Model:
Our system is currently designed to -> Never have ERPNext store card numbers. CVV never touches our backend. Collect.js handles PCI-sensitive data. NMI stores vault securely. ERPNext only stores vault ID
root@erpnext:/home/norman/frappe-bench#

View File

@@ -1,22 +1,29 @@
import frappe
import requests
import urllib.parse
from frappe import generate_hash
from frappe.utils import nowdate
from frappe.utils import cint
@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
)
}
@frappe.whitelist()
def run_autopay_payment(invoice):
inv = frappe.get_doc("Sales Invoice", invoice)
if inv.outstanding_amount <= 0:
@@ -24,8 +31,13 @@ def run_autopay_payment(invoice):
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,
@@ -36,70 +48,133 @@ def run_autopay_payment(invoice):
response = call_payment_api(payload)
if not response.get("success"):
frappe.throw(response.get("error", "Payment failed"))
payment_entry = create_payment_entry(
invoice=inv.name,
amount=payload["amount"],
transaction_id=response.get("transaction_id")
)
frappe.throw(
response.get("error", "Payment failed")
)
return {
"success": True,
"message": "AutoPay payment successful",
"payment_entry": payment_entry
"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")
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": invoice
"orderid": order_id
}
try:
response = requests.post(url, data=data, timeout=30)
response = requests.post(
url,
data=data,
timeout=30
)
response.raise_for_status()
log_response = response.text[:120]
frappe.logger("payments").info(
f"NMI PAYMENT | Invoice: {invoice} | Amount: {payload['amount']} | Response: {response.text}"
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")
# Parse gateway response
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]
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 Payment API Error"
)
frappe.throw(
"Payment processor unreachable"
)
if success == "1":
# Detect payment mode
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"],
@@ -116,202 +191,406 @@ def call_payment_api(payload):
"success": False,
"error": message
}
@frappe.whitelist()
def run_token_payment(invoice, token, cardholder_name=None, billing_zip=None, save_autopay=0):
def run_token_payment(
invoice,
token,
first_name=None,
last_name=None,
company=None,
billing_zip=None,
save_autopay=0
):
if not frappe.conf.get("enable_autopay_signup"):
if not token:
return {
"success": False,
"error": "Missing payment token"
}
if not frappe.conf.get(
"enable_autopay_signup"
):
save_autopay = 0
inv = frappe.get_doc("Sales Invoice", invoice)
customer = frappe.get_doc("Customer", inv.customer)
save_autopay = int(save_autopay or 0)
inv = frappe.get_doc(
"Sales Invoice",
invoice
)
customer = frappe.get_doc(
"Customer",
inv.customer
)
url = "https://secure.nmi.com/api/transact.php"
# Fallback logic
customer_name = (
cardholder_name
or inv.customer_name
or inv.customer
or "Customer"
)
billing_zip = (
billing_zip
or inv.get("billing_zip")
or inv.get("pincode")
or ""
)
# Name split
parts = customer_name.strip().split(" ", 1)
first_name = parts[0]
last_name = parts[1] if len(parts) > 1 else "."
sale_data = {
"security_key": frappe.conf.get("nmi_security_key"),
"type": "sale",
"payment_token": token,
"amount": inv.outstanding_amount,
"orderid": inv.name,
"first_name": first_name,
"last_name": last_name,
"email": inv.contact_email or "",
"zip": billing_zip,
}
sale_response = requests.post(url, data=sale_data)
sale_result = urllib.parse.parse_qs(sale_response.text)
frappe.logger("payments").info(f"NMI SALE RESPONSE: {sale_response.text}")
success = sale_result.get("response", ["0"])[0]
transaction_id = sale_result.get("transactionid", [""])[0]
if success != "1":
return {
"success": False,
"error": sale_result.get("responsetext", ["Error"])[0]
}
# Create payment entry
create_payment_entry(
invoice=invoice,
amount=inv.outstanding_amount,
transaction_id=transaction_id,
mode_of_payment="Credit Card"
)
vault_id = None
# Vault (if checked)
if cint(save_autopay):
vault_data = {
"security_key": frappe.conf.get("nmi_security_key"),
"type": "add_customer",
"payment_token": token,
"first_name": first_name,
"last_name": last_name,
"zip": billing_zip,
"customer_vault": "add_customer"
}
try:
vault_response = requests.post(url, data=vault_data, timeout=30)
vault_result = urllib.parse.parse_qs(vault_response.text)
frappe.logger("payments").info(f"NMI VAULT RESPONSE: {vault_response.text}")
vault_success = vault_result.get("response", ["0"])[0]
vault_id = vault_result.get("customer_vault_id", [""])[0]
if vault_success == "1" and vault_id:
customer.custom_auto_pay_id = vault_id
customer.custom_auto_pay_status = 1
customer.save(ignore_permissions=True)
except Exception:
frappe.log_error(frappe.get_traceback(), "Vault Creation Failed")
return {
"success": True,
"vault_id": vault_id
}
@frappe.whitelist()
def save_to_autopay(customer, token, cardholder_name=None, billing_zip=None):
cust = frappe.get_doc("Customer", customer)
# Priority: Form input → Customer record → fallback
final_name = (
cardholder_name
or cust.customer_name
or cust.name
first_name = (
first_name
or customer.customer_name
or "Customer"
).strip()
# ZIP fallback chain
final_zip = (
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",
"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,
}
# Save to vault DURING sale transaction
if save_autopay:
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"
)
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"
)
except Exception:
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]
if success != "1":
frappe.log_error(
sale_response.text[:120],
"AUTOPAY DEBUG - SALE FAILED"
)
return {
"success": False,
"error": sale_result.get(
"responsetext",
["Error"]
)[0]
}
existing_pe = frappe.db.exists(
"Payment Entry",
{"reference_no": transaction_id}
)
if not existing_pe:
create_payment_entry(
invoice=invoice,
amount=inv.outstanding_amount,
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()
frappe.log_error(
f"""
Vault save complete
customer={customer.name}
vault_id={vault_id}
""",
"AUTOPAY DEBUG - SAVE COMPLETE"
)
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
):
if not token:
return {
"success": False,
"error": "Missing payment token"
}
cust = frappe.get_doc(
"Customer",
customer
)
first_name = (
first_name
or cust.customer_name
or "Customer"
).strip()
last_name = (
last_name
or "."
).strip()
company = (
company or ""
).strip()
billing_zip = (
billing_zip
or cust.get("billing_zip")
or cust.get("pincode")
or ""
)
# Optional but recommended fields
email = cust.get("email_id") or ""
# Safe name split
parts = final_name.split(" ", 1)
first_name = parts[0]
last_name = parts[1] if len(parts) > 1 else "."
data = {
"security_key": frappe.conf.get("nmi_security_key"),
"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,
"email": email,
"company": company,
"zip": final_zip,
"zip": billing_zip,
}
try:
response = requests.post(
"https://secure.nmi.com/api/transact.php",
data=data,
timeout=30
)
result = urllib.parse.parse_qs(response.text)
result = urllib.parse.parse_qs(
response.text
)
frappe.logger("payments").info({
"vault_request": data,
"vault_response": response.text
})
success = result.get(
"response",
["0"]
)[0]
success = result.get("response", ["0"])[0]
vault_id = result.get("customer_vault_id", [""])[0]
message = result.get("responsetext", ["Failed"])[0]
returned_vault_id = result.get(
"customer_vault_id",
[cust.name.upper()]
)[0]
message = result.get(
"responsetext",
["Failed"]
)[0]
except Exception:
frappe.log_error(frappe.get_traceback(), "NMI Vault Error")
return {"success": False, "error": "Vault request failed"}
if success == "1" and vault_id:
frappe.log_error(
frappe.get_traceback(),
"NMI Vault Error"
)
return {
"success": False,
"error": "Vault request failed"
}
if (
success == "1"
or "duplicate" in message.lower()
):
cust.custom_auto_pay_id = (
returned_vault_id
)
# Save vault ID to ERP Customer
cust.custom_auto_pay_id = vault_id
cust.custom_auto_pay_status = 1
cust.custom_auto_pay_name = final_name
cust.custom_auto_pay_zip = final_zip
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": vault_id
"vault_id": returned_vault_id
}
return {
"success": False,
"error": message
}
@frappe.whitelist(allow_guest=True)
def crystalclear_webhook():
data = frappe.local.form_dict
if data.get("response") != "1":
@@ -337,20 +616,36 @@ def crystalclear_webhook():
return "ok"
def create_payment_entry(invoice, amount, transaction_id=None, mode_of_payment=None):
def create_payment_entry(
invoice,
amount,
transaction_id=None,
mode_of_payment=None
):
# Prevent duplicates
if transaction_id and frappe.db.exists(
"Payment Entry", {"reference_no": transaction_id}
if (
transaction_id
and frappe.db.exists(
"Payment Entry",
{"reference_no": transaction_id}
)
):
return
inv = frappe.get_doc("Sales Invoice", invoice)
inv = frappe.get_doc(
"Sales Invoice",
invoice
)
if mode_of_payment in [
"ACH",
"Credit Card"
]:
# Account logic
if mode_of_payment in ["ACH", "Credit Card"]:
paid_to = "ENB Bank Account - NIL"
else:
paid_to = frappe.db.get_value(
"Company",
inv.company,
@@ -358,30 +653,42 @@ def create_payment_entry(invoice, amount, transaction_id=None, mode_of_payment=N
)
if not paid_to:
frappe.throw("No receiving account configured")
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.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.append(
"references",
{
"reference_doctype": "Sales Invoice",
"reference_name": invoice,
"allocated_amount": amount
}
)
pe.insert(ignore_permissions=True)
pe.submit()
return pe.name
pe.submit()

View File

@@ -0,0 +1,215 @@
<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: 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>Invoice</strong>
<span>{{ doc.name }}</span><br>
<strong>Date:</strong>
{{ frappe.utils.formatdate(doc.posting_date, "MM-dd-yyyy") }}
</td>
</tr>
</table>
<!-- Due 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-family: Helvetica, Arial, sans-serif; font-size: 15px; line-height: 1.4;">
<strong>Customer Number:</strong> {{doc.customer}}<br>
<strong>Due Date:</strong> {{ frappe.utils.formatdate(doc.due_date, "MM-dd-yyyy") }}<br>
<strong>Terms:</strong> {{ doc.payment_terms_template or "Net 30" }}<br>
<strong>**Please include invoice <br> number with check**</strong>
</td>
</tr>
</table>
<!-- WINDOW ADDRESS (9x4 envelope position) -->
<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 WRAPPER -->
<div class="window-address-space">
{% if doc.custom_subscription_data or doc.custom_invoice_notes %}
<table style="width:100%; margin:0 0 6px 0; border-collapse:collapse;">
<tr>
<td style="font-size:14px; padding:0; line-height:1.3;">
{% if doc.custom_subscription_data %}
<div style="margin:0; padding:0; font-weight:bold;">
{{ doc.custom_subscription_data }}
</div>
{% endif %}
{% if doc.custom_invoice_notes %}
<div style="margin:0; padding:0; white-space:pre-line;">
{{ doc.custom_invoice_notes }}
</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-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>
<p style="display:flex; justify-content:space-between;">
<strong>Paid:</strong><span>{{ doc.total_advance }}</span>
</p>
{% set balance = doc.outstanding_amount or 0 %}
{% if balance == 0 %}
{% set balance_color = "#2e7d32" %}
{% elif balance > 0 %}
{% set balance_color = "#c62828" %}
{% else %}
{% set balance_color = "#ef6c00" %}
{% endif %}
<p style="display:flex; justify-content:space-between;">
<strong style="color: {{ balance_color }} !important;">Balance Due:</strong>
<span style="color: {{ balance_color }} !important;">{{ balance }}</span>
</p>
</div>
</div> <!-- end print space wrapper -->
<!-- Footer -->
<div style="margin-top: 23px; font-size: 10px; color: #777; text-align: center;">
<p style="white-space: pre-line; margin: 0;">
Prompt payment is always appreciated. We accept payments by check or over the phone using a debit or credit card. Automatic payment setup is also available upon request.
Invoices over 30 days may be charged a late payment fee of 1.5% per month with a minimum fee of $5.
Thanks for your business.
</p>
</div>
{% set balance = doc.outstanding_amount or 0 %}
{% if balance == 0 %}
<div style="text-align: center; margin-top: 35px;">
<div style="
margin-top: 35px;
text-align: center;
font-size: 28px;
font-weight: bold;
color: #2e7d32;
border: 3px solid #2e7d32;
display: inline-block;
padding: 10px 40px;
transform: rotate(-5deg);
">
PAID
</div>
</div>
{% endif %}
</div>

View File

@@ -1,58 +1,129 @@
frappe.provide("ns_app.customer");
// Preserve original quick entry
const _make_quick_entry = frappe.ui.form.make_quick_entry;
console.log("NS APP CUSTOMER JS LOADED");
// Override
frappe.ui.form.make_quick_entry = function (doctype, after_insert) {
$(document).ready(() => {
if (doctype === "Customer") {
setTimeout(() => {
console.log("NS App: Intercepted Customer Quick Entry");
const TargetClass =
frappe.ui.form.CustomerQuickEntryForm;
let customer_name = "";
if (!TargetClass) {
// Pull value from current form route options
if (frappe.route_options?.name) {
customer_name = frappe.route_options.name;
console.error(
"NS App: CustomerQuickEntryForm not found"
);
return;
}
// Fallback: get typed value from active link field
if (!customer_name) {
// Prevent duplicate patching
if (TargetClass.__ns_patched) {
const active = document.activeElement;
console.log(
"NS App: already patched"
);
if (active && active.value) {
customer_name = active.value;
}
return;
}
console.log("NS App: Captured customer name:", customer_name);
console.log(
"NS App: patching CustomerQuickEntryForm"
);
ns_app.customer.open_quick_entry({
callback: after_insert,
customer_name: customer_name
});
frappe.ui.form.CustomerQuickEntryForm =
class extends TargetClass {
return;
}
render_dialog() {
return _make_quick_entry.apply(this, arguments);
};
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
});
}
};
TargetClass.__ns_patched = true;
console.log(
"NS App: CustomerQuickEntryForm patched successfully"
);
}, 1000);
});
ns_app.customer.open_quick_entry = function (opts = {}) {
console.log("NS App: Custom Customer Quick Entry OPENED");
console.log(
"NS App: Custom Customer Quick Entry OPENED"
);
const d = new frappe.ui.Dialog({
title: "New Customer",
size: "large",
fields: [
// ───────── CUSTOMER ─────────
{
fieldtype: "Section Break",
label: "Customer Information"
@@ -63,18 +134,22 @@ ns_app.customer.open_quick_entry = function (opts = {}) {
label: "Customer Name",
fieldtype: "Data",
reqd: 1,
default: opts.customer_name || "",
description: "Enter the customer or company name"
default:
opts.customer_name || "",
description:
"Enter the customer or company name"
},
{
fieldname: "customer_type",
label: "Customer Type",
fieldtype: "Select",
options: "Company\nIndividual",
options:
"Company\nIndividual",
default: "Company",
reqd: 1,
description: "Select whether this customer is a company or individual"
description:
"Select whether this customer is a company or individual"
},
{
@@ -84,18 +159,23 @@ ns_app.customer.open_quick_entry = function (opts = {}) {
options: "Customer Group",
default: "Commercial",
reqd: 1,
description: "Select the customer group"
description:
"Select the customer group"
},
{
fieldname: "custom_send_via",
label: "Preferred Delivery Method",
label:
"Preferred Delivery Method",
fieldtype: "Select",
options: "mail\nemail\nfax",
description: "Choose how documents should be sent to the customer"
options:
"mail\nemail\nfax",
description:
"Choose how documents should be sent to the customer"
},
// ───────── CONTACT ─────────
{
fieldtype: "Section Break",
label: "Primary Contact"
@@ -106,36 +186,45 @@ ns_app.customer.open_quick_entry = function (opts = {}) {
label: "Email Address",
fieldtype: "Data",
options: "Email",
description: "Enter the customer's email address"
description:
"Enter the customer's email address"
},
{
fieldname: "mobile_no",
label: "Mobile Phone Number",
label:
"Mobile Phone Number",
fieldtype: "Data",
reqd: 1,
description: "Enter the customer's mobile phone number"
description:
"Enter the customer's mobile phone number"
},
// ───────── ADDRESS ─────────
{
fieldtype: "Section Break",
label: "Address Information"
label:
"Address Information"
},
{
fieldname: "address_line1",
label: "Address Line 1",
label:
"Address Line 1",
fieldtype: "Data",
reqd: 1,
description: "Enter the street address"
description:
"Enter the street address"
},
{
fieldname: "address_line2",
label: "Address Line 2",
label:
"Address Line 2",
fieldtype: "Data",
description: "Enter apartment, suite, or secondary address information"
description:
"Enter apartment, suite, or secondary address information"
},
{
@@ -143,21 +232,24 @@ ns_app.customer.open_quick_entry = function (opts = {}) {
label: "ZIP Code",
fieldtype: "Data",
reqd: 1,
description: "Enter the ZIP or postal code"
description:
"Enter the ZIP or postal code"
},
{
fieldname: "city",
label: "City",
fieldtype: "Data",
description: "Enter the city"
description:
"Enter the city"
},
{
fieldname: "state",
label: "State",
fieldtype: "Data",
description: "Enter the state"
description:
"Enter the state"
},
{
@@ -165,40 +257,61 @@ ns_app.customer.open_quick_entry = function (opts = {}) {
label: "Country",
fieldtype: "Link",
options: "Country",
default: "United States",
description: "Select the 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: Create Customer clicked",
values
);
d.disable_primary_action();
frappe.call({
method: "ns_app.api.customer.create_customer_full",
method:
"ns_app.api.customer.create_customer_full",
args: values,
callback(r) {
console.log("NS App: Customer created", r.message);
console.log(
"NS App: Customer created",
r.message
);
d.hide();
frappe.show_alert({
message: "Customer created via NS App",
message:
"Customer created via NS App",
indicator: "green"
});
if (opts.callback) {
opts.callback(r.message);
if (
opts.callback
) {
opts.callback(
r.message
);
}
},
always() {
d.enable_primary_action();
}
});
@@ -207,29 +320,38 @@ ns_app.customer.open_quick_entry = function (opts = {}) {
d.show();
// Accessibility labels for screen readers
// Accessibility labels
setTimeout(() => {
d.fields.forEach(field => {
const control = d.get_field(field.fieldname);
const control =
d.get_field(
field.fieldname
);
if (!control || !control.$input) return;
if (
!control ||
!control.$input
) {
return;
}
// ARIA label
control.$input.attr(
"aria-label",
field.label || field.fieldname
field.label ||
field.fieldname
);
// Screen reader title
control.$input.attr(
"title",
field.label || field.fieldname
field.label ||
field.fieldname
);
// Placeholder text
if (field.label) {
control.$input.attr(
"placeholder",
field.label
@@ -237,63 +359,127 @@ ns_app.customer.open_quick_entry = function (opts = {}) {
}
});
console.log("NS App: Accessibility labels applied");
console.log(
"NS App: accessibility applied"
);
}, 300);
// ZIP auto-fill
d.fields_dict.pincode.df.onchange = () => {
// ZIP autofill
const zip = d.get_value("pincode");
d.fields_dict.pincode.df.onchange =
() => {
if (!zip || zip.length < 5) return;
const zip =
d.get_value(
"pincode"
);
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(() => {});
};
// Prevent Enter from submitting unless primary button is focused
d.$wrapper.on("keydown", "input, select, textarea", function (e) {
if (e.key === "Enter") {
const active = document.activeElement;
// Allow Enter ONLY if primary action button is focused
if (
active &&
active.classList.contains("btn-primary")
!zip ||
zip.length < 5
) {
return;
}
e.preventDefault();
console.log(
"NS App: ZIP lookup",
zip
);
// Move to next field
const fields = d.$wrapper
.find("input, select, textarea")
.filter(":visible:not([disabled])");
fetch(
`https://api.zippopotam.us/us/${zip}`
)
.then(r =>
r.ok
? r.json()
: null
)
.then(data => {
const index = fields.index(this);
if (
!data ||
!data.places?.length
) {
return;
}
if (index > -1 && index + 1 < fields.length) {
fields.eq(index + 1).focus();
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();
}
}
}
});
);
};

View File

@@ -24,21 +24,35 @@ frappe.ui.form.on("Sales Invoice", {
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) {
frm.enable_save();
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);
}
}
});
@@ -49,37 +63,53 @@ function run_autopay(frm) {
frappe.confirm(
`Run AutoPay for ${format_currency(frm.doc.outstanding_amount)}?`,
() => {
// Change button to processing
frm.remove_custom_button("Run Payment");
frm.add_custom_button("Processing...", () => {}, null).prop("disabled", true);
frm.add_custom_button(
"Processing...",
() => {},
null
).prop("disabled", true);
frappe.call({
method: "ns_app.api.payments.run_autopay_payment",
args: {
invoice: frm.doc.name
},
freeze: true,
freeze_message: "Processing payment...",
callback(r) {
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) {
// Success UI
frm.remove_custom_button("Run Payment");
frm.remove_custom_button(
"Run Payment"
);
frm.add_custom_button("Paid ✓", () => {})
.prop("disabled", true);
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"
});
@@ -87,26 +117,32 @@ function run_autopay(frm) {
} 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) {
// Remove processing button
frm.remove_custom_button("Processing...");
// Add retry button
frm.add_custom_button("Retry Payment", () => {
run_payment_flow(frm);
});
frm.add_custom_button(
"Retry Payment",
() => {
run_payment_flow(frm);
}
);
frappe.msgprint({
title: "Payment Failed",
@@ -119,105 +155,223 @@ function show_payment_failed(frm, message) {
function open_manual_payment_form(frm) {
const uid = Date.now(); // unique per open
const uid = Date.now();
window.ns_payment_processing = false;
const dialog = new frappe.ui.Dialog({
title: "Secure Payment",
size: "large",
fields: [
{
fieldtype: "HTML",
fieldname: "payment_form",
options: `
<div style="padding: 20px;">
<div>
<label>Cardholder Name</label>
<input type="text" id="cardholder_name_${uid}" class="form-control"/>
<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"/>
<input
type="text"
id="billing_zip_${uid}"
class="form-control"
/>
</div>
<div class="mt-3">
<label style="color: red; font-weight: bold;">
<input type="checkbox" id="save_autopay_${uid}" />
Save for Auto Pay (** Not Fuctional Yet **)
<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>
<div
id="cc_number_${uid}"
class="mt-3"
></div>
<button id="pay_btn_${uid}" class="btn btn-primary mt-4">
Pay $${frm.doc.outstanding_amount}
<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>
`
}
],
// Destroy CollectJS instance if close button is clicked
primary_action_label: "Close",
primary_action() {
dialog.hide();
dialog.hide();
}
});
dialog.show();
// Destroy CollectJS Instance if closed with the X button
dialog.$wrapper.on("hidden.bs.modal", function () {
dialog.$wrapper.on(
"hidden.bs.modal",
function () {
// Remove leftover backdrop (fix dark screen)
document.querySelectorAll(".modal-backdrop").forEach(el => el.remove());
document.querySelectorAll(
".modal-backdrop"
).forEach(el => el.remove());
// Restore body scroll + click
document.body.classList.remove("modal-open");
document.body.style.overflow = "";
document.body.classList.remove(
"modal-open"
);
// safely remove dialog
dialog.$wrapper.remove();
document.body.style.overflow = "";
// WClean CollectJS
if (window.CollectJS) {
try {
delete window.CollectJS;
} catch (e) {}
dialog.$wrapper.remove();
window.ns_payment_processing = false;
if (window.CollectJS) {
try {
delete window.CollectJS;
} catch (e) {}
}
}
);
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(" ") || "";
}
// Prefill
setTimeout(() => {
const nameEl = document.getElementById(`cardholder_name_${uid}`);
const zipEl = document.getElementById(`billing_zip_${uid}`);
if (companyEl) {
companyEl.value =
frm.doc.customer || "";
}
if (zipEl) {
zipEl.value =
frm.doc.billing_zip
|| frm.doc.pincode
|| "";
}
if (nameEl) nameEl.value = frm.doc.customer_name || "";
if (zipEl) zipEl.value = frm.doc.billing_zip || frm.doc.pincode || "";
}, 50);
// Load CollectJS once
function loadCollectJS(callback) {
// Remove existing script if it exists
const existingScript = document.querySelector('script[src*="Collect.js"]');
const existingScript = document.querySelector(
'script[src*="Collect.js"]'
);
if (existingScript) {
existingScript.remove();
}
// Reset global
if (window.CollectJS) {
delete window.CollectJS;
try {
delete window.CollectJS;
} catch (e) {}
}
// Create fresh script every instance
const script = document.createElement("script");
script.src = "https://secure.nmi.com/token/Collect.js";
const script =
document.createElement("script");
script.src =
"https://secure.nmi.com/token/Collect.js";
script.setAttribute(
"data-tokenization-key",
@@ -225,7 +379,11 @@ function open_manual_payment_form(frm) {
);
script.onload = () => {
console.log("CollectJS loaded fresh");
console.log(
"CollectJS loaded fresh"
);
callback();
};
@@ -234,114 +392,265 @@ function open_manual_payment_form(frm) {
loadCollectJS(() => {
console.log("CollectJS ready");
setTimeout(() => {
CollectJS.configure({
variant: "inline",
styleSniffer: true,
fields: {
ccnumber: {
selector: `#cc_number_${uid}`,
placeholder: "Card Number"
selector:
`#cc_number_${uid}`,
placeholder:
"Card Number"
},
ccexp: {
selector: `#cc_exp_${uid}`,
placeholder: "MM / YY"
selector:
`#cc_exp_${uid}`,
placeholder:
"MM / YY"
},
cvv: {
selector: `#cc_cvv_${uid}`,
placeholder: "CVV"
selector:
`#cc_cvv_${uid}`,
placeholder:
"CVV"
}
},
callback: function (response) {
if (response.token) {
// Get name and ZIP
const enteredName = document.getElementById(`cardholder_name_${uid}`)?.value;
const enteredZip = document.getElementById(`billing_zip_${uid}`)?.value;
// Save name and ZIP
const finalName = enteredName || frm.doc.customer_name;
const finalZip = enteredZip || frm.doc.billing_zip || frm.doc.pincode;
// Check autopay enrollment choice
const AUTOPAY_ENABLED = false;
const checkbox = document.getElementById(`save_autopay_${uid}`);
const save_autopay = AUTOPAY_ENABLED ? checkbox?.checked : 0;
run_token_payment(frm, response.token, dialog, {
cardholder_name: finalName,
billing_zip: finalZip,
save_autopay: save_autopay
});
} else {
frappe.msgprint("Payment failed to tokenize");
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"
);
return;
}
const firstName =
document.getElementById(
`first_name_${uid}`
)?.value?.trim();
const lastName =
document.getElementById(
`last_name_${uid}`
)?.value?.trim();
const company =
document.getElementById(
`company_${uid}`
)?.value?.trim();
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
}
);
}
});
const btn = document.getElementById(`pay_btn_${uid}`);
const btn =
document.getElementById(
`pay_btn_${uid}`
);
if (!btn) {
console.error("Pay button not found");
console.error(
"Pay button not found"
);
return;
}
btn.onclick = function () {
if (
window.ns_payment_processing
) {
return;
}
btn.disabled = true;
btn.innerText = "Processing...";
frappe.show_alert({
message: "Processing payment...",
message:
"Processing payment...",
indicator: "blue"
});
CollectJS.startPaymentRequest();
};
}, 100);
}, 300);
});
}
function run_token_payment(frm, token, dialog, extra_data = {}) {
const save_autopay = extra_data.save_autopay ? 1 : 0;
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,
cardholder_name: extra_data.cardholder_name,
billing_zip: extra_data.billing_zip,
save_autopay: save_autopay
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...",
callback(r) {
if (r.message?.success) {
if (save_autopay && r.message.vault_id) {
if (
extra_data.save_autopay
&& r.message.vault_id
) {
frappe.show_alert({
message: "Payment successful + AutoPay enabled",
message:
`Payment successful + AutoPay enabled (${r.message.vault_id})`,
indicator: "green"
});
} else {
frappe.show_alert({
message: "Payment successful",
message:
"Payment successful",
indicator: "green"
});
}
window.ns_payment_processing = false;
dialog.hide();
frm.reload_doc();
}
else {
frappe.msgprint(r.message?.error || "Payment failed");
} else {
window.ns_payment_processing = false;
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)}`;
}
}
}
});