Finished sign up for auto pay feature from pay invoice form. Also, added a doc file for the payment flow.

This commit is contained in:
Ty Reynolds
2026-05-15 13:47:35 -04:00
parent d45e7cbbe1
commit 8c4f1f753e
3 changed files with 1107 additions and 288 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

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