Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions banking/custom/expense_claim.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
frappe.ui.form.on("Expense Claim", {
setup(frm) {
frm.make_methods = frm.make_methods || {};
frm.make_methods["SEPA Payment Order"] = () => {
frm.trigger("make_sepa_payment_order");
};
},

refresh(frm) {
const has_outstanding =
frm.doc.status !== "Paid" &&
frm.doc.docstatus === 1 &&
frm.doc.approval_status === "Approved" &&
!frm.doc.sepa_payment_order_status &&
flt(frm.doc.grand_total) - flt(frm.doc.total_amount_reimbursed) > 0;

if (has_outstanding) {
frm.add_custom_button(
__("SEPA Payment Order"),
() => frm.trigger("make_sepa_payment_order"),
__("Create")
);
}
},

make_sepa_payment_order(frm) {
frappe.model.open_mapped_doc({
method: "banking.custom.expense_claim.make_sepa_payment_order",
frm: frm,
freeze_message: __("Creating SEPA Payment Order ..."),
});
},
});
144 changes: 144 additions & 0 deletions banking/custom/expense_claim.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
from datetime import date
from typing import TYPE_CHECKING

import frappe
from frappe import _
from frappe.model.document import Document
from frappe.model.mapper import get_mapped_doc
from frappe.utils import flt
from frappe.utils.data import getdate

from banking.ebics.doctype.sepa_payment_order.sepa_payment_order import PaymentOrderStatus

if TYPE_CHECKING:
from hrms.hr.doctype.expense_claim.expense_claim import ExpenseClaim
from hrms.hr.doctype.expense_claim_detail.expense_claim_detail import ExpenseClaimDetail

from banking.ebics.doctype.sepa_payment.sepa_payment import SEPAPayment


@frappe.whitelist()
def make_sepa_payment_order(source_name: str, target_doc: str | Document | None = None):
if frappe.db.get_value("Expense Claim", source_name, "sepa_payment_order_status"):
frappe.throw(_("A SEPA Payment Order already exists for Expense Claim {0}.").format(source_name))

def set_missing_values(source, target):
if not target.bank_account:
bank_account = frappe.db.get_value(
"Bank Account",
{"is_company_account": 1, "company": source.company, "disabled": 0},
["name", "iban", "bank"],
order_by="is_default DESC",
as_dict=True,
)
if bank_account:
target.bank_account = bank_account.get("name")
target.iban = bank_account.get("iban")
target.bank = bank_account.get("bank")

def process_payment(source: "ExpenseClaimDetail", target: "SEPAPayment", source_parent: "ExpenseClaim"):
claim = source_parent
target.recipient = claim.employee_name
target.purpose = _get_employee_purpose(claim)

bank_account = _get_recipients_bank_account(claim)

if bank_account and bank_account.get("iban"):
target.iban = bank_account.get("iban")
if bank_account.get("bank"):
swift_number, bank_name = frappe.db.get_value(
"Bank", bank_account["bank"], ["swift_number", "bank_name"]
)
target.swift_number = swift_number
target.bank_name = bank_name
else:
employee_iban = frappe.db.get_value("Employee", claim.employee, "iban")
if employee_iban:
target.iban = employee_iban
else:
frappe.throw(_("No IBAN found for Employee {0}.").format(claim.employee))

target.currency = frappe.db.get_value("Company", claim.company, "default_currency")
target.eref = target.reference_name
target.amount = get_sepa_payment_amount(
claim,
method="get_mapped_doc",
reference_row_name=source.name,
execution_date=getdate(),
)

return get_mapped_doc(
"Expense Claim",
source_name,
{
"Expense Claim": {
"doctype": "SEPA Payment Order",
"validation": {
"docstatus": ("=", 1),
"approval_status": ("=", "Approved"),
"status": ("!=", "Paid"),
},
},
"Expense Claim Detail": {
"doctype": "SEPA Payment",
"field_map": {
"name": "reference_row_name",
"parent": "reference_name",
"parenttype": "reference_doctype",
},
"condition": lambda row: row.idx == 1
and flt(row.parent_doc.grand_total) - flt(row.parent_doc.total_amount_reimbursed) > 0.0,
"postprocess": process_payment,
},
},
target_doc,
postprocess=set_missing_values,
)


def _get_employee_purpose(claim: "ExpenseClaim"):
"""Return the bank transfer purpose for an expense claim reimbursement.

Example: "EC-00001, 2025-03-01"
"""
reference = ", ".join(str(ref).strip() for ref in [claim.name, claim.posting_date] if ref)
return reference.strip()


def _get_recipients_bank_account(claim: "ExpenseClaim"):
return frappe.db.get_value(
"Bank Account",
{"party_type": "Employee", "party": claim.employee, "disabled": 0},
["iban", "bank"],
order_by="is_default DESC",
as_dict=True,
)


@frappe.whitelist()
def make_bulk_sepa_payment_order(source_names: str):
target_doc = None
for source_name in frappe.parse_json(source_names):
if not isinstance(source_name, str):
raise TypeError

target_doc = make_sepa_payment_order(source_name, target_doc)

return target_doc


def sepa_payment_order_status_changed(
doc: "ExpenseClaim", method: str, reference_row_name: str, status: PaymentOrderStatus
):
"""Called via hooks when a linked SEPA Payment Order changes."""
doc.sepa_payment_order_status = status.value
doc.save(ignore_permissions=True)


def get_sepa_payment_amount(
doc: "ExpenseClaim", method: str, reference_row_name: str, execution_date: date
) -> float:
"""Return outstanding amount. No discount logic."""
precision = doc.precision("grand_total")
outstanding = flt(doc.grand_total, precision) - flt(doc.total_amount_reimbursed, precision)
return max(0, outstanding)
57 changes: 57 additions & 0 deletions banking/custom/expense_claim_list.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
frappe.listview_settings["Expense Claim"] =
frappe.listview_settings["Expense Claim"] || {};
const old_onload = frappe.listview_settings["Expense Claim"].onload;
const old_add_fields =
frappe.listview_settings["Expense Claim"].add_fields || [];
frappe.listview_settings["Expense Claim"].add_fields = [
...old_add_fields,
"approval_status",
"sepa_payment_order_status",
];

frappe.listview_settings["Expense Claim"].onload = function (listview) {
if (old_onload) old_onload(listview);

if (frappe.model.can_create("SEPA Payment Order")) {
listview.page.add_action_item(__("SEPA Payment Order"), () => {
const claims_to_pay = listview
.get_checked_items()
.filter(
(item) =>
item.status !== "Paid" &&
item.docstatus === 1 &&
item.approval_status === "Approved" &&
!item.sepa_payment_order_status
)
.map((item) => item.name);
if (!claims_to_pay.length) {
frappe.msgprint(
__(
"Only submitted, approved and unpaid Expense Claims without an existing SEPA Payment Order can be used. Rejected, paid or already linked claims were ignored."
),
__("SEPA Payment Order")
);
return;
}
frappe.call({
type: "POST",
method: "banking.custom.expense_claim.make_bulk_sepa_payment_order",
args: {
source_names: claims_to_pay,
},
freeze: true,
freeze_message: __("Creating SEPA Payment Order ..."),
callback: function (r) {
if (!r.exc && r.message) {
frappe.model.sync(r.message);
frappe.get_doc(
r.message.doctype,
r.message.name
).__run_link_triggers = true;
frappe.set_route("Form", r.message.doctype, r.message.name);
}
},
});
});
}
};
12 changes: 12 additions & 0 deletions banking/custom_fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,18 @@ def get_custom_fields():
depends_on="eval:doc.docstatus === 0 && doc.business_trip_employee && !doc.employee_bank_account && doc.pay_to_employee && frappe.model.can_create('Bank Account')",
),
],
"Expense Claim": [
dict(
fieldname="sepa_payment_order_status",
label=_("SEPA Payment Order Status"),
fieldtype="Select",
options="\n".join(PaymentOrderStatus),
insert_after="payable_account",
no_copy=1,
read_only=1,
allow_on_submit=1,
),
],
"Payment Schedule": [
dict(
fieldname="sepa_payment_order_status",
Expand Down
20 changes: 19 additions & 1 deletion banking/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,16 @@
# include js in doctype views
doctype_js = {
"Bank": "custom/bank.js",
"Expense Claim": "custom/expense_claim.js",
"Purchase Invoice": "custom/purchase_invoice.js",
"Employee": "custom/employee.js",
"Supplier": "custom/supplier.js",
"Bank Reconciliation Tool": "custom/bank_reconciliation_tool.js",
}
doctype_list_js = {"Purchase Invoice": "custom/purchase_invoice_list.js"}
doctype_list_js = {
"Expense Claim": "custom/expense_claim_list.js",
"Purchase Invoice": "custom/purchase_invoice_list.js",
}
# doctype_tree_js = {"doctype" : "public/js/doctype_tree.js"}
# doctype_calendar_js = {"doctype" : "public/js/doctype_calendar.js"}

Expand Down Expand Up @@ -117,6 +121,10 @@
"Employee": {
"validate": "banking.custom.employee.validate",
},
"Expense Claim": {
"sepa_payment_order_status_changed": "banking.custom.expense_claim.sepa_payment_order_status_changed",
"get_sepa_payment_amount": "banking.custom.expense_claim.get_sepa_payment_amount",
},
"Purchase Invoice": {
"sepa_payment_order_status_changed": "banking.custom.purchase_invoice.sepa_payment_order_status_changed",
"get_sepa_payment_amount": "banking.custom.purchase_invoice.get_sepa_payment_amount",
Expand Down Expand Up @@ -238,6 +246,16 @@
get_payment_entries = "banking.klarna_kosma_integration.doctype.bank_reconciliation_tool_beta.unpaid_vouchers.get_payment_entries"

alyf_banking_custom_records = [
{
"doctype": "DocType Link",
"parent": "Expense Claim",
"parentfield": "links",
"parenttype": "Customize Form",
"group": "Payment",
"link_doctype": "SEPA Payment Order",
"link_fieldname": "reference_name",
"custom": 1,
},
{
"doctype": "DocType Link",
"parent": "Purchase Invoice",
Expand Down
4 changes: 2 additions & 2 deletions banking/patches.txt
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[pre_model_sync]
banking.patches.recreate_custom_fields # 2025-10-23
banking.patches.recreate_custom_fields # 2026-03-04
banking.patches.remove_spaces_from_iban

[post_model_sync]
Expand All @@ -8,7 +8,7 @@ execute:frappe.delete_doc_if_exists("DocType", "Bank Consent")
execute:frappe.delete_doc_if_exists("DocType", "Klarna Kosma Session")
execute:frappe.delete_doc_if_exists("Custom Field", "Bank Account-kosma_account_id")
execute:frappe.delete_doc_if_exists("Custom Field", "Bank Transaction-kosma_party_name")
execute:from banking.install import insert_custom_records; insert_custom_records()
execute:from banking.install import insert_custom_records; insert_custom_records() #2026-03-27
banking.patches.set_voucher_matching_defaults
banking.patches.download_batch_transactions
execute:from banking.install import make_property_setters; make_property_setters()
Loading