Skip to content

Commit 52d0c0d

Browse files
committed
feat: Business BuyGoods with unit and integration tests
1 parent 442a4e0 commit 52d0c0d

5 files changed

Lines changed: 587 additions & 0 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
from .schemas import (
2+
BusinessBuyGoodsRequest,
3+
BusinessBuyGoodsResponse,
4+
BusinessBuyGoodsResultCallback,
5+
BusinessBuyGoodsResultCallbackResponse,
6+
BusinessBuyGoodsTimeoutCallback,
7+
BusinessBuyGoodsTimeoutCallbackResponse,
8+
)
9+
from .business_buy_goods import BusinessBuyGoods
10+
11+
__all__ = [
12+
"BusinessBuyGoods",
13+
"BusinessBuyGoodsRequest",
14+
"BusinessBuyGoodsResponse",
15+
"BusinessBuyGoodsResultCallback",
16+
"BusinessBuyGoodsResultCallbackResponse",
17+
"BusinessBuyGoodsTimeoutCallback",
18+
"BusinessBuyGoodsTimeoutCallbackResponse",
19+
]
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
"""BusinessBuyGoods: Handles M-Pesa Business Buy Goods API interactions.
2+
3+
This module provides functionality to initiate a Business Buy Goods 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+
BusinessBuyGoodsRequest,
13+
BusinessBuyGoodsResponse,
14+
)
15+
16+
17+
class BusinessBuyGoods(BaseModel):
18+
"""Represents the Business Buy Goods API client for M-Pesa operations.
19+
20+
https://developer.safaricom.co.ke/APIs/BusinessBuyGoods
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 buy_goods(self, request: BusinessBuyGoodsRequest) -> BusinessBuyGoodsResponse:
33+
"""Initiates a Business Buy Goods transaction.
34+
35+
Args:
36+
request (BusinessBuyGoodsRequest): The Business Buy Goods request data.
37+
38+
Returns:
39+
BusinessBuyGoodsResponse: 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(url, json=dict(request), headers=headers)
47+
return BusinessBuyGoodsResponse(**response_data)
Lines changed: 302 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,302 @@
1+
"""Schemas for M-Pesa Business Buy Goods API requests and responses."""
2+
3+
from pydantic import BaseModel, Field, ConfigDict
4+
from typing import Optional, List, Union
5+
6+
7+
class BusinessBuyGoodsRequest(BaseModel):
8+
"""Request schema for Business Buy Goods."""
9+
10+
Initiator: str = Field(..., description="M-Pesa API operator username.")
11+
SecurityCredential: str = Field(..., description="Encrypted security credential.")
12+
CommandID: str = "BusinessBuyGoods"
13+
SenderIdentifierType: int = 4
14+
RecieverIdentifierType: int = 4
15+
Amount: int = Field(..., description="Transaction amount.")
16+
PartyA: int = Field(..., description="Shortcode from which money is deducted.")
17+
PartyB: int = Field(..., description="Shortcode to which money is credited.")
18+
AccountReference: str = Field(
19+
..., description="Account number associated with the payment."
20+
)
21+
Requester: Optional[str] = Field(
22+
None, description="Consumer's mobile number (optional)."
23+
)
24+
Remarks: str = Field(
25+
..., description="Additional transaction information (max 100 chars)."
26+
)
27+
QueueTimeOutURL: str = Field(..., description="URL for timeout notifications.")
28+
ResultURL: str = Field(..., description="URL for result notifications.")
29+
Occassion: Optional[str] = Field(
30+
None, description="Additional transaction info (optional)."
31+
)
32+
33+
model_config = ConfigDict(
34+
json_schema_extra={
35+
"example": {
36+
"Initiator": "API_Username",
37+
"SecurityCredential": "encrypted_credential",
38+
"CommandID": "BusinessBuyGoods",
39+
"SenderIdentifierType": 4,
40+
"RecieverIdentifierType": 4,
41+
"Amount": 239,
42+
"PartyA": 123456,
43+
"PartyB": 000000,
44+
"AccountReference": "353353",
45+
"Requester": "254700000000",
46+
"Remarks": "OK",
47+
"QueueTimeOutURL": "https://mydomain.com/b2b/businessbuygoods/queue/",
48+
"ResultURL": "https://mydomain.com/b2b/businessbuygoods/result/",
49+
"Occassion": "Payment for goods",
50+
}
51+
}
52+
)
53+
54+
55+
class BusinessBuyGoodsResponse(BaseModel):
56+
"""Response schema for Business Buy Goods request."""
57+
58+
OriginatorConversationID: Optional[str] = Field(
59+
..., description="Unique ID for the request message."
60+
)
61+
ConversationID: Optional[str] = Field(
62+
..., description="Unique ID for the transaction."
63+
)
64+
ResponseCode: str = Field(..., description="Status code, 0 means success.")
65+
ResponseDescription: str = Field(..., description="Status message.")
66+
67+
model_config = ConfigDict(
68+
json_schema_extra={
69+
"example": {
70+
"OriginatorConversationID": "5118-111210482-1",
71+
"ConversationID": "AG_20230420_2010759fd5662ef6d054",
72+
"ResponseCode": "0",
73+
"ResponseDescription": "Accept the service request successfully.",
74+
}
75+
}
76+
)
77+
78+
def is_successful(self) -> bool:
79+
"""Check if the response indicates a successful transaction."""
80+
code = str(self.ResponseCode)
81+
return code.strip("0") == "" and code != ""
82+
83+
84+
class BusinessBuyGoodsResultParameter(BaseModel):
85+
"""Represents a single result parameter in the Business Buy Goods response."""
86+
87+
Key: str = Field(..., description="Parameter name.")
88+
Value: Union[str, int] = Field(..., description="Parameter value.")
89+
90+
91+
class BusinessBuyGoodsReferenceItem(BaseModel):
92+
"""Represents a single reference item in the Business Buy Goods response."""
93+
94+
Key: str = Field(..., description="Reference parameter name.")
95+
Value: Union[str, int] = Field(..., description="Reference parameter value.")
96+
97+
98+
class BusinessBuyGoodsReferenceData(BaseModel):
99+
"""Container for reference data in the Business Buy Goods response."""
100+
101+
ReferenceItem: Union[
102+
List[BusinessBuyGoodsReferenceItem], BusinessBuyGoodsReferenceItem
103+
] = Field(..., description="List of reference items or single item.")
104+
105+
106+
class BusinessBuyGoodsResultParameters(BaseModel):
107+
"""Container for result parameters in the Business Buy Goods response."""
108+
109+
ResultParameter: Union[
110+
List[BusinessBuyGoodsResultParameter], BusinessBuyGoodsResultParameter
111+
] = Field(..., description="List of result parameters or single parameter.")
112+
113+
114+
class BusinessBuyGoodsResultMetadata(BaseModel):
115+
"""Metadata for the result of a Business Buy Goods transaction."""
116+
117+
ResultType: int = Field(..., description="Type of result (0=Success, 1=Failure).")
118+
ResultCode: int | str = Field(..., description="Result code (0=Success).")
119+
ResultDesc: str = Field(..., description="Result description.")
120+
OriginatorConversationID: str = Field(
121+
..., description="Originator conversation ID."
122+
)
123+
ConversationID: str = Field(..., description="Conversation ID.")
124+
TransactionID: Optional[str] = Field(
125+
None, description="M-Pesa transaction ID (if available)."
126+
)
127+
ResultParameters: Optional[BusinessBuyGoodsResultParameters] = Field(
128+
None, description="Result parameters container."
129+
)
130+
ReferenceData: Optional[BusinessBuyGoodsReferenceData] = Field(
131+
None, description="Reference data."
132+
)
133+
134+
model_config = ConfigDict(
135+
json_schema_extra={
136+
"example": {
137+
"ResultType": 0,
138+
"ResultCode": 0,
139+
"ResultDesc": "The service request is processed successfully",
140+
"OriginatorConversationID": "626f6ddf-ab37-4650-b882-b1de92ec9aa4",
141+
"ConversationID": "12345677dfdf89099B3",
142+
"TransactionID": "QKA81LK5CY",
143+
"ResultParameters": {
144+
"ResultParameter": [
145+
{
146+
"Key": "DebitAccountBalance",
147+
"Value": "{Amount={CurrencyCode=KES, MinimumAmount=618683, BasicAmount=6186.83}}",
148+
},
149+
{"Key": "Amount", "Value": "190.00"},
150+
{
151+
"Key": "DebitPartyAffectedAccountBalance",
152+
"Value": "Working Account|KES|346568.83|6186.83|340382.00|0.00",
153+
},
154+
{"Key": "TransCompletedTime", "Value": "20221110110717"},
155+
{"Key": "DebitPartyCharges", "Value": ""},
156+
{
157+
"Key": "ReceiverPartyPublicName",
158+
"Value": "000000– Biller Company",
159+
},
160+
{"Key": "Currency", "Value": "KES"},
161+
{
162+
"Key": "InitiatorAccountCurrentBalance",
163+
"Value": "{Amount={CurrencyCode=KES, MinimumAmount=618683, BasicAmount=6186.83}}",
164+
},
165+
]
166+
},
167+
"ReferenceData": {
168+
"ReferenceItem": [
169+
{"Key": "BillReferenceNumber", "Value": "19008"},
170+
{
171+
"Key": "QueueTimeoutURL",
172+
"Value": "https://mydomain.com/b2b/businessbuygoods/queue/",
173+
},
174+
]
175+
},
176+
}
177+
}
178+
)
179+
180+
181+
class BusinessBuyGoodsResultCallback(BaseModel):
182+
"""Represents the result callback for Business Buy Goods transactions."""
183+
184+
Result: BusinessBuyGoodsResultMetadata = Field(..., description="Result metadata.")
185+
186+
model_config = ConfigDict(
187+
json_schema_extra={
188+
"example": {
189+
"Result": {
190+
"ResultType": 0,
191+
"ResultCode": 0,
192+
"ResultDesc": "The service request is processed successfully",
193+
"OriginatorConversationID": "626f6ddf-ab37-4650-b882-b1de92ec9aa4",
194+
"ConversationID": "12345677dfdf89099B3",
195+
"TransactionID": "QKA81LK5CY",
196+
"ResultParameters": {
197+
"ResultParameter": [
198+
{
199+
"Key": "DebitAccountBalance",
200+
"Value": "{Amount={CurrencyCode=KES, MinimumAmount=618683, BasicAmount=6186.83}}",
201+
},
202+
{"Key": "Amount", "Value": "190.00"},
203+
{
204+
"Key": "DebitPartyAffectedAccountBalance",
205+
"Value": "Working Account|KES|346568.83|6186.83|340382.00|0.00",
206+
},
207+
{"Key": "TransCompletedTime", "Value": "20221110110717"},
208+
{"Key": "DebitPartyCharges", "Value": ""},
209+
{
210+
"Key": "ReceiverPartyPublicName",
211+
"Value": "000000– Biller Company",
212+
},
213+
{"Key": "Currency", "Value": "KES"},
214+
{
215+
"Key": "InitiatorAccountCurrentBalance",
216+
"Value": "{Amount={CurrencyCode=KES, MinimumAmount=618683, BasicAmount=6186.83}}",
217+
},
218+
]
219+
},
220+
"ReferenceData": {
221+
"ReferenceItem": [
222+
{"Key": "BillReferenceNumber", "Value": "19008"},
223+
{
224+
"Key": "QueueTimeoutURL",
225+
"Value": "https://mydomain.com/b2b/businessbuygoods/queue/",
226+
},
227+
]
228+
},
229+
}
230+
}
231+
}
232+
)
233+
234+
def is_successful(self) -> bool:
235+
"""Check if the result indicates a successful transaction."""
236+
code = str(self.Result.ResultCode)
237+
return code.strip("0") == "" and code != ""
238+
239+
240+
class BusinessBuyGoodsResultCallbackResponse(BaseModel):
241+
"""Response schema for Business Buy Goods result callback."""
242+
243+
ResultCode: int = 0
244+
ResultDesc: str = "Callback received successfully."
245+
246+
model_config = ConfigDict(
247+
json_schema_extra={
248+
"example": {
249+
"ResultCode": 0,
250+
"ResultDesc": "The service request is processed successfully.",
251+
}
252+
}
253+
)
254+
255+
256+
class BusinessBuyGoodsTimeoutCallback(BaseModel):
257+
"""Represents the timeout callback for Business Buy Goods transactions."""
258+
259+
Result: BusinessBuyGoodsResultMetadata = Field(..., description="Result metadata.")
260+
261+
model_config = ConfigDict(
262+
json_schema_extra={
263+
"example": {
264+
"Result": {
265+
"ResultType": 0,
266+
"ResultCode": 2001,
267+
"ResultDesc": "The initiator information is invalid.",
268+
"OriginatorConversationID": "12337-23509183-5",
269+
"ConversationID": "AG_20200120_0000657265d5fa9ae5c0",
270+
"TransactionID": "OAK0000000",
271+
"ResultParameters": {
272+
"ResultParameter": {
273+
"Key": "BOCompletedTime",
274+
"Value": 20200120164825,
275+
}
276+
},
277+
"ReferenceData": {
278+
"ReferenceItem": {
279+
"Key": "QueueTimeoutURL",
280+
"Value": "https://mydomain.com/b2b/businessbuygoods/queue/",
281+
}
282+
},
283+
}
284+
}
285+
}
286+
)
287+
288+
289+
class BusinessBuyGoodsTimeoutCallbackResponse(BaseModel):
290+
"""Response schema for Business Buy Goods timeout callback."""
291+
292+
ResultCode: int = 0
293+
ResultDesc: str = "Timeout notification received successfully."
294+
295+
model_config = ConfigDict(
296+
json_schema_extra={
297+
"example": {
298+
"ResultCode": 0,
299+
"ResultDesc": "The service request timed out response received.",
300+
}
301+
}
302+
)

0 commit comments

Comments
 (0)