307 lines
12 KiB
Python
307 lines
12 KiB
Python
from datetime import timedelta
|
|
|
|
import pytest
|
|
from django.utils import timezone
|
|
|
|
from apps.events.models import Registration
|
|
from apps.payments.models import Payment
|
|
from apps.payments.tasks import reconcile_payment
|
|
|
|
pytestmark = [pytest.mark.integration, pytest.mark.django_db]
|
|
|
|
|
|
def _pending_payment(user_factory, event_factory):
|
|
user = user_factory()
|
|
event = event_factory(price=25000)
|
|
registration = Registration.objects.create(
|
|
event=event,
|
|
user=user,
|
|
status=Registration.StatusChoices.PENDING,
|
|
)
|
|
payment = Payment.objects.create(
|
|
user=user,
|
|
event=event,
|
|
registration=registration,
|
|
base_amount=25000,
|
|
amount=25000,
|
|
authority=f"AUTH-{user.pk}",
|
|
status=Payment.OrderStatusChoices.PENDING,
|
|
)
|
|
return payment, registration
|
|
|
|
|
|
def test_reconciliation_retry_confirms_payment_after_transient_timeout(
|
|
user_factory, event_factory, mocker, settings
|
|
):
|
|
payment, registration = _pending_payment(user_factory, event_factory)
|
|
settings.ZARINPAL_MERCHANT_ID = "test"
|
|
settings.ZARINPAL_VERIFY_URL = "https://gateway.example/verify"
|
|
gateway = mocker.patch("apps.payments.services.requests.post")
|
|
gateway.side_effect = [
|
|
RuntimeError("temporary timeout"),
|
|
mocker.Mock(**{"json.return_value": {"data": {"code": 100, "ref_id": "REF-RETRY"}}}),
|
|
]
|
|
notify = mocker.patch("apps.payments.api.views._notify_payment_status")
|
|
enqueue = mocker.patch("apps.payments.tasks.reconcile_payment.apply_async")
|
|
|
|
assert reconcile_payment.run(payment.id) == "retry_scheduled"
|
|
payment.refresh_from_db()
|
|
assert payment.verification_attempts == 1
|
|
assert payment.verification_next_attempt_at is not None
|
|
enqueue.assert_called_once_with(args=(payment.id,), countdown=60)
|
|
Payment.objects.filter(pk=payment.pk).update(
|
|
verification_next_attempt_at=timezone.now() - timedelta(seconds=1)
|
|
)
|
|
gateway.side_effect = [
|
|
mocker.Mock(**{"json.return_value": {"data": {"code": 100, "ref_id": "REF-RETRY"}}})
|
|
]
|
|
result = reconcile_payment.run(payment.id)
|
|
|
|
payment.refresh_from_db()
|
|
registration.refresh_from_db()
|
|
assert result == "paid"
|
|
assert gateway.call_count == 2
|
|
assert payment.status == Payment.OrderStatusChoices.PAID
|
|
assert payment.ref_id == "REF-RETRY"
|
|
assert payment.verification_attempts == 2
|
|
assert payment.verification_manual_review_at is None
|
|
assert registration.status == Registration.StatusChoices.CONFIRMED
|
|
notify.assert_called_once()
|
|
|
|
|
|
def test_reconciliation_exhaustion_leaves_payment_pending_for_review(
|
|
user_factory, event_factory, mocker, settings
|
|
):
|
|
payment, registration = _pending_payment(user_factory, event_factory)
|
|
settings.ZARINPAL_MERCHANT_ID = "test"
|
|
settings.ZARINPAL_VERIFY_URL = "https://gateway.example/verify"
|
|
settings.PAYMENT_VERIFICATION_MAX_ATTEMPTS = 6
|
|
gateway = mocker.patch("apps.payments.services.requests.post", side_effect=RuntimeError("gateway offline"))
|
|
notify = mocker.patch("apps.payments.api.views._notify_payment_status")
|
|
mocker.patch("apps.payments.tasks.reconcile_payment.apply_async")
|
|
|
|
for attempt_number in range(settings.PAYMENT_VERIFICATION_MAX_ATTEMPTS):
|
|
Payment.objects.filter(pk=payment.pk).update(
|
|
verification_next_attempt_at=timezone.now() - timedelta(seconds=1)
|
|
)
|
|
result = reconcile_payment.run(payment.id)
|
|
if attempt_number < settings.PAYMENT_VERIFICATION_MAX_ATTEMPTS - 1:
|
|
assert result == "retry_scheduled"
|
|
|
|
payment.refresh_from_db()
|
|
registration.refresh_from_db()
|
|
assert result == "pending_manual_review"
|
|
assert gateway.call_count == settings.PAYMENT_VERIFICATION_MAX_ATTEMPTS
|
|
assert payment.verification_attempts == settings.PAYMENT_VERIFICATION_MAX_ATTEMPTS
|
|
assert payment.status == Payment.OrderStatusChoices.PENDING
|
|
assert payment.verification_manual_review_at is not None
|
|
assert payment.verification_next_attempt_at is None
|
|
assert registration.status == Registration.StatusChoices.PENDING
|
|
notify.assert_not_called()
|
|
|
|
|
|
def test_reconciliation_marks_only_explicit_gateway_failure_as_failed(
|
|
user_factory, event_factory, mocker, settings
|
|
):
|
|
payment, registration = _pending_payment(user_factory, event_factory)
|
|
settings.ZARINPAL_MERCHANT_ID = "test"
|
|
settings.ZARINPAL_VERIFY_URL = "https://gateway.example/verify"
|
|
gateway = mocker.patch("apps.payments.services.requests.post")
|
|
gateway.return_value.json.return_value = {"data": {"code": -51}}
|
|
notify = mocker.patch("apps.payments.api.views._notify_payment_status")
|
|
|
|
result = reconcile_payment.run(payment.id)
|
|
|
|
payment.refresh_from_db()
|
|
registration.refresh_from_db()
|
|
assert result == "failed"
|
|
assert gateway.call_count == 1
|
|
assert payment.status == Payment.OrderStatusChoices.FAILED
|
|
assert registration.status == Registration.StatusChoices.CANCELLED
|
|
notify.assert_called_once()
|
|
|
|
|
|
def test_failed_attempt_does_not_cancel_registration_used_by_another_active_payment(
|
|
user_factory, event_factory, mocker, settings
|
|
):
|
|
payment, registration = _pending_payment(user_factory, event_factory)
|
|
Payment.objects.create(
|
|
user=payment.user,
|
|
event=payment.event,
|
|
registration=registration,
|
|
base_amount=payment.base_amount,
|
|
amount=payment.amount,
|
|
authority=f"AUTH-OTHER-{payment.user_id}",
|
|
status=Payment.OrderStatusChoices.PENDING,
|
|
)
|
|
settings.ZARINPAL_MERCHANT_ID = "test"
|
|
settings.ZARINPAL_VERIFY_URL = "https://gateway.example/verify"
|
|
gateway = mocker.patch("apps.payments.services.requests.post")
|
|
gateway.return_value.json.return_value = {"data": {"code": -51}}
|
|
|
|
reconcile_payment.run(payment.id)
|
|
|
|
payment.refresh_from_db()
|
|
registration.refresh_from_db()
|
|
assert payment.status == Payment.OrderStatusChoices.FAILED
|
|
assert registration.status == Registration.StatusChoices.PENDING
|
|
|
|
|
|
def _expired_checkout_payment(user_factory, event_factory):
|
|
now = timezone.now()
|
|
user = user_factory()
|
|
event = event_factory(price=25000, capacity=2)
|
|
registration = Registration.objects.create(
|
|
event=event,
|
|
user=user,
|
|
status=Registration.StatusChoices.CANCELLED,
|
|
checkout_started_at=now - timedelta(minutes=20),
|
|
checkout_expires_at=now - timedelta(minutes=5),
|
|
cancellation_reason=Registration.CancellationReasonChoices.CHECKOUT_EXPIRED,
|
|
final_price=25000,
|
|
)
|
|
payment = Payment.objects.create(
|
|
user=user,
|
|
event=event,
|
|
registration=registration,
|
|
base_amount=25000,
|
|
amount=25000,
|
|
authority=f"AUTH-LATE-{user.pk}",
|
|
status=Payment.OrderStatusChoices.PENDING,
|
|
)
|
|
return payment, registration
|
|
|
|
|
|
def test_late_success_without_provider_payment_time_keeps_registration_cancelled(
|
|
user_factory, event_factory, mocker
|
|
):
|
|
from apps.payments.api.views import _mark_payment_paid
|
|
|
|
payment, registration = _expired_checkout_payment(user_factory, event_factory)
|
|
mocker.patch("apps.payments.tasks.refund_payment.apply_async")
|
|
|
|
payment, newly_verified = _mark_payment_paid(payment.id, {"ref_id": "LATE-REF"})
|
|
|
|
registration.refresh_from_db()
|
|
assert newly_verified is True
|
|
assert payment.status == Payment.OrderStatusChoices.PAID
|
|
assert payment.refund_status == Payment.RefundStatusChoices.PENDING
|
|
assert registration.status == Registration.StatusChoices.CANCELLED
|
|
|
|
|
|
def test_late_success_with_proven_pre_expiry_payment_and_available_seat_is_honored(
|
|
user_factory, event_factory
|
|
):
|
|
from apps.payments.api.views import _mark_payment_paid
|
|
|
|
payment, registration = _expired_checkout_payment(user_factory, event_factory)
|
|
paid_at = (registration.checkout_expires_at - timedelta(seconds=1)).isoformat()
|
|
|
|
payment, _ = _mark_payment_paid(payment.id, {"ref_id": "ONTIME-REF", "paid_at": paid_at})
|
|
|
|
registration.refresh_from_db()
|
|
assert registration.status == Registration.StatusChoices.CONFIRMED
|
|
assert payment.refund_status == Payment.RefundStatusChoices.NONE
|
|
|
|
|
|
def test_late_success_with_proven_pre_expiry_time_is_refunded_when_capacity_is_full(
|
|
user_factory, event_factory
|
|
):
|
|
from apps.payments.api.views import _mark_payment_paid
|
|
|
|
payment, registration = _expired_checkout_payment(user_factory, event_factory)
|
|
payment.event.capacity = 1
|
|
payment.event.save(update_fields=["capacity"])
|
|
other_user = user_factory()
|
|
Registration.objects.create(
|
|
event=payment.event,
|
|
user=other_user,
|
|
status=Registration.StatusChoices.CONFIRMED,
|
|
)
|
|
paid_at = (registration.checkout_expires_at - timedelta(seconds=1)).isoformat()
|
|
|
|
payment, _ = _mark_payment_paid(payment.id, {"ref_id": "FULL-REF", "paid_at": paid_at})
|
|
|
|
registration.refresh_from_db()
|
|
assert registration.status == Registration.StatusChoices.CANCELLED
|
|
assert payment.refund_status == Payment.RefundStatusChoices.PENDING
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("reason", "cancel_event"),
|
|
[
|
|
(Registration.CancellationReasonChoices.USER_CANCELLED, False),
|
|
(Registration.CancellationReasonChoices.EVENT_CANCELLED, True),
|
|
],
|
|
)
|
|
def test_late_success_after_user_or_event_cancellation_is_refunded(
|
|
user_factory, event_factory, mocker, reason, cancel_event
|
|
):
|
|
from apps.payments.api.views import _mark_payment_paid
|
|
|
|
payment, registration = _expired_checkout_payment(user_factory, event_factory)
|
|
registration.cancellation_reason = reason
|
|
registration.save(update_fields=["cancellation_reason"])
|
|
if cancel_event:
|
|
payment.event.status = payment.event.StatusChoices.CANCELLED
|
|
payment.event.save(update_fields=["status"])
|
|
mocker.patch("apps.payments.tasks.refund_payment.apply_async")
|
|
paid_at = (registration.checkout_expires_at - timedelta(seconds=1)).isoformat()
|
|
|
|
payment, _ = _mark_payment_paid(payment.id, {"ref_id": "CANCELLED-REF", "paid_at": paid_at})
|
|
|
|
registration.refresh_from_db()
|
|
assert registration.status == Registration.StatusChoices.CANCELLED
|
|
assert registration.cancellation_reason == reason
|
|
assert payment.refund_status == Payment.RefundStatusChoices.PENDING
|
|
|
|
|
|
def test_late_success_refund_task_persists_provider_reversal(mocker, user_factory, event_factory):
|
|
from apps.payments.tasks import refund_payment
|
|
|
|
payment, registration = _expired_checkout_payment(user_factory, event_factory)
|
|
Payment.objects.filter(pk=payment.pk).update(
|
|
status=Payment.OrderStatusChoices.PAID,
|
|
refund_status=Payment.RefundStatusChoices.PENDING,
|
|
)
|
|
reverse = mocker.patch(
|
|
"apps.payments.tasks.request_payment_reversal",
|
|
return_value={"code": 100, "ref_id": "REVERSAL-1"},
|
|
)
|
|
|
|
result = refund_payment.run(payment.id)
|
|
|
|
payment.refresh_from_db()
|
|
assert result == "refunded"
|
|
assert payment.refund_status == Payment.RefundStatusChoices.SUCCEEDED
|
|
assert payment.refund_reference == "REVERSAL-1"
|
|
assert payment.refund_attempts == 1
|
|
reverse.assert_called_once()
|
|
|
|
|
|
def test_uncertain_reversal_retries_then_keeps_admin_review_state(mocker, user_factory, event_factory, settings):
|
|
from apps.payments.services import PaymentReversalUncertain
|
|
from apps.payments.tasks import refund_payment
|
|
|
|
payment, _ = _expired_checkout_payment(user_factory, event_factory)
|
|
Payment.objects.filter(pk=payment.pk).update(
|
|
status=Payment.OrderStatusChoices.PAID,
|
|
refund_status=Payment.RefundStatusChoices.PENDING,
|
|
refund_attempts=2,
|
|
)
|
|
settings.PAYMENT_REFUND_MAX_ATTEMPTS = 3
|
|
mocker.patch(
|
|
"apps.payments.tasks.request_payment_reversal",
|
|
side_effect=PaymentReversalUncertain("gateway timed out"),
|
|
)
|
|
enqueue = mocker.patch("apps.payments.tasks.refund_payment.apply_async")
|
|
|
|
result = refund_payment.run(payment.id)
|
|
|
|
payment.refresh_from_db()
|
|
assert result == "manual_review"
|
|
assert payment.refund_status == Payment.RefundStatusChoices.MANUAL_REVIEW
|
|
assert payment.refund_attempts == 3
|
|
assert payment.refund_next_attempt_at is None
|
|
enqueue.assert_not_called()
|