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:
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user