Prima Get an API key

Module kit / Billing and credits

Subscription manager

billing-credits/ui/subscription-managerlive

Kind frontend
Surface SPA
Maturity unmerged
Reusable extract
Design system alchemy-ui
Stack react-19, react-router-7, tailwind-3.4, alchemy-ui, axios-1, framer-motion
Depends on _shared/design-system-and-tokens, _shared/react-app-skeleton, _shared/error-envelope
Renders billing-credits/auto-topup-charge-and-retry, billing-credits/auto-topup-card-banking-and-refunds, billing-credits/prima-credit-grant-outbox, billing-credits/prima-threshold-sync, billing-credits/sparks-low-balance-notification, billing-credits/subscription-cancel-and-dunning
Entry alchemy_labs/frontend/src/pages/billing/SubscriptionManager.tsx
Reference implementation
  • alchemy_labs/frontend/src/pages/billing/SubscriptionManager.tsx
  • alchemy_labs/frontend/src/components/Navigation.tsx
  • alchemy_labs/frontend/src/components/SubscriptionModal.tsx
  • alchemy_labs/frontend/src/components/nav.tsx
  • alchemy_labs/frontend/src/services/api.ts
  • alchemy_labs/backend/api/routers/user_router.py
  • alchemy_labs/backend/api/routers/billing_router.py
  • alchemy_labs/backend/api/routers/voucher_router.py
  • alchemy_labs/backend/services/metal_service.py
  • alchemy_labs/backend/services/voucher_service.py
  • alchemy_labs/backend/core/metal_config.py
  • alchemy_labs/backend/app_fastapi.py
Measured on alchemy_labs w1-auto-topup af7a35246dd4a009485bc67b3a13fea6e5a688cc 2026-09-12
Version 1

Purpose

The customer's own balance page: a tenant opens /settings/billing and sees their current tier's metal tank (the subscription grant, any top-up and any welcome bonus, combined into one balance and one gauge), the full five-tier comparison, and can start a top-up checkout, open the change-tier dialog, cancel the subscription, or redeem a voucher code, all from one screen. The invariant that makes it trustworthy: everything shown is a straight read of GET /api/user/metal-status, computed on the server from the columns on the tenant's own user row (metal_tank_ml, metal_topup_ml, metal_welcome_ml), so a tenant who reloads always sees the number the next AI call is actually charged against, not a client-side estimate.

It makes visible the six backend recipes it renders on top of: the auto top-up charge and its retry (auto-topup-charge-and-retry), the card and banking side of that flow (auto-topup-card-banking-and-refunds), the credit grant outbox that turns a payment into tank milliliters (prima-credit-grant-outbox), PrimaAPI's own threshold sync (prima-threshold-sync), the low-balance notification (sparks-low-balance-notification) and the cancel and dunning flow (subscription-cancel-and-dunning); none of those six write anywhere a tenant can see except through this page's next GET /api/user/metal-status.

When to use

Trigger phrases: "the billing page", "my balance", "top up", "change my tier", "cancel my subscription", "redeem a voucher", "why is my tank empty", "the Kiosk page". Surface: SPA, a private Labs page behind PrivateRoute, reached at /settings/billing ("Billing" under the "Settings" domain, live for every signed-in tenant) and a second time at /billing/manage (the same component, mounted under the admin-only "Money" domain, nav.tsx lines 215 to 227 and 257 to 272 at af7a352); /billing itself redirects to /billing/manage (App.tsx line 1002).

Not this recipe: the checkout page a top-up or a tier change actually lands on (/billing/checkout, Checkout.tsx, a separate route this page never renders), the success and cancel redirect pages (BillingSuccess.tsx, BillingCancel.tsx), the change-tier dialog's own content (SubscriptionModal.tsx, opened by two buttons on this page but not part of it, and itself hard-codes currentTokens={0} and monthlyLimit={0} regardless of what this page just fetched, Gotchas), the admin console that credits a user's legacy token balance by hand (identity-auth/ui/admin-users-console, a different balance entirely, Data model), and the six backend recipes this page is a window onto (backend_modules above), none of which a tenant reaches directly.

Stack assumptions

S1 tokens and S2 skeleton assumed; pinned per the source at af7a352: react 19, react-router-dom 7, tailwindcss 3.4, lucide-react, axios 1, framer-motion (for the gauge fill's mount animation only, not one of the four packages a demo may import from, Frontend). Component library: @prima-modules/alchemy-ui, imported the same way identity-auth/ui/admin-users-console imports it, of which the page uses Button, Heading, Text, Card, CardContent (imported, never actually called, a dead import at af7a352, Gotchas), Badge, Input, Spinner, Divider and AZSymbol. Data-fetch style: the api singleton (S2), the Bearer header from accessToken in localStorage; none of the three routes this page calls read X-Workspace-ID (Security and tenancy). Copy language: German, de-CH, every sentence on the page except one, the voucher endpoint's success message is written in English server-side and reaches the screen unmodified (Gotchas).

Data model

Client state (SubscriptionManager.tsx lines 68 to 81), all in the page component, nothing persisted beyond the two auth tokens S2 already owns:

interface MetalStatus {                 // GET /api/user/metal-status, unchanged from the route's JSON
  tier: string; tier_display: string;
  tank_ml: number; tank_capacity_ml: number;
  topup_ml: number; welcome_ml: number;
  total_available_ml: number; total_capacity_ml: number;
  percentage_used: number;              // of tank_capacity_ml only, never of the total (Application logic)
  liters_remaining: number;
  period_start: string | null; period_end: string | null;
  price_chf: number;
  used_total_ml: number; used_total_liters: number;   // named liters, holds grams (Gotchas)
}
loading: boolean (initially true); metalStatus: MetalStatus | null; error: string | null;
canceling: boolean; topupLoading: boolean; showSubscriptionModal: boolean;
voucherCode: string; voucherLoading: boolean;
voucherSuccess: string | null; voucherError: string | null;

METAL_TIERS, the five-row array both the tier grid and the "Plan" line under the tank read from, is a literal in the page (lines 49 to 55), not fetched; it matches core/metal_config.py's METAL_TIERS (grams, price and per-gram price identical at af7a352, so the two have not drifted, though nothing keeps them in sync beyond a person editing both).

The demo's props (demo.tsx): { viewer, workspace, status: 'loading' | 'ok' | 'error', metalStatus: MetalStatus | null, error?, canceling?, topupLoading?, voucherCode?, voucherLoading?, voucherSuccess?, voucherError? }; voucherCode seeds a local useState the same way admin-users-console seeds its search box, since typing has to update a controlled Input.

Browser storage: nothing of its own, relies on S2's accessToken and refreshToken in localStorage. currentWorkspaceId, if set, is attached to the request by the API client's interceptor but read by none of the three routes below (Security and tenancy).

API surface

Contracts consumed, one read and three writes:

Call (line)RouteAuth / tenancyParsedRendered as
api.get('/api/user/metal-status') (90)GET /api/user/metal-statusActiveUser (a signed-in, active user, no workspace)the whole body into metalStatus; a missing user server-side returns {error: "user_not_found"} with 200, which the page stores as-is with no check for .error (Gotchas)the tank card, the tier grid's "Aktuell" row, the gratis fallback on any throw
api.post('/api/billing/topup') (103)POST /api/billing/topupActiveUserresponse.data.checkout_url; on success the tab navigates away (window.location.href), nothing else is reada full-page redirect to the top-up checkout on success; err.response?.data?.detail (undefined, Gotchas) as the top-level error card on failure
api.post('/api/billing/cancel') (120)POST /api/billing/cancelActiveUsernothing on 200 ({success, message} discarded); loadData() refetchesthe balance falls back to gratis on the refetch (tank zeroed server-side, top-up and welcome ml preserved, Application logic); err.response?.data?.detail as the error card on failure
api.post('/api/vouchers/redeem', {code}) (138)POST /api/vouchers/redeemActiveUserresponse.data.message into voucherSuccess; err.response?.data?.detail into voucherErrorthe voucher card's own green or red banner; a setTimeout(loadData, 1000) refetch the tank never reflects (Gotchas)

Error bodies as this page reads them: every refusal from these three routes is a plain HTTPException(detail=...) (billing_router.py, voucher_router.py), which app_fastapi.py's handler (line 724 to 793) wraps into the Labs envelope under the key error, not detail (create_error_response, S7); the page's four catch blocks all read err.response?.data?.detail, which is undefined on that shape, so every refusal from these three routes reaches the screen as the page's own fixed fallback sentence, never the server's actual message ("Payment processing is not configured", "No active subscription found", a voucher's "This voucher has already been redeemed", and so on, all silently discarded). This is the same mismatch identity-auth/ui/admin-users-console's Gotchas documents for the admin console reading the same envelope. No WebSocket, no postMessage; POST /api/billing/verify-session and /webhook exist on the same router but are never called from this page (they belong to the checkout return path and the payment provider's own callback).

Application logic

  1. SubscriptionManager mounts with loading: true, metalStatus: null; useEffect fires loadData() once (lines 83 to 85). While loading, the page returns <Navigation><div class="min-h-screen"><Spinner xl/></div></Navigation>, so the shell is already on screen, only the balance area is a single spinner (lines 159 to 167).
  2. loadData calls GET /api/user/metal-status, stores the body as metalStatus and clears error on success, or sets the fixed sentence "Metallstatus konnte nicht geladen werden" on any throw; loading goes false either way (87 to 97).
  3. Three booleans derive the rest of the render: isGratis = !metalStatus || metalStatus.tier === 'gratis', percentageUsed = metalStatus?.percentage_used ?? 0, isCritical = percentageUsed > 95, isNearLimit = percentageUsed > 80 (169 to 173). A failed load and a genuinely gratis tenant land on the identical isGratis branch (Gotchas): the "Kein aktives Abo" card and the single CTA are what a tenant sees both when they truly have no subscription and when the read simply failed.
  4. "Nachschub" (top-up): posts to /topup, and on a checkout_url in the response navigates the tab there directly (window.location.href, no client-side confirmation first); topupLoading disables the button and shows the atom's own spinner for the round trip; on failure the button re-enables and the top-level error card appears (99 to 111).
  5. "Abo kuendigen" (cancel): a window.confirm first ("Bist du sicher, dass du dein Abo kuendigen moechtest? Dein Tank wird auf 0 gesetzt."); on confirm, canceling disables the button and swaps its label to "Wird bearbeitet..."; POST /cancel zeroes metal_tank_ml and sets the tier back to gratis server-side while leaving metal_topup_ml and metal_welcome_ml untouched (billing_router.py 368 to 377), then loadData() refetches, so the page's own next render is the gratis card, now possibly still showing a leftover top-up or welcome balance the tenant paid for separately (113 to 127).
  6. "Abo aendern" and the gratis "Transmutation waehlen" both open SubscriptionModal with currentTokens={0} and monthlyLimit={0} hard-coded regardless of the fetched balance (536 to 545, Gotchas); the modal's own onSubscribe closes it and calls loadData() again.
  7. Voucher redemption: typing uppercases the input as it goes (e.target.value.toUpperCase(), line 477); submit is disabled on an empty or in-flight code; on submit, a client-side check refuses an empty trimmed code with "Bitte Voucher-Code eingeben" before any request leaves; on success, voucherSuccess is set to the route's own message, the input is cleared, and a setTimeout(loadData, 1000) refetches the tank, which the voucher never actually changes (Gotchas); on failure voucherError is set to the fixed fallback sentence and the typed code is left in the box (129 to 149).
  8. Sign-out, workspace switching and the tour are the shell's; the page owns no navigation beyond the top-up redirect.

Invariants

  • The balance shown is the balance charged: nothing here computes a number locally; the tank, the percentage and the breakdown are all read straight off one GET /api/user/metal-status response, and every write (top-up, cancel, voucher) is followed by a refetch of the same route rather than a local update (steps 4 to 7).
  • percentage_used measures the subscription tank only: it is (tank_capacity_ml - tank_ml) / tank_capacity_ml, so a tenant who has burned through their monthly grant but is still coasting on a large top-up or welcome balance can see a gauge reading near 100% and red messaging while total_available_ml, what actually pays for the next call, is still large (metal_service.py 197 to 203).
  • Cancelling never destroys money already paid for outside the subscription: only metal_tank_ml is zeroed; metal_topup_ml and metal_welcome_ml are explicitly preserved (billing_router.py 375).
  • A privileged action confirms first, except the one that moves real money without asking: cancel asks (window.confirm); top-up does not, because it only opens a payment provider's own checkout, where the actual charge is confirmed a second time.
  • The raw server error is never shown, only the page's own four fixed sentences or, on the voucher card, err.response?.data?.detail, which is always undefined against this envelope (API surface); a tenant never learns why a top-up, a cancel or a voucher redemption actually failed.

Frontend

The component, rebuilt from the demo in the kit with the design system the recipe names, one frame per state. What is in the frames is what the recipe describes and what Primacode builds from; it is not a picture of the running application.

First fetch: the spinner inside the shell, before any balance is known (line 159 to 167) loading server rendered, hydrated in view
Silber, 30% used: the amber gauge, the tank/top-up/welcome breakdown, all three actions enabled active-plan server rendered, hydrated in view

Kiosk

Dein Abo und Verbrauch verwalten

Aktuelles Abo

Aktiv

Plan

Silber

100g / Monat — CHF 90/Mt.

93g

verbleibend

30% verbraucht

Abo-Barren

70g

Nachschub

15g

Willkommens-Bonus

8.0g

Total verfügbar

93g

1. September 2026 — 30. September 2026

Gesamt verbraucht: 850.0g

Alle Abos

Kupfer

50g / Monat — CHF 1.00/g

CHF 50

/Monat

Silber

Aktuell

100g / Monat — CHF 0.90/g

CHF 90

/Monat

Gold

250g / Monat — CHF 0.80/g

CHF 199

/Monat

Platin

500g / Monat — CHF 0.60/g

CHF 300

/Monat

Iridium

1000g / Monat — CHF 0.50/g

CHF 500

/Monat

Voucher einlösen

Hast du einen Gutschein-Code? Löse ihn für Bonus-Metall ein.

19-stelliger Voucher-Code eingeben

Was ist Transmutation?

Transmutation ist dein AI-Budget. 1 Gramm = 1 CHF. Jeder AI-Aufruf verbraucht Metall basierend auf den tatsächlichen API-Kosten. Dein Barren wird monatlich automatisch aufgefüllt.

Einfache Frage

~0.01g

Komplexe Analyse

~0.05g

SEO-Seite generieren

~0.10g

Sichere Zahlung via zahls.ch — Jederzeit kündbar — Sofort aktiv

Gold, 97.4% used: the red gauge and the tighter breakdown right before the tank empties near-limit-critical server rendered, hydrated in view

Kiosk

Dein Abo und Verbrauch verwalten

Aktuelles Abo

Aktiv

Plan

Gold

250g / Monat — CHF 199/Mt.

7.7g

verbleibend

97% verbraucht

Abo-Barren

6.5g

Nachschub

1.2g

Total verfügbar

7.7g

1. September 2026 — 30. September 2026

Gesamt verbraucht: 2150.0g

Alle Abos

Kupfer

50g / Monat — CHF 1.00/g

CHF 50

/Monat

Silber

Beliebt

100g / Monat — CHF 0.90/g

CHF 90

/Monat

Gold

Aktuell

250g / Monat — CHF 0.80/g

CHF 199

/Monat

Platin

500g / Monat — CHF 0.60/g

CHF 300

/Monat

Iridium

1000g / Monat — CHF 0.50/g

CHF 500

/Monat

Voucher einlösen

Hast du einen Gutschein-Code? Löse ihn für Bonus-Metall ein.

19-stelliger Voucher-Code eingeben

Was ist Transmutation?

Transmutation ist dein AI-Budget. 1 Gramm = 1 CHF. Jeder AI-Aufruf verbraucht Metall basierend auf den tatsächlichen API-Kosten. Dein Barren wird monatlich automatisch aufgefüllt.

Einfache Frage

~0.01g

Komplexe Analyse

~0.05g

SEO-Seite generieren

~0.10g

Sichere Zahlung via zahls.ch — Jederzeit kündbar — Sofort aktiv

Gratis with an unspent welcome bonus: the info card and the single "Transmutation wählen" CTA free-tier server rendered, hydrated in view

Kiosk

Dein Abo und Verbrauch verwalten

Aktuelles Abo

Gratis

Plan

Gratis

Kein aktives Abo

Wähle ein Abo um AI-Features zu nutzen. 1 Gramm = 1 CHF. Du hast noch 33g Willkommens-Bonus.

Alle Abos

Kupfer

50g / Monat — CHF 1.00/g

CHF 50

/Monat

Silber

Beliebt

100g / Monat — CHF 0.90/g

CHF 90

/Monat

Gold

250g / Monat — CHF 0.80/g

CHF 199

/Monat

Platin

500g / Monat — CHF 0.60/g

CHF 300

/Monat

Iridium

1000g / Monat — CHF 0.50/g

CHF 500

/Monat

Voucher einlösen

Hast du einen Gutschein-Code? Löse ihn für Bonus-Metall ein.

19-stelliger Voucher-Code eingeben

Was ist Transmutation?

Transmutation ist dein AI-Budget. 1 Gramm = 1 CHF. Jeder AI-Aufruf verbraucht Metall basierend auf den tatsächlichen API-Kosten. Dein Barren wird monatlich automatisch aufgefüllt.

Einfache Frage

~0.01g

Komplexe Analyse

~0.05g

SEO-Seite generieren

~0.10g

Sichere Zahlung via zahls.ch — Jederzeit kündbar — Sofort aktiv

Cancel in flight on the Silber plan: "Wird bearbeitet...", the button disabled action-busy server rendered, hydrated in view

Kiosk

Dein Abo und Verbrauch verwalten

Aktuelles Abo

Aktiv

Plan

Silber

100g / Monat — CHF 90/Mt.

93g

verbleibend

30% verbraucht

Abo-Barren

70g

Nachschub

15g

Willkommens-Bonus

8.0g

Total verfügbar

93g

1. September 2026 — 30. September 2026

Gesamt verbraucht: 850.0g

Alle Abos

Kupfer

50g / Monat — CHF 1.00/g

CHF 50

/Monat

Silber

Aktuell

100g / Monat — CHF 0.90/g

CHF 90

/Monat

Gold

250g / Monat — CHF 0.80/g

CHF 199

/Monat

Platin

500g / Monat — CHF 0.60/g

CHF 300

/Monat

Iridium

1000g / Monat — CHF 0.50/g

CHF 500

/Monat

Voucher einlösen

Hast du einen Gutschein-Code? Löse ihn für Bonus-Metall ein.

19-stelliger Voucher-Code eingeben

Was ist Transmutation?

Transmutation ist dein AI-Budget. 1 Gramm = 1 CHF. Jeder AI-Aufruf verbraucht Metall basierend auf den tatsächlichen API-Kosten. Dein Barren wird monatlich automatisch aufgefüllt.

Einfache Frage

~0.01g

Komplexe Analyse

~0.05g

SEO-Seite generieren

~0.10g

Sichere Zahlung via zahls.ch — Jederzeit kündbar — Sofort aktiv

The balance failed to load (so the card falls back to its gratis look) while a voucher redemption succeeds beside it error-and-voucher-feedback server rendered, hydrated in view

Kiosk

Dein Abo und Verbrauch verwalten

Metallstatus konnte nicht geladen werden

Aktuelles Abo

Gratis

Plan

Gratis

Kein aktives Abo

Wähle ein Abo um AI-Features zu nutzen. 1 Gramm = 1 CHF.

Alle Abos

Kupfer

50g / Monat — CHF 1.00/g

CHF 50

/Monat

Silber

Beliebt

100g / Monat — CHF 0.90/g

CHF 90

/Monat

Gold

250g / Monat — CHF 0.80/g

CHF 199

/Monat

Platin

500g / Monat — CHF 0.60/g

CHF 300

/Monat

Iridium

1000g / Monat — CHF 0.50/g

CHF 500

/Monat

Voucher einlösen

Hast du einen Gutschein-Code? Löse ihn für Bonus-Metall ein.

Voucher redeemed successfully! 33,000 tokens added to your account.

19-stelliger Voucher-Code eingeben

Was ist Transmutation?

Transmutation ist dein AI-Budget. 1 Gramm = 1 CHF. Jeder AI-Aufruf verbraucht Metall basierend auf den tatsächlichen API-Kosten. Dein Barren wird monatlich automatisch aufgefüllt.

Einfache Frage

~0.01g

Komplexe Analyse

~0.05g

SEO-Seite generieren

~0.10g

Sichere Zahlung via zahls.ch — Jederzeit kündbar — Sofort aktiv

billing-credits/ui/subscription-manager/demo.tsx
/**
 * Live demo for billing-credits/ui/billing-credits/ui/subscription-manager.
 *
 * Labs' SubscriptionManager.tsx (w1-auto-topup, af7a352, 551 lines) with its
 * one read (GET /api/user/metal-status) and three writes (top-up, cancel,
 * voucher redemption) replaced by props: the fetch status, the parsed
 * balance object, the top-level error string, the two per-action busy flags
 * and the voucher panel's own draft/busy/success/error. Rendered inside the
 * shell demo's component the way every private page is (Navigation.tsx is a
 * thin delegator to AppShell, same as AdminNavigation), on `/settings/billing`
 * ("Billing" under the "Settings" domain, nav.tsx line 269 at af7a352 — the
 * page is also reachable at `/billing/manage` under the admin-only "Money"
 * domain, but `/settings/billing` is the one every signed-in tenant can
 * reach, so that is the path this rebuild sits on).
 *
 * What is kept from the source: the header, the top-level error card, the
 * "Aktuelles Abo" card with the metal-tank gauge and its tank/top-up/welcome
 * breakdown, the billing-period and lifetime-usage lines, the gratis info
 * card, the three action buttons (or the single gratis CTA), the full
 * "Alle Abos" tier grid (five tiers, the "Aktuell" and "Beliebt" badges), the
 * voucher card with its own success/error banners, and the static
 * "Was ist Transmutation?" info card. All copy is the source's, German
 * (de-CH), including the one English sentence the voucher endpoint returns
 * (Gotchas) — that inconsistency is real and is reproduced, not corrected.
 *
 * What is different, and why: `framer-motion`'s `motion.div` animates the
 * gauge's width on mount; the demo packages carry no animation library, so
 * the fill renders directly at its resting width with no transition (the
 * gate bundles every demo for Node with react and react-dom the only runtime
 * dependencies, and framer-motion is not one of the four packages a demo may
 * import from, AUTHORING.md "The exports"). `toLocaleDateString('de-CH', …)`
 * is replaced with a fixed month-name table read with the UTC getters (the
 * source's locale- and timezone-dependent formatting would not survive
 * server render into browser hydration — the same class of fix
 * identity-auth/ui/admin-users-console's demo applies to `toLocaleString()`
 * on its token counts). `window.location.href` (the top-up redirect to
 * checkout) and `window.confirm` (the cancel confirmation) are not wired: a
 * demo reaches no network and has no user to confirm with, so the top-up and
 * cancel buttons are inert and the state of `canceling` / `topupLoading` is
 * driven only by props, the same convention admin-users-console uses for its
 * prompt- and confirm-gated row actions. `SubscriptionModal` (the change-tier
 * / choose-a-tier dialog both `Button`s in the gratis and paid branches open)
 * is a page-local component, not part of the packages a demo may import from
 * (AUTHORING.md), so it is not mounted; both buttons that open it render with
 * their real label and icon but do nothing, which is called out under
 * Frontend and matches the source's own already-loose call sites (opening
 * the modal always passes `currentTokens={0}` and `monthlyLimit={0}`
 * regardless of the fetched balance, i.e. the modal renders assuming a
 * caller it cannot see, Gotchas).
 */
import React, { useState } from 'react';
import { AlertCircle, ArrowRight, Calendar, Check, Gem, Gift, Plus, TrendingUp, X } from 'lucide-react';
import { defineStates } from '@prima-modules/demo';
import { AZSymbol, Badge, Button, Card, Divider, Heading, Input, Spinner, Text } from '@prima-modules/alchemy-ui';
import { Shell, type ShellUser, type ShellWorkspace } from '../../../ui-design-system/ui/appshell-navigation/demo';
import { DOMAINS } from '../../../ui-design-system/ui/appshell-navigation/nav';

/* ---- props ------------------------------------------------------------- */

/** GET /api/user/metal-status's shape (MetalService.get_metal_status, backend/services/metal_service.py 177 to 221). */
export interface MetalStatus {
  tier: string;
  tier_display: string;
  tank_ml: number;
  tank_capacity_ml: number;
  topup_ml: number;
  welcome_ml: number;
  total_available_ml: number;
  total_capacity_ml: number;
  percentage_used: number;
  liters_remaining: number;
  period_start: string | null;
  period_end: string | null;
  price_chf: number;
  used_total_ml: number;
  /** Named "liters", holds grams: get_metal_status divides mg by 1000 and the page appends "g" (line 306). Reproduced as measured. */
  used_total_liters: number;
}

export interface Props {
  /** The signed-in tenant, for the shell's account row. */
  viewer: ShellUser;
  workspace: ShellWorkspace;
  /** `loading` is the first fetch (a spinner inside the shell, line 159 to 167); `error` is the read failing (line 92 to 93). */
  status: 'loading' | 'ok' | 'error';
  /** Null when `loading`, when the read failed, or for a tenant with no subscription and no leftover balance. */
  metalStatus: MetalStatus | null;
  /** The top-level error card's text (line 189 to 196): the load failure, or a top-up/cancel failure once loaded. */
  error?: string | null;
  /** The cancel button's busy flag: disabled, "Wird bearbeitet..." in place of the icon and label (line 373 to 389). */
  canceling?: boolean;
  /** The top-up button's busy flag: disabled, the atom's own spinner beside the label (line 350 to 360). */
  topupLoading?: boolean;
  /** The voucher input's starting value; typing updates it locally, same convention as admin-users-console's search box. */
  voucherCode?: string;
  voucherLoading?: boolean;
  voucherSuccess?: string | null;
  voucherError?: string | null;
}

/* ---- sample data --------------------------------------------------------- */

const viewer: ShellUser = { name: 'Elena Roth', email: '[email protected]', isAdmin: false };
const workspace: ShellWorkspace = { id: 'ws_1', name: 'Atelier Nordwind', slug: 'atelier-nordwind' };

/** Silber, comfortably under the 80% mark: tank 70g of 100g, 15g top-up and 8g welcome bonus left over. */
const activeStatus: MetalStatus = {
  tier: 'silber',
  tier_display: 'Silber',
  tank_ml: 70_000,
  tank_capacity_ml: 100_000,
  topup_ml: 15_000,
  welcome_ml: 8_000,
  total_available_ml: 93_000,
  total_capacity_ml: 100_000,
  percentage_used: 30.0,
  liters_remaining: 93.0,
  period_start: '2026-09-01T00:00:00Z',
  period_end: '2026-09-30T23:59:59Z',
  price_chf: 90,
  used_total_ml: 850_000,
  used_total_liters: 850.0,
};

/** Gold, past the 95% cutoff line: tank down to 6.5g of 250g, a thin 1.2g top-up left, welcome bonus already spent. */
const criticalStatus: MetalStatus = {
  tier: 'gold',
  tier_display: 'Gold',
  tank_ml: 6_500,
  tank_capacity_ml: 250_000,
  topup_ml: 1_200,
  welcome_ml: 0,
  total_available_ml: 7_700,
  total_capacity_ml: 250_000,
  percentage_used: 97.4,
  liters_remaining: 7.7,
  period_start: '2026-09-01T00:00:00Z',
  period_end: '2026-09-30T23:59:59Z',
  price_chf: 199,
  used_total_ml: 2_150_000,
  used_total_liters: 2150.0,
};

/** Gratis: no tank, the 33g welcome bonus (WELCOME_BONUS_ML, metal_config.py) still unspent. */
const freeStatus: MetalStatus = {
  tier: 'gratis',
  tier_display: 'Gratis',
  tank_ml: 0,
  tank_capacity_ml: 0,
  topup_ml: 0,
  welcome_ml: 33_000,
  total_available_ml: 33_000,
  total_capacity_ml: 0,
  percentage_used: 0,
  liters_remaining: 33.0,
  period_start: null,
  period_end: null,
  price_chf: 0,
  used_total_ml: 0,
  used_total_liters: 0,
};

const base: Props = {
  viewer,
  workspace,
  status: 'ok',
  metalStatus: activeStatus,
  error: null,
  canceling: false,
  topupLoading: false,
  voucherCode: '',
  voucherLoading: false,
  voucherSuccess: null,
  voucherError: null,
};

export const states = defineStates<Props>({
  'loading': { label: 'First fetch: the spinner inside the shell, before any balance is known (line 159 to 167)', props: { ...base, status: 'loading', metalStatus: null }, height: 560, viewport: 1440 },
  'active-plan': { label: 'Silber, 30% used: the amber gauge, the tank/top-up/welcome breakdown, all three actions enabled', props: base, height: 1500, viewport: 1440 },
  'near-limit-critical': { label: 'Gold, 97.4% used: the red gauge and the tighter breakdown right before the tank empties', props: { ...base, metalStatus: criticalStatus }, height: 1500, viewport: 1440 },
  'free-tier': { label: 'Gratis with an unspent welcome bonus: the info card and the single "Transmutation wählen" CTA', props: { ...base, metalStatus: freeStatus }, height: 1360, viewport: 1440 },
  'action-busy': { label: 'Cancel in flight on the Silber plan: "Wird bearbeitet...", the button disabled', props: { ...base, canceling: true }, height: 1500, viewport: 1440 },
  'error-and-voucher-feedback': { label: 'The balance failed to load (so the card falls back to its gratis look) while a voucher redemption succeeds beside it', props: { ...base, status: 'error', metalStatus: null, error: 'Metallstatus konnte nicht geladen werden', voucherCode: '', voucherSuccess: 'Voucher redeemed successfully! 33,000 tokens added to your account.' }, height: 1400, viewport: 1440 },
});

/* ---- helpers (SubscriptionManager.tsx lines 49 to 66) -------------------- */

const METAL_TIERS = [
  { id: 'kupfer', name: 'Kupfer', grams: 50, price: 50, pricePerGram: 1.0 },
  { id: 'silber', name: 'Silber', grams: 100, price: 90, pricePerGram: 0.9 },
  { id: 'gold', name: 'Gold', grams: 250, price: 199, pricePerGram: 0.8 },
  { id: 'platin', name: 'Platin', grams: 500, price: 300, pricePerGram: 0.6 },
  { id: 'iridium', name: 'Iridium', grams: 1000, price: 500, pricePerGram: 0.5 },
];

function formatGrams(ml: number): string {
  const grams = ml / 1000;
  const abs = Math.abs(grams);
  const sign = grams < 0 ? '-' : '';
  if (abs >= 100) return `${sign}${Math.round(abs)}g`;
  if (abs >= 10) return `${sign}${abs.toFixed(0)}g`;
  if (abs >= 0.1) return `${sign}${abs.toFixed(1)}g`;
  if (ml === 0) return '0g';
  return `${ml}ml`;
}

/** Fixed month table read with UTC getters: toLocaleDateString('de-CH', …) depends on the runtime's ICU data and the
 * viewer's timezone, so a server-rendered date can read differently once the same markup hydrates in the browser. */
const MONTHS_DE = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'];
function formatDate(dateString: string): string {
  const d = new Date(dateString);
  return `${d.getUTCDate()}. ${MONTHS_DE[d.getUTCMonth()]} ${d.getUTCFullYear()}`;
}

/* ---- the page ------------------------------------------------------------ */

function Kiosk({
  status,
  metalStatus,
  error,
  canceling = false,
  topupLoading = false,
  voucherCode: voucherCodeInitial = '',
  voucherLoading = false,
  voucherSuccess = null,
  voucherError = null,
}: Omit<Props, 'viewer' | 'workspace'>) {
  const [voucherCode, setVoucherCode] = useState(voucherCodeInitial);

  if (status === 'loading') {
    return (
      <div className="flex items-center justify-center min-h-[360px]">
        <Spinner size="xl" color="primary" />
      </div>
    );
  }

  const isGratis = !metalStatus || metalStatus.tier === 'gratis';
  const currentTierInfo = METAL_TIERS.find((t) => t.id === metalStatus?.tier);
  const percentageUsed = metalStatus?.percentage_used ?? 0;
  const isCritical = percentageUsed > 95;
  const isNearLimit = percentageUsed > 80;

  return (
    <div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
      {/* Header */}
      <div className="mb-8">
        <div className="flex items-center gap-3 mb-2">
          <div className="p-2 bg-gradient-to-br from-amber-400 to-amber-600 rounded-xl">
            <Gem className="w-6 h-6 text-white" />
          </div>
          <Heading level={1}>Kiosk</Heading>
        </div>
        <Text muted>Dein Abo und Verbrauch verwalten</Text>
      </div>

      {error && (
        <Card variant="glass" padding="md" className="mb-6 bg-red-50 border border-red-200">
          <div className="flex items-start gap-3">
            <AlertCircle className="text-red-500 flex-shrink-0 mt-0.5" size={20} />
            <Text className="text-red-600">{error}</Text>
          </div>
        </Card>
      )}

      {/* Current Plan + Metal Tank */}
      <Card variant="glass" padding="lg" className="mb-8">
        <div className="flex items-center justify-between mb-6">
          <Heading level={2}>Aktuelles Abo</Heading>
          {isGratis ? (
            <Badge variant="default" size="md">Gratis</Badge>
          ) : (
            <Badge variant="success" size="md" dot>Aktiv</Badge>
          )}
        </div>

        <div className="grid md:grid-cols-2 gap-8">
          {/* Left: Tier info + Tank */}
          <div>
            <div className="mb-6">
              <Text muted className="mb-1">Plan</Text>
              <Heading level={2} className="capitalize">
                {metalStatus?.tier_display ?? 'Gratis'}
              </Heading>
              {currentTierInfo && (
                <Text size="sm" muted className="mt-1">
                  {currentTierInfo.grams}g / Monat — CHF {currentTierInfo.price}/Mt.
                </Text>
              )}
            </div>

            {!isGratis && metalStatus && (
              <>
                <div className="mb-4">
                  <div className="flex items-baseline justify-between mb-2">
                    <Text weight="semibold" className="text-2xl text-gray-900">
                      {formatGrams(metalStatus.total_available_ml)}
                    </Text>
                    <Text size="sm" muted>verbleibend</Text>
                  </div>
                  <div className="relative w-full h-5 bg-gray-200 rounded-full overflow-hidden">
                    <div
                      className={`absolute top-0 left-0 h-full rounded-full ${
                        isCritical
                          ? 'bg-gradient-to-r from-red-500 to-red-600'
                          : isNearLimit
                          ? 'bg-gradient-to-r from-amber-500 to-orange-500'
                          : 'bg-gradient-to-r from-amber-400 to-amber-600'
                      }`}
                      style={{ width: `${Math.min(percentageUsed, 100)}%` }}
                    />
                    <div className="absolute inset-0 flex items-center justify-center">
                      <Text size="xs" weight="semibold" className="text-gray-700 drop-shadow-sm">
                        {percentageUsed.toFixed(0)}% verbraucht
                      </Text>
                    </div>
                  </div>
                </div>

                <Card variant="default" className="bg-amber-50 p-4 border-amber-200">
                  <div className="space-y-2 text-sm">
                    {metalStatus.tank_ml > 0 && (
                      <div className="flex justify-between">
                        <Text size="xs" className="text-amber-700">Abo-Barren</Text>
                        <Text size="xs" weight="medium" className="text-amber-900">{formatGrams(metalStatus.tank_ml)}</Text>
                      </div>
                    )}
                    {metalStatus.topup_ml > 0 && (
                      <div className="flex justify-between">
                        <Text size="xs" className="text-amber-700">Nachschub</Text>
                        <Text size="xs" weight="medium" className="text-amber-900">{formatGrams(metalStatus.topup_ml)}</Text>
                      </div>
                    )}
                    {metalStatus.welcome_ml > 0 && (
                      <div className="flex justify-between">
                        <Text size="xs" className="text-amber-700">Willkommens-Bonus</Text>
                        <Text size="xs" weight="medium" className="text-amber-900">{formatGrams(metalStatus.welcome_ml)}</Text>
                      </div>
                    )}
                    <div className="border-t border-amber-200 pt-2 flex justify-between">
                      <Text size="xs" weight="semibold" className="text-amber-900">Total verfügbar</Text>
                      <Text size="xs" weight="semibold" className="text-amber-900">{formatGrams(metalStatus.total_available_ml)}</Text>
                    </div>
                  </div>
                </Card>

                {metalStatus.period_start && metalStatus.period_end && (
                  <div className="mt-4 flex items-center gap-2 text-sm">
                    <Calendar size={16} className="text-amber-500" />
                    <Text size="sm" muted>
                      {formatDate(metalStatus.period_start)} — {formatDate(metalStatus.period_end)}
                    </Text>
                  </div>
                )}

                {metalStatus.used_total_liters > 0 && (
                  <div className="mt-2 flex items-center gap-2 text-sm">
                    <TrendingUp size={16} className="text-gray-400" />
                    <Text size="sm" muted>
                      Gesamt verbraucht: {metalStatus.used_total_liters.toFixed(1)}g
                    </Text>
                  </div>
                )}
              </>
            )}

            {isGratis && (
              <Card variant="default" className="bg-amber-50 p-4 border-amber-200 mt-4">
                <div className="flex items-start gap-3">
                  <AlertCircle className="w-5 h-5 text-amber-600 mt-0.5 flex-shrink-0" />
                  <div>
                    <Text size="sm" weight="semibold" className="text-amber-900 mb-1">Kein aktives Abo</Text>
                    <Text size="xs" className="text-amber-700 leading-relaxed">
                      Wähle ein Abo um AI-Features zu nutzen. 1 Gramm = 1 CHF.
                      {metalStatus && metalStatus.welcome_ml > 0 && (
                        <> Du hast noch {formatGrams(metalStatus.welcome_ml)} Willkommens-Bonus.</>
                      )}
                    </Text>
                  </div>
                </div>
              </Card>
            )}
          </div>

          {/* Right: Action Buttons */}
          <div className="space-y-4">
            {isGratis ? (
              <Button
                variant="primary"
                fullWidth
                className="py-4 bg-gradient-to-r from-amber-500 to-amber-600 hover:from-amber-600 hover:to-amber-700 text-lg font-bold"
              >
                <Gem size={20} />
                Transmutation wählen
                <ArrowRight size={16} />
              </Button>
            ) : (
              <>
                <Button
                  variant="primary"
                  fullWidth
                  disabled={topupLoading}
                  loading={topupLoading}
                  className="py-3 bg-gradient-to-r from-amber-500 to-amber-600 hover:from-amber-600 hover:to-amber-700"
                >
                  <Plus size={20} />
                  Nachschub (50g / CHF 50)
                </Button>

                <Button variant="secondary" fullWidth>
                  <TrendingUp size={20} />
                  Abo ändern
                </Button>

                <Button
                  variant="outline"
                  fullWidth
                  disabled={canceling}
                  loading={canceling}
                  className="hover:border-red-500 hover:bg-red-50 hover:text-red-600"
                >
                  {canceling ? (
                    'Wird bearbeitet...'
                  ) : (
                    <>
                      <X size={20} />
                      Abo kündigen
                    </>
                  )}
                </Button>
              </>
            )}
          </div>
        </div>
      </Card>

      {/* Tier Overview */}
      <Card variant="glass" padding="lg" className="mb-8">
        <Heading level={2} className="mb-6">Alle Abos</Heading>
        <div className="grid gap-3">
          {METAL_TIERS.map((tier) => {
            const isCurrentTier = metalStatus?.tier === tier.id;
            return (
              <div
                key={tier.id}
                className={`flex items-center justify-between p-4 rounded-xl border transition-all ${
                  isCurrentTier ? 'border-amber-500 bg-amber-50/50' : 'border-gray-200 hover:border-amber-300'
                }`}
              >
                <div className="flex items-center gap-3">
                  <div className={`w-8 h-8 rounded-full flex items-center justify-center ${isCurrentTier ? 'bg-amber-500 text-white' : 'bg-amber-100 text-amber-500'}`}>
                    <Gem className="w-4 h-4" />
                  </div>
                  <div>
                    <div className="flex items-center gap-2">
                      <Text weight="semibold" className="text-gray-900">{tier.name}</Text>
                      {isCurrentTier && <Badge variant="success" size="sm">Aktuell</Badge>}
                      {tier.id === 'silber' && !isCurrentTier && <Badge variant="info" size="sm">Beliebt</Badge>}
                    </div>
                    <Text size="xs" muted>{tier.grams}g / Monat — CHF {tier.pricePerGram.toFixed(2)}/g</Text>
                  </div>
                </div>
                <div className="text-right">
                  <Text weight="bold" className="text-gray-900">CHF {tier.price}</Text>
                  <Text size="xs" muted>/Monat</Text>
                </div>
              </div>
            );
          })}
        </div>
      </Card>

      {/* Voucher Redemption */}
      <Card variant="glass" padding="lg" className="mb-8">
        <div className="flex items-center gap-3 mb-6">
          <div className="p-3 bg-gradient-to-br from-amber-500 to-orange-500 rounded-xl">
            <Gift className="text-white" size={24} />
          </div>
          <div>
            <Heading level={2}>Voucher einlösen</Heading>
            <Text muted>Hast du einen Gutschein-Code? Löse ihn für Bonus-Metall ein.</Text>
          </div>
        </div>

        {voucherSuccess && (
          <Card variant="glass" padding="md" className="mb-6 bg-green-50 border border-green-200">
            <div className="flex items-start gap-3">
              <Check className="text-green-600 flex-shrink-0 mt-0.5" size={20} />
              <Text weight="semibold" className="text-green-800">{voucherSuccess}</Text>
            </div>
          </Card>
        )}

        {voucherError && (
          <Card variant="glass" padding="md" className="mb-6 bg-red-50 border border-red-200">
            <div className="flex items-start gap-3">
              <AlertCircle className="text-red-500 flex-shrink-0 mt-0.5" size={20} />
              <Text className="text-red-600">{voucherError}</Text>
            </div>
          </Card>
        )}

        <div className="flex flex-col sm:flex-row gap-4">
          <div className="flex-1">
            <Input
              type="text"
              value={voucherCode}
              onChange={(e) => setVoucherCode(e.target.value.toUpperCase())}
              placeholder="DEAP-XXXX-XXXX-XXXX"
              className="font-mono tracking-wider"
              inputSize="md"
              disabled={voucherLoading}
            />
            <div className="flex items-center gap-1 mt-2 px-4">
              <AZSymbol size={16} className="text-amber-500" />
              <Text size="sm" muted>19-stelliger Voucher-Code eingeben</Text>
            </div>
          </div>
          <div className="flex items-start">
            <Button
              variant="primary"
              disabled={voucherLoading || !voucherCode.trim()}
              loading={voucherLoading}
              className="whitespace-nowrap"
            >
              {voucherLoading ? 'Wird eingelöst...' : (
                <>
                  <Gift size={20} />
                  Einlösen
                </>
              )}
            </Button>
          </div>
        </div>
      </Card>

      {/* Info: What is Transmutation? */}
      <Card variant="glass" padding="lg">
        <Heading level={2} className="mb-4">Was ist Transmutation?</Heading>
        <Text size="sm" muted className="mb-4 leading-relaxed">
          Transmutation ist dein AI-Budget. 1 Gramm = 1 CHF. Jeder AI-Aufruf verbraucht Metall
          basierend auf den tatsächlichen API-Kosten. Dein Barren wird monatlich automatisch aufgefüllt.
        </Text>
        <div className="grid sm:grid-cols-3 gap-4">
          <Card variant="default" className="bg-gray-50 p-4 text-center">
            <Text size="xs" muted className="mb-1">Einfache Frage</Text>
            <Text weight="bold" className="text-gray-900">~0.01g</Text>
          </Card>
          <Card variant="default" className="bg-gray-50 p-4 text-center">
            <Text size="xs" muted className="mb-1">Komplexe Analyse</Text>
            <Text weight="bold" className="text-gray-900">~0.05g</Text>
          </Card>
          <Card variant="default" className="bg-gray-50 p-4 text-center">
            <Text size="xs" muted className="mb-1">SEO-Seite generieren</Text>
            <Text weight="bold" className="text-gray-900">~0.10g</Text>
          </Card>
        </div>
        <Divider className="my-6" />
        <Text size="xs" muted className="text-center">
          Sichere Zahlung via zahls.ch — Jederzeit kündbar — Sofort aktiv
        </Text>
      </Card>
    </div>
  );
}

export default function Demo({ viewer, workspace: ws, galleryMode, ...page }: Props & { galleryMode?: boolean }) {
  return (
    <Shell domains={DOMAINS} user={viewer} workspaces={[ws]} currentWorkspaceId={ws.id} activePath="/settings/billing" hasTour={false} hideChrome={galleryMode}>
      <Kiosk {...page} />
    </Shell>
  );
}
Component tree, from the recipe
<Route /settings/billing, /billing/manage> PrivateRoute (children)
  Navigation = AppShell
    SubscriptionManager
      [loading]      div.min-h-screen > Spinner xl                              (before the balance, inside the shell, line 159 to 167)
      [error]        Card glass border-red-200: AlertCircle + Text red          (top-level fallback sentence, line 189 to 196)
      Aktuelles Abo  Card glass lg
        Heading "Aktuelles Abo" + Badge (Gratis | Aktiv dot)
        left   Text "Plan" + Heading tier_display + [currentTierInfo] grams/price line
               [!isGratis] gauge div (amber | amber-orange | red gradient) + "% verbraucht"
                           breakdown Card: Abo-Barren, Nachschub, Willkommens-Bonus, Total verfuegbar (each row conditional on > 0)
                           [period_start && period_end] Calendar + date range
                           [used_total_liters > 0] TrendingUp + lifetime line
               [isGratis]  info Card: "Kein aktives Abo" (+ welcome-bonus sentence when welcome_ml > 0)
        right  [isGratis] Button primary "Transmutation waehlen" -> SubscriptionModal
               [else]     Button primary "Nachschub" (topupLoading)
                          Button secondary "Abo aendern" -> SubscriptionModal
                          Button outline "Abo kuendigen" (canceling, window.confirm first)
      Alle Abos      Card glass lg > METAL_TIERS.map: row (Gem icon, name, [Aktuell | Beliebt] Badge, grams/price, CHF price)
      Voucher card   Card glass lg
                     [voucherSuccess] Card glass green: Check + text
                     [voucherError]   Card glass red: AlertCircle + text
                     Input (uppercased on change) + AZSymbol hint + Button "Einloesen" (voucherLoading)
      Info card      Card glass lg: static copy, 3 x cost-example Card, Divider, footer line
      [showSubscriptionModal] SubscriptionModal                                 (not in the packages a demo may import from, not rendered)
Design system
alchemy-ui
Packages
@prima-modules/tokens 0.1.0, from alchemy_labs fd8558f, @prima-modules/alchemy-ui 0.1.0, from alchemy_labs fd8558f, @prima-modules/demo 0.1.0, from prima-modules 5d496a7
Content
Content synced from the prima-modules repository at 0df2ec8 on 2026-09-14.

The rendering is the live rebuild subscription-manager/demo.tsx, built from @prima-modules/alchemy-ui and @prima-modules/tokens and rendered by the docs host as one frame per state. The page's one read and three writes are props (status, metalStatus, error, canceling, topupLoading, voucherCode, voucherLoading, voucherSuccess, voucherError); the tree is the frontmatter's. The console is rendered inside the shell rebuild (ui-design-system/ui/appshell-navigation/demo.tsx, imported relatively the way admin-users-console's demo imports it) with a member viewer, not an admin, since /settings/billing needs no isAdmin, on /settings/billing, so "Settings" is the active rail entry.

States (rendering.states, in order): loading (the spinner inside the shell, before any balance is known, no tank card at all); active-plan (Silber at 30% used, the amber gauge, all three tank/top-up/welcome breakdown rows, the billing period and lifetime-usage lines, all three action buttons enabled); near-limit-critical (Gold at 97.4% used, isCritical true, the red gauge gradient and messaging, a thin top-up row left in the breakdown, welcome bonus already spent so that row is hidden, the same three actions still enabled since nothing about the balance disables them); free-tier (isGratis true with a 33g welcome bonus still unspent, the "Kein aktives Abo" card with its extra welcome-bonus sentence, the single "Transmutation waehlen" CTA in place of the three-button column); action-busy (the Silber plan again with canceling: true, the cancel button disabled and reading "Wird bearbeitet...", the other two buttons visually unaffected since only one boolean drives each button); error-and-voucher-feedback (status: 'error', metalStatus: null, the top-level red card with the load-failure sentence, and at the same time the voucher card showing its own green success banner, to prove the two banners are independent state and can be on screen together, as the real component's four separate pieces of state allow). The 80 to 95 percent amber-orange isNearLimit gradient, between the active-plan and near-limit-critical frames, is a real, distinct branch this recipe does not give its own state; only the two ends of the range are shown here, a genuine gap, not filled with an invented seventh state.

Tokens and classes as the source uses them: Card glass for every top-level section; Card default with bg-amber-50 and border-amber-200 for the breakdown and the gratis-info cards, the same amber palette (not S1 tokens) admin-users-console's Gotchas flags for its own stat-card icons; the gauge track bg-gray-200, its fill one of three Tailwind gradients keyed off isCritical and isNearLimit; Badge success (Aktiv), default (Gratis), and, on the tier grid, success (Aktuell) and info (Beliebt); the header icon and every gratis or top-up button on a from-amber-400/500 to-amber-600/700 gradient. dark: variants exist throughout the source and are dropped in the demo, the same "dark: dead" trap identity-auth/ui/admin-users-console's Gotchas names, since the docs host renders one committed light theme.

Responsive: the whole page is a single max-w-4xl column, so nothing here reflows into a table the way admin-users-console's DataTable does; below md the tank/actions grid stacks the action buttons under the tank instead of beside it (grid md:grid-cols-2); the voucher row stacks the input above the button below sm. Empty state: none as such, isGratis is the nearest thing, covered by free-tier. Error state: error-and-voucher-feedback, and, per the Application logic invariant above, the same visual branch a genuinely gratis tenant sees. Loading state: the bare spinner inside the shell on first load only; a refetch after a write (cancel, a voucher redemption) shows no loading indicator on the tank card itself, only on the button that triggered it.

What the demo deliberately does not render: window.confirm before cancel (native, the button is inert instead); the window.location.href redirect a successful top-up performs; SubscriptionModal, opened by two buttons that do nothing here since the component is page-local, not part of the packages a demo may import from; the setTimeout refetch after a voucher redemption; framer-motion's mount animation on the gauge fill, which renders at its resting width with no transition. All four are documented in the demo's own header comment, and the first three follow the convention identity-auth/ui/admin-users-console already established for its own prompt, confirm and toast.

Security & tenancy

What the browser may hold: S2's two tokens only, never a payment credential; the fetched balance (tier, tank, top-up and welcome milliliters, the billing period) is the tenant's own and stays in memory, not persisted. import.meta.env.VITE_API_URL is the only baked value. Origin and CSRF: the api singleton sends the Bearer header, not a cookie, unless VITE_USE_COOKIE_AUTH is on, the same as S2 and identity-auth/ui/admin-users-console. Surface gating: all three routes are mounted under LABS_SURFACES (app_fastapi.py 837, 880 to 881), so a prima_* session gets the surface-mismatch 403 before any handler runs (B4), the same gate B16's admin routes sit behind.

Tenancy here is per-user, not per-workspace: ActiveUser alone (api/dependencies.py), no workspace dependency and no workspace_id column on the metal fields, unlike most of the rest of Labs; currentWorkspaceId is attached to the request by the client interceptor but read by none of these three routes, so switching workspaces in the shell has no effect on this page, a genuine surprise for a page that renders inside a workspace-scoped shell (Gotchas). Hidden by CSS versus enforced: nothing; the three action buttons are visible to any signed-in tenant regardless of tier, and the server is the only thing that actually refuses a cancel with no subscription or a top-up with no payment provider configured. Attacker-controlled input the page sends: the voucher code, uppercased client-side and re-validated server-side for format, existence, redemption state and expiry (voucher_service.py 76 to 101) before it can touch a balance. What the page renders from the server unescaped: nothing; React escapes every string and no dangerouslySetInnerHTML is used.

Tests

  • Colocated with the demo: subscription-manager/demo.test.tsx (vitest, jsdom, @testing-library/react), registering afterEach(cleanup) for the same reason identity-auth/ui/admin-users-console's does (test-layout, the root config's globals: false): the tank's percentage text matches each state's percentageUsed.toFixed(0); the gauge fill carries the red gradient class only in near-limit-critical and the amber gradient in active-plan; free-tier renders no gauge and exactly one button; action-busy renders "Wird bearbeitet..." and a disabled cancel button; error-and-voucher-feedback renders both a red and a green card at once; no state's markup contains an access- or refresh-token shape.
  • The kit's render smoke: node tooling/verify/render-smoke.mjs billing-credits/ui/subscription-manager is meant to render the six states with renderToString and fetch stubbed; at present it cannot find this recipe by id at all (Gotchas, a path issue in the gate scripts, not in the demo, verified separately by bundling and rendering the file directly, Acceptance checks).
  • Labs at af7a352 has no unit test of SubscriptionManager.tsx (grep -rl SubscriptionManager frontend/src --include='*.test.*' is empty) and no Playwright spec for /settings/billing. Backend coverage of the three routes it calls belongs to the six backend_modules recipes' own suites (test_auto_topup.py, test_auto_topup_retry.py, and the cancel and dunning suite named in subscription-cancel-and-dunning), not to this page.
  • What a new app adds: a jsdom test with api mocked that asserts the four catch blocks read data.error ?? data.detail (once fixed) instead of data.detail alone; a test that a cancel's window.confirm(false) leaves no request on the wire; a Playwright spec that redeems a real voucher against a seeded balance and asserts the tank total changes, which would catch the gap this recipe's Gotchas documents today.

Deploy & ops

Build target: the Labs Vite bundle (S2), a lazy chunk (App.tsx line 120, lazy(() => import('./pages/billing/SubscriptionManager'))). Env baked at build: VITE_API_URL and VITE_USE_COOKIE_AUTH, S2's own. CSP and CORS: nothing of its own; the three routes answer on the API origin under the SPA's existing allowance, gated by LABS_SURFACES as above. Deploy path: S13's frontend rsync with the bundle; rollback is the bundle's. Nothing to migrate here directly; the schema the three routes read and write (metal_tank_ml and its siblings on users, the voucher tables) belongs to the six backend_modules recipes. Argus: a synthetic tenant session calling GET /api/user/metal-status returning 200 with a tier field is this page's liveness; the page has no health signal of its own beyond the top-level fallback sentence.

Gotchas

  • 2026-09-12 (measured at af7a352): the four catch blocks read err.response?.data?.detail (108, 123, 145); the Labs envelope's key is error, not detail (app_fastapi.py 773 to 793, the same shape identity-auth/ui/admin-users-console's Gotchas documents). So "Payment processing is not configured", "No active subscription found", every voucher-specific sentence ("This voucher has already been redeemed", "This voucher expired on...") and every other server message from these three routes all reach the screen as the page's own four fixed fallbacks. Fix: the same data.error ?? data.detail ?? data.message helper the sibling recipe proposes.
  • 2026-09-12 (measured at af7a352): a successful voucher redemption adds to user.voucher_tokens (voucher_service.py 145 to 146), the legacy token-quota field identity-auth/ui/admin-users-console's AdminUserRow interface documents; it does not touch metal_tank_ml, metal_topup_ml or metal_welcome_ml. The page's own setTimeout(() => loadData(), 1000) refetches GET /api/user/metal-status, which reads only the metal fields, so the tank card, the gauge and the total never change after a redemption that just told the tenant "Voucher redeemed successfully, X tokens added to your account." The voucher system and the metal system are two separate balances that this page conflates by rendering both on one screen with one refresh. A rebuild either credits the metal fields directly on redemption or shows the voucher balance as its own line.
  • 2026-09-12 (measured at af7a352): the voucher endpoint's success message is written in English server-side (voucher_router.py 96, f"Voucher redeemed successfully! {tokens_added:,} tokens added to your account.") and reaches the screen unmodified; every other sentence on this page, including every fallback, is German. It is the one non-German string a tenant can see here.
  • 2026-09-12 (measured at af7a352): get_metal_status returns {"error": "user_not_found"} with a 200 status when the row lookup misses (metal_service.py 185 to 186); the page stores that object as metalStatus with no check for an .error key. The object is truthy and its tier is undefined, not 'gratis', so isGratis evaluates false and the page would attempt the paid-tier branch against a status object with no metal fields at all, an edge case the source does not guard against. Not reachable from a normal session (the row that authenticated the request is the row being looked up), so left undemoed, noted here as measured, not fixed.
  • 2026-09-12 (measured at af7a352): tenancy for this page's three routes is per-user (ActiveUser, no workspace dependency), while the shell it renders inside is workspace-scoped throughout the rest of Labs; a tenant who switches workspaces in the rail sees no change here, which is correct but easy to mistake for a bug when every other page in the shell does change. Worth a line of copy in a rebuild, not present at af7a352.
  • 2026-09-12 (measured at af7a352): CardContent is imported (line 23) and never used anywhere in the file, a dead import, harmless, left as measured.
  • 2026-09-12 (measured at af7a352): const navigate = useNavigate(); (line 69) is declared and never called, a second dead reference, also harmless.
  • 2026-09-12 (measured at af7a352): dark: variants are present on nearly every element (dark:bg-red-900/20, dark:text-amber-100, and so on) with no toggle anywhere in Labs that switches a dark class on, the same "dark: dead" rendering trap identity-auth/ui/admin-users-console's Gotchas names for its own file; a comparison against this page in a dark-mode browser shows nothing different from light mode.
  • 2026-09-12 (kit): an earlier pass of this recipe and its six sibling backend recipes carried a doubled id (billing-credits/ui/billing-credits/ui/subscription-manager), which collided with scripts/check.mjs's inDemoFolder test and made npm run check misclassify this file as living inside a demo folder. Fixed by renaming the file and every cross-reference to the plain billing-credits/ui/subscription-manager id, matching the convention every other domain in this repo already uses (identity-auth/ui/admin-users-console and siblings). No tooling change was needed once the id stopped doubling.

Reference implementation

Branch: alchemy_labs w1-auto-topup at af7a35246dd4a009485bc67b3a13fea6e5a688cc (2026-09-12). Paths relative to the repo root:

  • frontend/src/pages/billing/SubscriptionManager.tsx (551 lines): imports and MetalStatus (1 to 47), the component and its state (68 to 81), loadData (83 to 97), handleTopup (99 to 111), handleCancelSubscription (113 to 127), handleRedeemVoucher (129 to 149), formatDate (151 to 157), the loading return (159 to 167), the derived booleans (169 to 173), the header and error card (176 to 196), the "Aktuelles Abo" card (199 to 394), the tier grid (397 to 440), the voucher card (443 to 505), the static info card (507 to 532), the modal mount (535 to 546).
  • frontend/src/components/Navigation.tsx (22 lines): the delegator to AppShell.
  • frontend/src/components/SubscriptionModal.tsx: the change-tier dialog this page opens but does not own.
  • frontend/src/components/nav.tsx: the settings domain (257 to 272) and the money domain's /billing/manage item (206 to 228).
  • frontend/src/App.tsx: the lazy import (120), the two routes (273, 313 to 316), the /billing redirect (1002).
  • frontend/src/services/api.ts: the singleton and its interceptors, unchanged from S2.
  • backend/api/routers/user_router.py: get_metal_status (375 to 382).
  • backend/api/routers/billing_router.py: create_topup_session (253 to 324), cancel_subscription_endpoint (327 to 388).
  • backend/api/routers/voucher_router.py: redeem_voucher (74 to 103).
  • backend/services/metal_service.py: get_metal_status (177 to 221).
  • backend/services/voucher_service.py: redeem_voucher (55 to 172).
  • backend/core/metal_config.py: METAL_TIERS, WELCOME_BONUS_ML, TOPUP_ML, TOPUP_PRICE_CHF (1 to 40).
  • backend/app_fastapi.py 724 to 793: the envelope the page misreads; 837, 880 to 881: the three routers' LABS_SURFACES mounts.
  • The demo: modules/billing-credits/ui/subscription-manager/demo.tsx; the shell it renders in: modules/ui-design-system/ui/appshell-navigation/demo.tsx and nav.tsx.

Deviations of the reference from this recipe, all dated 2026-09-12: the detail reads; the voucher balance that never reaches the tank; the one English sentence; the unchecked {error: "user_not_found"} shape; the workspace-scoped shell around a user-scoped page; the two dead references; dark: classes with no toggle; no confirmation before a top-up's real charge, only before cancel.

Acceptance checks

From alchemy_labs/frontend at af7a352 with the harness up (tooling/verify/labs-local.sh up, a tenant session), or from a new app built to this recipe:

  1. grep -c "data?.detail" src/pages/billing/SubscriptionManager.tsx prints 3 at af7a352 (top-up, cancel, voucher) and 0 after the fix.
  2. grep -n "api\.\(get\|post\)(" src/pages/billing/SubscriptionManager.tsx prints exactly four lines naming /api/user/metal-status, /api/billing/topup, /api/billing/cancel, /api/vouchers/redeem.
  3. Browser walk-through, signed in as a tenant with an active Silber subscription: (a) open /settings/billing, observe the shell paint first and a single spinner where the tank will be, then the amber gauge under 80%; (b) redeem a real voucher code, observe the green success banner and, in the network panel, the following GET /api/user/metal-status returning the same total_available_ml as before the redemption, the gap this recipe's Gotchas names; (c) click "Abo kuendigen", dismiss the confirm, observe no request in the network panel, confirm, observe the button read "Wird bearbeitet...", then the card fall back to the gratis look with any leftover top-up or welcome balance still shown; (d) on a paid account, unset the payment provider's key server-side and click "Nachschub", observe the fixed sentence "Nachschub fehlgeschlagen" rather than the server's own "Payment processing is not configured".
  4. Demo gate: node tooling/verify/render-smoke.mjs billing-credits/ui/subscription-manager currently answers that no recipe has this id, because of the inDemoFolder collision this recipe's Gotchas documents, rather than rendering. The demo was instead verified directly, bundled with esbuild the same way the script bundles a demo and mounted with renderToString outside the id lookup: all six states rendered, 0 failures, 2026-09-12. npm run check fails on this file for the same reason; the frontmatter shape, the internal-term scan and the states list matching the demo's exported keys all pass when checked by hand against the file in isolation.
  5. Verifier: once the id collision above is resolved, node tooling/verify/compare.mjs billing-credits/ui/subscription-manager --state active-plan --docs http://127.0.0.1:4321 --app http://127.0.0.1:3020 --session-file tooling/verify/.labs-local/session.json (a tenant session with an active Silber subscription seeded) writes a report with page_errors: []; mismatch_ratio is read for the header and the "Aktuelles Abo" card only, since the app's billing period and lifetime-usage numbers are the harness's seeded account and the demo's are sample data.

Content synced from the prima-modules repository at 0df2ec8 on 2026-09-14. The recipe file is the source; this page is a rendering of it.