fix error in gitignore

This commit is contained in:
2026-09-24 12:32:59 +03:30
commit 86c117ee2a
165 changed files with 5963 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
# payments app
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class PaymentsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.payments"
+36
View File
@@ -0,0 +1,36 @@
"""Payment gateway abstraction with idempotent callbacks.
Payments never talk to a concrete gateway directly; the gateway is selected
from configuration and isolated behind an interface.
"""
from typing import Protocol
from django.conf import settings
class PaymentGatewayInterface(Protocol):
def request_payment(self, amount, currency, order_number: str, callback_url: str) -> dict:
"""Return {'authority': str, 'redirect_url': str}."""
...
def verify(self, authority: str, amount) -> dict:
"""Return {'transaction_id': str, 'status': 'success'|'failed'|'cancelled'}."""
...
class MockGateway:
def request_payment(self, amount, currency, order_number: str, callback_url: str) -> dict:
return {
"authority": f"MOCK-AUTH-{order_number}",
"redirect_url": f"{callback_url}?authority=MOCK-AUTH-{order_number}",
}
def verify(self, authority: str, amount) -> dict:
return {"transaction_id": f"MOCK-TXN-{authority}", "status": "success"}
def get_gateway() -> PaymentGatewayInterface:
if settings.PAYMENT_GATEWAY_PROVIDER == "mock":
return MockGateway()
# Real gateways (GatewayA, GatewayB, ...) are selected here.
return MockGateway()
+39
View File
@@ -0,0 +1,39 @@
# Generated by Django 5.2.17 on 2026-09-15 09:29
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('orders', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='Payment',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('amount', models.DecimalField(decimal_places=2, max_digits=18)),
('currency', models.CharField(default='IRR', max_length=8)),
('gateway', models.CharField(max_length=40)),
('transaction_id', models.CharField(max_length=120)),
('authority', models.CharField(blank=True, max_length=120)),
('status', models.CharField(choices=[('pending', 'Pending'), ('success', 'Success'), ('failed', 'Failed'), ('cancelled', 'Cancelled')], default='pending', max_length=12)),
('request_time', models.DateTimeField(blank=True, null=True)),
('callback_time', models.DateTimeField(blank=True, null=True)),
('verification_time', models.DateTimeField(blank=True, null=True)),
('error', models.TextField(blank=True)),
('order', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='payments', to='orders.processingorder')),
],
options={
'constraints': [models.UniqueConstraint(fields=('gateway', 'transaction_id'), name='uniq_gateway_transaction')],
},
),
]
@@ -0,0 +1,30 @@
# Generated by Django 5.2.17 on 2026-09-15 11:56
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('orders', '0001_initial'),
('payments', '0001_initial'),
('reservations', '0002_reservation_approved_at_reservation_approved_by_and_more'),
]
operations = [
migrations.AddField(
model_name='payment',
name='reservation',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='payments', to='reservations.reservation'),
),
migrations.AlterField(
model_name='payment',
name='order',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='payments', to='orders.processingorder'),
),
migrations.AddConstraint(
model_name='payment',
constraint=models.CheckConstraint(condition=models.Q(('order__isnull', False), ('reservation__isnull', False), _connector='OR'), name='payment_has_target'),
),
]
+41
View File
@@ -0,0 +1,41 @@
from django.db import models
from apps.core.models import TimeStampedModel, UUIDModel
class Payment(UUIDModel, TimeStampedModel):
class Status(models.TextChoices):
PENDING = "pending", "Pending"
SUCCESS = "success", "Success"
FAILED = "failed", "Failed"
CANCELLED = "cancelled", "Cancelled"
# A payment targets either a reservation (upfront) or a processing order.
reservation = models.ForeignKey(
"reservations.Reservation", null=True, blank=True, on_delete=models.PROTECT, related_name="payments"
)
order = models.ForeignKey(
"orders.ProcessingOrder", null=True, blank=True, on_delete=models.PROTECT, related_name="payments"
)
amount = models.DecimalField(max_digits=18, decimal_places=2)
currency = models.CharField(max_length=8, default="IRR")
gateway = models.CharField(max_length=40)
transaction_id = models.CharField(max_length=120)
authority = models.CharField(max_length=120, blank=True)
status = models.CharField(max_length=12, choices=Status.choices, default=Status.PENDING)
request_time = models.DateTimeField(null=True, blank=True)
callback_time = models.DateTimeField(null=True, blank=True)
verification_time = models.DateTimeField(null=True, blank=True)
error = models.TextField(blank=True)
class Meta:
constraints = [
models.UniqueConstraint(fields=["gateway", "transaction_id"], name="uniq_gateway_transaction"),
models.CheckConstraint(
condition=models.Q(order__isnull=False) | models.Q(reservation__isnull=False),
name="payment_has_target",
),
]
def __str__(self) -> str:
return f"{self.gateway}:{self.transaction_id}"
+13
View File
@@ -0,0 +1,13 @@
from rest_framework import serializers
from apps.payments.models import Payment
class PaymentSerializer(serializers.ModelSerializer):
class Meta:
model = Payment
fields = "__all__"
read_only_fields = [
"id", "gateway", "transaction_id", "authority", "status",
"request_time", "callback_time", "verification_time", "error",
]
+39
View File
@@ -0,0 +1,39 @@
from decimal import Decimal
from django.contrib.auth import get_user_model
from django.db import IntegrityError, transaction
from django.test import TestCase
from apps.accounts.models import Customer
from apps.orders.models import ProcessingOrder
from apps.payments.gateways import get_gateway
from apps.payments.models import Payment
from apps.workflows.models import Workflow, WorkflowVersion
User = get_user_model()
class PaymentTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(username="c", password="***")
self.customer = Customer.objects.create(user=self.user)
wf = Workflow.objects.create(name="W", slug="w-pay")
version = WorkflowVersion.objects.create(workflow=wf, version=1)
self.order = ProcessingOrder.objects.create(customer=self.customer, workflow_version=version)
def test_mock_gateway_verify_returns_success(self):
result = get_gateway().verify("AUTH-1", "1000")
self.assertEqual(result["status"], "success")
self.assertTrue(result["transaction_id"])
def test_duplicate_gateway_transaction_rejected(self):
Payment.objects.create(
order=self.order, amount=Decimal("1000"), currency="IRR",
gateway="mock", transaction_id="TXN-1", status="success",
)
with self.assertRaises(IntegrityError):
with transaction.atomic():
Payment.objects.create(
order=self.order, amount=Decimal("1000"), currency="IRR",
gateway="mock", transaction_id="TXN-1", status="success",
)
+8
View File
@@ -0,0 +1,8 @@
from rest_framework.routers import DefaultRouter
from apps.payments.views import PaymentViewSet
router = DefaultRouter()
router.register("", PaymentViewSet, basename="payment")
urlpatterns = router.urls
+82
View File
@@ -0,0 +1,82 @@
from django.conf import settings
from django.db import transaction
from django.db.models import Q
from django.utils import timezone
from rest_framework import decorators, mixins, response, status, viewsets
from apps.core.errors import error_response
from apps.payments.gateways import get_gateway
from apps.payments.models import Payment
from apps.payments.serializers import PaymentSerializer
from apps.rbac.permissions import ActionPermission
class PaymentViewSet(
mixins.ListModelMixin, mixins.RetrieveModelMixin, mixins.CreateModelMixin, viewsets.GenericViewSet
):
serializer_class = PaymentSerializer
permission_classes = [ActionPermission]
code_map = {
"list": "invoice.view",
"retrieve": "invoice.view",
"create": "reservation.create",
"verify": "invoice.create",
}
def get_queryset(self):
user = self.request.user
qs = Payment.objects.select_related("order", "reservation").all()
if user.is_superuser:
return qs
return qs.filter(Q(order__customer__user=user) | Q(reservation__customer__user=user))
def create(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
order = serializer.validated_data.get("order")
reservation = serializer.validated_data.get("reservation")
if not order and not reservation:
return response.Response(
error_response("PAYMENT_TARGET_REQUIRED", "An order or reservation is required."),
status=status.HTTP_400_BAD_REQUEST,
)
amount = serializer.validated_data["amount"]
currency = order.currency if order else getattr(settings, "BASE_CURRENCY", "IRR")
reference = order.order_number if order else f"RES-{str(reservation.id)[:8]}"
gateway = get_gateway()
callback_url = request.build_absolute_uri("/api/v1/payments/callback/")
result = gateway.request_payment(str(amount), currency, reference, callback_url)
payment = Payment.objects.create(
order=order,
reservation=reservation,
amount=amount,
currency=currency,
gateway=settings.PAYMENT_GATEWAY_PROVIDER or "mock",
authority=result["authority"],
status=Payment.Status.PENDING,
request_time=timezone.now(),
)
return response.Response(
{"id": str(payment.id), "authority": result["authority"], "redirect_url": result["redirect_url"]},
status=status.HTTP_201_CREATED,
)
@decorators.action(detail=True, methods=["post"])
@transaction.atomic
def verify(self, request, pk=None):
payment = Payment.objects.select_for_update().get(pk=pk)
# Idempotent: a duplicate callback must not create a duplicate success.
if payment.status == Payment.Status.SUCCESS:
return response.Response(PaymentSerializer(payment).data)
gateway = get_gateway()
result = gateway.verify(payment.authority, str(payment.amount))
payment.verification_time = timezone.now()
if result.get("status") == "success":
payment.status = Payment.Status.SUCCESS
payment.transaction_id = result["transaction_id"]
else:
payment.status = Payment.Status.FAILED
payment.error = result.get("error", "")
payment.save()
return response.Response(PaymentSerializer(payment).data)