inital commit
This commit is contained in:
1
payment/__init__.py
Normal file
1
payment/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
19
payment/admin.py
Normal file
19
payment/admin.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from .models import PaymentRecord, WebhookEvent
|
||||
|
||||
|
||||
@admin.register(PaymentRecord)
|
||||
class PaymentRecordAdmin(admin.ModelAdmin):
|
||||
list_display = ("id", "booking", "stripe_payment_intent_id", "amount", "currency", "status", "created_at")
|
||||
list_filter = ("status", "currency")
|
||||
search_fields = ("stripe_payment_intent_id", "stripe_charge_id", "booking__id")
|
||||
readonly_fields = ("created_at", "updated_at")
|
||||
|
||||
|
||||
@admin.register(WebhookEvent)
|
||||
class WebhookEventAdmin(admin.ModelAdmin):
|
||||
list_display = ("stripe_event_id", "event_type", "processed", "processed_at", "created_at")
|
||||
list_filter = ("processed", "event_type")
|
||||
search_fields = ("stripe_event_id", "event_type")
|
||||
readonly_fields = ("created_at",)
|
||||
6
payment/apps.py
Normal file
6
payment/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class PaymentConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "payment"
|
||||
48
payment/migrations/0001_initial.py
Normal file
48
payment/migrations/0001_initial.py
Normal file
@@ -0,0 +1,48 @@
|
||||
# Generated by Django 6.0.4 on 2026-04-08 16:53
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('booking', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='WebhookEvent',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('stripe_event_id', models.CharField(max_length=255, unique=True)),
|
||||
('event_type', models.CharField(max_length=120)),
|
||||
('payload', models.JSONField(default=dict)),
|
||||
('processed', models.BooleanField(default=False)),
|
||||
('processed_at', models.DateTimeField(blank=True, null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
options={
|
||||
'ordering': ('-created_at',),
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='PaymentRecord',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('stripe_payment_intent_id', models.CharField(max_length=255, unique=True)),
|
||||
('stripe_charge_id', models.CharField(blank=True, max_length=255)),
|
||||
('amount', models.DecimalField(decimal_places=2, max_digits=10)),
|
||||
('currency', models.CharField(default='usd', max_length=8)),
|
||||
('status', models.CharField(choices=[('requires_payment', 'Requires Payment'), ('processing', 'Processing'), ('succeeded', 'Succeeded'), ('failed', 'Failed'), ('refunded', 'Refunded')], default='requires_payment', max_length=32)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('booking', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='payments', to='booking.booking')),
|
||||
],
|
||||
options={
|
||||
'ordering': ('-created_at',),
|
||||
},
|
||||
),
|
||||
]
|
||||
1
payment/migrations/__init__.py
Normal file
1
payment/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
40
payment/models.py
Normal file
40
payment/models.py
Normal file
@@ -0,0 +1,40 @@
|
||||
from django.db import models
|
||||
|
||||
|
||||
class PaymentRecord(models.Model):
|
||||
class Status(models.TextChoices):
|
||||
REQUIRES_PAYMENT = "requires_payment", "Requires Payment"
|
||||
PROCESSING = "processing", "Processing"
|
||||
SUCCEEDED = "succeeded", "Succeeded"
|
||||
FAILED = "failed", "Failed"
|
||||
REFUNDED = "refunded", "Refunded"
|
||||
|
||||
booking = models.ForeignKey("booking.Booking", on_delete=models.CASCADE, related_name="payments")
|
||||
stripe_payment_intent_id = models.CharField(max_length=255, unique=True)
|
||||
stripe_charge_id = models.CharField(max_length=255, blank=True)
|
||||
amount = models.DecimalField(max_digits=10, decimal_places=2)
|
||||
currency = models.CharField(max_length=8, default="usd")
|
||||
status = models.CharField(max_length=32, choices=Status.choices, default=Status.REQUIRES_PAYMENT)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"Payment {self.stripe_payment_intent_id} ({self.status})"
|
||||
|
||||
|
||||
class WebhookEvent(models.Model):
|
||||
stripe_event_id = models.CharField(max_length=255, unique=True)
|
||||
event_type = models.CharField(max_length=120)
|
||||
payload = models.JSONField(default=dict)
|
||||
processed = models.BooleanField(default=False)
|
||||
processed_at = models.DateTimeField(null=True, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.event_type} ({self.stripe_event_id})"
|
||||
46
payment/serializers.py
Normal file
46
payment/serializers.py
Normal file
@@ -0,0 +1,46 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from .models import PaymentRecord, WebhookEvent
|
||||
|
||||
|
||||
class PaymentRecordSerializer(serializers.ModelSerializer):
|
||||
booking_id = serializers.IntegerField(source="booking.id", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = PaymentRecord
|
||||
fields = (
|
||||
"id",
|
||||
"booking_id",
|
||||
"stripe_payment_intent_id",
|
||||
"stripe_charge_id",
|
||||
"amount",
|
||||
"currency",
|
||||
"status",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
)
|
||||
|
||||
|
||||
class CreatePaymentIntentSerializer(serializers.Serializer):
|
||||
booking_id = serializers.IntegerField()
|
||||
currency = serializers.CharField(required=False, default="usd", max_length=8)
|
||||
|
||||
|
||||
class MockWebhookSerializer(serializers.Serializer):
|
||||
stripe_event_id = serializers.CharField(max_length=255)
|
||||
event_type = serializers.ChoiceField(
|
||||
choices=(
|
||||
"payment_intent.processing",
|
||||
"payment_intent.succeeded",
|
||||
"payment_intent.payment_failed",
|
||||
"charge.refunded",
|
||||
)
|
||||
)
|
||||
stripe_payment_intent_id = serializers.CharField(max_length=255)
|
||||
payload = serializers.JSONField(required=False, default=dict)
|
||||
|
||||
|
||||
class WebhookEventSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = WebhookEvent
|
||||
fields = ("id", "stripe_event_id", "event_type", "processed", "processed_at", "created_at")
|
||||
45
payment/services.py
Normal file
45
payment/services.py
Normal file
@@ -0,0 +1,45 @@
|
||||
import uuid
|
||||
from decimal import Decimal
|
||||
|
||||
from .models import PaymentRecord
|
||||
|
||||
|
||||
def _generate_id(prefix):
|
||||
return f"{prefix}_{uuid.uuid4().hex[:24]}"
|
||||
|
||||
|
||||
def create_mock_payment_intent(*, booking, amount: Decimal, currency: str = "usd"):
|
||||
payment = PaymentRecord.objects.create(
|
||||
booking=booking,
|
||||
stripe_payment_intent_id=_generate_id("pi_mock"),
|
||||
amount=amount,
|
||||
currency=currency,
|
||||
status=PaymentRecord.Status.REQUIRES_PAYMENT,
|
||||
)
|
||||
client_secret = _generate_id("pi_secret_mock")
|
||||
return payment, client_secret
|
||||
|
||||
|
||||
def mark_payment_processing(payment):
|
||||
payment.status = PaymentRecord.Status.PROCESSING
|
||||
payment.save(update_fields=["status", "updated_at"])
|
||||
return payment
|
||||
|
||||
|
||||
def mark_payment_succeeded(payment):
|
||||
payment.status = PaymentRecord.Status.SUCCEEDED
|
||||
payment.stripe_charge_id = _generate_id("ch_mock")
|
||||
payment.save(update_fields=["status", "stripe_charge_id", "updated_at"])
|
||||
return payment
|
||||
|
||||
|
||||
def mark_payment_failed(payment):
|
||||
payment.status = PaymentRecord.Status.FAILED
|
||||
payment.save(update_fields=["status", "updated_at"])
|
||||
return payment
|
||||
|
||||
|
||||
def mark_payment_refunded(payment):
|
||||
payment.status = PaymentRecord.Status.REFUNDED
|
||||
payment.save(update_fields=["status", "updated_at"])
|
||||
return payment
|
||||
159
payment/tests.py
Normal file
159
payment/tests.py
Normal file
@@ -0,0 +1,159 @@
|
||||
from datetime import timedelta
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db import IntegrityError
|
||||
from django.test import TestCase
|
||||
from django.utils import timezone
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from accounts.models import VendorProfile
|
||||
from booking.models import Booking
|
||||
from equipment.models import EquipmentCategory, EquipmentItem
|
||||
|
||||
from .models import PaymentRecord, WebhookEvent
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class PaymentModelTests(TestCase):
|
||||
def setUp(self):
|
||||
vendor_user = User.objects.create_user(
|
||||
email="vendor-payment@example.com",
|
||||
password="Pass123456!",
|
||||
is_vendor=True,
|
||||
is_customer=False,
|
||||
)
|
||||
vendor = VendorProfile.objects.create(user=vendor_user, business_name="Payment Vendor")
|
||||
customer = User.objects.create_user(
|
||||
email="customer-payment@example.com",
|
||||
password="Pass123456!",
|
||||
is_vendor=False,
|
||||
is_customer=True,
|
||||
)
|
||||
category = EquipmentCategory.objects.create(name="Canoe", slug="canoe")
|
||||
item = EquipmentItem.objects.create(
|
||||
vendor=vendor,
|
||||
category=category,
|
||||
title="Canoe",
|
||||
public_id="canoe-001",
|
||||
price_per_day="45.00",
|
||||
is_active=True,
|
||||
)
|
||||
starts = timezone.now() + timedelta(days=3)
|
||||
ends = starts + timedelta(days=1)
|
||||
self.booking = Booking.objects.create(
|
||||
customer=customer,
|
||||
vendor=vendor,
|
||||
equipment_item=item,
|
||||
starts_at=starts,
|
||||
ends_at=ends,
|
||||
status=Booking.Status.APPROVED,
|
||||
total_price="45.00",
|
||||
)
|
||||
|
||||
def test_payment_idempotency_constraints(self):
|
||||
PaymentRecord.objects.create(
|
||||
booking=self.booking,
|
||||
stripe_payment_intent_id="pi_mock_dup",
|
||||
amount="45.00",
|
||||
currency="usd",
|
||||
)
|
||||
with self.assertRaises(IntegrityError):
|
||||
PaymentRecord.objects.create(
|
||||
booking=self.booking,
|
||||
stripe_payment_intent_id="pi_mock_dup",
|
||||
amount="45.00",
|
||||
currency="usd",
|
||||
)
|
||||
|
||||
WebhookEvent.objects.create(
|
||||
stripe_event_id="evt_mock_dup",
|
||||
event_type="payment_intent.succeeded",
|
||||
payload={},
|
||||
)
|
||||
with self.assertRaises(IntegrityError):
|
||||
WebhookEvent.objects.create(
|
||||
stripe_event_id="evt_mock_dup",
|
||||
event_type="payment_intent.succeeded",
|
||||
payload={},
|
||||
)
|
||||
|
||||
|
||||
class PaymentApiTests(APITestCase):
|
||||
def setUp(self):
|
||||
self.vendor_user = User.objects.create_user(
|
||||
email="vendor-api-payment@example.com",
|
||||
password="Pass123456!",
|
||||
is_vendor=True,
|
||||
is_customer=False,
|
||||
)
|
||||
self.vendor = VendorProfile.objects.create(user=self.vendor_user, business_name="Webhook Vendor")
|
||||
self.customer = User.objects.create_user(
|
||||
email="customer-api-payment@example.com",
|
||||
password="Pass123456!",
|
||||
is_vendor=False,
|
||||
is_customer=True,
|
||||
)
|
||||
category = EquipmentCategory.objects.create(name="Raft", slug="raft")
|
||||
item = EquipmentItem.objects.create(
|
||||
vendor=self.vendor,
|
||||
category=category,
|
||||
title="River Raft",
|
||||
public_id="raft-001",
|
||||
price_per_day="110.00",
|
||||
is_active=True,
|
||||
)
|
||||
starts = timezone.now() + timedelta(days=2)
|
||||
ends = starts + timedelta(days=1)
|
||||
self.booking = Booking.objects.create(
|
||||
customer=self.customer,
|
||||
vendor=self.vendor,
|
||||
equipment_item=item,
|
||||
starts_at=starts,
|
||||
ends_at=ends,
|
||||
status=Booking.Status.APPROVED,
|
||||
total_price="110.00",
|
||||
)
|
||||
|
||||
def test_mock_payment_intent_status_and_webhook_flow(self):
|
||||
self.client.force_authenticate(self.customer)
|
||||
create_res = self.client.post(
|
||||
"/api/v1/payment/intents/",
|
||||
{"booking_id": self.booking.id, "currency": "usd"},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(create_res.status_code, status.HTTP_201_CREATED)
|
||||
payment_id = create_res.data["payment"]["id"]
|
||||
intent_id = create_res.data["payment"]["stripe_payment_intent_id"]
|
||||
|
||||
status_res = self.client.get(f"/api/v1/payment/{payment_id}/status/")
|
||||
self.assertEqual(status_res.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(status_res.data["status"], PaymentRecord.Status.REQUIRES_PAYMENT)
|
||||
|
||||
webhook_res = self.client.post(
|
||||
"/api/v1/payment/webhooks/stripe/",
|
||||
{
|
||||
"stripe_event_id": "evt_mock_1001",
|
||||
"event_type": "payment_intent.succeeded",
|
||||
"stripe_payment_intent_id": intent_id,
|
||||
"payload": {"source": "test"},
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(webhook_res.status_code, status.HTTP_200_OK)
|
||||
self.booking.refresh_from_db()
|
||||
self.assertEqual(self.booking.status, Booking.Status.CONFIRMED)
|
||||
|
||||
idempotent_res = self.client.post(
|
||||
"/api/v1/payment/webhooks/stripe/",
|
||||
{
|
||||
"stripe_event_id": "evt_mock_1001",
|
||||
"event_type": "payment_intent.succeeded",
|
||||
"stripe_payment_intent_id": intent_id,
|
||||
"payload": {},
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(idempotent_res.status_code, status.HTTP_200_OK)
|
||||
self.assertTrue(idempotent_res.data["idempotent"])
|
||||
9
payment/urls.py
Normal file
9
payment/urls.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from django.urls import path
|
||||
|
||||
from .views import CreateMockPaymentIntentView, MockStripeWebhookView, PaymentStatusView
|
||||
|
||||
urlpatterns = [
|
||||
path("intents/", CreateMockPaymentIntentView.as_view(), name="payment_intent_create"),
|
||||
path("<int:payment_id>/status/", PaymentStatusView.as_view(), name="payment_status"),
|
||||
path("webhooks/stripe/", MockStripeWebhookView.as_view(), name="stripe_webhook_mock"),
|
||||
]
|
||||
137
payment/views.py
Normal file
137
payment/views.py
Normal file
@@ -0,0 +1,137 @@
|
||||
from django.utils import timezone
|
||||
from rest_framework import permissions, status
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from booking.models import Booking
|
||||
from booking.services import transition_booking_status
|
||||
|
||||
from .models import PaymentRecord, WebhookEvent
|
||||
from .serializers import (
|
||||
CreatePaymentIntentSerializer,
|
||||
MockWebhookSerializer,
|
||||
PaymentRecordSerializer,
|
||||
WebhookEventSerializer,
|
||||
)
|
||||
from .services import (
|
||||
create_mock_payment_intent,
|
||||
mark_payment_failed,
|
||||
mark_payment_processing,
|
||||
mark_payment_refunded,
|
||||
mark_payment_succeeded,
|
||||
)
|
||||
|
||||
|
||||
class CreateMockPaymentIntentView(APIView):
|
||||
permission_classes = (permissions.IsAuthenticated,)
|
||||
|
||||
def post(self, request):
|
||||
serializer = CreatePaymentIntentSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
booking = Booking.objects.select_related("vendor").filter(id=serializer.validated_data["booking_id"]).first()
|
||||
if not booking:
|
||||
return Response({"detail": "Booking not found."}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
# Only the booking customer can initiate payment.
|
||||
if booking.customer_id != request.user.id:
|
||||
raise PermissionDenied("Only the booking customer can create payment intents.")
|
||||
if booking.status != Booking.Status.APPROVED:
|
||||
return Response({"detail": "Payment intent can only be created for approved bookings."}, status=400)
|
||||
|
||||
existing = PaymentRecord.objects.filter(booking=booking).order_by("-created_at").first()
|
||||
if existing and existing.status in [PaymentRecord.Status.REQUIRES_PAYMENT, PaymentRecord.Status.PROCESSING]:
|
||||
return Response(
|
||||
{
|
||||
"detail": "An active payment already exists for this booking.",
|
||||
"payment": PaymentRecordSerializer(existing).data,
|
||||
"client_secret": f"reuse_{existing.stripe_payment_intent_id}",
|
||||
"mocked": True,
|
||||
},
|
||||
status=200,
|
||||
)
|
||||
|
||||
payment, client_secret = create_mock_payment_intent(
|
||||
booking=booking,
|
||||
amount=booking.total_price,
|
||||
currency=serializer.validated_data["currency"].lower(),
|
||||
)
|
||||
return Response(
|
||||
{
|
||||
"payment": PaymentRecordSerializer(payment).data,
|
||||
"client_secret": client_secret,
|
||||
"mocked": True,
|
||||
},
|
||||
status=201,
|
||||
)
|
||||
|
||||
|
||||
class PaymentStatusView(APIView):
|
||||
permission_classes = (permissions.IsAuthenticated,)
|
||||
|
||||
def get(self, request, payment_id):
|
||||
payment = PaymentRecord.objects.select_related("booking").filter(id=payment_id).first()
|
||||
if not payment:
|
||||
return Response({"detail": "Payment not found."}, status=404)
|
||||
if payment.booking.customer_id != request.user.id and payment.booking.vendor.user_id != request.user.id:
|
||||
raise PermissionDenied("You do not have access to this payment.")
|
||||
return Response(PaymentRecordSerializer(payment).data)
|
||||
|
||||
|
||||
class MockStripeWebhookView(APIView):
|
||||
permission_classes = (permissions.AllowAny,)
|
||||
|
||||
def post(self, request):
|
||||
serializer = MockWebhookSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
event_id = serializer.validated_data["stripe_event_id"]
|
||||
|
||||
existing_event = WebhookEvent.objects.filter(stripe_event_id=event_id).first()
|
||||
if existing_event:
|
||||
return Response(
|
||||
{
|
||||
"detail": "Event already processed.",
|
||||
"event": WebhookEventSerializer(existing_event).data,
|
||||
"idempotent": True,
|
||||
}
|
||||
)
|
||||
|
||||
event = WebhookEvent.objects.create(
|
||||
stripe_event_id=event_id,
|
||||
event_type=serializer.validated_data["event_type"],
|
||||
payload=serializer.validated_data.get("payload", {}),
|
||||
)
|
||||
|
||||
payment = PaymentRecord.objects.filter(
|
||||
stripe_payment_intent_id=serializer.validated_data["stripe_payment_intent_id"]
|
||||
).select_related("booking").first()
|
||||
if not payment:
|
||||
return Response({"detail": "Payment intent not found for webhook event."}, status=404)
|
||||
|
||||
if event.event_type == "payment_intent.processing":
|
||||
mark_payment_processing(payment)
|
||||
elif event.event_type == "payment_intent.succeeded":
|
||||
mark_payment_succeeded(payment)
|
||||
if payment.booking.status == Booking.Status.APPROVED:
|
||||
transition_booking_status(
|
||||
booking=payment.booking,
|
||||
to_status=Booking.Status.CONFIRMED,
|
||||
actor=None,
|
||||
note="Auto-confirmed by mock payment success webhook.",
|
||||
)
|
||||
elif event.event_type == "payment_intent.payment_failed":
|
||||
mark_payment_failed(payment)
|
||||
elif event.event_type == "charge.refunded":
|
||||
mark_payment_refunded(payment)
|
||||
|
||||
event.processed = True
|
||||
event.processed_at = timezone.now()
|
||||
event.save(update_fields=["processed", "processed_at"])
|
||||
return Response(
|
||||
{
|
||||
"detail": "Mock webhook processed.",
|
||||
"event": WebhookEventSerializer(event).data,
|
||||
"payment": PaymentRecordSerializer(payment).data,
|
||||
"mocked": True,
|
||||
}
|
||||
)
|
||||
Reference in New Issue
Block a user