diff --git a/api/blueprints/finstitutions/payments/callback.py b/api/blueprints/finstitutions/payments/callback.py new file mode 100644 index 0000000..42e9208 --- /dev/null +++ b/api/blueprints/finstitutions/payments/callback.py @@ -0,0 +1,194 @@ +""" + + AUTHOR: + + Khushal P Soonderji + + DATE: + + Monday, 16th Dec., 2024 + + OBJECTIVE: + + To receive payment updates from various financial institutions. + + REFERENCES: + + N/A + + DOWNLOADS: + + N/A + + NOTES: + + N/A + +""" + + +# ***************************************************************************************************************** +# ***** **** +# *** IMPORT *** +# ***** **** +# ***************************************************************************************************************** + + +# To make sibling directories accessible for imports: +import sys +sys.path.append(".") +sys.path.append("..") + +# For using Quart: +from quart import Blueprint, current_app, request + +# My utils: +from utils_v2.string import json +from utils_v2.api.codes import StatusCodes, HttpCodes +from utils_v2.api.response import ResponseModel +from utils_v2.api.async_quart import ( + set_api_version, + read_input, + get_session_info, + log_request_to_mongo, + log_chain_to_mongo, + should_not_be_under_maintenance, + only_whitelisted_ips, + limit_rate, + validate_input, + handle_cancelled_request +) + +# Common: +from shared import constants + +# To work with MongoDB: +from bson.objectid import ObjectId + +# Data Models: +from models.api.finstitutions.payments.request import PGPaymentRequestHeaders, PGPaymentRequestData +from models.core.auth_token import CoreAuthTokenModel +from models.core.payment import CorePaymentModel, PaymentEvent + +# For asynchronous activities: +import asyncio + + +# ***************************************************************************************************************** +# ***** **** +# *** MACROS / ONE-TIME INIT *** +# ***** **** +# ***************************************************************************************************************** + + +# Related to Quart: +pg_callback_bp = Blueprint("pg_callback", __name__) + + +# ***************************************************************************************************************** +# ***** **** +# *** VARIABLES *** +# ***** **** +# ***************************************************************************************************************** + + +# --- Nothing Yet + + +# ***************************************************************************************************************** +# ***** **** +# *** FUNCTIONS *** +# ***** **** +# ***************************************************************************************************************** + + +@pg_callback_bp.record_once +def init(blueprint_setup_state): + + # This gets called when the blueprint is registered. + # Consider this to be a one-time setup for the whole blueprint: + pass + + +# --------------------------------------------------------------------------------------------------------------------- + + +@pg_callback_bp.route("/callback/", methods = ["POST"]) +@set_api_version(api_version = "1.0.0") +@read_input(sanitize_headers = False, sanitize_data = False) +@log_request_to_mongo( + attr_name = "logs_mongo", + project = constants.PROJECT_NAME, + log_type = constants.MODULE_NAME, + operation = "pgClbkApi", + log_input = True, + log_output = True, + sensitive_keys = None +) +@log_chain_to_mongo(attr_name = "logs_mongo") +@should_not_be_under_maintenance(attr_name = "is_under_maintenance") +@handle_cancelled_request() +async def payment_event_callback( + payment_id: str = None, + inbound_headers: dict = None, + inbound_data: dict = None, + inbound_files: dict = None, + **kwargs +): + + """ + We receive payment updates for various payment gateways here. + :param payment_id: The id of the document in MongoDB that holds the reference to the payment. + :param inbound_headers: auto-extracted by the decorators. + :param inbound_data: auto-extracted by the decorators. + :param inbound_files: auto-extracted by the decorators. + :param kwargs: Any number of extra inputs supplied by the decorators. + :return: A standard response structure. + """ + + # ┏┓ ┏┓ ┳ ┏ + # ┃┓┏┓╋ ┃┃┏┓┓┏┏┳┓┏┓┏┓╋ ┃┏┓╋┏┓ + # ┗┛┗ ┗ ┣┛┗┻┗┫┛┗┗┗ ┛┗┗ ┻┛┗┛┗┛ + # ┛ + + # payment_info = await current_app.payment_controller.get_payment_internal( + # mongo_conn = current_app.data_mongo, + # payment_id = ObjectId(payment_id) + # ) + # print("PAYMENT INFO:", json.to_string(payment_info.model_dump(), default=str)) + + # For now, we just insert the event into the record: + event_note_success = await current_app.payment_controller.add_event( + mongo_conn = current_app.data_mongo, + payment_id = ObjectId(payment_id), + event = PaymentEvent( + paymentStatus = "unknown", + initByPG = True, + httpCode = None, + headers = inbound_headers, + payload = inbound_data + ) + ) + + # ┳┓ + # ┣┫┏┓┏┏┓┏┓┏┓┏┏┓ + # ┛┗┗ ┛┣┛┗┛┛┗┛┗ + # ┛ + + # Done here: + return ResponseModel( + status_code = StatusCodes.OK if event_note_success else StatusCodes.FAILED, + http_code = HttpCodes.NOT_IMPLEMENTED + ) + + +# ***************************************************************************************************************** +# ***** **** +# *** MAIN PROGRAM *** +# ***** **** +# ***************************************************************************************************************** + + +if __name__ == "__main__": + + pass diff --git a/api/blueprints/finstitutions/payments/request.py b/api/blueprints/finstitutions/payments/request.py new file mode 100644 index 0000000..f3e8c21 --- /dev/null +++ b/api/blueprints/finstitutions/payments/request.py @@ -0,0 +1,211 @@ +""" + + AUTHOR: + + Khushal P Soonderji + + DATE: + + Monday, 16th Dec., 2024 + + OBJECTIVE: + + To request payments from customers from various financial institutions. + + REFERENCES: + + N/A + + DOWNLOADS: + + N/A + + NOTES: + + N/A + +""" + + +# ***************************************************************************************************************** +# ***** **** +# *** IMPORT *** +# ***** **** +# ***************************************************************************************************************** + + +# To make sibling directories accessible for imports: +import sys +sys.path.append(".") +sys.path.append("..") + +# For using Quart: +from quart import Blueprint, current_app, request + +# My utils: +from utils_v2.string import json +from utils_v2.api.codes import StatusCodes, HttpCodes +from utils_v2.api.response import ResponseModel +from utils_v2.api.async_quart import ( + set_api_version, + read_input, + get_session_info, + log_request_to_mongo, + log_chain_to_mongo, + should_not_be_under_maintenance, + only_whitelisted_ips, + limit_rate, + validate_input, + handle_cancelled_request +) + +# Common: +from shared import constants + +# To work with MongoDB: +from bson.objectid import ObjectId + +# Data Models: +from models.core.user import CoreUserInfoModel +from models.api.finstitutions.payments.request import PGPaymentRequestHeaders, PGPaymentRequestData +from models.core.auth_token import CoreAuthTokenModel + +# For asynchronous activities: +import asyncio + + +# ***************************************************************************************************************** +# ***** **** +# *** MACROS / ONE-TIME INIT *** +# ***** **** +# ***************************************************************************************************************** + + +# Related to Quart: +pg_request_bp = Blueprint("pg_request", __name__) + + +# ***************************************************************************************************************** +# ***** **** +# *** VARIABLES *** +# ***** **** +# ***************************************************************************************************************** + + +# --- Nothing Yet + + +# ***************************************************************************************************************** +# ***** **** +# *** FUNCTIONS *** +# ***** **** +# ***************************************************************************************************************** + + +@pg_request_bp.record_once +def init(blueprint_setup_state): + + # This gets called when the blueprint is registered. + # Consider this to be a one-time setup for the whole blueprint: + pass + + +# --------------------------------------------------------------------------------------------------------------------- + + +@pg_request_bp.route("/request", methods = ["POST"]) +@set_api_version(api_version = "1.0.0") +@read_input(sanitize_headers = False, sanitize_data = False) +@get_session_info(key = "X-Session-Token", session_coro = "get_session") +@log_request_to_mongo( + attr_name = "logs_mongo", + project = constants.PROJECT_NAME, + log_type = constants.MODULE_NAME, + operation = "pgReqApi", + log_input = True, + log_output = True, + sensitive_keys = ["sessionToken", "X-Session-Token"] +) +@log_chain_to_mongo(attr_name = "logs_mongo") +@should_not_be_under_maintenance(attr_name = "is_under_maintenance") +@validate_input( + header_validator = lambda x: PGPaymentRequestHeaders(**x).model_dump(), + data_validator = lambda x: PGPaymentRequestData(**x) +) +@handle_cancelled_request() +async def request_payment( + inbound_headers: dict | PGPaymentRequestHeaders = None, + inbound_data: dict | PGPaymentRequestData = None, + inbound_files: dict = None, + **kwargs +): + + """ + To request payments from customers through payment gateways. + :param inbound_headers: auto-extracted by the decorators. + :param inbound_data: auto-extracted by the decorators. + :param inbound_files: auto-extracted by the decorators. + :param kwargs: Any number of extra inputs supplied by the decorators. + :return: A standard response structure. + """ + + # ┏┓ + # ┃┃┏┓┏┓┏┓┏┓┏┓┏┏┓┏┏ + # ┣┛┛ ┗ ┣┛┛ ┗┛┗┗ ┛┛ + # ┛ + + # If the session token is invalid/expired: + if kwargs.get("session_info") is None: + return ResponseModel( + status_code = StatusCodes.FAILED, + http_code = HttpCodes.UNAUTHORIZED + ) + + # ┳┓ ┏┓ + # ┣┫┏┓┏┓┓┏┏┓┏╋ ┃┃┏┓┓┏┏┳┓┏┓┏┓╋ + # ┛┗┗ ┗┫┗┻┗ ┛┗ ┣┛┗┻┗┫┛┗┗┗ ┛┗┗ + # ┗ ┛ + + # Get the token from the token key: + auth_token = await current_app.payment_controller.get_token( + mongo_conn = current_app.data_mongo, + token_key = inbound_data.tokenKey + ) + if auth_token is None: return ResponseModel( + status_code = StatusCodes.FAILED, + http_code = HttpCodes.UNAUTHORIZED, + message = f"no such token key" + ) + + # Make the request with the fetched token: + response = await current_app.payment_controller.request_payment( + mongo_conn = current_app.data_mongo, + http_client = current_app.http_client, + auth_token = auth_token, + user_info = CoreUserInfoModel(**kwargs["session_info"]), + payment_request = inbound_data + ) + + # ┳┓ + # ┣┫┏┓┏┏┓┏┓┏┓┏┏┓ + # ┛┗┗ ┛┣┛┗┛┛┗┛┗ + # ┛ + + # Done here: + return ResponseModel( + status_code = StatusCodes.OK if response.success else StatusCodes.FAILED, + http_code = HttpCodes.SUCCESS if response.success else HttpCodes.INTERNAL_SERVER_ERROR, + message = response.message + ) + + +# ***************************************************************************************************************** +# ***** **** +# *** MAIN PROGRAM *** +# ***** **** +# ***************************************************************************************************************** + + +if __name__ == "__main__": + + pass diff --git a/api/main.py b/api/main.py index d9f72e5..e60671e 100644 --- a/api/main.py +++ b/api/main.py @@ -65,7 +65,8 @@ from utils_v2.goog.gmail.gmail_client import AsyncGMailClient # Core Controller Models: from controllers.core.message import CoreMessageController from controllers.core.auth_token import CoreAuthTokenController -from controllers.core.ai.llm import LLMController +from controllers.core.ai.llm import CoreLLMController +from controllers.core.payment import CorePaymentController # API Controller Models: from controllers.api.mail import MailController @@ -98,6 +99,8 @@ from api.blueprints.sms.send import sms_send_bp # from api.blueprints.chat.webhook import chat_webhook_bp from api.blueprints.software.auth import sw_auth_bp from api.blueprints.finstitutions.payments.auth import pg_auth_bp +from api.blueprints.finstitutions.payments.request import pg_request_bp +from api.blueprints.finstitutions.payments.callback import pg_callback_bp from api.blueprints.tech.chat_alerts import tech_chat_alert_bp from api.blueprints.test.callback import test_callback_bp from api.blueprints.ai.llm.invoke import llm_invoke_bp @@ -140,6 +143,8 @@ app.register_blueprint(sms_send_bp, url_prefix = f"/{MODULE_BASE}/sms") # app.register_blueprint(chat_webhook_bp, url_prefix = f"/{MODULE_BASE}/chat") app.register_blueprint(sw_auth_bp, url_prefix = f"/{MODULE_BASE}/software") app.register_blueprint(pg_auth_bp, url_prefix = f"/{MODULE_BASE}/finstitutions/payments") +app.register_blueprint(pg_request_bp, url_prefix = f"/{MODULE_BASE}/finstitutions/payments") +app.register_blueprint(pg_callback_bp, url_prefix = f"/{MODULE_BASE}/finstitutions/payments") app.register_blueprint(tech_chat_alert_bp, url_prefix = f"/{MODULE_BASE}/tech/alert") app.register_blueprint(test_callback_bp, url_prefix = f"/{MODULE_BASE}/test") app.register_blueprint(llm_invoke_bp, url_prefix = f"/{MODULE_BASE}/ai") @@ -334,7 +339,7 @@ async def app_startup(**kwargs): alert_url = current_app.script_data["alerts"]["url"], http_client = current_app.http_client, debug = enable_debugging, - debug_prefix = "Message (CM) | ", + debug_prefix = "AuthToken (CM) | ", debug_only_errors = True ) current_app.core_message_controller = CoreMessageController( @@ -345,6 +350,14 @@ async def app_startup(**kwargs): debug_prefix = "Message (CM) | ", debug_only_errors = True ) + current_app.core_payment_controller = CorePaymentController( + cache = current_app.module_cache, + alert_url = current_app.script_data["alerts"]["url"], + http_client = current_app.http_client, + debug = enable_debugging, + debug_prefix = "Pymnt. (CM) | ", + debug_only_errors = True + ) # ┏┓┏┓┳ ┏┓ ┓┓ # ┣┫┃┃┃ ┃ ┏┓┏┓╋┏┓┏┓┃┃┏┓┏┓┏ @@ -377,7 +390,7 @@ async def app_startup(**kwargs): # ┛ # For LLMs: - current_app.llm = LLMController( + current_app.llm = CoreLLMController( llm_creds = { "model": script_cred["openAi"]["model"], "openai_api_key": script_cred["openAi"]["openai_api_key"] diff --git a/controllers/api/mail.py b/controllers/api/mail.py index 52dfbbc..5766893 100644 --- a/controllers/api/mail.py +++ b/controllers/api/mail.py @@ -61,7 +61,7 @@ from bson import ObjectId from pymongo import InsertOne, UpdateOne, ReplaceOne # To work with LLMs: -from controllers.core.ai.llm import LLMController +from controllers.core.ai.llm import CoreLLMController from models.core.ai.llm import LLMInput, LLMOutput # To work with datatypes: @@ -191,7 +191,7 @@ class MailController: self, mongo_conn: AsyncMongo, user_info: CoreUserInfoModel, - llm: LLMController, + llm: CoreLLMController, message: CoreMessageModel ) -> LLMOutput: @@ -319,7 +319,7 @@ class MailController: mail_client: AsyncGMailClient, google_tokens: GoogleAuthTokens, message_id: str, - llm: LLMController = None, + llm: CoreLLMController = None, force_sync: bool = False ) -> MailSyncOneResult: @@ -411,7 +411,7 @@ class MailController: user_info: CoreUserInfoModel, auth_token: CoreAuthTokenModel, mail_client: AsyncGMailClient, - llm: LLMController = None, + llm: CoreLLMController = None, force_sync: bool = False, start_date: datetime.datetime = None, end_date: datetime.datetime = None, @@ -524,7 +524,7 @@ class MailController: mongo_conn: AsyncMongo, user_info: CoreUserInfoModel, token_key: ObjectId | str, - llm: LLMController = None, + llm: CoreLLMController = None, force_sync: bool = False, start_date: datetime.datetime = None, end_date: datetime.datetime = None, diff --git a/controllers/api/payment.py b/controllers/api/payment.py index 4933416..7029ebb 100644 --- a/controllers/api/payment.py +++ b/controllers/api/payment.py @@ -47,9 +47,10 @@ from utils_v2.database.async_mongo_v2 import AsyncMongo, AsyncMongoStorage # Data models: from models.core.user import CoreUserInfoModel from models.core.auth_token import CoreAuthTokenModel -from models.core.payment import CorePaymentModel, PaymentEvent +from models.core.payment import CorePaymentModel, PaymentEvent, CustomerDetails from models.api.finstitutions.payments.request import ( - PaymentRequestOneResult + PGPaymentRequestData, + PaymentRequestOneResult, ) # Payment Clients: @@ -176,83 +177,118 @@ class PaymentController: # ┛┗┗ ┗┫┗┻┗ ┛┗ ┣┛┗┻┗┫┛┗┗┗ ┛┗┗┛ # ┗ ┛ - # @staticmethod - # async def __request_from_safaricom_m_pesa_express( - # http_client: httpx.AsyncClient, - # auth_token: CoreAuthTokenModel, - # payment: Union[SafaricomMPesaExpressRequest], - # callback_url: str - # ) -> PaymentRequestOneResult: - # - # # Start by assuming failure: - # request_result = PaymentRequestOneResult() - # - # # Create the client: - # client = SafaricomMPesaExpress( - # auth = MPesaExpressAuthorization( - # consumerKey = auth_token.auth["consumerKey"], - # consumerSecret = auth_token.auth["consumerSecret"], - # businessShortCode = auth_token.auth["businessShortCode"], - # appPasskey = auth_token.auth["appPasskey"] - # ), - # http_client = http_client - # ) - # - # # Make the payment request: - # client_response = await client.request_payment( - # amount = payment.amount, - # party_a = payment.partyA, - # type = payment.transactionType, - # reference = payment.accountReference, - # description = payment.transactionDescription, - # callback_url = callback_url, - # payer_no = payment.phoneNo, - # party_b = payment.partyB - # ) - # - # # Construct the payment details: - # payment_details = CorePaymentModel( - # user = None, - # lastEventTs = date_time.get_current_utc_date_time(as_string = False), - # lastPaymentStatus = "initiated" if client_response.success else "initFailed", - # tokenId = auth_token.authTokenId, - # amount = payment.amount, - # curencyCode = "KES", - # # metadata = payment. - # ) - # - # # Done here: - # request_result.success = client_response.success - # request_result.message = client_response.message - # request_result.message = client_response.message - # return request_result - # - # async def request_payment( - # self, - # mongo_conn: AsyncMongo, - # http_client: httpx.AsyncClient, - # auth_token: CoreAuthTokenModel, - # payment: Union[SafaricomMPesaExpressRequest], - # callback_url: str - # ) -> PaymentRequestOneResult: - # - # # Start by assuming failure: - # request_result = PaymentRequestOneResult() - # - # # Now we route the message to the appropriate client: - # match auth_token.client: - # case "safaricomMPesaExpress": - # request_result = await self.__request_from_safaricom_m_pesa_express( - # http_client = http_client, - # auth_token = auth_token, - # payment = payment, - # callback_url = callback_url - # ) - # case _: - # request_result.message = f"invalid client {auth_token.client}" - # - # # Done here: - # return request_result + @staticmethod + async def __request_from_safaricom_m_pesa_express( + mongo_conn: AsyncMongo, + http_client: httpx.AsyncClient, + auth_token: CoreAuthTokenModel, + payment_request: PGPaymentRequestData, + payment_id: ObjectId, + callback_url: str + ) -> PaymentRequestOneResult: + + # Start by assuming failure: + result = PaymentRequestOneResult() + + # Create the client: + client = SafaricomMPesaExpress( + auth = MPesaExpressAuthorization( + consumerKey = auth_token.auth["consumerKey"], + consumerSecret = auth_token.auth["consumerSecret"], + businessShortCode = auth_token.auth["businessShortCode"], + appPasskey = auth_token.auth["appPasskey"] + ), + http_client = http_client + ) + + # Make the payment request: + client_response = await client.request_payment( + amount = payment_request.amount, + party_a = payment_request.customerNo, + type = "CustomerPayBillOnline", + reference = payment_request.reference, + description = payment_request.description, + callback_url = callback_url, + payer_no = payment_request.payerNo, + party_b = auth_token.auth["businessShortCode"] + ) + + # Add this event to the payment's document: + event_note_success = await current_app.core_payment_controller.add_event( + mongo_conn = mongo_conn, + payment_id = payment_id, + event = PaymentEvent( + paymentStatus = "initiated" if client_response.success else "initFailed", + initByPG = False, + httpCode = client_response.httpCode, + headers = await client_response.get_headers(), + payload = await client_response.get_json() + ) + ) + + # Done here: + result.success = client_response.success and event_note_success + result.message = "; ".join([ + "payment request successfully" if client_response.success else "payment request failed", + "event noted successfully" if event_note_success else "payment event noting failed", + ]) + return result + + async def request_payment( + self, + mongo_conn: AsyncMongo, + http_client: httpx.AsyncClient, + auth_token: CoreAuthTokenModel, + user_info: CoreUserInfoModel, + payment_request: PGPaymentRequestData, + ) -> PaymentRequestOneResult: + + # Start by assuming failure: + request_result = PaymentRequestOneResult() + + # Create a basic database entry to note down the intended payment: + payment_id = await current_app.core_payment_controller.init( + mongo_conn = mongo_conn, + payment = CorePaymentModel( + user = user_info, + customer = CustomerDetails( + name = payment_request.customerName, + contactNo = payment_request.customerNo, + payerNo = payment_request.payerNo, + email = payment_request.email + ), + lastPaymentStatus = "queued", + tokenId = auth_token.authTokenId, + amount = payment_request.amount, + currencyCode = payment_request.currencyCode, + metadata = payment_request.metadata, + tags = ["payment", "safaricom", "mPesaExpress", "kenya"], + client = auth_token.client, + clientPaymentReferenceId = None, + events = [] + ) + ) + if payment_id is None: + request_result.message = "failed to queue the payment" + return request_result + else: request_result.message = "payment has been queued for processing" + + # Now we route the request to the appropriate client: + match auth_token.client: + case "safaricomMPesaExpress": + request_result = await self.__request_from_safaricom_m_pesa_express( + http_client = http_client, + mongo_conn = mongo_conn, + auth_token = auth_token, + payment_request = payment_request, + callback_url = f"https://api.thecaoffice.com/finstitutions/payments/callback/{payment_id}", + payment_id = payment_id + ) + case _: + request_result = None + + # Done here: + return request_result # ┓ • ┓ ┏┓ ┏┓ # ┃ ┓┏╋ ┏┓┏┓┏┫ ┃┓┏┓╋ ┃┃┏┓┓┏┏┳┓┏┓┏┓╋┏ @@ -299,6 +335,18 @@ class PaymentController: payment_id = payment_id ) + @staticmethod + async def get_payment_internal( + mongo_conn: AsyncMongo, + payment_id: ObjectId | str + ) -> CorePaymentModel | None: + + # Simply call the core model: + return await current_app.core_payment_controller.get_payment_internal( + mongo_conn = mongo_conn, + payment_id = payment_id + ) + # ┳┳ ┓ # ┃┃┏┓┏┫┏┓╋┏┓ # ┗┛┣┛┗┻┗┻┗┗ diff --git a/controllers/core/ai/llm.py b/controllers/core/ai/llm.py index 30b466d..92c543b 100644 --- a/controllers/core/ai/llm.py +++ b/controllers/core/ai/llm.py @@ -85,7 +85,7 @@ from langchain_openai import ChatOpenAI # ***************************************************************************************************************** -class LLMController(BaseModel): +class CoreLLMController(BaseModel): AI_USAGE_COLLECTION = "_aiUsage" diff --git a/controllers/core/payment.py b/controllers/core/payment.py index fb2503d..90e6166 100644 --- a/controllers/core/payment.py +++ b/controllers/core/payment.py @@ -134,10 +134,14 @@ class CorePaymentController(BaseModel): :return: The object id of the inserted document. """ + # Receive te JSON: + payment_json = payment.model_dump() + payment_json.pop("_id") + # Simply insert the document: return await mongo_conn.insert_one( collection = self.PAYMENTS_COLLECTION, - document = payment, + document = payment_json, raise_exception = True ) @@ -253,6 +257,34 @@ class CorePaymentController(BaseModel): # we return it as our data model: return CorePaymentModel(**record) + async def get_payment_internal( + self, + mongo_conn: AsyncMongo, + payment_id: ObjectId | str, + ) -> CorePaymentModel | None: + + """ + NOTE: DO NOT USE THIS IN USER-FACING APIS. USE THIS INTERNALLY TO FETCH RECORDS. + Gets one payment detail if you know its payment id. + :param mongo_conn: The instance of the database connector to use for the operation. + :param payment_id: The id of the payment detail that needs to be read. + :return: The contents of that one payment detail in a structured format. + """ + + # We fetch the whole payload of that one message: + record = await mongo_conn.find_one( + collection = self.PAYMENTS_COLLECTION, + filter = {"_id": ObjectId(payment_id)}, + raise_exception = True + ) + + # If no such message was found: + if record is None: return None + + # If a record was found, + # we return it as our data model: + return CorePaymentModel(**record) + # ┏┓┳┓┳┳┳┓ ┳┳ ┓ # ┃ ┣┫┃┃┃┃ ━━ ┃┃┏┓┏┫┏┓╋┏┓ # ┗┛┛┗┗┛┻┛ ┗┛┣┛┗┻┗┻┗┗ @@ -265,7 +297,8 @@ class CorePaymentController(BaseModel): self, mongo_conn: AsyncMongo, payment_id: ObjectId | str, - event: PaymentEvent + event: PaymentEvent, + client_reference_id: str = None ) -> bool: """ @@ -273,14 +306,28 @@ class CorePaymentController(BaseModel): :param mongo_conn: The instance of the database connector to use for the operation. :param payment_id: The id of the payment detail that needs to be read. :param event: The event that occurred. This will typically be generated by the third-party client. + :param client_reference_id: The way the client identifies this payment. You need to pass this only on the first + event. Typically, when you initiate the payment request. :return: True if successfully noted, else False. """ + # Prepare the update document: + update_json = { + "$push": { + "events": event.model_dump() + }, + "$set": { + "lastEventTs": date_time.get_current_ist_date_time(as_string = False), + "lastPaymentStatus": event.paymentStatus + } + } + if client_reference_id: update_json["$set"]["clientPaymentReferenceId"] = client_reference_id + # Try to update the existing record: return await mongo_conn.update_one( collection = self.PAYMENTS_COLLECTION, filter = {"_id": ObjectId(payment_id)}, - update = {"$push": {"events": event}}, + update = update_json, upsert = False, raise_exception = True ) diff --git a/models/api/finstitutions/payments/request.py b/models/api/finstitutions/payments/request.py index 934d350..67b7fca 100644 --- a/models/api/finstitutions/payments/request.py +++ b/models/api/finstitutions/payments/request.py @@ -110,17 +110,6 @@ class PGPaymentRequestHeaders(BaseModel): class PGPaymentRequestData(BaseModel): - # { - # "customerMobileNumber": "", - # "tokenId": "", - # "description": "", - # "payerNumber": "", - # "amount": "", - # "currency": "", - # "emailAddress": "", - # "reference": "", - # } - tokenKey: ObjectId = Field( description = "the auth token to use to send this message", frozen = True, @@ -164,6 +153,16 @@ class PGPaymentRequestData(BaseModel): examples = ["INR", "USD", "KES"] ) + reference: str = Field( + description = "some reference against which the payment was made", + frozen = True + ) + + description: str = Field( + description = "a short, human-readable description of the payment", + frozen = True + ) + metadata: dict = Field( description = "any extra information about this payment", frozen = True @@ -210,11 +209,6 @@ class PaymentRequestOneResult(BaseModel): default = None ) - paymentDetails: CorePaymentModel = Field( - description = "the actual data of the payment", - default = None - ) - # ┏┓ ┏• # ┃ ┏┓┏┓╋┓┏┓ # ┗┛┗┛┛┗┛┗┗┫ diff --git a/models/core/payment.py b/models/core/payment.py index 12c227b..c6c85eb 100644 --- a/models/core/payment.py +++ b/models/core/payment.py @@ -138,17 +138,20 @@ class PaymentEvent(BaseModel): eventTs: AwareDatetime = Field( description = "to know the date and time (utc) of this update", - frozen = True + frozen = True, + default_factory = lambda: date_time.get_current_utc_date_time(as_string = False) ) paymentStatus: Literal[ - "initFailed", # ... When we tried to initiate the request, but the payment gateway (PG) rejected it. + "queued", # ....... When the UI sends a payment request, but the payment gateway (PG) hasn't received it yet. + "initFailed", # ... When we tried to initiate the request, but the PG rejected it. "initiated", # .... When we made a successful payment request, or the customer initiated one from the PG. "failed", # ....... When the customer tried paying, but it failed (e.g.: because of an incorrect pin). "rejected", # ..... When the customer explicitly rejected the payment. "authorized", # ... When the customer made the payment (but it hasn't been settled in your account yet). "settled", # ...... When the PG sends the money to your account. "refunded", # ..... When the money was refunded to the client. + "unknown" # ....... When integrating a new gateway and some specific status is not known. ] = Field( description = "the status of the payment request to see what stage of the process we are in", frozen = False @@ -220,9 +223,10 @@ class CorePaymentModel(BaseModel): default_factory = lambda: date_time.get_current_utc_date_time(as_string = False) ) - lastEventTs: AwareDatetime = Field( + lastEventTs: AwareDatetime | None = Field( description = "the time (utc) at which the last event occurred", - frozen = True + frozen = True, + default = None ) lastPaymentStatus: str = Field( @@ -263,7 +267,7 @@ class CorePaymentModel(BaseModel): frozen = True ) - clientPaymentReferenceId: int | str = Field( + clientPaymentReferenceId: int | str | None = Field( description = "the reference id given by the third-party client", frozen = False, default = None @@ -271,7 +275,8 @@ class CorePaymentModel(BaseModel): events: List[PaymentEvent] = Field( description = "an array of all the events that happened in the process of this payment", - frozen = False + frozen = False, + default = [] ) # ┏┓ ┏• @@ -296,6 +301,7 @@ class CorePaymentModel(BaseModel): payment_json = { "paymentId": str(self.messageId), "user": self.user, + "customer": self.customer, "ts": self.ts.isoformat(), "lastEventTs": self.lastEventTs, "lastPaymentStatus": self.lastPaymentStatus, @@ -320,6 +326,7 @@ class CorePaymentModel(BaseModel): return { "paymentId": str(self.messageId), "user": self.user, + "customer": self.customer, "ts": self.ts.isoformat(), "lastEventTs": self.lastEventTs, "lastPaymentStatus": self.lastPaymentStatus, @@ -335,7 +342,7 @@ class CorePaymentModel(BaseModel): # ┃┃┏┓┃┓┏┫┏┓╋┓┏┓┏┓ # ┗┛┗┻┗┗┗┻┗┻┗┗┗┛┛┗ - @field_validator("lastEventTs", mode = "before") + @field_validator("ts", "lastEventTs", mode = "before") def parse_date_time(cls, value): return date_time.parse_date_time(input_value = value, timezone = date_time.TIMEZONE_UTC) diff --git a/ssh_server.sh b/ssh_server.sh index 1991711..16bdc4e 100644 --- a/ssh_server.sh +++ b/ssh_server.sh @@ -19,11 +19,12 @@ if [[ $SELECTION -gt 0 && $SELECTION -le ${#SERVERS[@]} ]]; then # Note down the selection in a variable: SELECTED_SERVER=${SERVERS[$((SELECTION - 1))]} - # Ask the user which directory he wants to upload and his username on the server: + # Ask the username and target port no. on the server:: read -rp "Your username on the server ..... : " USER + read -rp "The target port no. ............. : " PORT # Run the command: - ssh -p 19991 "$USER@$SELECTED_SERVER" + ssh -p "$PORT" "$USER@$SELECTED_SERVER" # Exit with success (assuming that the actual data sending went well): echo "session ended" diff --git a/utils_v2/payments/safaricom/controllers/m_pesa_express.py b/utils_v2/payments/safaricom/controllers/m_pesa_express.py index 1839cd0..8ab1fa8 100644 --- a/utils_v2/payments/safaricom/controllers/m_pesa_express.py +++ b/utils_v2/payments/safaricom/controllers/m_pesa_express.py @@ -355,7 +355,7 @@ class SafaricomMPesaExpress: 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 + api_response.success = True if str(api_json.get("ResponseCode")) == "0" else False # Done here: return api_response diff --git a/utils_v2/payments/safaricom/models/api_call.py b/utils_v2/payments/safaricom/models/api_call.py index 3b386b4..076c3fd 100644 --- a/utils_v2/payments/safaricom/models/api_call.py +++ b/utils_v2/payments/safaricom/models/api_call.py @@ -109,6 +109,10 @@ class MPesaExpressApiResponse(BaseModel): message += f"*EXCEPTION:*\n`{self.exception.__class__.__name__}: {str(self.exception)}`\n\n" return message + async def get_headers(self): + try: return self.response.headers + except: return {} + async def get_json(self): try: return self.response.json() except: return {}