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:
59
shop/migrations/0003_alter_product_options_and_more.py
Normal file
59
shop/migrations/0003_alter_product_options_and_more.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
32
shop/migrations/0004_cart.py
Normal file
32
shop/migrations/0004_cart.py
Normal 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')],
|
||||
},
|
||||
),
|
||||
]
|
||||
59
shop/migrations/0005_product_staff_role_cartitem_order.py
Normal file
59
shop/migrations/0005_product_staff_role_cartitem_order.py
Normal 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')],
|
||||
},
|
||||
),
|
||||
]
|
||||
18
shop/migrations/0006_alter_order_number.py
Normal file
18
shop/migrations/0006_alter_order_number.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
20
shop/migrations/0008_alter_applieddiscount_applied_by.py
Normal file
20
shop/migrations/0008_alter_applieddiscount_applied_by.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
Reference in New Issue
Block a user