mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-29 07:20:43 +00:00
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.
145 lines
6.0 KiB
TypeScript
145 lines
6.0 KiB
TypeScript
'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>
|
||
</>
|
||
)
|
||
}
|