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 frappe
from datetime import datetime
from frappe.utils import cint, flt
from difflib import SequenceMatcher
from kapital_bank.api import BIRBankClient
@ -98,11 +100,11 @@ def load_cards_to_registry(login_name=None):
@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
(contrAccount field) into the Kapital Bank Client registry.
Analogous to invoice_az loading customers/suppliers from invoice documents.
into Kapital Bank Customer / Kapital Bank Supplier registries based on transaction direction.
D (debit, money out) Supplier, C (credit, money in) Customer.
"""
try:
client = BIRBankClient(login_name)
@ -111,12 +113,11 @@ def load_clients_from_statements(from_date, to_date, login_name=None):
if not accounts:
return {"success": False, "message": "No accounts in registry. Load accounts first."}
created = skipped = 0
customers_created = suppliers_created = skipped = 0
debug_log = []
for acc in accounts:
iban = acc.iban
# API expects the numeric account number (custAcNo), not the full IBAN
account_number = acc.cust_ac_no or iban
debug_log.append(f"Account: {_mask(iban)} custAcNo={_mask(account_number)} ({from_date}{to_date})")
@ -132,7 +133,7 @@ def load_clients_from_statements(from_date, to_date, login_name=None):
data = resp.json()
except Exception as e:
debug_log.append(f" Error: {e}")
frappe.log_error(f"Statement fetch failed for {_mask(iban)}: {e}", "KB Load Clients")
frappe.log_error(f"Statement fetch failed for {_mask(iban)}: {e}", "KB Load Counterparties")
continue
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)}")
# Log first 3 entries to understand the real contrAccount format
if statement_list:
debug_log.append(f" First entry keys: {list(statement_list[0].keys())}")
for i, e in enumerate(statement_list[:3]):
ca = _mask(e.get('contrAccount', 'N/A'))
pu = e.get('purpose', '')[:60]
debug_log.append(f" Entry[{i}] contrAccount={ca!r} purpose={pu!r}")
for entry in statement_list:
contr_raw = (entry.get("contrAccount") or "").strip()
if not contr_raw:
continue
# contrAccount format: "IBAN / COMPANY NAME / VÖEN"
# e.g. "AZ12AIIB400...000 / TEST COMPANY LLC / 4800123456"
parts = [p.strip() for p in contr_raw.split(" / ")]
if len(parts) < 2:
continue # unrecognised format, skip
continue
contr_iban = parts[0]
contr_name = parts[1]
contr_voen = parts[2] if len(parts) >= 3 else ""
if not contr_name:
continue
# Deduplicate: by VÖEN if present, else by client_name
if contr_voen and frappe.db.exists("Kapital Bank Client", {"tax_id": contr_voen}):
skipped += 1
continue
if not contr_voen and frappe.db.exists("Kapital Bank Client", {"client_name": contr_name}):
# Determine direction: D = money out = Supplier, C = money in = Customer
dr_cr = (entry.get("drcrInd") or "").upper()
if dr_cr == "D":
# 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
continue
doc = frappe.new_doc("Kapital Bank Client")
doc.client_name = contr_name
doc.tax_id = contr_voen or None # NULL doesn't violate unique constraint
doc.status = "New"
# Deduplicate: by VÖEN if present, else by name
if contr_voen and frappe.db.exists(doctype, {"tax_id": contr_voen}):
skipped += 1
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:
doc.insert(ignore_permissions=True)
created += 1
if dr_cr == "D":
suppliers_created += 1
else:
customers_created += 1
except frappe.DuplicateEntryError:
skipped += 1
except Exception as ins_e:
frappe.log_error(f"Insert client failed for {_mask(contr_raw)}: {ins_e}", "KB Load Clients")
frappe.log_error(f"Insert counterparty failed for {_mask(contr_raw)}: {ins_e}", "KB Load Counterparties")
frappe.db.commit()
frappe.log_error("\n".join(debug_log), "KB Load Counterparties Debug")
# Always log the debug info so we can diagnose issues
frappe.log_error("\n".join(debug_log), "KB Load Clients Debug")
return {"success": True, "created": created, "skipped": skipped, "debug": debug_log}
return {
"success": True,
"customers_created": customers_created,
"suppliers_created": suppliers_created,
"skipped": skipped,
"debug": debug_log,
}
except Exception as e:
frappe.log_error(f"load_clients_from_statements: {e}\n{frappe.get_traceback()}", "KB Bank API")
frappe.log_error(f"load_counterparties_from_statements: {e}\n{frappe.get_traceback()}", "KB Bank API")
return {"success": False, "message": str(e)}
@ -222,9 +239,10 @@ def get_kb_reference_data_summary():
return {"total": total, "mapped": mapped}
summary = {
"accounts": counts("Kapital Bank Account", {"status": "Mapped"}),
"cards": counts("Kapital Bank Card", {"status": "Mapped"}),
"clients": counts("Kapital Bank Client", {"status": ["in", ["Mapped", "Active"]]}),
"accounts": counts("Kapital Bank Account", {"status": "Mapped"}),
"cards": counts("Kapital Bank Card", {"status": "Mapped"}),
"customers": counts("Kapital Bank Customer", {"status": "Mapped"}),
"suppliers": counts("Kapital Bank Supplier", {"status": "Mapped"}),
}
return {"success": True, "summary": summary}
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"
)
elif data_type == "clients":
total = frappe.db.count("Kapital Bank Client")
elif data_type == "customers":
total = frappe.db.count("Kapital Bank Customer")
data = frappe.get_all(
"Kapital Bank Client",
fields=["name", "client_name", "tax_id", "iban", "bank_code",
"status", "mapped_party_type", "mapped_party", "creation"],
"Kapital Bank Customer",
fields=["name", "customer_name", "tax_id", "iban", "bank_code",
"status", "mapped_customer", "creation"],
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:
@ -308,105 +336,110 @@ def get_unmapped_accounts():
@frappe.whitelist()
def get_unmapped_clients():
"""Return Kapital Bank Clients with status='New' not yet in client_mappings."""
def get_unmapped_customers():
"""Return Kapital Bank Customers with status='New' not yet in customer_mappings."""
try:
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(
"Kapital Bank Client",
customers = frappe.get_all(
"Kapital Bank Customer",
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]
return {"success": True, "clients": unmapped}
unmapped = [c for c in customers if c.name not in already_mapped]
return {"success": True, "customers": unmapped}
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)}
# ═══════════════════════════════════════════════════════════════════════════════
# CLIENT MATCHING & CREATION
# CUSTOMER MATCHING & CREATION
# ═══════════════════════════════════════════════════════════════════════════════
@frappe.whitelist()
def match_similar_clients():
"""Auto-match Kapital Bank Clients to ERPNext Customer/Supplier by name similarity.
Follows invoice_az pattern exactly."""
def match_similar_customers():
"""Auto-match Kapital Bank Customers to ERPNext Customers by name similarity."""
try:
doc = frappe.get_doc("Kapital Bank Settings")
threshold = flt(doc.similarity_threshold or 95) / 100.0
consider_azeri = bool(doc.consider_azeri_chars)
new_clients = frappe.get_all(
"Kapital Bank Client",
new_customers = frappe.get_all(
"Kapital Bank Customer",
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}
customers = frappe.get_all("Customer", fields=["name", "customer_name", "tax_id"])
suppliers = frappe.get_all("Supplier", fields=["name", "supplier_name", "tax_id"])
erp_customers = frappe.get_all("Customer", fields=["name", "customer_name", "tax_id"])
# VÖEN lookup: tax_id → (party_name, party_type) for exact matching
# VÖEN lookup for exact matching
voen_index = {}
for c in customers:
for c in erp_customers:
if c.tax_id:
voen_index[c.tax_id.strip()] = (c.name, "Customer")
for s in suppliers:
if s.tax_id:
voen_index[s.tax_id.strip()] = (s.name, "Supplier")
voen_index[c.tax_id.strip()] = c.name
# Build dict of existing mappings: client_name → erp_party
# Build dict of existing mappings: kb_customer_name → erp_customer
existing_mappings = {
row.client_name: row.erp_party
for row in doc.client_mappings if row.client_name
row.kb_customer_name: row.erp_customer
for row in doc.customer_mappings if row.kb_customer_name
}
# Group 1: clients already in the table but with empty erp_party
clients_with_empty_mapping = []
for client_name, erp_party in existing_mappings.items():
if not erp_party:
for c in new_clients:
if c.client_name == client_name:
clients_with_empty_mapping.append(c)
# Group 1: customers already in the table but with empty erp_customer
customers_with_empty_mapping = []
for kb_name, erp_customer in existing_mappings.items():
if not erp_customer:
for c in new_customers:
if c.name == kb_name:
customers_with_empty_mapping.append(c)
break
# Group 2: clients not in the table at all
clients_not_in_table = [c for c in new_clients if c.client_name not in existing_mappings]
# Group 2: customers not in the table at all
customers_not_in_table = [c for c in new_customers if c.name not in existing_mappings]
# Process both groups (same as invoice_az)
clients_to_process = clients_not_in_table + clients_with_empty_mapping
customers_to_process = customers_not_in_table + customers_with_empty_mapping
matched = 0
for client in clients_to_process:
for customer in customers_to_process:
best_party = None
best_type = None
# Priority 1: exact VÖEN match
if client.tax_id and client.tax_id.strip() in voen_index:
best_party, best_type = voen_index[client.tax_id.strip()]
if customer.tax_id and customer.tax_id.strip() in voen_index:
best_party = voen_index[customer.tax_id.strip()]
else:
# Priority 2: name similarity
kb_name = _normalize(client.client_name, consider_azeri)
kb_name = _normalize(customer.customer_name, consider_azeri)
best_score = 0
for c in customers:
for c in erp_customers:
score = SequenceMatcher(None, kb_name, _normalize(c.customer_name, consider_azeri)).ratio()
if score > best_score:
best_score = score
best_party = c.name
best_type = "Customer"
for s in suppliers:
score = SequenceMatcher(None, kb_name, _normalize(s.supplier_name, consider_azeri)).ratio()
if score > best_score:
best_score = score
best_party = s.name
best_type = "Supplier"
if best_score < threshold:
best_party = None
@ -414,28 +447,25 @@ def match_similar_clients():
if best_party:
# Update existing row if present, else append new row
existing_idx = None
for idx, row in enumerate(doc.client_mappings):
if row.client_name == client.client_name:
for idx, row in enumerate(doc.customer_mappings):
if row.kb_customer_name == customer.name:
existing_idx = idx
break
if existing_idx is not None:
doc.client_mappings[existing_idx].party_type = best_type
doc.client_mappings[existing_idx].erp_party = best_party
doc.client_mappings[existing_idx].mapping_type = "Automatic"
doc.customer_mappings[existing_idx].erp_customer = best_party
doc.customer_mappings[existing_idx].mapping_type = "Automatic"
else:
doc.append("client_mappings", {
"client_name": client.client_name,
"tax_id": client.tax_id or None,
"party_type": best_type,
"erp_party": best_party,
"mapping_type": "Automatic",
doc.append("customer_mappings", {
"kb_customer_name": customer.name,
"tax_id": customer.tax_id or None,
"erp_customer": best_party,
"mapping_type": "Automatic",
})
frappe.db.set_value("Kapital Bank Client", client.name, {
"mapped_party_type": best_type,
"mapped_party": best_party,
"status": "Mapped",
frappe.db.set_value("Kapital Bank Customer", customer.name, {
"mapped_customer": best_party,
"status": "Mapped",
}, update_modified=False)
matched += 1
@ -443,112 +473,254 @@ def match_similar_clients():
doc.save(ignore_permissions=True)
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:
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)}
@frappe.whitelist()
def create_unmapped_clients():
"""Create Customer/Supplier records for New Kapital Bank Clients that have no mapped party.
Follows invoice_az pattern: also appends created rows to Settings.client_mappings."""
def match_similar_suppliers():
"""Auto-match Kapital Bank Suppliers to ERPNext Suppliers by name similarity."""
try:
clients = frappe.get_all(
"Kapital Bank Client",
filters={"status": "New"},
fields=["name", "client_name", "tax_id", "mapped_party_type"]
)
doc = frappe.get_doc("Kapital Bank Settings")
already_in_mappings = {row.client_name for row in doc.client_mappings if row.client_name}
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:
if not c.mapped_party_type:
continue
try:
if c.mapped_party_type == "Customer":
party = frappe.new_doc("Customer")
party.customer_name = c.client_name
if doc.default_customer_group:
party.customer_group = doc.default_customer_group
if doc.default_territory:
party.territory = doc.default_territory
if doc.default_payment_terms:
party.payment_terms = doc.default_payment_terms
party.insert(ignore_permissions=True)
customers_count += 1
elif c.mapped_party_type == "Supplier":
party = frappe.new_doc("Supplier")
party.supplier_name = c.client_name
if doc.default_supplier_group:
party.supplier_group = doc.default_supplier_group
if doc.default_payment_terms:
party.payment_terms = doc.default_payment_terms
party.insert(ignore_permissions=True)
suppliers_count += 1
erp_suppliers = frappe.get_all("Supplier", fields=["name", "supplier_name", "tax_id"])
# VÖEN lookup for exact matching
voen_index = {}
for s in erp_suppliers:
if s.tax_id:
voen_index[s.tax_id.strip()] = s.name
# Build dict of existing mappings: kb_supplier_name → erp_supplier
existing_mappings = {
row.kb_supplier_name: row.erp_supplier
for row in doc.supplier_mappings if row.kb_supplier_name
}
# Group 1: suppliers already in the table but with empty erp_supplier
suppliers_with_empty_mapping = []
for kb_name, erp_supplier in existing_mappings.items():
if not erp_supplier:
for s in new_suppliers:
if s.name == kb_name:
suppliers_with_empty_mapping.append(s)
break
# 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:
# Priority 2: name similarity
kb_name = _normalize(supplier.supplier_name, consider_azeri)
best_score = 0
for s in erp_suppliers:
score = SequenceMatcher(None, kb_name, _normalize(s.supplier_name, consider_azeri)).ratio()
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",
}, update_modified=False)
matched += 1
if matched > 0:
doc.save(ignore_permissions=True)
frappe.db.commit()
return {"success": True, "matched_count": matched, "total_processed": len(suppliers_to_process)}
except Exception as e:
frappe.log_error(f"match_similar_suppliers: {e}\n{frappe.get_traceback()}", "KB Supplier Matching")
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
frappe.db.set_value("Kapital Bank Client", c.name, {
"mapped_party_type": c.mapped_party_type,
"mapped_party": party.name,
"status": "Mapped",
}, update_modified=False)
party = frappe.new_doc("Customer")
party.customer_name = customer_name
party.customer_type = "Company"
if c.client_name not in already_in_mappings:
doc.append("client_mappings", {
"client_name": c.client_name,
"tax_id": c.tax_id or None,
"party_type": c.mapped_party_type,
"erp_party": party.name,
"mapping_type": "Manual",
})
already_in_mappings.add(c.client_name)
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
except frappe.DuplicateEntryError:
existing = None
if c.mapped_party_type == "Customer":
existing = frappe.db.get_value("Customer", {"customer_name": c.client_name}, "name")
elif c.mapped_party_type == "Supplier":
existing = frappe.db.get_value("Supplier", {"supplier_name": c.client_name}, "name")
if existing:
frappe.db.set_value("Kapital Bank Client", c.name, {
"mapped_party": existing,
"status": "Mapped",
}, update_modified=False)
if c.client_name not in already_in_mappings:
doc.append("client_mappings", {
"client_name": c.client_name,
"tax_id": c.tax_id or None,
"party_type": c.mapped_party_type,
"erp_party": existing,
"mapping_type": "Manual",
})
already_in_mappings.add(c.client_name)
except Exception as inner_e:
frappe.log_error(f"create_unmapped_clients: error for {c.name}: {inner_e}", "KB Client Creation")
except Exception as e:
frappe.log_error(f"create_unmapped_customers: error for {kb_customer.customer_name}: {e}", "KB Customer Creation")
if created > 0:
doc.save(ignore_permissions=True)
frappe.db.commit()
return {
"success": True,
"created_count": created,
"customers_count": customers_count,
"suppliers_count": suppliers_count,
}
return {"success": True, "created_count": created}
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)}
@ -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)}
# ═══════════════════════════════════════════════════════════════════════════════
# 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
# ═══════════════════════════════════════════════════════════════════════════════

View File

@ -1,72 +1,592 @@
frappe.ui.form.on('Payment Entry', {
refresh(frm) {
if (frm.doc.docstatus !== 1) return;
const KBImport = {
cancelLoading: false,
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'), () => {
kb_send(frm, 'kapital_bank.payment_api.send_transfer', __('Transfer sent'));
}, __('Kapital Bank'));
KBImport.loadingErrors.push(errorEntry);
console.error('KB Import Error:', errorEntry);
},
frm.add_custom_button(__('Send Card Transfer'), () => {
kb_send(frm, 'kapital_bank.payment_api.send_card_transfer', __('Card transfer sent'));
}, __('Kapital Bank'));
clear: function() {
frappe.confirm(
__('Are you sure you want to clear the error log?'),
function() {
KBImport.loadingErrors = [];
frappe.show_alert({
message: __('Error log cleared'),
indicator: 'green'
}, 2);
}
);
},
frm.add_custom_button(__('Send VAT Payment'), () => {
kb_send(frm, 'kapital_bank.payment_api.send_vat_payment', __('VAT payment sent'));
}, __('Kapital Bank'));
showDetails: function() {
if (KBImport.loadingErrors.length === 0) {
frappe.msgprint(__('No errors to display'));
return;
}
frm.add_custom_button(__('Check Status'), () => {
frappe.call({
method: 'kapital_bank.payment_api.check_and_update_status',
args: { payment_entry_name: frm.doc.name },
freeze: true,
freeze_message: __('Checking status...'),
callback(r) {
if (r.message && r.message.success) {
frappe.show_alert({
message: __('Status: {0}', [r.message.status]),
indicator: 'blue'
}, 5);
frm.reload_doc();
} else {
frappe.msgprint({
title: __('Error'),
message: r.message ? r.message.message : __('Unknown error'),
indicator: 'red'
});
}
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';
}
});
}, __('Kapital Bank'));
}
// Show KB status badge if present
if (kb_status) {
const color = kb_status === 'Confirmed' ? 'green'
: kb_status === 'Cancelled' ? 'red'
: 'blue';
frm.dashboard.add_indicator(__('KB: {0}', [kb_status]), color);
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);
}
}
});
};
function kb_send(frm, method, success_msg) {
frappe.call({
method: method,
args: { payment_entry_name: frm.doc.name },
freeze: true,
freeze_message: __('Sending to BIRBank...'),
callback(r) {
if (r.message && r.message.success) {
frappe.show_alert({ message: success_msg, indicator: 'green' }, 5);
frm.reload_doc();
} else {
frappe.msgprint({
title: __('BIRBank Error'),
message: r.message ? r.message.message : __('Unknown error'),
indicator: 'red'
// ======= 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'
}, 5);
}
KBImport.import.showTransactionSelection(txns, accountIban);
} else {
frappe.msgprint({
title: __('Error'),
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') });
}
});
},
showTransactionSelection: function(txns, accountIban) {
let txnTable = '<div style="max-height: 500px; overflow-y: auto;"><table class="table table-bordered kb-txn-table" style="width: 100%; table-layout: fixed;">';
txnTable += '<thead><tr>' +
'<th style="width: 5%;"><input type="checkbox" class="kb-select-all-txns"></th>' +
'<th style="width: 15%;">' + __('Ref No') + '</th>' +
'<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>';
txns.forEach(function(txn, idx) {
const displayDate = txn.date ? moment(txn.date).format('DD.MM.YYYY') : '';
const amountFmt = parseFloat(txn.amount || 0).toFixed(2);
const drcrLabel = txn.drcr === 'D'
? '<span class="label label-danger">' + __('Pay') + '</span>'
: '<span class="label label-success">' + __('Receive') + '</span>';
txnTable += '<tr>' +
'<td><input type="checkbox" class="kb-select-txn" data-idx="' + idx + '"></td>' +
'<td style="word-break: break-word;">' + (txn.ref_no || '') + '</td>' +
'<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({
title: __('Warning'),
indicator: 'orange',
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_migrate = "kapital_bank.setup.after_migrate"
# Document Events
doc_events = {
"Payment Entry": {
"on_cancel": "kapital_bank.payment_api.on_cancel_payment_entry"
}
# JS for ERPNext forms
doctype_js = {
"Payment Entry": "client/payment_entry.js"
}
doctype_list_js = {
"Payment Entry": "client/payment_entry.js"
}
# 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
# ------------------

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
class KapitalBankClientMapping(Document):
pass
class KapitalBankCustomer(Document):
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
class KapitalBankClient(Document):
class KapitalBankPurposeMapping(Document):
pass

View File

@ -14,70 +14,127 @@ frappe.ui.form.on('Kapital Bank Settings', {
}
}, __('Accounts'));
// ── Clients buttons ───────────────────────────────────────────────────
frm.add_custom_button(__('Match clients by similar name'), () => {
frappe.confirm(
__('This will automatically match unmapped clients with similar Customer/Supplier names. Continue?'),
() => {
frappe.show_alert({ message: __('Matching clients...'), indicator: 'blue' });
frappe.call({
method: 'kapital_bank.bank_api.match_similar_clients',
callback(r) {
if (r.message && r.message.success) {
frappe.show_alert({
message: __('Matched {0} of {1} clients', [r.message.matched_count, r.message.total_processed]),
indicator: 'green'
}, 5);
frm.reload_doc();
} else {
frappe.msgprint({ title: __('Error'), indicator: 'red', message: r.message ? r.message.message : __('Error matching clients') });
}
}
});
}
);
}, __('Clients'));
frm.add_custom_button(__('Create matching clients'), () => {
frappe.confirm(
__('<div style="color:red;font-weight:bold;">WARNING! This will create new Customer/Supplier records for all unmapped clients!</div><p>This action is irreversible. Are you sure?</p>'),
() => {
frappe.confirm(__('Are you really sure? This cannot be undone.'), () => {
frappe.call({
method: 'kapital_bank.bank_api.create_unmapped_clients',
freeze: true,
freeze_message: __('Creating clients...'),
callback(r) {
if (r.message && r.message.success) {
frappe.show_alert({
message: __('Created {0} new parties ({1} customers, {2} suppliers)',
[r.message.created_count, r.message.customers_count, r.message.suppliers_count]),
indicator: 'green'
}, 6);
frm.reload_doc();
} else {
frappe.msgprint({ title: __('Error'), indicator: 'red', message: r.message ? r.message.message : __('Error creating clients') });
}
}
});
});
}
);
}, __('Clients'));
frm.add_custom_button(__('Add unmapped clients'), () => {
// ── Customers buttons ─────────────────────────────────────────────────
frm.add_custom_button(__('Match customers by similar name'), () => {
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 {
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 ────────────────────────────────────────────────
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
// ═══════════════════════════════════════════════════════════════════════════════
@ -98,15 +155,16 @@ function open_load_dialog(frm, summary) {
<div class="row">`;
const types = [
{ key: 'accounts', label: __('Accounts') },
{ key: 'cards', label: __('Cards') },
{ key: 'clients', label: __('Clients') }
{ key: 'accounts', label: __('Accounts') },
{ key: 'cards', label: __('Cards') },
{ key: 'customers', label: __('Customers') },
{ key: 'suppliers', label: __('Suppliers') }
];
types.forEach(t => {
const d = summary[t.key] || { total: 0, mapped: 0 };
const unmapped = d.total - d.mapped;
const pct = d.total > 0 ? Math.round(d.mapped / d.total * 100) : 0;
summary_html += `<div class="col-sm-4">
summary_html += `<div class="col-sm-3">
<div class="text-center" style="border:1px solid #ddd;padding:10px;border-radius:4px;background:#fff;">
<h6 style="margin-bottom:8px;">${t.label}</h6>
<div><strong>${d.total}</strong> ${__('total')}</div>
@ -126,7 +184,7 @@ function open_load_dialog(frm, summary) {
title: __('Load Data from BIRBank'),
fields: [
{ fieldname: 'summary_html', fieldtype: 'HTML', options: summary_html },
{ fieldname: 'sec_period', fieldtype: 'Section Break', label: __('Period (for clients from statements)') },
{ fieldname: '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: 'col_break', fieldtype: 'Column Break' },
{ 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') },
{ fieldname: 'load_cards', fieldtype: 'Check', label: __('Load Cards'), default: 1,
description: __('Fetch business cards from GET /cards') },
{ fieldname: 'load_clients', fieldtype: 'Check', label: __('Load Clients from statements'), default: 1,
{ fieldname: 'load_counterparties', fieldtype: 'Check', label: __('Load Counterparties from statements'), default: 1,
description: __('Extract counterparties from account statement transactions for the selected period') }
],
primary_action_label: __('Load'),
primary_action(values) {
if (values.load_clients && (!values.date_from || !values.date_to)) {
frappe.msgprint(__('Please set Date From and Date To to load clients from statements.'));
if (values.load_counterparties && (!values.date_from || !values.date_to)) {
frappe.msgprint(__('Please set Date From and Date To to load counterparties from statements.'));
return;
}
d.hide();
@ -152,7 +210,7 @@ function open_load_dialog(frm, summary) {
}
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; }
let done = 0;
@ -198,19 +256,20 @@ function start_loading(frm, values) {
});
}
if (values.load_clients) {
frappe.show_progress(__('Loading clients...'), 50, 100, __('Fetching statements to extract counterparties...'));
if (values.load_counterparties) {
frappe.show_progress(__('Loading counterparties...'), 50, 100, __('Fetching statements to extract counterparties...'));
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 },
callback(r) {
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 {
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({
method: 'kapital_bank.bank_api.get_unmapped_clients',
method: 'kapital_bank.bank_api.get_unmapped_customers',
callback(r) {
if (!r.message || !r.message.success) {
frappe.msgprint({ title: __('Error'), indicator: 'red', message: r.message ? r.message.message : __('Error') });
return;
}
const clients = r.message.clients;
if (!clients || clients.length === 0) {
frappe.show_alert({ message: __('No unmapped clients found'), indicator: 'blue' }, 5);
const customers = r.message.customers;
if (!customers || customers.length === 0) {
frappe.show_alert({ message: __('No unmapped customers found'), indicator: 'blue' }, 5);
return;
}
clients.forEach(c => {
const row = frm.add_child('client_mappings');
row.tax_id = c.tax_id || '';
row.client_name = c.client_name || '';
row.party_type = c.mapped_party_type || '';
row.erp_party = c.mapped_party || '';
customers.forEach(c => {
const row = frm.add_child('customer_mappings');
row.kb_customer_name = c.name;
row.tax_id = c.tax_id || '';
row.mapping_type = 'Manual';
});
frm.refresh_field('client_mappings');
frm.refresh_field('customer_mappings');
frm.save().then(() => {
frappe.show_alert({ message: __('Added {0} clients to mapping', [clients.length]), indicator: 'green' }, 5);
load_clients_tab();
frappe.show_alert({ message: __('Added {0} customers to mapping', [customers.length]), indicator: 'green' }, 5);
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
// ═══════════════════════════════════════════════════════════════════════════════
@ -284,7 +433,8 @@ function add_unmapped_clients(frm) {
function load_data_tabs(frm) {
load_accounts_tab();
load_cards_tab();
load_clients_tab();
load_customers_tab();
load_suppliers_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({
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) {
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 {
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) {
let html = list_header(__('Clients'), total_count, 'kb-refresh-clients');
function render_customers_list(customers, total_count) {
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;">
<thead class="grid-heading-row"><tr>
<th>${__('Client Name')}</th>
<th>${__('Tax ID (VÖEN)')}</th>
<th>${__('Customer Name')}</th>
<th>${__('Tax ID (VOEN)')}</th>
<th>${__('IBAN')}</th>
<th>${__('Status')}</th>
<th>${__('Party Type')}</th>
<th>${__('Mapped To')}</th>
<th>${__('Created')}</th>
</tr></thead><tbody>`;
clients.forEach(c => {
const pill = c.status === 'Mapped' ? 'green' : (c.status === 'Active' ? 'blue' : 'red');
const mapped_to = c.mapped_party ? `<a href="/app/${(c.mapped_party_type || 'customer').toLowerCase()}/${c.mapped_party}">${c.mapped_party}</a>` : '-';
customers.forEach(c => {
const pill = c.status === 'Mapped' ? 'green' : 'red';
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) : '-';
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.iban || '-'}</td>
<td><span class="indicator-pill ${pill}">${__(c.status)}</span></td>
<td>${c.mapped_party_type || '-'}</td>
<td>${mapped_to}</td>
<td>${created}</td>
</tr>`;
});
html += '</tbody></table>';
if (total_count > clients.length) {
html += `<p class="text-muted small">${__('Showing {0} of {1}', [clients.length, total_count])} — <a href="/app/kapital-bank-client">${__('View All')}</a></p>`;
if (total_count > customers.length) {
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 {
html += empty_state(__('No clients registered yet.'));
html += empty_state(__('No customers registered yet.'));
}
$('#kb-clients-container').html(html);
$('#kb-refresh-clients').on('click', () => {
$('#kb-clients-container').html(loading_html());
load_clients_tab();
$('#kb-customers-container').html(html);
$('#kb-refresh-customers').on('click', () => {
$('#kb-customers-container').html(loading_html());
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",
"cards_tab",
"cards_list_html",
"clients_tab",
"clients_list_html",
"customers_tab",
"customers_list_html",
"suppliers_tab",
"suppliers_list_html",
"account_mappings_tab",
"account_mappings_section",
"account_mappings",
"client_mappings_tab",
"client_mappings_section",
"client_mappings"
"customer_mappings_tab",
"customer_mappings_section",
"customer_mappings",
"supplier_mappings_tab",
"supplier_mappings_section",
"supplier_mappings",
"purpose_mappings_tab",
"purpose_mappings_section",
"purpose_mappings"
],
"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>"
},
{
"fieldname": "clients_tab",
"fieldname": "customers_tab",
"fieldtype": "Tab Break",
"label": "Clients"
"label": "Customers"
},
{
"fieldname": "clients_list_html",
"fieldname": "customers_list_html",
"fieldtype": "HTML",
"label": "Clients List",
"options": "<div id=\"kb-clients-container\"><div class=\"text-center text-muted\" style=\"padding:20px;\">Loading...</div></div>"
"label": "Customers List",
"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",
@ -162,26 +181,58 @@
"options": "Kapital Bank Account Mapping"
},
{
"fieldname": "client_mappings_tab",
"fieldname": "customer_mappings_tab",
"fieldtype": "Tab Break",
"label": "Client Mappings"
"label": "Customer Mappings"
},
{
"fieldname": "client_mappings_section",
"fieldname": "customer_mappings_section",
"fieldtype": "Section Break",
"label": "Client Mappings"
"label": "Customer Mappings"
},
{
"fieldname": "client_mappings",
"fieldname": "customer_mappings",
"fieldtype": "Table",
"label": "Client Mappings",
"options": "Kapital Bank Client Mapping"
"label": "Customer Mappings",
"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,
"issingle": 1,
"links": [],
"modified": "2026-02-25 00:00:00.000000",
"modified": "2026-02-26 00:00:00.000000",
"modified_by": "Administrator",
"module": "Kapital Bank",
"name": "Kapital Bank Settings",

View File

@ -3,29 +3,55 @@ from frappe.model.document import Document
class KapitalBankSettings(Document):
def on_update(self):
self._sync_client_statuses()
def on_update(self):
self._sync_customer_statuses()
self._sync_supplier_statuses()
def _sync_client_statuses(self):
"""Reset Kapital Bank Client status to 'New' for clients removed from client_mappings."""
mapped_names = {row.client_name for row in self.client_mappings if row.client_name}
def _sync_customer_statuses(self):
"""Reset Kapital Bank Customer status to 'New' for customers removed from customer_mappings."""
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(
"Kapital Bank Client",
filters={"status": "Mapped"},
fields=["name", "client_name"],
)
orphaned = frappe.get_all(
"Kapital Bank Customer",
filters={"status": "Mapped"},
fields=["name", "customer_name"],
)
for client in orphaned:
if client.client_name not in mapped_names:
frappe.db.set_value(
"Kapital Bank Client",
client.name,
{
"status": "New",
"mapped_party_type": None,
"mapped_party": None,
},
update_modified=False,
)
for customer in orphaned:
if customer.name not in mapped_names:
frappe.db.set_value(
"Kapital Bank Customer",
customer.name,
{
"status": "New",
"mapped_customer": 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,
)

View File

@ -1,35 +1,36 @@
{
"actions": [],
"allow_rename": 1,
"autoname": "field:client_name",
"creation": "2026-02-24 00:00:00.000000",
"autoname": "field:supplier_name",
"creation": "2026-02-26 00:00:00.000000",
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
"client_name",
"supplier_name",
"tax_id",
"status",
"column_break_1",
"iban",
"bank_code",
"mapping_section",
"mapped_party_type",
"mapped_party"
"supplier_mapping_section",
"mapped_supplier",
"supplier_group",
"payment_terms"
],
"fields": [
{
"fieldname": "client_name",
"fieldname": "supplier_name",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Client Name",
"reqd": 1
"label": "Supplier Name",
"length": 500,
"reqd": 1,
"unique": 1
},
{
"fieldname": "tax_id",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Tax ID (VÖEN)",
"unique": 1
"label": "Tax ID"
},
{
"default": "New",
@ -37,7 +38,8 @@
"fieldtype": "Select",
"in_list_view": 1,
"label": "Status",
"options": "New\nMapped\nActive"
"options": "New\nMapped",
"read_only": 1
},
{
"fieldname": "column_break_1",
@ -54,29 +56,38 @@
"label": "Bank Code"
},
{
"fieldname": "mapping_section",
"fieldname": "supplier_mapping_section",
"fieldtype": "Section Break",
"label": "ERP Mapping"
"label": "Supplier Mapping"
},
{
"fieldname": "mapped_party_type",
"fieldtype": "Select",
"label": "Mapped Party Type",
"options": "\nCustomer\nSupplier"
"fieldname": "mapped_supplier",
"fieldtype": "Link",
"label": "Mapped Supplier",
"options": "Supplier",
"read_only": 1
},
{
"fieldname": "mapped_party",
"fieldtype": "Dynamic Link",
"label": "Mapped Party",
"options": "mapped_party_type"
"fieldname": "supplier_group",
"fieldtype": "Link",
"label": "Supplier Group",
"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,
"links": [],
"modified": "2026-02-24 00:00:00.000000",
"modified": "2026-02-26 00:00:00.000000",
"modified_by": "Administrator",
"module": "Kapital Bank",
"name": "Kapital Bank Client",
"name": "Kapital Bank Supplier",
"naming_rule": "By fieldname",
"owner": "Administrator",
"permissions": [
@ -91,11 +102,21 @@
"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
}
],
"row_format": "Dynamic",
"sort_field": "modified",
"sort_order": "DESC",
"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
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"
)
# Removed: outgoing payment functions were removed.

View File

@ -1,83 +1,23 @@
import frappe
from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
def after_install():
create_payment_entry_custom_fields()
cleanup_payment_entry_custom_fields()
def after_migrate():
create_payment_entry_custom_fields()
cleanup_payment_entry_custom_fields()
def create_payment_entry_custom_fields():
custom_fields = {
"Payment Entry": [
{
"fieldname": "kb_section",
"fieldtype": "Section Break",
"label": "Kapital Bank",
"insert_after": "letter_head",
"collapsible": 1,
},
{
"fieldname": "kb_transfer_type",
"fieldtype": "Select",
"label": "KB Transfer Type",
"options": "\nInternal Transfer\nCard Transfer\nVAT Payment",
"insert_after": "kb_section",
},
{
"fieldname": "kb_operation_name",
"fieldtype": "Data",
"label": "KB Operation Name",
"insert_after": "kb_transfer_type",
"read_only": 1,
},
{
"fieldname": "kb_bank_id",
"fieldtype": "Data",
"label": "KB Bank ID",
"insert_after": "kb_operation_name",
"read_only": 1,
},
{
"fieldname": "kb_transfer_status",
"fieldtype": "Data",
"label": "KB Transfer Status",
"insert_after": "kb_bank_id",
"read_only": 1,
},
{
"fieldname": "kb_column_break",
"fieldtype": "Column Break",
"insert_after": "kb_transfer_status",
},
{
"fieldname": "kb_from_iban",
"fieldtype": "Data",
"label": "KB From IBAN",
"insert_after": "kb_column_break",
},
{
"fieldname": "kb_to_iban",
"fieldtype": "Data",
"label": "KB To IBAN",
"insert_after": "kb_from_iban",
},
{
"fieldname": "kb_to_tax_no",
"fieldtype": "Data",
"label": "KB To Tax No (VÖEN)",
"insert_after": "kb_to_iban",
},
{
"fieldname": "kb_ben_bank_code",
"fieldtype": "Data",
"label": "KB Ben Bank Code",
"insert_after": "kb_to_tax_no",
},
]
}
create_custom_fields(custom_fields, ignore_validate=True)
KB_FIELDS = [
"kb_section", "kb_transfer_type", "kb_operation_name", "kb_bank_id",
"kb_transfer_status", "kb_column_break", "kb_from_iban", "kb_to_iban",
"kb_to_tax_no", "kb_ben_bank_code",
]
def cleanup_payment_entry_custom_fields():
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)
frappe.db.commit()