init
This commit is contained in:
83
backend/payments/admin.py
Normal file
83
backend/payments/admin.py
Normal 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
6
backend/payments/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class PaymentsConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'payments'
|
||||
64
backend/payments/migrations/0001_initial.py
Normal file
64
backend/payments/migrations/0001_initial.py
Normal 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,
|
||||
},
|
||||
),
|
||||
]
|
||||
23
backend/payments/migrations/0002_initial.py
Normal file
23
backend/payments/migrations/0002_initial.py
Normal 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),
|
||||
),
|
||||
]
|
||||
20
backend/payments/migrations/0003_payment_registration.py
Normal file
20
backend/payments/migrations/0003_payment_registration.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
0
backend/payments/migrations/__init__.py
Normal file
0
backend/payments/migrations/__init__.py
Normal file
122
backend/payments/models.py
Normal file
122
backend/payments/models.py
Normal 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()}"
|
||||
|
||||
44
backend/payments/resources.py
Normal file
44
backend/payments/resources.py
Normal 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
|
||||
Reference in New Issue
Block a user