feat(api): migrate BIRBank integration to new v2 portal API

The bank rewrote its B2B API. Auth (login/refresh) is unchanged; everything
else moved to /b2b/<service>/portal/v{N}/... with a new response shape
(HTTP status codes + direct JSON instead of the responseData/response.code
envelope). Migrated all flows and verified against the live API.

Import:
- accounts → /b2b/accounts/portal/v1, cards → /b2b/cards/portal/v1
- account statement → /b2b/account/portal/v3/statements (top-level statementList)
- card statement → /b2b/cards/portal/v1/statements (accountNo+panLast4, paginated)
- ISO date parsing for trnDt; robust card date parsing; pagination safety cap

Bank dictionary:
- sync_bank_codes seeds from /banks?iban= per account; Bank Code gains bank_id + bic_code
- beneficiary bank/branch id resolved live from IBAN at send time

Outbound payments (rewritten):
- send → national-currency/internal (Kapital) or /inland (other banks) with
  nested payer/payee, payee.bank.id, name, tin; response {id, processKey}
- status → GET /transfers/portal/v1/{id}, mapped from frontState
- cancel → PUT /transfers/portal/v1/{id}/reject (mandatory comment)
- Kapital Bank Payment reworked: transfer_no/transfer_id/process_key/front_state
- VÖEN sourced from party (Bank Account has none); required for inland
- AZN-only guard on currency and sender account

Client/UX:
- bank error details (errors[]) surfaced via extract_error_message
- cancel dialogs show real errors instead of "Unknown error"

Security:
- Kapital Bank Login password: Data → encrypted Password field + migration patch

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ali 2026-06-22 16:02:26 +00:00
parent 14383c0e15
commit eb7bd51f1d
11 changed files with 473 additions and 250 deletions

View File

@ -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)

View File

@ -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)

View File

@ -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: "<ul>" + res.errors.map(e => `<li>${e}</li>`).join("") + "</ul>",
});
} 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"),

View File

@ -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: "<ul>" + res.errors.map(e => `<li>${e}</li>`).join("") + "</ul>",
});
} 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"),
});
}
},

View File

@ -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)

View File

@ -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,

View File

@ -39,7 +39,7 @@
},
{
"fieldname": "password",
"fieldtype": "Data",
"fieldtype": "Password",
"label": "Password",
"reqd": 1
},

View File

@ -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
}

View File

@ -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
kapital_bank.patches.migrate_counterparty_type
kapital_bank.patches.encrypt_login_password

View File

@ -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()

View File

@ -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}). "