added bank transaction

This commit is contained in:
Ali 2026-03-03 17:56:08 +04:00
parent cc12c99608
commit 89a372c490
11 changed files with 1054 additions and 26 deletions

81
CLAUDE.md Normal file
View File

@ -0,0 +1,81 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Kapital Bank is a Frappe app integrating BIRBank B2B API with ERPNext. It imports bank accounts, cards, and transactions as Payment Entries, with fuzzy matching of counterparties and purpose-based GL account mapping.
## Development Commands
```bash
# Start development server (from bench directory)
bench start
# Install/reinstall the app
bench install-app kapital_bank
# Run migrations after doctype changes
bench migrate
# Run tests
bench --site <site-name> run-tests --app kapital_bank
# Linting and formatting (from app directory)
pre-commit run --all-files
pre-commit run ruff --all-files # Python only
pre-commit run eslint --all-files # JavaScript only
```
## Code Style
- **Python**: Ruff linter/formatter — tabs, double quotes, line-length 110, target Python 3.10
- **JavaScript**: ESLint + Prettier — tabs, indent size 4
- **Indentation**: Tabs everywhere (spaces only in JSON, indent size 1)
## Architecture
### Core Modules
| File | Purpose |
|---|---|
| `kapital_bank/auth.py` | JWT auth: login, token refresh, scheduled renewal (cron every 4 min) |
| `kapital_bank/api.py` | `BIRBankClient` — HTTP wrapper with auto token refresh on 401 |
| `kapital_bank/bank_api.py` | Main business logic: registry loading, fuzzy matching, transaction import (~1400 lines) |
| `kapital_bank/hooks.py` | Frappe hooks: doctype_js, scheduler_events, doc_events |
| `kapital_bank/client/payment_entry.js` | Frontend: Payment Entry form/list customization |
### Data Flow
1. **Auth**: Credentials in `Kapital Bank Login` → JWT tokens stored as encrypted Password fields
2. **Registry**: API data fetched into local doctypes (Account, Card, Customer, Supplier)
3. **Mapping**: Settings tables link bank entities to ERPNext records (Bank Account, Customer, Supplier, GL Account)
4. **Import**: Statement transactions → fuzzy match party/purpose → create Payment Entry + tracking record
5. **Cleanup**: Doc events on Payment Entry (on_trash, on_cancel) auto-delete associated transaction records
### Matching Algorithm
Three-tier party matching with configurable similarity thresholds:
1. Exact VÖEN (tax ID) match
2. Name similarity via `SequenceMatcher` (defaults: customer 90%, supplier 80%, purpose 70%)
3. Optional Azerbaijani character normalization (Ə→E, ə→e, etc.)
### DocTypes
- **Single doctypes**: `Kapital Bank Login` (credentials), `Kapital Bank Settings` (central config hub with mapping child tables)
- **Registry doctypes**: Account, Card, Customer, Supplier — local copies of bank data
- **Child tables**: Account Mapping, Card Mapping, Customer Mapping, Supplier Mapping, Purpose Mapping
- **Tracking**: `Kapital Bank Transaction` — tracks imported Payment Entries by reference_no for deduplication
- **Utility**: `Kapital Bank API Test` — manual API testing
### Key Patterns
- All `@frappe.whitelist()` functions use `ignore_permissions=True` for system-level operations
- Bulk imports run as background jobs via `frappe.enqueue()` with 10-min timeout
- Progress published via `frappe.publish_realtime()` over Socket.IO
- Sensitive data (tokens, passwords) stored in Password-type fields (encrypted in DB)
- Error logging via `frappe.log_error()` with masked account numbers in debug output
## API Documentation
BIRBank B2B API documentation PDFs are in `birbank_docs/`. The base URL is `https://my.birbank.business/api/b2b`.

View File

@ -1,6 +1,5 @@
import frappe
import requests
import json
from frappe.utils import now_datetime
BASE_URL = "https://my.birbank.business/api/b2b"
@ -107,16 +106,29 @@ def logout(login_name):
return {"success": True, "message": "Logged out"}
def _relogin(login_name):
"""Auto re-login using stored credentials. Returns new jwt_token or raises."""
frappe.logger().info(f"[KB] Attempting auto re-login for {login_name}")
result = login(login_name)
if result.get("success"):
doc = _get_doc(login_name)
new_token = doc.get_password("jwt_token") if doc.jwt_token else ""
if new_token:
frappe.logger().info(f"[KB] Auto re-login successful for {login_name}")
return new_token
frappe.log_error(f"Auto re-login failed for {login_name}: {result.get('message')}", "KB Relogin Error")
raise frappe.AuthenticationError(f"Auto re-login failed: {result.get('message')}")
def refresh_token(login_name):
"""POST /api/b2b/refresh — get new JWT using refresh token. Returns new jwt_token or raises."""
"""POST /api/b2b/refresh — get new JWT using refresh token.
Falls back to full re-login if refresh token is expired/invalid."""
doc = _get_doc(login_name)
refresh_tok = doc.get_password("jwt_refresh_token") if doc.jwt_refresh_token else ""
if not refresh_tok:
frappe.log_error(f"No refresh token for {login_name}", "KB Refresh Error")
frappe.db.set_value("Kapital Bank Login", login_name, "auth_status", "Not Authenticated", update_modified=False)
frappe.db.commit()
raise frappe.AuthenticationError("No refresh token available")
frappe.logger().info(f"[KB] No refresh token for {login_name}, attempting re-login")
return _relogin(login_name)
payload = {"refreshToken": refresh_tok}
@ -135,9 +147,8 @@ def refresh_token(login_name):
resp_code = data.get("response", {}).get("code", "")
if resp_code == "100":
frappe.db.set_value("Kapital Bank Login", login_name, "auth_status", "Not Authenticated", update_modified=False)
frappe.db.commit()
raise frappe.AuthenticationError("Refresh token invalid (code 100)")
frappe.logger().info(f"[KB] Refresh token invalid (code 100) for {login_name}, attempting re-login")
return _relogin(login_name)
resp_data = data.get("responseData", {})
new_jwt = resp_data.get("jwttoken", "")
@ -154,9 +165,8 @@ def refresh_token(login_name):
return new_jwt
elif resp.status_code == 401:
frappe.db.set_value("Kapital Bank Login", login_name, "auth_status", "Not Authenticated", update_modified=False)
frappe.db.commit()
raise frappe.AuthenticationError("Refresh token expired (401)")
frappe.logger().info(f"[KB] Refresh token expired (401) for {login_name}, attempting re-login")
return _relogin(login_name)
else:
frappe.log_error(
@ -184,12 +194,15 @@ def renew_token(login_name=None):
return {"success": False, "message": "No default Kapital Bank Login found"}
doc = _get_doc(login_name)
if doc.auth_status != "Authenticated":
return {"success": False, "message": f"Login {login_name} is not authenticated"}
if doc.auth_status == "Authenticated":
new_token = refresh_token(login_name)
frappe.logger().info(f"[KB] Token renewed for {login_name}")
return {"success": True, "message": "Token renewed"}
new_token = refresh_token(login_name)
frappe.logger().info(f"[KB] Token renewed for {login_name}")
return {"success": True, "message": "Token renewed"}
# Not authenticated — try full re-login with stored credentials
frappe.logger().info(f"[KB] Login {login_name} is {doc.auth_status}, attempting re-login")
new_token = _relogin(login_name)
return {"success": True, "message": "Re-login successful"}
except Exception as e:
frappe.log_error(f"renew_token failed: {e}\n{frappe.get_traceback()}", "KB Token Renewal")

View File

@ -1259,8 +1259,8 @@ def import_single_transaction(txn_data, account_iban, login_name=None):
# ═══════════════════════════════════════════════════════════════════════════════
def _create_transaction_record(reference_no, posting_date, amount, currency,
party, source_type, source_label, payment_entry,
commit=True):
party, source_type, source_label, payment_entry=None,
bank_transaction=None, commit=True):
"""Create a Kapital Bank Transaction record to track imported transaction."""
reference_no = str(reference_no).strip()
if not reference_no:
@ -1279,7 +1279,10 @@ def _create_transaction_record(reference_no, posting_date, amount, currency,
doc.party = party
doc.source_type = source_type
doc.source_label = source_label
doc.payment_entry = payment_entry
if payment_entry:
doc.payment_entry = payment_entry
if bank_transaction:
doc.bank_transaction = bank_transaction
doc.status = "Imported"
doc.import_date = frappe.utils.now_datetime()
doc.insert(ignore_permissions=True)
@ -1331,6 +1334,213 @@ def on_cancel_payment_entry(doc, method):
)
# ═══════════════════════════════════════════════════════════════════════════════
# BANK TRANSACTION CLEANUP
# ═══════════════════════════════════════════════════════════════════════════════
def on_delete_bank_transaction(doc, method):
"""Delete linked Kapital Bank Transaction records when Bank Transaction is deleted."""
try:
records = frappe.get_all(
"Kapital Bank Transaction",
filters={"bank_transaction": doc.name},
fields=["name"],
)
for record in records:
frappe.delete_doc("Kapital Bank Transaction", record.name, ignore_permissions=True, force=True)
if records:
frappe.db.commit()
except Exception as e:
frappe.log_error(
f"Error deleting Kapital Bank Transaction for BT {doc.name}: {e}\n{frappe.get_traceback()}",
"Kapital Bank Transaction Cleanup",
)
def on_cancel_bank_transaction(doc, method):
"""Delete linked Kapital Bank Transaction records when Bank Transaction is cancelled."""
try:
records = frappe.get_all(
"Kapital Bank Transaction",
filters={"bank_transaction": doc.name},
fields=["name"],
)
for record in records:
frappe.db.delete("Kapital Bank Transaction", {"name": record.name})
if records:
frappe.db.commit()
except Exception as e:
frappe.log_error(
f"Error deleting Kapital Bank Transaction for cancelled BT {doc.name}: {e}\n{frappe.get_traceback()}",
"Kapital Bank Transaction Cleanup",
)
# ═══════════════════════════════════════════════════════════════════════════════
# BANK TRANSACTION IMPORT (simpler path — no purpose/party mappings required)
# ═══════════════════════════════════════════════════════════════════════════════
def _build_bank_account_lookup(settings):
"""Build IBAN/card-account-number → Bank Account lookup from settings mappings."""
ba_lookup = {}
for row in settings.account_mappings:
if row.iban and row.bank_account:
ba_lookup[row.iban.strip()] = row.bank_account
for row in settings.card_mappings:
if row.account_number and row.bank_account:
ba_lookup[row.account_number.strip()] = row.bank_account
return ba_lookup
def _import_one_bank_transaction(txn_data, settings, voen_to_party, name_to_party,
ba_lookup):
"""Import a single transaction as a Bank Transaction document. Returns result dict."""
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()
purpose = (txn_data.get("purpose") or "").strip()
contr_name = (txn_data.get("counterparty") or "").strip()
contr_voen = (txn_data.get("contr_voen") or "").strip()
currency = txn_data.get("currency", "AZN")
source_type = txn_data.get("source_type", "account")
source_label = txn_data.get("source_label", "")
# Determine bank_account from source identifier
bank_account = None
if source_type == "account":
# source_label is the IBAN for accounts
source_id = txn_data.get("source_id", "")
if source_id:
bank_account = ba_lookup.get(source_id.strip())
if not bank_account and source_label:
bank_account = ba_lookup.get(source_label.strip())
elif source_type == "card":
# For cards, try to extract account number from source_label ("Card: XXXX")
source_id = txn_data.get("source_id", "")
if source_id:
bank_account = ba_lookup.get(source_id.strip())
if not bank_account and source_label:
card_num = source_label.replace("Card: ", "").strip()
bank_account = ba_lookup.get(card_num)
# Optional party mapping: VÖEN → 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"]
# Create Bank Transaction
bt = frappe.new_doc("Bank Transaction")
bt.date = parsed_date
bt.deposit = amount if dr_cr == "C" else 0
bt.withdrawal = amount if dr_cr == "D" else 0
bt.currency = currency
bt.description = purpose
bt.reference_number = ref_no
bt.transaction_id = ref_no
bt.company = settings.default_company
bt.bank_party_name = contr_name or purpose
if bank_account:
bt.bank_account = bank_account
if party_type and erp_party:
bt.party_type = party_type
bt.party = erp_party
try:
bt.insert(ignore_permissions=True)
bt.submit()
except Exception as bt_err:
frappe.db.rollback()
frappe.local.message_log = []
return {
"success": False,
"error_type": "import_error",
"message": str(bt_err),
"txn_data": txn_data,
}
# Create tracking record
_create_transaction_record(
reference_no=ref_no,
posting_date=parsed_date,
amount=amount,
currency=currency,
party=contr_name,
source_type="Account" if source_type == "account" else "Card",
source_label=source_label,
bank_transaction=bt.name,
commit=False,
)
return {"success": True, "bank_transaction": bt.name}
@frappe.whitelist()
def import_bulk_bank_transactions(txn_list):
"""Enqueue bulk Bank Transaction import as a background job."""
if isinstance(txn_list, str):
txn_list = json.loads(txn_list)
user = frappe.session.user
frappe.enqueue(
_process_bulk_bt_import,
txn_list=txn_list,
user=user,
queue="default",
timeout=600,
)
return {"success": True, "enqueued": True, "total": len(txn_list)}
def _process_bulk_bt_import(txn_list, user):
"""Background job: import transactions as Bank Transaction with realtime progress."""
frappe.set_user(user)
settings = frappe.get_single("Kapital Bank Settings")
_purpose_rules, voen_to_party, name_to_party = _build_import_lookups(settings)
ba_lookup = _build_bank_account_lookup(settings)
total = len(txn_list)
imported_count = 0
errors = []
for idx, txn_data in enumerate(txn_list):
frappe.publish_realtime(
"kb_bt_import_progress",
{"current": idx + 1, "total": total},
user=user,
)
result = _import_one_bank_transaction(
txn_data, settings, voen_to_party, name_to_party, ba_lookup,
)
if result.get("success"):
imported_count += 1
else:
errors.append(result)
frappe.db.commit()
frappe.publish_realtime(
"kb_bt_import_complete",
{"total": total, "imported": imported_count, "errors": errors},
user=user,
)
# ═══════════════════════════════════════════════════════════════════════════════
# INTERNAL HELPERS
# ═══════════════════════════════════════════════════════════════════════════════

View File

@ -0,0 +1,704 @@
const KBBTImport = {
loadingErrors: [],
};
// ======= ERROR MANAGEMENT =======
KBBTImport.errors = {
add: function(txnData, errorType, errorMessage) {
const errorEntry = {
ref_no: txnData.ref_no || 'Unknown',
date: txnData.date || 'Unknown',
counterparty: txnData.counterparty || 'Unknown',
amount: txnData.amount || 0,
source_label: txnData.source_label || '',
error_type: errorType,
error_message: errorMessage,
timestamp: new Date().toISOString(),
};
KBBTImport.loadingErrors.push(errorEntry);
console.error('KB BT Import Error:', errorEntry);
},
clear: function() {
frappe.confirm(
__('Are you sure you want to clear the error log?'),
function() {
KBBTImport.loadingErrors = [];
frappe.show_alert({
message: __('Error log cleared'),
indicator: 'green'
}, 2);
}
);
},
showDetails: function() {
if (KBBTImport.loadingErrors.length === 0) {
frappe.msgprint(__('No errors to display'));
return;
}
let errorsHtml = '<div class="error-log-container">';
KBBTImport.loadingErrors.forEach(function(error) {
let documentDate = 'No date';
if (error.date && error.date !== 'Unknown') {
try {
documentDate = moment(error.date).format('DD.MM.YYYY');
} catch (e) {
documentDate = 'Invalid date';
}
}
errorsHtml += '<div class="error-item" style="margin-bottom: 20px; padding: 15px; border: 1px solid var(--border-color); border-radius: 6px; background: var(--bg-color);">';
// Header
errorsHtml += '<div class="error-header" style="margin-bottom: 10px; padding-bottom: 8px; border-bottom: 1px solid var(--border-color);">';
errorsHtml += '<div style="display: flex; justify-content: space-between; align-items: center;">';
errorsHtml += '<h5 style="margin: 0; color: var(--text-color);">' +
'<strong>' + error.ref_no + '</strong></h5>';
errorsHtml += '<span class="label label-danger">' + error.error_type + '</span>';
errorsHtml += '</div>';
errorsHtml += '<small style="color: var(--text-muted);">' + error.counterparty + '</small>';
if (error.source_label) {
errorsHtml += ' <small style="color: var(--text-muted);">(' + error.source_label + ')</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>';
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 = {};
KBBTImport.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> ' + KBBTImport.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-bt-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-bt-clear-log-dialog-btn">' +
'<i class="fa fa-trash"></i> ' + __('Clear') + '</button>';
statsHtml += '</div></div>';
const d = new frappe.ui.Dialog({
title: __('Error Details') + ' (' + KBBTImport.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-bt-export-csv-dialog-btn').off('click').on('click', function() {
KBBTImport.errors.exportCSV();
});
$('#kb-bt-clear-log-dialog-btn').off('click').on('click', function() {
KBBTImport.errors.clear();
d.hide();
});
}, 200);
},
exportCSV: function() {
if (KBBTImport.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,Source,Error Type,Error Message,Timestamp\n";
KBBTImport.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.source_label || '',
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_bt_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-bt-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-bt-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-bt-view-error-details-btn').off('click').on('click', function() {
summaryDialog.hide();
KBBTImport.errors.showDetails();
});
$('#kb-bt-export-error-log-btn').off('click').on('click', function() {
KBBTImport.errors.exportCSV();
});
}, 200);
}
}
};
// ======= IMPORT MODULE =======
KBBTImport.import = {
showDialog: function() {
// Load sources: try settings mappings first, fall back to registry doctypes
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 accountMappings = r.message.account_mappings || [];
const cardMappings = r.message.card_mappings || [];
if (accountMappings.length > 0 || cardMappings.length > 0) {
// Use mappings as source list
KBBTImport.import._showDialogWithSources(
accountMappings.map(function(m) {
return { type: 'account', id: m.iban, label: m.account_label ? (m.iban + ' — ' + m.account_label) : m.iban };
}).filter(function(s) { return s.id; }),
cardMappings.map(function(m) {
return { type: 'card', id: m.account_number, label: m.card_label ? (m.account_number + ' — ' + m.card_label) : m.account_number };
}).filter(function(s) { return s.id; })
);
} else {
// Fall back to registry doctypes
KBBTImport.import._loadRegistrySources();
}
}
});
},
_loadRegistrySources: function() {
let accounts = [];
let cards = [];
let pending = 2;
function onDone() {
pending--;
if (pending > 0) return;
if (accounts.length === 0 && cards.length === 0) {
frappe.msgprint({
title: __('No Sources'),
indicator: 'orange',
message: __('No bank accounts or cards found. Please load registry data in Kapital Bank Settings first.')
});
return;
}
KBBTImport.import._showDialogWithSources(accounts, cards);
}
frappe.call({
method: 'frappe.client.get_list',
args: {
doctype: 'Kapital Bank Account',
fields: ['iban', 'account_desc', 'currency'],
filters: { status: 'Active' },
limit_page_length: 0
},
callback: function(r) {
if (r.message) {
accounts = r.message.map(function(a) {
const label = a.account_desc ? (a.iban + ' — ' + a.account_desc) : a.iban;
return { type: 'account', id: a.iban, label: label };
});
}
onDone();
},
error: function() { onDone(); }
});
frappe.call({
method: 'frappe.client.get_list',
args: {
doctype: 'Kapital Bank Card',
fields: ['account_number', 'card_type', 'pan', 'currency'],
filters: { status: 'Active' },
limit_page_length: 0
},
callback: function(r) {
if (r.message) {
cards = r.message.map(function(c) {
const label = c.pan ? (c.account_number + ' — ' + c.pan) : c.account_number;
return { type: 'card', id: c.account_number, label: label };
});
}
onDone();
},
error: function() { onDone(); }
});
},
_showDialogWithSources: function(accounts, cards) {
let sourcesHtml = '<div style="max-height: 300px; overflow-y: auto;">';
sourcesHtml += '<div style="margin-bottom: 10px;">';
sourcesHtml += '<label><input type="checkbox" class="kb-bt-select-all-sources"> <strong>' + __('Select All') + '</strong></label>';
sourcesHtml += '</div>';
if (accounts.length > 0) {
sourcesHtml += '<div style="margin-bottom: 8px; font-weight: 600; color: var(--text-color);">' + __('Accounts') + '</div>';
accounts.forEach(function(a) {
sourcesHtml += '<div style="margin-left: 20px; margin-bottom: 4px;">';
sourcesHtml += '<label><input type="checkbox" class="kb-bt-source-check" data-type="' + a.type + '" data-id="' + a.id + '"> ' + a.label + '</label>';
sourcesHtml += '</div>';
});
}
if (cards.length > 0) {
sourcesHtml += '<div style="margin-bottom: 8px; margin-top: 10px; font-weight: 600; color: var(--text-color);">' + __('Cards') + '</div>';
cards.forEach(function(c) {
sourcesHtml += '<div style="margin-left: 20px; margin-bottom: 4px;">';
sourcesHtml += '<label><input type="checkbox" class="kb-bt-source-check" data-type="' + c.type + '" data-id="' + c.id + '"> ' + c.label + '</label>';
sourcesHtml += '</div>';
});
}
sourcesHtml += '</div>';
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_sources', fieldtype: 'Section Break', label: __('Sources') },
{
fieldname: 'sources_html',
fieldtype: 'HTML',
options: sourcesHtml
}
],
primary_action_label: __('Search'),
primary_action: function() {
const values = d.get_values();
const selectedSources = [];
d.$wrapper.find('.kb-bt-source-check:checked').each(function() {
selectedSources.push({
type: $(this).data('type'),
id: $(this).data('id')
});
});
if (selectedSources.length === 0) {
frappe.msgprint({
title: __('Warning'),
indicator: 'orange',
message: __('Please select at least one account or card')
});
return;
}
// Card statements are limited to 90 days by the API
const hasCards = selectedSources.some(s => s.type === 'card');
if (hasCards) {
const diffDays = frappe.datetime.get_diff(values.date_to, values.date_from);
if (diffDays > 90) {
frappe.msgprint({
title: __('Date Range Too Large'),
indicator: 'orange',
message: __('Card statements are limited to 90 days. Please shorten the date range or deselect cards.')
});
return;
}
}
d.hide();
KBBTImport.import.loadTransactionsMulti(values.date_from, values.date_to, selectedSources);
}
});
d.show();
// Select All handler
d.$wrapper.find('.kb-bt-select-all-sources').on('change', function() {
const isChecked = $(this).prop('checked');
d.$wrapper.find('.kb-bt-source-check').prop('checked', isChecked);
});
},
loadTransactionsMulti: function(fromDate, toDate, sources) {
const totalSources = sources.length;
let currentSourceIdx = 0;
let allTransactions = [];
let totalSkipped = 0;
function fetchNext() {
if (currentSourceIdx >= totalSources) {
frappe.hide_progress();
if (allTransactions.length === 0) {
let msg = __('No new transactions found for the specified period.');
if (totalSkipped > 0) {
msg += ' ' + __('({0} already imported)', [totalSkipped]);
}
frappe.msgprint({
title: __('Information'),
indicator: 'blue',
message: msg
});
return;
}
if (totalSkipped > 0) {
frappe.show_alert({
message: __('Skipped {0} already imported transactions', [totalSkipped]),
indicator: 'blue'
}, 5);
}
KBBTImport.import.showTransactionSelection(allTransactions);
return;
}
const source = sources[currentSourceIdx];
const sourceLabel = source.type === 'account'
? source.id
: __('Card: {0}', [source.id]);
frappe.show_progress(
__('Fetching Statements'),
currentSourceIdx,
totalSources,
__('Fetching statement {0} of {1}: {2}', [currentSourceIdx + 1, totalSources, sourceLabel])
);
const method = source.type === 'account'
? 'kapital_bank.bank_api.get_statement_transactions'
: 'kapital_bank.bank_api.get_card_statement_transactions';
const args = source.type === 'account'
? { from_date: fromDate, to_date: toDate, account_iban: source.id }
: { from_date: fromDate, to_date: toDate, card_account_number: source.id };
frappe.call({
method: method,
args: args,
callback: function(r) {
if (r.message && r.message.success) {
const txns = (r.message.transactions || []).map(function(txn) {
txn.source_id = source.id;
return txn;
});
allTransactions = allTransactions.concat(txns);
totalSkipped += (r.message.skipped_duplicates || 0);
} else {
frappe.show_alert({
message: __('Error fetching {0}: {1}', [sourceLabel, r.message ? r.message.message : 'unknown']),
indicator: 'red'
}, 5);
}
currentSourceIdx++;
setTimeout(fetchNext, 100);
},
error: function() {
frappe.show_alert({
message: __('Network error fetching {0}', [sourceLabel]),
indicator: 'red'
}, 5);
currentSourceIdx++;
setTimeout(fetchNext, 100);
}
});
}
fetchNext();
},
showTransactionSelection: function(txns) {
let txnTable = '<div style="max-height: 500px; overflow-y: auto;"><table class="table table-bordered kb-bt-txn-table" style="width: 100%; table-layout: fixed;">';
txnTable += '<thead><tr>' +
'<th style="width: 4%;"><input type="checkbox" class="kb-bt-select-all-txns"></th>' +
'<th style="width: 14%;">' + __('Ref No') + '</th>' +
'<th style="width: 10%;">' + __('Date') + '</th>' +
'<th style="width: 14%;">' + __('Account') + '</th>' +
'<th style="width: 20%;">' + __('Counterparty') + '</th>' +
'<th style="width: 12%; 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>';
const sourceLabel = txn.source_label || '';
txnTable += '<tr>' +
'<td><input type="checkbox" class="kb-bt-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; font-size: 0.85em;">' + sourceLabel + '</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-bt-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();
KBBTImport.import.loadSelectedTransactions(selectedTxns);
},
secondary_action_label: __('Cancel'),
secondary_action: function() {
d.hide();
}
});
d.$wrapper.find('.modal-dialog').css({
'max-width': '90%',
'width': '90%',
'margin': '30px auto'
});
d.$wrapper.find('.modal-body').css({
'padding': '15px'
});
d.show();
d.$wrapper.find('.kb-bt-select-all-txns').on('change', function() {
const isChecked = $(this).prop('checked');
d.$wrapper.find('.kb-bt-select-txn').prop('checked', isChecked);
});
},
loadSelectedTransactions: function(selectedTxns) {
KBBTImport.loadingErrors = [];
const total = selectedTxns.length;
frappe.show_progress(
__('Importing Transactions'), 0, total,
__('Starting import of {0} transactions...', [total])
);
// Listen for realtime progress from background job
frappe.realtime.off('kb_bt_import_progress');
frappe.realtime.on('kb_bt_import_progress', function(data) {
frappe.show_progress(
__('Importing Transactions'), data.current, data.total,
__('Importing transaction {0} of {1}', [data.current, data.total])
);
});
// Listen for completion from background job
frappe.realtime.off('kb_bt_import_complete');
frappe.realtime.on('kb_bt_import_complete', function(data) {
frappe.realtime.off('kb_bt_import_progress');
frappe.realtime.off('kb_bt_import_complete');
KBBTImport.import._onBulkComplete(data);
});
frappe.call({
method: 'kapital_bank.bank_api.import_bulk_bank_transactions',
args: { txn_list: JSON.stringify(selectedTxns) },
callback: function(r) {
// Job enqueued — progress & completion come via realtime events
if (!r.message || !r.message.enqueued) {
frappe.realtime.off('kb_bt_import_progress');
frappe.realtime.off('kb_bt_import_complete');
KBBTImport.import._closeProgress();
frappe.msgprint({
title: __('Error'),
indicator: 'red',
message: __('Failed to start import job')
});
}
},
error: function() {
frappe.realtime.off('kb_bt_import_progress');
frappe.realtime.off('kb_bt_import_complete');
KBBTImport.import._closeProgress();
frappe.msgprint({
title: __('Error'),
indicator: 'red',
message: __('Network error starting import')
});
}
});
},
_onBulkComplete: function(data) {
KBBTImport.import._closeProgress();
// Collect errors for the error panel
(data.errors || []).forEach(function(err) {
const txnData = err.txn_data || {};
KBBTImport.errors.add(txnData, 'Import Error', err.message);
});
KBBTImport.errors.showSummary(data.total, data.imported, data.errors ? data.errors.length : 0);
if (cur_list) {
cur_list.refresh();
}
},
_closeProgress: function() {
if (frappe.cur_progress) {
try {
frappe.cur_progress.$wrapper.modal('hide');
frappe.cur_progress.$wrapper.remove();
} catch(e) {}
frappe.cur_progress = null;
}
if ($('.modal:visible').length === 0) {
$('.modal-backdrop').remove();
$('body').removeClass('modal-open');
}
}
};
// ======= LIST VIEW SETUP =======
frappe.listview_settings['Bank Transaction'] = {
onload(listview) {
listview.page.add_inner_button(__('Import'), function() {
KBBTImport.import.showDialog();
}, __('Kapital Bank'));
}
};

View File

@ -661,8 +661,8 @@ KBImport.import = {
// ======= LIST VIEW SETUP =======
frappe.listview_settings['Payment Entry'] = {
onload(listview) {
listview.page.add_menu_item(__('Import from Kapital Bank'), function() {
listview.page.add_inner_button(__('Import'), function() {
KBImport.import.showDialog();
});
}, __('Kapital Bank'));
}
};

View File

@ -11,11 +11,13 @@ after_migrate = "kapital_bank.setup.after_migrate"
# JS for ERPNext forms
doctype_js = {
"Payment Entry": "client/payment_entry.js"
"Payment Entry": "client/payment_entry.js",
"Bank Transaction": "client/bank_transaction.js",
}
doctype_list_js = {
"Payment Entry": "client/payment_entry.js"
"Payment Entry": "client/payment_entry.js",
"Bank Transaction": "client/bank_transaction.js",
}
# Scheduled Tasks (refresh JWT every 4 minutes)
@ -155,7 +157,11 @@ doc_events = {
"Payment Entry": {
"on_trash": "kapital_bank.bank_api.on_delete_payment_entry",
"on_cancel": "kapital_bank.bank_api.on_cancel_payment_entry",
}
},
"Bank Transaction": {
"on_trash": "kapital_bank.bank_api.on_delete_bank_transaction",
"on_cancel": "kapital_bank.bank_api.on_cancel_bank_transaction",
},
}
# Scheduled Tasks

View File

@ -34,12 +34,14 @@
{
"fieldname": "bank_account",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Bank Account",
"options": "Bank Account"
},
{
"fieldname": "gl_account",
"fieldtype": "Link",
"in_list_view": 1,
"label": "GL Account",
"options": "Account"
}

View File

@ -35,12 +35,14 @@
{
"fieldname": "bank_account",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Bank Account",
"options": "Bank Account"
},
{
"fieldname": "gl_account",
"fieldtype": "Link",
"in_list_view": 1,
"label": "GL Account",
"options": "Account"
}

View File

@ -56,6 +56,7 @@
"default": "Manual",
"fieldname": "mapping_type",
"fieldtype": "Select",
"in_list_view": 1,
"label": "Mapping Type",
"options": "Manual\nAutomatic",
"read_only": 1

View File

@ -49,6 +49,7 @@
"default": "Manual",
"fieldname": "mapping_type",
"fieldtype": "Select",
"in_list_view": 1,
"label": "Mapping Type",
"options": "Manual\nAutomatic",
"read_only": 1

View File

@ -15,6 +15,7 @@
"source_label",
"link_section",
"payment_entry",
"bank_transaction",
"status",
"column_break_2",
"import_date",
@ -81,6 +82,13 @@
"options": "Payment Entry",
"read_only": 1
},
{
"fieldname": "bank_transaction",
"fieldtype": "Link",
"label": "Bank Transaction",
"options": "Bank Transaction",
"read_only": 1
},
{
"default": "Imported",
"fieldname": "status",
@ -109,7 +117,7 @@
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2026-02-27 00:00:00.000000",
"modified": "2026-03-03 00:00:00.000000",
"modified_by": "Administrator",
"module": "Kapital Bank",
"name": "Kapital Bank Transaction",