Files
billing/lib/analytics.test.ts
zooqueen c362541bce the funnel events reach the door
plan_clicked and checkout_started were sent by a bare beacon with no
credential, and the door refuses a write it cannot file under a project —
401 ingest_key_required — so every one of them was discarded. The money
funnel had no steps after the click because none of them ever landed.

They go through @hanzo/event now, which carries the publishable key for an
anonymous visitor and the buyer's own IAM token when there is one. That also
supplies the anonymous id, a first-party cookie on hanzo.ai, so a visitor who
read pricing on the marketing site is the same person here and the steps join.

The plans grid emits pricing_viewed: it is a tab, not a page, so a pageview
never saw it, and a buyer who arrives straight at billing looked like a plan
click from nowhere.
2026-08-15 10:12:18 -07:00

70 lines
2.4 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.
/**
* A funnel event must arrive attributable.
*
* The door refuses a write it cannot file under a project — 401
* `ingest_key_required` — so an event sent with no credential is not a weak
* event, it is no event at all. This pins the two properties that decide
* whether the money funnel exists: every write carries a credential, and a
* signed-in buyer's own token is preferred to the anonymous key, so their
* checkout lands in their org rather than in the anonymous one.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { analytics } from './analytics'
/** A localStorage the test controls. `window` is what tells the client and the
* token reader that they are in a browser. */
function browser(stored: Record<string, string>) {
const store = new Map(Object.entries(stored))
const local = {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => void store.set(k, v),
removeItem: (k: string) => void store.delete(k),
}
vi.stubGlobal('localStorage', local)
vi.stubGlobal('window', { localStorage: local, location: { href: 'https://billing.hanzo.ai/' } })
}
/** The Authorization header of the batch the client sent, and the names in it. */
function sent() {
const [, init] = vi.mocked(globalThis.fetch).mock.calls[0] ?? []
const auth = new Headers(init?.headers as HeadersInit).get('authorization')
const body = JSON.parse(String(init?.body ?? '{"batch":[]}'))
return { auth, names: (body.batch as { event?: string }[]).map((e) => e.event) }
}
beforeEach(() => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('{}', { status: 200 }))
})
afterEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
describe('the billing client is attributable at the door', () => {
it('carries the publishable key when nobody is signed in', () => {
browser({})
analytics.capture('plan_clicked', { plan: 'pro' })
analytics.flush()
const { auth, names } = sent()
expect(auth).toMatch(/^Bearer pk-/)
expect(names).toContain('plan_clicked')
})
it('carries the signed-in token instead, so the org is the buyer’s', () => {
browser({
hanzo_billing_access_token: 'a-real-iam-token',
hanzo_billing_expires_at: String(Date.now() + 60_000),
})
analytics.capture('checkout_started', { plan: 'pro' })
analytics.flush()
expect(sent().auth).toBe('Bearer a-real-iam-token')
})
})