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