forked from data.coop/membersystem
Changes to payment models (#32)
Flagging incoming changes, no actions required. This is stuff I consider "MVP", as in what we need urgently to send out payment links to members and receive payments via Stripe. - [x] Allow products, orders etc - [x] Define several products per membership type - [x] Possibility to create a membership BEFORE it's paid - [x] Mark memberships active when payments are received - [x] Create membership history for each member (via Django admin) - [x] Efficiently mark members in a list and choose "create <membership type> for current year with an unpaid order" (Django Admin actions) - [x] Order payment page w/ Stripe integration - [ ] Send email with order payment link - [ ] Send payment confirmation emails - [x] Re-generate migrations Co-authored-by: valberg <valberg@orn.li> Reviewed-on: data.coop/membersystem#32 Reviewed-by: valberg <valberg@orn.li> Co-authored-by: Benjamin Bach <benjamin@overtag.dk> Co-committed-by: Benjamin Bach <benjamin@overtag.dk>
This commit is contained in:
parent
59620aa309
commit
4254baf09d
|
@ -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
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
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",
|
||||||
"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",
|
||||||
|
"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
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
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
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
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
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()
|
||||||
|
|
||||||
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"]
|
||||||
|
|
Loading…
Reference in a new issue