Feat/workspaces 6 billing (#8508)

## Summary

Implements billing infrastructure for team workspaces, separate from
legacy personal billing.

## Changes

- **Billing abstraction**: New `useBillingContext` composable that
switches between legacy (personal) and workspace billing based on
context
- **Workspace subscription flows**: Pricing tables, plan transitions,
cancellation dialogs, and payment preview components for workspace
billing
- **Top-up credits**: Workspace-specific top-up dialog with polling for
payment confirmation
- **Workspace API**: Extended with billing endpoints (subscriptions,
invoices, payment methods, credits top-up)
- **Workspace switcher**: Now displays tier badges for each workspace
- **Subscribe polling**: Added polling mechanisms
(`useSubscribePolling`, `useTopupPolling`) for async payment flows

## Review Focus

- Billing flow correctness for workspace vs legacy contexts
- Polling timeout and error handling in payment flows

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8508-Feat-workspaces-6-billing-2f96d73d365081f69f65c1ddf369010d)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Simula_r
2026-02-06 20:52:53 -08:00
committed by GitHub
parent 030d4fd4d5
commit c5431de123
54 changed files with 4861 additions and 568 deletions

View File

@@ -15,6 +15,7 @@ interface Workspace {
export interface WorkspaceWithRole extends Workspace {
role: WorkspaceRole
subscription_tier?: SubscriptionTier
}
export interface Member {
@@ -62,14 +63,6 @@ interface AcceptInviteResponse {
workspace_name: string
}
interface BillingPortalRequest {
return_url: string
}
interface BillingPortalResponse {
billing_portal_url: string
}
interface CreateWorkspacePayload {
name: string
}
@@ -82,6 +75,206 @@ interface ListWorkspacesResponse {
workspaces: WorkspaceWithRole[]
}
export type SubscriptionTier =
| 'STANDARD'
| 'CREATOR'
| 'PRO'
| 'FOUNDERS_EDITION'
export type SubscriptionDuration = 'MONTHLY' | 'ANNUAL'
type PlanAvailabilityReason =
| 'same_plan'
| 'incompatible_transition'
| 'requires_team'
| 'requires_personal'
| 'exceeds_max_seats'
interface PlanAvailability {
available: boolean
reason?: PlanAvailabilityReason
}
interface PlanSeatSummary {
seat_count: number
total_cost_cents: number
total_credits_cents: number
}
export interface Plan {
slug: string
tier: SubscriptionTier
duration: SubscriptionDuration
price_cents: number
credits_cents: number
max_seats: number
availability: PlanAvailability
seat_summary: PlanSeatSummary
}
interface BillingPlansResponse {
current_plan_slug?: string
plans: Plan[]
}
type SubscriptionTransitionType =
| 'new_subscription'
| 'upgrade'
| 'downgrade'
| 'duration_change'
interface PreviewSubscribeRequest {
plan_slug: string
}
interface SubscribeRequest {
plan_slug: string
idempotency_key?: string
return_url?: string
cancel_url?: string
}
type SubscribeStatus = 'subscribed' | 'needs_payment_method' | 'pending_payment'
export interface SubscribeResponse {
billing_op_id: string
status: SubscribeStatus
effective_at?: string
payment_method_url?: string
}
interface CancelSubscriptionRequest {
idempotency_key?: string
}
interface CancelSubscriptionResponse {
billing_op_id: string
cancel_at: string
}
interface ResubscribeRequest {
idempotency_key?: string
}
interface ResubscribeResponse {
billing_op_id: string
status: 'active'
message?: string
}
interface PaymentPortalRequest {
return_url?: string
}
interface PaymentPortalResponse {
url: string
}
interface PreviewPlanInfo {
slug: string
tier: SubscriptionTier
duration: SubscriptionDuration
price_cents: number
credits_cents: number
seat_summary: PlanSeatSummary
period_start?: string
period_end?: string
}
export interface PreviewSubscribeResponse {
allowed: boolean
reason?: string
transition_type: SubscriptionTransitionType
effective_at: string
is_immediate: boolean
cost_today_cents: number
cost_next_period_cents: number
credits_today_cents: number
credits_next_period_cents: number
current_plan?: PreviewPlanInfo
new_plan: PreviewPlanInfo
}
type BillingSubscriptionStatus = 'active' | 'scheduled' | 'ended' | 'canceled'
type BillingStatus =
| 'awaiting_payment_method'
| 'pending_payment'
| 'paid'
| 'payment_failed'
| 'inactive'
export interface BillingStatusResponse {
is_active: boolean
subscription_status?: BillingSubscriptionStatus
subscription_tier?: SubscriptionTier
subscription_duration?: SubscriptionDuration
plan_slug?: string
billing_status?: BillingStatus
has_funds: boolean
cancel_at?: string
}
export interface BillingBalanceResponse {
amount_micros: number
prepaid_balance_micros?: number
cloud_credit_balance_micros?: number
pending_charges_micros?: number
effective_balance_micros?: number
currency: string
}
interface CreateTopupRequest {
amount_cents: number
idempotency_key?: string
}
type TopupStatus = 'pending' | 'completed' | 'failed'
interface CreateTopupResponse {
billing_op_id: string
topup_id: string
status: TopupStatus
amount_cents: number
}
interface TopupStatusResponse {
topup_id: string
status: TopupStatus
amount_cents: number
error_message?: string
created_at: string
completed_at?: string
}
type BillingOpStatus = 'pending' | 'succeeded' | 'failed'
export interface BillingOpStatusResponse {
id: string
status: BillingOpStatus
error_message?: string
started_at: string
completed_at?: string
}
interface BillingEvent {
event_type: string
event_id: string
params?: Record<string, unknown>
createdAt: string
}
interface BillingEventsResponse {
total: number
events: BillingEvent[]
page: number
limit: number
totalPages: number
}
interface GetBillingEventsParams {
page?: number
limit?: number
}
class WorkspaceApiError extends Error {
constructor(
message: string,
@@ -326,19 +519,230 @@ export const workspaceApi = {
},
/**
* Access the billing portal for the current workspace.
* POST /api/billing/portal
* Get billing status for the current workspace
* GET /api/billing/status
*/
async accessBillingPortal(
returnUrl?: string
): Promise<BillingPortalResponse> {
async getBillingStatus(): Promise<BillingStatusResponse> {
const headers = await getAuthHeaderOrThrow()
try {
const response = await workspaceApiClient.post<BillingPortalResponse>(
api.apiURL('/billing/portal'),
const response = await workspaceApiClient.get<BillingStatusResponse>(
api.apiURL('/billing/status'),
{ headers }
)
return response.data
} catch (err) {
handleAxiosError(err)
}
},
/**
* Get credit balance for the current workspace
* GET /api/billing/balance
*/
async getBillingBalance(): Promise<BillingBalanceResponse> {
const headers = await getAuthHeaderOrThrow()
try {
const response = await workspaceApiClient.get<BillingBalanceResponse>(
api.apiURL('/billing/balance'),
{ headers }
)
return response.data
} catch (err) {
handleAxiosError(err)
}
},
/**
* Get available subscription plans
* GET /api/billing/plans
*/
async getBillingPlans(): Promise<BillingPlansResponse> {
const headers = await getAuthHeaderOrThrow()
try {
const response = await workspaceApiClient.get<BillingPlansResponse>(
api.apiURL('/billing/plans'),
{ headers }
)
return response.data
} catch (err) {
handleAxiosError(err)
}
},
/**
* Preview subscription change
* POST /api/billing/preview-subscribe
*/
async previewSubscribe(planSlug: string): Promise<PreviewSubscribeResponse> {
const headers = await getAuthHeaderOrThrow()
try {
const response = await workspaceApiClient.post<PreviewSubscribeResponse>(
api.apiURL('/billing/preview-subscribe'),
{ plan_slug: planSlug } satisfies PreviewSubscribeRequest,
{ headers }
)
return response.data
} catch (err) {
handleAxiosError(err)
}
},
/**
* Subscribe to a billing plan
* POST /api/billing/subscribe
*/
async subscribe(
planSlug: string,
returnUrl?: string,
cancelUrl?: string
): Promise<SubscribeResponse> {
const headers = await getAuthHeaderOrThrow()
try {
const response = await workspaceApiClient.post<SubscribeResponse>(
api.apiURL('/billing/subscribe'),
{
return_url: returnUrl ?? window.location.href
} satisfies BillingPortalRequest,
plan_slug: planSlug,
return_url: returnUrl,
cancel_url: cancelUrl
} satisfies SubscribeRequest,
{ headers }
)
return response.data
} catch (err) {
handleAxiosError(err)
}
},
/**
* Cancel current subscription
* POST /api/billing/subscription/cancel
*/
async cancelSubscription(
idempotencyKey?: string
): Promise<CancelSubscriptionResponse> {
const headers = await getAuthHeaderOrThrow()
try {
const response =
await workspaceApiClient.post<CancelSubscriptionResponse>(
api.apiURL('/billing/subscription/cancel'),
{
idempotency_key: idempotencyKey
} satisfies CancelSubscriptionRequest,
{ headers }
)
return response.data
} catch (err) {
handleAxiosError(err)
}
},
/**
* Resubscribe (undo cancel) before period ends
* POST /api/billing/subscription/resubscribe
*/
async resubscribe(idempotencyKey?: string): Promise<ResubscribeResponse> {
const headers = await getAuthHeaderOrThrow()
try {
const response = await workspaceApiClient.post<ResubscribeResponse>(
api.apiURL('/billing/subscription/resubscribe'),
{ idempotency_key: idempotencyKey } satisfies ResubscribeRequest,
{ headers }
)
return response.data
} catch (err) {
handleAxiosError(err)
}
},
/**
* Get Stripe payment portal URL for managing payment methods
* POST /api/billing/payment-portal
*/
async getPaymentPortalUrl(
returnUrl?: string
): Promise<PaymentPortalResponse> {
const headers = await getAuthHeaderOrThrow()
try {
const response = await workspaceApiClient.post<PaymentPortalResponse>(
api.apiURL('/billing/payment-portal'),
{ return_url: returnUrl } satisfies PaymentPortalRequest,
{ headers }
)
return response.data
} catch (err) {
handleAxiosError(err)
}
},
/**
* Create a credit top-up
* POST /api/billing/topup
*/
async createTopup(
amountCents: number,
idempotencyKey?: string
): Promise<CreateTopupResponse> {
const headers = await getAuthHeaderOrThrow()
try {
const response = await workspaceApiClient.post<CreateTopupResponse>(
api.apiURL('/billing/topup'),
{
amount_cents: amountCents,
idempotency_key: idempotencyKey
} satisfies CreateTopupRequest,
{ headers }
)
return response.data
} catch (err) {
handleAxiosError(err)
}
},
/**
* Get top-up status
* GET /api/billing/topup/:id
*/
async getTopupStatus(topupId: string): Promise<TopupStatusResponse> {
const headers = await getAuthHeaderOrThrow()
try {
const response = await workspaceApiClient.get<TopupStatusResponse>(
api.apiURL(`/billing/topup/${topupId}`),
{ headers }
)
return response.data
} catch (err) {
handleAxiosError(err)
}
},
/**
* Get billing events
* GET /api/billing/events
*/
async getBillingEvents(
params?: GetBillingEventsParams
): Promise<BillingEventsResponse> {
const headers = await getAuthHeaderOrThrow()
try {
const response = await workspaceApiClient.get<BillingEventsResponse>(
api.apiURL('/billing/events'),
{ headers, params }
)
return response.data
} catch (err) {
handleAxiosError(err)
}
},
/**
* Get billing operation status
* GET /api/billing/ops/:id
*/
async getBillingOpStatus(opId: string): Promise<BillingOpStatusResponse> {
const headers = await getAuthHeaderOrThrow()
try {
const response = await workspaceApiClient.get<BillingOpStatusResponse>(
api.apiURL(`/billing/ops/${opId}`),
{ headers }
)
return response.data

View File

@@ -10,6 +10,7 @@ import type {
ListMembersParams,
Member,
PendingInvite as ApiPendingInvite,
SubscriptionTier,
WorkspaceWithRole
} from '../api/workspaceApi'
import { workspaceApi } from '../api/workspaceApi'
@@ -30,11 +31,12 @@ export interface PendingInvite {
expiryDate: Date
}
type SubscriptionPlan = 'PRO_MONTHLY' | 'PRO_YEARLY' | null
type SubscriptionPlan = string | null
interface WorkspaceState extends WorkspaceWithRole {
isSubscribed: boolean
subscriptionPlan: SubscriptionPlan
subscriptionTier: SubscriptionTier | null
members: WorkspaceMember[]
pendingInvites: PendingInvite[]
}
@@ -65,8 +67,10 @@ function createWorkspaceState(workspace: WorkspaceWithRole): WorkspaceState {
return {
...workspace,
// Personal workspaces use user-scoped subscription from useSubscription()
isSubscribed: workspace.type === 'personal',
isSubscribed:
workspace.type === 'personal' || !!workspace.subscription_tier,
subscriptionPlan: null,
subscriptionTier: workspace.subscription_tier ?? null,
members: [],
pendingInvites: []
}
@@ -561,10 +565,6 @@ export const useTeamWorkspaceStore = defineStore('teamWorkspace', () => {
}
}
// ════════════════════════════════════════════════════════════
// INVITE LINK HELPERS
// ════════════════════════════════════════════════════════════
function buildInviteLink(token: string): string {
const baseUrl = window.location.origin
return `${baseUrl}?invite=${encodeURIComponent(token)}`
@@ -671,6 +671,7 @@ export const useTeamWorkspaceStore = defineStore('teamWorkspace', () => {
copyInviteLink,
// Subscription
subscribeWorkspace
subscribeWorkspace,
updateActiveWorkspace
}
})