From b76cdd355ea804dbff192c97e279487e7c9563ad Mon Sep 17 00:00:00 2001 From: Ali <010109ali@gmail.com> Date: Thu, 26 Mar 2026 19:44:05 +0400 Subject: [PATCH] feat: add outbound payment flow via Kapital Bank API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CLAUDE.md | 83 ++++- .../List of budget classification codes.pdf | Bin kapital_bank/api.py | 15 +- kapital_bank/client/kapital_bank_bank_code.js | 23 ++ kapital_bank/client/payment_order.js | 77 ++++ kapital_bank/client/payment_request.js | 109 ++++++ kapital_bank/hooks.py | 9 +- .../kapital_bank_bank_code/__init__.py | 0 .../kapital_bank_bank_code.json | 55 +++ .../kapital_bank_bank_code.py | 5 + .../doctype/kapital_bank_payment/__init__.py | 0 .../kapital_bank_payment.json | 169 +++++++++ .../kapital_bank_payment.py | 11 + .../kapital_bank_settings.js | 15 + kapital_bank/overrides/__init__.py | 0 kapital_bank/overrides/payment_request.py | 20 + kapital_bank/payment_api.py | 348 +++++++++++++++++- kapital_bank/setup.py | 92 +++++ 18 files changed, 1013 insertions(+), 18 deletions(-) rename "birbank_docs/List of budget classification codes\n.pdf" => birbank_docs/List of budget classification codes.pdf (100%) create mode 100644 kapital_bank/client/kapital_bank_bank_code.js create mode 100644 kapital_bank/client/payment_order.js create mode 100644 kapital_bank/client/payment_request.js create mode 100644 kapital_bank/kapital_bank/doctype/kapital_bank_bank_code/__init__.py create mode 100644 kapital_bank/kapital_bank/doctype/kapital_bank_bank_code/kapital_bank_bank_code.json create mode 100644 kapital_bank/kapital_bank/doctype/kapital_bank_bank_code/kapital_bank_bank_code.py create mode 100644 kapital_bank/kapital_bank/doctype/kapital_bank_payment/__init__.py create mode 100644 kapital_bank/kapital_bank/doctype/kapital_bank_payment/kapital_bank_payment.json create mode 100644 kapital_bank/kapital_bank/doctype/kapital_bank_payment/kapital_bank_payment.py create mode 100644 kapital_bank/overrides/__init__.py create mode 100644 kapital_bank/overrides/payment_request.py diff --git a/CLAUDE.md b/CLAUDE.md index de3c7ea..d7f9968 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 run-tests --app kapital_bank +# Run a single test +bench --site 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` diff --git "a/birbank_docs/List of budget classification codes\n.pdf" b/birbank_docs/List of budget classification codes.pdf similarity index 100% rename from "birbank_docs/List of budget classification codes\n.pdf" rename to birbank_docs/List of budget classification codes.pdf diff --git a/kapital_bank/api.py b/kapital_bank/api.py index 817e0c9..4ad7073 100644 --- a/kapital_bank/api.py +++ b/kapital_bank/api.py @@ -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) diff --git a/kapital_bank/client/kapital_bank_bank_code.js b/kapital_bank/client/kapital_bank_bank_code.js new file mode 100644 index 0000000..bc039c0 --- /dev/null +++ b/kapital_bank/client/kapital_bank_bank_code.js @@ -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()); + }); + }, +}; diff --git a/kapital_bank/client/payment_order.js b/kapital_bank/client/payment_order.js new file mode 100644 index 0000000..318d180 --- /dev/null +++ b/kapital_bank/client/payment_order.js @@ -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 + ? "
    " + res.errors.map(e => `
  • ${e}
  • `).join("") + "
" + : 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")); + }, +}); diff --git a/kapital_bank/client/payment_request.js b/kapital_bank/client/payment_request.js new file mode 100644 index 0000000..28cc9b1 --- /dev/null +++ b/kapital_bank/client/payment_request.js @@ -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: "
    " + res.errors.map(e => `
  • ${e}
  • `).join("") + "
", + }); + } 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")); + }, +}); diff --git a/kapital_bank/hooks.py b/kapital_bank/hooks.py index e0946f2..251754e 100644 --- a/kapital_bank/hooks.py +++ b/kapital_bank/hooks.py @@ -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 diff --git a/kapital_bank/kapital_bank/doctype/kapital_bank_bank_code/__init__.py b/kapital_bank/kapital_bank/doctype/kapital_bank_bank_code/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/kapital_bank/kapital_bank/doctype/kapital_bank_bank_code/kapital_bank_bank_code.json b/kapital_bank/kapital_bank/doctype/kapital_bank_bank_code/kapital_bank_bank_code.json new file mode 100644 index 0000000..b822538 --- /dev/null +++ b/kapital_bank/kapital_bank/doctype/kapital_bank_bank_code/kapital_bank_bank_code.json @@ -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 +} diff --git a/kapital_bank/kapital_bank/doctype/kapital_bank_bank_code/kapital_bank_bank_code.py b/kapital_bank/kapital_bank/doctype/kapital_bank_bank_code/kapital_bank_bank_code.py new file mode 100644 index 0000000..a0147ab --- /dev/null +++ b/kapital_bank/kapital_bank/doctype/kapital_bank_bank_code/kapital_bank_bank_code.py @@ -0,0 +1,5 @@ +from frappe.model.document import Document + + +class KapitalBankBankCode(Document): + pass diff --git a/kapital_bank/kapital_bank/doctype/kapital_bank_payment/__init__.py b/kapital_bank/kapital_bank/doctype/kapital_bank_payment/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/kapital_bank/kapital_bank/doctype/kapital_bank_payment/kapital_bank_payment.json b/kapital_bank/kapital_bank/doctype/kapital_bank_payment/kapital_bank_payment.json new file mode 100644 index 0000000..1e5e842 --- /dev/null +++ b/kapital_bank/kapital_bank/doctype/kapital_bank_payment/kapital_bank_payment.json @@ -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 +} diff --git a/kapital_bank/kapital_bank/doctype/kapital_bank_payment/kapital_bank_payment.py b/kapital_bank/kapital_bank/doctype/kapital_bank_payment/kapital_bank_payment.py new file mode 100644 index 0000000..7a58173 --- /dev/null +++ b/kapital_bank/kapital_bank/doctype/kapital_bank_payment/kapital_bank_payment.py @@ -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) + ) diff --git a/kapital_bank/kapital_bank/doctype/kapital_bank_settings/kapital_bank_settings.js b/kapital_bank/kapital_bank/doctype/kapital_bank_settings/kapital_bank_settings.js index 080bc63..3846195 100644 --- a/kapital_bank/kapital_bank/doctype/kapital_bank_settings/kapital_bank_settings.js +++ b/kapital_bank/kapital_bank/doctype/kapital_bank_settings/kapital_bank_settings.js @@ -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()) { diff --git a/kapital_bank/overrides/__init__.py b/kapital_bank/overrides/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/kapital_bank/overrides/payment_request.py b/kapital_bank/overrides/payment_request.py new file mode 100644 index 0000000..9487e84 --- /dev/null +++ b/kapital_bank/overrides/payment_request.py @@ -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 diff --git a/kapital_bank/payment_api.py b/kapital_bank/payment_api.py index 0fb16f9..43626ee 100644 --- a/kapital_bank/payment_api.py +++ b/kapital_bank/payment_api.py @@ -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", + ) diff --git a/kapital_bank/setup.py b/kapital_bank/setup.py index 6be3f9d..4617c3c 100644 --- a/kapital_bank/setup.py +++ b/kapital_bank/setup.py @@ -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}):