diff --git a/src/apps/applets/migrations/0011_seed_wallet_shop_applet.py b/src/apps/applets/migrations/0011_seed_wallet_shop_applet.py new file mode 100644 index 0000000..edef9f5 --- /dev/null +++ b/src/apps/applets/migrations/0011_seed_wallet_shop_applet.py @@ -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), + ] diff --git a/src/apps/lyric/migrations/0008_shopitem_purchase.py b/src/apps/lyric/migrations/0008_shopitem_purchase.py new file mode 100644 index 0000000..e4b94f7 --- /dev/null +++ b/src/apps/lyric/migrations/0008_shopitem_purchase.py @@ -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'], + }, + ), + ] diff --git a/src/apps/lyric/migrations/0009_seed_shop_items.py b/src/apps/lyric/migrations/0009_seed_shop_items.py new file mode 100644 index 0000000..c2ddd42 --- /dev/null +++ b/src/apps/lyric/migrations/0009_seed_shop_items.py @@ -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), + ] diff --git a/src/apps/lyric/models.py b/src/apps/lyric/models.py index e38cfb1..16d6061 100644 --- a/src/apps/lyric/models.py +++ b/src/apps/lyric/models.py @@ -329,6 +329,131 @@ class PaymentMethod(models.Model): def __str__(self): 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) def create_wallet_and_tokens(sender, instance, created, **kwargs): diff --git a/src/apps/lyric/tests/integrated/test_shop_models.py b/src/apps/lyric/tests/integrated/test_shop_models.py new file mode 100644 index 0000000..5cf4f36 --- /dev/null +++ b/src/apps/lyric/tests/integrated/test_shop_models.py @@ -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) diff --git a/src/templates/apps/wallet/_partials/_applet-wallet-shop.html b/src/templates/apps/wallet/_partials/_applet-wallet-shop.html new file mode 100644 index 0000000..ba3b631 --- /dev/null +++ b/src/templates/apps/wallet/_partials/_applet-wallet-shop.html @@ -0,0 +1,14 @@ +
+ {% 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
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 %} +

Shop

+