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)
grid_cols = models.PositiveSmallIntegerField(default=12)
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):
return self.name

View File

@@ -13,9 +13,12 @@ def apply_applet_toggle(user, context, checked_slugs):
def applet_context(user, context):
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 [
{"applet": applets[slug], "visible": ua_map.get(applets[slug].pk, applets[slug].default_visible)}
for slug in applets
if slug in applets
{"applet": a, "visible": ua_map.get(a.pk, a.default_visible)}
for a in applets_qs
]

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,31 +64,113 @@ 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() {
const portal = document.getElementById('id_tooltip_portal');
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 => {
const tooltip = token.querySelector('.tt');
// Hover-persistence — keep the portal(s) open while the cursor moves
// from tile → portal → mini-portal so users can click the BUY-ITEM
// 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.
function _show(anchor, tooltipHtml, microHtml) {
_cancelHide();
const rect = anchor.getBoundingClientRect();
portal.innerHTML = tooltipHtml;
portal.classList.add('active');
const halfW = portal.offsetWidth / 2;
const rawLeft = rect.left + rect.width / 2;
const clampedLeft = Math.max(halfW + 8, Math.min(rawLeft, window.innerWidth - halfW - 8));
portal.style.left = Math.round(clampedLeft) + 'px';
portal.style.top = Math.round(rect.top) + 'px';
portal.style.transform = 'translate(-50%, calc(-100% - 0.5rem))';
if (miniPortal && microHtml) {
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);
}
token.addEventListener('mouseenter', () => {
const rect = token.getBoundingClientRect();
portal.innerHTML = tooltip.innerHTML;
portal.classList.add('active');
const halfW = portal.offsetWidth / 2;
const rawLeft = rect.left + rect.width / 2;
const clampedLeft = Math.max(halfW + 8, Math.min(rawLeft, window.innerWidth - halfW - 8));
portal.style.left = Math.round(clampedLeft) + 'px';
portal.style.top = Math.round(rect.top) + 'px';
portal.style.transform = 'translate(-50%, calc(-100% - 0.5rem))';
});
document.querySelectorAll('.wallet-tokens .token, .wallet-shop .shop-tile')
.forEach(_bindHover);
token.addEventListener('mouseleave', () => {
portal.classList.remove('active');
});
});
// 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', 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):
[_] = self.parsed.cssselect("#id_stripe_payment_element")
def test_wallet_page_shows_tithe_token_shop(self):
[_] = self.parsed.cssselect("#id_tithe_token_shop")
# Note: the legacy `#id_tithe_token_shop` HTML in Balances was
# 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")
self.assertGreater(len(bundles), 0)
class WalletTokensAppletAllTrinketsVisibleTest(TestCase):
"""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):

View File

@@ -10,6 +10,8 @@ urlpatterns = [
path('wallet/toggle-applets', views.toggle_wallet_applets, name='toggle_wallet_applets'),
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/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('sky/', views.sky_view, name='sky'),
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.urls import reverse
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.drama.models import Note
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"]
@@ -155,6 +155,18 @@ def toggle_applets(request):
})
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="/")
@ensure_csrf_cookie
def wallet(request):
@@ -162,11 +174,17 @@ def wallet(request):
token_type=Token.FREE, expires_at__gt=timezone.now()
).order_by("expires_at"))
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", {
"wallet": request.user.wallet,
"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(),
"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,
"tithe_tokens": tithe_tokens,
"free_count": len(free_tokens),
@@ -198,12 +216,17 @@ def toggle_wallet_applets(request):
checked = request.POST.getlist("applets")
apply_applet_toggle(request.user, "wallet", checked)
if request.headers.get("HX-Request"):
default_pm = request.user.payment_methods.order_by("-pk").first()
return render(request, "apps/wallet/_partials/_applets.html", {
"applets": applet_context(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,
"band": request.user.tokens.filter(token_type=Token.BAND).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)),
"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})
# ── 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) ────────────────────────────────────────────
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

@@ -329,6 +329,143 @@ class PaymentMethod(models.Model):
def __str__(self):
return f"{self.brand} ....{self.last4}"
class ShopItem(models.Model):
"""A purchasable bundle in the wallet's Shop applet — admin-managed
catalog. Each row defines (price → granted Tokens + writs); the
`Purchase.fulfill()` flow mints `granted_count` tokens of
`granted_token_type` + bumps `Wallet.writs` by `granted_writs`.
See [[project-wallet-shop-expansion]] for the broader design + the
3 starting catalog items (tithe-1, tithe-5, band-1) seeded in
`lyric/0009_seed_shop_items`."""
slug = models.SlugField(unique=True)
name = models.CharField(max_length=100)
description = models.TextField(blank=True, default="")
# `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)
def create_wallet_and_tokens(sender, instance, created, **kwargs):

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_PUBLISHABLE_KEY = os.environ.get("STRIPE_PUBLISHABLE_KEY", "")
STRIPE_SECRET_KEY = os.environ.get("STRIPE_SECRET_KEY", "")
STRIPE_WEBHOOK_SECRET = os.environ.get("STRIPE_WEBHOOK_SECRET", "")
# PySwiss ephemeris microservice
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('ap/', include('apps.ap.urls')),
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

View File

@@ -3,21 +3,62 @@ from selenium.webdriver.common.by import By
from .base import FunctionalTest
from apps.applets.models import Applet
from apps.lyric.models import ShopItem, Token, User
class WalletDisplayTest(FunctionalTest):
def setUp(self):
super().setUp()
Applet.objects.get_or_create(slug="wallet", defaults={"name": "Wallet"})
for slug, name, cols, rows in [
("wallet-balances", "Wallet Balances", 3, 3),
("wallet-tokens", "Wallet Tokens", 3, 3),
("wallet-payment", "Payment Methods", 6, 3),
for slug, name, cols, rows, order in [
("wallet-shop", "Shop", 12, 3, 10),
("wallet-balances", "Wallet Balances", 3, 3, 100),
("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,
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):
# 1. Log in as new user
@@ -179,20 +220,214 @@ class WalletDisplayTest(FunctionalTest):
)
self.assertEqual(rows, '3')
def test_user_can_purchase_tithe_token_bundle(self):
# 1. Log in, navigate to wallet page
def test_wallet_tokens_applet_shows_all_owned_trinket_types(self):
"""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.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(
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
bundle = self.browser.find_element(
By.CSS_SELECTOR, "#id_tithe_token_shop .token-bundle"
msg = portal.find_element(By.CSS_SELECTOR, ".guard-message").text
self.assertIn("Tithe", msg)
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)
self.assertIn("Writ", bundle.text)
# 5. (Placeholder) Purchase flow via Stripe not driven in this FT:
# Full charge assertion deferred until Stripe webhook handling implemented
def test_shop_buy_guard_portal_pins_item_tooltip(self):
"""While the BUY-ITEM guard portal is open, the item's main +
mini tooltip stay pinned — they don't dismiss when the cursor
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="NotePageSpec.js"></script>
<script src="RowLockSpec.js"></script>
<script src="WalletShopSpec.js"></script>
<!-- src files -->
<script src="/static/apps/applets/row-lock.js"></script>
<script src="/static/apps/dashboard/dashboard.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/epic/stage-card.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-expiry { font-size: 1rem; color: rgba(var(--priRd), 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,

View File

@@ -71,3 +71,106 @@
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="NotePageSpec.js"></script>
<script src="RowLockSpec.js"></script>
<script src="WalletShopSpec.js"></script>
<!-- src files -->
<script src="/static/apps/applets/row-lock.js"></script>
<script src="/static/apps/dashboard/dashboard.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/epic/stage-card.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" %}
</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="{% static "apps/dashboard/wallet.js" %}"></script>
<script src="{% static "apps/dashboard/wallet-shop.js" %}"></script>
{% endblock content %}

View File

@@ -2,19 +2,13 @@
id="id_wallet_balances"
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>
<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 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>

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>
<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 %}
<div id="id_pass_token" class="token">
<i class="fa-solid fa-clipboard"></i>
@@ -16,7 +22,8 @@
<p class="tt-expiry">{{ pass_token.tooltip_expiry }}</p>
</div>
</div>
{% elif band %}
{% endif %}
{% if band %}
<div id="id_band_token" class="token">
<i class="fa-solid fa-ring"></i>
<div class="tt">
@@ -28,7 +35,8 @@
<p class="tt-expiry">{{ band.tooltip_expiry }}</p>
</div>
</div>
{% elif coin %}
{% endif %}
{% if coin %}
<div id="id_coin_on_a_string" class="token">
<i class="fa-solid fa-medal"></i>
<div class="tt">
@@ -41,6 +49,19 @@
</div>
</div>
{% 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 %}
{% with free_tokens.0 as token %}
<div id="id_free_token" class="token">