Changes to payment models #32
|
@ -6,3 +6,5 @@ DATABASE_URL=postgres://postgres:postgres@postgres:5432/postgres
|
||||||
# Use something along the the following if you are not using docker
|
# Use something along the the following if you are not using docker
|
||||||
# DATABASE_URL=postgres://postgres:postgres@localhost:5432/datacoop_membersystem
|
# DATABASE_URL=postgres://postgres:postgres@localhost:5432/datacoop_membersystem
|
||||||
DEBUG=True
|
DEBUG=True
|
||||||
|
STRIPE_API_KEY=sk_test_
|
||||||
|
STRIPE_ENDPOINT_SECRET=whsec_
|
||||||
|
|
4
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_COMPOSE = COMPOSE_DOCKER_CLI_BUILD=1 DOCKER_BUILDKIT=1 docker compose
|
||||||
DOCKER_RUN = ${DOCKER_COMPOSE} run -u `id -u`
|
DOCKER_RUN = ${DOCKER_COMPOSE} run -u `id -u`
|
||||||
MANAGE_EXEC = python /app/src/manage.py
|
MANAGE_EXEC = python /app/src/manage.py
|
||||||
|
@ -23,3 +24,6 @@ manage_command:
|
||||||
|
|
||||||
build:
|
build:
|
||||||
${DOCKER_COMPOSE} build
|
${DOCKER_COMPOSE} build
|
||||||
|
|
||||||
|
requirements:
|
||||||
|
hatch run requirements
|
||||||
|
|
14
README.md
|
@ -85,12 +85,16 @@ make makemigrations
|
||||||
hatch run dev:server
|
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
|
```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
|
||||||
|
```
|
||||||
|
|
|
@ -12,7 +12,7 @@ authors = [
|
||||||
{ name = "Víðir Valberg Guðmundsson", email = "valberg@orn.li" },
|
{ name = "Víðir Valberg Guðmundsson", email = "valberg@orn.li" },
|
||||||
]
|
]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"Django~=5.0",
|
"Django>=5.1b1,<5.2",
|
||||||
benjaoming marked this conversation as resolved
|
|||||||
"django-money~=3.5",
|
"django-money~=3.5",
|
||||||
"django-allauth~=0.63",
|
"django-allauth~=0.63",
|
||||||
"psycopg[binary]~=3.2",
|
"psycopg[binary]~=3.2",
|
||||||
|
@ -23,6 +23,8 @@ dependencies = [
|
||||||
"django-registries==0.0.3",
|
"django-registries==0.0.3",
|
||||||
"django-view-decorator==0.0.4",
|
"django-view-decorator==0.0.4",
|
||||||
"django-oauth-toolkit~=2.4",
|
"django-oauth-toolkit~=2.4",
|
||||||
|
"django_stubs_ext~=5.0",
|
||||||
benjaoming marked this conversation as resolved
benjaoming
commented
Noting that this got reintroduced... I don't use it... but is it used? Or did you remove it on purpose @valberg ? Noting that this got reintroduced... I don't use it... but is it used? Or did you remove it on purpose @valberg ?
valberg
commented
I did not do it on purpose no 😊 my all means remove it if it is isn't being used I did not do it on purpose no 😊 my all means remove it if it is isn't being used
benjaoming
commented
Oh god now I know what's going on... it's for the mypy django-stubs thing... Yes, we need it for running mypy with Django. I think I spend 2 minutes to get it working. But I didn't actually fix any errors. I think we can keep it for now and then we should open an issue if we want to keep using it. Or we can decide that we don't want to spend time on this because it doesn't find any real issues. Oh god now I know what's going on... it's for the mypy django-stubs thing...
Yes, we need it for running mypy with Django. I think I spend 2 minutes to get it working. But I didn't actually fix any errors. I think we can keep it for now and then we should open an issue if we want to keep using it.
Or we can decide that we don't want to spend time on this because it doesn't find any real issues.
benjaoming
commented
https://git.data.coop/data.coop/membersystem/issues/37
|
|||||||
|
"stripe~=10.5",
|
||||||
]
|
]
|
||||||
version = "0.0.1"
|
version = "0.0.1"
|
||||||
|
|
||||||
|
@ -54,7 +56,7 @@ dependencies = [
|
||||||
|
|
||||||
[[tool.hatch.envs.tests.matrix]]
|
[[tool.hatch.envs.tests.matrix]]
|
||||||
python = ["3.12"]
|
python = ["3.12"]
|
||||||
django = ["5.0"]
|
django = ["5.1b1"]
|
||||||
|
|
||||||
[tool.hatch.envs.tests.overrides]
|
[tool.hatch.envs.tests.overrides]
|
||||||
matrix.django.dependencies = [
|
matrix.django.dependencies = [
|
||||||
|
@ -74,6 +76,8 @@ migrate = "./src/manage.py migrate"
|
||||||
makemigrations = "./src/manage.py makemigrations"
|
makemigrations = "./src/manage.py makemigrations"
|
||||||
createsuperuser = "./src/manage.py createsuperuser"
|
createsuperuser = "./src/manage.py createsuperuser"
|
||||||
shell = "./src/manage.py shell"
|
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]
|
[tool.pytest.ini_options]
|
||||||
DJANGO_SETTINGS_MODULE="tests.settings"
|
DJANGO_SETTINGS_MODULE="tests.settings"
|
||||||
|
@ -106,10 +110,10 @@ show_error_codes = true
|
||||||
strict = true
|
strict = true
|
||||||
warn_unreachable = true
|
warn_unreachable = true
|
||||||
follow_imports = "normal"
|
follow_imports = "normal"
|
||||||
#plugins = ["mypy_django_plugin.main"]
|
plugins = ["mypy_django_plugin.main"]
|
||||||
|
|
||||||
[tool.django-stubs]
|
[tool.django-stubs]
|
||||||
#django_settings_module = "tests.settings"
|
django_settings_module = "project.settings"
|
||||||
|
|
||||||
[[tool.mypy.overrides]]
|
[[tool.mypy.overrides]]
|
||||||
module = "tests.*"
|
module = "tests.*"
|
||||||
|
|
|
@ -5,11 +5,13 @@
|
||||||
# - django-money~=3.5
|
# - django-money~=3.5
|
||||||
# - django-oauth-toolkit~=2.4
|
# - django-oauth-toolkit~=2.4
|
||||||
# - django-registries==0.0.3
|
# - django-registries==0.0.3
|
||||||
|
# - django-stubs-ext~=5.0
|
||||||
# - django-view-decorator==0.0.4
|
# - django-view-decorator==0.0.4
|
||||||
# - django-zen-queries~=2.1
|
# - django-zen-queries~=2.1
|
||||||
# - django~=5.0
|
# - django<5.2,>=5.1b1
|
||||||
# - environs[django]<12,>=11
|
# - environs[django]<12,>=11
|
||||||
# - psycopg[binary]~=3.2
|
# - psycopg[binary]~=3.2
|
||||||
|
# - stripe~=10.5
|
||||||
# - uvicorn~=0.30
|
# - uvicorn~=0.30
|
||||||
# - whitenoise~=6.7
|
# - whitenoise~=6.7
|
||||||
#
|
#
|
||||||
|
@ -32,7 +34,7 @@ dj-database-url==2.2.0
|
||||||
# via environs
|
# via environs
|
||||||
dj-email-url==1.0.6
|
dj-email-url==1.0.6
|
||||||
# via environs
|
# via environs
|
||||||
django==5.0.7
|
django==5.1rc1
|
||||||
# via
|
# via
|
||||||
# hatch.envs.default
|
# hatch.envs.default
|
||||||
# dj-database-url
|
# dj-database-url
|
||||||
|
@ -40,6 +42,7 @@ django==5.0.7
|
||||||
# django-money
|
# django-money
|
||||||
# django-oauth-toolkit
|
# django-oauth-toolkit
|
||||||
# django-registries
|
# django-registries
|
||||||
|
# django-stubs-ext
|
||||||
# django-view-decorator
|
# django-view-decorator
|
||||||
# django-zen-queries
|
# django-zen-queries
|
||||||
django-allauth==0.63.6
|
django-allauth==0.63.6
|
||||||
|
@ -52,6 +55,8 @@ django-oauth-toolkit==2.4.0
|
||||||
# via hatch.envs.default
|
# via hatch.envs.default
|
||||||
django-registries==0.0.3
|
django-registries==0.0.3
|
||||||
# via hatch.envs.default
|
# via hatch.envs.default
|
||||||
|
django-stubs-ext==5.0.4
|
||||||
|
# via hatch.envs.default
|
||||||
django-view-decorator==0.0.4
|
django-view-decorator==0.0.4
|
||||||
# via hatch.envs.default
|
# via hatch.envs.default
|
||||||
django-zen-queries==2.1.0
|
django-zen-queries==2.1.0
|
||||||
|
@ -83,17 +88,23 @@ python-dotenv==1.0.1
|
||||||
pytz==2024.1
|
pytz==2024.1
|
||||||
# via django-oauth-toolkit
|
# via django-oauth-toolkit
|
||||||
requests==2.32.3
|
requests==2.32.3
|
||||||
# via django-oauth-toolkit
|
# via
|
||||||
|
# django-oauth-toolkit
|
||||||
|
# stripe
|
||||||
setuptools==72.1.0
|
setuptools==72.1.0
|
||||||
# via django-money
|
# via django-money
|
||||||
sqlparse==0.5.1
|
sqlparse==0.5.1
|
||||||
# via django
|
# via django
|
||||||
|
stripe==10.6.0
|
||||||
|
# via hatch.envs.default
|
||||||
typing-extensions==4.12.2
|
typing-extensions==4.12.2
|
||||||
# via
|
# via
|
||||||
# dj-database-url
|
# dj-database-url
|
||||||
|
# django-stubs-ext
|
||||||
# jwcrypto
|
# jwcrypto
|
||||||
# psycopg
|
# psycopg
|
||||||
# py-moneyed
|
# py-moneyed
|
||||||
|
# stripe
|
||||||
urllib3==2.2.2
|
urllib3==2.2.2
|
||||||
# via requests
|
# via requests
|
||||||
uvicorn==0.30.5
|
uvicorn==0.30.5
|
||||||
|
|
|
@ -15,11 +15,13 @@
|
||||||
# - django-money~=3.5
|
# - django-money~=3.5
|
||||||
# - django-oauth-toolkit~=2.4
|
# - django-oauth-toolkit~=2.4
|
||||||
# - django-registries==0.0.3
|
# - django-registries==0.0.3
|
||||||
|
# - django-stubs-ext~=5.0
|
||||||
# - django-view-decorator==0.0.4
|
# - django-view-decorator==0.0.4
|
||||||
# - django-zen-queries~=2.1
|
# - django-zen-queries~=2.1
|
||||||
# - django~=5.0
|
# - django<5.2,>=5.1b1
|
||||||
# - environs[django]<12,>=11
|
# - environs[django]<12,>=11
|
||||||
# - psycopg[binary]~=3.2
|
# - psycopg[binary]~=3.2
|
||||||
|
# - stripe~=10.5
|
||||||
# - uvicorn~=0.30
|
# - uvicorn~=0.30
|
||||||
# - whitenoise~=6.7
|
# - whitenoise~=6.7
|
||||||
#
|
#
|
||||||
|
@ -52,7 +54,7 @@ dj-database-url==2.2.0
|
||||||
# via environs
|
# via environs
|
||||||
dj-email-url==1.0.6
|
dj-email-url==1.0.6
|
||||||
# via environs
|
# via environs
|
||||||
django==5.0.7
|
django==5.1rc1
|
||||||
# via
|
# via
|
||||||
# hatch.envs.dev
|
# hatch.envs.dev
|
||||||
# dj-database-url
|
# dj-database-url
|
||||||
|
@ -84,7 +86,9 @@ django-registries==0.0.3
|
||||||
django-stubs==1.16.0
|
django-stubs==1.16.0
|
||||||
# via hatch.envs.dev
|
# via hatch.envs.dev
|
||||||
django-stubs-ext==5.0.4
|
django-stubs-ext==5.0.4
|
||||||
# via django-stubs
|
# via
|
||||||
|
# hatch.envs.dev
|
||||||
|
# django-stubs
|
||||||
django-view-decorator==0.0.4
|
django-view-decorator==0.0.4
|
||||||
# via hatch.envs.dev
|
# via hatch.envs.dev
|
||||||
django-zen-queries==2.1.0
|
django-zen-queries==2.1.0
|
||||||
|
@ -146,7 +150,9 @@ python-dotenv==1.0.1
|
||||||
pytz==2024.1
|
pytz==2024.1
|
||||||
# via django-oauth-toolkit
|
# via django-oauth-toolkit
|
||||||
requests==2.32.3
|
requests==2.32.3
|
||||||
# via django-oauth-toolkit
|
# via
|
||||||
|
# django-oauth-toolkit
|
||||||
|
# stripe
|
||||||
setuptools==72.1.0
|
setuptools==72.1.0
|
||||||
# via
|
# via
|
||||||
# django-money
|
# django-money
|
||||||
|
@ -155,6 +161,8 @@ sqlparse==0.5.1
|
||||||
# via
|
# via
|
||||||
# django
|
# django
|
||||||
# django-debug-toolbar
|
# django-debug-toolbar
|
||||||
|
stripe==10.6.0
|
||||||
|
# via hatch.envs.dev
|
||||||
tomli==2.0.1
|
tomli==2.0.1
|
||||||
# via django-stubs
|
# via django-stubs
|
||||||
types-pytz==2024.1.0.20240417
|
types-pytz==2024.1.0.20240417
|
||||||
|
@ -170,6 +178,7 @@ typing-extensions==4.12.2
|
||||||
# mypy
|
# mypy
|
||||||
# psycopg
|
# psycopg
|
||||||
# py-moneyed
|
# py-moneyed
|
||||||
|
# stripe
|
||||||
urllib3==2.2.2
|
urllib3==2.2.2
|
||||||
# via requests
|
# via requests
|
||||||
uvicorn==0.30.5
|
uvicorn==0.30.5
|
||||||
|
|
|
@ -1,36 +1,73 @@
|
||||||
"""Admin for the accounting app."""
|
"""Admin for the accounting app."""
|
||||||
|
|
||||||
|
from django import forms
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from .models import Order
|
from . import models
|
||||||
from .models import Payment
|
|
||||||
|
|
||||||
|
|
||||||
@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):
|
class OrderAdmin(admin.ModelAdmin):
|
||||||
"""Admin for the Order model."""
|
"""Admin for the Order model."""
|
||||||
|
|
||||||
list_display = ("who", "description", "created", "is_paid")
|
inlines = (OrderProductInline,)
|
||||||
|
form = OrderAdminForm
|
||||||
|
|
||||||
@admin.display(description=_("Customer"))
|
list_display = ("member", "description", "created", "is_paid")
|
||||||
def who(self, instance: Order) -> str:
|
|
||||||
"""Return the full name of the user who made the order."""
|
|
||||||
return instance.user.get_full_name()
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Payment)
|
@admin.register(models.Payment)
|
||||||
class PaymentAdmin(admin.ModelAdmin):
|
class PaymentAdmin(admin.ModelAdmin):
|
||||||
"""Admin for the Payment model."""
|
"""Admin for the Payment model."""
|
||||||
|
|
||||||
list_display = ("who", "description", "order_id", "created")
|
list_display = ("order__member", "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()
|
|
||||||
|
|
||||||
@admin.display(description=_("Order ID"))
|
@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 the ID of the order."""
|
||||||
return instance.order.id
|
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,)
|
||||||
|
|
|
@ -7,3 +7,7 @@ class AccountingConfig(AppConfig):
|
||||||
"""Accounting app config."""
|
"""Accounting app config."""
|
||||||
|
|
||||||
name = "accounting"
|
name = "accounting"
|
||||||
|
|
||||||
|
def ready(self) -> None:
|
||||||
|
"""Implicitly connect a signal handlers decorated with @receiver."""
|
||||||
|
from . import signals # noqa: F401
|
||||||
|
|
|
@ -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,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
|
@ -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'),
|
||||||
|
),
|
||||||
|
]
|
|
@ -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'),
|
||||||
|
),
|
||||||
|
]
|
|
@ -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'),
|
||||||
|
),
|
||||||
|
]
|
|
@ -29,10 +29,10 @@ class Account(CreatedModifiedAbstract):
|
||||||
can decide which account to use to pay for something.
|
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:
|
def __str__(self) -> str:
|
||||||
return f"Account of {self.owner.get_full_name()}"
|
return f"Account of {self.owner}"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def balance(self) -> Money:
|
def balance(self) -> Money:
|
||||||
|
@ -67,23 +67,15 @@ class Transaction(CreatedModifiedAbstract):
|
||||||
class Order(CreatedModifiedAbstract):
|
class Order(CreatedModifiedAbstract):
|
||||||
"""An order.
|
"""An order.
|
||||||
|
|
||||||
Scoped out: Contents of invoices will have to be tracked either here or in
|
We assemble the order from a number of products. Once an order is paid, the contents should be
|
||||||
a separate Invoice model. This is undecided because we are not generating
|
considered locked.
|
||||||
invoices at the moment.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
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)
|
account = models.ForeignKey(Account, on_delete=models.PROTECT)
|
||||||
|
|
||||||
description = models.CharField(max_length=1024, verbose_name=_("description"))
|
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"))
|
is_paid = models.BooleanField(default=False, verbose_name=_("is paid"))
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
@ -95,8 +87,18 @@ class Order(CreatedModifiedAbstract):
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def total(self) -> Money:
|
def total(self) -> Money:
|
||||||
"""Return the total price of the order."""
|
"""Return the total price of the order (excl VAT)."""
|
||||||
return self.price + self.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
|
@property
|
||||||
def display_id(self) -> str:
|
def display_id(self) -> str:
|
||||||
|
@ -114,6 +116,42 @@ class Order(CreatedModifiedAbstract):
|
||||||
return x.hexdigest()
|
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):
|
class Payment(CreatedModifiedAbstract):
|
||||||
"""A payment is a transaction that is made to pay for an order."""
|
"""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"))
|
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:
|
class Meta:
|
||||||
verbose_name = _("payment")
|
verbose_name = _("payment")
|
||||||
|
@ -137,11 +176,28 @@ class Payment(CreatedModifiedAbstract):
|
||||||
return str(self.id).zfill(6)
|
return str(self.id).zfill(6)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_order(cls, order: Order) -> Self:
|
def from_order(cls, order: Order, payment_type: "PaymentType") -> Self:
|
||||||
"""Create a payment from an order."""
|
"""Create a payment from an order."""
|
||||||
return cls.objects.create(
|
return cls.objects.create(
|
||||||
order=order,
|
order=order,
|
||||||
user=order.user,
|
user=order.user,
|
||||||
amount=order.total,
|
amount=order.total + order.total_vat,
|
||||||
description=order.description,
|
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}"
|
||||||
|
|
39
src/accounting/signals.py
Normal file
|
@ -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()
|
||||||
|
)
|
18
src/accounting/templates/accounting/order/cancel.html
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block head_title %}
|
||||||
|
{% trans "Payment cancelled" %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="content-view">
|
||||||
|
<h2>{% trans "Payment canceled" %}</h2>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<a href="{% order:detail order_id=order.id %}">{% trans "Return to order page" %}</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
49
src/accounting/templates/accounting/order/detail.html
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block head_title %}
|
||||||
|
{% trans "Order" %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="content-view">
|
||||||
|
<h2>Order: {{ order.id }}</h2>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
{% trans "Ordered" %}: {{ order.created }}<br>
|
||||||
|
{% trans "Status" %}: {{ order.is_paid|yesno:_("paid,unpaid") }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{% trans "Item" %}</th>
|
||||||
|
<th>{% trans "Quantity" %}</th>
|
||||||
|
<th>{% trans "Price" %}</th>
|
||||||
|
<th>{% trans "VAT" %}</th>
|
||||||
|
<th>{% trans "Total" %}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for item in order.items.all %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ item.product.name }}</td>
|
||||||
|
<td>{{ item.quantity }}</td>
|
||||||
|
<td>{{ item.price }}</td>
|
||||||
|
<td>{{ item.vat }}</td>
|
||||||
|
<td>{{ item.total_with_vat }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2>{% trans "Total price" %}: {{ order.total_with_vat }}</h2>
|
||||||
|
|
||||||
|
{% if not order.is_paid %}
|
||||||
|
<p>
|
||||||
|
<a href="{% url "order:pay" order_id=order.pk %}" class="button">{% trans "Pay now" %}</a>
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
20
src/accounting/templates/accounting/order/success.html
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block head_title %}
|
||||||
|
{% trans "Payment received" %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="content-view">
|
||||||
|
<h2>{% trans "Payment received" %}</h2>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
{% 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 %}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
177
src/accounting/views.py
Normal file
|
@ -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="<int:order_id>/",
|
||||||
|
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="<int:order_id>/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="<int:order_id>/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="<int:order_id>/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)
|
|
@ -1,12 +1,29 @@
|
||||||
"""Admin configuration for membership app."""
|
"""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 Membership
|
||||||
from .models import MembershipType
|
from .models import MembershipType
|
||||||
from .models import SubscriptionPeriod
|
from .models import SubscriptionPeriod
|
||||||
from .models import WaitingListEntry
|
from .models import WaitingListEntry
|
||||||
|
|
||||||
|
# Do not use existing user admin
|
||||||
|
admin.site.unregister(User)
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Membership)
|
@admin.register(Membership)
|
||||||
class MembershipAdmin(admin.ModelAdmin):
|
class MembershipAdmin(admin.ModelAdmin):
|
||||||
|
@ -23,6 +40,76 @@ class SubscriptionPeriodAdmin(admin.ModelAdmin):
|
||||||
"""Admin for SubscriptionPeriod model."""
|
"""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)
|
@admin.register(WaitingListEntry)
|
||||||
class WaitingListEntryAdmin(admin.ModelAdmin):
|
class WaitingListEntryAdmin(admin.ModelAdmin):
|
||||||
"""Admin for WaitingList model."""
|
"""Admin for WaitingList model."""
|
||||||
|
|
|
@ -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),
|
||||||
|
),
|
||||||
|
]
|
|
@ -10,6 +10,7 @@ from django.contrib.postgres.fields import RangeOperators
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
|
from djmoney.money import Money
|
||||||
from utils.mixins import CreatedModifiedAbstract
|
from utils.mixins import CreatedModifiedAbstract
|
||||||
|
|
||||||
|
|
||||||
|
@ -53,6 +54,22 @@ class SubscriptionPeriod(CreatedModifiedAbstract):
|
||||||
Denotes a period for which members should pay their membership fee for.
|
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"))
|
period = DateRangeField(verbose_name=_("period"))
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
@ -82,16 +99,17 @@ class Membership(CreatedModifiedAbstract):
|
||||||
"""Filter memberships for a given member."""
|
"""Filter memberships for a given member."""
|
||||||
return self.filter(user=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:
|
def _current(self) -> Self:
|
||||||
"""Filter memberships for the current period."""
|
"""Filter memberships for the current period."""
|
||||||
return self.filter(period__period__contains=timezone.now())
|
return self.filter(period__period__contains=timezone.now())
|
||||||
|
|
||||||
def current(self) -> "Membership | None":
|
def current(self) -> "Membership | None":
|
||||||
"""Get the current membership."""
|
"""Get the current membership."""
|
||||||
try:
|
return self._current().first()
|
||||||
return self._current().get()
|
|
||||||
except self.model.DoesNotExist:
|
|
||||||
return None
|
|
||||||
|
|
||||||
def previous(self) -> list["Membership"]:
|
def previous(self) -> list["Membership"]:
|
||||||
"""Get previous memberships."""
|
"""Get previous memberships."""
|
||||||
|
@ -102,7 +120,7 @@ class Membership(CreatedModifiedAbstract):
|
||||||
|
|
||||||
objects = QuerySet.as_manager()
|
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_type = models.ForeignKey(
|
||||||
"membership.MembershipType",
|
"membership.MembershipType",
|
||||||
|
@ -116,6 +134,31 @@ class Membership(CreatedModifiedAbstract):
|
||||||
on_delete=models.PROTECT,
|
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:
|
class Meta:
|
||||||
verbose_name = _("membership")
|
verbose_name = _("membership")
|
||||||
verbose_name_plural = _("memberships")
|
verbose_name_plural = _("memberships")
|
||||||
|
@ -133,6 +176,10 @@ class MembershipType(CreatedModifiedAbstract):
|
||||||
|
|
||||||
name = models.CharField(verbose_name=_("name"), max_length=64)
|
name = models.CharField(verbose_name=_("name"), max_length=64)
|
||||||
|
|
||||||
|
products = models.ManyToManyField("accounting.Product")
|
||||||
|
|
||||||
|
active = models.BooleanField(default=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = _("membership type")
|
verbose_name = _("membership type")
|
||||||
verbose_name_plural = _("membership types")
|
verbose_name_plural = _("membership types")
|
||||||
|
@ -140,6 +187,21 @@ class MembershipType(CreatedModifiedAbstract):
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
return self.name
|
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):
|
class WaitingListEntry(CreatedModifiedAbstract):
|
||||||
"""People who for some reason could want to be added to a waiting list and invited to join later."""
|
"""People who for some reason could want to be added to a waiting list and invited to join later."""
|
||||||
|
|
|
@ -2,9 +2,12 @@
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import django_stubs_ext
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from environs import Env
|
from environs import Env
|
||||||
|
|
||||||
|
django_stubs_ext.monkeypatch()
|
||||||
benjaoming marked this conversation as resolved
benjaoming
commented
This is where django-stubs is used. It's said to be "production safe" :) This is where django-stubs is used. It's said to be "production safe" :)
valberg
commented
Ah yes! I can vouch for it - we use it at $WORK without issues Ah yes! I can vouch for it - we use it at $WORK without issues
benjaoming
commented
Nice! I'm gonna vouch that we keep it for now and see if we'll start using mypy checks or not... now at least, it can be run. Nice! I'm gonna vouch that we keep it for now and see if we'll start using mypy checks or not... now at least, it can be run.
benjaoming
commented
https://git.data.coop/data.coop/membersystem/issues/37
|
|||||||
|
|
||||||
env = Env()
|
env = Env()
|
||||||
env.read_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:
|
if DEBUG:
|
||||||
INSTALLED_APPS += ["debug_toolbar", "django_browser_reload"]
|
INSTALLED_APPS += ["debug_toolbar", "django_browser_reload"]
|
||||||
|
|
It should be okay to use the new Django 5.1... then we can help out if we discover a bug :) it's rc1 now...