diff --git a/.github/helper/install.sh b/.github/helper/install.sh index 95e95b071534..86104ec09ebf 100644 --- a/.github/helper/install.sh +++ b/.github/helper/install.sh @@ -66,7 +66,7 @@ sed -i 's/schedule:/# schedule:/g' Procfile sed -i 's/socketio:/# socketio:/g' Procfile sed -i 's/redis_socketio:/# redis_socketio:/g' Procfile -bench get-app payments --branch develop +bench get-app "https://github.com/Shllokkk/payments" --branch "payment-gateway-fix" bench get-app erpnext "${GITHUB_WORKSPACE}" if [ "$TYPE" == "server" ]; then bench setup requirements --dev; fi diff --git a/erpnext/accounts/doctype/payment_gateway_account/__init__.py b/erpnext/accounts/doctype/payment_gateway_account/__init__.py deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/erpnext/accounts/doctype/payment_gateway_account/payment_gateway_account.js b/erpnext/accounts/doctype/payment_gateway_account/payment_gateway_account.js deleted file mode 100644 index ab9a50f8285a..000000000000 --- a/erpnext/accounts/doctype/payment_gateway_account/payment_gateway_account.js +++ /dev/null @@ -1,21 +0,0 @@ -// Copyright (c) 2019, Frappe Technologies Pvt. Ltd. and Contributors -// License: GNU General Public License v3. See license.txt - -frappe.ui.form.on("Payment Gateway Account", { - refresh(frm) { - erpnext.utils.check_payments_app(); - if (!frm.doc.__islocal) { - frm.set_df_property("payment_gateway", "read_only", 1); - } - }, - - setup(frm) { - frm.set_query("payment_account", function () { - return { - filters: { - company: frm.doc.company, - }, - }; - }); - }, -}); diff --git a/erpnext/accounts/doctype/payment_gateway_account/payment_gateway_account.json b/erpnext/accounts/doctype/payment_gateway_account/payment_gateway_account.json deleted file mode 100644 index cb258a89741d..000000000000 --- a/erpnext/accounts/doctype/payment_gateway_account/payment_gateway_account.json +++ /dev/null @@ -1,112 +0,0 @@ -{ - "actions": [], - "creation": "2015-12-23 21:31:52.699821", - "doctype": "DocType", - "editable_grid": 1, - "engine": "InnoDB", - "field_order": [ - "payment_gateway", - "payment_channel", - "company", - "is_default", - "column_break_4", - "payment_account", - "currency", - "payment_request_message", - "message", - "message_examples" - ], - "fields": [ - { - "fieldname": "payment_gateway", - "fieldtype": "Link", - "in_list_view": 1, - "label": "Payment Gateway", - "options": "Payment Gateway", - "reqd": 1 - }, - { - "default": "0", - "fieldname": "is_default", - "fieldtype": "Check", - "label": "Is Default" - }, - { - "fieldname": "column_break_4", - "fieldtype": "Column Break" - }, - { - "fieldname": "payment_account", - "fieldtype": "Link", - "in_list_view": 1, - "label": "Payment Account", - "options": "Account", - "reqd": 1 - }, - { - "fetch_from": "payment_account.account_currency", - "fieldname": "currency", - "fieldtype": "Read Only", - "label": "Currency" - }, - { - "depends_on": "eval: doc.payment_channel == 'Email' || (!doc.payment_channel)", - "fieldname": "payment_request_message", - "fieldtype": "Section Break" - }, - { - "default": "Please click on the link below to make your payment", - "fieldname": "message", - "fieldtype": "Small Text", - "label": "Default Payment Request Message" - }, - { - "fieldname": "message_examples", - "fieldtype": "HTML", - "label": "Message Examples", - "options": "
Message Example
\n\n<p> Thank You for being a part of {{ doc.company }}! We hope you are enjoying the service.</p>\n\n<p> Please find enclosed the E Bill statement. The outstanding amount is {{ doc.grand_total }}.</p>\n\n<p> We don't want you to be spending time running around in order to pay for your Bill.
After all, life is beautiful and the time you have in hand should be spent to enjoy it!
So here are our little ways to help you get more time for life! </p>\n\n<a href=\"{{ payment_url }}\"> click here to pay </a>\n\n
\n" - }, - { - "default": "Email", - "fieldname": "payment_channel", - "fieldtype": "Select", - "label": "Payment Channel", - "options": "\nEmail\nPhone\nOther" - }, - { - "fieldname": "company", - "fieldtype": "Link", - "in_list_view": 1, - "label": "Company", - "options": "Company", - "print_hide": 1, - "remember_last_selected_value": 1, - "reqd": 1 - } - ], - "index_web_pages_for_search": 1, - "links": [], - "modified": "2025-07-14 16:49:55.210352", - "modified_by": "Administrator", - "module": "Accounts", - "name": "Payment Gateway Account", - "owner": "Administrator", - "permissions": [ - { - "create": 1, - "delete": 1, - "email": 1, - "export": 1, - "print": 1, - "read": 1, - "report": 1, - "role": "Accounts Manager", - "share": 1, - "write": 1 - } - ], - "row_format": "Dynamic", - "sort_field": "creation", - "sort_order": "DESC", - "states": [] -} diff --git a/erpnext/accounts/doctype/payment_gateway_account/payment_gateway_account.py b/erpnext/accounts/doctype/payment_gateway_account/payment_gateway_account.py deleted file mode 100644 index 7c58949be8be..000000000000 --- a/erpnext/accounts/doctype/payment_gateway_account/payment_gateway_account.py +++ /dev/null @@ -1,50 +0,0 @@ -# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and contributors -# For license information, please see license.txt - - -import frappe -from frappe.model.document import Document - - -class PaymentGatewayAccount(Document): - # begin: auto-generated types - # This code is auto-generated. Do not modify anything in this block. - - from typing import TYPE_CHECKING - - if TYPE_CHECKING: - from frappe.types import DF - - company: DF.Link - currency: DF.ReadOnly | None - is_default: DF.Check - message: DF.SmallText | None - payment_account: DF.Link - payment_channel: DF.Literal["", "Email", "Phone"] - payment_gateway: DF.Link - # end: auto-generated types - - def autoname(self): - abbr = frappe.db.get_value("Company", self.company, "abbr") - self.name = self.payment_gateway + " - " + self.currency + " - " + abbr - - def validate(self): - self.currency = frappe.get_cached_value("Account", self.payment_account, "account_currency") - - self.update_default_payment_gateway() - self.set_as_default_if_not_set() - - def update_default_payment_gateway(self): - if self.is_default: - frappe.db.set_value( - "Payment Gateway Account", - {"is_default": 1, "name": ["!=", self.name], "company": self.company}, - "is_default", - 0, - ) - - def set_as_default_if_not_set(self): - if not frappe.db.exists( - "Payment Gateway Account", {"is_default": 1, "name": ("!=", self.name), "company": self.company} - ): - self.is_default = 1 diff --git a/erpnext/accounts/doctype/payment_gateway_account/payment_gateway_account_dashboard.py b/erpnext/accounts/doctype/payment_gateway_account/payment_gateway_account_dashboard.py deleted file mode 100644 index d0aaee88350c..000000000000 --- a/erpnext/accounts/doctype/payment_gateway_account/payment_gateway_account_dashboard.py +++ /dev/null @@ -1,6 +0,0 @@ -def get_data(): - return { - "fieldname": "payment_gateway_account", - "non_standard_fieldnames": {"Subscription Plan": "payment_gateway"}, - "transactions": [{"items": ["Payment Request"]}, {"items": ["Subscription Plan"]}], - } diff --git a/erpnext/accounts/doctype/payment_gateway_account/test_payment_gateway_account.py b/erpnext/accounts/doctype/payment_gateway_account/test_payment_gateway_account.py deleted file mode 100644 index fe70292144f0..000000000000 --- a/erpnext/accounts/doctype/payment_gateway_account/test_payment_gateway_account.py +++ /dev/null @@ -1,8 +0,0 @@ -# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# See license.txt - -from erpnext.tests.utils import ERPNextTestSuite - - -class TestPaymentGatewayAccount(ERPNextTestSuite): - pass diff --git a/erpnext/accounts/doctype/payment_request/payment_request.js b/erpnext/accounts/doctype/payment_request/payment_request.js index 9696a6bfc2ae..c4bec94e621f 100644 --- a/erpnext/accounts/doctype/payment_request/payment_request.js +++ b/erpnext/accounts/doctype/payment_request/payment_request.js @@ -1,133 +1,208 @@ -cur_frm.add_fetch("payment_gateway_account", "payment_account", "payment_account"); -cur_frm.add_fetch("payment_gateway_account", "payment_gateway", "payment_gateway"); -cur_frm.add_fetch("payment_gateway_account", "message", "message"); - frappe.ui.form.on("Payment Request", { - setup: function (frm) { + setup(frm) { frm.set_query("party_type", function () { return { query: "erpnext.setup.doctype.party_type.party_type.get_party_type", }; }); - frm.set_query("payment_gateway_account", function () { - return { - filters: { - company: frm.doc.company, - }, - }; - }); - }, -}); + if (frm.fields_dict.payment_account) { + frm.set_query("payment_account", function () { + return { + query: "erpnext.accounts.doctype.payment_request.payment_request.get_payment_account", + filters: { + payment_gateway: frm.doc.payment_gateway, + company: frm.doc.company, + }, + }; + }); + } -frappe.ui.form.on("Payment Request", "onload", function (frm, dt, dn) { - if (frm.doc.reference_doctype) { - frappe.call({ - method: "erpnext.accounts.doctype.payment_request.payment_request.get_print_format_list", - args: { ref_doctype: frm.doc.reference_doctype }, - callback: function (r) { - set_field_options("print_format", r.message["print_format"]); - }, - }); - } -}); + if (frm.doc.payment_request_type == "Outward") { + frm.set_query("bank_account", function () { + return { + filters: { + party_type: frm.doc.party_type, + party: frm.doc.party, + }, + }; + }); + } else { + frm.set_query("bank_account", function () { + return { + filters: { + is_company_account: 1, + company: frm.doc.company, + }, + }; + }); + } + }, -frappe.ui.form.on("Payment Request", "refresh", function (frm) { - if (frm.doc.status == "Failed") { - frm.set_intro(__("Failure: {0}", [frm.doc.failed_reason]), "red"); - } - - if ( - frm.doc.payment_request_type == "Inward" && - frm.doc.payment_channel !== "Phone" && - !["Initiated", "Paid"].includes(frm.doc.status) && - !frm.doc.__islocal && - frm.doc.docstatus == 1 - ) { - frm.add_custom_button(__("Resend Payment Email"), function () { + onload(frm) { + if (frm.doc.reference_doctype) { frappe.call({ - method: "erpnext.accounts.doctype.payment_request.payment_request.resend_payment_email", - args: { docname: frm.doc.name }, - freeze: true, - freeze_message: __("Sending"), + method: "erpnext.accounts.doctype.payment_request.payment_request.get_print_format_list", + args: { ref_doctype: frm.doc.reference_doctype }, callback: function (r) { - if (!r.exc) { - frappe.msgprint(__("Message Sent")); - } + set_field_options("print_format", r.message["print_format"]); }, }); - }); - } + } + }, + + refresh(frm) { + if (frm.doc.status == "Failed") { + frm.set_intro(__("Failure: {0}", [frm.doc.failed_reason]), "red"); + } + + if ( + frm.doc.payment_request_type == "Inward" && + frm.doc.payment_channel !== "Phone" && + !["Initiated", "Paid"].includes(frm.doc.status) && + !frm.doc.__islocal && + frm.doc.docstatus == 1 + ) { + frm.add_custom_button(__("Resend Payment Email"), function () { + frappe.call({ + method: "erpnext.accounts.doctype.payment_request.payment_request.resend_payment_email", + args: { docname: frm.doc.name }, + freeze: true, + freeze_message: __("Sending"), + callback: function (r) { + if (!r.exc) { + frappe.msgprint(__("Message Sent")); + } + }, + }); + }); + } + + if ( + frm.doc.payment_request_type == "Outward" && + ["Initiated", "Partially Paid"].includes(frm.doc.status) + ) { + frm.add_custom_button(__("Create Payment Entry"), function () { + frappe.call({ + method: "erpnext.accounts.doctype.payment_request.payment_request.make_payment_entry", + args: { docname: frm.doc.name }, + freeze: true, + callback: function (r) { + if (!r.exc) { + frappe.model.sync(r.message); + frappe.set_route("Form", r.message.doctype, r.message.name); + } + }, + }); + }).addClass("btn-primary"); + } + }, + + bank_account(frm) { + if (frm.doc.bank_account) { + frm.set_value("payment_gateway", null); + frm.set_value("payment_account", null); + frm.set_value("payment_channel", null); + frm.set_value("phone_number", null); + } + }, + + payment_gateway(frm) { + if (frm.doc.payment_gateway) { + frm.set_value("bank_account", null); + } + frm.set_value("payment_account", null); + frm.set_value("payment_channel", null); + frm.set_value("phone_number", null); + }, + + payment_account(frm) { + if (!frm.doc.payment_gateway || !frm.doc.payment_account || !frm.doc.company) { + frm.set_value("payment_channel", null); + return; + } + + frappe.db.get_value( + "Payment Gateway Account", + { + parent: frm.doc.payment_gateway, + payment_account: frm.doc.payment_account, + company: frm.doc.company, + }, + "payment_channel", + (r) => { + if (frm.fields_dict.payment_channel) { + frm.set_value("payment_channel", r?.payment_channel || null); + } + }, + "Payment Gateway" + ); + }, - if ( - frm.doc.payment_request_type == "Outward" && - ["Initiated", "Partially Paid"].includes(frm.doc.status) - ) { - frm.add_custom_button(__("Create Payment Entry"), function () { + is_a_subscription(frm) { + if (frm.fields_dict.payment_gateway) { + frm.toggle_reqd("payment_gateway", frm.doc.is_a_subscription); + } + + frm.toggle_reqd("subscription_plans", frm.doc.is_a_subscription); + + if (frm.doc.is_a_subscription && frm.doc.reference_doctype && frm.doc.reference_name) { frappe.call({ - method: "erpnext.accounts.doctype.payment_request.payment_request.make_payment_entry", - args: { docname: frm.doc.name }, + method: "erpnext.accounts.doctype.payment_request.payment_request.get_subscription_details", + args: { + reference_doctype: frm.doc.reference_doctype, + reference_name: frm.doc.reference_name, + }, freeze: true, - callback: function (r) { - if (!r.exc) { - var doc = frappe.model.sync(r.message); - frappe.set_route("Form", r.message.doctype, r.message.name); + callback: function (data) { + if (!data.exc) { + $.each(data.message || [], function (i, v) { + var d = frappe.model.add_child( + frm.doc, + "Subscription Plan Detail", + "subscription_plans" + ); + + d.qty = v.qty; + d.plan = v.plan; + }); + + frm.refresh_field("subscription_plans"); } }, }); - }).addClass("btn-primary"); - } -}); + } + }, -frappe.ui.form.on("Payment Request", "is_a_subscription", function (frm) { - frm.toggle_reqd("payment_gateway_account", frm.doc.is_a_subscription); - frm.toggle_reqd("subscription_plans", frm.doc.is_a_subscription); - - if (frm.doc.is_a_subscription && frm.doc.reference_doctype && frm.doc.reference_name) { - frappe.call({ - method: "erpnext.accounts.doctype.payment_request.payment_request.get_subscription_details", - args: { reference_doctype: frm.doc.reference_doctype, reference_name: frm.doc.reference_name }, - freeze: true, - callback: function (data) { - if (!data.exc) { - $.each(data.message || [], function (i, v) { - var d = frappe.model.add_child( - frm.doc, - "Subscription Plan Detail", - "subscription_plans" - ); - d.qty = v.qty; - d.plan = v.plan; - }); - frm.refresh_field("subscription_plans"); - } - }, - }); - } -}); + calculate_total_amount_by_selected_rows(frm) { + if (frm.doc.docstatus !== 0) { + frappe.msgprint(__("Cannot fetch selected rows for submitted Payment Request")); + return; + } + + const selected = frm.get_selected()?.payment_reference || []; -frappe.ui.form.on("Payment Request", "calculate_total_amount_by_selected_rows", function (frm) { - if (frm.doc.docstatus !== 0) { - frappe.msgprint(__("Cannot fetch selected rows for submitted Payment Request")); - return; - } - const selected = frm.get_selected()?.payment_reference || []; - if (!selected.length) { - frappe.throw(__("No rows selected")); - } - let total = 0; - selected.forEach((name) => { - const row = frm.doc.payment_reference.find((d) => d.name === name); - if (row) { - row.manually_selected = 1; - - total += row.amount; + if (!selected.length) { + frappe.throw(__("No rows selected")); } - }); - frm.doc.payment_reference.forEach((row) => { - row.auto_selected = 0; - }); - frm.set_value("grand_total", total); - frm.refresh_field("grand_total"); - frm.save(); + + let total = 0; + + selected.forEach((name) => { + const row = frm.doc.payment_reference.find((d) => d.name === name); + + if (row) { + row.manually_selected = 1; + total += row.amount; + } + }); + + frm.doc.payment_reference.forEach((row) => { + row.auto_selected = 0; + }); + + frm.set_value("grand_total", total); + frm.refresh_field("grand_total"); + frm.save(); + }, }); diff --git a/erpnext/accounts/doctype/payment_request/payment_request.json b/erpnext/accounts/doctype/payment_request/payment_request.json index 17d8b74aa14e..cf53dd289fc0 100644 --- a/erpnext/accounts/doctype/payment_request/payment_request.json +++ b/erpnext/accounts/doctype/payment_request/payment_request.json @@ -19,6 +19,7 @@ "column_break_4", "reference_doctype", "reference_name", + "make_sales_invoice", "payment_reference_section", "payment_reference", "transaction_details", @@ -43,28 +44,20 @@ "cost_center", "dimension_col_break", "project", - "recipient_and_message", + "recipient_details", "print_format", "email_to", "subject", "column_break_9", - "payment_gateway_account", "status", - "make_sales_invoice", - "section_break_10", + "message_details", "message", "message_examples", "mute_email", - "section_break_7", - "payment_gateway", - "payment_account", - "payment_channel", - "payment_order", + "section_break_jfix", "amended_from", - "column_break_pnyv", - "payment_url", - "column_break_iiuv", - "phone_number" + "column_break_ruax", + "payment_order" ], "fields": [ { @@ -73,6 +66,7 @@ "fieldtype": "Select", "label": "Payment Request Type", "options": "Outward\nInward", + "read_only": 1, "reqd": 1 }, { @@ -111,13 +105,15 @@ "fieldname": "party_type", "fieldtype": "Link", "label": "Party Type", - "options": "DocType" + "options": "DocType", + "read_only": 1 }, { "fieldname": "party", "fieldtype": "Dynamic Link", "label": "Party", - "options": "party_type" + "options": "party_type", + "read_only": 1 }, { "fieldname": "column_break_4", @@ -192,7 +188,7 @@ "options": "Subscription Plan Detail" }, { - "collapsible": 1, + "depends_on": "eval: !doc.payment_gateway", "fieldname": "bank_account_details", "fieldtype": "Section Break", "label": "Bank Account Details" @@ -270,13 +266,6 @@ "options": "Project" }, { - "depends_on": "eval: doc.payment_request_type == 'Inward'", - "fieldname": "recipient_and_message", - "fieldtype": "Section Break", - "label": "Recipient Message And Payment Details" - }, - { - "depends_on": "eval: doc.payment_channel == \"Email\" || (!doc.payment_channel)", "fieldname": "print_format", "fieldtype": "Select", "label": "Print Format" @@ -288,7 +277,6 @@ "label": "To" }, { - "depends_on": "eval: doc.payment_channel == \"Email\" || (!doc.payment_channel)", "fieldname": "subject", "fieldtype": "Data", "in_global_search": 1, @@ -298,13 +286,6 @@ "fieldname": "column_break_9", "fieldtype": "Column Break" }, - { - "depends_on": "eval: doc.payment_request_type == 'Inward'", - "fieldname": "payment_gateway_account", - "fieldtype": "Link", - "label": "Payment Gateway Account", - "options": "Payment Gateway Account" - }, { "default": "Draft", "fieldname": "status", @@ -325,18 +306,11 @@ "read_only": 1 }, { - "depends_on": "eval: doc.payment_request_type == 'Inward' || doc.payment_channel == \"Email\" || (!doc.payment_channel)", - "fieldname": "section_break_10", - "fieldtype": "Section Break" - }, - { - "depends_on": "eval: doc.payment_channel == \"Email\" || (!doc.payment_channel)", "fieldname": "message", "fieldtype": "Text", "label": "Message" }, { - "depends_on": "eval: doc.payment_channel == \"Email\" || (!doc.payment_channel)", "fieldname": "message_examples", "fieldtype": "HTML", "label": "Message Examples", @@ -354,44 +328,6 @@ "read_only": 1, "report_hide": 1 }, - { - "fieldname": "payment_url", - "fieldtype": "Data", - "label": "Payment URL", - "length": 500, - "options": "URL", - "read_only": 1 - }, - { - "collapsible": 1, - "collapsible_depends_on": "doc.payment_gateway_account", - "depends_on": "eval: doc.payment_request_type == 'Inward'", - "fieldname": "section_break_7", - "fieldtype": "Section Break", - "label": "Payment Gateway Details" - }, - { - "fetch_from": "payment_gateway_account.payment_gateway", - "fieldname": "payment_gateway", - "fieldtype": "Read Only", - "label": "Payment Gateway" - }, - { - "fetch_from": "payment_gateway_account.payment_account", - "fieldname": "payment_account", - "fieldtype": "Read Only", - "label": "Payment Account", - "read_only": 1 - }, - { - "depends_on": "eval: doc.payment_channel==\"Phone\"", - "fetch_from": "payment_gateway_account.payment_channel", - "fieldname": "payment_channel", - "fieldtype": "Select", - "label": "Payment Channel", - "options": "\nEmail\nPhone\nOther", - "read_only": 1 - }, { "fieldname": "payment_order", "fieldtype": "Link", @@ -435,10 +371,6 @@ "options": "Company", "read_only": 1 }, - { - "fieldname": "column_break_pnyv", - "fieldtype": "Column Break" - }, { "fieldname": "party_account_currency", "fieldtype": "Link", @@ -452,15 +384,6 @@ "label": "Party Name", "read_only": 1 }, - { - "fieldname": "column_break_iiuv", - "fieldtype": "Column Break" - }, - { - "fieldname": "phone_number", - "fieldtype": "Data", - "label": "Phone Number" - }, { "fieldname": "payment_reference_section", "fieldtype": "Section Break" @@ -471,6 +394,28 @@ "label": "Payment Reference", "options": "Payment Reference", "read_only": 1 + }, + { + "fieldname": "section_break_jfix", + "fieldtype": "Section Break" + }, + { + "fieldname": "column_break_ruax", + "fieldtype": "Column Break" + }, + { + "collapsible": 1, + "depends_on": "eval: !doc.payment_channel ? doc.payment_request_type == \"Inward\" : doc.payment_channel == \"Email\"", + "fieldname": "recipient_details", + "fieldtype": "Section Break", + "label": "Recipient Details" + }, + { + "collapsible": 1, + "depends_on": "eval: !doc.payment_channel ? doc.payment_request_type == \"Inward\" : doc.payment_channel == \"Email\"", + "fieldname": "message_details", + "fieldtype": "Section Break", + "label": "Message Details" } ], "grid_page_length": 50, @@ -478,7 +423,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2026-02-27 19:11:03.308896", + "modified": "2026-05-06 01:25:11.783984", "modified_by": "Administrator", "module": "Accounts", "name": "Payment Request", diff --git a/erpnext/accounts/doctype/payment_request/payment_request.py b/erpnext/accounts/doctype/payment_request/payment_request.py index a74f3808142a..b9933537cfdf 100644 --- a/erpnext/accounts/doctype/payment_request/payment_request.py +++ b/erpnext/accounts/doctype/payment_request/payment_request.py @@ -3,6 +3,7 @@ import frappe from frappe import _ from frappe.model.document import Document +from frappe.query_builder import DocType from frappe.query_builder.functions import Sum from frappe.utils import flt, nowdate from frappe.utils.background_jobs import enqueue @@ -95,15 +96,9 @@ class PaymentRequest(Document): party_account_currency: DF.Link | None party_name: DF.Data | None party_type: DF.Link | None - payment_account: DF.ReadOnly | None - payment_channel: DF.Literal["", "Email", "Phone", "Other"] - payment_gateway: DF.ReadOnly | None - payment_gateway_account: DF.Link | None payment_order: DF.Link | None payment_reference: DF.Table[PaymentReference] payment_request_type: DF.Literal["Outward", "Inward"] - payment_url: DF.Data | None - phone_number: DF.Data | None print_format: DF.Literal[None] project: DF.Link | None reference_doctype: DF.Link | None @@ -132,11 +127,16 @@ def validate(self): if self.get("__islocal"): self.status = "Draft" self.validate_reference_document() + self.validate_payment_flows() self.validate_against_payment_reference() self.validate_payment_request_amount() - # self.validate_currency() + self.validate_currency() self.validate_subscription_details() + def validate_payment_flows(self): + if self.bank_account and self.payment_gateway: + frappe.throw(_("Select either Bank Account or Payment Gateway.")) + def validate_against_payment_reference(self): if not self.payment_reference: return @@ -190,37 +190,45 @@ def validate_payment_request_amount(self): ) def validate_currency(self): + if not self.get("payment_account"): + return + ref_doc = frappe.get_doc(self.reference_doctype, self.reference_name) if self.payment_account and ref_doc.currency != frappe.get_cached_value( "Account", self.payment_account, "account_currency" ): - frappe.throw(_("Transaction currency must be same as Payment Gateway currency")) + frappe.throw(_("Transaction currency must be same as Payment Account currency")) def validate_subscription_details(self): - if self.is_a_subscription: - amount = 0 - for subscription_plan in self.subscription_plans: - payment_gateway = frappe.db.get_value( - "Subscription Plan", subscription_plan.plan, "payment_gateway" - ) - if payment_gateway != self.payment_gateway_account: - frappe.throw( - _( - "The payment gateway account in plan {0} is different from the payment gateway account in this payment request" - ).format(subscription_plan.name) - ) - - rate = get_plan_rate(subscription_plan.plan, quantity=subscription_plan.qty) + if not self.is_a_subscription: + return - amount += rate + if not self.get("payment_gateway"): + return - if amount != self.grand_total: - frappe.msgprint( + amount = 0 + for subscription_plan in self.subscription_plans: + payment_account = frappe.db.get_value( + "Subscription Plan", subscription_plan.plan, "payment_account" + ) + if payment_account != self.payment_account: + frappe.throw( _( - "The amount of {0} set in this payment request is different from the calculated amount of all payment plans: {1}. Make sure this is correct before submitting the document." - ).format(self.grand_total, amount) + "The payment account in plan {0} is different from the payment account in this payment request" + ).format(subscription_plan.name) ) + rate = get_plan_rate(subscription_plan.plan, quantity=subscription_plan.qty) + + amount += rate + + if amount != self.grand_total: + frappe.msgprint( + _( + "The amount of {0} set in this payment request is different from the calculated amount of all payment plans: {1}. Make sure this is correct before submitting the document." + ).format(self.grand_total, amount) + ) + def before_submit(self): if ( self.currency != self.party_account_currency @@ -248,17 +256,16 @@ def before_submit(self): elif self.payment_request_type == "Inward": self.status = "Requested" - if self.payment_request_type == "Inward" and self.payment_gateway: + if self.payment_request_type == "Inward": + if not getattr(self, "payment_gateway", None): + return + if _is_v2_gateway(self.payment_gateway): - # New PaymentController flow (v2 gateways) self._process_v2_gateway() - elif self.payment_channel == "Phone": - # Legacy v1 phone payment - phone payments do not generate email/link - # communications as the payment is initiated directly via phone channel + elif getattr(self, "payment_channel", None) == "Phone": self.request_phone_payment() return else: - # Legacy v1 URL payment self.set_payment_request_url() if not (self.mute_email or self.flags.mute_email): @@ -403,6 +410,9 @@ def _get_address_fields(self, address_name): } def request_phone_payment(self): + if not self.get("payment_gateway"): + return + controller = _get_payment_gateway_controller(self.payment_gateway) request_amount = self.get_request_amount() @@ -450,21 +460,30 @@ def make_invoice(self): si = si.insert(ignore_permissions=True) si.submit() - def payment_gateway_validation(self): + def validate_payment_gateway(self): try: controller = _get_payment_gateway_controller(self.payment_gateway) - if hasattr(controller, "on_payment_request_submission"): - return controller.on_payment_request_submission(self) - else: - return True except Exception: return False + if hasattr(controller, "on_payment_request_submission"): + return controller.on_payment_request_submission(self) + + return True + def set_payment_request_url(self): - if self.payment_account and self.payment_gateway and self.payment_gateway_validation(): - self.payment_url = self.get_payment_url() + if not self.get("payment_gateway"): + return + + if not self.validate_payment_gateway(): + return + + self.payment_url = self.get_payment_url() def get_payment_url(self): + if not self.get("payment_gateway"): + return + if self.reference_doctype != "Fees": data = frappe.db.get_value( self.reference_doctype, self.reference_name, ["company", "customer_name"], as_dict=1 @@ -476,6 +495,7 @@ def get_payment_url(self): data.update({"company": frappe.defaults.get_defaults().company}) controller = _get_payment_gateway_controller(self.payment_gateway) + controller.validate_transaction_currency(self.currency) if hasattr(controller, "validate_minimum_transaction_amount"): @@ -497,7 +517,7 @@ def get_payment_url(self): ) def set_as_paid(self): - if self.payment_channel == "Phone": + if hasattr(self, "payment_channel") and self.payment_channel == "Phone": self.db_set({"status": "Paid", "outstanding_amount": 0}) else: @@ -532,12 +552,12 @@ def create_payment_entry(self, submit=True): exchange_rate = ref_doc.get("conversion_rate") bank_amount = flt(self.outstanding_amount / exchange_rate, self.precision("grand_total")) - # outstanding amount is already in Part's account currency + # outstanding amount is already in Party's account currency payment_entry = get_payment_entry( self.reference_doctype, self.reference_name, party_amount=party_amount, - bank_account=self.payment_account, + bank_account=self.account or self.payment_account, # company bank account or payment account bank_amount=bank_amount, created_from_payment_request=True, ) @@ -583,7 +603,7 @@ def create_payment_entry(self, submit=True): return payment_entry def send_email(self): - """send email with payment link""" + """send email with optional payment link""" email_args = { "recipients": self.email_to, "sender": None, @@ -609,11 +629,11 @@ def send_email(self): ) def get_message(self): - """return message with payment gateway link""" + """return message with optional payment gateway link""" context = { "doc": frappe.get_doc(self.reference_doctype, self.reference_name), - "payment_url": self.payment_url, + "payment_url": self.payment_url if hasattr(self, "payment_url") else "", "payment_request": self, } @@ -737,7 +757,7 @@ def make_payment_request(**args): if not args.get("company"): args.company = ref_doc.company - gateway_account = get_gateway_details(args) or frappe._dict() + gateway_account = get_payment_gateway_account_details(args) or frappe._dict() # Schedule-based PRs are allowed only if no Payment Entry exists for this document. # Any existing Payment Entry forces legacy (amount-based) flow. @@ -791,7 +811,9 @@ def make_payment_request(**args): if selected_payment_schedules and not has_payment_entry: grand_total = sum(row.get("payment_amount") for row in selected_payment_schedules) else: - grand_total = get_amount(ref_doc, gateway_account.get("payment_account")) + grand_total = get_amount( + ref_doc, args.get("payment_account") or gateway_account.get("payment_account") + ) if not grand_total: frappe.throw(_("Payment Entry is already created")) @@ -857,10 +879,6 @@ def validate_and_calculate_grand_total(grand_total, existing_payment_request_amo pr.update( { - "payment_gateway_account": gateway_account.get("name"), - "payment_gateway": gateway_account.get("payment_gateway"), - "payment_account": gateway_account.get("payment_account"), - "payment_channel": gateway_account.get("payment_channel"), "payment_request_type": args.get("payment_request_type"), "currency": ref_doc.currency, "party_account_currency": party_account_currency, @@ -874,21 +892,35 @@ def validate_and_calculate_grand_total(grand_total, existing_payment_request_amo "company": ref_doc.get("company"), "party_type": party_type, "party": args.get("party") or ref_doc.get("customer"), - "bank_account": bank_account, + "bank_account": bank_account + if args.get("payment_request_type") == "Outward" + else args.get("bank_account"), "party_name": args.get("party_name") or ref_doc.get("customer_name"), "make_sales_invoice": ( args.make_sales_invoice # new standard or args.order_type == "Shopping Cart" # compat for webshop app ), "mute_email": ( - args.mute_email # new standard - or args.order_type == "Shopping Cart" # compat for webshop app - or gateway_account.get("payment_channel", "Email") != "Email" + args.mute_email + or args.order_type == "Shopping Cart" + or ( + gateway_account.get("payment_channel") + and gateway_account.get("payment_channel") != "Email" + ) ), - "phone_number": args.get("phone_number") if args.get("phone_number") else None, } ) + if args.get("payment_gateway") or gateway_account.get("payment_account"): + pr.update( + { + "payment_gateway": args.get("payment_gateway"), + "payment_account": args.get("payment_account") or gateway_account.get("payment_account"), + "payment_channel": gateway_account.get("payment_channel"), + "phone_number": args.get("phone_number"), + } + ) + if selected_payment_schedules: apply_payment_references(pr, payment_reference) @@ -970,44 +1002,47 @@ def get_existing_payment_entry(ref_docname): def get_amount(ref_doc, payment_account=None): """get amount based on doctype""" grand_total = 0 - dt = ref_doc.doctype + + def currency_adjust(amount): + if ref_doc.party_account_currency == ref_doc.currency: + return amount + return flt(amount / ref_doc.conversion_rate) + + # so/po if dt in ["Sales Order", "Purchase Order"]: advance_amount = flt(ref_doc.advance_paid) + if ref_doc.party_account_currency != ref_doc.currency: - advance_amount = flt(flt(ref_doc.advance_paid) / ref_doc.conversion_rate) + advance_amount = currency_adjust(advance_amount) grand_total = (flt(ref_doc.rounded_total) or flt(ref_doc.grand_total)) - advance_amount + # si/pi elif dt in ["Sales Invoice", "Purchase Invoice"]: - if ( - dt == "Sales Invoice" - and ref_doc.is_pos - and ref_doc.payments - and any( - [ - payment.type == "Phone" and payment.account == payment_account - for payment in ref_doc.payments - ] - ) + pos_phone_match = ( + dt == "Sales Invoice" and ref_doc.is_pos and ref_doc.get("payments") and payment_account + ) + + if pos_phone_match and any( + p.type == "Phone" and p.account == payment_account for p in ref_doc.payments ): grand_total = sum( - [ - payment.amount - for payment in ref_doc.payments - if payment.type == "Phone" and payment.account == payment_account - ] + p.amount for p in ref_doc.payments if p.type == "Phone" and p.account == payment_account ) + else: - if ref_doc.party_account_currency == ref_doc.currency: - grand_total = flt(ref_doc.outstanding_amount) - else: - grand_total = flt(flt(ref_doc.outstanding_amount) / ref_doc.conversion_rate) + grand_total = currency_adjust(flt(ref_doc.outstanding_amount)) + + # pos invoice elif dt == "POS Invoice": - for pay in ref_doc.payments: - if pay.type == "Phone" and pay.account == payment_account: - grand_total = pay.amount - break + if ref_doc.get("payments") and payment_account: + for pay in ref_doc.payments: + if pay.type == "Phone" and pay.account == payment_account: + grand_total = pay.amount + break + + # fees elif dt == "Fees": grand_total = ref_doc.outstanding_amount @@ -1081,19 +1116,30 @@ def get_existing_payment_request_amount(ref_doc, statuses: list | None = None) - return os_amount_in_transaction_currency -def get_gateway_details(args): # nosemgrep +def get_payment_gateway_account_details(args): # nosemgrep """ - Return gateway and payment account of default payment gateway + Fetch details of the specified or default Payment Gateway Account """ - gateway_account = args.get("payment_gateway_account", {"is_default": 1, "company": args.company}) - return get_payment_gateway_account(gateway_account) + if not args.get("payment_gateway"): + return + + filters = { + "parent": args.get("payment_gateway"), + "parenttype": "Payment Gateway", + } + + if args.get("company"): + filters["company"] = args.get("company") + if args.get("payment_account"): + filters["payment_account"] = args.get("payment_account") + else: + filters["is_default"] = 1 -def get_payment_gateway_account(filter): return frappe.db.get_value( "Payment Gateway Account", - filter, - ["name", "payment_gateway", "payment_account", "payment_channel", "message"], + filters, + ["payment_account", "payment_channel", "message"], as_dict=1, ) @@ -1355,3 +1401,37 @@ def get_existing_payment_references(reference_name): ).run(as_dict=True) return result + + +@frappe.whitelist() +@frappe.validate_and_sanitize_search_inputs +def get_payment_account(doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict): + PGA = DocType("Payment Gateway Account") + get_payment_account_query = frappe.qb.from_(PGA).select(PGA.payment_account) + + condition_list = [] + + if filters: + payment_gateway = filters.get("payment_gateway") + company = filters.get("company") + + if not payment_gateway: + return [] + + condition_list.append(PGA.parent == payment_gateway) + + if company: + condition_list.append(PGA.company == company) + else: + return [] + + for condition in condition_list: + get_payment_account_query = get_payment_account_query.where(condition) + + get_payment_account_query = get_payment_account_query.where(PGA.payment_account.like(f"%{txt}%")) + get_payment_account_query = get_payment_account_query.limit(page_len) + get_payment_account_query = get_payment_account_query.offset(start) + + result = get_payment_account_query.run() + + return result diff --git a/erpnext/accounts/doctype/payment_request/test_payment_request.py b/erpnext/accounts/doctype/payment_request/test_payment_request.py index 6694df135665..1e5f94a31c6d 100644 --- a/erpnext/accounts/doctype/payment_request/test_payment_request.py +++ b/erpnext/accounts/doctype/payment_request/test_payment_request.py @@ -22,42 +22,48 @@ PAYMENT_URL = "https://example.com/payment" payment_gateways = [ - {"doctype": "Payment Gateway", "gateway": "_Test Gateway"}, - {"doctype": "Payment Gateway", "gateway": "_Test Gateway Phone"}, - {"doctype": "Payment Gateway", "gateway": "_Test Gateway Other"}, -] - -payment_method = [ { - "doctype": "Payment Gateway Account", - "is_default": 1, - "payment_gateway": "_Test Gateway", - "payment_account": "_Test Bank - _TC", - "currency": "INR", - "company": "_Test Company", - }, - { - "doctype": "Payment Gateway Account", - "payment_gateway": "_Test Gateway", - "payment_account": "_Test Bank USD - _TC", - "currency": "USD", - "company": "_Test Company", + "doctype": "Payment Gateway", + "gateway_name": "_Test Gateway", + "payment_gateway_account": [ + { + "is_default": 1, + "payment_account": "_Test Bank - _TC", + "currency": "INR", + "company": "_Test Company", + }, + { + "payment_account": "_Test Bank USD - _TC", + "currency": "USD", + "company": "_Test Company", + }, + ], }, { - "doctype": "Payment Gateway Account", - "payment_gateway": "_Test Gateway Other", - "payment_account": "_Test Bank USD - _TC", - "payment_channel": "Other", - "currency": "USD", - "company": "_Test Company", + "doctype": "Payment Gateway", + "gateway_name": "_Test Gateway Other", + "payment_gateway_account": [ + { + "is_default": 1, + "payment_account": "_Test Bank USD - _TC", + "payment_channel": "Other", + "currency": "USD", + "company": "_Test Company", + } + ], }, { - "doctype": "Payment Gateway Account", - "payment_gateway": "_Test Gateway Phone", - "payment_account": "_Test Bank USD - _TC", - "payment_channel": "Phone", - "currency": "USD", - "company": "_Test Company", + "doctype": "Payment Gateway", + "gateway_name": "_Test Gateway Phone", + "payment_gateway_account": [ + { + "is_default": 1, + "payment_account": "_Test Bank USD - _TC", + "payment_channel": "Phone", + "currency": "USD", + "company": "_Test Company", + } + ], }, ] @@ -65,21 +71,9 @@ class TestPaymentRequest(ERPNextTestSuite): def setUp(self): for payment_gateway in payment_gateways: - if not frappe.db.get_value("Payment Gateway", payment_gateway["gateway"], "name"): + if not frappe.db.exists("Payment Gateway", payment_gateway["gateway_name"]): frappe.get_doc(payment_gateway).insert(ignore_permissions=True) - for method in payment_method: - if not frappe.db.get_value( - "Payment Gateway Account", - { - "payment_gateway": method["payment_gateway"], - "currency": method["currency"], - "company": method["company"], - }, - "name", - ): - frappe.get_doc(method).insert(ignore_permissions=True) - send_email = patch( "erpnext.accounts.doctype.payment_request.payment_request.PaymentRequest.send_email", return_value=None, @@ -99,6 +93,31 @@ def setUp(self): self._get_payment_gateway_controller = _get_payment_gateway_controller.start() self.addCleanup(_get_payment_gateway_controller.stop) + def test_validate_payment_flows(self): + bank = frappe.new_doc("Bank") + bank.bank_name = "_Test Bank" + bank.insert() + + bank_account = frappe.new_doc("Bank Account") + bank_account.bank = bank.name + bank_account.account_name = "_Test Bank Account" + bank_account.insert() + + so = make_sales_order(do_not_save=True) + so.save() + pr = make_payment_request( + dt="Sales Order", + dn=so.name, + payment_gateway="_Test Gateway", + payment_account="_Test Bank - _TC", + return_doc=1, + ) + + pr.bank_account = bank_account.name + self.assertRaisesRegex( + frappe.ValidationError, "Select either Bank Account or Payment Gateway.", pr.save + ) + def test_payment_request_linkings(self): so_inr = make_sales_order(currency="INR", do_not_save=True) so_inr.disable_rounded_total = 1 @@ -108,7 +127,8 @@ def test_payment_request_linkings(self): dt="Sales Order", dn=so_inr.name, recipient_id="saurabh@erpnext.com", - payment_gateway_account="_Test Gateway - INR - _TC", + payment_gateway="_Test Gateway", + payment_account="_Test Bank - _TC", ) self.assertEqual(pr.reference_doctype, "Sales Order") @@ -117,12 +137,18 @@ def test_payment_request_linkings(self): conversion_rate = get_exchange_rate("USD", "INR") - si_usd = create_sales_invoice(currency="USD", conversion_rate=conversion_rate) + si_usd = create_sales_invoice( + customer="_Test Customer 1", + currency="USD", + conversion_rate=conversion_rate, + debit_to="_Test Receivable USD - _TC", + ) pr = make_payment_request( dt="Sales Invoice", dn=si_usd.name, recipient_id="saurabh@erpnext.com", - payment_gateway_account="_Test Gateway - USD - _TC", + payment_gateway="_Test Gateway", + payment_account="_Test Bank USD - _TC", ) self.assertEqual(pr.reference_doctype, "Sales Invoice") @@ -135,7 +161,8 @@ def test_payment_channels(self): pr = make_payment_request( dt="Sales Order", dn=so.name, - payment_gateway_account="_Test Gateway Other - USD - _TC", + payment_gateway="_Test Gateway Other", + payment_account="_Test Bank USD - _TC", submit_doc=True, return_doc=True, ) @@ -150,7 +177,8 @@ def test_payment_channels(self): pr = make_payment_request( dt="Sales Order", dn=so.name, - payment_gateway_account="_Test Gateway - USD - _TC", # email channel + payment_gateway="_Test Gateway", + payment_account="_Test Bank USD - _TC", submit_doc=False, return_doc=True, ) @@ -168,7 +196,8 @@ def test_payment_channels(self): pr = make_payment_request( dt="Sales Order", dn=so.name, - payment_gateway_account="_Test Gateway Phone - USD - _TC", + payment_gateway="_Test Gateway Phone", + payment_account="_Test Bank USD - _TC", submit_doc=True, return_doc=True, ) @@ -185,7 +214,8 @@ def test_payment_channels(self): pr = make_payment_request( dt="Sales Order", dn=so.name, - payment_gateway_account="_Test Gateway - USD - _TC", # email channel + payment_gateway="_Test Gateway", + payment_account="_Test Bank USD - _TC", submit_doc=True, return_doc=True, ) @@ -206,7 +236,8 @@ def test_payment_channels(self): pr = make_payment_request( dt="Sales Order", dn=so.name, - payment_gateway_account="_Test Gateway - USD - _TC", # email channel + payment_gateway="_Test Gateway", + payment_account="_Test Bank USD - _TC", make_sales_invoice=True, mute_email=True, submit_doc=True, @@ -237,7 +268,8 @@ def test_payment_entry_against_purchase_invoice(self): party="_Test Supplier USD", recipient_id="user@example.com", mute_email=1, - payment_gateway_account="_Test Gateway - USD - _TC", + payment_gateway="_Test Gateway", + payment_account="_Test Bank USD - _TC", submit_doc=1, return_doc=1, ) @@ -262,7 +294,8 @@ def test_multiple_payment_entry_against_purchase_invoice(self): dn=purchase_invoice.name, recipient_id="user@example.com", mute_email=1, - payment_gateway_account="_Test Gateway - USD - _TC", + payment_gateway="_Test Gateway", + payment_account="_Test Bank USD - _TC", return_doc=1, ) @@ -281,7 +314,8 @@ def test_multiple_payment_entry_against_purchase_invoice(self): dn=purchase_invoice.name, recipient_id="user@example.com", mute_email=1, - payment_gateway_account="_Test Gateway - USD - _TC", + payment_gateway="_Test Gateway", + payment_account="_Test Bank USD - _TC", return_doc=1, ) @@ -305,7 +339,8 @@ def test_payment_entry(self): dn=so_inr.name, recipient_id="saurabh@erpnext.com", mute_email=1, - payment_gateway_account="_Test Gateway - INR - _TC", + payment_gateway="_Test Gateway", + payment_account="_Test Bank - _TC", submit_doc=1, return_doc=1, ) @@ -327,7 +362,8 @@ def test_payment_entry(self): dn=si_usd.name, recipient_id="saurabh@erpnext.com", mute_email=1, - payment_gateway_account="_Test Gateway - USD - _TC", + payment_gateway="_Test Gateway", + payment_account="_Test Bank USD - _TC", submit_doc=1, return_doc=1, ) @@ -371,7 +407,8 @@ def test_status(self): dn=si_usd.name, recipient_id="saurabh@erpnext.com", mute_email=1, - payment_gateway_account="_Test Gateway - USD - _TC", + payment_gateway="_Test Gateway", + payment_account="_Test Bank USD - _TC", submit_doc=1, return_doc=1, ) @@ -983,15 +1020,8 @@ class TestPaymentRequestV2Gateway(ERPNextTestSuite): def setUp(self): """Set up payment gateway fixtures for flow tests.""" for payment_gateway in payment_gateways: - if not frappe.db.get_value("Payment Gateway", payment_gateway["gateway"], "name"): + if not frappe.db.exists("Payment Gateway", payment_gateway["gateway_name"]): frappe.get_doc(payment_gateway).insert(ignore_permissions=True) - for method in payment_method: - if not frappe.db.get_value( - "Payment Gateway Account", - {"payment_gateway": method["payment_gateway"], "currency": method["currency"]}, - "name", - ): - frappe.get_doc(method).insert(ignore_permissions=True) def _mock_payments_modules(self, is_v2_gateway_return_value): """Helper to mock both payments and payments.utils modules. @@ -1237,7 +1267,8 @@ def test_v2_gateway_uses_process_v2_gateway(self): dt="Sales Order", dn=so.name, recipient_id="test@example.com", - payment_gateway_account="_Test Gateway - INR - _TC", + payment_gateway="_Test Gateway", + payment_account="_Test Bank - _TC", mute_email=True, submit_doc=True, return_doc=True, @@ -1276,7 +1307,8 @@ def test_v1_gateway_uses_legacy_flow(self): dt="Sales Order", dn=so.name, recipient_id="test@example.com", - payment_gateway_account="_Test Gateway - INR - _TC", + payment_gateway="_Test Gateway", + payment_account="_Test Bank - _TC", mute_email=True, submit_doc=True, return_doc=True, @@ -1311,8 +1343,8 @@ def test_process_v2_gateway_handles_initiate_failure(self): dt="Sales Order", dn=so.name, recipient_id="test@example.com", - payment_gateway_account="_Test Gateway - INR - _TC", - mute_email=True, + payment_gateway="_Test Gateway", + payment_account="_Test Bank - _TC", submit_doc=False, return_doc=True, ) @@ -1345,7 +1377,8 @@ def test_process_v2_gateway_handles_none_psl(self): dt="Sales Order", dn=so.name, recipient_id="test@example.com", - payment_gateway_account="_Test Gateway - INR - _TC", + payment_gateway="_Test Gateway", + payment_account="_Test Bank - _TC", mute_email=True, submit_doc=False, return_doc=True, @@ -1376,7 +1409,8 @@ def test_process_v2_gateway_sets_payment_session_log(self): dt="Sales Order", dn=so.name, recipient_id="test@example.com", - payment_gateway_account="_Test Gateway - INR - _TC", + payment_gateway="_Test Gateway", + payment_account="_Test Bank - _TC", mute_email=True, submit_doc=False, return_doc=True, @@ -1559,7 +1593,8 @@ def test_v2_gateway_sends_email_when_not_muted(self): dt="Sales Order", dn=so.name, recipient_id="test@example.com", - payment_gateway_account="_Test Gateway - INR - _TC", + payment_gateway="_Test Gateway", + payment_account="_Test Bank - _TC", mute_email=False, # Email should be sent submit_doc=True, return_doc=True, @@ -1589,7 +1624,8 @@ def test_v1_phone_payment_skips_email(self): dt="Sales Order", dn=so.name, recipient_id="test@example.com", - payment_gateway_account="_Test Gateway - INR - _TC", + payment_gateway="_Test Gateway", + payment_account="_Test Bank - _TC", mute_email=False, submit_doc=False, return_doc=True, @@ -1624,7 +1660,6 @@ def test_no_payment_gateway_skips_payment_processing(self): ) # Clear the payment gateway pr.payment_gateway = None - pr.payment_gateway_account = None pr.submit() # Neither v1 nor v2 flow should be called @@ -1677,7 +1712,8 @@ def test_flags_mute_email_suppresses_communication(self): dt="Sales Order", dn=so.name, recipient_id="test@example.com", - payment_gateway_account="_Test Gateway - INR - _TC", + payment_gateway="_Test Gateway", + payment_account="_Test Bank - _TC", mute_email=False, # Field says don't mute submit_doc=False, return_doc=True, @@ -1861,7 +1897,8 @@ def test_get_tx_data_multi_currency(self): dt="Sales Order", dn=so.name, recipient_id="test@example.com", - payment_gateway_account="_Test Gateway - USD - _TC", + payment_gateway="_Test Gateway", + payment_account="_Test Bank USD - _TC", mute_email=True, submit_doc=False, return_doc=True, @@ -1924,7 +1961,8 @@ def test_process_v2_gateway_sets_payment_url(self): dt="Sales Order", dn=so.name, recipient_id="test@example.com", - payment_gateway_account="_Test Gateway - INR - _TC", + payment_gateway="_Test Gateway", + payment_account="_Test Bank - _TC", mute_email=True, submit_doc=False, return_doc=True, @@ -1956,7 +1994,8 @@ def test_process_v2_gateway_logs_error_on_failure(self): dt="Sales Order", dn=so.name, recipient_id="test@example.com", - payment_gateway_account="_Test Gateway - INR - _TC", + payment_gateway="_Test Gateway", + payment_account="_Test Bank - _TC", mute_email=True, submit_doc=False, return_doc=True, diff --git a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py index 2925def84085..b65c3b2784af 100644 --- a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py +++ b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py @@ -815,12 +815,13 @@ def create_payment_request(self): return pay_req def get_new_payment_request(self, mop): - payment_gateway_account = frappe.db.get_value( + payment_gateway = frappe.db.get_value( "Payment Gateway Account", { "payment_account": mop.account, }, - ["name"], + ["parent", "payment_account"], + as_dict=1, ) args = { @@ -828,7 +829,8 @@ def get_new_payment_request(self, mop): "dn": self.name, "recipient_id": self.contact_mobile, "mode_of_payment": mop.mode_of_payment, - "payment_gateway_account": payment_gateway_account, + "payment_gateway": payment_gateway.parent, + "payment_account": payment_gateway.payment_account, "payment_request_type": "Inward", "party_type": "Customer", "party": self.customer, @@ -837,18 +839,10 @@ def get_new_payment_request(self, mop): return make_payment_request(**args) def get_existing_payment_request(self, pay): - payment_gateway_account = frappe.db.get_value( - "Payment Gateway Account", - { - "payment_account": pay.account, - }, - ["name"], - ) - filters = { "reference_doctype": "POS Invoice", "reference_name": self.name, - "payment_gateway_account": payment_gateway_account, + "payment_account": pay.account, "email_to": self.contact_mobile, } pr = frappe.db.get_value("Payment Request", filters=filters) diff --git a/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py index 509120acce38..87d812be7a1a 100644 --- a/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py +++ b/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py @@ -3165,6 +3165,7 @@ def make_purchase_invoice(**args): "use_serial_batch_fields": args.get("use_serial_batch_fields") or 0, "batch_no": args.get("batch_no") if args.get("use_serial_batch_fields") else "", "serial_no": args.get("serial_no") if args.get("use_serial_batch_fields") else "", + "delivered_by_supplier": 0, }, ) diff --git a/erpnext/accounts/doctype/subscription_plan/subscription_plan.js b/erpnext/accounts/doctype/subscription_plan/subscription_plan.js index 125dc7dd9ae9..dc560f0798b5 100644 --- a/erpnext/accounts/doctype/subscription_plan/subscription_plan.js +++ b/erpnext/accounts/doctype/subscription_plan/subscription_plan.js @@ -2,6 +2,23 @@ // For license information, please see license.txt frappe.ui.form.on("Subscription Plan", { + setup: function (frm) { + if (frm.fields_dict.payment_account) { + frm.set_query("payment_account", function () { + return { + query: "erpnext.accounts.doctype.payment_request.payment_request.get_payment_account", + filters: { + payment_gateway: frm.doc.payment_gateway, + }, + }; + }); + } + }, + + payment_gateway(frm) { + frm.set_value("payment_account", null); + }, + price_determination: function (frm) { frm.toggle_reqd("cost", frm.doc.price_determination === "Fixed rate"); frm.toggle_reqd("price_list", frm.doc.price_determination === "Based on price list"); diff --git a/erpnext/accounts/doctype/subscription_plan/subscription_plan.json b/erpnext/accounts/doctype/subscription_plan/subscription_plan.json index d8f57e21cdb6..15ab0b72ce34 100644 --- a/erpnext/accounts/doctype/subscription_plan/subscription_plan.json +++ b/erpnext/accounts/doctype/subscription_plan/subscription_plan.json @@ -23,7 +23,6 @@ "payment_plan_section", "product_price_id", "column_break_16", - "payment_gateway", "accounting_dimensions_section", "cost_center", "dimension_col_break" @@ -120,12 +119,6 @@ "fieldname": "column_break_16", "fieldtype": "Column Break" }, - { - "fieldname": "payment_gateway", - "fieldtype": "Link", - "label": "Payment Gateway", - "options": "Payment Gateway Account" - }, { "collapsible": 1, "fieldname": "accounting_dimensions_section", @@ -149,7 +142,7 @@ } ], "links": [], - "modified": "2024-03-27 13:10:47.998597", + "modified": "2026-04-28 13:20:59.817207", "modified_by": "Administrator", "module": "Accounts", "name": "Subscription Plan", @@ -193,8 +186,9 @@ "write": 1 } ], + "row_format": "Dynamic", "sort_field": "creation", "sort_order": "DESC", "states": [], "track_changes": 1 -} \ No newline at end of file +} diff --git a/erpnext/accounts/doctype/subscription_plan/subscription_plan.py b/erpnext/accounts/doctype/subscription_plan/subscription_plan.py index 932caaa2db24..ce9179d9a542 100644 --- a/erpnext/accounts/doctype/subscription_plan/subscription_plan.py +++ b/erpnext/accounts/doctype/subscription_plan/subscription_plan.py @@ -27,7 +27,6 @@ class SubscriptionPlan(Document): cost_center: DF.Link | None currency: DF.Link item: DF.Link - payment_gateway: DF.Link | None plan_name: DF.Data price_determination: DF.Literal["", "Fixed Rate", "Based On Price List", "Monthly Rate"] price_list: DF.Link | None diff --git a/erpnext/accounts/utils.py b/erpnext/accounts/utils.py index bc7794b880b8..d934b22ebc2c 100644 --- a/erpnext/accounts/utils.py +++ b/erpnext/accounts/utils.py @@ -1516,13 +1516,20 @@ def create_payment_gateway_account(gateway, payment_channel="Email", company=Non bank_account = create_bank_account({"company_name": company, "bank_account": _(gateway)}) if not bank_account: - frappe.msgprint(_("Payment Gateway Account not created, please create one manually.")) + frappe.msgprint( + _("Payment Gateway Account not created, please create one under a Payment Gateway manually.") + ) return # if payment gateway account exists, return if frappe.db.exists( "Payment Gateway Account", - {"payment_gateway": gateway, "currency": bank_account.account_currency}, + { + "parent": gateway, + "parenttype": "Payment Gateway", + "company": company, + "payment_account": bank_account.name, + }, ): return @@ -1530,8 +1537,10 @@ def create_payment_gateway_account(gateway, payment_channel="Email", company=Non frappe.get_doc( { "doctype": "Payment Gateway Account", + "parent": gateway, + "parenttype": "Payment Gateway", + "parentfield": "payment_gateway_account", "is_default": 1, - "payment_gateway": gateway, "payment_account": bank_account.name, "currency": bank_account.account_currency, "payment_channel": payment_channel, diff --git a/erpnext/hooks.py b/erpnext/hooks.py index bba172e498b7..820d94deae1c 100644 --- a/erpnext/hooks.py +++ b/erpnext/hooks.py @@ -64,6 +64,9 @@ setup_wizard_stages = "erpnext.setup.setup_wizard.setup_wizard.get_setup_stages" after_install = "erpnext.setup.install.after_install" +before_uninstall = "erpnext.setup.install.before_uninstall" + +before_migrate = "erpnext.setup.utils.validate_payments_compatibility" boot_session = "erpnext.startup.boot.boot_session" notification_config = "erpnext.startup.notifications.get_notification_config" diff --git a/erpnext/patches.txt b/erpnext/patches.txt index b17841bade57..2a7efce839d4 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -483,3 +483,4 @@ erpnext.patches.v16_0.packed_item_inv_dimen erpnext.patches.v16_0.set_not_applicable_on_german_item_tax_templates erpnext.patches.v16_0.clear_procedures_from_receivable_report erpnext.patches.v16_0.migrate_address_contact_custom_fields +erpnext.patches.v16_0.migrate_payment_gateway_related_data diff --git a/erpnext/patches/v16_0/migrate_payment_gateway_related_data.py b/erpnext/patches/v16_0/migrate_payment_gateway_related_data.py new file mode 100644 index 000000000000..d1df3e6a2f4e --- /dev/null +++ b/erpnext/patches/v16_0/migrate_payment_gateway_related_data.py @@ -0,0 +1,118 @@ +import frappe +from frappe.query_builder import DocType + + +def execute(): + apps = frappe.get_installed_apps() + + if "erpnext" not in apps or "payments" not in apps: + return + + doc = frappe.get_doc("DocType", "Payment Gateway Account") + doc.save() + + from payments.utils import make_payments_erpnext_custom_fields + + make_payments_erpnext_custom_fields() + + PG = DocType("Payment Gateway") + + update_pg_query = frappe.qb.update(PG).set(PG.gateway_name, PG.gateway) + update_pg_query.run() + + PGA = DocType("Payment Gateway Account") + + update_pga_query = ( + frappe.qb.update(PGA) + .set(PGA.parent, PGA.payment_gateway) + .set(PGA.parenttype, "Payment Gateway") + .set(PGA.parentfield, "payment_gateway_account") + ) + update_pga_query.run() + + pg_list = frappe.get_all("Payment Gateway", pluck="name") + + for pg in pg_list: + pga_list = frappe.get_all( + "Payment Gateway Account", + filters={"parent": pg}, + fields=["name", "company", "is_default"], + order_by="creation desc", + ) + + company_map = {} + + for pga in pga_list: + if not pga.company: + continue + + company_map.setdefault(pga.company, []).append(pga) + + for company_pgas in company_map.values(): + if any(pga.is_default for pga in company_pgas): + continue + + frappe.db.set_value( + "Payment Gateway Account", + company_pgas[0].name, + "is_default", + 1, + update_modified=False, + ) + + # save each parent so idx gets reassigned correctly + for pg in pg_list: + frappe.get_doc("Payment Gateway", pg).save(ignore_permissions=True) + + sp_list = frappe.get_all( + "Subscription Plan", + fields=["name", "payment_gateway"], + ) + + for sp in sp_list: + if sp.payment_gateway: + doc_dict = frappe.db.get_value( + "Payment Gateway Account", + sp.payment_gateway, + ["parent", "payment_account"], + as_dict=True, + ) + + if not doc_dict: + continue + + frappe.db.set_value( + "Subscription Plan", + sp.name, + { + "payment_gateway": doc_dict.parent, + "payment_account": doc_dict.payment_account, + }, + ) + + pr_list = frappe.get_all( + "Payment Request", + fields=["name", "payment_gateway_account"], + ) + + for pr in pr_list: + if pr.payment_gateway_account: + doc_dict = frappe.db.get_value( + "Payment Gateway Account", + pr.payment_gateway_account, + ["parent", "payment_account", "payment_channel"], + as_dict=True, + ) + + if not doc_dict: + continue + + frappe.db.set_value( + "Payment Request", + pr.name, + { + "payment_gateway": doc_dict.parent, + "payment_account": doc_dict.payment_account, + "payment_channel": doc_dict.payment_channel, + }, + ) diff --git a/erpnext/setup/install.py b/erpnext/setup/install.py index 043ac0a71930..9710a7256da7 100644 --- a/erpnext/setup/install.py +++ b/erpnext/setup/install.py @@ -42,6 +42,14 @@ def after_install(): toggle_hidden_fields() frappe.db.commit() + if "payments" in frappe.get_installed_apps(): + frappe.get_attr("payments.utils.make_payments_erpnext_custom_fields")() + + +def before_uninstall(): + if "payments" in frappe.get_installed_apps(): + frappe.get_attr("payments.utils.delete_payments_erpnext_custom_fields")() + def make_default_operations(): for operation in ["Assembly"]: diff --git a/erpnext/setup/utils.py b/erpnext/setup/utils.py index b6ea4781372b..b025fd2dbf4b 100644 --- a/erpnext/setup/utils.py +++ b/erpnext/setup/utils.py @@ -1,6 +1,8 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # License: GNU General Public License v3. See license.txt +from importlib import import_module + import frappe from frappe import _ from frappe.utils import add_days, flt, get_datetime_str, nowdate @@ -218,3 +220,20 @@ def identity(x, *args, **kwargs): Use like this: `from erpnext.setup.utils import identity as _` """ return x + + +def validate_payments_compatibility(): + """Ensure Payments app is compatible before site migration.""" + + if "payments" not in frappe.get_installed_apps(): + return + + try: + payments_utils = import_module("payments.utils.utils") + except Exception: + frappe.throw(_("Unable to load Payments utilities.\n\n") + frappe.get_traceback()) + + if not hasattr(payments_utils, "make_payments_erpnext_custom_fields"): + frappe.throw( + _("Incompatible Payments app version detected. Please update the Payments app before migration.") + )