kapital_bank/kapital_bank/payment_api.py

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",
)