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

@@ -1 +1,140 @@
# Register your models here.
from django.contrib import admin
from .models import (
AppliedDiscount,
Cart,
CartItem,
Discount,
Invoice,
Order,
OrderLine,
Payment,
Product,
)
class ClubScopedFKMixin:
"""Restrict club-scoped FK dropdowns to the edited object's (or parent's) club.
``scoped_fk_fields`` names FKs whose target is a club-scoped model. The club
is taken from the object being edited (or, for inlines, the parent object).
Enforcement still lives in each model's ``clean()``; this just keeps invalid
options out of the dropdowns.
"""
scoped_fk_fields = ()
def get_form(self, request, obj=None, **kwargs):
request._club_obj = obj
return super().get_form(request, obj, **kwargs)
def get_formset(self, request, obj=None, **kwargs):
request._club_obj = obj
return super().get_formset(request, obj, **kwargs)
def formfield_for_foreignkey(self, db_field, request, **kwargs):
if db_field.name in self.scoped_fk_fields:
club_id = getattr(getattr(request, "_club_obj", None), "club_id", None)
if club_id is not None:
kwargs["queryset"] = db_field.related_model._default_manager.filter(club_id=club_id)
return super().formfield_for_foreignkey(db_field, request, **kwargs)
@admin.register(Product)
class ProductAdmin(ClubScopedFKMixin, admin.ModelAdmin):
scoped_fk_fields = ("season", "staff_role")
list_display = ["name", "product_type", "club", "is_active", "is_public"]
list_filter = ["club", "product_type", "is_active", "is_public"]
search_fields = ["name", "slug"]
prepopulated_fields = {"slug": ["name"]}
class CartItemInline(ClubScopedFKMixin, admin.TabularInline):
model = CartItem
scoped_fk_fields = ("product", "team")
extra = 0
raw_id_fields = ["beneficiary"]
@admin.register(Cart)
class CartAdmin(admin.ModelAdmin):
list_display = ["user", "status", "club"]
list_filter = ["club", "status"]
search_fields = ["user__email"]
raw_id_fields = ["user"]
inlines = [CartItemInline]
@admin.register(CartItem)
class CartItemAdmin(admin.ModelAdmin):
list_display = ["cart", "product", "quantity", "beneficiary"]
search_fields = ["product__name"]
raw_id_fields = ["cart", "product", "beneficiary", "team"]
class OrderLineInline(ClubScopedFKMixin, admin.TabularInline):
model = OrderLine
scoped_fk_fields = ("product", "team")
extra = 0
raw_id_fields = ["beneficiary"]
class AppliedDiscountInline(ClubScopedFKMixin, admin.TabularInline):
model = AppliedDiscount
scoped_fk_fields = ("discount",)
extra = 0
raw_id_fields = ["applied_by"]
class PaymentInline(admin.TabularInline):
model = Payment
extra = 0
@admin.register(Order)
class OrderAdmin(admin.ModelAdmin):
list_display = ["number", "purchaser", "status", "total", "club", "created"]
list_filter = ["club", "status"]
search_fields = ["number", "purchaser__first_name", "purchaser__last_name"]
raw_id_fields = ["purchaser"]
readonly_fields = ["number", "created", "modified"]
inlines = [OrderLineInline, AppliedDiscountInline, PaymentInline]
@admin.register(OrderLine)
class OrderLineAdmin(admin.ModelAdmin):
list_display = ["order", "product", "quantity", "line_total"]
search_fields = ["product__name", "order__number"]
raw_id_fields = ["order", "product", "beneficiary", "team"]
@admin.register(Discount)
class DiscountAdmin(admin.ModelAdmin):
list_display = ["name", "discount_type", "discount_amount", "club", "is_active"]
list_filter = ["club", "discount_type", "is_active"]
search_fields = ["name", "slug"]
prepopulated_fields = {"slug": ["name"]}
@admin.register(AppliedDiscount)
class AppliedDiscountAdmin(admin.ModelAdmin):
list_display = ["order", "discount", "discount_type", "discount_amount"]
search_fields = ["order__number", "discount__name"]
raw_id_fields = ["order", "discount", "applied_by"]
@admin.register(Payment)
class PaymentAdmin(admin.ModelAdmin):
list_display = ["order", "amount", "method", "status", "paid_at"]
list_filter = ["method", "status"]
search_fields = ["order__number", "reference"]
raw_id_fields = ["order"]
@admin.register(Invoice)
class InvoiceAdmin(ClubScopedFKMixin, admin.ModelAdmin):
scoped_fk_fields = ("order",)
list_display = ["number", "order", "club", "issued_at", "due_date"]
list_filter = ["club"]
search_fields = ["number", "order__number"]
readonly_fields = ["number", "issued_at"]

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'),
),
]

View File

@@ -1,17 +1,307 @@
from django.db import models
from django.db.models import UniqueConstraint
from django.db import IntegrityError, models, transaction
from django.db.models import Q, UniqueConstraint
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from clubmanager.base import ClubScopedModel
from authentication.models import User
from club.models import Season
from club.tenancy import require_current_club
from clubmanager.base import ClubScopedModel, UUIDModel, validate_club_scope
from members.models import Member
from teams.models import Position, Team
def next_scoped_number(instance, code):
"""Next per-club sequential number for the current year: ``<code>-<year>-<seq>``."""
prefix = f"{code}-{timezone.now().year}-"
model = type(instance)
sequences = [int(suffix) for number in model.objects.filter(club=instance.club, number__startswith=prefix).values_list("number", flat=True) if (suffix := number.removeprefix(prefix)).isdigit()]
return f"{prefix}{max(sequences, default=0) + 1:05d}"
def save_with_number(instance, save):
"""Assign a unique per-club number (retrying on collision) then save.
Relies on the model's ``generate_number()`` and its ``(club, number)``
unique constraint as the source of truth.
"""
if instance.club_id is None:
instance.club = require_current_club()
if instance.number:
return save()
for attempt in range(5):
instance.number = instance.generate_number()
try:
with transaction.atomic():
return save()
except IntegrityError:
if attempt == 4:
raise
class Product(ClubScopedModel):
class ProductType(models.TextChoices):
MEMBERSHIP = "membership", _("Membership")
EVENT_FEE = "event_fee", _("Event fee")
MERCHANDISE = "merchandise", _("Merchandise")
DONATION = "donation", _("Donation")
class DiscountType(models.TextChoices):
PERCENTAGE = "percentage", _("Percentage")
FIXED_AMOUNT = "fixed_amount", _("Fixed amount")
name = models.CharField(_("name"), max_length=255)
slug = models.SlugField(_("slug"), max_length=255, blank=True)
product_type = models.CharField(_("product type"), max_length=255, choices=ProductType.choices, default=ProductType.MEMBERSHIP)
season = models.ForeignKey(Season, on_delete=models.PROTECT, related_name="products", verbose_name=_("season"), blank=True, null=True)
is_active = models.BooleanField(_("is active?"), default=True)
is_public = models.BooleanField(_("is public?"), default=True, help_text="Non-public products are only visible to staff members for adding on to an order later on.")
staff_role = models.ForeignKey(Position, on_delete=models.PROTECT, related_name="staff_products", verbose_name=_("staff role"), blank=True, null=True, limit_choices_to={"staff_position": True})
early_bird_discount_enabled = models.BooleanField(_("early bird discount enabled?"), default=False)
early_bird_discount_deadline = models.DateField(_("early bird discount deadline"), blank=True, null=True)
early_bird_discount_type = models.CharField(_("early bird discount type"), max_length=255, choices=DiscountType.choices, default=DiscountType.PERCENTAGE)
early_bird_discount_amount = models.DecimalField(_("early bird discount amount"), max_digits=10, decimal_places=2, default=0)
slug_source = "name"
class Meta:
verbose_name = _("product")
verbose_name_plural = _("products")
ordering = ["name"]
constraints = [
UniqueConstraint(fields=["club", "slug"], name="unique_product_slug_per_club"),
]
def __str__(self):
return self.name
def clean(self):
validate_club_scope(self, self.club_id, same_club_fields=("season", "staff_role"))
class Cart(ClubScopedModel):
class CartStatus(models.TextChoices):
OPEN = "open", _("Open")
CHECKED_OUT = "checked_out", _("Checked out")
ABANDONED = "abandoned", _("Abandoned")
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="carts", verbose_name=_("user"))
status = models.CharField(_("status"), max_length=255, choices=CartStatus.choices, default=CartStatus.OPEN)
class Meta:
verbose_name = _("cart")
verbose_name_plural = _("carts")
constraints = [
# At most one open cart per user per club (CartStatus.OPEN == "open").
UniqueConstraint(fields=["club", "user"], condition=Q(status="open"), name="unique_open_cart_per_user_per_club"),
]
def __str__(self):
return f"{self.user} - {self.status}"
class CartItem(UUIDModel):
cart = models.ForeignKey(Cart, on_delete=models.CASCADE, related_name="items", verbose_name=_("cart"))
product = models.ForeignKey(Product, on_delete=models.PROTECT, related_name="cart_items", verbose_name=_("product"))
quantity = models.PositiveSmallIntegerField(_("quantity"), default=1)
unit_price = models.DecimalField(_("unit price"), max_digits=10, decimal_places=2)
beneficiary = models.ForeignKey(Member, on_delete=models.PROTECT, related_name="cart_items", verbose_name=_("beneficiary"), blank=True, null=True)
team = models.ForeignKey(Team, on_delete=models.PROTECT, related_name="cart_items", verbose_name=_("team"), blank=True, null=True)
class Meta:
verbose_name = _("cart item")
verbose_name_plural = _("cart items")
constraints = [
UniqueConstraint(fields=["cart", "product", "beneficiary"], name="unique_product_per_cart_per_beneficiary"),
]
def __str__(self):
return f"{self.product} - {self.quantity}x"
def clean(self):
club_id = self.cart.club_id if self.cart_id else None
validate_club_scope(self, club_id, same_club_fields=("product", "team"), member_fields=("beneficiary",))
class Order(ClubScopedModel):
class OrderStatus(models.TextChoices):
PENDING = "pending", _("Pending")
PAID = "paid", _("Paid")
PARTIALLY_PAID = "partially_paid", _("Partially paid")
CANCELLED = "cancelled", _("Cancelled")
REFUNDED = "refunded", _("Refunded")
DELIVERED = "delivered", _("Delivered")
number = models.CharField(_("number"), max_length=255, blank=True)
purchaser = models.ForeignKey(Member, on_delete=models.PROTECT, related_name="orders", verbose_name=_("purchaser"))
status = models.CharField(_("status"), max_length=255, choices=OrderStatus.choices, default=OrderStatus.PENDING)
total = models.DecimalField(_("total"), max_digits=10, decimal_places=2)
created = models.DateTimeField(_("created at"), auto_now_add=True)
modified = models.DateTimeField(_("modified"), auto_now=True)
class Meta:
verbose_name = _("order")
verbose_name_plural = _("orders")
ordering = ["-created"]
constraints = [
UniqueConstraint(fields=["club", "number"], name="unique_order_number_per_club"),
]
def __str__(self):
return self.number
def clean(self):
validate_club_scope(self, self.club_id, member_fields=("purchaser",))
def generate_number(self):
"""Next per-club order number for the current year: ``ORD-<year>-<seq>``."""
return next_scoped_number(self, "ORD")
def save(self, *args, **kwargs):
return save_with_number(self, lambda: super(Order, self).save(*args, **kwargs))
class OrderLine(UUIDModel):
order = models.ForeignKey(Order, on_delete=models.CASCADE, related_name="order_items", verbose_name=_("order"))
product = models.ForeignKey(Product, on_delete=models.PROTECT, related_name="order_items", verbose_name=_("product"))
quantity = models.PositiveSmallIntegerField(_("quantity"), default=1)
unit_price = models.DecimalField(_("unit price"), max_digits=10, decimal_places=2)
beneficiary = models.ForeignKey(Member, on_delete=models.PROTECT, related_name="order_items", verbose_name=_("beneficiary"), blank=True, null=True)
team = models.ForeignKey(Team, on_delete=models.PROTECT, related_name="order_items", verbose_name=_("team"), blank=True, null=True)
line_total = models.DecimalField(_("line total"), max_digits=10, decimal_places=2)
class Meta:
verbose_name = _("order line")
verbose_name_plural = _("order lines")
def __str__(self):
return f"{self.product} - {self.quantity}x"
def clean(self):
club_id = self.order.club_id if self.order_id else None
validate_club_scope(self, club_id, same_club_fields=("product", "team"), member_fields=("beneficiary",))
class Discount(ClubScopedModel):
class DiscountType(models.TextChoices):
PERCENTAGE = "percentage", _("Percentage")
FIXED_AMOUNT = "fixed_amount", _("Fixed amount")
name = models.CharField(_("name"), max_length=255)
slug = models.SlugField(_("slug"), max_length=255, blank=True)
description = models.TextField(_("description"), blank=True)
discount_type = models.CharField(_("discount type"), max_length=255, choices=DiscountType.choices, default=DiscountType.PERCENTAGE)
discount_amount = models.DecimalField(_("discount amount"), max_digits=10, decimal_places=2, default=0)
is_active = models.BooleanField(_("is active?"), default=True)
slug_source = "name"
class Meta:
verbose_name = _("order discount type")
verbose_name_plural = _("order discount types")
ordering = ["name"]
constraints = [
UniqueConstraint(fields=["club", "slug"], name="unique_order_discount_type_slug_per_club"),
]
def __str__(self):
return self.name
class AppliedDiscount(UUIDModel):
class DiscountType(models.TextChoices):
PERCENTAGE = "percentage", _("Percentage")
FIXED_AMOUNT = "fixed_amount", _("Fixed amount")
order = models.ForeignKey(Order, on_delete=models.CASCADE, related_name="applied_discounts", verbose_name=_("order"))
discount = models.ForeignKey(Discount, on_delete=models.PROTECT, related_name="applied_discounts", verbose_name=_("discount"))
discount_type = models.CharField(_("discount type"), max_length=255, choices=DiscountType.choices, default=DiscountType.PERCENTAGE)
discount_amount = models.DecimalField(_("discount amount"), max_digits=10, decimal_places=2)
description = models.TextField(_("description"), blank=True)
applied_by = models.ForeignKey(Member, on_delete=models.PROTECT, related_name="applied_discounts", verbose_name=_("applied by"), blank=True, null=True)
class Meta:
verbose_name = _("applied discount")
verbose_name_plural = _("applied discounts")
constraints = [
UniqueConstraint(fields=["order", "discount"], name="unique_discount_per_order"),
]
def __str__(self):
suffix = "%" if self.discount_type == self.DiscountType.PERCENTAGE else ""
return f"{self.discount} - {self.discount_amount}{suffix}"
def clean(self):
club_id = self.order.club_id if self.order_id else None
validate_club_scope(self, club_id, same_club_fields=("discount",), member_fields=("applied_by",))
class Payment(UUIDModel):
class PaymentMethod(models.TextChoices):
CREDIT_CARD = "credit_card", _("Credit card")
BANK_TRANSFER = "bank_transfer", _("Bank transfer")
CASH = "cash", _("Cash")
class PaymentStatus(models.TextChoices):
PENDING = "pending", _("Pending")
CONFIRMED = "confirmed", _("Confirmed")
FAILED = "failed", _("Failed")
REFUNDED = "refunded", _("Refunded")
order = models.ForeignKey(Order, on_delete=models.CASCADE, related_name="payments", verbose_name=_("order"))
amount = models.DecimalField(_("amount"), max_digits=10, decimal_places=2)
method = models.CharField(_("method"), max_length=255, choices=PaymentMethod.choices, default=PaymentMethod.CREDIT_CARD)
status = models.CharField(_("status"), max_length=255, choices=PaymentStatus.choices, default=PaymentStatus.PENDING)
reference = models.CharField(_("reference"), max_length=255, blank=True)
paid_at = models.DateTimeField(_("paid at"), blank=True, null=True)
class Meta:
verbose_name = _("payment")
verbose_name_plural = _("payments")
def __str__(self):
return f"{self.order} - {self.status}"
class Invoice(ClubScopedModel):
number = models.CharField(_("number"), max_length=255, blank=True)
order = models.OneToOneField(Order, on_delete=models.CASCADE, related_name="invoice", verbose_name=_("order"))
issued_at = models.DateTimeField(_("issued at"), auto_now_add=True)
due_date = models.DateField(_("due date"), blank=True, null=True)
billing_snapshot = models.JSONField(_("billing snapshot"), blank=True, null=True) # Name/address information
pdf = models.FileField(_("PDF"), upload_to="invoices", blank=True, null=True)
class Meta:
verbose_name = _("invoice")
verbose_name_plural = _("invoices")
ordering = ["-issued_at"]
constraints = [
UniqueConstraint(fields=["club", "number"], name="unique_invoice_number_per_club"),
]
def __str__(self):
return self.number
def clean(self):
validate_club_scope(self, self.club_id, same_club_fields=("order",))
def generate_number(self):
"""Next per-club invoice number for the current year: ``INV-<year>-<seq>``."""
return next_scoped_number(self, "INV")
def save(self, *args, **kwargs):
return save_with_number(self, lambda: super(Invoice, self).save(*args, **kwargs))

View File

@@ -1,8 +1,32 @@
from django.test import TestCase
from datetime import timedelta
from decimal import Decimal
from unittest.mock import patch
from club.models import Club
from django.contrib.admin.sites import AdminSite
from django.core.exceptions import ValidationError
from django.db import IntegrityError
from django.db.models import ProtectedError
from django.test import RequestFactory, TestCase
from django.utils import timezone
from .models import Product
from authentication.models import User
from club.models import Club, ClubMembership, Season
from club.tenancy import reset_current_club, set_current_club
from members.models import Member
from teams.models import Position, Team
from .admin import ProductAdmin
from .models import (
AppliedDiscount,
Cart,
CartItem,
Discount,
Invoice,
Order,
OrderLine,
Payment,
Product,
)
class ProductSlugTests(TestCase):
@@ -37,3 +61,400 @@ class ProductSlugTests(TestCase):
product = Product.objects.create(club=self.club, name="###")
self.assertEqual(product.slug, "item")
def test_str_returns_name(self):
product = Product.objects.create(club=self.club, name="Home Jersey")
self.assertEqual(str(product), "Home Jersey")
class OpenCartConstraintTests(TestCase):
def setUp(self):
self.club = Club.objects.create(name="Ajax United", slug="ajax-united")
self.user = User.objects.create_user(email="shopper@example.com", password="pw")
def test_only_one_open_cart_per_user_per_club(self):
Cart.objects.create(club=self.club, user=self.user)
with self.assertRaises(IntegrityError):
Cart.objects.create(club=self.club, user=self.user)
def test_open_and_closed_carts_coexist(self):
Cart.objects.create(club=self.club, user=self.user, status=Cart.CartStatus.CHECKED_OUT)
Cart.objects.create(club=self.club, user=self.user, status=Cart.CartStatus.ABANDONED)
Cart.objects.create(club=self.club, user=self.user)
self.assertEqual(self.user.carts.count(), 3)
self.assertEqual(self.user.carts.filter(status=Cart.CartStatus.OPEN).count(), 1)
def test_open_cart_allowed_in_each_club(self):
other = Club.objects.create(name="Rival FC", slug="rival-fc")
Cart.objects.create(club=self.club, user=self.user)
Cart.objects.create(club=other, user=self.user)
self.assertEqual(self.user.carts.filter(status=Cart.CartStatus.OPEN).count(), 2)
def test_str(self):
cart = Cart.objects.create(club=self.club, user=self.user)
self.assertEqual(str(cart), f"{self.user} - open")
class CartItemTests(TestCase):
def test_str(self):
club = Club.objects.create(name="Ajax United", slug="ajax-united")
user = User.objects.create_user(email="shopper@example.com", password="pw")
cart = Cart.objects.create(club=club, user=user)
product = Product.objects.create(club=club, name="Home Jersey")
item = CartItem.objects.create(cart=cart, product=product, quantity=2, unit_price=Decimal("25.00"))
self.assertEqual(str(item), "Home Jersey - 2x")
class OrderNumberTests(TestCase):
def setUp(self):
self.club = Club.objects.create(name="Ajax United", slug="ajax-united")
self.member = Member.objects.create(first_name="Jane", last_name="Doe")
self.year = timezone.now().year
def make_order(self, **kwargs):
kwargs.setdefault("club", self.club)
kwargs.setdefault("purchaser", self.member)
kwargs.setdefault("total", Decimal("10.00"))
return Order.objects.create(**kwargs)
def test_number_is_generated(self):
order = self.make_order()
self.assertEqual(order.number, f"ORD-{self.year}-00001")
def test_number_increments_within_club_and_year(self):
first = self.make_order()
second = self.make_order()
self.assertEqual(first.number, f"ORD-{self.year}-00001")
self.assertEqual(second.number, f"ORD-{self.year}-00002")
def test_number_is_scoped_per_club(self):
other = Club.objects.create(name="Rival FC", slug="rival-fc")
self.make_order()
order = self.make_order(club=other)
self.assertEqual(order.number, f"ORD-{self.year}-00001")
def test_explicit_number_is_preserved(self):
order = self.make_order(number="CUSTOM-1")
self.assertEqual(order.number, "CUSTOM-1")
def test_resaving_keeps_the_number(self):
order = self.make_order()
original = order.number
order.status = Order.OrderStatus.PAID
order.save()
order.refresh_from_db()
self.assertEqual(order.number, original)
def test_non_numeric_suffix_is_ignored(self):
Order.objects.create(club=self.club, purchaser=self.member, total=Decimal("5"), number=f"ORD-{self.year}-oops")
order = self.make_order()
self.assertEqual(order.number, f"ORD-{self.year}-00001")
def test_club_filled_from_tenant_context(self):
token = set_current_club(self.club)
try:
order = Order.objects.create(purchaser=self.member, total=Decimal("5"))
finally:
reset_current_club(token)
self.assertEqual(order.club, self.club)
def test_retries_on_collision(self):
taken = self.make_order().number
with patch.object(Order, "generate_number", side_effect=[taken, "ORD-2999-00001"]):
order = Order(club=self.club, purchaser=self.member, total=Decimal("5"))
order.save()
self.assertEqual(order.number, "ORD-2999-00001")
def test_gives_up_after_exhausting_retries(self):
taken = self.make_order().number
with patch.object(Order, "generate_number", return_value=taken), self.assertRaises(IntegrityError):
Order(club=self.club, purchaser=self.member, total=Decimal("5")).save()
def test_str_is_the_number(self):
order = self.make_order()
self.assertEqual(str(order), order.number)
class ShopEntitiesTestBase(TestCase):
def setUp(self):
self.club = Club.objects.create(name="Ajax United", slug="ajax-united")
self.member = Member.objects.create(first_name="Jane", last_name="Doe")
self.product = Product.objects.create(club=self.club, name="Home Jersey")
self.order = Order.objects.create(club=self.club, purchaser=self.member, total=Decimal("50.00"))
self.year = timezone.now().year
class OrderLineTests(ShopEntitiesTestBase):
def make_line(self, **kwargs):
kwargs.setdefault("order", self.order)
kwargs.setdefault("product", self.product)
kwargs.setdefault("quantity", 2)
kwargs.setdefault("unit_price", Decimal("25.00"))
kwargs.setdefault("line_total", Decimal("50.00"))
return OrderLine.objects.create(**kwargs)
def test_str(self):
self.assertEqual(str(self.make_line()), "Home Jersey - 2x")
def test_deleting_order_cascades_to_lines(self):
self.make_line()
self.order.delete()
self.assertFalse(OrderLine.objects.exists())
def test_product_is_protected_while_referenced(self):
self.make_line()
with self.assertRaises(ProtectedError):
self.product.delete()
class DiscountTests(ShopEntitiesTestBase):
def test_slug_and_str(self):
discount = Discount.objects.create(club=self.club, name="Sibling discount")
self.assertEqual(discount.slug, "sibling-discount")
self.assertEqual(str(discount), "Sibling discount")
def test_slug_is_unique_per_club(self):
Discount.objects.create(club=self.club, name="Sibling")
second = Discount.objects.create(club=self.club, name="Sibling")
self.assertEqual(second.slug, "sibling-2")
class AppliedDiscountTests(ShopEntitiesTestBase):
def setUp(self):
super().setUp()
self.discount = Discount.objects.create(club=self.club, name="Sibling")
def apply(self, **kwargs):
kwargs.setdefault("order", self.order)
kwargs.setdefault("discount", self.discount)
kwargs.setdefault("discount_amount", Decimal("10.00"))
return AppliedDiscount.objects.create(**kwargs)
def test_str_percentage_shows_percent(self):
applied = self.apply(discount_type=AppliedDiscount.DiscountType.PERCENTAGE)
self.assertEqual(str(applied), "Sibling - 10.00%")
def test_str_fixed_amount_has_no_percent(self):
applied = self.apply(discount_type=AppliedDiscount.DiscountType.FIXED_AMOUNT)
self.assertEqual(str(applied), "Sibling - 10.00")
def test_deleting_order_cascades(self):
self.apply()
self.order.delete()
self.assertFalse(AppliedDiscount.objects.exists())
def test_discount_is_protected_while_referenced(self):
self.apply()
with self.assertRaises(ProtectedError):
self.discount.delete()
class PaymentTests(ShopEntitiesTestBase):
def test_str(self):
payment = Payment.objects.create(order=self.order, amount=Decimal("50.00"))
self.assertEqual(str(payment), f"{self.order} - pending")
def test_deleting_order_cascades(self):
Payment.objects.create(order=self.order, amount=Decimal("50.00"))
self.order.delete()
self.assertFalse(Payment.objects.exists())
class InvoiceTests(ShopEntitiesTestBase):
def test_number_generated_and_str(self):
invoice = Invoice.objects.create(club=self.club, order=self.order)
self.assertEqual(invoice.number, f"INV-{self.year}-00001")
self.assertEqual(str(invoice), invoice.number)
def test_number_increments_per_club(self):
second_order = Order.objects.create(club=self.club, purchaser=self.member, total=Decimal("10.00"))
Invoice.objects.create(club=self.club, order=self.order)
second = Invoice.objects.create(club=self.club, order=second_order)
self.assertEqual(second.number, f"INV-{self.year}-00002")
def test_one_invoice_per_order(self):
Invoice.objects.create(club=self.club, order=self.order)
with self.assertRaises(IntegrityError):
Invoice.objects.create(club=self.club, order=self.order)
def test_deleting_order_cascades_to_invoice(self):
Invoice.objects.create(club=self.club, order=self.order)
self.order.delete()
self.assertFalse(Invoice.objects.exists())
class ClubScopeValidationTests(TestCase):
def setUp(self):
self.club = Club.objects.create(name="Ajax United", slug="ajax-united")
self.other = Club.objects.create(name="Rival FC", slug="rival-fc")
today = timezone.localdate()
self.season = Season.objects.create(club=self.club, start_date=today, end_date=today + timedelta(days=300))
self.other_season = Season.objects.create(club=self.other, start_date=today, end_date=today + timedelta(days=300))
self.member = Member.objects.create(first_name="Jane", last_name="Doe")
ClubMembership.objects.create(club=self.club, member=self.member, season=self.season)
self.stranger = Member.objects.create(first_name="Stray", last_name="Ger")
def make_cart(self, club):
user = User.objects.create_user(email=f"u-{club.slug}@example.com", password="pw")
return Cart.objects.create(club=club, user=user)
def make_order(self, club):
return Order.objects.create(club=club, purchaser=self.member, total=Decimal("10.00"))
# --- Product ---
def test_product_rejects_cross_club_season(self):
product = Product(club=self.club, name="Jersey", season=self.other_season)
with self.assertRaises(ValidationError) as ctx:
product.full_clean()
self.assertIn("season", ctx.exception.error_dict)
def test_product_rejects_cross_club_staff_role(self):
position = Position.objects.create(club=self.other, name="Coach", short_name="C", staff_position=True)
product = Product(club=self.club, name="Jersey", staff_role=position)
with self.assertRaises(ValidationError) as ctx:
product.full_clean()
self.assertIn("staff_role", ctx.exception.error_dict)
def test_product_accepts_same_club_season(self):
Product(club=self.club, name="Jersey", season=self.season).full_clean()
# --- CartItem ---
def test_cartitem_rejects_cross_club_product(self):
cart = self.make_cart(self.club)
product = Product.objects.create(club=self.other, name="Jersey")
item = CartItem(cart=cart, product=product, unit_price=Decimal("5"))
with self.assertRaises(ValidationError) as ctx:
item.full_clean()
self.assertIn("product", ctx.exception.error_dict)
def test_cartitem_rejects_cross_club_team(self):
cart = self.make_cart(self.club)
product = Product.objects.create(club=self.club, name="Jersey")
team = Team.objects.create(club=self.other, name="First", short_name="1")
item = CartItem(cart=cart, product=product, team=team, unit_price=Decimal("5"))
with self.assertRaises(ValidationError) as ctx:
item.full_clean()
self.assertIn("team", ctx.exception.error_dict)
def test_cartitem_rejects_non_member_beneficiary(self):
cart = self.make_cart(self.club)
product = Product.objects.create(club=self.club, name="Jersey")
item = CartItem(cart=cart, product=product, beneficiary=self.stranger, unit_price=Decimal("5"))
with self.assertRaises(ValidationError) as ctx:
item.full_clean()
self.assertIn("beneficiary", ctx.exception.error_dict)
def test_cartitem_accepts_same_club(self):
cart = self.make_cart(self.club)
product = Product.objects.create(club=self.club, name="Jersey")
CartItem(cart=cart, product=product, beneficiary=self.member, unit_price=Decimal("5")).full_clean()
def test_cartitem_clean_without_cart_is_noop(self):
CartItem().clean()
# --- OrderLine ---
def test_orderline_rejects_cross_club_product(self):
order = self.make_order(self.club)
product = Product.objects.create(club=self.other, name="Jersey")
line = OrderLine(order=order, product=product, unit_price=Decimal("5"), line_total=Decimal("5"))
with self.assertRaises(ValidationError) as ctx:
line.full_clean()
self.assertIn("product", ctx.exception.error_dict)
def test_orderline_clean_without_order_is_noop(self):
OrderLine().clean()
# --- Order ---
def test_order_rejects_non_member_purchaser(self):
order = Order(club=self.club, purchaser=self.stranger, total=Decimal("10"))
with self.assertRaises(ValidationError) as ctx:
order.full_clean()
self.assertIn("purchaser", ctx.exception.error_dict)
def test_order_accepts_member_purchaser(self):
Order(club=self.club, purchaser=self.member, total=Decimal("10")).full_clean()
# --- AppliedDiscount ---
def test_applieddiscount_rejects_cross_club_discount(self):
order = self.make_order(self.club)
discount = Discount.objects.create(club=self.other, name="Sibling")
applied = AppliedDiscount(order=order, discount=discount, discount_amount=Decimal("5"))
with self.assertRaises(ValidationError) as ctx:
applied.full_clean()
self.assertIn("discount", ctx.exception.error_dict)
def test_applieddiscount_rejects_non_member_applied_by(self):
order = self.make_order(self.club)
discount = Discount.objects.create(club=self.club, name="Sibling")
applied = AppliedDiscount(order=order, discount=discount, discount_amount=Decimal("5"), applied_by=self.stranger)
with self.assertRaises(ValidationError) as ctx:
applied.full_clean()
self.assertIn("applied_by", ctx.exception.error_dict)
def test_applieddiscount_is_unique_per_order(self):
order = self.make_order(self.club)
discount = Discount.objects.create(club=self.club, name="Sibling")
AppliedDiscount.objects.create(order=order, discount=discount, discount_amount=Decimal("5"))
with self.assertRaises(IntegrityError):
AppliedDiscount.objects.create(order=order, discount=discount, discount_amount=Decimal("5"))
def test_applieddiscount_clean_without_order_is_noop(self):
AppliedDiscount().clean()
# --- Invoice ---
def test_invoice_rejects_cross_club_order(self):
order = self.make_order(self.other)
invoice = Invoice(club=self.club, order=order)
with self.assertRaises(ValidationError) as ctx:
invoice.full_clean()
self.assertIn("order", ctx.exception.error_dict)
def test_invoice_accepts_same_club_order(self):
order = self.make_order(self.club)
Invoice(club=self.club, order=order).full_clean()
class AdminScopingTests(TestCase):
def setUp(self):
self.club = Club.objects.create(name="Ajax United", slug="ajax-united")
self.other = Club.objects.create(name="Rival FC", slug="rival-fc")
today = timezone.localdate()
self.season = Season.objects.create(club=self.club, start_date=today, end_date=today + timedelta(days=300))
self.other_season = Season.objects.create(club=self.other, start_date=today, end_date=today + timedelta(days=300))
def test_fk_dropdown_scoped_to_object_club(self):
product = Product.objects.create(club=self.club, name="Jersey")
admin_obj = ProductAdmin(Product, AdminSite())
request = RequestFactory().get("/")
request._club_obj = product
field = admin_obj.formfield_for_foreignkey(Product._meta.get_field("season"), request)
self.assertIn(self.season, field.queryset)
self.assertNotIn(self.other_season, field.queryset)