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.
165 lines
6.2 KiB
TypeScript
165 lines
6.2 KiB
TypeScript
import { test, expect } from '@playwright/test'
|
|
import { readSubscription, readTier, renewal } from '@hanzo/app/lib/tier'
|
|
import { label, said } from '@hanzo/build/plan'
|
|
|
|
/**
|
|
* WHAT THE SIDEBAR SAYS IS LEFT IS WHAT THE LEDGER SAYS.
|
|
*
|
|
* `/v1/billing/tier` names the plan; its `effectiveAvailable` comes from
|
|
* commerce's store, which a usage debit never reaches, and carries a $1,000
|
|
* floor for the ecosystem orgs. `/v1/billing/balance` reads the ledger the gate
|
|
* spends from. `readTier` states the money from the second read, and a balance
|
|
* it could not read is a refusal, never a number.
|
|
*/
|
|
|
|
function bearer(claims: Record<string, unknown>): string {
|
|
return `header.${Buffer.from(JSON.stringify(claims)).toString('base64url')}.signature`
|
|
}
|
|
|
|
const tier = {
|
|
user: 'acme',
|
|
tier: { name: 'pro', displayName: 'Pro', dailyCreditsCents: 0, maxAgents: 10, unlimitedAgents: false },
|
|
balance: { currency: 'usd', prepaidAvailable: 2000, creditsRemaining: 0, dailyRemaining: 0, effectiveAvailable: 2000 },
|
|
}
|
|
|
|
/** A browser signed in to `acme`, answering each path from `answers`. */
|
|
function world(answers: Record<string, { status: number; body: unknown }>): { asked: string[]; done: () => void } {
|
|
const global = globalThis as { window?: unknown; fetch: typeof fetch }
|
|
const hadWindow = 'window' in global
|
|
const wasWindow = global.window
|
|
const wasFetch = global.fetch
|
|
const store = new Map([['hanzo_iam_access_token', bearer({ owner: 'hanzo', orgs: [{ org: 'acme' }] })]])
|
|
global.window = {
|
|
location: { hostname: 'hanzo.ai', origin: 'https://hanzo.ai' },
|
|
localStorage: { getItem: (k: string) => store.get(k) ?? null },
|
|
}
|
|
const asked: string[] = []
|
|
global.fetch = (async (input: string | URL, init?: RequestInit) => {
|
|
const url = new URL(String(input))
|
|
asked.push(`${url.pathname} ${new Headers(init?.headers).get('X-Org-Id')}`)
|
|
const a = answers[url.pathname]
|
|
return new Response(JSON.stringify(a?.body ?? {}), { status: a?.status ?? 404 })
|
|
}) as typeof fetch
|
|
return {
|
|
asked,
|
|
done: () => {
|
|
global.fetch = wasFetch
|
|
if (hadWindow) global.window = wasWindow
|
|
else delete global.window
|
|
},
|
|
}
|
|
}
|
|
|
|
test('the money is the ledger balance, in the organization this browser works in', async () => {
|
|
const w = world({
|
|
'/v1/billing/tier': { status: 200, body: tier },
|
|
'/v1/billing/balance': { status: 200, body: { balance: 1234, holds: 0, available: 1234, account: 'acme' } },
|
|
})
|
|
try {
|
|
const got = await readTier()
|
|
expect(got.tier.displayName).toBe('Pro')
|
|
expect(got.balance.effectiveAvailable).toBe(1234)
|
|
expect(w.asked.sort()).toEqual(['/v1/billing/balance acme', '/v1/billing/tier acme'])
|
|
} finally {
|
|
w.done()
|
|
}
|
|
})
|
|
|
|
test('the plan slug on GET /v1/billing/tier passes through and names the catalog row', async () => {
|
|
const w = world({
|
|
'/v1/billing/tier': { status: 200, body: { ...tier, plan: 'max-20x' } },
|
|
'/v1/billing/balance': { status: 200, body: { balance: 1234, holds: 0, available: 1234, account: 'acme' } },
|
|
})
|
|
try {
|
|
const got = await readTier()
|
|
expect(got.plan).toBe('max-20x')
|
|
expect(said(label(got.plan))).toBe('Max 20x')
|
|
} finally {
|
|
w.done()
|
|
}
|
|
})
|
|
|
|
test('an account with no plan slug names none, and the reader falls back to the tier', async () => {
|
|
expect(label('')).toBeNull()
|
|
expect(label(undefined)).toBeNull()
|
|
expect(said(label(''))).toBe('')
|
|
})
|
|
|
|
test('a balance that could not be read is a refusal, not a figure', async () => {
|
|
const w = world({
|
|
'/v1/billing/tier': { status: 200, body: tier },
|
|
'/v1/billing/balance': { status: 502, body: { detail: 'billing upstream unreachable' } },
|
|
})
|
|
try {
|
|
await expect(readTier()).rejects.toMatchObject({ status: 502, message: 'billing upstream unreachable' })
|
|
} finally {
|
|
w.done()
|
|
}
|
|
})
|
|
|
|
test('the plan is the one billing answered, whatever the organization is called', async () => {
|
|
const w = world({
|
|
'/v1/billing/tier': { status: 200, body: { ...tier, user: 'hanzo', tier: { ...tier.tier, name: 'free', displayName: 'Free' } } },
|
|
'/v1/billing/balance': { status: 200, body: { balance: 0, holds: 0, available: 0, account: 'hanzo' } },
|
|
})
|
|
try {
|
|
const got = await readTier()
|
|
expect(got.tier.name).toBe('free')
|
|
expect(got.tier.displayName).toBe('Free')
|
|
expect(got.tier.unlimitedAgents).toBe(false)
|
|
} finally {
|
|
w.done()
|
|
}
|
|
})
|
|
|
|
/**
|
|
* WHAT A PLAN COSTS IS ITS SUBSCRIPTION, NOT THE BALANCE.
|
|
*
|
|
* GET /v1/billing/subscriptions lists the org's rows; the one that is live is
|
|
* the plan's term: its price per seat times its seats, by its interval, and the
|
|
* day the period ends — renewed, or ended where it was cancelled.
|
|
*/
|
|
const sub = {
|
|
id: 'sub_1',
|
|
status: 'active',
|
|
quantity: 1,
|
|
currentPeriodEnd: '2026-10-30T00:00:00Z',
|
|
cancelAtPeriodEnd: false,
|
|
plan: { id: 'max-20x', name: 'Max 20x', price: 20000, currency: 'usd', interval: 'month' },
|
|
}
|
|
|
|
test('the live subscription is the plan, priced by its seats and renewed at its period end', async () => {
|
|
const w = world({
|
|
'/v1/billing/subscriptions': {
|
|
status: 200,
|
|
body: { subscriptions: [{ ...sub, id: 'old', status: 'canceled' }, sub], count: 2 },
|
|
},
|
|
})
|
|
try {
|
|
const got = await readSubscription()
|
|
expect(got?.id).toBe('sub_1')
|
|
expect(renewal(got!)).toBe('$200/month subscription, renews Oct 30, 2026')
|
|
expect(w.asked).toEqual(['/v1/billing/subscriptions acme'])
|
|
} finally {
|
|
w.done()
|
|
}
|
|
expect(renewal({ ...sub, quantity: 3, plan: { ...sub.plan, price: 3000 } })).toBe('$90/month subscription, renews Oct 30, 2026')
|
|
expect(renewal({ ...sub, plan: { ...sub.plan, price: 200000, interval: 'year' } })).toBe('$2,000/year subscription, renews Oct 30, 2026')
|
|
expect(renewal({ ...sub, cancelAtPeriodEnd: true })).toBe('$200/month subscription, ends Oct 30, 2026')
|
|
})
|
|
|
|
test('an org on no plan has no subscription, and a refusal is not an empty list', async () => {
|
|
const none = world({ '/v1/billing/subscriptions': { status: 200, body: { subscriptions: [], count: 0 } } })
|
|
try {
|
|
expect(await readSubscription()).toBeNull()
|
|
} finally {
|
|
none.done()
|
|
}
|
|
const down = world({ '/v1/billing/subscriptions': { status: 502, body: { detail: 'commerce unreachable' } } })
|
|
try {
|
|
await expect(readSubscription()).rejects.toMatchObject({ status: 502 })
|
|
} finally {
|
|
down.done()
|
|
}
|
|
})
|