diff options
Diffstat (limited to 'alphabetlearning/payments')
19 files changed, 691 insertions, 0 deletions
diff --git a/alphabetlearning/payments/__init__.py b/alphabetlearning/payments/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/alphabetlearning/payments/__init__.py diff --git a/alphabetlearning/payments/admin.py b/alphabetlearning/payments/admin.py new file mode 100644 index 0000000..f6a00a2 --- /dev/null +++ b/alphabetlearning/payments/admin.py @@ -0,0 +1,38 @@ +from django.contrib import admin + +from .models import CartItem +from .models import Price +from .models import Product +from .models import ShoppingCart +from .models import Subscription +from .models import SubscriptionPlan + + +class PriceInlineAdmin(admin.TabularInline): + model = Price + extra = 0 + +class ProductAdmin(admin.ModelAdmin): + inlines = [PriceInlineAdmin] + +class SubscriptionPlanAdmin(admin.ModelAdmin): + list_display = ('name', 'price', 'description', 'allowed_downloads') + + +class ShoppingCartAdmin(admin.ModelAdmin): + list_display = ('user', 'created_at', 'updated_at') + + +class CartItemAdmin(admin.ModelAdmin): + list_display = ('cart', 'resource', 'quantity', 'added_at') + + +class SubscriptionAdmin(admin.ModelAdmin): + list_display = ('user', 'is_active', 'start_date', 'end_date') + + +admin.site.register(Product, ProductAdmin) +admin.site.register(SubscriptionPlan) +admin.site.register(ShoppingCart) +admin.site.register(CartItem) +admin.site.register(Subscription) diff --git a/alphabetlearning/payments/apps.py b/alphabetlearning/payments/apps.py new file mode 100644 index 0000000..e9b0a47 --- /dev/null +++ b/alphabetlearning/payments/apps.py @@ -0,0 +1,9 @@ +from django.apps import AppConfig + + +class PaymentsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "alphabetlearning.payments" + + def ready(self): + import alphabetlearning.payments.signals # noqa: F401 diff --git a/alphabetlearning/payments/migrations/0001_initial.py b/alphabetlearning/payments/migrations/0001_initial.py new file mode 100644 index 0000000..33b7602 --- /dev/null +++ b/alphabetlearning/payments/migrations/0001_initial.py @@ -0,0 +1,103 @@ +# Generated by Django 5.0.4 on 2024-09-03 19:21 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("resources", "0019_alter_pdfpagesnapshot_options_and_more"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="ShoppingCart", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "user", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + migrations.CreateModel( + name="Subscription", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("is_active", models.BooleanField(default=False)), + ("start_date", models.DateTimeField(blank=True, null=True)), + ("end_date", models.DateTimeField(blank=True, null=True)), + ( + "stripe_subscription_id", + models.CharField(blank=True, max_length=255, null=True), + ), + ( + "user", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + migrations.CreateModel( + name="CartItem", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("quantity", models.PositiveIntegerField(default=1)), + ("added_at", models.DateTimeField(auto_now_add=True)), + ( + "resource", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="resources.resource", + ), + ), + ( + "cart", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="items", + to="payments.shoppingcart", + ), + ), + ], + options={ + "unique_together": {("cart", "resource")}, + }, + ), + ] diff --git a/alphabetlearning/payments/migrations/0002_subscriptionplan_and_more.py b/alphabetlearning/payments/migrations/0002_subscriptionplan_and_more.py new file mode 100644 index 0000000..cab49b5 --- /dev/null +++ b/alphabetlearning/payments/migrations/0002_subscriptionplan_and_more.py @@ -0,0 +1,36 @@ +# Generated by Django 5.0.4 on 2024-09-03 19:32 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("payments", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="SubscriptionPlan", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=255)), + ("price", models.DecimalField(decimal_places=2, max_digits=6)), + ("description", models.TextField()), + ("allowed_downloads", models.PositiveIntegerField()), + ("stripe_plan_id", models.CharField(max_length=255)), + ], + ), + migrations.RemoveField( + model_name="subscription", + name="stripe_subscription_id", + ), + ] diff --git a/alphabetlearning/payments/migrations/0003_product_price.py b/alphabetlearning/payments/migrations/0003_product_price.py new file mode 100644 index 0000000..b12d5dc --- /dev/null +++ b/alphabetlearning/payments/migrations/0003_product_price.py @@ -0,0 +1,53 @@ +# Generated by Django 5.0.4 on 2024-09-04 19:01 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("payments", "0002_subscriptionplan_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="Product", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=255)), + ("stripe_product_id", models.CharField(max_length=100)), + ], + ), + migrations.CreateModel( + name="Price", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("price", models.IntegerField(default=0)), + ("stripe_product_id", models.CharField(max_length=100)), + ( + "product", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="payments.product", + ), + ), + ], + ), + ] diff --git a/alphabetlearning/payments/migrations/0004_rename_stripe_product_id_price_stripe_price_id.py b/alphabetlearning/payments/migrations/0004_rename_stripe_product_id_price_stripe_price_id.py new file mode 100644 index 0000000..e5a339f --- /dev/null +++ b/alphabetlearning/payments/migrations/0004_rename_stripe_product_id_price_stripe_price_id.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.4 on 2024-09-04 19:17 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("payments", "0003_product_price"), + ] + + operations = [ + migrations.RenameField( + model_name="price", + old_name="stripe_product_id", + new_name="stripe_price_id", + ), + ] diff --git a/alphabetlearning/payments/migrations/0005_remove_subscriptionplan_stripe_plan_id.py b/alphabetlearning/payments/migrations/0005_remove_subscriptionplan_stripe_plan_id.py new file mode 100644 index 0000000..1e642b4 --- /dev/null +++ b/alphabetlearning/payments/migrations/0005_remove_subscriptionplan_stripe_plan_id.py @@ -0,0 +1,17 @@ +# Generated by Django 5.0.4 on 2024-09-08 19:23 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("payments", "0004_rename_stripe_product_id_price_stripe_price_id"), + ] + + operations = [ + migrations.RemoveField( + model_name="subscriptionplan", + name="stripe_plan_id", + ), + ] diff --git a/alphabetlearning/payments/migrations/0006_subscription_plan.py b/alphabetlearning/payments/migrations/0006_subscription_plan.py new file mode 100644 index 0000000..f54b5f9 --- /dev/null +++ b/alphabetlearning/payments/migrations/0006_subscription_plan.py @@ -0,0 +1,24 @@ +# Generated by Django 5.0.4 on 2024-09-08 20:21 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("payments", "0005_remove_subscriptionplan_stripe_plan_id"), + ] + + operations = [ + migrations.AddField( + model_name="subscription", + name="plan", + field=models.ForeignKey( + default=1, + on_delete=django.db.models.deletion.CASCADE, + to="payments.subscriptionplan", + ), + preserve_default=False, + ), + ] diff --git a/alphabetlearning/payments/migrations/0007_remove_cartitem_quantity_and_more.py b/alphabetlearning/payments/migrations/0007_remove_cartitem_quantity_and_more.py new file mode 100644 index 0000000..e976059 --- /dev/null +++ b/alphabetlearning/payments/migrations/0007_remove_cartitem_quantity_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.0.4 on 2024-09-14 14:40 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('payments', '0006_subscription_plan'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.RemoveField( + model_name='cartitem', + name='quantity', + ), + migrations.AddConstraint( + model_name='subscription', + constraint=models.UniqueConstraint(fields=('user', 'plan'), name='unique_user_plan'), + ), + ] diff --git a/alphabetlearning/payments/migrations/__init__.py b/alphabetlearning/payments/migrations/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/alphabetlearning/payments/migrations/__init__.py diff --git a/alphabetlearning/payments/models.py b/alphabetlearning/payments/models.py new file mode 100644 index 0000000..80d93c2 --- /dev/null +++ b/alphabetlearning/payments/models.py @@ -0,0 +1,71 @@ +from django.conf import settings +from django.core.exceptions import ValidationError +from django.db import models + +from alphabetlearning.resources.models import Resource + + +class Product(models.Model): + name = models.CharField(max_length=255) + stripe_product_id = models.CharField(max_length=100) + + +class Price(models.Model): + product = models.ForeignKey(Product, on_delete=models.CASCADE) + price = models.IntegerField(default=0) + stripe_price_id = models.CharField(max_length=100) + + def get_display_price(self): + return "{0:.2f}".format(self.price / 100) + + +class ShoppingCart(models.Model): + user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + def __str__(self): + return f"Shopping cart for {self.user.email}" + + def add_resource(self, resource: Resource): + if CartItem.objects.filter(cart=self, resource=resource).exists(): + raise ValidationError(f"{resource.name} is already in your shopping cart.") + item, created = CartItem.objects.get_or_create( + cart=self, + resource=resource, + ) + if not created: + item.save() + + +class CartItem(models.Model): + cart = models.ForeignKey(ShoppingCart, on_delete=models.CASCADE, related_name="items") + resource = models.ForeignKey(Resource, on_delete=models.CASCADE) + added_at = models.DateTimeField(auto_now_add=True) + + class Meta: + unique_together = ("cart", "resource") + + def __str__(self): + return f"{self.resource.name} in {self.cart.user.username}'s cart" + + +class SubscriptionPlan(models.Model): + name = models.CharField(max_length=255) + price = models.DecimalField(max_digits=6, decimal_places=2) + description = models.TextField() + allowed_downloads = models.PositiveIntegerField() + + +class Subscription(models.Model): + user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) + plan = models.ForeignKey(SubscriptionPlan, on_delete=models.CASCADE) + is_active = models.BooleanField(default=False) + start_date = models.DateTimeField(null=True, blank=True) + end_date = models.DateTimeField(null=True, blank=True) + + class Meta: + constraints = [models.UniqueConstraint(fields=["user", "plan"], name="unique_user_plan")] + + def __str__(self): + return f"Subscription for {self.user.username}" diff --git a/alphabetlearning/payments/signals.py b/alphabetlearning/payments/signals.py new file mode 100644 index 0000000..50d988b --- /dev/null +++ b/alphabetlearning/payments/signals.py @@ -0,0 +1,40 @@ +from datetime import timedelta + +from allauth.account.signals import user_signed_up +from django.db import transaction +from django.dispatch import receiver +from django.utils import timezone + +from .models import ShoppingCart +from .models import Subscription +from .models import SubscriptionPlan + + +@receiver(user_signed_up) +def assign_default_subscription(sender, request, user, **kwargs): + with transaction.atomic(): + # Get or create the free plan subscription + free_plan, _ = SubscriptionPlan.objects.get_or_create( + name="Free Plan", + defaults={ + "price": 0, + "description": "Free plan description", + "allowed_downloads": 10, + }, + ) + + # Create a SubscriptionPlan for the new user + Subscription.objects.create( + user=user, + plan=free_plan, + is_active=True, + start_date=timezone.now(), + end_date=timezone.now() + timedelta(days=365), # Example: 30 days + ) + + +@receiver(user_signed_up) +def assign_user_a_shopping_cart(sender, request, user, **kwargs): + with transaction.atomic(): + # Create a ShoppingCart for the new user + ShoppingCart.objects.create(user=user) diff --git a/alphabetlearning/payments/tests/__init__.py b/alphabetlearning/payments/tests/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/alphabetlearning/payments/tests/__init__.py diff --git a/alphabetlearning/payments/tests/conftest.py b/alphabetlearning/payments/tests/conftest.py new file mode 100644 index 0000000..7919170 --- /dev/null +++ b/alphabetlearning/payments/tests/conftest.py @@ -0,0 +1,6 @@ +import pytest + + +@pytest.fixture +def user_data(): + return {"email": "testuser@example.com", "password": "testpassword123"} diff --git a/alphabetlearning/payments/tests/test_models.py b/alphabetlearning/payments/tests/test_models.py new file mode 100644 index 0000000..8c509f5 --- /dev/null +++ b/alphabetlearning/payments/tests/test_models.py @@ -0,0 +1,116 @@ +import pytest +from allauth.account.signals import user_signed_up +from django.contrib.auth import get_user_model +from django.core.exceptions import ValidationError +from django.db import IntegrityError +from django.test import RequestFactory + +from alphabetlearning.payments.models import ShoppingCart +from alphabetlearning.payments.models import Subscription +from alphabetlearning.payments.models import SubscriptionPlan + +User = get_user_model() + + +@pytest.fixture +def user_data(): + return {"email": "testuser@example.com", "password": "testpassword123"} + + +@pytest.mark.django_db +def test_subscription_user_unique(): + # Ensure the free plan exists + free_plan, _ = SubscriptionPlan.objects.get_or_create( + name="Free Plan", + defaults={ + "price": 0, + "description": "Free plan description", + "allowed_downloads": 10, + }, + ) + + # Create a new user + user_data = {"email": "testuser@example.com", "password": "testpassword123"} + user = User.objects.create_user(**user_data) # type: ignore + + # Create a subscription for the user + Subscription.objects.create(user=user, plan=free_plan) + + # Try to create another subscription for the same user + with pytest.raises(IntegrityError): + Subscription.objects.create(user=user, plan=free_plan) + + +@pytest.mark.django_db +def test_user_signup_assigns_free_subscription(user_data): + # Ensure the free plan exists + free_plan, _ = SubscriptionPlan.objects.get_or_create( + name="Free Plan", + defaults={ + "price": 0, + "description": "Free plan description", + "allowed_downloads": 10, + }, + ) # Create a new user + user = User.objects.create_user(**user_data) # type: ignore + # Manually trigger the user_signed_up signal + request = RequestFactory().get("/") + user_signed_up.send(sender=user.__class__, request=request, user=user) + + # Check if a SubscriptionPlan was created for the user + subscription = user.subscription + assert subscription is not None + + # Check if the assigned plan is the free plan + assert subscription.plan == free_plan + + # Additional assertions can be added here to check other properties + # of the SubscriptionPlan or Subscription as needed + + +@pytest.mark.django_db +def test_shopping_cart_is_created_when_user_is_created(user_data): + user = User.objects.create_user(**user_data) # type: ignore + request = RequestFactory().get("/") + user_signed_up.send(sender=user.__class__, request=request, user=user) + shopping_cart = ShoppingCart.objects.get(user=user) + assert shopping_cart is not None + assert str(shopping_cart) == "Shopping cart for {}".format(user.email) + + +# When the user adds a `Resource` to their cart, create a new `CartItem` instance and associate it with the user's `ShoppingCart` and the selected `Resource`. + + +@pytest.mark.django_db +def test_cart_item_is_created_when_resource_is_added_to_cart(user_data, resource): + user = User.objects.create_user(**user_data) # type: ignore + request = RequestFactory().get("/") + user_signed_up.send(sender=user.__class__, request=request, user=user) + users_cart = ShoppingCart.objects.get(user=user) + users_cart.add_resource(resource) + assert users_cart.items.count() == 1 # type: ignore + + +@pytest.mark.django_db +def test_cannot_add_the_same_resource_to_cart_twice(user_data, resource): + user = User.objects.create_user(**user_data) # type: ignore + request = RequestFactory().get("/") + user_signed_up.send(sender=user.__class__, request=request, user=user) + users_cart = ShoppingCart.objects.get(user=user) + users_cart.add_resource(resource) + assert users_cart.items.count() == 1 # type: ignore + with pytest.raises(ValidationError): + users_cart.add_resource(resource) + + +@pytest.mark.django_db +def test_can_add_multiple_different_items_to_cart(user_data, resources): + user = User.objects.create_user(**user_data) # type: ignore + request = RequestFactory().get("/") + user_signed_up.send(sender=user.__class__, request=request, user=user) + users_cart = ShoppingCart.objects.get(user=user) + users_cart.add_resource(resources[0]) + users_cart.add_resource(resources[1]) + users_cart.add_resource(resources[2]) + users_cart.add_resource(resources[3]) + assert users_cart.items.count() == 4 # type: ignore diff --git a/alphabetlearning/payments/tests/test_views.py b/alphabetlearning/payments/tests/test_views.py new file mode 100644 index 0000000..7d0f8b5 --- /dev/null +++ b/alphabetlearning/payments/tests/test_views.py @@ -0,0 +1,19 @@ +import pytest +from django.urls import reverse + + +@pytest.mark.django_db +def test_cart_view(client, user): + url = reverse("payments:cart_detail") + client.force_login(user) + response = client.get(url) + assert response.status_code == 200 + assert "My basket" in str(response.content) + + +@pytest.mark.django_db +def test_add_resource_to_cart(client, resource, user): + url = reverse("payments:add_to_cart", kwargs={"resource_id": resource.id}) + client.force_login(user) + response = client.get(url) + assert response.status_code == 200 diff --git a/alphabetlearning/payments/urls.py b/alphabetlearning/payments/urls.py new file mode 100644 index 0000000..52e2451 --- /dev/null +++ b/alphabetlearning/payments/urls.py @@ -0,0 +1,22 @@ +from django.urls import path + +from . import views +from .views import CancelView +from .views import SuccessView + +app_name = "payments" + +urlpatterns = [ + path("checkout/", views.checkout, name="checkout"), + path("cart/", views.cart_detail, name="cart_detail"), + path("success/", SuccessView.as_view(), name="success"), + path("cancel/", CancelView.as_view(), name="cancel"), + path( + "create-checkout-session/<int:pk>/", + views.CreateCheckoutSessionView.as_view(), + name="create-checkout-session", + ), + path("add-to-card/<int:resource_id>", views.add_to_cart, name="add_to_cart"), + path("landing/", views.ProductLandingPageView.as_view(), name="landing"), + # path("webhook/", views.webhook, name="webhook"), +] diff --git a/alphabetlearning/payments/views.py b/alphabetlearning/payments/views.py new file mode 100644 index 0000000..8337177 --- /dev/null +++ b/alphabetlearning/payments/views.py @@ -0,0 +1,96 @@ +import stripe +from django.conf import settings +from django.contrib.auth.decorators import login_required +from django.shortcuts import get_object_or_404 +from django.shortcuts import redirect +from django.shortcuts import render +from django.views import View +from django.views.generic import TemplateView + +from alphabetlearning.resources.models import Resource +from alphabetlearning.users.models import User + +from .models import CartItem +from .models import Price +from .models import Product +from .models import ShoppingCart + +stripe.api_key = settings.STRIPE_SECRET_KEY + + +class CreateCheckoutSessionView(View): + def post(self, request, *args, **kwargs): + price = Price.objects.get(id=self.kwargs["pk"]) + domain = "http://localhost:8000" + checkout_session = stripe.checkout.Session.create( + payment_method_types=["card"], + line_items=[ + { + "price": price.stripe_price_id, + "quantity": 1, + }, + ], + mode="payment", + success_url=domain + "payments/success/", + cancel_url=domain + "payments/cancel/", + ) + return redirect(checkout_session.url, code=303) + + +class SuccessView(TemplateView): + template_name = "payments/success.html" + + +class CancelView(TemplateView): + template_name = "payments/cancel.html" + + +class ProductLandingPageView(TemplateView): + template_name = "payments/landingpage.html" + + def get_context_data(self, **kwargs): + product = Product.objects.get(name="Worksheet 1") + prices = Price.objects.filter(product=product) + context = super(ProductLandingPageView, self).get_context_data(**kwargs) + context.update({"product": product, "prices": prices}) + return context + + +@login_required +def add_to_cart(request, resource_id): + resource = get_object_or_404(Resource, id=resource_id) + cart, created = ShoppingCart.objects.get_or_create(user=request.user) + cart_item, created = CartItem.objects.get_or_create(cart=cart, resource=resource) + # cart_item.quantity += 1 + cart_item.save() + return render(request, "payments/cart_detail.html", {"cart": cart}) + + +@login_required +def cart_detail(request): + cart, created = ShoppingCart.objects.get_or_create(user=request.user) + return render(request, "payments/cart_detail.html", {"cart": cart}) + + +# def cart_detail(request): +# cart, created = ShoppingCart.objects.get_or_create(user=request.user) +# return render(request, "payments/cart_detail.html", {"cart": cart}) + + +@login_required +def checkout(request): + cart = ShoppingCart.objects.get(user=request.user) + total = sum(item.get_total_price() for item in cart.items.all()) + + if request.method == "POST": + # Create Stripe PaymentIntent + intent = stripe.PaymentIntent.create( + amount=int(total * 100), # Stripe amount is in cents + currency="usd", + automatic_payment_methods={"enabled": True}, + ) + + # Redirect to Stripe checkout or handle payment confirmation + return render(request, "cart/checkout_success.html") + + return render(request, "cart/checkout.html", {"cart": cart, "total": total}) |