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 "Ordered" %}: {{ order.created }}
+ {% trans "Status" %}: {{ order.is_paid|yesno:_("paid,unpaid") }}
+
{% trans "Item" %} | +{% trans "Quantity" %} | +{% trans "Price" %} | +{% trans "VAT" %} | +{% trans "Total" %} | +
---|---|---|---|---|
{{ item.product.name }} | +{{ item.quantity }} | +{{ item.price }} | +{{ item.vat }} | +{{ item.total_with_vat }} | +
+ {% 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 %} +
+ +