diff --git a/api/blueprints/finstitutions/payments/callback.py b/api/blueprints/finstitutions/payments/callback.py index 42e9208..3599ef9 100644 --- a/api/blueprints/finstitutions/payments/callback.py +++ b/api/blueprints/finstitutions/payments/callback.py @@ -113,14 +113,14 @@ def init(blueprint_setup_state): # --------------------------------------------------------------------------------------------------------------------- -@pg_callback_bp.route("/callback/", methods = ["POST"]) +@pg_callback_bp.route("/callback/safaricom/mpesaexpress", 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", + operation = "pgSafaricomMPesaExpressClbkApi", log_input = True, log_output = True, sensitive_keys = None @@ -128,8 +128,7 @@ def init(blueprint_setup_state): @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, +async def safaricom_m_pesa_express_callback( inbound_headers: dict = None, inbound_data: dict = None, inbound_files: dict = None, @@ -138,7 +137,6 @@ async def payment_event_callback( """ 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. @@ -146,28 +144,80 @@ async def payment_event_callback( :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)) + # ┏┓ ┓ ┓ ┏┓ + # ┣┫┏┫┏┫ ┣ ┓┏┏┓┏┓╋ + # ┛┗┗┻┗┻ ┗┛┗┛┗ ┛┗┗ + + # Map out the documented codes provided by the payment gateway. + # URL: https://developer.safaricom.co.ke/APIs/MpesaExpressSimulate + code_map = { + 0: { + "status": "settled", + "message": "Payment successful :)" + }, # ... Success + 1037: { + "status": "failed", + "message": "The payment gateway could not reach your customer." + }, # ... DS Timeout. User could not be reached. + 1025: { + "status": "failed", + "message": "There was a system error in the payment gateway (1025)." + }, # ... System error while trying to send the push request. + 9999: { + "status": "failed", + "message": "There was a system error in the payment gateway (9999)." + }, # ... System error while trying to send the push request. + 1032: { + "status": "rejected", + "message": "Your customer declined the payment request." + }, # ... Request Cancelled by the user. + 1: { + "status": "failed", + "message": "Your customer has insufficient balance." + }, # ... The user has insufficient balance. + 2001: { + "status": "failed", + "message": "The payment gateway says your credentials are invalid." + }, # ... Invalid credentials of the initiator. + 1019: { + "status": "failed", + "message": "The transaction expired before your customer processed it." + }, # ... Transaction expired. + 1001: { + "status": "failed", + "message": "Your customer is already in the middle of some transaction on the payment gateway." + }, # ... The payer is already making some transaction. + } + + # Figure out which of the above codes is relevant to you: + pg_reference_id = inbound_data["Body"]["stkCallback"]["CheckoutRequestID"] + pg_result_code = int(inbound_data["Body"]["stkCallback"]["ResultCode"]) + relevant_code = code_map.get( + pg_result_code, + { + "status": "unknown", + "message": f"Unknown code '{pg_result_code}' from the payment gateway." + } + ) + # For now, we just insert the event into the record: - event_note_success = await current_app.payment_controller.add_event( + event_note_success = await current_app.payment_controller.add_event_by_client_reference_id( mongo_conn = current_app.data_mongo, - payment_id = ObjectId(payment_id), event = PaymentEvent( - paymentStatus = "unknown", + paymentStatus = relevant_code["status"], + message = relevant_code["message"], initByPG = True, httpCode = None, headers = inbound_headers, payload = inbound_data - ) + ), + client_reference_id = pg_reference_id ) # ┳┓ @@ -178,7 +228,7 @@ async def payment_event_callback( # Done here: return ResponseModel( status_code = StatusCodes.OK if event_note_success else StatusCodes.FAILED, - http_code = HttpCodes.NOT_IMPLEMENTED + http_code = HttpCodes.SUCCESS if event_note_success else HttpCodes.INTERNAL_SERVER_ERROR ) diff --git a/api/blueprints/finstitutions/payments/list.py b/api/blueprints/finstitutions/payments/list.py new file mode 100644 index 0000000..de3f187 --- /dev/null +++ b/api/blueprints/finstitutions/payments/list.py @@ -0,0 +1,212 @@ +""" + + AUTHOR: + + Khushal P Soonderji + + DATE: + + Tuesday, 17th Dec., 2024 + + OBJECTIVE: + + To list payment records that are already present in our database. + + 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.list import PGPaymentListHeaders, PGPaymentListData +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_list_bp = Blueprint("pg_list", __name__) + + +# ***************************************************************************************************************** +# ***** **** +# *** VARIABLES *** +# ***** **** +# ***************************************************************************************************************** + + +# --- Nothing Yet + + +# ***************************************************************************************************************** +# ***** **** +# *** FUNCTIONS *** +# ***** **** +# ***************************************************************************************************************** + + +@pg_list_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_list_bp.route("/list", methods = ["GET"]) +@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 = "pgListApi", + log_input = True, + log_output = 1, + 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: PGPaymentListHeaders(**x).model_dump(), + data_validator = lambda x: PGPaymentListData(**x) +) +@handle_cancelled_request() +async def payment_list( + inbound_headers: dict | PGPaymentListHeaders = None, + inbound_data: dict | PGPaymentListData = None, + inbound_files: dict = None, + **kwargs +): + + """ + To filter and enlist payment records here. + :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, + message = "invalid session" + ) + + # ┏┓ ┓• ┳┳┓ •┓ + # ┣ ┏┓┃┓┏╋ ┃┃┃┏┓┓┃┏ + # ┗┛┛┗┗┗┛┗ ┛ ┗┗┻┗┗┛ + + # Get the token ids from the token keys: + auth_tokens = await current_app.payment_controller.get_tokens( + mongo_conn = current_app.data_mongo, + token_keys = inbound_data.tokenKeys + ) + token_ids = [t.authTokenId for t in auth_tokens] + + print("TOKEN IDS:", json.to_string(token_ids, default = str)) + + # Build the additional filter: + additional_filter = {} + if inbound_data.tags: additional_filter["tags"] = {"$in": inbound_data.tags} + if inbound_data.paymentStatus: additional_filter["lastPaymentStatus"] = {"$in": inbound_data.paymentStatus} + additional_filter = additional_filter or None + + print("ADDFIL:", json.to_string(additional_filter)) + + # Get the mails: + payment_records = await current_app.payment_controller.list_payments( + mongo_conn = current_app.data_mongo, + token_ids = token_ids, + limit = inbound_data.count, + skip = inbound_data.fromCount, + additional_filter = additional_filter + ) + + # Done here: + return ResponseModel( + status_code = StatusCodes.OK if payment_records else StatusCodes.FAILED, + http_code = HttpCodes.SUCCESS if payment_records else HttpCodes.NOT_FOUND, + data = [record.preview for record in payment_records], + message = f"{len(payment_records) if payment_records else 0} records(s) found" + ) + + +# ***************************************************************************************************************** +# ***** **** +# *** MAIN PROGRAM *** +# ***** **** +# ***************************************************************************************************************** + + +if __name__ == "__main__": + + pass diff --git a/api/blueprints/mail/retrieve/list.py b/api/blueprints/mail/retrieve/list.py index b6ff978..77dba10 100644 --- a/api/blueprints/mail/retrieve/list.py +++ b/api/blueprints/mail/retrieve/list.py @@ -172,7 +172,7 @@ async def list_mails( return ResponseModel( status_code = StatusCodes.FAILED, http_code = HttpCodes.UNAUTHORIZED, - messge = "invalid session" + message = "invalid session" ) # ┏┓ ┓• ┳┳┓ •┓ diff --git a/api/main.py b/api/main.py index e60671e..d03bc2c 100644 --- a/api/main.py +++ b/api/main.py @@ -73,37 +73,43 @@ from controllers.api.mail import MailController from controllers.api.sms import SMSController from controllers.api.payment import PaymentController -# # Old Behaviour Models: -# from controllers.mail.oauth_v3 import MailOAuthModel -# from controllers.mail.sync_v3 import MailSyncModel -# from controllers.mail.retrieve import MailRetrieveModel -# from controllers.sms.auth_v2 import SMSAuthModel -# from controllers.sms.send import SMSSendModel - # To make REST API calls: import httpx # For debugging: from icecream import IceCreamDebugger -# All the blueprints: +# Mail Blueprints: from api.blueprints.mail.oauth.request import mail_oauth_request_bp from api.blueprints.mail.oauth.callback import mail_oauth_callback_bp from api.blueprints.mail.sync.sync_v2 import mail_sync_bp from api.blueprints.mail.retrieve.list import mail_list_bp from api.blueprints.mail.retrieve.get import mail_get_bp from api.blueprints.mail.tags.update import mail_tags_update_bp + +# SMS Blueprints: from api.blueprints.sms.auth import sms_auth_bp from api.blueprints.sms.send import sms_send_bp + +# Chat Blueprints: # from api.blueprints.chat.auth import chat_auth_bp # from api.blueprints.chat.webhook import chat_webhook_bp + +# Software Blueprints: from api.blueprints.software.auth import sw_auth_bp + +# Payment Blueprints: 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.finstitutions.payments.list import pg_list_bp + +# AI Blueprints: +from api.blueprints.ai.llm.invoke import llm_invoke_bp + +# Tech and Testing Blueprints: 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 # All the helpers: from api.helpers.user import session @@ -131,23 +137,38 @@ APP_VERSION = constants.APP_VERSION # The Quart app: app = Quart(__name__, template_folder = r"../views") app = cors(app) + +# Mail Blueprints: app.register_blueprint(mail_oauth_request_bp, url_prefix = f"/{MODULE_BASE}/mail") app.register_blueprint(mail_oauth_callback_bp, url_prefix = f"/{MODULE_BASE}/mail") app.register_blueprint(mail_sync_bp, url_prefix = f"/{MODULE_BASE}/mail") app.register_blueprint(mail_list_bp, url_prefix = f"/{MODULE_BASE}/mail") app.register_blueprint(mail_get_bp, url_prefix = f"/{MODULE_BASE}/mail") app.register_blueprint(mail_tags_update_bp, url_prefix = f"/{MODULE_BASE}/mail") + +# SMS Blueprints: app.register_blueprint(sms_auth_bp, url_prefix = f"/{MODULE_BASE}/sms") app.register_blueprint(sms_send_bp, url_prefix = f"/{MODULE_BASE}/sms") + +# Chat Blueprints: # app.register_blueprint(chat_auth_bp, url_prefix = f"/{MODULE_BASE}/chat") # app.register_blueprint(chat_webhook_bp, url_prefix = f"/{MODULE_BASE}/chat") + +# Software Blueprints: app.register_blueprint(sw_auth_bp, url_prefix = f"/{MODULE_BASE}/software") + +# Payment Blueprints: 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(pg_list_bp, url_prefix = f"/{MODULE_BASE}/finstitutions/payments") + +# AI Blueprints: +app.register_blueprint(llm_invoke_bp, url_prefix = f"/{MODULE_BASE}/ai") + +# Tech and Testing Blueprints: 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") # ***************************************************************************************************************** diff --git a/controllers/api/payment.py b/controllers/api/payment.py index 7029ebb..ba0b237 100644 --- a/controllers/api/payment.py +++ b/controllers/api/payment.py @@ -172,13 +172,25 @@ class PaymentController: token_key = token_key ) + @staticmethod + async def get_tokens( + mongo_conn: AsyncMongo, + token_keys: ObjectId | str = None, + ) -> List[CoreAuthTokenModel] | None: + + # Simply call the core model: + return await current_app.core_auth_token_controller.get_tokens( + mongo_conn = mongo_conn, + token_keys = token_keys + ) + # ┳┓ ┏┓ # ┣┫┏┓┏┓┓┏┏┓┏╋ ┃┃┏┓┓┏┏┳┓┏┓┏┓╋┏ # ┛┗┗ ┗┫┗┻┗ ┛┗ ┣┛┗┻┗┫┛┗┗┗ ┛┗┗┛ # ┗ ┛ - @staticmethod async def __request_from_safaricom_m_pesa_express( + self, mongo_conn: AsyncMongo, http_client: httpx.AsyncClient, auth_token: CoreAuthTokenModel, @@ -214,7 +226,7 @@ class PaymentController: ) # Add this event to the payment's document: - event_note_success = await current_app.core_payment_controller.add_event( + event_note_success = await self.add_event_by_payment_id( mongo_conn = mongo_conn, payment_id = payment_id, event = PaymentEvent( @@ -223,14 +235,17 @@ class PaymentController: httpCode = client_response.httpCode, headers = await client_response.get_headers(), payload = await client_response.get_json() - ) + ), + client_reference_id = client_response.referenceId ) # 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", + "payment requested successfully" if client_response.success + else f"payment request failed (PG: {client_response.message})", + "event noted successfully" if event_note_success + else "event noting failed", ]) return result @@ -261,8 +276,9 @@ class PaymentController: tokenId = auth_token.authTokenId, amount = payment_request.amount, currencyCode = payment_request.currencyCode, - metadata = payment_request.metadata, + metadata = payment_request.metadata.model_dump(), tags = ["payment", "safaricom", "mPesaExpress", "kenya"], + serviceType = "paymentGateway", client = auth_token.client, clientPaymentReferenceId = None, events = [] @@ -281,7 +297,7 @@ class PaymentController: mongo_conn = mongo_conn, auth_token = auth_token, payment_request = payment_request, - callback_url = f"https://api.thecaoffice.com/finstitutions/payments/callback/{payment_id}", + callback_url = f"https://api.thecaoffice.com/finstitutions/payments/callback/safaricom/mpesaexpress", payment_id = payment_id ) case _: @@ -310,7 +326,7 @@ class PaymentController: # Regardless of what additional filter is provided from outside, # we add a payment-selecting filter here: if additional_filter is None: additional_filter = {} - additional_filter["serviceType"] = "paymentGateway" + # additional_filter["serviceType"] = "paymentGateway" # Simply call the core model: return await current_app.core_payment_controller.get_payment_previews( @@ -353,17 +369,33 @@ class PaymentController: # ┛ @staticmethod - async def add_event( + async def add_event_by_payment_id( mongo_conn: AsyncMongo, payment_id: ObjectId | str, - event: PaymentEvent + event: PaymentEvent, + client_reference_id: str = None ) -> bool: # Simply call the core model: - return await current_app.core_payment_controller.add_event( + return await current_app.core_payment_controller.add_event_by_payment_id( mongo_conn = mongo_conn, payment_id = payment_id, - event = event + event = event, + client_reference_id = client_reference_id + ) + + @staticmethod + async def add_event_by_client_reference_id( + mongo_conn: AsyncMongo, + client_reference_id: str, + event: PaymentEvent, + ) -> bool: + + # Simply call the core model: + return await current_app.core_payment_controller.add_event_by_client_reference_id( + mongo_conn = mongo_conn, + event = event, + client_reference_id = client_reference_id ) @staticmethod diff --git a/controllers/core/payment.py b/controllers/core/payment.py index 90e6166..d81cad8 100644 --- a/controllers/core/payment.py +++ b/controllers/core/payment.py @@ -293,7 +293,7 @@ class CorePaymentController(BaseModel): # We don't support updating payments themselves, # but we will allow updating fields like tags, adding events, etc. - async def add_event( + async def add_event_by_payment_id( self, mongo_conn: AsyncMongo, payment_id: ObjectId | str, @@ -332,6 +332,43 @@ class CorePaymentController(BaseModel): raise_exception = True ) + async def add_event_by_client_reference_id( + self, + mongo_conn: AsyncMongo, + client_reference_id: str, + event: PaymentEvent, + ) -> bool: + + """ + Add an event to an existing record of a payment detail. + :param mongo_conn: The instance of the database connector to use for the operation. + :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 = {"clientPaymentReferenceId": client_reference_id}, + update = update_json, + upsert = False, + raise_exception = True + ) + async def update_tags( self, mongo_conn: AsyncMongo, diff --git a/models/api/finstitutions/payments/list.py b/models/api/finstitutions/payments/list.py new file mode 100644 index 0000000..695bdac --- /dev/null +++ b/models/api/finstitutions/payments/list.py @@ -0,0 +1,181 @@ +""" + + AUTHOR: + + Khushal P Soonderji + + DATE: + + Tuesday, 17th Dec., 2024. + + OBJECTIVE: + + To provide a structure to list payments records that are already present in our 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 +from typing import Optional, Literal, Union, List, Any + +# My utils: +from utils_v2.string import regex +from utils_v2.date_time import date_time + +# Models: +from models.core.payment import CorePaymentModel + +# To work with date and time: +import datetime + +# To work with MongoDB: +from bson.objectid import ObjectId + +# To work with currencies: +import pycountry + + +# ***************************************************************************************************************** +# ***** **** +# *** 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 PGPaymentListHeaders(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 PGPaymentListData(BaseModel): + + tokenKeys: str | List[str] = Field( + description = "the token identifier(s) that tell you which auth-tokens were used for fetching those records", + frozen = True, + ) + + count: int = Field( + description = "the no. of records to list", + default = 25, + ge = 1, + le = 500, + frozen = True + ) + + fromCount: int = Field( + description = "the no. of records to skip before picking mails to list; useful for pagination", + ge = 0, + default = 0, + frozen = True + ) + + tags: List[Any] | None = Field( + description = "any no. of tags that you want to filter by", + default = None + ) + + paymentStatus: List[Literal[ + "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. + ]] | None = Field( + description = "one or more status filters to apply when listing records", + frozen = False, + default = None + ) + + # ┏┓ ┏• + # ┃ ┏┓┏┓╋┓┏┓ + # ┗┛┗┛┛┗┛┗┗┫ + # ┛ + + class Config: + extra = "forbid" + + # ┓┏ ┓• ┓ • + # ┃┃┏┓┃┓┏┫┏┓╋┓┏┓┏┓ + # ┗┛┗┻┗┗┗┻┗┻┗┗┗┛┛┗ + + @field_validator("tokenKeys", "tags", "paymentStatus", mode = "before") + def ensure_list(cls, value): + if not isinstance(value, list): value = [value] + return value + + +# ***************************************************************************************************************** +# ***** **** +# *** MAIN PROGRAM *** +# ***** **** +# ***************************************************************************************************************** + + +if __name__ == "__main__": + + pass diff --git a/models/api/finstitutions/payments/request.py b/models/api/finstitutions/payments/request.py index 67b7fca..9d19c6c 100644 --- a/models/api/finstitutions/payments/request.py +++ b/models/api/finstitutions/payments/request.py @@ -108,6 +108,30 @@ class PGPaymentRequestHeaders(BaseModel): # --------------------------------------------------------------------------------------------------------------------- +class PaymentRequestMetadata(BaseModel): + + idClient: str | int = Field( + description = "account master id for the user's customer", + frozen = True + ) + + inAccount: str | int = Field( + description = "account master id for the user's bank account", + frozen = True + ) + + # ┏┓ ┏• + # ┃ ┏┓┏┓╋┓┏┓ + # ┗┛┗┛┛┗┛┗┗┫ + # ┛ + + class Config: + extra = "allow" + + +# --------------------------------------------------------------------------------------------------------------------- + + class PGPaymentRequestData(BaseModel): tokenKey: ObjectId = Field( @@ -163,7 +187,7 @@ class PGPaymentRequestData(BaseModel): frozen = True ) - metadata: dict = Field( + metadata: PaymentRequestMetadata = Field( description = "any extra information about this payment", frozen = True ) diff --git a/models/api/mail/list.py b/models/api/mail/list.py index d762785..6e9255b 100644 --- a/models/api/mail/list.py +++ b/models/api/mail/list.py @@ -134,6 +134,15 @@ class MailListRequestData(BaseModel): class Config: extra = "forbid" + # ┓┏ ┓• ┓ • + # ┃┃┏┓┃┓┏┫┏┓╋┓┏┓┏┓ + # ┗┛┗┻┗┗┗┻┗┻┗┗┗┛┛┗ + + @field_validator("tokenKeys", "tags", mode = "before") + def ensure_list(cls, value): + if not isinstance(value, list): value = [value] + return value + # ***************************************************************************************************************** # ***** **** diff --git a/models/core/payment.py b/models/core/payment.py index c6c85eb..f0acba4 100644 --- a/models/core/payment.py +++ b/models/core/payment.py @@ -157,6 +157,12 @@ class PaymentEvent(BaseModel): frozen = False ) + message: str | None = Field( + description = "a hint about what happened at this stage", + frozen = True, + default = None + ) + initByPG: bool = Field( description = "to figure out whether the payment gateway initiated this event or we did", frozen = True @@ -258,10 +264,15 @@ class CorePaymentModel(BaseModel): tags: List[Any] = Field( description = "a list of keywords to apply to this file/dir to filter it later", frozen = False, - default = [], examples = ["renewal", "subscription"] ) + serviceType: Literal["paymentGateway"] = Field( + description = "to identify the kind of service", + frozen = True, + default = None + ) + client: Literal["razorpay", "safaricomMPesaExpress"] = Field( description = "the third-part client that was used", frozen = True @@ -275,8 +286,7 @@ class CorePaymentModel(BaseModel): events: List[PaymentEvent] = Field( description = "an array of all the events that happened in the process of this payment", - frozen = False, - default = [] + frozen = False ) # ┏┓ ┏• @@ -299,9 +309,9 @@ class CorePaymentModel(BaseModel): @property def full(self): payment_json = { - "paymentId": str(self.messageId), - "user": self.user, - "customer": self.customer, + "paymentId": str(self.paymentId), + "user": self.user.model_dump(), + "customer": self.customer.model_dump(), "ts": self.ts.isoformat(), "lastEventTs": self.lastEventTs, "lastPaymentStatus": self.lastPaymentStatus, @@ -324,9 +334,9 @@ class CorePaymentModel(BaseModel): @property def preview(self): return { - "paymentId": str(self.messageId), - "user": self.user, - "customer": self.customer, + "paymentId": str(self.paymentId), + "user": self.user.model_dump(), + "customer": self.customer.model_dump(), "ts": self.ts.isoformat(), "lastEventTs": self.lastEventTs, "lastPaymentStatus": self.lastPaymentStatus, @@ -360,8 +370,8 @@ class CorePaymentModel(BaseModel): if currency is None: raise ValueError("invalid currency code, please use iso 4217 standard") return value - @field_validator("tags", mode = "before") - def validate_tags(cls, value): + @field_validator("tags", "events", mode = "before") + def validate_null_lists(cls, value): if value is None: value = [] return value diff --git a/utils_v2/payments/safaricom/controllers/m_pesa_express.py b/utils_v2/payments/safaricom/controllers/m_pesa_express.py index 8ab1fa8..e2052d7 100644 --- a/utils_v2/payments/safaricom/controllers/m_pesa_express.py +++ b/utils_v2/payments/safaricom/controllers/m_pesa_express.py @@ -353,9 +353,11 @@ class SafaricomMPesaExpress: # If the call failed: if api_response.httpCode in [200]: api_json = await api_response.get_json() + success = True if str(api_json.get("ResponseCode")) == "0" else False api_response.message = api_json.get("ResponseDescription", "N/A") api_response.data = api_json - api_response.success = True if str(api_json.get("ResponseCode")) == "0" else False + api_response.success = success + api_response.referenceId = str(api_json["CheckoutRequestID"]) # 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 076c3fd..ec24bf0 100644 --- a/utils_v2/payments/safaricom/models/api_call.py +++ b/utils_v2/payments/safaricom/models/api_call.py @@ -82,6 +82,7 @@ class MPesaExpressApiResponse(BaseModel): success: bool = False message: str = None data: Any = None + referenceId: str = None exception: Any = None @@ -105,6 +106,7 @@ class MPesaExpressApiResponse(BaseModel): 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"*REFERENCE ID:*\n`{self.referenceId}`\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