From 2dabd2182f32802e8b7e211cdac07ef56c251eb2 Mon Sep 17 00:00:00 2001 From: Mangesh-Khairnar Date: Thu, 10 Sep 2020 17:05:43 +0530 Subject: [PATCH 01/22] feat: add mpesa integration --- .../doctype/mpesa_settings/__init__.py | 0 .../doctype/mpesa_settings/mpesa_connector.py | 12 ++ .../doctype/mpesa_settings/mpesa_settings.js | 8 ++ .../mpesa_settings/mpesa_settings.json | 110 ++++++++++++++++++ .../doctype/mpesa_settings/mpesa_settings.py | 29 +++++ .../mpesa_settings/test_mpesa_settings.py | 10 ++ 6 files changed, 169 insertions(+) create mode 100644 erpnext/erpnext_integrations/doctype/mpesa_settings/__init__.py create mode 100644 erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_connector.py create mode 100644 erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.js create mode 100644 erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.json create mode 100644 erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py create mode 100644 erpnext/erpnext_integrations/doctype/mpesa_settings/test_mpesa_settings.py diff --git a/erpnext/erpnext_integrations/doctype/mpesa_settings/__init__.py b/erpnext/erpnext_integrations/doctype/mpesa_settings/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_connector.py b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_connector.py new file mode 100644 index 00000000000..9252f5dc26c --- /dev/null +++ b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_connector.py @@ -0,0 +1,12 @@ +import requests +import datetime + +class MpesaConnector(): + def __init__(self, env="sandbox", app_key=None, app_secret=None, sandbox_url="https://sandbox.safaricom.co.ke", + live_url="https://safaricom.co.ke"): + self.env = env + self.app_key = app_key + self.app_secret = app_secret + self.sandbox_url = sandbox_url + self.live_url = live_url + self.authenticate() \ No newline at end of file diff --git a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.js b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.js new file mode 100644 index 00000000000..8a1c1912cfb --- /dev/null +++ b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.js @@ -0,0 +1,8 @@ +// Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.ui.form.on('Mpesa Settings', { + // refresh: function(frm) { + + // } +}); diff --git a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.json b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.json new file mode 100644 index 00000000000..9c0bef1584a --- /dev/null +++ b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.json @@ -0,0 +1,110 @@ +{ + "actions": [], + "autoname": "field:payment_gateway_name", + "creation": "2020-09-10 13:21:27.398088", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "payment_gateway_name", + "consumer_key", + "consumer_secret", + "column_break_4", + "till_number", + "online_passkey", + "sandbox" + ], + "fields": [ + { + "fieldname": "payment_gateway_name", + "fieldtype": "Data", + "label": "Payment Gateway Name", + "reqd": 1, + "unique": 1 + }, + { + "fieldname": "consumer_key", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Consumer Key", + "reqd": 1 + }, + { + "fieldname": "consumer_secret", + "fieldtype": "Password", + "in_list_view": 1, + "label": "Consumer Secret", + "reqd": 1 + }, + { + "fieldname": "till_number", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Till Number", + "reqd": 1 + }, + { + "default": "0", + "fieldname": "sandbox", + "fieldtype": "Check", + "label": "Sandbox" + }, + { + "fieldname": "column_break_4", + "fieldtype": "Column Break" + }, + { + "fieldname": "online_passkey", + "fieldtype": "Password", + "label": " Online PassKey", + "reqd": 1 + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2020-09-10 09:07:28.557461", + "modified_by": "Administrator", + "module": "ERPNext Integrations", + "name": "Mpesa Settings", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Accounts Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Accounts User", + "share": 1, + "write": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC" +} \ No newline at end of file diff --git a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py new file mode 100644 index 00000000000..de5df1f2fd4 --- /dev/null +++ b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, Frappe Technologies and contributors +# For license information, please see license.txt + + +from __future__ import unicode_literals +import json +import requests +from six.moves.urllib.parse import urlencode + +import frappe +from frappe.model.document import Document +from frappe import _ +from frappe.utils import get_url, call_hook_method, cint, flt, cstr +from frappe.integrations.utils import create_request_log, create_payment_gateway +from frappe.utils import get_request_site_address +from frappe.utils.password import get_decrypted_password +from erpnext.erpnext_integrations.doctype.mpesa_settings.mpesa_connector import MpesaConnector + +class MpesaSettings(Document): + supported_currencies = ["KSh"] + + def validate(self): + create_payment_gateway('Mpesa-' + self.payment_gateway_name, settings='Mpesa Settings', controller=self.payment_gateway_name) + call_hook_method('payment_gateway_enabled', gateway='Mpesa-' + self.payment_gateway_name) + + def validate_transaction_currency(self, currency): + if currency not in self.supported_currencies: + frappe.throw(_("Please select another payment method. Mpesa does not support transactions in currency '{0}'").format(currency)) diff --git a/erpnext/erpnext_integrations/doctype/mpesa_settings/test_mpesa_settings.py b/erpnext/erpnext_integrations/doctype/mpesa_settings/test_mpesa_settings.py new file mode 100644 index 00000000000..4aa970ef8a1 --- /dev/null +++ b/erpnext/erpnext_integrations/doctype/mpesa_settings/test_mpesa_settings.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt +from __future__ import unicode_literals + +# import frappe +import unittest + +class TestMpesaSettings(unittest.TestCase): + pass From 40d3add63c4c30c3261e25d1b55ce8dc5858c563 Mon Sep 17 00:00:00 2001 From: Mangesh-Khairnar Date: Mon, 14 Sep 2020 15:14:36 +0530 Subject: [PATCH 02/22] feat(payment-gateway-account): add additional payment channel --- .../payment_gateway_account.json | 371 ++++-------------- 1 file changed, 78 insertions(+), 293 deletions(-) diff --git a/erpnext/accounts/doctype/payment_gateway_account/payment_gateway_account.json b/erpnext/accounts/doctype/payment_gateway_account/payment_gateway_account.json index 8dc26288206..12e6f5ef22d 100644 --- a/erpnext/accounts/doctype/payment_gateway_account/payment_gateway_account.json +++ b/erpnext/accounts/doctype/payment_gateway_account/payment_gateway_account.json @@ -1,313 +1,98 @@ { - "allow_copy": 0, - "allow_guest_to_view": 0, - "allow_import": 0, - "allow_rename": 0, - "beta": 0, - "creation": "2015-12-23 21:31:52.699821", - "custom": 0, - "docstatus": 0, - "doctype": "DocType", - "document_type": "", - "editable_grid": 1, + "actions": [], + "creation": "2015-12-23 21:31:52.699821", + "doctype": "DocType", + "editable_grid": 1, + "field_order": [ + "payment_gateway", + "payment_channel", + "is_default", + "column_break_4", + "payment_account", + "currency", + "payment_request_message", + "message", + "message_examples" + ], "fields": [ { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "payment_gateway", - "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, + "fieldname": "payment_gateway", + "fieldtype": "Link", "in_list_view": 1, - "in_standard_filter": 0, - "label": "Payment Gateway", - "length": 0, - "no_copy": 0, - "options": "Payment Gateway", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 1, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "label": "Payment Gateway", + "options": "Payment Gateway", + "reqd": 1 + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "is_default", - "fieldtype": "Check", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Is Default", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "default": "0", + "fieldname": "is_default", + "fieldtype": "Check", + "label": "Is Default" + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "column_break_4", - "fieldtype": "Column Break", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "column_break_4", + "fieldtype": "Column Break" + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "payment_account", - "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, + "fieldname": "payment_account", + "fieldtype": "Link", "in_list_view": 1, - "in_standard_filter": 0, - "label": "Payment Account", - "length": 0, - "no_copy": 0, - "options": "Account", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 1, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "label": "Payment Account", + "options": "Account", + "reqd": 1 + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "fetch_from": "payment_account.account_currency", "fieldname": "currency", - "fieldtype": "Read Only", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Currency", - "length": 0, - "no_copy": 0, - "options": "", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldtype": "Read Only", + "label": "Currency" + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "payment_request_message", - "fieldtype": "Section Break", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "depends_on": "eval: doc.payment_channel !== \"Phone\"", + "fieldname": "payment_request_message", + "fieldtype": "Section Break" + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "default": "Please click on the link below to make your payment", - "fieldname": "message", - "fieldtype": "Small Text", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Default Payment Request Message", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "default": "Please click on the link below to make your payment", + "fieldname": "message", + "fieldtype": "Small Text", + "label": "Default Payment Request Message" + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "message_examples", - "fieldtype": "HTML", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Message Examples", - "length": 0, - "no_copy": 0, - "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", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "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" } - ], - "has_web_view": 0, - "hide_heading": 0, - "hide_toolbar": 0, - "idx": 0, - "image_view": 0, - "in_create": 0, - "is_submittable": 0, - "issingle": 0, - "istable": 0, - "max_attachments": 0, - "modified": "2018-05-16 22:43:34.970491", - "modified_by": "Administrator", - "module": "Accounts", - "name": "Payment Gateway Account", - "name_case": "", - "owner": "Administrator", + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2020-09-20 13:30:27.722852", + "modified_by": "Administrator", + "module": "Accounts", + "name": "Payment Gateway Account", + "owner": "Administrator", "permissions": [ { - "amend": 0, - "cancel": 0, - "create": 1, - "delete": 1, - "email": 1, - "export": 1, - "if_owner": 0, - "import": 0, - "permlevel": 0, - "print": 1, - "read": 1, - "report": 1, - "role": "Accounts Manager", - "set_user_permissions": 0, - "share": 1, - "submit": 0, + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Accounts Manager", + "share": 1, "write": 1 } - ], - "quick_entry": 0, - "read_only": 0, - "read_only_onload": 0, - "show_name_in_global_search": 0, - "sort_field": "modified", - "sort_order": "DESC", - "track_changes": 0, - "track_seen": 0 + ], + "sort_field": "modified", + "sort_order": "DESC" } \ No newline at end of file From 757fa5d010b61d9664c3f4f7bf1d651e40fecf60 Mon Sep 17 00:00:00 2001 From: Mangesh-Khairnar Date: Mon, 14 Sep 2020 17:44:51 +0530 Subject: [PATCH 03/22] fix(shopping-cart-settings): filter payment gateway accounts --- .../doctype/shopping_cart_settings/shopping_cart_settings.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/erpnext/shopping_cart/doctype/shopping_cart_settings/shopping_cart_settings.js b/erpnext/shopping_cart/doctype/shopping_cart_settings/shopping_cart_settings.js index 21fa4c3065f..20c6342d6c2 100644 --- a/erpnext/shopping_cart/doctype/shopping_cart_settings/shopping_cart_settings.js +++ b/erpnext/shopping_cart/doctype/shopping_cart_settings/shopping_cart_settings.js @@ -7,6 +7,10 @@ frappe.ui.form.on("Shopping Cart Settings", { frm.fields_dict.quotation_series.df.options = frm.doc.__onload.quotation_series; frm.refresh_field("quotation_series"); } + + frm.set_query('payment_gateway_account', function() { + return { 'filters': { 'payment_channel': "Email" } }; + }); }, enabled: function(frm) { if (frm.doc.enabled === 1) { From 27f81e06ea8e061b97eba7ccb4349a3a14aa7db0 Mon Sep 17 00:00:00 2001 From: Mangesh-Khairnar Date: Tue, 15 Sep 2020 14:38:06 +0530 Subject: [PATCH 04/22] fix: create custom pos fields --- .../mpesa_settings/mpesa_custom_fields.py | 44 +++++++++++++++++++ .../doctype/mpesa_settings/mpesa_settings.py | 6 +++ erpnext/erpnext_integrations/utils.py | 20 +++++++++ 3 files changed, 70 insertions(+) create mode 100644 erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_custom_fields.py diff --git a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_custom_fields.py b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_custom_fields.py new file mode 100644 index 00000000000..f3410e1818a --- /dev/null +++ b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_custom_fields.py @@ -0,0 +1,44 @@ +import frappe +from frappe.custom.doctype.custom_field.custom_field import create_custom_fields + +def create_custom_pos_fields(): + """ + Create custom fields corresponding to POS Settings and POS Invoice + """ + pos_field = { + "POS Invoice": [ + { + "fieldname": "request_for_payment", + "label": "Request for Payment", + "fieldtype": "Button", + "hidden": 1, + "insert_after": "contact_email" + }, + ] + } + if not frappe.get_meta("POS Invoice").has_field("request_for_payment"): + create_custom_fields(pos_field) + + record_dict = [{ + "doctype": "POS Field", + "fieldname": "contact_mobile", + "label": "Mobile No", + "fieldtype": "Data", + "options": "Phone", + "parent": "POS Settings" + }, + { + "doctype": "POS Field", + "fieldname": "request_for_payment", + "label": "Request for Payment", + "fieldtype": "Button", + "parent": "POS Settings" + } + ] + create_pos_settings(record_dict) + +def create_pos_settings(record_dict): + for record in record_dict: + if frappe.db.exists("POS Field", {"fieldname": record.get("fieldname")}): + continue + frappe.get_doc(record).insert() \ No newline at end of file diff --git a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py index de5df1f2fd4..fb48cb5ff7b 100644 --- a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py +++ b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py @@ -15,15 +15,21 @@ from frappe.utils import get_url, call_hook_method, cint, flt, cstr from frappe.integrations.utils import create_request_log, create_payment_gateway from frappe.utils import get_request_site_address from frappe.utils.password import get_decrypted_password +from erpnext.erpnext_integrations.utils import create_mode_of_payment from erpnext.erpnext_integrations.doctype.mpesa_settings.mpesa_connector import MpesaConnector +from erpnext.erpnext_integrations.doctype.mpesa_settings.mpesa_custom_fields import create_custom_pos_fields class MpesaSettings(Document): supported_currencies = ["KSh"] def validate(self): create_payment_gateway('Mpesa-' + self.payment_gateway_name, settings='Mpesa Settings', controller=self.payment_gateway_name) + create_mode_of_payment('Mpesa-' + self.payment_gateway_name) call_hook_method('payment_gateway_enabled', gateway='Mpesa-' + self.payment_gateway_name) def validate_transaction_currency(self, currency): if currency not in self.supported_currencies: frappe.throw(_("Please select another payment method. Mpesa does not support transactions in currency '{0}'").format(currency)) + + def on_update(self): + create_custom_pos_fields() \ No newline at end of file diff --git a/erpnext/erpnext_integrations/utils.py b/erpnext/erpnext_integrations/utils.py index 84f7f5a5d41..78a5fced77c 100644 --- a/erpnext/erpnext_integrations/utils.py +++ b/erpnext/erpnext_integrations/utils.py @@ -3,6 +3,7 @@ import frappe from frappe import _ import base64, hashlib, hmac from six.moves.urllib.parse import urlparse +from erpnext import get_default_company def validate_webhooks_request(doctype, hmac_key, secret_key='secret'): def innerfn(fn): @@ -41,3 +42,22 @@ def get_webhook_address(connector_name, method, exclude_uri=False): server_url = '{uri.scheme}://{uri.netloc}/api/method/{endpoint}'.format(uri=urlparse(url), endpoint=endpoint) return server_url + +def create_mode_of_payment(gateway): + payment_gateway_account = frappe.db.get_value("Payment Gateway Account", { + "payment_gateway": gateway + }, ['payment_account']) + + if not frappe.db.exists("Mode of Payment", gateway) and payment_gateway_account: + mode_of_payment = frappe.get_doc({ + "doctype": "Mode of Payment", + "mode_of_payment": gateway, + "enabled": 1, + "type": "General", + "account": { + "doctype": "Mode of Payment Account", + "company": get_default_company(), + "default_account": payment_gateway_account + } + }) + mode_of_payment.insert(ignore_permissions=True) \ No newline at end of file From 7126e179c39268fca4e4cc187276020f4d33e945 Mon Sep 17 00:00:00 2001 From: Saqib Date: Fri, 18 Sep 2020 15:27:17 +0530 Subject: [PATCH 05/22] fix: button click event not working in POS custom fields (#23358) --- .../doctype/pos_settings/pos_settings.js | 8 ++++---- .../selling/page/point_of_sale/pos_payment.js | 16 +++++++++++++--- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/erpnext/accounts/doctype/pos_settings/pos_settings.js b/erpnext/accounts/doctype/pos_settings/pos_settings.js index 504941d8b6f..05cb7f0b4b5 100644 --- a/erpnext/accounts/doctype/pos_settings/pos_settings.js +++ b/erpnext/accounts/doctype/pos_settings/pos_settings.js @@ -7,10 +7,10 @@ frappe.ui.form.on('POS Settings', { }, get_invoice_fields: function(frm) { - frappe.model.with_doctype("Sales Invoice", () => { - var fields = $.map(frappe.get_doc("DocType", "Sales Invoice").fields, function(d) { + frappe.model.with_doctype("POS Invoice", () => { + var fields = $.map(frappe.get_doc("DocType", "POS Invoice").fields, function(d) { if (frappe.model.no_value_type.indexOf(d.fieldtype) === -1 || - d.fieldtype === 'Table') { + ['Table', 'Button'].includes(d.fieldtype)) { return { label: d.label + ' (' + d.fieldtype + ')', value: d.fieldname }; } else { return null; @@ -25,7 +25,7 @@ frappe.ui.form.on('POS Settings', { frappe.ui.form.on("POS Field", { fieldname: function(frm, doctype, name) { var doc = frappe.get_doc(doctype, name); - var df = $.map(frappe.get_doc("DocType", "Sales Invoice").fields, function(d) { + var df = $.map(frappe.get_doc("DocType", "POS Invoice").fields, function(d) { return doc.fieldname == d.fieldname ? d : null; })[0]; diff --git a/erpnext/selling/page/point_of_sale/pos_payment.js b/erpnext/selling/page/point_of_sale/pos_payment.js index e1c54f64a71..7f0cabed8b8 100644 --- a/erpnext/selling/page/point_of_sale/pos_payment.js +++ b/erpnext/selling/page/point_of_sale/pos_payment.js @@ -70,13 +70,23 @@ erpnext.PointOfSale.Payment = class { this.$invoice_fields.append( `
` ); + let df_events = { + onchange: function() { frm.set_value(this.df.fieldname, this.value); } + } + if (df.fieldtype == "Button") { + df_events = { + click: function() { + if (frm.script_manager.has_handlers(df.fieldname, frm.doc.doctype)) { + frm.script_manager.trigger(df.fieldname, frm.doc.doctype, frm.doc.docname); + } + } + } + } this[`${df.fieldname}_field`] = frappe.ui.form.make_control({ df: { ...df, - onchange: function() { - frm.set_value(this.df.fieldname, this.value); - } + ...df_events }, parent: this.$invoice_fields.find(`.${df.fieldname}-field`), render_input: true, From a3ac4bf68184d11c6afbb8d6c10cfecf9b5d0658 Mon Sep 17 00:00:00 2001 From: Mangesh-Khairnar Date: Fri, 18 Sep 2020 19:47:33 +0530 Subject: [PATCH 06/22] fix: create payment request via pos --- .../mode_of_payment/mode_of_payment.json | 9 ++++-- .../payment_request/payment_request.json | 18 +++++++++-- .../payment_request/payment_request.py | 30 ++++++++++++++----- .../doctype/pos_invoice/pos_invoice.py | 21 +++++++++++++ .../mpesa_settings/mpesa_custom_fields.py | 8 +++-- 5 files changed, 71 insertions(+), 15 deletions(-) diff --git a/erpnext/accounts/doctype/mode_of_payment/mode_of_payment.json b/erpnext/accounts/doctype/mode_of_payment/mode_of_payment.json index f3df1f0bc98..27431919f46 100644 --- a/erpnext/accounts/doctype/mode_of_payment/mode_of_payment.json +++ b/erpnext/accounts/doctype/mode_of_payment/mode_of_payment.json @@ -1,4 +1,5 @@ { + "actions": [], "allow_import": 1, "allow_rename": 1, "autoname": "field:mode_of_payment", @@ -28,7 +29,7 @@ "fieldtype": "Select", "in_standard_filter": 1, "label": "Type", - "options": "Cash\nBank\nGeneral" + "options": "Cash\nBank\nGeneral\nPhone" }, { "fieldname": "accounts", @@ -45,8 +46,10 @@ ], "icon": "fa fa-credit-card", "idx": 1, - "modified": "2019-08-14 14:58:42.079115", - "modified_by": "sammish.thundiyil@gmail.com", + "index_web_pages_for_search": 1, + "links": [], + "modified": "2020-09-18 17:57:23.835236", + "modified_by": "Administrator", "module": "Accounts", "name": "Mode of Payment", "owner": "harshada@webnotestech.com", diff --git a/erpnext/accounts/doctype/payment_request/payment_request.json b/erpnext/accounts/doctype/payment_request/payment_request.json index 8eadfd0b24a..2ee356aaf40 100644 --- a/erpnext/accounts/doctype/payment_request/payment_request.json +++ b/erpnext/accounts/doctype/payment_request/payment_request.json @@ -48,6 +48,7 @@ "section_break_7", "payment_gateway", "payment_account", + "payment_channel", "payment_order", "amended_from" ], @@ -230,6 +231,7 @@ "label": "Recipient Message And Payment Details" }, { + "depends_on": "eval: doc.payment_channel != \"Phone\"", "fieldname": "print_format", "fieldtype": "Select", "label": "Print Format" @@ -241,6 +243,7 @@ "label": "To" }, { + "depends_on": "eval: doc.payment_channel != \"Phone\"", "fieldname": "subject", "fieldtype": "Data", "in_global_search": 1, @@ -277,16 +280,18 @@ "read_only": 1 }, { - "depends_on": "eval: doc.payment_request_type == 'Inward'", + "depends_on": "eval: doc.payment_request_type == 'Inward' || doc.payment_channel != \"Phone\"", "fieldname": "section_break_10", "fieldtype": "Section Break" }, { + "depends_on": "eval: doc.payment_channel != \"Phone\"", "fieldname": "message", "fieldtype": "Text", "label": "Message" }, { + "depends_on": "eval: doc.payment_channel != \"Phone\"", "fieldname": "message_examples", "fieldtype": "HTML", "label": "Message Examples", @@ -347,12 +352,21 @@ "options": "Payment Request", "print_hide": 1, "read_only": 1 + }, + { + "fetch_from": "payment_gateway_account.payment_channel", + "fieldname": "payment_channel", + "fieldtype": "Select", + "label": "Payment Channel", + "options": "\nEmail\nPhone", + "read_only": 1 } ], "in_create": 1, + "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2020-07-17 14:06:42.185763", + "modified": "2020-09-18 12:24:14.178853", "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 e93ec951fb0..dcf302db6ed 100644 --- a/erpnext/accounts/doctype/payment_request/payment_request.py +++ b/erpnext/accounts/doctype/payment_request/payment_request.py @@ -36,7 +36,7 @@ class PaymentRequest(Document): ref_doc = frappe.get_doc(self.reference_doctype, self.reference_name) if (hasattr(ref_doc, "order_type") \ and getattr(ref_doc, "order_type") != "Shopping Cart"): - ref_amount = get_amount(ref_doc) + ref_amount = get_amount(ref_doc, self.payment_account) if existing_payment_request_amount + flt(self.grand_total)> ref_amount: frappe.throw(_("Total Payment Request amount cannot be greater than {0} amount") @@ -76,11 +76,16 @@ class PaymentRequest(Document): or self.flags.mute_email: send_mail = False - if send_mail: + if send_mail and self.payment_channel != "Phone": self.set_payment_request_url() self.send_email() self.make_communication_entry() + elif self.payment_channel == "Phone": + controller = get_payment_gateway_controller(self.payment_gateway) + print(vars(self)) + controller.request_for_payment(**vars(self)) + def on_cancel(self): self.check_if_payment_entry_exists() self.set_as_cancelled() @@ -105,13 +110,14 @@ class PaymentRequest(Document): return False def set_payment_request_url(self): - if self.payment_account: + if self.payment_account and self.payment_channel != "Phone": self.payment_url = self.get_payment_url() if self.payment_url: self.db_set('payment_url', self.payment_url) - if self.payment_url or not self.payment_gateway_account: + if self.payment_url or not self.payment_gateway_account \ + or (self.payment_gateway_account and self.payment_channel == "Phone"): self.db_set('status', 'Initiated') def get_payment_url(self): @@ -280,7 +286,9 @@ def make_payment_request(**args): args = frappe._dict(args) ref_doc = frappe.get_doc(args.dt, args.dn) - grand_total = get_amount(ref_doc) + gateway_account = get_gateway_details(args) or frappe._dict() + + grand_total = get_amount(ref_doc, gateway_account.get("payment_account")) if args.loyalty_points and args.dt == "Sales Order": from erpnext.accounts.doctype.loyalty_program.loyalty_program import validate_loyalty_points loyalty_amount = validate_loyalty_points(ref_doc, int(args.loyalty_points)) @@ -288,8 +296,6 @@ def make_payment_request(**args): frappe.db.set_value("Sales Order", args.dn, "loyalty_amount", loyalty_amount, update_modified=False) grand_total = grand_total - loyalty_amount - gateway_account = get_gateway_details(args) or frappe._dict() - bank_account = (get_party_bank_account(args.get('party_type'), args.get('party')) if args.get('party_type') else '') @@ -314,6 +320,7 @@ def make_payment_request(**args): "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, "grand_total": grand_total, @@ -344,9 +351,10 @@ def make_payment_request(**args): return pr.as_dict() -def get_amount(ref_doc): +def get_amount(ref_doc, payment_account=None): """get amount based on doctype""" dt = ref_doc.doctype + print(dt) if dt in ["Sales Order", "Purchase Order"]: grand_total = flt(ref_doc.grand_total) - flt(ref_doc.advance_paid) @@ -356,6 +364,12 @@ def get_amount(ref_doc): else: grand_total = flt(ref_doc.outstanding_amount) / ref_doc.conversion_rate + elif dt == "POS Invoice": + for pay in ref_doc.payments: + if pay.type == "Phone" and pay.account == payment_account: + grand_total = pay.amount + break + elif dt == "Fees": grand_total = ref_doc.outstanding_amount diff --git a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py index ba68df7673f..155b95e9d9e 100644 --- a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py +++ b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py @@ -15,6 +15,7 @@ from erpnext.accounts.doctype.loyalty_program.loyalty_program import \ from erpnext.accounts.doctype.sales_invoice.sales_invoice import SalesInvoice, get_bank_cash_account, update_multi_mode_option from erpnext.stock.doctype.serial_no.serial_no import get_pos_reserved_serial_nos +from erpnext.accounts.doctype.payment_request.payment_request import make_payment_request from six import iteritems @@ -313,6 +314,26 @@ class POSInvoice(SalesInvoice): if not pay.account: pay.account = get_bank_cash_account(pay.mode_of_payment, self.company).get("account") + def create_payment_request(self): + for pay in self.payments: + + if pay.type == "Phone": + payment_gateway = frappe.db.get_value("Payment Gateway Account", { + "payment_account": pay.account, + }) + record = { + "payment_gateway": payment_gateway, + "dt": "POS Invoice", + "dn": self.name, + "payment_request_type": "Inward", + "party_type": "Customer", + "party": self.customer, + "recipient_id": self.contact_mobile, + "submit_doc": True + } + + return make_payment_request(**record) + @frappe.whitelist() def get_stock_availability(item_code, warehouse): latest_sle = frappe.db.sql("""select qty_after_transaction diff --git a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_custom_fields.py b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_custom_fields.py index f3410e1818a..0d3912e34df 100644 --- a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_custom_fields.py +++ b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_custom_fields.py @@ -25,14 +25,18 @@ def create_custom_pos_fields(): "label": "Mobile No", "fieldtype": "Data", "options": "Phone", - "parent": "POS Settings" + "parenttype": "POS Settings", + "parent": "POS Settings", + "parentfield": "invoice_fields" }, { "doctype": "POS Field", "fieldname": "request_for_payment", "label": "Request for Payment", "fieldtype": "Button", - "parent": "POS Settings" + "parenttype": "POS Settings", + "parent": "POS Settings", + "parentfield": "invoice_fields" } ] create_pos_settings(record_dict) From 97ab96c8bfec84a519dfcc8464834db3187b4cfd Mon Sep 17 00:00:00 2001 From: Mangesh-Khairnar Date: Tue, 22 Sep 2020 12:58:32 +0530 Subject: [PATCH 07/22] fix: handle api changes from callbacks --- .../payment_request/payment_request.py | 14 ++- erpnext/accounts/utils.py | 5 +- .../doctype/mpesa_settings/mpesa_connector.py | 112 +++++++++++++++++- .../doctype/mpesa_settings/mpesa_settings.js | 1 - .../doctype/mpesa_settings/mpesa_settings.py | 78 ++++++++++-- 5 files changed, 194 insertions(+), 16 deletions(-) diff --git a/erpnext/accounts/doctype/payment_request/payment_request.py b/erpnext/accounts/doctype/payment_request/payment_request.py index dcf302db6ed..41a135fb055 100644 --- a/erpnext/accounts/doctype/payment_request/payment_request.py +++ b/erpnext/accounts/doctype/payment_request/payment_request.py @@ -83,8 +83,17 @@ class PaymentRequest(Document): elif self.payment_channel == "Phone": controller = get_payment_gateway_controller(self.payment_gateway) - print(vars(self)) - controller.request_for_payment(**vars(self)) + payment_record = dict( + reference_doctype=self.reference_doctype, + reference_docname=self.reference_name, + grand_total=self.grand_total, + sender=self.email_to, + payment_request_name=self.name, + currency=self.currency, + payment_gateway=self.payment_gateway + ) + controller.validate_transaction_currency(self.currency) + controller.request_for_payment(**payment_record) def on_cancel(self): self.check_if_payment_entry_exists() @@ -354,7 +363,6 @@ def make_payment_request(**args): def get_amount(ref_doc, payment_account=None): """get amount based on doctype""" dt = ref_doc.doctype - print(dt) if dt in ["Sales Order", "Purchase Order"]: grand_total = flt(ref_doc.grand_total) - flt(ref_doc.advance_paid) diff --git a/erpnext/accounts/utils.py b/erpnext/accounts/utils.py index 51ac7cfbfac..f6acd7236ae 100644 --- a/erpnext/accounts/utils.py +++ b/erpnext/accounts/utils.py @@ -794,7 +794,7 @@ def get_children(doctype, parent, company, is_root=False): return acc -def create_payment_gateway_account(gateway): +def create_payment_gateway_account(gateway, payment_channel="Email"): from erpnext.setup.setup_wizard.operations.company_setup import create_bank_account company = frappe.db.get_value("Global Defaults", None, "default_company") @@ -829,7 +829,8 @@ def create_payment_gateway_account(gateway): "is_default": 1, "payment_gateway": gateway, "payment_account": bank_account.name, - "currency": bank_account.account_currency + "currency": bank_account.account_currency, + "payment_channel": payment_channel }).insert(ignore_permissions=True) except frappe.DuplicateEntryError: diff --git a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_connector.py b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_connector.py index 9252f5dc26c..d79cdaa5392 100644 --- a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_connector.py +++ b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_connector.py @@ -1,4 +1,6 @@ +import base64 import requests +from requests.auth import HTTPBasicAuth import datetime class MpesaConnector(): @@ -7,6 +9,110 @@ class MpesaConnector(): self.env = env self.app_key = app_key self.app_secret = app_secret - self.sandbox_url = sandbox_url - self.live_url = live_url - self.authenticate() \ No newline at end of file + if env == "sandbox": + self.base_url = sandbox_url + else: + self.base_url = live_url + self.authenticate() + + def authenticate(self): + """ + To make Mpesa API calls, you will need to authenticate your app. This method is used to fetch the access token + required by Mpesa. Mpesa supports client_credentials grant type. To authorize your API calls to Mpesa, + you will need a Basic Auth over HTTPS authorization token. The Basic Auth string is a base64 encoded string + of your app's client key and client secret. + + Returns: + access_token (str): This token is to be used with the Bearer header for further API calls to Mpesa. + """ + authenticate_uri = "/oauth/v1/generate?grant_type=client_credentials" + authenticate_url = "{0}{1}".format(self.base_url, authenticate_uri) + r = requests.get( + authenticate_url, + auth=HTTPBasicAuth(self.app_key, self.app_secret) + ) + self.authentication_token = r.json()['access_token'] + return r.json()['access_token'] + + def get_balance(self, initiator=None, security_credential=None, party_a=None, identifier_type=None, + remarks=None, queue_timeout_url=None,result_url=None): + """ + This method uses Mpesa's Account Balance API to to enquire the balance on a M-Pesa BuyGoods (Till Number). + Args: + initiator (str): Username used to authenticate the transaction. + security_credential (str): Generate from developer portal. + command_id (str): AccountBalance. + party_a (int): Till number being queried. + identifier_type (int): Type of organization receiving the transaction. (MSISDN/Till Number/Organization short code) + remarks (str): Comments that are sent along with the transaction(maximum 100 characters). + queue_timeout_url (str): The url that handles information of timed out transactions. + result_url (str): The url that receives results from M-Pesa api call. + + Returns: + OriginatorConverstionID (str): The unique request ID for tracking a transaction. + ConversationID (str): The unique request ID returned by mpesa for each request made + ResponseDescription (str): Response Description message + """ + + payload = { + "Initiator": initiator, + "SecurityCredential": security_credential, + "CommandID": "AccountBalance", + "PartyA": party_a, + "IdentifierType": identifier_type, + "Remarks": remarks, + "QueueTimeOutURL": queue_timeout_url, + "ResultURL": result_url + } + headers = {'Authorization': 'Bearer {0}'.format(self.authentication_token), 'Content-Type': "application/json"} + saf_url = "{0}{1}".format(self.base_url, "/mpesa/accountbalance/v1/query") + r = requests.post(saf_url, headers=headers, json=payload) + return r.json() + + def stk_push(self, business_shortcode=None, passcode=None, amount=None, callback_url=None, reference_code=None, + phone_number=None, description=None): + """ + This method uses Mpesa's Express API to initiate online payment on behalf of a customer. + Args: + business_shortcode (int): The short code of the organization. + passcode (str): Get from developer portal + amount (int): The amount being transacted + callback_url (str): A CallBack URL is a valid secure URL that is used to receive notifications from M-Pesa API. + reference_code(str): Account Reference: This is an Alpha-Numeric parameter that is defined by your system as an Identifier of the transaction for CustomerPayBillOnline transaction type. + phone_number(int): The Mobile Number to receive the STK Pin Prompt. + description(str): This is any additional information/comment that can be sent along with the request from your system. MAX 13 characters + + Success Response: + CustomerMessage(str): Messages that customers can understand. + CheckoutRequestID(str): This is a global unique identifier of the processed checkout transaction request. + ResponseDescription(str): Describes Success or failure + MerchantRequestID(str): This is a global unique Identifier for any submitted payment request. + ResponseCode(int): 0 means success all others are error codes. e.g.404.001.03 + + Error Reponse: + requestId(str): This is a unique requestID for the payment request + errorCode(str): This is a predefined code that indicates the reason for request failure. + errorMessage(str): This is a predefined code that indicates the reason for request failure. + """ + + time = str(datetime.datetime.now()).split(".")[0].replace("-", "").replace(" ", "").replace(":", "") + password = "{0}{1}{2}".format(str(business_shortcode), str(passcode), time) + encoded = base64.b64encode(bytes(password, encoding='utf8')) + payload = { + "BusinessShortCode": business_shortcode, + "Password": encoded.decode("utf-8"), + "Timestamp": time, + "TransactionType": "CustomerPayBillOnline", + "Amount": amount, + "PartyA": int(phone_number), + "PartyB": business_shortcode, + "PhoneNumber": int(phone_number), + "CallBackURL": callback_url, + "AccountReference": reference_code, + "TransactionDesc": description + } + headers = {'Authorization': 'Bearer {0}'.format(self.authentication_token), 'Content-Type': "application/json"} + + saf_url = "{0}{1}".format(self.base_url, "/mpesa/stkpush/v1/processrequest") + r = requests.post(saf_url, headers=headers, json=payload) + return r.json() \ No newline at end of file diff --git a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.js b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.js index 8a1c1912cfb..48e0c0bd35c 100644 --- a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.js +++ b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.js @@ -3,6 +3,5 @@ frappe.ui.form.on('Mpesa Settings', { // refresh: function(frm) { - // } }); diff --git a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py index fb48cb5ff7b..c92c1b23bc1 100644 --- a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py +++ b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py @@ -15,21 +15,85 @@ from frappe.utils import get_url, call_hook_method, cint, flt, cstr from frappe.integrations.utils import create_request_log, create_payment_gateway from frappe.utils import get_request_site_address from frappe.utils.password import get_decrypted_password +from frappe.utils import get_request_site_address from erpnext.erpnext_integrations.utils import create_mode_of_payment from erpnext.erpnext_integrations.doctype.mpesa_settings.mpesa_connector import MpesaConnector from erpnext.erpnext_integrations.doctype.mpesa_settings.mpesa_custom_fields import create_custom_pos_fields class MpesaSettings(Document): - supported_currencies = ["KSh"] - - def validate(self): - create_payment_gateway('Mpesa-' + self.payment_gateway_name, settings='Mpesa Settings', controller=self.payment_gateway_name) - create_mode_of_payment('Mpesa-' + self.payment_gateway_name) - call_hook_method('payment_gateway_enabled', gateway='Mpesa-' + self.payment_gateway_name) + supported_currencies = ["KES"] def validate_transaction_currency(self, currency): if currency not in self.supported_currencies: frappe.throw(_("Please select another payment method. Mpesa does not support transactions in currency '{0}'").format(currency)) def on_update(self): - create_custom_pos_fields() \ No newline at end of file + create_custom_pos_fields() + create_payment_gateway('Mpesa-' + self.payment_gateway_name, settings='Mpesa Settings', controller=self.payment_gateway_name) + create_mode_of_payment('Mpesa-' + self.payment_gateway_name) + call_hook_method('payment_gateway_enabled', gateway='Mpesa-' + self.payment_gateway_name, payment_channel="Phone") + + def request_for_payment(self, **kwargs): + response = frappe._dict(generate_stk_push(**kwargs)) + # check error response + if hasattr(response, "requestId"): + req_name = getattr(response, "requestId") + error = response + else: + # global checkout id used as request name + req_name = getattr(response, "CheckoutRequestID") + error = None + + create_request_log(kwargs, "Host", "Mpesa", req_name, error) + if error: + frappe.throw(_(getattr(response, "errorMessage")), title=_("Transaction Error")) + +def generate_stk_push(**kwargs): + args = frappe._dict(kwargs) + try: + callback_url = get_request_site_address(True) + "/api/method/erpnext.erpnext_integrations.doctype.mpesa_settings.mpesa_settings.verify_transaction" + + mpesa_settings = frappe.get_doc("Mpesa Settings", args.payment_gateway[6:]) + env = "production" if not mpesa_settings.sandbox else "sandbox" + + connector = MpesaConnector(env=env, + app_key=mpesa_settings.consumer_key, + app_secret=mpesa_settings.get_password("consumer_secret")) + + response = connector.stk_push(business_shortcode=mpesa_settings.till_number, + passcode=mpesa_settings.get_password("online_passkey"), amount=args.grand_total, + callback_url=callback_url, reference_code=args.payment_request_name, + phone_number=args.sender, description="POS Payment") + + return response + + except Exception: + frappe.log_error(title=_("Mpesa Express Transaction Error")) + frappe.throw(_("Issue detected with Mpesa configuration, check the error logs for more details"), title=_("Mpesa Express Error")) + +@frappe.whitelist(allow_guest=True) +def verify_transaction(**kwargs): + """ Verify the transaction result received via callback """ + transaction_response = frappe._dict(kwargs["Body"]["stkCallback"]) + + checkout_id = getattr(transaction_response, "CheckoutRequestID") + request = frappe.get_doc("Integration Request", checkout_id) + transaction_data = frappe._dict(json.loads(request.data)) + + if transaction_response['ResultCode'] == 0: + if transaction_data.reference_doctype and transaction_data.reference_docname: + try: + frappe.get_doc(transaction_data.reference_doctype, + transaction_data.reference_docname).run_method("on_payment_authorized", 'Completed') + request.db_set('output', transaction_response) + request.db_set('status', 'Completed') + except Exception: + request.db_set('error', transaction_response) + request.db_set('status', 'Failed') + frappe.log_error(frappe.get_traceback()) + + else: + request.db_set('error', transaction_response) + request.db_set('status', 'Failed') + + frappe.publish_realtime('process_phone_payment', after_commit=True, user=request.owner, message=transaction_response) \ No newline at end of file From 4eb215badb73f6be93abde705566e1602f413b95 Mon Sep 17 00:00:00 2001 From: Mangesh-Khairnar Date: Wed, 23 Sep 2020 15:55:21 +0530 Subject: [PATCH 08/22] fix: bind callback callback for realtime event --- .../doctype/payment_request/payment_request.py | 5 ++--- .../doctype/pos_invoice/pos_invoice.js | 17 +++++++++++++++++ .../selling/page/point_of_sale/pos_payment.js | 18 ++++++++++++++++++ 3 files changed, 37 insertions(+), 3 deletions(-) diff --git a/erpnext/accounts/doctype/payment_request/payment_request.py b/erpnext/accounts/doctype/payment_request/payment_request.py index 41a135fb055..8eba647c596 100644 --- a/erpnext/accounts/doctype/payment_request/payment_request.py +++ b/erpnext/accounts/doctype/payment_request/payment_request.py @@ -84,11 +84,10 @@ class PaymentRequest(Document): elif self.payment_channel == "Phone": controller = get_payment_gateway_controller(self.payment_gateway) payment_record = dict( - reference_doctype=self.reference_doctype, - reference_docname=self.reference_name, + reference_doctype="Payment Request", + reference_docname=self.name, grand_total=self.grand_total, sender=self.email_to, - payment_request_name=self.name, currency=self.currency, payment_gateway=self.payment_gateway ) diff --git a/erpnext/accounts/doctype/pos_invoice/pos_invoice.js b/erpnext/accounts/doctype/pos_invoice/pos_invoice.js index 3be43044aad..bedf5e5eef8 100644 --- a/erpnext/accounts/doctype/pos_invoice/pos_invoice.js +++ b/erpnext/accounts/doctype/pos_invoice/pos_invoice.js @@ -142,6 +142,23 @@ erpnext.selling.POSInvoiceController = erpnext.selling.SellingController.extend( frm: cur_frm }) }, + + request_for_payment: function (frm) { + frm.save().then(() => { + frappe.dom.freeze(); + frappe.call({ + method: 'create_payment_request', + doc: frm.doc, + }) + .fail(() => { + frappe.dom.unfreeze(); + frappe.msgprint('Payment request failed'); + }) + .then(() => { + frappe.msgprint('Payment request sent successfully'); + }); + }); + } }) $.extend(cur_frm.cscript, new erpnext.selling.POSInvoiceController({ frm: cur_frm })) diff --git a/erpnext/selling/page/point_of_sale/pos_payment.js b/erpnext/selling/page/point_of_sale/pos_payment.js index 7f0cabed8b8..35cd408b53d 100644 --- a/erpnext/selling/page/point_of_sale/pos_payment.js +++ b/erpnext/selling/page/point_of_sale/pos_payment.js @@ -174,6 +174,24 @@ erpnext.PointOfSale.Payment = class { } }) + frappe.realtime.on("process_phone_payments", function(data) { + frappe.msgprint({message: 'help', title:'now'}) + // frappe.dom.unfreeze(); + // let message = data["ResultDesc"]; + // let title = __("Payment Failed"); + // const frm = me.events.get_frm(); + + // if (data["ResultCode"] == 0) { + // title = __("Payment Received"); + // $('[data-fieldname=request_for_payment]').text("Paid") + // } + + // frappe.msgprint({ + // "message": message, + // "title": title + // }); + }); + this.$payment_modes.on('click', '.shortcut', function(e) { const value = $(this).attr('data-value'); me.selected_mode.set_value(value); From a26c6b4c2d884202cd16901cfaf43a9b062c6d1f Mon Sep 17 00:00:00 2001 From: Mangesh-Khairnar Date: Fri, 25 Sep 2020 23:56:39 +0530 Subject: [PATCH 09/22] fix: fetch account balance info --- .../mpesa_settings/account_balance.html | 28 +++++ .../doctype/mpesa_settings/mpesa_settings.js | 25 ++++- .../mpesa_settings/mpesa_settings.json | 33 +++++- .../doctype/mpesa_settings/mpesa_settings.py | 104 +++++++++++++++--- 4 files changed, 167 insertions(+), 23 deletions(-) create mode 100644 erpnext/erpnext_integrations/doctype/mpesa_settings/account_balance.html diff --git a/erpnext/erpnext_integrations/doctype/mpesa_settings/account_balance.html b/erpnext/erpnext_integrations/doctype/mpesa_settings/account_balance.html new file mode 100644 index 00000000000..2c4d4bbdecf --- /dev/null +++ b/erpnext/erpnext_integrations/doctype/mpesa_settings/account_balance.html @@ -0,0 +1,28 @@ + +{% if not jQuery.isEmptyObject(data) %} +
{{ __("Balance Details") }}
+ + + + + + + + + + + + {% for(const [key, value] of Object.entries(data)) { %} + + + + + + + + {% } %} + +
{{ __("Account Type") }}{{ __("Current Balance") }}{{ __("Available Balance") }}{{ __("Reserved Balance") }}{{ __("Uncleared Balance") }}
{%= key %} {%= value["current_balance"] %} {%= value["available_balance"] %} {%= value["reserved_balance"] %} {%= value["uncleared_balance"] %}
+{% else %} +

Account Balance Information Not Available.

+{% endif %} \ No newline at end of file diff --git a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.js b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.js index 48e0c0bd35c..239a0bc9b27 100644 --- a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.js +++ b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.js @@ -2,6 +2,27 @@ // For license information, please see license.txt frappe.ui.form.on('Mpesa Settings', { - // refresh: function(frm) { - // } + onload_post_render: function(frm) { + frm.events.setup_account_balance_html(frm); + }, + + get_account_balance: function(frm) { + if (!frm.initiator_name && !frm.security_credentials) return; + frappe.call({ + method: "get_account_balance_info", + doc: frm.doc + }); + }, + + setup_account_balance_html: function(frm) { + console.log(frm.doc.account_balance) + $("div").remove(".form-dashboard-section.custom"); + frm.dashboard.add_section( + frappe.render_template('account_balance', { + data: JSON.parse(frm.doc.account_balance) + }) + ); + frm.dashboard.show(); + } + }); diff --git a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.json b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.json index 9c0bef1584a..fc7b310c087 100644 --- a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.json +++ b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.json @@ -9,10 +9,14 @@ "payment_gateway_name", "consumer_key", "consumer_secret", - "column_break_4", + "initiator_name", "till_number", + "sandbox", + "column_break_4", "online_passkey", - "sandbox" + "security_credential", + "get_account_balance", + "account_balance" ], "fields": [ { @@ -58,11 +62,32 @@ "fieldtype": "Password", "label": " Online PassKey", "reqd": 1 + }, + { + "fieldname": "initiator_name", + "fieldtype": "Data", + "label": "Initiator Name" + }, + { + "fieldname": "security_credential", + "fieldtype": "Small Text", + "label": "Security Credential" + }, + { + "fieldname": "account_balance", + "fieldtype": "Long Text", + "hidden": 1, + "label": "Account Balance", + "read_only": 1 + }, + { + "fieldname": "get_account_balance", + "fieldtype": "Button", + "label": "Get Account Balance" } ], - "index_web_pages_for_search": 1, "links": [], - "modified": "2020-09-10 09:07:28.557461", + "modified": "2020-09-25 20:21:38.215494", "modified_by": "Administrator", "module": "ERPNext Integrations", "name": "Mpesa Settings", diff --git a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py index c92c1b23bc1..3af0baaa50c 100644 --- a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py +++ b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py @@ -4,17 +4,14 @@ from __future__ import unicode_literals -import json -import requests -from six.moves.urllib.parse import urlencode +from json import loads, dumps import frappe from frappe.model.document import Document from frappe import _ -from frappe.utils import get_url, call_hook_method, cint, flt, cstr +from frappe.utils import call_hook_method from frappe.integrations.utils import create_request_log, create_payment_gateway from frappe.utils import get_request_site_address -from frappe.utils.password import get_decrypted_password from frappe.utils import get_request_site_address from erpnext.erpnext_integrations.utils import create_mode_of_payment from erpnext.erpnext_integrations.doctype.mpesa_settings.mpesa_connector import MpesaConnector @@ -35,16 +32,29 @@ class MpesaSettings(Document): def request_for_payment(self, **kwargs): response = frappe._dict(generate_stk_push(**kwargs)) + self.handle_api_response("CheckoutRequestID", kwargs, response) + + def get_account_balance_info(self): + payload = dict( + reference_doctype="Mpesa Settings", + reference_docname=self.name, + doc_details=vars(self) + ) + response = frappe._dict(get_account_balance(payload)) + self.handle_api_response("ConversationID", payload, response) + + def handle_api_response(self, global_id, request_dict, response): # check error response - if hasattr(response, "requestId"): + if getattr(response, "requestId"): req_name = getattr(response, "requestId") error = response else: # global checkout id used as request name - req_name = getattr(response, "CheckoutRequestID") + req_name = getattr(response, global_id) error = None - create_request_log(kwargs, "Host", "Mpesa", req_name, error) + create_request_log(request_dict, "Host", "Mpesa", req_name, error) + if error: frappe.throw(_(getattr(response, "errorMessage")), title=_("Transaction Error")) @@ -76,24 +86,84 @@ def verify_transaction(**kwargs): """ Verify the transaction result received via callback """ transaction_response = frappe._dict(kwargs["Body"]["stkCallback"]) - checkout_id = getattr(transaction_response, "CheckoutRequestID") + checkout_id = getattr(transaction_response, "CheckoutRequestID", "") request = frappe.get_doc("Integration Request", checkout_id) - transaction_data = frappe._dict(json.loads(request.data)) + transaction_data = frappe._dict(loads(request.data)) if transaction_response['ResultCode'] == 0: if transaction_data.reference_doctype and transaction_data.reference_docname: try: frappe.get_doc(transaction_data.reference_doctype, transaction_data.reference_docname).run_method("on_payment_authorized", 'Completed') - request.db_set('output', transaction_response) - request.db_set('status', 'Completed') + request.process_response('error', transaction_response) except Exception: - request.db_set('error', transaction_response) - request.db_set('status', 'Failed') + request.process_response('error', transaction_response) frappe.log_error(frappe.get_traceback()) else: - request.db_set('error', transaction_response) - request.db_set('status', 'Failed') + request.process_response('error', transaction_response) - frappe.publish_realtime('process_phone_payment', after_commit=True, user=request.owner, message=transaction_response) \ No newline at end of file + frappe.publish_realtime('process_phone_payment', after_commit=True, doctype=transaction_data.reference_doctype, + docname=transaction_data.reference_docname, user=request.owner, message=transaction_response) + +def get_account_balance(request_payload): + """ Call account balance API to send the request to the Mpesa Servers """ + try: + mpesa_settings = frappe.get_doc("Mpesa Settings", request_payload.get("reference_docname")) + env = "production" if not mpesa_settings.sandbox else "sandbox" + connector = MpesaConnector(env=env, + app_key=mpesa_settings.consumer_key, + app_secret=mpesa_settings.get_password("consumer_secret")) + + # callback_url = get_request_site_address(True) + "/api/method/erpnext.erpnext_integrations.doctype.mpesa_settings.mpesa_settings.process_balance_info" + callback_url = "https://b014ca8e7957.ngrok.io/api/method/erpnext.erpnext_integrations.doctype.mpesa_settings.mpesa_settings.process_balance_info" + + response = connector.get_balance(mpesa_settings.initiator_name, mpesa_settings.security_credential, mpesa_settings.till_number, 4, mpesa_settings.name, callback_url, callback_url) + return response + except Exception: + frappe.log_error(title=_("Account Balance Processing Error")) + frappe.throw(title=_("Error"), message=_("Please check your configuration and try again")) + +@frappe.whitelist(allow_guest=True) +def process_balance_info(**kwargs): + + account_balance_response = frappe._dict(kwargs["Result"]) + + conversation_id = getattr(account_balance_response, "ConversationID", "") + request = frappe.get_doc("Integration Request", conversation_id) + + if request.status == "Completed": + return + + transaction_data = frappe._dict(loads(request.data)) + frappe.logger().debug(account_balance_response) + + if account_balance_response["ResultCode"] == 0: + try: + result_params = account_balance_response["ResultParameters"]["ResultParameter"] + for param in result_params: + if param["Key"] == "AccountBalance": + balance_info = param["Value"] + balance_info = convert_to_json(balance_info) + + ref_doc = frappe.get_doc(transaction_data.reference_doctype, transaction_data.reference_docname) + ref_doc.db_set("account_balance", balance_info) + + request.process_response('output', account_balance_response) + except: + request.process_response('error', account_balance_response) + frappe.log_error(title=_("Mpesa Account Balance Processing Error"), message=account_balance_response) + else: + request.process_response('error', account_balance_response) + +def convert_to_json(balance_info): + balance_dict = frappe._dict() + for account_info in balance_info.split("&"): + account_info = account_info.split('|') + balance_dict[account_info[0]] = dict( + current_balance=account_info[2], + available_balance=account_info[3], + reserved_balance=account_info[4], + uncleared_balance=account_info[5] + ) + return dumps(balance_dict) \ No newline at end of file From 8d12c3841f7956b287ead3ce2bbcd5a7ab411429 Mon Sep 17 00:00:00 2001 From: Mangesh-Khairnar Date: Mon, 28 Sep 2020 19:40:42 +0530 Subject: [PATCH 10/22] fix: process transaction callback --- .../doctype/pos_invoice/pos_invoice.js | 34 +++++----- .../doctype/pos_invoice/pos_invoice.json | 8 +-- .../doctype/mpesa_settings/mpesa_connector.py | 3 + .../mpesa_settings/mpesa_custom_fields.py | 11 +++- .../doctype/mpesa_settings/mpesa_settings.js | 1 - .../doctype/mpesa_settings/mpesa_settings.py | 65 +++++++++++++------ 6 files changed, 77 insertions(+), 45 deletions(-) diff --git a/erpnext/accounts/doctype/pos_invoice/pos_invoice.js b/erpnext/accounts/doctype/pos_invoice/pos_invoice.js index bedf5e5eef8..c43cb794aa5 100644 --- a/erpnext/accounts/doctype/pos_invoice/pos_invoice.js +++ b/erpnext/accounts/doctype/pos_invoice/pos_invoice.js @@ -142,23 +142,6 @@ erpnext.selling.POSInvoiceController = erpnext.selling.SellingController.extend( frm: cur_frm }) }, - - request_for_payment: function (frm) { - frm.save().then(() => { - frappe.dom.freeze(); - frappe.call({ - method: 'create_payment_request', - doc: frm.doc, - }) - .fail(() => { - frappe.dom.unfreeze(); - frappe.msgprint('Payment request failed'); - }) - .then(() => { - frappe.msgprint('Payment request sent successfully'); - }); - }); - } }) $.extend(cur_frm.cscript, new erpnext.selling.POSInvoiceController({ frm: cur_frm })) @@ -218,5 +201,22 @@ frappe.ui.form.on('POS Invoice', { } frm.set_value("loyalty_amount", loyalty_amount); } + }, + + request_for_payment: function (frm) { + frm.save().then(() => { + frappe.dom.freeze(); + frappe.call({ + method: 'create_payment_request', + doc: frm.doc, + }) + .fail(() => { + frappe.dom.unfreeze(); + frappe.msgprint('Payment request failed'); + }) + .then(() => { + frappe.msgprint('Payment request sent successfully'); + }); + }); } }); \ No newline at end of file diff --git a/erpnext/accounts/doctype/pos_invoice/pos_invoice.json b/erpnext/accounts/doctype/pos_invoice/pos_invoice.json index 4780688471c..1cff3c661d5 100644 --- a/erpnext/accounts/doctype/pos_invoice/pos_invoice.json +++ b/erpnext/accounts/doctype/pos_invoice/pos_invoice.json @@ -279,8 +279,7 @@ "fieldtype": "Check", "label": "Is Return (Credit Note)", "no_copy": 1, - "print_hide": 1, - "set_only_once": 1 + "print_hide": 1 }, { "fieldname": "column_break1", @@ -461,7 +460,7 @@ }, { "fieldname": "contact_mobile", - "fieldtype": "Small Text", + "fieldtype": "Data", "hidden": 1, "label": "Mobile No", "read_only": 1 @@ -1579,10 +1578,9 @@ } ], "icon": "fa fa-file-text", - "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2020-09-07 12:43:09.138720", + "modified": "2020-09-28 16:51:24.641755", "modified_by": "Administrator", "module": "Accounts", "name": "POS Invoice", diff --git a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_connector.py b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_connector.py index d79cdaa5392..dced7b03975 100644 --- a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_connector.py +++ b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_connector.py @@ -6,6 +6,7 @@ import datetime class MpesaConnector(): def __init__(self, env="sandbox", app_key=None, app_secret=None, sandbox_url="https://sandbox.safaricom.co.ke", live_url="https://safaricom.co.ke"): + """Setup configuration for Mpesa connector and generate new access token.""" self.env = env self.app_key = app_key self.app_secret = app_secret @@ -38,6 +39,7 @@ class MpesaConnector(): remarks=None, queue_timeout_url=None,result_url=None): """ This method uses Mpesa's Account Balance API to to enquire the balance on a M-Pesa BuyGoods (Till Number). + Args: initiator (str): Username used to authenticate the transaction. security_credential (str): Generate from developer portal. @@ -73,6 +75,7 @@ class MpesaConnector(): phone_number=None, description=None): """ This method uses Mpesa's Express API to initiate online payment on behalf of a customer. + Args: business_shortcode (int): The short code of the organization. passcode (str): Get from developer portal diff --git a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_custom_fields.py b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_custom_fields.py index 0d3912e34df..5d32a1c8f90 100644 --- a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_custom_fields.py +++ b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_custom_fields.py @@ -2,9 +2,7 @@ import frappe from frappe.custom.doctype.custom_field.custom_field import create_custom_fields def create_custom_pos_fields(): - """ - Create custom fields corresponding to POS Settings and POS Invoice - """ + """Create custom fields corresponding to POS Settings and POS Invoice.""" pos_field = { "POS Invoice": [ { @@ -14,6 +12,13 @@ def create_custom_pos_fields(): "hidden": 1, "insert_after": "contact_email" }, + { + "fieldname": "Mpesa Receipt Number", + "label": "mpesa_receipt_number", + "fieldtype": "Data", + "read_only": 1, + "insert_after": "company" + } ] } if not frappe.get_meta("POS Invoice").has_field("request_for_payment"): diff --git a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.js b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.js index 239a0bc9b27..7742a45746e 100644 --- a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.js +++ b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.js @@ -15,7 +15,6 @@ frappe.ui.form.on('Mpesa Settings', { }, setup_account_balance_html: function(frm) { - console.log(frm.doc.account_balance) $("div").remove(".form-dashboard-section.custom"); frm.dashboard.add_section( frappe.render_template('account_balance', { diff --git a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py index 3af0baaa50c..8fc05c65b1e 100644 --- a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py +++ b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py @@ -9,10 +9,9 @@ from json import loads, dumps import frappe from frappe.model.document import Document from frappe import _ -from frappe.utils import call_hook_method +from frappe.utils import call_hook_method, fmt_money from frappe.integrations.utils import create_request_log, create_payment_gateway from frappe.utils import get_request_site_address -from frappe.utils import get_request_site_address from erpnext.erpnext_integrations.utils import create_mode_of_payment from erpnext.erpnext_integrations.doctype.mpesa_settings.mpesa_connector import MpesaConnector from erpnext.erpnext_integrations.doctype.mpesa_settings.mpesa_custom_fields import create_custom_pos_fields @@ -27,7 +26,7 @@ class MpesaSettings(Document): def on_update(self): create_custom_pos_fields() create_payment_gateway('Mpesa-' + self.payment_gateway_name, settings='Mpesa Settings', controller=self.payment_gateway_name) - create_mode_of_payment('Mpesa-' + self.payment_gateway_name) + create_mode_of_payment('Mpesa-' + self.payment_gateway_name, payment_type="Phone") call_hook_method('payment_gateway_enabled', gateway='Mpesa-' + self.payment_gateway_name, payment_channel="Phone") def request_for_payment(self, **kwargs): @@ -44,6 +43,8 @@ class MpesaSettings(Document): self.handle_api_response("ConversationID", payload, response) def handle_api_response(self, global_id, request_dict, response): + """Response received from API calls returns a global identifier for each transaction, + this code is returned during the callback""" # check error response if getattr(response, "requestId"): req_name = getattr(response, "requestId") @@ -59,6 +60,7 @@ class MpesaSettings(Document): frappe.throw(_(getattr(response, "errorMessage")), title=_("Transaction Error")) def generate_stk_push(**kwargs): + """Generate stk push by making a API call to the stk push API.""" args = frappe._dict(kwargs) try: callback_url = get_request_site_address(True) + "/api/method/erpnext.erpnext_integrations.doctype.mpesa_settings.mpesa_settings.verify_transaction" @@ -70,6 +72,8 @@ def generate_stk_push(**kwargs): app_key=mpesa_settings.consumer_key, app_secret=mpesa_settings.get_password("consumer_secret")) + mobile_number = sanitize_mobile_number(args.sender) + response = connector.stk_push(business_shortcode=mpesa_settings.till_number, passcode=mpesa_settings.get_password("online_passkey"), amount=args.grand_total, callback_url=callback_url, reference_code=args.payment_request_name, @@ -81,10 +85,15 @@ def generate_stk_push(**kwargs): frappe.log_error(title=_("Mpesa Express Transaction Error")) frappe.throw(_("Issue detected with Mpesa configuration, check the error logs for more details"), title=_("Mpesa Express Error")) +def sanitize_mobile_number(number): + """Add country code and strip leading zeroes from the phone number.""" + return "254" + str(number).lstrip("0") + @frappe.whitelist(allow_guest=True) def verify_transaction(**kwargs): - """ Verify the transaction result received via callback """ + """ Verify the transaction result received via callback from stk """ transaction_response = frappe._dict(kwargs["Body"]["stkCallback"]) + frappe.logger().debug(transaction_response) checkout_id = getattr(transaction_response, "CheckoutRequestID", "") request = frappe.get_doc("Integration Request", checkout_id) @@ -93,9 +102,13 @@ def verify_transaction(**kwargs): if transaction_response['ResultCode'] == 0: if transaction_data.reference_doctype and transaction_data.reference_docname: try: - frappe.get_doc(transaction_data.reference_doctype, + doc = frappe.get_doc(transaction_data.reference_doctype, transaction_data.reference_docname).run_method("on_payment_authorized", 'Completed') - request.process_response('error', transaction_response) + + item_response = transaction_response["CallbackMetadata"]["Item"] + mpesa_receipt = fetch_param_value(item_response, "MpesaReceiptNumber", "Name") + frappe.db.set_value("POS Invoice", doc.reference_docname, "mpesa_receipt_number", mpesa_receipt) + request.process_response('output', transaction_response) except Exception: request.process_response('error', transaction_response) frappe.log_error(frappe.get_traceback()) @@ -107,7 +120,7 @@ def verify_transaction(**kwargs): docname=transaction_data.reference_docname, user=request.owner, message=transaction_response) def get_account_balance(request_payload): - """ Call account balance API to send the request to the Mpesa Servers """ + """Call account balance API to send the request to the Mpesa Servers.""" try: mpesa_settings = frappe.get_doc("Mpesa Settings", request_payload.get("reference_docname")) env = "production" if not mpesa_settings.sandbox else "sandbox" @@ -115,8 +128,7 @@ def get_account_balance(request_payload): app_key=mpesa_settings.consumer_key, app_secret=mpesa_settings.get_password("consumer_secret")) - # callback_url = get_request_site_address(True) + "/api/method/erpnext.erpnext_integrations.doctype.mpesa_settings.mpesa_settings.process_balance_info" - callback_url = "https://b014ca8e7957.ngrok.io/api/method/erpnext.erpnext_integrations.doctype.mpesa_settings.mpesa_settings.process_balance_info" + callback_url = get_request_site_address(True) + "/api/method/erpnext.erpnext_integrations.doctype.mpesa_settings.mpesa_settings.process_balance_info" response = connector.get_balance(mpesa_settings.initiator_name, mpesa_settings.security_credential, mpesa_settings.till_number, 4, mpesa_settings.name, callback_url, callback_url) return response @@ -126,7 +138,8 @@ def get_account_balance(request_payload): @frappe.whitelist(allow_guest=True) def process_balance_info(**kwargs): - + """Process and store account balance information received via callback + from the account balance API call.""" account_balance_response = frappe._dict(kwargs["Result"]) conversation_id = getattr(account_balance_response, "ConversationID", "") @@ -141,10 +154,9 @@ def process_balance_info(**kwargs): if account_balance_response["ResultCode"] == 0: try: result_params = account_balance_response["ResultParameters"]["ResultParameter"] - for param in result_params: - if param["Key"] == "AccountBalance": - balance_info = param["Value"] - balance_info = convert_to_json(balance_info) + + balance_info = fetch_param_value(result_params, "AccountBalance", "Key") + balance_info = convert_to_json(balance_info) ref_doc = frappe.get_doc(transaction_data.reference_doctype, transaction_data.reference_docname) ref_doc.db_set("account_balance", balance_info) @@ -157,13 +169,28 @@ def process_balance_info(**kwargs): request.process_response('error', account_balance_response) def convert_to_json(balance_info): + """ + Convert string to json + + e.g: '''Working Account|KES|481000.00|481000.00|0.00|0.00''' + => {'Working Account': {'current_balance': '481000.00', + 'available_balance': '481000.00', + 'reserved_balance': '0.00', + 'uncleared_balance': '0.00'} + """ balance_dict = frappe._dict() for account_info in balance_info.split("&"): account_info = account_info.split('|') balance_dict[account_info[0]] = dict( - current_balance=account_info[2], - available_balance=account_info[3], - reserved_balance=account_info[4], - uncleared_balance=account_info[5] + current_balance=fmt_money(account_info[2], currency="KES"), + available_balance=fmt_money(account_info[3], currency="KES"), + reserved_balance=fmt_money(account_info[4], currency="KES"), + uncleared_balance=fmt_money(account_info[5], currency="KES") ) - return dumps(balance_dict) \ No newline at end of file + return dumps(balance_dict) + +def fetch_param_value(response, key, key_field): + """Fetch the specified key from list of dictionary. Key is identified via the key field""" + for param in response: + if param[key_field] == key: + return param["Value"] \ No newline at end of file From 1bef6a530ccddf486b8cbc65fe21db1c8b97c391 Mon Sep 17 00:00:00 2001 From: Mangesh-Khairnar Date: Sun, 4 Oct 2020 13:01:11 +0530 Subject: [PATCH 11/22] fix: create payment entry against payment request --- .../doctype/payment_entry/payment_entry.py | 16 ++++++------ .../payment_request/payment_request.py | 8 +++--- .../doctype/pos_invoice/pos_invoice.py | 7 ++++- erpnext/erpnext_integrations/utils.py | 8 +++--- .../selling/page/point_of_sale/pos_payment.js | 26 +++++++++---------- 5 files changed, 35 insertions(+), 30 deletions(-) diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py index 11ab02021be..b7d80ae6089 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py @@ -950,12 +950,12 @@ def get_reference_details(reference_doctype, reference_name, party_account_curre @frappe.whitelist() -def get_payment_entry(dt, dn, party_amount=None, bank_account=None, bank_amount=None): +def get_payment_entry(dt, dn, party_amount=None, bank_account=None, bank_amount=None, mode_of_payment=None): doc = frappe.get_doc(dt, dn) if dt in ("Sales Order", "Purchase Order") and flt(doc.per_billed, 2) > 0: frappe.throw(_("Can only make payment against unbilled {0}").format(dt)) - if dt in ("Sales Invoice", "Sales Order", "Dunning"): + if dt in ("Sales Invoice", "Sales Order", "Dunning", "POS Invoice"): party_type = "Customer" elif dt in ("Purchase Invoice", "Purchase Order"): party_type = "Supplier" @@ -965,7 +965,7 @@ def get_payment_entry(dt, dn, party_amount=None, bank_account=None, bank_amount= party_type = "Student" # party account - if dt == "Sales Invoice": + if dt in ["Sales Invoice", "POS Invoice"]: party_account = get_party_account_based_on_invoice_discounting(dn) or doc.debit_to elif dt == "Purchase Invoice": party_account = doc.credit_to @@ -984,7 +984,7 @@ def get_payment_entry(dt, dn, party_amount=None, bank_account=None, bank_amount= party_account_currency = doc.get("party_account_currency") or get_account_currency(party_account) # payment type - if (dt == "Sales Order" or (dt in ("Sales Invoice", "Fees", "Dunning") and doc.outstanding_amount > 0)) \ + if (dt == "Sales Order" or (dt in ("Sales Invoice", "Fees", "Dunning", "POS Invoice") and doc.outstanding_amount > 0)) \ or (dt=="Purchase Invoice" and doc.outstanding_amount < 0): payment_type = "Receive" else: @@ -994,7 +994,7 @@ def get_payment_entry(dt, dn, party_amount=None, bank_account=None, bank_amount= grand_total = outstanding_amount = 0 if party_amount: grand_total = outstanding_amount = party_amount - elif dt in ("Sales Invoice", "Purchase Invoice"): + elif dt in ("Sales Invoice", "Purchase Invoice", "POS Invoice"): if party_account_currency == doc.company_currency: grand_total = doc.base_rounded_total or doc.base_grand_total else: @@ -1021,11 +1021,11 @@ def get_payment_entry(dt, dn, party_amount=None, bank_account=None, bank_amount= outstanding_amount = grand_total - flt(doc.advance_paid) # bank or cash - bank = get_default_bank_cash_account(doc.company, "Bank", mode_of_payment=doc.get("mode_of_payment"), + bank = get_default_bank_cash_account(doc.company, "Bank", mode_of_payment=doc.get("mode_of_payment", mode_of_payment), account=bank_account) if not bank: - bank = get_default_bank_cash_account(doc.company, "Cash", mode_of_payment=doc.get("mode_of_payment"), + bank = get_default_bank_cash_account(doc.company, "Cash", mode_of_payment=doc.get("mode_of_payment", mode_of_payment), account=bank_account) paid_amount = received_amount = 0 @@ -1050,7 +1050,7 @@ def get_payment_entry(dt, dn, party_amount=None, bank_account=None, bank_amount= pe.company = doc.company pe.cost_center = doc.get("cost_center") pe.posting_date = nowdate() - pe.mode_of_payment = doc.get("mode_of_payment") + pe.mode_of_payment = doc.get("mode_of_payment", mode_of_payment) pe.party_type = party_type pe.party = doc.get(scrub(party_type)) pe.contact_person = doc.get("contact_person") diff --git a/erpnext/accounts/doctype/payment_request/payment_request.py b/erpnext/accounts/doctype/payment_request/payment_request.py index 8eba647c596..d01f2984458 100644 --- a/erpnext/accounts/doctype/payment_request/payment_request.py +++ b/erpnext/accounts/doctype/payment_request/payment_request.py @@ -165,7 +165,7 @@ class PaymentRequest(Document): ref_doc = frappe.get_doc(self.reference_doctype, self.reference_name) - if self.reference_doctype == "Sales Invoice": + if self.reference_doctype in ["Sales Invoice", "POS Invoice"]: party_account = ref_doc.debit_to elif self.reference_doctype == "Purchase Invoice": party_account = ref_doc.credit_to @@ -180,8 +180,8 @@ class PaymentRequest(Document): else: party_amount = self.grand_total - payment_entry = get_payment_entry(self.reference_doctype, self.reference_name, - party_amount=party_amount, bank_account=self.payment_account, bank_amount=bank_amount) + payment_entry = get_payment_entry(self.reference_doctype, self.reference_name, party_amount=party_amount, + bank_account=self.payment_account, bank_amount=bank_amount, mode_of_payment=self.mode_of_payment) payment_entry.update({ "reference_no": self.name, @@ -269,7 +269,7 @@ class PaymentRequest(Document): # if shopping cart enabled and in session if (shopping_cart_settings.enabled and hasattr(frappe.local, "session") - and frappe.local.session.user != "Guest"): + and frappe.local.session.user != "Guest") and self.payment_channel != "Phone": success_url = shopping_cart_settings.payment_success_url if success_url: diff --git a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py index 155b95e9d9e..73cf1188e23 100644 --- a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py +++ b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py @@ -316,8 +316,13 @@ class POSInvoice(SalesInvoice): def create_payment_request(self): for pay in self.payments: - if pay.type == "Phone": + if pay.amount <= 0: + frappe.throw(_("Payment amount cannot be less than or equal to 0")) + + if not self.contact_mobile: + frappe.throw(_("Please enter the phone number first")) + payment_gateway = frappe.db.get_value("Payment Gateway Account", { "payment_account": pay.account, }) diff --git a/erpnext/erpnext_integrations/utils.py b/erpnext/erpnext_integrations/utils.py index 78a5fced77c..e278fd78071 100644 --- a/erpnext/erpnext_integrations/utils.py +++ b/erpnext/erpnext_integrations/utils.py @@ -43,7 +43,7 @@ def get_webhook_address(connector_name, method, exclude_uri=False): return server_url -def create_mode_of_payment(gateway): +def create_mode_of_payment(gateway, payment_type="General"): payment_gateway_account = frappe.db.get_value("Payment Gateway Account", { "payment_gateway": gateway }, ['payment_account']) @@ -53,11 +53,11 @@ def create_mode_of_payment(gateway): "doctype": "Mode of Payment", "mode_of_payment": gateway, "enabled": 1, - "type": "General", - "account": { + "type": payment_type, + "accounts": [{ "doctype": "Mode of Payment Account", "company": get_default_company(), "default_account": payment_gateway_account - } + }] }) mode_of_payment.insert(ignore_permissions=True) \ No newline at end of file diff --git a/erpnext/selling/page/point_of_sale/pos_payment.js b/erpnext/selling/page/point_of_sale/pos_payment.js index 35cd408b53d..915564c0290 100644 --- a/erpnext/selling/page/point_of_sale/pos_payment.js +++ b/erpnext/selling/page/point_of_sale/pos_payment.js @@ -175,21 +175,21 @@ erpnext.PointOfSale.Payment = class { }) frappe.realtime.on("process_phone_payments", function(data) { - frappe.msgprint({message: 'help', title:'now'}) - // frappe.dom.unfreeze(); - // let message = data["ResultDesc"]; - // let title = __("Payment Failed"); - // const frm = me.events.get_frm(); + frappe.dom.unfreeze(); + let message = data["ResultDesc"]; + let title = __("Payment Failed"); + const frm = me.events.get_frm(); - // if (data["ResultCode"] == 0) { - // title = __("Payment Received"); - // $('[data-fieldname=request_for_payment]').text("Paid") - // } + if (data["ResultCode"] == 0) { + title = __("Payment Received"); + $('[data-fieldname=request_for_payment]').text("Paid") + cur_pos.submit() + } - // frappe.msgprint({ - // "message": message, - // "title": title - // }); + frappe.msgprint({ + "message": message, + "title": title + }); }); this.$payment_modes.on('click', '.shortcut', function(e) { From 8d54d61c283abdfc557506086467ddb5e414c1e1 Mon Sep 17 00:00:00 2001 From: Mangesh-Khairnar Date: Mon, 5 Oct 2020 22:37:31 +0530 Subject: [PATCH 12/22] fix: handle payment completion via payment request --- .../doctype/payment_entry/payment_entry.py | 16 +++--- .../payment_request/payment_request.js | 2 +- .../payment_request/payment_request.py | 14 ++++-- .../doctype/pos_invoice/pos_invoice.py | 14 ++++++ .../doctype/mpesa_settings/mpesa_settings.py | 15 +++--- .../selling/page/point_of_sale/pos_payment.js | 50 +++++++++---------- 6 files changed, 66 insertions(+), 45 deletions(-) diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py index b7d80ae6089..11ab02021be 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py @@ -950,12 +950,12 @@ def get_reference_details(reference_doctype, reference_name, party_account_curre @frappe.whitelist() -def get_payment_entry(dt, dn, party_amount=None, bank_account=None, bank_amount=None, mode_of_payment=None): +def get_payment_entry(dt, dn, party_amount=None, bank_account=None, bank_amount=None): doc = frappe.get_doc(dt, dn) if dt in ("Sales Order", "Purchase Order") and flt(doc.per_billed, 2) > 0: frappe.throw(_("Can only make payment against unbilled {0}").format(dt)) - if dt in ("Sales Invoice", "Sales Order", "Dunning", "POS Invoice"): + if dt in ("Sales Invoice", "Sales Order", "Dunning"): party_type = "Customer" elif dt in ("Purchase Invoice", "Purchase Order"): party_type = "Supplier" @@ -965,7 +965,7 @@ def get_payment_entry(dt, dn, party_amount=None, bank_account=None, bank_amount= party_type = "Student" # party account - if dt in ["Sales Invoice", "POS Invoice"]: + if dt == "Sales Invoice": party_account = get_party_account_based_on_invoice_discounting(dn) or doc.debit_to elif dt == "Purchase Invoice": party_account = doc.credit_to @@ -984,7 +984,7 @@ def get_payment_entry(dt, dn, party_amount=None, bank_account=None, bank_amount= party_account_currency = doc.get("party_account_currency") or get_account_currency(party_account) # payment type - if (dt == "Sales Order" or (dt in ("Sales Invoice", "Fees", "Dunning", "POS Invoice") and doc.outstanding_amount > 0)) \ + if (dt == "Sales Order" or (dt in ("Sales Invoice", "Fees", "Dunning") and doc.outstanding_amount > 0)) \ or (dt=="Purchase Invoice" and doc.outstanding_amount < 0): payment_type = "Receive" else: @@ -994,7 +994,7 @@ def get_payment_entry(dt, dn, party_amount=None, bank_account=None, bank_amount= grand_total = outstanding_amount = 0 if party_amount: grand_total = outstanding_amount = party_amount - elif dt in ("Sales Invoice", "Purchase Invoice", "POS Invoice"): + elif dt in ("Sales Invoice", "Purchase Invoice"): if party_account_currency == doc.company_currency: grand_total = doc.base_rounded_total or doc.base_grand_total else: @@ -1021,11 +1021,11 @@ def get_payment_entry(dt, dn, party_amount=None, bank_account=None, bank_amount= outstanding_amount = grand_total - flt(doc.advance_paid) # bank or cash - bank = get_default_bank_cash_account(doc.company, "Bank", mode_of_payment=doc.get("mode_of_payment", mode_of_payment), + bank = get_default_bank_cash_account(doc.company, "Bank", mode_of_payment=doc.get("mode_of_payment"), account=bank_account) if not bank: - bank = get_default_bank_cash_account(doc.company, "Cash", mode_of_payment=doc.get("mode_of_payment", mode_of_payment), + bank = get_default_bank_cash_account(doc.company, "Cash", mode_of_payment=doc.get("mode_of_payment"), account=bank_account) paid_amount = received_amount = 0 @@ -1050,7 +1050,7 @@ def get_payment_entry(dt, dn, party_amount=None, bank_account=None, bank_amount= pe.company = doc.company pe.cost_center = doc.get("cost_center") pe.posting_date = nowdate() - pe.mode_of_payment = doc.get("mode_of_payment", mode_of_payment) + pe.mode_of_payment = doc.get("mode_of_payment") pe.party_type = party_type pe.party = doc.get(scrub(party_type)) pe.contact_person = doc.get("contact_person") diff --git a/erpnext/accounts/doctype/payment_request/payment_request.js b/erpnext/accounts/doctype/payment_request/payment_request.js index e1e43140c01..5c7218608d2 100644 --- a/erpnext/accounts/doctype/payment_request/payment_request.js +++ b/erpnext/accounts/doctype/payment_request/payment_request.js @@ -25,7 +25,7 @@ frappe.ui.form.on("Payment Request", "onload", function(frm, dt, dn){ }) frappe.ui.form.on("Payment Request", "refresh", function(frm) { - if(frm.doc.payment_request_type == 'Inward' && + if(frm.doc.payment_request_type == 'Inward' && frm.doc.payment_channel === "Phone" && !in_list(["Initiated", "Paid"], frm.doc.status) && !frm.doc.__islocal && frm.doc.docstatus==1){ frm.add_custom_button(__('Resend Payment Email'), function(){ frappe.call({ diff --git a/erpnext/accounts/doctype/payment_request/payment_request.py b/erpnext/accounts/doctype/payment_request/payment_request.py index d01f2984458..ebe8cb1330e 100644 --- a/erpnext/accounts/doctype/payment_request/payment_request.py +++ b/erpnext/accounts/doctype/payment_request/payment_request.py @@ -86,6 +86,7 @@ class PaymentRequest(Document): payment_record = dict( reference_doctype="Payment Request", reference_docname=self.name, + payment_reference=self.reference_name, grand_total=self.grand_total, sender=self.email_to, currency=self.currency, @@ -154,10 +155,14 @@ class PaymentRequest(Document): }) def set_as_paid(self): - payment_entry = self.create_payment_entry() - self.make_invoice() + if self.payment_channel == "Phone": + self.db_set("status", "Paid") - return payment_entry + else: + payment_entry = self.create_payment_entry() + self.make_invoice() + + return payment_entry def create_payment_entry(self, submit=True): """create entry""" @@ -269,7 +274,7 @@ class PaymentRequest(Document): # if shopping cart enabled and in session if (shopping_cart_settings.enabled and hasattr(frappe.local, "session") - and frappe.local.session.user != "Guest") and self.payment_channel != "Phone": + and frappe.local.session.user != "Guest") and self.payment_channel != "Phone": success_url = shopping_cart_settings.payment_success_url if success_url: @@ -332,6 +337,7 @@ def make_payment_request(**args): "payment_request_type": args.get("payment_request_type"), "currency": ref_doc.currency, "grand_total": grand_total, + "mode_of_payment": args.mode_of_payment, "email_to": args.recipient_id or ref_doc.owner, "subject": _("Payment Request for {0}").format(args.dn), "message": gateway_account.get("message") or get_dummy_message(ref_doc), diff --git a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py index 73cf1188e23..1a304ca3a88 100644 --- a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py +++ b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py @@ -58,6 +58,7 @@ class POSInvoice(SalesInvoice): against_psi_doc.make_loyalty_point_entry() if self.redeem_loyalty_points and self.loyalty_points: self.apply_loyalty_points() + self.check_phone_payments() self.set_status(update=True) def on_cancel(self): @@ -70,6 +71,18 @@ class POSInvoice(SalesInvoice): against_psi_doc.delete_loyalty_point_entry() against_psi_doc.make_loyalty_point_entry() + def check_phone_payments(self): + for pay in self.payments: + if pay.type == "Phone" and pay.amount >= 0: + paid_amt = frappe.db.get_value("Payment Request", + filters=dict( + reference_doctype="POS Invoice", reference_name=self.name, + mode_of_payment=pay.mode_of_payment, status="Paid"), + fieldname="grand_total") + + if pay.amount != paid_amt: + return frappe.throw(_("Payment related to {0} is not completed").format(pay.mode_of_payment)) + def validate_stock_availablility(self): allow_negative_stock = frappe.db.get_value('Stock Settings', None, 'allow_negative_stock') @@ -333,6 +346,7 @@ class POSInvoice(SalesInvoice): "payment_request_type": "Inward", "party_type": "Customer", "party": self.customer, + "mode_of_payment": pay.mode_of_payment, "recipient_id": self.contact_mobile, "submit_doc": True } diff --git a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py index 8fc05c65b1e..6c36c16b619 100644 --- a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py +++ b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py @@ -76,7 +76,7 @@ def generate_stk_push(**kwargs): response = connector.stk_push(business_shortcode=mpesa_settings.till_number, passcode=mpesa_settings.get_password("online_passkey"), amount=args.grand_total, - callback_url=callback_url, reference_code=args.payment_request_name, + callback_url=callback_url, reference_code=mpesa_settings.till_number, phone_number=args.sender, description="POS Payment") return response @@ -100,14 +100,15 @@ def verify_transaction(**kwargs): transaction_data = frappe._dict(loads(request.data)) if transaction_response['ResultCode'] == 0: - if transaction_data.reference_doctype and transaction_data.reference_docname: + if request.reference_doctype and request.reference_docname: try: - doc = frappe.get_doc(transaction_data.reference_doctype, - transaction_data.reference_docname).run_method("on_payment_authorized", 'Completed') + doc = frappe.get_doc(request.reference_doctype, + request.reference_docname) + doc.run_method("on_payment_authorized", 'Completed') item_response = transaction_response["CallbackMetadata"]["Item"] mpesa_receipt = fetch_param_value(item_response, "MpesaReceiptNumber", "Name") - frappe.db.set_value("POS Invoice", doc.reference_docname, "mpesa_receipt_number", mpesa_receipt) + frappe.db.set_value("POS Invoice", doc.reference_name, "mpesa_receipt_number", mpesa_receipt) request.process_response('output', transaction_response) except Exception: request.process_response('error', transaction_response) @@ -116,8 +117,8 @@ def verify_transaction(**kwargs): else: request.process_response('error', transaction_response) - frappe.publish_realtime('process_phone_payment', after_commit=True, doctype=transaction_data.reference_doctype, - docname=transaction_data.reference_docname, user=request.owner, message=transaction_response) + frappe.publish_realtime('process_phone_payment', doctype="POS Invoice", + docname=transaction_data.payment_reference, user=request.owner, message=transaction_response) def get_account_balance(request_payload): """Call account balance API to send the request to the Mpesa Servers.""" diff --git a/erpnext/selling/page/point_of_sale/pos_payment.js b/erpnext/selling/page/point_of_sale/pos_payment.js index 915564c0290..b1f7de00db9 100644 --- a/erpnext/selling/page/point_of_sale/pos_payment.js +++ b/erpnext/selling/page/point_of_sale/pos_payment.js @@ -9,8 +9,8 @@ erpnext.PointOfSale.Payment = class { } init_component() { - this.prepare_dom(); - this.initialize_numpad(); + this.prepare_dom(); + this.initialize_numpad(); this.bind_events(); this.attach_shortcuts(); @@ -18,32 +18,32 @@ erpnext.PointOfSale.Payment = class { prepare_dom() { this.wrapper.append( - `
+ `
PAYMENT METHOD
-
-
-
-
-
-
-
-
-
-
- Complete Order +
+
+
+
+
+
+
+
+
+
+ Complete Order
-
-
-
-
` - ) - this.$component = this.wrapper.find('.payment-section'); + + + +
` + ) + this.$component = this.wrapper.find('.payment-section'); this.$payment_modes = this.$component.find('.payment-modes'); this.$totals_remarks = this.$component.find('.totals-remarks'); this.$totals = this.$component.find('.totals'); @@ -174,16 +174,16 @@ erpnext.PointOfSale.Payment = class { } }) - frappe.realtime.on("process_phone_payments", function(data) { + frappe.realtime.on("process_phone_payment", function(data) { + console.log('within') frappe.dom.unfreeze(); let message = data["ResultDesc"]; let title = __("Payment Failed"); - const frm = me.events.get_frm(); if (data["ResultCode"] == 0) { title = __("Payment Received"); - $('[data-fieldname=request_for_payment]').text("Paid") - cur_pos.submit() + $('.btn.btn-xs.btn-default[data-fieldname=request_for_payment]').html(`Payment Received`) + me.events.submit_invoice(); } frappe.msgprint({ @@ -527,5 +527,5 @@ erpnext.PointOfSale.Payment = class { toggle_component(show) { show ? this.$component.removeClass('d-none') : this.$component.addClass('d-none'); - } + } } \ No newline at end of file From 862433febb392bec6b81c1132bac363e1c9f4126 Mon Sep 17 00:00:00 2001 From: Mangesh-Khairnar Date: Tue, 6 Oct 2020 10:59:59 +0530 Subject: [PATCH 13/22] fix: create custom field for mpesa receipt --- .../doctype/mpesa_settings/mpesa_custom_fields.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_custom_fields.py b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_custom_fields.py index 5d32a1c8f90..0499e88b5e7 100644 --- a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_custom_fields.py +++ b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_custom_fields.py @@ -13,8 +13,8 @@ def create_custom_pos_fields(): "insert_after": "contact_email" }, { - "fieldname": "Mpesa Receipt Number", - "label": "mpesa_receipt_number", + "fieldname": "mpesa_receipt_number", + "label": "Mpesa Receipt Number", "fieldtype": "Data", "read_only": 1, "insert_after": "company" From faa0cce7a76613bd7a8277e2d94a8d2e3f2f3add Mon Sep 17 00:00:00 2001 From: Mangesh-Khairnar Date: Tue, 6 Oct 2020 13:58:54 +0530 Subject: [PATCH 14/22] fix: pass mobile number to generate the stk push --- .../doctype/mpesa_settings/mpesa_settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py index 6c36c16b619..15606acc957 100644 --- a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py +++ b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py @@ -77,7 +77,7 @@ def generate_stk_push(**kwargs): response = connector.stk_push(business_shortcode=mpesa_settings.till_number, passcode=mpesa_settings.get_password("online_passkey"), amount=args.grand_total, callback_url=callback_url, reference_code=mpesa_settings.till_number, - phone_number=args.sender, description="POS Payment") + phone_number=mobile_number, description="POS Payment") return response From 51228b5ee3ea5543486f68f036b21a2ab9165e3d Mon Sep 17 00:00:00 2001 From: Mangesh-Khairnar Date: Tue, 13 Oct 2020 17:10:16 +0530 Subject: [PATCH 15/22] fix: make response handling more descriptive --- .../doctype/payment_request/payment_request.py | 2 +- .../doctype/mpesa_settings/mpesa_settings.py | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/erpnext/accounts/doctype/payment_request/payment_request.py b/erpnext/accounts/doctype/payment_request/payment_request.py index ebe8cb1330e..51c090cea81 100644 --- a/erpnext/accounts/doctype/payment_request/payment_request.py +++ b/erpnext/accounts/doctype/payment_request/payment_request.py @@ -186,7 +186,7 @@ class PaymentRequest(Document): party_amount = self.grand_total payment_entry = get_payment_entry(self.reference_doctype, self.reference_name, party_amount=party_amount, - bank_account=self.payment_account, bank_amount=bank_amount, mode_of_payment=self.mode_of_payment) + bank_account=self.payment_account, bank_amount=bank_amount) payment_entry.update({ "reference_no": self.name, diff --git a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py index 15606acc957..8fe1972d14a 100644 --- a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py +++ b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py @@ -109,13 +109,13 @@ def verify_transaction(**kwargs): item_response = transaction_response["CallbackMetadata"]["Item"] mpesa_receipt = fetch_param_value(item_response, "MpesaReceiptNumber", "Name") frappe.db.set_value("POS Invoice", doc.reference_name, "mpesa_receipt_number", mpesa_receipt) - request.process_response('output', transaction_response) + request.handle_success(transaction_response) except Exception: - request.process_response('error', transaction_response) + request.handle_failure(transaction_response) frappe.log_error(frappe.get_traceback()) else: - request.process_response('error', transaction_response) + request.handle_failure(transaction_response) frappe.publish_realtime('process_phone_payment', doctype="POS Invoice", docname=transaction_data.payment_reference, user=request.owner, message=transaction_response) @@ -162,12 +162,12 @@ def process_balance_info(**kwargs): ref_doc = frappe.get_doc(transaction_data.reference_doctype, transaction_data.reference_docname) ref_doc.db_set("account_balance", balance_info) - request.process_response('output', account_balance_response) + request.handle_success(account_balance_response) except: - request.process_response('error', account_balance_response) + request.handle_failure(account_balance_response) frappe.log_error(title=_("Mpesa Account Balance Processing Error"), message=account_balance_response) else: - request.process_response('error', account_balance_response) + request.handle_failure(account_balance_response) def convert_to_json(balance_info): """ From cab5d22ef5cd9643e83f93c1e5005f4f09dac2fb Mon Sep 17 00:00:00 2001 From: Mangesh-Khairnar Date: Fri, 16 Oct 2020 11:38:07 +0530 Subject: [PATCH 16/22] fix: add a realtime publish to refresh the dashboard content --- .../doctype/mpesa_settings/mpesa_settings.js | 6 ++++++ .../doctype/mpesa_settings/mpesa_settings.py | 1 + erpnext/selling/page/point_of_sale/pos_payment.js | 1 - 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.js b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.js index 7742a45746e..c24a104fcae 100644 --- a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.js +++ b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.js @@ -6,6 +6,12 @@ frappe.ui.form.on('Mpesa Settings', { frm.events.setup_account_balance_html(frm); }, + refresh: function(frm) { + frappe.realtime.on("refresh_mpesa_dashboard", function(){ + frm.reload_doc(); + }); + }, + get_account_balance: function(frm) { if (!frm.initiator_name && !frm.security_credentials) return; frappe.call({ diff --git a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py index 8fe1972d14a..9d34a543e18 100644 --- a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py +++ b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py @@ -163,6 +163,7 @@ def process_balance_info(**kwargs): ref_doc.db_set("account_balance", balance_info) request.handle_success(account_balance_response) + frappe.publish_realtime("refresh_mpesa_dashboard") except: request.handle_failure(account_balance_response) frappe.log_error(title=_("Mpesa Account Balance Processing Error"), message=account_balance_response) diff --git a/erpnext/selling/page/point_of_sale/pos_payment.js b/erpnext/selling/page/point_of_sale/pos_payment.js index b1f7de00db9..20539039432 100644 --- a/erpnext/selling/page/point_of_sale/pos_payment.js +++ b/erpnext/selling/page/point_of_sale/pos_payment.js @@ -175,7 +175,6 @@ erpnext.PointOfSale.Payment = class { }) frappe.realtime.on("process_phone_payment", function(data) { - console.log('within') frappe.dom.unfreeze(); let message = data["ResultDesc"]; let title = __("Payment Failed"); From c66fce4965bf26b010677ddc2fee1c4729077695 Mon Sep 17 00:00:00 2001 From: Mangesh-Khairnar Date: Fri, 16 Oct 2020 12:03:08 +0530 Subject: [PATCH 17/22] fix: reload current pos doc after updating the information via callback --- .../doctype/mpesa_settings/mpesa_connector.py | 5 +---- .../doctype/mpesa_settings/mpesa_settings.py | 14 ++++++-------- erpnext/selling/page/point_of_sale/pos_payment.js | 1 + 3 files changed, 8 insertions(+), 12 deletions(-) diff --git a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_connector.py b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_connector.py index dced7b03975..d33b0a70894 100644 --- a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_connector.py +++ b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_connector.py @@ -18,10 +18,7 @@ class MpesaConnector(): def authenticate(self): """ - To make Mpesa API calls, you will need to authenticate your app. This method is used to fetch the access token - required by Mpesa. Mpesa supports client_credentials grant type. To authorize your API calls to Mpesa, - you will need a Basic Auth over HTTPS authorization token. The Basic Auth string is a base64 encoded string - of your app's client key and client secret. + This method is used to fetch the access token required by Mpesa. Returns: access_token (str): This token is to be used with the Bearer header for further API calls to Mpesa. diff --git a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py index 9d34a543e18..1d318bbf04f 100644 --- a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py +++ b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py @@ -43,8 +43,7 @@ class MpesaSettings(Document): self.handle_api_response("ConversationID", payload, response) def handle_api_response(self, global_id, request_dict, response): - """Response received from API calls returns a global identifier for each transaction, - this code is returned during the callback""" + """Response received from API calls returns a global identifier for each transaction, this code is returned during the callback.""" # check error response if getattr(response, "requestId"): req_name = getattr(response, "requestId") @@ -91,7 +90,7 @@ def sanitize_mobile_number(number): @frappe.whitelist(allow_guest=True) def verify_transaction(**kwargs): - """ Verify the transaction result received via callback from stk """ + """Verify the transaction result received via callback from stk.""" transaction_response = frappe._dict(kwargs["Body"]["stkCallback"]) frappe.logger().debug(transaction_response) @@ -139,8 +138,7 @@ def get_account_balance(request_payload): @frappe.whitelist(allow_guest=True) def process_balance_info(**kwargs): - """Process and store account balance information received via callback - from the account balance API call.""" + """Process and store account balance information received via callback from the account balance API call.""" account_balance_response = frappe._dict(kwargs["Result"]) conversation_id = getattr(account_balance_response, "ConversationID", "") @@ -164,7 +162,7 @@ def process_balance_info(**kwargs): request.handle_success(account_balance_response) frappe.publish_realtime("refresh_mpesa_dashboard") - except: + except Exception: request.handle_failure(account_balance_response) frappe.log_error(title=_("Mpesa Account Balance Processing Error"), message=account_balance_response) else: @@ -172,7 +170,7 @@ def process_balance_info(**kwargs): def convert_to_json(balance_info): """ - Convert string to json + Convert string to json. e.g: '''Working Account|KES|481000.00|481000.00|0.00|0.00''' => {'Working Account': {'current_balance': '481000.00', @@ -192,7 +190,7 @@ def convert_to_json(balance_info): return dumps(balance_dict) def fetch_param_value(response, key, key_field): - """Fetch the specified key from list of dictionary. Key is identified via the key field""" + """Fetch the specified key from list of dictionary. Key is identified via the key field.""" for param in response: if param[key_field] == key: return param["Value"] \ No newline at end of file diff --git a/erpnext/selling/page/point_of_sale/pos_payment.js b/erpnext/selling/page/point_of_sale/pos_payment.js index 20539039432..ec886d7957b 100644 --- a/erpnext/selling/page/point_of_sale/pos_payment.js +++ b/erpnext/selling/page/point_of_sale/pos_payment.js @@ -176,6 +176,7 @@ erpnext.PointOfSale.Payment = class { frappe.realtime.on("process_phone_payment", function(data) { frappe.dom.unfreeze(); + cur_frm.reload_doc(); let message = data["ResultDesc"]; let title = __("Payment Failed"); From ebb8ee3dc6a6c623e82659f76f6dac4d4200321e Mon Sep 17 00:00:00 2001 From: Mangesh-Khairnar Date: Mon, 19 Oct 2020 18:27:40 +0530 Subject: [PATCH 18/22] fix(payment gateway account): add patch to set the payment channel as email --- .../doctype/payment_request/payment_request.js | 2 +- erpnext/patches.txt | 1 + ...ayment_channel_in_payment_gateway_account.py | 17 +++++++++++++++++ 3 files changed, 19 insertions(+), 1 deletion(-) create mode 100644 erpnext/patches/v13_0/set_payment_channel_in_payment_gateway_account.py diff --git a/erpnext/accounts/doctype/payment_request/payment_request.js b/erpnext/accounts/doctype/payment_request/payment_request.js index 5c7218608d2..901ef1987b4 100644 --- a/erpnext/accounts/doctype/payment_request/payment_request.js +++ b/erpnext/accounts/doctype/payment_request/payment_request.js @@ -25,7 +25,7 @@ frappe.ui.form.on("Payment Request", "onload", function(frm, dt, dn){ }) frappe.ui.form.on("Payment Request", "refresh", function(frm) { - if(frm.doc.payment_request_type == 'Inward' && frm.doc.payment_channel === "Phone" && + if(frm.doc.payment_request_type == 'Inward' && frm.doc.payment_channel !== "Phone" && !in_list(["Initiated", "Paid"], frm.doc.status) && !frm.doc.__islocal && frm.doc.docstatus==1){ frm.add_custom_button(__('Resend Payment Email'), function(){ frappe.call({ diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 6087ce29aa5..43564d8267f 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -729,3 +729,4 @@ erpnext.patches.v13_0.setting_custom_roles_for_some_regional_reports erpnext.patches.v13_0.rename_issue_doctype_fields erpnext.patches.v13_0.change_default_pos_print_format erpnext.patches.v13_0.set_youtube_video_id +erpnext.patches.v13_0.set_payment_channel_in_payment_gateway_account \ No newline at end of file diff --git a/erpnext/patches/v13_0/set_payment_channel_in_payment_gateway_account.py b/erpnext/patches/v13_0/set_payment_channel_in_payment_gateway_account.py new file mode 100644 index 00000000000..edca2383930 --- /dev/null +++ b/erpnext/patches/v13_0/set_payment_channel_in_payment_gateway_account.py @@ -0,0 +1,17 @@ +from __future__ import unicode_literals +import frappe + +def execute(): + """Set the payment gateway account as Email for all the existing payment channel.""" + doc_meta = frappe.get_meta("Payment Gateway Account") + if doc_meta.get_field("payment_channel"): + return + + frappe.reload_doc("Accounts", "doctype", "Payment Gateway Account") + set_payment_channel_as_email() + +def set_payment_channel_as_email(): + frappe.db.sql(""" + UPDATE `tabPayment Gateway Account` + SET `payment_channel` = "Email" + """) \ No newline at end of file From c8a04fec35880e13e39b5c0b1fd9417eba356713 Mon Sep 17 00:00:00 2001 From: Mangesh-Khairnar Date: Wed, 21 Oct 2020 14:07:00 +0530 Subject: [PATCH 19/22] fix(mpesa-settings): add test cases to verify transactions --- .../doctype/mpesa_settings/mpesa_settings.py | 30 ++- .../mpesa_settings/test_mpesa_settings.py | 236 +++++++++++++++++- 2 files changed, 254 insertions(+), 12 deletions(-) diff --git a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py index 1d318bbf04f..dea4d817701 100644 --- a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py +++ b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.py @@ -26,11 +26,19 @@ class MpesaSettings(Document): def on_update(self): create_custom_pos_fields() create_payment_gateway('Mpesa-' + self.payment_gateway_name, settings='Mpesa Settings', controller=self.payment_gateway_name) - create_mode_of_payment('Mpesa-' + self.payment_gateway_name, payment_type="Phone") call_hook_method('payment_gateway_enabled', gateway='Mpesa-' + self.payment_gateway_name, payment_channel="Phone") + # required to fetch the bank account details from the payment gateway account + frappe.db.commit() + create_mode_of_payment('Mpesa-' + self.payment_gateway_name, payment_type="Phone") + def request_for_payment(self, **kwargs): - response = frappe._dict(generate_stk_push(**kwargs)) + if frappe.flags.in_test: + from erpnext.erpnext_integrations.doctype.mpesa_settings.test_mpesa_settings import get_payment_request_response_payload + response = frappe._dict(get_payment_request_response_payload()) + else: + response = frappe._dict(generate_stk_push(**kwargs)) + self.handle_api_response("CheckoutRequestID", kwargs, response) def get_account_balance_info(self): @@ -39,7 +47,13 @@ class MpesaSettings(Document): reference_docname=self.name, doc_details=vars(self) ) - response = frappe._dict(get_account_balance(payload)) + + if frappe.flags.in_test: + from erpnext.erpnext_integrations.doctype.mpesa_settings.test_mpesa_settings import get_test_account_balance_response + response = frappe._dict(get_test_account_balance_response()) + else: + response = frappe._dict(get_account_balance(payload)) + self.handle_api_response("ConversationID", payload, response) def handle_api_response(self, global_id, request_dict, response): @@ -92,7 +106,6 @@ def sanitize_mobile_number(number): def verify_transaction(**kwargs): """Verify the transaction result received via callback from stk.""" transaction_response = frappe._dict(kwargs["Body"]["stkCallback"]) - frappe.logger().debug(transaction_response) checkout_id = getattr(transaction_response, "CheckoutRequestID", "") request = frappe.get_doc("Integration Request", checkout_id) @@ -148,14 +161,13 @@ def process_balance_info(**kwargs): return transaction_data = frappe._dict(loads(request.data)) - frappe.logger().debug(account_balance_response) if account_balance_response["ResultCode"] == 0: try: result_params = account_balance_response["ResultParameters"]["ResultParameter"] balance_info = fetch_param_value(result_params, "AccountBalance", "Key") - balance_info = convert_to_json(balance_info) + balance_info = format_string_to_json(balance_info) ref_doc = frappe.get_doc(transaction_data.reference_doctype, transaction_data.reference_docname) ref_doc.db_set("account_balance", balance_info) @@ -168,15 +180,15 @@ def process_balance_info(**kwargs): else: request.handle_failure(account_balance_response) -def convert_to_json(balance_info): +def format_string_to_json(balance_info): """ - Convert string to json. + Format string to json. e.g: '''Working Account|KES|481000.00|481000.00|0.00|0.00''' => {'Working Account': {'current_balance': '481000.00', 'available_balance': '481000.00', 'reserved_balance': '0.00', - 'uncleared_balance': '0.00'} + 'uncleared_balance': '0.00'}} """ balance_dict = frappe._dict() for account_info in balance_info.split("&"): diff --git a/erpnext/erpnext_integrations/doctype/mpesa_settings/test_mpesa_settings.py b/erpnext/erpnext_integrations/doctype/mpesa_settings/test_mpesa_settings.py index 4aa970ef8a1..55ccff30fe2 100644 --- a/erpnext/erpnext_integrations/doctype/mpesa_settings/test_mpesa_settings.py +++ b/erpnext/erpnext_integrations/doctype/mpesa_settings/test_mpesa_settings.py @@ -2,9 +2,239 @@ # Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors # See license.txt from __future__ import unicode_literals - -# import frappe +from json import dumps +import frappe import unittest +from erpnext.erpnext_integrations.doctype.mpesa_settings.mpesa_settings import process_balance_info, verify_transaction +from erpnext.accounts.doctype.pos_invoice.test_pos_invoice import create_pos_invoice class TestMpesaSettings(unittest.TestCase): - pass + def test_creation_of_payment_gateway(self): + mpesa_doc = create_mpesa_settings(payment_gateway_name="_Test") + + mode_of_payment = frappe.get_doc("Mode of Payment", "Mpesa-_Test") + self.assertTrue(frappe.db.exists("Payment Gateway Account", {'payment_gateway': "Mpesa-_Test"})) + self.assertTrue(mode_of_payment.name) + self.assertEquals(mode_of_payment.type, "Phone") + + def test_processing_of_account_balance(self): + mpesa_doc = create_mpesa_settings(payment_gateway_name="_Account Balance") + mpesa_doc.get_account_balance_info() + + callback_response = get_account_balance_callback_payload() + process_balance_info(**callback_response) + integration_request = frappe.get_doc("Integration Request", "AG_20200927_00007cdb1f9fb6494315") + + # test integration request creation and successful update of the status on receiving callback response + self.assertTrue(integration_request) + self.assertEquals(integration_request.status, "Completed") + + # test formatting of account balance received as string to json with appropriate currency symbol + mpesa_doc.reload() + self.assertEquals(mpesa_doc.account_balance, dumps({ + "Working Account": { + "current_balance": "Sh 481,000.00", + "available_balance": "Sh 481,000.00", + "reserved_balance": "Sh 0.00", + "uncleared_balance": "Sh 0.00" + } + })) + + def test_processing_of_callback_payload(self): + mpesa_doc = create_mpesa_settings(payment_gateway_name="Payment") + mpesa_account = frappe.db.get_value("Payment Gateway Account", {"payment_gateway": 'Mpesa-Payment'}, "payment_account") + frappe.db.set_value("Account", mpesa_account, "account_currency", "KES") + + pos_invoice = create_pos_invoice(do_not_submit=1) + pos_invoice.append("payments", {'mode_of_payment': 'Mpesa-Payment', 'account': mpesa_account, 'amount': 500}) + pos_invoice.contact_mobile = "093456543894" + pos_invoice.currency = "KES" + pos_invoice.save() + + pr = pos_invoice.create_payment_request() + # test payment request creation + self.assertEquals(pr.payment_gateway, "Mpesa-Payment") + + callback_response = get_payment_callback_payload() + verify_transaction(**callback_response) + # test creation of integration request + integration_request = frappe.get_doc("Integration Request", "ws_CO_061020201133231972") + + # test integration request creation and successful update of the status on receiving callback response + self.assertTrue(integration_request) + self.assertEquals(integration_request.status, "Completed") + + pos_invoice.reload() + integration_request.reload() + self.assertEquals(pos_invoice.mpesa_receipt_number, "LGR7OWQX0R") + self.assertEquals(integration_request.status, "Completed") + +def create_mpesa_settings(payment_gateway_name="Express"): + if frappe.db.exists("Mpesa Settings", payment_gateway_name): + return frappe.get_doc("Mpesa Settings", payment_gateway_name) + + doc = frappe.get_doc(dict( + doctype="Mpesa Settings", + payment_gateway_name=payment_gateway_name, + consumer_key="5sMu9LVI1oS3oBGPJfh3JyvLHwZOdTKn", + consumer_secret="VI1oS3oBGPJfh3JyvLHw", + online_passkey="LVI1oS3oBGPJfh3JyvLHwZOd", + till_number="174379" + )) + + doc.insert(ignore_permissions=True) + return doc + +def get_test_account_balance_response(): + """Response received after calling the account balance API.""" + return { + "ResultType":0, + "ResultCode":0, + "ResultDesc":"The service request has been accepted successfully.", + "OriginatorConversationID":"10816-694520-2", + "ConversationID":"AG_20200927_00007cdb1f9fb6494315", + "TransactionID":"LGR0000000", + "ResultParameters":{ + "ResultParameter":[ + { + "Key":"ReceiptNo", + "Value":"LGR919G2AV" + }, + { + "Key":"Conversation ID", + "Value":"AG_20170727_00004492b1b6d0078fbe" + }, + { + "Key":"FinalisedTime", + "Value":20170727101415 + }, + { + "Key":"Amount", + "Value":10 + }, + { + "Key":"TransactionStatus", + "Value":"Completed" + }, + { + "Key":"ReasonType", + "Value":"Salary Payment via API" + }, + { + "Key":"TransactionReason" + }, + { + "Key":"DebitPartyCharges", + "Value":"Fee For B2C Payment|KES|33.00" + }, + { + "Key":"DebitAccountType", + "Value":"Utility Account" + }, + { + "Key":"InitiatedTime", + "Value":20170727101415 + }, + { + "Key":"Originator Conversation ID", + "Value":"19455-773836-1" + }, + { + "Key":"CreditPartyName", + "Value":"254708374149 - John Doe" + }, + { + "Key":"DebitPartyName", + "Value":"600134 - Safaricom157" + } + ] + }, + "ReferenceData":{ + "ReferenceItem":{ + "Key":"Occasion", + "Value":"aaaa" + } + } + } + +def get_payment_request_response_payload(): + """Response received after successfully calling the stk push process request API.""" + return { + "MerchantRequestID": "8071-27184008-1", + "CheckoutRequestID": "ws_CO_061020201133231972", + "ResultCode": 0, + "ResultDesc": "The service request is processed successfully.", + "CallbackMetadata": { + "Item": [ + { "Name": "Amount", "Value": 500.0 }, + { "Name": "MpesaReceiptNumber", "Value": "LGR7OWQX0R" }, + { "Name": "TransactionDate", "Value": 20201006113336 }, + { "Name": "PhoneNumber", "Value": 254723575670 } + ] + } + } + + +def get_payment_callback_payload(): + """Response received from the server as callback after calling the stkpush process request API.""" + return { + "Body":{ + "stkCallback":{ + "MerchantRequestID":"19465-780693-1", + "CheckoutRequestID":"ws_CO_061020201133231972", + "ResultCode":0, + "ResultDesc":"The service request is processed successfully.", + "CallbackMetadata":{ + "Item":[ + { + "Name":"Amount", + "Value":500 + }, + { + "Name":"MpesaReceiptNumber", + "Value":"LGR7OWQX0R" + }, + { + "Name":"Balance" + }, + { + "Name":"TransactionDate", + "Value":20170727154800 + }, + { + "Name":"PhoneNumber", + "Value":254721566839 + } + ] + } + } + } + } + +def get_account_balance_callback_payload(): + """Response received from the server as callback after calling the account balance API.""" + return { + "Result":{ + "ResultType": 0, + "ResultCode": 0, + "ResultDesc": "The service request is processed successfully.", + "OriginatorConversationID": "16470-170099139-1", + "ConversationID": "AG_20200927_00007cdb1f9fb6494315", + "TransactionID": "OIR0000000", + "ResultParameters": { + "ResultParameter": [ + { + "Key": "AccountBalance", + "Value": "Working Account|KES|481000.00|481000.00|0.00|0.00" + }, + { "Key": "BOCompletedTime", "Value": 20200927234123 } + ] + }, + "ReferenceData": { + "ReferenceItem": { + "Key": "QueueTimeoutURL", + "Value": "https://internalsandbox.safaricom.co.ke/mpesa/abresults/v1/submit" + } + } + } + } \ No newline at end of file From 1a6d82a447d7348ca2a06d343e8c7b5203e198fa Mon Sep 17 00:00:00 2001 From: Mangesh-Khairnar Date: Thu, 22 Oct 2020 01:07:49 +0530 Subject: [PATCH 20/22] fix(payment-request): only consider paid transactions --- erpnext/accounts/doctype/payment_request/payment_request.py | 2 +- .../doctype/mpesa_settings/mpesa_settings.js | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/erpnext/accounts/doctype/payment_request/payment_request.py b/erpnext/accounts/doctype/payment_request/payment_request.py index 51c090cea81..cb58f89dfb9 100644 --- a/erpnext/accounts/doctype/payment_request/payment_request.py +++ b/erpnext/accounts/doctype/payment_request/payment_request.py @@ -400,7 +400,7 @@ def get_existing_payment_request_amount(ref_dt, ref_dn): reference_doctype = %s and reference_name = %s and docstatus = 1 - and status != 'Paid' + and status = 'Paid' """, (ref_dt, ref_dn)) return flt(existing_payment_request_amount[0][0]) if existing_payment_request_amount else 0 diff --git a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.js b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.js index c24a104fcae..a7e6dec4d3a 100644 --- a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.js +++ b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.js @@ -21,6 +21,7 @@ frappe.ui.form.on('Mpesa Settings', { }, setup_account_balance_html: function(frm) { + if (!frm.doc.account_balance) return; $("div").remove(".form-dashboard-section.custom"); frm.dashboard.add_section( frappe.render_template('account_balance', { From e03a02d9c0ede3e6d021e8baae47777bc04ba411 Mon Sep 17 00:00:00 2001 From: Mangesh-Khairnar Date: Fri, 23 Oct 2020 13:38:55 +0530 Subject: [PATCH 21/22] fix: show descriptive message for missing fields --- .../doctype/payment_request/test_payment_request.py | 1 + .../doctype/mpesa_settings/mpesa_settings.js | 4 +++- .../doctype/mpesa_settings/test_mpesa_settings.py | 6 +++--- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/erpnext/accounts/doctype/payment_request/test_payment_request.py b/erpnext/accounts/doctype/payment_request/test_payment_request.py index 8a10e2cbd95..747bad8bb59 100644 --- a/erpnext/accounts/doctype/payment_request/test_payment_request.py +++ b/erpnext/accounts/doctype/payment_request/test_payment_request.py @@ -127,6 +127,7 @@ class TestPaymentRequest(unittest.TestCase): recipient_id="nabin@erpnext.com", return_doc=1) pr1.grand_total = 200 pr1.submit() + pr1.set_as_paid() # Make a 2nd Payment Request pr2 = make_payment_request(dt="Sales Order", dn=so.name, diff --git a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.js b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.js index a7e6dec4d3a..636aa99de44 100644 --- a/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.js +++ b/erpnext/erpnext_integrations/doctype/mpesa_settings/mpesa_settings.js @@ -13,7 +13,9 @@ frappe.ui.form.on('Mpesa Settings', { }, get_account_balance: function(frm) { - if (!frm.initiator_name && !frm.security_credentials) return; + if (!frm.initiator_name && !frm.security_credentials) { + frappe.throw(__("Please set the initiator name and the security credential")); + } frappe.call({ method: "get_account_balance_info", doc: frm.doc diff --git a/erpnext/erpnext_integrations/doctype/mpesa_settings/test_mpesa_settings.py b/erpnext/erpnext_integrations/doctype/mpesa_settings/test_mpesa_settings.py index 55ccff30fe2..4e86d365e36 100644 --- a/erpnext/erpnext_integrations/doctype/mpesa_settings/test_mpesa_settings.py +++ b/erpnext/erpnext_integrations/doctype/mpesa_settings/test_mpesa_settings.py @@ -10,7 +10,7 @@ from erpnext.accounts.doctype.pos_invoice.test_pos_invoice import create_pos_inv class TestMpesaSettings(unittest.TestCase): def test_creation_of_payment_gateway(self): - mpesa_doc = create_mpesa_settings(payment_gateway_name="_Test") + create_mpesa_settings(payment_gateway_name="_Test") mode_of_payment = frappe.get_doc("Mode of Payment", "Mpesa-_Test") self.assertTrue(frappe.db.exists("Payment Gateway Account", {'payment_gateway': "Mpesa-_Test"})) @@ -41,7 +41,7 @@ class TestMpesaSettings(unittest.TestCase): })) def test_processing_of_callback_payload(self): - mpesa_doc = create_mpesa_settings(payment_gateway_name="Payment") + create_mpesa_settings(payment_gateway_name="Payment") mpesa_account = frappe.db.get_value("Payment Gateway Account", {"payment_gateway": 'Mpesa-Payment'}, "payment_account") frappe.db.set_value("Account", mpesa_account, "account_currency", "KES") @@ -73,7 +73,7 @@ def create_mpesa_settings(payment_gateway_name="Express"): if frappe.db.exists("Mpesa Settings", payment_gateway_name): return frappe.get_doc("Mpesa Settings", payment_gateway_name) - doc = frappe.get_doc(dict( + doc = frappe.get_doc(dict( #nosec doctype="Mpesa Settings", payment_gateway_name=payment_gateway_name, consumer_key="5sMu9LVI1oS3oBGPJfh3JyvLHwZOdTKn", From 675f79920e99013917bb271415c230f3baf4a534 Mon Sep 17 00:00:00 2001 From: Mangesh-Khairnar Date: Fri, 23 Oct 2020 15:41:05 +0530 Subject: [PATCH 22/22] fix: consider the existing paid payment request for phone payment channel --- .../accounts/doctype/payment_request/payment_request.py | 8 +++++++- .../doctype/payment_request/test_payment_request.py | 1 - 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/erpnext/accounts/doctype/payment_request/payment_request.py b/erpnext/accounts/doctype/payment_request/payment_request.py index cb58f89dfb9..1b97050eb13 100644 --- a/erpnext/accounts/doctype/payment_request/payment_request.py +++ b/erpnext/accounts/doctype/payment_request/payment_request.py @@ -393,6 +393,10 @@ def get_amount(ref_doc, payment_account=None): frappe.throw(_("Payment Entry is already created")) def get_existing_payment_request_amount(ref_dt, ref_dn): + """ + Get the existing payment request which are unpaid or partially paid for payment channel other than Phone + and get the summation of existing paid payment request for Phone payment channel. + """ existing_payment_request_amount = frappe.db.sql(""" select sum(grand_total) from `tabPayment Request` @@ -400,7 +404,9 @@ def get_existing_payment_request_amount(ref_dt, ref_dn): reference_doctype = %s and reference_name = %s and docstatus = 1 - and status = 'Paid' + and (status != 'Paid' + or (payment_channel = 'Phone' + and status = 'Paid')) """, (ref_dt, ref_dn)) return flt(existing_payment_request_amount[0][0]) if existing_payment_request_amount else 0 diff --git a/erpnext/accounts/doctype/payment_request/test_payment_request.py b/erpnext/accounts/doctype/payment_request/test_payment_request.py index 747bad8bb59..8a10e2cbd95 100644 --- a/erpnext/accounts/doctype/payment_request/test_payment_request.py +++ b/erpnext/accounts/doctype/payment_request/test_payment_request.py @@ -127,7 +127,6 @@ class TestPaymentRequest(unittest.TestCase): recipient_id="nabin@erpnext.com", return_doc=1) pr1.grand_total = 200 pr1.submit() - pr1.set_as_paid() # Make a 2nd Payment Request pr2 = make_payment_request(dt="Sales Order", dn=so.name,