Skip to content

Commit

Permalink
Merge branch 'feature/integrate_p2p_payments' into dev
Browse files Browse the repository at this point in the history
# Conflicts:
#	locale/ka/LC_MESSAGES/django.po
  • Loading branch information
nika-alaverdashvili committed Nov 1, 2024
2 parents 89844a6 + bc9c9dc commit e42b2d0
Show file tree
Hide file tree
Showing 7 changed files with 247 additions and 27 deletions.
45 changes: 45 additions & 0 deletions apis/admin.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin
from django.core.exceptions import ValidationError
from django.db import transaction

from apis.models import CustomUser
from payments.models import Payment
from payments.models.balance import Balance


class CustomUserAdmin(UserAdmin):
Expand Down Expand Up @@ -166,5 +169,47 @@ class PaymentAdmin(admin.ModelAdmin):
readonly_fields = ("created_at", "updated_at")


class BalanceAdmin(admin.ModelAdmin):
model = Balance
list_display = ("user", "amount")
search_fields = ("user__email",)
readonly_fields = ("amount",)

actions = ["add_to_balance", "subtract_from_balance"]

@admin.action(description="Add to balance")
def add_to_balance(self, request, queryset):
amount = float(request.POST.get("amount", 0))
if amount <= 0:
self.message_user(request, "Amount must be positive.", level="error")
return

for balance in queryset:
try:
with transaction.atomic():
balance.add(amount)
self.message_user(request, f"Added {amount} to {balance.user}'s balance.")
except ValidationError as e:
self.message_user(request, str(e), level="error")

@admin.action(description="Subtract from balance")
def subtract_from_balance(self, request, queryset):
amount = float(request.POST.get("amount", 0))
if amount <= 0:
self.message_user(request, "Amount must be positive.", level="error")
return

for balance in queryset:
try:
with transaction.atomic():
balance.subtract(amount)
self.message_user(
request, f"Subtracted {amount} from {balance.user}'s balance."
)
except ValidationError as e:
self.message_user(request, str(e), level="error")


admin.site.register(Balance, BalanceAdmin)
admin.site.register(CustomUser, CustomUserAdmin)
admin.site.register(Payment, PaymentAdmin)
42 changes: 42 additions & 0 deletions payments/migrations/0002_balance.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Generated by Django 5.0.4 on 2024-11-01 11:31

import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("payments", "0001_initial"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]

operations = [
migrations.CreateModel(
name="Balance",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"amount",
models.DecimalField(decimal_places=2, default=0.0, max_digits=10),
),
(
"user",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="balance",
to=settings.AUTH_USER_MODEL,
),
),
],
),
]
1 change: 1 addition & 0 deletions payments/models/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
from payments.models.payment import Payment
from payments.models.balance import Balance
29 changes: 29 additions & 0 deletions payments/models/balance.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import models, transaction


class Balance(models.Model):
user = models.OneToOneField(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="balance"
)
amount = models.DecimalField(max_digits=10, decimal_places=2, default=0.0)

def add(self, amount):
"""Add money to the balance."""
if amount <= 0:
raise ValidationError("Amount must be positive.")
with transaction.atomic():
self.amount += amount
self.save()

def subtract(self, amount):
"""Subtract money from the balance if funds are sufficient."""
if amount > self.amount:
raise ValidationError("Insufficient balance.")
with transaction.atomic():
self.amount -= amount
self.save()

def __str__(self):
return f"{self.user} - Balance: {self.amount}"
6 changes: 6 additions & 0 deletions payments/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,9 @@ class Meta:
def validate_amount(self, value):
"""Convert dollars to cents for internal processing"""
return int(value * 100)


class DepositSerializer(serializers.Serializer):
amount = serializers.DecimalField(
min_value=0.01, max_digits=10, decimal_places=2, help_text="Amount to deposit."
)
11 changes: 10 additions & 1 deletion payments/urls.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
from django.urls import path

from .views import CreateCheckoutSession, stripe_webhook
from .views import (
BalanceView,
CreateCheckoutSession,
DepositBalanceView,
stripe_webhook,
)

urlpatterns = [
path(
Expand All @@ -9,4 +14,8 @@
name="create-checkout-session",
),
path("webhook/", stripe_webhook, name="stripe-webhook"),
path("balance/<uuid:user_id>/", BalanceView.as_view(), name="balance-view"),
path(
"deposit/", DepositBalanceView.as_view(), name="deposit-balance"
), # New deposit endpoint
]
140 changes: 114 additions & 26 deletions payments/views.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,30 @@
import logging
import os
from decimal import Decimal

import stripe
from django.contrib.auth import get_user_model
from django.db import transaction
from django.http import HttpResponse
from django.shortcuts import get_object_or_404
from django.views.decorators.csrf import csrf_exempt
from drf_spectacular.utils import extend_schema
from rest_framework import status
from rest_framework.exceptions import ValidationError
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView

from accounts.settings.base import STRIPE_WEBHOOK_SECRET
from apis.permissions import IsBuyer

from .models import Payment
from .models.balance import Balance
from .openapi.payment_openapi_examples import create_checkout_session_examples
from .serializers import CheckoutSessionSerializer
from .serializers import CheckoutSessionSerializer, DepositSerializer

User = get_user_model()
logger = logging.getLogger(__name__)


@extend_schema(
Expand Down Expand Up @@ -101,46 +112,123 @@ def post(self, request):
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)


class DepositBalanceView(APIView):
permission_classes = [IsAuthenticated]

@extend_schema(
request=DepositSerializer,
responses={200: "URL to Stripe checkout session"},
description="Create a Stripe Checkout session to deposit funds into the user's balance.",
)
def post(self, request):
serializer = DepositSerializer(data=request.data)
if serializer.is_valid():
amount = serializer.validated_data["amount"]

try:
checkout_session = stripe.checkout.Session.create(
payment_method_types=["card"],
line_items=[
{
"price_data": {
"currency": "usd",
"product_data": {
"name": "Account Balance Deposit",
},
"unit_amount": int(amount * 100),
},
"quantity": 1,
}
],
mode="payment",
success_url="https://reverse-auction-front-g2-424868328181.europe-west3.run.app/balance-success",
cancel_url="https://reverse-auction-front-g2-424868328181.europe-west3.run.app/balance-cancel",
metadata={
"user_id": str(request.user.id),
"amount": str(amount),
"deposit": "true",
},
)
return Response({"url": checkout_session.url}, status=status.HTTP_200_OK)
except Exception as e:
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)


class BalanceView(APIView):
permission_classes = [IsAuthenticated]

def get(self, request, user_id):
"""
Retrieve the balance for a user by their ID.
"""
user = get_object_or_404(User, id=user_id)
balance, created = Balance.objects.get_or_create(user=user)

balance_amount = balance.amount
return Response({"balance": balance_amount}, status=status.HTTP_200_OK)


@csrf_exempt
def stripe_webhook(request):
"""
Handle Stripe webhook events.
This view function listens for incoming webhook events from Stripe, verifies
the event signature, and updates the Payment object status based on the event type.
When a `checkout.session.completed` event is received, it retrieves the associated
Payment object using the `stripe_session_id`. If the payment status is "paid",
the Payment object's status is set to `COMPLETED`. If the payment status is "canceled",
the Payment object's status is set to `CANCELED`.
Returns:
HttpResponse: A response with status 200 for successful processing,
or appropriate error status if there are issues.
"""
payload = request.body
sig_header = request.META.get("HTTP_STRIPE_SIGNATURE")
event = None

endpoint_secret = os.getenv("STRIPE_WEBHOOK_SECRET")

try:
# Verify the event by Stripe's signature
event = stripe.Webhook.construct_event(payload, sig_header, endpoint_secret)
except (ValueError, stripe.error.SignatureVerificationError):
event = stripe.Webhook.construct_event(payload, sig_header, STRIPE_WEBHOOK_SECRET)
logger.info(f"[Webhook] Received event type: {event['type']}")
except (ValueError, stripe.error.SignatureVerificationError) as e:
logger.error(f"[Webhook] Invalid signature: {e}")
return HttpResponse(status=400, content="Invalid signature")

if event["type"] == "checkout.session.completed":
session = event["data"]["object"]

logger.debug(f"[Webhook] Session data: {session}")

if "user_id" not in session["metadata"] or "deposit" not in session["metadata"]:
logger.error(
"[Webhook] Missing required metadata for user_id or deposit flag"
)
return HttpResponse(status=400, content="Missing metadata")

user_id = session["metadata"]["user_id"]
try:
amount = Decimal(session["metadata"]["amount"])
logger.info(f"[Webhook] Amount to add to balance: {amount}")
except (KeyError, ValueError, TypeError) as e:
logger.error(f"[Webhook] Invalid or missing amount in metadata: {e}")
return HttpResponse(status=400, content="Invalid amount")

try:
payment = Payment.objects.get(stripe_session_id=session["id"])

if session["payment_status"] == "paid":
payment.status = Payment.Status.COMPLETED
elif session["payment_status"] == "canceled":
payment.status = Payment.Status.CANCELED
payment.save()
except Payment.DoesNotExist:
return HttpResponse(status=404, content="Payment object not found")
user = User.objects.get(id=user_id)
logger.info(f"[Webhook] Found user: {user.email} (ID: {user.id})")

with transaction.atomic():
balance, created = Balance.objects.get_or_create(user=user)

if created:
logger.info(
f"[Webhook] Created new balance record for user: {user.email}"
)
else:
logger.info(
f"[Webhook] Existing balance record found for user: {user.email}"
)

balance.add(amount)
logger.info(
f"[Webhook] Added ${amount} to {user.email}'s balance. New balance: {balance.amount}"
)
except User.DoesNotExist:
logger.error(f"[Webhook] User with ID {user_id} does not exist")
return HttpResponse(status=400, content="User not found")
except Exception as e:
logger.error(f"[Webhook] Unexpected error updating balance: {e}")
return HttpResponse(status=400, content="Error updating balance")

return HttpResponse(status=200)

0 comments on commit e42b2d0

Please sign in to comment.