From 9d5fb6805b9b52da6ac74489bd1d452c93cfaf51 Mon Sep 17 00:00:00 2001 From: khushal Date: Sat, 7 Dec 2024 17:52:51 +0530 Subject: [PATCH] (20241207) Worked on telegram Bot and Safaricom's M-Pesa payments. --- models/data/api/__init__.py | 0 models/data/database/__init__.py | 0 models/data/database/auth_token.py | 186 ++++++++ models/data/database/message.py | 145 +++++++ models/data/database/payment.py | 163 +++++++ utils_v2/api/async_quart.py | 2 +- .../safaricom/models/behaviour/__init__.py | 0 .../models/behaviour/m_pesa_express.py | 404 ++++++++++++++++++ .../data/api_call.py} | 72 ++-- .../data/{m_pesa_express.py => auth.py} | 58 ++- 10 files changed, 983 insertions(+), 47 deletions(-) create mode 100644 models/data/api/__init__.py create mode 100644 models/data/database/__init__.py create mode 100644 models/data/database/auth_token.py create mode 100644 models/data/database/message.py create mode 100644 models/data/database/payment.py create mode 100644 utils_v2/payments/safaricom/models/behaviour/__init__.py create mode 100644 utils_v2/payments/safaricom/models/behaviour/m_pesa_express.py rename utils_v2/payments/safaricom/{m_pesa_express.py => models/data/api_call.py} (65%) rename utils_v2/payments/safaricom/models/data/{m_pesa_express.py => auth.py} (89%) diff --git a/models/data/api/__init__.py b/models/data/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/models/data/database/__init__.py b/models/data/database/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/models/data/database/auth_token.py b/models/data/database/auth_token.py new file mode 100644 index 0000000..9b9222c --- /dev/null +++ b/models/data/database/auth_token.py @@ -0,0 +1,186 @@ +""" + + AUTHOR: + + Khushal P Soonderji + + DATE: + + Saturday, 7th Dec., 2024. + + OBJECTIVE: + + To define how messages will be stored in the database. + + REFERENCES: + + N/A + + DOWNLOADS: + + N/A + +""" + + +# ***************************************************************************************************************** +# ***** **** +# *** IMPORT *** +# ***** **** +# ***************************************************************************************************************** + + +# To make sibling directories accessible for imports: +import sys +sys.path.append(".") +sys.path.append("..") + +# For making data behaviour_models: +from pydantic import BaseModel, Field, field_validator, PastDatetime, AwareDatetime +from typing import Optional, Literal, Union + +# My utils: +from utils_v2.string import regex +from utils_v2.date_time import date_time + +# To work with MongoDB: +from bson.objectid import ObjectId + +# To work with date and time: +import datetime + + +# ***************************************************************************************************************** +# ***** **** +# *** MACROS / ONE-TIME INIT *** +# ***** **** +# ***************************************************************************************************************** + + +# --- Nothing Yet + + +# ***************************************************************************************************************** +# ***** **** +# *** VARIABLES *** +# ***** **** +# ***************************************************************************************************************** + + +# --- Nothing Yet + + +# ***************************************************************************************************************** +# ***** **** +# *** FUNCTIONS *** +# ***** **** +# ***************************************************************************************************************** + + +class CoreMessageModel(BaseModel): + + version: str = Field( + description = "a hint about the version no. of this message", + min_length = 1, + frozen = True, + default = "1.0.0" + ) + + ts: AwareDatetime = Field( + description = "the time (utc) at which this message was sent by the sender", + frozen = True + ) + + readTs: AwareDatetime = Field( + description = "the time (utc) at which this message was read and stored by your server", + frozen = True, + default_factory = lambda: date_time.get_current_utc_date_time(as_string = False) + ) + + tokenId: ObjectId = Field( + description = "the id of the auth token that is associated with this message", + frozen = True + ) + + serviceType: Literal["email", "sms", "chat"] = Field( + description = "the kind of service this message was sent/received from", + frozen = True + ) + + client: Literal[ + "gmail", + "outlook", + "telegram", + "whatsapp", + "nimbusSmsIndia", + "savvyBulkSmsKenya" + ] = Field( + description = "the third-part client that was used", + frozen = True + ) + + clientMessageId: str | int = Field( + description = "how the client identifies this message", + frozen = True + ) + + clientThreadId: str | int | None = Field( + description = "how the client identifies the chat/thread in which this message was sent/received", + frozen = True, + default = None + ) + + payload: dict = Field( + description = "the actual contents of the message", + frozen = True + ) + + # ┏┓ ┏• + # ┃ ┏┓┏┓╋┓┏┓ + # ┗┛┗┛┛┗┛┗┗┫ + # ┛ + + class Config: + extra = "allow" + arbitrary_types_allowed = True + + # ┓┏ ┓• ┓ • + # ┃┃┏┓┃┓┏┫┏┓╋┓┏┓┏┓ + # ┗┛┗┻┗┗┗┻┗┻┗┗┗┛┛┗ + + @field_validator("ts", "readTs", mode = "before") + def parse_date_time(cls, value): + return date_time.parse_date_time(input_value = value, timezone = date_time.TIMEZONE_UTC) + + @field_validator("tokenId", mode = "before") + def parse_oid(cls, value): + try: value = ObjectId(value) + except: pass + return value + + +# ***************************************************************************************************************** +# ***** **** +# *** MAIN PROGRAM *** +# ***** **** +# ***************************************************************************************************************** + + +if __name__ == "__main__": + from utils_v2.string import json + + message = CoreMessageModel( + ts = date_time.get_current_utc_date_time(as_string = False), + tokenId = "67519cf3a7804fcbc6f12452", + serviceType = "email", + client = "gmail", + clientMessageId = 123, + clientThreadId = 456, + payload = { + "from": "bhopli@gmil.com", + "to": "hello@thecaoffice.com", + "message": "Hello, World!" + } + ) + + print("MESSAGE MODEL:", json.to_string(message.model_dump(), default = str)) diff --git a/models/data/database/message.py b/models/data/database/message.py new file mode 100644 index 0000000..1edc15c --- /dev/null +++ b/models/data/database/message.py @@ -0,0 +1,145 @@ +""" + + AUTHOR: + + Khushal P Soonderji + + DATE: + + Friday, 6th Dec., 2024. + + OBJECTIVE: + + To provide a structure to receive auth details of various chat apps (like Telegram and WhatsApp). + + REFERENCES: + + N/A + + DOWNLOADS: + + N/A + +""" + + +# ***************************************************************************************************************** +# ***** **** +# *** IMPORT *** +# ***** **** +# ***************************************************************************************************************** + + +# To make sibling directories accessible for imports: +import sys +sys.path.append(".") +sys.path.append("..") + +# For making data behaviour_models: +from pydantic import BaseModel, Field, field_validator, PastDatetime +from typing import Optional, Literal, Union + +# My utils: +from utils_v2.string import regex +from utils_v2.date_time import date_time + +# To work with date and time: +import datetime + + +# ***************************************************************************************************************** +# ***** **** +# *** MACROS / ONE-TIME INIT *** +# ***** **** +# ***************************************************************************************************************** + + +# RegEx Patterns: +REGEX_SESSION_TOKEN = r"^[a-f0-9]{8}-[a-f0-9]{4}-[1-5][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$" + + +# ***************************************************************************************************************** +# ***** **** +# *** VARIABLES *** +# ***** **** +# ***************************************************************************************************************** + + +# --- Nothing Yet + + +# ***************************************************************************************************************** +# ***** **** +# *** FUNCTIONS *** +# ***** **** +# ***************************************************************************************************************** + + +class TelegramAuth(BaseModel): + + botToken: str = Field( + description = "the token granted by BotFather", + min_length = 1, + frozen = True + ) + + # ┏┓ ┏• + # ┃ ┏┓┏┓╋┓┏┓ + # ┗┛┗┛┛┗┛┗┗┫ + # ┛ + + class Config: + extra = "forbid" + + +# --------------------------------------------------------------------------------------------------------------------- + + +class ChatAuthRequestHeaders(BaseModel): + + sessionToken: str = Field( + description = "the session token of the user who is requesting the service", + pattern = REGEX_SESSION_TOKEN, + frozen = True, + alias = "X-Session-Token" + ) + + # ┏┓ ┏• + # ┃ ┏┓┏┓╋┓┏┓ + # ┗┛┗┛┛┗┛┗┗┫ + # ┛ + + class Config: + extra = "allow" + + def model_dump(self, *args, **kwargs): + return super().model_dump(*args, by_alias = True, **kwargs) + + +# --------------------------------------------------------------------------------------------------------------------- + + +class ChatAuthRequestData(BaseModel): + + chatClient: Literal["telegram", "whatsapp"] = Field(alias = "client") + auth: Union[TelegramAuth] + + # ┏┓ ┏• + # ┃ ┏┓┏┓╋┓┏┓ + # ┗┛┗┛┛┗┛┗┗┫ + # ┛ + + class Config: + extra = "forbid" + + +# ***************************************************************************************************************** +# ***** **** +# *** MAIN PROGRAM *** +# ***** **** +# ***************************************************************************************************************** + + +if __name__ == "__main__": + + pass diff --git a/models/data/database/payment.py b/models/data/database/payment.py new file mode 100644 index 0000000..59123a8 --- /dev/null +++ b/models/data/database/payment.py @@ -0,0 +1,163 @@ +""" + + AUTHOR: + + Khushal P Soonderji + + DATE: + + Saturday, 7th Dec., 2024. + + OBJECTIVE: + + To define how messages will be stored in the database. + + REFERENCES: + + N/A + + DOWNLOADS: + + N/A + +""" + + +# ***************************************************************************************************************** +# ***** **** +# *** IMPORT *** +# ***** **** +# ***************************************************************************************************************** + + +# To make sibling directories accessible for imports: +import sys +sys.path.append(".") +sys.path.append("..") + +# For making data behaviour_models: +from pydantic import BaseModel, Field, field_validator, PastDatetime, AwareDatetime +from typing import Optional, Literal, Union + +# My utils: +from utils_v2.string import regex +from utils_v2.date_time import date_time + +# To work with MongoDB: +from bson.objectid import ObjectId + +# To work with date and time: +import datetime + + +# ***************************************************************************************************************** +# ***** **** +# *** MACROS / ONE-TIME INIT *** +# ***** **** +# ***************************************************************************************************************** + + +# --- Nothing Yet + + +# ***************************************************************************************************************** +# ***** **** +# *** VARIABLES *** +# ***** **** +# ***************************************************************************************************************** + + +# --- Nothing Yet + + +# ***************************************************************************************************************** +# ***** **** +# *** FUNCTIONS *** +# ***** **** +# ***************************************************************************************************************** + + +class CoreMessageModel(BaseModel): + + version: str = Field( + description = "a hint about the version no. of this message", + min_length = 1, + frozen = True, + default = "1.0.0" + ) + + ts: AwareDatetime = Field( + description = "the time (utc) at which this message was sent by the sender", + frozen = True + ) + + readTs: AwareDatetime = Field( + description = "the time (utc) at which this message was read and stored by your server", + frozen = True + ) + + tokenId: ObjectId = Field( + description = "the id of the auth token that is associated with this message", + frozen = True + ) + + serviceType: Literal["email", "sms", "chat"] = Field( + description = "the kind of service this message was sent/received from", + frozen = True + ) + + client: str = Field( + description = "the third-part client that was used", + frozen = True, + examples = ["gmail", "outlook", "telegram", "whatsapp"] + ) + + clientMessageId: str | int = Field( + description = "how the client identifies this message", + frozen = True + ) + + clientThreadId: str | int | None = Field( + description = "how the client identifies the chat/thread in which this message was sent/received", + frozen = True, + default = None + ) + + payload: dict = Field( + description = "the actual contents of the message", + frozen = True + ) + + # ┏┓ ┏• + # ┃ ┏┓┏┓╋┓┏┓ + # ┗┛┗┛┛┗┛┗┗┫ + # ┛ + + class Config: + extra = "allow" + + # ┓┏ ┓• ┓ • + # ┃┃┏┓┃┓┏┫┏┓╋┓┏┓┏┓ + # ┗┛┗┻┗┗┗┻┗┻┗┗┗┛┛┗ + + @field_validator("ts", "readTs", mode = "before") + def parse_date_time(cls, value): + return date_time.parse_date_time(input_value = value, timezone = date_time.TIMEZONE_UTC) + + @field_validator("tokenId", mode = "before") + def parse_oid(cls, value): + try: value = ObjectId(value) + except: pass + return value + + +# ***************************************************************************************************************** +# ***** **** +# *** MAIN PROGRAM *** +# ***** **** +# ***************************************************************************************************************** + + +if __name__ == "__main__": + + pass diff --git a/utils_v2/api/async_quart.py b/utils_v2/api/async_quart.py index 5331eda..99915da 100644 --- a/utils_v2/api/async_quart.py +++ b/utils_v2/api/async_quart.py @@ -358,7 +358,7 @@ def messages_from_pydantic_exception(exception, as_str = True, sep = ", "): def summarize_variable( value, - str_limit = 100, + str_limit = 1024, expand: bool | int = False, sensitive_keys: list[str] = None ): diff --git a/utils_v2/payments/safaricom/models/behaviour/__init__.py b/utils_v2/payments/safaricom/models/behaviour/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/utils_v2/payments/safaricom/models/behaviour/m_pesa_express.py b/utils_v2/payments/safaricom/models/behaviour/m_pesa_express.py new file mode 100644 index 0000000..d23cec7 --- /dev/null +++ b/utils_v2/payments/safaricom/models/behaviour/m_pesa_express.py @@ -0,0 +1,404 @@ +""" + + AUTHOR: + + Khushal P Soonderji + + DATE: + + Saturday, 7th Dec., 2024 + + OBJECTIVE: + + To make payments requests from M-PESA Express. + + REFERENCES: + + 1. Simulator & Docs: https://developer.safaricom.co.ke/APIs/MpesaExpressSimulate + 2. Postman Collection: https://api.postman.com/collections/4395533-1a8f1c81-0502-4f9d-8699-d45551834b7d?access_key=PMAT-01J8R72MBSHP5CJ4J9Q46TG6G9 + + DOWNLOADS: + + N/A + +""" + + +# ***************************************************************************************************************** +# ***** **** +# *** IMPORT *** +# ***** **** +# ***************************************************************************************************************** + + +# To make sibling directories accessible for imports: +import sys +sys.path.append(".") +sys.path.append("..") + +# System-level activities: +import io + +# My utils: +from utils_v2.string import json +from utils_v2.date_time import date_time + +# Data models: +from utils_v2.payments.safaricom.models.data.auth import MPesaExpressAuthorization +from utils_v2.payments.safaricom.models.data.api_call import MPesaExpressApiResponse + +# To make REST-ful requests: +import httpx + +# To work with datatypes: +from typing import Literal + +# To work with date and time: +import datetime + +# Misc: +import base64 + +# For debugging: +from icecream import IceCreamDebugger +import inspect + + +# ***************************************************************************************************************** +# ***** **** +# *** MACROS / ONE-TIME INIT *** +# ***** **** +# ***************************************************************************************************************** + + +# --- Nothing Yet + + +# ***************************************************************************************************************** +# ***** **** +# *** VARIABLES *** +# ***** **** +# ***************************************************************************************************************** + + +# --- Nothing Yet + + +# ***************************************************************************************************************** +# ***** **** +# *** FUNCTIONS *** +# ***** **** +# ***************************************************************************************************************** + + +# --- Nothing Yet + + +# ***************************************************************************************************************** +# ***** **** +# *** CLASSES *** +# ***** **** +# ***************************************************************************************************************** + + +class MPesaExpress: + + def __init__( + self, + auth: MPesaExpressAuthorization, + http_client: httpx.AsyncClient = None, + debug = True, + debug_prefix = "M-Pesa Exp | ", + debug_only_errors = True + ): + + # Prepare the debugging utility: + self.__debug_prefix = debug_prefix + self.__printer = IceCreamDebugger(prefix = debug_prefix, includeContext = True) + if not debug: self.__printer.disable() + self.__debug_only_errors = debug_only_errors + + # Accept the input configuration: + self.__auth = auth + + # Accept/create an HTTP client to work with: + if http_client: self.__http_client = http_client + else: self.__http_client = httpx.AsyncClient( + limits = httpx.Limits( + max_connections = 100, # ............ Maximum number of connections allowed in the pool. + max_keepalive_connections = 50, # ... Maximum number of connections that can be kept alive. + ), + timeout = httpx.Timeout( + pool = 120.0, # .... Time to wait for a free connection from the pool. + connect = 5.0, # ... Time to wait for establishing a connection to the server. + write = 10.0, # .... Time to wait for sending data. + read = 120.0 # ..... Time to wait for receiving data. + ) + ) + + def enable_debug(self): + self.__printer.enable() + + def disable_debug(self): + self.__printer.disable() + + def debug_only_errors(self): + self.__debug_only_errors = True + + def debug_everything(self): + self.__debug_only_errors = False + + # ┏┓┏┓┳ ┏┓ ┓┓• + # ┣┫┃┃┃ ┃ ┏┓┃┃┓┏┓┏┓ + # ┛┗┣┛┻ ┗┛┗┻┗┗┗┛┗┗┫ + # ┛ + + async def __get( + self, + url: str, + headers: dict = None, + params: dict = None + ) -> MPesaExpressApiResponse: + + """ + To call an API using the GET method. + :param url: The URL to call. + :param headers: The headers to pass. + :param params: The params to send in the query string itself. + :return: A structured response that includes the raw response, the exception (if any), and so on. + """ + + # Prepare the structure of the response: + api_response = MPesaExpressApiResponse( + action = inspect.stack()[1].function, + url = url, + method = "GET" + ) + + try: + + # Make the API call: + response = await self.__http_client.get( + url = url, + headers = headers, + params = params + ) + + # Note down the results: + api_response.response = response + api_response.httpCode = response.status_code + api_response.message = response.reason_phrase + + # If something goes wrong: + except Exception as exception: + api_response.exception = exception + api_response.message = str(exception) + self.__printer(exception, api_response.url, api_response.method, headers, params) + + # Done here: + return api_response + + async def __post( + self, + url: str, + headers: dict = None, + json: dict = None, + data: dict = None, + params: dict = None, + content: str | bytes = None, + files: dict = None + ) -> MPesaExpressApiResponse: + + """ + To call an API using the POST method. + :param url: The URL to call. + :param headers: The headers to pass. + :param json: The params to send in the JSON body. + :param data: The params to send in the form-data in the body. + :param params: The params to send in the query string itself. + :param content: The raw content to be sent in the body (typically as an octet-stream). + :param files: Any file that you may want to send. + :return: A structured response that includes the raw response, the exception (if any), and so on. + """ + + # Prepare the structure of the response: + api_response = MPesaExpressApiResponse( + action = inspect.stack()[1].function, + url = url, + method = "POST" + ) + + try: + + # Make the API call: + response = await self.__http_client.post( + url = url, + headers = headers, + json = json, + data = data, + params = params, + content = content, + files = files + ) + + # Note down the results: + api_response.response = response + api_response.httpCode = response.status_code + api_response.message = response.reason_phrase + + # If something goes wrong: + except Exception as exception: + api_response.exception = exception + api_response.message = str(exception) + self.__printer(exception, api_response.url, api_response.method, headers, json, data) + + # Done here: + return api_response + + # ┓┏ ┓ + # ┣┫┏┓┃┏┓┏┓┏┓┏ + # ┛┗┗ ┗┣┛┗ ┛ ┛ + # ┛ + + def generate_password( + self, + timestamp: str + ) -> str: + + """ + The password is a combination of the short code, the app's passkey, and the timestamp in base64 + encoded string. + DOCUMENTATION: + 1. https://developer.safaricom.co.ke/APIs/Authorization + :param timestamp: The time (YYYYMMDDHHmmss) at which the request is being made. + :return: The base-64 encoded string that has to be used as the password. + """ + + if not self.__debug_only_errors: + self.__printer("Generating Password") + + if timestamp is None: timestamp = "" + if self.__auth.appPasskey is None: self.__auth.appPasskey = "" + if self.__auth.businessShortCode is None: self.__auth.businessShortCode = "" + return base64.b64encode((self.__auth.businessShortCode + self.__auth.appPasskey + timestamp).encode()).decode() + + # ┏┓ + # ┃┃┏┓┓┏┏┳┓┏┓┏┓╋┏ + # ┣┛┗┻┗┫┛┗┗┗ ┛┗┗┛ + # ┛ + + async def request_payment( + self, + amount: float | int, + party_a: str, + type: Literal["CustomerPayBillOnline", "CustomerBuyGoodsOnline"], + reference: str, + description: str, + callback_url: str = None, + payer_no: str = None, + party_b: str = None, + ) -> MPesaExpressApiResponse: + + """ + To request a payment from a user. When this method is called, the user's phone will immediately receive a flash + message with the payment details and payment options. The user then gets to choose his action. + DOCUMENTATION: + 1. https://developer.safaricom.co.ke/APIs/MpesaExpressSimulate + :param amount: The amount in Kenyan Shillings that the user must pay. + :param party_a: The phone no. that will make the payment. Needs to be a valid Safaricom no. of the format + 2547xxxxxxxx and must be registered with M-Pesa. + :param type: "CustomerPayBillOnline" for PayBill nos. and "CustomerBuyGoodsOnline" for Till nos. + :param reference: A reference id from your system (not Safaricom's system) for you to identify this transaction. + This value will be displayed to the paying customer. Can be max. of 12 characters long. + :param description: A description about the payment. Can be max. of 13 characters long. + :param callback_url: The URL that will receive a webhook callback when the customer either pays or declines the + payment request. If not provided, the callback URL from the auth details will be used. + :param payer_no: The phone no. that shall receive the payment prompt. If not provided, the value of 'party_a' + will be copied here. + :param party_b: The organization that receives the funds. If not provided, the Business Short Code from the auth + details will be used. + :return: + """ + + # Prepare the inputs: + request_ts = datetime.datetime.now().strftime("%Y%m%d%H%M%S") + access_token = await self.__auth.get_access_token(http_client = self.__http_client, force_refresh = False) + input_headers = {"Authorization": f"Bearer {access_token}"} + input_json = { + "BusinessShortCode": self.__auth.businessShortCode, + "Password": self.generate_password(timestamp = request_ts), + "Timestamp": request_ts, + "TransactionType": type, + "PartyA": party_a, + "PhoneNumber": payer_no or party_a, + "Amount": str(int(amount)), + "PartyB": party_b or self.__auth.businessShortCode, + "CallBackURL": callback_url or self.__auth.callbackUrl, + "AccountReference": reference, + "TransactionDesc": description[:13] if len(description) > 13 else description + } + + # Make the API call: + api_response = await self.__post( + url = r"https://api.safaricom.co.ke/mpesa/stkpush/v1/processrequest", + headers = input_headers, + json = input_json + ) + + # If the call failed: + if api_response.httpCode == 400: + api_json = await api_response.get_json() + api_response.message = f"{api_json['errorCode']} -> {api_json['errorMessage']}" + + # If the call failed: + if api_response.httpCode in [200]: + api_json = await api_response.get_json() + api_response.message = api_json.get("ResponseDescription", "N/A") + api_response.data = api_json + api_response.success = True + + # Done here: + return api_response + + +# ***************************************************************************************************************** +# ***** **** +# *** MAIN PROGRAM *** +# ***** **** +# ***************************************************************************************************************** + + +if __name__ == "__main__": + + import asyncio + + m_pesa_auth = MPesaExpressAuthorization( + consumerKey = "kFiHZ3G1vCqxkQfHgMZzPvkPd5ilsJD3", + consumerSecret = "NIp2mp1V0cSEQ63G", + businessShortCode = "4092041", + appPasskey = "cf5c0f05298e63b4039c60e3fd12c2f72e1adac840d3dbd68c88a33b43dbef82", + callbackUrl = None + ) + + my_m_pesa = MPesaExpress( + auth = m_pesa_auth + ) + + async def main(): + + # Make the request: + response = await my_m_pesa.request_payment( + amount = 1.00, + party_a = "254748877373", + type = "CustomerPayBillOnline", + reference = "TestTransactionTXN12345678", + description = "Some description about the payment reason...", + callback_url = r"https://api.thecaoffice.com/converse/test/callback", + ) + + # Show the response: + print("SUMMARY:", response.to_markdown(), "\n\n---\n\n") + if response.success: print("DATA:", json.to_string(response.data, default = str)) + else: print("JSON:", json.to_string(await response.get_json(), default = str)) + + asyncio.run(main()) diff --git a/utils_v2/payments/safaricom/m_pesa_express.py b/utils_v2/payments/safaricom/models/data/api_call.py similarity index 65% rename from utils_v2/payments/safaricom/m_pesa_express.py rename to utils_v2/payments/safaricom/models/data/api_call.py index 48b4553..3b386b4 100644 --- a/utils_v2/payments/safaricom/m_pesa_express.py +++ b/utils_v2/payments/safaricom/models/data/api_call.py @@ -6,16 +6,15 @@ DATE: - Wednesday, 4th Dec., 2024 + Saturday, 7th Dec., 2024. OBJECTIVE: - To make payments requests from M-PESA Express. + To provide a data model for describing the API response from Safaricom's M-Pesa Express APIs. REFERENCES: - 1. Simulator & Docs: https://developer.safaricom.co.ke/APIs/MpesaExpressSimulate - 2. Postman Collection: https://api.postman.com/collections/4395533-1a8f1c81-0502-4f9d-8699-d45551834b7d?access_key=PMAT-01J8R72MBSHP5CJ4J9Q46TG6G9 + N/A DOWNLOADS: @@ -36,21 +35,13 @@ import sys sys.path.append(".") sys.path.append("..") -# System-level activities: -import io +# For making data behaviour_models: +from pydantic import BaseModel, Field, field_validator, model_validator +from typing import Optional, Literal, Union, Dict, List, Any # My utils: from utils_v2.string import json -from utils_v2.date_time import date_time - -# To make REST-ful requests: -import requests - -# To work with datatypes: -from typing import Literal - -# Misc: -import base64 +from utils_v2.string import regex # ***************************************************************************************************************** @@ -80,20 +71,51 @@ import base64 # ***************************************************************************************************************** -# --- Nothing Yet +class MPesaExpressApiResponse(BaseModel): + action: str = Field(frozen = True, default = None) + url: str = Field(frozen = True) + method: str = Field(frozen = True) + response: Any = None + httpCode: int = None -# ***************************************************************************************************************** -# ***** **** -# *** CLASSES *** -# ***** **** -# ***************************************************************************************************************** + success: bool = False + message: str = None + data: Any = None + exception: Any = None -class MPesaExpress: + # ┏┓ ┏• + # ┃ ┏┓┏┓╋┓┏┓ + # ┗┛┗┛┛┗┛┗┗┫ + # ┛ - def __init__(self): - pass + class Config: + extra = "forbid" + + # ┏┓ ┏┓ + # ┃ ┓┏┏╋┏┓┏┳┓ ┣ ┓┏┏┓┏┏ + # ┗┛┗┻┛┗┗┛┛┗┗ ┻ ┗┻┛┗┗┛ + + def to_markdown(self): + if self.exception: message = "❌ *M-PESA API EXCEPTION:* ❌\n\n" + else: message = "*M-PESA API RESPONSE:*\n\n" + message += f"*ACTION:*\n`{self.action}`\n\n" + message += f"*URL:*\n`{self.url}`\n\n" + message += f"*METHOD:*\n`{self.method}`\n\n" + message += f"*RESPONSE:*\n`{self.response}`\n\n" + message += f"*SUCCESS:*\n`{self.success}`\n\n" + message += f"*MESSAGE:*\n`{self.message}`\n\n" + message += f"*EXCEPTION:*\n`{self.exception.__class__.__name__}: {str(self.exception)}`\n\n" + return message + + async def get_json(self): + try: return self.response.json() + except: return {} + + async def get_content(self): + try: return self.response.content + except: return b"" # ***************************************************************************************************************** diff --git a/utils_v2/payments/safaricom/models/data/m_pesa_express.py b/utils_v2/payments/safaricom/models/data/auth.py similarity index 89% rename from utils_v2/payments/safaricom/models/data/m_pesa_express.py rename to utils_v2/payments/safaricom/models/data/auth.py index dfef608..2baf7cf 100644 --- a/utils_v2/payments/safaricom/models/data/m_pesa_express.py +++ b/utils_v2/payments/safaricom/models/data/auth.py @@ -101,7 +101,7 @@ class MPesaExpressAuthorization(BaseModel): frozen = True ) - shortCode: str = Field( + businessShortCode: str = Field( description = "your app's business short code; found in 'my apps'", frozen = True ) @@ -111,10 +111,11 @@ class MPesaExpressAuthorization(BaseModel): frozen = True ) - callbackUrl: str = Field( + callbackUrl: str | None = Field( description = "the url for m-pesa to inform you about a successful or failed payment", frozen = True, - pattern = regex.REGEX_HTTPS_URL + pattern = regex.REGEX_HTTPS_URL, + default = None ) # Don't pass these values from outside, @@ -170,22 +171,17 @@ class MPesaExpressAuthorization(BaseModel): # ┃ ┓┏┏╋┏┓┏┳┓ ┣ ┓┏┏┓┏┏ # ┗┛┗┻┛┗┗┛┛┗┗ ┻ ┗┻┛┗┗┛ - async def get_access_token( + async def refresh( self, - http_client: httpx.AsyncClient = None - ) -> str | None: + http_client: httpx.AsyncClient = None, + force_refresh: bool = False + ) -> bool: - """ - If you just pick the 'accessToken' variable, you may get a stale token. Using this method will ensure that the - token is refreshed, if needed, before being handed to you. - :param http_client: A preset HTTP client to use. If not given, one will be created inside and used to make the - API call. Giving it from outside could save time in setting up a new client. You may start passing this when - you notice delays. - :return: Either a valid access token or None if the refreshing fails. - """ + # Start by assuming failure: + token_refreshed = False - # If the token is stale: - if self.expired: + # If the token is stale or a refresh is being forced: + if self.expired or force_refresh: # Prepare the inputs: url = M_PESA_EXPRESS_AUTH_API_URL @@ -193,7 +189,7 @@ class MPesaExpressAuthorization(BaseModel): input_headers = {"Authorization": "Basic " + key} input_params = {"grant_type": "client_credentials"} - # make the API call: + # Make the API call: if http_client: api_response = await http_client.get( url = url, @@ -214,9 +210,29 @@ class MPesaExpressAuthorization(BaseModel): self.accessToken = api_json.get("access_token") self.lastRefresh = date_time.get_current_utc_date_time() self.expiresAt = self.lastRefresh + datetime.timedelta(seconds = int(0.95 * float(api_json.get("expires_in")))) + token_refreshed = True - # Now that refreshing attempt is done: - return None if self.expired else self.accessToken + # Done here: + return token_refreshed + + async def get_access_token( + self, + http_client: httpx.AsyncClient = None, + force_refresh: bool = False + ) -> str | None: + + """ + If you just pick the 'accessToken' variable, you may get a stale token. Using this method will ensure that the + token is refreshed, if needed, before being handed to you. + :param http_client: A preset HTTP client to use. If not given, one will be created inside and used to make the + API call. Giving it from outside could save time in setting up a new client. You may start passing this when + you notice delays. + :param force_refresh: Use this to force a token refresh even if it hasn't expired yet. + :return: Either a valid access token or None if the refreshing fails. + """ + + await self.refresh(http_client = http_client, force_refresh = force_refresh) + return self.accessToken # ***************************************************************************************************************** @@ -233,9 +249,9 @@ if __name__ == "__main__": auth = MPesaExpressAuthorization( consumerKey = "kFiHZ3G1vCqxkQfHgMZzPvkPd5ilsJD3", consumerSecret = "NIp2mp1V0cSEQ63G", - shortCode = "4092041", + businessShortCode = "4092041", appPasskey = "cf5c0f05298e63b4039c60e3fd12c2f72e1adac840d3dbd68c88a33b43dbef82", - callbackUrl = "https://www.something.otherthing.com/my/callback/path?with=params" + # callbackUrl = "https://www.something.otherthing.com/my/callback/path?with=params" ) print("AUTH:", auth.model_dump_json(indent = 4))