feat(shop): cart, order, invoice and discounts

Build out the shop domain: Cart/CartItem (one open cart per user per club),
Order/OrderLine, Discount/AppliedDiscount, Payment and Invoice.

Order and Invoice allocate a per-club, per-year sequential number
(ORD-<year>-<seq> / INV-<year>-<seq>) via shared helpers, retrying on collision
with the (club, number) unique constraint as the source of truth.

Fixes found while testing:
- Invoice had no number generator, so a second invoice in a club collided on
  the empty string and could never be created.
- AppliedDiscount printed a "%" suffix even for fixed-amount discounts, and had
  no (order, discount) uniqueness, so a discount could be applied twice.

Every model validates its club-scoped FKs (product/team/discount/order/season/
staff_role) against the owning club, and Member FKs against club membership.
Register all models in the admin, with FK dropdowns scoped to the owning club.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-13 14:42:29 +02:00
parent d43ca0cfa8
commit 78fdcdf138
10 changed files with 1176 additions and 7 deletions

View File

@@ -0,0 +1,59 @@
# Generated by Django 6.0.6 on 2026-07-12 19:59
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('club', '0008_alter_clubmembership_unique_together_and_more'),
('shop', '0002_alter_product_slug_and_more'),
]
operations = [
migrations.AlterModelOptions(
name='product',
options={'ordering': ['name'], 'verbose_name': 'product', 'verbose_name_plural': 'products'},
),
migrations.AddField(
model_name='product',
name='early_bird_discount_amount',
field=models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='early bird discount amount'),
),
migrations.AddField(
model_name='product',
name='early_bird_discount_deadline',
field=models.DateField(blank=True, null=True, verbose_name='early bird discount deadline'),
),
migrations.AddField(
model_name='product',
name='early_bird_discount_enabled',
field=models.BooleanField(default=False, verbose_name='early bird discount enabled?'),
),
migrations.AddField(
model_name='product',
name='early_bird_discount_type',
field=models.CharField(choices=[('percentage', 'Percentage'), ('fixed_amount', 'Fixed amount')], default='percentage', max_length=255, verbose_name='early bird discount type'),
),
migrations.AddField(
model_name='product',
name='is_active',
field=models.BooleanField(default=True, verbose_name='is active?'),
),
migrations.AddField(
model_name='product',
name='is_public',
field=models.BooleanField(default=True, help_text='Non-public products are only visible to staff members for adding on to an order later on.', verbose_name='is public?'),
),
migrations.AddField(
model_name='product',
name='product_type',
field=models.CharField(choices=[('membership', 'Membership'), ('event_fee', 'Event fee'), ('merchandise', 'Merchandise'), ('donation', 'Donation')], default='membership', max_length=255, verbose_name='product type'),
),
migrations.AddField(
model_name='product',
name='season',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='products', to='club.season', verbose_name='season'),
),
]

View File

@@ -0,0 +1,32 @@
# Generated by Django 6.0.6 on 2026-07-12 20:02
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('club', '0008_alter_clubmembership_unique_together_and_more'),
('shop', '0003_alter_product_options_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Cart',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('status', models.CharField(choices=[('open', 'Open'), ('checked_out', 'Checked out'), ('abandoned', 'Abandoned')], default='open', max_length=255, verbose_name='status')),
('club', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='club.club')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='carts', to=settings.AUTH_USER_MODEL, verbose_name='user')),
],
options={
'verbose_name': 'cart',
'verbose_name_plural': 'carts',
'constraints': [models.UniqueConstraint(condition=models.Q(('status', 'open')), fields=('club', 'user'), name='unique_open_cart_per_user_per_club')],
},
),
]

View File

@@ -0,0 +1,59 @@
# Generated by Django 6.0.6 on 2026-07-12 20:12
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('club', '0008_alter_clubmembership_unique_together_and_more'),
('members', '0002_alter_familymembership_unique_together_and_more'),
('shop', '0004_cart'),
('teams', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='product',
name='staff_role',
field=models.ForeignKey(blank=True, limit_choices_to={'staff_position': True}, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='staff_products', to='teams.position', verbose_name='staff role'),
),
migrations.CreateModel(
name='CartItem',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('quantity', models.PositiveSmallIntegerField(default=1, verbose_name='quantity')),
('unit_price', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='unit price')),
('beneficiary', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='cart_items', to='members.member', verbose_name='beneficiary')),
('cart', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='shop.cart', verbose_name='cart')),
('product', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='cart_items', to='shop.product', verbose_name='product')),
('team', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='cart_items', to='teams.team', verbose_name='team')),
],
options={
'verbose_name': 'cart item',
'verbose_name_plural': 'cart items',
'constraints': [models.UniqueConstraint(fields=('cart', 'product', 'beneficiary'), name='unique_product_per_cart_per_beneficiary')],
},
),
migrations.CreateModel(
name='Order',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('number', models.CharField(max_length=255, verbose_name='number')),
('status', models.CharField(choices=[('pending', 'Pending'), ('paid', 'Paid'), ('partially_paid', 'Partially paid'), ('cancelled', 'Cancelled'), ('refunded', 'Refunded'), ('delivered', 'Delivered')], default='pending', max_length=255, verbose_name='status')),
('total', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='total')),
('created', models.DateTimeField(auto_now_add=True, verbose_name='created at')),
('modified', models.DateTimeField(auto_now=True, verbose_name='modified')),
('club', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='club.club')),
('purchaser', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='orders', to='members.member', verbose_name='purchaser')),
],
options={
'verbose_name': 'order',
'verbose_name_plural': 'orders',
'ordering': ['-created'],
'constraints': [models.UniqueConstraint(fields=('club', 'number'), name='unique_order_number_per_club')],
},
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 6.0.6 on 2026-07-12 20:14
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('shop', '0005_product_staff_role_cartitem_order'),
]
operations = [
migrations.AlterField(
model_name='order',
name='number',
field=models.CharField(blank=True, max_length=255, verbose_name='number'),
),
]

View File

@@ -0,0 +1,113 @@
# Generated by Django 6.0.6 on 2026-07-12 21:25
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('club', '0008_alter_clubmembership_unique_together_and_more'),
('members', '0002_alter_familymembership_unique_together_and_more'),
('shop', '0006_alter_order_number'),
('teams', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Discount',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('name', models.CharField(max_length=255, verbose_name='name')),
('slug', models.SlugField(blank=True, max_length=255, verbose_name='slug')),
('description', models.TextField(blank=True, verbose_name='description')),
('discount_type', models.CharField(choices=[('percentage', 'Percentage'), ('fixed_amount', 'Fixed amount')], default='percentage', max_length=255, verbose_name='discount type')),
('discount_amount', models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='discount amount')),
('is_active', models.BooleanField(default=True, verbose_name='is active?')),
('club', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='club.club')),
],
options={
'verbose_name': 'order discount type',
'verbose_name_plural': 'order discount types',
'ordering': ['name'],
},
),
migrations.CreateModel(
name='AppliedDiscount',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('discount_type', models.CharField(choices=[('percentage', 'Percentage'), ('fixed_amount', 'Fixed amount')], default='percentage', max_length=255, verbose_name='discount type')),
('discount_amount', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='discount amount')),
('description', models.TextField(blank=True, verbose_name='description')),
('applied_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='applied_discounts', to=settings.AUTH_USER_MODEL, verbose_name='applied by')),
('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='applied_discounts', to='shop.order', verbose_name='order')),
('discount', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='applied_discounts', to='shop.discount', verbose_name='discount')),
],
options={
'verbose_name': 'applied discount',
'verbose_name_plural': 'applied discounts',
},
),
migrations.CreateModel(
name='Invoice',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('number', models.CharField(blank=True, max_length=255, verbose_name='number')),
('issued_at', models.DateTimeField(auto_now_add=True, verbose_name='issued at')),
('due_date', models.DateField(blank=True, null=True, verbose_name='due date')),
('billing_snapshot', models.JSONField(blank=True, null=True, verbose_name='billing snapshot')),
('pdf', models.FileField(blank=True, null=True, upload_to='invoices', verbose_name='PDF')),
('club', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='club.club')),
('order', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='invoice', to='shop.order', verbose_name='order')),
],
options={
'verbose_name': 'invoice',
'verbose_name_plural': 'invoices',
'ordering': ['-issued_at'],
},
),
migrations.CreateModel(
name='OrderLine',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('quantity', models.PositiveSmallIntegerField(default=1, verbose_name='quantity')),
('unit_price', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='unit price')),
('line_total', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='line total')),
('beneficiary', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='order_items', to='members.member', verbose_name='beneficiary')),
('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='order_items', to='shop.order', verbose_name='order')),
('product', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='order_items', to='shop.product', verbose_name='product')),
('team', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='order_items', to='teams.team', verbose_name='team')),
],
options={
'verbose_name': 'order line',
'verbose_name_plural': 'order lines',
},
),
migrations.CreateModel(
name='Payment',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('amount', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='amount')),
('method', models.CharField(choices=[('credit_card', 'Credit card'), ('bank_transfer', 'Bank transfer'), ('cash', 'Cash')], default='credit_card', max_length=255, verbose_name='method')),
('status', models.CharField(choices=[('pending', 'Pending'), ('confirmed', 'Confirmed'), ('failed', 'Failed'), ('refunded', 'Refunded')], default='pending', max_length=255, verbose_name='status')),
('reference', models.CharField(blank=True, max_length=255, verbose_name='reference')),
('paid_at', models.DateTimeField(blank=True, null=True, verbose_name='paid at')),
('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='payments', to='shop.order', verbose_name='order')),
],
options={
'verbose_name': 'payment',
'verbose_name_plural': 'payments',
},
),
migrations.AddConstraint(
model_name='discount',
constraint=models.UniqueConstraint(fields=('club', 'slug'), name='unique_order_discount_type_slug_per_club'),
),
migrations.AddConstraint(
model_name='invoice',
constraint=models.UniqueConstraint(fields=('club', 'number'), name='unique_invoice_number_per_club'),
),
]

View File

@@ -0,0 +1,20 @@
# Generated by Django 6.0.6 on 2026-07-12 21:31
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('members', '0002_alter_familymembership_unique_together_and_more'),
('shop', '0007_discount_applieddiscount_invoice_orderline_payment_and_more'),
]
operations = [
migrations.AlterField(
model_name='applieddiscount',
name='applied_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='applied_discounts', to='members.member', verbose_name='applied by'),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 6.0.6 on 2026-07-12 21:41
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('members', '0002_alter_familymembership_unique_together_and_more'),
('shop', '0008_alter_applieddiscount_applied_by'),
]
operations = [
migrations.AddConstraint(
model_name='applieddiscount',
constraint=models.UniqueConstraint(fields=('order', 'discount'), name='unique_discount_per_order'),
),
]