From bbd19eedad973286bdbcba88b0234ce01ca119f4 Mon Sep 17 00:00:00 2001 From: Charles-Henri Decultot Date: Mon, 10 Nov 2025 14:16:31 +0100 Subject: [PATCH 1/3] fix: better internal APIs for deductions on payment entries --- .../stancer_reconciliation.py | 124 ++++++ .../doctype/payment_entry/payment_entry.py | 412 ++++++++++++++++++ .../payment_request/payment_request.py | 102 ++--- erpnext/patches.txt | 27 +- 4 files changed, 610 insertions(+), 55 deletions(-) create mode 100644 erpnext/accounts/doctype/bank_transaction/auto_reconciliation/stancer_reconciliation.py diff --git a/erpnext/accounts/doctype/bank_transaction/auto_reconciliation/stancer_reconciliation.py b/erpnext/accounts/doctype/bank_transaction/auto_reconciliation/stancer_reconciliation.py new file mode 100644 index 0000000000..72b8502b2d --- /dev/null +++ b/erpnext/accounts/doctype/bank_transaction/auto_reconciliation/stancer_reconciliation.py @@ -0,0 +1,124 @@ +# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import re +from typing import TYPE_CHECKING + +import frappe +from payments.payment_gateways.doctype.stancer_settings.api import StancerPaymentsAPI, StancerPayoutsAPI + +from erpnext.accounts.page.bank_reconciliation.bank_reconciliation import BankReconciliation + +if TYPE_CHECKING: + from erpnext.accounts.doctype.payment_entry.payment_entry import PaymentEntry + + +def reconcile_stancer_payouts(bank_transactions): + stancer_transactions = [ + transaction + for transaction in bank_transactions + if "iliad trans stancer" in (transaction.get("description") or "").lower() + ] + if not stancer_transactions: + return + + bank_account_number = frappe.get_cached_value( + "Bank Account", stancer_transactions[0].get("bank_account"), "account" + ) + stancer_payment_gateways = frappe.get_all( + "Mode of Payment Account", + filters={"default_account": bank_account_number, "payment_gateway": ("is", "set")}, + pluck="payment_gateway", + ) + + stancer_accounts = frappe.get_all( + "Payment Gateway", + filters={ + "disabled": 0, + "gateway_settings": "Stancer Settings", + "name": ("in", stancer_payment_gateways), + }, + pluck="gateway_controller", + ) + + if not stancer_accounts: + return + + _reconcile_stancer_payouts(bank_transactions=stancer_transactions, stancer_accounts=stancer_accounts) + + +def _reconcile_stancer_payouts(bank_transactions, stancer_accounts): + reconciled_transactions = [] + for stancer_account in stancer_accounts: + stancer_settings = frappe.get_doc("Stancer Settings", stancer_account) + + for bank_transaction in bank_transactions: + if bank_transaction.get("name") not in reconciled_transactions: + bank_reconciliation = StancerReconciliation(stancer_settings, bank_transaction) + bank_reconciliation.reconcile() + if bank_reconciliation.documents: + reconciled_transactions.append(bank_transaction.get("name")) + + +class StancerReconciliation: + def __init__(self, stancer_settings, bank_transaction): + self.stancer_settings = stancer_settings + self.bank_transaction = bank_transaction + self.date = self.bank_transaction.get("date") + self.payments = [] + self.filtered_payout = {} + self.documents = [] + + def reconcile(self): + self.get_unreconciled_payments() + self.get_payouts_and_transactions() + self.get_payment_references() + + if self.documents: + BankReconciliation([self.bank_transaction], self.documents).reconcile() + + def get_unreconciled_payments(self): + payments = frappe.get_list( + "Payment Entry", + filters={"docstatus": 1, "unreconciled_amount": (">", 0)}, + fields=["name", "reference_no"], + ) + filtered_payments = [p for p in payments if p.reference_no.startswith("paym_")] + self.payment_references = {p.reference_no: p.name for p in filtered_payments} + + def get_payouts_and_transactions(self): + payout_number = None + found_reference = re.search( + r"Ild78-Trans-(?:.*?)Stancer (?:.*?)No(.*?)of", self.bank_transaction.get("description") + ) + payments_api = self.stancer_settings.get_payments_api() + if found_reference: + reference = found_reference.group(1).strip() + payouts_api = self.stancer_settings.get_payouts_api() + for payment_reference in self.payment_references: + payouts = payouts_api.get_list(params={"payment": payment_reference}) + for payout in payouts.get("payouts", []): + if payout.get("reference") == reference: + payout_number = payout.get("id") + break + + if payout_number: + payments = payments_api.get_list(params={"payout": payout_number, "limit": 100}) + + for payment in payments.get("payments"): + self.payments.append( + {"id": payment.get("id"), "amount": payment.get("amount"), "fees": payment.get("fees")} + ) + + def get_payment_references(self): + for payment in self.payments: + if docname := frappe.db.get_value( + "Payment Entry", dict(docstatus=1, reference_no=self.payment_references[payment]("id")) + ): + doc: PaymentEntry = frappe.get_doc("Payment Entry", docname) # type: ignore + if doc.paid_amount == self.payment_references[payment]("amount") and self.payment_references[ + payment + ]("fees"): + doc = self.stancer_settings.amend_payment_with_fees(doc.name) + + self.documents.append(frappe.get_doc("Payment Entry", docname).as_dict()) # type: ignore diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py index df9aef69bd..823cd83cf5 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py @@ -1720,6 +1720,418 @@ class PaymentEntry(AccountsController): return current_tax_fraction + def set_matched_unset_payment_requests_to_response(self): + """ + Find matched Payment Requests for those references which have no Payment Request set.\n + And set to `frappe.response` to show in the frontend for allocation. + """ + if not self.references: + return + + matched_payment_requests = get_matched_payment_request_of_references( + [row for row in self.references if not row.payment_request] + ) + + if not matched_payment_requests: + return + + frappe.response["matched_payment_requests"] = matched_payment_requests + + @frappe.whitelist() + def allocate_amount_to_references(self, paid_amount, paid_amount_change, allocate_payment_amount): + """ + Allocate `Allocated Amount` and `Payment Request` against `Reference` based on `Paid Amount` and `Outstanding Amount`.\n + :param paid_amount: Paid Amount / Received Amount. + :param paid_amount_change: Flag to check if `Paid Amount` is changed or not. + :param allocate_payment_amount: Flag to allocate amount or not. (Payment Request is also dependent on this flag) + """ + if not self.references: + return + + if not allocate_payment_amount: + for ref in self.references: + ref.allocated_amount = 0 + return + + # calculating outstanding amounts + precision = self.precision("paid_amount") + total_positive_outstanding_including_order = 0 + total_negative_outstanding = 0 + paid_amount -= sum(flt(d.amount, precision) for d in self.deductions) + + for ref in self.references: + reference_outstanding_amount = flt(ref.outstanding_amount) + abs_outstanding_amount = abs(reference_outstanding_amount) + + if reference_outstanding_amount > 0: + total_positive_outstanding_including_order += abs_outstanding_amount + else: + total_negative_outstanding += abs_outstanding_amount + + # calculating allocated outstanding amounts + allocated_negative_outstanding = 0 + allocated_positive_outstanding = 0 + + # checking party type and payment type + if (self.payment_type == "Receive" and self.party_type == "Customer") or ( + self.payment_type == "Pay" and self.party_type in ("Supplier", "Employee") + ): + if total_positive_outstanding_including_order > paid_amount: + remaining_outstanding = flt( + total_positive_outstanding_including_order - paid_amount, precision + ) + allocated_negative_outstanding = min(remaining_outstanding, total_negative_outstanding) + + allocated_positive_outstanding = paid_amount + allocated_negative_outstanding + + elif self.party_type in ("Supplier", "Customer"): + if paid_amount > total_negative_outstanding: + if total_negative_outstanding == 0: + frappe.msgprint( + _("Cannot {} from {} without any negative outstanding invoice").format( + _(self.payment_type).lower(), + _(self.party_type), + ) + ) + else: + frappe.msgprint( + _("Paid Amount cannot be greater than total negative outstanding amount {0}").format( + total_negative_outstanding + ) + ) + + return + + else: + allocated_positive_outstanding = flt(total_negative_outstanding - paid_amount, precision) + allocated_negative_outstanding = paid_amount + min( + total_positive_outstanding_including_order, allocated_positive_outstanding + ) + + # inner function to set `allocated_amount` to those row which have no PR + def _allocation_to_unset_pr_row( + row, outstanding_amount, allocated_positive_outstanding, allocated_negative_outstanding + ): + if outstanding_amount > 0 and allocated_positive_outstanding >= 0: + row.allocated_amount = min(allocated_positive_outstanding, outstanding_amount) + allocated_positive_outstanding = flt( + allocated_positive_outstanding - row.allocated_amount, precision + ) + elif outstanding_amount < 0 and allocated_negative_outstanding: + row.allocated_amount = min(allocated_negative_outstanding, abs(outstanding_amount)) * -1 + allocated_negative_outstanding = flt( + allocated_negative_outstanding - abs(row.allocated_amount), precision + ) + return allocated_positive_outstanding, allocated_negative_outstanding + + # allocate amount based on `paid_amount` is changed or not + if not paid_amount_change: + for ref in self.references: + allocated_positive_outstanding, allocated_negative_outstanding = _allocation_to_unset_pr_row( + ref, + ref.outstanding_amount, + allocated_positive_outstanding, + allocated_negative_outstanding, + ) + + allocate_open_payment_requests_to_references(self.references, self.precision("paid_amount")) + + else: + payment_request_outstanding_amounts = ( + get_payment_request_outstanding_set_in_references(self.references) or {} + ) + references_outstanding_amounts = get_references_outstanding_amount(self.references) or {} + remaining_references_allocated_amounts = references_outstanding_amounts.copy() + + # Re allocate amount to those references which have PR set (Higher priority) + for ref in self.references: + if not (ref.reference_doctype and ref.reference_name and ref.payment_request): + continue + + # fetch outstanding_amount of `Reference` (Payment Term) and `Payment Request` to allocate new amount + key = (ref.reference_doctype, ref.reference_name, ref.get("payment_term")) + reference_outstanding_amount = references_outstanding_amounts[key] + pr_outstanding_amount = payment_request_outstanding_amounts[ref.payment_request] + + if reference_outstanding_amount > 0 and allocated_positive_outstanding >= 0: + # allocate amount according to outstanding amounts + outstanding_amounts = ( + allocated_positive_outstanding, + reference_outstanding_amount, + pr_outstanding_amount, + ) + + ref.allocated_amount = min(outstanding_amounts) + + # update amounts to track allocation + allocated_amount = ref.allocated_amount + allocated_positive_outstanding = flt( + allocated_positive_outstanding - allocated_amount, precision + ) + remaining_references_allocated_amounts[key] = flt( + remaining_references_allocated_amounts[key] - allocated_amount, precision + ) + payment_request_outstanding_amounts[ref.payment_request] = flt( + payment_request_outstanding_amounts[ref.payment_request] - allocated_amount, precision + ) + + elif reference_outstanding_amount < 0 and allocated_negative_outstanding: + # allocate amount according to outstanding amounts + outstanding_amounts = ( + allocated_negative_outstanding, + abs(reference_outstanding_amount), + pr_outstanding_amount, + ) + + ref.allocated_amount = min(outstanding_amounts) * -1 + + # update amounts to track allocation + allocated_amount = abs(ref.allocated_amount) + allocated_negative_outstanding = flt( + allocated_negative_outstanding - allocated_amount, precision + ) + remaining_references_allocated_amounts[key] += allocated_amount # negative amount + payment_request_outstanding_amounts[ref.payment_request] = flt( + payment_request_outstanding_amounts[ref.payment_request] - allocated_amount, precision + ) + # Re allocate amount to those references which have no PR (Lower priority) + for ref in self.references: + if ref.payment_request or not (ref.reference_doctype and ref.reference_name): + continue + + key = (ref.reference_doctype, ref.reference_name, ref.get("payment_term")) + reference_outstanding_amount = remaining_references_allocated_amounts[key] + + allocated_positive_outstanding, allocated_negative_outstanding = _allocation_to_unset_pr_row( + ref, + reference_outstanding_amount, + allocated_positive_outstanding, + allocated_negative_outstanding, + ) + + @frappe.whitelist() + def set_matched_payment_requests(self, matched_payment_requests): + """ + Set `Payment Request` against `Reference` based on `matched_payment_requests`.\n + :param matched_payment_requests: List of tuple of matched Payment Requests. + + --- + Example: [(reference_doctype, reference_name, allocated_amount, payment_request), ...] + """ + if not self.references or not matched_payment_requests: + return + + if isinstance(matched_payment_requests, str): + matched_payment_requests = json.loads(matched_payment_requests) + + # modify matched_payment_requests + # like (reference_doctype, reference_name, allocated_amount): payment_request + payment_requests = {} + + for row in matched_payment_requests: + key = tuple(row[:3]) + payment_requests[key] = row[3] + + for ref in self.references: + if ref.payment_request: + continue + + key = (ref.reference_doctype, ref.reference_name, ref.allocated_amount) + + if key in payment_requests: + ref.payment_request = payment_requests[key] + del payment_requests[key] # to avoid duplicate allocation + + def set_advance_reference_for_down_payments(self): # @dokos + for ref in self.references: + if ref.reference_doctype == "Sales Invoice" and frappe.db.get_value( + ref.reference_doctype, ref.reference_name, "is_down_payment_invoice" + ): + if sales_order := frappe.db.get_value( + "Sales Invoice Item", + { + "parenttype": ref.reference_doctype, + "parent": ref.reference_name, + "sales_order": ("is", "set"), + }, + "sales_order", + ): + ref.advance_voucher_type = "Sales Order" + ref.advance_voucher_no = sales_order + + def add_payment_gateway_fees(self, account, cost_center, fees): + self.add_deductions(account, cost_center, fees) + + def add_payment_gateway_taxes(self, account, cost_center, taxes): + self.add_deductions(account, cost_center, taxes) + + def add_deductions(self, account, cost_center, amount): + self.append( + "deductions", + { + "account": account, + "cost_center": cost_center, + "amount": amount, + }, + ) + + def deduct_fees_from_paid_amount(self): + fees = sum(deduction.amount for deduction in self.deductions) + self.paid_amount = flt(self.paid_amount) - fees + self.received_amount = flt(self.received_amount) - fees + + +def get_matched_payment_request_of_references(references=None): + """ + Get those `Payment Requests` which are matched with `References`.\n + - Amount must be same. + - Only single `Payment Request` available for this amount. + + Example: [(reference_doctype, reference_name, allocated_amount, payment_request), ...] + """ + if not references: + return + + # to fetch matched rows + refs = { + (row.reference_doctype, row.reference_name, row.allocated_amount) + for row in references + if row.reference_doctype and row.reference_name and row.allocated_amount + } + + if not refs: + return + + PR = frappe.qb.DocType("Payment Request") + + # query to group by reference_doctype, reference_name, outstanding_amount + subquery = ( + frappe.qb.from_(PR) + .select( + PR.reference_doctype, + PR.reference_name, + PR.outstanding_amount.as_("allocated_amount"), + PR.name.as_("payment_request"), + Count("*").as_("count"), + ) + .where(Tuple(PR.reference_doctype, PR.reference_name, PR.outstanding_amount).isin(refs)) + .where(PR.status != "Paid") + .where(PR.docstatus == 1) + .groupby(PR.reference_doctype, PR.reference_name, PR.outstanding_amount) + ) + + # query to fetch matched rows which are single + matched_prs = ( + frappe.qb.from_(subquery) + .select( + subquery.reference_doctype, + subquery.reference_name, + subquery.allocated_amount, + subquery.payment_request, + ) + .where(subquery.count == 1) + .run() + ) + + return matched_prs if matched_prs else None + + +def get_references_outstanding_amount(references=None): + """ + Fetch accurate outstanding amount of `References`.\n + - If `Payment Term` is set, then fetch outstanding amount from `Payment Schedule`. + - If `Payment Term` is not set, then fetch outstanding amount from `References` it self. + + Example: {(reference_doctype, reference_name, payment_term): outstanding_amount, ...} + """ + if not references: + return + + refs_with_payment_term = get_outstanding_of_references_with_payment_term(references) or {} + refs_without_payment_term = get_outstanding_of_references_with_no_payment_term(references) or {} + + return {**refs_with_payment_term, **refs_without_payment_term} + + +def get_outstanding_of_references_with_payment_term(references=None): + """ + Fetch outstanding amount of `References` which have `Payment Term` set.\n + Example: {(reference_doctype, reference_name, payment_term): outstanding_amount, ...} + """ + if not references: + return + + refs = { + (row.reference_doctype, row.reference_name, row.payment_term) + for row in references + if row.reference_doctype and row.reference_name and row.payment_term + } + + if not refs: + return + + PS = frappe.qb.DocType("Payment Schedule") + + response = ( + frappe.qb.from_(PS) + .select(PS.parenttype, PS.parent, PS.payment_term, PS.outstanding) + .where(Tuple(PS.parenttype, PS.parent, PS.payment_term).isin(refs)) + ).run(as_dict=True) + + if not response: + return + + return {(row.parenttype, row.parent, row.payment_term): row.outstanding for row in response} + + +def get_outstanding_of_references_with_no_payment_term(references): + """ + Fetch outstanding amount of `References` which have no `Payment Term` set.\n + - Fetch outstanding amount from `References` it self. + + Note: `None` is used for allocation of `Payment Request` + Example: {(reference_doctype, reference_name, None): outstanding_amount, ...} + """ + if not references: + return + + outstanding_amounts = {} + + for ref in references: + if ref.payment_term: + continue + + key = (ref.reference_doctype, ref.reference_name, None) + + if key not in outstanding_amounts: + outstanding_amounts[key] = ref.outstanding_amount + + return outstanding_amounts + + +def get_payment_request_outstanding_set_in_references(references=None): + """ + Fetch outstanding amount of `Payment Request` which are set in `References`.\n + Example: {payment_request: outstanding_amount, ...} + """ + if not references: + return + + referenced_payment_requests = {row.payment_request for row in references if row.payment_request} + + if not referenced_payment_requests: + return + + PR = frappe.qb.DocType("Payment Request") + + response = ( + frappe.qb.from_(PR) + .select(PR.name, PR.outstanding_amount) + .where(PR.name.isin(referenced_payment_requests)) + ).run() + + return dict(response) if response else None + def validate_inclusive_tax(tax, doc): def _on_previous_row_error(row_range): diff --git a/erpnext/accounts/doctype/payment_request/payment_request.py b/erpnext/accounts/doctype/payment_request/payment_request.py index 53484469f1..2452b4ee42 100644 --- a/erpnext/accounts/doctype/payment_request/payment_request.py +++ b/erpnext/accounts/doctype/payment_request/payment_request.py @@ -3,6 +3,7 @@ import json import warnings +from typing import TYPE_CHECKING import frappe from frappe import _ @@ -14,11 +15,23 @@ from payments.utils.utils import can_make_immediate_payment from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import ( get_accounting_dimensions, ) -from erpnext.accounts.doctype.payment_entry.payment_entry import get_company_defaults, get_payment_entry +from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry from erpnext.accounts.party import get_party_account, get_party_bank_account from erpnext.accounts.utils import get_account_currency, get_currency_precision from erpnext.utilities import payment_app_import_guard, webshop_app_import_guard +if TYPE_CHECKING: + from erpnext.accounts.doctype.payment_entry.payment_entry import PaymentEntry + +ALLOWED_DOCTYPES_FOR_PAYMENT_REQUEST = [ + "Sales Order", + "Purchase Order", + "Sales Invoice", + "Purchase Invoice", + "POS Invoice", + "Fees", +] + def _get_payment_gateway_controller(*args, **kwargs): with payment_app_import_guard(): @@ -372,7 +385,7 @@ class PaymentRequest(Document): customer.flags.ignore_permissions = True customer.save() - def create_payment_entry(self, submit=True, reference_no=None): + def create_payment_entry(self, submit=True, reference_no=None) -> "PaymentEntry": """create entry""" frappe.flags.ignore_account_permission = True frappe.flags.ignore_permissions = True @@ -384,16 +397,10 @@ class PaymentRequest(Document): "company_currency", frappe.db.get_value("Company", ref_doc.company, "default_currency") ) - mode_of_payment_defaults = ( - frappe.db.get_value( - "Mode of Payment Account", - dict(parent=self.mode_of_payment, company=ref_doc.company), - ["default_account", "fee_account", "tax_account", "cost_center"], - as_dict=1, - ) - if self.mode_of_payment - else dict() - ) + party_amount = bank_amount = self.outstanding_amount + + # @dokos + mode_of_payment_defaults = self.get_mode_of_payment_defaults(ref_doc.company) or dict() if self.reference_doctype == "Sales Invoice": party_account = ref_doc.debit_to @@ -402,21 +409,12 @@ class PaymentRequest(Document): "Customer", ref_doc.get("customer"), ref_doc.company, tax_category=ref_doc.get("tax_category") ) # @dokos - party_account_currency = ref_doc.get("party_account_currency") or get_account_currency( - party_account - ) - - bank_amount = self.grand_total - if party_account_currency == company_currency and party_account_currency != self.currency: - party_amount = ref_doc.get("base_rounded_total") or ref_doc.get("base_grand_total") - else: - party_amount = self.grand_total - - payment_entry = get_payment_entry( + # outstanding amount is already in Part's account currency + payment_entry: PaymentEntry = get_payment_entry( self.reference_doctype, self.reference_name, party_amount=party_amount, - bank_account=mode_of_payment_defaults.get("default_account") or self.get_payment_account(), + bank_account=mode_of_payment_defaults.get("default_account") or self.get_payment_account(), # type: ignore bank_amount=bank_amount, ) @@ -435,34 +433,19 @@ class PaymentRequest(Document): } ) - self.get_payment_gateway_fees(reference_no) - - total_fee_amount = 0.0 - for value in ["fee", "tax"]: - if ( - self.get(f"{value}_amount") - and mode_of_payment_defaults.get(f"{value}_account") - and mode_of_payment_defaults.get("cost_center") - ): - total_fee_amount += flt(self.get(f"{value}_amount")) * flt(self.get("target_exchange_rate", 1)) - - payment_entry.append( - "deductions", - { - "account": mode_of_payment_defaults.get(f"{value}_account"), - "cost_center": mode_of_payment_defaults.get("cost_center"), - "amount": self.get(f"{value}_amount"), - }, - ) - - payment_entry.update( - { - "paid_amount": flt(self.base_amount or self.grand_total) - total_fee_amount, - "received_amount": flt(self.grand_total) - total_fee_amount, - } - ) - - payment_entry.set_amounts() + # Allocate payment_request for each reference in payment_entry (Payment Term can splits the row) + self._allocate_payment_request_to_pe_references(references=payment_entry.references) + + # @dokos + if total_fees: + for value in ["fee", "tax"]: + if mode_of_payment_defaults.get(f"{value}_account") and self.get(f"{value}_amount"): + payment_entry.add_deductions( + account=mode_of_payment_defaults.get(f"{value}_account"), + cost_center=mode_of_payment_defaults.get("cost_center"), + amount=self.get(f"{value}_amount"), + ) + # @dokos # Update dimensions payment_entry.update( @@ -472,8 +455,11 @@ class PaymentRequest(Document): } ) - for dimension in get_accounting_dimensions(): - payment_entry.update({dimension: self.get(dimension)}) + # @dokos + payment_entry.deduct_fees_from_paid_amount() + # @dokos + + payment_entry.set_amounts() if payment_entry.difference_amount: company_details = get_company_defaults(ref_doc.company) @@ -661,6 +647,14 @@ class PaymentRequest(Document): if meta.has_field("subscription"): self.subscription = frappe.db.get_value(self.reference_doctype, self.reference_name, "subscription") + def get_mode_of_payment_defaults(self, company): + return frappe.db.get_value( + "Mode of Payment Account", + dict(parent=self.mode_of_payment, company=company), + ["default_account", "fee_account", "tax_account", "cost_center"], + as_dict=True, + ) + @frappe.whitelist(allow_guest=True) def make_payment_request(*args, **kwargs): diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 5ddb141a36..9bdc011691 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -326,7 +326,9 @@ erpnext.patches.v15_0.update_gpa_and_ndb_for_assdeprsch execute:frappe.delete_doc_if_exists("Workspace", "Loans") execute:frappe.delete_doc_if_exists("Workspace", "Loan Management") erpnext.patches.v14_0.create_accounting_dimensions_for_closing_balance -erpnext.patches.v14_0.update_closing_balances #14-07-2023 +erpnext.patches.v14_0.set_period_start_end_date_in_pcv +erpnext.patches.v16_0.set_reporting_currency +erpnext.patches.v14_0.update_closing_balances #20-12-2024 execute:frappe.db.set_single_value("Accounts Settings", "merge_similar_account_heads", 0) erpnext.patches.v14_0.update_reference_type_in_journal_entry_accounts execute:frappe.delete_doc("Report", "Tax Detail", force=True) @@ -422,3 +424,26 @@ erpnext.patches.v15_0.update_asset_status_to_work_in_progress erpnext.patches.v15_0.rename_manufacturing_settings_field erpnext.patches.v14_0.update_posting_datetime erpnext.patches.v14_0.update_full_name_in_contract +erpnext.patches.v15_0.drop_sle_indexes #2025-09-18 +erpnext.patches.v15_0.update_pick_list_fields +execute:frappe.db.set_single_value("Accounts Settings", "confirm_before_resetting_posting_date", 1) +erpnext.patches.v15_0.rename_pos_closing_entry_fields #2025-06-13 +erpnext.patches.v15_0.update_pegged_currencies +erpnext.patches.v15_0.set_status_cancelled_on_cancelled_pos_opening_entry_and_pos_closing_entry +erpnext.patches.v15_0.set_company_on_pos_inv_merge_log +erpnext.patches.v15_0.update_payment_ledger_entries_against_advance_doctypes +erpnext.patches.v15_0.rename_price_list_to_buying_price_list +erpnext.patches.v15_0.repost_gl_entries_with_no_account_subcontracting #2025-08-04 +erpnext.patches.v15_0.patch_missing_buying_price_list_in_material_request +erpnext.patches.v15_0.remove_sales_partner_from_consolidated_sales_invoice +erpnext.patches.v15_0.update_uae_zero_rated_fetch +erpnext.patches.v15_0.add_company_payment_gateway_account +erpnext.patches.v16_0.set_invoice_type_in_pos_settings +erpnext.patches.v15_0.update_fieldname_in_accounting_dimension_filter +erpnext.patches.v16_0.make_workstation_operating_components #1 +erpnext.patches.v16_0.set_posting_datetime_for_sabb_and_drop_indexes +erpnext.patches.v16_0.update_serial_no_reference_name +erpnext.patches.v16_0.rename_subcontracted_quantity +erpnext.patches.v16_0.add_new_stock_entry_types +erpnext.patches.v15_0.set_asset_status_if_not_already_set +erpnext.patches.v15_0.toggle_legacy_controller_for_period_closing -- GitLab From 3c8506c835a67bc31d1f4752343cba60e799c32d Mon Sep 17 00:00:00 2001 From: Charles-Henri Decultot Date: Mon, 10 Nov 2025 17:29:12 +0100 Subject: [PATCH 2/3] fix: pass fees instead of calculating them --- erpnext/accounts/doctype/payment_entry/payment_entry.py | 3 +-- erpnext/accounts/doctype/payment_request/payment_request.py | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py index 823cd83cf5..9443e6ccf1 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py @@ -1975,8 +1975,7 @@ class PaymentEntry(AccountsController): }, ) - def deduct_fees_from_paid_amount(self): - fees = sum(deduction.amount for deduction in self.deductions) + def deduct_fees_from_paid_amount(self, fees): self.paid_amount = flt(self.paid_amount) - fees self.received_amount = flt(self.received_amount) - fees diff --git a/erpnext/accounts/doctype/payment_request/payment_request.py b/erpnext/accounts/doctype/payment_request/payment_request.py index 2452b4ee42..21d9bd6a50 100644 --- a/erpnext/accounts/doctype/payment_request/payment_request.py +++ b/erpnext/accounts/doctype/payment_request/payment_request.py @@ -456,7 +456,7 @@ class PaymentRequest(Document): ) # @dokos - payment_entry.deduct_fees_from_paid_amount() + payment_entry.deduct_fees_from_paid_amount(fees=total_fees) # @dokos payment_entry.set_amounts() -- GitLab From 7b73e650a5ba4169bbcb1819a5e6b50f76180658 Mon Sep 17 00:00:00 2001 From: Charles-Henri Decultot Date: Wed, 12 Nov 2025 09:25:04 +0100 Subject: [PATCH 3/3] fix: merge conflicts --- .../stancer_reconciliation.py | 124 ------ .../doctype/payment_entry/payment_entry.py | 390 ------------------ .../payment_request/payment_request.py | 102 ++--- .../stancer_reconciliation.py | 29 +- erpnext/patches.txt | 27 +- 5 files changed, 78 insertions(+), 594 deletions(-) delete mode 100644 erpnext/accounts/doctype/bank_transaction/auto_reconciliation/stancer_reconciliation.py diff --git a/erpnext/accounts/doctype/bank_transaction/auto_reconciliation/stancer_reconciliation.py b/erpnext/accounts/doctype/bank_transaction/auto_reconciliation/stancer_reconciliation.py deleted file mode 100644 index 72b8502b2d..0000000000 --- a/erpnext/accounts/doctype/bank_transaction/auto_reconciliation/stancer_reconciliation.py +++ /dev/null @@ -1,124 +0,0 @@ -# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors -# For license information, please see license.txt - -import re -from typing import TYPE_CHECKING - -import frappe -from payments.payment_gateways.doctype.stancer_settings.api import StancerPaymentsAPI, StancerPayoutsAPI - -from erpnext.accounts.page.bank_reconciliation.bank_reconciliation import BankReconciliation - -if TYPE_CHECKING: - from erpnext.accounts.doctype.payment_entry.payment_entry import PaymentEntry - - -def reconcile_stancer_payouts(bank_transactions): - stancer_transactions = [ - transaction - for transaction in bank_transactions - if "iliad trans stancer" in (transaction.get("description") or "").lower() - ] - if not stancer_transactions: - return - - bank_account_number = frappe.get_cached_value( - "Bank Account", stancer_transactions[0].get("bank_account"), "account" - ) - stancer_payment_gateways = frappe.get_all( - "Mode of Payment Account", - filters={"default_account": bank_account_number, "payment_gateway": ("is", "set")}, - pluck="payment_gateway", - ) - - stancer_accounts = frappe.get_all( - "Payment Gateway", - filters={ - "disabled": 0, - "gateway_settings": "Stancer Settings", - "name": ("in", stancer_payment_gateways), - }, - pluck="gateway_controller", - ) - - if not stancer_accounts: - return - - _reconcile_stancer_payouts(bank_transactions=stancer_transactions, stancer_accounts=stancer_accounts) - - -def _reconcile_stancer_payouts(bank_transactions, stancer_accounts): - reconciled_transactions = [] - for stancer_account in stancer_accounts: - stancer_settings = frappe.get_doc("Stancer Settings", stancer_account) - - for bank_transaction in bank_transactions: - if bank_transaction.get("name") not in reconciled_transactions: - bank_reconciliation = StancerReconciliation(stancer_settings, bank_transaction) - bank_reconciliation.reconcile() - if bank_reconciliation.documents: - reconciled_transactions.append(bank_transaction.get("name")) - - -class StancerReconciliation: - def __init__(self, stancer_settings, bank_transaction): - self.stancer_settings = stancer_settings - self.bank_transaction = bank_transaction - self.date = self.bank_transaction.get("date") - self.payments = [] - self.filtered_payout = {} - self.documents = [] - - def reconcile(self): - self.get_unreconciled_payments() - self.get_payouts_and_transactions() - self.get_payment_references() - - if self.documents: - BankReconciliation([self.bank_transaction], self.documents).reconcile() - - def get_unreconciled_payments(self): - payments = frappe.get_list( - "Payment Entry", - filters={"docstatus": 1, "unreconciled_amount": (">", 0)}, - fields=["name", "reference_no"], - ) - filtered_payments = [p for p in payments if p.reference_no.startswith("paym_")] - self.payment_references = {p.reference_no: p.name for p in filtered_payments} - - def get_payouts_and_transactions(self): - payout_number = None - found_reference = re.search( - r"Ild78-Trans-(?:.*?)Stancer (?:.*?)No(.*?)of", self.bank_transaction.get("description") - ) - payments_api = self.stancer_settings.get_payments_api() - if found_reference: - reference = found_reference.group(1).strip() - payouts_api = self.stancer_settings.get_payouts_api() - for payment_reference in self.payment_references: - payouts = payouts_api.get_list(params={"payment": payment_reference}) - for payout in payouts.get("payouts", []): - if payout.get("reference") == reference: - payout_number = payout.get("id") - break - - if payout_number: - payments = payments_api.get_list(params={"payout": payout_number, "limit": 100}) - - for payment in payments.get("payments"): - self.payments.append( - {"id": payment.get("id"), "amount": payment.get("amount"), "fees": payment.get("fees")} - ) - - def get_payment_references(self): - for payment in self.payments: - if docname := frappe.db.get_value( - "Payment Entry", dict(docstatus=1, reference_no=self.payment_references[payment]("id")) - ): - doc: PaymentEntry = frappe.get_doc("Payment Entry", docname) # type: ignore - if doc.paid_amount == self.payment_references[payment]("amount") and self.payment_references[ - payment - ]("fees"): - doc = self.stancer_settings.amend_payment_with_fees(doc.name) - - self.documents.append(frappe.get_doc("Payment Entry", docname).as_dict()) # type: ignore diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py index 9443e6ccf1..fcfebb3007 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py @@ -1720,244 +1720,6 @@ class PaymentEntry(AccountsController): return current_tax_fraction - def set_matched_unset_payment_requests_to_response(self): - """ - Find matched Payment Requests for those references which have no Payment Request set.\n - And set to `frappe.response` to show in the frontend for allocation. - """ - if not self.references: - return - - matched_payment_requests = get_matched_payment_request_of_references( - [row for row in self.references if not row.payment_request] - ) - - if not matched_payment_requests: - return - - frappe.response["matched_payment_requests"] = matched_payment_requests - - @frappe.whitelist() - def allocate_amount_to_references(self, paid_amount, paid_amount_change, allocate_payment_amount): - """ - Allocate `Allocated Amount` and `Payment Request` against `Reference` based on `Paid Amount` and `Outstanding Amount`.\n - :param paid_amount: Paid Amount / Received Amount. - :param paid_amount_change: Flag to check if `Paid Amount` is changed or not. - :param allocate_payment_amount: Flag to allocate amount or not. (Payment Request is also dependent on this flag) - """ - if not self.references: - return - - if not allocate_payment_amount: - for ref in self.references: - ref.allocated_amount = 0 - return - - # calculating outstanding amounts - precision = self.precision("paid_amount") - total_positive_outstanding_including_order = 0 - total_negative_outstanding = 0 - paid_amount -= sum(flt(d.amount, precision) for d in self.deductions) - - for ref in self.references: - reference_outstanding_amount = flt(ref.outstanding_amount) - abs_outstanding_amount = abs(reference_outstanding_amount) - - if reference_outstanding_amount > 0: - total_positive_outstanding_including_order += abs_outstanding_amount - else: - total_negative_outstanding += abs_outstanding_amount - - # calculating allocated outstanding amounts - allocated_negative_outstanding = 0 - allocated_positive_outstanding = 0 - - # checking party type and payment type - if (self.payment_type == "Receive" and self.party_type == "Customer") or ( - self.payment_type == "Pay" and self.party_type in ("Supplier", "Employee") - ): - if total_positive_outstanding_including_order > paid_amount: - remaining_outstanding = flt( - total_positive_outstanding_including_order - paid_amount, precision - ) - allocated_negative_outstanding = min(remaining_outstanding, total_negative_outstanding) - - allocated_positive_outstanding = paid_amount + allocated_negative_outstanding - - elif self.party_type in ("Supplier", "Customer"): - if paid_amount > total_negative_outstanding: - if total_negative_outstanding == 0: - frappe.msgprint( - _("Cannot {} from {} without any negative outstanding invoice").format( - _(self.payment_type).lower(), - _(self.party_type), - ) - ) - else: - frappe.msgprint( - _("Paid Amount cannot be greater than total negative outstanding amount {0}").format( - total_negative_outstanding - ) - ) - - return - - else: - allocated_positive_outstanding = flt(total_negative_outstanding - paid_amount, precision) - allocated_negative_outstanding = paid_amount + min( - total_positive_outstanding_including_order, allocated_positive_outstanding - ) - - # inner function to set `allocated_amount` to those row which have no PR - def _allocation_to_unset_pr_row( - row, outstanding_amount, allocated_positive_outstanding, allocated_negative_outstanding - ): - if outstanding_amount > 0 and allocated_positive_outstanding >= 0: - row.allocated_amount = min(allocated_positive_outstanding, outstanding_amount) - allocated_positive_outstanding = flt( - allocated_positive_outstanding - row.allocated_amount, precision - ) - elif outstanding_amount < 0 and allocated_negative_outstanding: - row.allocated_amount = min(allocated_negative_outstanding, abs(outstanding_amount)) * -1 - allocated_negative_outstanding = flt( - allocated_negative_outstanding - abs(row.allocated_amount), precision - ) - return allocated_positive_outstanding, allocated_negative_outstanding - - # allocate amount based on `paid_amount` is changed or not - if not paid_amount_change: - for ref in self.references: - allocated_positive_outstanding, allocated_negative_outstanding = _allocation_to_unset_pr_row( - ref, - ref.outstanding_amount, - allocated_positive_outstanding, - allocated_negative_outstanding, - ) - - allocate_open_payment_requests_to_references(self.references, self.precision("paid_amount")) - - else: - payment_request_outstanding_amounts = ( - get_payment_request_outstanding_set_in_references(self.references) or {} - ) - references_outstanding_amounts = get_references_outstanding_amount(self.references) or {} - remaining_references_allocated_amounts = references_outstanding_amounts.copy() - - # Re allocate amount to those references which have PR set (Higher priority) - for ref in self.references: - if not (ref.reference_doctype and ref.reference_name and ref.payment_request): - continue - - # fetch outstanding_amount of `Reference` (Payment Term) and `Payment Request` to allocate new amount - key = (ref.reference_doctype, ref.reference_name, ref.get("payment_term")) - reference_outstanding_amount = references_outstanding_amounts[key] - pr_outstanding_amount = payment_request_outstanding_amounts[ref.payment_request] - - if reference_outstanding_amount > 0 and allocated_positive_outstanding >= 0: - # allocate amount according to outstanding amounts - outstanding_amounts = ( - allocated_positive_outstanding, - reference_outstanding_amount, - pr_outstanding_amount, - ) - - ref.allocated_amount = min(outstanding_amounts) - - # update amounts to track allocation - allocated_amount = ref.allocated_amount - allocated_positive_outstanding = flt( - allocated_positive_outstanding - allocated_amount, precision - ) - remaining_references_allocated_amounts[key] = flt( - remaining_references_allocated_amounts[key] - allocated_amount, precision - ) - payment_request_outstanding_amounts[ref.payment_request] = flt( - payment_request_outstanding_amounts[ref.payment_request] - allocated_amount, precision - ) - - elif reference_outstanding_amount < 0 and allocated_negative_outstanding: - # allocate amount according to outstanding amounts - outstanding_amounts = ( - allocated_negative_outstanding, - abs(reference_outstanding_amount), - pr_outstanding_amount, - ) - - ref.allocated_amount = min(outstanding_amounts) * -1 - - # update amounts to track allocation - allocated_amount = abs(ref.allocated_amount) - allocated_negative_outstanding = flt( - allocated_negative_outstanding - allocated_amount, precision - ) - remaining_references_allocated_amounts[key] += allocated_amount # negative amount - payment_request_outstanding_amounts[ref.payment_request] = flt( - payment_request_outstanding_amounts[ref.payment_request] - allocated_amount, precision - ) - # Re allocate amount to those references which have no PR (Lower priority) - for ref in self.references: - if ref.payment_request or not (ref.reference_doctype and ref.reference_name): - continue - - key = (ref.reference_doctype, ref.reference_name, ref.get("payment_term")) - reference_outstanding_amount = remaining_references_allocated_amounts[key] - - allocated_positive_outstanding, allocated_negative_outstanding = _allocation_to_unset_pr_row( - ref, - reference_outstanding_amount, - allocated_positive_outstanding, - allocated_negative_outstanding, - ) - - @frappe.whitelist() - def set_matched_payment_requests(self, matched_payment_requests): - """ - Set `Payment Request` against `Reference` based on `matched_payment_requests`.\n - :param matched_payment_requests: List of tuple of matched Payment Requests. - - --- - Example: [(reference_doctype, reference_name, allocated_amount, payment_request), ...] - """ - if not self.references or not matched_payment_requests: - return - - if isinstance(matched_payment_requests, str): - matched_payment_requests = json.loads(matched_payment_requests) - - # modify matched_payment_requests - # like (reference_doctype, reference_name, allocated_amount): payment_request - payment_requests = {} - - for row in matched_payment_requests: - key = tuple(row[:3]) - payment_requests[key] = row[3] - - for ref in self.references: - if ref.payment_request: - continue - - key = (ref.reference_doctype, ref.reference_name, ref.allocated_amount) - - if key in payment_requests: - ref.payment_request = payment_requests[key] - del payment_requests[key] # to avoid duplicate allocation - - def set_advance_reference_for_down_payments(self): # @dokos - for ref in self.references: - if ref.reference_doctype == "Sales Invoice" and frappe.db.get_value( - ref.reference_doctype, ref.reference_name, "is_down_payment_invoice" - ): - if sales_order := frappe.db.get_value( - "Sales Invoice Item", - { - "parenttype": ref.reference_doctype, - "parent": ref.reference_name, - "sales_order": ("is", "set"), - }, - "sales_order", - ): - ref.advance_voucher_type = "Sales Order" - ref.advance_voucher_no = sales_order def add_payment_gateway_fees(self, account, cost_center, fees): self.add_deductions(account, cost_center, fees) @@ -1980,158 +1742,6 @@ class PaymentEntry(AccountsController): self.received_amount = flt(self.received_amount) - fees -def get_matched_payment_request_of_references(references=None): - """ - Get those `Payment Requests` which are matched with `References`.\n - - Amount must be same. - - Only single `Payment Request` available for this amount. - - Example: [(reference_doctype, reference_name, allocated_amount, payment_request), ...] - """ - if not references: - return - - # to fetch matched rows - refs = { - (row.reference_doctype, row.reference_name, row.allocated_amount) - for row in references - if row.reference_doctype and row.reference_name and row.allocated_amount - } - - if not refs: - return - - PR = frappe.qb.DocType("Payment Request") - - # query to group by reference_doctype, reference_name, outstanding_amount - subquery = ( - frappe.qb.from_(PR) - .select( - PR.reference_doctype, - PR.reference_name, - PR.outstanding_amount.as_("allocated_amount"), - PR.name.as_("payment_request"), - Count("*").as_("count"), - ) - .where(Tuple(PR.reference_doctype, PR.reference_name, PR.outstanding_amount).isin(refs)) - .where(PR.status != "Paid") - .where(PR.docstatus == 1) - .groupby(PR.reference_doctype, PR.reference_name, PR.outstanding_amount) - ) - - # query to fetch matched rows which are single - matched_prs = ( - frappe.qb.from_(subquery) - .select( - subquery.reference_doctype, - subquery.reference_name, - subquery.allocated_amount, - subquery.payment_request, - ) - .where(subquery.count == 1) - .run() - ) - - return matched_prs if matched_prs else None - - -def get_references_outstanding_amount(references=None): - """ - Fetch accurate outstanding amount of `References`.\n - - If `Payment Term` is set, then fetch outstanding amount from `Payment Schedule`. - - If `Payment Term` is not set, then fetch outstanding amount from `References` it self. - - Example: {(reference_doctype, reference_name, payment_term): outstanding_amount, ...} - """ - if not references: - return - - refs_with_payment_term = get_outstanding_of_references_with_payment_term(references) or {} - refs_without_payment_term = get_outstanding_of_references_with_no_payment_term(references) or {} - - return {**refs_with_payment_term, **refs_without_payment_term} - - -def get_outstanding_of_references_with_payment_term(references=None): - """ - Fetch outstanding amount of `References` which have `Payment Term` set.\n - Example: {(reference_doctype, reference_name, payment_term): outstanding_amount, ...} - """ - if not references: - return - - refs = { - (row.reference_doctype, row.reference_name, row.payment_term) - for row in references - if row.reference_doctype and row.reference_name and row.payment_term - } - - if not refs: - return - - PS = frappe.qb.DocType("Payment Schedule") - - response = ( - frappe.qb.from_(PS) - .select(PS.parenttype, PS.parent, PS.payment_term, PS.outstanding) - .where(Tuple(PS.parenttype, PS.parent, PS.payment_term).isin(refs)) - ).run(as_dict=True) - - if not response: - return - - return {(row.parenttype, row.parent, row.payment_term): row.outstanding for row in response} - - -def get_outstanding_of_references_with_no_payment_term(references): - """ - Fetch outstanding amount of `References` which have no `Payment Term` set.\n - - Fetch outstanding amount from `References` it self. - - Note: `None` is used for allocation of `Payment Request` - Example: {(reference_doctype, reference_name, None): outstanding_amount, ...} - """ - if not references: - return - - outstanding_amounts = {} - - for ref in references: - if ref.payment_term: - continue - - key = (ref.reference_doctype, ref.reference_name, None) - - if key not in outstanding_amounts: - outstanding_amounts[key] = ref.outstanding_amount - - return outstanding_amounts - - -def get_payment_request_outstanding_set_in_references(references=None): - """ - Fetch outstanding amount of `Payment Request` which are set in `References`.\n - Example: {payment_request: outstanding_amount, ...} - """ - if not references: - return - - referenced_payment_requests = {row.payment_request for row in references if row.payment_request} - - if not referenced_payment_requests: - return - - PR = frappe.qb.DocType("Payment Request") - - response = ( - frappe.qb.from_(PR) - .select(PR.name, PR.outstanding_amount) - .where(PR.name.isin(referenced_payment_requests)) - ).run() - - return dict(response) if response else None - - def validate_inclusive_tax(tax, doc): def _on_previous_row_error(row_range): throw( diff --git a/erpnext/accounts/doctype/payment_request/payment_request.py b/erpnext/accounts/doctype/payment_request/payment_request.py index 21d9bd6a50..53484469f1 100644 --- a/erpnext/accounts/doctype/payment_request/payment_request.py +++ b/erpnext/accounts/doctype/payment_request/payment_request.py @@ -3,7 +3,6 @@ import json import warnings -from typing import TYPE_CHECKING import frappe from frappe import _ @@ -15,23 +14,11 @@ from payments.utils.utils import can_make_immediate_payment from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import ( get_accounting_dimensions, ) -from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry +from erpnext.accounts.doctype.payment_entry.payment_entry import get_company_defaults, get_payment_entry from erpnext.accounts.party import get_party_account, get_party_bank_account from erpnext.accounts.utils import get_account_currency, get_currency_precision from erpnext.utilities import payment_app_import_guard, webshop_app_import_guard -if TYPE_CHECKING: - from erpnext.accounts.doctype.payment_entry.payment_entry import PaymentEntry - -ALLOWED_DOCTYPES_FOR_PAYMENT_REQUEST = [ - "Sales Order", - "Purchase Order", - "Sales Invoice", - "Purchase Invoice", - "POS Invoice", - "Fees", -] - def _get_payment_gateway_controller(*args, **kwargs): with payment_app_import_guard(): @@ -385,7 +372,7 @@ class PaymentRequest(Document): customer.flags.ignore_permissions = True customer.save() - def create_payment_entry(self, submit=True, reference_no=None) -> "PaymentEntry": + def create_payment_entry(self, submit=True, reference_no=None): """create entry""" frappe.flags.ignore_account_permission = True frappe.flags.ignore_permissions = True @@ -397,10 +384,16 @@ class PaymentRequest(Document): "company_currency", frappe.db.get_value("Company", ref_doc.company, "default_currency") ) - party_amount = bank_amount = self.outstanding_amount - - # @dokos - mode_of_payment_defaults = self.get_mode_of_payment_defaults(ref_doc.company) or dict() + mode_of_payment_defaults = ( + frappe.db.get_value( + "Mode of Payment Account", + dict(parent=self.mode_of_payment, company=ref_doc.company), + ["default_account", "fee_account", "tax_account", "cost_center"], + as_dict=1, + ) + if self.mode_of_payment + else dict() + ) if self.reference_doctype == "Sales Invoice": party_account = ref_doc.debit_to @@ -409,12 +402,21 @@ class PaymentRequest(Document): "Customer", ref_doc.get("customer"), ref_doc.company, tax_category=ref_doc.get("tax_category") ) # @dokos - # outstanding amount is already in Part's account currency - payment_entry: PaymentEntry = get_payment_entry( + party_account_currency = ref_doc.get("party_account_currency") or get_account_currency( + party_account + ) + + bank_amount = self.grand_total + if party_account_currency == company_currency and party_account_currency != self.currency: + party_amount = ref_doc.get("base_rounded_total") or ref_doc.get("base_grand_total") + else: + party_amount = self.grand_total + + payment_entry = get_payment_entry( self.reference_doctype, self.reference_name, party_amount=party_amount, - bank_account=mode_of_payment_defaults.get("default_account") or self.get_payment_account(), # type: ignore + bank_account=mode_of_payment_defaults.get("default_account") or self.get_payment_account(), bank_amount=bank_amount, ) @@ -433,19 +435,34 @@ class PaymentRequest(Document): } ) - # Allocate payment_request for each reference in payment_entry (Payment Term can splits the row) - self._allocate_payment_request_to_pe_references(references=payment_entry.references) - - # @dokos - if total_fees: - for value in ["fee", "tax"]: - if mode_of_payment_defaults.get(f"{value}_account") and self.get(f"{value}_amount"): - payment_entry.add_deductions( - account=mode_of_payment_defaults.get(f"{value}_account"), - cost_center=mode_of_payment_defaults.get("cost_center"), - amount=self.get(f"{value}_amount"), - ) - # @dokos + self.get_payment_gateway_fees(reference_no) + + total_fee_amount = 0.0 + for value in ["fee", "tax"]: + if ( + self.get(f"{value}_amount") + and mode_of_payment_defaults.get(f"{value}_account") + and mode_of_payment_defaults.get("cost_center") + ): + total_fee_amount += flt(self.get(f"{value}_amount")) * flt(self.get("target_exchange_rate", 1)) + + payment_entry.append( + "deductions", + { + "account": mode_of_payment_defaults.get(f"{value}_account"), + "cost_center": mode_of_payment_defaults.get("cost_center"), + "amount": self.get(f"{value}_amount"), + }, + ) + + payment_entry.update( + { + "paid_amount": flt(self.base_amount or self.grand_total) - total_fee_amount, + "received_amount": flt(self.grand_total) - total_fee_amount, + } + ) + + payment_entry.set_amounts() # Update dimensions payment_entry.update( @@ -455,11 +472,8 @@ class PaymentRequest(Document): } ) - # @dokos - payment_entry.deduct_fees_from_paid_amount(fees=total_fees) - # @dokos - - payment_entry.set_amounts() + for dimension in get_accounting_dimensions(): + payment_entry.update({dimension: self.get(dimension)}) if payment_entry.difference_amount: company_details = get_company_defaults(ref_doc.company) @@ -647,14 +661,6 @@ class PaymentRequest(Document): if meta.has_field("subscription"): self.subscription = frappe.db.get_value(self.reference_doctype, self.reference_name, "subscription") - def get_mode_of_payment_defaults(self, company): - return frappe.db.get_value( - "Mode of Payment Account", - dict(parent=self.mode_of_payment, company=company), - ["default_account", "fee_account", "tax_account", "cost_center"], - as_dict=True, - ) - @frappe.whitelist(allow_guest=True) def make_payment_request(*args, **kwargs): diff --git a/erpnext/accounts/page/bank_reconciliation/stancer_reconciliation.py b/erpnext/accounts/page/bank_reconciliation/stancer_reconciliation.py index 361440a02b..34a3bffcd8 100644 --- a/erpnext/accounts/page/bank_reconciliation/stancer_reconciliation.py +++ b/erpnext/accounts/page/bank_reconciliation/stancer_reconciliation.py @@ -3,12 +3,19 @@ import re +from typing import TYPE_CHECKING + + import frappe from payments.payment_gateways.doctype.stancer_settings.api import StancerPaymentsAPI, StancerPayoutsAPI from erpnext.accounts.page.bank_reconciliation.bank_reconciliation import BankReconciliation +if TYPE_CHECKING: + from erpnext.accounts.doctype.payment_entry.payment_entry import PaymentEntry + + def reconcile_stancer_payouts(bank_transactions): stancer_transactions = [ transaction @@ -87,9 +94,10 @@ class StancerReconciliation: found_reference = re.search( r"Ild78-Trans-(?:.*?)Stancer (?:.*?)No(.*?)of", self.bank_transaction.get("description") ) + payments_api = self.stancer_settings.get_payments_api() if found_reference: reference = found_reference.group(1).strip() - payouts_api = StancerPayoutsAPI(self.stancer_settings) + payouts_api = self.stancer_settings.get_payouts_api() for payment_reference in self.payment_references: payouts = payouts_api.get_list(params={"payment": payment_reference}) for payout in payouts.get("payouts", []): @@ -98,13 +106,22 @@ class StancerReconciliation: break if payout_number: - payments = StancerPaymentsAPI(self.stancer_settings).get_list( - params={"payout": payout_number, "limit": 100} - ) + payments = payments_api.get_list(params={"payout": payout_number, "limit": 100}) for payment in payments.get("payments"): - self.payments.append(payment.get("id")) + self.payments.append( + {"id": payment.get("id"), "amount": payment.get("amount"), "fees": payment.get("fees")} + ) def get_payment_references(self): for payment in self.payments: - self.documents.append(frappe.get_doc("Payment Entry", self.payment_references[payment]).as_dict()) + if docname := frappe.db.get_value( + "Payment Entry", dict(docstatus=1, reference_no=self.payment_references[payment]("id")) + ): + doc: PaymentEntry = frappe.get_doc("Payment Entry", docname) # type: ignore + if doc.paid_amount == self.payment_references[payment]("amount") and self.payment_references[ + payment + ]("fees"): + doc = self.stancer_settings.amend_payment_with_fees(doc.name) + + self.documents.append(frappe.get_doc("Payment Entry", docname).as_dict()) # type: ignore diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 9bdc011691..5ddb141a36 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -326,9 +326,7 @@ erpnext.patches.v15_0.update_gpa_and_ndb_for_assdeprsch execute:frappe.delete_doc_if_exists("Workspace", "Loans") execute:frappe.delete_doc_if_exists("Workspace", "Loan Management") erpnext.patches.v14_0.create_accounting_dimensions_for_closing_balance -erpnext.patches.v14_0.set_period_start_end_date_in_pcv -erpnext.patches.v16_0.set_reporting_currency -erpnext.patches.v14_0.update_closing_balances #20-12-2024 +erpnext.patches.v14_0.update_closing_balances #14-07-2023 execute:frappe.db.set_single_value("Accounts Settings", "merge_similar_account_heads", 0) erpnext.patches.v14_0.update_reference_type_in_journal_entry_accounts execute:frappe.delete_doc("Report", "Tax Detail", force=True) @@ -424,26 +422,3 @@ erpnext.patches.v15_0.update_asset_status_to_work_in_progress erpnext.patches.v15_0.rename_manufacturing_settings_field erpnext.patches.v14_0.update_posting_datetime erpnext.patches.v14_0.update_full_name_in_contract -erpnext.patches.v15_0.drop_sle_indexes #2025-09-18 -erpnext.patches.v15_0.update_pick_list_fields -execute:frappe.db.set_single_value("Accounts Settings", "confirm_before_resetting_posting_date", 1) -erpnext.patches.v15_0.rename_pos_closing_entry_fields #2025-06-13 -erpnext.patches.v15_0.update_pegged_currencies -erpnext.patches.v15_0.set_status_cancelled_on_cancelled_pos_opening_entry_and_pos_closing_entry -erpnext.patches.v15_0.set_company_on_pos_inv_merge_log -erpnext.patches.v15_0.update_payment_ledger_entries_against_advance_doctypes -erpnext.patches.v15_0.rename_price_list_to_buying_price_list -erpnext.patches.v15_0.repost_gl_entries_with_no_account_subcontracting #2025-08-04 -erpnext.patches.v15_0.patch_missing_buying_price_list_in_material_request -erpnext.patches.v15_0.remove_sales_partner_from_consolidated_sales_invoice -erpnext.patches.v15_0.update_uae_zero_rated_fetch -erpnext.patches.v15_0.add_company_payment_gateway_account -erpnext.patches.v16_0.set_invoice_type_in_pos_settings -erpnext.patches.v15_0.update_fieldname_in_accounting_dimension_filter -erpnext.patches.v16_0.make_workstation_operating_components #1 -erpnext.patches.v16_0.set_posting_datetime_for_sabb_and_drop_indexes -erpnext.patches.v16_0.update_serial_no_reference_name -erpnext.patches.v16_0.rename_subcontracted_quantity -erpnext.patches.v16_0.add_new_stock_entry_types -erpnext.patches.v15_0.set_asset_status_if_not_already_set -erpnext.patches.v15_0.toggle_legacy_controller_for_period_closing -- GitLab