payment entry import works

This commit is contained in:
Ali 2026-02-26 22:04:10 +04:00
parent 70c0f33d37
commit 4a560dbde5
24 changed files with 2042 additions and 852 deletions

View File

@ -1,5 +1,7 @@
import json
import re import re
import frappe import frappe
from datetime import datetime
from frappe.utils import cint, flt from frappe.utils import cint, flt
from difflib import SequenceMatcher from difflib import SequenceMatcher
from kapital_bank.api import BIRBankClient from kapital_bank.api import BIRBankClient
@ -98,11 +100,11 @@ def load_cards_to_registry(login_name=None):
@frappe.whitelist() @frappe.whitelist()
def load_clients_from_statements(from_date, to_date, login_name=None): def load_counterparties_from_statements(from_date, to_date, login_name=None):
""" """
Fetch account statements for all registered accounts and extract counterparties Fetch account statements for all registered accounts and extract counterparties
(contrAccount field) into the Kapital Bank Client registry. into Kapital Bank Customer / Kapital Bank Supplier registries based on transaction direction.
Analogous to invoice_az loading customers/suppliers from invoice documents. D (debit, money out) Supplier, C (credit, money in) Customer.
""" """
try: try:
client = BIRBankClient(login_name) client = BIRBankClient(login_name)
@ -111,12 +113,11 @@ def load_clients_from_statements(from_date, to_date, login_name=None):
if not accounts: if not accounts:
return {"success": False, "message": "No accounts in registry. Load accounts first."} return {"success": False, "message": "No accounts in registry. Load accounts first."}
created = skipped = 0 customers_created = suppliers_created = skipped = 0
debug_log = [] debug_log = []
for acc in accounts: for acc in accounts:
iban = acc.iban iban = acc.iban
# API expects the numeric account number (custAcNo), not the full IBAN
account_number = acc.cust_ac_no or iban account_number = acc.cust_ac_no or iban
debug_log.append(f"Account: {_mask(iban)} custAcNo={_mask(account_number)} ({from_date}{to_date})") debug_log.append(f"Account: {_mask(iban)} custAcNo={_mask(account_number)} ({from_date}{to_date})")
@ -132,7 +133,7 @@ def load_clients_from_statements(from_date, to_date, login_name=None):
data = resp.json() data = resp.json()
except Exception as e: except Exception as e:
debug_log.append(f" Error: {e}") debug_log.append(f" Error: {e}")
frappe.log_error(f"Statement fetch failed for {_mask(iban)}: {e}", "KB Load Clients") frappe.log_error(f"Statement fetch failed for {_mask(iban)}: {e}", "KB Load Counterparties")
continue continue
resp_obj = data.get("response", {}) resp_obj = data.get("response", {})
@ -151,60 +152,76 @@ def load_clients_from_statements(from_date, to_date, login_name=None):
) )
debug_log.append(f" statementList entries: {len(statement_list)}") 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: for entry in statement_list:
contr_raw = (entry.get("contrAccount") or "").strip() contr_raw = (entry.get("contrAccount") or "").strip()
if not contr_raw: if not contr_raw:
continue 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(" / ")] parts = [p.strip() for p in contr_raw.split(" / ")]
if len(parts) < 2: if len(parts) < 2:
continue # unrecognised format, skip continue
contr_iban = parts[0]
contr_name = parts[1] contr_name = parts[1]
contr_voen = parts[2] if len(parts) >= 3 else "" contr_voen = parts[2] if len(parts) >= 3 else ""
if not contr_name: if not contr_name:
continue continue
# Deduplicate: by VÖEN if present, else by client_name # Determine direction: D = money out = Supplier, C = money in = Customer
if contr_voen and frappe.db.exists("Kapital Bank Client", {"tax_id": contr_voen}): dr_cr = (entry.get("drcrInd") or "").upper()
skipped += 1
continue if dr_cr == "D":
if not contr_voen and frappe.db.exists("Kapital Bank Client", {"client_name": contr_name}): # Supplier
doctype = "Kapital Bank Supplier"
name_field = "supplier_name"
elif dr_cr == "C":
# Customer
doctype = "Kapital Bank Customer"
name_field = "customer_name"
else:
skipped += 1 skipped += 1
continue continue
doc = frappe.new_doc("Kapital Bank Client") # Deduplicate: by VÖEN if present, else by name
doc.client_name = contr_name if contr_voen and frappe.db.exists(doctype, {"tax_id": contr_voen}):
doc.tax_id = contr_voen or None # NULL doesn't violate unique constraint skipped += 1
doc.status = "New" continue
if not contr_voen and frappe.db.exists(doctype, {name_field: contr_name}):
skipped += 1
continue
doc = frappe.new_doc(doctype)
doc.update({
name_field: contr_name,
"tax_id": contr_voen or None,
"iban": contr_iban or None,
"status": "New",
})
try: try:
doc.insert(ignore_permissions=True) doc.insert(ignore_permissions=True)
created += 1 if dr_cr == "D":
suppliers_created += 1
else:
customers_created += 1
except frappe.DuplicateEntryError: except frappe.DuplicateEntryError:
skipped += 1 skipped += 1
except Exception as ins_e: except Exception as ins_e:
frappe.log_error(f"Insert client failed for {_mask(contr_raw)}: {ins_e}", "KB Load Clients") frappe.log_error(f"Insert counterparty failed for {_mask(contr_raw)}: {ins_e}", "KB Load Counterparties")
frappe.db.commit() frappe.db.commit()
frappe.log_error("\n".join(debug_log), "KB Load Counterparties Debug")
# Always log the debug info so we can diagnose issues return {
frappe.log_error("\n".join(debug_log), "KB Load Clients Debug") "success": True,
"customers_created": customers_created,
return {"success": True, "created": created, "skipped": skipped, "debug": debug_log} "suppliers_created": suppliers_created,
"skipped": skipped,
"debug": debug_log,
}
except Exception as e: except Exception as e:
frappe.log_error(f"load_clients_from_statements: {e}\n{frappe.get_traceback()}", "KB Bank API") frappe.log_error(f"load_counterparties_from_statements: {e}\n{frappe.get_traceback()}", "KB Bank API")
return {"success": False, "message": str(e)} return {"success": False, "message": str(e)}
@ -224,7 +241,8 @@ def get_kb_reference_data_summary():
summary = { summary = {
"accounts": counts("Kapital Bank Account", {"status": "Mapped"}), "accounts": counts("Kapital Bank Account", {"status": "Mapped"}),
"cards": counts("Kapital Bank Card", {"status": "Mapped"}), "cards": counts("Kapital Bank Card", {"status": "Mapped"}),
"clients": counts("Kapital Bank Client", {"status": ["in", ["Mapped", "Active"]]}), "customers": counts("Kapital Bank Customer", {"status": "Mapped"}),
"suppliers": counts("Kapital Bank Supplier", {"status": "Mapped"}),
} }
return {"success": True, "summary": summary} return {"success": True, "summary": summary}
except Exception as e: except Exception as e:
@ -259,14 +277,24 @@ def get_kb_reference_data_list(data_type, limit=100, offset=0):
order_by="card_type asc" order_by="card_type asc"
) )
elif data_type == "clients": elif data_type == "customers":
total = frappe.db.count("Kapital Bank Client") total = frappe.db.count("Kapital Bank Customer")
data = frappe.get_all( data = frappe.get_all(
"Kapital Bank Client", "Kapital Bank Customer",
fields=["name", "client_name", "tax_id", "iban", "bank_code", fields=["name", "customer_name", "tax_id", "iban", "bank_code",
"status", "mapped_party_type", "mapped_party", "creation"], "status", "mapped_customer", "creation"],
limit=limit, start=offset, limit=limit, start=offset,
order_by="client_name asc" order_by="customer_name asc"
)
elif data_type == "suppliers":
total = frappe.db.count("Kapital Bank Supplier")
data = frappe.get_all(
"Kapital Bank Supplier",
fields=["name", "supplier_name", "tax_id", "iban", "bank_code",
"status", "mapped_supplier", "creation"],
limit=limit, start=offset,
order_by="supplier_name asc"
) )
else: else:
@ -308,105 +336,110 @@ def get_unmapped_accounts():
@frappe.whitelist() @frappe.whitelist()
def get_unmapped_clients(): def get_unmapped_customers():
"""Return Kapital Bank Clients with status='New' not yet in client_mappings.""" """Return Kapital Bank Customers with status='New' not yet in customer_mappings."""
try: try:
settings = frappe.get_single("Kapital Bank Settings") settings = frappe.get_single("Kapital Bank Settings")
already_mapped = {row.client_name for row in settings.client_mappings if row.client_name} already_mapped = {row.kb_customer_name for row in settings.customer_mappings if row.kb_customer_name}
clients = frappe.get_all( customers = frappe.get_all(
"Kapital Bank Client", "Kapital Bank Customer",
filters={"status": "New"}, filters={"status": "New"},
fields=["name", "client_name", "tax_id", "iban", "mapped_party_type", "mapped_party"] fields=["name", "customer_name", "tax_id", "iban"]
) )
unmapped = [c for c in clients if c.client_name not in already_mapped] unmapped = [c for c in customers if c.name not in already_mapped]
return {"success": True, "clients": unmapped} return {"success": True, "customers": unmapped}
except Exception as e: except Exception as e:
frappe.log_error(str(e), "KB get_unmapped_clients") frappe.log_error(str(e), "KB get_unmapped_customers")
return {"success": False, "message": str(e)}
@frappe.whitelist()
def get_unmapped_suppliers():
"""Return Kapital Bank Suppliers with status='New' not yet in supplier_mappings."""
try:
settings = frappe.get_single("Kapital Bank Settings")
already_mapped = {row.kb_supplier_name for row in settings.supplier_mappings if row.kb_supplier_name}
suppliers = frappe.get_all(
"Kapital Bank Supplier",
filters={"status": "New"},
fields=["name", "supplier_name", "tax_id", "iban"]
)
unmapped = [s for s in suppliers if s.name not in already_mapped]
return {"success": True, "suppliers": unmapped}
except Exception as e:
frappe.log_error(str(e), "KB get_unmapped_suppliers")
return {"success": False, "message": str(e)} return {"success": False, "message": str(e)}
# ═══════════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════════
# CLIENT MATCHING & CREATION # CUSTOMER MATCHING & CREATION
# ═══════════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════════
@frappe.whitelist() @frappe.whitelist()
def match_similar_clients(): def match_similar_customers():
"""Auto-match Kapital Bank Clients to ERPNext Customer/Supplier by name similarity. """Auto-match Kapital Bank Customers to ERPNext Customers by name similarity."""
Follows invoice_az pattern exactly."""
try: try:
doc = frappe.get_doc("Kapital Bank Settings") doc = frappe.get_doc("Kapital Bank Settings")
threshold = flt(doc.similarity_threshold or 95) / 100.0 threshold = flt(doc.similarity_threshold or 95) / 100.0
consider_azeri = bool(doc.consider_azeri_chars) consider_azeri = bool(doc.consider_azeri_chars)
new_clients = frappe.get_all( new_customers = frappe.get_all(
"Kapital Bank Client", "Kapital Bank Customer",
filters={"status": "New"}, filters={"status": "New"},
fields=["name", "client_name", "tax_id"] fields=["name", "customer_name", "tax_id"]
) )
if not new_clients: if not new_customers:
return {"success": True, "matched_count": 0, "total_processed": 0} return {"success": True, "matched_count": 0, "total_processed": 0}
customers = frappe.get_all("Customer", fields=["name", "customer_name", "tax_id"]) erp_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 # VÖEN lookup for exact matching
voen_index = {} voen_index = {}
for c in customers: for c in erp_customers:
if c.tax_id: if c.tax_id:
voen_index[c.tax_id.strip()] = (c.name, "Customer") voen_index[c.tax_id.strip()] = c.name
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 # Build dict of existing mappings: kb_customer_name → erp_customer
existing_mappings = { existing_mappings = {
row.client_name: row.erp_party row.kb_customer_name: row.erp_customer
for row in doc.client_mappings if row.client_name for row in doc.customer_mappings if row.kb_customer_name
} }
# Group 1: clients already in the table but with empty erp_party # Group 1: customers already in the table but with empty erp_customer
clients_with_empty_mapping = [] customers_with_empty_mapping = []
for client_name, erp_party in existing_mappings.items(): for kb_name, erp_customer in existing_mappings.items():
if not erp_party: if not erp_customer:
for c in new_clients: for c in new_customers:
if c.client_name == client_name: if c.name == kb_name:
clients_with_empty_mapping.append(c) customers_with_empty_mapping.append(c)
break break
# Group 2: clients not in the table at all # Group 2: customers not in the table at all
clients_not_in_table = [c for c in new_clients if c.client_name not in existing_mappings] customers_not_in_table = [c for c in new_customers if c.name not in existing_mappings]
# Process both groups (same as invoice_az) customers_to_process = customers_not_in_table + customers_with_empty_mapping
clients_to_process = clients_not_in_table + clients_with_empty_mapping
matched = 0 matched = 0
for client in clients_to_process: for customer in customers_to_process:
best_party = None best_party = None
best_type = None
# Priority 1: exact VÖEN match # Priority 1: exact VÖEN match
if client.tax_id and client.tax_id.strip() in voen_index: if customer.tax_id and customer.tax_id.strip() in voen_index:
best_party, best_type = voen_index[client.tax_id.strip()] best_party = voen_index[customer.tax_id.strip()]
else: else:
# Priority 2: name similarity # Priority 2: name similarity
kb_name = _normalize(client.client_name, consider_azeri) kb_name = _normalize(customer.customer_name, consider_azeri)
best_score = 0 best_score = 0
for c in customers: for c in erp_customers:
score = SequenceMatcher(None, kb_name, _normalize(c.customer_name, consider_azeri)).ratio() score = SequenceMatcher(None, kb_name, _normalize(c.customer_name, consider_azeri)).ratio()
if score > best_score: if score > best_score:
best_score = score best_score = score
best_party = c.name 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: if best_score < threshold:
best_party = None best_party = None
@ -414,27 +447,24 @@ def match_similar_clients():
if best_party: if best_party:
# Update existing row if present, else append new row # Update existing row if present, else append new row
existing_idx = None existing_idx = None
for idx, row in enumerate(doc.client_mappings): for idx, row in enumerate(doc.customer_mappings):
if row.client_name == client.client_name: if row.kb_customer_name == customer.name:
existing_idx = idx existing_idx = idx
break break
if existing_idx is not None: if existing_idx is not None:
doc.client_mappings[existing_idx].party_type = best_type doc.customer_mappings[existing_idx].erp_customer = best_party
doc.client_mappings[existing_idx].erp_party = best_party doc.customer_mappings[existing_idx].mapping_type = "Automatic"
doc.client_mappings[existing_idx].mapping_type = "Automatic"
else: else:
doc.append("client_mappings", { doc.append("customer_mappings", {
"client_name": client.client_name, "kb_customer_name": customer.name,
"tax_id": client.tax_id or None, "tax_id": customer.tax_id or None,
"party_type": best_type, "erp_customer": best_party,
"erp_party": best_party,
"mapping_type": "Automatic", "mapping_type": "Automatic",
}) })
frappe.db.set_value("Kapital Bank Client", client.name, { frappe.db.set_value("Kapital Bank Customer", customer.name, {
"mapped_party_type": best_type, "mapped_customer": best_party,
"mapped_party": best_party,
"status": "Mapped", "status": "Mapped",
}, update_modified=False) }, update_modified=False)
matched += 1 matched += 1
@ -443,112 +473,254 @@ def match_similar_clients():
doc.save(ignore_permissions=True) doc.save(ignore_permissions=True)
frappe.db.commit() frappe.db.commit()
return {"success": True, "matched_count": matched, "total_processed": len(clients_to_process)} return {"success": True, "matched_count": matched, "total_processed": len(customers_to_process)}
except Exception as e: except Exception as e:
frappe.log_error(f"match_similar_clients: {e}\n{frappe.get_traceback()}", "KB Client Matching") frappe.log_error(f"match_similar_customers: {e}\n{frappe.get_traceback()}", "KB Customer Matching")
return {"success": False, "message": str(e)} return {"success": False, "message": str(e)}
@frappe.whitelist() @frappe.whitelist()
def create_unmapped_clients(): def match_similar_suppliers():
"""Create Customer/Supplier records for New Kapital Bank Clients that have no mapped party. """Auto-match Kapital Bank Suppliers to ERPNext Suppliers by name similarity."""
Follows invoice_az pattern: also appends created rows to Settings.client_mappings."""
try: 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") doc = frappe.get_doc("Kapital Bank Settings")
already_in_mappings = {row.client_name for row in doc.client_mappings if row.client_name} threshold = flt(doc.similarity_threshold or 95) / 100.0
consider_azeri = bool(doc.consider_azeri_chars)
created = customers_count = suppliers_count = 0 new_suppliers = frappe.get_all(
"Kapital Bank Supplier",
filters={"status": "New"},
fields=["name", "supplier_name", "tax_id"]
)
if not new_suppliers:
return {"success": True, "matched_count": 0, "total_processed": 0}
for c in clients: erp_suppliers = frappe.get_all("Supplier", fields=["name", "supplier_name", "tax_id"])
if not c.mapped_party_type:
continue # VÖEN lookup for exact matching
try: voen_index = {}
if c.mapped_party_type == "Customer": for s in erp_suppliers:
party = frappe.new_doc("Customer") if s.tax_id:
party.customer_name = c.client_name voen_index[s.tax_id.strip()] = s.name
if doc.default_customer_group:
party.customer_group = doc.default_customer_group # Build dict of existing mappings: kb_supplier_name → erp_supplier
if doc.default_territory: existing_mappings = {
party.territory = doc.default_territory row.kb_supplier_name: row.erp_supplier
if doc.default_payment_terms: for row in doc.supplier_mappings if row.kb_supplier_name
party.payment_terms = doc.default_payment_terms }
party.insert(ignore_permissions=True)
customers_count += 1 # Group 1: suppliers already in the table but with empty erp_supplier
elif c.mapped_party_type == "Supplier": suppliers_with_empty_mapping = []
party = frappe.new_doc("Supplier") for kb_name, erp_supplier in existing_mappings.items():
party.supplier_name = c.client_name if not erp_supplier:
if doc.default_supplier_group: for s in new_suppliers:
party.supplier_group = doc.default_supplier_group if s.name == kb_name:
if doc.default_payment_terms: suppliers_with_empty_mapping.append(s)
party.payment_terms = doc.default_payment_terms break
party.insert(ignore_permissions=True)
suppliers_count += 1 # Group 2: suppliers not in the table at all
suppliers_not_in_table = [s for s in new_suppliers if s.name not in existing_mappings]
suppliers_to_process = suppliers_not_in_table + suppliers_with_empty_mapping
matched = 0
for supplier in suppliers_to_process:
best_party = None
# Priority 1: exact VÖEN match
if supplier.tax_id and supplier.tax_id.strip() in voen_index:
best_party = voen_index[supplier.tax_id.strip()]
else: else:
continue # Priority 2: name similarity
kb_name = _normalize(supplier.supplier_name, consider_azeri)
best_score = 0
frappe.db.set_value("Kapital Bank Client", c.name, { for s in erp_suppliers:
"mapped_party_type": c.mapped_party_type, score = SequenceMatcher(None, kb_name, _normalize(s.supplier_name, consider_azeri)).ratio()
"mapped_party": party.name, if score > best_score:
best_score = score
best_party = s.name
if best_score < threshold:
best_party = None
if best_party:
existing_idx = None
for idx, row in enumerate(doc.supplier_mappings):
if row.kb_supplier_name == supplier.name:
existing_idx = idx
break
if existing_idx is not None:
doc.supplier_mappings[existing_idx].erp_supplier = best_party
doc.supplier_mappings[existing_idx].mapping_type = "Automatic"
else:
doc.append("supplier_mappings", {
"kb_supplier_name": supplier.name,
"tax_id": supplier.tax_id or None,
"erp_supplier": best_party,
"mapping_type": "Automatic",
})
frappe.db.set_value("Kapital Bank Supplier", supplier.name, {
"mapped_supplier": best_party,
"status": "Mapped", "status": "Mapped",
}, update_modified=False) }, update_modified=False)
matched += 1
if c.client_name not in already_in_mappings: if matched > 0:
doc.append("client_mappings", { doc.save(ignore_permissions=True)
"client_name": c.client_name,
"tax_id": c.tax_id or None, frappe.db.commit()
"party_type": c.mapped_party_type, return {"success": True, "matched_count": matched, "total_processed": len(suppliers_to_process)}
"erp_party": party.name,
"mapping_type": "Manual", except Exception as e:
}) frappe.log_error(f"match_similar_suppliers: {e}\n{frappe.get_traceback()}", "KB Supplier Matching")
already_in_mappings.add(c.client_name) return {"success": False, "message": str(e)}
@frappe.whitelist()
def create_unmapped_customers(settings_name=None):
"""Create Customer records for unmapped customers from Settings.customer_mappings table."""
try:
doc = frappe.get_doc("Kapital Bank Settings")
unmapped = []
for mapping in doc.customer_mappings:
if not mapping.erp_customer and mapping.kb_customer_name:
if frappe.db.exists("Kapital Bank Customer", mapping.kb_customer_name):
kb_customer = frappe.get_doc("Kapital Bank Customer", mapping.kb_customer_name)
unmapped.append((mapping, kb_customer))
if not unmapped:
return {"success": True, "created_count": 0, "message": "No unmapped customers in table to create"}
created = 0
for mapping, kb_customer in unmapped:
try:
customer_name = kb_customer.customer_name
if frappe.db.exists("Customer", {"customer_name": customer_name}):
continue
party = frappe.new_doc("Customer")
party.customer_name = customer_name
party.customer_type = "Company"
customer_group = mapping.customer_group or doc.default_customer_group
if not customer_group or not frappe.db.exists("Customer Group", customer_group):
customer_group = "All Customer Groups"
party.customer_group = customer_group
territory = mapping.territory or doc.default_territory or "All Territories"
if not frappe.db.exists("Territory", territory):
territory = "All Territories"
party.territory = territory
if kb_customer.tax_id:
party.tax_id = kb_customer.tax_id
payment_terms = mapping.payment_terms or doc.default_payment_terms
if payment_terms:
party.payment_terms = payment_terms
party.insert(ignore_permissions=True)
# Update mapping row
mapping.erp_customer = party.name
mapping.mapping_type = "Automatic"
# Update KB Customer status
frappe.db.set_value("Kapital Bank Customer", kb_customer.name, {
"status": "Mapped",
"mapped_customer": party.name,
"customer_group": party.customer_group,
"territory": party.territory,
"payment_terms": payment_terms or None,
}, update_modified=False)
created += 1 created += 1
except frappe.DuplicateEntryError: except Exception as e:
existing = None frappe.log_error(f"create_unmapped_customers: error for {kb_customer.customer_name}: {e}", "KB Customer Creation")
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: if created > 0:
doc.save(ignore_permissions=True) doc.save(ignore_permissions=True)
frappe.db.commit() frappe.db.commit()
return { return {"success": True, "created_count": created}
"success": True,
"created_count": created,
"customers_count": customers_count,
"suppliers_count": suppliers_count,
}
except Exception as e: except Exception as e:
frappe.log_error(f"create_unmapped_clients: {e}\n{frappe.get_traceback()}", "KB Client Creation") frappe.log_error(f"create_unmapped_customers: {e}\n{frappe.get_traceback()}", "KB Customer Creation")
return {"success": False, "message": str(e)}
@frappe.whitelist()
def create_unmapped_suppliers(settings_name=None):
"""Create Supplier records for unmapped suppliers from Settings.supplier_mappings table."""
try:
doc = frappe.get_doc("Kapital Bank Settings")
unmapped = []
for mapping in doc.supplier_mappings:
if not mapping.erp_supplier and mapping.kb_supplier_name:
if frappe.db.exists("Kapital Bank Supplier", mapping.kb_supplier_name):
kb_supplier = frappe.get_doc("Kapital Bank Supplier", mapping.kb_supplier_name)
unmapped.append((mapping, kb_supplier))
if not unmapped:
return {"success": True, "created_count": 0, "message": "No unmapped suppliers in table to create"}
created = 0
for mapping, kb_supplier in unmapped:
try:
supplier_name = kb_supplier.supplier_name
if frappe.db.exists("Supplier", {"supplier_name": supplier_name}):
continue
party = frappe.new_doc("Supplier")
party.supplier_name = supplier_name
party.supplier_type = "Company"
supplier_group = mapping.supplier_group or doc.default_supplier_group
if not supplier_group or not frappe.db.exists("Supplier Group", supplier_group):
supplier_group = "All Supplier Groups"
party.supplier_group = supplier_group
if kb_supplier.tax_id:
party.tax_id = kb_supplier.tax_id
payment_terms = mapping.payment_terms or doc.default_payment_terms
if payment_terms:
party.payment_terms = payment_terms
party.insert(ignore_permissions=True)
# Update mapping row
mapping.erp_supplier = party.name
mapping.mapping_type = "Automatic"
# Update KB Supplier status
frappe.db.set_value("Kapital Bank Supplier", kb_supplier.name, {
"status": "Mapped",
"mapped_supplier": party.name,
"supplier_group": party.supplier_group,
"payment_terms": payment_terms or None,
}, update_modified=False)
created += 1
except Exception as e:
frappe.log_error(f"create_unmapped_suppliers: error for {kb_supplier.supplier_name}: {e}", "KB Supplier Creation")
if created > 0:
doc.save(ignore_permissions=True)
frappe.db.commit()
return {"success": True, "created_count": created}
except Exception as e:
frappe.log_error(f"create_unmapped_suppliers: {e}\n{frappe.get_traceback()}", "KB Supplier Creation")
return {"success": False, "message": str(e)} return {"success": False, "message": str(e)}
@ -610,6 +782,245 @@ def get_card_statement(login_name=None, account_no=None, from_date=None, to_date
return {"success": False, "message": str(e)} return {"success": False, "message": str(e)}
# ═══════════════════════════════════════════════════════════════════════════════
# STATEMENT IMPORT (BIRBank → Payment Entry)
# ═══════════════════════════════════════════════════════════════════════════════
def _parse_txn_date(trn_dt):
"""Parse transaction date string into a date object. Returns None on failure."""
trn_dt = (trn_dt or "").strip()
if not trn_dt:
return None
for fmt in ("%b %d, %Y", "%b %d,%Y"):
try:
return datetime.strptime(trn_dt, fmt).date()
except ValueError:
continue
return None
def _parse_counterparty(contr_raw):
"""Parse contrAccount string → (name, voen)."""
parts = [p.strip() for p in (contr_raw or "").split(" / ")]
contr_name = parts[1] if len(parts) > 1 else ""
contr_voen = parts[2] if len(parts) > 2 else ""
return contr_name, contr_voen
@frappe.whitelist()
def get_statement_transactions(from_date, to_date, account_iban, login_name=None):
"""Fetch bank statement and return parsed transactions for UI selection.
Filters out duplicates (already imported by reference_no).
Returns: {success, transactions: [...], total_fetched, skipped_duplicates}
"""
try:
account_iban = account_iban.strip()
# Find cust_ac_no for this IBAN
cust_ac_no = frappe.db.get_value("Kapital Bank Account", account_iban, "cust_ac_no")
if not cust_ac_no:
return {"success": False, "message": f"Account {account_iban} not found in registry."}
# Fetch statement
client = BIRBankClient(login_name)
resp = client.get(
"/v2/statement/account",
params={
"accountNumber": cust_ac_no,
"fromDate": _fmt_date(from_date),
"toDate": _fmt_date(to_date),
}
)
if not resp.ok:
return {"success": False, "message": f"HTTP {resp.status_code}: {resp.text[:200]}"}
data = resp.json()
resp_code = data.get("response", {}).get("code", "?")
if resp_code != "0":
resp_msg = data.get("response", {}).get("message", "")
return {"success": False, "message": f"API error code={resp_code}: {resp_msg}"}
statement_list = (
data.get("responseData", {})
.get("operations", {})
.get("statementList", [])
)
transactions = []
skipped_duplicates = 0
for txn in statement_list:
trn_ref = (txn.get("trnRefNo") or "").strip()
# Deduplication
if trn_ref and frappe.db.exists("Payment Entry", {"reference_no": trn_ref}):
skipped_duplicates += 1
continue
# Parse date
parsed_date = _parse_txn_date(txn.get("trnDt"))
if not parsed_date:
continue
# Amount and currency
amount = abs(flt(txn.get("fcyAmount") or txn.get("lcyAmount") or 0))
currency = (txn.get("acCcy") or "AZN").strip()
# Direction
dr_cr = (txn.get("drcrInd") or "").upper()
# Counterparty
contr_name, contr_voen = _parse_counterparty(txn.get("contrAccount"))
# Purpose
purpose = (txn.get("purpose") or "").strip()
transactions.append({
"ref_no": trn_ref,
"date": str(parsed_date),
"counterparty": contr_name,
"contr_voen": contr_voen,
"amount": amount,
"currency": currency,
"drcr": dr_cr or "D",
"purpose": purpose,
"raw_data": txn,
})
return {
"success": True,
"transactions": transactions,
"total_fetched": len(statement_list),
"skipped_duplicates": skipped_duplicates,
}
except Exception as e:
frappe.log_error(f"get_statement_transactions: {e}\n{frappe.get_traceback()}", "KB Import Statement")
return {"success": False, "message": str(e)}
@frappe.whitelist()
def import_single_transaction(txn_data, account_iban, login_name=None):
"""Import a single transaction → create Payment Entry.
txn_data comes as JSON string from the JS client.
Returns: {success, payment_entry} or {success: False, error_type, message}
"""
try:
if isinstance(txn_data, str):
txn_data = json.loads(txn_data)
settings = frappe.get_single("Kapital Bank Settings")
# Build lookup dicts
purpose_rules = []
for row in settings.purpose_mappings:
if row.purpose_keyword and row.paid_from and row.paid_to:
purpose_rules.append((row.purpose_keyword.lower(), row.paid_from, row.paid_to))
# Build party lookups from BOTH customer and supplier mappings
voen_to_party = {}
name_to_party = {}
for row in settings.customer_mappings:
if not row.erp_customer:
continue
entry = {"party_type": "Customer", "erp_party": row.erp_customer}
if row.tax_id:
voen_to_party[row.tax_id.strip()] = entry
if row.kb_customer_name:
# Resolve KB Customer name → customer_name for name matching
kb_customer_name = frappe.db.get_value("Kapital Bank Customer", row.kb_customer_name, "customer_name")
if kb_customer_name:
name_to_party[kb_customer_name.strip()] = entry
for row in settings.supplier_mappings:
if not row.erp_supplier:
continue
entry = {"party_type": "Supplier", "erp_party": row.erp_supplier}
if row.tax_id:
voen_to_party[row.tax_id.strip()] = entry
if row.kb_supplier_name:
kb_supplier_name = frappe.db.get_value("Kapital Bank Supplier", row.kb_supplier_name, "supplier_name")
if kb_supplier_name:
name_to_party[kb_supplier_name.strip()] = entry
# Parse fields
ref_no = txn_data.get("ref_no", "")
parsed_date = txn_data.get("date")
amount = abs(flt(txn_data.get("amount", 0)))
dr_cr = (txn_data.get("drcr") or "D").upper()
payment_type = "Pay" if dr_cr == "D" else "Receive"
purpose = (txn_data.get("purpose") or "").strip()
contr_name = (txn_data.get("counterparty") or "").strip()
contr_voen = (txn_data.get("contr_voen") or "").strip()
# Purpose mapping
purpose_lower = purpose.lower()
paid_from = paid_to = None
for keyword, pf, pt in purpose_rules:
if keyword in purpose_lower:
paid_from = pf
paid_to = pt
break
if not paid_from or not paid_to:
return {
"success": False,
"error_type": "unmapped_purpose",
"message": f"No purpose mapping for: {purpose}",
"unmatched_purpose": [{"purpose": purpose}],
}
# Party mapping: VOEN → name → None
party_type = None
erp_party = None
if contr_voen and contr_voen in voen_to_party:
party_type = voen_to_party[contr_voen]["party_type"]
erp_party = voen_to_party[contr_voen]["erp_party"]
elif contr_name and contr_name in name_to_party:
party_type = name_to_party[contr_name]["party_type"]
erp_party = name_to_party[contr_name]["erp_party"]
if not party_type or not erp_party:
return {
"success": False,
"error_type": "unmapped_party",
"message": f"No party mapping for: {contr_name} (VOEN: {contr_voen})",
"unmatched_party": [{"name": contr_name, "voen": contr_voen}],
}
# Create Payment Entry
pe = frappe.new_doc("Payment Entry")
pe.payment_type = payment_type
pe.company = settings.default_company
pe.posting_date = parsed_date
pe.paid_from = paid_from
pe.paid_to = paid_to
pe.paid_amount = amount
pe.received_amount = amount
pe.reference_no = ref_no
pe.reference_date = parsed_date
pe.remarks = purpose
pe.party_type = party_type
pe.party = erp_party
try:
pe.insert(ignore_permissions=True)
pe.submit()
except Exception as pe_err:
frappe.db.rollback()
frappe.local.message_log = []
return {"success": False, "error_type": "import_error", "message": str(pe_err)}
return {"success": True, "payment_entry": pe.name}
except Exception as e:
frappe.local.message_log = []
return {"success": False, "error_type": "import_error", "message": str(e)}
# ═══════════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════════
# INTERNAL HELPERS # INTERNAL HELPERS
# ═══════════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════════

View File

@ -1,72 +1,592 @@
frappe.ui.form.on('Payment Entry', { const KBImport = {
refresh(frm) { cancelLoading: false,
if (frm.doc.docstatus !== 1) return; loadingErrors: [],
PROGRESS_UPDATE_DELAY: 50,
};
const kb_status = frm.doc.kb_transfer_status; // ======= ERROR MANAGEMENT =======
KBImport.errors = {
add: function(txnData, errorType, errorMessage, additionalData = {}) {
const errorEntry = {
ref_no: txnData.ref_no || 'Unknown',
date: txnData.date || 'Unknown',
counterparty: txnData.counterparty || 'Unknown',
amount: txnData.amount || 0,
error_type: errorType,
error_message: errorMessage,
timestamp: new Date().toISOString(),
...additionalData
};
frm.add_custom_button(__('Send Transfer'), () => { KBImport.loadingErrors.push(errorEntry);
kb_send(frm, 'kapital_bank.payment_api.send_transfer', __('Transfer sent')); console.error('KB Import Error:', errorEntry);
}, __('Kapital Bank')); },
frm.add_custom_button(__('Send Card Transfer'), () => { clear: function() {
kb_send(frm, 'kapital_bank.payment_api.send_card_transfer', __('Card transfer sent')); frappe.confirm(
}, __('Kapital Bank')); __('Are you sure you want to clear the error log?'),
function() {
frm.add_custom_button(__('Send VAT Payment'), () => { KBImport.loadingErrors = [];
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({ frappe.show_alert({
message: __('Status: {0}', [r.message.status]), message: __('Error log cleared'),
indicator: 'green'
}, 2);
}
);
},
showDetails: function() {
if (KBImport.loadingErrors.length === 0) {
frappe.msgprint(__('No errors to display'));
return;
}
let errorsHtml = '<div class="error-log-container">';
KBImport.loadingErrors.forEach(function(error) {
let documentDate = 'No date';
if (error.date && error.date !== 'Unknown') {
try {
documentDate = moment(error.date).format('DD.MM.YYYY');
} catch (e) {
documentDate = 'Invalid date';
}
}
errorsHtml += '<div class="error-item" style="margin-bottom: 20px; padding: 15px; border: 1px solid var(--border-color); border-radius: 6px; background: var(--bg-color);">';
// Header
errorsHtml += '<div class="error-header" style="margin-bottom: 10px; padding-bottom: 8px; border-bottom: 1px solid var(--border-color);">';
errorsHtml += '<div style="display: flex; justify-content: space-between; align-items: center;">';
errorsHtml += '<h5 style="margin: 0; color: var(--text-color);">' +
'<strong>' + error.ref_no + '</strong></h5>';
errorsHtml += '<span class="label label-danger">' + error.error_type + '</span>';
errorsHtml += '</div>';
errorsHtml += '<small style="color: var(--text-muted);">' + error.counterparty + '</small>';
errorsHtml += '</div>';
// Error message
errorsHtml += '<div class="error-message" style="margin-bottom: 10px;">';
errorsHtml += '<strong style="color: var(--text-color);">' + error.error_message + '</strong>';
errorsHtml += '</div>';
// Details — unmapped purpose
if (error.unmatched_purpose && error.unmatched_purpose.length > 0) {
errorsHtml += '<div class="error-details">';
errorsHtml += '<div style="font-weight: 600; margin-bottom: 5px; color: var(--text-color);">' + __('Unmapped purpose:') + '</div>';
errorsHtml += '<ul style="margin: 0; padding-left: 20px; color: var(--text-muted);">';
error.unmatched_purpose.forEach(function(item) {
errorsHtml += '<li>' + item.purpose + '</li>';
});
errorsHtml += '</ul></div>';
}
// Details — unmapped party
if (error.unmatched_party && error.unmatched_party.length > 0) {
errorsHtml += '<div class="error-details">';
errorsHtml += '<div style="font-weight: 600; margin-bottom: 5px; color: var(--text-color);">' + __('Unmapped party:') + '</div>';
errorsHtml += '<ul style="margin: 0; padding-left: 20px; color: var(--text-muted);">';
error.unmatched_party.forEach(function(party) {
errorsHtml += '<li>' + party.name;
if (party.voen) {
errorsHtml += ' <em>(VOEN: ' + party.voen + ')</em>';
}
errorsHtml += '</li>';
});
errorsHtml += '</ul></div>';
}
errorsHtml += '<div style="text-align: right; margin-top: 10px;">';
errorsHtml += '<small style="color: var(--text-muted);"><em>' + documentDate + '</em></small>';
errorsHtml += '</div>';
errorsHtml += '</div>';
});
errorsHtml += '</div>';
// Statistics
const errorTypes = {};
KBImport.loadingErrors.forEach(function(error) {
errorTypes[error.error_type] = (errorTypes[error.error_type] || 0) + 1;
});
let statsHtml = '<div style="margin-bottom: 20px; padding: 15px; background: var(--bg-light); border-radius: 6px; border: 1px solid var(--border-color);">';
statsHtml += '<div style="display: flex; justify-content: space-between; align-items: center;">';
statsHtml += '<div>';
statsHtml += '<strong style="color: var(--text-color);">' + __('Total Errors:') + '</strong> ' + KBImport.loadingErrors.length + '<br>';
statsHtml += '<span style="color: var(--text-muted);">';
Object.keys(errorTypes).forEach(function(type, index) {
if (index > 0) statsHtml += ' &bull; ';
statsHtml += type + ': ' + errorTypes[type];
});
statsHtml += '</span>';
statsHtml += '</div>';
statsHtml += '<div>';
statsHtml += '<button class="btn btn-default btn-sm" id="kb-export-csv-dialog-btn" style="margin-right: 5px;">' +
'<i class="fa fa-download"></i> ' + __('Export CSV') + '</button>';
statsHtml += '<button class="btn btn-warning btn-sm" id="kb-clear-log-dialog-btn">' +
'<i class="fa fa-trash"></i> ' + __('Clear') + '</button>';
statsHtml += '</div></div>';
const d = new frappe.ui.Dialog({
title: __('Error Details') + ' (' + KBImport.loadingErrors.length + ')',
size: 'large',
fields: [
{
fieldname: 'stats_html',
fieldtype: 'HTML',
options: statsHtml
},
{
fieldname: 'errors_html',
fieldtype: 'HTML',
options: '<div style="max-height: 500px; overflow-y: auto;">' + errorsHtml + '</div>'
}
],
primary_action_label: __('Close'),
primary_action: function() {
d.hide();
}
});
d.show();
setTimeout(function() {
$('#kb-export-csv-dialog-btn').off('click').on('click', function() {
KBImport.errors.exportCSV();
});
$('#kb-clear-log-dialog-btn').off('click').on('click', function() {
KBImport.errors.clear();
d.hide();
});
}, 200);
},
exportCSV: function() {
if (KBImport.loadingErrors.length === 0) {
frappe.msgprint(__('No errors to export'));
return;
}
try {
let csvContent = "data:text/csv;charset=utf-8,";
csvContent += "Ref No,Date,Counterparty,Amount,Error Type,Error Message,Timestamp\n";
KBImport.loadingErrors.forEach(function(error) {
const cleanMessage = error.error_message.replace(/"/g, '""').replace(/\n/g, ' ').replace(/\r/g, ' ');
const cleanCounterparty = (error.counterparty || '').replace(/"/g, '""');
const row = [
error.ref_no,
error.date,
cleanCounterparty,
error.amount,
error.error_type,
cleanMessage,
moment(error.timestamp).format('DD.MM.YYYY HH:mm:ss')
].map(function(field) {
return '"' + (field || '') + '"';
}).join(',');
csvContent += row + "\n";
});
const encodedUri = encodeURI(csvContent);
const link = document.createElement("a");
link.setAttribute("href", encodedUri);
link.setAttribute("download", "kb_import_errors_" + moment().format('YYYY-MM-DD_HH-mm-ss') + ".csv");
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
frappe.show_alert({
message: __('Error log exported successfully'),
indicator: 'green'
}, 3);
} catch (e) {
console.error('Export error:', e);
frappe.show_alert({
message: __('Failed to export error log'),
indicator: 'red'
}, 3);
}
},
showSummary: function(totalTransactions, processedCount, errorsCount) {
if (errorsCount === 0) {
frappe.msgprint({
title: __('Import Completed Successfully'),
indicator: 'green',
message: __('All ') + totalTransactions + __(' transactions were imported successfully.')
});
} else {
const title = errorsCount === totalTransactions ? __('Import Failed') : __('Import Completed with Errors');
const indicator = processedCount > 0 ? 'orange' : 'red';
let message = '<div style="margin-bottom: 15px;">';
if (processedCount > 0) {
message += __('Successfully imported: ') + '<strong>' + processedCount + '</strong>' + __(' transactions') + '<br>';
}
message += __('Failed: ') + '<strong>' + errorsCount + '</strong>' + __(' transactions');
message += '</div>';
message += '<div style="text-align: center;">' +
'<button class="btn btn-primary btn-sm" id="kb-view-error-details-btn" style="margin-right: 10px;">' +
'<i class="fa fa-list"></i> ' + __('View Error Details') + '</button>' +
'<button class="btn btn-default btn-sm" id="kb-export-error-log-btn">' +
'<i class="fa fa-download"></i> ' + __('Export CSV') + '</button>' +
'</div>';
const summaryDialog = frappe.msgprint({
title: title,
indicator: indicator,
message: message
});
setTimeout(function() {
$('#kb-view-error-details-btn').off('click').on('click', function() {
summaryDialog.hide();
KBImport.errors.showDetails();
});
$('#kb-export-error-log-btn').off('click').on('click', function() {
KBImport.errors.exportCSV();
});
}, 200);
}
}
};
// ======= IMPORT MODULE =======
KBImport.import = {
showDialog: function() {
frappe.call({
method: 'frappe.client.get',
args: { doctype: 'Kapital Bank Settings', name: 'Kapital Bank Settings' },
callback: function(r) {
if (!r.message) {
frappe.msgprint({ title: __('Error'), indicator: 'red', message: __('Could not load Kapital Bank Settings') });
return;
}
const mappings = r.message.account_mappings || [];
const iban_options = mappings.filter(m => m.iban).map(m => m.iban);
if (iban_options.length === 0) {
frappe.msgprint({
title: __('No Accounts'),
indicator: 'orange',
message: __('Please add account mappings in Kapital Bank Settings first.')
});
return;
}
const today = frappe.datetime.get_today();
const first_day = frappe.datetime.month_start(today);
const d = new frappe.ui.Dialog({
title: __('Import Bank Transactions'),
fields: [
{ fieldname: 'sec_period', fieldtype: 'Section Break', label: __('Period') },
{ fieldname: 'date_from', fieldtype: 'Date', label: __('Date From'), reqd: 1, default: first_day },
{ fieldname: 'col_break', fieldtype: 'Column Break' },
{ fieldname: 'date_to', fieldtype: 'Date', label: __('Date To'), reqd: 1, default: today },
{ fieldname: 'sec_account', fieldtype: 'Section Break', label: __('Account') },
{
fieldname: 'account_iban',
fieldtype: 'Select',
label: __('Account IBAN'),
reqd: 1,
options: iban_options.join('\n')
}
],
primary_action_label: __('Search'),
primary_action: function() {
const values = d.get_values();
d.hide();
KBImport.import.loadTransactions(values.date_from, values.date_to, values.account_iban);
}
});
d.show();
}
});
},
loadTransactions: function(fromDate, toDate, accountIban) {
frappe.show_progress(__('Loading...'), 0, 100, __('Fetching transactions from Kapital Bank...'));
frappe.call({
method: 'kapital_bank.bank_api.get_statement_transactions',
args: {
from_date: fromDate,
to_date: toDate,
account_iban: accountIban
},
callback: function(r) {
frappe.hide_progress();
if (r.message && r.message.success) {
const txns = r.message.transactions || [];
const skipped = r.message.skipped_duplicates || 0;
if (txns.length === 0) {
let msg = __('No new transactions found for the specified period.');
if (skipped > 0) {
msg += ' ' + __('({0} already imported)', [skipped]);
}
frappe.msgprint({
title: __('Information'),
indicator: 'blue',
message: msg
});
return;
}
if (skipped > 0) {
frappe.show_alert({
message: __('Skipped {0} already imported transactions', [skipped]),
indicator: 'blue' indicator: 'blue'
}, 5); }, 5);
frm.reload_doc(); }
KBImport.import.showTransactionSelection(txns, accountIban);
} else { } else {
frappe.msgprint({ frappe.msgprint({
title: __('Error'), title: __('Error'),
message: r.message ? r.message.message : __('Unknown error'), indicator: 'red',
indicator: 'red' message: r.message ? r.message.message : __('Unknown error')
}); });
} }
},
error: function() {
frappe.hide_progress();
frappe.msgprint({ title: __('Error'), indicator: 'red', message: __('Network error while fetching transactions') });
} }
}); });
}, __('Kapital Bank')); },
// Show KB status badge if present showTransactionSelection: function(txns, accountIban) {
if (kb_status) { let txnTable = '<div style="max-height: 500px; overflow-y: auto;"><table class="table table-bordered kb-txn-table" style="width: 100%; table-layout: fixed;">';
const color = kb_status === 'Confirmed' ? 'green' txnTable += '<thead><tr>' +
: kb_status === 'Cancelled' ? 'red' '<th style="width: 5%;"><input type="checkbox" class="kb-select-all-txns"></th>' +
: 'blue'; '<th style="width: 15%;">' + __('Ref No') + '</th>' +
frm.dashboard.add_indicator(__('KB: {0}', [kb_status]), color); '<th style="width: 12%;">' + __('Date') + '</th>' +
} '<th style="width: 28%;">' + __('Counterparty') + '</th>' +
} '<th style="width: 14%; text-align: right;">' + __('Amount') + '</th>' +
}); '<th style="width: 6%; text-align: center;">' + __('Type') + '</th>' +
'<th style="width: 20%;">' + __('Purpose') + '</th>' +
'</tr></thead><tbody>';
function kb_send(frm, method, success_msg) { txns.forEach(function(txn, idx) {
frappe.call({ const displayDate = txn.date ? moment(txn.date).format('DD.MM.YYYY') : '';
method: method, const amountFmt = parseFloat(txn.amount || 0).toFixed(2);
args: { payment_entry_name: frm.doc.name }, const drcrLabel = txn.drcr === 'D'
freeze: true, ? '<span class="label label-danger">' + __('Pay') + '</span>'
freeze_message: __('Sending to BIRBank...'), : '<span class="label label-success">' + __('Receive') + '</span>';
callback(r) {
if (r.message && r.message.success) { txnTable += '<tr>' +
frappe.show_alert({ message: success_msg, indicator: 'green' }, 5); '<td><input type="checkbox" class="kb-select-txn" data-idx="' + idx + '"></td>' +
frm.reload_doc(); '<td style="word-break: break-word;">' + (txn.ref_no || '') + '</td>' +
} else { '<td>' + displayDate + '</td>' +
'<td style="word-break: break-word;">' + (txn.counterparty || '') + '</td>' +
'<td style="text-align: right;">' + amountFmt + ' ' + (txn.currency || '') + '</td>' +
'<td style="text-align: center;">' + drcrLabel + '</td>' +
'<td style="word-break: break-word; font-size: 0.9em;">' + (txn.purpose || '') + '</td>' +
'</tr>';
});
txnTable += '</tbody></table></div>';
const infoMessage = '<div class="alert alert-info">' +
__('Transactions found: ') + txns.length +
'</div>';
const d = new frappe.ui.Dialog({
title: __('Select Transactions to Import'),
size: 'large',
fields: [
{
fieldname: 'info_html',
fieldtype: 'HTML',
options: infoMessage
},
{
fieldname: 'txns_html',
fieldtype: 'HTML',
options: txnTable
}
],
primary_action_label: __('Load selected'),
primary_action: function() {
const selectedTxns = [];
d.$wrapper.find('.kb-select-txn:checked').each(function() {
const idx = $(this).data('idx');
selectedTxns.push(txns[idx]);
});
if (selectedTxns.length === 0) {
frappe.msgprint({ frappe.msgprint({
title: __('BIRBank Error'), title: __('Warning'),
message: r.message ? r.message.message : __('Unknown error'), indicator: 'orange',
indicator: 'red' message: __('No transactions selected')
}); });
return;
} }
d.hide();
KBImport.cancelLoading = false;
KBImport.import.loadSelectedTransactions(selectedTxns, accountIban, 0, 0);
},
secondary_action_label: __('Cancel'),
secondary_action: function() {
d.hide();
} }
}); });
}
d.$wrapper.find('.modal-dialog').css({
'max-width': '80%',
'width': '80%',
'margin': '30px auto'
});
d.$wrapper.find('.modal-body').css({
'padding': '15px'
});
d.show();
d.$wrapper.find('.kb-select-all-txns').on('change', function() {
const isChecked = $(this).prop('checked');
d.$wrapper.find('.kb-select-txn').prop('checked', isChecked);
});
},
loadSelectedTransactions: function(txnList, accountIban, processedCount, currentIndex) {
if (processedCount === 0 && currentIndex === 0) {
KBImport.loadingErrors = [];
window.kbTotalToProcess = txnList.length;
}
$(document).off('progress-cancel.kb_loading_txns');
if (txnList.length === 0) {
frappe.hide_progress();
const errorsCount = KBImport.loadingErrors.length;
const totalTxns = window.kbTotalToProcess || processedCount;
KBImport.errors.showSummary(totalTxns, processedCount, errorsCount);
window.kbTotalToProcess = null;
if (cur_list) {
cur_list.refresh();
}
return;
}
if (KBImport.cancelLoading) {
txnList = [];
frappe.hide_progress();
KBImport.import.loadSelectedTransactions(txnList, accountIban, processedCount, currentIndex);
return;
}
const txnData = txnList.shift();
const isLast = txnList.length === 0;
const totalToProcess = window.kbTotalToProcess || (processedCount + txnList.length + 1);
currentIndex++;
frappe.show_progress(__('Importing Transactions'), currentIndex - 1, totalToProcess,
__('Importing transaction ') + currentIndex + __(' of ') + totalToProcess, null, true);
$(document).on('progress-cancel.kb_loading_txns', function() {
KBImport.cancelLoading = true;
frappe.show_alert({
message: __('Cancelling import... Finishing current transaction.'),
indicator: 'orange'
}, 3);
frappe.hide_progress();
});
frappe.call({
method: 'kapital_bank.bank_api.import_single_transaction',
args: {
txn_data: JSON.stringify(txnData),
account_iban: accountIban
},
callback: function(importR) {
KBImport.import._handleImportResult(importR, txnData,
txnList, accountIban, processedCount, currentIndex, isLast);
},
error: function(xhr, status, error) {
const errorMessage = 'Network error during import: ' + (error || 'Unknown network error');
KBImport.errors.add(txnData, 'Network Error', errorMessage);
KBImport.import._continueOrFinish(txnList, accountIban, processedCount, currentIndex, isLast);
}
});
},
_handleImportResult: function(importR, txnData, txnList, accountIban, processedCount, currentIndex, isLast) {
if (importR.message && importR.message.success) {
frappe.show_alert({
message: __('Transaction imported: ') + (txnData.ref_no || ''),
indicator: 'green'
}, 3);
processedCount++;
KBImport.import._continueOrFinish(txnList, accountIban, processedCount, currentIndex, isLast);
} else if (importR.message && importR.message.error_type === 'unmapped_purpose') {
KBImport.errors.add(txnData, 'Unmapped Purpose', importR.message.message, {
unmatched_purpose: importR.message.unmatched_purpose || []
});
KBImport.import._continueOrFinish(txnList, accountIban, processedCount, currentIndex, isLast);
} else if (importR.message && importR.message.error_type === 'unmapped_party') {
KBImport.errors.add(txnData, 'Unmapped Party', importR.message.message, {
unmatched_party: importR.message.unmatched_party || []
});
KBImport.import._continueOrFinish(txnList, accountIban, processedCount, currentIndex, isLast);
} else {
const errorMessage = importR.message ? importR.message.message : 'Unknown import error';
KBImport.errors.add(txnData, 'Import Error', errorMessage);
KBImport.import._continueOrFinish(txnList, accountIban, processedCount, currentIndex, isLast);
}
},
_continueOrFinish: function(txnList, accountIban, processedCount, currentIndex, isLast) {
if (isLast) {
frappe.hide_progress();
$(document).off('progress-cancel.kb_loading_txns');
const totalToProcess = window.kbTotalToProcess || processedCount;
const errorsCount = KBImport.loadingErrors.length;
KBImport.errors.showSummary(totalToProcess, processedCount, errorsCount);
if (cur_list) {
cur_list.refresh();
}
return;
}
setTimeout(function() {
if (txnList.length === 0) {
frappe.hide_progress();
}
KBImport.import.loadSelectedTransactions(txnList, accountIban, processedCount, currentIndex);
}, KBImport.PROGRESS_UPDATE_DELAY);
}
};
// ======= LIST VIEW SETUP =======
frappe.listview_settings['Payment Entry'] = {
onload(listview) {
listview.page.add_menu_item(__('Import from Kapital Bank'), function() {
KBImport.import.showDialog();
});
}
};

View File

@ -9,11 +9,13 @@ app_license = "unlicense"
after_install = "kapital_bank.setup.after_install" after_install = "kapital_bank.setup.after_install"
after_migrate = "kapital_bank.setup.after_migrate" after_migrate = "kapital_bank.setup.after_migrate"
# Document Events # JS for ERPNext forms
doc_events = { doctype_js = {
"Payment Entry": { "Payment Entry": "client/payment_entry.js"
"on_cancel": "kapital_bank.payment_api.on_cancel_payment_entry" }
}
doctype_list_js = {
"Payment Entry": "client/payment_entry.js"
} }
# Scheduled Tasks (refresh JWT every 4 minutes) # Scheduled Tasks (refresh JWT every 4 minutes)
@ -23,11 +25,6 @@ scheduler_events = {
} }
} }
# JS for ERPNext forms
doctype_js = {
"Payment Entry": "kapital_bank/client/payment_entry.js"
}
# Apps # Apps
# ------------------ # ------------------

View File

@ -1,61 +0,0 @@
{
"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,130 @@
{
"actions": [],
"autoname": "field:customer_name",
"creation": "2026-02-26 00:00:00.000000",
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
"customer_name",
"tax_id",
"status",
"column_break_1",
"iban",
"bank_code",
"customer_mapping_section",
"mapped_customer",
"customer_group",
"territory",
"payment_terms"
],
"fields": [
{
"fieldname": "customer_name",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Customer Name",
"length": 500,
"reqd": 1,
"unique": 1
},
{
"fieldname": "tax_id",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Tax ID"
},
{
"default": "New",
"fieldname": "status",
"fieldtype": "Select",
"in_list_view": 1,
"label": "Status",
"options": "New\nMapped",
"read_only": 1
},
{
"fieldname": "column_break_1",
"fieldtype": "Column Break"
},
{
"fieldname": "iban",
"fieldtype": "Data",
"label": "IBAN"
},
{
"fieldname": "bank_code",
"fieldtype": "Data",
"label": "Bank Code"
},
{
"fieldname": "customer_mapping_section",
"fieldtype": "Section Break",
"label": "Customer Mapping"
},
{
"fieldname": "mapped_customer",
"fieldtype": "Link",
"label": "Mapped Customer",
"options": "Customer",
"read_only": 1
},
{
"fieldname": "customer_group",
"fieldtype": "Link",
"label": "Customer Group",
"options": "Customer Group",
"read_only": 1
},
{
"fieldname": "territory",
"fieldtype": "Link",
"label": "Territory",
"options": "Territory",
"read_only": 1
},
{
"fieldname": "payment_terms",
"fieldtype": "Link",
"label": "Payment Terms",
"options": "Payment Terms Template",
"read_only": 1
}
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2026-02-26 00:00:00.000000",
"modified_by": "Administrator",
"module": "Kapital Bank",
"name": "Kapital Bank Customer",
"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
},
{
"create": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Accounts User",
"share": 1,
"write": 1
}
],
"sort_field": "modified",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}

View File

@ -2,5 +2,5 @@ import frappe
from frappe.model.document import Document from frappe.model.document import Document
class KapitalBankClientMapping(Document): class KapitalBankCustomer(Document):
pass pass

View File

@ -0,0 +1,77 @@
{
"actions": [],
"creation": "2026-02-26 00:00:00.000000",
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
"kb_customer_name",
"tax_id",
"erp_customer",
"customer_group",
"territory",
"payment_terms",
"mapping_type"
],
"fields": [
{
"fieldname": "kb_customer_name",
"fieldtype": "Link",
"in_list_view": 1,
"label": "KB Customer Name",
"options": "Kapital Bank Customer",
"reqd": 1
},
{
"fieldname": "tax_id",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Tax ID"
},
{
"fieldname": "erp_customer",
"fieldtype": "Link",
"in_list_view": 1,
"label": "ERP Customer",
"options": "Customer"
},
{
"fieldname": "customer_group",
"fieldtype": "Link",
"label": "Customer Group",
"options": "Customer Group"
},
{
"fieldname": "territory",
"fieldtype": "Link",
"label": "Territory",
"options": "Territory"
},
{
"fieldname": "payment_terms",
"fieldtype": "Link",
"label": "Payment Terms",
"options": "Payment Terms Template"
},
{
"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-26 00:00:00.000000",
"modified_by": "Administrator",
"module": "Kapital Bank",
"name": "Kapital Bank Customer Mapping",
"owner": "Administrator",
"permissions": [],
"sort_field": "modified",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}

View File

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

View File

@ -0,0 +1,59 @@
{
"actions": [],
"creation": "2026-02-26 00:00:00.000000",
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
"purpose_keyword",
"column_break_1",
"paid_from",
"paid_to",
"notes"
],
"fields": [
{
"fieldname": "purpose_keyword",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Purpose Keyword",
"reqd": 1
},
{
"fieldname": "column_break_1",
"fieldtype": "Column Break"
},
{
"fieldname": "paid_from",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Paid From (Account)",
"options": "Account",
"reqd": 1
},
{
"fieldname": "paid_to",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Paid To (Account)",
"options": "Account",
"reqd": 1
},
{
"fieldname": "notes",
"fieldtype": "Data",
"label": "Notes"
}
],
"index_web_pages_for_search": 0,
"istable": 1,
"links": [],
"modified": "2026-02-26 00:00:00.000000",
"modified_by": "Administrator",
"module": "Kapital Bank",
"name": "Kapital Bank Purpose Mapping",
"owner": "Administrator",
"permissions": [],
"sort_field": "modified",
"sort_order": "DESC",
"states": []
}

View File

@ -2,5 +2,5 @@ import frappe
from frappe.model.document import Document from frappe.model.document import Document
class KapitalBankClient(Document): class KapitalBankPurposeMapping(Document):
pass pass

View File

@ -14,70 +14,127 @@ frappe.ui.form.on('Kapital Bank Settings', {
} }
}, __('Accounts')); }, __('Accounts'));
// ── Clients buttons ─────────────────────────────────────────────────── // ── Customers buttons ─────────────────────────────────────────────────
frm.add_custom_button(__('Match clients by similar name'), () => { frm.add_custom_button(__('Match customers 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()) { if (frm.is_dirty()) {
frm.save().then(() => add_unmapped_clients(frm)); frappe.confirm(
__('Document contains unsaved changes. Save before performing the operation?'),
() => frm.save().then(() => _match_customers(frm)),
() => frappe.show_alert({ message: __('Operation canceled. Please save the document first.'), indicator: 'red' }, 5)
);
} else { } else {
add_unmapped_clients(frm); _match_customers(frm);
} }
}, __('Clients')); }, __('Customers'));
frm.add_custom_button(__('Create matching customers'), () => {
if (frm.is_dirty()) {
frappe.confirm(
__('Document contains unsaved changes. Save before performing the operation?'),
() => frm.save().then(() => create_unmapped_customers(frm)),
() => frappe.show_alert({ message: __('Operation canceled. Please save the document first.'), indicator: 'red' }, 5)
);
} else {
create_unmapped_customers(frm);
}
}, __('Customers'));
frm.add_custom_button(__('Add unmapped customers'), () => {
if (frm.is_dirty()) {
frm.save().then(() => add_unmapped_customers(frm));
} else {
add_unmapped_customers(frm);
}
}, __('Customers'));
// ── Suppliers buttons ─────────────────────────────────────────────────
frm.add_custom_button(__('Match suppliers by similar name'), () => {
if (frm.is_dirty()) {
frappe.confirm(
__('Document contains unsaved changes. Save before performing the operation?'),
() => frm.save().then(() => _match_suppliers(frm)),
() => frappe.show_alert({ message: __('Operation canceled. Please save the document first.'), indicator: 'red' }, 5)
);
} else {
_match_suppliers(frm);
}
}, __('Suppliers'));
frm.add_custom_button(__('Create matching suppliers'), () => {
if (frm.is_dirty()) {
frappe.confirm(
__('Document contains unsaved changes. Save before performing the operation?'),
() => frm.save().then(() => create_unmapped_suppliers(frm)),
() => frappe.show_alert({ message: __('Operation canceled. Please save the document first.'), indicator: 'red' }, 5)
);
} else {
create_unmapped_suppliers(frm);
}
}, __('Suppliers'));
frm.add_custom_button(__('Add unmapped suppliers'), () => {
if (frm.is_dirty()) {
frm.save().then(() => add_unmapped_suppliers(frm));
} else {
add_unmapped_suppliers(frm);
}
}, __('Suppliers'));
// ── Populate data tabs ──────────────────────────────────────────────── // ── Populate data tabs ────────────────────────────────────────────────
load_data_tabs(frm); load_data_tabs(frm);
} }
}); });
// ═══════════════════════════════════════════════════════════════════════════════
// MATCH HELPERS
// ═══════════════════════════════════════════════════════════════════════════════
function _match_customers(frm) {
frappe.confirm(
__('This will automatically match unmapped customers with similar Customer names. Continue?'),
() => {
frappe.show_alert({ message: __('Matching customers...'), indicator: 'blue' });
frappe.call({
method: 'kapital_bank.bank_api.match_similar_customers',
callback(r) {
if (r.message && r.message.success) {
frappe.show_alert({
message: __('Matched {0} of {1} customers', [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 customers') });
}
}
});
}
);
}
function _match_suppliers(frm) {
frappe.confirm(
__('This will automatically match unmapped suppliers with similar Supplier names. Continue?'),
() => {
frappe.show_alert({ message: __('Matching suppliers...'), indicator: 'blue' });
frappe.call({
method: 'kapital_bank.bank_api.match_similar_suppliers',
callback(r) {
if (r.message && r.message.success) {
frappe.show_alert({
message: __('Matched {0} of {1} suppliers', [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 suppliers') });
}
}
});
}
);
}
// ═══════════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════════
// LOAD DATA DIALOG // LOAD DATA DIALOG
// ═══════════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════════
@ -100,13 +157,14 @@ function open_load_dialog(frm, summary) {
const types = [ const types = [
{ key: 'accounts', label: __('Accounts') }, { key: 'accounts', label: __('Accounts') },
{ key: 'cards', label: __('Cards') }, { key: 'cards', label: __('Cards') },
{ key: 'clients', label: __('Clients') } { key: 'customers', label: __('Customers') },
{ key: 'suppliers', label: __('Suppliers') }
]; ];
types.forEach(t => { types.forEach(t => {
const d = summary[t.key] || { total: 0, mapped: 0 }; const d = summary[t.key] || { total: 0, mapped: 0 };
const unmapped = d.total - d.mapped; const unmapped = d.total - d.mapped;
const pct = d.total > 0 ? Math.round(d.mapped / d.total * 100) : 0; const pct = d.total > 0 ? Math.round(d.mapped / d.total * 100) : 0;
summary_html += `<div class="col-sm-4"> summary_html += `<div class="col-sm-3">
<div class="text-center" style="border:1px solid #ddd;padding:10px;border-radius:4px;background:#fff;"> <div class="text-center" style="border:1px solid #ddd;padding:10px;border-radius:4px;background:#fff;">
<h6 style="margin-bottom:8px;">${t.label}</h6> <h6 style="margin-bottom:8px;">${t.label}</h6>
<div><strong>${d.total}</strong> ${__('total')}</div> <div><strong>${d.total}</strong> ${__('total')}</div>
@ -126,7 +184,7 @@ function open_load_dialog(frm, summary) {
title: __('Load Data from BIRBank'), title: __('Load Data from BIRBank'),
fields: [ fields: [
{ fieldname: 'summary_html', fieldtype: 'HTML', options: summary_html }, { fieldname: 'summary_html', fieldtype: 'HTML', options: summary_html },
{ fieldname: 'sec_period', fieldtype: 'Section Break', label: __('Period (for clients from statements)') }, { fieldname: 'sec_period', fieldtype: 'Section Break', label: __('Period (for counterparties from statements)') },
{ fieldname: 'date_from', fieldtype: 'Date', label: __('Date From'), default: first_day, reqd: 0 }, { fieldname: 'date_from', fieldtype: 'Date', label: __('Date From'), default: first_day, reqd: 0 },
{ fieldname: 'col_break', fieldtype: 'Column Break' }, { fieldname: 'col_break', fieldtype: 'Column Break' },
{ fieldname: 'date_to', fieldtype: 'Date', label: __('Date To'), default: last_day, reqd: 0 }, { fieldname: 'date_to', fieldtype: 'Date', label: __('Date To'), default: last_day, reqd: 0 },
@ -135,13 +193,13 @@ function open_load_dialog(frm, summary) {
description: __('Fetch current accounts from GET /accounts') }, description: __('Fetch current accounts from GET /accounts') },
{ fieldname: 'load_cards', fieldtype: 'Check', label: __('Load Cards'), default: 1, { fieldname: 'load_cards', fieldtype: 'Check', label: __('Load Cards'), default: 1,
description: __('Fetch business cards from GET /cards') }, description: __('Fetch business cards from GET /cards') },
{ fieldname: 'load_clients', fieldtype: 'Check', label: __('Load Clients from statements'), default: 1, { fieldname: 'load_counterparties', fieldtype: 'Check', label: __('Load Counterparties from statements'), default: 1,
description: __('Extract counterparties from account statement transactions for the selected period') } description: __('Extract counterparties from account statement transactions for the selected period') }
], ],
primary_action_label: __('Load'), primary_action_label: __('Load'),
primary_action(values) { primary_action(values) {
if (values.load_clients && (!values.date_from || !values.date_to)) { if (values.load_counterparties && (!values.date_from || !values.date_to)) {
frappe.msgprint(__('Please set Date From and Date To to load clients from statements.')); frappe.msgprint(__('Please set Date From and Date To to load counterparties from statements.'));
return; return;
} }
d.hide(); d.hide();
@ -152,7 +210,7 @@ function open_load_dialog(frm, summary) {
} }
function start_loading(frm, values) { function start_loading(frm, values) {
let total = (values.load_accounts ? 1 : 0) + (values.load_cards ? 1 : 0) + (values.load_clients ? 1 : 0); let total = (values.load_accounts ? 1 : 0) + (values.load_cards ? 1 : 0) + (values.load_counterparties ? 1 : 0);
if (total === 0) { frappe.show_alert({ message: __('Nothing selected'), indicator: 'orange' }, 3); return; } if (total === 0) { frappe.show_alert({ message: __('Nothing selected'), indicator: 'orange' }, 3); return; }
let done = 0; let done = 0;
@ -198,19 +256,20 @@ function start_loading(frm, values) {
}); });
} }
if (values.load_clients) { if (values.load_counterparties) {
frappe.show_progress(__('Loading clients...'), 50, 100, __('Fetching statements to extract counterparties...')); frappe.show_progress(__('Loading counterparties...'), 50, 100, __('Fetching statements to extract counterparties...'));
frappe.call({ frappe.call({
method: 'kapital_bank.bank_api.load_clients_from_statements', method: 'kapital_bank.bank_api.load_counterparties_from_statements',
args: { from_date: values.date_from, to_date: values.date_to }, args: { from_date: values.date_from, to_date: values.date_to },
callback(r) { callback(r) {
if (r.message && r.message.success) { if (r.message && r.message.success) {
on_done(__('Clients: {0} new, {1} skipped (already exist)', [r.message.created, r.message.skipped])); on_done(__('Counterparties: {0} customers, {1} suppliers, {2} skipped',
[r.message.customers_created, r.message.suppliers_created, r.message.skipped]));
} else { } else {
on_done(__('Clients error: {0}', [r.message ? r.message.message : 'unknown'])); on_done(__('Counterparties error: {0}', [r.message ? r.message.message : 'unknown']));
} }
}, },
error() { on_done(__('Clients: network error')); } error() { on_done(__('Counterparties: network error')); }
}); });
} }
} }
@ -247,36 +306,126 @@ function add_unmapped_accounts(frm) {
}); });
} }
function add_unmapped_clients(frm) { function add_unmapped_customers(frm) {
frappe.call({ frappe.call({
method: 'kapital_bank.bank_api.get_unmapped_clients', method: 'kapital_bank.bank_api.get_unmapped_customers',
callback(r) { callback(r) {
if (!r.message || !r.message.success) { if (!r.message || !r.message.success) {
frappe.msgprint({ title: __('Error'), indicator: 'red', message: r.message ? r.message.message : __('Error') }); frappe.msgprint({ title: __('Error'), indicator: 'red', message: r.message ? r.message.message : __('Error') });
return; return;
} }
const clients = r.message.clients; const customers = r.message.customers;
if (!clients || clients.length === 0) { if (!customers || customers.length === 0) {
frappe.show_alert({ message: __('No unmapped clients found'), indicator: 'blue' }, 5); frappe.show_alert({ message: __('No unmapped customers found'), indicator: 'blue' }, 5);
return; return;
} }
clients.forEach(c => { customers.forEach(c => {
const row = frm.add_child('client_mappings'); const row = frm.add_child('customer_mappings');
row.kb_customer_name = c.name;
row.tax_id = c.tax_id || ''; 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'; row.mapping_type = 'Manual';
}); });
frm.refresh_field('client_mappings'); frm.refresh_field('customer_mappings');
frm.save().then(() => { frm.save().then(() => {
frappe.show_alert({ message: __('Added {0} clients to mapping', [clients.length]), indicator: 'green' }, 5); frappe.show_alert({ message: __('Added {0} customers to mapping', [customers.length]), indicator: 'green' }, 5);
load_clients_tab(); load_customers_tab();
}); });
} }
}); });
} }
function add_unmapped_suppliers(frm) {
frappe.call({
method: 'kapital_bank.bank_api.get_unmapped_suppliers',
callback(r) {
if (!r.message || !r.message.success) {
frappe.msgprint({ title: __('Error'), indicator: 'red', message: r.message ? r.message.message : __('Error') });
return;
}
const suppliers = r.message.suppliers;
if (!suppliers || suppliers.length === 0) {
frappe.show_alert({ message: __('No unmapped suppliers found'), indicator: 'blue' }, 5);
return;
}
suppliers.forEach(s => {
const row = frm.add_child('supplier_mappings');
row.kb_supplier_name = s.name;
row.tax_id = s.tax_id || '';
row.mapping_type = 'Manual';
});
frm.refresh_field('supplier_mappings');
frm.save().then(() => {
frappe.show_alert({ message: __('Added {0} suppliers to mapping', [suppliers.length]), indicator: 'green' }, 5);
load_suppliers_tab();
});
}
});
}
function create_unmapped_customers(frm) {
frappe.confirm(
__('<div style="color:red;font-weight:bold;">WARNING! This will create new Customer records for all unmapped customers!</div><p>This action is irreversible. Are you sure?</p>'),
function() {
frappe.confirm(
__('Are you really sure? This cannot be undone.'),
function() {
frappe.call({
method: 'kapital_bank.bank_api.create_unmapped_customers',
args: { 'settings_name': frm.doc.name },
callback: function(r) {
if (r.message && r.message.success) {
frappe.show_alert({
message: __('Created {0} new customers', [r.message.created_count]),
indicator: 'green'
}, 6);
frm.reload_doc();
} else {
frappe.msgprint({
title: __('Error'),
indicator: 'red',
message: r.message ? r.message.message : __('Error creating customers')
});
}
}
});
}
);
}
);
}
function create_unmapped_suppliers(frm) {
frappe.confirm(
__('<div style="color:red;font-weight:bold;">WARNING! This will create new Supplier records for all unmapped suppliers!</div><p>This action is irreversible. Are you sure?</p>'),
function() {
frappe.confirm(
__('Are you really sure? This cannot be undone.'),
function() {
frappe.call({
method: 'kapital_bank.bank_api.create_unmapped_suppliers',
args: { 'settings_name': frm.doc.name },
callback: function(r) {
if (r.message && r.message.success) {
frappe.show_alert({
message: __('Created {0} new suppliers', [r.message.created_count]),
indicator: 'green'
}, 6);
frm.reload_doc();
} else {
frappe.msgprint({
title: __('Error'),
indicator: 'red',
message: r.message ? r.message.message : __('Error creating suppliers')
});
}
}
});
}
);
}
);
}
// ═══════════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════════
// DATA TABS // DATA TABS
// ═══════════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════════
@ -284,7 +433,8 @@ function add_unmapped_clients(frm) {
function load_data_tabs(frm) { function load_data_tabs(frm) {
load_accounts_tab(); load_accounts_tab();
load_cards_tab(); load_cards_tab();
load_clients_tab(); load_customers_tab();
load_suppliers_tab();
} }
// ─── Accounts tab ──────────────────────────────────────────────────────────── // ─── Accounts tab ────────────────────────────────────────────────────────────
@ -409,64 +559,121 @@ function render_cards_list(cards, total_count) {
}); });
} }
// ─── Clients tab ───────────────────────────────────────────────────────────── // ─── Customers tab ───────────────────────────────────────────────────────────
function load_clients_tab() { function load_customers_tab() {
frappe.call({ frappe.call({
method: 'kapital_bank.bank_api.get_kb_reference_data_list', method: 'kapital_bank.bank_api.get_kb_reference_data_list',
args: { data_type: 'clients', limit: 100, offset: 0 }, args: { data_type: 'customers', limit: 100, offset: 0 },
callback(r) { callback(r) {
if (r.message && r.message.success) { if (r.message && r.message.success) {
render_clients_list(r.message.data, r.message.total_count); render_customers_list(r.message.data, r.message.total_count);
} else { } else {
const msg = r.message ? r.message.message : __('Unknown error'); const msg = r.message ? r.message.message : __('Unknown error');
$('#kb-clients-container').html(error_html(msg, load_clients_tab)); $('#kb-customers-container').html(error_html(msg, load_customers_tab));
} }
}, },
error() { $('#kb-clients-container').html(error_html(__('Network error'), load_clients_tab)); } error() { $('#kb-customers-container').html(error_html(__('Network error'), load_customers_tab)); }
}); });
} }
function render_clients_list(clients, total_count) { function render_customers_list(customers, total_count) {
let html = list_header(__('Clients'), total_count, 'kb-refresh-clients'); let html = list_header(__('Customers'), total_count, 'kb-refresh-customers');
if (clients && clients.length > 0) { if (customers && customers.length > 0) {
html += `<table class="table table-bordered" style="font-size:13px;"> html += `<table class="table table-bordered" style="font-size:13px;">
<thead class="grid-heading-row"><tr> <thead class="grid-heading-row"><tr>
<th>${__('Client Name')}</th> <th>${__('Customer Name')}</th>
<th>${__('Tax ID (VÖEN)')}</th> <th>${__('Tax ID (VOEN)')}</th>
<th>${__('IBAN')}</th> <th>${__('IBAN')}</th>
<th>${__('Status')}</th> <th>${__('Status')}</th>
<th>${__('Party Type')}</th>
<th>${__('Mapped To')}</th> <th>${__('Mapped To')}</th>
<th>${__('Created')}</th> <th>${__('Created')}</th>
</tr></thead><tbody>`; </tr></thead><tbody>`;
clients.forEach(c => { customers.forEach(c => {
const pill = c.status === 'Mapped' ? 'green' : (c.status === 'Active' ? 'blue' : 'red'); const pill = c.status === 'Mapped' ? 'green' : 'red';
const mapped_to = c.mapped_party ? `<a href="/app/${(c.mapped_party_type || 'customer').toLowerCase()}/${c.mapped_party}">${c.mapped_party}</a>` : '-'; const mapped_to = c.mapped_customer ? `<a href="/app/customer/${c.mapped_customer}">${c.mapped_customer}</a>` : '-';
const created = c.creation ? frappe.datetime.str_to_user(c.creation) : '-'; const created = c.creation ? frappe.datetime.str_to_user(c.creation) : '-';
html += `<tr> html += `<tr>
<td><a href="/app/kapital-bank-client/${c.name}">${c.client_name}</a></td> <td><a href="/app/kapital-bank-customer/${c.name}">${c.customer_name}</a></td>
<td>${c.tax_id || '-'}</td> <td>${c.tax_id || '-'}</td>
<td>${c.iban || '-'}</td> <td>${c.iban || '-'}</td>
<td><span class="indicator-pill ${pill}">${__(c.status)}</span></td> <td><span class="indicator-pill ${pill}">${__(c.status)}</span></td>
<td>${c.mapped_party_type || '-'}</td>
<td>${mapped_to}</td> <td>${mapped_to}</td>
<td>${created}</td> <td>${created}</td>
</tr>`; </tr>`;
}); });
html += '</tbody></table>'; html += '</tbody></table>';
if (total_count > clients.length) { if (total_count > customers.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>`; html += `<p class="text-muted small">${__('Showing {0} of {1}', [customers.length, total_count])} — <a href="/app/kapital-bank-customer">${__('View All')}</a></p>`;
} }
} else { } else {
html += empty_state(__('No clients registered yet.')); html += empty_state(__('No customers registered yet.'));
} }
$('#kb-clients-container').html(html); $('#kb-customers-container').html(html);
$('#kb-refresh-clients').on('click', () => { $('#kb-refresh-customers').on('click', () => {
$('#kb-clients-container').html(loading_html()); $('#kb-customers-container').html(loading_html());
load_clients_tab(); load_customers_tab();
});
}
// ─── Suppliers tab ───────────────────────────────────────────────────────────
function load_suppliers_tab() {
frappe.call({
method: 'kapital_bank.bank_api.get_kb_reference_data_list',
args: { data_type: 'suppliers', limit: 100, offset: 0 },
callback(r) {
if (r.message && r.message.success) {
render_suppliers_list(r.message.data, r.message.total_count);
} else {
const msg = r.message ? r.message.message : __('Unknown error');
$('#kb-suppliers-container').html(error_html(msg, load_suppliers_tab));
}
},
error() { $('#kb-suppliers-container').html(error_html(__('Network error'), load_suppliers_tab)); }
});
}
function render_suppliers_list(suppliers, total_count) {
let html = list_header(__('Suppliers'), total_count, 'kb-refresh-suppliers');
if (suppliers && suppliers.length > 0) {
html += `<table class="table table-bordered" style="font-size:13px;">
<thead class="grid-heading-row"><tr>
<th>${__('Supplier Name')}</th>
<th>${__('Tax ID (VOEN)')}</th>
<th>${__('IBAN')}</th>
<th>${__('Status')}</th>
<th>${__('Mapped To')}</th>
<th>${__('Created')}</th>
</tr></thead><tbody>`;
suppliers.forEach(s => {
const pill = s.status === 'Mapped' ? 'green' : 'red';
const mapped_to = s.mapped_supplier ? `<a href="/app/supplier/${s.mapped_supplier}">${s.mapped_supplier}</a>` : '-';
const created = s.creation ? frappe.datetime.str_to_user(s.creation) : '-';
html += `<tr>
<td><a href="/app/kapital-bank-supplier/${s.name}">${s.supplier_name}</a></td>
<td>${s.tax_id || '-'}</td>
<td>${s.iban || '-'}</td>
<td><span class="indicator-pill ${pill}">${__(s.status)}</span></td>
<td>${mapped_to}</td>
<td>${created}</td>
</tr>`;
});
html += '</tbody></table>';
if (total_count > suppliers.length) {
html += `<p class="text-muted small">${__('Showing {0} of {1}', [suppliers.length, total_count])} — <a href="/app/kapital-bank-supplier">${__('View All')}</a></p>`;
}
} else {
html += empty_state(__('No suppliers registered yet.'));
}
$('#kb-suppliers-container').html(html);
$('#kb-refresh-suppliers').on('click', () => {
$('#kb-suppliers-container').html(loading_html());
load_suppliers_tab();
}); });
} }

View File

@ -22,14 +22,22 @@
"accounts_list_html", "accounts_list_html",
"cards_tab", "cards_tab",
"cards_list_html", "cards_list_html",
"clients_tab", "customers_tab",
"clients_list_html", "customers_list_html",
"suppliers_tab",
"suppliers_list_html",
"account_mappings_tab", "account_mappings_tab",
"account_mappings_section", "account_mappings_section",
"account_mappings", "account_mappings",
"client_mappings_tab", "customer_mappings_tab",
"client_mappings_section", "customer_mappings_section",
"client_mappings" "customer_mappings",
"supplier_mappings_tab",
"supplier_mappings_section",
"supplier_mappings",
"purpose_mappings_tab",
"purpose_mappings_section",
"purpose_mappings"
], ],
"fields": [ "fields": [
{ {
@ -135,15 +143,26 @@
"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>" "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", "fieldname": "customers_tab",
"fieldtype": "Tab Break", "fieldtype": "Tab Break",
"label": "Clients" "label": "Customers"
}, },
{ {
"fieldname": "clients_list_html", "fieldname": "customers_list_html",
"fieldtype": "HTML", "fieldtype": "HTML",
"label": "Clients List", "label": "Customers List",
"options": "<div id=\"kb-clients-container\"><div class=\"text-center text-muted\" style=\"padding:20px;\">Loading...</div></div>" "options": "<div id=\"kb-customers-container\"><div class=\"text-center text-muted\" style=\"padding:20px;\">Loading...</div></div>"
},
{
"fieldname": "suppliers_tab",
"fieldtype": "Tab Break",
"label": "Suppliers"
},
{
"fieldname": "suppliers_list_html",
"fieldtype": "HTML",
"label": "Suppliers List",
"options": "<div id=\"kb-suppliers-container\"><div class=\"text-center text-muted\" style=\"padding:20px;\">Loading...</div></div>"
}, },
{ {
"fieldname": "account_mappings_tab", "fieldname": "account_mappings_tab",
@ -162,26 +181,58 @@
"options": "Kapital Bank Account Mapping" "options": "Kapital Bank Account Mapping"
}, },
{ {
"fieldname": "client_mappings_tab", "fieldname": "customer_mappings_tab",
"fieldtype": "Tab Break", "fieldtype": "Tab Break",
"label": "Client Mappings" "label": "Customer Mappings"
}, },
{ {
"fieldname": "client_mappings_section", "fieldname": "customer_mappings_section",
"fieldtype": "Section Break", "fieldtype": "Section Break",
"label": "Client Mappings" "label": "Customer Mappings"
}, },
{ {
"fieldname": "client_mappings", "fieldname": "customer_mappings",
"fieldtype": "Table", "fieldtype": "Table",
"label": "Client Mappings", "label": "Customer Mappings",
"options": "Kapital Bank Client Mapping" "options": "Kapital Bank Customer Mapping"
},
{
"fieldname": "supplier_mappings_tab",
"fieldtype": "Tab Break",
"label": "Supplier Mappings"
},
{
"fieldname": "supplier_mappings_section",
"fieldtype": "Section Break",
"label": "Supplier Mappings"
},
{
"fieldname": "supplier_mappings",
"fieldtype": "Table",
"label": "Supplier Mappings",
"options": "Kapital Bank Supplier Mapping"
},
{
"fieldname": "purpose_mappings_tab",
"fieldtype": "Tab Break",
"label": "Purpose Mappings"
},
{
"fieldname": "purpose_mappings_section",
"fieldtype": "Section Break",
"label": "Purpose Mappings"
},
{
"fieldname": "purpose_mappings",
"fieldtype": "Table",
"label": "Purpose Mappings",
"options": "Kapital Bank Purpose Mapping"
} }
], ],
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"issingle": 1, "issingle": 1,
"links": [], "links": [],
"modified": "2026-02-25 00:00:00.000000", "modified": "2026-02-26 00:00:00.000000",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Kapital Bank", "module": "Kapital Bank",
"name": "Kapital Bank Settings", "name": "Kapital Bank Settings",

View File

@ -4,28 +4,54 @@ from frappe.model.document import Document
class KapitalBankSettings(Document): class KapitalBankSettings(Document):
def on_update(self): def on_update(self):
self._sync_client_statuses() self._sync_customer_statuses()
self._sync_supplier_statuses()
def _sync_client_statuses(self): def _sync_customer_statuses(self):
"""Reset Kapital Bank Client status to 'New' for clients removed from client_mappings.""" """Reset Kapital Bank Customer status to 'New' for customers removed from customer_mappings."""
mapped_names = {row.client_name for row in self.client_mappings if row.client_name} mapped_names = {row.kb_customer_name for row in self.customer_mappings if row.kb_customer_name}
# Find clients marked as Mapped but no longer present in the mapping table
orphaned = frappe.get_all( orphaned = frappe.get_all(
"Kapital Bank Client", "Kapital Bank Customer",
filters={"status": "Mapped"}, filters={"status": "Mapped"},
fields=["name", "client_name"], fields=["name", "customer_name"],
) )
for client in orphaned: for customer in orphaned:
if client.client_name not in mapped_names: if customer.name not in mapped_names:
frappe.db.set_value( frappe.db.set_value(
"Kapital Bank Client", "Kapital Bank Customer",
client.name, customer.name,
{ {
"status": "New", "status": "New",
"mapped_party_type": None, "mapped_customer": None,
"mapped_party": None, "customer_group": None,
"territory": None,
"payment_terms": None,
},
update_modified=False,
)
def _sync_supplier_statuses(self):
"""Reset Kapital Bank Supplier status to 'New' for suppliers removed from supplier_mappings."""
mapped_names = {row.kb_supplier_name for row in self.supplier_mappings if row.kb_supplier_name}
orphaned = frappe.get_all(
"Kapital Bank Supplier",
filters={"status": "Mapped"},
fields=["name", "supplier_name"],
)
for supplier in orphaned:
if supplier.name not in mapped_names:
frappe.db.set_value(
"Kapital Bank Supplier",
supplier.name,
{
"status": "New",
"mapped_supplier": None,
"supplier_group": None,
"payment_terms": None,
}, },
update_modified=False, update_modified=False,
) )

View File

@ -1,35 +1,36 @@
{ {
"actions": [], "actions": [],
"allow_rename": 1, "autoname": "field:supplier_name",
"autoname": "field:client_name", "creation": "2026-02-26 00:00:00.000000",
"creation": "2026-02-24 00:00:00.000000",
"doctype": "DocType", "doctype": "DocType",
"engine": "InnoDB", "engine": "InnoDB",
"field_order": [ "field_order": [
"client_name", "supplier_name",
"tax_id", "tax_id",
"status", "status",
"column_break_1", "column_break_1",
"iban", "iban",
"bank_code", "bank_code",
"mapping_section", "supplier_mapping_section",
"mapped_party_type", "mapped_supplier",
"mapped_party" "supplier_group",
"payment_terms"
], ],
"fields": [ "fields": [
{ {
"fieldname": "client_name", "fieldname": "supplier_name",
"fieldtype": "Data", "fieldtype": "Data",
"in_list_view": 1, "in_list_view": 1,
"label": "Client Name", "label": "Supplier Name",
"reqd": 1 "length": 500,
"reqd": 1,
"unique": 1
}, },
{ {
"fieldname": "tax_id", "fieldname": "tax_id",
"fieldtype": "Data", "fieldtype": "Data",
"in_list_view": 1, "in_list_view": 1,
"label": "Tax ID (VÖEN)", "label": "Tax ID"
"unique": 1
}, },
{ {
"default": "New", "default": "New",
@ -37,7 +38,8 @@
"fieldtype": "Select", "fieldtype": "Select",
"in_list_view": 1, "in_list_view": 1,
"label": "Status", "label": "Status",
"options": "New\nMapped\nActive" "options": "New\nMapped",
"read_only": 1
}, },
{ {
"fieldname": "column_break_1", "fieldname": "column_break_1",
@ -54,29 +56,38 @@
"label": "Bank Code" "label": "Bank Code"
}, },
{ {
"fieldname": "mapping_section", "fieldname": "supplier_mapping_section",
"fieldtype": "Section Break", "fieldtype": "Section Break",
"label": "ERP Mapping" "label": "Supplier Mapping"
}, },
{ {
"fieldname": "mapped_party_type", "fieldname": "mapped_supplier",
"fieldtype": "Select", "fieldtype": "Link",
"label": "Mapped Party Type", "label": "Mapped Supplier",
"options": "\nCustomer\nSupplier" "options": "Supplier",
"read_only": 1
}, },
{ {
"fieldname": "mapped_party", "fieldname": "supplier_group",
"fieldtype": "Dynamic Link", "fieldtype": "Link",
"label": "Mapped Party", "label": "Supplier Group",
"options": "mapped_party_type" "options": "Supplier Group",
"read_only": 1
},
{
"fieldname": "payment_terms",
"fieldtype": "Link",
"label": "Payment Terms",
"options": "Payment Terms Template",
"read_only": 1
} }
], ],
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"links": [], "links": [],
"modified": "2026-02-24 00:00:00.000000", "modified": "2026-02-26 00:00:00.000000",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Kapital Bank", "module": "Kapital Bank",
"name": "Kapital Bank Client", "name": "Kapital Bank Supplier",
"naming_rule": "By fieldname", "naming_rule": "By fieldname",
"owner": "Administrator", "owner": "Administrator",
"permissions": [ "permissions": [
@ -91,11 +102,21 @@
"role": "System Manager", "role": "System Manager",
"share": 1, "share": 1,
"write": 1 "write": 1
},
{
"create": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Accounts User",
"share": 1,
"write": 1
} }
], ],
"row_format": "Dynamic",
"sort_field": "modified", "sort_field": "modified",
"sort_order": "DESC", "sort_order": "DESC",
"states": [], "states": [],
"title_field": "client_name" "track_changes": 1
} }

View File

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

View File

@ -0,0 +1,70 @@
{
"actions": [],
"creation": "2026-02-26 00:00:00.000000",
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
"kb_supplier_name",
"tax_id",
"erp_supplier",
"supplier_group",
"payment_terms",
"mapping_type"
],
"fields": [
{
"fieldname": "kb_supplier_name",
"fieldtype": "Link",
"in_list_view": 1,
"label": "KB Supplier Name",
"options": "Kapital Bank Supplier",
"reqd": 1
},
{
"fieldname": "tax_id",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Tax ID"
},
{
"fieldname": "erp_supplier",
"fieldtype": "Link",
"in_list_view": 1,
"label": "ERP Supplier",
"options": "Supplier"
},
{
"fieldname": "supplier_group",
"fieldtype": "Link",
"label": "Supplier Group",
"options": "Supplier Group"
},
{
"fieldname": "payment_terms",
"fieldtype": "Link",
"label": "Payment Terms",
"options": "Payment Terms Template"
},
{
"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-26 00:00:00.000000",
"modified_by": "Administrator",
"module": "Kapital Bank",
"name": "Kapital Bank Supplier Mapping",
"owner": "Administrator",
"permissions": [],
"sort_field": "modified",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}

View File

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

View File

@ -1,277 +1 @@
import frappe # Removed: outgoing payment functions were removed.
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"
)

View File

@ -1,83 +1,23 @@
import frappe import frappe
from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
def after_install(): def after_install():
create_payment_entry_custom_fields() cleanup_payment_entry_custom_fields()
def after_migrate(): def after_migrate():
create_payment_entry_custom_fields() cleanup_payment_entry_custom_fields()
def create_payment_entry_custom_fields(): KB_FIELDS = [
custom_fields = { "kb_section", "kb_transfer_type", "kb_operation_name", "kb_bank_id",
"Payment Entry": [ "kb_transfer_status", "kb_column_break", "kb_from_iban", "kb_to_iban",
{ "kb_to_tax_no", "kb_ben_bank_code",
"fieldname": "kb_section", ]
"fieldtype": "Section Break",
"label": "Kapital Bank",
"insert_after": "letter_head", def cleanup_payment_entry_custom_fields():
"collapsible": 1, for fieldname in KB_FIELDS:
}, if frappe.db.exists("Custom Field", {"dt": "Payment Entry", "fieldname": fieldname}):
{ frappe.delete_doc("Custom Field", f"Payment Entry-{fieldname}", ignore_missing=True)
"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() frappe.db.commit()