Skip to content

Commit 91b681f

Browse files
authored
Merge pull request #9 from RafaelJohn9/feature/qrcode-api
Feature/qrcode api
2 parents 669fe7f + 071bef3 commit 91b681f

8 files changed

Lines changed: 511 additions & 1 deletion

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
from .dynamic_qr_code import DynamicQRCode
2+
from .schemas import (
3+
DynamicQRGenerateRequest,
4+
DynamicQRGenerateResponse,
5+
DynamicQRTransactionType,
6+
)
7+
8+
__all__ = [
9+
"DynamicQRCode",
10+
"DynamicQRGenerateRequest",
11+
"DynamicQRGenerateResponse",
12+
"DynamicQRTransactionType",
13+
]
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
"""Dynamic QR Code: Generates a dynamic M-Pesa QR Code.
2+
3+
This module provides functionality to generate a Dynamic QR code using the M-Pesa API.
4+
It requires a valid access token for authentication and uses the HttpClient for making 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+
DynamicQRGenerateRequest,
13+
DynamicQRGenerateResponse,
14+
)
15+
16+
17+
class DynamicQRCode(BaseModel):
18+
"""Represents the request payload for generating a Dynamic M-Pesa QR code.
19+
20+
https://developer.safaricom.co.ke/APIs/DynamicQR
21+
22+
Attributes:
23+
http_client (HttpClient): The HTTP client used to make requests to the M-Pesa API.
24+
token_manager (TokenManager): The token manager for handling access tokens.
25+
"""
26+
27+
http_client: HttpClient
28+
token_manager: TokenManager
29+
30+
model_config = ConfigDict(arbitrary_types_allowed=True)
31+
32+
def generate(self, request: DynamicQRGenerateRequest) -> DynamicQRGenerateResponse:
33+
"""Generates a Dynamic M-Pesa QR Code.
34+
35+
Args:
36+
request (DynamicQRGenerateRequest): The request data for generating the QR code.
37+
38+
Returns:
39+
DynamicQRGenerateResponse: The response from the M-Pesa API after generating the QR code.
40+
"""
41+
url = "/mpesa/qrcode/v1/generate"
42+
headers = {
43+
"Authorization": f"Bearer {self.token_manager.get_token()}",
44+
"Content-Type": "application/json",
45+
}
46+
47+
response_data = self.http_client.post(url, json=dict(request), headers=headers)
48+
49+
return DynamicQRGenerateResponse(**response_data)
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
"""mpesa_sdk.dynamic_qr_code.schemas.
2+
3+
This module defines the schemas for generating a dynamic M-Pesa QR code.
4+
It includes request and response models using Pydantic for validation and serialization.
5+
"""
6+
7+
from enum import Enum
8+
from typing import Optional
9+
from pydantic import BaseModel, Field, ConfigDict
10+
from pydantic import model_validator
11+
from mpesa_sdk.utils.phone import normalize_phone_number
12+
13+
14+
class DynamicQRTransactionType(str, Enum):
15+
"""Enum representing the supported transaction types for Dynamic QR."""
16+
17+
BUY_GOODS = "BG" # Pay Merchant (Buy Goods)
18+
WITHDRAW_CASH = "WA" # Withdraw Cash at Agent Till
19+
PAYBILL = "PB" # Paybill or Business number
20+
SEND_MONEY = "SM" # Send Money (Mobile number)
21+
SEND_TO_BUSINESS = "SB" # Sent to Business (Bu.siness number CPI in MSISDN format)
22+
23+
24+
class DynamicQRGenerateRequest(BaseModel):
25+
"""Represents the request payload for generating a Dynamic QR code.
26+
27+
https://developer.safaricom.co.ke/APIs/DynamicQRCode
28+
29+
Attributes:
30+
MerchantName (str): Name of the Company/M-Pesa Merchant Name.
31+
RefNo (str): Transaction Reference. For Paybill, Withdraw Cash, and similar transactions, this is where you enter your account number.
32+
Amount (float): The total amount for the sale/transaction.
33+
TrxCode (DynamicQRTransactionType): Transaction Type.
34+
CPI (str): Credit Party Identifier (Mobile Number, Business Number, Agent Till, Paybill, etc.).
35+
Size (str): Size of the QR code image in pixels (always a square image).
36+
"""
37+
38+
MerchantName: str = Field(
39+
...,
40+
description="Name of the Company/M-Pesa Merchant Name.",
41+
examples=["TEST SUPERMARKET"],
42+
)
43+
RefNo: str = Field(
44+
...,
45+
description="Transaction Reference. For Paybill, Withdraw Cash, and similar transactions, this is where you enter your account number.",
46+
examples=["Invoice Test", "xewr34fer4t", "ACC12345"],
47+
)
48+
Amount: int = Field(
49+
...,
50+
description="The total amount for the sale/transaction.",
51+
examples=[1, 2000],
52+
gt=0,
53+
)
54+
TrxCode: str = Field(
55+
...,
56+
description="Transaction Type. Supported: BG, WA, PB, SM, SB.",
57+
examples=["BG"],
58+
)
59+
60+
CPI: str = Field(
61+
...,
62+
description="Credit Party Identifier. Can be a Mobile Number, Business Number, Agent Till, Paybill or Business number, or Merchant Buy Goods.",
63+
examples=["373132", "174379"],
64+
)
65+
Size: str = Field(
66+
...,
67+
description="Size of the QR code image in pixels. QR code image will always be a square image.",
68+
examples=["300"],
69+
)
70+
71+
model_config = ConfigDict(
72+
json_schema_extra={
73+
"example": {
74+
"MerchantName": "TEST SUPERMARKET",
75+
"RefNo": "Invoice Test",
76+
"Amount": 1,
77+
"TrxCode": "BG",
78+
"CPI": "373132",
79+
"Size": "300",
80+
}
81+
}
82+
)
83+
84+
@classmethod
85+
def validate_trx_code(cls, value):
86+
"""Validates the transaction code against the DynamicQRTransactionType enum."""
87+
try:
88+
DynamicQRTransactionType(value)
89+
except ValueError:
90+
raise ValueError(
91+
f"TrxCode must be one of: {[e.value for e in DynamicQRTransactionType]}"
92+
)
93+
return value
94+
95+
@classmethod
96+
def normalize_cpi_for_send_money(cls, values):
97+
"""If TrxCode is SEND_MONEY, normalize the CPI (mobile number).
98+
99+
- If it starts with '0', replace with '254'
100+
- If it starts with '+254', replace with '254'
101+
- If it can't be normalized, raise ValueError
102+
"""
103+
trx_code = values.get("TrxCode")
104+
cpi = values.get("CPI")
105+
if trx_code == DynamicQRTransactionType.SEND_MONEY.value and isinstance(
106+
cpi, str
107+
):
108+
normalized = normalize_phone_number(cpi)
109+
if normalized is None:
110+
raise ValueError(
111+
"CPI for SEND_MONEY must be a valid Kenyan phone number starting with '0', '+254', or '254'."
112+
)
113+
values["CPI"] = normalized
114+
return values
115+
116+
@model_validator(mode="before")
117+
def validate(cls, values):
118+
"""Validates the TrxCode field before model validation."""
119+
# Validate the TrxCode field
120+
trx_code = values.get("TrxCode")
121+
if trx_code is not None:
122+
cls.validate_trx_code(trx_code)
123+
124+
# Normalize CPI for SEND_MONEY transaction type
125+
values = cls.normalize_cpi_for_send_money(values)
126+
127+
return values
128+
129+
130+
class DynamicQRGenerateResponse(BaseModel):
131+
"""Represents the response returned after generating a Dynamic QR code.
132+
133+
https://developer.safaricom.co.ke/APIs/DynamicQRCode
134+
135+
Attributes:
136+
ResponseCode (str): Used to return the Transaction Type (alpha-numeric string).
137+
RequestID (str): Unique identifier for the request.
138+
ResponseDescription (str): Description of the transaction status.
139+
QRCode (str): QR Code Image/Data/String (base64 or similar).
140+
"""
141+
142+
ResponseCode: str = Field(
143+
...,
144+
description="Used to show if the transaction was successful or not. 00 indicates success.",
145+
examples=["00"],
146+
)
147+
ResponseDescription: str = Field(
148+
...,
149+
description="This is a response describing the status of the transaction.",
150+
examples=["QR Code Successfully Generated."],
151+
)
152+
QRCode: str = Field(
153+
...,
154+
description="QR Code Image/Data/String.",
155+
examples=["iVBORw0KGgoAAAANSUhEUgAAASwAAAEsCAIAAAD2HxkiAAAHtElEQVR42..."],
156+
)
157+
158+
model_config = ConfigDict(
159+
json_schema_extra={
160+
"example": {
161+
"ResponseCode": "00",
162+
"RequestID": "16738-27456357-1",
163+
"ResponseDescription": "QR Code Successfully Generated.",
164+
"QRCode": "iVBORw0KGgoAAAANSUhEUgAAASwAAAEsCAIAAAD2HxkiAAAHtElEQVR42...",
165+
}
166+
}
167+
)

mpesa_sdk/utils/phone.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
"""Utility functions for handling phone numbers in the M-Pesa SDK."""
2+
3+
4+
def normalize_phone_number(phone: str) -> str | None:
5+
"""Normalize a Kenyan phone number to the '2547XXXXXXXX' format.
6+
7+
- If it starts with '0', replace with '254'
8+
- If it starts with '+254', replace with '254'
9+
- If it starts with '254', return as is
10+
- Handles whitespace anywhere in the number
11+
- Otherwise, return None
12+
13+
Args:
14+
phone (str): The phone number to normalize.
15+
16+
Returns:
17+
str | None: Normalized phone number or None if invalid.
18+
"""
19+
if not isinstance(phone, str):
20+
return None
21+
phone = phone.strip().replace(" ", "")
22+
normalized = None
23+
if phone.startswith("+254") and len(phone) == 13:
24+
normalized = "254" + phone[4:]
25+
elif phone.startswith("0") and len(phone) == 10:
26+
normalized = "254" + phone[1:]
27+
elif phone.startswith("254") and len(phone) == 12:
28+
normalized = phone
29+
30+
if (
31+
normalized
32+
and normalized.isdigit()
33+
and len(normalized) == 12
34+
and normalized.startswith("2547")
35+
):
36+
return normalized
37+
else:
38+
return None
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
"""End-to-End Test for M-Pesa Dynamic QR Code Generation."""
2+
3+
import os
4+
import pytest
5+
from dotenv import load_dotenv
6+
7+
from mpesa_sdk.dynamic_qr_code import (
8+
DynamicQRGenerateRequest,
9+
DynamicQRCode,
10+
DynamicQRTransactionType,
11+
)
12+
from mpesa_sdk.auth import TokenManager
13+
from mpesa_sdk.http_client.mpesa_http_client import MpesaHttpClient
14+
15+
pytestmark = pytest.mark.live
16+
17+
load_dotenv()
18+
19+
20+
@pytest.fixture
21+
def dynamic_qr_service():
22+
"""Initialize the M-Pesa Dynamic QR Code service with authentication."""
23+
http_client = MpesaHttpClient(env=os.getenv("MPESA_ENV", "sandbox"))
24+
token_manager = TokenManager(
25+
http_client=http_client,
26+
consumer_key=os.getenv("MPESA_CONSUMER_KEY"),
27+
consumer_secret=os.getenv("MPESA_CONSUMER_SECRET"),
28+
)
29+
return DynamicQRCode(http_client=http_client, token_manager=token_manager)
30+
31+
32+
def test_dynamic_qr_code_generate(dynamic_qr_service):
33+
"""End-to-end test for M-Pesa Dynamic QR Code generation."""
34+
request = DynamicQRGenerateRequest(
35+
MerchantName="Test Supermarket",
36+
RefNo="xewr34fer4t",
37+
Amount=200,
38+
TrxCode=DynamicQRTransactionType.BUY_GOODS,
39+
CPI="373132",
40+
Size="300",
41+
)
42+
response = dynamic_qr_service.generate(request)
43+
# Basic assertions - adapt as needed for your SDK's response structure
44+
assert response is not None
45+
assert hasattr(response, "QRCode") or hasattr(response, "qr_code")
46+
assert getattr(response, "QRCode", None) or getattr(response, "qr_code", None)

tests/integration/mpesa_express/test_stk_push_e2e.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ def test_stk_push_full_e2e_with_query(stk_service, fastapi_server, ngrok_tunnel)
113113
callback = None
114114
for _ in range(30):
115115
time.sleep(1)
116-
r = requests.get(f"{callback_base_url}/latest")
116+
r = requests.get(f"{callback_base_url}/latest", timeout=45)
117117
if r.status_code == 200:
118118
callback_received = True
119119
callback_json = r.json()["parsed"]

0 commit comments

Comments
 (0)