added kapital bank settings and app rework

This commit is contained in:
Ali 2026-02-25 22:07:07 +04:00
parent a0d4ce9f81
commit 70c0f33d37
65 changed files with 3307 additions and 0 deletions

39
api_endpoints.txt Normal file
View File

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
birbank_docs/Login.pdf Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
birbank_docs/Refresh.pdf Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

79
kapital_bank/api.py Normal file
View File

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

196
kapital_bank/auth.py Normal file
View File

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

643
kapital_bank/bank_api.py Normal file
View File

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

View File

@ -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'
});
}
}
});
}

View File

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

View File

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

View File

@ -0,0 +1,6 @@
import frappe
from frappe.model.document import Document
class KapitalBankAccount(Document):
pass

View File

@ -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": []
}

View File

@ -0,0 +1,6 @@
import frappe
from frappe.model.document import Document
class KapitalBankAccountMapping(Document):
pass

View File

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

View File

@ -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": []
}

View File

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

View File

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

View File

@ -0,0 +1,6 @@
import frappe
from frappe.model.document import Document
class KapitalBankCard(Document):
pass

View File

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

View File

@ -0,0 +1,6 @@
import frappe
from frappe.model.document import Document
class KapitalBankClient(Document):
pass

View File

@ -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": []
}

View File

@ -0,0 +1,6 @@
import frappe
from frappe.model.document import Document
class KapitalBankClientMapping(Document):
pass

View File

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

View File

@ -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": []
}

View File

@ -0,0 +1,6 @@
import frappe
from frappe.model.document import Document
class KapitalBankLogin(Document):
pass

View File

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

View File

@ -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(
__('<div style="color:red;font-weight:bold;">WARNING! This will create new Customer/Supplier records for all unmapped clients!</div><p>This action is irreversible. Are you sure?</p>'),
() => {
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 = `<div style="background:#f8f9fa;padding:15px;border-radius:6px;margin-bottom:15px;">
<h5 style="margin-bottom:15px;">${__('Current Registry Data')}</h5>
<div class="row">`;
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 += `<div class="col-sm-4">
<div class="text-center" style="border:1px solid #ddd;padding:10px;border-radius:4px;background:#fff;">
<h6 style="margin-bottom:8px;">${t.label}</h6>
<div><strong>${d.total}</strong> ${__('total')}</div>
<div style="color:#28a745;"><strong>${d.mapped}</strong> ${__('mapped')}</div>
<div style="color:#dc3545;"><strong>${unmapped}</strong> ${__('unmapped')}</div>
<div style="font-size:11px;color:#666;margin-top:4px;">${pct}% ${__('mapped')}</div>
</div>
</div>`;
});
summary_html += '</div></div>';
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 += `<table class="table table-bordered" style="font-size:13px;">
<thead class="grid-heading-row"><tr>
<th>${__('IBAN')}</th>
<th>${__('Currency')}</th>
<th>${__('Current Balance')}</th>
<th>${__('Planned Balance')}</th>
<th>${__('Account Status')}</th>
<th>${__('Status')}</th>
<th>${__('Mapped To')}</th>
</tr></thead><tbody>`;
accounts.forEach(a => {
const pill = a.status === 'Mapped' ? 'green' : 'red';
const mapped_to = a.bank_account ? `<a href="/app/bank-account/${a.bank_account}">${a.bank_account}</a>` : '-';
html += `<tr>
<td><a href="/app/kapital-bank-account/${a.name}">${a.iban}</a></td>
<td>${a.currency || '-'}</td>
<td class="text-right">${fmt_num(a.current_balance)}</td>
<td class="text-right">${fmt_num(a.planned_balance)}</td>
<td>${a.account_status || '-'}</td>
<td><span class="indicator-pill ${pill}">${__(a.status)}</span></td>
<td>${mapped_to}</td>
</tr>`;
});
html += '</tbody></table>';
if (total_count > accounts.length) {
html += `<p class="text-muted small">${__('Showing {0} of {1}', [accounts.length, total_count])} — <a href="/app/kapital-bank-account">${__('View All')}</a></p>`;
}
} 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 += `<table class="table table-bordered" style="font-size:13px;">
<thead class="grid-heading-row"><tr>
<th>${__('PAN')}</th>
<th>${__('Card Type')}</th>
<th>${__('Account Number')}</th>
<th>${__('Currency')}</th>
<th>${__('Balance')}</th>
<th>${__('Expiry')}</th>
<th>${__('Status')}</th>
<th>${__('Mapped To')}</th>
</tr></thead><tbody>`;
cards.forEach(c => {
const pill = c.status === 'Mapped' ? 'green' : 'red';
const mapped_to = c.bank_account ? `<a href="/app/bank-account/${c.bank_account}">${c.bank_account}</a>` : '-';
html += `<tr>
<td><a href="/app/kapital-bank-card/${c.name}"><strong>${c.pan || '-'}</strong></a></td>
<td>${c.card_type || '-'}</td>
<td>${c.account_number || '-'}</td>
<td>${c.currency || '-'}</td>
<td class="text-right">${fmt_num(c.balance)}</td>
<td>${c.expiry_date || '-'}</td>
<td><span class="indicator-pill ${pill}">${__(c.status)}</span></td>
<td>${mapped_to}</td>
</tr>`;
});
html += '</tbody></table>';
if (total_count > cards.length) {
html += `<p class="text-muted small">${__('Showing {0} of {1}', [cards.length, total_count])} — <a href="/app/kapital-bank-card">${__('View All')}</a></p>`;
}
} 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 += `<table class="table table-bordered" style="font-size:13px;">
<thead class="grid-heading-row"><tr>
<th>${__('Client Name')}</th>
<th>${__('Tax ID (VÖEN)')}</th>
<th>${__('IBAN')}</th>
<th>${__('Status')}</th>
<th>${__('Party Type')}</th>
<th>${__('Mapped To')}</th>
<th>${__('Created')}</th>
</tr></thead><tbody>`;
clients.forEach(c => {
const pill = c.status === 'Mapped' ? 'green' : (c.status === 'Active' ? 'blue' : 'red');
const mapped_to = c.mapped_party ? `<a href="/app/${(c.mapped_party_type || 'customer').toLowerCase()}/${c.mapped_party}">${c.mapped_party}</a>` : '-';
const created = c.creation ? frappe.datetime.str_to_user(c.creation) : '-';
html += `<tr>
<td><a href="/app/kapital-bank-client/${c.name}">${c.client_name}</a></td>
<td>${c.tax_id || '-'}</td>
<td>${c.iban || '-'}</td>
<td><span class="indicator-pill ${pill}">${__(c.status)}</span></td>
<td>${c.mapped_party_type || '-'}</td>
<td>${mapped_to}</td>
<td>${created}</td>
</tr>`;
});
html += '</tbody></table>';
if (total_count > clients.length) {
html += `<p class="text-muted small">${__('Showing {0} of {1}', [clients.length, total_count])} — <a href="/app/kapital-bank-client">${__('View All')}</a></p>`;
}
} 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 `<div class="text-center text-muted" style="padding:20px;"><i class="fa fa-spinner fa-spin"></i> ${__('Loading...')}</div>`;
}
function empty_state(msg) {
return `<div class="text-center text-muted" style="padding:30px;"><p>${msg}</p></div>`;
}
function error_html(msg, retry_fn) {
return `<div class="alert alert-warning">
<p>${__('Error loading data')}: ${msg}</p>
<button class="btn btn-sm btn-default kb-retry-btn">${__('Try Again')}</button>
</div>`;
}
function list_header(label, total_count, refresh_id) {
return `<div class="clearfix" style="margin-bottom:10px;">
<div class="pull-left"><h6 class="text-muted">${label} (${total_count} ${__('total')})</h6></div>
<div class="pull-right">
<button class="btn btn-xs btn-default" id="${refresh_id}">
<i class="octicon octicon-sync"></i> ${__('Refresh')}
</button>
</div>
</div>`;
}

View File

@ -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": "<div id=\"kb-accounts-container\"><div class=\"text-center text-muted\" style=\"padding:20px;\">Click \"Load Data\" to fetch accounts from BIRBank</div></div>"
},
{
"fieldname": "cards_tab",
"fieldtype": "Tab Break",
"label": "Cards"
},
{
"fieldname": "cards_list_html",
"fieldtype": "HTML",
"label": "Cards List",
"options": "<div id=\"kb-cards-container\"><div class=\"text-center text-muted\" style=\"padding:20px;\">Click \"Load Data\" to fetch cards from BIRBank</div></div>"
},
{
"fieldname": "clients_tab",
"fieldtype": "Tab Break",
"label": "Clients"
},
{
"fieldname": "clients_list_html",
"fieldtype": "HTML",
"label": "Clients List",
"options": "<div id=\"kb-clients-container\"><div class=\"text-center text-muted\" style=\"padding:20px;\">Loading...</div></div>"
},
{
"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
}

View File

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

277
kapital_bank/payment_api.py Normal file
View File

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

83
kapital_bank/setup.py Normal file
View File

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