Implement Moyasar payments flow with webhooks
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
import uuid
|
||||
from datetime import timedelta
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from apps.accounts.models import User, UserRole
|
||||
from apps.bookings.models import Booking, BookingStatus
|
||||
from apps.payments.models import Payment, PaymentProvider, PaymentStatus
|
||||
from apps.salons.models import Salon, Service, StaffProfile
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def booking_entities():
|
||||
owner = User.objects.create_user(email="owner@example.com", password="pass", role=UserRole.MANAGER)
|
||||
customer = User.objects.create_user(email="customer@example.com", password="pass")
|
||||
staff_user = User.objects.create_user(email="staff@example.com", password="pass", role=UserRole.STAFF)
|
||||
|
||||
salon = Salon.objects.create(
|
||||
owner=owner,
|
||||
name="Main Salon",
|
||||
description="",
|
||||
address="123 King Rd",
|
||||
city="Riyadh",
|
||||
phone_number="0512345678",
|
||||
)
|
||||
service = Service.objects.create(
|
||||
salon=salon,
|
||||
name="Haircut",
|
||||
description="",
|
||||
duration_minutes=60,
|
||||
price_amount=120,
|
||||
currency="SAR",
|
||||
)
|
||||
staff = StaffProfile.objects.create(user=staff_user, salon=salon)
|
||||
|
||||
start_time = timezone.now() + timedelta(days=1)
|
||||
end_time = start_time + timedelta(minutes=60)
|
||||
booking = Booking.objects.create(
|
||||
salon=salon,
|
||||
customer=customer,
|
||||
service=service,
|
||||
staff=staff,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
status=BookingStatus.PENDING,
|
||||
price_amount=service.price_amount,
|
||||
currency=service.currency,
|
||||
notes="",
|
||||
)
|
||||
return customer, booking
|
||||
|
||||
|
||||
def _mock_gateway_response(payment_id="pay_test", status="initiated"):
|
||||
response = Mock()
|
||||
response.status_code = 201
|
||||
response.json.return_value = {
|
||||
"id": payment_id,
|
||||
"status": status,
|
||||
"source": {"transaction_url": "https://moyasar.example/tx"},
|
||||
}
|
||||
response.content = b"{}"
|
||||
return response
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("apps.payments.services.gateway.requests.post")
|
||||
def test_create_payment_idempotency_returns_existing(mock_post, booking_entities, monkeypatch):
|
||||
customer, booking = booking_entities
|
||||
client = APIClient()
|
||||
client.force_authenticate(user=customer)
|
||||
|
||||
monkeypatch.setenv("MOYASAR_SECRET_KEY", "sk_test")
|
||||
monkeypatch.setenv("MOYASAR_PUBLISHABLE_KEY", "pk_test")
|
||||
|
||||
mock_post.return_value = _mock_gateway_response()
|
||||
request_id = str(uuid.uuid4())
|
||||
|
||||
payload = {
|
||||
"booking_id": booking.id,
|
||||
"provider": PaymentProvider.MOYASAR,
|
||||
"idempotency_key": request_id,
|
||||
"source": {"type": "stcpay", "mobile": "0500000000"},
|
||||
}
|
||||
|
||||
response = client.post(reverse("payment-list"), payload, content_type="application/json")
|
||||
assert response.status_code == 201
|
||||
|
||||
response_repeat = client.post(reverse("payment-list"), payload, content_type="application/json")
|
||||
assert response_repeat.status_code == 200
|
||||
assert Payment.objects.count() == 1
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_rejects_creditcard_source(booking_entities):
|
||||
customer, booking = booking_entities
|
||||
client = APIClient()
|
||||
client.force_authenticate(user=customer)
|
||||
|
||||
payload = {
|
||||
"booking_id": booking.id,
|
||||
"provider": PaymentProvider.MOYASAR,
|
||||
"idempotency_key": str(uuid.uuid4()),
|
||||
"source": {"type": "creditcard", "number": "4111111111111111"},
|
||||
}
|
||||
|
||||
response = client.post(reverse("payment-list"), payload, content_type="application/json")
|
||||
assert response.status_code == 400
|
||||
assert "source" in response.json()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_webhook_paid_updates_status(booking_entities, monkeypatch):
|
||||
_, booking = booking_entities
|
||||
monkeypatch.setenv("MOYASAR_WEBHOOK_SECRET", "secret")
|
||||
|
||||
payment = Payment.objects.create(
|
||||
booking=booking,
|
||||
provider=PaymentProvider.MOYASAR,
|
||||
status=PaymentStatus.INITIATED,
|
||||
amount=booking.price_amount,
|
||||
currency=booking.currency,
|
||||
external_id="pay_webhook",
|
||||
metadata={},
|
||||
)
|
||||
|
||||
payload = {
|
||||
"type": "payment_paid",
|
||||
"secret_token": "secret",
|
||||
"data": {"id": "pay_webhook"},
|
||||
}
|
||||
|
||||
client = APIClient()
|
||||
response = client.post(reverse("payment-webhook"), payload, content_type="application/json")
|
||||
assert response.status_code == 200
|
||||
|
||||
payment.refresh_from_db()
|
||||
assert payment.status == PaymentStatus.PAID
|
||||
assert payment.paid_at is not None
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_webhook_invalid_secret_is_rejected(booking_entities, monkeypatch):
|
||||
_, booking = booking_entities
|
||||
monkeypatch.setenv("MOYASAR_WEBHOOK_SECRET", "secret")
|
||||
|
||||
Payment.objects.create(
|
||||
booking=booking,
|
||||
provider=PaymentProvider.MOYASAR,
|
||||
status=PaymentStatus.INITIATED,
|
||||
amount=booking.price_amount,
|
||||
currency=booking.currency,
|
||||
external_id="pay_webhook",
|
||||
metadata={},
|
||||
)
|
||||
|
||||
payload = {
|
||||
"type": "payment_paid",
|
||||
"secret_token": "wrong",
|
||||
"data": {"id": "pay_webhook"},
|
||||
}
|
||||
|
||||
client = APIClient()
|
||||
response = client.post(reverse("payment-webhook"), payload, content_type="application/json")
|
||||
assert response.status_code == 401
|
||||
Reference in New Issue
Block a user