From 8d12c3841f7956b287ead3ce2bbcd5a7ab411429 Mon Sep 17 00:00:00 2001 From: Mangesh-Khairnar Date: Mon, 28 Sep 2020 19:40:42 +0530 Subject: [PATCH] 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 bedf5e5eef..c43cb794aa 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 4780688471..1cff3c661d 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 d79cdaa539..dced7b0397 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 0d3912e34d..5d32a1c8f9 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 239a0bc9b2..7742a45746 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 3af0baaa50..8fc05c65b1 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