|
| 1 | +"""Schemas for M-PESA Ratiba (Standing Order) APIs.""" |
| 2 | + |
| 3 | +from pydantic import BaseModel, Field, HttpUrl, ConfigDict, model_validator |
| 4 | +from typing import Optional, List, Any |
| 5 | +from datetime import datetime |
| 6 | +from enum import Enum |
| 7 | + |
| 8 | +from mpesa_sdk.utils.phone import normalize_phone_number |
| 9 | + |
| 10 | + |
| 11 | +class FrequencyEnum(str, Enum): |
| 12 | + """Enumeration for transaction frequency in Standing Order API.""" |
| 13 | + |
| 14 | + ONE_OFF = "1" |
| 15 | + DAILY = "2" |
| 16 | + WEEKLY = "3" |
| 17 | + MONTHLY = "4" |
| 18 | + BI_MONTHLY = "5" |
| 19 | + QUARTERLY = "6" |
| 20 | + HALF_YEAR = "7" |
| 21 | + YEARLY = "8" |
| 22 | + |
| 23 | + |
| 24 | +class TransactionTypeEnum(str, Enum): |
| 25 | + """Enumeration for transaction types in Standing Order API.""" |
| 26 | + |
| 27 | + STANDING_ORDER_CUSTOMER_PAY_BILL = "Standing Order Customer Pay Bill" |
| 28 | + STANDING_ORDER_CUSTOMER_PAY_MERCHANT = "Standing Order Customer Pay Merchant" |
| 29 | + |
| 30 | + |
| 31 | +class ReceiverPartyIdentifierTypeEnum(str, Enum): |
| 32 | + """Enumeration for receiver party identifier type in Standing Order API.""" |
| 33 | + |
| 34 | + MERCHANT_TILL = "2" # Till Number |
| 35 | + BUSINESS_SHORT_CODE = "4" # PayBill |
| 36 | + |
| 37 | + |
| 38 | +class StandingOrderRequest(BaseModel): |
| 39 | + """Request schema for creating a Standing Order.""" |
| 40 | + |
| 41 | + StandingOrderName: str = Field( |
| 42 | + ..., description="Unique name for the standing order per customer." |
| 43 | + ) |
| 44 | + StartDate: str = Field( |
| 45 | + ..., description="Start date for the standing order (yyyymmdd)." |
| 46 | + ) |
| 47 | + EndDate: str = Field(..., description="End date for the standing order (yyyymmdd).") |
| 48 | + BusinessShortCode: str = Field( |
| 49 | + ..., description="Business short code to receive payment." |
| 50 | + ) |
| 51 | + TransactionType: TransactionTypeEnum = Field( |
| 52 | + ..., |
| 53 | + description="Transaction type: 'Standing Order Customer Pay Bill' or 'Standing Order Customer Pay Merchant'.", |
| 54 | + ) |
| 55 | + ReceiverPartyIdentifierType: ReceiverPartyIdentifierTypeEnum = Field( |
| 56 | + ..., |
| 57 | + description="Receiver party identifier type: '2' for Till, '4' for PayBill.", |
| 58 | + ) |
| 59 | + Amount: str = Field( |
| 60 | + ..., description="Amount to be transacted (whole number as string)." |
| 61 | + ) |
| 62 | + PartyA: str = Field( |
| 63 | + ..., |
| 64 | + description="Customer's M-PESA registered phone number (format: 2547XXXXXXXX).", |
| 65 | + ) |
| 66 | + CallBackURL: HttpUrl = Field( |
| 67 | + ..., description="URL to receive notifications from Standing Order Solution." |
| 68 | + ) |
| 69 | + AccountReference: str = Field( |
| 70 | + ..., |
| 71 | + description="Account reference for PayBill transactions (max 12 chars).", |
| 72 | + max_length=12, |
| 73 | + ) |
| 74 | + TransactionDesc: str = Field( |
| 75 | + ..., description="Additional info/comment (max 13 chars).", max_length=13 |
| 76 | + ) |
| 77 | + Frequency: FrequencyEnum = Field( |
| 78 | + ..., description="Frequency of transactions: 1=One Off, 2=Daily, ..., 8=Yearly." |
| 79 | + ) |
| 80 | + |
| 81 | + model_config = ConfigDict( |
| 82 | + json_schema_extra={ |
| 83 | + "example": { |
| 84 | + "StandingOrderName": "Test Standing Order", |
| 85 | + "StartDate": "20240905", |
| 86 | + "EndDate": "20250905", |
| 87 | + "BusinessShortCode": "174379", |
| 88 | + "TransactionType": "Standing Order Customer Pay Bill", |
| 89 | + "ReceiverPartyIdentifierType": "4", |
| 90 | + "Amount": "4500", |
| 91 | + "PartyA": "254708374149", |
| 92 | + "CallBackURL": "https://mydomain.com/pat", |
| 93 | + "AccountReference": "Test", |
| 94 | + "TransactionDesc": "Electric Bike Repayment", |
| 95 | + "Frequency": "2", |
| 96 | + } |
| 97 | + } |
| 98 | + ) |
| 99 | + |
| 100 | + @model_validator(mode="before") |
| 101 | + @classmethod |
| 102 | + def validate(cls, values): |
| 103 | + """Validate the request data before processing.""" |
| 104 | + cls._validate_and_format_date(values) |
| 105 | + cls._validate_phone_number(values) |
| 106 | + return values |
| 107 | + |
| 108 | + @classmethod |
| 109 | + def _validate_phone_number(cls, values): |
| 110 | + """Ensure PartyA is a valid M-PESA phone number.""" |
| 111 | + phone = values.get("PartyA") |
| 112 | + normalized_phone = normalize_phone_number(phone) |
| 113 | + if not normalized_phone: |
| 114 | + raise ValueError(f"Invalid PartyA phone number: {phone}") |
| 115 | + values["PartyA"] = normalized_phone |
| 116 | + |
| 117 | + @classmethod |
| 118 | + def _validate_and_format_date(cls, values): |
| 119 | + """Ensure StartDate and EndDate are in the correct format.""" |
| 120 | + for field in ["StartDate", "EndDate"]: |
| 121 | + date_str = values.get(field) |
| 122 | + if date_str: |
| 123 | + formatted_date = cls.format_date(date_str) |
| 124 | + values[field] = formatted_date |
| 125 | + |
| 126 | + @classmethod |
| 127 | + def format_date(cls, date_str: str) -> str: |
| 128 | + """Format date string to 'yyyymmdd' and validate it.""" |
| 129 | + # Normalize date separators to empty string |
| 130 | + normalized_date_str = "".join(filter(str.isdigit, date_str)) |
| 131 | + if len(normalized_date_str) != 8: |
| 132 | + raise ValueError("Date must be in 'yyyymmdd' format and a valid date.") |
| 133 | + try: |
| 134 | + dt = datetime.strptime(normalized_date_str, "%Y%m%d") |
| 135 | + return dt.strftime("%Y%m%d") |
| 136 | + except ValueError: |
| 137 | + raise ValueError("Date must be in 'yyyymmdd' format and a valid date.") |
| 138 | + |
| 139 | + |
| 140 | +class StandingOrderResponseHeader(BaseModel): |
| 141 | + """Response header metadata for Standing Order API.""" |
| 142 | + |
| 143 | + responseRefID: str = Field(..., description="Unique reference ID for the response.") |
| 144 | + requestRefID: Optional[str] = Field( |
| 145 | + None, description="Unique reference ID for the request (callback only)." |
| 146 | + ) |
| 147 | + responseCode: str = Field( |
| 148 | + ..., description="HTTP response code: '200', '401', '500', etc." |
| 149 | + ) |
| 150 | + responseDescription: str = Field( |
| 151 | + ..., description="Description of the response status." |
| 152 | + ) |
| 153 | + ResultDesc: Optional[str] = Field( |
| 154 | + None, description="Additional result description (optional)." |
| 155 | + ) |
| 156 | + |
| 157 | + model_config = ConfigDict( |
| 158 | + json_schema_extra={ |
| 159 | + "example": { |
| 160 | + "responseRefID": "4dd9b5d9-d738-42ba-9326-2cc99e966000", |
| 161 | + "requestRefID": "c8c2bb31-3b3a-402e-84fc-21ef35161e48", |
| 162 | + "responseCode": "200", |
| 163 | + "responseDescription": "Request accepted for processing", |
| 164 | + "ResultDesc": "The service request is processed successfully.", |
| 165 | + } |
| 166 | + } |
| 167 | + ) |
| 168 | + |
| 169 | + |
| 170 | +class StandingOrderResponseBody(BaseModel): |
| 171 | + """Response body metadata for Standing Order API.""" |
| 172 | + |
| 173 | + responseDescription: Optional[str] = Field( |
| 174 | + None, description="Descriptive message for the async request." |
| 175 | + ) |
| 176 | + responseCode: Optional[str] = Field( |
| 177 | + None, description="HTTP response code: '200', '401', '500', etc." |
| 178 | + ) |
| 179 | + |
| 180 | + model_config = ConfigDict( |
| 181 | + json_schema_extra={ |
| 182 | + "example": { |
| 183 | + "responseDescription": "Request accepted for processing", |
| 184 | + "responseCode": "200", |
| 185 | + } |
| 186 | + } |
| 187 | + ) |
| 188 | + |
| 189 | + |
| 190 | +class StandingOrderResponse(BaseModel): |
| 191 | + """Immediate response schema for Standing Order request.""" |
| 192 | + |
| 193 | + ResponseHeader: StandingOrderResponseHeader = Field( |
| 194 | + ..., description="Response header metadata." |
| 195 | + ) |
| 196 | + ResponseBody: StandingOrderResponseBody = Field( |
| 197 | + ..., description="Response body metadata." |
| 198 | + ) |
| 199 | + |
| 200 | + model_config = ConfigDict( |
| 201 | + json_schema_extra={ |
| 202 | + "example": { |
| 203 | + "ResponseHeader": { |
| 204 | + "responseRefID": "4dd9b5d9-d738-42ba-9326-2cc99e966000", |
| 205 | + "responseCode": "200", |
| 206 | + "responseDescription": "Request accepted for processing", |
| 207 | + "ResultDesc": "The service request is processed successfully.", |
| 208 | + }, |
| 209 | + "ResponseBody": { |
| 210 | + "responseDescription": "Request accepted for processing", |
| 211 | + "responseCode": "200", |
| 212 | + }, |
| 213 | + } |
| 214 | + } |
| 215 | + ) |
| 216 | + |
| 217 | + def is_successful(self) -> bool: |
| 218 | + """Check if the response indicates a successful transaction.""" |
| 219 | + return self.ResponseHeader.responseCode == "200" |
| 220 | + |
| 221 | + |
| 222 | +class StandingOrderCallbackDataItem(BaseModel): |
| 223 | + """Key-value pair for callback response data.""" |
| 224 | + |
| 225 | + Name: str = Field( |
| 226 | + ..., |
| 227 | + description="Name of the response data item (e.g., TransactionID, responseCode, Status, Msisdn).", |
| 228 | + ) |
| 229 | + Value: Any = Field(..., description="Value of the response data item.") |
| 230 | + |
| 231 | + model_config = ConfigDict( |
| 232 | + json_schema_extra={ |
| 233 | + "example": { |
| 234 | + "Name": "TransactionID", |
| 235 | + "Value": "SC8F2IQMH5", |
| 236 | + } |
| 237 | + } |
| 238 | + ) |
| 239 | + |
| 240 | + |
| 241 | +class StandingOrderCallbackBody(BaseModel): |
| 242 | + """Callback response body containing response data.""" |
| 243 | + |
| 244 | + ResponseData: List[StandingOrderCallbackDataItem] = Field( |
| 245 | + default_factory=list, description="List of response data items." |
| 246 | + ) |
| 247 | + |
| 248 | + model_config = ConfigDict( |
| 249 | + json_schema_extra={ |
| 250 | + "example": { |
| 251 | + "ResponseData": [ |
| 252 | + {"Name": "TransactionID", "Value": "SC8F2IQMH5"}, |
| 253 | + {"Name": "responseCode", "Value": "0"}, |
| 254 | + {"Name": "Status", "Value": "OKAY"}, |
| 255 | + {"Name": "Msisdn", "Value": "254******867"}, |
| 256 | + ] |
| 257 | + } |
| 258 | + } |
| 259 | + ) |
| 260 | + |
| 261 | + |
| 262 | +class StandingOrderCallback(BaseModel): |
| 263 | + """Callback response schema for Standing Order.""" |
| 264 | + |
| 265 | + ResponseHeader: StandingOrderResponseHeader = Field( |
| 266 | + ..., description="Response header metadata." |
| 267 | + ) |
| 268 | + ResponseBody: StandingOrderCallbackBody = Field( |
| 269 | + ..., description="Response body with response data." |
| 270 | + ) |
| 271 | + |
| 272 | + model_config = ConfigDict( |
| 273 | + json_schema_extra={ |
| 274 | + "example": { |
| 275 | + "ResponseHeader": { |
| 276 | + "responseRefID": "0acc0239-20fa-4a52-8b9d-9bd64c0465c3", |
| 277 | + "requestRefID": "0acc0239-20fa-4a52-8b9d-9bd64c0465c3", |
| 278 | + "responseCode": "0", |
| 279 | + "responseDescription": "The service request is processed successfully", |
| 280 | + }, |
| 281 | + "ResponseBody": { |
| 282 | + "ResponseData": [ |
| 283 | + {"Name": "TransactionID", "Value": "SC8F2IQMH5"}, |
| 284 | + {"Name": "responseCode", "Value": "0"}, |
| 285 | + {"Name": "Status", "Value": "OKAY"}, |
| 286 | + {"Name": "Msisdn", "Value": "254******867"}, |
| 287 | + ] |
| 288 | + }, |
| 289 | + } |
| 290 | + } |
| 291 | + ) |
| 292 | + |
| 293 | + def is_successful(self) -> bool: |
| 294 | + """Check if the callback indicates a successful transaction.""" |
| 295 | + for item in self.ResponseBody.ResponseData: |
| 296 | + if item.Name.lower() == "responsecode" and str(item.Value) == "0": |
| 297 | + return True |
| 298 | + return False |
| 299 | + |
| 300 | + |
| 301 | +class StandingOrderCallbackResponse(BaseModel): |
| 302 | + """Response after receiving a callback from the Standing Order API.""" |
| 303 | + |
| 304 | + ResultDesc: str = Field( |
| 305 | + default="The service request is processed successfully", |
| 306 | + description="Description of the result of the callback processing.", |
| 307 | + ) |
| 308 | + ResultCode: str = Field( |
| 309 | + default="0", |
| 310 | + description="Result code indicating success (0) or failure (non-zero).", |
| 311 | + ) |
| 312 | + |
| 313 | + model_config = ConfigDict( |
| 314 | + json_schema_extra={ |
| 315 | + "example": { |
| 316 | + "ResultDesc": "The service request is processed successfully", |
| 317 | + "ResultCode": "0", |
| 318 | + } |
| 319 | + } |
| 320 | + ) |
0 commit comments