diff --git a/.env.sample b/.env.sample index 0678aec..1d07f17 100644 --- a/.env.sample +++ b/.env.sample @@ -5,3 +5,4 @@ POSTGRES_DB=POSTGRES_DB POSTGRES_USER=POSTGRES_USER POSTGRES_PASSWORD=POSTGRES_PASSWORD RABBIT_URL=RABBIT_URL +DOMAIN=http://localhost:8080 diff --git a/books_fixture.json b/books_fixture.json index e0981fe..6bba622 100644 --- a/books_fixture.json +++ b/books_fixture.json @@ -299,14 +299,6 @@ "model": "session" } }, - { - "model": "contenttypes.contenttype", - "pk": 7, - "fields": { - "app_label": "book_service", - "model": "book" - } - }, { "model": "book_service.book", "pk": 1, diff --git a/borrowing_service/serializers.py b/borrowing_service/serializers.py index 3ba7a5e..34bb358 100644 --- a/borrowing_service/serializers.py +++ b/borrowing_service/serializers.py @@ -1,8 +1,10 @@ from django.db import transaction +from django.db.models import Q from rest_framework import serializers from rest_framework.exceptions import ValidationError from borrowing_service.models import Borrowing +from payment_service.models import Payment from payment_service.serializers import ( PaymentSerializer, PaymentListSerializer @@ -33,10 +35,20 @@ class Meta: def create(self, validated_data): user = validated_data.get("user") borrowed_book = validated_data.get("book") + if borrowed_book.inventory == 0: raise ValidationError("Sorry no books available!") - if user.borrowings.filter(is_active=True).count(): - raise ValidationError("You must return your active borrowing!") + + pending_payments = Payment.objects.filter( + Q(borrowing__user_id=user.id) + & Q(status=Payment.PaymentStatus.PENDING) + ).count() + if pending_payments: + raise ValidationError( + "You must complete your pending payments " + "before borrowing new book!" + ) + borrowed_book.inventory -= 1 borrowed_book.save() borrowing = Borrowing.objects.create( diff --git a/core/celery.py b/core/celery.py index 9ed4164..c1a625a 100644 --- a/core/celery.py +++ b/core/celery.py @@ -26,12 +26,16 @@ app.config_from_object(settings, namespace="CELERY") # Celery Beat Settings -# app.conf.beat_schedule = { -# "check-overdue-task": { -# "task": "borrowing_service.tasks.check_overdue_task", -# "schedule": crontab(minute="*/1"), -# } -# } +app.conf.beat_schedule = { + "check-overdue-task": { + "task": "borrowing_service.tasks.check_overdue_task", + "schedule": crontab(minute="*/1"), + }, + "check-payment-session-expiry": { + "task": "payment_service.tasks.verify_session_status", + "schedule": crontab(minute="*/1"), + }, +} # Load task modules from all registered Django apps. app.autodiscover_tasks() diff --git a/core/settings.py b/core/settings.py index f65678a..c2d6545 100644 --- a/core/settings.py +++ b/core/settings.py @@ -150,10 +150,12 @@ "ROTATE_REFRESH_TOKENS": False, } -CELERY_BROKER_URL=os.environ["RABBIT_URL"] +CELERY_BROKER_URL = os.environ["RABBIT_URL"] #CELERY BEAT CELERY_BEAT_SCHEDULER = "django_celery_beat.schedulers:DatabaseScheduler" STRIPE_SECRET_KEY = os.environ.get("STRIPE_SECRET_KEY") STRIPE_PUBLISHABLE_KEY = os.environ.get("STRIPE_PUBLISHABLE_KEY") + +DOMAIN = os.environ["DOMAIN"] diff --git a/payment_service/migrations/0001_initial.py b/payment_service/migrations/0001_initial.py index 02397c4..a3aec5c 100644 --- a/payment_service/migrations/0001_initial.py +++ b/payment_service/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.8 on 2023-12-15 14:54 +# Generated by Django 4.2.8 on 2023-12-15 22:44 from django.db import migrations, models import django.db.models.deletion @@ -8,7 +8,7 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ("borrowing_service", "0001_initial"), + ("borrowing_service", "0002_initial"), ] operations = [ @@ -40,11 +40,13 @@ class Migration(migrations.Migration): max_length=40, ), ), - ("session_url", models.URLField()), - ("session_id", models.CharField(max_length=255)), + ("session_url", models.URLField(blank=True, null=True)), + ("session_id", models.CharField(blank=True, max_length=255, null=True)), ( "money_to_be_paid", - models.DecimalField(decimal_places=2, max_digits=10000), + models.DecimalField( + blank=True, decimal_places=2, max_digits=15, null=True + ), ), ( "borrowing", diff --git a/payment_service/migrations/0002_alter_payment_money_to_be_paid_and_more.py b/payment_service/migrations/0002_alter_payment_money_to_be_paid_and_more.py deleted file mode 100644 index 2a95b57..0000000 --- a/payment_service/migrations/0002_alter_payment_money_to_be_paid_and_more.py +++ /dev/null @@ -1,29 +0,0 @@ -# Generated by Django 4.2.8 on 2023-12-15 16:06 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("payment_service", "0001_initial"), - ] - - operations = [ - migrations.AlterField( - model_name="payment", - name="money_to_be_paid", - field=models.DecimalField( - blank=True, decimal_places=2, max_digits=10000, null=True - ), - ), - migrations.AlterField( - model_name="payment", - name="session_id", - field=models.CharField(blank=True, max_length=255, null=True), - ), - migrations.AlterField( - model_name="payment", - name="session_url", - field=models.URLField(blank=True, null=True), - ), - ] diff --git a/payment_service/migrations/0002_alter_payment_session_url.py b/payment_service/migrations/0002_alter_payment_session_url.py new file mode 100644 index 0000000..3e2dee5 --- /dev/null +++ b/payment_service/migrations/0002_alter_payment_session_url.py @@ -0,0 +1,18 @@ +# Generated by Django 4.0.4 on 2023-12-16 01:20 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('payment_service', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='payment', + name='session_url', + field=models.TextField(blank=True, null=True), + ), + ] diff --git a/payment_service/models.py b/payment_service/models.py index ae292f3..c0d5626 100644 --- a/payment_service/models.py +++ b/payment_service/models.py @@ -7,6 +7,7 @@ class Payment(models.Model): class PaymentStatus(models.TextChoices): PENDING = "PENDING", _("pending") PAID = "PAID", _("paid") + EXPIRED = "EXPIRED", _("expired") class PaymentTypes(models.TextChoices): PAYMENT = "PAYMENT", _("payment") @@ -25,8 +26,8 @@ class PaymentTypes(models.TextChoices): borrowing = models.ForeignKey( Borrowing, on_delete=models.CASCADE, related_name="payments" ) - session_url = models.URLField(null=True, blank=True) + session_url = models.TextField(null=True, blank=True) session_id = models.CharField(max_length=255, null=True, blank=True) money_to_be_paid = models.DecimalField( - max_digits=10000, decimal_places=2, null=True, blank=True + max_digits=15, decimal_places=2, null=True, blank=True ) diff --git a/payment_service/serializers.py b/payment_service/serializers.py index f58069c..ae60cb2 100644 --- a/payment_service/serializers.py +++ b/payment_service/serializers.py @@ -1,5 +1,4 @@ from rest_framework import serializers -from rest_framework.serializers import ModelSerializer from payment_service.models import Payment diff --git a/payment_service/services.py b/payment_service/services.py index dbabc42..e238640 100644 --- a/payment_service/services.py +++ b/payment_service/services.py @@ -1,11 +1,8 @@ from __future__ import annotations -from datetime import datetime - import stripe -from decimal import Decimal -from django.urls import reverse_lazy, reverse +from django.urls import reverse from stripe.checkout import Session from borrowing_service.models import Borrowing @@ -39,8 +36,15 @@ def get_checkout_session(borrowing: Borrowing, payment_id: int) -> Session: else: payment_amount = calculate_payment_amount(borrowing) - success_url = reverse("payment_service:success", args=[payment_id]) - cancel_url = reverse("payment_service:cancel", args=[payment_id]) + success_url = reverse( + "payment_service:payments-payment-successful", + args=[payment_id] + ) + cancel_url = reverse( + "payment_service:payments-payment-canceled", + args=[payment_id] + ) + stripe.api_key = settings.STRIPE_SECRET_KEY return Session.create( payment_method_types=["card"], line_items=[ @@ -54,8 +58,8 @@ def get_checkout_session(borrowing: Borrowing, payment_id: int) -> Session: }, ], mode="payment", - success_url=success_url, - cancel_url=cancel_url, + success_url=settings.DOMAIN + success_url, + cancel_url=settings.DOMAIN + cancel_url, ) diff --git a/payment_service/tasks.py b/payment_service/tasks.py new file mode 100644 index 0000000..d9804b1 --- /dev/null +++ b/payment_service/tasks.py @@ -0,0 +1,26 @@ +import stripe +from celery import shared_task + +from payment_service.models import Payment + + +def check_if_session_expired(session_id: str) -> bool: + """Check is session status is expired""" + session = stripe.checkout.Session.retrieve(session_id) + status = session.get("payment_intent", {}).get("status") + if status == "expired": + return True + return False + + +@shared_task +def verify_session_status() -> None: + """Verify if pending sessions did not expire""" + print("checking for expired checkouts") + + payments = Payment.objects.filter(status=Payment.PaymentStatus.PENDING) + + for payment in payments: + if check_if_session_expired(payment.session_id): + payment.status = Payment.PaymentStatus.EXPIRED + payment.save() diff --git a/payment_service/views.py b/payment_service/views.py index 216c122..8b993d6 100644 --- a/payment_service/views.py +++ b/payment_service/views.py @@ -3,7 +3,6 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.decorators import action -from django.shortcuts import get_object_or_404 from rest_framework import viewsets from payment_service.serializers import ( PaymentListSerializer, @@ -11,6 +10,7 @@ ) from payment_service.models import Payment +from payment_service.services import get_checkout_session stripe.api_key = settings.STRIPE_SECRET_KEY @@ -33,6 +33,15 @@ def get_serializer_class(self): @action(methods=["GET"], url_path="success", detail=True) def payment_successful(self, request, pk: None): payment = self.get_object() + session = stripe.checkout.Session.retrieve(payment.session_id) + status = session.get("payment_intent", {}).get("status") + if status != "succeeded": + return Response( + {"status": "fail", + "message": "Payment failed, please complete payment " + "within 24 hours from book borrowing time!"}, + status=400, + ) payment.status = "paid" payment.save() return Response( @@ -46,6 +55,19 @@ def payment_successful(self, request, pk: None): @action(methods=["GET"], url_path="cancel", detail=True) def payment_canceled(self, request, pk: None): return Response( - {"status": "fail", "message": "Payment was canceled"}, + {"status": "fail", + "message": "Payment was canceled. Please complete payment " + "within 24 hours from book borrowing time!"}, status=400, ) + + @action(methods=["POST"], url_path="renew-session", detail=True) + def renew_checkout(self, request, pk: None): + payment = self.get_object() + new_session = get_checkout_session(payment.borrowing, payment.id) + + if new_session.status != "open": + raise stripe.error.StripeError + + payment.session_id = new_session.id + payment.url = new_session.url