initial commit
Some checks failed
Backend CI/CD / test (push) Has been cancelled
Backend CI/CD / deploy (push) Has been cancelled

This commit is contained in:
2026-05-19 20:53:08 +03:30
commit 88b793ed9f
169 changed files with 16763 additions and 0 deletions

83
apps/payments/admin.py Normal file
View File

@@ -0,0 +1,83 @@
from django.contrib import admin
from import_export.admin import ImportExportModelAdmin
from core.admin import SoftDeleteListFilter, BaseModelAdmin
from apps.payments.resources import DiscountResource, PaymentResource
from apps.payments.models import Payment, DiscountCode
@admin.register(DiscountCode)
class DiscountCodeAdmin(BaseModelAdmin, ImportExportModelAdmin):
resource_class = DiscountResource
list_display = (
'code', 'type', 'value', 'is_active', 'starts_at', 'ends_at',
'usage_limit_total', 'usage_limit_per_user', 'min_amount', 'is_deleted'
)
list_filter = (
'type', 'is_active', 'starts_at', 'ends_at', 'applicable_events',
SoftDeleteListFilter,
)
search_fields = ('code', )
readonly_fields = ('id', 'deleted_at', 'created_at', 'updated_at')
fieldsets = (
('Discount Code Details', {
'fields': ('code', 'type', 'value', 'applicable_events', 'is_active')
}),
('Limitations', {
'fields': ('starts_at', 'ends_at', 'usage_limit_total', 'usage_limit_per_user', 'min_amount')
}),
('Soft Delete', {
'fields': ('is_deleted', 'deleted_at'),
'classes': ('collapse',)
}),
)
readonly_fields = ('deleted_at', )
actions = BaseModelAdmin.actions + [
'deactivate_codes',
]
@admin.action(description="Deactivate selected discount codes")
def deactivate_codes(self, request, queryset):
queryset.update(is_active=False)
self.message_user(request, f"Deactivate {queryset.count()} discount codes.")
@admin.register(Payment)
class PaymentAdmin(BaseModelAdmin, ImportExportModelAdmin):
resource_class = PaymentResource
list_display = (
'id', 'user', 'event', 'base_amount', 'discount_code', 'discount_amount', 'amount',
'status', 'created_at', 'verified_at', 'is_deleted'
)
list_filter = (
'status', 'event',
SoftDeleteListFilter,
)
search_fields = (
'user__email', 'authority', 'ref_id', 'discount_code__code'
)
readonly_fields = (
'user', 'event', 'base_amount', 'discount_code', 'discount_code', 'discount_amount', 'amount', 'authority',
'status', 'ref_id', 'card_pan', 'card_hash', 'created_at', 'updated_at', 'deleted_at'
)
fieldsets = (
('Payment Details', {
'fields': ('user', 'event', 'status', 'created_at', 'updated_at')
}),
('Price Info', {
'fields': ('base_amount', 'discount_code', 'discount_amount', 'amount')
}),
('Others', {
'fields': ('authority', 'ref_id', 'card_pan', 'card_hash')
}),
('Soft Delete', {
'fields': ('is_deleted', 'deleted_at'),
'classes': ('collapse',)
}),
)

View File

View File

@@ -0,0 +1,35 @@
from ninja import Schema
class CreatePaymentIn(Schema):
event_id: int
description: str
discount_code: str | None = None
mobile: str | None = None
email: str | None = None
class CreatePaymentOut(Schema):
start_pay_url: str | None = None
authority: str | None = None
base_amount: int
discount_amount: int
amount: int
class PaymentDetailOut(Schema):
ref_id: str | None = None
authority: str | None = None
base_amount: int
discount_amount: int
amount: int
status: str
verified_at: str | None = None
event: dict
class CouponVerifyIn(Schema):
event_id: int
code: str
class CouponVerifyOut(Schema):
discount_amount: int
final_price: int

240
apps/payments/api/views.py Normal file
View File

@@ -0,0 +1,240 @@
from django.conf import settings
from django.shortcuts import redirect, get_object_or_404
from django.utils import timezone
from ninja import Router
from ninja.errors import HttpError
import requests
from apps.payments.models import Payment, DiscountCode
from apps.events.models import Event, Registration
from core.authentication import jwt_auth
from apps.payments.api.schemas import CouponVerifyIn, CouponVerifyOut, CreatePaymentIn, CreatePaymentOut, PaymentDetailOut
payments_router = Router(tags=["Payments"])
@payments_router.post("create", response=CreatePaymentOut, auth=jwt_auth)
def create_payment(request, payload: CreatePaymentIn):
event = get_object_or_404(Event, pk=payload.event_id)
if Payment.objects.filter(status=Payment.OrderStatusChoices.PAID, user=request.auth, event=event).exists():
raise HttpError(400, "You have already registered in this event")
registration = (
Registration.objects.filter(event=event, user=request.auth, is_deleted=False)
.order_by("-registered_at")
.first()
)
if not registration or registration.status == Registration.StatusChoices.CANCELLED:
registration = Registration.objects.create(
event=event,
user=request.auth,
status=Registration.StatusChoices.PENDING,
final_price=event.price,
)
elif registration.final_price is None:
registration.final_price = event.price
registration.save(update_fields=["final_price"])
discount_code = None
discount_amount = 0
final_amount = event.price
if payload.discount_code:
discount_code = DiscountCode.objects.filter(code=payload.discount_code, applicable_events=event, is_active=True).first()
if discount_code:
final_amount, discount_amount = discount_code.calculate_discount(event, request.auth)
registration_updates = []
if discount_code and registration.discount_code_id != discount_code.id:
registration.discount_code = discount_code
registration_updates.append("discount_code")
if registration.discount_amount != discount_amount:
registration.discount_amount = discount_amount
registration_updates.append("discount_amount")
if registration.final_price != final_amount:
registration.final_price = final_amount
registration_updates.append("final_price")
if final_amount == 0:
if registration.status != Registration.StatusChoices.CONFIRMED:
registration.status = Registration.StatusChoices.CONFIRMED
registration_updates.append("status")
if registration_updates:
registration.save(update_fields=list(set(registration_updates)))
else:
registration.save(update_fields=["status"])
return {
"start_pay_url": None,
"authority": None,
"base_amount": event.price,
"discount_amount": discount_amount if discount_amount else 0,
"amount": 0,
}
if registration_updates:
registration.save(update_fields=list(set(registration_updates)))
pay = Payment.objects.create(
user=request.auth,
event=event,
base_amount=event.price,
discount_code=discount_code,
discount_amount=discount_amount,
amount=final_amount,
status=Payment.OrderStatusChoices.INIT,
registration=registration,
)
callback_url = getattr(settings, "ZARINPAL_CALLBACK_URL", "http://localhost:8000/api/payments/callback")
body = {
"merchant_id": settings.ZARINPAL_MERCHANT_ID,
"amount": final_amount,
"callback_url": callback_url,
"description": payload.description,
"metadata": {
k: v for k, v in {
"mobile": payload.mobile,
"email": payload.email,
"event_id": event.id,
"user_id": request.auth.id,
"payment_id": pay.id,
"discount_code": discount_code.code if discount_code else None,
}.items() if v
}
}
try:
response = requests.post(
settings.ZARINPAL_REQUEST_URL,
json=body,
headers={"accept":"application/json","content-type":"application/json"},
timeout=15
)
jd = response.json()
except Exception as e:
pay.delete()
raise HttpError(502, f"Gateway request failed: {e}")
code = (jd.get("data") or {}).get("code")
if code != 100:
pay.delete()
raise HttpError(502, f"Zarinpal error: {jd.get('errors') or jd}")
authority = jd["data"]["authority"]
pay.authority = authority
pay.status = Payment.OrderStatusChoices.PENDING
pay.save(update_fields=["authority","status"])
return {
"start_pay_url": f"{settings.ZARINPAL_STARTPAY}{authority}",
"authority": authority,
"base_amount": event.price,
"discount_amount": discount_amount if discount_amount else 0,
"amount": final_amount,
}
@payments_router.get("callback")
def callback(request, Authority: str | None = None, Status: str | None = None):
if not Authority:
raise HttpError(400, "Missing Authority")
pay = Payment.objects.filter(authority=Authority).select_related("event","user","discount_code").first()
if not pay:
raise HttpError(404, "Payment not found")
if Status != "OK":
pay.status = Payment.OrderStatusChoices.CANCELED
pay.save(update_fields=["status"])
return redirect(f"{settings.FRONTEND_CALLBACK_URL}?status=failed&event_id={pay.event_id}")
verify_body = {
"merchant_id": settings.ZARINPAL_MERCHANT_ID,
"amount": pay.amount,
"authority": Authority,
}
try:
vresp = requests.post(
settings.ZARINPAL_VERIFY_URL,
json=verify_body,
headers={"accept":"application/json","content-type":"application/json"},
timeout=15
)
vjd = vresp.json()
except Exception:
pay.status = Payment.OrderStatusChoices.FAILED
pay.save(update_fields=["status"])
return redirect(f"{settings.FRONTEND_CALLBACK_URL}?status=failed&event_id={pay.event_id}")
vcode = (vjd.get("data") or {}).get("code")
if vcode in (100, 101):
data = vjd.get("data") or {}
pay.status = Payment.OrderStatusChoices.PAID
pay.ref_id = data.get("ref_id")
pay.card_pan = data.get("card_pan")
pay.card_hash = data.get("card_hash")
pay.verified_at = timezone.now()
pay.save(update_fields=["status", "ref_id", "card_pan", "card_hash", "verified_at"])
registration = pay.registration or Registration.objects.filter(
user=pay.user,
event=pay.event,
status=Registration.StatusChoices.PENDING,
).first()
if registration:
registration.status = Registration.StatusChoices.CONFIRMED
updates = ["status"]
if registration.final_price is None:
registration.final_price = pay.amount
updates.append("final_price")
registration.save(update_fields=updates)
return redirect(f"{settings.FRONTEND_CALLBACK_URL}?status=success&event_id={pay.event_id}&ref_id={pay.ref_id}")
pay.status = Payment.OrderStatusChoices.FAILED
pay.save(update_fields=["status"])
return redirect(f"{settings.FRONTEND_CALLBACK_URL}?status=failed&event_id={pay.event_id}")
@payments_router.get("by-ref/{ref_id}", response=PaymentDetailOut)
def payment_by_ref(request, ref_id: str):
pay = get_object_or_404(Payment.objects.select_related("event"), ref_id=ref_id)
ev = pay.event
return {
"ref_id": pay.ref_id,
"authority": pay.authority,
"base_amount": pay.base_amount,
"discount_amount": pay.discount_amount or 0,
"amount": pay.amount,
"status": pay.get_status_display(),
"verified_at": pay.verified_at.isoformat() if pay.verified_at else None,
"event": {
"id": ev.id,
"title": ev.title,
"slug": ev.slug,
"image_url": request.build_absolute_uri(ev.featured_image.url) if ev.featured_image else None,
"success_markdown": ev.registration_success_markdown,
},
}
@payments_router.post("/coupon/check", response=CouponVerifyOut, auth=jwt_auth)
def check_coupon(request, payload: CouponVerifyIn):
event = get_object_or_404(Event, id=payload.event_id)
code = payload.code
if not code:
raise HttpError(404, "لطفا کد تخفیف را وارد کنید")
try:
c = DiscountCode.objects.get(code=code, applicable_events=event, is_active=True)
final_price, disc = c.calculate_discount(event, request.auth)
return {
"discount_amount": disc,
"final_price": final_price,
}
except DiscountCode.DoesNotExist:
raise HttpError(404, "کد تخفیف معتبر نیست")

6
apps/payments/apps.py Normal file
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class PaymentsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.payments"

View File

@@ -0,0 +1,64 @@
# Generated by Django 5.2.5 on 2025-10-16 12:07
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('events', '0002_initial'),
]
operations = [
migrations.CreateModel(
name='DiscountCode',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('is_deleted', models.BooleanField(default=False)),
('deleted_at', models.DateTimeField(blank=True, null=True)),
('code', models.CharField(max_length=64, unique=True)),
('type', models.CharField(choices=[('percent', 'Percent'), ('fixed', 'Fixed (IRR)')], default='percent', max_length=10)),
('value', models.PositiveIntegerField()),
('max_discount', models.PositiveIntegerField(blank=True, null=True)),
('is_active', models.BooleanField(default=True)),
('starts_at', models.DateTimeField(blank=True, null=True)),
('ends_at', models.DateTimeField(blank=True, null=True)),
('usage_limit_total', models.PositiveIntegerField(blank=True, null=True)),
('usage_limit_per_user', models.PositiveIntegerField(blank=True, null=True)),
('min_amount', models.PositiveIntegerField(blank=True, null=True)),
('applicable_events', models.ManyToManyField(blank=True, related_name='discount_codes', to='events.event')),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='Payment',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('is_deleted', models.BooleanField(default=False)),
('deleted_at', models.DateTimeField(blank=True, null=True)),
('base_amount', models.PositiveIntegerField(editable=False)),
('discount_amount', models.PositiveIntegerField(default=0, editable=False)),
('amount', models.PositiveIntegerField(editable=False)),
('authority', models.CharField(blank=True, editable=False, max_length=64, null=True, unique=True)),
('status', models.IntegerField(choices=[(0, 'Initiated'), (1, 'Pending'), (2, 'Paid'), (3, 'Failed'), (4, 'Canceled')], default=0, editable=False)),
('ref_id', models.CharField(blank=True, editable=False, max_length=64, null=True)),
('card_pan', models.CharField(blank=True, editable=False, max_length=32, null=True)),
('card_hash', models.CharField(blank=True, editable=False, max_length=128, null=True)),
('verified_at', models.DateTimeField(blank=True, editable=False, null=True)),
('discount_code', models.ForeignKey(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='payments', to='payments.discountcode')),
('event', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.PROTECT, related_name='payments', to='events.event')),
],
options={
'abstract': False,
},
),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 5.2.5 on 2025-10-16 12:07
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('payments', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='payment',
name='user',
field=models.ForeignKey(editable=False, on_delete=django.db.models.deletion.PROTECT, related_name='payments', to=settings.AUTH_USER_MODEL),
),
]

View File

@@ -0,0 +1,20 @@
# Generated by Django 4.2.13 on 2025-11-17 13:15
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('events', '0009_registration_discount_amount_and_more'),
('payments', '0002_initial'),
]
operations = [
migrations.AddField(
model_name='payment',
name='registration',
field=models.ForeignKey(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='payments', to='events.registration'),
),
]

View File

122
apps/payments/models.py Normal file
View File

@@ -0,0 +1,122 @@
from django.db import models
from django.db.models import Q, Count
from django.core.exceptions import ValidationError
from django.conf import settings
from django.utils import timezone
from core.models import BaseModel
from apps.events.models import Event
from ninja.errors import HttpError
User = settings.AUTH_USER_MODEL
class DiscountCode(BaseModel):
class Type(models.TextChoices):
PERCENT = "percent", "Percent"
FIXED = "fixed", "Fixed (IRR)"
code = models.CharField(max_length=64, unique=True)
type = models.CharField(max_length=10, choices=Type.choices, default=Type.PERCENT)
value = models.PositiveIntegerField()
max_discount = models.PositiveIntegerField(null=True, blank=True)
is_active = models.BooleanField(default=True)
starts_at = models.DateTimeField(null=True, blank=True)
ends_at = models.DateTimeField(null=True, blank=True)
usage_limit_total = models.PositiveIntegerField(null=True, blank=True)
usage_limit_per_user = models.PositiveIntegerField(null=True, blank=True)
min_amount = models.PositiveIntegerField(null=True, blank=True)
applicable_events = models.ManyToManyField(Event, blank=True, related_name="discount_codes")
def __str__(self):
return f"{self.code} ({self.get_type_display()} {self.value})"
def calculate_discount(self, event: Event, user: User):
if not event.price:
return (0, 0)
if not self.is_active:
raise HttpError(400, "کد تخفیف نامعتبر یا غیرفعال است.")
n = timezone.now()
if self.starts_at and n < self.starts_at:
raise HttpError(400, "کد تخفیف هنوز فعال نشده است.")
if self.ends_at and n > self.ends_at:
raise HttpError(400, "کد تخفیف منقضی شده است.")
if self.applicable_events.exists() and not self.applicable_events.filter(pk=event.pk).exists():
raise HttpError(400, "کد تخفیف برای این رویداد قابل استفاده نیست.")
if self.min_amount and event.price < self.min_amount:
raise HttpError(400, "مبلغ سفارش کمتر از حداقل لازم برای این کد است.")
used_qs = Payment.objects.filter(discount_code=self, status__in=[Payment.OrderStatusChoices.PAID, Payment.OrderStatusChoices.PENDING])
if self.usage_limit_total is not None and used_qs.count() >= self.usage_limit_total:
raise HttpError(400, "حداکثر تعداد استفاده از این کد تخفیف تکمیل شده است.")
used_by_user = used_qs.filter(user=user).count()
if self.usage_limit_per_user is not None and used_by_user >= self.usage_limit_per_user:
raise HttpError(400, "شما حداکثر تعداد مجاز استفاده از این کد تخفیف را مصرف کرده‌اید.")
if self.type == DiscountCode.Type.FIXED:
disc = min(self.value, event.price)
else:
disc = (event.price * self.value) // 100
if self.max_discount:
disc = min(disc, self.max_discount)
final_amount = max(event.price - disc, 0)
if 0 < final_amount < 10_000:
raise HttpError(400, "با این تخفیف مبلغ قابل پرداخت به کمتر از ۱۰٬۰۰۰ ریال می‌رسد.")
return (final_amount, disc)
class Payment(BaseModel):
class OrderStatusChoices(models.IntegerChoices):
INIT = 0, "Initiated"
PENDING = 1, "Pending"
PAID = 2, "Paid"
FAILED = 3, "Failed"
CANCELED = 4, "Canceled"
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='payments', editable=False)
event = models.ForeignKey(Event, on_delete=models.PROTECT, related_name='payments', editable=False)
base_amount = models.PositiveIntegerField(editable=False)
discount_code = models.ForeignKey(DiscountCode, on_delete=models.PROTECT, null=True, blank=True, editable=False, related_name="payments")
discount_amount = models.PositiveIntegerField(default=0, editable=False)
amount = models.PositiveIntegerField(editable=False)
registration = models.ForeignKey(
"events.Registration",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="payments",
editable=False,
)
authority = models.CharField(max_length=64, unique=True, null=True, blank=True, editable=False)
status = models.IntegerField(choices=OrderStatusChoices.choices, default=OrderStatusChoices.INIT, editable=False)
ref_id = models.CharField(max_length=64, null=True, blank=True, editable=False)
card_pan = models.CharField(max_length=32, null=True, blank=True, editable=False)
card_hash = models.CharField(max_length=128, null=True, blank=True, editable=False)
verified_at = models.DateTimeField(null=True, blank=True, editable=False)
def clean(self):
if self.discount_amount and self.amount + self.discount_amount != self.base_amount:
raise ValidationError({"amount": "amount + discount_amount must equal base_amount"})
def save(self, *args, **kwargs):
self.full_clean()
return super().save(*args, **kwargs)
@property
def status_label(self):
"""Human-readable label for the payment status."""
return self.get_status_display()
def __str__(self):
return f"{self.user.email}:{self.event} - {self.get_status_display()}"

View File

@@ -0,0 +1,44 @@
from import_export import resources, fields
from import_export.widgets import ForeignKeyWidget, ManyToManyWidget
from apps.payments.models import Payment, DiscountCode
from apps.events.models import Event
from apps.users.models import User
class DiscountResource(resources.ModelResource):
event = fields.Field(
column_name='applicable_events',
attribute='applicable_events',
widget=ManyToManyWidget(Event, field='title', separator='||')
)
class Meta:
model = Event
fields = (
'id', 'code', 'type', 'value', 'max_discount', 'is_active',
'starts_at', 'ends_at', 'usage_limit_total', 'usage_limit_per_user',
'min_amount', 'applicable_events', 'created_at', 'updated_at',
'is_deleted', 'deleted_at'
)
export_order = fields
class PaymentResource(resources.ModelResource):
event = fields.Field(
column_name='event',
attribute='event',
widget=ForeignKeyWidget(Event, 'title')
)
user = fields.Field(
column_name='user',
attribute='user',
widget=ForeignKeyWidget(User, 'username')
)
class Meta:
model = Payment
fields = (
'id', 'event', 'user', 'base_amount', 'discount_code', 'discount_amount', 'amount',
'authority', 'status', 'red_id', 'card_pan', 'card_hash', 'verified_at', 'created_at',
'updated_at', 'is_deleted', 'deleted_at'
)
export_order = fields

View File

View File

@@ -0,0 +1,282 @@
import json
from datetime import timedelta
from unittest import mock
from django.test import TestCase, override_settings
from django.utils import timezone
from core.authentication import create_jwt_token
from apps.events.models import Event, Registration
from apps.payments.models import Payment, DiscountCode
from apps.users.models import User
@override_settings(
ZARINPAL_MERCHANT_ID="MID",
ZARINPAL_REQUEST_URL="https://zarinpal/request",
ZARINPAL_STARTPAY="https://zarinpal/start/",
ZARINPAL_VERIFY_URL="https://zarinpal/verify",
ZARINPAL_CALLBACK_URL="https://frontend/callback",
)
class PaymentsAPIIntegrationTests(TestCase):
password = "PaymentPass!123"
@classmethod
def setUpTestData(cls):
cls.user = User.objects.create_user(
username="pay_user",
email="pay.user@example.com",
password=cls.password,
)
cls.user.is_email_verified = True
cls.user.save(update_fields=["is_email_verified"])
def setUp(self):
super().setUp()
self.event = Event.objects.create(
title="Pay Event",
description="Payment event",
start_time=timezone.now(),
end_time=timezone.now() + timedelta(hours=2),
registration_start_date=timezone.now() - timedelta(days=1),
registration_end_date=timezone.now() + timedelta(days=1),
slug="pay-event",
price=50000,
capacity=10,
status=Event.StatusChoices.PUBLISHED,
)
self.token = create_jwt_token(self.user)
def _headers(self):
return {"HTTP_AUTHORIZATION": f"Bearer {self.token}"}
def _create_paid_event(self):
return Event.objects.create(
title="Paid Event",
description="Paid",
start_time=timezone.now(),
end_time=timezone.now() + timedelta(hours=1),
registration_start_date=timezone.now() - timedelta(days=1),
registration_end_date=timezone.now() + timedelta(days=2),
slug=f"paid-{timezone.now().timestamp()}",
price=20000,
capacity=5,
status=Event.StatusChoices.PUBLISHED,
)
def _create_discount_code(self, event):
code = DiscountCode.objects.create(
code="DISC50",
value=50,
is_active=True,
)
code.applicable_events.add(event)
return code
def test_create_payment_for_free_event(self):
free = Event.objects.create(
title="Free",
description="Zero",
start_time=timezone.now(),
end_time=timezone.now() + timedelta(hours=1),
registration_start_date=timezone.now() - timedelta(days=1),
registration_end_date=timezone.now() + timedelta(days=1),
slug="free-event",
price=0,
capacity=10,
status=Event.StatusChoices.PUBLISHED,
)
response = self.client.post(
"/api/payments/create",
data=json.dumps(
{
"event_id": free.id,
"description": "Free registration",
}
),
content_type="application/json",
**self._headers(),
)
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertEqual(data["amount"], 0)
self.assertIsNone(data["start_pay_url"])
@mock.patch("apps.payments.api.views.requests.post")
def test_create_payment_with_discount(self, mock_post):
mock_response = mock.Mock()
mock_response.json.return_value = {"data": {"code": 100, "authority": "AUTH"}}
mock_post.return_value = mock_response
code = self._create_discount_code(self.event)
response = self.client.post(
"/api/payments/create",
data=json.dumps(
{
"event_id": self.event.id,
"description": "Pay with discount",
"discount_code": code.code,
}
),
content_type="application/json",
**self._headers(),
)
self.assertEqual(response.status_code, 200)
payload = response.json()
self.assertEqual(payload["discount_amount"], self.event.price // 2)
self.assertEqual(payload["amount"], self.event.price // 2)
self.assertIn("start_pay_url", payload)
payment = Payment.objects.get(user=self.user, event=self.event)
self.assertEqual(payment.discount_code, code)
@mock.patch("apps.payments.api.views.requests.post")
def test_callback_success_marks_paid(self, mock_post):
payment = Payment.objects.create(
user=self.user,
event=self.event,
base_amount=self.event.price,
amount=self.event.price,
status=Payment.OrderStatusChoices.PENDING,
authority="AUTH123",
)
mock_resp = mock.Mock()
mock_resp.json.return_value = {"data": {"code": 100, "ref_id": "REF", "card_pan": "123", "card_hash": "ABC"}}
mock_post.return_value = mock_resp
response = self.client.get(
"/api/payments/callback",
{"Authority": "AUTH123", "Status": "OK"},
)
payment.refresh_from_db()
self.assertEqual(payment.status, Payment.OrderStatusChoices.PAID)
self.assertTrue("status=success" in response.url)
@mock.patch("apps.payments.api.views.requests.post")
def test_callback_failure_redirects_failed(self, mock_post):
payment = Payment.objects.create(
user=self.user,
event=self.event,
base_amount=self.event.price,
amount=self.event.price,
status=Payment.OrderStatusChoices.PENDING,
authority="AUTH456",
)
mock_resp = mock.Mock()
mock_resp.json.return_value = {"data": {"code": 101, "ref_id": "REF"}}
mock_post.return_value = mock_resp
response = self.client.get(
"/api/payments/callback",
{"Authority": "AUTH456", "Status": "OK"},
)
payment.refresh_from_db()
self.assertEqual(payment.status, Payment.OrderStatusChoices.PAID)
self.assertTrue("status=success" in response.url)
def test_callback_missing_authority_returns_error(self):
response = self.client.get("/api/payments/callback", {"Status": "OK"})
self.assertEqual(response.status_code, 400)
def test_callback_not_ok_cancels(self):
payment = Payment.objects.create(
user=self.user,
event=self.event,
base_amount=self.event.price,
amount=self.event.price,
status=Payment.OrderStatusChoices.PENDING,
authority="AUTH789",
)
response = self.client.get(
"/api/payments/callback",
{"Authority": "AUTH789", "Status": "NOK"},
)
payment.refresh_from_db()
self.assertEqual(payment.status, Payment.OrderStatusChoices.CANCELED)
self.assertIn("status=failed", response.url)
@mock.patch("apps.payments.api.views.requests.post", side_effect=RuntimeError("down"))
def test_create_payment_gateway_failure(self, mock_post):
response = self.client.post(
"/api/payments/create",
data=json.dumps(
{
"event_id": self.event.id,
"description": "Gateway fail",
}
),
content_type="application/json",
**self._headers(),
)
self.assertEqual(response.status_code, 502)
self.assertFalse(Payment.objects.filter(user=self.user).exists())
def test_create_payment_when_already_paid(self):
Payment.objects.create(
user=self.user,
event=self.event,
base_amount=self.event.price,
amount=self.event.price,
status=Payment.OrderStatusChoices.PAID,
)
response = self.client.post(
"/api/payments/create",
data=json.dumps({"event_id": self.event.id, "description": "Duplicate"}),
content_type="application/json",
**self._headers(),
)
self.assertEqual(response.status_code, 400)
@mock.patch("apps.payments.api.views.requests.post")
def test_registration_final_price_none_updates(self, mock_post):
registration = Registration.objects.create(
event=self.event,
user=self.user,
status=Registration.StatusChoices.PENDING,
final_price=None,
)
mock_response = mock.Mock()
mock_response.json.return_value = {"data": {"code": 100, "authority": "AUTH"}}
mock_post.return_value = mock_response
response = self.client.post(
"/api/payments/create",
data=json.dumps({"event_id": self.event.id, "description": "Update"}),
content_type="application/json",
**self._headers(),
)
self.assertEqual(response.status_code, 200)
registration.refresh_from_db()
if registration.final_price is None:
self.fail("final_price should be populated")
def test_coupon_check_success_and_errors(self):
code = DiscountCode.objects.create(code="PAYCO", value=20, is_active=True, type=DiscountCode.Type.PERCENT)
code.applicable_events.add(self.event)
# missing code
missing = self.client.post(
"/api/payments/coupon/check",
data=json.dumps({"event_id": self.event.id}),
content_type="application/json",
**self._headers(),
)
self.assertEqual(missing.status_code, 422)
# invalid code
invalid = self.client.post(
"/api/payments/coupon/check",
data=json.dumps({"event_id": self.event.id, "code": "INVALID"}),
content_type="application/json",
**self._headers(),
)
self.assertEqual(invalid.status_code, 404)
success = self.client.post(
"/api/payments/coupon/check",
data=json.dumps({"event_id": self.event.id, "code": code.code}),
content_type="application/json",
**self._headers(),
)
self.assertEqual(success.status_code, 200)
self.assertIn("final_price", success.json())

View File

View File

@@ -0,0 +1,194 @@
import uuid
from datetime import timedelta
from types import SimpleNamespace
from django.core.exceptions import ValidationError
from django.test import TestCase
from django.utils import timezone
from django.contrib.admin import AdminSite
from apps.payments.admin import DiscountCodeAdmin
from apps.payments.models import DiscountCode, Payment
from apps.payments.resources import DiscountResource, PaymentResource
from apps.events.models import Event
from apps.users.models import User
from ninja.errors import HttpError
class PaymentTestMixin:
@staticmethod
def _create_user(**overrides):
data = {
"username": f"user_{uuid.uuid4().hex[:6]}",
"email": f"user_{uuid.uuid4().hex[:6]}@example.com",
"password": "Test!1234",
}
data.update(overrides)
return User.objects.create_user(**data)
@staticmethod
def _create_event(**overrides):
now = timezone.now()
defaults = {
"title": "Sample",
"description": "Desc",
"start_time": now,
"end_time": now + timedelta(hours=2),
"registration_start_date": now - timedelta(days=1),
"registration_end_date": now + timedelta(days=5),
"slug": f"event-{uuid.uuid4().hex[:6]}",
"price": 100000,
"capacity": 10,
"status": Event.StatusChoices.PUBLISHED,
}
defaults.update(overrides)
return Event.objects.create(**defaults)
@staticmethod
def _discount_code(**overrides):
defaults = {
"code": f"CODE{uuid.uuid4().hex[:4]}",
"value": 50,
"is_active": True,
"type": DiscountCode.Type.PERCENT,
}
defaults.update(overrides)
return DiscountCode.objects.create(**defaults)
class DiscountCodeModelTests(TestCase, PaymentTestMixin):
def setUp(self):
self.event = self._create_event()
self.user = self._create_user(is_email_verified=True)
def test_zero_price_returns_zero_discount(self):
event = self._create_event(price=0)
code = self._discount_code()
code.applicable_events.add(event)
self.assertEqual(code.calculate_discount(event, self.user), (0, 0))
def test_inactive_raises_error(self):
code = self._discount_code(is_active=False)
code.applicable_events.add(self.event)
with self.assertRaises(HttpError):
code.calculate_discount(self.event, self.user)
def test_start_date_validation(self):
code = self._discount_code(starts_at=timezone.now() + timedelta(days=1))
code.applicable_events.add(self.event)
with self.assertRaises(HttpError):
code.calculate_discount(self.event, self.user)
def test_end_date_validation(self):
code = self._discount_code(ends_at=timezone.now() - timedelta(days=1))
code.applicable_events.add(self.event)
with self.assertRaises(HttpError):
code.calculate_discount(self.event, self.user)
def test_applicable_events_enforcement(self):
code = self._discount_code()
other_event = self._create_event()
code.applicable_events.add(other_event)
with self.assertRaises(HttpError):
code.calculate_discount(self.event, self.user)
def test_min_amount_guard(self):
code = self._discount_code(min_amount=200000)
code.applicable_events.add(self.event)
with self.assertRaises(HttpError):
code.calculate_discount(self.event, self.user)
def test_usage_limit_total(self):
code = self._discount_code(usage_limit_total=1)
code.applicable_events.add(self.event)
Payment.objects.create(
user=self.user,
event=self.event,
base_amount=self.event.price,
amount=self.event.price,
discount_amount=0,
status=Payment.OrderStatusChoices.PAID,
discount_code=code,
)
with self.assertRaises(HttpError):
code.calculate_discount(self.event, self.user)
def test_usage_limit_per_user(self):
code = self._discount_code(usage_limit_per_user=1)
code.applicable_events.add(self.event)
Payment.objects.create(
user=self.user,
event=self.event,
base_amount=self.event.price,
amount=self.event.price,
discount_amount=0,
status=Payment.OrderStatusChoices.PENDING,
discount_code=code,
)
with self.assertRaises(HttpError):
code.calculate_discount(self.event, self.user)
def test_final_price_below_min_post_discount(self):
event = self._create_event(price=15000)
code = self._discount_code(value=80)
code.applicable_events.add(event)
with self.assertRaises(HttpError):
code.calculate_discount(event, self.user)
def test_fixed_discount_type(self):
code = self._discount_code(type=DiscountCode.Type.FIXED, value=5000)
code.applicable_events.add(self.event)
final, disc = code.calculate_discount(self.event, self.user)
self.assertEqual(disc, 5000)
self.assertEqual(final, self.event.price - 5000)
class PaymentModelAndResourceTests(TestCase, PaymentTestMixin):
def setUp(self):
self.event = self._create_event()
self.user = self._create_user(is_email_verified=True)
def test_payment_clean_validates_amount(self):
payment = Payment(
user=self.user,
event=self.event,
base_amount=1000,
amount=500,
discount_amount=400,
status=Payment.OrderStatusChoices.INIT,
)
with self.assertRaises(ValidationError):
payment.full_clean()
def test_payment_resource_defers_user_event(self):
payment = Payment.objects.create(
user=self.user,
event=self.event,
base_amount=1000,
amount=1000,
discount_amount=0,
status=Payment.OrderStatusChoices.INIT,
)
resource = PaymentResource()
user_cell = resource.fields["user"].widget.clean(self.user.username, None)
self.assertEqual(user_cell, self.user)
event_cell = resource.fields["event"].widget.clean(self.event.title, None)
self.assertEqual(event_cell, self.event)
def test_discount_resource_expands_events(self):
resource = DiscountResource()
widget = resource.fields["event"].widget
self.assertEqual(widget.separator, "||")
class DiscountCodeAdminTests(TestCase, PaymentTestMixin):
def setUp(self):
self.admin = DiscountCodeAdmin(DiscountCode, AdminSite())
def test_deactivate_codes_action(self):
code = self._discount_code()
queryset = DiscountCode.objects.filter(pk=code.pk)
request = SimpleNamespace(_messages=SimpleNamespace(add=lambda *args, **kwargs: None))
self.admin.deactivate_codes(request, queryset)
code.refresh_from_db()
self.assertFalse(code.is_active)