Skip to content

Commit 8c00f83

Browse files
committed
feat (B2B Service): added async.
1 parent c107312 commit 8c00f83

3 files changed

Lines changed: 288 additions & 6 deletions

File tree

mpesakit/services/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from .b2b import B2BService
1+
from .b2b import B2BService, AsyncB2BService
22
from .b2c import B2CService
33
from .balance import BalanceService
44
from .bill import BillService

mpesakit/services/b2b.py

Lines changed: 125 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
"""Facade for M-Pesa B2B APIs (Express Checkout)."""
22

33
from typing import Optional
4-
from mpesakit.auth import TokenManager
5-
from mpesakit.http_client import HttpClient
4+
from mpesakit.auth import TokenManager, AsyncTokenManager
5+
from mpesakit.http_client import HttpClient, AsyncHttpClient
6+
from mpesakit.b2b_express_checkout import AsyncB2BExpressCheckout
7+
from mpesakit.business_paybill import AsyncBusinessPayBill
68
from mpesakit.business_buy_goods import (
79
BusinessBuyGoods,
10+
AsyncBusinessBuyGoods,
811
BusinessBuyGoodsRequest,
912
BusinessBuyGoodsResponse,
1013
)
@@ -186,3 +189,123 @@ def buygoods(
186189
},
187190
)
188191
return self._business_buygoods.buy_goods(request)
192+
193+
class AsyncB2BService:
194+
"""Async facade for all M-Pesa B2B APIs."""
195+
196+
def __init__(self, http_client: AsyncHttpClient, token_manager: AsyncTokenManager) -> None:
197+
"""Initialize Async B2BService facade."""
198+
self.http_client = http_client
199+
self.token_manager = token_manager
200+
201+
202+
self._express_checkout = AsyncB2BExpressCheckout(
203+
http_client=self.http_client,
204+
token_manager=self.token_manager,
205+
)
206+
self._business_paybill = AsyncBusinessPayBill(
207+
http_client=self.http_client,
208+
token_manager=self.token_manager,
209+
)
210+
self._business_buygoods = AsyncBusinessBuyGoods(
211+
http_client=self.http_client,
212+
token_manager=self.token_manager,
213+
)
214+
215+
async def express_checkout(
216+
self,
217+
primary_short_code: str,
218+
receiver_short_code: str,
219+
amount: int,
220+
payment_ref: str,
221+
callback_url: str,
222+
partner_name: str,
223+
request_ref_id: str,
224+
**kwargs,
225+
) -> B2BExpressCheckoutResponse:
226+
"""Initiate a B2B Express Checkout USSD Push transaction to another merchant."""
227+
request = B2BExpressCheckoutRequest(
228+
primaryShortCode=primary_short_code,
229+
receiverShortCode=receiver_short_code,
230+
amount=amount,
231+
paymentRef=payment_ref,
232+
callbackUrl=callback_url,
233+
partnerName=partner_name,
234+
RequestRefID=request_ref_id,
235+
**{
236+
k: v
237+
for k, v in kwargs.items()
238+
if k in B2BExpressCheckoutRequest.model_fields
239+
},
240+
)
241+
return await self._express_checkout.ussd_push(request)
242+
243+
async def paybill(
244+
self,
245+
initiator: str,
246+
security_credential: str,
247+
amount: int,
248+
party_a: int,
249+
party_b: int,
250+
account_reference: str,
251+
requester: str,
252+
remarks: str,
253+
queue_timeout_url: str,
254+
result_url: str,
255+
**kwargs,
256+
) -> BusinessPayBillResponse:
257+
"""Initiate a Business PayBill transaction to another merchant."""
258+
request = BusinessPayBillRequest(
259+
Initiator=initiator,
260+
SecurityCredential=security_credential,
261+
Amount=amount,
262+
PartyA=party_a,
263+
PartyB=party_b,
264+
AccountReference=account_reference,
265+
Requester=requester,
266+
Remarks=remarks,
267+
QueueTimeOutURL=queue_timeout_url,
268+
ResultURL=result_url,
269+
**{
270+
k: v
271+
for k, v in kwargs.items()
272+
if k in BusinessPayBillRequest.model_fields
273+
},
274+
)
275+
return await self._business_paybill.paybill(request)
276+
277+
async def buygoods(
278+
self,
279+
initiator: str,
280+
security_credential: str,
281+
amount: int,
282+
party_a: int,
283+
party_b: int,
284+
account_reference: str,
285+
requester: str,
286+
remarks: str,
287+
queue_timeout_url: str,
288+
result_url: str,
289+
occassion: Optional[str] = None,
290+
**kwargs,
291+
) -> BusinessBuyGoodsResponse:
292+
"""Initiate a Business Buy Goods transaction to another merchant."""
293+
request = BusinessBuyGoodsRequest(
294+
Initiator=initiator,
295+
SecurityCredential=security_credential,
296+
Amount=amount,
297+
PartyA=party_a,
298+
PartyB=party_b,
299+
AccountReference=account_reference,
300+
Requester=requester,
301+
Remarks=remarks,
302+
QueueTimeOutURL=queue_timeout_url,
303+
ResultURL=result_url,
304+
Occassion=occassion,
305+
**{
306+
k: v
307+
for k, v in kwargs.items()
308+
if k in BusinessBuyGoodsRequest.model_fields
309+
},
310+
)
311+
return await self._business_buygoods.buy_goods(request)

tests/unit/services/test_b2b_service.py

Lines changed: 162 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import pytest
44
from unittest.mock import MagicMock
55

6-
from mpesakit.services.b2b import B2BService
6+
from mpesakit.services.b2b import B2BService, AsyncB2BService
77
from mpesakit.business_buy_goods import (
88
BusinessBuyGoodsResponse,
99
)
@@ -13,8 +13,9 @@
1313
from mpesakit.b2b_express_checkout import (
1414
B2BExpressCheckoutResponse,
1515
)
16-
from mpesakit.auth import TokenManager
17-
from mpesakit.http_client import HttpClient
16+
from mpesakit.auth import TokenManager, AsyncTokenManager
17+
from mpesakit.http_client import HttpClient, AsyncHttpClient
18+
from unittest.mock import AsyncMock
1819

1920

2021
@pytest.fixture
@@ -30,6 +31,18 @@ def mock_http_client():
3031
"""Mock HttpClient to simulate HTTP requests."""
3132
return MagicMock(spec=HttpClient)
3233

34+
@pytest.fixture
35+
def mock_async_token_manager():
36+
"""Mock AsyncTokenManager to return a fixed token."""
37+
mock = AsyncMock(spec=AsyncTokenManager)
38+
mock.get_token.return_value = "async_test_token"
39+
return mock
40+
41+
42+
@pytest.fixture
43+
def mock_async_http_client():
44+
"""Mock AsyncHttpClient to simulate async HTTP requests."""
45+
return AsyncMock(spec=AsyncHttpClient)
3346

3447
@pytest.fixture
3548
def b2b_service(mock_http_client, mock_token_manager):
@@ -156,3 +169,149 @@ def test_b2b_service_initializes_services_correctly(
156169
if hasattr(service, "buygoods_service"):
157170
assert service.buygoods_service.http_client is mock_http_client
158171
assert service.buygoods_service.token_manager is mock_token_manager
172+
@pytest.fixture
173+
def mock_async_http_client():
174+
"""Mock async HttpClient to simulate async HTTP requests."""
175+
client = MagicMock(spec=HttpClient)
176+
client.post = AsyncMock()
177+
return client
178+
179+
180+
@pytest.fixture
181+
def async_b2b_service(mock_async_http_client, mock_async_token_manager):
182+
"""Fixture to create an AsyncB2BService instance with mocked dependencies."""
183+
return AsyncB2BService(
184+
http_client=mock_async_http_client,
185+
token_manager=mock_async_token_manager
186+
)
187+
188+
189+
@pytest.mark.asyncio
190+
async def test_async_express_checkout_calls_ussd_push(
191+
async_b2b_service, mock_async_http_client
192+
):
193+
"""Test that async express_checkout calls the B2BExpressCheckout service."""
194+
response_data = {"code": "0", "status": "USSD Initiated Successfully"}
195+
mock_async_http_client.post.return_value = response_data
196+
197+
resp = await async_b2b_service.express_checkout(
198+
primary_short_code="123456",
199+
receiver_short_code="654321",
200+
amount=100,
201+
payment_ref="Invoice123",
202+
callback_url="http://example.com/result",
203+
partner_name="VendorName",
204+
request_ref_id="550e8400-e29b-41d4-a716-446655440000",
205+
)
206+
207+
assert isinstance(resp, B2BExpressCheckoutResponse)
208+
assert resp.code == "0"
209+
assert resp.status == "USSD Initiated Successfully"
210+
211+
212+
@pytest.mark.asyncio
213+
async def test_async_paybill_calls_paybill(async_b2b_service, mock_async_http_client):
214+
"""Test that async paybill calls the BusinessPayBill service."""
215+
response_data = {
216+
"OriginatorConversationID": "5118-111210482-1",
217+
"ConversationID": "AG_20230420_2010759fd5662ef6d054",
218+
"ResponseCode": "0",
219+
"ResponseDescription": "Accept the service request successfully.",
220+
}
221+
mock_async_http_client.post.return_value = response_data
222+
223+
resp = await async_b2b_service.paybill(
224+
initiator="apiuser",
225+
security_credential="secure",
226+
amount=200,
227+
party_a=111111,
228+
party_b=222222,
229+
account_reference="Ref001",
230+
requester="254700000000",
231+
remarks="Test",
232+
queue_timeout_url="http://timeout.url",
233+
result_url="http://result.url",
234+
)
235+
236+
assert isinstance(resp, BusinessPayBillResponse)
237+
assert resp.is_successful() is True
238+
assert resp.ResponseDescription == "Accept the service request successfully."
239+
240+
241+
@pytest.mark.asyncio
242+
async def test_async_buygoods_calls_buy_goods(
243+
async_b2b_service, mock_async_http_client
244+
):
245+
"""Test that async buygoods calls the BusinessBuyGoods service."""
246+
response_data = {
247+
"OriginatorConversationID": "5118-111210482-1",
248+
"ConversationID": "AG_20230420_2010759fd5662ef6d054",
249+
"ResponseCode": "0",
250+
"ResponseDescription": "Accept the service request successfully.",
251+
}
252+
mock_async_http_client.post.return_value = response_data
253+
254+
resp = await async_b2b_service.buygoods(
255+
initiator="apiuser",
256+
security_credential="secure",
257+
amount=300,
258+
party_a=333333,
259+
party_b=444444,
260+
account_reference="Ref002",
261+
requester="254711111111",
262+
remarks="BuyGoods",
263+
queue_timeout_url="http://timeout.url",
264+
result_url="http://result.url",
265+
occassion="Occasion",
266+
)
267+
268+
assert isinstance(resp, BusinessBuyGoodsResponse)
269+
assert resp.is_successful() is True
270+
assert resp.ResponseDescription == "Accept the service request successfully."
271+
272+
273+
@pytest.mark.asyncio
274+
async def test_async_express_checkout_filters_kwargs(
275+
async_b2b_service, mock_async_http_client
276+
):
277+
"""Test that async express_checkout filters out unexpected kwargs."""
278+
response_data = {"code": "0", "status": "USSD Initiated Successfully"}
279+
mock_async_http_client.post.return_value = response_data
280+
281+
resp = await async_b2b_service.express_checkout(
282+
primary_short_code="123456",
283+
receiver_short_code="654321",
284+
amount=100,
285+
payment_ref="Invoice123",
286+
callback_url="http://example.com/result",
287+
partner_name="VendorName",
288+
request_ref_id="550e8400-e29b-41d4-a716-446655440000",
289+
unexpected_field="should be ignored",
290+
)
291+
292+
assert isinstance(resp, B2BExpressCheckoutResponse)
293+
assert resp.is_successful() is True
294+
assert resp.status == "USSD Initiated Successfully"
295+
296+
297+
def test_async_b2b_service_initializes_services_correctly(
298+
mock_async_http_client, mock_async_token_manager
299+
):
300+
"""Test AsyncB2BService initializes dependencies with correct arguments."""
301+
service = AsyncB2BService(
302+
http_client=mock_async_http_client,
303+
token_manager=mock_async_token_manager,
304+
)
305+
306+
assert service.http_client is mock_async_http_client
307+
assert service.token_manager is mock_async_token_manager
308+
309+
if hasattr(service, "express_checkout_service"):
310+
assert service.express_checkout_service.http_client is mock_async_http_client
311+
assert service.express_checkout_service.token_manager is mock_async_token_manager
312+
if hasattr(service, "paybill_service"):
313+
assert service.paybill_service.http_client is mock_async_http_client
314+
assert service.paybill_service.token_manager is mock_async_token_manager
315+
if hasattr(service, "buygoods_service"):
316+
assert service.buygoods_service.http_client is mock_async_http_client
317+
assert service.buygoods_service.token_manager is mock_async_token_manager

0 commit comments

Comments
 (0)