From 849ef3c3107a33087d6f0de2913f5fb1c304603a Mon Sep 17 00:00:00 2001 From: Disco DeDisco Date: Fri, 22 May 2026 00:30:59 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20ShopItem=20+=20Purchase=20models=20+=20?= =?UTF-8?q?seed=20`tithe-1`=20/=20`tithe-5`=20/=20`band-1`=20+=20wallet-sh?= =?UTF-8?q?op=20Applet=20=E2=80=94=20Chunk=202=20of=20[[project-wallet-sho?= =?UTF-8?q?p-expansion]].=20`ShopItem`=20is=20the=20admin-managed=20catalo?= =?UTF-8?q?g:=20`slug`,=20`name`,=20`description`,=20`icon`=20(FA=20class)?= =?UTF-8?q?,=20`badge=5Ftext`=20(eg=20"=C3=975"),=20`price=5Fcents`,=20`gr?= =?UTF-8?q?anted=5Ftoken=5Ftype`=20(any=20Token=20type),=20`granted=5Fcoun?= =?UTF-8?q?t`,=20`granted=5Fwrits`=20(default=200),=20`max=5Fowned`=20(nul?= =?UTF-8?q?lable;=20BAND=3D1),=20`display=5Forder`,=20`active`.=20`is=5Fav?= =?UTF-8?q?ailable=5Ffor(user)`=20enforces=20`max=5Fowned`=20by=20comparin?= =?UTF-8?q?g=20user's=20owned-count=20of=20the=20granted=20token=20type.?= =?UTF-8?q?=20`price=5Fdisplay()`=20renders=20cents=20=E2=86=92=20"$1"=20/?= =?UTF-8?q?=20"$4.20"=20for=20tooltip=20prose.=20`Purchase`=20is=20the=20p?= =?UTF-8?q?er-tx=20audit=20trail:=20`user`=20+=20`shop=5Fitem`=20+=20`stri?= =?UTF-8?q?pe=5Fpayment=5Fintent=5Fid`=20(unique)=20+=20`status`=20(PENDIN?= =?UTF-8?q?G/SUCCEEDED/FAILED/REFUNDED)=20+=20`amount=5Fcents`=20snapshot?= =?UTF-8?q?=20+=20`granted=5Fwrits`=20snapshot=20+=20`granted=5Ftoken=5Fid?= =?UTF-8?q?s`=20JSONField=20(PKs=20of=20minted=20tokens)=20+=20`created=5F?= =?UTF-8?q?at`=20+=20`succeeded=5Fat`.=20`fulfill()`=20is=20idempotent=20?= =?UTF-8?q?=E2=80=94=20short-circuits=20if=20status=3D=3DSUCCEEDED=20+=20r?= =?UTF-8?q?efuses=20non-PENDING=20rows=20so=20a=20webhook=20+=20sync=20`/s?= =?UTF-8?q?hop/confirm`=20racing=20each=20other=20can't=20double-mint.=20S?= =?UTF-8?q?chema=20migration=20`lyric/0008=5Fshopitem=5Fpurchase`=20autoge?= =?UTF-8?q?nerated.=20Seed=20migration=20`lyric/0009=5Fseed=5Fshop=5Fitems?= =?UTF-8?q?`=20populates=20the=203=20starting=20items=20per=20locked=20dec?= =?UTF-8?q?isions:=20tithe-1=20($1=20=E2=86=92=201=20TITHE=20+=20144=20wri?= =?UTF-8?q?ts,=20no=20cap,=20order=3D10);=20tithe-5=20($4=20=E2=86=92=205?= =?UTF-8?q?=20TITHE=20+=20750=20writs,=20no=20cap,=20badge=20"=C3=975",=20?= =?UTF-8?q?order=3D20);=20band-1=20($20=20=E2=86=92=201=20BAND=20+=200=20w?= =?UTF-8?q?rits,=20max=5Fowned=3D1,=20order=3D30).=20Applet=20migration=20?= =?UTF-8?q?`applets/0011=5Fseed=5Fwallet=5Fshop=5Fapplet`=20adds=20the=20`?= =?UTF-8?q?wallet-shop`=20Applet=20(context=3Dwallet,=2012=20cols=20=C3=97?= =?UTF-8?q?=203=20rows).=20Stub=20`=5Fapplet-wallet-shop.html`=20lands=20w?= =?UTF-8?q?.=20just=20``=20+=20`

S?= =?UTF-8?q?hop

`=20=E2=80=94=20`=5Fapplets.html`'s=20auto-include-by-s?= =?UTF-8?q?lug=20pattern=20would=20500=20the=20wallet=20page=20on=20Templa?= =?UTF-8?q?teDoesNotExist=20otherwise=20(caught=20mid-Chunk-2=20by=20the?= =?UTF-8?q?=20full=20app=20suite).=20Chunk=204=20fills=20in=20the=20shop-t?= =?UTF-8?q?ile=20grid=20+=20BUY-ITEM=20microtooltip=20+=20Stripe.js=20wiri?= =?UTF-8?q?ng.=20TDD=20=E2=80=94=2022=20ITs=20in=20`test=5Fshop=5Fmodels.p?= =?UTF-8?q?y`:=20`ShopItemModelTest`=20(9=20cases=20=E2=80=94=20minimal=20?= =?UTF-8?q?create,=20defaults=20for=20granted=5Fwrits=20/=20max=5Fowned=20?= =?UTF-8?q?/=20active,=20`is=5Favailable=5Ffor`=20w/=20+=20w/o=20max=5Fown?= =?UTF-8?q?ed=20cap,=20str=20repr),=20`PurchaseModelTest`=20(8=20cases=20?= =?UTF-8?q?=E2=80=94=20minimal=20create,=20PI=20ID=20uniqueness=20constrai?= =?UTF-8?q?nt,=20fulfill=20mints=20tokens=20+=20grants=20writs=20+=20marks?= =?UTF-8?q?=20SUCCEEDED=20+=20records=20granted=5Ftoken=5Fids=20+=20is=20i?= =?UTF-8?q?dempotent=20on=20re-fire=20+=20creates=20N=20tokens=20for=20bun?= =?UTF-8?q?dle),=20`SeededShopCatalogTest`=20(4=20cases=20pin=20tithe-1=20?= =?UTF-8?q?/=20tithe-5=20/=20band-1=20row=20shapes=20+=20display=5Forder?= =?UTF-8?q?=20ascending),=20`SeededWalletShopAppletTest`=20(1=20case=20pin?= =?UTF-8?q?s=20Applet=20seeded).=201191=20IT/UT=20green?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- .../0011_seed_wallet_shop_applet.py | 43 +++ .../migrations/0008_shopitem_purchase.py | 54 ++++ .../lyric/migrations/0009_seed_shop_items.py | 80 ++++++ src/apps/lyric/models.py | 125 +++++++++ .../tests/integrated/test_shop_models.py | 263 ++++++++++++++++++ .../wallet/_partials/_applet-wallet-shop.html | 14 + 6 files changed, 579 insertions(+) create mode 100644 src/apps/applets/migrations/0011_seed_wallet_shop_applet.py create mode 100644 src/apps/lyric/migrations/0008_shopitem_purchase.py create mode 100644 src/apps/lyric/migrations/0009_seed_shop_items.py create mode 100644 src/apps/lyric/tests/integrated/test_shop_models.py create mode 100644 src/templates/apps/wallet/_partials/_applet-wallet-shop.html 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

+