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 |
|
| 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) | Route | Auth / tenancy | Parsed | Rendered as |
|---|---|---|---|---|
api.get('/api/user/metal-status') (90) | GET /api/user/metal-status | ActiveUser (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/topup | ActiveUser | response.data.checkout_url; on success the tab navigates away (window.location.href), nothing else is read | a 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/cancel | ActiveUser | nothing on 200 ({success, message} discarded); loadData() refetches | the 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/redeem | ActiveUser | response.data.message into voucherSuccess; err.response?.data?.detail into voucherError | the 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
SubscriptionManagermounts withloading: true,metalStatus: null;useEffectfiresloadData()once (lines 83 to 85). Whileloading, 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).loadDatacallsGET /api/user/metal-status, stores the body asmetalStatusand clearserroron success, or sets the fixed sentence "Metallstatus konnte nicht geladen werden" on any throw;loadinggoes false either way (87 to 97).- 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 identicalisGratisbranch (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. - "Nachschub" (top-up): posts to
/topup, and on acheckout_urlin the response navigates the tab there directly (window.location.href, no client-side confirmation first);topupLoadingdisables 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). - "Abo kuendigen" (cancel): a
window.confirmfirst ("Bist du sicher, dass du dein Abo kuendigen moechtest? Dein Tank wird auf 0 gesetzt."); on confirm,cancelingdisables the button and swaps its label to "Wird bearbeitet...";POST /cancelzeroesmetal_tank_mland sets the tier back to gratis server-side while leavingmetal_topup_mlandmetal_welcome_mluntouched (billing_router.py368 to 377), thenloadData()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). - "Abo aendern" and the gratis "Transmutation waehlen" both open
SubscriptionModalwithcurrentTokens={0}andmonthlyLimit={0}hard-coded regardless of the fetched balance (536 to 545, Gotchas); the modal's ownonSubscribecloses it and callsloadData()again. - 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,voucherSuccessis set to the route's own message, the input is cleared, and asetTimeout(loadData, 1000)refetches the tank, which the voucher never actually changes (Gotchas); on failurevoucherErroris set to the fixed fallback sentence and the typed code is left in the box (129 to 149). - 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-statusresponse, 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_usedmeasures 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 whiletotal_available_ml, what actually pays for the next call, is still large (metal_service.py197 to 203).- Cancelling never destroys money already paid for outside the subscription: only
metal_tank_mlis zeroed;metal_topup_mlandmetal_welcome_mlare explicitly preserved (billing_router.py375). - 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.
loading server rendered, hydrated in view active-plan server rendered, hydrated in view near-limit-critical server rendered, hydrated in view free-tier server rendered, hydrated in view action-busy server rendered, hydrated in view error-and-voucher-feedback server rendered, hydrated in view 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: 'elena@example.com', 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) 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), registeringafterEach(cleanup)for the same reason identity-auth/ui/admin-users-console's does (test-layout, the root config'sglobals: false): the tank's percentage text matches each state'spercentageUsed.toFixed(0); the gauge fill carries the red gradient class only innear-limit-criticaland the amber gradient inactive-plan;free-tierrenders no gauge and exactly one button;action-busyrenders "Wird bearbeitet..." and a disabled cancel button;error-and-voucher-feedbackrenders 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-manageris meant to render the six states withrenderToStringandfetchstubbed; 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 sixbackend_modulesrecipes' own suites (test_auto_topup.py,test_auto_topup_retry.py, and the cancel and dunning suite named insubscription-cancel-and-dunning), not to this page. - What a new app adds: a jsdom test with
apimocked that asserts the four catch blocks readdata.error ?? data.detail(once fixed) instead ofdata.detailalone; a test that a cancel'swindow.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 iserror, notdetail(app_fastapi.py773 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 samedata.error ?? data.detail ?? data.messagehelper the sibling recipe proposes. - 2026-09-12 (measured at af7a352): a successful voucher redemption adds to
user.voucher_tokens(voucher_service.py145 to 146), the legacy token-quota field identity-auth/ui/admin-users-console'sAdminUserRowinterface documents; it does not touchmetal_tank_ml,metal_topup_mlormetal_welcome_ml. The page's ownsetTimeout(() => loadData(), 1000)refetchesGET /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.py96,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_statusreturns{"error": "user_not_found"}with a 200 status when the row lookup misses (metal_service.py185 to 186); the page stores that object asmetalStatuswith no check for an.errorkey. The object is truthy and itstierisundefined, not'gratis', soisGratisevaluates 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):
CardContentis 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 adarkclass 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 withscripts/check.mjs'sinDemoFoldertest and madenpm run checkmisclassify this file as living inside a demo folder. Fixed by renaming the file and every cross-reference to the plainbilling-credits/ui/subscription-managerid, matching the convention every other domain in this repo already uses (identity-auth/ui/admin-users-consoleand 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 andMetalStatus(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 toAppShell.frontend/src/components/SubscriptionModal.tsx: the change-tier dialog this page opens but does not own.frontend/src/components/nav.tsx: thesettingsdomain (257 to 272) and themoneydomain's/billing/manageitem (206 to 228).frontend/src/App.tsx: the lazy import (120), the two routes (273, 313 to 316), the/billingredirect (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.py724 to 793: the envelope the page misreads; 837, 880 to 881: the three routers'LABS_SURFACESmounts.- The demo:
modules/billing-credits/ui/subscription-manager/demo.tsx; the shell it renders in:modules/ui-design-system/ui/appshell-navigation/demo.tsxandnav.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:
grep -c "data?.detail" src/pages/billing/SubscriptionManager.tsxprints3at af7a352 (top-up, cancel, voucher) and0after the fix.grep -n "api\.\(get\|post\)(" src/pages/billing/SubscriptionManager.tsxprints exactly four lines naming/api/user/metal-status,/api/billing/topup,/api/billing/cancel,/api/vouchers/redeem.- 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 followingGET /api/user/metal-statusreturning the sametotal_available_mlas 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". - Demo gate:
node tooling/verify/render-smoke.mjs billing-credits/ui/subscription-managercurrently answers that no recipe has this id, because of theinDemoFoldercollision 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 withrenderToStringoutside the id lookup: all six states rendered, 0 failures, 2026-09-12.npm run checkfails 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. - 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 withpage_errors: [];mismatch_ratiois 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.