Skip to content

Commit 0f1b3e0

Browse files
authored
Merge pull request #31 from RafaelJohn9/feature/B2CAccountTopUp
feat: Added B2C Account Top Up feature with unit and integration tests
2 parents 09199a1 + 1b025f9 commit 0f1b3e0

File tree

5 files changed

+621
-0
lines changed

5 files changed

+621
-0
lines changed
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
"""B2CAccountTopUp: Handles M-Pesa B2C Account Topup API interactions.
2+
3+
This module provides functionality to initiate a B2C Account Topup 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+
B2CAccountTopUpRequest,
13+
B2CAccountTopUpResponse,
14+
)
15+
16+
17+
class B2CAccountTopUp(BaseModel):
18+
"""Represents the B2C Account TopUp API client for M-Pesa operations.
19+
20+
https://developer.safaricom.co.ke/APIs/B2CAccountTopUp
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 topup(self, request: B2CAccountTopUpRequest) -> B2CAccountTopUpResponse:
33+
"""Initiates a B2C Account TopUp transaction.
34+
35+
Args:
36+
request (B2CAccountTopUpRequest): The B2C Account TopUp request data.
37+
38+
Returns:
39+
B2CAccountTopUpResponse: Response from the M-Pesa API.
40+
"""
41+
url = "/mpesa/b2b/v1/paymentrequest"
42+
headers = {
43+
"Authorization": f"Bearer {self.token_manager.get_token()}",
44+
"Content-Type": "application/json",
45+
}
46+
response_data = self.http_client.post(
47+
url, json=request.model_dump(mode="json"), headers=headers
48+
)
49+
return B2CAccountTopUpResponse(**response_data)
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
from .schemas import (
2+
B2CAccountTopUpRequest,
3+
B2CAccountTopUpResponse,
4+
B2CAccountTopUpCallback,
5+
B2CAccountTopUpCallbackResponse,
6+
B2CAccountTopUpTimeoutCallback,
7+
B2CAccountTopUpTimeoutCallbackResponse,
8+
)
9+
from .B2C_account_top_up import B2CAccountTopUp
10+
11+
__all__ = [
12+
"B2CAccountTopUp",
13+
"B2CAccountTopUpRequest",
14+
"B2CAccountTopUpResponse",
15+
"B2CAccountTopUpCallback",
16+
"B2CAccountTopUpCallbackResponse",
17+
"B2CAccountTopUpTimeoutCallback",
18+
"B2CAccountTopUpTimeoutCallbackResponse",
19+
]
Lines changed: 316 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,316 @@
1+
"""Schemas for M-PESA B2C Account TopUp APIs."""
2+
3+
from pydantic import BaseModel, Field, HttpUrl, ConfigDict
4+
from typing import Optional, List, Any
5+
6+
7+
class B2CAccountTopUpRequest(BaseModel):
8+
"""Request schema for B2C Account TopUp."""
9+
10+
Initiator: str = Field(..., description="M-Pesa API operator username.")
11+
SecurityCredential: str = Field(
12+
..., description="Encrypted password of the API operator."
13+
)
14+
CommandID: str = "BusinessPayToBulk"
15+
SenderIdentifierType: int = 4
16+
RecieverIdentifierType: int = 4
17+
Amount: int = Field(..., description="Transaction amount.")
18+
PartyA: int = Field(..., description="Shortcode from which money will be deducted.")
19+
PartyB: int = Field(..., description="Shortcode to which money will be moved.")
20+
AccountReference: str = Field(..., description="Reference for the transaction.")
21+
Requester: Optional[str] = Field(
22+
None, description="Consumer’s mobile number on behalf of whom you are paying."
23+
)
24+
Remarks: Optional[str] = Field(
25+
None, description="Additional information for the transaction."
26+
)
27+
QueueTimeOutURL: HttpUrl = Field(..., description="URL for timeout notification.")
28+
ResultURL: HttpUrl = Field(
29+
..., description="URL for transaction result notification."
30+
)
31+
32+
model_config = ConfigDict(
33+
json_schema_extra={
34+
"example": {
35+
"Initiator": "testapi",
36+
"SecurityCredential": "SecurityCredential",
37+
"CommandID": "BusinessPayToBulk",
38+
"SenderIdentifierType": 4,
39+
"RecieverIdentifierType": 4,
40+
"Amount": 239,
41+
"PartyA": 600979,
42+
"PartyB": 600000,
43+
"AccountReference": "353353",
44+
"Requester": "254708374149",
45+
"Remarks": "OK",
46+
"QueueTimeOutURL": "https://mydomain/path/timeout",
47+
"ResultURL": "https://mydomain/path/result",
48+
}
49+
}
50+
)
51+
52+
53+
class B2CAccountTopUpResponse(BaseModel):
54+
"""Immediate response schema for B2C Account TopUp request."""
55+
56+
OriginatorConversationID: str = Field(
57+
..., description="Unique request identifier assigned by Daraja."
58+
)
59+
ConversationID: str = Field(
60+
..., description="Unique request identifier assigned by M-Pesa."
61+
)
62+
ResponseCode: str = Field(
63+
..., description="Status code for request submission. 0 indicates success."
64+
)
65+
ResponseDescription: str = Field(
66+
..., description="Descriptive message of the request submission status."
67+
)
68+
69+
model_config = ConfigDict(
70+
json_schema_extra={
71+
"example": {
72+
"OriginatorConversationID": "5118-111210482-1",
73+
"ConversationID": "AG_20230420_2010759fd5662ef6d054",
74+
"ResponseCode": "0",
75+
"ResponseDescription": "Accept the service request successfully.",
76+
}
77+
}
78+
)
79+
80+
def is_successful(self) -> bool:
81+
"""Check if the response indicates a successful submission."""
82+
return self.ResponseCode == "0"
83+
84+
85+
class ResultParameterItem(BaseModel):
86+
"""Result parameter for B2C Account TopUp callback."""
87+
88+
Key: str = Field(..., description="Parameter key.")
89+
Value: Any = Field(..., description="Parameter value.")
90+
91+
92+
class RefItem(BaseModel):
93+
"""Reference item for B2C Account TopUp callback."""
94+
95+
Key: str = Field(..., description="Reference item key.")
96+
Value: Optional[Any] = Field(None, description="Reference item value.")
97+
98+
99+
class ResultParams(BaseModel):
100+
"""Result parameters for B2C Account TopUp callback."""
101+
102+
ResultParameter: List[ResultParameterItem] = Field(
103+
default_factory=list, description="List of result parameters."
104+
)
105+
106+
107+
class RefData(BaseModel):
108+
"""Reference data for B2C Account TopUp callback."""
109+
110+
ReferenceItem: List[RefItem] = Field(
111+
default_factory=list, description="List of reference items."
112+
)
113+
114+
model_config = ConfigDict(arbitrary_types_allowed=True)
115+
116+
117+
class B2CAccountTopUpCallbackResult(BaseModel):
118+
"""Callback result schema for B2C Account TopUp."""
119+
120+
ResultType: int = Field(
121+
..., description="Status code for transaction sent to listener."
122+
)
123+
ResultCode: int = Field(
124+
..., description="Transaction result status code. 0 means success."
125+
)
126+
ResultDesc: str = Field(
127+
..., description="Descriptive message for the transaction result."
128+
)
129+
OriginatorConversationID: str = Field(
130+
..., description="Unique request identifier assigned by API gateway."
131+
)
132+
ConversationID: str = Field(
133+
..., description="Unique request identifier assigned by M-Pesa."
134+
)
135+
TransactionID: str = Field(
136+
..., description="Unique M-PESA transaction ID for the payment request."
137+
)
138+
ResultParameters: Optional[ResultParams] = Field(
139+
None, description="Additional transaction details."
140+
)
141+
ReferenceData: Optional[RefData] = Field(
142+
None, description="Additional transaction reference data."
143+
)
144+
145+
model_config = ConfigDict(
146+
json_schema_extra={
147+
"example": {
148+
"ResultType": 0,
149+
"ResultCode": 0,
150+
"ResultDesc": "The service request is processed successfully",
151+
"OriginatorConversationID": "626f6ddf-ab37-4650-b882-b1de92ec9aa4",
152+
"ConversationID": "12345677dfdf89099B3",
153+
"TransactionID": "QKA81LK5CY",
154+
"ResultParameters": {
155+
"ResultParameter": [
156+
{
157+
"Key": "DebitAccountBalance",
158+
"Value": "{Amount={CurrencyCode=KES, MinimumAmount=618683, BasicAmount=6186.83}}",
159+
},
160+
{"Key": "Amount", "Value": "190.00"},
161+
{
162+
"Key": "DebitPartyAffectedAccountBalance",
163+
"Value": "Working Account|KES|346568.83|6186.83|340382.00|0.00",
164+
},
165+
{"Key": "TransCompletedTime", "Value": "20221110110717"},
166+
{"Key": "DebitPartyCharges", "Value": ""},
167+
{
168+
"Key": "ReceiverPartyPublicName",
169+
"Value": "000000– Biller Company",
170+
},
171+
{"Key": "Currency", "Value": "KES"},
172+
{
173+
"Key": "InitiatorAccountCurrentBalance",
174+
"Value": "{Amount={CurrencyCode=KES, MinimumAmount=618683, BasicAmount=6186.83}}",
175+
},
176+
]
177+
},
178+
"ReferenceData": {
179+
"ReferenceItem": [
180+
{"Key": "BillReferenceNumber", "Value": "19008"},
181+
{
182+
"Key": "QueueTimeoutURL",
183+
"Value": "https://mydomain.com/b2b/businessbuygoods/queue/",
184+
},
185+
]
186+
},
187+
}
188+
}
189+
)
190+
191+
192+
class B2CAccountTopUpCallback(BaseModel):
193+
"""Callback schema for B2C Account TopUp."""
194+
195+
Result: B2CAccountTopUpCallbackResult = Field(
196+
..., description="Result object containing transaction details."
197+
)
198+
199+
model_config = ConfigDict(
200+
json_schema_extra={
201+
"example": {
202+
"Result": {
203+
"ResultType": 0,
204+
"ResultCode": 0,
205+
"ResultDesc": "The service request is processed successfully",
206+
"OriginatorConversationID": "626f6ddf-ab37-4650-b882-b1de92ec9aa4",
207+
"ConversationID": "12345677dfdf89099B3",
208+
"TransactionID": "QKA81LK5CY",
209+
"ResultParameters": {
210+
"ResultParameter": [
211+
{
212+
"Key": "DebitAccountBalance",
213+
"Value": "{Amount={CurrencyCode=KES, MinimumAmount=618683, BasicAmount=6186.83}}",
214+
},
215+
{"Key": "Amount", "Value": "190.00"},
216+
]
217+
},
218+
"ReferenceData": {
219+
"ReferenceItem": [
220+
{"Key": "BillReferenceNumber", "Value": "19008"},
221+
{
222+
"Key": "QueueTimeoutURL",
223+
"Value": "https://mydomain.com/b2b/businessbuygoods/queue/",
224+
},
225+
]
226+
},
227+
}
228+
}
229+
}
230+
)
231+
232+
def is_successful(self) -> bool:
233+
"""Check if the callback indicates a successful transaction."""
234+
return self.Result.ResultCode == 0
235+
236+
237+
class B2CAccountTopUpCallbackResponse(BaseModel):
238+
"""Response schema for B2C Account TopUp callback."""
239+
240+
ResultCode: int = Field(
241+
default=0, description="Result code of the callback. 0 indicates success."
242+
)
243+
ResultDesc: str = Field(
244+
default="Callback processed successfully",
245+
description="Descriptive message of the callback result.",
246+
)
247+
248+
249+
class B2CAccountTopUpTimeoutResultMetadata(BaseModel):
250+
"""Result metadata for B2C Account TopUp timeout callback."""
251+
252+
ResultType: int = Field(..., description="Type of result, 1 indicates timeout.")
253+
ResultCode: str = Field(..., description="Result code, 1 indicates timeout.")
254+
ResultDesc: str = Field(..., description="Description of the timeout event.")
255+
OriginatorConversationID: str = Field(
256+
..., description="Unique request identifier assigned by Daraja."
257+
)
258+
ConversationID: str = Field(
259+
..., description="Unique request identifier assigned by M-Pesa."
260+
)
261+
262+
model_config = ConfigDict(
263+
json_schema_extra={
264+
"example": {
265+
"ResultType": 1,
266+
"ResultCode": "1",
267+
"ResultDesc": "The service request timed out.",
268+
"OriginatorConversationID": "8521-4298025-1",
269+
"ConversationID": "AG_20181005_00004d7ee675c0c7ee0b",
270+
}
271+
}
272+
)
273+
274+
275+
class B2CAccountTopUpTimeoutCallback(BaseModel):
276+
"""Schema for B2C Account TopUp sent to QueueTimeOutURL."""
277+
278+
Result: B2CAccountTopUpTimeoutResultMetadata = Field(
279+
..., description="Result metadata."
280+
)
281+
282+
model_config = ConfigDict(
283+
json_schema_extra={
284+
"example": {
285+
"Result": {
286+
"ResultType": 1,
287+
"ResultCode": "1",
288+
"ResultDesc": "The service request timed out.",
289+
"OriginatorConversationID": "8521-4298025-1",
290+
"ConversationID": "AG_20181005_00004d7ee675c0c7ee0b",
291+
}
292+
}
293+
}
294+
)
295+
296+
297+
class B2CAccountTopUpTimeoutCallbackResponse(BaseModel):
298+
"""Schema for response to B2C Account TopUp timeout callback."""
299+
300+
ResultCode: int = Field(
301+
default=0,
302+
description="Result code (0=Success, other=Failure).",
303+
)
304+
ResultDesc: str = Field(
305+
default="Timeout notification received and processed successfully.",
306+
description="Result description.",
307+
)
308+
309+
model_config = ConfigDict(
310+
json_schema_extra={
311+
"example": {
312+
"ResultCode": 0,
313+
"ResultDesc": "Timeout notification received and processed successfully.",
314+
}
315+
}
316+
)

0 commit comments

Comments
 (0)