Replace persisted event statuses with publication and cancellation flags, and compute public lifecycle labels from event timing and registration capacity. Keep filters, registration checks, and profile responses aligned with the derived status.
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.is_cancelled = True
|
|
payment.event.save(update_fields=["is_cancelled"])
|
|
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()
|