fix error in gitignore
This commit is contained in:
@@ -0,0 +1 @@
|
||||
# payments app
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class PaymentsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.payments"
|
||||
@@ -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()
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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}"
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user