main #17
@@ -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}
|
||||
160
ns_app/print_formats/print_formats/ns_dunning_double_window.html
Normal file
160
ns_app/print_formats/print_formats/ns_dunning_double_window.html
Normal 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>
|
||||
153
ns_app/print_formats/print_formats/quotation_double_window.html
Normal file
153
ns_app/print_formats/print_formats/quotation_double_window.html
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
});
|
||||
};
|
||||
@@ -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)}`;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user