Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
// Copyright (c) 2023, ALYF GmbH and contributors
// For license information, please see license.txt

const CURRENCY_CONVERSION_SUPPORTED_DOCTYPES = [
"Sales Invoice",
"Purchase Invoice",
"Expense Claim",
];

frappe.ui.form.on("Bank Reconciliation Tool Beta", {
setup: function (frm) {
frm.set_query("bank_account", function (doc) {
Expand Down Expand Up @@ -48,6 +54,39 @@ frappe.ui.form.on("Bank Reconciliation Tool Beta", {
}
},

/**
* Handles reconcile-time currency conversion for mismatched voucher selections.
*
* When selected voucher currency matches the bank transaction currency, this
* hook exits without altering the default reconcile flow. For currency
* mismatches, it enforces single-voucher selection, restricts conversion to
* supported voucher types, fetches backend prefill context, and opens the
* manual conversion dialog.
*/
before_reconcile: async function (frm, transaction, selected_vouchers) {
const mismatched_vouchers = get_currency_mismatched_vouchers(
transaction,
selected_vouchers
);

if (!mismatched_vouchers.length) {
return;
}

validate_single_voucher_selection(selected_vouchers);

const voucher = selected_vouchers[0];
validate_currency_conversion_voucher_type(voucher);

const context = await fetch_reconcile_amount_context(transaction, voucher);

return erpnext.accounts.bank_reconciliation.prompt_manual_reconcile_amounts(
context,
transaction,
voucher
);
},

refresh: function (frm) {
frm.disable_save();
frm.fields_dict["filters_section"].collapse(false);
Expand Down Expand Up @@ -257,6 +296,66 @@ frappe.ui.form.on("Bank Reconciliation Tool Beta", {
},
});

function get_currency_mismatched_vouchers(transaction, selected_vouchers) {
return (selected_vouchers || []).filter(
(voucher) => voucher.currency && voucher.currency !== transaction.currency
);
}

function validate_single_voucher_selection(selected_vouchers) {
if ((selected_vouchers || []).length === 1) {
return;
}

frappe.show_alert({
message: __(
"Currency conversion reconcile is only supported for one voucher at a time."
),
indicator: "orange",
});
throw new Error("currency_mismatch_requires_single_voucher");
}

function validate_currency_conversion_voucher_type(voucher) {
if (
CURRENCY_CONVERSION_SUPPORTED_DOCTYPES.includes(voucher.payment_doctype)
) {
return;
}

frappe.show_alert({
message: __(
"Currency conversion reconcile is only available for Sales Invoice, Purchase Invoice, and Expense Claim."
),
indicator: "orange",
});
throw new Error("unsupported_voucher_type_for_currency_conversion");
}

async function fetch_reconcile_amount_context(transaction, voucher) {
const context = await frappe
.call({
method:
"banking.klarna_kosma_integration.doctype.bank_reconciliation_tool_beta.bank_reconciliation_tool_beta.get_reconcile_amount_context",
args: {
bank_transaction_name: transaction.name,
voucher_doctype: voucher.payment_doctype,
voucher_name: voucher.payment_name,
},
})
.then((result) => result.message);

if (context) {
return context;
}

frappe.show_alert({
message: __("Unable to prepare conversion details for reconciliation."),
indicator: "red",
});
throw new Error("missing_reconcile_amount_context");
}

function show_camt_uploader(frm) {
if (!frm.doc.bank_account) {
frappe.throw({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
get_total_allocated_amount,
)
from erpnext.accounts.utils import get_account_currency
from erpnext.setup.utils import get_exchange_rate
from frappe import _
from frappe.model.document import Document
from frappe.query_builder.custom import ConstantColumn
Expand Down Expand Up @@ -475,6 +476,56 @@ def get_linked_payments(
return matching


@frappe.whitelist()
def get_reconcile_amount_context(bank_transaction_name: str, voucher_doctype: str, voucher_name: str) -> dict:
"""Return minimal server-authoritative prefill context for conversion dialog.

Business context: in the single-voucher cross-currency reconcile flow, the UI
needs only the voucher allocation currency plus a posting-date exchange-rate
prefill. Keeping this payload narrow avoids duplicating client-side state and
keeps server ownership of permission-checked, date-aware rate lookup.
"""
transaction: CustomBankTransaction = frappe.get_doc("Bank Transaction", bank_transaction_name)
transaction.check_permission("read")

target_currency = _get_voucher_allocation_currency(voucher_doctype, voucher_name)
source_currency = transaction.currency
exchange_rate = (
1
if source_currency == target_currency
else get_exchange_rate(
source_currency,
target_currency,
transaction.date,
)
)

return {
"voucher_currency": target_currency,
"exchange_rate": flt(exchange_rate),
}
Comment thread
barredterra marked this conversation as resolved.


def _get_voucher_allocation_currency(voucher_doctype: str, voucher_name: str) -> str:
"""Resolve the settlement currency used for voucher-side allocation.

Business context: manual conversion must target the same currency that Payment
Entry allocation expects for the selected voucher type. Centralizing this
mapping keeps dialog prefill and downstream backend validation aligned.
"""
allowed_vouchers = {"Sales Invoice", "Purchase Invoice", "Expense Claim"}
if voucher_doctype not in allowed_vouchers:
frappe.throw(_("Unsupported voucher type for manual reconciliation: {0}").format(voucher_doctype))

voucher = frappe.get_doc(voucher_doctype, voucher_name)
voucher.check_permission("read")

if voucher_doctype in {"Sales Invoice", "Purchase Invoice"}:
return voucher.party_account_currency

return get_company_currency(voucher.company)


def subtract_allocations(gl_account, vouchers):
"""Look up & subtract any existing Bank Transaction allocations.

Expand Down Expand Up @@ -1125,11 +1176,14 @@ def get_unpaid_si_matching_query(
.when(is_converted, sales_invoice.currency)
.else_(sales_invoice.party_account_currency)
)
currency_match_condition = display_currency == currency
currency_match = frappe.qb.terms.Case().when(currency_match_condition, 1).else_(0)

party_filter = sales_invoice.customer == common_filters.party
party_rank = frappe.qb.terms.Case().when(party_filter, 1).else_(0)

amount_rank = amount_rank_condition(invoice_outstanding, common_filters.amount)
base_amount_rank = amount_rank_condition(invoice_outstanding, common_filters.amount)
amount_rank = frappe.qb.terms.Case().when(currency_match_condition, base_amount_rank).else_(0)

# Check reference field equality with common_filters.reference_no
reference_field_is_set = reference_field and reference_field != "name"
Expand All @@ -1154,7 +1208,9 @@ def get_unpaid_si_matching_query(
)
date_rank = frappe.qb.terms.Case().when(date_condition, 1).else_(0)

rank_expression = ref_rank + party_rank + amount_rank + date_rank + name_match + ref_match + 1
rank_expression = (
ref_rank + party_rank + currency_match + amount_rank + date_rank + name_match + ref_match + 1
)

query = (
frappe.qb.from_(sales_invoice)
Expand All @@ -1170,6 +1226,7 @@ def get_unpaid_si_matching_query(
sales_invoice.customer_name.as_("party_name"),
sales_invoice.posting_date,
display_currency.as_("currency"),
currency_match.as_("currency_match"),
party_rank.as_("party_match"),
amount_rank.as_("amount_match"),
date_rank.as_("date_match"),
Expand All @@ -1180,15 +1237,14 @@ def get_unpaid_si_matching_query(
.where(sales_invoice.docstatus == 1)
.where(sales_invoice.company == company) # because we do not have bank account check
.where(sales_invoice.outstanding_amount != 0.0)
.where((sales_invoice.currency == currency) | (sales_invoice.party_account_currency == currency))
.orderby(rank_expression, order=Order.desc)
.limit(MAX_QUERY_RESULTS)
)

if include_only_returns:
query = query.where(sales_invoice.is_return == 1)
if exact_match:
query = query.where(invoice_outstanding == common_filters.amount)
query = query.where(currency_match_condition).where(invoice_outstanding == common_filters.amount)
if common_filters.exact_party_match:
query = query.where(party_filter)

Expand Down Expand Up @@ -1321,11 +1377,14 @@ def get_unpaid_pi_matching_query(
.when(is_converted, purchase_invoice.currency)
.else_(purchase_invoice.party_account_currency)
)
currency_match_condition = display_currency == currency
currency_match = frappe.qb.terms.Case().when(currency_match_condition, 1).else_(0)

party_filter = purchase_invoice.supplier == common_filters.party
party_match = frappe.qb.terms.Case().when(party_filter, 1).else_(0)

amount_rank = amount_rank_condition(invoice_outstanding, common_filters.amount)
base_amount_rank = amount_rank_condition(invoice_outstanding, common_filters.amount)
amount_rank = frappe.qb.terms.Case().when(currency_match_condition, base_amount_rank).else_(0)

# Check reference field equality with common_filters.reference_no
reference_field_is_set = reference_field and reference_field != "name"
Expand All @@ -1352,7 +1411,9 @@ def get_unpaid_pi_matching_query(
)
date_rank = frappe.qb.terms.Case().when(date_condition, 1).else_(0)

rank_expression = ref_rank + party_match + amount_rank + date_rank + name_match + ref_match + 1
rank_expression = (
ref_rank + party_match + currency_match + amount_rank + date_rank + name_match + ref_match + 1
)

query = (
frappe.qb.from_(purchase_invoice)
Expand All @@ -1368,6 +1429,7 @@ def get_unpaid_pi_matching_query(
purchase_invoice.supplier_name.as_("party_name"),
purchase_invoice.posting_date,
display_currency.as_("currency"),
currency_match.as_("currency_match"),
party_match.as_("party_match"),
amount_rank.as_("amount_match"),
date_rank.as_("date_match"),
Expand All @@ -1379,17 +1441,14 @@ def get_unpaid_pi_matching_query(
.where(purchase_invoice.company == company)
.where(purchase_invoice.outstanding_amount != 0.0)
.where(purchase_invoice.is_paid == 0)
.where(
(purchase_invoice.currency == currency) | (purchase_invoice.party_account_currency == currency)
)
.orderby(rank_expression, order=Order.desc)
.limit(MAX_QUERY_RESULTS)
)

if include_only_returns:
query = query.where(purchase_invoice.is_return == 1)
if exact_match:
query = query.where(invoice_outstanding == common_filters.amount)
query = query.where(currency_match_condition).where(invoice_outstanding == common_filters.amount)
if common_filters.exact_party_match:
query = query.where(party_filter)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -591,15 +591,15 @@ def test_configurable_reference_field(self):
self.assertEqual(len(matched_vouchers), 2)
self.assertEqual(first_match["reference_no"], si.custom_ref_no)
self.assertEqual(first_match["name"], si.name)
self.assertEqual(first_match["rank"], 5)
self.assertEqual(first_match["rank"], 6)
self.assertEqual(first_match["ref_in_desc_match"], 1)
self.assertEqual(first_match["reference_number_match"], 1)
self.assertEqual(second_match["ref_in_desc_match"], 0)
self.assertEqual(second_match["reference_number_match"], 0)
# Check if ranking across another SI is correct
self.assertEqual(second_match["reference_no"], si2.custom_ref_no)
self.assertEqual(second_match["name"], si2.name)
self.assertEqual(second_match["rank"], 2)
self.assertEqual(second_match["rank"], 3)
self.assertEqual(second_match["ref_in_desc_match"], 0)

def test_no_configurable_reference_field(self):
Expand Down Expand Up @@ -634,7 +634,7 @@ def test_no_configurable_reference_field(self):
self.assertEqual(len(matched_vouchers), 1)
self.assertEqual(first_match["reference_no"], si.name)
self.assertEqual(first_match["name"], si.name)
self.assertEqual(first_match["rank"], 3)
self.assertEqual(first_match["rank"], 4)
self.assertEqual(first_match["amount_match"], 1)
self.assertEqual(first_match["ref_in_desc_match"], 0)

Expand Down
Loading
Loading