diff --git a/kapital_bank/api.py b/kapital_bank/api.py
index 4ad7073..a2725aa 100644
--- a/kapital_bank/api.py
+++ b/kapital_bank/api.py
@@ -2,7 +2,35 @@ import frappe
import requests
from kapital_bank.auth import refresh_token, get_default_login, BROWSER_HEADERS
-BASE_URL = "https://my.birbank.business/api/b2b"
+# Root host. Auth (login/refresh) still lives under /api/b2b (see auth.py); all
+# other v2 portal endpoints are passed as full paths starting with /b2b/...
+BASE_URL = "https://my.birbank.business"
+
+
+def extract_error_message(resp):
+ """Best-effort human-readable error from a bank response.
+
+ Includes field-level validation errors — the v2 portal returns
+ {"message": "...", "errors": [{"property": "...", "message": "..."}]}.
+ """
+ try:
+ body = resp.json()
+ except ValueError:
+ return (resp.text[:500] if resp.text else "") or f"HTTP {resp.status_code}"
+ if not isinstance(body, dict):
+ return str(body)
+
+ msg = body.get("response", {}).get("message") or body.get("message") or ""
+ details = body.get("errors")
+ if isinstance(details, list):
+ parts = []
+ for d in details:
+ if isinstance(d, dict) and d.get("message"):
+ prop = d.get("property")
+ parts.append(f"{prop}: {d['message']}" if prop else d["message"])
+ if parts:
+ msg = (f"{msg} — " if msg else "") + "; ".join(parts)
+ return msg or str(body) or f"HTTP {resp.status_code}"
class BIRBankClient:
@@ -65,6 +93,9 @@ class BIRBankClient:
return resp
return self._request("POST", path, json=json)
+ def put(self, path, json=None, params=None):
+ return self._request("PUT", path, json=json, params=params)
+
def delete(self, path, params=None):
return self._request("DELETE", path, params=params)
@@ -78,19 +109,14 @@ class BIRBankClient:
self._check_response(resp)
return resp.json()
+ def put_json(self, path, json=None, params=None):
+ resp = self.put(path, json=json, params=params)
+ self._check_response(resp)
+ return resp.json()
+
def _check_response(self, resp):
- """Raise with the bank's own message if available, otherwise fall back to HTTP error."""
+ """Raise with the bank's own message (incl. field errors) if available."""
if resp.ok:
return
- msg = ""
- try:
- body = resp.json()
- # Handle both {"response": {"message": "..."}} and {"message": "..."}
- msg = (
- body.get("response", {}).get("message")
- or body.get("message")
- or str(body)
- )
- except (ValueError, KeyError):
- msg = resp.text[:500] if resp.text else ""
+ msg = extract_error_message(resp)
raise requests.HTTPError(msg or f"HTTP {resp.status_code}", response=resp)
diff --git a/kapital_bank/bank_api.py b/kapital_bank/bank_api.py
index 51e1337..d7fb660 100644
--- a/kapital_bank/bank_api.py
+++ b/kapital_bank/bank_api.py
@@ -5,7 +5,7 @@ import frappe
from datetime import datetime
from frappe.utils import cint, flt
from difflib import SequenceMatcher
-from kapital_bank.api import BIRBankClient
+from kapital_bank.api import BIRBankClient, extract_error_message
def _mask(text):
@@ -15,15 +15,8 @@ def _mask(text):
def _api_error_message(resp):
- """Extract the bank's own error message from response, or fall back to HTTP status text."""
- try:
- body = resp.json()
- msg = body.get("response", {}).get("message", "")
- if msg:
- return msg
- except (ValueError, KeyError):
- pass
- return f"HTTP {resp.status_code}: {resp.text[:200]}"
+ """Extract the bank's own error message (incl. field-level errors)."""
+ return extract_error_message(resp) or f"HTTP {resp.status_code}: {resp.text[:200]}"
def _resolve_leaf_node(doctype, configured, label):
@@ -69,27 +62,27 @@ def _find_existing_party(doctype, name_field, name, tax_id):
@frappe.whitelist()
def load_accounts_to_registry(login_name=None):
- """Fetch accounts from GET /accounts and upsert into Kapital Bank Account registry."""
+ """Fetch accounts from GET /b2b/accounts/portal/v1 and upsert into Kapital Bank Account registry."""
try:
client = BIRBankClient(login_name)
- data = client.get_json("/accounts")
- accounts = data.get("responseData", {}).get("accountsList", [])
+ data = client.get_json("/b2b/accounts/portal/v1")
+ accounts = data.get("accounts", [])
created = updated = 0
for a in accounts:
- iban = a.get("ibanAcNo", "").strip()
+ iban = (a.get("iban") or "").strip()
if not iban:
continue
values = {
- "cust_ac_no": a.get("custAcNo", ""),
- "currency": a.get("ccy", ""),
- "account_desc": a.get("acDesc", ""),
+ "cust_ac_no": a.get("accountNumber", ""),
+ "currency": a.get("currency", ""),
+ "account_desc": a.get("description", ""),
"account_status": a.get("status", ""),
"branch_code": a.get("branchCode", ""),
- "current_balance": flt(a.get("currAmt", 0)),
- "planned_balance": flt(a.get("plannedAmt", 0)),
- "hold": flt(a.get("hold", 0)),
+ "current_balance": flt(a.get("balance", 0)),
+ "planned_balance": flt(a.get("plannedBalance", 0)),
+ "hold": 0,
}
if frappe.db.exists("Kapital Bank Account", iban):
@@ -112,11 +105,11 @@ def load_accounts_to_registry(login_name=None):
@frappe.whitelist()
def load_cards_to_registry(login_name=None):
- """Fetch cards from GET /cards and upsert into Kapital Bank Card registry."""
+ """Fetch cards from GET /b2b/cards/portal/v1 and upsert into Kapital Bank Card registry."""
try:
client = BIRBankClient(login_name)
- data = client.get_json("/cards")
- cards = data.get("responseData", {}).get("cards", [])
+ data = client.get_json("/b2b/cards/portal/v1")
+ cards = data.get("cards", [])
created = updated = 0
for c in cards:
@@ -125,10 +118,10 @@ def load_cards_to_registry(login_name=None):
continue
values = {
- "card_type": c.get("cardType", ""),
- "pan": c.get("pan", ""),
- "currency": c.get("currency", ""),
- "balance": flt(c.get("balance", 0)),
+ "card_type": c.get("cardProductName", ""),
+ "pan": c.get("maskedPan", ""),
+ "currency": c.get("currencyCode", ""),
+ "balance": flt(c.get("availBalance", 0)),
"expiry_date": str(c.get("expiryDate", "")),
}
@@ -174,8 +167,8 @@ def load_counterparties_from_statements(from_date, to_date, login_name=None):
try:
resp = client.get(
- "/v2/statement/account",
- params={"accountNumber": account_number, "fromDate": _fmt_date(from_date), "toDate": _fmt_date(to_date)}
+ "/b2b/account/portal/v3/statements",
+ params={"accountNumber": account_number, "fromDate": _iso_date(from_date), "toDate": _iso_date(to_date)}
)
debug_log.append(f" HTTP {resp.status_code}")
if not resp.ok:
@@ -187,20 +180,7 @@ def load_counterparties_from_statements(from_date, to_date, login_name=None):
frappe.log_error(f"Statement fetch failed for {_mask(iban)}: {e}", "Kapital Bank Load Counterparties")
continue
- resp_obj = data.get("response", {})
- resp_code = resp_obj.get("code", "?")
- resp_msg = resp_obj.get("message", "")
- debug_log.append(f" response.code={resp_code} message={resp_msg!r}")
-
- if resp_code != "0":
- debug_log.append(f" SKIPPED (non-zero response code)")
- continue
-
- statement_list = (
- data.get("responseData", {})
- .get("operations", {})
- .get("statementList", [])
- )
+ statement_list = data.get("statementList", [])
debug_log.append(f" statementList entries: {len(statement_list)}")
for entry in statement_list:
@@ -1148,26 +1128,18 @@ def load_purposes_from_statements(from_date, to_date, login_name=None):
continue
try:
resp = client.get(
- "/v2/statement/account",
+ "/b2b/account/portal/v3/statements",
params={
"accountNumber": account.cust_ac_no,
- "fromDate": _fmt_date(from_date),
- "toDate": _fmt_date(to_date),
+ "fromDate": _iso_date(from_date),
+ "toDate": _iso_date(to_date),
}
)
if not resp.ok:
continue
data = resp.json()
- resp_code = data.get("response", {}).get("code", "?")
- if resp_code != "0":
- continue
-
- statement_list = (
- data.get("responseData", {})
- .get("operations", {})
- .get("statementList", [])
- )
+ statement_list = data.get("statementList", [])
for txn in statement_list:
purpose = (txn.get("purpose") or "").strip()
@@ -1227,11 +1199,11 @@ def load_purposes_from_statements(from_date, to_date, login_name=None):
@frappe.whitelist()
def get_accounts(login_name=None):
- """GET /api/b2b/accounts — raw account list (not saved to registry)."""
+ """GET /b2b/accounts/portal/v1 — raw account list (not saved to registry)."""
try:
client = BIRBankClient(login_name)
- data = client.get_json("/accounts")
- accounts = data.get("responseData", {}).get("accountsList", [])
+ data = client.get_json("/b2b/accounts/portal/v1")
+ accounts = data.get("accounts", [])
return {"success": True, "accounts": accounts}
except Exception as e:
frappe.log_error(f"get_accounts failed: {e}\n{frappe.get_traceback()}", "Kapital Bank API")
@@ -1240,13 +1212,16 @@ def get_accounts(login_name=None):
@frappe.whitelist()
def get_account_statement(login_name=None, iban=None, from_date=None, to_date=None):
- """GET /api/b2b/v2/statement/account — account statement for a date range."""
+ """GET /b2b/account/portal/v3/statements — account statement for a date range.
+
+ Note: `iban` here is the account number (custAcNo), not the IBAN — the v3
+ endpoint rejects a raw IBAN.
+ """
try:
client = BIRBankClient(login_name)
- params = {"accountNumber": iban, "fromDate": _fmt_date(from_date), "toDate": _fmt_date(to_date)}
- data = client.get_json("/v2/statement/account", params=params)
- operations = data.get("responseData", {}).get("operations", {})
- return {"success": True, "data": operations}
+ params = {"accountNumber": iban, "fromDate": _iso_date(from_date), "toDate": _iso_date(to_date)}
+ data = client.get_json("/b2b/account/portal/v3/statements", params=params)
+ return {"success": True, "data": data}
except Exception as e:
frappe.log_error(f"get_account_statement failed: {e}\n{frappe.get_traceback()}", "Kapital Bank API")
return {"success": False, "message": str(e)}
@@ -1254,11 +1229,11 @@ def get_account_statement(login_name=None, iban=None, from_date=None, to_date=No
@frappe.whitelist()
def get_cards(login_name=None):
- """GET /api/b2b/cards — raw card list (not saved to registry)."""
+ """GET /b2b/cards/portal/v1 — raw card list (not saved to registry)."""
try:
client = BIRBankClient(login_name)
- data = client.get_json("/cards")
- cards = data.get("responseData", {}).get("cards", [])
+ data = client.get_json("/b2b/cards/portal/v1")
+ cards = data.get("cards", [])
return {"success": True, "cards": cards}
except Exception as e:
frappe.log_error(f"get_cards failed: {e}\n{frappe.get_traceback()}", "Kapital Bank API")
@@ -1267,13 +1242,20 @@ def get_cards(login_name=None):
@frappe.whitelist()
def get_card_statement(login_name=None, account_no=None, from_date=None, to_date=None):
- """GET /api/b2b/v2/statement/card — card statement for a date range."""
+ """GET /b2b/cards/portal/v1/statements — card statement (first page) for a date range."""
try:
client = BIRBankClient(login_name)
- params = {"accountNumber": account_no, "fromDate": str(from_date) if from_date else None, "toDate": str(to_date) if to_date else None}
- data = client.get_json("/v2/statement/card", params=params)
- operations = data.get("responseData", {}).get("operations", {})
- return {"success": True, "data": operations}
+ pan = frappe.db.get_value("Kapital Bank Card", account_no, "pan") or ""
+ pan_last4 = re.sub(r"\D", "", pan)[-4:]
+ params = {
+ "accountNo": account_no,
+ "panLast4": pan_last4,
+ "dateFrom": str(from_date) if from_date else None,
+ "dateTo": str(to_date) if to_date else None,
+ "pageId": 0,
+ }
+ data = client.get_json("/b2b/cards/portal/v1/statements", params=params)
+ return {"success": True, "data": data}
except Exception as e:
frappe.log_error(f"get_card_statement failed: {e}\n{frappe.get_traceback()}", "Kapital Bank API")
return {"success": False, "message": str(e)}
@@ -1288,7 +1270,9 @@ def _parse_txn_date(trn_dt):
trn_dt = (trn_dt or "").strip()
if not trn_dt:
return None
- for fmt in ("%b %d, %Y", "%b %d,%Y"):
+ # v3 portal statements return ISO dates ("2026-05-14"); legacy fell back to
+ # the "May 14, 2026" form, which we still accept for safety.
+ for fmt in ("%Y-%m-%d", "%b %d, %Y", "%b %d,%Y"):
try:
return datetime.strptime(trn_dt, fmt).date()
except ValueError:
@@ -1297,14 +1281,20 @@ def _parse_txn_date(trn_dt):
def _parse_card_txn_date(date_str):
- """Parse card transaction date DD.MM.YYYY into a date object."""
+ """Parse a card transaction date into a date object.
+
+ The portal echoes DD.MM.YYYY, but accept ISO and date-time variants too so a
+ format change can't silently drop every card transaction. Returns None on failure.
+ """
date_str = (date_str or "").strip()
if not date_str:
return None
- try:
- return datetime.strptime(date_str, "%d.%m.%Y").date()
- except ValueError:
- return None
+ for fmt in ("%d.%m.%Y", "%Y-%m-%d", "%d.%m.%Y %H:%M:%S", "%Y-%m-%dT%H:%M:%S"):
+ try:
+ return datetime.strptime(date_str, fmt).date()
+ except ValueError:
+ continue
+ return None
def _parse_counterparty(contr_raw):
@@ -1333,27 +1323,18 @@ def get_statement_transactions(from_date, to_date, account_iban, login_name=None
# Fetch statement
client = BIRBankClient(login_name)
resp = client.get(
- "/v2/statement/account",
+ "/b2b/account/portal/v3/statements",
params={
"accountNumber": cust_ac_no,
- "fromDate": _fmt_date(from_date),
- "toDate": _fmt_date(to_date),
+ "fromDate": _iso_date(from_date),
+ "toDate": _iso_date(to_date),
}
)
if not resp.ok:
return {"success": False, "message": _api_error_message(resp)}
data = resp.json()
- resp_code = data.get("response", {}).get("code", "?")
- if resp_code != "0":
- resp_msg = data.get("response", {}).get("message", "")
- return {"success": False, "message": resp_msg or f"API error code={resp_code}"}
-
- statement_list = (
- data.get("responseData", {})
- .get("operations", {})
- .get("statementList", [])
- )
+ statement_list = data.get("statementList", [])
# Use the account's own currency for all transactions from this account
account_currency = frappe.db.get_value("Kapital Bank Account", account_iban, "currency") or "AZN"
@@ -1434,7 +1415,8 @@ def get_statement_transactions(from_date, to_date, account_iban, login_name=None
def get_card_statement_transactions(from_date, to_date, card_account_number, login_name=None):
"""Fetch card statement and return parsed transactions for UI selection.
- Uses /v2/statement/card with DD.MM.YYYY date format.
+ Uses GET /b2b/cards/portal/v1/statements (paginated). Requires the card's
+ account number plus the last 4 digits of the PAN.
Filters out duplicates (already imported by reference_no).
Returns: {success, transactions: [...], total_fetched, skipped_duplicates}
"""
@@ -1454,27 +1436,36 @@ def get_card_statement_transactions(from_date, to_date, card_account_number, log
pan = (card_doc.pan if card_doc else None) or card_account_number
card_currency = (card_doc.currency if card_doc else None) or "AZN"
- # Fetch card statement (date format: YYYY-MM-DD, raw Frappe date)
+ # The v1 card-statement endpoint needs the last 4 PAN digits, not the masked PAN.
+ pan_last4 = re.sub(r"\D", "", pan)[-4:]
+ if len(pan_last4) != 4:
+ return {"success": False, "message": f"Cannot derive last 4 PAN digits for card {card_account_number}."}
+
+ # Fetch all pages (date format: YYYY-MM-DD, raw Frappe date)
client = BIRBankClient(login_name)
- resp = client.get(
- "/v2/statement/card",
- params={
- "accountNumber": card_account_number,
- "fromDate": str(from_date) if from_date else None,
- "toDate": str(to_date) if to_date else None,
- }
- )
- if not resp.ok:
- return {"success": False, "message": _api_error_message(resp)}
+ operation_list = []
+ page_id = 0
+ MAX_PAGES = 500 # safety cap against a misbehaving `next` flag
+ while page_id < MAX_PAGES:
+ resp = client.get(
+ "/b2b/cards/portal/v1/statements",
+ params={
+ "accountNo": card_account_number,
+ "panLast4": pan_last4,
+ "dateFrom": str(from_date) if from_date else None,
+ "dateTo": str(to_date) if to_date else None,
+ "pageId": page_id,
+ }
+ )
+ if not resp.ok:
+ return {"success": False, "message": _api_error_message(resp)}
- data = resp.json()
- resp_code = data.get("response", {}).get("code", "?")
- if resp_code != "0":
- resp_msg = data.get("response", {}).get("message", "")
- return {"success": False, "message": resp_msg or f"API error code={resp_code}"}
+ data = resp.json()
+ operation_list.extend(data.get("cardStatements", []))
- # Card statements use responseData.operation[] (not operations.statementList[])
- operation_list = data.get("responseData", {}).get("operation", [])
+ if not data.get("next"):
+ break
+ page_id += 1
transactions = []
skipped_duplicates = 0
@@ -2442,17 +2433,11 @@ def _normalize(text, consider_azeri=True):
return " ".join(s.lower().split())
-def _fmt_date(d, sep='-'):
- """Convert YYYY-MM-DD string or date object → DD{sep}MM{sep}YYYY.
-
- Account statement → sep='-' → DD-MM-YYYY
- Card statement → sep='.' → DD.MM.YYYY
- """
+def _iso_date(d):
+ """Normalise a date string/object → ISO YYYY-MM-DD (the format the v3 portal
+ statement endpoints expect). Returns None for falsy input."""
if not d:
return None
if hasattr(d, 'strftime'):
- return d.strftime(f'%d{sep}%m{sep}%Y')
- parts = str(d).split('-')
- if len(parts) == 3:
- return f"{parts[2]}{sep}{parts[1]}{sep}{parts[0]}"
+ return d.strftime('%Y-%m-%d')
return str(d)
diff --git a/kapital_bank/client/payment_order.js b/kapital_bank/client/payment_order.js
index 318d180..c9e9cd9 100644
--- a/kapital_bank/client/payment_order.js
+++ b/kapital_bank/client/payment_order.js
@@ -56,12 +56,24 @@ frappe.ui.form.on("Payment Order", {
callback(r) {
const res = r.message;
if (!res) return;
- if (res.success) {
+ if (res.success && res.cancelled > 0) {
frappe.show_alert(
{ message: __("Cancelled: {0}", [res.cancelled]), indicator: "green" },
5
);
frm.reload_doc();
+ } else if (res.errors && res.errors.length) {
+ frappe.msgprint({
+ title: __("Cancel Failed"),
+ indicator: "red",
+ message: "
" + res.errors.map(e => `- ${e}
`).join("") + "
",
+ });
+ } else if (res.success) {
+ frappe.show_alert(
+ { message: res.message || __("Nothing to cancel"), indicator: "grey" },
+ 5
+ );
+ frm.reload_doc();
} else {
frappe.msgprint({
title: __("Cancel Failed"),
diff --git a/kapital_bank/client/payment_request.js b/kapital_bank/client/payment_request.js
index 28cc9b1..9caf0f8 100644
--- a/kapital_bank/client/payment_request.js
+++ b/kapital_bank/client/payment_request.js
@@ -9,7 +9,7 @@ const KB_STATUS_COLOR = {
function kb_show_status(frm) {
frappe.db.get_list("Kapital Bank Payment", {
filters: { payment_request: frm.doc.name },
- fields: ["status", "operation_name"],
+ fields: ["status", "transfer_no"],
order_by: "creation desc",
limit: 1,
}).then(rows => {
@@ -92,13 +92,19 @@ frappe.ui.form.on("Payment Request", {
if (res.success && res.cancelled > 0) {
frappe.show_alert({ message: __("Bank transfer cancelled successfully."), indicator: "green" }, 6);
frm.reload_doc();
+ } else if (res.errors && res.errors.length) {
+ frappe.msgprint({
+ title: __("Cancel Failed"),
+ indicator: "red",
+ message: "" + res.errors.map(e => `- ${e}
`).join("") + "
",
+ });
} else if (res.message) {
frappe.show_alert({ message: res.message, indicator: "grey" }, 5);
} else {
frappe.msgprint({
title: __("Cancel Failed"),
indicator: "red",
- message: res.message || __("Unknown error"),
+ message: __("Unknown error"),
});
}
},
diff --git a/kapital_bank/kapital_bank/doctype/kapital_bank_api_test/kapital_bank_api_test.py b/kapital_bank/kapital_bank/doctype/kapital_bank_api_test/kapital_bank_api_test.py
index f3b3373..de9fc06 100644
--- a/kapital_bank/kapital_bank/doctype/kapital_bank_api_test/kapital_bank_api_test.py
+++ b/kapital_bank/kapital_bank/doctype/kapital_bank_api_test/kapital_bank_api_test.py
@@ -13,22 +13,6 @@ class KapitalBankAPITest(frappe.model.document.Document):
# Helpers
# ─────────────────────────────────────────────────────────────────────────────
-def _fmt_date(d, sep='-'):
- """Convert Frappe date (YYYY-MM-DD string or date object) → DD{sep}MM{sep}YYYY.
-
- Account statement → sep='-' → DD-MM-YYYY
- Card statement → sep='.' → DD.MM.YYYY
- """
- if not d:
- return None
- if hasattr(d, 'strftime'):
- return d.strftime(f'%d{sep}%m{sep}%Y')
- parts = str(d).split('-')
- if len(parts) == 3:
- return f"{parts[2]}{sep}{parts[1]}{sep}{parts[0]}"
- return str(d)
-
-
def _dump_get(path, params=None):
"""Build human-readable GET request dump."""
url = f"{BASE_URL}{path}"
@@ -127,9 +111,9 @@ def do_refresh_token(docname):
def do_get_accounts(docname):
doc = frappe.get_doc("Kapital Bank API Test", docname)
from kapital_bank.api import BIRBankClient
- req = _dump_get("/accounts")
+ req = _dump_get("/b2b/accounts/portal/v1")
def _call():
- return BIRBankClient(doc.login_name).get_json("/accounts")
+ return BIRBankClient(doc.login_name).get_json("/b2b/accounts/portal/v1")
return _run(docname, _call, req)
@@ -139,12 +123,12 @@ def do_get_account_statement(docname):
from kapital_bank.api import BIRBankClient
params = {
"accountNumber": doc.iban,
- "fromDate": _fmt_date(doc.from_date),
- "toDate": _fmt_date(doc.to_date),
+ "fromDate": str(doc.from_date) if doc.from_date else None,
+ "toDate": str(doc.to_date) if doc.to_date else None,
}
- req = _dump_get("/v2/statement/account", params)
+ req = _dump_get("/b2b/account/portal/v3/statements", params)
def _call():
- return BIRBankClient(doc.login_name).get_json("/v2/statement/account", params=params)
+ return BIRBankClient(doc.login_name).get_json("/b2b/account/portal/v3/statements", params=params)
return _run(docname, _call, req)
@@ -156,24 +140,28 @@ def do_get_account_statement(docname):
def do_get_cards(docname):
doc = frappe.get_doc("Kapital Bank API Test", docname)
from kapital_bank.api import BIRBankClient
- req = _dump_get("/cards")
+ req = _dump_get("/b2b/cards/portal/v1")
def _call():
- return BIRBankClient(doc.login_name).get_json("/cards")
+ return BIRBankClient(doc.login_name).get_json("/b2b/cards/portal/v1")
return _run(docname, _call, req)
@frappe.whitelist()
def do_get_card_statement(docname):
+ import re
doc = frappe.get_doc("Kapital Bank API Test", docname)
from kapital_bank.api import BIRBankClient
+ pan = frappe.db.get_value("Kapital Bank Card", doc.account_number, "pan") or ""
params = {
- "accountNumber": doc.account_number,
- "fromDate": str(doc.from_date) if doc.from_date else None,
- "toDate": str(doc.to_date) if doc.to_date else None,
+ "accountNo": doc.account_number,
+ "panLast4": re.sub(r"\D", "", pan)[-4:],
+ "dateFrom": str(doc.from_date) if doc.from_date else None,
+ "dateTo": str(doc.to_date) if doc.to_date else None,
+ "pageId": 0,
}
- req = _dump_get("/v2/statement/card", params)
+ req = _dump_get("/b2b/cards/portal/v1/statements", params)
def _call():
- return BIRBankClient(doc.login_name).get_json("/v2/statement/card", params=params)
+ return BIRBankClient(doc.login_name).get_json("/b2b/cards/portal/v1/statements", params=params)
return _run(docname, _call, req)
@@ -185,8 +173,9 @@ def do_get_card_statement(docname):
def do_transfer_status(docname):
doc = frappe.get_doc("Kapital Bank API Test", docname)
from kapital_bank.api import BIRBankClient
- params = {"operatorName": doc.operation_name_param}
- req = _dump_get("/internal-transfer/status", params)
+ transfer_id = (doc.operation_name_param or "").strip()
+ path = f"/b2b/transfers/portal/v1/{transfer_id}"
+ req = _dump_get(path)
def _call():
- return BIRBankClient(doc.login_name).get_json("/internal-transfer/status", params=params)
+ return BIRBankClient(doc.login_name).get_json(path)
return _run(docname, _call, req)
diff --git a/kapital_bank/kapital_bank/doctype/kapital_bank_bank_code/kapital_bank_bank_code.json b/kapital_bank/kapital_bank/doctype/kapital_bank_bank_code/kapital_bank_bank_code.json
index b822538..4d1216e 100644
--- a/kapital_bank/kapital_bank/doctype/kapital_bank_bank_code/kapital_bank_bank_code.json
+++ b/kapital_bank/kapital_bank/doctype/kapital_bank_bank_code/kapital_bank_bank_code.json
@@ -6,7 +6,9 @@
"engine": "InnoDB",
"field_order": [
"bank_code",
- "bank_name"
+ "bank_name",
+ "bank_id",
+ "bic_code"
],
"fields": [
{
@@ -23,6 +25,18 @@
"in_list_view": 1,
"label": "Bank Name",
"reqd": 1
+ },
+ {
+ "fieldname": "bank_id",
+ "fieldtype": "Data",
+ "in_list_view": 1,
+ "label": "Bank ID"
+ },
+ {
+ "fieldname": "bic_code",
+ "fieldtype": "Data",
+ "in_list_view": 1,
+ "label": "BIC Code"
}
],
"index_web_pages_for_search": 1,
diff --git a/kapital_bank/kapital_bank/doctype/kapital_bank_login/kapital_bank_login.json b/kapital_bank/kapital_bank/doctype/kapital_bank_login/kapital_bank_login.json
index 33f23ba..f486b94 100644
--- a/kapital_bank/kapital_bank/doctype/kapital_bank_login/kapital_bank_login.json
+++ b/kapital_bank/kapital_bank/doctype/kapital_bank_login/kapital_bank_login.json
@@ -39,7 +39,7 @@
},
{
"fieldname": "password",
- "fieldtype": "Data",
+ "fieldtype": "Password",
"label": "Password",
"reqd": 1
},
diff --git a/kapital_bank/kapital_bank/doctype/kapital_bank_payment/kapital_bank_payment.json b/kapital_bank/kapital_bank/doctype/kapital_bank_payment/kapital_bank_payment.json
index 1e5e842..3d61b47 100644
--- a/kapital_bank/kapital_bank/doctype/kapital_bank_payment/kapital_bank_payment.json
+++ b/kapital_bank/kapital_bank/doctype/kapital_bank_payment/kapital_bank_payment.json
@@ -7,18 +7,21 @@
"field_order": [
"payment_request",
"payment_entry",
- "operation_name",
- "operation_id",
+ "transfer_no",
+ "transfer_id",
+ "process_key",
"status",
+ "front_state",
"column_break_1",
"from_account",
"to_account",
"to_tax_no",
"to_cust_name",
"ben_bank_code",
+ "ben_bank_id",
"amount",
"details_section",
- "purpose1",
+ "description",
"error_message",
"column_break_2",
"sent_at",
@@ -42,18 +45,24 @@
"read_only": 1
},
{
- "fieldname": "operation_name",
+ "fieldname": "transfer_no",
"fieldtype": "Data",
"in_list_view": 1,
- "label": "Operation Name",
+ "label": "Transfer No",
"read_only": 1,
"reqd": 1,
"unique": 1
},
{
- "fieldname": "operation_id",
+ "fieldname": "transfer_id",
"fieldtype": "Data",
- "label": "Operation ID",
+ "label": "Transfer ID",
+ "read_only": 1
+ },
+ {
+ "fieldname": "process_key",
+ "fieldtype": "Data",
+ "label": "Process Key",
"read_only": 1
},
{
@@ -65,6 +74,12 @@
"options": "Draft\nSent\nConfirm Wait\nSuccess\nRejected\nCancelled",
"read_only": 1
},
+ {
+ "fieldname": "front_state",
+ "fieldtype": "Data",
+ "label": "Bank State (raw)",
+ "read_only": 1
+ },
{
"fieldname": "column_break_1",
"fieldtype": "Column Break"
@@ -99,6 +114,12 @@
"label": "Beneficiary Bank Code",
"read_only": 1
},
+ {
+ "fieldname": "ben_bank_id",
+ "fieldtype": "Data",
+ "label": "Beneficiary Bank ID",
+ "read_only": 1
+ },
{
"fieldname": "amount",
"fieldtype": "Currency",
@@ -111,9 +132,9 @@
"label": "Details"
},
{
- "fieldname": "purpose1",
+ "fieldname": "description",
"fieldtype": "Data",
- "label": "Purpose",
+ "label": "Description",
"read_only": 1
},
{
@@ -164,6 +185,6 @@
"sort_field": "modified",
"sort_order": "DESC",
"states": [],
- "title_field": "operation_name",
+ "title_field": "transfer_no",
"track_changes": 1
}
diff --git a/kapital_bank/patches.txt b/kapital_bank/patches.txt
index 753f784..45aaeef 100644
--- a/kapital_bank/patches.txt
+++ b/kapital_bank/patches.txt
@@ -5,4 +5,5 @@ kapital_bank.patches.rename_purpose_mapping_to_transaction_mapping
[post_model_sync]
# Patches added in this section will be executed after doctypes are migrated
-kapital_bank.patches.migrate_counterparty_type
\ No newline at end of file
+kapital_bank.patches.migrate_counterparty_type
+kapital_bank.patches.encrypt_login_password
\ No newline at end of file
diff --git a/kapital_bank/patches/encrypt_login_password.py b/kapital_bank/patches/encrypt_login_password.py
new file mode 100644
index 0000000..8b28f49
--- /dev/null
+++ b/kapital_bank/patches/encrypt_login_password.py
@@ -0,0 +1,40 @@
+import frappe
+from frappe.utils.password import decrypt, get_decrypted_password, set_encrypted_password
+
+
+def execute():
+ """Migrate the Kapital Bank Login `password` field from plain Data to an
+ encrypted Password field.
+
+ The encrypted secret is stored in the `__Auth` table and the doctype column
+ is masked, so `doc.get_password("password")` transparently decrypts it.
+ Idempotent: skips records already migrated, and recovers from an earlier
+ revision of this patch that mistakenly wrote ciphertext into the column."""
+ if not frappe.db.exists("DocType", "Kapital Bank Login"):
+ return
+
+ for name in frappe.get_all("Kapital Bank Login", pluck="name"):
+ # Already stored in __Auth → nothing to do.
+ if get_decrypted_password("Kapital Bank Login", name, "password", raise_exception=False):
+ continue
+
+ col = frappe.db.get_value("Kapital Bank Login", name, "password")
+ if not col:
+ continue
+
+ # Recover the real password: clear text is used as-is; ciphertext left by
+ # an earlier run of this patch is decrypted back first.
+ plaintext = col
+ try:
+ recovered = decrypt(col)
+ if recovered:
+ plaintext = recovered
+ except Exception:
+ plaintext = col
+
+ set_encrypted_password("Kapital Bank Login", name, plaintext, "password")
+ frappe.db.set_value(
+ "Kapital Bank Login", name, "password", "*" * len(plaintext), update_modified=False
+ )
+
+ frappe.db.commit()
diff --git a/kapital_bank/payment_api.py b/kapital_bank/payment_api.py
index ebfbabd..54c16a5 100644
--- a/kapital_bank/payment_api.py
+++ b/kapital_bank/payment_api.py
@@ -3,6 +3,7 @@ from frappe import _
from frappe.utils import now_datetime, flt
from datetime import datetime
from kapital_bank.api import BIRBankClient
+from kapital_bank.bank_api import _api_error_message
_AZ_MAP = str.maketrans("ƏəİışŞÇçÖöÜüĞğ", "EeIissCcOoUuGg")
@@ -14,36 +15,110 @@ def _latinize(text: str, max_len: int = None) -> str:
return text[:max_len] if max_len else text
+def _fetch_banks_for_iban(client, iban):
+ """GET /b2b/dictionaries/portal/v1/banks?iban= → list of branch dicts.
+
+ The endpoint returns every branch of the bank that owns the given IBAN
+ (matched by the IBAN's 4-letter SWIFT prefix), each carrying its own
+ 6-digit bankCode plus the numeric bank id required for transfers.
+ """
+ resp = client.get_json("/b2b/dictionaries/portal/v1/banks", params={"iban": iban})
+ return resp.get("banks", [])
+
+
+def _resolve_party_tax_id(pr, payee_ba):
+ """Resolve the beneficiary's VÖEN (tax id) from the party (Customer/Supplier).
+ Tries the Payment Request's party first, then the Bank Account's linked party."""
+ for ptype, pname in (
+ (pr.get("party_type"), pr.get("party")),
+ (payee_ba.get("party_type"), payee_ba.get("party")),
+ ):
+ if ptype in ("Customer", "Supplier") and pname:
+ tax_id = frappe.db.get_value(ptype, pname, "tax_id")
+ if tax_id:
+ return tax_id.strip()
+ return ""
+
+
+def _resolve_beneficiary_bank(client, payee_iban, kb_bank_code):
+ """Return the branch dict whose bankCode matches the configured 6-digit
+ kb_bank_code for the beneficiary's IBAN, or None if there is no match.
+ The branch carries the numeric `id` and `bicCode` needed to route a transfer."""
+ target = (kb_bank_code or "").strip()
+ for b in _fetch_banks_for_iban(client, payee_iban):
+ if (b.get("bankCode") or "").strip() == target:
+ return b
+ return None
+
+
+# Maps the bank's free-form transfer `frontState` onto our tracking statuses.
+# The portal exposes frontState as an opaque string; we classify by keyword so
+# new/renamed states degrade gracefully (unknown → left pending).
+def _map_front_state(state):
+ s = (state or "").upper()
+ if "CANCEL" in s:
+ return "Cancelled"
+ if any(k in s for k in ("REJECT", "DECLIN", "FAIL", "ERROR")):
+ return "Rejected"
+ if any(k in s for k in ("SUCCESS", "CONFIRMED", "COMPLET", "DONE", "EXECUT", "PAID")):
+ return "Success"
+ if any(k in s for k in ("CREAT", "SIGN", "WAIT", "PEND", "PROCESS", "NEW", "SENT", "QUEUE")):
+ # CREATED = transfer accepted by the bank, awaiting signature/confirmation
+ # in Birbank Business — still pending from our side.
+ return "Confirm Wait"
+ return None
+
+
@frappe.whitelist()
def sync_bank_codes(login_name=None):
- """Fetch bank codes from API and upsert into Kapital Bank Bank Code doctype."""
+ """Refresh Kapital Bank Bank Code reference data.
+
+ The new API has no "list all banks" endpoint — banks are looked up per IBAN.
+ We seed/refresh the dictionary from every registered Kapital Bank Account
+ (which yields all Kapital branches) and from any beneficiary bank accounts
+ configured locally. Existing rows for other banks are preserved.
+ """
client = BIRBankClient(login_name)
- resp = client.get_json("/bank-codes")
- bank_codes = resp.get("responseData", {}).get("bankCodes", [])
- added = 0
- updated = 0
+ ibans = set()
+ for iban in frappe.get_all("Kapital Bank Account", pluck="iban"):
+ if iban:
+ ibans.add(iban)
+ for iban in frappe.get_all("Bank Account", filters={"iban": ["is", "set"]}, pluck="iban"):
+ if iban:
+ ibans.add(iban)
- for item in bank_codes:
- bank_code = (item.get("bankCode") or "").strip()
- bank_name = (item.get("bankName") or "").strip()
- if not bank_code:
+ seen = {}
+ for iban in ibans:
+ try:
+ for b in _fetch_banks_for_iban(client, iban):
+ code = (b.get("bankCode") or "").strip()
+ if code:
+ seen[code] = b
+ except Exception:
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)
+ added = updated = 0
+ for code, b in seen.items():
+ values = {
+ "bank_name": (b.get("name") or "").strip(),
+ "bank_id": str(b.get("id") or "").strip(),
+ "bic_code": (b.get("bicCode") or "").strip(),
+ }
+ if frappe.db.exists("Kapital Bank Bank Code", code):
+ current = frappe.db.get_value("Kapital Bank Bank Code", code, list(values.keys()), as_dict=True)
+ if any((current.get(k) or "") != v for k, v in values.items()):
+ frappe.db.set_value("Kapital Bank Bank Code", code, values)
updated += 1
else:
doc = frappe.new_doc("Kapital Bank Bank Code")
- doc.bank_code = bank_code
- doc.bank_name = bank_name
+ doc.bank_code = code
+ doc.update(values)
doc.insert(ignore_permissions=True)
added += 1
frappe.db.commit()
- return {"success": True, "added": added, "updated": updated, "total": len(bank_codes)}
+ return {"success": True, "added": added, "updated": updated, "total": len(seen)}
@frappe.whitelist()
@@ -95,6 +170,15 @@ def send_payment_request(name, login_name=None):
if not flt(pr.grand_total):
errors.append(_("Field 'Amount' is zero on Payment Request {0}.").format(name))
+ # National-currency transfers (the only outbound flow we support) are AZN only.
+ # Guard both the document currency and the sender account currency so a non-AZN
+ # amount is never sent to the AZN endpoint.
+ payer_ccy = (frappe.db.get_value("Kapital Bank Account", kb_account, "currency") or "").upper()
+ if (pr.currency or "").upper() not in ("", "AZN"):
+ errors.append(_("Only AZN transfers are supported; Payment Request currency is {0}.").format(pr.currency))
+ if payer_ccy and payer_ccy != "AZN":
+ errors.append(_("Sender account {0} is not in AZN (currency: {1}).").format(kb_account, payer_ccy))
+
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))
@@ -110,47 +194,83 @@ def send_payment_request(name, login_name=None):
if errors:
return {"success": False, "errors": errors}
- op_name = f"PAY{datetime.now().strftime('%Y%m%d%H%M%S')}00"
+ transfer_no = f"KB{datetime.now().strftime('%Y%m%d%H%M%S')}"
payee_iban = payee_ba.iban
- tax_id = payee_ba.get("tax_id") or ""
+ # Bank Account has no tax_id; the beneficiary's VÖEN lives on the party
+ # (Customer/Supplier). Prefer the Payment Request's party, then the Bank
+ # Account's linked party.
+ tax_id = _resolve_party_tax_id(pr, payee_ba)
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,
- },
- }
+ description = _latinize(pr.name, 64)
+ amount = float(flt(pr.grand_total))
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", "")
+
+ # Resolve the beneficiary's exact branch (and numeric bank id) from the IBAN.
+ branch = _resolve_beneficiary_bank(client, payee_iban, kb_bank_code)
+ if not branch:
+ return {"success": False, "message": _(
+ "Bank code {0} is not valid for beneficiary IBAN {1}."
+ ).format(kb_bank_code, payee_iban)}
+
+ bank_id = str(branch.get("id") or "")
+ is_kapital = (branch.get("bicCode") or "").upper().startswith("AIIB")
+
+ if is_kapital:
+ # Kapital → Kapital: internal transfer needs only the beneficiary IBAN.
+ endpoint = "/b2b/transfers/portal/v1/national-currency/internal"
+ payload = {
+ "transferNo": transfer_no,
+ "payer": {"iban": from_iban},
+ "amount": amount,
+ "description": description,
+ "payee": {"iban": payee_iban},
+ "self": False,
+ }
+ else:
+ # Other domestic bank: inland transfer requires the routed bank id
+ # and the beneficiary's VÖEN (tin), which the bank treats as mandatory.
+ if not tax_id:
+ return {"success": False, "errors": [
+ _("Beneficiary VÖEN (Tax ID) is required for transfers to other banks. "
+ "Set 'Tax ID' on Bank Account {0}.").format(party_bank_account)
+ ]}
+ endpoint = "/b2b/transfers/portal/v1/national-currency/inland"
+ payload = {
+ "transferNo": transfer_no,
+ "payer": {"iban": from_iban},
+ "amount": amount,
+ "description": description,
+ "payee": {
+ "bank": {"id": int(bank_id) if bank_id.isdigit() else bank_id},
+ "iban": payee_iban,
+ "name": to_cust_name,
+ "tin": tax_id,
+ },
+ }
+
+ resp = client.post_json(endpoint, json=payload)
+ transfer_id = str(resp.get("id") or "")
+ process_key = str(resp.get("processKey") or "")
kbp = frappe.new_doc("Kapital Bank Payment")
kbp.update({
"payment_request": name,
- "operation_name": op_name,
- "operation_id": operation_id,
+ "transfer_no": transfer_no,
+ "transfer_id": transfer_id,
+ "process_key": process_key,
"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,
+ "ben_bank_id": bank_id,
"amount": flt(pr.grand_total),
- "purpose1": purpose,
+ "description": description,
"sent_at": now_datetime(),
"status_updated_at": now_datetime(),
})
@@ -173,7 +293,7 @@ def check_payment_status(name, login_name=None):
pending = frappe.get_all(
"Kapital Bank Payment",
filters={"payment_request": name, "status": ["in", ["Sent", "Confirm Wait"]]},
- fields=["name", "operation_name", "payment_request", "operation_id"],
+ fields=["name", "transfer_no", "payment_request", "transfer_id"],
)
if not pending:
@@ -181,35 +301,28 @@ def check_payment_status(name, login_name=None):
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")
+ if not kbp.transfer_id:
+ frappe.logger().warning(f"[KB] Status check for {kbp.transfer_no}: no transfer_id")
continue
- entry = data[0]
- bank_status = entry.get("status", "")
- new_status = STATUS_MAP.get(bank_status)
+ resp = client.get_json(f"/b2b/transfers/portal/v1/{kbp.transfer_id}")
+ front_state = resp.get("frontState", "")
+ new_status = _map_front_state(front_state)
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()},
+ {
+ "front_state": front_state,
+ "error_message": f"Unmapped bank state: {front_state}",
+ "status_updated_at": now_datetime(),
+ },
update_modified=False,
)
frappe.db.commit()
@@ -220,8 +333,9 @@ def check_payment_status(name, login_name=None):
kbp.name,
{
"status": new_status,
+ "front_state": front_state,
"status_updated_at": now_datetime(),
- "error_message": entry.get("description", ""),
+ "error_message": resp.get("description", ""),
},
update_modified=False,
)
@@ -236,42 +350,57 @@ def check_payment_status(name, login_name=None):
)
frappe.db.commit()
elif new_status == "Rejected":
- _handle_rejected_pr(kbp.payment_request, entry.get("description", ""))
+ _handle_rejected_pr(kbp.payment_request, resp.get("description", ""))
except Exception as e:
- frappe.log_error(frappe.get_traceback(), f"KB Check Status {kbp.operation_name}")
+ frappe.log_error(frappe.get_traceback(), f"KB Check Status {kbp.transfer_no}")
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."""
+def cancel_payment_request(name, login_name=None, comment=None):
+ """Cancel (reject) 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"],
+ fields=["name", "transfer_no", "transfer_id"],
)
if not pending:
return {"success": True, "cancelled": 0, "message": _("Nothing to cancel")}
+ # The reject endpoint requires a non-empty comment.
+ comment = (comment or "").strip() or "Cancelled via ERPNext"
+
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")
+ if not kbp.transfer_id:
+ frappe.logger().warning(f"[KB] Cancel: no transfer_id for {kbp.transfer_no}, cancelling locally")
else:
- resp = client.delete("/internal-transfer", params={"operationId": kbp.operation_id})
+ resp = client.put(
+ f"/b2b/transfers/portal/v1/{kbp.transfer_id}/reject",
+ json={"comment": comment},
+ )
if not resp.ok:
frappe.log_error(
- f"[KB] Cancel DELETE failed for {kbp.operation_name}: HTTP {resp.status_code}",
+ f"[KB] Cancel reject failed for {kbp.transfer_no}: HTTP {resp.status_code}\n{resp.text[:1000]}",
"KB Cancel Error",
)
+ # The bank's reject endpoint refuses transfers that are still
+ # awaiting signature (frontState CREATED) with an opaque error;
+ # those can only be cancelled from Birbank Business directly.
+ errors.append(_(
+ "Could not cancel transfer {0} via API ({1}). If it is still "
+ "awaiting signature, reject it directly in Birbank Business — "
+ "its status will sync back here automatically."
+ ).format(kbp.transfer_no, _api_error_message(resp)))
+ continue
frappe.db.set_value(
"Kapital Bank Payment",
@@ -283,7 +412,7 @@ def cancel_payment_request(name, login_name=None):
cancelled += 1
except Exception as e:
- frappe.log_error(frappe.get_traceback(), f"KB Cancel {kbp.operation_name}")
+ frappe.log_error(frappe.get_traceback(), f"KB Cancel {kbp.transfer_no}")
errors.append(str(e))
return {"success": True, "cancelled": cancelled, "errors": errors}
@@ -313,10 +442,10 @@ def on_cancel_payment_request(doc, method):
pending = frappe.get_all(
"Kapital Bank Payment",
filters={"payment_request": doc.name, "status": ["in", ["Sent", "Confirm Wait"]]},
- fields=["name", "operation_name"],
+ fields=["name", "transfer_no"],
)
if pending:
- names = ", ".join(p.operation_name for p in pending)
+ names = ", ".join(p.transfer_no for p in pending)
frappe.throw(
_(
"Cannot cancel Payment Request {0}: pending bank transfers exist ({1}). "