348 lines
10 KiB
Python
348 lines
10 KiB
Python
import frappe
|
|
from frappe import _
|
|
from frappe.utils import now_datetime, flt
|
|
from datetime import datetime
|
|
from kapital_bank.api import BIRBankClient
|
|
|
|
_AZ_MAP = str.maketrans("ƏəİışŞÇçÖöÜüĞğ", "EeIissCcOoUuGg")
|
|
|
|
|
|
def _latinize(text: str, max_len: int = None) -> str:
|
|
"""Transliterate Azərbaycan → Latin, strip non-ASCII, truncate."""
|
|
text = str(text or "").translate(_AZ_MAP)
|
|
text = text.encode("ascii", errors="ignore").decode("ascii").strip()
|
|
return text[:max_len] if max_len else text
|
|
|
|
|
|
@frappe.whitelist()
|
|
def sync_bank_codes(login_name=None):
|
|
"""Fetch bank codes from API and upsert into Kapital Bank Bank Code doctype."""
|
|
client = BIRBankClient(login_name)
|
|
resp = client.get_json("/bank-codes")
|
|
bank_codes = resp.get("responseData", {}).get("bankCodes", [])
|
|
|
|
added = 0
|
|
updated = 0
|
|
|
|
for item in bank_codes:
|
|
bank_code = (item.get("bankCode") or "").strip()
|
|
bank_name = (item.get("bankName") or "").strip()
|
|
if not bank_code:
|
|
continue
|
|
|
|
if frappe.db.exists("Kapital Bank Bank Code", bank_code):
|
|
existing_name = frappe.db.get_value("Kapital Bank Bank Code", bank_code, "bank_name")
|
|
if existing_name != bank_name:
|
|
frappe.db.set_value("Kapital Bank Bank Code", bank_code, "bank_name", bank_name)
|
|
updated += 1
|
|
else:
|
|
doc = frappe.new_doc("Kapital Bank Bank Code")
|
|
doc.bank_code = bank_code
|
|
doc.bank_name = bank_name
|
|
doc.insert(ignore_permissions=True)
|
|
added += 1
|
|
|
|
frappe.db.commit()
|
|
return {"success": True, "added": added, "updated": updated, "total": len(bank_codes)}
|
|
|
|
|
|
@frappe.whitelist()
|
|
def send_payment_request(name, login_name=None):
|
|
"""Validate Payment Request and send outgoing transfer to Kapital Bank."""
|
|
pr = frappe.get_doc("Payment Request", name)
|
|
|
|
if pr.docstatus != 1:
|
|
frappe.throw(_("Payment Request {0} is not submitted.").format(name))
|
|
|
|
if pr.payment_request_type != "Outward":
|
|
frappe.throw(_("Payment Request {0} is not of type Outward.").format(name))
|
|
|
|
if pr.status not in ("Initiated", "Partially Paid"):
|
|
frappe.throw(_("Payment Request {0} has invalid status: {1}.").format(name, pr.status))
|
|
|
|
kb_account = pr.get("kb_account")
|
|
if not kb_account:
|
|
frappe.throw(_("Field 'Kapital Bank Account' is not set."))
|
|
|
|
mapping = frappe.db.get_value(
|
|
"Kapital Bank Account Mapping",
|
|
{"iban": kb_account, "parenttype": "Kapital Bank Settings"},
|
|
["iban", "bank_account"],
|
|
as_dict=True,
|
|
)
|
|
if not mapping:
|
|
frappe.throw(_("Kapital Bank Account {0} is not mapped in Kapital Bank Settings.").format(kb_account))
|
|
|
|
from_iban = kb_account
|
|
|
|
party_bank_account = pr.get("kb_beneficiary_bank_account")
|
|
if not party_bank_account:
|
|
frappe.throw(_("Field 'Beneficiary Bank Account' is not set."))
|
|
|
|
payee_ba = frappe.get_doc("Bank Account", party_bank_account)
|
|
|
|
errors = []
|
|
|
|
if not payee_ba.iban:
|
|
errors.append(_("Field 'IBAN' is not set on Bank Account {0}.").format(party_bank_account))
|
|
|
|
kb_bank_code = payee_ba.get("kb_bank_code") or ""
|
|
if not kb_bank_code:
|
|
errors.append(_("Field 'KB Bank Code' is not set on Bank Account {0}.").format(party_bank_account))
|
|
elif len(kb_bank_code) != 6:
|
|
errors.append(_("Field 'KB Bank Code' on Bank Account {0} must be exactly 6 characters.").format(party_bank_account))
|
|
|
|
if not flt(pr.grand_total):
|
|
errors.append(_("Field 'Amount' is zero on Payment Request {0}.").format(name))
|
|
|
|
to_cust_name = _latinize(pr.party_name, 34)
|
|
if not to_cust_name:
|
|
errors.append(_("Party name '{0}' is empty after Latin transliteration.").format(pr.party_name))
|
|
|
|
existing = frappe.db.get_value(
|
|
"Kapital Bank Payment",
|
|
{"payment_request": name, "status": ["not in", ["Rejected", "Cancelled"]]},
|
|
"name",
|
|
)
|
|
if existing:
|
|
errors.append(_("Active KB Payment {0} already exists for this Payment Request.").format(existing))
|
|
|
|
if errors:
|
|
return {"success": False, "errors": errors}
|
|
|
|
op_name = f"PAY{datetime.now().strftime('%Y%m%d%H%M%S')}00"
|
|
payee_iban = payee_ba.iban
|
|
tax_id = payee_ba.get("tax_id") or ""
|
|
to_cust_name = _latinize(pr.party_name, 34)
|
|
purpose = _latinize(pr.name, 64)
|
|
|
|
payload = {
|
|
"fromAccount": from_iban,
|
|
"transferData": {
|
|
"operationName": op_name,
|
|
"benBankCode": kb_bank_code,
|
|
"toAccount": payee_iban,
|
|
"toTaxNo": tax_id,
|
|
"toCustName": to_cust_name,
|
|
"amount": float(flt(pr.grand_total)),
|
|
"purpose1": purpose,
|
|
},
|
|
}
|
|
|
|
import requests as _requests
|
|
|
|
frappe.log_error(f"[KB] Sending transfer payload for {name}:\n{payload}", "KB Transfer Payload")
|
|
|
|
try:
|
|
client = BIRBankClient(login_name)
|
|
resp = client.post_json("/v2/internal-transfer", json=payload)
|
|
operation_id = resp.get("operationId", "")
|
|
|
|
kbp = frappe.new_doc("Kapital Bank Payment")
|
|
kbp.update({
|
|
"payment_request": name,
|
|
"operation_name": op_name,
|
|
"operation_id": operation_id,
|
|
"status": "Sent",
|
|
"from_account": from_iban,
|
|
"to_account": payee_iban,
|
|
"to_tax_no": tax_id,
|
|
"to_cust_name": to_cust_name,
|
|
"ben_bank_code": kb_bank_code,
|
|
"amount": flt(pr.grand_total),
|
|
"purpose1": purpose,
|
|
"sent_at": now_datetime(),
|
|
"status_updated_at": now_datetime(),
|
|
})
|
|
kbp.insert(ignore_permissions=True)
|
|
frappe.db.commit()
|
|
except _requests.HTTPError as e:
|
|
frappe.db.rollback()
|
|
return {"success": False, "message": str(e)}
|
|
except Exception:
|
|
frappe.log_error(frappe.get_traceback(), f"KB Send Payment Request {name}")
|
|
frappe.db.rollback()
|
|
raise
|
|
|
|
return {"success": True}
|
|
|
|
|
|
@frappe.whitelist()
|
|
def check_payment_status(name, login_name=None):
|
|
"""Poll status for pending KB Payments of a Payment Request."""
|
|
pending = frappe.get_all(
|
|
"Kapital Bank Payment",
|
|
filters={"payment_request": name, "status": ["in", ["Sent", "Confirm Wait"]]},
|
|
fields=["name", "operation_name", "payment_request", "operation_id"],
|
|
)
|
|
|
|
if not pending:
|
|
return {"success": True, "updated": 0, "message": _("No pending transfers")}
|
|
|
|
client = BIRBankClient(login_name)
|
|
|
|
STATUS_MAP = {
|
|
"CONFIRM_WAIT": "Confirm Wait",
|
|
"SUCCESS": "Success",
|
|
"CONFIRMED": "Success",
|
|
"REJECTED": "Rejected",
|
|
"CANCELLED": "Cancelled",
|
|
"CANCELED": "Cancelled",
|
|
}
|
|
|
|
updated = 0
|
|
errors = []
|
|
|
|
for kbp in pending:
|
|
try:
|
|
resp = client.get_json("/internal-transfer/status", params={"operatorName": kbp.operation_name})
|
|
data = resp.get("data", [])
|
|
if not data:
|
|
frappe.logger().warning(f"[KB] Status check for {kbp.operation_name}: empty data response")
|
|
continue
|
|
|
|
entry = data[0]
|
|
bank_status = entry.get("status", "")
|
|
new_status = STATUS_MAP.get(bank_status)
|
|
|
|
if new_status is None:
|
|
frappe.db.set_value(
|
|
"Kapital Bank Payment",
|
|
kbp.name,
|
|
{"error_message": f"Unknown bank status: {bank_status}", "status_updated_at": now_datetime()},
|
|
update_modified=False,
|
|
)
|
|
frappe.db.commit()
|
|
continue
|
|
|
|
frappe.db.set_value(
|
|
"Kapital Bank Payment",
|
|
kbp.name,
|
|
{
|
|
"status": new_status,
|
|
"status_updated_at": now_datetime(),
|
|
"error_message": entry.get("description", ""),
|
|
},
|
|
update_modified=False,
|
|
)
|
|
frappe.db.commit()
|
|
updated += 1
|
|
|
|
if new_status == "Success":
|
|
pe_name = _create_pe_from_pr(kbp.payment_request)
|
|
if pe_name:
|
|
frappe.db.set_value(
|
|
"Kapital Bank Payment", kbp.name, "payment_entry", pe_name, update_modified=False
|
|
)
|
|
frappe.db.commit()
|
|
elif new_status == "Rejected":
|
|
_handle_rejected_pr(kbp.payment_request, entry.get("description", ""))
|
|
|
|
except Exception as e:
|
|
frappe.log_error(frappe.get_traceback(), f"KB Check Status {kbp.operation_name}")
|
|
errors.append(str(e))
|
|
|
|
return {"success": True, "updated": updated, "errors": errors}
|
|
|
|
|
|
@frappe.whitelist()
|
|
def cancel_payment_request(name, login_name=None):
|
|
"""Cancel pending bank transfers for a Payment Request."""
|
|
pending = frappe.get_all(
|
|
"Kapital Bank Payment",
|
|
filters={"payment_request": name, "status": ["in", ["Sent", "Confirm Wait"]]},
|
|
fields=["name", "operation_name", "operation_id"],
|
|
)
|
|
|
|
if not pending:
|
|
return {"success": True, "cancelled": 0, "message": _("Nothing to cancel")}
|
|
|
|
client = BIRBankClient(login_name)
|
|
cancelled = 0
|
|
errors = []
|
|
|
|
for kbp in pending:
|
|
try:
|
|
if not kbp.operation_id:
|
|
frappe.logger().warning(f"[KB] Cancel: no operation_id for {kbp.operation_name}, cancelling locally")
|
|
else:
|
|
resp = client.delete("/internal-transfer", params={"operationId": kbp.operation_id})
|
|
if not resp.ok:
|
|
frappe.log_error(
|
|
f"[KB] Cancel DELETE failed for {kbp.operation_name}: HTTP {resp.status_code}",
|
|
"KB Cancel Error",
|
|
)
|
|
|
|
frappe.db.set_value(
|
|
"Kapital Bank Payment",
|
|
kbp.name,
|
|
{"status": "Cancelled", "status_updated_at": now_datetime()},
|
|
update_modified=False,
|
|
)
|
|
frappe.db.commit()
|
|
cancelled += 1
|
|
|
|
except Exception as e:
|
|
frappe.log_error(frappe.get_traceback(), f"KB Cancel {kbp.operation_name}")
|
|
errors.append(str(e))
|
|
|
|
return {"success": True, "cancelled": cancelled, "errors": errors}
|
|
|
|
|
|
def poll_pending_payments():
|
|
"""Scheduler: poll status for all Payment Requests with pending KB Payments."""
|
|
rows = frappe.db.sql(
|
|
"""
|
|
SELECT DISTINCT payment_request
|
|
FROM `tabKapital Bank Payment`
|
|
WHERE status IN ('Sent', 'Confirm Wait')
|
|
AND payment_request IS NOT NULL
|
|
""",
|
|
as_dict=True,
|
|
)
|
|
|
|
for row in rows:
|
|
try:
|
|
check_payment_status(row.payment_request)
|
|
except Exception:
|
|
frappe.log_error(frappe.get_traceback(), "KB Poll Error")
|
|
|
|
|
|
def on_cancel_payment_request(doc, method):
|
|
"""Doc event: block cancellation of Payment Request if pending bank transfers exist."""
|
|
pending = frappe.get_all(
|
|
"Kapital Bank Payment",
|
|
filters={"payment_request": doc.name, "status": ["in", ["Sent", "Confirm Wait"]]},
|
|
fields=["name", "operation_name"],
|
|
)
|
|
if pending:
|
|
names = ", ".join(p.operation_name for p in pending)
|
|
frappe.throw(
|
|
_(
|
|
"Cannot cancel Payment Request {0}: pending bank transfers exist ({1}). "
|
|
"Cancel the bank transfers first using the 'Kapital Bank' menu."
|
|
).format(doc.name, names)
|
|
)
|
|
|
|
|
|
def _create_pe_from_pr(pr_name):
|
|
"""Create and submit a Payment Entry from a Payment Request. Returns PE name or None."""
|
|
pr = frappe.get_doc("Payment Request", pr_name)
|
|
if pr.status not in ("Initiated", "Partially Paid"):
|
|
frappe.log_error(
|
|
f"KB: Payment Request {pr_name} has status '{pr.status}', skipping PE creation",
|
|
"KB Auto PE",
|
|
)
|
|
return None
|
|
pe = pr.create_payment_entry(submit=True)
|
|
frappe.db.commit()
|
|
return pe.name if pe else None
|
|
|
|
|
|
def _handle_rejected_pr(pr_name, description):
|
|
"""Log rejection — PR stays Initiated so user can retry or cancel manually."""
|
|
frappe.log_error(
|
|
f"KB Transfer rejected for Payment Request {pr_name}: {description}",
|
|
"KB Payment Rejected",
|
|
)
|