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:
parent
197257d044
commit
b76cdd355e
83
CLAUDE.md
83
CLAUDE.md
|
|
@ -4,7 +4,10 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||||
|
|
||||||
## Project Overview
|
## 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
|
## Development Commands
|
||||||
|
|
||||||
|
|
@ -21,6 +24,9 @@ bench migrate
|
||||||
# Run tests
|
# Run tests
|
||||||
bench --site <site-name> run-tests --app kapital_bank
|
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)
|
# Linting and formatting (from app directory)
|
||||||
pre-commit run --all-files
|
pre-commit run --all-files
|
||||||
pre-commit run ruff --all-files # Python only
|
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
|
- **Python**: Ruff linter/formatter — tabs, double quotes, line-length 110, target Python 3.10
|
||||||
- **JavaScript**: ESLint + Prettier — tabs, indent size 4
|
- **JavaScript**: ESLint + Prettier — tabs, indent size 4
|
||||||
- **Indentation**: Tabs everywhere (spaces only in JSON, indent size 1)
|
- **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
|
## 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/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/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/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
|
### 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)
|
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)
|
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 + tracking record
|
4. **Import**: Statement transactions → fuzzy match party/purpose → create Payment Entry or Bank Transaction + tracking record
|
||||||
5. **Cleanup**: Doc events on Payment Entry (on_trash, on_cancel) auto-delete associated transaction records
|
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
|
### 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)
|
- **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
|
- **Registry doctypes**: Account, Card, Customer, Supplier — local copies of bank data
|
||||||
- **Child tables**: Account Mapping, Card Mapping, Customer Mapping, Supplier Mapping, Transaction Mapping
|
- **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
|
- **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
|
### Key Patterns
|
||||||
|
|
||||||
- All `@frappe.whitelist()` functions use `ignore_permissions=True` for system-level operations
|
- 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
|
- 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)
|
- 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
|
## API Documentation
|
||||||
|
|
||||||
BIRBank B2B API documentation PDFs are in `birbank_docs/`. The base URL is `https://my.birbank.business/api/b2b`.
|
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`
|
||||||
|
|
|
||||||
|
|
@ -82,12 +82,15 @@ class BIRBankClient:
|
||||||
"""Raise with the bank's own message if available, otherwise fall back to HTTP error."""
|
"""Raise with the bank's own message if available, otherwise fall back to HTTP error."""
|
||||||
if resp.ok:
|
if resp.ok:
|
||||||
return
|
return
|
||||||
|
msg = ""
|
||||||
try:
|
try:
|
||||||
body = resp.json()
|
body = resp.json()
|
||||||
api_resp = body.get("response", {})
|
# Handle both {"response": {"message": "..."}} and {"message": "..."}
|
||||||
msg = api_resp.get("message", "")
|
msg = (
|
||||||
if msg:
|
body.get("response", {}).get("message")
|
||||||
raise requests.HTTPError(msg, response=resp)
|
or body.get("message")
|
||||||
|
or str(body)
|
||||||
|
)
|
||||||
except (ValueError, KeyError):
|
except (ValueError, KeyError):
|
||||||
pass
|
msg = resp.text[:500] if resp.text else ""
|
||||||
resp.raise_for_status()
|
raise requests.HTTPError(msg or f"HTTP {resp.status_code}", response=resp)
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -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"));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -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"));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -9,22 +9,26 @@ app_license = "unlicense"
|
||||||
after_install = "kapital_bank.setup.after_install"
|
after_install = "kapital_bank.setup.after_install"
|
||||||
after_migrate = "kapital_bank.setup.after_migrate"
|
after_migrate = "kapital_bank.setup.after_migrate"
|
||||||
|
|
||||||
|
|
||||||
# JS for ERPNext forms
|
# JS for ERPNext forms
|
||||||
doctype_js = {
|
doctype_js = {
|
||||||
"Payment Entry": "client/payment_entry.js",
|
"Payment Entry": "client/payment_entry.js",
|
||||||
"Bank Transaction": "client/bank_transaction.js",
|
"Bank Transaction": "client/bank_transaction.js",
|
||||||
"Bank Reconciliation Tool": "client/bank_reconciliation_tool.js",
|
"Bank Reconciliation Tool": "client/bank_reconciliation_tool.js",
|
||||||
|
"Payment Request": "client/payment_request.js",
|
||||||
}
|
}
|
||||||
|
|
||||||
doctype_list_js = {
|
doctype_list_js = {
|
||||||
"Payment Entry": "client/payment_entry.js",
|
"Payment Entry": "client/payment_entry.js",
|
||||||
"Bank Transaction": "client/bank_transaction.js",
|
"Bank Transaction": "client/bank_transaction.js",
|
||||||
|
"Kapital Bank Bank Code": "client/kapital_bank_bank_code.js",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Scheduled Tasks (refresh JWT every 4 minutes)
|
# Scheduled Tasks (refresh JWT every 4 minutes)
|
||||||
scheduler_events = {
|
scheduler_events = {
|
||||||
"cron": {
|
"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_trash": "kapital_bank.bank_api.on_delete_bank_transaction",
|
||||||
"on_cancel": "kapital_bank.bank_api.on_cancel_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
|
# Scheduled Tasks
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
from frappe.model.document import Document
|
||||||
|
|
||||||
|
|
||||||
|
class KapitalBankBankCode(Document):
|
||||||
|
pass
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
)
|
||||||
|
|
@ -5,6 +5,21 @@ frappe.ui.form.on('Kapital Bank Settings', {
|
||||||
show_load_data_dialog(frm);
|
show_load_data_dialog(frm);
|
||||||
}, __('Kapital Bank'));
|
}, __('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 ──────────────────────────────────────────────────
|
// ── Accounts buttons ──────────────────────────────────────────────────
|
||||||
frm.add_custom_button(__('Match accounts to Bank Accounts'), () => {
|
frm.add_custom_button(__('Match accounts to Bank Accounts'), () => {
|
||||||
if (frm.is_dirty()) {
|
if (frm.is_dirty()) {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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",
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,15 @@ import frappe
|
||||||
def after_install():
|
def after_install():
|
||||||
cleanup_payment_entry_custom_fields()
|
cleanup_payment_entry_custom_fields()
|
||||||
ensure_bank_transaction_custom_fields()
|
ensure_bank_transaction_custom_fields()
|
||||||
|
ensure_bank_account_custom_fields()
|
||||||
|
ensure_payment_request_custom_fields()
|
||||||
|
|
||||||
|
|
||||||
def after_migrate():
|
def after_migrate():
|
||||||
cleanup_payment_entry_custom_fields()
|
cleanup_payment_entry_custom_fields()
|
||||||
ensure_bank_transaction_custom_fields()
|
ensure_bank_transaction_custom_fields()
|
||||||
|
ensure_bank_account_custom_fields()
|
||||||
|
ensure_payment_request_custom_fields()
|
||||||
|
|
||||||
|
|
||||||
KB_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():
|
def cleanup_payment_entry_custom_fields():
|
||||||
for fieldname in KB_FIELDS:
|
for fieldname in KB_FIELDS:
|
||||||
if frappe.db.exists("Custom Field", {"dt": "Payment Entry", "fieldname": fieldname}):
|
if frappe.db.exists("Custom Field", {"dt": "Payment Entry", "fieldname": fieldname}):
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue