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
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
});
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@
|
|||
},
|
||||
{
|
||||
"fieldname": "password",
|
||||
"fieldtype": "Data",
|
||||
"fieldtype": "Password",
|
||||
"label": "Password",
|
||||
"reqd": 1
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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 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}). "
|
||||
|
|
|
|||
Loading…
Reference in New Issue