diff --git a/api_endpoints.txt b/api_endpoints.txt new file mode 100644 index 0000000..a8a7053 --- /dev/null +++ b/api_endpoints.txt @@ -0,0 +1,39 @@ +https://api.birbank.business/products/13/1 +https://api.birbank.business/products/13/2 + +https://api.birbank.business/products/7/1 +https://api.birbank.business/products/7/2 + +https://api.birbank.business/products/14/1 +https://api.birbank.business/products/14/2 + +https://api.birbank.business/products/2/9 +https://api.birbank.business/products/2/4 +https://api.birbank.business/products/2/1 +https://api.birbank.business/products/2/7 +https://api.birbank.business/products/2/2 +https://api.birbank.business/products/2/3 +https://api.birbank.business/products/2/5 +https://api.birbank.business/products/2/6 +https://api.birbank.business/products/2/8 + +https://api.birbank.business/products/8/1 +https://api.birbank.business/products/8/2 + +https://api.birbank.business/products/5/1 + +https://api.birbank.business/products/15/1 + +https://api.birbank.business/products/3/1 +https://api.birbank.business/products/3/2 + +https://api.birbank.business/products/10/1 +https://api.birbank.business/products/10/3 +https://api.birbank.business/products/10/4 +https://api.birbank.business/products/10/2 +https://api.birbank.business/products/10/1 + +https://api.birbank.business/products/16/1 +https://api.birbank.business/products/16/2 + +https://api.birbank.business/products/17/1 \ No newline at end of file diff --git a/birbank_docs/18% VAT payment.pdf b/birbank_docs/18% VAT payment.pdf new file mode 100644 index 0000000..2d809fe Binary files /dev/null and b/birbank_docs/18% VAT payment.pdf differ diff --git a/birbank_docs/Bulk money transfers.pdf b/birbank_docs/Bulk money transfers.pdf new file mode 100644 index 0000000..97ad349 Binary files /dev/null and b/birbank_docs/Bulk money transfers.pdf differ diff --git a/birbank_docs/Business card statement.pdf b/birbank_docs/Business card statement.pdf new file mode 100644 index 0000000..f859300 Binary files /dev/null and b/birbank_docs/Business card statement.pdf differ diff --git a/birbank_docs/Business card.pdf b/birbank_docs/Business card.pdf new file mode 100644 index 0000000..2b734a3 Binary files /dev/null and b/birbank_docs/Business card.pdf differ diff --git a/birbank_docs/Card details.pdf b/birbank_docs/Card details.pdf new file mode 100644 index 0000000..b12489a Binary files /dev/null and b/birbank_docs/Card details.pdf differ diff --git "a/birbank_docs/Cashless currency exchange\n.pdf" "b/birbank_docs/Cashless currency exchange\n.pdf" new file mode 100644 index 0000000..1ae22c2 Binary files /dev/null and "b/birbank_docs/Cashless currency exchange\n.pdf" differ diff --git "a/birbank_docs/Commission fee\n.pdf" "b/birbank_docs/Commission fee\n.pdf" new file mode 100644 index 0000000..e370203 Binary files /dev/null and "b/birbank_docs/Commission fee\n.pdf" differ diff --git a/birbank_docs/Current account information.pdf b/birbank_docs/Current account information.pdf new file mode 100644 index 0000000..3672677 Binary files /dev/null and b/birbank_docs/Current account information.pdf differ diff --git a/birbank_docs/Current account statement.pdf b/birbank_docs/Current account statement.pdf new file mode 100644 index 0000000..0b3d58a Binary files /dev/null and b/birbank_docs/Current account statement.pdf differ diff --git "a/birbank_docs/E-commerce info\n.pdf" "b/birbank_docs/E-commerce info\n.pdf" new file mode 100644 index 0000000..ed803cf Binary files /dev/null and "b/birbank_docs/E-commerce info\n.pdf" differ diff --git "a/birbank_docs/International money transfer\n.pdf" "b/birbank_docs/International money transfer\n.pdf" new file mode 100644 index 0000000..b8ae4d3 Binary files /dev/null and "b/birbank_docs/International money transfer\n.pdf" differ diff --git a/birbank_docs/List of bank codes.pdf b/birbank_docs/List of bank codes.pdf new file mode 100644 index 0000000..5267678 Binary files /dev/null and b/birbank_docs/List of bank codes.pdf differ diff --git "a/birbank_docs/List of branches\n.pdf" "b/birbank_docs/List of branches\n.pdf" new file mode 100644 index 0000000..a4df350 Binary files /dev/null and "b/birbank_docs/List of branches\n.pdf" differ diff --git "a/birbank_docs/List of budget classification codes\n.pdf" "b/birbank_docs/List of budget classification codes\n.pdf" new file mode 100644 index 0000000..ec1cf32 Binary files /dev/null and "b/birbank_docs/List of budget classification codes\n.pdf" differ diff --git "a/birbank_docs/List of budget level codes\n.pdf" "b/birbank_docs/List of budget level codes\n.pdf" new file mode 100644 index 0000000..87e2272 Binary files /dev/null and "b/birbank_docs/List of budget level codes\n.pdf" differ diff --git a/birbank_docs/Login.pdf b/birbank_docs/Login.pdf new file mode 100644 index 0000000..2743c2c Binary files /dev/null and b/birbank_docs/Login.pdf differ diff --git a/birbank_docs/Money transfer within the country.pdf b/birbank_docs/Money transfer within the country.pdf new file mode 100644 index 0000000..2ffd6be Binary files /dev/null and b/birbank_docs/Money transfer within the country.pdf differ diff --git "a/birbank_docs/POS-system data\n.pdf" "b/birbank_docs/POS-system data\n.pdf" new file mode 100644 index 0000000..bce06ee Binary files /dev/null and "b/birbank_docs/POS-system data\n.pdf" differ diff --git "a/birbank_docs/POS-system statement\n.pdf" "b/birbank_docs/POS-system statement\n.pdf" new file mode 100644 index 0000000..758af3f Binary files /dev/null and "b/birbank_docs/POS-system statement\n.pdf" differ diff --git a/birbank_docs/Refresh.pdf b/birbank_docs/Refresh.pdf new file mode 100644 index 0000000..a7a288d Binary files /dev/null and b/birbank_docs/Refresh.pdf differ diff --git a/birbank_docs/Replenishment status.pdf b/birbank_docs/Replenishment status.pdf new file mode 100644 index 0000000..04f3732 Binary files /dev/null and b/birbank_docs/Replenishment status.pdf differ diff --git "a/birbank_docs/SWIFT codes list\n.pdf" "b/birbank_docs/SWIFT codes list\n.pdf" new file mode 100644 index 0000000..ea777f6 Binary files /dev/null and "b/birbank_docs/SWIFT codes list\n.pdf" differ diff --git a/birbank_docs/Salary cards orders.pdf b/birbank_docs/Salary cards orders.pdf new file mode 100644 index 0000000..7b251ec Binary files /dev/null and b/birbank_docs/Salary cards orders.pdf differ diff --git "a/birbank_docs/Salary cards replenishment\n.pdf" "b/birbank_docs/Salary cards replenishment\n.pdf" new file mode 100644 index 0000000..64503a3 Binary files /dev/null and "b/birbank_docs/Salary cards replenishment\n.pdf" differ diff --git a/birbank_docs/Status of order.pdf b/birbank_docs/Status of order.pdf new file mode 100644 index 0000000..f775e93 Binary files /dev/null and b/birbank_docs/Status of order.pdf differ diff --git a/birbank_docs/Transfer cancelation.pdf b/birbank_docs/Transfer cancelation.pdf new file mode 100644 index 0000000..d264055 Binary files /dev/null and b/birbank_docs/Transfer cancelation.pdf differ diff --git a/birbank_docs/Transfer from account to business card.pdf b/birbank_docs/Transfer from account to business card.pdf new file mode 100644 index 0000000..2d8b92c Binary files /dev/null and b/birbank_docs/Transfer from account to business card.pdf differ diff --git a/birbank_docs/Transfer status.pdf b/birbank_docs/Transfer status.pdf new file mode 100644 index 0000000..ce3287b Binary files /dev/null and b/birbank_docs/Transfer status.pdf differ diff --git a/kapital_bank/api.py b/kapital_bank/api.py new file mode 100644 index 0000000..a16b8ee --- /dev/null +++ b/kapital_bank/api.py @@ -0,0 +1,79 @@ +import frappe +import requests +from kapital_bank.auth import refresh_token, get_default_login, BROWSER_HEADERS + +BASE_URL = "https://my.birbank.business/api/b2b" + + +class BIRBankClient: + """HTTP client for BIRBank B2B API with automatic token refresh on 401.""" + + def __init__(self, login_name=None): + if not login_name: + login_name = get_default_login() + if not login_name: + frappe.throw("No authenticated Kapital Bank Login found") + self.login_name = login_name + self._load_token() + + def _load_token(self): + doc = frappe.get_doc("Kapital Bank Login", self.login_name) + self.token = doc.get_password("jwt_token") if doc.jwt_token else "" + + def _headers(self): + return {**BROWSER_HEADERS, "Authorization": f"Bearer {self.token}"} + + def _request(self, method, path, **kwargs): + url = f"{BASE_URL}{path}" + kwargs.setdefault("timeout", 30) + + resp = requests.request(method, url, headers=self._headers(), **kwargs) + + if resp.status_code == 401: + # Try refreshing once + frappe.logger().info(f"[KB] 401 on {path}, refreshing token for {self.login_name}") + self.token = refresh_token(self.login_name) + resp = requests.request(method, url, headers=self._headers(), **kwargs) + + if not resp.ok: + frappe.log_error( + f"[KB] {method} {url} → HTTP {resp.status_code}\nResponse body: {resp.text[:2000]}", + "KB API Response Error" + ) + + return resp + + def get(self, path, params=None): + return self._request("GET", path, params=params) + + def post(self, path, json=None, data=None, files=None, headers_override=None): + if files: + # For multipart requests, don't set Content-Type (requests sets it automatically) + hdrs = {k: v for k, v in BROWSER_HEADERS.items() if k != "Content-Type"} + hdrs["Authorization"] = f"Bearer {self.token}" + url = f"{BASE_URL}{path}" + resp = requests.post(url, headers=hdrs, data=data, files=files, timeout=30) + if resp.status_code == 401: + self.token = refresh_token(self.login_name) + hdrs["Authorization"] = f"Bearer {self.token}" + resp = requests.post(url, headers=hdrs, data=data, files=files, timeout=30) + if not resp.ok: + frappe.log_error( + f"[KB] POST {url} (multipart) → HTTP {resp.status_code}\nResponse body: {resp.text[:2000]}", + "KB API Response Error" + ) + return resp + return self._request("POST", path, json=json) + + def delete(self, path, params=None): + return self._request("DELETE", path, params=params) + + def get_json(self, path, params=None): + resp = self.get(path, params=params) + resp.raise_for_status() + return resp.json() + + def post_json(self, path, json=None): + resp = self.post(path, json=json) + resp.raise_for_status() + return resp.json() diff --git a/kapital_bank/auth.py b/kapital_bank/auth.py new file mode 100644 index 0000000..19e37fb --- /dev/null +++ b/kapital_bank/auth.py @@ -0,0 +1,196 @@ +import frappe +import requests +import json +from frappe.utils import now_datetime + +BASE_URL = "https://my.birbank.business/api/b2b" +LOGIN_URL = f"{BASE_URL}/login" +REFRESH_URL = f"{BASE_URL}/refresh" + +BROWSER_HEADERS = { + "Content-Type": "application/json", + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "Accept": "application/json, text/plain, */*", + "Accept-Language": "en-US,en;q=0.9", + "Origin": "https://my.birbank.business", + "Referer": "https://my.birbank.business/", +} + + +def _get_doc(login_name): + return frappe.get_doc("Kapital Bank Login", login_name) + + +@frappe.whitelist() +def login(login_name): + """POST /api/b2b/login — get JWT token and refresh token.""" + doc = _get_doc(login_name) + password = doc.get_password("password") + + payload = { + "username": doc.username, + "password": password + } + + try: + resp = requests.post(LOGIN_URL, json=payload, headers=BROWSER_HEADERS, timeout=15) + except requests.RequestException as e: + frappe.log_error(f"Network error during login for {login_name}: {e}", "KB Login Error") + return {"success": False, "message": f"Network error: {e}"} + + if resp.status_code == 200: + try: + data = resp.json() + except Exception: + frappe.log_error(f"Invalid JSON response: {resp.text[:500]}", "KB Login Error") + return {"success": False, "message": "Invalid response from server"} + + resp_data = data.get("responseData", {}) + code = data.get("response", {}).get("code", "") + + if code != "0": + msg = data.get("response", {}).get("message", "Login failed") + frappe.log_error(f"Login failed for {login_name}: code={code} msg={msg}", "KB Login Error") + frappe.db.set_value("Kapital Bank Login", login_name, "auth_status", "Failed", update_modified=False) + frappe.db.commit() + return {"success": False, "message": msg} + + jwt_token = resp_data.get("jwttoken", "") + jwt_refresh = resp_data.get("jwtrefreshtoken", "") + + user_info = resp_data.get("userInfo", {}) + name_val = f"{user_info.get('name', '')} {user_info.get('surname', '')}".strip() + chat_data = user_info.get("chatData", {}).get("clientData", {}) + company = chat_data.get("company", "") + cif = chat_data.get("cif", "") + + frappe.db.set_value("Kapital Bank Login", login_name, { + "jwt_token": jwt_token, + "jwt_refresh_token": jwt_refresh, + "auth_status": "Authenticated", + "last_activity": now_datetime(), + "user_info": name_val, + "company_name": company, + "cif": cif, + }, update_modified=False) + frappe.db.commit() + + return {"success": True, "message": "Login successful"} + + elif resp.status_code == 401: + frappe.db.set_value("Kapital Bank Login", login_name, "auth_status", "Failed", update_modified=False) + frappe.db.commit() + return {"success": False, "message": "Invalid credentials (401)"} + + else: + frappe.log_error( + f"Login failed for {login_name}: HTTP {resp.status_code} — {resp.text[:300]}", + "KB Login Error" + ) + frappe.db.set_value("Kapital Bank Login", login_name, "auth_status", "Failed", update_modified=False) + frappe.db.commit() + return {"success": False, "message": f"Server error: HTTP {resp.status_code}"} + + +@frappe.whitelist() +def logout(login_name): + """Clear tokens and mark as Not Authenticated.""" + frappe.db.set_value("Kapital Bank Login", login_name, { + "jwt_token": "", + "jwt_refresh_token": "", + "auth_status": "Not Authenticated", + "user_info": "", + "company_name": "", + "cif": "", + }, update_modified=False) + frappe.db.commit() + return {"success": True, "message": "Logged out"} + + +def refresh_token(login_name): + """POST /api/b2b/refresh — get new JWT using refresh token. Returns new jwt_token or raises.""" + doc = _get_doc(login_name) + refresh_tok = doc.get_password("jwt_refresh_token") if doc.jwt_refresh_token else "" + + if not refresh_tok: + frappe.log_error(f"No refresh token for {login_name}", "KB Refresh Error") + frappe.db.set_value("Kapital Bank Login", login_name, "auth_status", "Not Authenticated", update_modified=False) + frappe.db.commit() + raise frappe.AuthenticationError("No refresh token available") + + payload = {"refreshToken": refresh_tok} + + try: + resp = requests.post(REFRESH_URL, json=payload, headers=BROWSER_HEADERS, timeout=15) + except requests.RequestException as e: + frappe.log_error(f"Network error during refresh for {login_name}: {e}", "KB Refresh Error") + raise + + if resp.status_code == 200: + try: + data = resp.json() + except Exception: + frappe.log_error(f"Invalid JSON in refresh response: {resp.text[:300]}", "KB Refresh Error") + raise frappe.ValidationError("Invalid refresh response") + + resp_code = data.get("response", {}).get("code", "") + if resp_code == "100": + frappe.db.set_value("Kapital Bank Login", login_name, "auth_status", "Not Authenticated", update_modified=False) + frappe.db.commit() + raise frappe.AuthenticationError("Refresh token invalid (code 100)") + + resp_data = data.get("responseData", {}) + new_jwt = resp_data.get("jwttoken", "") + new_refresh = resp_data.get("jwtrefreshtoken", "") + + frappe.db.set_value("Kapital Bank Login", login_name, { + "jwt_token": new_jwt, + "jwt_refresh_token": new_refresh, + "auth_status": "Authenticated", + "last_activity": now_datetime(), + }, update_modified=False) + frappe.db.commit() + + return new_jwt + + elif resp.status_code == 401: + frappe.db.set_value("Kapital Bank Login", login_name, "auth_status", "Not Authenticated", update_modified=False) + frappe.db.commit() + raise frappe.AuthenticationError("Refresh token expired (401)") + + else: + frappe.log_error( + f"Refresh failed for {login_name}: HTTP {resp.status_code} — {resp.text[:300]}", + "KB Refresh Error" + ) + raise frappe.ValidationError(f"Refresh failed: HTTP {resp.status_code}") + + +def get_default_login(): + """Return the default Kapital Bank Login document name, or None.""" + rows = frappe.get_all("Kapital Bank Login", filters={"is_default": 1}, fields=["name"], limit=1) + if not rows: + rows = frappe.get_all("Kapital Bank Login", filters={"auth_status": "Authenticated"}, fields=["name"], limit=1) + return rows[0].name if rows else None + + +@frappe.whitelist() +def renew_token(login_name=None): + """Cron entry — refresh token for the default (or specified) login.""" + try: + if not login_name: + login_name = get_default_login() + if not login_name: + return {"success": False, "message": "No default Kapital Bank Login found"} + + doc = _get_doc(login_name) + if doc.auth_status != "Authenticated": + return {"success": False, "message": f"Login {login_name} is not authenticated"} + + new_token = refresh_token(login_name) + frappe.logger().info(f"[KB] Token renewed for {login_name}") + return {"success": True, "message": "Token renewed"} + + except Exception as e: + frappe.log_error(f"renew_token failed: {e}\n{frappe.get_traceback()}", "KB Token Renewal") + return {"success": False, "message": str(e)} diff --git a/kapital_bank/bank_api.py b/kapital_bank/bank_api.py new file mode 100644 index 0000000..fa65a7a --- /dev/null +++ b/kapital_bank/bank_api.py @@ -0,0 +1,643 @@ +import re +import frappe +from frappe.utils import cint, flt +from difflib import SequenceMatcher +from kapital_bank.api import BIRBankClient + + +def _mask(text): + """Mask digit sequences longer than 6 chars: keep first 3 + *** + last 3.""" + return re.sub(r'\d{7,}', lambda m: m.group()[:3] + '***' + m.group()[-3:], str(text)) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# REGISTRY LOADERS (fetch from BIRBank → save to local DocTypes) +# ═══════════════════════════════════════════════════════════════════════════════ + +@frappe.whitelist() +def load_accounts_to_registry(login_name=None): + """Fetch accounts from GET /accounts and upsert into Kapital Bank Account registry.""" + try: + client = BIRBankClient(login_name) + data = client.get_json("/accounts") + accounts = data.get("responseData", {}).get("accountsList", []) + + created = updated = 0 + for a in accounts: + iban = a.get("ibanAcNo", "").strip() + if not iban: + continue + + values = { + "cust_ac_no": a.get("custAcNo", ""), + "currency": a.get("ccy", ""), + "account_desc": a.get("acDesc", ""), + "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)), + } + + if frappe.db.exists("Kapital Bank Account", iban): + frappe.db.set_value("Kapital Bank Account", iban, values, update_modified=False) + updated += 1 + else: + doc = frappe.new_doc("Kapital Bank Account") + doc.iban = iban + doc.update(values) + doc.insert(ignore_permissions=True) + created += 1 + + frappe.db.commit() + return {"success": True, "created": created, "updated": updated, "total": len(accounts)} + + except Exception as e: + frappe.log_error(f"load_accounts_to_registry failed: {e}\n{frappe.get_traceback()}", "KB Bank API") + return {"success": False, "message": str(e)} + + +@frappe.whitelist() +def load_cards_to_registry(login_name=None): + """Fetch cards from GET /cards and upsert into Kapital Bank Card registry.""" + try: + client = BIRBankClient(login_name) + data = client.get_json("/cards") + cards = data.get("responseData", {}).get("cards", []) + + created = updated = 0 + for c in cards: + account_number = str(c.get("accountNumber", "")).strip() + if not account_number: + continue + + values = { + "card_type": c.get("cardType", ""), + "pan": c.get("pan", ""), + "currency": c.get("currency", ""), + "balance": flt(c.get("balance", 0)), + "expiry_date": str(c.get("expiryDate", "")), + } + + if frappe.db.exists("Kapital Bank Card", account_number): + frappe.db.set_value("Kapital Bank Card", account_number, values, update_modified=False) + updated += 1 + else: + doc = frappe.new_doc("Kapital Bank Card") + doc.account_number = account_number + doc.update(values) + doc.insert(ignore_permissions=True) + created += 1 + + frappe.db.commit() + return {"success": True, "created": created, "updated": updated, "total": len(cards)} + + except Exception as e: + frappe.log_error(f"load_cards_to_registry failed: {e}\n{frappe.get_traceback()}", "KB Bank API") + return {"success": False, "message": str(e)} + + +@frappe.whitelist() +def load_clients_from_statements(from_date, to_date, login_name=None): + """ + Fetch account statements for all registered accounts and extract counterparties + (contrAccount field) into the Kapital Bank Client registry. + Analogous to invoice_az loading customers/suppliers from invoice documents. + """ + try: + client = BIRBankClient(login_name) + + accounts = frappe.get_all("Kapital Bank Account", fields=["iban", "cust_ac_no"]) + if not accounts: + return {"success": False, "message": "No accounts in registry. Load accounts first."} + + created = skipped = 0 + debug_log = [] + + for acc in accounts: + iban = acc.iban + # API expects the numeric account number (custAcNo), not the full IBAN + account_number = acc.cust_ac_no or iban + debug_log.append(f"Account: {_mask(iban)} custAcNo={_mask(account_number)} ({from_date} → {to_date})") + + try: + resp = client.get( + "/v2/statement/account", + params={"accountNumber": account_number, "fromDate": _fmt_date(from_date), "toDate": _fmt_date(to_date)} + ) + debug_log.append(f" HTTP {resp.status_code}") + if not resp.ok: + debug_log.append(f" Response body: {_mask(resp.text[:300])}") + continue + data = resp.json() + except Exception as e: + debug_log.append(f" Error: {e}") + frappe.log_error(f"Statement fetch failed for {_mask(iban)}: {e}", "KB Load Clients") + 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", []) + ) + debug_log.append(f" statementList entries: {len(statement_list)}") + + # Log first 3 entries to understand the real contrAccount format + if statement_list: + debug_log.append(f" First entry keys: {list(statement_list[0].keys())}") + for i, e in enumerate(statement_list[:3]): + ca = _mask(e.get('contrAccount', 'N/A')) + pu = e.get('purpose', '')[:60] + debug_log.append(f" Entry[{i}] contrAccount={ca!r} purpose={pu!r}") + + for entry in statement_list: + contr_raw = (entry.get("contrAccount") or "").strip() + if not contr_raw: + continue + + # contrAccount format: "IBAN / COMPANY NAME / VÖEN" + # e.g. "AZ12AIIB400...000 / TEST COMPANY LLC / 4800123456" + parts = [p.strip() for p in contr_raw.split(" / ")] + if len(parts) < 2: + continue # unrecognised format, skip + + contr_name = parts[1] + contr_voen = parts[2] if len(parts) >= 3 else "" + + if not contr_name: + continue + + # Deduplicate: by VÖEN if present, else by client_name + if contr_voen and frappe.db.exists("Kapital Bank Client", {"tax_id": contr_voen}): + skipped += 1 + continue + if not contr_voen and frappe.db.exists("Kapital Bank Client", {"client_name": contr_name}): + skipped += 1 + continue + + doc = frappe.new_doc("Kapital Bank Client") + doc.client_name = contr_name + doc.tax_id = contr_voen or None # NULL doesn't violate unique constraint + doc.status = "New" + try: + doc.insert(ignore_permissions=True) + created += 1 + except frappe.DuplicateEntryError: + skipped += 1 + except Exception as ins_e: + frappe.log_error(f"Insert client failed for {_mask(contr_raw)}: {ins_e}", "KB Load Clients") + + frappe.db.commit() + + # Always log the debug info so we can diagnose issues + frappe.log_error("\n".join(debug_log), "KB Load Clients Debug") + + return {"success": True, "created": created, "skipped": skipped, "debug": debug_log} + + except Exception as e: + frappe.log_error(f"load_clients_from_statements: {e}\n{frappe.get_traceback()}", "KB Bank API") + return {"success": False, "message": str(e)} + + +# ═══════════════════════════════════════════════════════════════════════════════ +# REFERENCE DATA SUMMARY & LISTS (for Settings data tabs) +# ═══════════════════════════════════════════════════════════════════════════════ + +@frappe.whitelist() +def get_kb_reference_data_summary(): + """Return counts for the Load Data dialog summary.""" + try: + def counts(doctype, mapped_filter): + total = frappe.db.count(doctype) + mapped = frappe.db.count(doctype, filters=mapped_filter) + return {"total": total, "mapped": mapped} + + summary = { + "accounts": counts("Kapital Bank Account", {"status": "Mapped"}), + "cards": counts("Kapital Bank Card", {"status": "Mapped"}), + "clients": counts("Kapital Bank Client", {"status": ["in", ["Mapped", "Active"]]}), + } + return {"success": True, "summary": summary} + except Exception as e: + frappe.log_error(str(e), "KB Reference Data Summary") + return {"success": False, "message": str(e)} + + +@frappe.whitelist() +def get_kb_reference_data_list(data_type, limit=100, offset=0): + """Return paginated list of registry records for HTML tab rendering.""" + limit = cint(limit) + offset = cint(offset) + + try: + if data_type == "accounts": + total = frappe.db.count("Kapital Bank Account") + data = frappe.get_all( + "Kapital Bank Account", + fields=["name", "iban", "currency", "current_balance", "planned_balance", + "account_status", "status", "bank_account"], + limit=limit, start=offset, + order_by="iban asc" + ) + + elif data_type == "cards": + total = frappe.db.count("Kapital Bank Card") + data = frappe.get_all( + "Kapital Bank Card", + fields=["name", "account_number", "card_type", "pan", "currency", + "balance", "expiry_date", "status", "bank_account"], + limit=limit, start=offset, + order_by="card_type asc" + ) + + elif data_type == "clients": + total = frappe.db.count("Kapital Bank Client") + data = frappe.get_all( + "Kapital Bank Client", + fields=["name", "client_name", "tax_id", "iban", "bank_code", + "status", "mapped_party_type", "mapped_party", "creation"], + limit=limit, start=offset, + order_by="client_name asc" + ) + + else: + return {"success": False, "message": f"Unknown data_type: {data_type}"} + + return { + "success": True, + "data": data, + "total_count": total, + "has_more": (offset + limit) < total + } + + except Exception as e: + frappe.log_error(f"get_kb_reference_data_list({data_type}): {e}\n{frappe.get_traceback()}", "KB Bank API") + return {"success": False, "message": str(e)} + + +# ═══════════════════════════════════════════════════════════════════════════════ +# UNMAPPED GETTERS (for "Add unmapped" buttons) +# ═══════════════════════════════════════════════════════════════════════════════ + +@frappe.whitelist() +def get_unmapped_accounts(): + """Return accounts not yet present in account_mappings child table.""" + try: + settings = frappe.get_single("Kapital Bank Settings") + already_mapped = {row.iban for row in settings.account_mappings if row.iban} + + all_accounts = frappe.get_all( + "Kapital Bank Account", + fields=["name", "iban", "account_desc", "currency", "current_balance", "account_status"] + ) + unmapped = [a for a in all_accounts if a.iban not in already_mapped] + return {"success": True, "accounts": unmapped} + + except Exception as e: + frappe.log_error(str(e), "KB get_unmapped_accounts") + return {"success": False, "message": str(e)} + + +@frappe.whitelist() +def get_unmapped_clients(): + """Return Kapital Bank Clients with status='New' not yet in client_mappings.""" + try: + settings = frappe.get_single("Kapital Bank Settings") + already_mapped = {row.client_name for row in settings.client_mappings if row.client_name} + + clients = frappe.get_all( + "Kapital Bank Client", + filters={"status": "New"}, + fields=["name", "client_name", "tax_id", "iban", "mapped_party_type", "mapped_party"] + ) + unmapped = [c for c in clients if c.client_name not in already_mapped] + return {"success": True, "clients": unmapped} + + except Exception as e: + frappe.log_error(str(e), "KB get_unmapped_clients") + return {"success": False, "message": str(e)} + + +# ═══════════════════════════════════════════════════════════════════════════════ +# CLIENT MATCHING & CREATION +# ═══════════════════════════════════════════════════════════════════════════════ + +@frappe.whitelist() +def match_similar_clients(): + """Auto-match Kapital Bank Clients to ERPNext Customer/Supplier by name similarity. + Follows invoice_az pattern exactly.""" + try: + doc = frappe.get_doc("Kapital Bank Settings") + threshold = flt(doc.similarity_threshold or 95) / 100.0 + consider_azeri = bool(doc.consider_azeri_chars) + + new_clients = frappe.get_all( + "Kapital Bank Client", + filters={"status": "New"}, + fields=["name", "client_name", "tax_id"] + ) + if not new_clients: + return {"success": True, "matched_count": 0, "total_processed": 0} + + customers = frappe.get_all("Customer", fields=["name", "customer_name", "tax_id"]) + suppliers = frappe.get_all("Supplier", fields=["name", "supplier_name", "tax_id"]) + + # VÖEN lookup: tax_id → (party_name, party_type) for exact matching + voen_index = {} + for c in customers: + if c.tax_id: + voen_index[c.tax_id.strip()] = (c.name, "Customer") + for s in suppliers: + if s.tax_id: + voen_index[s.tax_id.strip()] = (s.name, "Supplier") + + # Build dict of existing mappings: client_name → erp_party + existing_mappings = { + row.client_name: row.erp_party + for row in doc.client_mappings if row.client_name + } + + # Group 1: clients already in the table but with empty erp_party + clients_with_empty_mapping = [] + for client_name, erp_party in existing_mappings.items(): + if not erp_party: + for c in new_clients: + if c.client_name == client_name: + clients_with_empty_mapping.append(c) + break + + # Group 2: clients not in the table at all + clients_not_in_table = [c for c in new_clients if c.client_name not in existing_mappings] + + # Process both groups (same as invoice_az) + clients_to_process = clients_not_in_table + clients_with_empty_mapping + + matched = 0 + for client in clients_to_process: + best_party = None + best_type = None + + # Priority 1: exact VÖEN match + if client.tax_id and client.tax_id.strip() in voen_index: + best_party, best_type = voen_index[client.tax_id.strip()] + else: + # Priority 2: name similarity + kb_name = _normalize(client.client_name, consider_azeri) + best_score = 0 + + for c in customers: + score = SequenceMatcher(None, kb_name, _normalize(c.customer_name, consider_azeri)).ratio() + if score > best_score: + best_score = score + best_party = c.name + best_type = "Customer" + + for s in suppliers: + score = SequenceMatcher(None, kb_name, _normalize(s.supplier_name, consider_azeri)).ratio() + if score > best_score: + best_score = score + best_party = s.name + best_type = "Supplier" + + if best_score < threshold: + best_party = None + + if best_party: + # Update existing row if present, else append new row + existing_idx = None + for idx, row in enumerate(doc.client_mappings): + if row.client_name == client.client_name: + existing_idx = idx + break + + if existing_idx is not None: + doc.client_mappings[existing_idx].party_type = best_type + doc.client_mappings[existing_idx].erp_party = best_party + doc.client_mappings[existing_idx].mapping_type = "Automatic" + else: + doc.append("client_mappings", { + "client_name": client.client_name, + "tax_id": client.tax_id or None, + "party_type": best_type, + "erp_party": best_party, + "mapping_type": "Automatic", + }) + + frappe.db.set_value("Kapital Bank Client", client.name, { + "mapped_party_type": best_type, + "mapped_party": best_party, + "status": "Mapped", + }, update_modified=False) + matched += 1 + + if matched > 0: + doc.save(ignore_permissions=True) + + frappe.db.commit() + return {"success": True, "matched_count": matched, "total_processed": len(clients_to_process)} + + except Exception as e: + frappe.log_error(f"match_similar_clients: {e}\n{frappe.get_traceback()}", "KB Client Matching") + return {"success": False, "message": str(e)} + + +@frappe.whitelist() +def create_unmapped_clients(): + """Create Customer/Supplier records for New Kapital Bank Clients that have no mapped party. + Follows invoice_az pattern: also appends created rows to Settings.client_mappings.""" + try: + clients = frappe.get_all( + "Kapital Bank Client", + filters={"status": "New"}, + fields=["name", "client_name", "tax_id", "mapped_party_type"] + ) + + doc = frappe.get_doc("Kapital Bank Settings") + already_in_mappings = {row.client_name for row in doc.client_mappings if row.client_name} + + created = customers_count = suppliers_count = 0 + + for c in clients: + if not c.mapped_party_type: + continue + try: + if c.mapped_party_type == "Customer": + party = frappe.new_doc("Customer") + party.customer_name = c.client_name + if doc.default_customer_group: + party.customer_group = doc.default_customer_group + if doc.default_territory: + party.territory = doc.default_territory + if doc.default_payment_terms: + party.payment_terms = doc.default_payment_terms + party.insert(ignore_permissions=True) + customers_count += 1 + elif c.mapped_party_type == "Supplier": + party = frappe.new_doc("Supplier") + party.supplier_name = c.client_name + if doc.default_supplier_group: + party.supplier_group = doc.default_supplier_group + if doc.default_payment_terms: + party.payment_terms = doc.default_payment_terms + party.insert(ignore_permissions=True) + suppliers_count += 1 + else: + continue + + frappe.db.set_value("Kapital Bank Client", c.name, { + "mapped_party_type": c.mapped_party_type, + "mapped_party": party.name, + "status": "Mapped", + }, update_modified=False) + + if c.client_name not in already_in_mappings: + doc.append("client_mappings", { + "client_name": c.client_name, + "tax_id": c.tax_id or None, + "party_type": c.mapped_party_type, + "erp_party": party.name, + "mapping_type": "Manual", + }) + already_in_mappings.add(c.client_name) + + created += 1 + + except frappe.DuplicateEntryError: + existing = None + if c.mapped_party_type == "Customer": + existing = frappe.db.get_value("Customer", {"customer_name": c.client_name}, "name") + elif c.mapped_party_type == "Supplier": + existing = frappe.db.get_value("Supplier", {"supplier_name": c.client_name}, "name") + + if existing: + frappe.db.set_value("Kapital Bank Client", c.name, { + "mapped_party": existing, + "status": "Mapped", + }, update_modified=False) + if c.client_name not in already_in_mappings: + doc.append("client_mappings", { + "client_name": c.client_name, + "tax_id": c.tax_id or None, + "party_type": c.mapped_party_type, + "erp_party": existing, + "mapping_type": "Manual", + }) + already_in_mappings.add(c.client_name) + + except Exception as inner_e: + frappe.log_error(f"create_unmapped_clients: error for {c.name}: {inner_e}", "KB Client Creation") + + if created > 0: + doc.save(ignore_permissions=True) + + frappe.db.commit() + return { + "success": True, + "created_count": created, + "customers_count": customers_count, + "suppliers_count": suppliers_count, + } + + except Exception as e: + frappe.log_error(f"create_unmapped_clients: {e}\n{frappe.get_traceback()}", "KB Client Creation") + return {"success": False, "message": str(e)} + + +# ═══════════════════════════════════════════════════════════════════════════════ +# RAW API ACCESSORS (for reports / manual use) +# ═══════════════════════════════════════════════════════════════════════════════ + +@frappe.whitelist() +def get_accounts(login_name=None): + """GET /api/b2b/accounts — raw account list (not saved to registry).""" + try: + client = BIRBankClient(login_name) + data = client.get_json("/accounts") + accounts = data.get("responseData", {}).get("accountsList", []) + return {"success": True, "accounts": accounts} + except Exception as e: + frappe.log_error(f"get_accounts failed: {e}\n{frappe.get_traceback()}", "KB Bank API") + return {"success": False, "message": str(e)} + + +@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.""" + 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} + except Exception as e: + frappe.log_error(f"get_account_statement failed: {e}\n{frappe.get_traceback()}", "KB Bank API") + return {"success": False, "message": str(e)} + + +@frappe.whitelist() +def get_cards(login_name=None): + """GET /api/b2b/cards — raw card list (not saved to registry).""" + try: + client = BIRBankClient(login_name) + data = client.get_json("/cards") + cards = data.get("responseData", {}).get("cards", []) + return {"success": True, "cards": cards} + except Exception as e: + frappe.log_error(f"get_cards failed: {e}\n{frappe.get_traceback()}", "KB Bank API") + return {"success": False, "message": str(e)} + + +@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.""" + 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} + except Exception as e: + frappe.log_error(f"get_card_statement failed: {e}\n{frappe.get_traceback()}", "KB Bank API") + return {"success": False, "message": str(e)} + + +# ═══════════════════════════════════════════════════════════════════════════════ +# INTERNAL HELPERS +# ═══════════════════════════════════════════════════════════════════════════════ + +_AZERI_MAP = str.maketrans("ƏəÜüÖöĞğİıÇ窺", "EeUuOoGgIiCcSs") + +def _normalize(text, consider_azeri=True): + """Normalize text for similarity comparison. + Optionally replaces Azerbaijani characters before lowercasing.""" + if not text: + return "" + s = str(text) + if consider_azeri: + s = s.translate(_AZERI_MAP) + 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 + """ + 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) diff --git a/kapital_bank/client/payment_entry.js b/kapital_bank/client/payment_entry.js new file mode 100644 index 0000000..694a056 --- /dev/null +++ b/kapital_bank/client/payment_entry.js @@ -0,0 +1,72 @@ +frappe.ui.form.on('Payment Entry', { + refresh(frm) { + if (frm.doc.docstatus !== 1) return; + + const kb_status = frm.doc.kb_transfer_status; + + frm.add_custom_button(__('Send Transfer'), () => { + kb_send(frm, 'kapital_bank.payment_api.send_transfer', __('Transfer sent')); + }, __('Kapital Bank')); + + frm.add_custom_button(__('Send Card Transfer'), () => { + kb_send(frm, 'kapital_bank.payment_api.send_card_transfer', __('Card transfer sent')); + }, __('Kapital Bank')); + + frm.add_custom_button(__('Send VAT Payment'), () => { + kb_send(frm, 'kapital_bank.payment_api.send_vat_payment', __('VAT payment sent')); + }, __('Kapital Bank')); + + frm.add_custom_button(__('Check Status'), () => { + frappe.call({ + method: 'kapital_bank.payment_api.check_and_update_status', + args: { payment_entry_name: frm.doc.name }, + freeze: true, + freeze_message: __('Checking status...'), + callback(r) { + if (r.message && r.message.success) { + frappe.show_alert({ + message: __('Status: {0}', [r.message.status]), + indicator: 'blue' + }, 5); + frm.reload_doc(); + } else { + frappe.msgprint({ + title: __('Error'), + message: r.message ? r.message.message : __('Unknown error'), + indicator: 'red' + }); + } + } + }); + }, __('Kapital Bank')); + + // Show KB status badge if present + if (kb_status) { + const color = kb_status === 'Confirmed' ? 'green' + : kb_status === 'Cancelled' ? 'red' + : 'blue'; + frm.dashboard.add_indicator(__('KB: {0}', [kb_status]), color); + } + } +}); + +function kb_send(frm, method, success_msg) { + frappe.call({ + method: method, + args: { payment_entry_name: frm.doc.name }, + freeze: true, + freeze_message: __('Sending to BIRBank...'), + callback(r) { + if (r.message && r.message.success) { + frappe.show_alert({ message: success_msg, indicator: 'green' }, 5); + frm.reload_doc(); + } else { + frappe.msgprint({ + title: __('BIRBank Error'), + message: r.message ? r.message.message : __('Unknown error'), + indicator: 'red' + }); + } + } + }); +} diff --git a/kapital_bank/hooks.py b/kapital_bank/hooks.py index 43e64a8..0d38b45 100644 --- a/kapital_bank/hooks.py +++ b/kapital_bank/hooks.py @@ -5,6 +5,29 @@ app_description = "Kapital Bank Integration" app_email = "info@jeyerp.az" app_license = "unlicense" +# Installation hooks +after_install = "kapital_bank.setup.after_install" +after_migrate = "kapital_bank.setup.after_migrate" + +# Document Events +doc_events = { + "Payment Entry": { + "on_cancel": "kapital_bank.payment_api.on_cancel_payment_entry" + } +} + +# Scheduled Tasks (refresh JWT every 4 minutes) +scheduler_events = { + "cron": { + "*/4 * * * *": ["kapital_bank.auth.renew_token"] + } +} + +# JS for ERPNext forms +doctype_js = { + "Payment Entry": "kapital_bank/client/payment_entry.js" +} + # Apps # ------------------ diff --git a/kapital_bank/kapital_bank/doctype/__init__.py b/kapital_bank/kapital_bank/doctype/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/kapital_bank/kapital_bank/doctype/kapital_bank_account/__init__.py b/kapital_bank/kapital_bank/doctype/kapital_bank_account/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/kapital_bank/kapital_bank/doctype/kapital_bank_account/kapital_bank_account.json b/kapital_bank/kapital_bank/doctype/kapital_bank_account/kapital_bank_account.json new file mode 100644 index 0000000..5bb3576 --- /dev/null +++ b/kapital_bank/kapital_bank/doctype/kapital_bank_account/kapital_bank_account.json @@ -0,0 +1,145 @@ +{ + "actions": [], + "allow_rename": 0, + "autoname": "field:iban", + "creation": "2026-02-24 00:00:00.000000", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "iban", + "cust_ac_no", + "currency", + "account_desc", + "account_status", + "branch_code", + "column_break_1", + "current_balance", + "planned_balance", + "hold", + "mapping_section", + "status", + "bank_account", + "gl_account" + ], + "fields": [ + { + "fieldname": "iban", + "fieldtype": "Data", + "in_list_view": 1, + "label": "IBAN", + "read_only": 1, + "reqd": 1, + "unique": 1 + }, + { + "fieldname": "cust_ac_no", + "fieldtype": "Data", + "label": "Account Number", + "read_only": 1 + }, + { + "fieldname": "currency", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Currency", + "read_only": 1 + }, + { + "fieldname": "account_desc", + "fieldtype": "Data", + "label": "Account Description", + "read_only": 1 + }, + { + "fieldname": "account_status", + "fieldtype": "Data", + "label": "Account Status", + "read_only": 1 + }, + { + "fieldname": "branch_code", + "fieldtype": "Data", + "label": "Branch Code", + "read_only": 1 + }, + { + "fieldname": "column_break_1", + "fieldtype": "Column Break" + }, + { + "fieldname": "current_balance", + "fieldtype": "Float", + "in_list_view": 1, + "label": "Current Balance", + "read_only": 1 + }, + { + "fieldname": "planned_balance", + "fieldtype": "Float", + "label": "Planned Balance", + "read_only": 1 + }, + { + "fieldname": "hold", + "fieldtype": "Float", + "label": "Hold (Blocked)", + "read_only": 1 + }, + { + "fieldname": "mapping_section", + "fieldtype": "Section Break", + "label": "ERP Mapping" + }, + { + "default": "New", + "fieldname": "status", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Status", + "options": "New\nMapped", + "read_only": 1 + }, + { + "fieldname": "bank_account", + "fieldtype": "Link", + "label": "Bank Account", + "options": "Bank Account", + "read_only": 1 + }, + { + "fieldname": "gl_account", + "fieldtype": "Link", + "label": "GL Account", + "options": "Account", + "read_only": 1 + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2026-02-24 00:00:00.000000", + "modified_by": "Administrator", + "module": "Kapital Bank", + "name": "Kapital Bank Account", + "naming_rule": "By fieldname", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "row_format": "Dynamic", + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "title_field": "iban", + "track_changes": 1 +} diff --git a/kapital_bank/kapital_bank/doctype/kapital_bank_account/kapital_bank_account.py b/kapital_bank/kapital_bank/doctype/kapital_bank_account/kapital_bank_account.py new file mode 100644 index 0000000..462b833 --- /dev/null +++ b/kapital_bank/kapital_bank/doctype/kapital_bank_account/kapital_bank_account.py @@ -0,0 +1,6 @@ +import frappe +from frappe.model.document import Document + + +class KapitalBankAccount(Document): + pass diff --git a/kapital_bank/kapital_bank/doctype/kapital_bank_account_mapping/__init__.py b/kapital_bank/kapital_bank/doctype/kapital_bank_account_mapping/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/kapital_bank/kapital_bank/doctype/kapital_bank_account_mapping/kapital_bank_account_mapping.json b/kapital_bank/kapital_bank/doctype/kapital_bank_account_mapping/kapital_bank_account_mapping.json new file mode 100644 index 0000000..f2dc9f7 --- /dev/null +++ b/kapital_bank/kapital_bank/doctype/kapital_bank_account_mapping/kapital_bank_account_mapping.json @@ -0,0 +1,59 @@ +{ + "actions": [], + "creation": "2026-02-24 00:00:00.000000", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "iban", + "account_label", + "currency", + "bank_account", + "gl_account" + ], + "fields": [ + { + "fieldname": "iban", + "fieldtype": "Data", + "in_list_view": 1, + "label": "IBAN", + "reqd": 1 + }, + { + "fieldname": "account_label", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Account Label" + }, + { + "fieldname": "currency", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Currency", + "options": "Currency" + }, + { + "fieldname": "bank_account", + "fieldtype": "Link", + "label": "Bank Account", + "options": "Bank Account" + }, + { + "fieldname": "gl_account", + "fieldtype": "Link", + "label": "GL Account", + "options": "Account" + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2026-02-24 00:00:00.000000", + "modified_by": "Administrator", + "module": "Kapital Bank", + "name": "Kapital Bank Account Mapping", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} diff --git a/kapital_bank/kapital_bank/doctype/kapital_bank_account_mapping/kapital_bank_account_mapping.py b/kapital_bank/kapital_bank/doctype/kapital_bank_account_mapping/kapital_bank_account_mapping.py new file mode 100644 index 0000000..7fa0ea7 --- /dev/null +++ b/kapital_bank/kapital_bank/doctype/kapital_bank_account_mapping/kapital_bank_account_mapping.py @@ -0,0 +1,6 @@ +import frappe +from frappe.model.document import Document + + +class KapitalBankAccountMapping(Document): + pass diff --git a/kapital_bank/kapital_bank/doctype/kapital_bank_api_test/__init__.py b/kapital_bank/kapital_bank/doctype/kapital_bank_api_test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/kapital_bank/kapital_bank/doctype/kapital_bank_api_test/kapital_bank_api_test.js b/kapital_bank/kapital_bank/doctype/kapital_bank_api_test/kapital_bank_api_test.js new file mode 100644 index 0000000..a3213ed --- /dev/null +++ b/kapital_bank/kapital_bank/doctype/kapital_bank_api_test/kapital_bank_api_test.js @@ -0,0 +1,58 @@ +const _MODULE = 'kapital_bank.kapital_bank.doctype.kapital_bank_api_test.kapital_bank_api_test'; + +frappe.ui.form.on('Kapital Bank API Test', { + refresh(frm) { + // ── Auth ──────────────────────────────────────────────────────────── + frm.add_custom_button(__('Login'), () => _call(frm, 'do_login'), __('Auth')); + frm.add_custom_button(__('Logout'), () => _call(frm, 'do_logout'), __('Auth')); + frm.add_custom_button(__('Refresh Token'), () => _call(frm, 'do_refresh_token'), __('Auth')); + + // ── Accounts ──────────────────────────────────────────────────────── + frm.add_custom_button(__('Get Accounts'), () => _call(frm, 'do_get_accounts'), __('Accounts')); + frm.add_custom_button(__('Get Account Statement'), () => _call(frm, 'do_get_account_statement'), __('Accounts')); + + // ── Cards ─────────────────────────────────────────────────────────── + frm.add_custom_button(__('Get Cards'), () => _call(frm, 'do_get_cards'), __('Cards')); + frm.add_custom_button(__('Get Card Statement'), () => _call(frm, 'do_get_card_statement'), __('Cards')); + + // ── Payments ──────────────────────────────────────────────────────── + frm.add_custom_button(__('Transfer Status'), () => _call(frm, 'do_transfer_status'), __('Payments')); + } +}); + +function _call(frm, method) { + const _execute = () => { + frappe.show_progress(__('Running...'), 0, 100, __('Calling BIRBank API...')); + frappe.call({ + method: `${_MODULE}.${method}`, + args: { docname: frm.doc.name }, + freeze: false, + callback(r) { + frappe.hide_progress(); + if (r.exc) { + frappe.msgprint({ title: __('Error'), message: r.exc, indicator: 'red' }); + return; + } + const res = r.message; + frm.reload_doc(); + const ok = res && res.status === 'Success'; + frappe.show_alert({ + message: ok + ? __('Success ({0} ms)', [res.elapsed_ms]) + : __('Failed ({0} ms)', [res ? res.elapsed_ms : 0]), + indicator: ok ? 'green' : 'red' + }, 6); + }, + error() { + frappe.hide_progress(); + frappe.msgprint({ title: __('Error'), message: __('Request failed'), indicator: 'red' }); + } + }); + }; + + if (frm.is_new() || frm.is_dirty()) { + frm.save('Save', _execute); + } else { + _execute(); + } +} diff --git a/kapital_bank/kapital_bank/doctype/kapital_bank_api_test/kapital_bank_api_test.json b/kapital_bank/kapital_bank/doctype/kapital_bank_api_test/kapital_bank_api_test.json new file mode 100644 index 0000000..448c84d --- /dev/null +++ b/kapital_bank/kapital_bank/doctype/kapital_bank_api_test/kapital_bank_api_test.json @@ -0,0 +1,141 @@ +{ + "actions": [], + "autoname": "KB-TEST-.####", + "creation": "2026-02-25 00:00:00", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "config_section", + "login_name", + "column_break_config", + "test_status", + "response_time_ms", + "request_dump_section", + "request_dump", + "params_section", + "iban", + "account_number", + "column_break_params", + "from_date", + "to_date", + "operation_name_param", + "result_section", + "result" + ], + "fields": [ + { + "fieldname": "config_section", + "fieldtype": "Section Break", + "label": "Test Configuration" + }, + { + "fieldname": "login_name", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Login", + "options": "Kapital Bank Login", + "reqd": 1 + }, + { + "fieldname": "column_break_config", + "fieldtype": "Column Break" + }, + { + "fieldname": "test_status", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Test Status", + "options": "\nSuccess\nFailed", + "read_only": 1 + }, + { + "fieldname": "response_time_ms", + "fieldtype": "Int", + "label": "Response Time (ms)", + "read_only": 1 + }, + { + "fieldname": "request_dump_section", + "fieldtype": "Section Break", + "label": "Last Request" + }, + { + "fieldname": "request_dump", + "fieldtype": "Code", + "label": "Request", + "read_only": 1 + }, + { + "fieldname": "params_section", + "fieldtype": "Section Break", + "label": "Parameters" + }, + { + "fieldname": "iban", + "fieldtype": "Data", + "label": "IBAN" + }, + { + "fieldname": "account_number", + "fieldtype": "Data", + "label": "Account Number" + }, + { + "fieldname": "column_break_params", + "fieldtype": "Column Break" + }, + { + "fieldname": "from_date", + "fieldtype": "Date", + "label": "From Date" + }, + { + "fieldname": "to_date", + "fieldtype": "Date", + "label": "To Date" + }, + { + "fieldname": "operation_name_param", + "fieldtype": "Data", + "label": "Operation Name" + }, + { + "fieldname": "result_section", + "fieldtype": "Section Break", + "label": "Result" + }, + { + "fieldname": "result", + "fieldtype": "Code", + "label": "Result", + "options": "JSON", + "read_only": 1 + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2026-02-25 00:00:00.000000", + "modified_by": "Administrator", + "module": "Kapital Bank", + "name": "Kapital Bank API Test", + "naming_rule": "Expression (make_autoname)", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "row_format": "Dynamic", + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} 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 new file mode 100644 index 0000000..f3b3373 --- /dev/null +++ b/kapital_bank/kapital_bank/doctype/kapital_bank_api_test/kapital_bank_api_test.py @@ -0,0 +1,192 @@ +import time +import json +import frappe + +BASE_URL = "https://my.birbank.business/api/b2b" + + +class KapitalBankAPITest(frappe.model.document.Document): + pass + + +# ───────────────────────────────────────────────────────────────────────────── +# 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}" + if params: + clean = {k: v for k, v in params.items() if v is not None} + qs = "&".join(f"{k}={v}" for k, v in clean.items()) + lines = [f"GET {url}", f"Params: {json.dumps(clean, indent=2, ensure_ascii=False)}"] + else: + lines = [f"GET {url}"] + return "\n".join(lines) + + +def _dump_post(path, body=None): + """Build human-readable POST request dump.""" + url = f"{BASE_URL}{path}" + lines = [f"POST {url}"] + if body: + lines.append(f"Body: {json.dumps(body, indent=2, ensure_ascii=False)}") + return "\n".join(lines) + + +def _is_success(data): + """Detect success from both auth-wrapper dicts and raw bank responses.""" + if not data: + return False + if "success" in data: + return bool(data["success"]) + code = data.get("response", {}).get("code") + if code is not None: + return str(code) == "0" + return True + + +def _finish(docname, data, elapsed_ms, request_dump=""): + """Persist result + request dump to the doc and return a uniform response dict.""" + status = "Success" if _is_success(data) else "Failed" + result_json = json.dumps(data, indent=2, ensure_ascii=False, default=str) + frappe.db.set_value("Kapital Bank API Test", docname, { + "request_dump": request_dump, + "result": result_json, + "test_status": status, + "response_time_ms": elapsed_ms, + }, update_modified=False) + frappe.db.commit() + return {"status": status, "result": result_json, "elapsed_ms": elapsed_ms} + + +def _run(docname, fn, request_dump=""): + """Call fn(), measure time, persist result, return response dict.""" + start = time.time() + try: + data = fn() + except Exception as e: + frappe.log_error( + f"API Test [{docname}]: {e}\n{frappe.get_traceback()}", + "KB API Test" + ) + data = {"success": False, "message": str(e)} + return _finish(docname, data, int((time.time() - start) * 1000), request_dump) + + +# ───────────────────────────────────────────────────────────────────────────── +# Auth +# ───────────────────────────────────────────────────────────────────────────── + +@frappe.whitelist() +def do_login(docname): + doc = frappe.get_doc("Kapital Bank API Test", docname) + login_doc = frappe.get_doc("Kapital Bank Login", doc.login_name) + req = _dump_post("/login", {"username": login_doc.username, "password": "***"}) + from kapital_bank.auth import login + return _run(docname, lambda: login(doc.login_name), req) + + +@frappe.whitelist() +def do_logout(docname): + doc = frappe.get_doc("Kapital Bank API Test", docname) + req = "(local operation — clears tokens in DB, no HTTP request to bank)" + from kapital_bank.auth import logout + return _run(docname, lambda: logout(doc.login_name), req) + + +@frappe.whitelist() +def do_refresh_token(docname): + doc = frappe.get_doc("Kapital Bank API Test", docname) + req = _dump_post("/refresh", {"refreshToken": "***"}) + from kapital_bank.auth import renew_token + return _run(docname, lambda: renew_token(doc.login_name), req) + + +# ───────────────────────────────────────────────────────────────────────────── +# Accounts +# ───────────────────────────────────────────────────────────────────────────── + +@frappe.whitelist() +def do_get_accounts(docname): + doc = frappe.get_doc("Kapital Bank API Test", docname) + from kapital_bank.api import BIRBankClient + req = _dump_get("/accounts") + def _call(): + return BIRBankClient(doc.login_name).get_json("/accounts") + return _run(docname, _call, req) + + +@frappe.whitelist() +def do_get_account_statement(docname): + doc = frappe.get_doc("Kapital Bank API Test", docname) + from kapital_bank.api import BIRBankClient + params = { + "accountNumber": doc.iban, + "fromDate": _fmt_date(doc.from_date), + "toDate": _fmt_date(doc.to_date), + } + req = _dump_get("/v2/statement/account", params) + def _call(): + return BIRBankClient(doc.login_name).get_json("/v2/statement/account", params=params) + return _run(docname, _call, req) + + +# ───────────────────────────────────────────────────────────────────────────── +# Cards +# ───────────────────────────────────────────────────────────────────────────── + +@frappe.whitelist() +def do_get_cards(docname): + doc = frappe.get_doc("Kapital Bank API Test", docname) + from kapital_bank.api import BIRBankClient + req = _dump_get("/cards") + def _call(): + return BIRBankClient(doc.login_name).get_json("/cards") + return _run(docname, _call, req) + + +@frappe.whitelist() +def do_get_card_statement(docname): + doc = frappe.get_doc("Kapital Bank API Test", docname) + from kapital_bank.api import BIRBankClient + 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, + } + req = _dump_get("/v2/statement/card", params) + def _call(): + return BIRBankClient(doc.login_name).get_json("/v2/statement/card", params=params) + return _run(docname, _call, req) + + +# ───────────────────────────────────────────────────────────────────────────── +# Payments +# ───────────────────────────────────────────────────────────────────────────── + +@frappe.whitelist() +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) + def _call(): + return BIRBankClient(doc.login_name).get_json("/internal-transfer/status", params=params) + return _run(docname, _call, req) diff --git a/kapital_bank/kapital_bank/doctype/kapital_bank_card/__init__.py b/kapital_bank/kapital_bank/doctype/kapital_bank_card/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/kapital_bank/kapital_bank/doctype/kapital_bank_card/kapital_bank_card.json b/kapital_bank/kapital_bank/doctype/kapital_bank_card/kapital_bank_card.json new file mode 100644 index 0000000..f030059 --- /dev/null +++ b/kapital_bank/kapital_bank/doctype/kapital_bank_card/kapital_bank_card.json @@ -0,0 +1,118 @@ +{ + "actions": [], + "allow_rename": 0, + "autoname": "field:account_number", + "creation": "2026-02-24 00:00:00.000000", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "account_number", + "card_type", + "pan", + "currency", + "column_break_1", + "balance", + "expiry_date", + "mapping_section", + "status", + "bank_account" + ], + "fields": [ + { + "fieldname": "account_number", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Account Number", + "read_only": 1, + "reqd": 1, + "unique": 1 + }, + { + "fieldname": "card_type", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Card Type", + "read_only": 1 + }, + { + "fieldname": "pan", + "fieldtype": "Data", + "in_list_view": 1, + "label": "PAN (Masked)", + "read_only": 1 + }, + { + "fieldname": "currency", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Currency", + "read_only": 1 + }, + { + "fieldname": "column_break_1", + "fieldtype": "Column Break" + }, + { + "fieldname": "balance", + "fieldtype": "Float", + "in_list_view": 1, + "label": "Balance", + "read_only": 1 + }, + { + "fieldname": "expiry_date", + "fieldtype": "Data", + "label": "Expiry Date", + "read_only": 1 + }, + { + "fieldname": "mapping_section", + "fieldtype": "Section Break", + "label": "ERP Mapping" + }, + { + "default": "New", + "fieldname": "status", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Status", + "options": "New\nMapped", + "read_only": 1 + }, + { + "fieldname": "bank_account", + "fieldtype": "Link", + "label": "Bank Account", + "options": "Bank Account", + "read_only": 1 + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2026-02-24 00:00:00.000000", + "modified_by": "Administrator", + "module": "Kapital Bank", + "name": "Kapital Bank Card", + "naming_rule": "By fieldname", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "row_format": "Dynamic", + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "title_field": "pan", + "track_changes": 1 +} diff --git a/kapital_bank/kapital_bank/doctype/kapital_bank_card/kapital_bank_card.py b/kapital_bank/kapital_bank/doctype/kapital_bank_card/kapital_bank_card.py new file mode 100644 index 0000000..de8c728 --- /dev/null +++ b/kapital_bank/kapital_bank/doctype/kapital_bank_card/kapital_bank_card.py @@ -0,0 +1,6 @@ +import frappe +from frappe.model.document import Document + + +class KapitalBankCard(Document): + pass diff --git a/kapital_bank/kapital_bank/doctype/kapital_bank_client/__init__.py b/kapital_bank/kapital_bank/doctype/kapital_bank_client/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/kapital_bank/kapital_bank/doctype/kapital_bank_client/kapital_bank_client.json b/kapital_bank/kapital_bank/doctype/kapital_bank_client/kapital_bank_client.json new file mode 100644 index 0000000..689a1b3 --- /dev/null +++ b/kapital_bank/kapital_bank/doctype/kapital_bank_client/kapital_bank_client.json @@ -0,0 +1,101 @@ +{ + "actions": [], + "allow_rename": 1, + "autoname": "field:client_name", + "creation": "2026-02-24 00:00:00.000000", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "client_name", + "tax_id", + "status", + "column_break_1", + "iban", + "bank_code", + "mapping_section", + "mapped_party_type", + "mapped_party" + ], + "fields": [ + { + "fieldname": "client_name", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Client Name", + "reqd": 1 + }, + { + "fieldname": "tax_id", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Tax ID (VÖEN)", + "unique": 1 + }, + { + "default": "New", + "fieldname": "status", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Status", + "options": "New\nMapped\nActive" + }, + { + "fieldname": "column_break_1", + "fieldtype": "Column Break" + }, + { + "fieldname": "iban", + "fieldtype": "Data", + "label": "IBAN" + }, + { + "fieldname": "bank_code", + "fieldtype": "Data", + "label": "Bank Code" + }, + { + "fieldname": "mapping_section", + "fieldtype": "Section Break", + "label": "ERP Mapping" + }, + { + "fieldname": "mapped_party_type", + "fieldtype": "Select", + "label": "Mapped Party Type", + "options": "\nCustomer\nSupplier" + }, + { + "fieldname": "mapped_party", + "fieldtype": "Dynamic Link", + "label": "Mapped Party", + "options": "mapped_party_type" + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2026-02-24 00:00:00.000000", + "modified_by": "Administrator", + "module": "Kapital Bank", + "name": "Kapital Bank Client", + "naming_rule": "By fieldname", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "row_format": "Dynamic", + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "title_field": "client_name" +} diff --git a/kapital_bank/kapital_bank/doctype/kapital_bank_client/kapital_bank_client.py b/kapital_bank/kapital_bank/doctype/kapital_bank_client/kapital_bank_client.py new file mode 100644 index 0000000..1232bc2 --- /dev/null +++ b/kapital_bank/kapital_bank/doctype/kapital_bank_client/kapital_bank_client.py @@ -0,0 +1,6 @@ +import frappe +from frappe.model.document import Document + + +class KapitalBankClient(Document): + pass diff --git a/kapital_bank/kapital_bank/doctype/kapital_bank_client_mapping/__init__.py b/kapital_bank/kapital_bank/doctype/kapital_bank_client_mapping/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/kapital_bank/kapital_bank/doctype/kapital_bank_client_mapping/kapital_bank_client_mapping.json b/kapital_bank/kapital_bank/doctype/kapital_bank_client_mapping/kapital_bank_client_mapping.json new file mode 100644 index 0000000..6c67657 --- /dev/null +++ b/kapital_bank/kapital_bank/doctype/kapital_bank_client_mapping/kapital_bank_client_mapping.json @@ -0,0 +1,61 @@ +{ + "actions": [], + "creation": "2026-02-24 00:00:00.000000", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "tax_id", + "client_name", + "party_type", + "erp_party", + "mapping_type" + ], + "fields": [ + { + "fieldname": "tax_id", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Tax ID (VÖEN)" + }, + { + "fieldname": "client_name", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Client Name" + }, + { + "fieldname": "party_type", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Party Type", + "options": "\nCustomer\nSupplier" + }, + { + "fieldname": "erp_party", + "fieldtype": "Dynamic Link", + "in_list_view": 1, + "label": "ERP Party", + "options": "party_type" + }, + { + "default": "Manual", + "fieldname": "mapping_type", + "fieldtype": "Select", + "label": "Mapping Type", + "options": "Manual\nAutomatic", + "read_only": 1 + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2026-02-24 00:00:00.000000", + "modified_by": "Administrator", + "module": "Kapital Bank", + "name": "Kapital Bank Client Mapping", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} diff --git a/kapital_bank/kapital_bank/doctype/kapital_bank_client_mapping/kapital_bank_client_mapping.py b/kapital_bank/kapital_bank/doctype/kapital_bank_client_mapping/kapital_bank_client_mapping.py new file mode 100644 index 0000000..d07bb91 --- /dev/null +++ b/kapital_bank/kapital_bank/doctype/kapital_bank_client_mapping/kapital_bank_client_mapping.py @@ -0,0 +1,6 @@ +import frappe +from frappe.model.document import Document + + +class KapitalBankClientMapping(Document): + pass diff --git a/kapital_bank/kapital_bank/doctype/kapital_bank_login/__init__.py b/kapital_bank/kapital_bank/doctype/kapital_bank_login/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/kapital_bank/kapital_bank/doctype/kapital_bank_login/kapital_bank_login.js b/kapital_bank/kapital_bank/doctype/kapital_bank_login/kapital_bank_login.js new file mode 100644 index 0000000..ea6f74a --- /dev/null +++ b/kapital_bank/kapital_bank/doctype/kapital_bank_login/kapital_bank_login.js @@ -0,0 +1,67 @@ +frappe.ui.form.on('Kapital Bank Login', { + refresh(frm) { + frm.add_custom_button(__('Login'), () => { + do_login(frm); + }, __('Actions')); + + frm.add_custom_button(__('Logout'), () => { + do_logout(frm); + }, __('Actions')); + } +}); + +function do_login(frm) { + if (frm.is_new() || frm.is_dirty()) { + frm.save('Save', () => { _call_login(frm); }); + } else { + _call_login(frm); + } +} + +function _call_login(frm) { + frappe.show_progress(__('Logging in...'), 0, 100, __('Connecting to BIRBank...')); + + frappe.call({ + method: 'kapital_bank.auth.login', + args: { login_name: frm.doc.name }, + freeze: false, + callback(r) { + frappe.hide_progress(); + if (r.exc) { + frappe.msgprint({ title: __('Error'), message: r.exc, indicator: 'red' }); + return; + } + const res = r.message; + if (res && res.success) { + frappe.show_alert({ message: __('Login successful'), indicator: 'green' }, 5); + frm.reload_doc(); + } else { + frappe.msgprint({ + title: __('Login Failed'), + message: res ? res.message : __('Unknown error'), + indicator: 'red' + }); + } + }, + error() { + frappe.hide_progress(); + frappe.msgprint({ title: __('Error'), message: __('Request failed'), indicator: 'red' }); + } + }); +} + +function do_logout(frm) { + frappe.confirm(__('Are you sure you want to logout?'), () => { + frappe.call({ + method: 'kapital_bank.auth.logout', + args: { login_name: frm.doc.name }, + freeze: true, + callback(r) { + if (r.message && r.message.success) { + frappe.show_alert({ message: __('Logged out successfully'), indicator: 'green' }, 5); + frm.reload_doc(); + } + } + }); + }); +} 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 new file mode 100644 index 0000000..33f23ba --- /dev/null +++ b/kapital_bank/kapital_bank/doctype/kapital_bank_login/kapital_bank_login.json @@ -0,0 +1,148 @@ +{ + "actions": [], + "allow_rename": 1, + "autoname": "field:username", + "creation": "2026-02-24 00:00:00", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "auth_section", + "username", + "password", + "column_break_1", + "is_default", + "auth_status", + "token_section", + "jwt_token", + "jwt_refresh_token", + "column_break_2", + "token_expiry", + "last_activity", + "info_section", + "user_info", + "company_name", + "cif" + ], + "fields": [ + { + "fieldname": "auth_section", + "fieldtype": "Section Break", + "label": "Authentication" + }, + { + "fieldname": "username", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Username", + "reqd": 1, + "unique": 1 + }, + { + "fieldname": "password", + "fieldtype": "Data", + "label": "Password", + "reqd": 1 + }, + { + "fieldname": "column_break_1", + "fieldtype": "Column Break" + }, + { + "default": "0", + "fieldname": "is_default", + "fieldtype": "Check", + "label": "Is Default" + }, + { + "default": "Not Authenticated", + "fieldname": "auth_status", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Auth Status", + "options": "Not Authenticated\nAuthenticated\nFailed", + "read_only": 1 + }, + { + "fieldname": "token_section", + "fieldtype": "Section Break", + "label": "Tokens" + }, + { + "fieldname": "jwt_token", + "fieldtype": "Password", + "hidden": 1, + "label": "JWT Token" + }, + { + "fieldname": "jwt_refresh_token", + "fieldtype": "Password", + "hidden": 1, + "label": "JWT Refresh Token" + }, + { + "fieldname": "column_break_2", + "fieldtype": "Column Break" + }, + { + "fieldname": "token_expiry", + "fieldtype": "Datetime", + "label": "Token Expiry", + "read_only": 1 + }, + { + "fieldname": "last_activity", + "fieldtype": "Datetime", + "label": "Last Activity", + "read_only": 1 + }, + { + "fieldname": "info_section", + "fieldtype": "Section Break", + "label": "User Info" + }, + { + "fieldname": "user_info", + "fieldtype": "Data", + "label": "User Name", + "read_only": 1 + }, + { + "fieldname": "company_name", + "fieldtype": "Data", + "label": "Company Name", + "read_only": 1 + }, + { + "fieldname": "cif", + "fieldtype": "Data", + "label": "CIF", + "read_only": 1 + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2026-02-24 19:40:23.904132", + "modified_by": "Administrator", + "module": "Kapital Bank", + "name": "Kapital Bank Login", + "naming_rule": "By fieldname", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "row_format": "Dynamic", + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} diff --git a/kapital_bank/kapital_bank/doctype/kapital_bank_login/kapital_bank_login.py b/kapital_bank/kapital_bank/doctype/kapital_bank_login/kapital_bank_login.py new file mode 100644 index 0000000..2daed35 --- /dev/null +++ b/kapital_bank/kapital_bank/doctype/kapital_bank_login/kapital_bank_login.py @@ -0,0 +1,6 @@ +import frappe +from frappe.model.document import Document + + +class KapitalBankLogin(Document): + pass diff --git a/kapital_bank/kapital_bank/doctype/kapital_bank_login/test_kapital_bank_login.py b/kapital_bank/kapital_bank/doctype/kapital_bank_login/test_kapital_bank_login.py new file mode 100644 index 0000000..784550b --- /dev/null +++ b/kapital_bank/kapital_bank/doctype/kapital_bank_login/test_kapital_bank_login.py @@ -0,0 +1,22 @@ +# Copyright (c) 2026, Jey ERP and Contributors +# See license.txt + +# import frappe +from frappe.tests import IntegrationTestCase + + +# On IntegrationTestCase, the doctype test records and all +# link-field test record dependencies are recursively loaded +# Use these module variables to add/remove to/from that list +EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] +IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] + + + +class IntegrationTestKapitalBankLogin(IntegrationTestCase): + """ + Integration tests for KapitalBankLogin. + Use this class for testing interactions between multiple components. + """ + + pass diff --git a/kapital_bank/kapital_bank/doctype/kapital_bank_settings/__init__.py b/kapital_bank/kapital_bank/doctype/kapital_bank_settings/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/kapital_bank/kapital_bank/doctype/kapital_bank_settings/kapital_bank_settings.js b/kapital_bank/kapital_bank/doctype/kapital_bank_settings/kapital_bank_settings.js new file mode 100644 index 0000000..b367017 --- /dev/null +++ b/kapital_bank/kapital_bank/doctype/kapital_bank_settings/kapital_bank_settings.js @@ -0,0 +1,508 @@ +frappe.ui.form.on('Kapital Bank Settings', { + refresh(frm) { + // ── Main: Load Data from BIRBank ────────────────────────────────────── + frm.add_custom_button(__('Load Data'), () => { + show_load_data_dialog(frm); + }, __('BIRBank')); + + // ── Accounts buttons ────────────────────────────────────────────────── + frm.add_custom_button(__('Add unmapped accounts'), () => { + if (frm.is_dirty()) { + frm.save().then(() => add_unmapped_accounts(frm)); + } else { + add_unmapped_accounts(frm); + } + }, __('Accounts')); + + // ── Clients buttons ─────────────────────────────────────────────────── + frm.add_custom_button(__('Match clients by similar name'), () => { + frappe.confirm( + __('This will automatically match unmapped clients with similar Customer/Supplier names. Continue?'), + () => { + frappe.show_alert({ message: __('Matching clients...'), indicator: 'blue' }); + frappe.call({ + method: 'kapital_bank.bank_api.match_similar_clients', + callback(r) { + if (r.message && r.message.success) { + frappe.show_alert({ + message: __('Matched {0} of {1} clients', [r.message.matched_count, r.message.total_processed]), + indicator: 'green' + }, 5); + frm.reload_doc(); + } else { + frappe.msgprint({ title: __('Error'), indicator: 'red', message: r.message ? r.message.message : __('Error matching clients') }); + } + } + }); + } + ); + }, __('Clients')); + + frm.add_custom_button(__('Create matching clients'), () => { + frappe.confirm( + __('
WARNING! This will create new Customer/Supplier records for all unmapped clients!

This action is irreversible. Are you sure?

'), + () => { + frappe.confirm(__('Are you really sure? This cannot be undone.'), () => { + frappe.call({ + method: 'kapital_bank.bank_api.create_unmapped_clients', + freeze: true, + freeze_message: __('Creating clients...'), + callback(r) { + if (r.message && r.message.success) { + frappe.show_alert({ + message: __('Created {0} new parties ({1} customers, {2} suppliers)', + [r.message.created_count, r.message.customers_count, r.message.suppliers_count]), + indicator: 'green' + }, 6); + frm.reload_doc(); + } else { + frappe.msgprint({ title: __('Error'), indicator: 'red', message: r.message ? r.message.message : __('Error creating clients') }); + } + } + }); + }); + } + ); + }, __('Clients')); + + frm.add_custom_button(__('Add unmapped clients'), () => { + if (frm.is_dirty()) { + frm.save().then(() => add_unmapped_clients(frm)); + } else { + add_unmapped_clients(frm); + } + }, __('Clients')); + + // ── Populate data tabs ──────────────────────────────────────────────── + load_data_tabs(frm); + } +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// LOAD DATA DIALOG +// ═══════════════════════════════════════════════════════════════════════════════ + +function show_load_data_dialog(frm) { + frappe.call({ + method: 'kapital_bank.bank_api.get_kb_reference_data_summary', + callback(r) { + const summary = (r.message && r.message.success) ? r.message.summary : {}; + open_load_dialog(frm, summary); + } + }); +} + +function open_load_dialog(frm, summary) { + let summary_html = `
+
${__('Current Registry Data')}
+
`; + + const types = [ + { key: 'accounts', label: __('Accounts') }, + { key: 'cards', label: __('Cards') }, + { key: 'clients', label: __('Clients') } + ]; + types.forEach(t => { + const d = summary[t.key] || { total: 0, mapped: 0 }; + const unmapped = d.total - d.mapped; + const pct = d.total > 0 ? Math.round(d.mapped / d.total * 100) : 0; + summary_html += `
+
+
${t.label}
+
${d.total} ${__('total')}
+
${d.mapped} ${__('mapped')}
+
${unmapped} ${__('unmapped')}
+
${pct}% ${__('mapped')}
+
+
`; + }); + summary_html += '
'; + + const today = frappe.datetime.get_today(); + const first_day = frappe.datetime.month_start(today); + const last_day = frappe.datetime.month_end(today); + + const d = new frappe.ui.Dialog({ + title: __('Load Data from BIRBank'), + fields: [ + { fieldname: 'summary_html', fieldtype: 'HTML', options: summary_html }, + { fieldname: 'sec_period', fieldtype: 'Section Break', label: __('Period (for clients from statements)') }, + { fieldname: 'date_from', fieldtype: 'Date', label: __('Date From'), default: first_day, reqd: 0 }, + { fieldname: 'col_break', fieldtype: 'Column Break' }, + { fieldname: 'date_to', fieldtype: 'Date', label: __('Date To'), default: last_day, reqd: 0 }, + { fieldname: 'sec_what', fieldtype: 'Section Break', label: __('What to load') }, + { fieldname: 'load_accounts', fieldtype: 'Check', label: __('Load Accounts'), default: 1, + description: __('Fetch current accounts from GET /accounts') }, + { fieldname: 'load_cards', fieldtype: 'Check', label: __('Load Cards'), default: 1, + description: __('Fetch business cards from GET /cards') }, + { fieldname: 'load_clients', fieldtype: 'Check', label: __('Load Clients from statements'), default: 1, + description: __('Extract counterparties from account statement transactions for the selected period') } + ], + primary_action_label: __('Load'), + primary_action(values) { + if (values.load_clients && (!values.date_from || !values.date_to)) { + frappe.msgprint(__('Please set Date From and Date To to load clients from statements.')); + return; + } + d.hide(); + start_loading(frm, values); + } + }); + d.show(); +} + +function start_loading(frm, values) { + let total = (values.load_accounts ? 1 : 0) + (values.load_cards ? 1 : 0) + (values.load_clients ? 1 : 0); + if (total === 0) { frappe.show_alert({ message: __('Nothing selected'), indicator: 'orange' }, 3); return; } + + let done = 0; + const results = []; + + function on_done(msg) { + results.push(msg); + done++; + if (done >= total) { + frappe.hide_progress(); + frappe.show_alert({ message: results.join(' | '), indicator: 'green' }, 6); + frm.reload_doc(); + } + } + + frappe.show_progress(__('Loading...'), 0, 100, __('Fetching data from BIRBank...')); + + if (values.load_accounts) { + frappe.call({ + method: 'kapital_bank.bank_api.load_accounts_to_registry', + callback(r) { + if (r.message && r.message.success) { + on_done(__('Accounts: {0} created, {1} updated', [r.message.created, r.message.updated])); + } else { + on_done(__('Accounts error: {0}', [r.message ? r.message.message : 'unknown'])); + } + }, + error() { on_done(__('Accounts: network error')); } + }); + } + + if (values.load_cards) { + frappe.call({ + method: 'kapital_bank.bank_api.load_cards_to_registry', + callback(r) { + if (r.message && r.message.success) { + on_done(__('Cards: {0} created, {1} updated', [r.message.created, r.message.updated])); + } else { + on_done(__('Cards error: {0}', [r.message ? r.message.message : 'unknown'])); + } + }, + error() { on_done(__('Cards: network error')); } + }); + } + + if (values.load_clients) { + frappe.show_progress(__('Loading clients...'), 50, 100, __('Fetching statements to extract counterparties...')); + frappe.call({ + method: 'kapital_bank.bank_api.load_clients_from_statements', + args: { from_date: values.date_from, to_date: values.date_to }, + callback(r) { + if (r.message && r.message.success) { + on_done(__('Clients: {0} new, {1} skipped (already exist)', [r.message.created, r.message.skipped])); + } else { + on_done(__('Clients error: {0}', [r.message ? r.message.message : 'unknown'])); + } + }, + error() { on_done(__('Clients: network error')); } + }); + } +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// ADD UNMAPPED TO MAPPING TABLES +// ═══════════════════════════════════════════════════════════════════════════════ + +function add_unmapped_accounts(frm) { + frappe.call({ + method: 'kapital_bank.bank_api.get_unmapped_accounts', + callback(r) { + if (!r.message || !r.message.success) { + frappe.msgprint({ title: __('Error'), indicator: 'red', message: r.message ? r.message.message : __('Error') }); + return; + } + const accounts = r.message.accounts; + if (!accounts || accounts.length === 0) { + frappe.show_alert({ message: __('No unmapped accounts found'), indicator: 'blue' }, 5); + return; + } + accounts.forEach(a => { + const row = frm.add_child('account_mappings'); + row.iban = a.iban; + row.account_label = a.account_desc || a.iban; + row.currency = a.currency || ''; + }); + frm.refresh_field('account_mappings'); + frm.save().then(() => { + frappe.show_alert({ message: __('Added {0} accounts to mapping', [accounts.length]), indicator: 'green' }, 5); + load_accounts_tab(); + }); + } + }); +} + +function add_unmapped_clients(frm) { + frappe.call({ + method: 'kapital_bank.bank_api.get_unmapped_clients', + callback(r) { + if (!r.message || !r.message.success) { + frappe.msgprint({ title: __('Error'), indicator: 'red', message: r.message ? r.message.message : __('Error') }); + return; + } + const clients = r.message.clients; + if (!clients || clients.length === 0) { + frappe.show_alert({ message: __('No unmapped clients found'), indicator: 'blue' }, 5); + return; + } + clients.forEach(c => { + const row = frm.add_child('client_mappings'); + row.tax_id = c.tax_id || ''; + row.client_name = c.client_name || ''; + row.party_type = c.mapped_party_type || ''; + row.erp_party = c.mapped_party || ''; + row.mapping_type = 'Manual'; + }); + frm.refresh_field('client_mappings'); + frm.save().then(() => { + frappe.show_alert({ message: __('Added {0} clients to mapping', [clients.length]), indicator: 'green' }, 5); + load_clients_tab(); + }); + } + }); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// DATA TABS +// ═══════════════════════════════════════════════════════════════════════════════ + +function load_data_tabs(frm) { + load_accounts_tab(); + load_cards_tab(); + load_clients_tab(); +} + +// ─── Accounts tab ──────────────────────────────────────────────────────────── + +function load_accounts_tab() { + frappe.call({ + method: 'kapital_bank.bank_api.get_kb_reference_data_list', + args: { data_type: 'accounts', limit: 100, offset: 0 }, + callback(r) { + if (r.message && r.message.success) { + render_accounts_list(r.message.data, r.message.total_count); + } else { + const msg = r.message ? r.message.message : __('Unknown error'); + $('#kb-accounts-container').html(error_html(msg, load_accounts_tab)); + } + }, + error() { $('#kb-accounts-container').html(error_html(__('Network error'), load_accounts_tab)); } + }); +} + +function render_accounts_list(accounts, total_count) { + let html = list_header(__('Accounts'), total_count, 'kb-refresh-accounts'); + + if (accounts && accounts.length > 0) { + html += ` + + + + + + + + + `; + accounts.forEach(a => { + const pill = a.status === 'Mapped' ? 'green' : 'red'; + const mapped_to = a.bank_account ? `${a.bank_account}` : '-'; + html += ` + + + + + + + + `; + }); + html += '
${__('IBAN')}${__('Currency')}${__('Current Balance')}${__('Planned Balance')}${__('Account Status')}${__('Status')}${__('Mapped To')}
${a.iban}${a.currency || '-'}${fmt_num(a.current_balance)}${fmt_num(a.planned_balance)}${a.account_status || '-'}${__(a.status)}${mapped_to}
'; + if (total_count > accounts.length) { + html += `

${__('Showing {0} of {1}', [accounts.length, total_count])} — ${__('View All')}

`; + } + } else { + html += empty_state(__('No accounts in registry. Use "Load Data" to fetch from BIRBank.')); + } + + $('#kb-accounts-container').html(html); + $('#kb-refresh-accounts').on('click', () => { + $('#kb-accounts-container').html(loading_html()); + load_accounts_tab(); + }); +} + +// ─── Cards tab ─────────────────────────────────────────────────────────────── + +function load_cards_tab() { + frappe.call({ + method: 'kapital_bank.bank_api.get_kb_reference_data_list', + args: { data_type: 'cards', limit: 100, offset: 0 }, + callback(r) { + if (r.message && r.message.success) { + render_cards_list(r.message.data, r.message.total_count); + } else { + const msg = r.message ? r.message.message : __('Unknown error'); + $('#kb-cards-container').html(error_html(msg, load_cards_tab)); + } + }, + error() { $('#kb-cards-container').html(error_html(__('Network error'), load_cards_tab)); } + }); +} + +function render_cards_list(cards, total_count) { + let html = list_header(__('Cards'), total_count, 'kb-refresh-cards'); + + if (cards && cards.length > 0) { + html += ` + + + + + + + + + + `; + cards.forEach(c => { + const pill = c.status === 'Mapped' ? 'green' : 'red'; + const mapped_to = c.bank_account ? `${c.bank_account}` : '-'; + html += ` + + + + + + + + + `; + }); + html += '
${__('PAN')}${__('Card Type')}${__('Account Number')}${__('Currency')}${__('Balance')}${__('Expiry')}${__('Status')}${__('Mapped To')}
${c.pan || '-'}${c.card_type || '-'}${c.account_number || '-'}${c.currency || '-'}${fmt_num(c.balance)}${c.expiry_date || '-'}${__(c.status)}${mapped_to}
'; + if (total_count > cards.length) { + html += `

${__('Showing {0} of {1}', [cards.length, total_count])} — ${__('View All')}

`; + } + } else { + html += empty_state(__('No cards in registry. Use "Load Data" to fetch from BIRBank.')); + } + + $('#kb-cards-container').html(html); + $('#kb-refresh-cards').on('click', () => { + $('#kb-cards-container').html(loading_html()); + load_cards_tab(); + }); +} + +// ─── Clients tab ───────────────────────────────────────────────────────────── + +function load_clients_tab() { + frappe.call({ + method: 'kapital_bank.bank_api.get_kb_reference_data_list', + args: { data_type: 'clients', limit: 100, offset: 0 }, + callback(r) { + if (r.message && r.message.success) { + render_clients_list(r.message.data, r.message.total_count); + } else { + const msg = r.message ? r.message.message : __('Unknown error'); + $('#kb-clients-container').html(error_html(msg, load_clients_tab)); + } + }, + error() { $('#kb-clients-container').html(error_html(__('Network error'), load_clients_tab)); } + }); +} + +function render_clients_list(clients, total_count) { + let html = list_header(__('Clients'), total_count, 'kb-refresh-clients'); + + if (clients && clients.length > 0) { + html += ` + + + + + + + + + `; + clients.forEach(c => { + const pill = c.status === 'Mapped' ? 'green' : (c.status === 'Active' ? 'blue' : 'red'); + const mapped_to = c.mapped_party ? `${c.mapped_party}` : '-'; + const created = c.creation ? frappe.datetime.str_to_user(c.creation) : '-'; + html += ` + + + + + + + + `; + }); + html += '
${__('Client Name')}${__('Tax ID (VÖEN)')}${__('IBAN')}${__('Status')}${__('Party Type')}${__('Mapped To')}${__('Created')}
${c.client_name}${c.tax_id || '-'}${c.iban || '-'}${__(c.status)}${c.mapped_party_type || '-'}${mapped_to}${created}
'; + if (total_count > clients.length) { + html += `

${__('Showing {0} of {1}', [clients.length, total_count])} — ${__('View All')}

`; + } + } else { + html += empty_state(__('No clients registered yet.')); + } + + $('#kb-clients-container').html(html); + $('#kb-refresh-clients').on('click', () => { + $('#kb-clients-container').html(loading_html()); + load_clients_tab(); + }); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// HELPERS +// ═══════════════════════════════════════════════════════════════════════════════ + +function fmt_num(val) { + if (val === undefined || val === null || val === '') return '-'; + const n = parseFloat(val); + if (isNaN(n)) return val; + return format_number(n, null, 2); +} + +function loading_html() { + return `
${__('Loading...')}
`; +} + +function empty_state(msg) { + return `

${msg}

`; +} + +function error_html(msg, retry_fn) { + return `
+

${__('Error loading data')}: ${msg}

+ +
`; +} + +function list_header(label, total_count, refresh_id) { + return `
+
${label} (${total_count} ${__('total')})
+
+ +
+
`; +} diff --git a/kapital_bank/kapital_bank/doctype/kapital_bank_settings/kapital_bank_settings.json b/kapital_bank/kapital_bank/doctype/kapital_bank_settings/kapital_bank_settings.json new file mode 100644 index 0000000..b7976d8 --- /dev/null +++ b/kapital_bank/kapital_bank/doctype/kapital_bank_settings/kapital_bank_settings.json @@ -0,0 +1,208 @@ +{ + "actions": [], + "creation": "2026-02-24 00:00:00.000000", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "settings_tab", + "is_active", + "default_login", + "default_company", + "company_code", + "general_section", + "similarity_threshold", + "consider_azeri_chars", + "default_party_settings_section", + "default_supplier_group", + "default_customer_group", + "default_payment_terms", + "col_break_party", + "default_territory", + "accounts_tab", + "accounts_list_html", + "cards_tab", + "cards_list_html", + "clients_tab", + "clients_list_html", + "account_mappings_tab", + "account_mappings_section", + "account_mappings", + "client_mappings_tab", + "client_mappings_section", + "client_mappings" + ], + "fields": [ + { + "fieldname": "settings_tab", + "fieldtype": "Tab Break", + "label": "Settings" + }, + { + "default": "1", + "fieldname": "is_active", + "fieldtype": "Check", + "label": "Is Active" + }, + { + "fieldname": "default_login", + "fieldtype": "Link", + "label": "Default Login", + "options": "Kapital Bank Login" + }, + { + "fieldname": "default_company", + "fieldtype": "Link", + "label": "Default Company", + "options": "Company" + }, + { + "fieldname": "company_code", + "fieldtype": "Data", + "label": "Company Code" + }, + { + "fieldname": "general_section", + "fieldtype": "Section Break", + "label": "General Settings" + }, + { + "default": "95", + "description": "Minimum similarity percentage for automatic mapping", + "fieldname": "similarity_threshold", + "fieldtype": "Percent", + "label": "Similarity Threshold (%)" + }, + { + "default": "1", + "description": "Consider replacement of Azerbaijani letters with Latin equivalents when matching", + "fieldname": "consider_azeri_chars", + "fieldtype": "Check", + "label": "Consider Azerbaijani Characters" + }, + { + "fieldname": "default_party_settings_section", + "fieldtype": "Section Break", + "label": "Default Business Partner Settings" + }, + { + "fieldname": "default_supplier_group", + "fieldtype": "Link", + "label": "Default Supplier Group", + "options": "Supplier Group" + }, + { + "fieldname": "default_customer_group", + "fieldtype": "Link", + "label": "Default Customer Group", + "options": "Customer Group" + }, + { + "fieldname": "default_payment_terms", + "fieldtype": "Link", + "label": "Default Payment Terms", + "options": "Payment Terms Template" + }, + { + "fieldname": "col_break_party", + "fieldtype": "Column Break" + }, + { + "fieldname": "default_territory", + "fieldtype": "Link", + "label": "Default Territory", + "options": "Territory" + }, + { + "fieldname": "accounts_tab", + "fieldtype": "Tab Break", + "label": "Accounts" + }, + { + "fieldname": "accounts_list_html", + "fieldtype": "HTML", + "label": "Accounts List", + "options": "
Click \"Load Data\" to fetch accounts from BIRBank
" + }, + { + "fieldname": "cards_tab", + "fieldtype": "Tab Break", + "label": "Cards" + }, + { + "fieldname": "cards_list_html", + "fieldtype": "HTML", + "label": "Cards List", + "options": "
Click \"Load Data\" to fetch cards from BIRBank
" + }, + { + "fieldname": "clients_tab", + "fieldtype": "Tab Break", + "label": "Clients" + }, + { + "fieldname": "clients_list_html", + "fieldtype": "HTML", + "label": "Clients List", + "options": "
Loading...
" + }, + { + "fieldname": "account_mappings_tab", + "fieldtype": "Tab Break", + "label": "Account Mappings" + }, + { + "fieldname": "account_mappings_section", + "fieldtype": "Section Break", + "label": "Account Mappings" + }, + { + "fieldname": "account_mappings", + "fieldtype": "Table", + "label": "Account Mappings", + "options": "Kapital Bank Account Mapping" + }, + { + "fieldname": "client_mappings_tab", + "fieldtype": "Tab Break", + "label": "Client Mappings" + }, + { + "fieldname": "client_mappings_section", + "fieldtype": "Section Break", + "label": "Client Mappings" + }, + { + "fieldname": "client_mappings", + "fieldtype": "Table", + "label": "Client Mappings", + "options": "Kapital Bank Client Mapping" + } + ], + "index_web_pages_for_search": 1, + "issingle": 1, + "links": [], + "modified": "2026-02-25 00:00:00.000000", + "modified_by": "Administrator", + "module": "Kapital Bank", + "name": "Kapital Bank Settings", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "row_format": "Dynamic", + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "track_changes": 1 +} diff --git a/kapital_bank/kapital_bank/doctype/kapital_bank_settings/kapital_bank_settings.py b/kapital_bank/kapital_bank/doctype/kapital_bank_settings/kapital_bank_settings.py new file mode 100644 index 0000000..5b9609a --- /dev/null +++ b/kapital_bank/kapital_bank/doctype/kapital_bank_settings/kapital_bank_settings.py @@ -0,0 +1,31 @@ +import frappe +from frappe.model.document import Document + + +class KapitalBankSettings(Document): + def on_update(self): + self._sync_client_statuses() + + def _sync_client_statuses(self): + """Reset Kapital Bank Client status to 'New' for clients removed from client_mappings.""" + mapped_names = {row.client_name for row in self.client_mappings if row.client_name} + + # Find clients marked as Mapped but no longer present in the mapping table + orphaned = frappe.get_all( + "Kapital Bank Client", + filters={"status": "Mapped"}, + fields=["name", "client_name"], + ) + + for client in orphaned: + if client.client_name not in mapped_names: + frappe.db.set_value( + "Kapital Bank Client", + client.name, + { + "status": "New", + "mapped_party_type": None, + "mapped_party": None, + }, + update_modified=False, + ) diff --git a/kapital_bank/payment_api.py b/kapital_bank/payment_api.py new file mode 100644 index 0000000..9238546 --- /dev/null +++ b/kapital_bank/payment_api.py @@ -0,0 +1,277 @@ +import frappe +import json +from frappe.utils import nowdate +from kapital_bank.api import BIRBankClient +from kapital_bank.auth import get_default_login + + +def _get_client(login_name=None): + return BIRBankClient(login_name) + + +def _pe_doc(payment_entry_name): + return frappe.get_doc("Payment Entry", payment_entry_name) + + +@frappe.whitelist() +def send_transfer(payment_entry_name, login_name=None): + """POST /api/b2b/v2/internal-transfer — domestic bank transfer.""" + pe = _pe_doc(payment_entry_name) + client = _get_client(login_name) + + operation_name = pe.get("kb_operation_name") or pe.name[:20] + + payload = { + "fromAccount": pe.get("kb_from_iban") or pe.paid_from, + "transferData": { + "operationName": operation_name, + "benBankCode": pe.get("kb_ben_bank_code", ""), + "toAccount": pe.get("kb_to_iban") or pe.paid_to, + "toTaxNo": pe.get("kb_to_tax_no", ""), + "toCustName": pe.party_name or pe.party, + "amount": pe.paid_amount, + "purpose1": (pe.remarks or "")[:64], + "purpose2": "", + } + } + + try: + data = client.post_json("/v2/internal-transfer", json=payload) + except Exception as e: + frappe.log_error(f"send_transfer failed for {payment_entry_name}: {e}\n{frappe.get_traceback()}", "KB Payment API") + return {"success": False, "message": str(e)} + + status = data.get("status", "") + operation_id = data.get("operationId", "") + + if status == "Success": + frappe.db.set_value("Payment Entry", payment_entry_name, { + "kb_operation_name": operation_name, + "kb_bank_id": operation_id, + "kb_transfer_status": "Submitted", + "kb_transfer_type": "Internal Transfer", + }, update_modified=False) + frappe.db.commit() + return {"success": True, "operation_id": operation_id} + else: + err = data.get("message") or data.get("errorMessage") or str(data) + frappe.log_error(f"send_transfer error for {payment_entry_name}: {err}", "KB Payment API") + return {"success": False, "message": err} + + +@frappe.whitelist() +def send_bulk_transfers(payment_entry_names, login_name=None): + """POST /api/b2b/v2/internal-transfers — bulk domestic transfer (up to 50).""" + if isinstance(payment_entry_names, str): + payment_entry_names = json.loads(payment_entry_names) + + client = _get_client(login_name) + results = [] + + # BIRBank requires a sign file for bulk transfers. + # We send the JSON part as a multipart form. + transfer_datas = [] + for name in payment_entry_names: + pe = _pe_doc(name) + transfer_datas.append({ + "operationName": pe.get("kb_operation_name") or pe.name[:20], + "benBankCode": pe.get("kb_ben_bank_code", ""), + "toAccount": pe.get("kb_to_iban") or pe.paid_to, + "toTaxNo": pe.get("kb_to_tax_no", ""), + "toCustName": pe.party_name or pe.party, + "amount": pe.paid_amount, + "purpose1": (pe.remarks or "")[:64], + }) + + first_pe = _pe_doc(payment_entry_names[0]) + payload_json = json.dumps({ + "fromAccount": first_pe.get("kb_from_iban") or first_pe.paid_from, + "transferDatas": transfer_datas, + }) + + try: + resp = client.post("/v2/internal-transfers", data={"json": payload_json}, files=[]) + resp.raise_for_status() + data = resp.json() + except Exception as e: + frappe.log_error(f"send_bulk_transfers failed: {e}\n{frappe.get_traceback()}", "KB Payment API") + return {"success": False, "message": str(e)} + + successes = data.get("successes", {}).get("transferDatas", []) + errors = data.get("errors", {}).get("transferDatas", []) + + for item in successes: + op_name = item.get("operationName") + op_id = item.get("operationId") + frappe.db.set_value("Payment Entry", op_name, { + "kb_bank_id": op_id, + "kb_transfer_status": "Submitted", + "kb_transfer_type": "Internal Transfer", + }, update_modified=False) + + frappe.db.commit() + return { + "success": True, + "successes": len(successes), + "errors": [e.get("errorMessages") for e in errors], + } + + +@frappe.whitelist() +def send_card_transfer(payment_entry_name, login_name=None): + """POST /api/b2b/transfer/account-to-card — transfer to business card.""" + pe = _pe_doc(payment_entry_name) + client = _get_client(login_name) + + operation_name = pe.get("kb_operation_name") or pe.name[:20] + payload_data = { + "operationName": operation_name, + "fromAccount": pe.get("kb_from_iban") or pe.paid_from, + "toAccount": pe.get("kb_to_iban") or pe.paid_to, + "amount": str(pe.paid_amount), + "purpose": pe.get("kb_purpose_code") or "1", + } + + try: + resp = client.post( + "/transfer/account-to-card", + data={"json": frappe.as_json(payload_data)}, + files=[] + ) + resp.raise_for_status() + data = resp.json() + except Exception as e: + frappe.log_error(f"send_card_transfer failed for {payment_entry_name}: {e}\n{frappe.get_traceback()}", "KB Payment API") + return {"success": False, "message": str(e)} + + status = data.get("status", "") + operation_id = data.get("data", {}).get("operationId", "") + + if status == "Success": + frappe.db.set_value("Payment Entry", payment_entry_name, { + "kb_operation_name": operation_name, + "kb_bank_id": operation_id, + "kb_transfer_status": "Submitted", + "kb_transfer_type": "Card Transfer", + }, update_modified=False) + frappe.db.commit() + return {"success": True, "operation_id": operation_id} + else: + err = data.get("message") or str(data) + return {"success": False, "message": err} + + +@frappe.whitelist() +def send_vat_payment(payment_entry_name, login_name=None): + """POST /api/b2b/internal-transfer/vat — 18% VAT payment.""" + pe = _pe_doc(payment_entry_name) + client = _get_client(login_name) + + operation_name = pe.get("kb_operation_name") or pe.name[:20] + payload = { + "operationName": operation_name, + "amount": pe.paid_amount if pe.paid_amount else None, + "purpose": pe.remarks or None, + } + + try: + data = client.post_json("/internal-transfer/vat", json=payload) + except Exception as e: + frappe.log_error(f"send_vat_payment failed for {payment_entry_name}: {e}\n{frappe.get_traceback()}", "KB Payment API") + return {"success": False, "message": str(e)} + + status = data.get("status", "") + resp_data = data.get("data", {}) + operation_id = resp_data.get("operationId", "") + + if status == "Success": + frappe.db.set_value("Payment Entry", payment_entry_name, { + "kb_operation_name": operation_name, + "kb_bank_id": operation_id, + "kb_transfer_status": "Submitted", + "kb_transfer_type": "VAT Payment", + }, update_modified=False) + frappe.db.commit() + return {"success": True, "operation_id": operation_id} + else: + err = data.get("message") or str(data) + return {"success": False, "message": err} + + +@frappe.whitelist() +def get_transfer_status(operation_name, login_name=None): + """GET /api/b2b/internal-transfer/status — check transfer status.""" + try: + client = _get_client(login_name) + data = client.get_json("/internal-transfer/status", params={"operatorName": operation_name}) + status_list = data.get("data", []) + return {"success": True, "status": data.get("status"), "data": status_list} + except Exception as e: + frappe.log_error(f"get_transfer_status failed for {operation_name}: {e}\n{frappe.get_traceback()}", "KB Payment API") + return {"success": False, "message": str(e)} + + +@frappe.whitelist() +def check_and_update_status(payment_entry_name, login_name=None): + """Check status and update Payment Entry kb_transfer_status field.""" + pe = _pe_doc(payment_entry_name) + operation_name = pe.get("kb_operation_name") + if not operation_name: + return {"success": False, "message": "No KB operation name on this Payment Entry"} + + result = get_transfer_status(operation_name, login_name) + if not result.get("success"): + return result + + status_data = result.get("data", []) + if status_data: + bank_status = status_data[0].get("status", "") + frappe.db.set_value("Payment Entry", payment_entry_name, "kb_transfer_status", bank_status, update_modified=False) + frappe.db.commit() + return {"success": True, "status": bank_status, "data": status_data} + + return {"success": True, "status": "Unknown", "data": []} + + +@frappe.whitelist() +def cancel_transfer(payment_entry_name, login_name=None): + """DELETE /api/b2b/internal-transfer — cancel a pending transfer.""" + pe = _pe_doc(payment_entry_name) + operation_name = pe.get("kb_operation_name") + if not operation_name: + return {"success": False, "message": "No KB operation name on this Payment Entry"} + + try: + client = _get_client(login_name) + resp = client.delete("/internal-transfer", params={"operationId": operation_name}) + resp.raise_for_status() + data = resp.json() + except Exception as e: + frappe.log_error(f"cancel_transfer failed for {payment_entry_name}: {e}\n{frappe.get_traceback()}", "KB Payment API") + return {"success": False, "message": str(e)} + + if data.get("message") == "Success": + frappe.db.set_value("Payment Entry", payment_entry_name, "kb_transfer_status", "Cancelled", update_modified=False) + frappe.db.commit() + return {"success": True} + else: + return {"success": False, "message": str(data)} + + +def on_cancel_payment_entry(doc, method): + """doc_events hook — try to cancel in BIRBank when Payment Entry is cancelled.""" + if not doc.get("kb_operation_name"): + return + + status = doc.get("kb_transfer_status", "") + # Don't try to cancel if already confirmed/completed + if status in ("Confirmed", "Completed", "Cancelled"): + return + + try: + cancel_transfer(doc.name) + except Exception as e: + frappe.log_error( + f"Auto-cancel failed for Payment Entry {doc.name}: {e}\n{frappe.get_traceback()}", + "KB Payment Entry Cancel" + ) diff --git a/kapital_bank/setup.py b/kapital_bank/setup.py new file mode 100644 index 0000000..2b323aa --- /dev/null +++ b/kapital_bank/setup.py @@ -0,0 +1,83 @@ +import frappe +from frappe.custom.doctype.custom_field.custom_field import create_custom_fields + + +def after_install(): + create_payment_entry_custom_fields() + + +def after_migrate(): + create_payment_entry_custom_fields() + + +def create_payment_entry_custom_fields(): + custom_fields = { + "Payment Entry": [ + { + "fieldname": "kb_section", + "fieldtype": "Section Break", + "label": "Kapital Bank", + "insert_after": "letter_head", + "collapsible": 1, + }, + { + "fieldname": "kb_transfer_type", + "fieldtype": "Select", + "label": "KB Transfer Type", + "options": "\nInternal Transfer\nCard Transfer\nVAT Payment", + "insert_after": "kb_section", + }, + { + "fieldname": "kb_operation_name", + "fieldtype": "Data", + "label": "KB Operation Name", + "insert_after": "kb_transfer_type", + "read_only": 1, + }, + { + "fieldname": "kb_bank_id", + "fieldtype": "Data", + "label": "KB Bank ID", + "insert_after": "kb_operation_name", + "read_only": 1, + }, + { + "fieldname": "kb_transfer_status", + "fieldtype": "Data", + "label": "KB Transfer Status", + "insert_after": "kb_bank_id", + "read_only": 1, + }, + { + "fieldname": "kb_column_break", + "fieldtype": "Column Break", + "insert_after": "kb_transfer_status", + }, + { + "fieldname": "kb_from_iban", + "fieldtype": "Data", + "label": "KB From IBAN", + "insert_after": "kb_column_break", + }, + { + "fieldname": "kb_to_iban", + "fieldtype": "Data", + "label": "KB To IBAN", + "insert_after": "kb_from_iban", + }, + { + "fieldname": "kb_to_tax_no", + "fieldtype": "Data", + "label": "KB To Tax No (VÖEN)", + "insert_after": "kb_to_iban", + }, + { + "fieldname": "kb_ben_bank_code", + "fieldtype": "Data", + "label": "KB Ben Bank Code", + "insert_after": "kb_to_tax_no", + }, + ] + } + create_custom_fields(custom_fields, ignore_validate=True) + frappe.db.commit()