Compare commits

...

7 Commits

Author SHA1 Message Date
Disco DeDisco
e90f10fe47 feat: shop tooltip price moves to the title row, right-aligned --priGn. The <h4 class="tt-title"> already has display: flex; justify-content: space-between; align-items: baseline; gap: 0.5rem (from _tooltips.scss:31-46's .tt block, originally meant for the .token-count chip pattern in Tokens row), so wrapping the name + price as two sibling <span>s inside the h4 auto-spaces: name pinned left, price pinned right, on the same baseline. .tt-price joins .tt-expiry (priRd) + .tt-date (priGn) in the shared %tt-token-fields placeholder at _tooltips.scss:8-19 — same shape (1rem) as both, --priGn coloring to mirror .tt-date's "in the green" semantics for the payment cue. Standalone <p class="tt-price"> line below the description is dropped (price now lives in the title row). 1211 IT/UT still green; no test changes needed — existing FT assertion (assertIn("$1", tithe1_tt)) reads .tt innerHTML which still contains the dollar string in either position
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 02:32:05 -04:00
Disco DeDisco
25f55f728a feat: wallet Shop polish — microtooltip extraction, Shop-first ordering, DRY tooltip styling, writs rebalance, "no expiry" on all items. Visual-pass tweaks landing atop the 5-chunk Shop rollout (commits 8e476f5d28cf7b). **Microtooltip extraction**: .tt-microbutton-portal (Chunk 4's wrap-inside-.tt) replaced w. a sibling .tt-micro div on each .shop-tile. wallet.js's initWalletTooltips clones BOTH into separate portals on hover — .tt#id_tooltip_portal (main card), .tt-micro#id_mini_tooltip_portal (small italic pill at bottom-right of main, mirroring Game Kit's Equipped/Unequipped/In-Use mini portal). Hover persistence covers both portals + the source tile w. a 200ms grace timer cancelled by mouseenter on any of the 3 zones. Capped items (BAND-owned) render NO btn at all — just "Already owned" microtext (mirrors Game Kit's status-only "Equipped" pill rather than the disabled-× pattern that lived in Chunk 4). **Tooltip-pin on guard open**: WalletTooltips.pin() / .unpin() exposed on window; wallet-shop.js's BUY click calls pin() before showGuard() + both onConfirm / onDismiss callbacks call unpin() → the item tooltip stays visible behind the guard's "Buy {name} for ${price}?" prompt instead of orphaning. **Shop-first applet ordering**: new Applet.display_order field (default 100, lower = earlier; PK tie-break preserves legacy insertion-order for the existing 3 applets); seed migration sets wallet-shop.display_order=10 so Shop renders atop Balances/Tokens/Payment. applet_context() updated to .order_by("display_order", "pk"). New WalletAppletOrderTest (2 ITs) pins Shop-first DOM order + view-context list. **DRY tooltip styling**: shop tooltip now uses the same 4-slot .tt-title / .tt-description / .tt-shoptalk / .tt-expiry classes as the Tokens row. New ShopItem.shoptalk field for the italic flavor line (band-1 = "Unlimited free entry (BYOB)" split out of description; tithes blank). New ShopItem.tooltip_expiry() method returns "no expiry" — eternal-stock convention (all current items; seasonal listings could override later). **Writs rebalance**: locked 2026-05-22 — tithe-1 144→12 writs, tithe-5 750→60 writs. Description text updated in lockstep ("1 Tithe Token + 12 Writs" / "5 Tithe Tokens + 60 Writs"). **Badge tweak**: ×N badge shrunk 2rem → 1.5rem + nudged further off-tile (top: -0.7rem, right: -1rem) so most of the underlying icon stays visible. **SCSS**: .tt-micro hidden in source DOM (portal-only); #id_mini_tooltip_portal mostly mirrors gameboard's mini at _gameboard.scss:140 but allows BUY-btn label to wrap onto multiple lines (white-space: normal on .tt-buy-btn); .tt-already-owned styled w. --secUser italic at 0.85rem to match Game Kit pills. **Migrations** — 5 new: lyric/0010_repricing_tithe_writs (writs + description), lyric/0011_shopitem_shoptalk (schema), lyric/0012_seed_shop_shoptalk (band split), applets/0012_applet_display_order (schema), applets/0013_wallet_shop_display_order (Shop atop). All idempotent. **TDD** — 5 new ITs across test_shop_models.py (shoptalk default + per-item assertions, tooltip_expiry method, updated tithe writs values, WalletAppletOrderTest), 1 new FT (test_shop_buy_guard_portal_pins_item_tooltip — programmatically dispatches mouseenter/mouseleave to exercise the pin/unpin race), 3 new Jasmine specs (T6 pin-on-click, T7 unpin-on-confirm, T8 unpin-on-dismiss). Existing FT band-owned assertion switched to .tt-micro (no .tt-buy-btn present), Jasmine T2 rewritten to assert no btn renders. **3 traps caught** mid-build: (a) multi-line {# #} comment leaked into DOM again (cf [[feedback-django-comments-single-line-only]]) — pinned the trap; (b) spyOn(window, 'fetch') Jasmine double-spy collision (cf trapped previously); (c) async pollution where afterEach restores window.Stripe=undefined before _doBuy's continuation hits it — fixed by per-test never-resolving fetch mock. 1211 IT/UT + 9 wallet FTs green; Jasmine SpecRunner verified visually (FT hangs Selenium-side on spec count). Pipeline will sweep all FTs
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 02:21:10 -04:00
Disco DeDisco
d28cf7b538 chore: drop legacy #id_tithe_token_shop block from Balances applet — Chunk 5 (final) of [[project-wallet-shop-expansion]]. The inline 1 Tithe Token +144 Writs $1.00 / 5 Tithe Tokens +750 Writs $4.00 token-bundle HTML in _applet-wallet-balances.html was display-only (no purchase wiring was ever attached) + has been fully superseded by the dedicated Shop applet shipped in Chunks 2-4. Per the locked decision in the scope doc, Balances is now read-only — writs + esteem totals only — and the Shop is the canonical purchase surface. **Removed**: 8 lines of <div id="id_tithe_token_shop"> w. 2 .token-bundle children. **Replaced with** a {% comment %} pointer noting the move so the next archeologist looking at the Balances HTML doesn't reinvent the wheel. **Dropped tests**: WalletViewTest.test_wallet_page_shows_tithe_token_shop + :test_tithe_token_shop_shows_bundle ITs + the legacy test_user_can_purchase_tithe_token_bundle FT — all asserted the now-removed selector. Replaced w. a comment pointing to the 3 new shop FTs (test_shop_applet_renders_seeded_items_with_icons_and_badges, test_shop_buy_click_opens_guard_portal_with_purchase_prompt, test_shop_band_already_owned_shows_disabled_buy_btn) + the model + view ITs in test_shop_models.py + test_shop_views.py. 1206 IT/UT (was 1208 — 2 stale ITs gone) + 8 wallet FTs (was 9 — 1 stale FT gone) green
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 01:23:07 -04:00
Disco DeDisco
81b3c112b4 feat: wallet Shop applet — tile grid + BUY-ITEM microbutton + Stripe.js wiring — Chunk 4 of [[project-wallet-shop-expansion]]. The shop applet (slug wallet-shop, seeded in Chunk 2) now renders the catalog as a horizontal grid of .shop-tile icons: tithe-1 ($1, fa-piggy-bank), tithe-5 ($4, fa-piggy-bank w. ×5 badge), band-1 ($20, fa-ring). Each tile hosts a hover-portaled tooltip carrying name + description + price + a .tt-microbutton-portal w. a .btn-primary BUY ITEM button — clicking opens #id_guard_portal w. "Buy {name} for ${price}?" prompt; confirming triggers Stripe.js confirmCardPayment then POSTs to /shop/confirm + reloads. Items where the user's owned-count has hit max_owned (eg. BAND, owned=1, cap=1) render w. .btn-disabled + × glyph + "Already owned" microtooltip text — visible-but-unbuyable per the locked decision. View context — wallet view + toggle_wallet_applets view both pass shop_items (decorated w. per-user .available via the new _shop_items_for(user) helper) + default_payment_method_id + stripe_publishable_key. SCSS — .wallet-shop (flex column wrapping .shop-grid flex row), .shop-tile (inline-flex tooltip target), .shop-badge (2rem circle, --quaUser glyph on --quiUser bg, top-right corner per spec), .tt-microbutton-portal (column-flex, BUY btn + 'Already owned' caption styling). JS in wallet-shop.js exposes a singleton WalletShop module (matching the project's Brief / SeaDeal / StageCard module pattern) w. a tested initWalletShop() method — uses event delegation on the shop root (so portal-relocated buy btns still hit the handler) + a DOM-keyed data-shop-wired flag (not a module-level boolean) so per-test fixture rebuilds re-wire cleanly. Wired into wallet.html after wallet.js. **TDD** — 5 Jasmine specs in WalletShopSpec.js: T1 click-on-enabled-BUY opens guard w. correct prompt; T2 click-on-disabled-BUY no-op; T3 onConfirm POSTs shop_item_slug to /shop/buy; T4 init idempotent (calling twice doesn't double-wire); T5 missing-root no-throw. **2 Jasmine traps caught**: (a) spyOn(window, 'fetch') collides if another spec already spied on fetch — switched to save+restore via per-test _origFetch capture; (b) T3 async pollution — sync assertion passed, afterEach restored window.Stripe=undefined, then _doBuy's async continuation hit Stripe(pubKey) and threw "Unhandled promise rejection". Fixed by T3-local fetch mock returning a never-resolving promise so the chain pauses at the first await. **3 new FTs** in test_dash_wallet.py: tiles + icons + ×5 badge + tooltip prose; BUY click opens guard portal + NVM dismisses; BAND-already-owned shows disabled BUY w. 'Already owned' microtext (reads via textContent since .tt is display: none). FT trap caught: TransactionTestCase wipes both migration-seeded Applets + ShopItems → setUp must re-seed both manually (mirrors test_shop_views.py's _seed_starting_items pattern). 1208 IT/UT + 9 wallet FTs + 5 Jasmine specs green
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 01:15:05 -04:00
Disco DeDisco
410664fb0f feat: shop PaymentIntent flow — shop_buy + shop_confirm + stripe_webhook — Chunk 3 of [[project-wallet-shop-expansion]]. Three-endpoint split per the locked Stripe design: webhook is authoritative for fulfillment (resilient to 3DS, browser closes, network drops); sync /shop/confirm is a best-effort UX speedup (fulfills immediately when Stripe.js confirms client-side, no waiting for webhook delivery); both call Purchase.fulfill() which is idempotent — whichever lands first wins, the other becomes a no-op via the status==SUCCEEDED guard. **POST /dashboard/wallet/shop/buy** (form-encoded shop_item_slug): looks up active ShopItem (404 if missing/inactive); enforces max_owned via is_available_for(user) (409 if cap hit, eg already-owned BAND); requires a saved PaymentMethod (402 otherwise — picks most-recent via order_by('-pk').first() per the open-Q note in the scope doc); creates Stripe PaymentIntent (amount=item.price_cents, currency=usd, customer=user.stripe_customer_id, payment_method=pm.stripe_pm_id, automatic_payment_methods={enabled, allow_redirects=never} for in-window 3DS); creates Purchase w. pi.id; backfills pi.metadata.purchase_id via PaymentIntent.modify so the webhook handler can resolve back to the row; returns {client_secret, purchase_id} JSON for Stripe.js confirmCardPayment. **POST /dashboard/wallet/shop/confirm** (form-encoded purchase_id): retrieves PI from Stripe, if status=='succeeded' calls purchase.fulfill(); returns {status} JSON. 404 if the purchase doesn't belong to request.user. Idempotent — re-firing after fulfill is a safe no-op. **POST /stripe/webhook** (csrf_exempt, mounted at root /stripe/webhook so the URL stays stable across app-routing refactors w. Stripe's dashboard config): verifies signature via stripe.Webhook.construct_event against STRIPE_WEBHOOK_SECRET env var (400 on mismatch — Stripe won't retry on 4xx, only 5xx); on payment_intent.succeeded looks up Purchase by metadata.purchase_id w. fall-back to stripe_payment_intent_id (both unique). Unknown event types are no-op 200 (Stripe sends charge.dispute.created etc. + would retry indefinitely on 5xx). New STRIPE_WEBHOOK_SECRET = os.environ.get(...) setting; user swaps it on staging+prod per the live-mode env-var-only decision. TDD — 17 ITs in test_shop_views.py across 3 classes: ShopBuyViewTest (7 cases — login required, success path creates PI + Purchase w. correct shape, PI.create called w. correct args, unknown slug 404, inactive item 404, max_owned 409, no PM 402); ShopConfirmViewTest (5 cases — login required, succeeded PI triggers fulfill, processing PI leaves PENDING, idempotent on already-SUCCEEDED, other user's purchase 404); StripeWebhookViewTest (5 cases — sig mismatch 400, succeeded event triggers fulfill, unknown event type 2xx no-op, duplicate delivery idempotent, unknown purchase_id 2xx no-op). All Stripe API calls mocked via mock.patch('apps.dashboard.views.stripe'). 1208 IT/UT green
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 00:42:09 -04:00
Disco DeDisco
849ef3c310 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>
2026-05-22 00:30:59 -04:00
Disco DeDisco
8e476f5658 feat: wallet Tokens applet shows CARTE + BAND + COIN + PASS independently — Chunk 1 of the Shop applet rollout per [[project-wallet-shop-expansion]]. Pre-Chunk-1 the _applet-wallet-tokens.html template used a {% if pass_token %} ... {% elif band %} ... {% elif coin %} chain that suppressed 2-of-3 trinkets from the wallet whenever the user held a higher-priority one — bad UX since the equip slot is now the user's opt-in for trinket-as-token use per [[feedback-equip-slot-gates-trinket-use]], so ALL owned trinkets need visibility. Fix: dropped the elif chain → independent {% if %} blocks for PASS / BAND / COIN; added a new CARTE block w. fa-money-check icon mirroring the Game Kit's render. View context (apps.dashboard.views.wallet + :toggle_wallet_applets) now passes carte = user.tokens.filter(token_type=Token.CARTE).first() alongside the existing pass/band/coin keys (no is_staff filter — CARTE has no admin gate). TDD — new WalletTokensAppletAllTrinketsVisibleTest (9 ITs): 6 pin individual #id_<token> visibility for a staff user holding all 5 types, 2 pin view-context shape (carte + band keys), 1 pins CARTE-on-non-staff. New FT test_wallet_tokens_applet_shows_all_owned_trinket_types reads BAND/CARTE .tt innerHTML directly (no hover ceremony — already covered by the COIN/FREE hover paths in test_new_user_wallet_shows_starting_balances) to pin the new template blocks server-render full tooltip prose. **Trap caught mid-build**: initial multi-line {# ... #} Django comment leaked as plain text into the rendered DOM (Django's hash-comment is single-line only), pushing the COIN tile off-screen + breaking the existing hover FT. Switched to {% comment %}...{% endcomment %}. Captured in [[feedback-django-comments-single-line-only]] — symptom signature: previously-passing Selenium hover times out + screendump shows literal {# ... text near the broken element. 1169 IT/UT + 6 wallet FTs green
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 23:07:42 -04:00
31 changed files with 2511 additions and 61 deletions

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

View File

@@ -0,0 +1,18 @@
# Generated by Django 6.0 on 2026-05-22 05:27
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('applets', '0011_seed_wallet_shop_applet'),
]
operations = [
migrations.AddField(
model_name='applet',
name='display_order',
field=models.PositiveSmallIntegerField(default=100),
),
]

View File

@@ -0,0 +1,31 @@
"""Pin the wallet Shop applet atop the wallet row.
The 4-applet wallet layout (per [[project-wallet-shop-expansion]]) wants
Shop first; the other 3 (Balances, Tokens, Payment) keep their historical
order via the default `display_order=100` + PK tie-break.
Idempotent — `update_or_create(slug=…, defaults={display_order: 10})`
also covers fresh DBs where `0011_seed_wallet_shop_applet` already ran.
"""
from django.db import migrations
def forward(apps, schema_editor):
Applet = apps.get_model("applets", "Applet")
Applet.objects.filter(slug="wallet-shop").update(display_order=10)
def reverse(apps, schema_editor):
Applet = apps.get_model("applets", "Applet")
Applet.objects.filter(slug="wallet-shop").update(display_order=100)
class Migration(migrations.Migration):
dependencies = [
("applets", "0012_applet_display_order"),
]
operations = [
migrations.RunPython(forward, reverse),
]

View File

@@ -18,6 +18,11 @@ class Applet(models.Model):
default_visible = models.BooleanField(default=True) default_visible = models.BooleanField(default=True)
grid_cols = models.PositiveSmallIntegerField(default=12) grid_cols = models.PositiveSmallIntegerField(default=12)
grid_rows = models.PositiveSmallIntegerField(default=3) grid_rows = models.PositiveSmallIntegerField(default=3)
# Render-time sort key. Lower = earlier in the applets row. Default 100
# gives every existing applet a tied position → falls back to PK insertion
# order (the historical behavior), so this field is backwards-compatible.
# Set to <100 to pin an applet ABOVE the rest (eg. wallet-shop = 10).
display_order = models.PositiveSmallIntegerField(default=100)
def __str__(self): def __str__(self):
return self.name return self.name

View File

@@ -13,9 +13,12 @@ def apply_applet_toggle(user, context, checked_slugs):
def applet_context(user, context): def applet_context(user, context):
ua_map = {ua.applet_id: ua.visible for ua in user.user_applets.all()} ua_map = {ua.applet_id: ua.visible for ua in user.user_applets.all()}
applets = {a.slug: a for a in Applet.objects.filter(context=context)} # `display_order` (lower = earlier) is the primary sort key; `pk` tie-breaks
# so applets at the default order=100 keep their historical insertion-order
# rendering. New applets that want pinned positions set order < 100 in
# their seed migration (eg. wallet-shop = 10 to render atop the wallet row).
applets_qs = Applet.objects.filter(context=context).order_by("display_order", "pk")
return [ return [
{"applet": applets[slug], "visible": ua_map.get(applets[slug].pk, applets[slug].default_visible)} {"applet": a, "visible": ua_map.get(a.pk, a.default_visible)}
for slug in applets for a in applets_qs
if slug in applets
] ]

View File

@@ -0,0 +1,153 @@
// wallet-shop.js — BUY-ITEM click handler for the wallet's Shop applet.
//
// Flow:
// 1. User clicks a `.tt-buy-btn` inside a `.shop-tile`.
// 2. We open the global guard portal (`window.showGuard`) w. a prompt
// naming the item + price.
// 3. On OK confirm → POST /dashboard/wallet/shop/buy {shop_item_slug} →
// get {client_secret, purchase_id}.
// 4. Stripe.js confirmCardPayment (handles 3DS natively).
// 5. On Stripe success → POST /dashboard/wallet/shop/confirm {purchase_id}.
// 6. Reload the wallet (tokens + balances update server-side via the
// Purchase.fulfill() chain that fires from BOTH /shop/confirm AND
// the /stripe/webhook handler — whichever lands first, idempotent).
//
// Disabled tiles (`.btn-disabled` on the BUY btn — eg already-owned BAND)
// are a no-op: clicks don't open the guard portal.
//
// Module pattern matches the rest of the project (`Brief`, `SeaDeal`,
// `StageCard`) — exposes a singleton `WalletShop` w. a tested public
// `initWalletShop()` method.
const WalletShop = (function () {
'use strict';
function _getCsrf() {
const m = document.cookie.match(/csrftoken=([^;]+)/);
return m ? m[1] : '';
}
async function _doBuy(slug, btn, shopRoot) {
const pmId = shopRoot.dataset.defaultPaymentMethodId || '';
const pubKey = shopRoot.dataset.stripePublishableKey || '';
if (!pmId) {
// No saved payment method — surface a friendly message + bail.
// (Server-side this would 402, but we'd rather not waste the
// round trip OR confuse the user w. a stripe-side error.)
alert('Add a payment method below first.');
return;
}
const buyRes = await fetch('/dashboard/wallet/shop/buy', {
method: 'POST',
headers: {
'X-CSRFToken': _getCsrf(),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: 'shop_item_slug=' + encodeURIComponent(slug),
});
if (!buyRes.ok) {
alert('Could not start purchase (' + buyRes.status + ').');
return;
}
const { client_secret, purchase_id } = await buyRes.json();
const stripe = window.Stripe(pubKey);
const { error, paymentIntent } = await stripe.confirmCardPayment(
client_secret, { payment_method: pmId },
);
if (error) {
alert('Payment failed: ' + (error.message || 'unknown error'));
return;
}
if (paymentIntent && paymentIntent.status === 'succeeded') {
// Sync-confirm endpoint: belt-and-suspenders w. the webhook —
// whichever lands first wins via Purchase.fulfill()'s idempotent
// status guard. We always call it for the snappy UX.
await fetch('/dashboard/wallet/shop/confirm', {
method: 'POST',
headers: {
'X-CSRFToken': _getCsrf(),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: 'purchase_id=' + encodeURIComponent(purchase_id),
});
// Reload to refresh the Tokens + Balances applets w. the
// freshly-minted tokens + writs.
window.location.reload();
}
}
function _onBuyClick(e) {
const btn = e.target.closest('.tt-buy-btn');
if (!btn) return;
if (btn.classList.contains('btn-disabled')) return;
e.preventDefault();
e.stopPropagation();
// The btn may be the original (inside .shop-tile) OR the portal
// clone (sibling of .wallet-page) — `closest('.shop-tile')` only
// works on the former. Read every datum from the btn itself + look
// up the shop root via document.querySelector (singleton).
const shopRoot = document.querySelector('.wallet-shop');
if (!shopRoot) return;
const slug = btn.dataset.shopItemSlug;
const name = btn.dataset.itemName || slug;
const priceCents = parseInt(btn.dataset.priceCents || '0', 10);
// Render "$1" / "$4" / "$20.50" — trim ".00" for whole-dollar prices.
const dollars = priceCents / 100;
const priceStr = (dollars === Math.floor(dollars))
? '$' + dollars
: '$' + dollars.toFixed(2);
const message = 'Buy ' + name + ' for ' + priceStr + '?';
if (typeof window.showGuard === 'function') {
// Pin the wallet tooltip so the item card + microbtn stay
// visible while the guard portal is open — otherwise the
// cursor moving from the BUY btn down to the guard's OK/NVM
// triggers the tooltip's mouseleave, leaving the guard's
// "Buy {name} for ${price}?" prompt floating w. no referent.
// Both callbacks (confirm + dismiss) unpin so the next
// mouseleave (or the unpin-triggered _scheduleHide) dismisses
// the tooltip after the user resolves the prompt.
if (window.WalletTooltips && typeof window.WalletTooltips.pin === 'function') {
window.WalletTooltips.pin();
}
window.showGuard(
btn, message,
function () { // onConfirm
if (window.WalletTooltips && window.WalletTooltips.unpin) {
window.WalletTooltips.unpin();
}
_doBuy(slug, btn, shopRoot);
},
function () { // onDismiss
if (window.WalletTooltips && window.WalletTooltips.unpin) {
window.WalletTooltips.unpin();
}
},
);
}
}
function initWalletShop() {
const shopRoot = document.querySelector('.wallet-shop');
if (!shopRoot) return;
// Wired-state is DOM-keyed (`data-shop-wired`) rather than a
// module-scope flag so per-test fixture rebuilds re-wire cleanly.
// Calling twice on the SAME root is still a safe no-op (idempotent).
if (shopRoot.dataset.shopWired === '1') return;
// Triple delegation — the BUY btn click can come from:
// (a) original tile (inside `.shop-tile`) — shopRoot listener
// (b) cloned main portal — kept for legacy / non-mini-portal pages
// (c) cloned mini portal (`#id_mini_tooltip_portal`) — production
// path post-microtooltip refactor; the BUY btn lives in
// `.tt-micro` which clones into the mini portal on hover.
shopRoot.addEventListener('click', _onBuyClick);
const portal = document.getElementById('id_tooltip_portal');
if (portal) portal.addEventListener('click', _onBuyClick);
const miniPortal = document.getElementById('id_mini_tooltip_portal');
if (miniPortal) miniPortal.addEventListener('click', _onBuyClick);
shopRoot.dataset.shopWired = '1';
}
return { initWalletShop: initWalletShop };
})();
document.addEventListener('DOMContentLoaded', WalletShop.initWalletShop);

View File

@@ -64,17 +64,60 @@ const initWallet = () => {
}); });
}; };
// `WalletTooltips` module — exposes pin/unpin so other JS (eg. wallet-shop.js'
// BUY-flow guard portal) can hold the wallet tooltip open across user
// interactions that would otherwise dismiss it. The internals (hide
// timer, _show/_hide helpers) live inside the singleton's IIFE — only
// pin/unpin/initWalletTooltips are public.
const WalletTooltips = (function () {
'use strict';
let _hideTimer = null;
let _pinned = false;
const HIDE_DELAY_MS = 200;
let _portal = null;
let _miniPortal = null;
function _hideAll() {
if (_portal) _portal.classList.remove('active');
if (_miniPortal) _miniPortal.classList.remove('active');
}
function _cancelHide() {
if (_hideTimer) { clearTimeout(_hideTimer); _hideTimer = null; }
}
function _scheduleHide() {
// Pinned (eg. guard portal open) — suppress the hide. unpin() will
// call _scheduleHide() again to dismiss after the guard closes.
if (_pinned) return;
_cancelHide();
_hideTimer = setTimeout(() => { _hideAll(); _hideTimer = null; }, HIDE_DELAY_MS);
}
function pin() { _pinned = true; _cancelHide(); }
function unpin(){ _pinned = false; _scheduleHide(); }
function initWalletTooltips() { function initWalletTooltips() {
const portal = document.getElementById('id_tooltip_portal'); const portal = document.getElementById('id_tooltip_portal');
if (!portal) return; if (!portal) return;
_portal = portal;
// Mini portal — used by shop tiles (BUY ITEM / "Already owned" pill).
// Tokens applet tiles have no `.tt-micro` sibling so the mini stays
// hidden on those hovers. Mirrors gameboard.html's mini portal.
const miniPortal = document.getElementById('id_mini_tooltip_portal');
_miniPortal = miniPortal;
document.querySelectorAll('.wallet-tokens .token').forEach(token => { // Hover-persistence — keep the portal(s) open while the cursor moves
const tooltip = token.querySelector('.tt'); // from tile → portal → mini-portal so users can click the BUY-ITEM
if (!tooltip) return; // microbutton. A short hide delay covers the gap between
// mouseleave-on-tile and mouseenter-on-portal; entering any of the
// 3 zones cancels the hide.
token.addEventListener('mouseenter', () => { function _show(anchor, tooltipHtml, microHtml) {
const rect = token.getBoundingClientRect(); _cancelHide();
portal.innerHTML = tooltip.innerHTML; const rect = anchor.getBoundingClientRect();
portal.innerHTML = tooltipHtml;
portal.classList.add('active'); portal.classList.add('active');
const halfW = portal.offsetWidth / 2; const halfW = portal.offsetWidth / 2;
const rawLeft = rect.left + rect.width / 2; const rawLeft = rect.left + rect.width / 2;
@@ -82,13 +125,52 @@ function initWalletTooltips() {
portal.style.left = Math.round(clampedLeft) + 'px'; portal.style.left = Math.round(clampedLeft) + 'px';
portal.style.top = Math.round(rect.top) + 'px'; portal.style.top = Math.round(rect.top) + 'px';
portal.style.transform = 'translate(-50%, calc(-100% - 0.5rem))'; portal.style.transform = 'translate(-50%, calc(-100% - 0.5rem))';
});
token.addEventListener('mouseleave', () => { if (miniPortal && microHtml) {
portal.classList.remove('active'); miniPortal.innerHTML = microHtml;
}); miniPortal.classList.add('active');
}); // Pin mini-portal to the bottom-right of the main portal —
// same anchor pattern as gameboard.js's gameKit tooltips.
const mainRect = portal.getBoundingClientRect();
miniPortal.style.left = '';
miniPortal.style.right = Math.round(window.innerWidth - mainRect.right) + 'px';
miniPortal.style.top = (mainRect.bottom + 4) + 'px';
} else if (miniPortal) {
miniPortal.classList.remove('active');
}
}
function _bindHover(anchor) {
const tooltip = anchor.querySelector('.tt');
if (!tooltip) return;
// `.tt-micro` is a SIBLING of `.tt` (not a child) so it lives
// alongside the main tooltip content without nesting — keeps the
// BUY-ITEM btn visually distinct in the mini portal.
const micro = anchor.querySelector(':scope > .tt-micro');
const microHtml = micro ? micro.innerHTML : null;
anchor.addEventListener('mouseenter', () => _show(anchor, tooltip.innerHTML, microHtml));
anchor.addEventListener('mouseleave', _scheduleHide);
}
document.querySelectorAll('.wallet-tokens .token, .wallet-shop .shop-tile')
.forEach(_bindHover);
// Re-entering either portal cancels the pending hide — keeps the
// microbutton clickable. Leaving either restarts the hide timer.
portal.addEventListener('mouseenter', _cancelHide);
portal.addEventListener('mouseleave', _scheduleHide);
if (miniPortal) {
miniPortal.addEventListener('mouseenter', _cancelHide);
miniPortal.addEventListener('mouseleave', _scheduleHide);
}
} }
return { initWalletTooltips: initWalletTooltips, pin: pin, unpin: unpin };
})();
// Expose globally so wallet-shop.js can call WalletTooltips.pin/unpin
// without depending on script-load order.
window.WalletTooltips = WalletTooltips;
document.addEventListener('DOMContentLoaded', initWallet); document.addEventListener('DOMContentLoaded', initWallet);
document.addEventListener('DOMContentLoaded', initWalletTooltips); document.addEventListener('DOMContentLoaded', WalletTooltips.initWalletTooltips);

View File

@@ -0,0 +1,338 @@
"""Shop view ITs — Chunk 3 of the wallet-expansion sprint.
Three endpoints under test:
* `POST /dashboard/wallet/shop/buy` (`shop_buy`) — creates a Stripe
PaymentIntent + a `Purchase` row in PENDING; returns
`{client_secret, purchase_id}` for Stripe.js confirmCardPayment.
* `POST /dashboard/wallet/shop/confirm` (`shop_confirm`) — sync follow-up
after Stripe.js confirms client-side; retrieves the PI from Stripe,
if `status=='succeeded'` calls `Purchase.fulfill()` (idempotent w. the
webhook's parallel call).
* `POST /stripe/webhook` (`stripe_webhook`) — async fulfillment fallback
+ bulletproof for 3DS-completed cards. Verifies signature against
`STRIPE_WEBHOOK_SECRET`; on `payment_intent.succeeded` calls
`Purchase.fulfill()` (same idempotent method as `shop_confirm`).
`apps.dashboard.views.stripe` is mocked across all three. The webhook
view's signature verification is also mocked via
`stripe.Webhook.construct_event`.
"""
from unittest import mock
from django.test import TestCase, override_settings
from apps.lyric.models import PaymentMethod, Purchase, ShopItem, Token, User
def _seed_starting_items():
"""Mirror the seed-migration row shape so each TestCase starts w. a
known catalog (TestCase rolls back the data migration, so the rows
seeded by `0009_seed_shop_items` aren't there during tests)."""
ShopItem.objects.update_or_create(
slug="tithe-1",
defaults={
"name": "Tithe Token", "description": "1 Tithe Token + 12 Writs",
"icon": "fa-piggy-bank", "badge_text": "",
"price_cents": 100, "granted_token_type": Token.TITHE,
"granted_count": 1, "granted_writs": 12,
"max_owned": None, "display_order": 10, "active": True,
},
)
ShopItem.objects.update_or_create(
slug="band-1",
defaults={
"name": "Wristband", "description": "Admit All Entry",
"shoptalk": "Unlimited free entry (BYOB)",
"icon": "fa-ring", "badge_text": "",
"price_cents": 2000, "granted_token_type": Token.BAND,
"granted_count": 1, "granted_writs": 0,
"max_owned": 1, "display_order": 30, "active": True,
},
)
class ShopBuyViewTest(TestCase):
def setUp(self):
_seed_starting_items()
self.user = User.objects.create(email="buyer@test.io")
self.user.stripe_customer_id = "cus_buyer"
self.user.save()
PaymentMethod.objects.create(
user=self.user, stripe_pm_id="pm_test_4242",
last4="4242", brand="visa",
)
self.client.force_login(self.user)
def test_requires_login(self):
self.client.logout()
response = self.client.post(
"/dashboard/wallet/shop/buy", {"shop_item_slug": "tithe-1"}
)
self.assertRedirects(
response, "/?next=/dashboard/wallet/shop/buy",
fetch_redirect_response=False,
)
@mock.patch("apps.dashboard.views.stripe")
def test_success_creates_payment_intent_and_purchase(self, mock_stripe):
mock_stripe.PaymentIntent.create.return_value = mock.Mock(
id="pi_test_abc", client_secret="pi_test_abc_secret",
)
response = self.client.post(
"/dashboard/wallet/shop/buy", {"shop_item_slug": "tithe-1"},
)
self.assertEqual(response.status_code, 200)
body = response.json()
self.assertEqual(body["client_secret"], "pi_test_abc_secret")
purchase = Purchase.objects.get(pk=body["purchase_id"])
self.assertEqual(purchase.user, self.user)
self.assertEqual(purchase.shop_item.slug, "tithe-1")
self.assertEqual(purchase.status, Purchase.PENDING)
self.assertEqual(purchase.stripe_payment_intent_id, "pi_test_abc")
self.assertEqual(purchase.amount_cents, 100)
self.assertEqual(purchase.granted_writs, 12)
@mock.patch("apps.dashboard.views.stripe")
def test_payment_intent_called_with_correct_args(self, mock_stripe):
mock_stripe.PaymentIntent.create.return_value = mock.Mock(
id="pi_a", client_secret="pi_a_secret",
)
self.client.post(
"/dashboard/wallet/shop/buy", {"shop_item_slug": "tithe-1"},
)
kwargs = mock_stripe.PaymentIntent.create.call_args.kwargs
self.assertEqual(kwargs["amount"], 100)
self.assertEqual(kwargs["currency"], "usd")
self.assertEqual(kwargs["customer"], "cus_buyer")
self.assertEqual(kwargs["payment_method"], "pm_test_4242")
@mock.patch("apps.dashboard.views.stripe")
def test_unknown_item_slug_returns_404(self, mock_stripe):
response = self.client.post(
"/dashboard/wallet/shop/buy", {"shop_item_slug": "no-such-item"},
)
self.assertEqual(response.status_code, 404)
@mock.patch("apps.dashboard.views.stripe")
def test_inactive_item_returns_404(self, mock_stripe):
item = ShopItem.objects.get(slug="tithe-1")
item.active = False
item.save()
response = self.client.post(
"/dashboard/wallet/shop/buy", {"shop_item_slug": "tithe-1"},
)
self.assertEqual(response.status_code, 404)
@mock.patch("apps.dashboard.views.stripe")
def test_max_owned_violation_returns_409(self, mock_stripe):
"""User already owns 1 BAND (max_owned=1) → buy refused w. 409."""
Token.objects.create(user=self.user, token_type=Token.BAND)
response = self.client.post(
"/dashboard/wallet/shop/buy", {"shop_item_slug": "band-1"},
)
self.assertEqual(response.status_code, 409)
# No PaymentIntent created
mock_stripe.PaymentIntent.create.assert_not_called()
@mock.patch("apps.dashboard.views.stripe")
def test_no_payment_method_returns_402(self, mock_stripe):
"""User w. no saved PaymentMethod → 402 Payment Required."""
self.user.payment_methods.all().delete()
response = self.client.post(
"/dashboard/wallet/shop/buy", {"shop_item_slug": "tithe-1"},
)
self.assertEqual(response.status_code, 402)
mock_stripe.PaymentIntent.create.assert_not_called()
class ShopConfirmViewTest(TestCase):
def setUp(self):
_seed_starting_items()
self.user = User.objects.create(email="confirm@test.io")
self.user.tokens.all().delete()
self.user.refresh_from_db()
self.client.force_login(self.user)
self.tithe = ShopItem.objects.get(slug="tithe-1")
self.purchase = Purchase.objects.create(
user=self.user, shop_item=self.tithe,
stripe_payment_intent_id="pi_conf_1",
amount_cents=100, granted_writs=12,
)
def test_requires_login(self):
self.client.logout()
response = self.client.post(
"/dashboard/wallet/shop/confirm", {"purchase_id": self.purchase.pk},
)
self.assertRedirects(
response, "/?next=/dashboard/wallet/shop/confirm",
fetch_redirect_response=False,
)
@mock.patch("apps.dashboard.views.stripe")
def test_succeeded_pi_triggers_fulfill(self, mock_stripe):
mock_stripe.PaymentIntent.retrieve.return_value = mock.Mock(status="succeeded")
response = self.client.post(
"/dashboard/wallet/shop/confirm", {"purchase_id": self.purchase.pk},
)
self.assertEqual(response.status_code, 200)
self.purchase.refresh_from_db()
self.assertEqual(self.purchase.status, Purchase.SUCCEEDED)
self.assertEqual(
self.user.tokens.filter(token_type=Token.TITHE).count(), 1
)
@mock.patch("apps.dashboard.views.stripe")
def test_pending_pi_does_not_fulfill(self, mock_stripe):
"""Stripe still processing → leave Purchase PENDING for the webhook."""
mock_stripe.PaymentIntent.retrieve.return_value = mock.Mock(status="processing")
self.client.post(
"/dashboard/wallet/shop/confirm", {"purchase_id": self.purchase.pk},
)
self.purchase.refresh_from_db()
self.assertEqual(self.purchase.status, Purchase.PENDING)
self.assertEqual(
self.user.tokens.filter(token_type=Token.TITHE).count(), 0
)
@mock.patch("apps.dashboard.views.stripe")
def test_idempotent_when_already_succeeded(self, mock_stripe):
"""Webhook already fulfilled — confirm endpoint shouldn't double-mint."""
self.purchase.fulfill()
self.assertEqual(self.purchase.status, Purchase.SUCCEEDED)
mock_stripe.PaymentIntent.retrieve.return_value = mock.Mock(status="succeeded")
response = self.client.post(
"/dashboard/wallet/shop/confirm", {"purchase_id": self.purchase.pk},
)
self.assertEqual(response.status_code, 200)
self.assertEqual(
self.user.tokens.filter(token_type=Token.TITHE).count(), 1
)
@mock.patch("apps.dashboard.views.stripe")
def test_other_users_purchase_returns_404(self, mock_stripe):
"""A user trying to confirm someone else's PendingPurchase gets 404."""
other = User.objects.create(email="other@test.io")
other_purchase = Purchase.objects.create(
user=other, shop_item=self.tithe,
stripe_payment_intent_id="pi_other",
amount_cents=100, granted_writs=12,
)
response = self.client.post(
"/dashboard/wallet/shop/confirm", {"purchase_id": other_purchase.pk},
)
self.assertEqual(response.status_code, 404)
@override_settings(STRIPE_WEBHOOK_SECRET="whsec_test_123")
class StripeWebhookViewTest(TestCase):
def setUp(self):
_seed_starting_items()
self.user = User.objects.create(email="webhook@test.io")
self.user.tokens.all().delete()
self.user.refresh_from_db()
self.tithe = ShopItem.objects.get(slug="tithe-1")
self.purchase = Purchase.objects.create(
user=self.user, shop_item=self.tithe,
stripe_payment_intent_id="pi_wh_1",
amount_cents=100, granted_writs=12,
)
@mock.patch("apps.dashboard.views.stripe")
def test_signature_mismatch_returns_400(self, mock_stripe):
mock_stripe.Webhook.construct_event.side_effect = ValueError("bad sig")
response = self.client.post(
"/stripe/webhook",
data=b"{}",
content_type="application/json",
HTTP_STRIPE_SIGNATURE="t=0,v1=deadbeef",
)
self.assertEqual(response.status_code, 400)
@mock.patch("apps.dashboard.views.stripe")
def test_payment_intent_succeeded_triggers_fulfill(self, mock_stripe):
# `Mock(intent_obj, ...)` returns a `dict()`-style object that supports
# `event["type"]` AND `event["data"]["object"]["id"]` — match what the
# webhook view will read.
mock_stripe.Webhook.construct_event.return_value = {
"type": "payment_intent.succeeded",
"data": {"object": {
"id": "pi_wh_1",
"metadata": {"purchase_id": str(self.purchase.pk)},
}},
}
response = self.client.post(
"/stripe/webhook", data=b"{}",
content_type="application/json",
HTTP_STRIPE_SIGNATURE="t=0,v1=goodsig",
)
self.assertEqual(response.status_code, 200)
self.purchase.refresh_from_db()
self.assertEqual(self.purchase.status, Purchase.SUCCEEDED)
self.assertEqual(
self.user.tokens.filter(token_type=Token.TITHE).count(), 1
)
@mock.patch("apps.dashboard.views.stripe")
def test_unknown_event_type_is_noop(self, mock_stripe):
"""Stripe sends a lot of event types we don't care about (eg
`charge.dispute.created`). Webhook view should return 2xx so
Stripe doesn't retry, but NOT touch the Purchase."""
mock_stripe.Webhook.construct_event.return_value = {
"type": "charge.dispute.created",
"data": {"object": {"id": "pi_wh_1"}},
}
response = self.client.post(
"/stripe/webhook", data=b"{}",
content_type="application/json",
HTTP_STRIPE_SIGNATURE="t=0,v1=goodsig",
)
self.assertEqual(response.status_code, 200)
self.purchase.refresh_from_db()
self.assertEqual(self.purchase.status, Purchase.PENDING)
@mock.patch("apps.dashboard.views.stripe")
def test_duplicate_delivery_is_idempotent(self, mock_stripe):
"""Stripe may redeliver a webhook (network blip, our 5xx, etc.).
Re-firing the same `payment_intent.succeeded` event must not
double-mint tokens."""
mock_stripe.Webhook.construct_event.return_value = {
"type": "payment_intent.succeeded",
"data": {"object": {
"id": "pi_wh_1",
"metadata": {"purchase_id": str(self.purchase.pk)},
}},
}
self.client.post(
"/stripe/webhook", data=b"{}",
content_type="application/json",
HTTP_STRIPE_SIGNATURE="t=0,v1=goodsig",
)
self.client.post(
"/stripe/webhook", data=b"{}",
content_type="application/json",
HTTP_STRIPE_SIGNATURE="t=0,v1=goodsig",
)
self.assertEqual(
self.user.tokens.filter(token_type=Token.TITHE).count(), 1
)
@mock.patch("apps.dashboard.views.stripe")
def test_unknown_purchase_id_in_metadata_is_noop(self, mock_stripe):
"""If metadata.purchase_id doesn't match any row, log + 200 (don't
crash the webhook listener — Stripe would retry on 5xx)."""
mock_stripe.Webhook.construct_event.return_value = {
"type": "payment_intent.succeeded",
"data": {"object": {
"id": "pi_unknown",
"metadata": {"purchase_id": "999999"},
}},
}
response = self.client.post(
"/stripe/webhook", data=b"{}",
content_type="application/json",
HTTP_STRIPE_SIGNATURE="t=0,v1=goodsig",
)
self.assertEqual(response.status_code, 200)
self.purchase.refresh_from_db()
self.assertEqual(self.purchase.status, Purchase.PENDING)

View File

@@ -40,12 +40,101 @@ class WalletViewTest(TestCase):
def test_wallet_page_shows_stripe_payment_element(self): def test_wallet_page_shows_stripe_payment_element(self):
[_] = self.parsed.cssselect("#id_stripe_payment_element") [_] = self.parsed.cssselect("#id_stripe_payment_element")
def test_wallet_page_shows_tithe_token_shop(self): # Note: the legacy `#id_tithe_token_shop` HTML in Balances was
[_] = self.parsed.cssselect("#id_tithe_token_shop") # superseded by the dedicated Shop applet in Chunk 5 of
# [[project-wallet-shop-expansion]]. Shop-applet coverage lives in
# `WalletTokensAppletAllTrinketsVisibleTest` below + `test_shop_models.py`
# + `test_shop_views.py`.
def test_tithe_token_shop_shows_bundle(self):
bundles = self.parsed.cssselect("#id_tithe_token_shop .token-bundle") class WalletTokensAppletAllTrinketsVisibleTest(TestCase):
self.assertGreater(len(bundles), 0) """Chunk 1 of the Shop applet rollout (2026-05-22) — the Tokens applet
in `wallet.html` must show every owned trinket-as-token type at once.
Pre-Chunk-1 the template's `{% if pass_token %} ... {% elif band %}
... {% elif coin %}` chain hid two of the three from any user holding
a higher-priority trinket — bad UX since all three are usable at the
gate (per [[feedback-equip-slot-gates-trinket-use]], the user picks
WHICH one fires via the equip slot)."""
def setUp(self):
self.user = User.objects.create(email="multitoken@test.io", is_staff=True)
# Auto-COIN (equipped) + FREE created by post_save signal; PASS auto-
# granted by the is_staff branch of the same signal. Add the rest.
Token.objects.create(user=self.user, token_type=Token.BAND)
Token.objects.create(user=self.user, token_type=Token.CARTE)
Token.objects.create(user=self.user, token_type=Token.TITHE)
self.client.force_login(self.user)
response = self.client.get("/dashboard/wallet/")
self.parsed = lxml.html.fromstring(response.content)
def test_wallet_shows_pass_token(self):
[_] = self.parsed.cssselect("#id_pass_token")
def test_wallet_shows_band_token(self):
[_] = self.parsed.cssselect("#id_band_token")
def test_wallet_shows_coin_on_a_string(self):
[_] = self.parsed.cssselect("#id_coin_on_a_string")
def test_wallet_shows_carte_token(self):
[_] = self.parsed.cssselect("#id_carte_token")
def test_wallet_shows_free_token(self):
[_] = self.parsed.cssselect("#id_free_token")
def test_wallet_shows_tithe_token(self):
[_] = self.parsed.cssselect("#id_tithe_token")
def test_view_context_passes_carte(self):
"""Defense-in-depth: not just the template but the view context too —
a renamed/refactored template should still receive `carte` in ctx."""
response = self.client.get("/dashboard/wallet/")
self.assertEqual(response.context["carte"].token_type, Token.CARTE)
def test_view_context_passes_band(self):
response = self.client.get("/dashboard/wallet/")
self.assertEqual(response.context["band"].token_type, Token.BAND)
def test_non_staff_user_with_carte_still_sees_carte(self):
"""CARTE has no `is_staff` gating (unlike PASS) — a regular gamer
holding a CARTE must see it in the Tokens applet."""
non_staff = User.objects.create(email="grunt@test.io")
Token.objects.create(user=non_staff, token_type=Token.CARTE)
self.client.force_login(non_staff)
response = self.client.get("/dashboard/wallet/")
parsed = lxml.html.fromstring(response.content)
[_] = parsed.cssselect("#id_carte_token")
class WalletAppletOrderTest(TestCase):
"""The wallet row renders Shop first, then Balances/Tokens/Payment in
their historical insertion order — pinned via `Applet.display_order`
(lower = earlier; default 100 + PK tie-break preserves the legacy
order for the rest). Bug-prevention pin: a future migration that
renames or reseeds applets must keep wallet-shop at order < 100.
See [[project-wallet-shop-expansion]] for the locked layout spec."""
def setUp(self):
self.user = User.objects.create(email="layout@test.io")
self.client.force_login(self.user)
def test_shop_applet_renders_first_in_wallet_row(self):
response = self.client.get("/dashboard/wallet/")
html = response.content.decode()
shop_pos = html.find('id="id_wallet_shop"')
balances_pos = html.find('id="id_wallet_balances"')
tokens_pos = html.find('id_writs_balance') # inside balances applet
# Shop's id_wallet_shop appears before Balances' id_wallet_balances
self.assertGreater(shop_pos, 0)
self.assertGreater(balances_pos, 0)
self.assertLess(shop_pos, balances_pos)
self.assertLess(shop_pos, tokens_pos)
def test_shop_applet_first_in_context_list(self):
"""View-context shape pin: `applets` is a list ordered Shop-first."""
response = self.client.get("/dashboard/wallet/")
slugs = [e["applet"].slug for e in response.context["applets"]]
self.assertEqual(slugs[0], "wallet-shop")
class WalletPassTokenVisibilityTest(TestCase): class WalletPassTokenVisibilityTest(TestCase):

View File

@@ -10,6 +10,8 @@ urlpatterns = [
path('wallet/toggle-applets', views.toggle_wallet_applets, name='toggle_wallet_applets'), path('wallet/toggle-applets', views.toggle_wallet_applets, name='toggle_wallet_applets'),
path('wallet/setup-intent', views.setup_intent, name='setup_intent'), path('wallet/setup-intent', views.setup_intent, name='setup_intent'),
path('wallet/save-payment-method', views.save_payment_method, name='save_payment_method'), path('wallet/save-payment-method', views.save_payment_method, name='save_payment_method'),
path('wallet/shop/buy', views.shop_buy, name='shop_buy'),
path('wallet/shop/confirm', views.shop_confirm, name='shop_confirm'),
path('kit-bag/', views.kit_bag, name='kit_bag'), path('kit-bag/', views.kit_bag, name='kit_bag'),
path('sky/', views.sky_view, name='sky'), path('sky/', views.sky_view, name='sky'),
path('sky/preview', views.sky_preview, name='sky_preview'), path('sky/preview', views.sky_preview, name='sky_preview'),

View File

@@ -13,12 +13,12 @@ from django.http import HttpResponse, HttpResponseForbidden, HttpResponseRedirec
from django.shortcuts import redirect, render from django.shortcuts import redirect, render
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from django.views.decorators.csrf import ensure_csrf_cookie from django.views.decorators.csrf import csrf_exempt, ensure_csrf_cookie
from apps.applets.utils import applet_context, apply_applet_toggle from apps.applets.utils import applet_context, apply_applet_toggle
from apps.drama.models import Note from apps.drama.models import Note
from apps.epic.utils import _compute_distinctions from apps.epic.utils import _compute_distinctions
from apps.lyric.models import PaymentMethod, Token, User, Wallet, is_reserved_username from apps.lyric.models import PaymentMethod, Purchase, ShopItem, Token, User, Wallet, is_reserved_username
APPLET_ORDER = ["wallet", "username", "palette"] APPLET_ORDER = ["wallet", "username", "palette"]
@@ -155,6 +155,18 @@ def toggle_applets(request):
}) })
return redirect("home") return redirect("home")
def _shop_items_for(user):
"""Decorate the active ShopItem catalog w. per-user availability so the
template can render `.btn-disabled` + 'Already owned' microtooltip
for `max_owned`-capped items the user already holds. Items are returned
in `display_order` ASC (matches the seeded `tithe-1` < `tithe-5` < `band-1`)."""
items = []
for item in ShopItem.objects.filter(active=True).order_by("display_order", "slug"):
item.available = item.is_available_for(user)
items.append(item)
return items
@login_required(login_url="/") @login_required(login_url="/")
@ensure_csrf_cookie @ensure_csrf_cookie
def wallet(request): def wallet(request):
@@ -162,11 +174,17 @@ def wallet(request):
token_type=Token.FREE, expires_at__gt=timezone.now() token_type=Token.FREE, expires_at__gt=timezone.now()
).order_by("expires_at")) ).order_by("expires_at"))
tithe_tokens = list(request.user.tokens.filter(token_type=Token.TITHE)) tithe_tokens = list(request.user.tokens.filter(token_type=Token.TITHE))
shop_items = _shop_items_for(request.user)
default_pm = request.user.payment_methods.order_by("-pk").first()
return render(request, "apps/dashboard/wallet.html", { return render(request, "apps/dashboard/wallet.html", {
"wallet": request.user.wallet, "wallet": request.user.wallet,
"pass_token": request.user.tokens.filter(token_type=Token.PASS).first() if request.user.is_staff else None, "pass_token": request.user.tokens.filter(token_type=Token.PASS).first() if request.user.is_staff else None,
"band": request.user.tokens.filter(token_type=Token.BAND).first(), "band": request.user.tokens.filter(token_type=Token.BAND).first(),
"coin": request.user.tokens.filter(token_type=Token.COIN).first(), "coin": request.user.tokens.filter(token_type=Token.COIN).first(),
"carte": request.user.tokens.filter(token_type=Token.CARTE).first(),
"shop_items": shop_items,
"default_payment_method_id": default_pm.stripe_pm_id if default_pm else "",
"stripe_publishable_key": settings.STRIPE_PUBLISHABLE_KEY,
"free_tokens": free_tokens, "free_tokens": free_tokens,
"tithe_tokens": tithe_tokens, "tithe_tokens": tithe_tokens,
"free_count": len(free_tokens), "free_count": len(free_tokens),
@@ -198,12 +216,17 @@ def toggle_wallet_applets(request):
checked = request.POST.getlist("applets") checked = request.POST.getlist("applets")
apply_applet_toggle(request.user, "wallet", checked) apply_applet_toggle(request.user, "wallet", checked)
if request.headers.get("HX-Request"): if request.headers.get("HX-Request"):
default_pm = request.user.payment_methods.order_by("-pk").first()
return render(request, "apps/wallet/_partials/_applets.html", { return render(request, "apps/wallet/_partials/_applets.html", {
"applets": applet_context(request.user, "wallet"), "applets": applet_context(request.user, "wallet"),
"wallet": request.user.wallet, "wallet": request.user.wallet,
"pass_token": request.user.tokens.filter(token_type=Token.PASS).first() if request.user.is_staff else None, "pass_token": request.user.tokens.filter(token_type=Token.PASS).first() if request.user.is_staff else None,
"band": request.user.tokens.filter(token_type=Token.BAND).first(), "band": request.user.tokens.filter(token_type=Token.BAND).first(),
"coin": request.user.tokens.filter(token_type=Token.COIN).first(), "coin": request.user.tokens.filter(token_type=Token.COIN).first(),
"carte": request.user.tokens.filter(token_type=Token.CARTE).first(),
"shop_items": _shop_items_for(request.user),
"default_payment_method_id": default_pm.stripe_pm_id if default_pm else "",
"stripe_publishable_key": settings.STRIPE_PUBLISHABLE_KEY,
"free_tokens": list(request.user.tokens.filter(token_type=Token.FREE)), "free_tokens": list(request.user.tokens.filter(token_type=Token.FREE)),
"tithe_tokens": list(request.user.tokens.filter(token_type=Token.TITHE)), "tithe_tokens": list(request.user.tokens.filter(token_type=Token.TITHE)),
}) })
@@ -238,6 +261,146 @@ def save_payment_method(request):
return JsonResponse({"last4": pm.card.last4, "brand": pm.card.brand}) return JsonResponse({"last4": pm.card.last4, "brand": pm.card.brand})
# ── Shop: PaymentIntent flow ─────────────────────────────────────────────────
# Three endpoints split fulfillment responsibility:
# /shop/buy creates a Purchase (PENDING) + a Stripe PaymentIntent.
# Returns client_secret so Stripe.js can confirmCardPayment
# (handles 3DS natively).
# /shop/confirm sync follow-up after Stripe.js confirms client-side. Pulls
# PI status from Stripe; if SUCCEEDED, calls Purchase.fulfill()
# immediately (faster UX than waiting for the webhook round-trip).
# /stripe/webhook async fulfillment from Stripe's webhook delivery. Same
# Purchase.fulfill() call — whichever (confirm or webhook)
# lands first wins; the other becomes a no-op via fulfill()'s
# idempotent guard.
#
# Decisions locked 2026-05-21 in [[project-wallet-shop-expansion]]:
# * Webhook is THE authoritative source for fulfillment (resilient to 3DS,
# network drops, browser closes during checkout).
# * Confirm endpoint is a UX-speedup belt-and-suspenders; never required.
# * Webhook idempotency via Purchase.fulfill()'s status==SUCCEEDED guard.
# * No STRIPE_LIVE_MODE setting — env-var swap is all that's needed.
@login_required(login_url="/")
def shop_buy(request):
"""Create a Stripe PaymentIntent + a PENDING Purchase row.
Body: `shop_item_slug` (form-encoded).
Returns: 200 `{client_secret, purchase_id}` on success;
402 if the user has no saved PaymentMethod;
404 if the slug doesn't match an active ShopItem;
409 if the item's max_owned cap is reached for this user.
"""
stripe.api_key = settings.STRIPE_SECRET_KEY
slug = request.POST.get("shop_item_slug", "")
item = ShopItem.objects.filter(slug=slug, active=True).first()
if item is None:
return HttpResponse(status=404)
if not item.is_available_for(request.user):
return HttpResponse(status=409)
pm = request.user.payment_methods.order_by("-pk").first()
if pm is None:
return HttpResponse(status=402)
intent = stripe.PaymentIntent.create(
amount=item.price_cents,
currency="usd",
customer=request.user.stripe_customer_id,
payment_method=pm.stripe_pm_id,
# `automatic_payment_methods` so Stripe.js picks the right confirm
# method (cards, wallets, etc.) without us hard-coding payment-method-
# type plumbing. `allow_redirects=never` keeps the 3DS challenge in
# the same window — Stripe.js handles the modal natively.
automatic_payment_methods={"enabled": True, "allow_redirects": "never"},
metadata={
# Webhook handler looks up the Purchase by this on
# `payment_intent.succeeded`. Belt-and-suspenders w. looking up
# by `stripe_payment_intent_id` (also unique).
"purchase_id": "_pending_", # overwritten after Purchase.save() below
},
)
purchase = Purchase.objects.create(
user=request.user,
shop_item=item,
stripe_payment_intent_id=intent.id,
amount_cents=item.price_cents,
granted_writs=item.granted_writs,
)
# Now we have purchase.pk — backfill the metadata on the PI so the
# webhook handler can resolve back to it.
stripe.PaymentIntent.modify(
intent.id, metadata={"purchase_id": str(purchase.pk)},
)
return JsonResponse({
"client_secret": intent.client_secret,
"purchase_id": purchase.pk,
})
@login_required(login_url="/")
def shop_confirm(request):
"""Sync follow-up after Stripe.js confirms client-side. Polls the PI
once + fulfills if SUCCEEDED. Idempotent w. the webhook handler via
`Purchase.fulfill()`'s status guard.
Body: `purchase_id` (form-encoded).
Returns: 200 always (sync fulfillment is best-effort; webhook is
authoritative). 404 if the purchase doesn't belong to this user.
"""
stripe.api_key = settings.STRIPE_SECRET_KEY
purchase_id = request.POST.get("purchase_id")
purchase = Purchase.objects.filter(
pk=purchase_id, user=request.user,
).first()
if purchase is None:
return HttpResponse(status=404)
if purchase.status != Purchase.SUCCEEDED:
intent = stripe.PaymentIntent.retrieve(purchase.stripe_payment_intent_id)
if intent.status == "succeeded":
purchase.fulfill()
return JsonResponse({"status": purchase.status})
@csrf_exempt
def stripe_webhook(request):
"""Stripe webhook listener. Verifies signature against
`STRIPE_WEBHOOK_SECRET`; on `payment_intent.succeeded` calls
`Purchase.fulfill()` (idempotent w. `/shop/confirm`).
Always returns 2xx (even on unknown event types or already-fulfilled
purchases) — Stripe retries on 5xx, which would just deliver the same
event repeatedly. 4xx is reserved for signature mismatch (a genuine
auth failure that Stripe should NOT retry).
"""
stripe.api_key = settings.STRIPE_SECRET_KEY
payload = request.body
sig_header = request.headers.get("Stripe-Signature", "")
try:
event = stripe.Webhook.construct_event(
payload, sig_header, settings.STRIPE_WEBHOOK_SECRET,
)
except (ValueError, Exception) as e:
# ValueError = invalid payload; SignatureVerificationError = bad sig.
# Either way, refuse — Stripe will alert if it can't deliver.
if isinstance(e, ValueError) or "Signature" in type(e).__name__:
return HttpResponse(status=400)
raise
if event["type"] == "payment_intent.succeeded":
intent = event["data"]["object"]
purchase_id = intent.get("metadata", {}).get("purchase_id")
purchase = None
if purchase_id and purchase_id.isdigit():
purchase = Purchase.objects.filter(pk=int(purchase_id)).first()
# Fall-back lookup by PI ID in case metadata's missing for any reason.
if purchase is None:
purchase = Purchase.objects.filter(
stripe_payment_intent_id=intent.get("id", ""),
).first()
if purchase is not None:
purchase.fulfill()
return HttpResponse(status=200)
# ── My Sky (personal natal chart) ──────────────────────────────────────────── # ── My Sky (personal natal chart) ────────────────────────────────────────────
def _sky_preview_data(request): def _sky_preview_data(request):

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

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

View File

@@ -0,0 +1,57 @@
"""Re-balance the Tithe shop items' writs payout.
User-locked 2026-05-22: `tithe-1` drops 144 → 12 Writs, `tithe-5` drops
750 → 60 Writs. Description strings updated in lockstep so the tooltip
prose tracks the new numbers.
`granted_token_type`, `granted_count`, `price_cents`, `max_owned`,
`display_order` all unchanged. Only the writs grant + the description
text shift.
"""
from django.db import migrations
_UPDATES = [
{
"slug": "tithe-1",
"description": "1 Tithe Token + 12 Writs",
"granted_writs": 12,
},
{
"slug": "tithe-5",
"description": "5 Tithe Tokens + 60 Writs",
"granted_writs": 60,
},
]
_OLD = [
{"slug": "tithe-1", "description": "1 Tithe Token + 144 Writs", "granted_writs": 144},
{"slug": "tithe-5", "description": "5 Tithe Tokens + 750 Writs", "granted_writs": 750},
]
def _apply(apps, specs):
ShopItem = apps.get_model("lyric", "ShopItem")
for spec in specs:
ShopItem.objects.filter(slug=spec["slug"]).update(
description=spec["description"],
granted_writs=spec["granted_writs"],
)
def forward(apps, schema_editor):
_apply(apps, _UPDATES)
def reverse(apps, schema_editor):
_apply(apps, _OLD)
class Migration(migrations.Migration):
dependencies = [
("lyric", "0009_seed_shop_items"),
]
operations = [
migrations.RunPython(forward, reverse),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 6.0 on 2026-05-22 06:18
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('lyric', '0010_repricing_tithe_writs'),
]
operations = [
migrations.AddField(
model_name='shopitem',
name='shoptalk',
field=models.CharField(blank=True, default='', max_length=200),
),
]

View File

@@ -0,0 +1,40 @@
"""Populate `shoptalk` for existing shop items + split BAND's description.
Pre-migration: `band-1.description` carried both the game function
("Admit All Entry") + the italic flavor ("unlimited free entry (BYOB)")
crammed onto one line. The wallet shop tooltip now uses the DRY four-
slot pattern of `.tt-title` / `.tt-description` / `.tt-shoptalk` /
`.tt-expiry` — same classes the Tokens row already styles — so the
flavor line moves to its own `shoptalk` field, mirroring how
`Token.tooltip_shoptalk` separates flavor from description.
Tithes don't carry shoptalk (their Token tooltips don't either).
"""
from django.db import migrations
def forward(apps, schema_editor):
ShopItem = apps.get_model("lyric", "ShopItem")
ShopItem.objects.filter(slug="band-1").update(
description="Admit All Entry",
shoptalk="Unlimited free entry (BYOB)",
)
def reverse(apps, schema_editor):
ShopItem = apps.get_model("lyric", "ShopItem")
ShopItem.objects.filter(slug="band-1").update(
description="Admit All Entry — unlimited free entry (BYOB)",
shoptalk="",
)
class Migration(migrations.Migration):
dependencies = [
("lyric", "0011_shopitem_shoptalk"),
]
operations = [
migrations.RunPython(forward, reverse),
]

View File

@@ -330,6 +330,143 @@ 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="")
# `shoptalk` is the italic flavor line that mirrors `Token.tooltip_shoptalk` —
# rendered via the `.tt-shoptalk` SCSS class (DRY w. the wallet's Token row).
# Blank → the `{% if item.shoptalk %}` slot in the template is skipped.
shoptalk = models.CharField(max_length=200, 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}"
def tooltip_expiry(self):
"""All shop items are eternal stock (no time-bound listings yet) so
the tooltip's `.tt-expiry` slot always shows 'no expiry' — same
red-callout styling as PASS/BAND/CARTE token tooltips. If a future
seasonal item needs a real expiry, override on the row + return
the formatted string here."""
return "no expiry"
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):
if not created: if not created:

View File

@@ -0,0 +1,295 @@
"""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_shoptalk_defaults_to_blank(self):
"""Most items have no shoptalk (italic flavor line) — defaults to ""
so the tooltip's `{% if item.shoptalk %}` skips the slot cleanly."""
item = ShopItem.objects.create(
slug="probe", name="Probe", price_cents=100,
granted_token_type=Token.TITHE, granted_count=1,
)
self.assertEqual(item.shoptalk, "")
def test_tooltip_expiry_returns_no_expiry(self):
"""All shop items are eternal stock — the tooltip's expiry slot
always reads 'no expiry' (matches PASS/BAND/CARTE token tooltips,
DRY-reuses `.tt-expiry` SCSS for the red callout)."""
item = ShopItem.objects.create(
slug="probe", name="Probe", price_cents=100,
granted_token_type=Token.TITHE, granted_count=1,
)
self.assertEqual(item.tooltip_expiry(), "no expiry")
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)
# Re-balanced 2026-05-22 (migration `0010_repricing_tithe_writs`):
# 144 → 12 writs per tithe-1 purchase.
self.assertEqual(item.granted_writs, 12)
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)
# Re-balanced 2026-05-22: 750 → 60 writs per bundle purchase.
self.assertEqual(item.granted_writs, 60)
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)
# BAND carries the italic shoptalk line from the Token tooltip —
# DRY w. `Token.tooltip_shoptalk` for the BAND type.
self.assertEqual(item.shoptalk, "Unlimited free entry (BYOB)")
def test_tithe_items_have_no_shoptalk(self):
"""Tithes don't carry italic flavor in the Token tooltip; shop
mirrors that — `.tt-shoptalk` slot is empty + the template's
`{% if %}` skips the line cleanly."""
self.assertEqual(ShopItem.objects.get(slug="tithe-1").shoptalk, "")
self.assertEqual(ShopItem.objects.get(slug="tithe-5").shoptalk, "")
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)

View File

@@ -221,6 +221,7 @@ MAILGUN_DOMAIN = "howdy.earthmanrpg.com" # Your Mailgun domain
# Stripe payment settings # Stripe payment settings
STRIPE_PUBLISHABLE_KEY = os.environ.get("STRIPE_PUBLISHABLE_KEY", "") STRIPE_PUBLISHABLE_KEY = os.environ.get("STRIPE_PUBLISHABLE_KEY", "")
STRIPE_SECRET_KEY = os.environ.get("STRIPE_SECRET_KEY", "") STRIPE_SECRET_KEY = os.environ.get("STRIPE_SECRET_KEY", "")
STRIPE_WEBHOOK_SECRET = os.environ.get("STRIPE_WEBHOOK_SECRET", "")
# PySwiss ephemeris microservice # PySwiss ephemeris microservice
PYSWISS_URL = os.environ.get("PYSWISS_URL", "http://127.0.0.1:8001") PYSWISS_URL = os.environ.get("PYSWISS_URL", "http://127.0.0.1:8001")

View File

@@ -17,6 +17,10 @@ urlpatterns = [
path('billboard/', include('apps.billboard.urls')), path('billboard/', include('apps.billboard.urls')),
path('ap/', include('apps.ap.urls')), path('ap/', include('apps.ap.urls')),
path('.well-known/webfinger', ap_views.webfinger, name='webfinger'), path('.well-known/webfinger', ap_views.webfinger, name='webfinger'),
# Stripe webhook lives at a stable root-level URL (no `dashboard/` prefix
# so we can keep the same endpoint pinned in the Stripe dashboard's
# webhook config across any future app-routing refactors).
path('stripe/webhook', dash_views.stripe_webhook, name='stripe_webhook'),
] ]
# Please remove the following urlpattern # Please remove the following urlpattern

View File

@@ -3,20 +3,61 @@ from selenium.webdriver.common.by import By
from .base import FunctionalTest from .base import FunctionalTest
from apps.applets.models import Applet from apps.applets.models import Applet
from apps.lyric.models import ShopItem, Token, User
class WalletDisplayTest(FunctionalTest): class WalletDisplayTest(FunctionalTest):
def setUp(self): def setUp(self):
super().setUp() super().setUp()
Applet.objects.get_or_create(slug="wallet", defaults={"name": "Wallet"}) Applet.objects.get_or_create(slug="wallet", defaults={"name": "Wallet"})
for slug, name, cols, rows in [ for slug, name, cols, rows, order in [
("wallet-balances", "Wallet Balances", 3, 3), ("wallet-shop", "Shop", 12, 3, 10),
("wallet-tokens", "Wallet Tokens", 3, 3), ("wallet-balances", "Wallet Balances", 3, 3, 100),
("wallet-payment", "Payment Methods", 6, 3), ("wallet-tokens", "Wallet Tokens", 3, 3, 100),
("wallet-payment", "Payment Methods", 6, 3, 100),
]: ]:
Applet.objects.get_or_create( Applet.objects.update_or_create(
slug=slug, slug=slug,
defaults={"name": name, "grid_cols": cols, "grid_rows": rows, "context": "wallet"}, defaults={
"name": name, "grid_cols": cols, "grid_rows": rows,
"context": "wallet", "display_order": order,
},
)
# Seed the 3 starting ShopItems — migration `lyric/0009_seed_shop_items`
# populates these in fresh DBs, but FTs run on `TransactionTestCase`
# which wipes all data between tests (post-migrate signal pollution
# doesn't restore RunPython-seeded rows). Mirror the migration's
# locked row shapes here.
ShopItem.objects.update_or_create(
slug="tithe-1",
defaults={
"name": "Tithe Token", "description": "1 Tithe Token + 12 Writs",
"icon": "fa-piggy-bank", "badge_text": "",
"price_cents": 100, "granted_token_type": Token.TITHE,
"granted_count": 1, "granted_writs": 12,
"max_owned": None, "display_order": 10, "active": True,
},
)
ShopItem.objects.update_or_create(
slug="tithe-5",
defaults={
"name": "Tithe Bundle", "description": "5 Tithe Tokens + 60 Writs",
"icon": "fa-piggy-bank", "badge_text": "×5",
"price_cents": 400, "granted_token_type": Token.TITHE,
"granted_count": 5, "granted_writs": 60,
"max_owned": None, "display_order": 20, "active": True,
},
)
ShopItem.objects.update_or_create(
slug="band-1",
defaults={
"name": "Wristband", "description": "Admit All Entry",
"shoptalk": "Unlimited free entry (BYOB)",
"icon": "fa-ring", "badge_text": "",
"price_cents": 2000, "granted_token_type": Token.BAND,
"granted_count": 1, "granted_writs": 0,
"max_owned": 1, "display_order": 30, "active": True,
},
) )
def test_new_user_wallet_shows_starting_balances(self): def test_new_user_wallet_shows_starting_balances(self):
@@ -179,20 +220,214 @@ class WalletDisplayTest(FunctionalTest):
) )
self.assertEqual(rows, '3') self.assertEqual(rows, '3')
def test_user_can_purchase_tithe_token_bundle(self): def test_wallet_tokens_applet_shows_all_owned_trinket_types(self):
# 1. Log in, navigate to wallet page """Wallet Tokens applet renders every owned trinket-as-token type
independently — PASS (staff), BAND, COIN, CARTE — alongside FREE
+ TITHE. Pre-2026-05-22 the template had an if/elif chain that
only showed ONE of {PASS, BAND, COIN}; that hid BAND + COIN +
CARTE entirely from the wallet for any staff user holding a PASS,
even though all three are usable at the gate. Chunk 1 of the
Shop applet rollout drops that exclusivity so each trinket gets
its own tooltipped icon in the row."""
# 1. Build a staff user holding every trinket-as-token type
staff = User.objects.create(email="ledger@test.io", is_staff=True)
# post_save signal already created COIN (auto-equipped) + FREE + PASS;
# mint the rest manually so we exercise the full inventory render.
Token.objects.create(user=staff, token_type=Token.BAND)
Token.objects.create(user=staff, token_type=Token.CARTE)
Token.objects.create(user=staff, token_type=Token.TITHE)
# 2. Log in + land on wallet page
self.create_pre_authenticated_session("ledger@test.io")
self.browser.get(self.live_server_url + "/dashboard/wallet/")
# 3. Every trinket-as-token icon is present (no if/elif suppression)
self.wait_for(lambda: self.browser.find_element(By.ID, "id_pass_token"))
self.browser.find_element(By.ID, "id_band_token")
self.browser.find_element(By.ID, "id_coin_on_a_string")
self.browser.find_element(By.ID, "id_carte_token")
# 4. Consumable tokens still present
self.browser.find_element(By.ID, "id_free_token")
self.browser.find_element(By.ID, "id_tithe_token")
# 5. BAND tile carries its tooltip content in the DOM (the wallet's
# `initWalletTooltips` clones `.tt` innerHTML into the portal on
# hover — already exercised by the COIN/FREE hover paths in
# `test_new_user_wallet_shows_starting_balances`; here we just pin
# that the new BAND/CARTE template blocks server-render their full
# tooltip prose, which is the Chunk 1 contract).
band_tt = self.browser.find_element(By.CSS_SELECTOR, "#id_band_token .tt").get_attribute("innerHTML")
self.assertIn("Wristband", band_tt)
self.assertIn("Admit All Entry", band_tt)
self.assertIn("no expiry", band_tt)
# 6. CARTE tile carries its tooltip content too
carte_tt = self.browser.find_element(By.CSS_SELECTOR, "#id_carte_token .tt").get_attribute("innerHTML")
self.assertIn("Carte Blanche", carte_tt)
self.assertIn("Admit up to +6", carte_tt)
self.assertIn("no expiry", carte_tt)
def test_shop_applet_renders_seeded_items_with_icons_and_badges(self):
"""Chunk 4 of [[project-wallet-shop-expansion]] — the new Shop
applet renders the 3 seeded items (`tithe-1`, `tithe-5`, `band-1`)
as tooltipped tiles. The bundle (`tithe-5`) carries a `×5` badge;
the single-tithe + band tiles render without a badge.
Pinned visual contract: tile presence (id), icon class, badge text,
and price text inside the tooltip prose."""
self.create_pre_authenticated_session("capman@test.io") self.create_pre_authenticated_session("capman@test.io")
self.browser.get(self.live_server_url + "/dashboard/wallet/") self.browser.get(self.live_server_url + "/dashboard/wallet/")
# 2. Assert Tithe Token purchase section present # 1. Shop applet is present
self.wait_for(lambda: self.browser.find_element(By.ID, "id_wallet_shop"))
# 2. Each of the 3 seeded items renders w. its own tile
tithe1 = self.browser.find_element(By.ID, "id_shop_tithe-1")
tithe5 = self.browser.find_element(By.ID, "id_shop_tithe-5")
band1 = self.browser.find_element(By.ID, "id_shop_band-1")
# 3. Icons are correct
self.assertIn("fa-piggy-bank", tithe1.find_element(By.CSS_SELECTOR, "i").get_attribute("class"))
self.assertIn("fa-piggy-bank", tithe5.find_element(By.CSS_SELECTOR, "i").get_attribute("class"))
self.assertIn("fa-ring", band1.find_element(By.CSS_SELECTOR, "i").get_attribute("class"))
# 4. Bundle carries the ×5 badge; singles don't
self.assertIn("×5", tithe5.find_element(By.CSS_SELECTOR, ".shop-badge").text)
self.assertEqual(tithe1.find_elements(By.CSS_SELECTOR, ".shop-badge"), [])
self.assertEqual(band1.find_elements(By.CSS_SELECTOR, ".shop-badge"), [])
# 5. Tooltip prose includes name + price (read .tt innerHTML directly
# — hover→portal cloning is already exercised by the existing
# COIN/FREE hover tests).
tithe1_tt = tithe1.find_element(By.CSS_SELECTOR, ".tt").get_attribute("innerHTML")
self.assertIn("Tithe Token", tithe1_tt)
self.assertIn("$1", tithe1_tt)
band1_tt = band1.find_element(By.CSS_SELECTOR, ".tt").get_attribute("innerHTML")
self.assertIn("Wristband", band1_tt)
self.assertIn("$20", band1_tt)
def test_shop_buy_click_opens_guard_portal_with_purchase_prompt(self):
"""BUY ITEM click opens `#id_guard_portal` w. an 'Are you sure?'-style
prompt naming the item + price. Click NVM dismisses; click OK
triggers the Stripe.js dance (mocked out / deferred to a follow-up
FT)."""
self.create_pre_authenticated_session("capman@test.io")
self.browser.get(self.live_server_url + "/dashboard/wallet/")
self.wait_for(lambda: self.browser.find_element(By.ID, "id_wallet_shop"))
# 1. Find BUY ITEM btn inside the tithe-1 tile's microtooltip
buy_btn = self.browser.find_element(
By.CSS_SELECTOR, "#id_shop_tithe-1 .tt-buy-btn"
)
# 2. Click it via JS (microtooltip lives in the portal layer + may
# not be hover-visible during Selenium's strict-target check)
self.browser.execute_script("arguments[0].click();", buy_btn)
# 3. Guard portal opens w. a prompt mentioning Tithe + $1
portal = self.wait_for(
lambda: self.browser.find_element(By.ID, "id_guard_portal")
)
self.wait_for( self.wait_for(
lambda: self.browser.find_element(By.ID, "id_tithe_token_shop") lambda: self.assertIn("active", portal.get_attribute("class"))
) )
# 3. Assert min. +1 bundle option is visible msg = portal.find_element(By.CSS_SELECTOR, ".guard-message").text
bundle = self.browser.find_element( self.assertIn("Tithe", msg)
By.CSS_SELECTOR, "#id_tithe_token_shop .token-bundle" self.assertIn("$1", msg)
# 4. NVM dismisses
portal.find_element(By.CSS_SELECTOR, ".guard-no").click()
self.wait_for(
lambda: self.assertNotIn("active", portal.get_attribute("class"))
) )
# 4. Assert ea. bundle shows token count & writ bonus placeholder
self.assertIn("Tithe Token", bundle.text) def test_shop_buy_guard_portal_pins_item_tooltip(self):
self.assertIn("Writ", bundle.text) """While the BUY-ITEM guard portal is open, the item's main +
# 5. (Placeholder) Purchase flow via Stripe not driven in this FT: mini tooltip stay pinned — they don't dismiss when the cursor
# Full charge assertion deferred until Stripe webhook handling implemented leaves the BUY btn area to reach the guard's OK / NVM. Fixes
the orphan-prompt UX where "Buy {item} for ${price}?" floated
on its own w. no visual referent. Pinning is released on
either confirm OR dismiss; both schedule the normal hide."""
import time
self.create_pre_authenticated_session("capman@test.io")
self.browser.get(self.live_server_url + "/dashboard/wallet/")
self.wait_for(lambda: self.browser.find_element(By.ID, "id_wallet_shop"))
# 1. Programmatically trigger the tile-enter → both portals active.
self.browser.execute_script(
"document.getElementById('id_shop_tithe-5').dispatchEvent("
"new MouseEvent('mouseenter', {bubbles: true}));"
)
self.wait_for(
lambda: self.assertIn(
"active",
self.browser.find_element(By.ID, "id_tooltip_portal").get_attribute("class"),
)
)
self.assertIn(
"active",
self.browser.find_element(By.ID, "id_mini_tooltip_portal").get_attribute("class"),
)
# 2. Click the cloned BUY btn inside the mini portal — opens guard.
self.browser.execute_script(
"document.querySelector('#id_mini_tooltip_portal .tt-buy-btn').click();"
)
guard = self.wait_for(
lambda: self.browser.find_element(By.ID, "id_guard_portal")
)
self.wait_for(
lambda: self.assertIn("active", guard.get_attribute("class"))
)
# 3. Simulate cursor leaving both portals to reach the guard btns.
self.browser.execute_script("""
document.getElementById('id_mini_tooltip_portal').dispatchEvent(
new MouseEvent('mouseleave', {bubbles: true}));
document.getElementById('id_tooltip_portal').dispatchEvent(
new MouseEvent('mouseleave', {bubbles: true}));
""")
# 4. Wait longer than the wallet.js hide delay (200ms) + buffer.
time.sleep(0.4)
# 5. Both portals are STILL active — pinned by the open guard.
self.assertIn(
"active",
self.browser.find_element(By.ID, "id_tooltip_portal").get_attribute("class"),
)
self.assertIn(
"active",
self.browser.find_element(By.ID, "id_mini_tooltip_portal").get_attribute("class"),
)
# 6. Click NVM — guard dismisses, pin releases.
guard.find_element(By.CSS_SELECTOR, ".guard-no").click()
self.wait_for(
lambda: self.assertNotIn("active", guard.get_attribute("class"))
)
# 7. After unpin + hide delay, portals fade out.
time.sleep(0.4)
self.assertNotIn(
"active",
self.browser.find_element(By.ID, "id_tooltip_portal").get_attribute("class"),
)
self.assertNotIn(
"active",
self.browser.find_element(By.ID, "id_mini_tooltip_portal").get_attribute("class"),
)
def test_shop_band_already_owned_shows_disabled_buy_btn(self):
"""User who already owns 1 BAND (`max_owned=1`) sees the band-1
tile w. its BUY btn disabled + an 'Already owned' microtooltip
— visible-but-unbuyable per the decision in [[project-wallet-
shop-expansion]]."""
owner = User.objects.create(email="bander@test.io")
Token.objects.create(user=owner, token_type=Token.BAND)
self.create_pre_authenticated_session("bander@test.io")
self.browser.get(self.live_server_url + "/dashboard/wallet/")
self.wait_for(lambda: self.browser.find_element(By.ID, "id_wallet_shop"))
band_tile = self.browser.find_element(By.ID, "id_shop_band-1")
# Capped item — NO buy btn at all (parity w. Game Kit's status-
# only "Equipped" / "In-Use: X" pills, which never pair status
# text w. a disabled action btn).
self.assertEqual(band_tile.find_elements(By.CSS_SELECTOR, ".tt-buy-btn"), [])
# 'Already owned' microtext lives in the `.tt-micro` sibling-of-.tt
# (cloned into `#id_mini_tooltip_portal` on hover by wallet.js).
micro_html = band_tile.find_element(By.CSS_SELECTOR, ".tt-micro").get_attribute("innerHTML")
self.assertIn("Already owned", micro_html)
# Legacy `test_user_can_purchase_tithe_token_bundle` FT (asserting
# `#id_tithe_token_shop` inside Balances) was removed in Chunk 5 of
# [[project-wallet-shop-expansion]] — the tithe purchase surface
# moved to the dedicated Shop applet. Coverage now lives in:
# - `test_shop_applet_renders_seeded_items_with_icons_and_badges`
# (tile + icon + badge + price)
# - `test_shop_buy_click_opens_guard_portal_with_purchase_prompt`
# (BUY → guard portal → NVM dismisses)
# - `test_shop_band_already_owned_shows_disabled_buy_btn`
# (max_owned cap renders BUY as `.btn-disabled` w. microtext)

View File

@@ -29,10 +29,12 @@
<script src="NoteSpec.js"></script> <script src="NoteSpec.js"></script>
<script src="NotePageSpec.js"></script> <script src="NotePageSpec.js"></script>
<script src="RowLockSpec.js"></script> <script src="RowLockSpec.js"></script>
<script src="WalletShopSpec.js"></script>
<!-- src files --> <!-- src files -->
<script src="/static/apps/applets/row-lock.js"></script> <script src="/static/apps/applets/row-lock.js"></script>
<script src="/static/apps/dashboard/dashboard.js"></script> <script src="/static/apps/dashboard/dashboard.js"></script>
<script src="/static/apps/dashboard/note.js"></script> <script src="/static/apps/dashboard/note.js"></script>
<script src="/static/apps/dashboard/wallet-shop.js"></script>
<script src="/static/apps/billboard/note-page.js"></script> <script src="/static/apps/billboard/note-page.js"></script>
<script src="/static/apps/epic/stage-card.js"></script> <script src="/static/apps/epic/stage-card.js"></script>
<script src="/static/apps/epic/role-select.js"></script> <script src="/static/apps/epic/role-select.js"></script>

View File

@@ -0,0 +1,203 @@
// ── WalletShopSpec.js ────────────────────────────────────────────────────────
//
// Unit specs for wallet-shop.js — the BUY-ITEM click handler that drives
// the shop applet's purchase flow (Chunk 4 of the wallet-expansion sprint).
//
// DOM contract assumed by `initWalletShop()`:
//
// Each shop tile carries `data-shop-item-slug`, `data-price-cents`, and
// `data-item-name`. Inside the tile, a `.tt-buy-btn` element is the
// click target. Disabled tiles have `.btn-disabled` on the BUY btn +
// should NOT trigger the buy flow.
//
// `window.showGuard(anchor, message, onConfirm)` is the project's
// guard-portal API (from base.html). We spy on it to assert message
// + callback wiring.
//
// `window.Stripe` and `window.fetch` are stubbed out per-test so we
// never hit the real Stripe.js or backend.
//
// API under test:
// `initWalletShop()` — attaches click listeners to every `.tt-buy-btn`
// inside `.wallet-shop`. Idempotent (calling twice
// doesn't double-fire).
// ─────────────────────────────────────────────────────────────────────────────
function _seedShopFixture() {
const root = document.createElement('section');
root.className = 'wallet-shop';
root.id = 'id_wallet_shop';
// `_doBuy` reads these from `.wallet-shop` dataset; without them the
// buy flow short-circuits at the no-PM alert before reaching fetch.
root.dataset.defaultPaymentMethodId = 'pm_test_4242';
root.dataset.stripePublishableKey = 'pk_test_fixture';
root.innerHTML = `
<div id="id_shop_tithe-1" class="shop-tile">
<i class="fa-solid fa-piggy-bank"></i>
<div class="tt">
<h4 class="tt-title">Tithe Token</h4>
<p class="tt-description">1 Tithe + 144 Writs</p>
<p class="tt-price">$1</p>
</div>
<div class="tt-micro">
<button class="btn btn-primary tt-buy-btn"
data-shop-item-slug="tithe-1"
data-item-name="Tithe Token"
data-price-cents="100">BUY ITEM</button>
</div>
</div>
<div id="id_shop_band-1" class="shop-tile">
<i class="fa-solid fa-ring"></i>
<div class="tt">
<h4 class="tt-title">Wristband</h4>
<p class="tt-price">$20</p>
</div>
<div class="tt-micro">
<span class="tt-already-owned">Already owned</span>
</div>
</div>
`;
document.body.appendChild(root);
return root;
}
describe('WalletShop.initWalletShop', () => {
let fixture;
let _origFetch;
let _origStripe;
let _origShowGuard;
let _origWalletTooltips;
beforeEach(() => {
fixture = _seedShopFixture();
// Save+stub instead of spyOn — another spec elsewhere may already
// have a fetch spy active, and Jasmine refuses to double-spy on
// the same method ("fetch has already been spied upon"). Manual
// save+restore is double-spy-safe + scoped per spec block.
_origFetch = window.fetch;
window.fetch = jasmine.createSpy('fetch').and.returnValue(
Promise.resolve({ ok: true, json: () => Promise.resolve({}) })
);
_origStripe = window.Stripe;
window.Stripe = jasmine.createSpy('Stripe').and.returnValue({
confirmCardPayment: jasmine.createSpy('confirmCardPayment')
.and.returnValue(Promise.resolve({ paymentIntent: { status: 'succeeded' } })),
});
_origShowGuard = window.showGuard;
window.showGuard = jasmine.createSpy('showGuard');
// Stub WalletTooltips.pin/unpin so we can assert the buy flow
// pins the tooltip on guard-open + unpins on confirm/dismiss.
_origWalletTooltips = window.WalletTooltips;
window.WalletTooltips = {
pin: jasmine.createSpy('pin'),
unpin: jasmine.createSpy('unpin'),
};
});
afterEach(() => {
fixture.remove();
window.fetch = _origFetch;
window.Stripe = _origStripe;
window.showGuard = _origShowGuard;
window.WalletTooltips = _origWalletTooltips;
});
// ── T1 ── click on enabled BUY opens guard portal w. price prompt ───────
it('T1: clicking BUY ITEM on tithe-1 opens guard with the item name + price', () => {
WalletShop.initWalletShop();
const btn = fixture.querySelector('#id_shop_tithe-1 .tt-buy-btn');
btn.click();
expect(window.showGuard).toHaveBeenCalled();
const args = window.showGuard.calls.mostRecent().args;
expect(args[0]).toBe(btn); // anchor
expect(args[1]).toContain('Tithe Token');
expect(args[1]).toContain('$1');
expect(typeof args[2]).toBe('function'); // onConfirm callback
});
// ── T2 ── capped items render no BUY btn at all (status-only microtext)
it('T2: capped band-1 tile renders no BUY btn (only the "Already owned" pill)', () => {
WalletShop.initWalletShop();
// No `.tt-buy-btn` inside the band tile — capped items follow the
// Game Kit "Equipped" / "In-Use: X" pattern: status text only, no
// disabled action btn. Clicking anywhere on the tile is a no-op.
const btns = fixture.querySelectorAll('#id_shop_band-1 .tt-buy-btn');
expect(btns.length).toBe(0);
// The microtext is rendered (cloned into the mini portal on hover).
const ownedText = fixture.querySelector('#id_shop_band-1 .tt-already-owned');
expect(ownedText).not.toBeNull();
expect(ownedText.textContent.trim()).toBe('Already owned');
});
// ── T3 ── onConfirm callback POSTs to /shop/buy w. the right slug ───────
it('T3: invoking the onConfirm callback POSTs shop_item_slug to /shop/buy', () => {
// T3-local fetch override: never-resolving promise so the async
// chain pauses at the FIRST await + doesn't progress into the
// Stripe / confirm-endpoint dance that gets torn down by
// afterEach. Without this, the unawaited continuation fires
// `window.Stripe(pubKey)` after afterEach has restored
// window.Stripe = undefined → unhandled-promise-rejection error.
window.fetch = jasmine.createSpy('fetch').and.returnValue(new Promise(() => {}));
WalletShop.initWalletShop();
const btn = fixture.querySelector('#id_shop_tithe-1 .tt-buy-btn');
btn.click();
const onConfirm = window.showGuard.calls.mostRecent().args[2];
onConfirm();
expect(window.fetch).toHaveBeenCalled();
const [url, opts] = window.fetch.calls.mostRecent().args;
expect(url).toContain('/dashboard/wallet/shop/buy');
expect(opts.method).toBe('POST');
expect(opts.body).toContain('shop_item_slug=tithe-1');
});
// ── T4 ── initWalletShop is idempotent (calling twice doesn't double-wire) ─
it('T4: calling initWalletShop twice does not double-fire the guard portal', () => {
WalletShop.initWalletShop();
WalletShop.initWalletShop();
const btn = fixture.querySelector('#id_shop_tithe-1 .tt-buy-btn');
btn.click();
expect(window.showGuard.calls.count()).toBe(1);
});
// ── T5 ── no `.wallet-shop` in DOM → init is a quiet no-op ──────────────
it('T5: initWalletShop with no `.wallet-shop` root does not throw', () => {
fixture.remove();
expect(() => WalletShop.initWalletShop()).not.toThrow();
});
// ── T6 ── clicking BUY pins the tooltip BEFORE opening the guard ──────
it('T6: clicking BUY ITEM pins the WalletTooltips (so the item ' +
'tooltip stays visible while the guard portal is open)', () => {
WalletShop.initWalletShop();
const btn = fixture.querySelector('#id_shop_tithe-1 .tt-buy-btn');
btn.click();
// pin() called BEFORE showGuard so the tooltip never has a
// chance to hide between the click + the guard render.
expect(window.WalletTooltips.pin).toHaveBeenCalled();
expect(window.showGuard).toHaveBeenCalled();
});
// ── T7 ── guard onConfirm unpins the tooltip ───────────────────────────
it('T7: invoking the onConfirm callback unpins the WalletTooltips', () => {
// Block fetch like in T3 to avoid Stripe-chain async cleanup pollution.
window.fetch = jasmine.createSpy('fetch').and.returnValue(new Promise(() => {}));
WalletShop.initWalletShop();
const btn = fixture.querySelector('#id_shop_tithe-1 .tt-buy-btn');
btn.click();
const onConfirm = window.showGuard.calls.mostRecent().args[2];
onConfirm();
expect(window.WalletTooltips.unpin).toHaveBeenCalled();
});
// ── T8 ── guard onDismiss unpins the tooltip ───────────────────────────
it('T8: invoking the onDismiss callback unpins the WalletTooltips', () => {
WalletShop.initWalletShop();
const btn = fixture.querySelector('#id_shop_tithe-1 .tt-buy-btn');
btn.click();
const onDismiss = window.showGuard.calls.mostRecent().args[3];
expect(typeof onDismiss).toBe('function');
onDismiss();
expect(window.WalletTooltips.unpin).toHaveBeenCalled();
});
});

View File

@@ -11,6 +11,11 @@
.tt-shoptalk { font-size: 0.75rem; opacity: 0.75; } .tt-shoptalk { font-size: 0.75rem; opacity: 0.75; }
.tt-expiry { font-size: 1rem; color: rgba(var(--priRd), 1); } .tt-expiry { font-size: 1rem; color: rgba(var(--priRd), 1); }
.tt-date { font-size: 1rem; color: rgba(var(--priGn), 1); } .tt-date { font-size: 1rem; color: rgba(var(--priGn), 1); }
// `.tt-price` — wallet Shop tooltip. Same shape as .tt-expiry (size +
// semantics) but --priGn for the "in the green" payment cue. Lives
// inside the `.tt-title` h4 (which is `display: flex; justify-content:
// space-between`) so the price floats top-right opposite the name.
.tt-price { font-size: 1rem; color: rgba(var(--priGn), 1); }
} }
.token-tooltip, .token-tooltip,

View File

@@ -71,3 +71,106 @@
transform: translateX(-50%); transform: translateX(-50%);
} }
} }
// ── Wallet Shop applet ───────────────────────────────────────────────────────
// Mimics `.wallet-tokens` (horizontal row of tooltipped icons) but each tile
// carries an admin-defined catalog item + an optional `.shop-badge` (eg "×5"
// for the bundle) + a BUY-ITEM microbutton hosted in the tooltip portal.
// JS wiring lives in `apps/dashboard/static/apps/dashboard/wallet-shop.js`.
.wallet-shop {
display: flex;
flex-direction: column;
overflow: visible;
.shop-grid {
flex: 1;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-evenly;
gap: 1rem;
overflow: visible;
}
}
.shop-tile {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
color: rgba(var(--terUser), 1);
cursor: help;
}
// ×N quantity badge — top-right corner, --quaUser glyph on --quiUser bg.
// User-tweaked 2026-05-22: shrunk from 2rem → 1.5rem + nudged further up
// + right so most of the underlying tile icon stays visible.
.shop-badge {
position: absolute;
top: -0.8rem;
right: -1.2rem;
width: 1.5rem;
height: 1.5rem;
border-radius: 50%;
background: rgba(var(--secUser), 1);
color: rgba(var(--priUser), 1);
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
font-weight: 900;
pointer-events: none;
}
// `.tt-micro` — sibling of `.tt` on each `.shop-tile`. Holds the BUY-ITEM
// btn (or × + 'Already owned' when capped). wallet.js's tooltip handler
// clones this into `#id_mini_tooltip_portal` on hover so the btn appears
// as a small floating bubble adjacent to the main tooltip card —
// mirroring Game Kit's Equipped/Unequipped/In-Use microtooltip pattern.
// Hidden in the source DOM; only the portal clone is visible.
.tt-micro {
display: none;
}
// Wallet-side mini portal — pinned to the bottom-right of the main
// portal by wallet.js (mirrors gameboard.js's gameKit positioning).
// Mostly mirrors gameboard's mini at `_gameboard.scss:140` but allows
// the BUY-ITEM btn label to wrap onto multiple lines (gameboard's
// mini holds short status text like "In-Use: X" which wants nowrap;
// our buy btn is round + needs the label to break onto 2 lines).
#id_mini_tooltip_portal {
position: fixed;
z-index: 9999;
width: fit-content;
text-align: center;
padding: 0.5rem 0.75rem;
display: none;
flex-direction: column;
align-items: center;
gap: 0.25rem;
.tt-buy-btn {
padding: 0.25rem 0.75rem;
white-space: normal;
word-break: normal;
line-height: 1.1;
}
// `.tt-already-owned` text — match Game Kit's "Equipped" / "In-Use: X"
// microtext styling (--secUser at full alpha, slightly bigger than
// 0.75rem) so the wallet shop's "Already owned" pill reads as the
// same widget as the gameboard's status pills.
.tt-already-owned {
font-size: 0.85rem;
margin: 0;
font-style: italic;
color: rgba(var(--secUser), 1);
white-space: nowrap;
}
&.active { display: flex; }
}

View File

@@ -29,10 +29,12 @@
<script src="NoteSpec.js"></script> <script src="NoteSpec.js"></script>
<script src="NotePageSpec.js"></script> <script src="NotePageSpec.js"></script>
<script src="RowLockSpec.js"></script> <script src="RowLockSpec.js"></script>
<script src="WalletShopSpec.js"></script>
<!-- src files --> <!-- src files -->
<script src="/static/apps/applets/row-lock.js"></script> <script src="/static/apps/applets/row-lock.js"></script>
<script src="/static/apps/dashboard/dashboard.js"></script> <script src="/static/apps/dashboard/dashboard.js"></script>
<script src="/static/apps/dashboard/note.js"></script> <script src="/static/apps/dashboard/note.js"></script>
<script src="/static/apps/dashboard/wallet-shop.js"></script>
<script src="/static/apps/billboard/note-page.js"></script> <script src="/static/apps/billboard/note-page.js"></script>
<script src="/static/apps/epic/stage-card.js"></script> <script src="/static/apps/epic/stage-card.js"></script>
<script src="/static/apps/epic/role-select.js"></script> <script src="/static/apps/epic/role-select.js"></script>

View File

@@ -0,0 +1,203 @@
// ── WalletShopSpec.js ────────────────────────────────────────────────────────
//
// Unit specs for wallet-shop.js — the BUY-ITEM click handler that drives
// the shop applet's purchase flow (Chunk 4 of the wallet-expansion sprint).
//
// DOM contract assumed by `initWalletShop()`:
//
// Each shop tile carries `data-shop-item-slug`, `data-price-cents`, and
// `data-item-name`. Inside the tile, a `.tt-buy-btn` element is the
// click target. Disabled tiles have `.btn-disabled` on the BUY btn +
// should NOT trigger the buy flow.
//
// `window.showGuard(anchor, message, onConfirm)` is the project's
// guard-portal API (from base.html). We spy on it to assert message
// + callback wiring.
//
// `window.Stripe` and `window.fetch` are stubbed out per-test so we
// never hit the real Stripe.js or backend.
//
// API under test:
// `initWalletShop()` — attaches click listeners to every `.tt-buy-btn`
// inside `.wallet-shop`. Idempotent (calling twice
// doesn't double-fire).
// ─────────────────────────────────────────────────────────────────────────────
function _seedShopFixture() {
const root = document.createElement('section');
root.className = 'wallet-shop';
root.id = 'id_wallet_shop';
// `_doBuy` reads these from `.wallet-shop` dataset; without them the
// buy flow short-circuits at the no-PM alert before reaching fetch.
root.dataset.defaultPaymentMethodId = 'pm_test_4242';
root.dataset.stripePublishableKey = 'pk_test_fixture';
root.innerHTML = `
<div id="id_shop_tithe-1" class="shop-tile">
<i class="fa-solid fa-piggy-bank"></i>
<div class="tt">
<h4 class="tt-title">Tithe Token</h4>
<p class="tt-description">1 Tithe + 144 Writs</p>
<p class="tt-price">$1</p>
</div>
<div class="tt-micro">
<button class="btn btn-primary tt-buy-btn"
data-shop-item-slug="tithe-1"
data-item-name="Tithe Token"
data-price-cents="100">BUY ITEM</button>
</div>
</div>
<div id="id_shop_band-1" class="shop-tile">
<i class="fa-solid fa-ring"></i>
<div class="tt">
<h4 class="tt-title">Wristband</h4>
<p class="tt-price">$20</p>
</div>
<div class="tt-micro">
<span class="tt-already-owned">Already owned</span>
</div>
</div>
`;
document.body.appendChild(root);
return root;
}
describe('WalletShop.initWalletShop', () => {
let fixture;
let _origFetch;
let _origStripe;
let _origShowGuard;
let _origWalletTooltips;
beforeEach(() => {
fixture = _seedShopFixture();
// Save+stub instead of spyOn — another spec elsewhere may already
// have a fetch spy active, and Jasmine refuses to double-spy on
// the same method ("fetch has already been spied upon"). Manual
// save+restore is double-spy-safe + scoped per spec block.
_origFetch = window.fetch;
window.fetch = jasmine.createSpy('fetch').and.returnValue(
Promise.resolve({ ok: true, json: () => Promise.resolve({}) })
);
_origStripe = window.Stripe;
window.Stripe = jasmine.createSpy('Stripe').and.returnValue({
confirmCardPayment: jasmine.createSpy('confirmCardPayment')
.and.returnValue(Promise.resolve({ paymentIntent: { status: 'succeeded' } })),
});
_origShowGuard = window.showGuard;
window.showGuard = jasmine.createSpy('showGuard');
// Stub WalletTooltips.pin/unpin so we can assert the buy flow
// pins the tooltip on guard-open + unpins on confirm/dismiss.
_origWalletTooltips = window.WalletTooltips;
window.WalletTooltips = {
pin: jasmine.createSpy('pin'),
unpin: jasmine.createSpy('unpin'),
};
});
afterEach(() => {
fixture.remove();
window.fetch = _origFetch;
window.Stripe = _origStripe;
window.showGuard = _origShowGuard;
window.WalletTooltips = _origWalletTooltips;
});
// ── T1 ── click on enabled BUY opens guard portal w. price prompt ───────
it('T1: clicking BUY ITEM on tithe-1 opens guard with the item name + price', () => {
WalletShop.initWalletShop();
const btn = fixture.querySelector('#id_shop_tithe-1 .tt-buy-btn');
btn.click();
expect(window.showGuard).toHaveBeenCalled();
const args = window.showGuard.calls.mostRecent().args;
expect(args[0]).toBe(btn); // anchor
expect(args[1]).toContain('Tithe Token');
expect(args[1]).toContain('$1');
expect(typeof args[2]).toBe('function'); // onConfirm callback
});
// ── T2 ── capped items render no BUY btn at all (status-only microtext)
it('T2: capped band-1 tile renders no BUY btn (only the "Already owned" pill)', () => {
WalletShop.initWalletShop();
// No `.tt-buy-btn` inside the band tile — capped items follow the
// Game Kit "Equipped" / "In-Use: X" pattern: status text only, no
// disabled action btn. Clicking anywhere on the tile is a no-op.
const btns = fixture.querySelectorAll('#id_shop_band-1 .tt-buy-btn');
expect(btns.length).toBe(0);
// The microtext is rendered (cloned into the mini portal on hover).
const ownedText = fixture.querySelector('#id_shop_band-1 .tt-already-owned');
expect(ownedText).not.toBeNull();
expect(ownedText.textContent.trim()).toBe('Already owned');
});
// ── T3 ── onConfirm callback POSTs to /shop/buy w. the right slug ───────
it('T3: invoking the onConfirm callback POSTs shop_item_slug to /shop/buy', () => {
// T3-local fetch override: never-resolving promise so the async
// chain pauses at the FIRST await + doesn't progress into the
// Stripe / confirm-endpoint dance that gets torn down by
// afterEach. Without this, the unawaited continuation fires
// `window.Stripe(pubKey)` after afterEach has restored
// window.Stripe = undefined → unhandled-promise-rejection error.
window.fetch = jasmine.createSpy('fetch').and.returnValue(new Promise(() => {}));
WalletShop.initWalletShop();
const btn = fixture.querySelector('#id_shop_tithe-1 .tt-buy-btn');
btn.click();
const onConfirm = window.showGuard.calls.mostRecent().args[2];
onConfirm();
expect(window.fetch).toHaveBeenCalled();
const [url, opts] = window.fetch.calls.mostRecent().args;
expect(url).toContain('/dashboard/wallet/shop/buy');
expect(opts.method).toBe('POST');
expect(opts.body).toContain('shop_item_slug=tithe-1');
});
// ── T4 ── initWalletShop is idempotent (calling twice doesn't double-wire) ─
it('T4: calling initWalletShop twice does not double-fire the guard portal', () => {
WalletShop.initWalletShop();
WalletShop.initWalletShop();
const btn = fixture.querySelector('#id_shop_tithe-1 .tt-buy-btn');
btn.click();
expect(window.showGuard.calls.count()).toBe(1);
});
// ── T5 ── no `.wallet-shop` in DOM → init is a quiet no-op ──────────────
it('T5: initWalletShop with no `.wallet-shop` root does not throw', () => {
fixture.remove();
expect(() => WalletShop.initWalletShop()).not.toThrow();
});
// ── T6 ── clicking BUY pins the tooltip BEFORE opening the guard ──────
it('T6: clicking BUY ITEM pins the WalletTooltips (so the item ' +
'tooltip stays visible while the guard portal is open)', () => {
WalletShop.initWalletShop();
const btn = fixture.querySelector('#id_shop_tithe-1 .tt-buy-btn');
btn.click();
// pin() called BEFORE showGuard so the tooltip never has a
// chance to hide between the click + the guard render.
expect(window.WalletTooltips.pin).toHaveBeenCalled();
expect(window.showGuard).toHaveBeenCalled();
});
// ── T7 ── guard onConfirm unpins the tooltip ───────────────────────────
it('T7: invoking the onConfirm callback unpins the WalletTooltips', () => {
// Block fetch like in T3 to avoid Stripe-chain async cleanup pollution.
window.fetch = jasmine.createSpy('fetch').and.returnValue(new Promise(() => {}));
WalletShop.initWalletShop();
const btn = fixture.querySelector('#id_shop_tithe-1 .tt-buy-btn');
btn.click();
const onConfirm = window.showGuard.calls.mostRecent().args[2];
onConfirm();
expect(window.WalletTooltips.unpin).toHaveBeenCalled();
});
// ── T8 ── guard onDismiss unpins the tooltip ───────────────────────────
it('T8: invoking the onDismiss callback unpins the WalletTooltips', () => {
WalletShop.initWalletShop();
const btn = fixture.querySelector('#id_shop_tithe-1 .tt-buy-btn');
btn.click();
const onDismiss = window.showGuard.calls.mostRecent().args[3];
expect(typeof onDismiss).toBe('function');
onDismiss();
expect(window.WalletTooltips.unpin).toHaveBeenCalled();
});
});

View File

@@ -10,6 +10,10 @@
{% include "apps/wallet/_partials/_applets.html" %} {% include "apps/wallet/_partials/_applets.html" %}
</div> </div>
<div id="id_tooltip_portal" class="token-tooltip"></div> <div id="id_tooltip_portal" class="token-tooltip"></div>
{# Microtooltip for the Shop applet's BUY-ITEM btn / 'Already owned' note. #}
{# Mirrors gameboard.html's mini portal (Equipped/Unequipped/In-Use). #}
<div id="id_mini_tooltip_portal" class="token-tooltip token-tooltip--mini"></div>
<script src="https://js.stripe.com/v3/"></script> <script src="https://js.stripe.com/v3/"></script>
<script src="{% static "apps/dashboard/wallet.js" %}"></script> <script src="{% static "apps/dashboard/wallet.js" %}"></script>
<script src="{% static "apps/dashboard/wallet-shop.js" %}"></script>
{% endblock content %} {% endblock content %}

View File

@@ -2,19 +2,13 @@
id="id_wallet_balances" id="id_wallet_balances"
style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};" style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};"
> >
{% comment %}
Tithe purchase UI was moved out to the dedicated Shop applet in
Chunk 5 of [[project-wallet-shop-expansion]] — the Shop is the
canonical purchase surface; Balances is read-only (writs + esteem
totals).
{% endcomment %}
<h2>Balances</h2> <h2>Balances</h2>
<div><i class="fa-solid fa-ticket"></i>: <span id="id_writs_balance">{{ wallet.writs }}</span></div> <div><i class="fa-solid fa-ticket"></i>: <span id="id_writs_balance">{{ wallet.writs }}</span></div>
<div>Esteem: <span id="id_esteem_balance">{{ wallet.esteem }}</span></div> <div>Esteem: <span id="id_esteem_balance">{{ wallet.esteem }}</span></div>
<div id="id_tithe_token_shop">
<div class="token-bundle" data-qty="1" data-price-cents="100">
<span class="bundle-qty">1 Tithe Token</span>
<span class="bundle-writs">+144 Writs</span>
<span class="bundle-price">$1.00</span>
</div>
<div class="token-bundle" data-qty="5" data-price-cents="400">
<span class="bundle-qty">5 Tithe Tokens</span>
<span class="bundle-writs">+750 Writs</span>
<span class="bundle-price">$4.00</span>
</div>
</div>
</section> </section>

View File

@@ -0,0 +1,65 @@
<section
id="id_wallet_shop"
class="wallet-shop"
style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};"
data-default-payment-method-id="{{ default_payment_method_id }}"
data-stripe-publishable-key="{{ stripe_publishable_key }}"
>
<h2>Shop</h2>
<div class="shop-grid">
{% for item in shop_items %}
<div
id="id_shop_{{ item.slug }}"
class="shop-tile"
data-shop-item-slug="{{ item.slug }}"
data-price-cents="{{ item.price_cents }}"
data-item-name="{{ item.name }}"
>
<i class="fa-solid {{ item.icon }}"></i>
{% if item.badge_text %}
<span class="shop-badge">{{ item.badge_text }}</span>
{% endif %}
<div class="tt">
{# DRY-reuses the Tokens row's 4-slot tooltip pattern — same #}
{# `.tt-title/.tt-description/.tt-shoptalk/.tt-expiry` SCSS #}
{# classes so shop + token tooltips render as the same widget.#}
{# Price lives inside the h4 (which already has flex space- #}
{# between for `.token-count`-style chips) so it pins top- #}
{# right opposite the name — `.tt-price` styled --priGn. #}
<h4 class="tt-title">
<span>{{ item.name }}</span>
<span class="tt-price">{{ item.price_display }}</span>
</h4>
<p class="tt-description">{{ item.description }}</p>
{% if item.shoptalk %}
<p class="tt-shoptalk"><em>{{ item.shoptalk }}</em></p>
{% endif %}
<p class="tt-expiry">{{ item.tooltip_expiry }}</p>
</div>
{% comment %}
Sibling-of-.tt microtooltip — mirrors Game Kit's
Equipped/Unequipped/In-Use mini portal pattern. wallet.js's
tooltip handler clones this into #id_mini_tooltip_portal on
hover; staying separate keeps the BUY-ITEM btn visually
distinct from the main name+price card.
{% endcomment %}
<div class="tt-micro">
{% if item.available %}
<button
type="button"
class="btn btn-primary tt-buy-btn"
data-shop-item-slug="{{ item.slug }}"
data-item-name="{{ item.name }}"
data-price-cents="{{ item.price_cents }}"
>BUY ITEM</button>
{% else %}
{# Capped item — no BUY btn at all (parity w. Game Kit's #}
{# 'In-Use: X' / 'Equipped' microtext pattern, which never #}
{# pairs status text w. a disabled action btn). #}
<span class="tt-already-owned">Already owned</span>
{% endif %}
</div>
</div>
{% endfor %}
</div>
</section>

View File

@@ -4,6 +4,12 @@
> >
<h2>Tokens</h2> <h2>Tokens</h2>
<div class="token-row"> <div class="token-row">
{% comment %}
Trinket-as-token row — each trinket type renders independently (no
if/elif suppression). Every one of PASS/BAND/COIN/CARTE is usable at
the gate; the user picks WHICH via the equip slot per
[[feedback-equip-slot-gates-trinket-use]].
{% endcomment %}
{% if pass_token %} {% if pass_token %}
<div id="id_pass_token" class="token"> <div id="id_pass_token" class="token">
<i class="fa-solid fa-clipboard"></i> <i class="fa-solid fa-clipboard"></i>
@@ -16,7 +22,8 @@
<p class="tt-expiry">{{ pass_token.tooltip_expiry }}</p> <p class="tt-expiry">{{ pass_token.tooltip_expiry }}</p>
</div> </div>
</div> </div>
{% elif band %} {% endif %}
{% if band %}
<div id="id_band_token" class="token"> <div id="id_band_token" class="token">
<i class="fa-solid fa-ring"></i> <i class="fa-solid fa-ring"></i>
<div class="tt"> <div class="tt">
@@ -28,7 +35,8 @@
<p class="tt-expiry">{{ band.tooltip_expiry }}</p> <p class="tt-expiry">{{ band.tooltip_expiry }}</p>
</div> </div>
</div> </div>
{% elif coin %} {% endif %}
{% if coin %}
<div id="id_coin_on_a_string" class="token"> <div id="id_coin_on_a_string" class="token">
<i class="fa-solid fa-medal"></i> <i class="fa-solid fa-medal"></i>
<div class="tt"> <div class="tt">
@@ -41,6 +49,19 @@
</div> </div>
</div> </div>
{% endif %} {% endif %}
{% if carte %}
<div id="id_carte_token" class="token">
<i class="fa-solid fa-money-check"></i>
<div class="tt">
<h4 class="tt-title">{{ carte.tooltip_name }}</h4>
<p class="tt-description">{{ carte.tooltip_description }}</p>
{% if carte.tooltip_shoptalk %}
<p class="tt-shoptalk"><em>{{ carte.tooltip_shoptalk }}</em></p>
{% endif %}
<p class="tt-expiry">{{ carte.tooltip_expiry }}</p>
</div>
</div>
{% endif %}
{% if free_tokens %} {% if free_tokens %}
{% with free_tokens.0 as token %} {% with free_tokens.0 as token %}
<div id="id_free_token" class="token"> <div id="id_free_token" class="token">