feat: add outbound payment flow via Kapital Bank API

Add Payment Request → bank API → status polling workflow:
- payment_api.py: send transfers, poll status, cancel, sync bank codes
- Kapital Bank Payment doctype for tracking transfer status
- Kapital Bank Bank Code doctype for transfer routing
- Client-side buttons on Payment Request and Payment Order
- PaymentRequest.before_submit override to survive wkhtmltopdf failures
- Custom fields on Bank Account (kb_bank_code) and Payment Request
- Improved HTTP error handling in BIRBankClient
- Updated CLAUDE.md with full architecture documentation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ali 2026-03-26 19:44:05 +04:00
parent 197257d044
commit b76cdd355e
18 changed files with 1013 additions and 18 deletions

View File

@ -4,7 +4,10 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## 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.
Kapital Bank is a Frappe app integrating BIRBank B2B API with ERPNext. It handles three workflows:
1. **Import**: Bank statement transactions → Payment Entries / Bank Transactions with fuzzy matching
2. **Mapping**: Purpose-based GL account routing with multi-currency Journal Entry creation
3. **Payments**: Outbound transfers via Payment Request → bank API → status polling
## Development Commands
@ -21,6 +24,9 @@ bench migrate
# Run tests
bench --site <site-name> run-tests --app kapital_bank
# Run a single test
bench --site <site-name> run-tests --app kapital_bank --module kapital_bank.kapital_bank.doctype.kapital_bank_settings.test_kapital_bank_settings
# Linting and formatting (from app directory)
pre-commit run --all-files
pre-commit run ruff --all-files # Python only
@ -32,6 +38,7 @@ pre-commit run eslint --all-files # JavaScript only
- **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)
- **Ruff ignores**: B017, E101/402/501/741, W191, UP030/031/032, F401/403/405
## Architecture
@ -41,17 +48,55 @@ pre-commit run eslint --all-files # JavaScript only
|---|---|
| `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/bank_api.py` | Registry loading, fuzzy matching, transaction import (largest module) |
| `kapital_bank/payment_api.py` | Outbound transfer execution, status polling, cancellation |
| `kapital_bank/mapping.py` | Purpose-based GL mapping + multi-currency Journal Entry creation |
| `kapital_bank/setup.py` | Custom field injection on Bank Transaction, Bank Account, Payment Request |
| `kapital_bank/overrides/payment_request.py` | Wraps `before_submit()` to survive wkhtmltopdf failures during email send |
| `kapital_bank/hooks.py` | Frappe hooks: doctype_js, scheduler_events, doc_events |
| `kapital_bank/client/payment_entry.js` | Frontend: Payment Entry form/list customization |
### Client-Side Modules
| File | Purpose |
|---|---|
| `client/bank_transaction.js` | Bank Transaction import UI: date/source selection, realtime progress, error CSV export |
| `client/payment_entry.js` | Payment Entry import UI (mirrors bank_transaction.js functionality) |
| `client/bank_reconciliation_tool.js` | Adds checkbox selection + "Create & Reconcile" bulk action to BRT |
| `client/mapping.js` | Shared module: purpose search/preview table for mapping workflows |
| `client/payment_order.js` | "Send to Kapital Bank" / "Check Status" / "Cancel" buttons on Payment Order |
| `client/payment_request.js` | Same transfer buttons + KB status indicator on Payment Request |
| `client/kapital_bank_bank_code.js` | "Sync Bank Codes" button on Bank Code list view |
### Data Flow
1. **Auth**: Credentials in `Kapital Bank Login` → JWT tokens stored as encrypted Password fields
1. **Auth**: Credentials in `Kapital Bank Login` → JWT tokens stored as encrypted Password fields → auto-refresh on 401, cron renewal every 4 min
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
3. **Mapping**: Settings child tables link bank entities to ERPNext records (Bank Account, Customer, Supplier, GL Account)
4. **Import**: Statement transactions → fuzzy match party/purpose → create Payment Entry or Bank Transaction + tracking record
5. **Purpose mapping**: Bank Transactions → match purpose/counterparty → create Transaction Mapping → optionally create Journal Entry + auto-reconcile
6. **Outbound payments**: Payment Request → `send_payment_request()` → Kapital Bank Payment (status tracking) → `poll_pending_payments()` cron → auto-create Payment Entry on success
7. **Cleanup**: Doc events on Payment Entry, Journal Entry, Bank Transaction (on_trash/on_cancel) auto-delete associated KB Transaction records
### Outbound Payment Flow
```
Payment Request (Outward, submitted)
→ send_payment_request() validates: KB Account mapping, beneficiary IBAN + Bank Code, party name latinization
→ POST /v2/internal-transfer → creates Kapital Bank Payment (status: "Sent")
→ poll_pending_payments() cron (every 30 min) → GET /internal-transfer/status
→ Success: auto-creates Payment Entry via PR.create_payment_entry()
→ Rejected: logs error, PR stays "Initiated"
→ Cancel: DELETE /internal-transfer (blocked if pending KB Payments exist)
```
### Purpose Mapping Modes
`mapping.py` supports three modes via `create_purpose_mappings()`:
- **Mappings Only**: Creates Transaction Mapping rows (purpose→GL account) without documents
- **Documents & Reconcile**: Creates Journal Entries with exchange rates + auto-reconciles Bank Transactions
- **Both**: Creates mappings AND documents
Priority-based matching in `_find_mapping_for_txn()`: purpose+party > purpose-only > party-only > fallback
### Matching Algorithm
@ -65,17 +110,35 @@ Three-tier party matching with configurable similarity thresholds:
- **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, Transaction Mapping
- **Tracking**: `Kapital Bank Transaction` — tracks imported Payment Entries by reference_no for deduplication
- **Tracking**: `Kapital Bank Transaction` — tracks imported Payment Entries/Journal Entries/Bank Transactions by reference_no for deduplication
- **Payment tracking**: `Kapital Bank Payment` — tracks outbound transfer status (Sent → Confirm Wait → Success/Rejected/Cancelled)
- **Routing**: `Kapital Bank Bank Code` — 6-char bank codes for transfer routing (synced from API)
- **Purpose master**: `Kapital Bank Purpose` — unique purpose keywords with direction (Incoming/Outgoing/Both), status (New/Mapped)
- **Utility**: `Kapital Bank API Test` — manual API testing
### Custom Fields Injected on ERPNext Doctypes
- **Bank Transaction**: `kb_currency` (Link to Currency) — for multi-currency matching
- **Bank Account**: `kb_bank_code` (Link to Kapital Bank Bank Code) — required for outbound transfers
- **Payment Request**: `kb_account` (Link to KB Account) + `kb_beneficiary_bank_account` (Link to Bank Account with IBAN)
### 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
- Progress published via `frappe.publish_realtime()` over Socket.IO (`kb_bt_import_progress`, `kb_bt_import_complete`)
- 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
- Error logging via `frappe.log_error()` with `_mask()` hiding account numbers (7+ digits → `first3***last3`)
- Party names latinized via `_latinize()` for bank transfers (Azerbaijani → ASCII)
- Card statements limited to 90-day range; cross-account transfers deduplicated by reference_no
### Scheduler Events
- **Every 4 min**: `auth.renew_token` — JWT token renewal
- **Every 30 min**: `payment_api.poll_pending_payments` — check status of outbound transfers
## API Documentation
BIRBank B2B API documentation PDFs are in `birbank_docs/`. The base URL is `https://my.birbank.business/api/b2b`.
Key endpoints: `/accounts`, `/statements/{id}/transactions`, `/card-statements/{id}/transactions`, `/v2/internal-transfer`, `/internal-transfer/status`, `/bank-codes`

View File

@ -82,12 +82,15 @@ class BIRBankClient:
"""Raise with the bank's own message if available, otherwise fall back to HTTP error."""
if resp.ok:
return
msg = ""
try:
body = resp.json()
api_resp = body.get("response", {})
msg = api_resp.get("message", "")
if msg:
raise requests.HTTPError(msg, response=resp)
# Handle both {"response": {"message": "..."}} and {"message": "..."}
msg = (
body.get("response", {}).get("message")
or body.get("message")
or str(body)
)
except (ValueError, KeyError):
pass
resp.raise_for_status()
msg = resp.text[:500] if resp.text else ""
raise requests.HTTPError(msg or f"HTTP {resp.status_code}", response=resp)

View File

@ -0,0 +1,23 @@
function _sync_bank_codes(callback) {
frappe.show_alert({ message: __("Syncing bank codes..."), indicator: "blue" });
frappe.call({
method: "kapital_bank.payment_api.sync_bank_codes",
callback(r) {
const res = r.message;
if (!res) return;
frappe.show_alert({
message: __("Bank codes synced: {0} added, {1} updated (total {2})", [res.added, res.updated, res.total]),
indicator: "green",
}, 6);
if (callback) callback();
},
});
}
frappe.listview_settings["Kapital Bank Bank Code"] = {
onload(listview) {
listview.page.add_inner_button(__("Sync Bank Codes"), () => {
_sync_bank_codes(() => listview.refresh());
});
},
};

View File

@ -0,0 +1,77 @@
frappe.ui.form.on("Payment Order", {
refresh(frm) {
if (frm.doc.docstatus !== 1 || frm.doc.payment_order_type !== "Payment Entry") return;
// Send to Kapital Bank
frm.add_custom_button(__("Send to Kapital Bank"), () => {
frappe.confirm(__("Send all Payment Entries to Kapital Bank?"), () => {
frappe.show_alert({ message: __("Sending..."), indicator: "blue" });
frappe.call({
method: "kapital_bank.payment_api.send_payment_order",
args: { name: frm.doc.name },
callback(r) {
const res = r.message;
if (!res) return;
if (res.success) {
const ind = res.failed > 0 ? "orange" : "green";
frappe.show_alert(
{ message: __("Sent: {0}, Failed: {1}", [res.sent, res.failed]), indicator: ind },
6
);
frm.reload_doc();
} else {
const msg =
res.errors && res.errors.length
? "<ul>" + res.errors.map(e => `<li>${e}</li>`).join("") + "</ul>"
: res.message || __("Unknown error");
frappe.msgprint({ title: __("Send Failed"), indicator: "red", message: msg });
}
},
});
});
}, __("Kapital Bank"));
// Check Status
frm.add_custom_button(__("Check Status"), () => {
frappe.show_alert({ message: __("Checking..."), indicator: "blue" });
frappe.call({
method: "kapital_bank.payment_api.check_payment_status",
args: { name: frm.doc.name },
callback(r) {
const res = r.message;
if (!res) return;
const msg = res.message || __("Updated: {0}", [res.updated || 0]);
frappe.show_alert({ message: msg, indicator: res.success ? "green" : "orange" }, 5);
frm.reload_doc();
},
});
}, __("Kapital Bank"));
// Cancel Bank Transfer
frm.add_custom_button(__("Cancel Bank Transfer"), () => {
frappe.confirm(__("Cancel all pending bank transfers for this Payment Order?"), () => {
frappe.call({
method: "kapital_bank.payment_api.cancel_payment_order",
args: { name: frm.doc.name },
callback(r) {
const res = r.message;
if (!res) return;
if (res.success) {
frappe.show_alert(
{ message: __("Cancelled: {0}", [res.cancelled]), indicator: "green" },
5
);
frm.reload_doc();
} else {
frappe.msgprint({
title: __("Cancel Failed"),
indicator: "red",
message: res.message || __("Unknown error"),
});
}
},
});
});
}, __("Kapital Bank"));
},
});

View File

@ -0,0 +1,109 @@
const KB_STATUS_COLOR = {
"Sent": "blue",
"Confirm Wait": "yellow",
"Success": "green",
"Rejected": "red",
"Cancelled": "grey",
};
function kb_show_status(frm) {
frappe.db.get_list("Kapital Bank Payment", {
filters: { payment_request: frm.doc.name },
fields: ["status", "operation_name"],
order_by: "creation desc",
limit: 1,
}).then(rows => {
if (!rows || !rows.length) return;
const r = rows[0];
const color = KB_STATUS_COLOR[r.status] || "grey";
frm.page.set_indicator(__("KB: {0}", [r.status]), color);
});
}
frappe.ui.form.on("Payment Request", {
refresh(frm) {
if (frm.doc.payment_request_type === "Outward" && frm.doc.docstatus === 1) {
kb_show_status(frm);
}
if (frm.doc.docstatus !== 1 || frm.doc.payment_request_type !== "Outward") return;
frm.add_custom_button(__("Send to Kapital Bank"), () => {
frappe.confirm(__("Send this payment to Kapital Bank?"), () => {
frm.disable_save();
frappe.show_alert({ message: __("Sending to Kapital Bank..."), indicator: "blue" });
frappe.call({
method: "kapital_bank.payment_api.send_payment_request",
args: { name: frm.doc.name },
callback(r) {
frm.enable_save();
const res = r.message;
if (!res) return;
if (res.success) {
frappe.show_alert({ message: __("Transfer sent successfully. Waiting for bank confirmation."), indicator: "green" }, 8);
frm.reload_doc();
} else if (res.errors && res.errors.length) {
frappe.msgprint({
title: __("Cannot Send — Validation Failed"),
indicator: "red",
message: "<ul>" + res.errors.map(e => `<li>${e}</li>`).join("") + "</ul>",
});
} else {
frappe.msgprint({
title: __("Bank Error"),
indicator: "red",
message: res.message || __("Unknown error"),
});
}
},
});
});
}, __("Kapital Bank"));
frm.add_custom_button(__("Check Status"), () => {
frappe.show_alert({ message: __("Checking transfer status..."), indicator: "blue" });
frappe.call({
method: "kapital_bank.payment_api.check_payment_status",
args: { name: frm.doc.name },
callback(r) {
const res = r.message;
if (!res) return;
if (res.message) {
frappe.show_alert({ message: res.message, indicator: "grey" }, 5);
} else if (res.updated > 0) {
frappe.show_alert({ message: __("Status updated successfully."), indicator: "green" }, 5);
} else {
frappe.show_alert({ message: __("No status changes from bank."), indicator: "grey" }, 5);
}
frm.reload_doc();
},
});
}, __("Kapital Bank"));
frm.add_custom_button(__("Cancel Bank Transfer"), () => {
frappe.confirm(__("Cancel the pending bank transfer? This cannot be undone."), () => {
frappe.show_alert({ message: __("Cancelling..."), indicator: "blue" });
frappe.call({
method: "kapital_bank.payment_api.cancel_payment_request",
args: { name: frm.doc.name },
callback(r) {
const res = r.message;
if (!res) return;
if (res.success && res.cancelled > 0) {
frappe.show_alert({ message: __("Bank transfer cancelled successfully."), indicator: "green" }, 6);
frm.reload_doc();
} else if (res.message) {
frappe.show_alert({ message: res.message, indicator: "grey" }, 5);
} else {
frappe.msgprint({
title: __("Cancel Failed"),
indicator: "red",
message: res.message || __("Unknown error"),
});
}
},
});
});
}, __("Kapital Bank"));
},
});

View File

@ -9,22 +9,26 @@ app_license = "unlicense"
after_install = "kapital_bank.setup.after_install"
after_migrate = "kapital_bank.setup.after_migrate"
# JS for ERPNext forms
doctype_js = {
"Payment Entry": "client/payment_entry.js",
"Bank Transaction": "client/bank_transaction.js",
"Bank Reconciliation Tool": "client/bank_reconciliation_tool.js",
"Payment Request": "client/payment_request.js",
}
doctype_list_js = {
"Payment Entry": "client/payment_entry.js",
"Bank Transaction": "client/bank_transaction.js",
"Kapital Bank Bank Code": "client/kapital_bank_bank_code.js",
}
# Scheduled Tasks (refresh JWT every 4 minutes)
scheduler_events = {
"cron": {
"*/4 * * * *": ["kapital_bank.auth.renew_token"]
"*/4 * * * *": ["kapital_bank.auth.renew_token"],
"*/30 * * * *": ["kapital_bank.payment_api.poll_pending_payments"],
}
}
@ -167,6 +171,9 @@ doc_events = {
"on_trash": "kapital_bank.bank_api.on_delete_bank_transaction",
"on_cancel": "kapital_bank.bank_api.on_cancel_bank_transaction",
},
"Payment Request": {
"on_cancel": "kapital_bank.payment_api.on_cancel_payment_request",
},
}
# Scheduled Tasks

View File

@ -0,0 +1,55 @@
{
"actions": [],
"autoname": "field:bank_code",
"creation": "2026-03-17 00:00:00.000000",
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
"bank_code",
"bank_name"
],
"fields": [
{
"fieldname": "bank_code",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Bank Code",
"reqd": 1,
"unique": 1
},
{
"fieldname": "bank_name",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Bank Name",
"reqd": 1
}
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2026-03-17 00:00:00.000000",
"modified_by": "Administrator",
"module": "Kapital Bank",
"name": "Kapital Bank Bank Code",
"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
}
],
"sort_field": "bank_code",
"sort_order": "ASC",
"states": [],
"title_field": "bank_name",
"track_changes": 0
}

View File

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

View File

@ -0,0 +1,169 @@
{
"actions": [],
"autoname": "KBP-.#####",
"creation": "2026-03-17 00:00:00.000000",
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
"payment_request",
"payment_entry",
"operation_name",
"operation_id",
"status",
"column_break_1",
"from_account",
"to_account",
"to_tax_no",
"to_cust_name",
"ben_bank_code",
"amount",
"details_section",
"purpose1",
"error_message",
"column_break_2",
"sent_at",
"status_updated_at"
],
"fields": [
{
"fieldname": "payment_request",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Payment Request",
"options": "Payment Request",
"read_only": 1
},
{
"fieldname": "payment_entry",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Payment Entry",
"options": "Payment Entry",
"read_only": 1
},
{
"fieldname": "operation_name",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Operation Name",
"read_only": 1,
"reqd": 1,
"unique": 1
},
{
"fieldname": "operation_id",
"fieldtype": "Data",
"label": "Operation ID",
"read_only": 1
},
{
"default": "Draft",
"fieldname": "status",
"fieldtype": "Select",
"in_list_view": 1,
"label": "Status",
"options": "Draft\nSent\nConfirm Wait\nSuccess\nRejected\nCancelled",
"read_only": 1
},
{
"fieldname": "column_break_1",
"fieldtype": "Column Break"
},
{
"fieldname": "from_account",
"fieldtype": "Data",
"label": "From Account (IBAN)",
"read_only": 1
},
{
"fieldname": "to_account",
"fieldtype": "Data",
"label": "To Account (IBAN)",
"read_only": 1
},
{
"fieldname": "to_tax_no",
"fieldtype": "Data",
"label": "To Tax No",
"read_only": 1
},
{
"fieldname": "to_cust_name",
"fieldtype": "Data",
"label": "To Customer Name",
"read_only": 1
},
{
"fieldname": "ben_bank_code",
"fieldtype": "Data",
"label": "Beneficiary Bank Code",
"read_only": 1
},
{
"fieldname": "amount",
"fieldtype": "Currency",
"label": "Amount",
"read_only": 1
},
{
"fieldname": "details_section",
"fieldtype": "Section Break",
"label": "Details"
},
{
"fieldname": "purpose1",
"fieldtype": "Data",
"label": "Purpose",
"read_only": 1
},
{
"fieldname": "error_message",
"fieldtype": "Small Text",
"label": "Error Message",
"read_only": 1
},
{
"fieldname": "column_break_2",
"fieldtype": "Column Break"
},
{
"fieldname": "sent_at",
"fieldtype": "Datetime",
"label": "Sent At",
"read_only": 1
},
{
"fieldname": "status_updated_at",
"fieldtype": "Datetime",
"label": "Status Updated At",
"read_only": 1
}
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2026-03-17 00:00:00.000000",
"modified_by": "Administrator",
"module": "Kapital Bank",
"name": "Kapital Bank Payment",
"naming_rule": "Expression (old style)",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1,
"write": 1
}
],
"sort_field": "modified",
"sort_order": "DESC",
"states": [],
"title_field": "operation_name",
"track_changes": 1
}

View File

@ -0,0 +1,11 @@
import frappe
from frappe import _
from frappe.model.document import Document
class KapitalBankPayment(Document):
def on_trash(self):
if self.status in ("Sent", "Confirm Wait"):
frappe.throw(
_("Cannot delete {0}: status is {1}. Cancel first.").format(self.name, self.status)
)

View File

@ -5,6 +5,21 @@ frappe.ui.form.on('Kapital Bank Settings', {
show_load_data_dialog(frm);
}, __('Kapital Bank'));
frm.add_custom_button(__('Sync Bank Codes'), () => {
frappe.show_alert({ message: __('Syncing bank codes...'), indicator: 'blue' });
frappe.call({
method: 'kapital_bank.payment_api.sync_bank_codes',
callback(r) {
const res = r.message;
if (!res) return;
frappe.show_alert({
message: __('Bank codes synced: {0} added, {1} updated (total {2})', [res.added, res.updated, res.total]),
indicator: 'green',
}, 6);
},
});
}, __('Kapital Bank'));
// ── Accounts buttons ──────────────────────────────────────────────────
frm.add_custom_button(__('Match accounts to Bank Accounts'), () => {
if (frm.is_dirty()) {

View File

View File

@ -0,0 +1,20 @@
import frappe
from frappe import _
class PaymentRequestOverride:
"""Mixin for Payment Request — wraps send_email to survive wkhtmltopdf failures."""
def before_submit(self):
try:
super().before_submit()
except OSError as e:
if "wkhtmltopdf" in str(e) or "pdf" in str(e).lower():
frappe.log_error(str(e), "KB Payment Request PDF Error")
frappe.msgprint(
_("Warning: payment request email could not be sent (PDF generation failed). Proceeding with submit."),
alert=True,
indicator="orange",
)
else:
raise

View File

@ -1 +1,347 @@
# Removed: outgoing payment functions were removed.
import frappe
from frappe import _
from frappe.utils import now_datetime, flt
from datetime import datetime
from kapital_bank.api import BIRBankClient
_AZ_MAP = str.maketrans("ƏəİışŞÇçÖöÜüĞğ", "EeIissCcOoUuGg")
def _latinize(text: str, max_len: int = None) -> str:
"""Transliterate Azerbaijani → Latin, strip non-ASCII, truncate."""
text = str(text or "").translate(_AZ_MAP)
text = text.encode("ascii", errors="ignore").decode("ascii").strip()
return text[:max_len] if max_len else text
@frappe.whitelist()
def sync_bank_codes(login_name=None):
"""Fetch bank codes from API and upsert into Kapital Bank Bank Code doctype."""
client = BIRBankClient(login_name)
resp = client.get_json("/bank-codes")
bank_codes = resp.get("responseData", {}).get("bankCodes", [])
added = 0
updated = 0
for item in bank_codes:
bank_code = (item.get("bankCode") or "").strip()
bank_name = (item.get("bankName") or "").strip()
if not bank_code:
continue
if frappe.db.exists("Kapital Bank Bank Code", bank_code):
existing_name = frappe.db.get_value("Kapital Bank Bank Code", bank_code, "bank_name")
if existing_name != bank_name:
frappe.db.set_value("Kapital Bank Bank Code", bank_code, "bank_name", bank_name)
updated += 1
else:
doc = frappe.new_doc("Kapital Bank Bank Code")
doc.bank_code = bank_code
doc.bank_name = bank_name
doc.insert(ignore_permissions=True)
added += 1
frappe.db.commit()
return {"success": True, "added": added, "updated": updated, "total": len(bank_codes)}
@frappe.whitelist()
def send_payment_request(name, login_name=None):
"""Validate Payment Request and send outgoing transfer to Kapital Bank."""
pr = frappe.get_doc("Payment Request", name)
if pr.docstatus != 1:
frappe.throw(_("Payment Request {0} is not submitted.").format(name))
if pr.payment_request_type != "Outward":
frappe.throw(_("Payment Request {0} is not of type Outward.").format(name))
if pr.status not in ("Initiated", "Partially Paid"):
frappe.throw(_("Payment Request {0} has invalid status: {1}.").format(name, pr.status))
kb_account = pr.get("kb_account")
if not kb_account:
frappe.throw(_("Field 'Kapital Bank Account' is not set."))
mapping = frappe.db.get_value(
"Kapital Bank Account Mapping",
{"iban": kb_account, "parenttype": "Kapital Bank Settings"},
["iban", "bank_account"],
as_dict=True,
)
if not mapping:
frappe.throw(_("Kapital Bank Account {0} is not mapped in Kapital Bank Settings.").format(kb_account))
from_iban = kb_account
party_bank_account = pr.get("kb_beneficiary_bank_account")
if not party_bank_account:
frappe.throw(_("Field 'Beneficiary Bank Account' is not set."))
payee_ba = frappe.get_doc("Bank Account", party_bank_account)
errors = []
if not payee_ba.iban:
errors.append(_("Field 'IBAN' is not set on Bank Account {0}.").format(party_bank_account))
kb_bank_code = payee_ba.get("kb_bank_code") or ""
if not kb_bank_code:
errors.append(_("Field 'KB Bank Code' is not set on Bank Account {0}.").format(party_bank_account))
elif len(kb_bank_code) != 6:
errors.append(_("Field 'KB Bank Code' on Bank Account {0} must be exactly 6 characters.").format(party_bank_account))
if not flt(pr.grand_total):
errors.append(_("Field 'Amount' is zero on Payment Request {0}.").format(name))
to_cust_name = _latinize(pr.party_name, 34)
if not to_cust_name:
errors.append(_("Party name '{0}' is empty after Latin transliteration.").format(pr.party_name))
existing = frappe.db.get_value(
"Kapital Bank Payment",
{"payment_request": name, "status": ["not in", ["Rejected", "Cancelled"]]},
"name",
)
if existing:
errors.append(_("Active KB Payment {0} already exists for this Payment Request.").format(existing))
if errors:
return {"success": False, "errors": errors}
op_name = f"PAY{datetime.now().strftime('%Y%m%d%H%M%S')}00"
payee_iban = payee_ba.iban
tax_id = payee_ba.get("tax_id") or ""
to_cust_name = _latinize(pr.party_name, 34)
purpose = _latinize(pr.name, 64)
payload = {
"fromAccount": from_iban,
"transferData": {
"operationName": op_name,
"benBankCode": kb_bank_code,
"toAccount": payee_iban,
"toTaxNo": tax_id,
"toCustName": to_cust_name,
"amount": float(flt(pr.grand_total)),
"purpose1": purpose,
},
}
import requests as _requests
frappe.log_error(f"[KB] Sending transfer payload for {name}:\n{payload}", "KB Transfer Payload")
try:
client = BIRBankClient(login_name)
resp = client.post_json("/v2/internal-transfer", json=payload)
operation_id = resp.get("operationId", "")
kbp = frappe.new_doc("Kapital Bank Payment")
kbp.update({
"payment_request": name,
"operation_name": op_name,
"operation_id": operation_id,
"status": "Sent",
"from_account": from_iban,
"to_account": payee_iban,
"to_tax_no": tax_id,
"to_cust_name": to_cust_name,
"ben_bank_code": kb_bank_code,
"amount": flt(pr.grand_total),
"purpose1": purpose,
"sent_at": now_datetime(),
"status_updated_at": now_datetime(),
})
kbp.insert(ignore_permissions=True)
frappe.db.commit()
except _requests.HTTPError as e:
frappe.db.rollback()
return {"success": False, "message": str(e)}
except Exception:
frappe.log_error(frappe.get_traceback(), f"KB Send Payment Request {name}")
frappe.db.rollback()
raise
return {"success": True}
@frappe.whitelist()
def check_payment_status(name, login_name=None):
"""Poll status for pending KB Payments of a Payment Request."""
pending = frappe.get_all(
"Kapital Bank Payment",
filters={"payment_request": name, "status": ["in", ["Sent", "Confirm Wait"]]},
fields=["name", "operation_name", "payment_request", "operation_id"],
)
if not pending:
return {"success": True, "updated": 0, "message": _("No pending transfers")}
client = BIRBankClient(login_name)
STATUS_MAP = {
"CONFIRM_WAIT": "Confirm Wait",
"SUCCESS": "Success",
"CONFIRMED": "Success",
"REJECTED": "Rejected",
"CANCELLED": "Cancelled",
"CANCELED": "Cancelled",
}
updated = 0
errors = []
for kbp in pending:
try:
resp = client.get_json("/internal-transfer/status", params={"operatorName": kbp.operation_name})
data = resp.get("data", [])
if not data:
frappe.logger().warning(f"[KB] Status check for {kbp.operation_name}: empty data response")
continue
entry = data[0]
bank_status = entry.get("status", "")
new_status = STATUS_MAP.get(bank_status)
if new_status is None:
frappe.db.set_value(
"Kapital Bank Payment",
kbp.name,
{"error_message": f"Unknown bank status: {bank_status}", "status_updated_at": now_datetime()},
update_modified=False,
)
frappe.db.commit()
continue
frappe.db.set_value(
"Kapital Bank Payment",
kbp.name,
{
"status": new_status,
"status_updated_at": now_datetime(),
"error_message": entry.get("description", ""),
},
update_modified=False,
)
frappe.db.commit()
updated += 1
if new_status == "Success":
pe_name = _create_pe_from_pr(kbp.payment_request)
if pe_name:
frappe.db.set_value(
"Kapital Bank Payment", kbp.name, "payment_entry", pe_name, update_modified=False
)
frappe.db.commit()
elif new_status == "Rejected":
_handle_rejected_pr(kbp.payment_request, entry.get("description", ""))
except Exception as e:
frappe.log_error(frappe.get_traceback(), f"KB Check Status {kbp.operation_name}")
errors.append(str(e))
return {"success": True, "updated": updated, "errors": errors}
@frappe.whitelist()
def cancel_payment_request(name, login_name=None):
"""Cancel pending bank transfers for a Payment Request."""
pending = frappe.get_all(
"Kapital Bank Payment",
filters={"payment_request": name, "status": ["in", ["Sent", "Confirm Wait"]]},
fields=["name", "operation_name", "operation_id"],
)
if not pending:
return {"success": True, "cancelled": 0, "message": _("Nothing to cancel")}
client = BIRBankClient(login_name)
cancelled = 0
errors = []
for kbp in pending:
try:
if not kbp.operation_id:
frappe.logger().warning(f"[KB] Cancel: no operation_id for {kbp.operation_name}, cancelling locally")
else:
resp = client.delete("/internal-transfer", params={"operationId": kbp.operation_id})
if not resp.ok:
frappe.log_error(
f"[KB] Cancel DELETE failed for {kbp.operation_name}: HTTP {resp.status_code}",
"KB Cancel Error",
)
frappe.db.set_value(
"Kapital Bank Payment",
kbp.name,
{"status": "Cancelled", "status_updated_at": now_datetime()},
update_modified=False,
)
frappe.db.commit()
cancelled += 1
except Exception as e:
frappe.log_error(frappe.get_traceback(), f"KB Cancel {kbp.operation_name}")
errors.append(str(e))
return {"success": True, "cancelled": cancelled, "errors": errors}
def poll_pending_payments():
"""Scheduler: poll status for all Payment Requests with pending KB Payments."""
rows = frappe.db.sql(
"""
SELECT DISTINCT payment_request
FROM `tabKapital Bank Payment`
WHERE status IN ('Sent', 'Confirm Wait')
AND payment_request IS NOT NULL
""",
as_dict=True,
)
for row in rows:
try:
check_payment_status(row.payment_request)
except Exception:
frappe.log_error(frappe.get_traceback(), "KB Poll Error")
def on_cancel_payment_request(doc, method):
"""Doc event: block cancellation of Payment Request if pending bank transfers exist."""
pending = frappe.get_all(
"Kapital Bank Payment",
filters={"payment_request": doc.name, "status": ["in", ["Sent", "Confirm Wait"]]},
fields=["name", "operation_name"],
)
if pending:
names = ", ".join(p.operation_name for p in pending)
frappe.throw(
_(
"Cannot cancel Payment Request {0}: pending bank transfers exist ({1}). "
"Cancel the bank transfers first using the 'Kapital Bank' menu."
).format(doc.name, names)
)
def _create_pe_from_pr(pr_name):
"""Create and submit a Payment Entry from a Payment Request. Returns PE name or None."""
pr = frappe.get_doc("Payment Request", pr_name)
if pr.status not in ("Initiated", "Partially Paid"):
frappe.log_error(
f"KB: Payment Request {pr_name} has status '{pr.status}', skipping PE creation",
"KB Auto PE",
)
return None
pe = pr.create_payment_entry(submit=True)
frappe.db.commit()
return pe.name if pe else None
def _handle_rejected_pr(pr_name, description):
"""Log rejection — PR stays Initiated so user can retry or cancel manually."""
frappe.log_error(
f"KB Transfer rejected for Payment Request {pr_name}: {description}",
"KB Payment Rejected",
)

View File

@ -4,11 +4,15 @@ import frappe
def after_install():
cleanup_payment_entry_custom_fields()
ensure_bank_transaction_custom_fields()
ensure_bank_account_custom_fields()
ensure_payment_request_custom_fields()
def after_migrate():
cleanup_payment_entry_custom_fields()
ensure_bank_transaction_custom_fields()
ensure_bank_account_custom_fields()
ensure_payment_request_custom_fields()
KB_FIELDS = [
@ -33,6 +37,94 @@ def ensure_bank_transaction_custom_fields():
def ensure_bank_account_custom_fields():
existing = frappe.db.get_value(
"Custom Field",
{"dt": "Bank Account", "fieldname": "kb_bank_code"},
["name", "fieldtype"],
as_dict=True,
)
if not existing:
cf = frappe.new_doc("Custom Field")
cf.dt = "Bank Account"
cf.fieldname = "kb_bank_code"
cf.fieldtype = "Link"
cf.options = "Kapital Bank Bank Code"
cf.label = "KB Bank Code"
cf.insert_after = "bank_account_no"
cf.read_only = 0
cf.description = "Kapital Bank bank code required for outgoing transfers"
cf.insert(ignore_permissions=True)
elif existing.fieldtype != "Link":
frappe.db.set_value(
"Custom Field",
existing.name,
{"fieldtype": "Link", "options": "Kapital Bank Bank Code"},
)
frappe.db.commit()
def ensure_payment_request_custom_fields():
# Clean up old field iterations
for old in (
"Payment Request-party_bank_account",
"Payment Request-kb_company_bank_account",
):
frappe.delete_doc("Custom Field", old, ignore_missing=True, force=True)
_pr_fields = [
{
"fieldname": "kb_section",
"fieldtype": "Section Break",
"label": "Kapital Bank",
"insert_after": "party_account_currency",
"depends_on": "eval:doc.payment_request_type == 'Outward'",
},
{
"fieldname": "kb_account",
"fieldtype": "Link",
"label": "Kapital Bank Account",
"options": "Kapital Bank Account",
"insert_after": "kb_section",
"description": "Company KB account to send payment from. Must be mapped in Kapital Bank Settings.",
},
{
"fieldname": "kb_column_break",
"fieldtype": "Column Break",
"insert_after": "kb_account",
},
{
"fieldname": "kb_beneficiary_bank_account",
"fieldtype": "Link",
"label": "Beneficiary Bank Account",
"options": "Bank Account",
"insert_after": "kb_column_break",
"description": "Supplier bank account with IBAN and KB Bank Code set.",
},
]
for f in _pr_fields:
existing = frappe.db.get_value(
"Custom Field", {"dt": "Payment Request", "fieldname": f["fieldname"]}, "name"
)
if existing:
frappe.db.set_value("Custom Field", existing, {
"label": f.get("label", ""),
"insert_after": f["insert_after"],
"depends_on": f.get("depends_on", ""),
"mandatory_depends_on": "",
"description": f.get("description", ""),
})
else:
cf = frappe.new_doc("Custom Field")
cf.dt = "Payment Request"
cf.update(f)
cf.read_only = 0
cf.insert(ignore_permissions=True)
frappe.db.commit()
def cleanup_payment_entry_custom_fields():
for fieldname in KB_FIELDS:
if frappe.db.exists("Custom Field", {"dt": "Payment Entry", "fieldname": fieldname}):