diff --git a/.env.example b/.env.example index 9da2eda..404c3b3 100644 --- a/.env.example +++ b/.env.example @@ -6,3 +6,5 @@ DATABASE_URL=postgres://postgres:postgres@postgres:5432/postgres # Use something along the the following if you are not using docker # DATABASE_URL=postgres://postgres:postgres@localhost:5432/datacoop_membersystem DEBUG=True +STRIPE_API_KEY=sk_test_ +STRIPE_ENDPOINT_SECRET=whsec_ diff --git a/Makefile b/Makefile index 0eeaafa..bcf05e5 100644 --- a/Makefile +++ b/Makefile @@ -1,3 +1,4 @@ +.PHONY: run makemigrations migrate createsuperuser shell manage_command build requirements DOCKER_COMPOSE = COMPOSE_DOCKER_CLI_BUILD=1 DOCKER_BUILDKIT=1 docker compose DOCKER_RUN = ${DOCKER_COMPOSE} run -u `id -u` MANAGE_EXEC = python /app/src/manage.py @@ -23,3 +24,6 @@ manage_command: build: ${DOCKER_COMPOSE} build + +requirements: + hatch run requirements diff --git a/README.md b/README.md index a91763d..7a0256d 100644 --- a/README.md +++ b/README.md @@ -85,12 +85,16 @@ make makemigrations hatch run dev:server ``` -#### Updating requirements +### Updating requirements -If you want to update the requirements, you can run the following command: +We use hatch-pip-compile. That means we have a set of loosely defined `dependencies` in `pyproject.toml` and then we can keep the exactly pinned version in our `requirements.txt` (auto-generated). + +To generate `requirements.txt` and `requirements/requirements-dev.txt`, run the following command: ```bash -hatch run requirements -``` +# Build requirements.txt etc +make requirements -This uses [hatch-pip-compile](https://juftin.com/hatch-pip-compile/) to update the requirements. +# Build Docker image with new Python requirements +make build +``` diff --git a/pyproject.toml b/pyproject.toml index 1c98bea..a14bfbc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,7 @@ authors = [ { name = "Víðir Valberg Guðmundsson", email = "valberg@orn.li" }, ] dependencies = [ - "Django~=5.0", + "Django>=5.1b1,<5.2", "django-money~=3.5", "django-allauth~=0.63", "psycopg[binary]~=3.2", @@ -23,6 +23,8 @@ dependencies = [ "django-registries==0.0.3", "django-view-decorator==0.0.4", "django-oauth-toolkit~=2.4", + "django_stubs_ext~=5.0", + "stripe~=10.5", ] version = "0.0.1" @@ -54,7 +56,7 @@ dependencies = [ [[tool.hatch.envs.tests.matrix]] python = ["3.12"] -django = ["5.0"] +django = ["5.1b1"] [tool.hatch.envs.tests.overrides] matrix.django.dependencies = [ @@ -74,6 +76,8 @@ migrate = "./src/manage.py migrate" makemigrations = "./src/manage.py makemigrations" createsuperuser = "./src/manage.py createsuperuser" shell = "./src/manage.py shell" +# You need to install Stripe CLI from here to run this: https://github.com/stripe/stripe-cli/releases +stripe_cli = "stripe listen --forward-to 0.0.0.0:8000/order/stripe/webhook/" [tool.pytest.ini_options] DJANGO_SETTINGS_MODULE="tests.settings" @@ -106,10 +110,10 @@ show_error_codes = true strict = true warn_unreachable = true follow_imports = "normal" -#plugins = ["mypy_django_plugin.main"] +plugins = ["mypy_django_plugin.main"] [tool.django-stubs] -#django_settings_module = "tests.settings" +django_settings_module = "project.settings" [[tool.mypy.overrides]] module = "tests.*" diff --git a/requirements.txt b/requirements.txt index 2ba31a3..418b0bc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,11 +5,13 @@ # - django-money~=3.5 # - django-oauth-toolkit~=2.4 # - django-registries==0.0.3 +# - django-stubs-ext~=5.0 # - django-view-decorator==0.0.4 # - django-zen-queries~=2.1 -# - django~=5.0 +# - django<5.2,>=5.1b1 # - environs[django]<12,>=11 # - psycopg[binary]~=3.2 +# - stripe~=10.5 # - uvicorn~=0.30 # - whitenoise~=6.7 # @@ -32,7 +34,7 @@ dj-database-url==2.2.0 # via environs dj-email-url==1.0.6 # via environs -django==5.0.7 +django==5.1rc1 # via # hatch.envs.default # dj-database-url @@ -40,6 +42,7 @@ django==5.0.7 # django-money # django-oauth-toolkit # django-registries + # django-stubs-ext # django-view-decorator # django-zen-queries django-allauth==0.63.6 @@ -52,6 +55,8 @@ django-oauth-toolkit==2.4.0 # via hatch.envs.default django-registries==0.0.3 # via hatch.envs.default +django-stubs-ext==5.0.4 + # via hatch.envs.default django-view-decorator==0.0.4 # via hatch.envs.default django-zen-queries==2.1.0 @@ -83,17 +88,23 @@ python-dotenv==1.0.1 pytz==2024.1 # via django-oauth-toolkit requests==2.32.3 - # via django-oauth-toolkit + # via + # django-oauth-toolkit + # stripe setuptools==72.1.0 # via django-money sqlparse==0.5.1 # via django +stripe==10.6.0 + # via hatch.envs.default typing-extensions==4.12.2 # via # dj-database-url + # django-stubs-ext # jwcrypto # psycopg # py-moneyed + # stripe urllib3==2.2.2 # via requests uvicorn==0.30.5 diff --git a/requirements/requirements-dev.txt b/requirements/requirements-dev.txt index 274ee17..0617412 100644 --- a/requirements/requirements-dev.txt +++ b/requirements/requirements-dev.txt @@ -15,11 +15,13 @@ # - django-money~=3.5 # - django-oauth-toolkit~=2.4 # - django-registries==0.0.3 +# - django-stubs-ext~=5.0 # - django-view-decorator==0.0.4 # - django-zen-queries~=2.1 -# - django~=5.0 +# - django<5.2,>=5.1b1 # - environs[django]<12,>=11 # - psycopg[binary]~=3.2 +# - stripe~=10.5 # - uvicorn~=0.30 # - whitenoise~=6.7 # @@ -52,7 +54,7 @@ dj-database-url==2.2.0 # via environs dj-email-url==1.0.6 # via environs -django==5.0.7 +django==5.1rc1 # via # hatch.envs.dev # dj-database-url @@ -84,7 +86,9 @@ django-registries==0.0.3 django-stubs==1.16.0 # via hatch.envs.dev django-stubs-ext==5.0.4 - # via django-stubs + # via + # hatch.envs.dev + # django-stubs django-view-decorator==0.0.4 # via hatch.envs.dev django-zen-queries==2.1.0 @@ -146,7 +150,9 @@ python-dotenv==1.0.1 pytz==2024.1 # via django-oauth-toolkit requests==2.32.3 - # via django-oauth-toolkit + # via + # django-oauth-toolkit + # stripe setuptools==72.1.0 # via # django-money @@ -155,6 +161,8 @@ sqlparse==0.5.1 # via # django # django-debug-toolbar +stripe==10.6.0 + # via hatch.envs.dev tomli==2.0.1 # via django-stubs types-pytz==2024.1.0.20240417 @@ -170,6 +178,7 @@ typing-extensions==4.12.2 # mypy # psycopg # py-moneyed + # stripe urllib3==2.2.2 # via requests uvicorn==0.30.5 diff --git a/src/accounting/admin.py b/src/accounting/admin.py index 38664f7..b8b311c 100644 --- a/src/accounting/admin.py +++ b/src/accounting/admin.py @@ -1,36 +1,73 @@ """Admin for the accounting app.""" +from django import forms from django.contrib import admin from django.utils.translation import gettext_lazy as _ -from .models import Order -from .models import Payment +from . import models -@admin.register(Order) +class OrderProductInline(admin.TabularInline): + """Administer contents of an order inline.""" + + model = models.OrderProduct + + +class OrderAdminForm(forms.ModelForm): + """Special Form for the OrderAdmin so we don't need to require the account field.""" + + account = forms.ModelChoiceField( + required=False, + queryset=models.Account.objects.all(), + help_text=_("Leave empty to auto-choose the member's own account or to create one."), + ) + + class Meta: + model = models.Order + exclude = () # noqa: DJ006 + + def clean(self): # noqa: D102, ANN201 + cd = super().clean() + if not cd["account"] and cd["member"]: + try: + cd["account"] = models.Account.objects.get_or_create(owner=cd["member"])[0] + except models.Account.MultipleObjectsReturned: + cd["account"] = models.Account.objects.filter(owner=cd["member"]).first() + return cd + + +@admin.register(models.Order) class OrderAdmin(admin.ModelAdmin): """Admin for the Order model.""" - list_display = ("who", "description", "created", "is_paid") + inlines = (OrderProductInline,) + form = OrderAdminForm - @admin.display(description=_("Customer")) - def who(self, instance: Order) -> str: - """Return the full name of the user who made the order.""" - return instance.user.get_full_name() + list_display = ("member", "description", "created", "is_paid") -@admin.register(Payment) +@admin.register(models.Payment) class PaymentAdmin(admin.ModelAdmin): """Admin for the Payment model.""" - list_display = ("who", "description", "order_id", "created") - - @admin.display(description=_("Customer")) - def who(self, instance: Payment) -> str: - """Return the full name of the user who made the payment.""" - return instance.order.user.get_full_name() + list_display = ("order__member", "description", "order_id", "created") @admin.display(description=_("Order ID")) - def order_id(self, instance: Payment) -> int: + def order_id(self, instance: models.Payment) -> int: """Return the ID of the order.""" return instance.order.id + + +@admin.register(models.Product) +class ProductAdmin(admin.ModelAdmin): # noqa: D101 + list_display = ("name", "price", "vat") + + +class TransactionInline(admin.TabularInline): # noqa: D101 + model = models.Transaction + + +@admin.register(models.Account) +class AccountAdmin(admin.ModelAdmin): # noqa: D101 + list_display = ("owner", "balance") + inlines = (TransactionInline,) diff --git a/src/accounting/apps.py b/src/accounting/apps.py index 296dae8..9193122 100644 --- a/src/accounting/apps.py +++ b/src/accounting/apps.py @@ -7,3 +7,7 @@ class AccountingConfig(AppConfig): """Accounting app config.""" name = "accounting" + + def ready(self) -> None: + """Implicitly connect a signal handlers decorated with @receiver.""" + from . import signals # noqa: F401 diff --git a/src/accounting/migrations/0004_paymenttype_product_payment_external_transaction_id_and_more.py b/src/accounting/migrations/0004_paymenttype_product_payment_external_transaction_id_and_more.py new file mode 100644 index 0000000..240546f --- /dev/null +++ b/src/accounting/migrations/0004_paymenttype_product_payment_external_transaction_id_and_more.py @@ -0,0 +1,78 @@ +# Generated by Django 5.0.7 on 2024-07-21 14:12 + +import django.db.models.deletion +import djmoney.models.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounting', '0003_alter_payment_stripe_charge_id'), + ] + + operations = [ + migrations.CreateModel( + name='PaymentType', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('modified', models.DateTimeField(auto_now=True, verbose_name='modified')), + ('created', models.DateTimeField(auto_now_add=True, verbose_name='oprettet')), + ('name', models.CharField(max_length=1024, verbose_name='description')), + ('description', models.TextField(blank=True, max_length=2048)), + ('enabled', models.BooleanField(default=True)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='Product', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('modified', models.DateTimeField(auto_now=True, verbose_name='modified')), + ('created', models.DateTimeField(auto_now_add=True, verbose_name='oprettet')), + ('name', models.CharField(max_length=512)), + ('price_currency', djmoney.models.fields.CurrencyField(choices=[('DKK', 'DKK')], default=None, editable=False, max_length=3)), + ('price', djmoney.models.fields.MoneyField(decimal_places=2, max_digits=16)), + ('vat_currency', djmoney.models.fields.CurrencyField(choices=[('DKK', 'DKK')], default=None, editable=False, max_length=3)), + ('vat', djmoney.models.fields.MoneyField(decimal_places=2, max_digits=16)), + ], + options={ + 'abstract': False, + }, + ), + migrations.AddField( + model_name='payment', + name='external_transaction_id', + field=models.CharField(blank=True, default='', max_length=255), + ), + migrations.AlterField( + model_name='payment', + name='stripe_charge_id', + field=models.CharField(blank=True, default='', max_length=255), + ), + migrations.AddField( + model_name='payment', + name='payment_type', + field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.PROTECT, to='accounting.paymenttype'), + preserve_default=False, + ), + migrations.CreateModel( + name='OrderProduct', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('modified', models.DateTimeField(auto_now=True, verbose_name='modified')), + ('created', models.DateTimeField(auto_now_add=True, verbose_name='oprettet')), + ('price_currency', djmoney.models.fields.CurrencyField(choices=[('DKK', 'DKK')], default=None, editable=False, max_length=3)), + ('price', djmoney.models.fields.MoneyField(decimal_places=2, max_digits=16)), + ('vat_currency', djmoney.models.fields.CurrencyField(choices=[('DKK', 'DKK')], default=None, editable=False, max_length=3)), + ('vat', djmoney.models.fields.MoneyField(decimal_places=2, max_digits=16)), + ('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ordered_products', to='accounting.order')), + ('product', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='ordered_products', to='accounting.product')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/src/accounting/migrations/0005_remove_order_price_remove_order_price_currency_and_more.py b/src/accounting/migrations/0005_remove_order_price_remove_order_price_currency_and_more.py new file mode 100644 index 0000000..6252335 --- /dev/null +++ b/src/accounting/migrations/0005_remove_order_price_remove_order_price_currency_and_more.py @@ -0,0 +1,40 @@ +# Generated by Django 5.0.7 on 2024-07-21 14:53 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounting', '0004_paymenttype_product_payment_external_transaction_id_and_more'), + ] + + operations = [ + migrations.RemoveField( + model_name='order', + name='price', + ), + migrations.RemoveField( + model_name='order', + name='price_currency', + ), + migrations.RemoveField( + model_name='order', + name='vat', + ), + migrations.RemoveField( + model_name='order', + name='vat_currency', + ), + migrations.AlterField( + model_name='orderproduct', + name='order', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='order_products', to='accounting.order'), + ), + migrations.AlterField( + model_name='orderproduct', + name='product', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='order_products', to='accounting.product'), + ), + ] diff --git a/src/accounting/migrations/0006_alter_account_owner_alter_order_user.py b/src/accounting/migrations/0006_alter_account_owner_alter_order_user.py new file mode 100644 index 0000000..cdaeaff --- /dev/null +++ b/src/accounting/migrations/0006_alter_account_owner_alter_order_user.py @@ -0,0 +1,25 @@ +# Generated by Django 5.0.7 on 2024-07-21 15:17 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounting', '0005_remove_order_price_remove_order_price_currency_and_more'), + ('membership', '0006_waitinglistentry_alter_membership_options'), + ] + + operations = [ + migrations.AlterField( + model_name='account', + name='owner', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='membership.member'), + ), + migrations.AlterField( + model_name='order', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='membership.member'), + ), + ] diff --git a/src/accounting/migrations/0007_alter_orderproduct_options_rename_user_order_member_and_more.py b/src/accounting/migrations/0007_alter_orderproduct_options_rename_user_order_member_and_more.py new file mode 100644 index 0000000..c60aba3 --- /dev/null +++ b/src/accounting/migrations/0007_alter_orderproduct_options_rename_user_order_member_and_more.py @@ -0,0 +1,42 @@ +# Generated by Django 5.1b1 on 2024-08-01 10:50 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounting', '0006_alter_account_owner_alter_order_user'), + ] + + operations = [ + migrations.AlterModelOptions( + name='orderproduct', + options={'verbose_name': 'ordered product', 'verbose_name_plural': 'ordered products'}, + ), + migrations.RenameField( + model_name='order', + old_name='user', + new_name='member', + ), + migrations.RemoveField( + model_name='payment', + name='stripe_charge_id', + ), + migrations.AddField( + model_name='orderproduct', + name='quantity', + field=models.PositiveSmallIntegerField(default=1), + ), + migrations.AlterField( + model_name='orderproduct', + name='order', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='accounting.order'), + ), + migrations.AlterField( + model_name='orderproduct', + name='product', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='accounting.product'), + ), + ] diff --git a/src/accounting/models.py b/src/accounting/models.py index 5243b04..d7f7315 100644 --- a/src/accounting/models.py +++ b/src/accounting/models.py @@ -29,10 +29,10 @@ class Account(CreatedModifiedAbstract): can decide which account to use to pay for something. """ - owner = models.ForeignKey("auth.User", on_delete=models.PROTECT) + owner = models.ForeignKey("membership.Member", on_delete=models.PROTECT) def __str__(self) -> str: - return f"Account of {self.owner.get_full_name()}" + return f"Account of {self.owner}" @property def balance(self) -> Money: @@ -67,23 +67,15 @@ class Transaction(CreatedModifiedAbstract): class Order(CreatedModifiedAbstract): """An order. - Scoped out: Contents of invoices will have to be tracked either here or in - a separate Invoice model. This is undecided because we are not generating - invoices at the moment. + We assemble the order from a number of products. Once an order is paid, the contents should be + considered locked. """ - user = models.ForeignKey("auth.User", on_delete=models.PROTECT) + member = models.ForeignKey("membership.Member", on_delete=models.PROTECT) account = models.ForeignKey(Account, on_delete=models.PROTECT) description = models.CharField(max_length=1024, verbose_name=_("description")) - price = MoneyField( - verbose_name=_("price (excl. VAT)"), - max_digits=16, - decimal_places=2, - ) - vat = MoneyField(verbose_name=_("VAT"), max_digits=16, decimal_places=2) - is_paid = models.BooleanField(default=False, verbose_name=_("is paid")) class Meta: @@ -95,8 +87,18 @@ class Order(CreatedModifiedAbstract): @property def total(self) -> Money: - """Return the total price of the order.""" - return self.price + self.vat + """Return the total price of the order (excl VAT).""" + return sum(item.price * item.quantity for item in self.items.all()) + + @property + def total_vat(self) -> Money: + """Return the total VAT of the order.""" + return sum(item.vat * item.quantity for item in self.items.all()) + + @property + def total_with_vat(self) -> Money: + """Return the TOTAL amount WITH VAT.""" + return self.total + self.total_vat @property def display_id(self) -> str: @@ -114,6 +116,42 @@ class Order(CreatedModifiedAbstract): return x.hexdigest() +class Product(CreatedModifiedAbstract): + """A generic product, for instance a membership or a service fee.""" + + name = models.CharField(max_length=512) + price = MoneyField(max_digits=16, decimal_places=2) + vat = MoneyField(max_digits=16, decimal_places=2) + + def __str__(self) -> str: + return self.name + + +class OrderProduct(CreatedModifiedAbstract): + """When a product is ordered, we store the product on the order. + + This includes pricing information. + """ + + order = models.ForeignKey(Order, on_delete=models.CASCADE, related_name="items") + product = models.ForeignKey(Product, on_delete=models.PROTECT) + price = MoneyField(max_digits=16, decimal_places=2) + vat = MoneyField(max_digits=16, decimal_places=2) + quantity = models.PositiveSmallIntegerField(default=1) + + class Meta: + verbose_name = _("ordered product") + verbose_name_plural = _("ordered products") + + def __str__(self) -> str: + return f"{self.product.name}" + + @property + def total_with_vat(self) -> Money: + """Total price of this item.""" + return (self.price + self.vat) * self.quantity + + class Payment(CreatedModifiedAbstract): """A payment is a transaction that is made to pay for an order.""" @@ -122,7 +160,8 @@ class Payment(CreatedModifiedAbstract): description = models.CharField(max_length=1024, verbose_name=_("description")) - stripe_charge_id = models.CharField(max_length=255, blank=True) + payment_type = models.ForeignKey("PaymentType", on_delete=models.PROTECT) + external_transaction_id = models.CharField(max_length=255, default="", blank=True) class Meta: verbose_name = _("payment") @@ -137,11 +176,28 @@ class Payment(CreatedModifiedAbstract): return str(self.id).zfill(6) @classmethod - def from_order(cls, order: Order) -> Self: + def from_order(cls, order: Order, payment_type: "PaymentType") -> Self: """Create a payment from an order.""" return cls.objects.create( order=order, user=order.user, - amount=order.total, + amount=order.total + order.total_vat, description=order.description, + payment_type=payment_type, ) + + +class PaymentType(CreatedModifiedAbstract): + """Types of payments available in the system. + + - bank transfer + - card payment (specific provider) + """ + + name = models.CharField(max_length=1024, verbose_name=_("description")) + description = models.TextField(max_length=2048, blank=True) + + enabled = models.BooleanField(default=True) + + def __str__(self) -> str: + return f"{self.name}" diff --git a/src/accounting/signals.py b/src/accounting/signals.py new file mode 100644 index 0000000..f33263d --- /dev/null +++ b/src/accounting/signals.py @@ -0,0 +1,39 @@ +"""Loaded with the AppConfig.ready() method.""" + +from django.conf import settings +from django.core.mail import send_mail +from django.db.models.signals import post_save +from django.dispatch import receiver +from django.utils import timezone +from membership.models import Membership + +from . import models + + +# method for updating +@receiver(post_save, sender=models.Payment) +def check_total_amount(sender: models.Payment, instance: models.Payment, **kwargs: dict) -> None: # noqa: ARG001 + """Check that we receive Payments with the correct amount.""" + if instance.amount != instance.order.total_with_vat: + send_mail( + "Payment received: wrong amount", + f"Please check payment ID {instance.pk}", + settings.DEFAULT_FROM_EMAIL, + settings.ADMINS, + ) + + +@receiver(post_save, sender=models.Payment) +def mark_order_paid(sender: models.Payment, instance: models.Payment, **kwargs: dict) -> None: # noqa: ARG001 + """Mark an order as paid when payment is received.""" + instance.order.is_paid = True + instance.order.save() + + +@receiver(post_save, sender=models.Order) +def activate_membership(sender: models.Order, instance: models.Order, **kwargs: dict) -> None: # noqa: ARG001 + """Mark a membership as activated when its order is marked as paid.""" + if instance.is_paid: + Membership.objects.filter(order=instance, activated=False, activated_on=None).update( + activated=True, activated_on=timezone.now() + ) diff --git a/src/accounting/templates/accounting/order/cancel.html b/src/accounting/templates/accounting/order/cancel.html new file mode 100644 index 0000000..bc73b69 --- /dev/null +++ b/src/accounting/templates/accounting/order/cancel.html @@ -0,0 +1,18 @@ +{% extends "base.html" %} +{% load i18n %} + +{% block head_title %} + {% trans "Payment cancelled" %} +{% endblock %} + +{% block content %} + +
+

{% trans "Payment canceled" %}

+ +

+ {% trans "Return to order page" %} +

+ +
+{% endblock %} diff --git a/src/accounting/templates/accounting/order/detail.html b/src/accounting/templates/accounting/order/detail.html new file mode 100644 index 0000000..a3a0b0f --- /dev/null +++ b/src/accounting/templates/accounting/order/detail.html @@ -0,0 +1,49 @@ +{% extends "base.html" %} +{% load i18n %} + +{% block head_title %} + {% trans "Order" %} +{% endblock %} + +{% block content %} + +
+

Order: {{ order.id }}

+ +

+ {% trans "Ordered" %}: {{ order.created }}
+ {% trans "Status" %}: {{ order.is_paid|yesno:_("paid,unpaid") }} +

+ + + + + + + + + + + + + {% for item in order.items.all %} + + + + + + + + {% endfor %} + +
{% trans "Item" %}{% trans "Quantity" %}{% trans "Price" %}{% trans "VAT" %}{% trans "Total" %}
{{ item.product.name }}{{ item.quantity }}{{ item.price }}{{ item.vat }}{{ item.total_with_vat }}
+ +

{% trans "Total price" %}: {{ order.total_with_vat }}

+ + {% if not order.is_paid %} +

+ {% trans "Pay now" %} +

+ {% endif %} +
+{% endblock %} diff --git a/src/accounting/templates/accounting/order/success.html b/src/accounting/templates/accounting/order/success.html new file mode 100644 index 0000000..8ebce6d --- /dev/null +++ b/src/accounting/templates/accounting/order/success.html @@ -0,0 +1,20 @@ +{% extends "base.html" %} +{% load i18n %} + +{% block head_title %} + {% trans "Payment received" %} +{% endblock %} + +{% block content %} + +
+

{% trans "Payment received" %}

+ +

+ {% blocktrans trimmed with order.id as order_id %} + Thanks fellow member! We received your payment for Order {{ order_id }}. We're adding more features to the site, so expect to see a confirmation email (receipt) for the order soon. + {% endblocktrans %} +

+ +
+{% endblock %} diff --git a/src/accounting/views.py b/src/accounting/views.py new file mode 100644 index 0000000..acfc077 --- /dev/null +++ b/src/accounting/views.py @@ -0,0 +1,177 @@ +"""Views for the membership app.""" + +import stripe +from django.conf import settings +from django.contrib.sites.models import Site +from django.core.mail import send_mail +from django.db import transaction +from django.http import HttpRequest +from django.http import HttpResponse +from django.shortcuts import get_object_or_404 +from django.shortcuts import redirect +from django.shortcuts import render +from django.urls import reverse +from django.views.decorators.csrf import csrf_exempt +from django_view_decorator import namespaced_decorator_factory +from djmoney.money import Money + +from . import models + +order_view = namespaced_decorator_factory(namespace="order", base_path="order") + +stripe.api_key = settings.STRIPE_API_KEY + + +@order_view( + paths="/", + name="detail", + login_required=True, +) +def order_detail(request: HttpRequest, order_id: int) -> HttpResponse: + """View to show the details of a member.""" + user = request.user # People just need to login to pay something, not necessarily be a member + order = models.Order.objects.get(pk=order_id, member=user) + + context = { + "order": order, + } + + return render( + request=request, + template_name="accounting/order/detail.html", + context=context, + ) + + +@order_view( + paths="/pay/", + name="pay", + login_required=True, +) +def order_pay(request: HttpRequest, order_id: int) -> HttpResponse: + """Create a Stripe session and redirects to Stripe Checkout.""" + user = request.user # People just need to login to pay something, not necessarily be a member + order = models.Order.objects.get(pk=order_id, member=user) + current_site = Site.objects.get_current(request) + base_domain = f"https://{current_site.domain}" + if settings.DEBUG: + f"http://{current_site.domain}" + + try: + line_items = [] + for item in order.items.all(): + line_items.append( # noqa: PERF401 + { + "price_data": { + "currency": item.total_with_vat.currency, + "unit_amount": int((item.price + item.vat).amount * 100), + "product_data": { + "name": item.product.name, + }, + }, + "quantity": item.quantity, + } + ) + checkout_session = stripe.checkout.Session.create( + line_items=line_items, + metadata={"order_id": order.id}, + mode="payment", + success_url=base_domain + reverse("order:success", kwargs={"order_id": order.id}), + cancel_url=base_domain + "/cancel", + ) + except Exception as e: + send_mail("Error in checkout", str(e), settings.DEFAULT_FROM_EMAIL, settings.ADMINS) + raise + + # TODO: Redirect with status=303 + return redirect(checkout_session.url) + + +@transaction.atomic +@order_view( + paths="/pay/success/", + name="success", + login_required=True, +) +def success(request: HttpRequest, order_id: int) -> HttpResponse: + """Create a Stripe session and redirects to Stripe Checkout. + + From Stripe docs: When you have a webhook endpoint set up to listen for checkout.session.completed events and + you set a success_url, Checkout waits for your server to respond to the webhook event delivery before redirecting + your customer. If you use this approach, make sure your server responds to checkout.session.completed events as + quickly as possible. + """ + user = request.user # People just need to login to pay something, not necessarily be a member + order = models.Order.objects.get(pk=order_id, member=user) + + context = { + "order": order, + } + + return render( + request=request, + template_name="accounting/order/success.html", + context=context, + ) + + +@transaction.atomic +@order_view( + paths="/pay/cancel/", + name="cancel", + login_required=True, +) +def cancel(request: HttpRequest, order_id: int) -> HttpResponse: + """Page to display when a payment is canceled.""" + user = request.user # People just need to login to pay something, not necessarily be a member + order = models.Order.objects.get(pk=order_id, member=user) + + context = { + "order": order, + } + + return render( + request=request, + template_name="accounting/order/cancel.html", + context=context, + ) + + +@transaction.atomic +@order_view( + paths="stripe/webhook/", + name="webhook", +) +@csrf_exempt +def stripe_webhook(request: HttpRequest) -> HttpResponse: + """Handle Stripe webhook. + + https://docs.stripe.com/metadata/use-cases + """ + payload = request.body + sig_header = request.headers["stripe-signature"] + event = None + + try: + event = stripe.Webhook.construct_event(payload, sig_header, settings.STRIPE_ENDPOINT_SECRET) + except ValueError: + # Invalid payload + return HttpResponse(status=400) + except stripe.error.SignatureVerificationError: + # Invalid signature + return HttpResponse(status=400) + + if event["type"] == "checkout.session.completed" or event["type"] == "checkout.session.async_payment_succeeded": + # Order is marked paid via signals, Membership is activated via signals. + order_id = event["data"]["object"]["metadata"]["order_id"] + order = get_object_or_404(models.Order, pk=order_id) + if not models.Payment.objects.filter(order=order).exists(): + models.Payment.objects.create( + order=order, + amount=Money(event["data"]["object"]["amount_total"], event["data"]["object"]["currency"]), + description="Paid via Stripe", + payment_type=models.PaymentType.objects.get_or_create(name="Stripe")[0], + external_transaction_id=event["id"], + ) + + return HttpResponse(status=200) diff --git a/src/membership/admin.py b/src/membership/admin.py index 4ee0163..a08de86 100644 --- a/src/membership/admin.py +++ b/src/membership/admin.py @@ -1,12 +1,29 @@ """Admin configuration for membership app.""" -from django.contrib import admin +from collections.abc import Callable +from accounting.models import Account +from accounting.models import Order +from accounting.models import OrderProduct +from django.contrib import admin +from django.contrib import messages +from django.contrib.admin import ModelAdmin +from django.contrib.auth.admin import UserAdmin +from django.contrib.auth.models import User +from django.db import transaction +from django.db.models import QuerySet +from django.http import HttpRequest +from django.http import HttpResponse + +from .models import Member from .models import Membership from .models import MembershipType from .models import SubscriptionPeriod from .models import WaitingListEntry +# Do not use existing user admin +admin.site.unregister(User) + @admin.register(Membership) class MembershipAdmin(admin.ModelAdmin): @@ -23,6 +40,76 @@ class SubscriptionPeriodAdmin(admin.ModelAdmin): """Admin for SubscriptionPeriod model.""" +class MembershipInlineAdmin(admin.TabularInline): + """Inline admin.""" + + model = Membership + + +def decorate_ensure_membership_type_exists(membership_type: MembershipType, label: str) -> Callable: + """Generate an admin action for given membership type and label.""" + + @admin.action(description=label) + def admin_action(modeladmin: ModelAdmin, request: HttpRequest, queryset: QuerySet) -> HttpResponse: # noqa: ARG001 + return ensure_membership_type_exists(request, queryset, membership_type) + + return admin_action + + +@transaction.atomic +def ensure_membership_type_exists( + request: HttpRequest, + queryset: QuerySet, + membership_type: MembershipType, +) -> HttpResponse: + """Inner function that ensures that a membership exists for a given queryset of Member objects.""" + for member in queryset: + if member.memberships.filter(membership_type=membership_type).current(): + messages.info(request, f"{member} already has a membership {membership_type}") + else: + # Get the default account of the member. We don't really know what to do if a person owns multiple accounts. + account, __ = Account.objects.get_or_create(owner=member) + # Create an Order for the products in the membership + order = Order.objects.create(member=member, account=account, description=membership_type.name) + # Add stuff to the order + for product in membership_type.products.all(): + OrderProduct.objects.create(order=order, product=product, price=product.price, vat=product.vat) + # Create the Membership + Membership.objects.create( + membership_type=membership_type, + user=member, + period=SubscriptionPeriod.objects.current(), + order=order, + ) + + # Associate the order with that membership + messages.success(request, f"{member} has ordered a '{membership_type}' (unpaid)") + + +@admin.register(Member) +class MemberAdmin(UserAdmin): + """Member admin is actually an admin for User objects.""" + + inlines = (MembershipInlineAdmin,) + actions: list[Callable] = [] # noqa: RUF012 + + def get_actions(self, request: HttpRequest) -> dict: + """Populate actions with dynamic data (MembershipType).""" + current_period = SubscriptionPeriod.objects.current() + + super_dict = super().get_actions(request) + + if current_period: + for i, mtype in enumerate(MembershipType.objects.filter(active=True)): + action_label = f"Ensure membership {mtype.name}, {current_period.period}, {mtype.total_including_vat}" + action_func = decorate_ensure_membership_type_exists(mtype, action_label) + # Django ModelAdmin uses the non-unique __name__ property, so we need to suffix it to make it unique + action_func.__name__ += f"_{i}" + self.actions.append(action_func) + + return super_dict + + @admin.register(WaitingListEntry) class WaitingListEntryAdmin(admin.ModelAdmin): """Admin for WaitingList model.""" diff --git a/src/membership/migrations/0007_membership_activated_membership_activated_on_and_more.py b/src/membership/migrations/0007_membership_activated_membership_activated_on_and_more.py new file mode 100644 index 0000000..cedb798 --- /dev/null +++ b/src/membership/migrations/0007_membership_activated_membership_activated_on_and_more.py @@ -0,0 +1,62 @@ +# Generated by Django 5.1b1 on 2024-08-01 10:50 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounting', '0007_alter_orderproduct_options_rename_user_order_member_and_more'), + ('membership', '0006_waitinglistentry_alter_membership_options'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='membership', + name='activated', + field=models.BooleanField(default=False, help_text='Membership was activated.', verbose_name='activated'), + ), + migrations.AddField( + model_name='membership', + name='activated_on', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='membership', + name='order', + field=models.ForeignKey(blank=True, help_text='The order filled in for paying this membership.', null=True, on_delete=django.db.models.deletion.PROTECT, to='accounting.order', verbose_name='order'), + ), + migrations.AddField( + model_name='membership', + name='revoked', + field=models.BooleanField(default=False, help_text='Membership has explicitly been revoked. Revoking a membership is not associated with regular expiration of the membership period.', verbose_name='revoked'), + ), + migrations.AddField( + model_name='membership', + name='revoked_on', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='membership', + name='revoked_reason', + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name='membershiptype', + name='active', + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name='membershiptype', + name='products', + field=models.ManyToManyField(to='accounting.product'), + ), + migrations.AlterField( + model_name='membership', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='memberships', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/src/membership/models.py b/src/membership/models.py index 4fec013..f00c458 100644 --- a/src/membership/models.py +++ b/src/membership/models.py @@ -10,6 +10,7 @@ from django.contrib.postgres.fields import RangeOperators from django.db import models from django.utils import timezone from django.utils.translation import gettext as _ +from djmoney.money import Money from utils.mixins import CreatedModifiedAbstract @@ -53,6 +54,22 @@ class SubscriptionPeriod(CreatedModifiedAbstract): Denotes a period for which members should pay their membership fee for. """ + class QuerySet(models.QuerySet): + """QuerySet for the Membership model.""" + + def _current(self) -> Self: + """Filter memberships for the current period.""" + return self.filter(period__contains=timezone.now()) + + def current(self) -> "Membership | None": + """Get the current membership.""" + try: + return self._current().get() + except self.model.DoesNotExist: + return None + + objects = QuerySet.as_manager() + period = DateRangeField(verbose_name=_("period")) class Meta: @@ -82,16 +99,17 @@ class Membership(CreatedModifiedAbstract): """Filter memberships for a given member.""" return self.filter(user=member) + def active(self) -> Self: + """Get only activated, non-revoked memberships (may have expired so use also current()).""" + return self.filter(activated=True, revoked=False) + def _current(self) -> Self: """Filter memberships for the current period.""" return self.filter(period__period__contains=timezone.now()) def current(self) -> "Membership | None": """Get the current membership.""" - try: - return self._current().get() - except self.model.DoesNotExist: - return None + return self._current().first() def previous(self) -> list["Membership"]: """Get previous memberships.""" @@ -102,7 +120,7 @@ class Membership(CreatedModifiedAbstract): objects = QuerySet.as_manager() - user = models.ForeignKey("auth.User", on_delete=models.PROTECT) + user = models.ForeignKey("auth.User", on_delete=models.PROTECT, related_name="memberships") membership_type = models.ForeignKey( "membership.MembershipType", @@ -116,6 +134,31 @@ class Membership(CreatedModifiedAbstract): on_delete=models.PROTECT, ) + order = models.ForeignKey( + "accounting.Order", + null=True, + blank=True, + verbose_name=_("order"), + help_text=_("The order filled in for paying this membership."), + on_delete=models.PROTECT, + ) + + activated = models.BooleanField( + default=False, verbose_name=_("activated"), help_text=_("Membership was activated.") + ) + activated_on = models.DateTimeField(null=True, blank=True) + + revoked = models.BooleanField( + default=False, + verbose_name=_("revoked"), + help_text=_( + "Membership has explicitly been revoked. Revoking a membership is not associated with regular expiration " + "of the membership period." + ), + ) + revoked_reason = models.TextField(blank=True) + revoked_on = models.DateTimeField(null=True, blank=True) + class Meta: verbose_name = _("membership") verbose_name_plural = _("memberships") @@ -133,6 +176,10 @@ class MembershipType(CreatedModifiedAbstract): name = models.CharField(verbose_name=_("name"), max_length=64) + products = models.ManyToManyField("accounting.Product") + + active = models.BooleanField(default=True) + class Meta: verbose_name = _("membership type") verbose_name_plural = _("membership types") @@ -140,6 +187,21 @@ class MembershipType(CreatedModifiedAbstract): def __str__(self) -> str: return self.name + def create_membership(self, user: User) -> Membership: + """Create a current membership for this type.""" + from .selectors import get_current_subscription_period + + return Membership.objects.create( + membership_type=self, + user=user, + period=get_current_subscription_period(), + ) + + @property + def total_including_vat(self) -> Money: + """Calculate the total price of this membership (including VAT).""" + return sum(product.price + product.vat for product in self.products.all()) + class WaitingListEntry(CreatedModifiedAbstract): """People who for some reason could want to be added to a waiting list and invited to join later.""" diff --git a/src/project/settings.py b/src/project/settings.py index 9cf846d..2f78775 100644 --- a/src/project/settings.py +++ b/src/project/settings.py @@ -2,9 +2,12 @@ from pathlib import Path +import django_stubs_ext from django.utils.translation import gettext_lazy as _ from environs import Env +django_stubs_ext.monkeypatch() + env = Env() env.read_env() @@ -175,6 +178,8 @@ LOGGING = { }, } +STRIPE_API_KEY = env.str("STRIPE_API_KEY") +STRIPE_ENDPOINT_SECRET = env.str("STRIPE_ENDPOINT_SECRET") if DEBUG: INSTALLED_APPS += ["debug_toolbar", "django_browser_reload"]