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:
parent
14383c0e15
commit
eb7bd51f1d
|
|
@ -2,7 +2,35 @@ import frappe
|
||||||
import requests
|
import requests
|
||||||
from kapital_bank.auth import refresh_token, get_default_login, BROWSER_HEADERS
|
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:
|
class BIRBankClient:
|
||||||
|
|
@ -65,6 +93,9 @@ class BIRBankClient:
|
||||||
return resp
|
return resp
|
||||||
return self._request("POST", path, json=json)
|
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):
|
def delete(self, path, params=None):
|
||||||
return self._request("DELETE", path, params=params)
|
return self._request("DELETE", path, params=params)
|
||||||
|
|
||||||
|
|
@ -78,19 +109,14 @@ class BIRBankClient:
|
||||||
self._check_response(resp)
|
self._check_response(resp)
|
||||||
return resp.json()
|
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):
|
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:
|
if resp.ok:
|
||||||
return
|
return
|
||||||
msg = ""
|
msg = extract_error_message(resp)
|
||||||
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 ""
|
|
||||||
raise requests.HTTPError(msg or f"HTTP {resp.status_code}", response=resp)
|
raise requests.HTTPError(msg or f"HTTP {resp.status_code}", response=resp)
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import frappe
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from frappe.utils import cint, flt
|
from frappe.utils import cint, flt
|
||||||
from difflib import SequenceMatcher
|
from difflib import SequenceMatcher
|
||||||
from kapital_bank.api import BIRBankClient
|
from kapital_bank.api import BIRBankClient, extract_error_message
|
||||||
|
|
||||||
|
|
||||||
def _mask(text):
|
def _mask(text):
|
||||||
|
|
@ -15,15 +15,8 @@ def _mask(text):
|
||||||
|
|
||||||
|
|
||||||
def _api_error_message(resp):
|
def _api_error_message(resp):
|
||||||
"""Extract the bank's own error message from response, or fall back to HTTP status text."""
|
"""Extract the bank's own error message (incl. field-level errors)."""
|
||||||
try:
|
return extract_error_message(resp) or f"HTTP {resp.status_code}: {resp.text[:200]}"
|
||||||
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]}"
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_leaf_node(doctype, configured, label):
|
def _resolve_leaf_node(doctype, configured, label):
|
||||||
|
|
@ -69,27 +62,27 @@ def _find_existing_party(doctype, name_field, name, tax_id):
|
||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def load_accounts_to_registry(login_name=None):
|
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:
|
try:
|
||||||
client = BIRBankClient(login_name)
|
client = BIRBankClient(login_name)
|
||||||
data = client.get_json("/accounts")
|
data = client.get_json("/b2b/accounts/portal/v1")
|
||||||
accounts = data.get("responseData", {}).get("accountsList", [])
|
accounts = data.get("accounts", [])
|
||||||
|
|
||||||
created = updated = 0
|
created = updated = 0
|
||||||
for a in accounts:
|
for a in accounts:
|
||||||
iban = a.get("ibanAcNo", "").strip()
|
iban = (a.get("iban") or "").strip()
|
||||||
if not iban:
|
if not iban:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
values = {
|
values = {
|
||||||
"cust_ac_no": a.get("custAcNo", ""),
|
"cust_ac_no": a.get("accountNumber", ""),
|
||||||
"currency": a.get("ccy", ""),
|
"currency": a.get("currency", ""),
|
||||||
"account_desc": a.get("acDesc", ""),
|
"account_desc": a.get("description", ""),
|
||||||
"account_status": a.get("status", ""),
|
"account_status": a.get("status", ""),
|
||||||
"branch_code": a.get("branchCode", ""),
|
"branch_code": a.get("branchCode", ""),
|
||||||
"current_balance": flt(a.get("currAmt", 0)),
|
"current_balance": flt(a.get("balance", 0)),
|
||||||
"planned_balance": flt(a.get("plannedAmt", 0)),
|
"planned_balance": flt(a.get("plannedBalance", 0)),
|
||||||
"hold": flt(a.get("hold", 0)),
|
"hold": 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
if frappe.db.exists("Kapital Bank Account", iban):
|
if frappe.db.exists("Kapital Bank Account", iban):
|
||||||
|
|
@ -112,11 +105,11 @@ def load_accounts_to_registry(login_name=None):
|
||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def load_cards_to_registry(login_name=None):
|
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:
|
try:
|
||||||
client = BIRBankClient(login_name)
|
client = BIRBankClient(login_name)
|
||||||
data = client.get_json("/cards")
|
data = client.get_json("/b2b/cards/portal/v1")
|
||||||
cards = data.get("responseData", {}).get("cards", [])
|
cards = data.get("cards", [])
|
||||||
|
|
||||||
created = updated = 0
|
created = updated = 0
|
||||||
for c in cards:
|
for c in cards:
|
||||||
|
|
@ -125,10 +118,10 @@ def load_cards_to_registry(login_name=None):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
values = {
|
values = {
|
||||||
"card_type": c.get("cardType", ""),
|
"card_type": c.get("cardProductName", ""),
|
||||||
"pan": c.get("pan", ""),
|
"pan": c.get("maskedPan", ""),
|
||||||
"currency": c.get("currency", ""),
|
"currency": c.get("currencyCode", ""),
|
||||||
"balance": flt(c.get("balance", 0)),
|
"balance": flt(c.get("availBalance", 0)),
|
||||||
"expiry_date": str(c.get("expiryDate", "")),
|
"expiry_date": str(c.get("expiryDate", "")),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -174,8 +167,8 @@ def load_counterparties_from_statements(from_date, to_date, login_name=None):
|
||||||
|
|
||||||
try:
|
try:
|
||||||
resp = client.get(
|
resp = client.get(
|
||||||
"/v2/statement/account",
|
"/b2b/account/portal/v3/statements",
|
||||||
params={"accountNumber": account_number, "fromDate": _fmt_date(from_date), "toDate": _fmt_date(to_date)}
|
params={"accountNumber": account_number, "fromDate": _iso_date(from_date), "toDate": _iso_date(to_date)}
|
||||||
)
|
)
|
||||||
debug_log.append(f" HTTP {resp.status_code}")
|
debug_log.append(f" HTTP {resp.status_code}")
|
||||||
if not resp.ok:
|
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")
|
frappe.log_error(f"Statement fetch failed for {_mask(iban)}: {e}", "Kapital Bank Load Counterparties")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
resp_obj = data.get("response", {})
|
statement_list = data.get("statementList", [])
|
||||||
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", [])
|
|
||||||
)
|
|
||||||
debug_log.append(f" statementList entries: {len(statement_list)}")
|
debug_log.append(f" statementList entries: {len(statement_list)}")
|
||||||
|
|
||||||
for entry in statement_list:
|
for entry in statement_list:
|
||||||
|
|
@ -1148,26 +1128,18 @@ def load_purposes_from_statements(from_date, to_date, login_name=None):
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
resp = client.get(
|
resp = client.get(
|
||||||
"/v2/statement/account",
|
"/b2b/account/portal/v3/statements",
|
||||||
params={
|
params={
|
||||||
"accountNumber": account.cust_ac_no,
|
"accountNumber": account.cust_ac_no,
|
||||||
"fromDate": _fmt_date(from_date),
|
"fromDate": _iso_date(from_date),
|
||||||
"toDate": _fmt_date(to_date),
|
"toDate": _iso_date(to_date),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
if not resp.ok:
|
if not resp.ok:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
resp_code = data.get("response", {}).get("code", "?")
|
statement_list = data.get("statementList", [])
|
||||||
if resp_code != "0":
|
|
||||||
continue
|
|
||||||
|
|
||||||
statement_list = (
|
|
||||||
data.get("responseData", {})
|
|
||||||
.get("operations", {})
|
|
||||||
.get("statementList", [])
|
|
||||||
)
|
|
||||||
|
|
||||||
for txn in statement_list:
|
for txn in statement_list:
|
||||||
purpose = (txn.get("purpose") or "").strip()
|
purpose = (txn.get("purpose") or "").strip()
|
||||||
|
|
@ -1227,11 +1199,11 @@ def load_purposes_from_statements(from_date, to_date, login_name=None):
|
||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def get_accounts(login_name=None):
|
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:
|
try:
|
||||||
client = BIRBankClient(login_name)
|
client = BIRBankClient(login_name)
|
||||||
data = client.get_json("/accounts")
|
data = client.get_json("/b2b/accounts/portal/v1")
|
||||||
accounts = data.get("responseData", {}).get("accountsList", [])
|
accounts = data.get("accounts", [])
|
||||||
return {"success": True, "accounts": accounts}
|
return {"success": True, "accounts": accounts}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
frappe.log_error(f"get_accounts failed: {e}\n{frappe.get_traceback()}", "Kapital Bank API")
|
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()
|
@frappe.whitelist()
|
||||||
def get_account_statement(login_name=None, iban=None, from_date=None, to_date=None):
|
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:
|
try:
|
||||||
client = BIRBankClient(login_name)
|
client = BIRBankClient(login_name)
|
||||||
params = {"accountNumber": iban, "fromDate": _fmt_date(from_date), "toDate": _fmt_date(to_date)}
|
params = {"accountNumber": iban, "fromDate": _iso_date(from_date), "toDate": _iso_date(to_date)}
|
||||||
data = client.get_json("/v2/statement/account", params=params)
|
data = client.get_json("/b2b/account/portal/v3/statements", params=params)
|
||||||
operations = data.get("responseData", {}).get("operations", {})
|
return {"success": True, "data": data}
|
||||||
return {"success": True, "data": operations}
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
frappe.log_error(f"get_account_statement failed: {e}\n{frappe.get_traceback()}", "Kapital Bank API")
|
frappe.log_error(f"get_account_statement failed: {e}\n{frappe.get_traceback()}", "Kapital Bank API")
|
||||||
return {"success": False, "message": str(e)}
|
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()
|
@frappe.whitelist()
|
||||||
def get_cards(login_name=None):
|
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:
|
try:
|
||||||
client = BIRBankClient(login_name)
|
client = BIRBankClient(login_name)
|
||||||
data = client.get_json("/cards")
|
data = client.get_json("/b2b/cards/portal/v1")
|
||||||
cards = data.get("responseData", {}).get("cards", [])
|
cards = data.get("cards", [])
|
||||||
return {"success": True, "cards": cards}
|
return {"success": True, "cards": cards}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
frappe.log_error(f"get_cards failed: {e}\n{frappe.get_traceback()}", "Kapital Bank API")
|
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()
|
@frappe.whitelist()
|
||||||
def get_card_statement(login_name=None, account_no=None, from_date=None, to_date=None):
|
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:
|
try:
|
||||||
client = BIRBankClient(login_name)
|
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}
|
pan = frappe.db.get_value("Kapital Bank Card", account_no, "pan") or ""
|
||||||
data = client.get_json("/v2/statement/card", params=params)
|
pan_last4 = re.sub(r"\D", "", pan)[-4:]
|
||||||
operations = data.get("responseData", {}).get("operations", {})
|
params = {
|
||||||
return {"success": True, "data": operations}
|
"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:
|
except Exception as e:
|
||||||
frappe.log_error(f"get_card_statement failed: {e}\n{frappe.get_traceback()}", "Kapital Bank API")
|
frappe.log_error(f"get_card_statement failed: {e}\n{frappe.get_traceback()}", "Kapital Bank API")
|
||||||
return {"success": False, "message": str(e)}
|
return {"success": False, "message": str(e)}
|
||||||
|
|
@ -1288,7 +1270,9 @@ def _parse_txn_date(trn_dt):
|
||||||
trn_dt = (trn_dt or "").strip()
|
trn_dt = (trn_dt or "").strip()
|
||||||
if not trn_dt:
|
if not trn_dt:
|
||||||
return None
|
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:
|
try:
|
||||||
return datetime.strptime(trn_dt, fmt).date()
|
return datetime.strptime(trn_dt, fmt).date()
|
||||||
except ValueError:
|
except ValueError:
|
||||||
|
|
@ -1297,14 +1281,20 @@ def _parse_txn_date(trn_dt):
|
||||||
|
|
||||||
|
|
||||||
def _parse_card_txn_date(date_str):
|
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()
|
date_str = (date_str or "").strip()
|
||||||
if not date_str:
|
if not date_str:
|
||||||
return None
|
return None
|
||||||
try:
|
for fmt in ("%d.%m.%Y", "%Y-%m-%d", "%d.%m.%Y %H:%M:%S", "%Y-%m-%dT%H:%M:%S"):
|
||||||
return datetime.strptime(date_str, "%d.%m.%Y").date()
|
try:
|
||||||
except ValueError:
|
return datetime.strptime(date_str, fmt).date()
|
||||||
return None
|
except ValueError:
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _parse_counterparty(contr_raw):
|
def _parse_counterparty(contr_raw):
|
||||||
|
|
@ -1333,27 +1323,18 @@ def get_statement_transactions(from_date, to_date, account_iban, login_name=None
|
||||||
# Fetch statement
|
# Fetch statement
|
||||||
client = BIRBankClient(login_name)
|
client = BIRBankClient(login_name)
|
||||||
resp = client.get(
|
resp = client.get(
|
||||||
"/v2/statement/account",
|
"/b2b/account/portal/v3/statements",
|
||||||
params={
|
params={
|
||||||
"accountNumber": cust_ac_no,
|
"accountNumber": cust_ac_no,
|
||||||
"fromDate": _fmt_date(from_date),
|
"fromDate": _iso_date(from_date),
|
||||||
"toDate": _fmt_date(to_date),
|
"toDate": _iso_date(to_date),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
if not resp.ok:
|
if not resp.ok:
|
||||||
return {"success": False, "message": _api_error_message(resp)}
|
return {"success": False, "message": _api_error_message(resp)}
|
||||||
|
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
resp_code = data.get("response", {}).get("code", "?")
|
statement_list = data.get("statementList", [])
|
||||||
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", [])
|
|
||||||
)
|
|
||||||
|
|
||||||
# Use the account's own currency for all transactions from this account
|
# 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"
|
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):
|
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.
|
"""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).
|
Filters out duplicates (already imported by reference_no).
|
||||||
Returns: {success, transactions: [...], total_fetched, skipped_duplicates}
|
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
|
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"
|
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)
|
client = BIRBankClient(login_name)
|
||||||
resp = client.get(
|
operation_list = []
|
||||||
"/v2/statement/card",
|
page_id = 0
|
||||||
params={
|
MAX_PAGES = 500 # safety cap against a misbehaving `next` flag
|
||||||
"accountNumber": card_account_number,
|
while page_id < MAX_PAGES:
|
||||||
"fromDate": str(from_date) if from_date else None,
|
resp = client.get(
|
||||||
"toDate": str(to_date) if to_date else None,
|
"/b2b/cards/portal/v1/statements",
|
||||||
}
|
params={
|
||||||
)
|
"accountNo": card_account_number,
|
||||||
if not resp.ok:
|
"panLast4": pan_last4,
|
||||||
return {"success": False, "message": _api_error_message(resp)}
|
"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()
|
data = resp.json()
|
||||||
resp_code = data.get("response", {}).get("code", "?")
|
operation_list.extend(data.get("cardStatements", []))
|
||||||
if resp_code != "0":
|
|
||||||
resp_msg = data.get("response", {}).get("message", "")
|
|
||||||
return {"success": False, "message": resp_msg or f"API error code={resp_code}"}
|
|
||||||
|
|
||||||
# Card statements use responseData.operation[] (not operations.statementList[])
|
if not data.get("next"):
|
||||||
operation_list = data.get("responseData", {}).get("operation", [])
|
break
|
||||||
|
page_id += 1
|
||||||
|
|
||||||
transactions = []
|
transactions = []
|
||||||
skipped_duplicates = 0
|
skipped_duplicates = 0
|
||||||
|
|
@ -2442,17 +2433,11 @@ def _normalize(text, consider_azeri=True):
|
||||||
return " ".join(s.lower().split())
|
return " ".join(s.lower().split())
|
||||||
|
|
||||||
|
|
||||||
def _fmt_date(d, sep='-'):
|
def _iso_date(d):
|
||||||
"""Convert YYYY-MM-DD string or date object → DD{sep}MM{sep}YYYY.
|
"""Normalise a date string/object → ISO YYYY-MM-DD (the format the v3 portal
|
||||||
|
statement endpoints expect). Returns None for falsy input."""
|
||||||
Account statement → sep='-' → DD-MM-YYYY
|
|
||||||
Card statement → sep='.' → DD.MM.YYYY
|
|
||||||
"""
|
|
||||||
if not d:
|
if not d:
|
||||||
return None
|
return None
|
||||||
if hasattr(d, 'strftime'):
|
if hasattr(d, 'strftime'):
|
||||||
return d.strftime(f'%d{sep}%m{sep}%Y')
|
return d.strftime('%Y-%m-%d')
|
||||||
parts = str(d).split('-')
|
|
||||||
if len(parts) == 3:
|
|
||||||
return f"{parts[2]}{sep}{parts[1]}{sep}{parts[0]}"
|
|
||||||
return str(d)
|
return str(d)
|
||||||
|
|
|
||||||
|
|
@ -56,12 +56,24 @@ frappe.ui.form.on("Payment Order", {
|
||||||
callback(r) {
|
callback(r) {
|
||||||
const res = r.message;
|
const res = r.message;
|
||||||
if (!res) return;
|
if (!res) return;
|
||||||
if (res.success) {
|
if (res.success && res.cancelled > 0) {
|
||||||
frappe.show_alert(
|
frappe.show_alert(
|
||||||
{ message: __("Cancelled: {0}", [res.cancelled]), indicator: "green" },
|
{ message: __("Cancelled: {0}", [res.cancelled]), indicator: "green" },
|
||||||
5
|
5
|
||||||
);
|
);
|
||||||
frm.reload_doc();
|
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 {
|
} else {
|
||||||
frappe.msgprint({
|
frappe.msgprint({
|
||||||
title: __("Cancel Failed"),
|
title: __("Cancel Failed"),
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ const KB_STATUS_COLOR = {
|
||||||
function kb_show_status(frm) {
|
function kb_show_status(frm) {
|
||||||
frappe.db.get_list("Kapital Bank Payment", {
|
frappe.db.get_list("Kapital Bank Payment", {
|
||||||
filters: { payment_request: frm.doc.name },
|
filters: { payment_request: frm.doc.name },
|
||||||
fields: ["status", "operation_name"],
|
fields: ["status", "transfer_no"],
|
||||||
order_by: "creation desc",
|
order_by: "creation desc",
|
||||||
limit: 1,
|
limit: 1,
|
||||||
}).then(rows => {
|
}).then(rows => {
|
||||||
|
|
@ -92,13 +92,19 @@ frappe.ui.form.on("Payment Request", {
|
||||||
if (res.success && res.cancelled > 0) {
|
if (res.success && res.cancelled > 0) {
|
||||||
frappe.show_alert({ message: __("Bank transfer cancelled successfully."), indicator: "green" }, 6);
|
frappe.show_alert({ message: __("Bank transfer cancelled successfully."), indicator: "green" }, 6);
|
||||||
frm.reload_doc();
|
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) {
|
} else if (res.message) {
|
||||||
frappe.show_alert({ message: res.message, indicator: "grey" }, 5);
|
frappe.show_alert({ message: res.message, indicator: "grey" }, 5);
|
||||||
} else {
|
} else {
|
||||||
frappe.msgprint({
|
frappe.msgprint({
|
||||||
title: __("Cancel Failed"),
|
title: __("Cancel Failed"),
|
||||||
indicator: "red",
|
indicator: "red",
|
||||||
message: res.message || __("Unknown error"),
|
message: __("Unknown error"),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -13,22 +13,6 @@ class KapitalBankAPITest(frappe.model.document.Document):
|
||||||
# Helpers
|
# 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):
|
def _dump_get(path, params=None):
|
||||||
"""Build human-readable GET request dump."""
|
"""Build human-readable GET request dump."""
|
||||||
url = f"{BASE_URL}{path}"
|
url = f"{BASE_URL}{path}"
|
||||||
|
|
@ -127,9 +111,9 @@ def do_refresh_token(docname):
|
||||||
def do_get_accounts(docname):
|
def do_get_accounts(docname):
|
||||||
doc = frappe.get_doc("Kapital Bank API Test", docname)
|
doc = frappe.get_doc("Kapital Bank API Test", docname)
|
||||||
from kapital_bank.api import BIRBankClient
|
from kapital_bank.api import BIRBankClient
|
||||||
req = _dump_get("/accounts")
|
req = _dump_get("/b2b/accounts/portal/v1")
|
||||||
def _call():
|
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)
|
return _run(docname, _call, req)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -139,12 +123,12 @@ def do_get_account_statement(docname):
|
||||||
from kapital_bank.api import BIRBankClient
|
from kapital_bank.api import BIRBankClient
|
||||||
params = {
|
params = {
|
||||||
"accountNumber": doc.iban,
|
"accountNumber": doc.iban,
|
||||||
"fromDate": _fmt_date(doc.from_date),
|
"fromDate": str(doc.from_date) if doc.from_date else None,
|
||||||
"toDate": _fmt_date(doc.to_date),
|
"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():
|
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)
|
return _run(docname, _call, req)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -156,24 +140,28 @@ def do_get_account_statement(docname):
|
||||||
def do_get_cards(docname):
|
def do_get_cards(docname):
|
||||||
doc = frappe.get_doc("Kapital Bank API Test", docname)
|
doc = frappe.get_doc("Kapital Bank API Test", docname)
|
||||||
from kapital_bank.api import BIRBankClient
|
from kapital_bank.api import BIRBankClient
|
||||||
req = _dump_get("/cards")
|
req = _dump_get("/b2b/cards/portal/v1")
|
||||||
def _call():
|
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)
|
return _run(docname, _call, req)
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def do_get_card_statement(docname):
|
def do_get_card_statement(docname):
|
||||||
|
import re
|
||||||
doc = frappe.get_doc("Kapital Bank API Test", docname)
|
doc = frappe.get_doc("Kapital Bank API Test", docname)
|
||||||
from kapital_bank.api import BIRBankClient
|
from kapital_bank.api import BIRBankClient
|
||||||
|
pan = frappe.db.get_value("Kapital Bank Card", doc.account_number, "pan") or ""
|
||||||
params = {
|
params = {
|
||||||
"accountNumber": doc.account_number,
|
"accountNo": doc.account_number,
|
||||||
"fromDate": str(doc.from_date) if doc.from_date else None,
|
"panLast4": re.sub(r"\D", "", pan)[-4:],
|
||||||
"toDate": str(doc.to_date) if doc.to_date else None,
|
"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():
|
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)
|
return _run(docname, _call, req)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -185,8 +173,9 @@ def do_get_card_statement(docname):
|
||||||
def do_transfer_status(docname):
|
def do_transfer_status(docname):
|
||||||
doc = frappe.get_doc("Kapital Bank API Test", docname)
|
doc = frappe.get_doc("Kapital Bank API Test", docname)
|
||||||
from kapital_bank.api import BIRBankClient
|
from kapital_bank.api import BIRBankClient
|
||||||
params = {"operatorName": doc.operation_name_param}
|
transfer_id = (doc.operation_name_param or "").strip()
|
||||||
req = _dump_get("/internal-transfer/status", params)
|
path = f"/b2b/transfers/portal/v1/{transfer_id}"
|
||||||
|
req = _dump_get(path)
|
||||||
def _call():
|
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)
|
return _run(docname, _call, req)
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,9 @@
|
||||||
"engine": "InnoDB",
|
"engine": "InnoDB",
|
||||||
"field_order": [
|
"field_order": [
|
||||||
"bank_code",
|
"bank_code",
|
||||||
"bank_name"
|
"bank_name",
|
||||||
|
"bank_id",
|
||||||
|
"bic_code"
|
||||||
],
|
],
|
||||||
"fields": [
|
"fields": [
|
||||||
{
|
{
|
||||||
|
|
@ -23,6 +25,18 @@
|
||||||
"in_list_view": 1,
|
"in_list_view": 1,
|
||||||
"label": "Bank Name",
|
"label": "Bank Name",
|
||||||
"reqd": 1
|
"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,
|
"index_web_pages_for_search": 1,
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"fieldname": "password",
|
"fieldname": "password",
|
||||||
"fieldtype": "Data",
|
"fieldtype": "Password",
|
||||||
"label": "Password",
|
"label": "Password",
|
||||||
"reqd": 1
|
"reqd": 1
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -7,18 +7,21 @@
|
||||||
"field_order": [
|
"field_order": [
|
||||||
"payment_request",
|
"payment_request",
|
||||||
"payment_entry",
|
"payment_entry",
|
||||||
"operation_name",
|
"transfer_no",
|
||||||
"operation_id",
|
"transfer_id",
|
||||||
|
"process_key",
|
||||||
"status",
|
"status",
|
||||||
|
"front_state",
|
||||||
"column_break_1",
|
"column_break_1",
|
||||||
"from_account",
|
"from_account",
|
||||||
"to_account",
|
"to_account",
|
||||||
"to_tax_no",
|
"to_tax_no",
|
||||||
"to_cust_name",
|
"to_cust_name",
|
||||||
"ben_bank_code",
|
"ben_bank_code",
|
||||||
|
"ben_bank_id",
|
||||||
"amount",
|
"amount",
|
||||||
"details_section",
|
"details_section",
|
||||||
"purpose1",
|
"description",
|
||||||
"error_message",
|
"error_message",
|
||||||
"column_break_2",
|
"column_break_2",
|
||||||
"sent_at",
|
"sent_at",
|
||||||
|
|
@ -42,18 +45,24 @@
|
||||||
"read_only": 1
|
"read_only": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"fieldname": "operation_name",
|
"fieldname": "transfer_no",
|
||||||
"fieldtype": "Data",
|
"fieldtype": "Data",
|
||||||
"in_list_view": 1,
|
"in_list_view": 1,
|
||||||
"label": "Operation Name",
|
"label": "Transfer No",
|
||||||
"read_only": 1,
|
"read_only": 1,
|
||||||
"reqd": 1,
|
"reqd": 1,
|
||||||
"unique": 1
|
"unique": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"fieldname": "operation_id",
|
"fieldname": "transfer_id",
|
||||||
"fieldtype": "Data",
|
"fieldtype": "Data",
|
||||||
"label": "Operation ID",
|
"label": "Transfer ID",
|
||||||
|
"read_only": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "process_key",
|
||||||
|
"fieldtype": "Data",
|
||||||
|
"label": "Process Key",
|
||||||
"read_only": 1
|
"read_only": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -65,6 +74,12 @@
|
||||||
"options": "Draft\nSent\nConfirm Wait\nSuccess\nRejected\nCancelled",
|
"options": "Draft\nSent\nConfirm Wait\nSuccess\nRejected\nCancelled",
|
||||||
"read_only": 1
|
"read_only": 1
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "front_state",
|
||||||
|
"fieldtype": "Data",
|
||||||
|
"label": "Bank State (raw)",
|
||||||
|
"read_only": 1
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"fieldname": "column_break_1",
|
"fieldname": "column_break_1",
|
||||||
"fieldtype": "Column Break"
|
"fieldtype": "Column Break"
|
||||||
|
|
@ -99,6 +114,12 @@
|
||||||
"label": "Beneficiary Bank Code",
|
"label": "Beneficiary Bank Code",
|
||||||
"read_only": 1
|
"read_only": 1
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "ben_bank_id",
|
||||||
|
"fieldtype": "Data",
|
||||||
|
"label": "Beneficiary Bank ID",
|
||||||
|
"read_only": 1
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"fieldname": "amount",
|
"fieldname": "amount",
|
||||||
"fieldtype": "Currency",
|
"fieldtype": "Currency",
|
||||||
|
|
@ -111,9 +132,9 @@
|
||||||
"label": "Details"
|
"label": "Details"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"fieldname": "purpose1",
|
"fieldname": "description",
|
||||||
"fieldtype": "Data",
|
"fieldtype": "Data",
|
||||||
"label": "Purpose",
|
"label": "Description",
|
||||||
"read_only": 1
|
"read_only": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -164,6 +185,6 @@
|
||||||
"sort_field": "modified",
|
"sort_field": "modified",
|
||||||
"sort_order": "DESC",
|
"sort_order": "DESC",
|
||||||
"states": [],
|
"states": [],
|
||||||
"title_field": "operation_name",
|
"title_field": "transfer_no",
|
||||||
"track_changes": 1
|
"track_changes": 1
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,4 +5,5 @@ kapital_bank.patches.rename_purpose_mapping_to_transaction_mapping
|
||||||
|
|
||||||
[post_model_sync]
|
[post_model_sync]
|
||||||
# Patches added in this section will be executed after doctypes are migrated
|
# 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
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -3,6 +3,7 @@ from frappe import _
|
||||||
from frappe.utils import now_datetime, flt
|
from frappe.utils import now_datetime, flt
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from kapital_bank.api import BIRBankClient
|
from kapital_bank.api import BIRBankClient
|
||||||
|
from kapital_bank.bank_api import _api_error_message
|
||||||
|
|
||||||
_AZ_MAP = str.maketrans("ƏəİışŞÇçÖöÜüĞğ", "EeIissCcOoUuGg")
|
_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
|
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()
|
@frappe.whitelist()
|
||||||
def sync_bank_codes(login_name=None):
|
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)
|
client = BIRBankClient(login_name)
|
||||||
resp = client.get_json("/bank-codes")
|
|
||||||
bank_codes = resp.get("responseData", {}).get("bankCodes", [])
|
|
||||||
|
|
||||||
added = 0
|
ibans = set()
|
||||||
updated = 0
|
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:
|
seen = {}
|
||||||
bank_code = (item.get("bankCode") or "").strip()
|
for iban in ibans:
|
||||||
bank_name = (item.get("bankName") or "").strip()
|
try:
|
||||||
if not bank_code:
|
for b in _fetch_banks_for_iban(client, iban):
|
||||||
|
code = (b.get("bankCode") or "").strip()
|
||||||
|
if code:
|
||||||
|
seen[code] = b
|
||||||
|
except Exception:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if frappe.db.exists("Kapital Bank Bank Code", bank_code):
|
added = updated = 0
|
||||||
existing_name = frappe.db.get_value("Kapital Bank Bank Code", bank_code, "bank_name")
|
for code, b in seen.items():
|
||||||
if existing_name != bank_name:
|
values = {
|
||||||
frappe.db.set_value("Kapital Bank Bank Code", bank_code, "bank_name", bank_name)
|
"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
|
updated += 1
|
||||||
else:
|
else:
|
||||||
doc = frappe.new_doc("Kapital Bank Bank Code")
|
doc = frappe.new_doc("Kapital Bank Bank Code")
|
||||||
doc.bank_code = bank_code
|
doc.bank_code = code
|
||||||
doc.bank_name = bank_name
|
doc.update(values)
|
||||||
doc.insert(ignore_permissions=True)
|
doc.insert(ignore_permissions=True)
|
||||||
added += 1
|
added += 1
|
||||||
|
|
||||||
frappe.db.commit()
|
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()
|
@frappe.whitelist()
|
||||||
|
|
@ -95,6 +170,15 @@ def send_payment_request(name, login_name=None):
|
||||||
if not flt(pr.grand_total):
|
if not flt(pr.grand_total):
|
||||||
errors.append(_("Field 'Amount' is zero on Payment Request {0}.").format(name))
|
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)
|
to_cust_name = _latinize(pr.party_name, 34)
|
||||||
if not to_cust_name:
|
if not to_cust_name:
|
||||||
errors.append(_("Party name '{0}' is empty after Latin transliteration.").format(pr.party_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:
|
if errors:
|
||||||
return {"success": False, "errors": 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
|
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)
|
to_cust_name = _latinize(pr.party_name, 34)
|
||||||
purpose = _latinize(pr.name, 64)
|
description = _latinize(pr.name, 64)
|
||||||
|
amount = float(flt(pr.grand_total))
|
||||||
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
|
import requests as _requests
|
||||||
|
|
||||||
frappe.log_error(f"[KB] Sending transfer payload for {name}:\n{payload}", "KB Transfer Payload")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
client = BIRBankClient(login_name)
|
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 = frappe.new_doc("Kapital Bank Payment")
|
||||||
kbp.update({
|
kbp.update({
|
||||||
"payment_request": name,
|
"payment_request": name,
|
||||||
"operation_name": op_name,
|
"transfer_no": transfer_no,
|
||||||
"operation_id": operation_id,
|
"transfer_id": transfer_id,
|
||||||
|
"process_key": process_key,
|
||||||
"status": "Sent",
|
"status": "Sent",
|
||||||
"from_account": from_iban,
|
"from_account": from_iban,
|
||||||
"to_account": payee_iban,
|
"to_account": payee_iban,
|
||||||
"to_tax_no": tax_id,
|
"to_tax_no": tax_id,
|
||||||
"to_cust_name": to_cust_name,
|
"to_cust_name": to_cust_name,
|
||||||
"ben_bank_code": kb_bank_code,
|
"ben_bank_code": kb_bank_code,
|
||||||
|
"ben_bank_id": bank_id,
|
||||||
"amount": flt(pr.grand_total),
|
"amount": flt(pr.grand_total),
|
||||||
"purpose1": purpose,
|
"description": description,
|
||||||
"sent_at": now_datetime(),
|
"sent_at": now_datetime(),
|
||||||
"status_updated_at": now_datetime(),
|
"status_updated_at": now_datetime(),
|
||||||
})
|
})
|
||||||
|
|
@ -173,7 +293,7 @@ def check_payment_status(name, login_name=None):
|
||||||
pending = frappe.get_all(
|
pending = frappe.get_all(
|
||||||
"Kapital Bank Payment",
|
"Kapital Bank Payment",
|
||||||
filters={"payment_request": name, "status": ["in", ["Sent", "Confirm Wait"]]},
|
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:
|
if not pending:
|
||||||
|
|
@ -181,35 +301,28 @@ def check_payment_status(name, login_name=None):
|
||||||
|
|
||||||
client = BIRBankClient(login_name)
|
client = BIRBankClient(login_name)
|
||||||
|
|
||||||
STATUS_MAP = {
|
|
||||||
"CONFIRM_WAIT": "Confirm Wait",
|
|
||||||
"SUCCESS": "Success",
|
|
||||||
"CONFIRMED": "Success",
|
|
||||||
"REJECTED": "Rejected",
|
|
||||||
"CANCELLED": "Cancelled",
|
|
||||||
"CANCELED": "Cancelled",
|
|
||||||
}
|
|
||||||
|
|
||||||
updated = 0
|
updated = 0
|
||||||
errors = []
|
errors = []
|
||||||
|
|
||||||
for kbp in pending:
|
for kbp in pending:
|
||||||
try:
|
try:
|
||||||
resp = client.get_json("/internal-transfer/status", params={"operatorName": kbp.operation_name})
|
if not kbp.transfer_id:
|
||||||
data = resp.get("data", [])
|
frappe.logger().warning(f"[KB] Status check for {kbp.transfer_no}: no transfer_id")
|
||||||
if not data:
|
|
||||||
frappe.logger().warning(f"[KB] Status check for {kbp.operation_name}: empty data response")
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
entry = data[0]
|
resp = client.get_json(f"/b2b/transfers/portal/v1/{kbp.transfer_id}")
|
||||||
bank_status = entry.get("status", "")
|
front_state = resp.get("frontState", "")
|
||||||
new_status = STATUS_MAP.get(bank_status)
|
new_status = _map_front_state(front_state)
|
||||||
|
|
||||||
if new_status is None:
|
if new_status is None:
|
||||||
frappe.db.set_value(
|
frappe.db.set_value(
|
||||||
"Kapital Bank Payment",
|
"Kapital Bank Payment",
|
||||||
kbp.name,
|
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,
|
update_modified=False,
|
||||||
)
|
)
|
||||||
frappe.db.commit()
|
frappe.db.commit()
|
||||||
|
|
@ -220,8 +333,9 @@ def check_payment_status(name, login_name=None):
|
||||||
kbp.name,
|
kbp.name,
|
||||||
{
|
{
|
||||||
"status": new_status,
|
"status": new_status,
|
||||||
|
"front_state": front_state,
|
||||||
"status_updated_at": now_datetime(),
|
"status_updated_at": now_datetime(),
|
||||||
"error_message": entry.get("description", ""),
|
"error_message": resp.get("description", ""),
|
||||||
},
|
},
|
||||||
update_modified=False,
|
update_modified=False,
|
||||||
)
|
)
|
||||||
|
|
@ -236,42 +350,57 @@ def check_payment_status(name, login_name=None):
|
||||||
)
|
)
|
||||||
frappe.db.commit()
|
frappe.db.commit()
|
||||||
elif new_status == "Rejected":
|
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:
|
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))
|
errors.append(str(e))
|
||||||
|
|
||||||
return {"success": True, "updated": updated, "errors": errors}
|
return {"success": True, "updated": updated, "errors": errors}
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def cancel_payment_request(name, login_name=None):
|
def cancel_payment_request(name, login_name=None, comment=None):
|
||||||
"""Cancel pending bank transfers for a Payment Request."""
|
"""Cancel (reject) pending bank transfers for a Payment Request."""
|
||||||
pending = frappe.get_all(
|
pending = frappe.get_all(
|
||||||
"Kapital Bank Payment",
|
"Kapital Bank Payment",
|
||||||
filters={"payment_request": name, "status": ["in", ["Sent", "Confirm Wait"]]},
|
filters={"payment_request": name, "status": ["in", ["Sent", "Confirm Wait"]]},
|
||||||
fields=["name", "operation_name", "operation_id"],
|
fields=["name", "transfer_no", "transfer_id"],
|
||||||
)
|
)
|
||||||
|
|
||||||
if not pending:
|
if not pending:
|
||||||
return {"success": True, "cancelled": 0, "message": _("Nothing to cancel")}
|
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)
|
client = BIRBankClient(login_name)
|
||||||
cancelled = 0
|
cancelled = 0
|
||||||
errors = []
|
errors = []
|
||||||
|
|
||||||
for kbp in pending:
|
for kbp in pending:
|
||||||
try:
|
try:
|
||||||
if not kbp.operation_id:
|
if not kbp.transfer_id:
|
||||||
frappe.logger().warning(f"[KB] Cancel: no operation_id for {kbp.operation_name}, cancelling locally")
|
frappe.logger().warning(f"[KB] Cancel: no transfer_id for {kbp.transfer_no}, cancelling locally")
|
||||||
else:
|
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:
|
if not resp.ok:
|
||||||
frappe.log_error(
|
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",
|
"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(
|
frappe.db.set_value(
|
||||||
"Kapital Bank Payment",
|
"Kapital Bank Payment",
|
||||||
|
|
@ -283,7 +412,7 @@ def cancel_payment_request(name, login_name=None):
|
||||||
cancelled += 1
|
cancelled += 1
|
||||||
|
|
||||||
except Exception as e:
|
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))
|
errors.append(str(e))
|
||||||
|
|
||||||
return {"success": True, "cancelled": cancelled, "errors": errors}
|
return {"success": True, "cancelled": cancelled, "errors": errors}
|
||||||
|
|
@ -313,10 +442,10 @@ def on_cancel_payment_request(doc, method):
|
||||||
pending = frappe.get_all(
|
pending = frappe.get_all(
|
||||||
"Kapital Bank Payment",
|
"Kapital Bank Payment",
|
||||||
filters={"payment_request": doc.name, "status": ["in", ["Sent", "Confirm Wait"]]},
|
filters={"payment_request": doc.name, "status": ["in", ["Sent", "Confirm Wait"]]},
|
||||||
fields=["name", "operation_name"],
|
fields=["name", "transfer_no"],
|
||||||
)
|
)
|
||||||
if pending:
|
if pending:
|
||||||
names = ", ".join(p.operation_name for p in pending)
|
names = ", ".join(p.transfer_no for p in pending)
|
||||||
frappe.throw(
|
frappe.throw(
|
||||||
_(
|
_(
|
||||||
"Cannot cancel Payment Request {0}: pending bank transfers exist ({1}). "
|
"Cannot cancel Payment Request {0}: pending bank transfers exist ({1}). "
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue