From 97ab96c8bfec84a519dfcc8464834db3187b4cfd Mon Sep 17 00:00:00 2001 From: Mangesh-Khairnar Date: Tue, 22 Sep 2020 12:58:32 +0530 Subject: [PATCH] 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 dcf302db6e..41a135fb05 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 51ac7cfbfa..f6acd7236a 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 9252f5dc26..d79cdaa539 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 8a1c1912cf..48e0c0bd35 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 fb48cb5ff7..c92c1b23bc 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