Enhance documentation, implement Twilio OTP delivery, and update payment gateway methods. Updated AGENTS.md and README.md for clarity on ExecPlans and architecture. Added Twilio as a dependency and implemented capture/refund methods in MoyasarGateway. Improved frontend routing with react-router-dom and added authentication context. Updated styles and localization files for better user experience.
This commit is contained in:
@@ -50,6 +50,8 @@ class ConsoleOtpProvider(BaseOtpProvider):
|
||||
|
||||
|
||||
class TwilioOtpProvider(BaseOtpProvider):
|
||||
"""Twilio provider for SMS and WhatsApp OTP delivery. Requires TWILIO_* env vars."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.account_sid = os.getenv("TWILIO_ACCOUNT_SID")
|
||||
self.auth_token = os.getenv("TWILIO_AUTH_TOKEN")
|
||||
@@ -60,15 +62,23 @@ class TwilioOtpProvider(BaseOtpProvider):
|
||||
if not self.account_sid or not self.auth_token or not self.from_number:
|
||||
raise ValueError(_("Twilio credentials are not configured"))
|
||||
|
||||
def send_sms(self, to_number: str, message: str) -> None:
|
||||
def _get_client(self):
|
||||
from twilio.rest import Client
|
||||
self._assert_config()
|
||||
raise NotImplementedError(_("Twilio SMS adapter not implemented yet"))
|
||||
return Client(self.account_sid, self.auth_token)
|
||||
|
||||
def send_sms(self, to_number: str, message: str) -> None:
|
||||
client = self._get_client()
|
||||
client.messages.create(body=message, from_=self.from_number, to=to_number)
|
||||
|
||||
def send_whatsapp(self, to_number: str, message: str) -> None:
|
||||
self._assert_config()
|
||||
if not self.whatsapp_from:
|
||||
raise ValueError(_("Twilio WhatsApp sender is not configured"))
|
||||
raise NotImplementedError(_("Twilio WhatsApp adapter not implemented yet"))
|
||||
client = self._get_client()
|
||||
from_ = f"whatsapp:{self.whatsapp_from}"
|
||||
to = f"whatsapp:{to_number}"
|
||||
client.messages.create(body=message, from_=from_, to=to)
|
||||
|
||||
|
||||
class UnifonicOtpProvider(BaseOtpProvider):
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Tests for Twilio OTP provider implementation."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("apps.accounts.services.otp.TwilioOtpProvider._get_client")
|
||||
def test_twilio_send_sms_calls_client(mock_get_client):
|
||||
from apps.accounts.services.otp import TwilioOtpProvider
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_get_client.return_value = mock_client
|
||||
|
||||
with patch.dict("os.environ", {
|
||||
"TWILIO_ACCOUNT_SID": "AC123",
|
||||
"TWILIO_AUTH_TOKEN": "token",
|
||||
"TWILIO_FROM_NUMBER": "+966500000000",
|
||||
}):
|
||||
provider = TwilioOtpProvider()
|
||||
provider.send_sms("+966512345678", "Your code is 123456")
|
||||
|
||||
mock_client.messages.create.assert_called_once_with(
|
||||
body="Your code is 123456",
|
||||
from_="+966500000000",
|
||||
to="+966512345678",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("apps.accounts.services.otp.TwilioOtpProvider._get_client")
|
||||
def test_twilio_send_whatsapp_calls_client(mock_get_client):
|
||||
from apps.accounts.services.otp import TwilioOtpProvider
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_get_client.return_value = mock_client
|
||||
|
||||
with patch.dict("os.environ", {
|
||||
"TWILIO_ACCOUNT_SID": "AC123",
|
||||
"TWILIO_AUTH_TOKEN": "token",
|
||||
"TWILIO_FROM_NUMBER": "+966500000000",
|
||||
"TWILIO_WHATSAPP_FROM": "14155238886",
|
||||
}):
|
||||
provider = TwilioOtpProvider()
|
||||
provider.send_whatsapp("+966512345678", "Your code is 123456")
|
||||
|
||||
mock_client.messages.create.assert_called_once_with(
|
||||
body="Your code is 123456",
|
||||
from_="whatsapp:14155238886",
|
||||
to="whatsapp:+966512345678",
|
||||
)
|
||||
@@ -33,10 +33,12 @@ class BasePaymentGateway:
|
||||
) -> PaymentInitResult:
|
||||
raise NotImplementedError
|
||||
|
||||
def capture_payment(self, external_id: str) -> None:
|
||||
def capture_payment(self, external_id: str, amount: Optional[int] = None) -> None:
|
||||
"""Capture an authorized payment. Amount in minor units; omit for full capture."""
|
||||
raise NotImplementedError
|
||||
|
||||
def refund_payment(self, external_id: str, amount: Optional[str] = None) -> None:
|
||||
def refund_payment(self, external_id: str, amount: Optional[int] = None) -> None:
|
||||
"""Refund a paid/captured payment. Amount in minor units; omit for full refund."""
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
@@ -101,10 +103,36 @@ class MoyasarGateway(BasePaymentGateway):
|
||||
payload=data,
|
||||
)
|
||||
|
||||
def capture_payment(self, external_id: str) -> None:
|
||||
def capture_payment(self, external_id: str, amount: Optional[int] = None) -> None:
|
||||
"""Capture an authorized payment. Amount in minor units; omit for full capture."""
|
||||
self._assert_config()
|
||||
raise NotImplementedError("Moyasar capture not implemented yet")
|
||||
url = f"{self.base_url}/v1/payments/{external_id}/capture"
|
||||
payload = {} if amount is None else {"amount": amount}
|
||||
try:
|
||||
response = requests.post(url, json=payload, auth=(self.secret_key, ""), timeout=10)
|
||||
except requests.RequestException as exc:
|
||||
raise PaymentGatewayError("Failed to reach Moyasar for capture") from exc
|
||||
if response.status_code not in (200, 201):
|
||||
data = response.json() if response.content else {}
|
||||
raise PaymentGatewayError(
|
||||
"Moyasar capture failed",
|
||||
status_code=response.status_code,
|
||||
payload=data,
|
||||
)
|
||||
|
||||
def refund_payment(self, external_id: str, amount: Optional[str] = None) -> None:
|
||||
def refund_payment(self, external_id: str, amount: Optional[int] = None) -> None:
|
||||
"""Refund a paid/captured payment. Amount in minor units; omit for full refund."""
|
||||
self._assert_config()
|
||||
raise NotImplementedError("Moyasar refund not implemented yet")
|
||||
url = f"{self.base_url}/v1/payments/{external_id}/refund"
|
||||
payload = {} if amount is None else {"amount": amount}
|
||||
try:
|
||||
response = requests.post(url, json=payload, auth=(self.secret_key, ""), timeout=10)
|
||||
except requests.RequestException as exc:
|
||||
raise PaymentGatewayError("Failed to reach Moyasar for refund") from exc
|
||||
if response.status_code not in (200, 201):
|
||||
data = response.json() if response.content else {}
|
||||
raise PaymentGatewayError(
|
||||
"Moyasar refund failed",
|
||||
status_code=response.status_code,
|
||||
payload=data,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Tests for Moyasar capture and refund gateway methods."""
|
||||
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from apps.payments.services.gateway import MoyasarGateway, PaymentGatewayError
|
||||
|
||||
|
||||
@patch("apps.payments.services.gateway.requests.post")
|
||||
def test_moyasar_capture_calls_api(mock_post):
|
||||
mock_post.return_value = Mock(status_code=200, content=b"{}", json=lambda: {"id": "pay_1", "status": "captured"})
|
||||
|
||||
with patch.dict("os.environ", {
|
||||
"MOYASAR_SECRET_KEY": "sk_test",
|
||||
"MOYASAR_PUBLISHABLE_KEY": "pk_test",
|
||||
}):
|
||||
gateway = MoyasarGateway()
|
||||
gateway.capture_payment("pay_1")
|
||||
|
||||
mock_post.assert_called_once()
|
||||
call_args = mock_post.call_args
|
||||
assert "pay_1/capture" in call_args[0][0]
|
||||
assert call_args[1]["auth"] == ("sk_test", "")
|
||||
|
||||
|
||||
@patch("apps.payments.services.gateway.requests.post")
|
||||
def test_moyasar_refund_calls_api(mock_post):
|
||||
mock_post.return_value = Mock(status_code=200, content=b"{}", json=lambda: {"id": "pay_1", "status": "refunded"})
|
||||
|
||||
with patch.dict("os.environ", {
|
||||
"MOYASAR_SECRET_KEY": "sk_test",
|
||||
"MOYASAR_PUBLISHABLE_KEY": "pk_test",
|
||||
}):
|
||||
gateway = MoyasarGateway()
|
||||
gateway.refund_payment("pay_1")
|
||||
|
||||
mock_post.assert_called_once()
|
||||
call_args = mock_post.call_args
|
||||
assert "pay_1/refund" in call_args[0][0]
|
||||
|
||||
|
||||
@patch("apps.payments.services.gateway.requests.post")
|
||||
def test_moyasar_capture_raises_on_error(mock_post):
|
||||
mock_post.return_value = Mock(status_code=400, content=b'{"message":"Invalid"}', json=lambda: {"message": "Invalid"})
|
||||
|
||||
with patch.dict("os.environ", {
|
||||
"MOYASAR_SECRET_KEY": "sk_test",
|
||||
"MOYASAR_PUBLISHABLE_KEY": "pk_test",
|
||||
}):
|
||||
gateway = MoyasarGateway()
|
||||
with pytest.raises(PaymentGatewayError) as exc_info:
|
||||
gateway.capture_payment("pay_1")
|
||||
assert exc_info.value.status_code == 400
|
||||
Reference in New Issue
Block a user