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