Files
zeekay 6c65e3e941 @hanzo/app 0.2.0: the app SDK, renamed from @hanzo/rooms
Every import, the catalog pin and the docs name @hanzo/app and
github.com/hanzoai/app. lib/rooms.ts is lib/app.ts and
components/rooms.tsx is components/app.tsx.
2026-10-22 19:07:20 -07:00

145 lines
6.0 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
/**
* Billing: the plan, the balance, the card on file and the invoices — read here,
* and changed on the pay site, which is where money moves (lib/plans.ts
* `payPage`). This page charges nothing and holds no card. Each control opens
* the pay screen that does what it says: Change plan the plan picker (/cart),
* Add funds the amount step (/amount/card), Manage the cards (/cards). pay on
* this origin reads the same session, so none of them asks to sign in again.
*
* The plan is `GET /v1/billing/tier`, its term the live subscription from
* `GET /v1/billing/subscriptions` — "$200/month subscription, renews Oct 30,
* 2026" — and the balance the ledger's `GET /v1/billing/balance`
* (lib/hanzo/tier.ts), all for the acting org. A plan is a subscription with
* usage limits; its usage never draws on the balance, which is shown as it
* stands. The
* balance names the account it is OF — the org's pool, or, where billing pays a
* member from their own wallet, that wallet — so the page says whose money it is
* rather than guessing.
*/
import { useEffect, useState } from 'react'
import { Text, XStack, YStack } from '@hanzo/ui'
import { payPage } from '@hanzo/app/lib/pay'
import { ready, usePlans } from '@/lib/api/use'
import { cents, invoices, methods, say, type Invoice, type Method } from '@hanzo/app/lib/org'
import { renewal, useSubscription } from '@hanzo/app/lib/tier'
import { useStanding } from './Frame'
import { Go, Heading, Line, Section, Tag, day } from './kit'
export function Billing() {
const { org, admin, funds, tier, tierWrong } = useStanding()
const [cards, setCards] = useState<Method[] | null>(null)
const [cardsWrong, setCardsWrong] = useState<string | null>(null)
const [bills, setBills] = useState<Invoice[] | null>(null)
const [billsWrong, setBillsWrong] = useState<string | null>(null)
const sub = useSubscription(Boolean(org), org)
// A plan's name is the plan authority's, read when the page opens (lib/api);
// until it is, the tier's own name stands.
const catalog = ready(usePlans())
useEffect(() => {
if (!org) return
let live = true
methods()
.then((m) => live && setCards(m))
.catch((e: unknown) => live && setCardsWrong(say(e)))
invoices()
.then((i) => live && setBills(i))
.catch((e: unknown) => live && setBillsWrong(say(e)))
return () => {
live = false
}
}, [org])
if (!org) return null
const plan = tier ? (catalog?.find((p) => p.id === tier.plan)?.name ?? tier.tier.displayName ?? tier.tier.name) : null
const account = tier?.balance.account
const currency = tier?.balance.currency || 'usd'
return (
<>
<Heading title="Billing" lede="What this organization is on and what it has to spend. Payments are made on the Hanzo pay site." />
<Section title="Plan" ruled={false} action={admin ? <Go href={payPage('/cart')}>Change plan</Go> : undefined}>
{plan ? (
<YStack gap="$1">
<Text fontSize="$6" fontWeight="500" color="$ink">
{plan}
</Text>
{sub ? <Line>{renewal(sub)}</Line> : null}
</YStack>
) : tierWrong ? (
<Line tone="bad">The plan could not be read: {say(tierWrong)}</Line>
) : (
<Line>Reading the plan…</Line>
)}
</Section>
<Section
title="Credits"
lede={account && account !== org ? `The balance of ${account}, the wallet billing pays your use from here.` : 'Prepaid. Your plan’s usage never draws on it.'}
action={funds ? <Go href={payPage('/amount/card')} strong>Add funds</Go> : undefined}
>
{tier ? (
<Text fontSize="$8" fontWeight="500" color="$ink">
{cents(tier.balance.effectiveAvailable, currency)}
</Text>
) : tierWrong ? (
<Line tone="bad">The balance could not be read: {say(tierWrong)}</Line>
) : (
<Line>Reading the balance…</Line>
)}
</Section>
<Section title="Payment method" action={admin ? <Go href={payPage('/cards')}>Manage</Go> : undefined}>
{cardsWrong ? <Line tone="bad">{cardsWrong}</Line> : null}
{cards === null && !cardsWrong ? <Line>Reading cards on file…</Line> : null}
{cards && cards.length === 0 ? <Line>No card on file.</Line> : null}
{(cards ?? []).map((c) => (
<XStack key={c.id} items="center" gap="$3" py="$2" borderBottomWidth={1} borderColor="$edge">
<Text fontSize="$3" color="$ink" flex={1}>
{(c.brand || 'Card').replace(/^\w/, (x) => x.toUpperCase())} ending {c.last4 || '····'}
</Text>
{c.expMonth && c.expYear ? (
<Text fontSize="$2" color="$soft">
Expires {String(c.expMonth).padStart(2, '0')}/{c.expYear}
</Text>
) : null}
{c.isDefault ? <Tag>Default</Tag> : null}
</XStack>
))}
</Section>
<Section title="Invoices" action={admin ? <Go href={payPage('/invoices')}>All invoices</Go> : undefined}>
{billsWrong ? <Line tone="bad">{billsWrong}</Line> : null}
{bills === null && !billsWrong ? <Line>Reading invoices…</Line> : null}
{bills && bills.length === 0 ? <Line>No invoices yet.</Line> : null}
<YStack>
{(bills ?? []).slice(0, 12).map((b) => (
<XStack key={b.id} items="center" gap="$3" py="$2" borderBottomWidth={1} borderColor="$edge" flexWrap="wrap">
<Text fontSize="$3" color="$ink" flex={1} minW={120}>
{b.numberStr || b.id}
</Text>
<Text fontSize="$2" color="$soft">
{day(b.createdAt)}
</Text>
<Text fontSize="$3" color="$ink">
{cents(b.amountDue, b.currency || currency)}
</Text>
{b.status ? <Tag>{b.status}</Tag> : null}
</XStack>
))}
</YStack>
{admin ? (
<XStack>
<Go href={payPage('/transactions')}>Transactions</Go>
</XStack>
) : null}
</Section>
</>
)
}