init
Some checks failed
CI/CD / Backend & Frontend Checks (push) Has been cancelled
CI/CD / Deploy to Production (push) Has been cancelled

This commit is contained in:
2026-05-18 11:34:07 +03:30
commit 7a8ddeabed
279 changed files with 37390 additions and 0 deletions

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

@@ -0,0 +1,83 @@
from django.contrib import admin
from import_export.admin import ImportExportModelAdmin
from utils.admin import SoftDeleteListFilter, BaseModelAdmin
from payments.resources import DiscountResource, PaymentResource
from 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',)
}),
)

6
backend/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 = '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
backend/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 utils.models import BaseModel
from 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 payments.models import Payment, DiscountCode
from events.models import Event
from 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