feat: ShopItem + Purchase models + seed tithe-1 / tithe-5 / band-1 + wallet-shop Applet — Chunk 2 of [[project-wallet-shop-expansion]]. ShopItem is the admin-managed catalog: slug, name, description, icon (FA class), badge_text (eg "×5"), price_cents, granted_token_type (any Token type), granted_count, granted_writs (default 0), max_owned (nullable; BAND=1), display_order, active. is_available_for(user) enforces max_owned by comparing user's owned-count of the granted token type. price_display() renders cents → "$1" / "$4.20" for tooltip prose. Purchase is the per-tx audit trail: user + shop_item + stripe_payment_intent_id (unique) + status (PENDING/SUCCEEDED/FAILED/REFUNDED) + amount_cents snapshot + granted_writs snapshot + granted_token_ids JSONField (PKs of minted tokens) + created_at + succeeded_at. fulfill() is idempotent — short-circuits if status==SUCCEEDED + refuses non-PENDING rows so a webhook + sync /shop/confirm racing each other can't double-mint. Schema migration lyric/0008_shopitem_purchase autogenerated. Seed migration lyric/0009_seed_shop_items populates the 3 starting items per locked decisions: tithe-1 ($1 → 1 TITHE + 144 writs, no cap, order=10); tithe-5 ($4 → 5 TITHE + 750 writs, no cap, badge "×5", order=20); band-1 ($20 → 1 BAND + 0 writs, max_owned=1, order=30). Applet migration applets/0011_seed_wallet_shop_applet adds the wallet-shop Applet (context=wallet, 12 cols × 3 rows). Stub _applet-wallet-shop.html lands w. just <section id="id_wallet_shop"> + <h2>Shop</h2> — _applets.html's auto-include-by-slug pattern would 500 the wallet page on TemplateDoesNotExist otherwise (caught mid-Chunk-2 by the full app suite). Chunk 4 fills in the shop-tile grid + BUY-ITEM microtooltip + Stripe.js wiring. TDD — 22 ITs in test_shop_models.py: ShopItemModelTest (9 cases — minimal create, defaults for granted_writs / max_owned / active, is_available_for w/ + w/o max_owned cap, str repr), PurchaseModelTest (8 cases — minimal create, PI ID uniqueness constraint, fulfill mints tokens + grants writs + marks SUCCEEDED + records granted_token_ids + is idempotent on re-fire + creates N tokens for bundle), SeededShopCatalogTest (4 cases pin tithe-1 / tithe-5 / band-1 row shapes + display_order ascending), SeededWalletShopAppletTest (1 case pins Applet seeded). 1191 IT/UT green
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
43
src/apps/applets/migrations/0011_seed_wallet_shop_applet.py
Normal file
43
src/apps/applets/migrations/0011_seed_wallet_shop_applet.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
"""Seed the wallet Shop applet — Chunk 2 of the wallet expansion sprint.
|
||||||
|
|
||||||
|
Locked spec from [[project-wallet-shop-expansion]]: 4 rows total in the
|
||||||
|
wallet context (Shop atop, Balances + Tokens + Payment beneath); 12 cols
|
||||||
|
in landscape (full-width row). Mimics the existing wallet applets'
|
||||||
|
grid_cols=12 / grid_rows=3 shape.
|
||||||
|
|
||||||
|
`display_order` is NOT a field on Applet — applet ordering is dictated
|
||||||
|
by the wallet template's include order in `_applets.html` + the applet
|
||||||
|
slug alphabetical fallback in `applet_context()`. The template's include
|
||||||
|
order is set in Chunk 4; this migration just ensures the row exists.
|
||||||
|
"""
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
def seed(apps, schema_editor):
|
||||||
|
Applet = apps.get_model("applets", "Applet")
|
||||||
|
Applet.objects.update_or_create(
|
||||||
|
slug="wallet-shop",
|
||||||
|
defaults={
|
||||||
|
"name": "Shop",
|
||||||
|
"context": "wallet",
|
||||||
|
"default_visible": True,
|
||||||
|
"grid_cols": 12,
|
||||||
|
"grid_rows": 3,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def unseed(apps, schema_editor):
|
||||||
|
Applet = apps.get_model("applets", "Applet")
|
||||||
|
Applet.objects.filter(slug="wallet-shop").delete()
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("applets", "0010_rename_my_sign_applet"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(seed, unseed),
|
||||||
|
]
|
||||||
54
src/apps/lyric/migrations/0008_shopitem_purchase.py
Normal file
54
src/apps/lyric/migrations/0008_shopitem_purchase.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
# Generated by Django 6.0 on 2026-05-22 03:09
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('lyric', '0007_alter_token_token_type'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ShopItem',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('slug', models.SlugField(unique=True)),
|
||||||
|
('name', models.CharField(max_length=100)),
|
||||||
|
('description', models.TextField(blank=True, default='')),
|
||||||
|
('icon', models.CharField(max_length=50)),
|
||||||
|
('badge_text', models.CharField(blank=True, default='', max_length=8)),
|
||||||
|
('price_cents', models.PositiveIntegerField()),
|
||||||
|
('granted_token_type', models.CharField(choices=[('coin', 'Coin-on-a-String'), ('Free', 'Free Token'), ('tithe', 'Tithe Token'), ('pass', 'Backstage Pass'), ('band', 'Wristband'), ('carte', 'Carte Blanche')], max_length=8)),
|
||||||
|
('granted_count', models.PositiveSmallIntegerField(default=1)),
|
||||||
|
('granted_writs', models.PositiveIntegerField(default=0)),
|
||||||
|
('max_owned', models.PositiveSmallIntegerField(blank=True, null=True)),
|
||||||
|
('display_order', models.PositiveSmallIntegerField(default=100)),
|
||||||
|
('active', models.BooleanField(default=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['display_order', 'slug'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Purchase',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('stripe_payment_intent_id', models.CharField(max_length=255, unique=True)),
|
||||||
|
('status', models.CharField(choices=[('PENDING', 'Pending'), ('SUCCEEDED', 'Succeeded'), ('FAILED', 'Failed'), ('REFUNDED', 'Refunded')], default='PENDING', max_length=10)),
|
||||||
|
('amount_cents', models.PositiveIntegerField()),
|
||||||
|
('granted_writs', models.PositiveIntegerField(default=0)),
|
||||||
|
('granted_token_ids', models.JSONField(blank=True, default=list)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('succeeded_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='purchases', to=settings.AUTH_USER_MODEL)),
|
||||||
|
('shop_item', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='purchases', to='lyric.shopitem')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['-created_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
80
src/apps/lyric/migrations/0009_seed_shop_items.py
Normal file
80
src/apps/lyric/migrations/0009_seed_shop_items.py
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
"""Seed the 3 starting catalog items for the wallet Shop applet.
|
||||||
|
|
||||||
|
Locked spec from [[project-wallet-shop-expansion]]:
|
||||||
|
tithe-1 — $1 → 1 × TITHE + 144 writs (no cap)
|
||||||
|
tithe-5 — $4 → 5 × TITHE + 750 writs (no cap; badge "×5")
|
||||||
|
band-1 — $20 → 1 × BAND + 0 writs (max_owned=1)
|
||||||
|
|
||||||
|
Pricing migration-safe — admin can still adjust via Django admin without
|
||||||
|
needing a code change; this is the floor / initial offer.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
SHOP_ITEMS = [
|
||||||
|
{
|
||||||
|
"slug": "tithe-1",
|
||||||
|
"name": "Tithe Token",
|
||||||
|
"description": "1 Tithe Token + 144 Writs",
|
||||||
|
"icon": "fa-piggy-bank",
|
||||||
|
"badge_text": "",
|
||||||
|
"price_cents": 100,
|
||||||
|
"granted_token_type": "tithe",
|
||||||
|
"granted_count": 1,
|
||||||
|
"granted_writs": 144,
|
||||||
|
"max_owned": None,
|
||||||
|
"display_order": 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "tithe-5",
|
||||||
|
"name": "Tithe Bundle",
|
||||||
|
"description": "5 Tithe Tokens + 750 Writs",
|
||||||
|
"icon": "fa-piggy-bank",
|
||||||
|
"badge_text": "×5",
|
||||||
|
"price_cents": 400,
|
||||||
|
"granted_token_type": "tithe",
|
||||||
|
"granted_count": 5,
|
||||||
|
"granted_writs": 750,
|
||||||
|
"max_owned": None,
|
||||||
|
"display_order": 20,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "band-1",
|
||||||
|
"name": "Wristband",
|
||||||
|
"description": "Admit All Entry — unlimited free entry (BYOB)",
|
||||||
|
"icon": "fa-ring",
|
||||||
|
"badge_text": "",
|
||||||
|
"price_cents": 2000,
|
||||||
|
"granted_token_type": "band",
|
||||||
|
"granted_count": 1,
|
||||||
|
"granted_writs": 0,
|
||||||
|
"max_owned": 1,
|
||||||
|
"display_order": 30,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def seed_forward(apps, schema_editor):
|
||||||
|
ShopItem = apps.get_model("lyric", "ShopItem")
|
||||||
|
for spec in SHOP_ITEMS:
|
||||||
|
ShopItem.objects.update_or_create(
|
||||||
|
slug=spec["slug"],
|
||||||
|
defaults={k: v for k, v in spec.items() if k != "slug"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def seed_reverse(apps, schema_editor):
|
||||||
|
ShopItem = apps.get_model("lyric", "ShopItem")
|
||||||
|
ShopItem.objects.filter(slug__in=[s["slug"] for s in SHOP_ITEMS]).delete()
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("lyric", "0008_shopitem_purchase"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(seed_forward, seed_reverse),
|
||||||
|
]
|
||||||
@@ -329,6 +329,131 @@ class PaymentMethod(models.Model):
|
|||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.brand} ....{self.last4}"
|
return f"{self.brand} ....{self.last4}"
|
||||||
|
|
||||||
|
|
||||||
|
class ShopItem(models.Model):
|
||||||
|
"""A purchasable bundle in the wallet's Shop applet — admin-managed
|
||||||
|
catalog. Each row defines (price → granted Tokens + writs); the
|
||||||
|
`Purchase.fulfill()` flow mints `granted_count` tokens of
|
||||||
|
`granted_token_type` + bumps `Wallet.writs` by `granted_writs`.
|
||||||
|
|
||||||
|
See [[project-wallet-shop-expansion]] for the broader design + the
|
||||||
|
3 starting catalog items (tithe-1, tithe-5, band-1) seeded in
|
||||||
|
`lyric/0009_seed_shop_items`."""
|
||||||
|
|
||||||
|
slug = models.SlugField(unique=True)
|
||||||
|
name = models.CharField(max_length=100)
|
||||||
|
description = models.TextField(blank=True, default="")
|
||||||
|
icon = models.CharField(max_length=50) # FA icon class (eg "fa-piggy-bank")
|
||||||
|
badge_text = models.CharField(max_length=8, blank=True, default="") # eg "×5"; "" = no badge
|
||||||
|
price_cents = models.PositiveIntegerField()
|
||||||
|
granted_token_type = models.CharField(
|
||||||
|
max_length=8, choices=Token.TOKEN_TYPE_CHOICES,
|
||||||
|
)
|
||||||
|
granted_count = models.PositiveSmallIntegerField(default=1)
|
||||||
|
granted_writs = models.PositiveIntegerField(default=0)
|
||||||
|
# `max_owned=None` → unlimited stock per user. `max_owned=1` → BAND-style
|
||||||
|
# "you can only have one of these" — the shop UI disables BUY w. an
|
||||||
|
# "Already owned" microtooltip when the user's owned-count of the granted
|
||||||
|
# token type has reached this cap.
|
||||||
|
max_owned = models.PositiveSmallIntegerField(null=True, blank=True)
|
||||||
|
display_order = models.PositiveSmallIntegerField(default=100)
|
||||||
|
active = models.BooleanField(default=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["display_order", "slug"]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
def is_available_for(self, user):
|
||||||
|
"""True iff the user can purchase another of this item right now.
|
||||||
|
Honors `max_owned` (compares to user's owned-count of the granted
|
||||||
|
token type). Items w. `max_owned=None` are always available."""
|
||||||
|
if self.max_owned is None:
|
||||||
|
return True
|
||||||
|
owned = user.tokens.filter(token_type=self.granted_token_type).count()
|
||||||
|
return owned < self.max_owned
|
||||||
|
|
||||||
|
def price_display(self):
|
||||||
|
"""Render-ready dollar string for tooltips. Cents trimmed for whole
|
||||||
|
dollars; otherwise two decimals."""
|
||||||
|
dollars = self.price_cents / 100
|
||||||
|
if dollars == int(dollars):
|
||||||
|
return f"${int(dollars)}"
|
||||||
|
return f"${dollars:.2f}"
|
||||||
|
|
||||||
|
|
||||||
|
class Purchase(models.Model):
|
||||||
|
"""Audit-trail row for one shop transaction. Created at PENDING on
|
||||||
|
Stripe PaymentIntent creation, advanced to SUCCEEDED via `fulfill()`
|
||||||
|
(called from EITHER the synchronous `/shop/confirm` view OR the
|
||||||
|
`/stripe/webhook` handler — whichever wins). `fulfill()` is idempotent
|
||||||
|
so the race is harmless.
|
||||||
|
|
||||||
|
`granted_token_ids` snapshots the PKs of every Token row this purchase
|
||||||
|
minted so we can audit / refund / rebuild later without re-deriving
|
||||||
|
from `created_at`."""
|
||||||
|
|
||||||
|
PENDING = "PENDING"
|
||||||
|
SUCCEEDED = "SUCCEEDED"
|
||||||
|
FAILED = "FAILED"
|
||||||
|
REFUNDED = "REFUNDED"
|
||||||
|
STATUS_CHOICES = [
|
||||||
|
(PENDING, "Pending"),
|
||||||
|
(SUCCEEDED, "Succeeded"),
|
||||||
|
(FAILED, "Failed"),
|
||||||
|
(REFUNDED, "Refunded"),
|
||||||
|
]
|
||||||
|
|
||||||
|
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="purchases")
|
||||||
|
shop_item = models.ForeignKey(ShopItem, on_delete=models.PROTECT, related_name="purchases")
|
||||||
|
stripe_payment_intent_id = models.CharField(max_length=255, unique=True)
|
||||||
|
status = models.CharField(max_length=10, choices=STATUS_CHOICES, default=PENDING)
|
||||||
|
amount_cents = models.PositiveIntegerField() # snapshot — ShopItem price may change later
|
||||||
|
granted_writs = models.PositiveIntegerField(default=0) # snapshot
|
||||||
|
granted_token_ids = models.JSONField(default=list, blank=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
succeeded_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["-created_at"]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Purchase({self.user_id}, {self.shop_item.slug}, {self.status})"
|
||||||
|
|
||||||
|
def fulfill(self):
|
||||||
|
"""Mint tokens + grant writs. Idempotent — re-firing on a row that's
|
||||||
|
already SUCCEEDED is a safe no-op (the webhook + the sync
|
||||||
|
`/shop/confirm` view both call this; whichever lands first wins).
|
||||||
|
|
||||||
|
Failures elsewhere shouldn't reach this method — `status=FAILED`
|
||||||
|
rows stay FAILED + don't fulfill."""
|
||||||
|
from django.db import transaction
|
||||||
|
if self.status == self.SUCCEEDED:
|
||||||
|
return
|
||||||
|
if self.status not in (self.PENDING,):
|
||||||
|
# FAILED / REFUNDED — refuse to fulfill.
|
||||||
|
return
|
||||||
|
item = self.shop_item
|
||||||
|
with transaction.atomic():
|
||||||
|
granted_ids = []
|
||||||
|
for _ in range(item.granted_count):
|
||||||
|
t = Token.objects.create(
|
||||||
|
user=self.user,
|
||||||
|
token_type=item.granted_token_type,
|
||||||
|
)
|
||||||
|
granted_ids.append(t.pk)
|
||||||
|
if self.granted_writs:
|
||||||
|
wallet = self.user.wallet
|
||||||
|
wallet.writs = wallet.writs + self.granted_writs
|
||||||
|
wallet.save(update_fields=["writs"])
|
||||||
|
self.granted_token_ids = granted_ids
|
||||||
|
self.status = self.SUCCEEDED
|
||||||
|
self.succeeded_at = timezone.now()
|
||||||
|
self.save(update_fields=[
|
||||||
|
"granted_token_ids", "status", "succeeded_at",
|
||||||
|
])
|
||||||
|
|
||||||
@receiver(post_save, sender=User)
|
@receiver(post_save, sender=User)
|
||||||
def create_wallet_and_tokens(sender, instance, created, **kwargs):
|
def create_wallet_and_tokens(sender, instance, created, **kwargs):
|
||||||
|
|||||||
263
src/apps/lyric/tests/integrated/test_shop_models.py
Normal file
263
src/apps/lyric/tests/integrated/test_shop_models.py
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
"""Shop model ITs — Chunk 2 of the wallet-expansion sprint.
|
||||||
|
|
||||||
|
Pins:
|
||||||
|
* `ShopItem` row shape (slug, name, prose, icon, badge, pricing,
|
||||||
|
granted-token spec, max_owned, display_order, active).
|
||||||
|
* `Purchase` row shape + the `fulfill()` method's idempotent token-
|
||||||
|
mint + writs-grant semantics.
|
||||||
|
* `ShopItem.is_available_for(user)` w/ `max_owned` enforcement.
|
||||||
|
* Seeded catalog: `tithe-1`, `tithe-5`, `band-1` present + correctly
|
||||||
|
configured per the locked decisions in [[project-wallet-shop-
|
||||||
|
expansion]].
|
||||||
|
* Seeded `wallet-shop` Applet present.
|
||||||
|
"""
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from apps.applets.models import Applet
|
||||||
|
from apps.lyric.models import Purchase, ShopItem, Token, User
|
||||||
|
|
||||||
|
|
||||||
|
class ShopItemModelTest(TestCase):
|
||||||
|
def test_create_shopitem_minimal(self):
|
||||||
|
item = ShopItem.objects.create(
|
||||||
|
slug="probe",
|
||||||
|
name="Probe",
|
||||||
|
price_cents=100,
|
||||||
|
granted_token_type=Token.TITHE,
|
||||||
|
granted_count=1,
|
||||||
|
)
|
||||||
|
self.assertEqual(item.slug, "probe")
|
||||||
|
self.assertEqual(item.price_cents, 100)
|
||||||
|
self.assertEqual(item.granted_count, 1)
|
||||||
|
|
||||||
|
def test_granted_writs_defaults_to_zero(self):
|
||||||
|
item = ShopItem.objects.create(
|
||||||
|
slug="probe",
|
||||||
|
name="Probe",
|
||||||
|
price_cents=100,
|
||||||
|
granted_token_type=Token.TITHE,
|
||||||
|
granted_count=1,
|
||||||
|
)
|
||||||
|
self.assertEqual(item.granted_writs, 0)
|
||||||
|
|
||||||
|
def test_max_owned_defaults_to_null(self):
|
||||||
|
item = ShopItem.objects.create(
|
||||||
|
slug="probe",
|
||||||
|
name="Probe",
|
||||||
|
price_cents=100,
|
||||||
|
granted_token_type=Token.TITHE,
|
||||||
|
granted_count=1,
|
||||||
|
)
|
||||||
|
self.assertIsNone(item.max_owned)
|
||||||
|
|
||||||
|
def test_active_defaults_to_true(self):
|
||||||
|
item = ShopItem.objects.create(
|
||||||
|
slug="probe",
|
||||||
|
name="Probe",
|
||||||
|
price_cents=100,
|
||||||
|
granted_token_type=Token.TITHE,
|
||||||
|
granted_count=1,
|
||||||
|
)
|
||||||
|
self.assertTrue(item.active)
|
||||||
|
|
||||||
|
def test_str_returns_name(self):
|
||||||
|
item = ShopItem.objects.create(
|
||||||
|
slug="probe", name="Wristband", price_cents=2000,
|
||||||
|
granted_token_type=Token.BAND, granted_count=1,
|
||||||
|
)
|
||||||
|
self.assertEqual(str(item), "Wristband")
|
||||||
|
|
||||||
|
def test_is_available_for_unlimited_item(self):
|
||||||
|
"""`max_owned=None` → item is always available."""
|
||||||
|
item = ShopItem.objects.create(
|
||||||
|
slug="probe", name="Tithe", price_cents=100,
|
||||||
|
granted_token_type=Token.TITHE, granted_count=1,
|
||||||
|
)
|
||||||
|
user = User.objects.create(email="ok@test.io")
|
||||||
|
self.assertTrue(item.is_available_for(user))
|
||||||
|
|
||||||
|
def test_is_available_for_when_under_max_owned(self):
|
||||||
|
"""`max_owned=1` + user owns 0 → available."""
|
||||||
|
item = ShopItem.objects.create(
|
||||||
|
slug="band", name="Wristband", price_cents=2000,
|
||||||
|
granted_token_type=Token.BAND, granted_count=1, max_owned=1,
|
||||||
|
)
|
||||||
|
user = User.objects.create(email="ok@test.io")
|
||||||
|
self.assertTrue(item.is_available_for(user))
|
||||||
|
|
||||||
|
def test_is_not_available_for_at_max_owned(self):
|
||||||
|
"""`max_owned=1` + user already owns 1 BAND → not available."""
|
||||||
|
item = ShopItem.objects.create(
|
||||||
|
slug="band", name="Wristband", price_cents=2000,
|
||||||
|
granted_token_type=Token.BAND, granted_count=1, max_owned=1,
|
||||||
|
)
|
||||||
|
user = User.objects.create(email="bandowner@test.io")
|
||||||
|
Token.objects.create(user=user, token_type=Token.BAND)
|
||||||
|
self.assertFalse(item.is_available_for(user))
|
||||||
|
|
||||||
|
|
||||||
|
class PurchaseModelTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create(email="buyer@test.io")
|
||||||
|
# Drop the auto-FREE so the FREE-token count assertions stay tight.
|
||||||
|
self.user.tokens.all().delete()
|
||||||
|
self.user.refresh_from_db()
|
||||||
|
self.tithe_item = ShopItem.objects.create(
|
||||||
|
slug="probe-tithe", name="Tithe", price_cents=100,
|
||||||
|
granted_token_type=Token.TITHE, granted_count=1, granted_writs=144,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_create_purchase_minimal(self):
|
||||||
|
purchase = Purchase.objects.create(
|
||||||
|
user=self.user,
|
||||||
|
shop_item=self.tithe_item,
|
||||||
|
stripe_payment_intent_id="pi_test_123",
|
||||||
|
amount_cents=100,
|
||||||
|
granted_writs=144,
|
||||||
|
)
|
||||||
|
self.assertEqual(purchase.user, self.user)
|
||||||
|
self.assertEqual(purchase.shop_item, self.tithe_item)
|
||||||
|
self.assertEqual(purchase.status, Purchase.PENDING)
|
||||||
|
self.assertEqual(purchase.granted_token_ids, [])
|
||||||
|
self.assertIsNone(purchase.succeeded_at)
|
||||||
|
|
||||||
|
def test_stripe_payment_intent_id_is_unique(self):
|
||||||
|
Purchase.objects.create(
|
||||||
|
user=self.user, shop_item=self.tithe_item,
|
||||||
|
stripe_payment_intent_id="pi_dup", amount_cents=100, granted_writs=144,
|
||||||
|
)
|
||||||
|
with self.assertRaises(Exception):
|
||||||
|
Purchase.objects.create(
|
||||||
|
user=self.user, shop_item=self.tithe_item,
|
||||||
|
stripe_payment_intent_id="pi_dup",
|
||||||
|
amount_cents=100, granted_writs=144,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_fulfill_mints_tokens(self):
|
||||||
|
purchase = Purchase.objects.create(
|
||||||
|
user=self.user, shop_item=self.tithe_item,
|
||||||
|
stripe_payment_intent_id="pi_fulfill_1",
|
||||||
|
amount_cents=100, granted_writs=144,
|
||||||
|
)
|
||||||
|
purchase.fulfill()
|
||||||
|
self.assertEqual(
|
||||||
|
self.user.tokens.filter(token_type=Token.TITHE).count(), 1
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_fulfill_grants_writs(self):
|
||||||
|
purchase = Purchase.objects.create(
|
||||||
|
user=self.user, shop_item=self.tithe_item,
|
||||||
|
stripe_payment_intent_id="pi_fulfill_2",
|
||||||
|
amount_cents=100, granted_writs=144,
|
||||||
|
)
|
||||||
|
before = self.user.wallet.writs
|
||||||
|
purchase.fulfill()
|
||||||
|
self.user.wallet.refresh_from_db()
|
||||||
|
self.assertEqual(self.user.wallet.writs, before + 144)
|
||||||
|
|
||||||
|
def test_fulfill_marks_succeeded(self):
|
||||||
|
purchase = Purchase.objects.create(
|
||||||
|
user=self.user, shop_item=self.tithe_item,
|
||||||
|
stripe_payment_intent_id="pi_fulfill_3",
|
||||||
|
amount_cents=100, granted_writs=144,
|
||||||
|
)
|
||||||
|
purchase.fulfill()
|
||||||
|
purchase.refresh_from_db()
|
||||||
|
self.assertEqual(purchase.status, Purchase.SUCCEEDED)
|
||||||
|
self.assertIsNotNone(purchase.succeeded_at)
|
||||||
|
|
||||||
|
def test_fulfill_records_granted_token_ids(self):
|
||||||
|
purchase = Purchase.objects.create(
|
||||||
|
user=self.user, shop_item=self.tithe_item,
|
||||||
|
stripe_payment_intent_id="pi_fulfill_4",
|
||||||
|
amount_cents=100, granted_writs=144,
|
||||||
|
)
|
||||||
|
purchase.fulfill()
|
||||||
|
purchase.refresh_from_db()
|
||||||
|
tithe = self.user.tokens.filter(token_type=Token.TITHE).first()
|
||||||
|
self.assertIn(tithe.pk, purchase.granted_token_ids)
|
||||||
|
|
||||||
|
def test_fulfill_is_idempotent(self):
|
||||||
|
"""Webhook + sync-confirm race could fire `fulfill()` twice — second
|
||||||
|
call must be a no-op (no double-mint, no double-writs)."""
|
||||||
|
purchase = Purchase.objects.create(
|
||||||
|
user=self.user, shop_item=self.tithe_item,
|
||||||
|
stripe_payment_intent_id="pi_idem",
|
||||||
|
amount_cents=100, granted_writs=144,
|
||||||
|
)
|
||||||
|
purchase.fulfill()
|
||||||
|
first_writs = self.user.wallet.refresh_from_db() or self.user.wallet.writs
|
||||||
|
purchase.fulfill()
|
||||||
|
self.user.wallet.refresh_from_db()
|
||||||
|
self.assertEqual(self.user.wallet.writs, first_writs)
|
||||||
|
self.assertEqual(
|
||||||
|
self.user.tokens.filter(token_type=Token.TITHE).count(), 1
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_fulfill_creates_multiple_tokens_for_bundle(self):
|
||||||
|
bundle = ShopItem.objects.create(
|
||||||
|
slug="probe-bundle", name="Tithe ×5", price_cents=400,
|
||||||
|
granted_token_type=Token.TITHE, granted_count=5, granted_writs=750,
|
||||||
|
)
|
||||||
|
purchase = Purchase.objects.create(
|
||||||
|
user=self.user, shop_item=bundle,
|
||||||
|
stripe_payment_intent_id="pi_bundle",
|
||||||
|
amount_cents=400, granted_writs=750,
|
||||||
|
)
|
||||||
|
purchase.fulfill()
|
||||||
|
self.assertEqual(
|
||||||
|
self.user.tokens.filter(token_type=Token.TITHE).count(), 5
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SeededShopCatalogTest(TestCase):
|
||||||
|
"""Migration `lyric/0008_seed_shop_items` seeds the 3 starting items.
|
||||||
|
Pinned here so a future migration that touches the table can't quietly
|
||||||
|
drop / rename / re-price them without a deliberate update to these
|
||||||
|
assertions."""
|
||||||
|
|
||||||
|
def test_tithe_one_item_present(self):
|
||||||
|
item = ShopItem.objects.get(slug="tithe-1")
|
||||||
|
self.assertEqual(item.price_cents, 100)
|
||||||
|
self.assertEqual(item.granted_token_type, Token.TITHE)
|
||||||
|
self.assertEqual(item.granted_count, 1)
|
||||||
|
self.assertEqual(item.granted_writs, 144)
|
||||||
|
self.assertIsNone(item.max_owned)
|
||||||
|
|
||||||
|
def test_tithe_five_bundle_item_present(self):
|
||||||
|
item = ShopItem.objects.get(slug="tithe-5")
|
||||||
|
self.assertEqual(item.price_cents, 400)
|
||||||
|
self.assertEqual(item.granted_count, 5)
|
||||||
|
self.assertEqual(item.granted_writs, 750)
|
||||||
|
self.assertEqual(item.badge_text, "×5")
|
||||||
|
|
||||||
|
def test_band_item_present(self):
|
||||||
|
item = ShopItem.objects.get(slug="band-1")
|
||||||
|
self.assertEqual(item.price_cents, 2000)
|
||||||
|
self.assertEqual(item.granted_token_type, Token.BAND)
|
||||||
|
self.assertEqual(item.granted_count, 1)
|
||||||
|
self.assertEqual(item.max_owned, 1)
|
||||||
|
self.assertEqual(item.granted_writs, 0)
|
||||||
|
|
||||||
|
def test_all_three_items_active(self):
|
||||||
|
for slug in ("tithe-1", "tithe-5", "band-1"):
|
||||||
|
self.assertTrue(
|
||||||
|
ShopItem.objects.get(slug=slug).active,
|
||||||
|
msg=f"{slug} should be active",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_display_order_ascends(self):
|
||||||
|
"""Catalog display order matches the spec: tithe-1 < tithe-5 < band-1."""
|
||||||
|
slugs = list(
|
||||||
|
ShopItem.objects.order_by("display_order").values_list("slug", flat=True)
|
||||||
|
)
|
||||||
|
# filter to the seeded ones (in case future migrations add more)
|
||||||
|
seeded = [s for s in slugs if s in ("tithe-1", "tithe-5", "band-1")]
|
||||||
|
self.assertEqual(seeded, ["tithe-1", "tithe-5", "band-1"])
|
||||||
|
|
||||||
|
|
||||||
|
class SeededWalletShopAppletTest(TestCase):
|
||||||
|
def test_wallet_shop_applet_present(self):
|
||||||
|
applet = Applet.objects.get(slug="wallet-shop")
|
||||||
|
self.assertEqual(applet.context, Applet.WALLET)
|
||||||
|
self.assertEqual(applet.grid_cols, 12)
|
||||||
14
src/templates/apps/wallet/_partials/_applet-wallet-shop.html
Normal file
14
src/templates/apps/wallet/_partials/_applet-wallet-shop.html
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<section
|
||||||
|
id="id_wallet_shop"
|
||||||
|
class="wallet-shop"
|
||||||
|
style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};"
|
||||||
|
>
|
||||||
|
{% comment %}
|
||||||
|
Stub. Chunk 2 of the wallet-expansion sprint ships the row + model
|
||||||
|
catalog; Chunk 4 fills in shop-tile rendering + BUY-ITEM microtooltip
|
||||||
|
+ Stripe.js wiring. Keep the <section> element + id_wallet_shop hook
|
||||||
|
so any test that just checks for shop visibility passes from Chunk 2
|
||||||
|
onward (TDD's "ship the skeleton, fill the body next" pattern).
|
||||||
|
{% endcomment %}
|
||||||
|
<h2>Shop</h2>
|
||||||
|
</section>
|
||||||
Reference in New Issue
Block a user