diff --git a/banking/custom/expense_claim.js b/banking/custom/expense_claim.js new file mode 100644 index 00000000..59ae045e --- /dev/null +++ b/banking/custom/expense_claim.js @@ -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 ..."), + }); + }, +}); diff --git a/banking/custom/expense_claim.py b/banking/custom/expense_claim.py new file mode 100644 index 00000000..74bfb950 --- /dev/null +++ b/banking/custom/expense_claim.py @@ -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) diff --git a/banking/custom/expense_claim_list.js b/banking/custom/expense_claim_list.js new file mode 100644 index 00000000..77c94ef5 --- /dev/null +++ b/banking/custom/expense_claim_list.js @@ -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); + } + }, + }); + }); + } +}; diff --git a/banking/custom_fields.py b/banking/custom_fields.py index 841b5daa..dd001b7b 100644 --- a/banking/custom_fields.py +++ b/banking/custom_fields.py @@ -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", diff --git a/banking/hooks.py b/banking/hooks.py index 7a2addad..ce5a1571 100644 --- a/banking/hooks.py +++ b/banking/hooks.py @@ -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"} @@ -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", @@ -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", diff --git a/banking/patches.txt b/banking/patches.txt index 15a2faee..e9170634 100644 --- a/banking/patches.txt +++ b/banking/patches.txt @@ -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] @@ -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()