Skip to content

feat: create SEPA Payment Order from Expense Claim#352

Open
MarcCon wants to merge 11 commits intoversion-15-hotfixfrom
create-sepa-from-expense
Open

feat: create SEPA Payment Order from Expense Claim#352
MarcCon wants to merge 11 commits intoversion-15-hotfixfrom
create-sepa-from-expense

Conversation

@MarcCon
Copy link
Copy Markdown
Collaborator

@MarcCon MarcCon commented Mar 2, 2026

Create SEPA Payment Orders from Expense Claims.
Follows the same general logic as for Purchase Invoices.

Key differences compared to Purchase Invoice:

  • One payment per Expense Claim (with calculation: grand_total - total_amount_reimbursed)
  • Recipient is always the Employee, using the Employee’s bank account
  • Additional validation: approval_status = "Approved"
  • Add custom SEPA status field for tracking changes

Closes #349

@MarcCon MarcCon requested a review from barredterra March 2, 2026 13:57
@barredterra
Copy link
Copy Markdown
Member

No SEPA Order Status update logic for Expense Claim (No Status field)

@MarcCon what do you think, should we keep this part analogous to Purchase Invoice as well? This way we could avoid the creation of duplicate payment orders and filter expense claims by payment status.

MarcCon and others added 2 commits March 16, 2026 10:22
Co-authored-by: Raffael Meyer <14891507+barredterra@users.noreply.github.com>
Copy link
Copy Markdown
Member

@barredterra barredterra left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Functionality works fine! Two small remaining issues.

@MarcCon MarcCon requested a review from barredterra March 27, 2026 07:55
@barredterra
Copy link
Copy Markdown
Member

@greptileai

@greptile-apps
Copy link
Copy Markdown

greptile-apps bot commented Mar 27, 2026

Greptile Summary

This PR adds SEPA Payment Order creation from Expense Claims, following the same general architecture as the existing Purchase Invoice flow. A new sepa_payment_order_status custom field is added to Expense Claim, server-side mapping logic is wired through get_mapped_doc, and both a single-record button and a list-view bulk action are provided.\n\nThe implementation is well-structured and consistent with the existing codebase. A few issues warrant attention before merging:\n\n- Missing outstanding-amount guard (expense_claim.py line 86): The condition for Expense Claim Detail rows uses lambda row: row.idx == 1, which always selects a row regardless of the outstanding amount. The Purchase Invoice equivalent uses lambda payment: round(payment.outstanding, 2) > 0, preventing zero-amount payment rows. Without this guard, edge cases where total_amount_reimbursed >= grand_total but status != \"Paid\" can produce a 0-amount SEPA Payment that fails at XML generation time.\n- Potentially dead Employee.iban fallback (expense_claim.py line 52): The fallback to frappe.db.get_value(\"Employee\", …, \"iban\") relies on a field not present in standard HRMS or added by this app.\n- No server-side duplicate-order guard (expense_claim.py lines 67–92): The get_mapped_doc validation does not check sepa_payment_order_status, so a direct API call can create duplicate SEPA Payment Orders for the same claim.\n- frappe.listview_settings[\"Expense Claim\"] accessed without null guard (expense_claim_list.js line 1): If the key is undefined, the file crashes at load time and silently disables the bulk action.

Confidence Score: 4/5

Safe to merge with low risk, but the missing outstanding-amount guard can produce unprocessable 0-amount SEPA Payment Orders in edge cases.

One P1 finding (zero-amount payment guard) describes a present defect on the changed path. The remaining findings are P2. Score is 4 rather than 5 because of the P1 finding.

banking/custom/expense_claim.py — the row-condition logic and the missing duplicate-order guard need review before merge.

Important Files Changed

Filename Overview
banking/custom/expense_claim.py Core server-side logic for SEPA Payment Order creation from Expense Claims; contains a missing outstanding-amount guard (could produce 0-amount payments), a potentially dead Employee.iban fallback, and no server-side duplicate-order prevention.
banking/custom/expense_claim.js Client-side form script; correctly adds "Create > SEPA Payment Order" button with proper outstanding/approval/status guards and integrates with frappe.model.open_mapped_doc.
banking/custom/expense_claim_list.js List-view bulk action script; adds approval/status filtering for the bulk SEPA payment action but accesses frappe.listview_settings["Expense Claim"] at module load time without a null guard.
banking/custom_fields.py Adds a read-only, no-copy sepa_payment_order_status Select field to Expense Claim using PaymentOrderStatus enum options; correctly follows the existing Payment Schedule pattern.
banking/hooks.py Registers the new JS files, doc_events hooks, and DocType Link for Expense Claim; mirrors the Purchase Invoice registration pattern correctly.
banking/patches.txt Updates the recreate_custom_fields patch timestamp and adds a dated comment to the insert_custom_records execute line to trigger re-execution on upgrade.

Sequence Diagram

sequenceDiagram
    participant User
    participant JS as Expense Claim JS
    participant PY as expense_claim.py
    participant Mapper as get_mapped_doc
    participant Order as SEPA Payment Order
    participant Hooks as doc_events hooks

    User->>JS: Click Create SEPA Payment Order
    JS->>PY: make_sepa_payment_order(source_name)
    PY->>Mapper: get_mapped_doc with validation rules
    Note over Mapper: docstatus=1, approval_status=Approved, status!=Paid
    Mapper->>Mapper: condition row.idx==1 picks first Expense Claim Detail
    Mapper->>PY: process_payment callback
    PY->>PY: _get_recipients_bank_account(claim)
    PY->>PY: get_sepa_payment_amount = grand_total - total_amount_reimbursed
    Mapper->>PY: set_missing_values fills company bank account
    PY-->>JS: Unsaved SEPA Payment Order doc
    JS->>User: Open SEPA Payment Order form

    Order->>Hooks: after_insert notify DRAFT
    Hooks->>PY: sepa_payment_order_status_changed doc DRAFT
    PY->>PY: doc.sepa_payment_order_status = Draft

    Order->>Hooks: on_submit notify APPROVED
    Hooks->>PY: sepa_payment_order_status_changed doc APPROVED

    Order->>Hooks: on_update_after_submit notify TRANSMITTED
    Hooks->>PY: sepa_payment_order_status_changed doc TRANSMITTED

    Order->>Hooks: on_cancel or on_trash notify CANCELLED
    Hooks->>PY: sepa_payment_order_status_changed doc empty string
    Note over PY: Status cleared, Create button reappears
Loading

Reviews (1): Last reviewed commit: "fix(Expense Claim): get iban from employ..." | Re-trigger Greptile

Comment on lines +86 to +88
"condition": lambda row: row.idx == 1,
"postprocess": process_payment,
},
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Missing outstanding-amount guard allows 0-amount payment

The condition for Expense Claim Detail is lambda row: row.idx == 1, which always selects the first row regardless of the outstanding amount. In contrast, the analogous Purchase Invoice condition is lambda payment: round(payment.outstanding, 2) > 0, which prevents row selection when nothing is owed.

If an Expense Claim reaches total_amount_reimbursed == grand_total but ERPNext hasn't yet set status = "Paid" (e.g., partial reimbursement edge cases, or a timing gap between payment and status update), the server-side get_mapped_doc validation passes, a SEPA Payment row is created, and get_sepa_payment_amount returns max(0, 0) = 0. The resulting SEPA Payment Order will fail at XML generation time because the SEPA spec does not allow zero-amount transfers, leaving the user with an unprocessable order.

Add an outstanding-amount guard to the condition so no row — and therefore no payment — is created when there is nothing to pay:

"condition": lambda row: row.idx == 1
    and flt(row.parent_doc.grand_total) - flt(row.parent_doc.total_amount_reimbursed) > 0,

Or, equivalently, add a server-side check in set_missing_values / process_payment that throws a user-friendly error when the computed amount is 0, mirroring the frappe.throw already present for the missing-IBAN case.

Comment on lines +67 to +92
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,
"postprocess": process_payment,
},
},
target_doc,
postprocess=set_missing_values,
)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 No server-side guard against duplicate SEPA Payment Orders

The get_mapped_doc validation checks docstatus = 1, approval_status = Approved, and status != Paid, but does not check sepa_payment_order_status. A direct POST call to banking.custom.expense_claim.make_sepa_payment_order (bypassing the client-side !frm.doc.sepa_payment_order_status guard in expense_claim.js) can create a second SEPA Payment Order for the same claim, overwriting the status field and potentially triggering a double payment.

Consider adding a server-side check at the start of make_sepa_payment_order:

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))

Comment on lines +1 to +4
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 = [
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Top-level access to frappe.listview_settings["Expense Claim"] may throw if HRMS is absent

Lines 1–4 access frappe.listview_settings["Expense Claim"] at module evaluation time. If this key does not exist (e.g., HRMS is not installed or its list-view script hasn't run yet), line 1 throws a TypeError and the entire file fails to load, silently disabling the bulk action for all users.

A defensive guard is worth adding:

frappe.listview_settings["Expense Claim"] =
    frappe.listview_settings["Expense Claim"] || {};

const old_onload = frappe.listview_settings["Expense Claim"].onload;

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Create SEPA Payment Order from Expense Claim

2 participants