Skip to main content

Source: ocean/docs/billing-page.md | ✏️ Edit on GitHub

Enterprise SaaS Billing Page Design Guide

GraphQL Yoga + TanStack + React 19 + Supabase Stack

Executive Summary

This guide provides a comprehensive approach to building an enterprise SaaS billing page using GraphQL Yoga, TanStack Router/Form/Table, React 19, Node 24, deployed on Vercel with Supabase as the database. The architecture emphasizes type safety, modern React patterns, and seamless Stripe integration while maintaining excellent developer experience and user delight.

Repository alignment for this project (2025-08-12)

  • This repository runs GraphQL Yoga inside a Supabase Edge Function (supabase/functions/graphql-v2) and the frontend calls ${env.supabase.url}/functions/v1/graphql-v2 (see src/lib/graphql-client.ts).
  • Frontend queries expect names like billingDetails, availableProducts, availablePrices, paymentMethods. Implement or alias corresponding resolvers server-side (see alignment section below).
  • Prefer invoices table UI with a portal fallback. Current code links to the Stripe portal; table UI can be added incrementally.
  • Unify Stripe SDK/API version across functions (target apiVersion: '2025-06-30').
  • Keep PostHog analytics via useAnalytics helpers (trackUserAction, etc.) and optionally add thin wrappers for the billing event taxonomy proposed here.

Tech Stack Architecture

Backend Architecture

// Stack Components
- GraphQL Yoga on Supabase Edge Functions (Deno runtime)
- Stripe SDK: Payment processing (apiVersion '2025-06-30')
- Supabase: PostgreSQL database + Row Level Security
- GraphQL endpoint: `${SUPABASE_URL}/functions/v1/graphql-v2`
- Optional: Vercel for hosting the frontend app

Frontend Architecture

// Stack Components
- React 19: With server components and use hook
- TanStack Router: Type-safe routing with search params
- TanStack Form: Form state management with validation
- TanStack Table: Advanced table features for invoices
- TanStack Query: Server state management
- shadcn/ui: Component library with Tailwind CSS

User Expectations by Role

Account Owners

  • Primary Needs: Complete financial oversight and control
  • Key Features:
    • Clear visibility into total costs and ROI metrics
    • Full access to invoice history with downloadable PDFs
    • Seamless plan changes with immediate effect visibility
    • Usage tracking with predictive analytics for budgeting
    • Exportable reports for financial planning and accounting

Billing Admins

  • Primary Needs: Efficient billing operations management
  • Key Features:
    • Role-based permissions via Supabase RLS
    • Team usage visibility and seat management
    • Bulk invoice processing via TanStack Table
    • Integration capabilities with accounting tools
    • Dispute handling through GraphQL mutations

Team Members

  • Primary Needs: Limited, relevant information only
  • Key Features:
    • Read-only access enforced by Supabase RLS
    • Non-intrusive notifications via GraphQL subscriptions
    • Clear communication about billing-related impacts
    • Visible "Contact Admin" prompts for requests

Project Setup & Configuration

1. Initialize Project Structure

# Project structure
billing-platform/
├── apps/
│ ├── web/ # React 19 app
│ │ ├── src/
│ │ │ ├── routes/ # TanStack Router routes
│ │ │ ├── components/ # React components
│ │ │ ├── hooks/ # Custom hooks
│ │ │ └── lib/ # Utilities
│ │ └── package.json
│ └── api/ # GraphQL Yoga server
│ ├── src/
│ │ ├── schema/ # GraphQL schemas
│ │ ├── resolvers/ # Resolvers
│ │ ├── services/ # Business logic
│ │ └── index.ts
│ └── package.json
├── packages/
│ ├── database/ # Supabase migrations
│ └── shared/ # Shared types
└── vercel.json # Vercel configuration

2. Supabase Database Schema

-- Supabase migrations for billing tables
CREATE TABLE organizations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
stripe_customer_id TEXT UNIQUE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE TABLE subscriptions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID REFERENCES organizations(id),
stripe_subscription_id TEXT UNIQUE,
status TEXT NOT NULL,
current_period_start TIMESTAMPTZ,
current_period_end TIMESTAMPTZ,
plan_id TEXT NOT NULL,
seats INTEGER DEFAULT 1,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE TABLE invoices (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID REFERENCES organizations(id),
stripe_invoice_id TEXT UNIQUE,
amount_paid INTEGER,
amount_due INTEGER,
status TEXT NOT NULL,
invoice_pdf TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE TABLE billing_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID REFERENCES organizations(id),
event_type TEXT NOT NULL,
metadata JSONB,
created_at TIMESTAMPTZ DEFAULT NOW()
);

-- Enable Row Level Security
ALTER TABLE organizations ENABLE ROW LEVEL SECURITY;
ALTER TABLE subscriptions ENABLE ROW LEVEL SECURITY;
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;

-- RLS Policies
CREATE POLICY "Users can view their organization's data" ON subscriptions
FOR SELECT USING (
organization_id IN (
SELECT organization_id FROM organization_members
WHERE user_id = auth.uid()
)
);

CREATE POLICY "Only billing admins can update" ON subscriptions
FOR UPDATE USING (
EXISTS (
SELECT 1 FROM organization_members
WHERE user_id = auth.uid()
AND organization_id = subscriptions.organization_id
AND role IN ('owner', 'billing_admin')
)
);

GraphQL over Supabase Edge Functions (current project)

// GraphQL endpoint for the web app
// src/lib/graphql-client.ts
const GRAPHQL_ENDPOINT = `${env.supabase.url}/functions/v1/graphql-v2`

// Supabase Edge Function entry
// supabase/functions/graphql-v2/index.ts
// - loads typeDefs from schema.ts
// - merges base + billing resolvers (enhancedBillingResolvers)
// - creates Yoga instance and serves at /graphql-v2

// Billing resolvers live under:
// supabase/functions/graphql-v2/resolvers/billing/
// - query-resolvers.ts (products, billing overview, invoices, usage)
// - mutation-resolvers.ts (createSubscription, checkout, spending limit)
// - type-resolvers.ts (Price.recurring, PaymentMethod.card, etc.)

API contract alignment (frontend ↔️ GraphQL)

  • Query.billingDetails → implement by returning current billingOverview payload on the server.
  • Query.availableProducts → map to current products resolver (or alias to availableProducts).
  • Query.availablePrices(productId) → add resolver that queries stripe_prices by product_id.
  • Query.paymentMethods → expose payment methods currently returned inside billingOverview.
  • Mutations expected by UI:
    • createSetupIntent → proxy to supabase/functions/stripe-setup-intent and return clientSecret.
    • createCustomerPortalSession(returnUrl) → create Stripe portal session server-side and return url.
    • cancelSubscription/resumeSubscription/updateSubscription → proxy to supabase/functions/stripe-subscription (DELETE/POST).
    • setDefaultPaymentMethod/detachPaymentMethod/attachPaymentMethod → call Stripe and update payment_methods table.
  • Invoices:
    • Keep current simple invoices(limit, offset) or move to a connection (edges/pageInfo) as the UI evolves.

Note: keep names in src/graphql/billing/*.ts and src/hooks/billing/*.ts consistent with schema.

GraphQL Yoga Server Setup (Vercel/Node alternative)

// apps/api/src/index.ts
import { createYoga } from 'graphql-yoga'
import { createServer } from 'node:http'
import { useResponseCache } from '@graphql-yoga/plugin-response-cache'
import { useRateLimiter } from '@graphql-yoga/plugin-rate-limiter'
import { schema } from './schema'
import { createContext } from './context'

export const yoga = createYoga({
schema,
context: createContext,
plugins: [
useResponseCache({
session: (request) => request.headers.get('authorization'),
ttl: 60_000, // 1 minute cache for billing data
ttlPerType: {
Invoice: 300_000, // 5 minutes for invoices
},
}),
useRateLimiter({
identifyFn: (context) => context.user?.id,
limit: 100,
window: '1m',
}),
],
graphqlEndpoint: '/api/graphql',
landingPage: false,
cors: {
origin: process.env.VERCEL_URL || 'http://localhost:3000',
credentials: true,
},
})

// For Vercel deployment
export default yoga

4. GraphQL Type Definitions

// apps/api/src/schema/billing.graphql
type Subscription {
id: ID!
status: SubscriptionStatus!
currentPlan: Plan!
seats: Int!
usedSeats: Int!
nextBillingDate: DateTime!
nextInvoiceAmount: Money!
currentPeriodStart: DateTime!
currentPeriodEnd: DateTime!
cancelAtPeriodEnd: Boolean!
}

type Plan {
id: ID!
name: String!
price: Money!
interval: BillingInterval!
features: [PlanFeature!]!
limits: PlanLimits!
}

type Invoice {
id: ID!
number: String!
status: InvoiceStatus!
amountPaid: Money!
amountDue: Money!
dueDate: DateTime
pdfUrl: String!
lineItems: [LineItem!]!
createdAt: DateTime!
}

type Query {
currentSubscription: Subscription
availablePlans: [Plan!]!
invoices(first: Int, after: String): InvoiceConnection!
upcomingInvoice: Invoice
paymentMethods: [PaymentMethod!]!
usageMetrics(period: UsagePeriod!): UsageData!
}

type Mutation {
updateSubscription(input: UpdateSubscriptionInput!): SubscriptionResult!
updateSeats(seats: Int!): SubscriptionResult!
addPaymentMethod(input: PaymentMethodInput!): PaymentMethodResult!
removePaymentMethod(id: ID!): RemovePaymentMethodResult!
downloadInvoice(id: ID!): DownloadInvoiceResult!
previewProration(input: UpdateSubscriptionInput!): ProrationPreview!
}

type Subscription {
subscriptionUpdated(organizationId: ID!): Subscription!
invoicePaid(organizationId: ID!): Invoice!
paymentFailed(organizationId: ID!): PaymentFailedEvent!
}

5. GraphQL Resolvers with Supabase

// apps/api/src/resolvers/billing.ts
import { createClient } from '@supabase/supabase-js'
import Stripe from 'stripe'
import { GraphQLError } from 'graphql'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2025-03-31',
})

const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_KEY!)

export const billingResolvers = {
Query: {
currentSubscription: async (_: any, __: any, context: Context) => {
// Check permissions
if (!context.user) {
throw new GraphQLError('Not authenticated', {
extensions: { code: 'UNAUTHENTICATED' },
})
}

// Fetch from Supabase with RLS
const { data: subscription, error } = await supabase
.from('subscriptions')
.select(
`
*,
organization:organizations(*)
`
)
.eq('organization_id', context.organizationId)
.single()

if (error) throw new GraphQLError(error.message)

// Fetch latest data from Stripe
const stripeSubscription = await stripe.subscriptions.retrieve(
subscription.stripe_subscription_id,
{ expand: ['latest_invoice', 'plan.product'] }
)

// Merge and return
return {
...subscription,
status: stripeSubscription.status,
nextInvoiceAmount: stripeSubscription.latest_invoice?.amount_due,
}
},

invoices: async (_: any, args: any, context: Context) => {
const { first = 10, after } = args

// Fetch from Supabase with pagination
const query = supabase
.from('invoices')
.select('*', { count: 'exact' })
.eq('organization_id', context.organizationId)
.order('created_at', { ascending: false })
.limit(first)

if (after) {
query.gt('created_at', after)
}

const { data, error, count } = await query

if (error) throw new GraphQLError(error.message)

return {
edges: data?.map((invoice) => ({
node: invoice,
cursor: invoice.created_at,
})),
pageInfo: {
hasNextPage: (count || 0) > first,
endCursor: data?.[data.length - 1]?.created_at,
},
}
},
},

Mutation: {
updateSubscription: async (_: any, args: any, context: Context) => {
// Validate permissions
await validateBillingPermission(context)

// Generate idempotency key
const idempotencyKey = `update_sub_${context.organizationId}_${Date.now()}`

try {
// Update in Stripe
const subscription = await stripe.subscriptions.update(
args.input.subscriptionId,
{
items: [
{
id: args.input.itemId,
price: args.input.newPriceId,
},
],
proration_behavior: 'create_prorations',
},
{ idempotencyKey }
)

// Update in Supabase
const { error } = await supabase
.from('subscriptions')
.update({
plan_id: args.input.newPriceId,
updated_at: new Date().toISOString(),
})
.eq('stripe_subscription_id', subscription.id)

if (error) throw error

// Log event
await supabase.from('billing_events').insert({
organization_id: context.organizationId,
event_type: 'subscription.updated',
metadata: {
old_plan: args.input.currentPriceId,
new_plan: args.input.newPriceId,
user_id: context.user.id,
},
})

return {
success: true,
subscription,
}
} catch (error) {
throw new GraphQLError('Failed to update subscription', {
extensions: {
code: 'SUBSCRIPTION_UPDATE_FAILED',
originalError: error,
},
})
}
},
},

Subscription: {
subscriptionUpdated: {
subscribe: async function* (_: any, args: any, context: Context) {
// Use Supabase Realtime
const channel = supabase
.channel('subscription_updates')
.on(
'postgres_changes',
{
event: 'UPDATE',
schema: 'public',
table: 'subscriptions',
filter: `organization_id=eq.${args.organizationId}`,
},
(payload) => {
return payload.new
}
)
.subscribe()

// Yield updates as they come
for await (const update of channel) {
yield { subscriptionUpdated: update }
}
},
},
},
}

Frontend Implementation with React 19 & TanStack

PostHog Setup & Configuration

// apps/web/src/lib/posthog.ts
import posthog from 'posthog-js'
import { PostHogProvider } from 'posthog-js/react'

if (typeof window !== 'undefined') {
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY!, {
api_host: process.env.NEXT_PUBLIC_POSTHOG_HOST,
capture_pageview: false, // Manual control for better tracking
capture_pageleave: true,
autocapture: {
dom_event_allowlist: ['click', 'submit'], // Track billing interactions
element_allowlist: ['button', 'input', 'select', 'a'],
css_selector_allowlist: [
'[data-track]', // Custom tracking attributes
'.billing-action', // Billing-specific actions
],
},
})
}

// Billing-specific event tracking
export const billingEvents = {
viewedBillingPage: (tab?: string) => posthog.capture('billing_page_viewed', { tab }),

startedUpgrade: (currentPlan: string, targetPlan: string) =>
posthog.capture('upgrade_started', {
current_plan: currentPlan,
target_plan: targetPlan,
$feature_flag: 'new_pricing_page',
}),

completedUpgrade: (plan: string, revenue: number) =>
posthog.capture('upgrade_completed', {
plan,
revenue,
$revenue: revenue, // PostHog revenue tracking
}),

addedPaymentMethod: (type: string) => posthog.capture('payment_method_added', { type }),

downloadedInvoice: (invoiceId: string) =>
posthog.capture('invoice_downloaded', { invoice_id: invoiceId }),

changedSeats: (oldCount: number, newCount: number) =>
posthog.capture('seats_changed', {
old_count: oldCount,
new_count: newCount,
delta: newCount - oldCount,
}),
}

// Feature flag hooks
export const billingFeatureFlags = {
newPricingPage: 'new_pricing_page',
annualDiscounts: 'annual_billing_discounts',
usageAlerts: 'proactive_usage_alerts',
teamBilling: 'team_billing_management',
customInvoicing: 'custom_invoice_branding',
}

1. TanStack Router Setup with PostHog

// apps/web/src/routes/__root.tsx
import { createRootRoute, Outlet } from '@tanstack/react-router'
import { TanStackRouterDevtools } from '@tanstack/router-devtools'
import { QueryClient } from '@tanstack/react-query'
import { PostHogProvider } from 'posthog-js/react'
import posthog from 'posthog-js'

export const Route = createRootRoute({
component: () => (
<PostHogProvider client={posthog}>
<Outlet />
<TanStackRouterDevtools />
</PostHogProvider>
),
})

// apps/web/src/routes/billing/index.tsx
import { createFileRoute } from '@tanstack/react-router'
import { z } from 'zod'

const billingSearchSchema = z.object({
tab: z.enum(['overview', 'invoices', 'payment', 'team']).optional(),
period: z.enum(['month', 'year']).optional(),
})

export const Route = createFileRoute('/billing/')({
validateSearch: billingSearchSchema,
loaderDeps: ({ search }) => ({ tab: search.tab }),
loader: async ({ context }) => {
// Prefetch billing data
await context.queryClient.prefetchQuery({
queryKey: ['subscription'],
queryFn: () => fetchSubscription(),
})
return null
},
component: BillingPage,
})

2. Billing Page Component with React 19

// apps/web/src/routes/billing/BillingPage.tsx
import { use, Suspense } from 'react'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useSearch, useNavigate } from '@tanstack/react-router'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { BillingOverview } from './BillingOverview'
import { InvoiceHistory } from './InvoiceHistory'
import { PaymentMethods } from './PaymentMethods'
import { TeamManagement } from './TeamManagement'

function BillingPage() {
const search = useSearch({ from: '/billing/' })
const navigate = useNavigate({ from: '/billing' })

// React 19 use hook with TanStack Query
const { data: subscription } = useSuspenseQuery({
queryKey: ['subscription'],
queryFn: fetchSubscription,
})

const handleTabChange = (tab: string) => {
navigate({
search: (prev) => ({ ...prev, tab }),
replace: true,
})
}

return (
<div className="container mx-auto py-8">
<h1 className="text-3xl font-bold mb-8">Billing & Subscription</h1>

<Tabs value={search.tab || 'overview'} onValueChange={handleTabChange}>
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="invoices">Invoices</TabsTrigger>
<TabsTrigger value="payment">Payment</TabsTrigger>
<TabsTrigger value="team">Team</TabsTrigger>
</TabsList>

<Suspense fallback={<BillingSkeleton />}>
<TabsContent value="overview">
<BillingOverview subscription={subscription} />
</TabsContent>

<TabsContent value="invoices">
<InvoiceHistory />
</TabsContent>

<TabsContent value="payment">
<PaymentMethods />
</TabsContent>

<TabsContent value="team">
<TeamManagement subscription={subscription} />
</TabsContent>
</Suspense>
</Tabs>
</div>
)
}

3. TanStack Form for Payment Updates

// apps/web/src/components/billing/PaymentMethodForm.tsx
import { useForm } from '@tanstack/react-form'
import { useMutation } from '@tanstack/react-query'
import { zodValidator } from '@tanstack/zod-form-adapter'
import { z } from 'zod'
import { loadStripe } from '@stripe/stripe-js'
import { Elements, CardElement, useStripe, useElements } from '@stripe/react-stripe-js'

const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_KEY!)

const paymentSchema = z.object({
cardholderName: z.string().min(1, 'Name is required'),
billingEmail: z.string().email('Valid email required'),
saveAsDefault: z.boolean().default(false),
})

function PaymentMethodForm() {
const stripe = useStripe()
const elements = useElements()

const form = useForm({
defaultValues: {
cardholderName: '',
billingEmail: '',
saveAsDefault: false,
},
onSubmit: async ({ value }) => {
if (!stripe || !elements) return

const card = elements.getElement(CardElement)
if (!card) return

// Create payment method with Stripe
const { error, paymentMethod } = await stripe.createPaymentMethod({
type: 'card',
card,
billing_details: {
name: value.cardholderName,
email: value.billingEmail,
},
})

if (error) {
form.setFieldMeta('cardholderName', {
errors: [error.message],
})
return
}

// Save to backend
await addPaymentMethod.mutateAsync({
paymentMethodId: paymentMethod.id,
setAsDefault: value.saveAsDefault,
})
},
validatorAdapter: zodValidator,
})

const addPaymentMethod = useMutation({
mutationFn: async (data: any) => {
const response = await fetch('/api/graphql', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query: `
mutation AddPaymentMethod($input: PaymentMethodInput!) {
addPaymentMethod(input: $input) {
success
paymentMethod {
id
last4
brand
}
}
}
`,
variables: { input: data },
}),
})
return response.json()
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['paymentMethods'] })
toast.success('Payment method added successfully')
},
})

return (
<form
onSubmit={(e) => {
e.preventDefault()
form.handleSubmit()
}}
>
<form.Field
name="cardholderName"
children={(field) => (
<div className="space-y-2">
<Label htmlFor={field.name}>Cardholder Name</Label>
<Input
id={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
/>
{field.state.meta.errors && (
<p className="text-sm text-red-500">{field.state.meta.errors.join(', ')}</p>
)}
</div>
)}
/>

<div className="space-y-2 mt-4">
<Label>Card Details</Label>
<div className="p-3 border rounded-md">
<CardElement
options={{
style: {
base: {
fontSize: '16px',
color: '#424770',
'::placeholder': { color: '#aab7c4' },
},
},
}}
/>
</div>
</div>

<form.Field
name="saveAsDefault"
children={(field) => (
<div className="flex items-center space-x-2 mt-4">
<Checkbox
id={field.name}
checked={field.state.value}
onCheckedChange={(checked) => field.handleChange(checked as boolean)}
/>
<Label htmlFor={field.name}>Save as default payment method</Label>
</div>
)}
/>

<form.Subscribe
selector={(state) => [state.canSubmit, state.isSubmitting]}
children={([canSubmit, isSubmitting]) => (
<Button type="submit" disabled={!canSubmit || isSubmitting} className="w-full mt-6">
{isSubmitting ? 'Adding...' : 'Add Payment Method'}
</Button>
)}
/>
</form>
)
}

4. TanStack Table for Invoice History

// apps/web/src/components/billing/InvoiceTable.tsx
import {
useReactTable,
getCoreRowModel,
getPaginationRowModel,
getSortedRowModel,
getFilteredRowModel,
flexRender,
createColumnHelper,
} from '@tanstack/react-table'
import { useSuspenseInfiniteQuery } from '@tanstack/react-query'
import { format } from 'date-fns'
import { Download, ExternalLink } from 'lucide-react'

const columnHelper = createColumnHelper<Invoice>()

const columns = [
columnHelper.accessor('number', {
header: 'Invoice',
cell: (info) => <span className="font-mono">{info.getValue()}</span>,
}),
columnHelper.accessor('createdAt', {
header: 'Date',
cell: (info) => format(new Date(info.getValue()), 'MMM dd, yyyy'),
}),
columnHelper.accessor('status', {
header: 'Status',
cell: (info) => {
const status = info.getValue()
return (
<Badge
variant={status === 'paid' ? 'success' : status === 'pending' ? 'warning' : 'destructive'}
>
{status}
</Badge>
)
},
}),
columnHelper.accessor('amountDue', {
header: 'Amount',
cell: (info) => {
const amount = info.getValue()
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(amount / 100)
},
}),
columnHelper.display({
id: 'actions',
header: 'Actions',
cell: ({ row }) => (
<div className="flex gap-2">
<Button variant="ghost" size="sm" onClick={() => downloadInvoice(row.original.id)}>
<Download className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => window.open(row.original.pdfUrl, '_blank')}
>
<ExternalLink className="h-4 w-4" />
</Button>
</div>
),
}),
]

export function InvoiceTable() {
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useSuspenseInfiniteQuery({
queryKey: ['invoices'],
queryFn: async ({ pageParam = null }) => {
const response = await graphqlClient.request(
gql`
query GetInvoices($after: String) {
invoices(first: 10, after: $after) {
edges {
node {
id
number
status
amountDue
amountPaid
createdAt
pdfUrl
}
cursor
}
pageInfo {
hasNextPage
endCursor
}
}
}
`,
{ after: pageParam }
)
return response.invoices
},
getNextPageParam: (lastPage) =>
lastPage.pageInfo.hasNextPage ? lastPage.pageInfo.endCursor : null,
})

const allInvoices = data?.pages.flatMap((page) => page.edges.map((edge) => edge.node)) ?? []

const table = useReactTable({
data: allInvoices,
columns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
})

return (
<div className="space-y-4">
<div className="rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
No invoices found.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>

{hasNextPage && (
<Button
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage}
variant="outline"
className="w-full"
>
{isFetchingNextPage ? 'Loading more...' : 'Load more invoices'}
</Button>
)}
</div>
)
}

Supabase Edge Function Deployment

The GraphQL endpoint is provided by the Supabase Edge Function graphql-v2 and is reachable at ${SUPABASE_URL}/functions/v1/graphql-v2. Ensure the following environment variables are set in Supabase project settings for all involved functions:

  • SUPABASE_URL
  • SUPABASE_SERVICE_ROLE_KEY
  • STRIPE_SECRET_KEY
  • STRIPE_WEBHOOK_SECRET

Client code should continue using src/lib/graphql-client.ts which targets the function URL.

Vercel Deployment Configuration (optional)

1. Vercel Configuration

// vercel.json
{
"functions": {
"apps/api/index.ts": {
"runtime": "nodejs24.x",
"maxDuration": 10,
"memory": 512
}
},
"rewrites": [
{
"source": "/api/graphql",
"destination": "/api"
}
],
"env": {
"STRIPE_SECRET_KEY": "@stripe-secret-key",
"STRIPE_WEBHOOK_SECRET": "@stripe-webhook-secret",
"SUPABASE_URL": "@supabase-url",
"SUPABASE_SERVICE_KEY": "@supabase-service-key"
},
"crons": [
{
"path": "/api/cron/sync-subscriptions",
"schedule": "0 */6 * * *"
}
]
}

2. Stripe Webhook Handler for Vercel Edge

// apps/api/webhooks/stripe.ts
import { createClient } from '@supabase/supabase-js'
import Stripe from 'stripe'

export const config = {
runtime: 'edge',
}

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2025-03-31',
})

export default async function handler(req: Request) {
const body = await req.text()
const sig = req.headers.get('stripe-signature')!

let event: Stripe.Event

try {
event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!)
} catch (err) {
return new Response(`Webhook Error: ${err.message}`, { status: 400 })
}

const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_KEY!)

switch (event.type) {
case 'invoice.paid': {
const invoice = event.data.object as Stripe.Invoice

await supabase.from('invoices').upsert({
stripe_invoice_id: invoice.id,
organization_id: invoice.metadata?.organization_id,
amount_paid: invoice.amount_paid,
amount_due: invoice.amount_due,
status: 'paid',
invoice_pdf: invoice.invoice_pdf,
})

// Trigger GraphQL subscription via Supabase Realtime
await supabase.from('billing_events').insert({
organization_id: invoice.metadata?.organization_id,
event_type: 'invoice.paid',
metadata: { invoice_id: invoice.id },
})
break
}

case 'customer.subscription.updated': {
const subscription = event.data.object as Stripe.Subscription

await supabase
.from('subscriptions')
.update({
status: subscription.status,
current_period_start: new Date(subscription.current_period_start * 1000).toISOString(),
current_period_end: new Date(subscription.current_period_end * 1000).toISOString(),
updated_at: new Date().toISOString(),
})
.eq('stripe_subscription_id', subscription.id)
break
}

case 'invoice.payment_failed': {
const invoice = event.data.object as Stripe.Invoice

// Update invoice status
await supabase
.from('invoices')
.update({
status: 'failed',
})
.eq('stripe_invoice_id', invoice.id)

// Trigger dunning process
await fetch(`${process.env.VERCEL_URL}/api/dunning`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
customerId: invoice.customer,
invoiceId: invoice.id,
}),
})
break
}
}

return new Response('Success', { status: 200 })
}

If you keep the webhook in Supabase, see supabase/functions/handle-stripe-webhook/index.ts for the Deno implementation already present in this repo.

Advanced Features Implementation

A/B Testing Pricing Components with PostHog

// apps/web/src/components/billing/PricingComparison.tsx
import { useFeatureFlagVariantKey } from 'posthog-js/react'
import { usePostHog } from 'posthog-js/react'
import { useMutation } from '@tanstack/react-query'
import { billingEvents } from '@/lib/posthog'

export function PricingComparison({ currentPlan, plans }) {
const posthog = usePostHog()
const pricingVariant = useFeatureFlagVariantKey('pricing_display_test')

// Track which variant the user sees
useEffect(() => {
posthog.capture('pricing_comparison_viewed', {
variant: pricingVariant,
current_plan: currentPlan.id,
})
}, [pricingVariant])

// A/B Test: Different pricing display strategies
const PricingDisplay = {
control: ControlPricingTable,
emphasized_savings: SavingsEmphasisTable,
social_proof: SocialProofPricingTable,
urgency: UrgencyPricingTable,
}[pricingVariant || 'control']

const upgradeMutation = useMutation({
mutationFn: async (planId: string) => {
// Track conversion with revenue data
const targetPlan = plans.find((p) => p.id === planId)
billingEvents.startedUpgrade(currentPlan.name, targetPlan.name)

const result = await graphqlClient.request(upgradeSubscriptionMutation, {
planId,
idempotencyKey: `upgrade_${Date.now()}`,
})

if (result.success) {
billingEvents.completedUpgrade(targetPlan.name, targetPlan.price)

// Track conversion for A/B test
posthog.capture('ab_test_conversion', {
test_name: 'pricing_display_test',
variant: pricingVariant,
revenue: targetPlan.price,
})
}

return result
},
})

return (
<PricingDisplay plans={plans} currentPlan={currentPlan} onUpgrade={upgradeMutation.mutate} />
)
}

// Variant A: Control
function ControlPricingTable({ plans, currentPlan, onUpgrade }) {
return (
<div className="grid md:grid-cols-3 gap-6">
{plans.map((plan) => (
<Card key={plan.id} data-track={`plan_${plan.id}`}>
<CardHeader>
<CardTitle>{plan.name}</CardTitle>
<p className="text-2xl font-bold">${plan.price}/mo</p>
</CardHeader>
<CardContent>
<ul className="space-y-2">
{plan.features.map((feature) => (
<li key={feature}>{feature}</li>
))}
</ul>
<Button
onClick={() => onUpgrade(plan.id)}
disabled={plan.id === currentPlan.id}
className="w-full mt-4 billing-action"
>
{plan.id === currentPlan.id ? 'Current Plan' : 'Upgrade'}
</Button>
</CardContent>
</Card>
))}
</div>
)
}

// Variant B: Emphasize Savings
function SavingsEmphasisTable({ plans, currentPlan, onUpgrade }) {
return (
<div className="space-y-4">
<Alert className="bg-green-50 border-green-200">
<AlertDescription>
💰 Upgrade now and save up to $500/year with annual billing!
</AlertDescription>
</Alert>
{/* Pricing cards with savings badges */}
</div>
)
}

// Variant C: Social Proof
function SocialProofPricingTable({ plans, currentPlan, onUpgrade }) {
return (
<div>
{plans.map((plan) => (
<Card key={plan.id}>
{plan.popular && (
<Badge className="absolute -top-2 right-4">
Most Popular - 73% of teams choose this
</Badge>
)}
{/* Rest of pricing card */}
</Card>
))}
</div>
)
}

Conversion Funnel Tracking

// apps/web/src/components/billing/UpgradeFlow.tsx
import { usePostHog } from 'posthog-js/react'
import { useState } from 'react'

export function UpgradeFlow() {
const posthog = usePostHog()
const [step, setStep] = useState(1)

// Track funnel progression
const trackFunnelStep = (stepName: string, stepNumber: number) => {
posthog.capture('billing_funnel_step', {
step: stepNumber,
step_name: stepName,
funnel_id: 'upgrade_flow',
})
}

useEffect(() => {
// Track funnel entry
trackFunnelStep('viewed_plans', 1)

// Set up funnel tracking
posthog.capture('$funnel_entry', {
funnel_name: 'Billing Upgrade',
entry_point: 'billing_page',
})
}, [])

const handlePlanSelection = (plan) => {
trackFunnelStep('selected_plan', 2)
setSelectedPlan(plan)
setStep(2)
}

const handlePaymentSubmit = async (paymentData) => {
trackFunnelStep('entered_payment', 3)

try {
const result = await processUpgrade(paymentData)

trackFunnelStep('completed_upgrade', 4)
posthog.capture('$funnel_completion', {
funnel_name: 'Billing Upgrade',
revenue: selectedPlan.price,
})

} catch (error) {
posthog.capture('upgrade_error', {
step: 'payment',
error: error.message,
})
}
}

return (
<div>
{step === 1 && <PlanSelection onSelect={handlePlanSelection} />}
{step === 2 && <PaymentForm onSubmit={handlePaymentSubmit} />}
{step === 3 && <UpgradeConfirmation />}
</div>
)
}

Session Recording Insights

// apps/web/src/hooks/useBillingInsights.ts
import { usePostHog } from 'posthog-js/react'

export function useBillingInsights() {
const posthog = usePostHog()

// Start session recording for billing pages
useEffect(() => {
if (window.location.pathname.includes('/billing')) {
posthog.startSessionRecording()

// Tag session for easy filtering
posthog.capture('billing_session_started', {
page: window.location.pathname,
subscription_status: subscription?.status,
})
}

return () => {
posthog.stopSessionRecording()
}
}, [])

// Track rage clicks on billing elements
useEffect(() => {
const handleRageClick = (e: MouseEvent) => {
if (e.detail >= 3) {
// Triple click or more
posthog.capture('billing_rage_click', {
element: e.target?.className,
text: e.target?.textContent,
})
}
}

document.addEventListener('click', handleRageClick)
return () => document.removeEventListener('click', handleRageClick)
}, [])
}

1. Usage Tracking with Supabase Functions & PostHog

// supabase/functions/track-usage/index.ts
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'

serve(async (req) => {
const { organizationId, metric, value } = await req.json()

const supabase = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
)

// Record usage
await supabase.from('usage_metrics').insert({
organization_id: organizationId,
metric_name: metric,
value,
timestamp: new Date().toISOString(),
})

// Check if approaching limits
const { data: subscription } = await supabase
.from('subscriptions')
.select('plan_limits')
.eq('organization_id', organizationId)
.single()

if (value > subscription.plan_limits[metric] * 0.8) {
// Send alert
await supabase.from('notifications').insert({
organization_id: organizationId,
type: 'usage_warning',
message: `You've used 80% of your ${metric} limit`,
})
}

return new Response(JSON.stringify({ success: true }), {
headers: { 'Content-Type': 'application/json' },
})
})

2. Real-time Updates with GraphQL Subscriptions (optional)

// apps/web/src/hooks/useBillingSubscription.ts
import { useSubscription } from '@apollo/client'
import { useEffect } from 'react'
import { toast } from 'sonner'

const SUBSCRIPTION_UPDATED = gql`
subscription OnSubscriptionUpdated($organizationId: ID!) {
subscriptionUpdated(organizationId: $organizationId) {
id
status
currentPlan {
name
}
}
}
`

export function useBillingSubscription(organizationId: string) {
const { data, loading, error } = useSubscription(SUBSCRIPTION_UPDATED, {
variables: { organizationId },
})

useEffect(() => {
if (data?.subscriptionUpdated) {
toast.success(`Subscription updated to ${data.subscriptionUpdated.currentPlan.name}`)
// Invalidate relevant queries
queryClient.invalidateQueries({ queryKey: ['subscription'] })
}
}, [data])

return { data, loading, error }
}

Security & Performance Best Practices

Stripe SDK version standardization

  • Use a single Stripe SDK and API version across functions to avoid subtle behavior differences.
  • Target: apiVersion: '2025-06-30' in all places (stripe-setup-intent, stripe-subscription, any future resolvers).

1. Rate Limiting with GraphQL Yoga

// apps/api/src/plugins/rateLimiting.ts
import { useRateLimiter } from '@graphql-yoga/plugin-rate-limiter'

export const rateLimitPlugin = useRateLimiter({
identifyFn: (context) => context.user?.id || context.ip,
rules: [
{
type: 'mutation',
name: 'updateSubscription',
max: 5,
window: '1h',
},
{
type: 'query',
name: 'invoices',
max: 100,
window: '1m',
},
],
})

2. Caching Strategy

// apps/web/src/lib/queryClient.ts
import { QueryClient } from '@tanstack/react-query'

export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 10, // 10 minutes
retry: (failureCount, error: any) => {
if (error?.response?.status === 404) return false
if (error?.response?.status === 401) return false
return failureCount < 3
},
},
},
})

// Selective cache invalidation
export const invalidateBillingData = () => {
queryClient.invalidateQueries({
queryKey: ['subscription'],
exact: false,
})
queryClient.invalidateQueries({
queryKey: ['invoices'],
exact: false,
})
}

Testing Strategy

1. Component Testing with Vitest & PostHog

// apps/web/src/components/billing/__tests__/BillingOverview.test.tsx
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { vi } from 'vitest'
import { PostHogProvider } from 'posthog-js/react'
import { BillingOverview } from '../BillingOverview'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'

// Mock PostHog for tests
const mockPostHog = {
capture: vi.fn(),
isFeatureEnabled: vi.fn(),
getFeatureFlag: vi.fn(() => 'control'),
}

describe('BillingOverview', () => {
it('should track upgrade funnel events', async () => {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
})

render(
<QueryClientProvider client={queryClient}>
<PostHogProvider client={mockPostHog}>
<BillingOverview subscription={mockSubscription} />
</PostHogProvider>
</QueryClientProvider>
)

const upgradeButton = screen.getByText('Upgrade Plan')
await userEvent.click(upgradeButton)

expect(mockPostHog.capture).toHaveBeenCalledWith('upgrade_started', {
current_plan: 'starter',
target_plan: 'pro',
})
})

it('should display correct variant based on feature flag', () => {
mockPostHog.getFeatureFlag.mockReturnValue('social_proof')

render(
<PostHogProvider client={mockPostHog}>
<PricingComparison plans={mockPlans} />
</PostHogProvider>
)

// Should show social proof elements
expect(screen.getByText(/73% of teams choose this/)).toBeInTheDocument()
})
})

2. A/B Test Validation

// apps/web/src/tests/ab-tests.test.ts
import { calculateSampleSize, validateExperiment } from '@/lib/ab-testing'

describe('A/B Test Configuration', () => {
it('should have sufficient sample size for pricing test', () => {
const sampleSize = calculateSampleSize({
baselineConversion: 0.05,
minimumDetectableEffect: 0.2, // 20% lift
power: 0.8,
significanceLevel: 0.05,
})

expect(sampleSize).toBeLessThan(10000) // Achievable traffic
})

it('should properly segment users for annual discount test', () => {
const experiment = validateExperiment({
name: 'annual_discount_banner',
variants: ['control', 'banner_top', 'modal_popup'],
traffic_allocation: [0.33, 0.33, 0.34],
targeting: {
filters: [
{ property: 'plan_type', value: 'monthly' },
{ property: 'account_age_days', operator: 'gt', value: 30 },
],
},
})

expect(experiment.isValid).toBe(true)
expect(experiment.estimatedDuration).toBeLessThan(30) // days
})
})

3. E2E Testing with Playwright & PostHog

// e2e/billing-experiments.spec.ts
import { test, expect } from '@playwright/test'

test.describe('Billing A/B Tests', () => {
test('should track complete upgrade funnel', async ({ page }) => {
// Inject PostHog tracking verification
await page.addInitScript(() => {
window.posthogEvents = []
window.posthog = {
capture: (event, properties) => {
window.posthogEvents.push({ event, properties })
},
isFeatureEnabled: () => true,
}
})

// Complete upgrade flow
await page.goto('/billing')
await page.click('text=Upgrade Plan')
await page.click('[data-plan="pro"]')
await page.fill('[name="cardNumber"]', '4242424242424242')
await page.click('text=Confirm Upgrade')

// Verify funnel events were tracked
const events = await page.evaluate(() => window.posthogEvents)

expect(events).toContainEqual(
expect.objectContaining({
event: 'billing_page_viewed',
})
)
expect(events).toContainEqual(
expect.objectContaining({
event: 'upgrade_started',
})
)
expect(events).toContainEqual(
expect.objectContaining({
event: 'upgrade_completed',
properties: expect.objectContaining({
revenue: expect.any(Number),
}),
})
)
})

test('should respect feature flags', async ({ page }) => {
// Override feature flag
await page.addInitScript(() => {
window.posthog = {
getFeatureFlag: (flag) => {
if (flag === 'team_billing_management') return false
return 'control'
},
}
})

await page.goto('/billing')

// Team tab should not be visible
await expect(page.locator('text=Team')).not.toBeVisible()
})
})

Quick Implementation Checklist

Foundation & Analytics Setup

  • Set up Supabase project with billing tables
  • Configure GraphQL Yoga (Supabase Edge Function) with type definitions
  • Initialize PostHog project and install SDK
  • Set up PostHog feature flags for A/B tests
  • Configure PostHog session recording for billing pages
  • Implement Stripe webhook handler (Supabase Edge Function)
  • Set up TanStack Router with billing routes
  • Configure authentication with Supabase Auth

Core Features with Tracking

  • Build subscription display with GraphQL queries
  • Add PostHog event tracking to all billing actions
  • Implement plan upgrade/downgrade mutations
  • Set up upgrade funnel tracking in PostHog
  • Create invoice history with TanStack Table
  • Add payment method management with TanStack Form
  • Track payment method events (add, remove, fail)
  • Set up real-time subscriptions with GraphQL Yoga

UI & Layout

  • Use shadcn/ui components (Cards, Buttons, Separator) across billing page
  • Prevent layout shift with skeletons and React Suspense fallbacks
  • Align cards with responsive grid (grid grid-cols-1 md:grid-cols-2 gap-6) to avoid misalignment

A/B Testing & Optimization

  • Launch pricing display A/B test
  • Implement annual discount banner experiment
  • Set up conversion rate tracking dashboard
  • Add usage tracking with Supabase Functions
  • Create PostHog cohorts for user segmentation
  • Implement proration preview calculations
  • Create dunning process for failed payments
  • Track dunning effectiveness in PostHog

Polish & Insights

  • Analyze A/B test results and implement winners
  • Set up PostHog alerts for billing anomalies
  • Create revenue analytics dashboard
  • Implement comprehensive error handling
  • Add error tracking with PostHog context
  • Add loading states with React Suspense
  • Review session recordings for UX improvements
  • Deploy (Supabase Edge Functions + routing) with environment variables

PostHog Best Practices for Billing

Event Naming Convention

// Consistent event naming for better analysis
const eventNaming = {
// Format: [domain]_[object]_[action]
billing_page_viewed: {
/* page view */
},
billing_plan_upgraded: {
/* successful upgrade */
},
billing_invoice_downloaded: {
/* invoice action */
},
billing_payment_failed: {
/* payment issue */
},
billing_seats_modified: {
/* team changes */
},
}

Property Standards

// Standardized properties across events
interface BillingEventProperties {
// Always include
organization_id: string
user_id: string
timestamp: string

// Context properties
current_plan?: string
subscription_status?: string

// Financial properties (in cents)
revenue?: number
mrr_change?: number

// Experiment properties
experiment_name?: string
variant?: string
}

Feature Flag Strategy

// Progressive rollout configuration
const featureRollout = {
'new_billing_ui': {
rollout_percentage: 10, // Start with 10%
filters: [
{ property: 'account_type', value: 'beta_tester' }, // Always on for beta
],
payloads: {
announcement_text: 'Try our new billing experience!',
},
},

'smart_dunning': {
rollout_percentage: 50,
filters: [
{ property: 'failed_payments_count', operator: 'gt', value: 0 },
],
},
}

## Performance Monitoring with PostHog

### Revenue & Billing Analytics Dashboard
```typescript
// apps/web/src/lib/posthog-dashboards.ts
export const billingDashboards = {
// Key metrics to track
metrics: {
mrr: 'monthly_recurring_revenue',
arpu: 'average_revenue_per_user',
churnRate: 'churn_rate',
ltv: 'lifetime_value',
conversionRate: 'upgrade_conversion_rate',
},

// Custom dashboard queries
queries: {
revenueByPlan: {
event: 'upgrade_completed',
breakdown: 'plan',
math: 'sum',
math_property: 'revenue',
},

upgradeConversionFunnel: {
events: [
{ id: 'billing_page_viewed', name: 'Viewed Billing' },
{ id: 'upgrade_started', name: 'Started Upgrade' },
{ id: 'payment_method_added', name: 'Added Payment' },
{ id: 'upgrade_completed', name: 'Completed' },
],
},

failedPaymentRate: {
events: ['payment_failed', 'payment_succeeded'],
formula: 'payment_failed / (payment_failed + payment_succeeded)',
},
},
}

// PostHog Insights Component
export function BillingInsights() {
const posthog = usePostHog()

useEffect(() => {
// Set up dashboard tracking
posthog.capture('dashboard_viewed', {
dashboard: 'billing_insights',
})
}, [])

return (
<div className="grid grid-cols-2 gap-4">
<MetricCard
title="Conversion Rate"
value={conversionRate}
change={changeFromLastPeriod}
onClick={() => posthog.capture('metric_clicked', { metric: 'conversion_rate' })}
/>
{/* More metrics */}
</div>
)
}

A/B Test Results Analysis

// apps/web/src/hooks/useExperimentResults.ts
import { useQuery } from '@tanstack/react-query'
import { usePostHog } from 'posthog-js/react'

export function useExperimentResults(experimentName: string) {
const posthog = usePostHog()

return useQuery({
queryKey: ['experiment', experimentName],
queryFn: async () => {
// Fetch experiment results from PostHog API
const results = await fetch('/api/posthog/experiments', {
method: 'POST',
body: JSON.stringify({
experiment: experimentName,
metrics: ['conversion_rate', 'revenue_per_user'],
}),
}).then(r => r.json())

// Track experiment analysis
posthog.capture('experiment_analyzed', {
experiment: experimentName,
winning_variant: results.winner,
lift: results.lift,
})

return results
},
})
}

// Display experiment results in billing settings
export function BillingExperiments() {
const pricingTest = useExperimentResults('pricing_display_test')
const annualDiscountTest = useExperimentResults('annual_discount_banner')

return (
<Card>
<CardHeader>
<CardTitle>Active Billing Experiments</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{pricingTest.data && (
<ExperimentCard
name="Pricing Display Test"
status={pricingTest.data.status}
winner={pricingTest.data.winner}
lift={pricingTest.data.lift}
confidence={pricingTest.data.confidence}
/>
)}
</div>
</CardContent>
</Card>
)
}

Error Tracking Integration

// apps/web/src/lib/error-tracking.ts
import { usePostHog } from 'posthog-js/react'

export function useBillingErrorTracking() {
const posthog = usePostHog()

const trackBillingError = (error: Error, context: any) => {
// Send to PostHog for analysis
posthog.capture('billing_error', {
error_message: error.message,
error_stack: error.stack,
context,
user_id: context.userId,
organization_id: context.organizationId,
// Include billing context
subscription_status: context.subscription?.status,
last_payment_status: context.lastPayment?.status,
})

// Also log to console in dev
if (process.env.NODE_ENV === 'development') {
console.error('Billing Error:', error, context)
}
}

return { trackBillingError }
}

// Use in mutation error handling
const upgradeMutation = useMutation({
onError: (error, variables) => {
trackBillingError(error, {
action: 'upgrade_subscription',
variables,
timestamp: new Date().toISOString(),
})
},
})

User Behavior Cohorts

// apps/web/src/lib/posthog-cohorts.ts
export const billingCohorts = {
// Define cohorts for targeted features
highValueUsers: {
filters: [
{ property: 'monthly_spend', operator: 'gt', value: 500 },
{ property: 'account_age_days', operator: 'gt', value: 90 },
],
},

churnRisk: {
filters: [
{ property: 'failed_payments_count', operator: 'gt', value: 2 },
{ property: 'support_tickets_30d', operator: 'gt', value: 3 },
{ property: 'last_login_days_ago', operator: 'gt', value: 14 },
],
},

expansionReady: {
filters: [
{ property: 'seat_utilization', operator: 'gt', value: 0.8 },
{ property: 'days_until_renewal', operator: 'lt', value: 30 },
],
},
}

// Use cohorts for targeted experiences
export function useTargetedBillingFeatures() {
const posthog = usePostHog()
const [cohorts, setCohorts] = useState({})

useEffect(() => {
// Check user cohort membership
const checkCohorts = async () => {
const userCohorts = await posthog.isFeatureEnabled('premium_support')
const isHighValue = await posthog.getFeatureFlag('high_value_features')

setCohorts({
highValue: isHighValue,
showPremiumSupport: userCohorts,
})
}

checkCohorts()
}, [])

return cohorts
}

Real-time Monitoring Alerts

// apps/api/src/monitoring/billing-alerts.ts
import { createClient } from '@supabase/supabase-js'
import PostHog from 'posthog-node'

const posthog = new PostHog(process.env.POSTHOG_API_KEY!)

export async function monitorBillingHealth() {
const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_KEY!)

// Monitor failed payment rate
const { data: failedPayments } = await supabase
.from('billing_events')
.select('*')
.eq('event_type', 'payment_failed')
.gte('created_at', new Date(Date.now() - 3600000).toISOString())

if (failedPayments.length > 10) {
posthog.capture({
distinctId: 'system',
event: 'billing_alert',
properties: {
alert_type: 'high_failure_rate',
failure_count: failedPayments.length,
threshold: 10,
severity: 'high',
},
})

// Trigger incident response
await notifyOncall({
severity: 'P2',
title: 'High payment failure rate detected',
details: `${failedPayments.length} failures in last hour`,
})
}

// Monitor upgrade conversion rate
const conversionRate = await calculateConversionRate()
if (conversionRate < 0.02) {
// Less than 2%
posthog.capture({
distinctId: 'system',
event: 'billing_alert',
properties: {
alert_type: 'low_conversion',
conversion_rate: conversionRate,
threshold: 0.02,
},
})
}
}

Conclusion

This architecture leverages the modern capabilities of GraphQL Yoga for flexible API development, TanStack libraries for robust frontend state management, React 19's latest features, and Vercel's edge infrastructure for optimal performance. The integration of PostHog provides powerful A/B testing capabilities, detailed user behavior analytics, and feature flag management that enables data-driven optimization of your billing flows.

The combination of Supabase for data persistence with built-in RLS, Stripe for payment processing, and PostHog for experimentation creates a billing system that not only handles transactions securely but continuously improves through measured experiments and user insights. With PostHog's session recordings, you can identify and fix UX issues before they impact revenue, while feature flags allow safe, gradual rollouts of new billing features.

Key advantages of this stack:

  • Data-Driven Optimization: PostHog enables continuous improvement through A/B testing and cohort analysis
  • Real-Time Insights: Combine GraphQL subscriptions with PostHog events for instant visibility
  • Secure by Design: Supabase RLS and Stripe's PCI compliance ensure data protection
  • Developer Velocity: Type-safe APIs, hot reload, and excellent debugging tools
  • User Delight: Seamless self-service with personalized experiences based on user behavior

This billing system is built to scale, optimize, and delight users while providing the business intelligence needed to maximize revenue and reduce churn.