mirror of
https://github.com/hanzoai/billing.git
synced 2026-10-30 18:46:54 +00:00
Twelve read methods on the commerce client turned any failure into an empty
success — `catch { return [] }`, and for usage `catch { return { totalCost: 0 } }`.
`loadBillingData` then used allSettled and acted only on `fulfilled`, so a
rejection left the initial state untouched. The two together meant a live 404 or
502 arrived on screen as a fact about the customer's money: Transactions said
"No transactions found." while /v1/billing/transactions was answering 404, and
the overview said "No usage yet" — with a nudge to go start using the product —
while /v1/billing/usage was answering 502, to an account that may have been
spending all month.
An empty list and a failed read are different claims and now render differently.
The client lets CommerceApiError propagate, loadBillingData records which panel
rejected and why, and the panels that would otherwise show an empty state say
"couldn't load", carry the reason, and offer a retry. The Transactions header
stops reporting "0 transaction(s)" for a ledger it never read.
This is the shape UsagePanel already had — error and onRetry beside the data —
so it is the existing answer applied to the panels that lacked it, not a second
mechanism.
The top-up page also stops pretending it can be embedded. It read a bearer token
from `?token=` and a `hanzo-auth-token` cookie, took `userId` and `returnUrl`
from the query, and reported completion by posting to `window.parent` — but the
deployment serves `frame-ancestors 'none'` and `X-Frame-Options: DENY`, so there
is never a parent frame, and nothing has ever set that cookie. A bearer token in
a URL is also how a token reaches browser history, referrers and access logs.
Identity comes from the session on this origin; the amount is the only thing a
link may still suggest. Success now offers the way back rather than a redirect
to a caller that cannot exist.
Co-authored-by: Hanzo Dev <[email protected]>
87 lines
3.8 KiB
TypeScript
87 lines
3.8 KiB
TypeScript
import type { SubscriptionPlan } from './billing/types'
|
|
import { SALES_URL } from './config'
|
|
|
|
/**
|
|
* Public plan catalog — the ONE logged-out source of pricing.
|
|
*
|
|
* The commerce catalog (GET /v1/billing/plans on commerce-api) is IAM-gated and
|
|
* 403s logged-out, so a prospect hitting the pricing grid without a session used
|
|
* to land on a login wall. The same @hanzo/plans catalog is served publicly by
|
|
* cloud, but the direct cloud host (api.cloud.hanzo.ai/v1/plans/subscriptions)
|
|
* emits NO Access-Control-Allow-Origin — a cross-origin browser fetch from
|
|
* billing.hanzo.ai is blocked. The gateway (api.hanzo.ai) proxies the identical
|
|
* route, is public (200 unauthenticated), AND returns
|
|
* `access-control-allow-origin: https://billing.hanzo.ai`, so it is the correct
|
|
* browser-facing source. Prices here are in DOLLARS (priceMonthly: 20), unlike
|
|
* commerce's cents.
|
|
*/
|
|
export const PUBLIC_PLANS_URL = 'https://api.hanzo.ai/v1/plans/subscriptions'
|
|
|
|
// Subscription tiers shown on the pricing grid (the catalog also carries
|
|
// world/social plans, which have their own product surfaces).
|
|
const PLAN_CATEGORIES = new Set(['personal', 'team', 'enterprise'])
|
|
|
|
interface PublicPlan {
|
|
id?: string
|
|
name?: string
|
|
description?: string
|
|
category?: string
|
|
priceMonthly?: number // DOLLARS (0 = free)
|
|
priceAnnual?: number // DOLLARS per month billed annually
|
|
currency?: string
|
|
features?: string[]
|
|
limits?: Record<string, number | null | undefined>
|
|
popular?: boolean
|
|
contactSales?: boolean
|
|
trialPeriodDays?: number
|
|
price_ref?: { recurring?: { per_seat?: boolean } }
|
|
}
|
|
|
|
function mapPublicPlan(p: PublicPlan): SubscriptionPlan {
|
|
const limits: Record<string, number> = {}
|
|
for (const [k, v] of Object.entries(p.limits ?? {})) {
|
|
if (typeof v === 'number') limits[k] = v
|
|
}
|
|
const perSeat = p.price_ref?.recurring?.per_seat ?? p.category === 'team'
|
|
// A tier with NO price is sold by a person, whether or not the catalog also
|
|
// flags it. Deriving it here is what keeps the fact alive past `?? 0` below —
|
|
// otherwise a plan priced `null` and unflagged reaches the cards as a zero and
|
|
// renders as "Free".
|
|
const sold = !!p.contactSales || p.priceMonthly == null
|
|
return {
|
|
id: p.id ?? '',
|
|
name: p.name ?? '',
|
|
description: p.description ?? '',
|
|
// A tier sold by a person has NO price, and `?? 0` erased that distinction:
|
|
// Enterprise arrives as `priceMonthly: null` and every downstream reader
|
|
// then saw a zero, which renders as "Free". The zero is kept — the shape
|
|
// wants a number — but `contactSales` below carries the fact that survives
|
|
// it, and every price-rendering site asks that FIRST.
|
|
price: p.priceMonthly ?? 0,
|
|
annualPrice: p.priceAnnual || undefined,
|
|
category: p.category,
|
|
billingPeriod: 'monthly',
|
|
currency: p.currency ?? 'usd',
|
|
features: p.features ?? [],
|
|
limits,
|
|
highlighted: p.popular,
|
|
badge: sold ? 'Contact Sales' : p.popular ? 'Popular' : undefined,
|
|
contactSales: sold,
|
|
externalUrl: sold ? SALES_URL : undefined,
|
|
trialDays: p.trialPeriodDays || undefined,
|
|
perSeat,
|
|
// commerce rejects fewer than 2 seats on per-seat tiers; the selector floor
|
|
// matches so the public grid can't propose an amount the checkout refuses.
|
|
minSeats: perSeat ? limits.minSeats ?? 2 : undefined,
|
|
}
|
|
}
|
|
|
|
/** Fetch and project the public catalog onto the pricing-grid plan shape. */
|
|
export async function fetchPublicPlans(): Promise<SubscriptionPlan[]> {
|
|
const res = await fetch(PUBLIC_PLANS_URL, { headers: { Accept: 'application/json' } })
|
|
if (!res.ok) throw new Error(`plan catalog HTTP ${res.status}`)
|
|
const data = (await res.json()) as { plans?: PublicPlan[] }
|
|
const plans = Array.isArray(data.plans) ? data.plans : []
|
|
return plans.filter((p) => PLAN_CATEGORIES.has(p.category ?? '')).map(mapPublicPlan)
|
|
}
|