Skip to content

Commit 6f4ba5f

Browse files
authored
Merge pull request #32 from RafaelJohn9/feature/MpesaRatiba
feat: added MpesaRatiba with unit and integration tests
2 parents 0f1b3e0 + 7bb3214 commit 6f4ba5f

File tree

5 files changed

+713
-0
lines changed

5 files changed

+713
-0
lines changed

mpesa_sdk/mpesa_ratiba/__init__.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
from .schemas import (
2+
FrequencyEnum,
3+
TransactionTypeEnum,
4+
ReceiverPartyIdentifierTypeEnum,
5+
StandingOrderRequest,
6+
StandingOrderResponse,
7+
StandingOrderCallback,
8+
StandingOrderCallbackResponse,
9+
)
10+
from .mpesa_ratiba import MpesaRatiba
11+
12+
__all__ = [
13+
"StandingOrderRequest",
14+
"StandingOrderResponse",
15+
"StandingOrderCallback",
16+
"StandingOrderCallbackResponse",
17+
"FrequencyEnum",
18+
"TransactionTypeEnum",
19+
"ReceiverPartyIdentifierTypeEnum",
20+
"MpesaRatiba",
21+
]
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
"""MpesaRatiba: Handles M-Pesa Standing Order (Ratiba) API interactions.
2+
3+
This module provides functionality to initiate a Standing Order transaction and handle result/timeout notifications
4+
using the M-Pesa API. Requires a valid access token for authentication and uses the HttpClient for HTTP requests.
5+
"""
6+
7+
from pydantic import BaseModel, ConfigDict
8+
from mpesa_sdk.auth import TokenManager
9+
from mpesa_sdk.http_client import HttpClient
10+
11+
from .schemas import (
12+
StandingOrderRequest,
13+
StandingOrderResponse,
14+
)
15+
16+
17+
class MpesaRatiba(BaseModel):
18+
"""Represents the Standing Order (Ratiba) API client for M-Pesa operations.
19+
20+
https://developer.safaricom.co.ke/APIs/MpesaRatiba
21+
22+
Attributes:
23+
http_client (HttpClient): HTTP client for making requests to the M-Pesa API.
24+
token_manager (TokenManager): Manages access tokens for authentication.
25+
"""
26+
27+
http_client: HttpClient
28+
token_manager: TokenManager
29+
30+
model_config = ConfigDict(arbitrary_types_allowed=True)
31+
32+
def create_standing_order(
33+
self, request: StandingOrderRequest
34+
) -> StandingOrderResponse:
35+
"""Initiates a Standing Order transaction.
36+
37+
Args:
38+
request (StandingOrderRequest): The Standing Order request data.
39+
40+
Returns:
41+
StandingOrderResponse: Response from the M-Pesa API.
42+
"""
43+
url = "/standingorder/v1/createStandingOrderExternal"
44+
headers = {
45+
"Authorization": f"Bearer {self.token_manager.get_token()}",
46+
"Content-Type": "application/json",
47+
}
48+
response_data = self.http_client.post(
49+
url, json=request.model_dump(mode="json"), headers=headers
50+
)
51+
return StandingOrderResponse(**response_data)

mpesa_sdk/mpesa_ratiba/schemas.py

Lines changed: 320 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,320 @@
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

Comments
 (0)