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
|
||||
# DATABASE_URL=postgres://postgres:postgres@localhost:5432/datacoop_membersystem
|
||||
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_RUN = ${DOCKER_COMPOSE} run -u `id -u`
|
||||
MANAGE_EXEC = python /app/src/manage.py
|
||||
|
@ -23,3 +24,6 @@ manage_command:
|
|||
|
||||
build:
|
||||
${DOCKER_COMPOSE} build
|
||||
|
||||
requirements:
|
||||
hatch run requirements
|
||||
|
|
14
README.md
14
README.md
|
@ -85,12 +85,16 @@ make makemigrations
|
|||
hatch run dev:server
|
||||
```
|
||||
|
||||
#### Updating requirements
|
||||
### Updating requirements
|
||||
|
||||
If you want to update the requirements, you can run the following command:
|
||||
We use hatch-pip-compile. That means we have a set of loosely defined `dependencies` in `pyproject.toml` and then we can keep the exactly pinned version in our `requirements.txt` (auto-generated).
|
||||
|
||||
To generate `requirements.txt` and `requirements/requirements-dev.txt`, run the following command:
|
||||
|
||||
```bash
|
||||
hatch run requirements
|
||||
```
|
||||
# Build requirements.txt etc
|
||||
make requirements
|
||||
|
||||
This uses [hatch-pip-compile](https://juftin.com/hatch-pip-compile/) to update the requirements.
|
||||
# Build Docker image with new Python requirements
|
||||
make build
|
||||
```
|
||||
|
|
|
@ -12,7 +12,7 @@ authors = [
|
|||
{ name = "Víðir Valberg Guðmundsson", email = "valberg@orn.li" },
|
||||
]
|
||||
dependencies = [
|
||||
"Django~=5.0",
|
||||
"Django>=5.1b1,<5.2",
|
||||
"django-money~=3.5",
|
||||
"django-allauth~=0.63",
|
||||
"psycopg[binary]~=3.2",
|
||||
|
@ -23,6 +23,8 @@ dependencies = [
|
|||
"django-registries==0.0.3",
|
||||
"django-view-decorator==0.0.4",
|
||||
"django-oauth-toolkit~=2.4",
|
||||
"django_stubs_ext~=5.0",
|
||||
"stripe~=10.5",
|
||||
]
|
||||
version = "0.0.1"
|
||||
|
||||
|
@ -54,7 +56,7 @@ dependencies = [
|
|||
|
||||
[[tool.hatch.envs.tests.matrix]]
|
||||
python = ["3.12"]
|
||||
django = ["5.0"]
|
||||
django = ["5.1b1"]
|
||||
|
||||
[tool.hatch.envs.tests.overrides]
|
||||
matrix.django.dependencies = [
|
||||
|
@ -74,6 +76,8 @@ migrate = "./src/manage.py migrate"
|
|||
makemigrations = "./src/manage.py makemigrations"
|
||||
createsuperuser = "./src/manage.py createsuperuser"
|
||||
shell = "./src/manage.py shell"
|
||||
# You need to install Stripe CLI from here to run this: https://github.com/stripe/stripe-cli/releases
|
||||
stripe_cli = "stripe listen --forward-to 0.0.0.0:8000/order/stripe/webhook/"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
DJANGO_SETTINGS_MODULE="tests.settings"
|
||||
|
@ -106,10 +110,10 @@ show_error_codes = true
|
|||
strict = true
|
||||
warn_unreachable = true
|
||||
follow_imports = "normal"
|
||||
#plugins = ["mypy_django_plugin.main"]
|
||||
plugins = ["mypy_django_plugin.main"]
|
||||
|
||||
[tool.django-stubs]
|
||||
#django_settings_module = "tests.settings"
|
||||
django_settings_module = "project.settings"
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module = "tests.*"
|
||||
|
|
|
@ -5,11 +5,13 @@
|
|||
# - django-money~=3.5
|
||||
# - django-oauth-toolkit~=2.4
|
||||
# - django-registries==0.0.3
|
||||
# - django-stubs-ext~=5.0
|
||||
# - django-view-decorator==0.0.4
|
||||
# - django-zen-queries~=2.1
|
||||
# - django~=5.0
|
||||
# - django<5.2,>=5.1b1
|
||||
# - environs[django]<12,>=11
|
||||
# - psycopg[binary]~=3.2
|
||||
# - stripe~=10.5
|
||||
# - uvicorn~=0.30
|
||||
# - whitenoise~=6.7
|
||||
#
|
||||
|
@ -32,7 +34,7 @@ dj-database-url==2.2.0
|
|||
# via environs
|
||||
dj-email-url==1.0.6
|
||||
# via environs
|
||||
django==5.0.7
|
||||
django==5.1rc1
|
||||
# via
|
||||
# hatch.envs.default
|
||||
# dj-database-url
|
||||
|
@ -40,6 +42,7 @@ django==5.0.7
|
|||
# django-money
|
||||
# django-oauth-toolkit
|
||||
# django-registries
|
||||
# django-stubs-ext
|
||||
# django-view-decorator
|
||||
# django-zen-queries
|
||||
django-allauth==0.63.6
|
||||
|
@ -52,6 +55,8 @@ django-oauth-toolkit==2.4.0
|
|||
# via hatch.envs.default
|
||||
django-registries==0.0.3
|
||||
# via hatch.envs.default
|
||||
django-stubs-ext==5.0.4
|
||||
# via hatch.envs.default
|
||||
django-view-decorator==0.0.4
|
||||
# via hatch.envs.default
|
||||
django-zen-queries==2.1.0
|
||||
|
@ -83,17 +88,23 @@ python-dotenv==1.0.1
|
|||
pytz==2024.1
|
||||
# via django-oauth-toolkit
|
||||
requests==2.32.3
|
||||
# via django-oauth-toolkit
|
||||
# via
|
||||
# django-oauth-toolkit
|
||||
# stripe
|
||||
setuptools==72.1.0
|
||||
# via django-money
|
||||
sqlparse==0.5.1
|
||||
# via django
|
||||
stripe==10.6.0
|
||||
# via hatch.envs.default
|
||||
typing-extensions==4.12.2
|
||||
# via
|
||||
# dj-database-url
|
||||
# django-stubs-ext
|
||||
# jwcrypto
|
||||
# psycopg
|
||||
# py-moneyed
|
||||
# stripe
|
||||
urllib3==2.2.2
|
||||
# via requests
|
||||
uvicorn==0.30.5
|
||||
|
|
|
@ -15,11 +15,13 @@
|
|||
# - django-money~=3.5
|
||||
# - django-oauth-toolkit~=2.4
|
||||
# - django-registries==0.0.3
|
||||
# - django-stubs-ext~=5.0
|
||||
# - django-view-decorator==0.0.4
|
||||
# - django-zen-queries~=2.1
|
||||
# - django~=5.0
|
||||
# - django<5.2,>=5.1b1
|
||||
# - environs[django]<12,>=11
|
||||
# - psycopg[binary]~=3.2
|
||||
# - stripe~=10.5
|
||||
# - uvicorn~=0.30
|
||||
# - whitenoise~=6.7
|
||||
#
|
||||
|
@ -52,7 +54,7 @@ dj-database-url==2.2.0
|
|||
# via environs
|
||||
dj-email-url==1.0.6
|
||||
# via environs
|
||||
django==5.0.7
|
||||
django==5.1rc1
|
||||
# via
|
||||
# hatch.envs.dev
|
||||
# dj-database-url
|
||||
|
@ -84,7 +86,9 @@ django-registries==0.0.3
|
|||
django-stubs==1.16.0
|
||||
# via hatch.envs.dev
|
||||
django-stubs-ext==5.0.4
|
||||
# via django-stubs
|
||||
# via
|
||||
# hatch.envs.dev
|
||||
# django-stubs
|
||||
django-view-decorator==0.0.4
|
||||
# via hatch.envs.dev
|
||||
django-zen-queries==2.1.0
|
||||
|
@ -146,7 +150,9 @@ python-dotenv==1.0.1
|
|||
pytz==2024.1
|
||||
# via django-oauth-toolkit
|
||||
requests==2.32.3
|
||||
# via django-oauth-toolkit
|
||||
# via
|
||||
# django-oauth-toolkit
|
||||
# stripe
|
||||
setuptools==72.1.0
|
||||
# via
|
||||
# django-money
|
||||
|
@ -155,6 +161,8 @@ sqlparse==0.5.1
|
|||
# via
|
||||
# django
|
||||
# django-debug-toolbar
|
||||
stripe==10.6.0
|
||||
# via hatch.envs.dev
|
||||
tomli==2.0.1
|
||||
# via django-stubs
|
||||
types-pytz==2024.1.0.20240417
|
||||
|
@ -170,6 +178,7 @@ typing-extensions==4.12.2
|
|||
# mypy
|
||||
# psycopg
|
||||
# py-moneyed
|
||||
# stripe
|
||||
urllib3==2.2.2
|
||||
# via requests
|
||||
uvicorn==0.30.5
|
||||
|
|
|
@ -1,36 +1,73 @@
|
|||
"""Admin for the accounting app."""
|
||||
|
||||
from django import forms
|
||||
from django.contrib import admin
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from .models import Order
|
||||
from .models import Payment
|
||||
from . import models
|
||||
|
||||
|
||||
@admin.register(Order)
|
||||
class OrderProductInline(admin.TabularInline):
|
||||
"""Administer contents of an order inline."""
|
||||
|
||||
model = models.OrderProduct
|
||||
|
||||
|
||||
class OrderAdminForm(forms.ModelForm):
|
||||
"""Special Form for the OrderAdmin so we don't need to require the account field."""
|
||||
|
||||
account = forms.ModelChoiceField(
|
||||
required=False,
|
||||
queryset=models.Account.objects.all(),
|
||||
help_text=_("Leave empty to auto-choose the member's own account or to create one."),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = models.Order
|
||||
exclude = () # noqa: DJ006
|
||||
|
||||
def clean(self): # noqa: D102, ANN201
|
||||
cd = super().clean()
|
||||
if not cd["account"] and cd["member"]:
|
||||
try:
|
||||
cd["account"] = models.Account.objects.get_or_create(owner=cd["member"])[0]
|
||||
except models.Account.MultipleObjectsReturned:
|
||||
cd["account"] = models.Account.objects.filter(owner=cd["member"]).first()
|
||||
return cd
|
||||
|
||||
|
||||
@admin.register(models.Order)
|
||||
class OrderAdmin(admin.ModelAdmin):
|
||||
"""Admin for the Order model."""
|
||||
|
||||
list_display = ("who", "description", "created", "is_paid")
|
||||
inlines = (OrderProductInline,)
|
||||
form = OrderAdminForm
|
||||
|
||||
@admin.display(description=_("Customer"))
|
||||
def who(self, instance: Order) -> str:
|
||||
"""Return the full name of the user who made the order."""
|
||||
return instance.user.get_full_name()
|
||||
list_display = ("member", "description", "created", "is_paid")
|
||||
|
||||
|
||||
@admin.register(Payment)
|
||||
@admin.register(models.Payment)
|
||||
class PaymentAdmin(admin.ModelAdmin):
|
||||
"""Admin for the Payment model."""
|
||||
|
||||
list_display = ("who", "description", "order_id", "created")
|
||||
|
||||
@admin.display(description=_("Customer"))
|
||||
def who(self, instance: Payment) -> str:
|
||||
"""Return the full name of the user who made the payment."""
|
||||
return instance.order.user.get_full_name()
|
||||
list_display = ("order__member", "description", "order_id", "created")
|
||||
|
||||
@admin.display(description=_("Order ID"))
|
||||
def order_id(self, instance: Payment) -> int:
|
||||
def order_id(self, instance: models.Payment) -> int:
|
||||
"""Return the ID of the order."""
|
||||
return instance.order.id
|
||||
|
||||
|
||||
@admin.register(models.Product)
|
||||
class ProductAdmin(admin.ModelAdmin): # noqa: D101
|
||||
list_display = ("name", "price", "vat")
|
||||
|
||||
|
||||
class TransactionInline(admin.TabularInline): # noqa: D101
|
||||
model = models.Transaction
|
||||
|
||||
|
||||
@admin.register(models.Account)
|
||||
class AccountAdmin(admin.ModelAdmin): # noqa: D101
|
||||
list_display = ("owner", "balance")
|
||||
inlines = (TransactionInline,)
|
||||
|
|
|
@ -7,3 +7,7 @@ class AccountingConfig(AppConfig):
|
|||
"""Accounting app config."""
|
||||
|
||||
name = "accounting"
|
||||
|
||||
def ready(self) -> None:
|
||||
"""Implicitly connect a signal handlers decorated with @receiver."""
|
||||
from . import signals # noqa: F401
|
||||
|
|
|
@ -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.
|
||||
"""
|
||||
|
||||
owner = models.ForeignKey("auth.User", on_delete=models.PROTECT)
|
||||
owner = models.ForeignKey("membership.Member", on_delete=models.PROTECT)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"Account of {self.owner.get_full_name()}"
|
||||
return f"Account of {self.owner}"
|
||||
|
||||
@property
|
||||
def balance(self) -> Money:
|
||||
|
@ -67,23 +67,15 @@ class Transaction(CreatedModifiedAbstract):
|
|||
class Order(CreatedModifiedAbstract):
|
||||
"""An order.
|
||||
|
||||
Scoped out: Contents of invoices will have to be tracked either here or in
|
||||
a separate Invoice model. This is undecided because we are not generating
|
||||
invoices at the moment.
|
||||
We assemble the order from a number of products. Once an order is paid, the contents should be
|
||||
considered locked.
|
||||
"""
|
||||
|
||||
user = models.ForeignKey("auth.User", on_delete=models.PROTECT)
|
||||
member = models.ForeignKey("membership.Member", on_delete=models.PROTECT)
|
||||
account = models.ForeignKey(Account, on_delete=models.PROTECT)
|
||||
|
||||
description = models.CharField(max_length=1024, verbose_name=_("description"))
|
||||
|
||||
price = MoneyField(
|
||||
verbose_name=_("price (excl. VAT)"),
|
||||
max_digits=16,
|
||||
decimal_places=2,
|
||||
)
|
||||
vat = MoneyField(verbose_name=_("VAT"), max_digits=16, decimal_places=2)
|
||||
|
||||
is_paid = models.BooleanField(default=False, verbose_name=_("is paid"))
|
||||
|
||||
class Meta:
|
||||
|
@ -95,8 +87,18 @@ class Order(CreatedModifiedAbstract):
|
|||
|
||||
@property
|
||||
def total(self) -> Money:
|
||||
"""Return the total price of the order."""
|
||||
return self.price + self.vat
|
||||
"""Return the total price of the order (excl VAT)."""
|
||||
return sum(item.price * item.quantity for item in self.items.all())
|
||||
|
||||
@property
|
||||
def total_vat(self) -> Money:
|
||||
"""Return the total VAT of the order."""
|
||||
return sum(item.vat * item.quantity for item in self.items.all())
|
||||
|
||||
@property
|
||||
def total_with_vat(self) -> Money:
|
||||
"""Return the TOTAL amount WITH VAT."""
|
||||
return self.total + self.total_vat
|
||||
|
||||
@property
|
||||
def display_id(self) -> str:
|
||||
|
@ -114,6 +116,42 @@ class Order(CreatedModifiedAbstract):
|
|||
return x.hexdigest()
|
||||
|
||||
|
||||
class Product(CreatedModifiedAbstract):
|
||||
"""A generic product, for instance a membership or a service fee."""
|
||||
|
||||
name = models.CharField(max_length=512)
|
||||
price = MoneyField(max_digits=16, decimal_places=2)
|
||||
vat = MoneyField(max_digits=16, decimal_places=2)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name
|
||||
|
||||
|
||||
class OrderProduct(CreatedModifiedAbstract):
|
||||
"""When a product is ordered, we store the product on the order.
|
||||
|
||||
This includes pricing information.
|
||||
"""
|
||||
|
||||
order = models.ForeignKey(Order, on_delete=models.CASCADE, related_name="items")
|
||||
product = models.ForeignKey(Product, on_delete=models.PROTECT)
|
||||
price = MoneyField(max_digits=16, decimal_places=2)
|
||||
vat = MoneyField(max_digits=16, decimal_places=2)
|
||||
quantity = models.PositiveSmallIntegerField(default=1)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("ordered product")
|
||||
verbose_name_plural = _("ordered products")
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.product.name}"
|
||||
|
||||
@property
|
||||
def total_with_vat(self) -> Money:
|
||||
"""Total price of this item."""
|
||||
return (self.price + self.vat) * self.quantity
|
||||
|
||||
|
||||
class Payment(CreatedModifiedAbstract):
|
||||
"""A payment is a transaction that is made to pay for an order."""
|
||||
|
||||
|
@ -122,7 +160,8 @@ class Payment(CreatedModifiedAbstract):
|
|||
|
||||
description = models.CharField(max_length=1024, verbose_name=_("description"))
|
||||
|
||||
stripe_charge_id = models.CharField(max_length=255, blank=True)
|
||||
payment_type = models.ForeignKey("PaymentType", on_delete=models.PROTECT)
|
||||
external_transaction_id = models.CharField(max_length=255, default="", blank=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("payment")
|
||||
|
@ -137,11 +176,28 @@ class Payment(CreatedModifiedAbstract):
|
|||
return str(self.id).zfill(6)
|
||||
|
||||
@classmethod
|
||||
def from_order(cls, order: Order) -> Self:
|
||||
def from_order(cls, order: Order, payment_type: "PaymentType") -> Self:
|
||||
"""Create a payment from an order."""
|
||||
return cls.objects.create(
|
||||
order=order,
|
||||
user=order.user,
|
||||
amount=order.total,
|
||||
amount=order.total + order.total_vat,
|
||||
description=order.description,
|
||||
payment_type=payment_type,
|
||||
)
|
||||
|
||||
|
||||
class PaymentType(CreatedModifiedAbstract):
|
||||
"""Types of payments available in the system.
|
||||
|
||||
- bank transfer
|
||||
- card payment (specific provider)
|
||||
"""
|
||||
|
||||
name = models.CharField(max_length=1024, verbose_name=_("description"))
|
||||
description = models.TextField(max_length=2048, blank=True)
|
||||
|
||||
enabled = models.BooleanField(default=True)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.name}"
|
||||
|
|
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."""
|
||||
|
||||
from django.contrib import admin
|
||||
from collections.abc import Callable
|
||||
|
||||
from accounting.models import Account
|
||||
from accounting.models import Order
|
||||
from accounting.models import OrderProduct
|
||||
from django.contrib import admin
|
||||
from django.contrib import messages
|
||||
from django.contrib.admin import ModelAdmin
|
||||
from django.contrib.auth.admin import UserAdmin
|
||||
from django.contrib.auth.models import User
|
||||
from django.db import transaction
|
||||
from django.db.models import QuerySet
|
||||
from django.http import HttpRequest
|
||||
from django.http import HttpResponse
|
||||
|
||||
from .models import Member
|
||||
from .models import Membership
|
||||
from .models import MembershipType
|
||||
from .models import SubscriptionPeriod
|
||||
from .models import WaitingListEntry
|
||||
|
||||
# Do not use existing user admin
|
||||
admin.site.unregister(User)
|
||||
|
||||
|
||||
@admin.register(Membership)
|
||||
class MembershipAdmin(admin.ModelAdmin):
|
||||
|
@ -23,6 +40,76 @@ class SubscriptionPeriodAdmin(admin.ModelAdmin):
|
|||
"""Admin for SubscriptionPeriod model."""
|
||||
|
||||
|
||||
class MembershipInlineAdmin(admin.TabularInline):
|
||||
"""Inline admin."""
|
||||
|
||||
model = Membership
|
||||
|
||||
|
||||
def decorate_ensure_membership_type_exists(membership_type: MembershipType, label: str) -> Callable:
|
||||
"""Generate an admin action for given membership type and label."""
|
||||
|
||||
@admin.action(description=label)
|
||||
def admin_action(modeladmin: ModelAdmin, request: HttpRequest, queryset: QuerySet) -> HttpResponse: # noqa: ARG001
|
||||
return ensure_membership_type_exists(request, queryset, membership_type)
|
||||
|
||||
return admin_action
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def ensure_membership_type_exists(
|
||||
request: HttpRequest,
|
||||
queryset: QuerySet,
|
||||
membership_type: MembershipType,
|
||||
) -> HttpResponse:
|
||||
"""Inner function that ensures that a membership exists for a given queryset of Member objects."""
|
||||
for member in queryset:
|
||||
if member.memberships.filter(membership_type=membership_type).current():
|
||||
messages.info(request, f"{member} already has a membership {membership_type}")
|
||||
else:
|
||||
# Get the default account of the member. We don't really know what to do if a person owns multiple accounts.
|
||||
account, __ = Account.objects.get_or_create(owner=member)
|
||||
# Create an Order for the products in the membership
|
||||
order = Order.objects.create(member=member, account=account, description=membership_type.name)
|
||||
# Add stuff to the order
|
||||
for product in membership_type.products.all():
|
||||
OrderProduct.objects.create(order=order, product=product, price=product.price, vat=product.vat)
|
||||
# Create the Membership
|
||||
Membership.objects.create(
|
||||
membership_type=membership_type,
|
||||
user=member,
|
||||
period=SubscriptionPeriod.objects.current(),
|
||||
order=order,
|
||||
)
|
||||
|
||||
# Associate the order with that membership
|
||||
messages.success(request, f"{member} has ordered a '{membership_type}' (unpaid)")
|
||||
|
||||
|
||||
@admin.register(Member)
|
||||
class MemberAdmin(UserAdmin):
|
||||
"""Member admin is actually an admin for User objects."""
|
||||
|
||||
inlines = (MembershipInlineAdmin,)
|
||||
actions: list[Callable] = [] # noqa: RUF012
|
||||
|
||||
def get_actions(self, request: HttpRequest) -> dict:
|
||||
"""Populate actions with dynamic data (MembershipType)."""
|
||||
current_period = SubscriptionPeriod.objects.current()
|
||||
|
||||
super_dict = super().get_actions(request)
|
||||
|
||||
if current_period:
|
||||
for i, mtype in enumerate(MembershipType.objects.filter(active=True)):
|
||||
action_label = f"Ensure membership {mtype.name}, {current_period.period}, {mtype.total_including_vat}"
|
||||
action_func = decorate_ensure_membership_type_exists(mtype, action_label)
|
||||
# Django ModelAdmin uses the non-unique __name__ property, so we need to suffix it to make it unique
|
||||
action_func.__name__ += f"_{i}"
|
||||
self.actions.append(action_func)
|
||||
|
||||
return super_dict
|
||||
|
||||
|
||||
@admin.register(WaitingListEntry)
|
||||
class WaitingListEntryAdmin(admin.ModelAdmin):
|
||||
"""Admin for WaitingList model."""
|
||||
|
|
|
@ -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.utils import timezone
|
||||
from django.utils.translation import gettext as _
|
||||
from djmoney.money import Money
|
||||
from utils.mixins import CreatedModifiedAbstract
|
||||
|
||||
|
||||
|
@ -53,6 +54,22 @@ class SubscriptionPeriod(CreatedModifiedAbstract):
|
|||
Denotes a period for which members should pay their membership fee for.
|
||||
"""
|
||||
|
||||
class QuerySet(models.QuerySet):
|
||||
"""QuerySet for the Membership model."""
|
||||
|
||||
def _current(self) -> Self:
|
||||
"""Filter memberships for the current period."""
|
||||
return self.filter(period__contains=timezone.now())
|
||||
|
||||
def current(self) -> "Membership | None":
|
||||
"""Get the current membership."""
|
||||
try:
|
||||
return self._current().get()
|
||||
except self.model.DoesNotExist:
|
||||
return None
|
||||
|
||||
objects = QuerySet.as_manager()
|
||||
|
||||
period = DateRangeField(verbose_name=_("period"))
|
||||
|
||||
class Meta:
|
||||
|
@ -82,16 +99,17 @@ class Membership(CreatedModifiedAbstract):
|
|||
"""Filter memberships for a given member."""
|
||||
return self.filter(user=member)
|
||||
|
||||
def active(self) -> Self:
|
||||
"""Get only activated, non-revoked memberships (may have expired so use also current())."""
|
||||
return self.filter(activated=True, revoked=False)
|
||||
|
||||
def _current(self) -> Self:
|
||||
"""Filter memberships for the current period."""
|
||||
return self.filter(period__period__contains=timezone.now())
|
||||
|
||||
def current(self) -> "Membership | None":
|
||||
"""Get the current membership."""
|
||||
try:
|
||||
return self._current().get()
|
||||
except self.model.DoesNotExist:
|
||||
return None
|
||||
return self._current().first()
|
||||
|
||||
def previous(self) -> list["Membership"]:
|
||||
"""Get previous memberships."""
|
||||
|
@ -102,7 +120,7 @@ class Membership(CreatedModifiedAbstract):
|
|||
|
||||
objects = QuerySet.as_manager()
|
||||
|
||||
user = models.ForeignKey("auth.User", on_delete=models.PROTECT)
|
||||
user = models.ForeignKey("auth.User", on_delete=models.PROTECT, related_name="memberships")
|
||||
|
||||
membership_type = models.ForeignKey(
|
||||
"membership.MembershipType",
|
||||
|
@ -116,6 +134,31 @@ class Membership(CreatedModifiedAbstract):
|
|||
on_delete=models.PROTECT,
|
||||
)
|
||||
|
||||
order = models.ForeignKey(
|
||||
"accounting.Order",
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name=_("order"),
|
||||
help_text=_("The order filled in for paying this membership."),
|
||||
on_delete=models.PROTECT,
|
||||
)
|
||||
|
||||
activated = models.BooleanField(
|
||||
default=False, verbose_name=_("activated"), help_text=_("Membership was activated.")
|
||||
)
|
||||
activated_on = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
revoked = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name=_("revoked"),
|
||||
help_text=_(
|
||||
"Membership has explicitly been revoked. Revoking a membership is not associated with regular expiration "
|
||||
"of the membership period."
|
||||
),
|
||||
)
|
||||
revoked_reason = models.TextField(blank=True)
|
||||
revoked_on = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("membership")
|
||||
verbose_name_plural = _("memberships")
|
||||
|
@ -133,6 +176,10 @@ class MembershipType(CreatedModifiedAbstract):
|
|||
|
||||
name = models.CharField(verbose_name=_("name"), max_length=64)
|
||||
|
||||
products = models.ManyToManyField("accounting.Product")
|
||||
|
||||
active = models.BooleanField(default=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("membership type")
|
||||
verbose_name_plural = _("membership types")
|
||||
|
@ -140,6 +187,21 @@ class MembershipType(CreatedModifiedAbstract):
|
|||
def __str__(self) -> str:
|
||||
return self.name
|
||||
|
||||
def create_membership(self, user: User) -> Membership:
|
||||
"""Create a current membership for this type."""
|
||||
from .selectors import get_current_subscription_period
|
||||
|
||||
return Membership.objects.create(
|
||||
membership_type=self,
|
||||
user=user,
|
||||
period=get_current_subscription_period(),
|
||||
)
|
||||
|
||||
@property
|
||||
def total_including_vat(self) -> Money:
|
||||
"""Calculate the total price of this membership (including VAT)."""
|
||||
return sum(product.price + product.vat for product in self.products.all())
|
||||
|
||||
|
||||
class WaitingListEntry(CreatedModifiedAbstract):
|
||||
"""People who for some reason could want to be added to a waiting list and invited to join later."""
|
||||
|
|
|
@ -2,9 +2,12 @@
|
|||
|
||||
from pathlib import Path
|
||||
|
||||
import django_stubs_ext
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from environs import Env
|
||||
|
||||
django_stubs_ext.monkeypatch()
|
||||
|
||||
env = Env()
|
||||
env.read_env()
|
||||
|
||||
|
@ -175,6 +178,8 @@ LOGGING = {
|
|||
},
|
||||
}
|
||||
|
||||
STRIPE_API_KEY = env.str("STRIPE_API_KEY")
|
||||
STRIPE_ENDPOINT_SECRET = env.str("STRIPE_ENDPOINT_SECRET")
|
||||
|
||||
if DEBUG:
|
||||
INSTALLED_APPS += ["debug_toolbar", "django_browser_reload"]
|
||||
|
|
Loading…
Reference in a new issue