| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import { enqueueSentryCall } from '@/bootstrap/sentry-defer'; |
| import { |
| getConvexClient, |
| getConvexApi, |
| waitForConvexAuthForUser, |
| } from './convex-client'; |
| import { getCurrentClerkUser } from './clerk'; |
| import { |
| assertAccountStillCurrent, |
| isAccountStillCurrent, |
| settleAccountOperation, |
| } from './account-operation'; |
| import { extractBillingErrorKind } from './_billing-error'; |
| import type { Id } from '../../convex/_generated/dataModel'; |
|
|
| export interface SubscriptionInfo { |
| |
| |
| |
| activationKey?: string; |
| |
| |
| |
| |
| activationOnboardingEligible?: boolean; |
| planKey: string; |
| displayName: string; |
| status: 'active' | 'on_hold' | 'cancelled' | 'expired'; |
| currentPeriodEnd: number; |
| |
| |
| |
| renewalVerificationState: 'pending' | 'failed' | 'lapsed' | null; |
| } |
|
|
| |
| let currentSubscription: SubscriptionInfo | null = null; |
| let subscriptionLoaded = false; |
| const listeners = new Set<(sub: SubscriptionInfo | null) => void>(); |
| let initialized = false; |
| let unsubscribeConvex: (() => void) | null = null; |
|
|
| |
| |
| |
| |
| function normalizeCaughtError(action: string, err: unknown): Error { |
| if (err instanceof Error) return err; |
| const rendered = err === undefined ? 'undefined' : String(err); |
| const wrapped = new Error(`[billing] ${action} threw non-Error: ${rendered}`); |
| |
| |
| |
| (wrapped as Error & { cause?: unknown }).cause = err; |
| return wrapped; |
| } |
|
|
| function requireSignedInUserId(action: string): string { |
| const userId = getCurrentClerkUser()?.id; |
| if (!userId) throw new Error(`Sign in to ${action}.`); |
| return userId; |
| } |
|
|
| async function requireCurrentConvexUser( |
| userId: string, |
| action: string, |
| ): Promise<void> { |
| if (!await waitForConvexAuthForUser(userId)) { |
| throw new Error(`Account changed while ${action}. Try again.`); |
| } |
| assertAccountStillCurrent(userId, action); |
| } |
|
|
| |
| |
| |
| |
| |
| export async function initSubscriptionWatch( |
| _userId?: string, |
| isCurrent: () => boolean = () => true, |
| ): Promise<void> { |
| const isExpectedAccount = (): boolean => ( |
| isCurrent() && (_userId === undefined || getCurrentClerkUser()?.id === _userId) |
| ); |
| if (initialized || !isExpectedAccount()) return; |
|
|
| try { |
| const client = await getConvexClient(); |
| if (!client) { |
| console.warn('[billing] No VITE_CONVEX_URL -- skipping subscription watch'); |
| return; |
| } |
|
|
| const api = await getConvexApi(); |
| if (!api) { |
| console.warn('[billing] Could not load Convex API -- skipping subscription watch'); |
| return; |
| } |
| if (!isExpectedAccount()) return; |
|
|
| unsubscribeConvex = client.onUpdate( |
| api.payments.billing.getSubscriptionForUser, |
| {}, |
| (result: SubscriptionInfo | null) => { |
| if (!isExpectedAccount()) return; |
| currentSubscription = result; |
| subscriptionLoaded = true; |
| for (const cb of listeners) cb(result); |
| }, |
| (err: Error) => { |
| if (!isExpectedAccount()) return; |
| console.warn('[billing] Subscription query error:', err.message); |
| |
| currentSubscription = null; |
| subscriptionLoaded = true; |
| for (const cb of listeners) cb(null); |
| }, |
| ); |
|
|
| initialized = true; |
| } catch (err) { |
| console.error('[billing] Failed to initialize subscription watch:', err); |
| |
| const initErr = normalizeCaughtError('initSubscriptionWatch', err); |
| enqueueSentryCall((s) => s.captureException( |
| initErr, |
| { tags: { component: 'dodo-billing', action: 'initSubscriptionWatch' } }, |
| )); |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| export function onSubscriptionChange( |
| cb: (sub: SubscriptionInfo | null) => void, |
| ): () => void { |
| listeners.add(cb); |
|
|
| |
| if (subscriptionLoaded) { |
| cb(currentSubscription); |
| } |
|
|
| return () => { |
| listeners.delete(cb); |
| }; |
| } |
|
|
| |
| |
| |
| export function destroySubscriptionWatch(): void { |
| if (unsubscribeConvex) { |
| unsubscribeConvex(); |
| unsubscribeConvex = null; |
| } |
| initialized = false; |
| subscriptionLoaded = false; |
| currentSubscription = null; |
| |
| |
| } |
|
|
| |
| |
| |
| export function getSubscription(): SubscriptionInfo | null { |
| return currentSubscription; |
| } |
|
|
| export type ProActivationClaimOutcome = |
| | 'claimed' |
| | 'not_eligible' |
| | 'already_presented' |
| | 'already_claimed'; |
|
|
| |
| |
| |
| |
| export async function claimProActivationPresentation( |
| activationKey: string, |
| claimNonce: string, |
| ): Promise<ProActivationClaimOutcome> { |
| const userId = requireSignedInUserId('claim Pro activation'); |
| const client = await getConvexClient(); |
| const api = await getConvexApi(); |
| if (!client || !api) throw new Error('Convex unavailable'); |
| await requireCurrentConvexUser(userId, 'claiming Pro activation'); |
| const result = await settleAccountOperation( |
| userId, |
| 'claiming Pro activation', |
| () => client.mutation( |
| (api as any).payments.billing.claimProActivationPresentation, |
| { activationKey, claimNonce }, |
| ), |
| ) as { status: ProActivationClaimOutcome }; |
| assertAccountStillCurrent(userId, 'claiming Pro activation'); |
| return result.status; |
| } |
|
|
| |
| export async function confirmProActivationPresentation( |
| activationKey: string, |
| claimNonce: string, |
| ): Promise<boolean> { |
| const userId = requireSignedInUserId('confirm Pro activation'); |
| const client = await getConvexClient(); |
| const api = await getConvexApi(); |
| if (!client || !api) throw new Error('Convex unavailable'); |
| await requireCurrentConvexUser(userId, 'confirming Pro activation'); |
| return settleAccountOperation( |
| userId, |
| 'confirming Pro activation', |
| () => client.mutation( |
| (api as any).payments.billing.confirmProActivationPresentation, |
| { activationKey, claimNonce, outcomeTrackingVersion: 1 }, |
| ) as Promise<boolean>, |
| ); |
| } |
|
|
| export type ProActivationDay0Outcome = |
| | 'opened' |
| | 'already_recorded' |
| | 'not_eligible' |
| | 'superseded'; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export async function openProActivationDay0Presentation( |
| activationKey: string, |
| claimNonce: string, |
| sessionStartedAt: number, |
| ): Promise<ProActivationDay0Outcome> { |
| const userId = requireSignedInUserId('open Pro activation'); |
| const client = await getConvexClient(); |
| const api = await getConvexApi(); |
| if (!client || !api) throw new Error('Convex unavailable'); |
| await requireCurrentConvexUser(userId, 'opening Pro activation'); |
| const result = await settleAccountOperation( |
| userId, |
| 'opening Pro activation', |
| () => client.mutation( |
| (api as any).payments.billing.openProActivationDay0Presentation, |
| { activationKey, claimNonce, sessionStartedAt }, |
| ), |
| ) as { status: ProActivationDay0Outcome }; |
| assertAccountStillCurrent(userId, 'opening Pro activation'); |
| return result.status; |
| } |
|
|
| export type ProActivationOutcomeStepId = 'brief' | 'alerts' | 'power'; |
|
|
| export interface ProActivationOutcomeSnapshot { |
| |
| cohort?: 'day0'; |
| confirmedSteps: ProActivationOutcomeStepId[]; |
| skippedSteps: ProActivationOutcomeStepId[]; |
| |
| |
| |
| |
| |
| |
| |
| blockedSteps: ProActivationOutcomeStepId[]; |
| failedSteps: ProActivationOutcomeStepId[]; |
| revision: number; |
| finalized: boolean; |
| } |
|
|
| |
| |
| |
| |
| |
| export async function recordProActivationOutcome( |
| activationKey: string, |
| claimNonce: string, |
| outcome: ProActivationOutcomeSnapshot, |
| ): Promise<boolean> { |
| const userId = requireSignedInUserId('record Pro activation'); |
| const client = await getConvexClient(); |
| const api = await getConvexApi(); |
| if (!client || !api) throw new Error('Convex unavailable'); |
| await requireCurrentConvexUser(userId, 'recording Pro activation'); |
| return settleAccountOperation( |
| userId, |
| 'recording Pro activation', |
| () => client.mutation( |
| (api as any).payments.billing.recordProActivationOutcome, |
| { activationKey, claimNonce, ...outcome }, |
| ) as Promise<boolean>, |
| ); |
| } |
|
|
| const DODO_PORTAL_FALLBACK_URL = 'https://customer.dodopayments.com'; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export function prereserveBillingPortalTab(): Window | null { |
| return window.open('', '_blank', 'noopener,noreferrer'); |
| } |
|
|
| export type OpenBillingPortalOutcome = |
| | { outcome: 'opened'; url: string } |
| | { outcome: 'no-customer' } |
| | { outcome: 'account-changed' }; |
|
|
| export async function openBillingPortal( |
| preopened?: Window | null, |
| ): Promise<OpenBillingPortalOutcome> { |
| const reservedWin = preopened ?? null; |
| const navigate = (url: string): { outcome: 'opened'; url: string } => { |
| if (reservedWin && !reservedWin.closed) { |
| reservedWin.location.href = url; |
| } else { |
| const fresh = window.open(url, '_blank', 'noopener,noreferrer'); |
| if (!fresh) window.location.assign(url); |
| } |
| return { outcome: 'opened', url }; |
| }; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const closeReserved = (): void => { |
| if (reservedWin && !reservedWin.closed) reservedWin.close(); |
| }; |
|
|
| const userId = getCurrentClerkUser()?.id; |
| if (!userId) return navigate(DODO_PORTAL_FALLBACK_URL); |
|
|
| try { |
| const client = await getConvexClient(); |
| assertAccountStillCurrent(userId, 'opening the billing portal'); |
| if (!client) { |
| return navigate(DODO_PORTAL_FALLBACK_URL); |
| } |
|
|
| const api = await getConvexApi(); |
| assertAccountStillCurrent(userId, 'opening the billing portal'); |
| if (!api) { |
| return navigate(DODO_PORTAL_FALLBACK_URL); |
| } |
|
|
| await requireCurrentConvexUser(userId, 'opening the billing portal'); |
| const result = await settleAccountOperation( |
| userId, |
| 'opening the billing portal', |
| () => client.action(api.payments.billing.getCustomerPortalUrl, {}), |
| ); |
| assertAccountStillCurrent(userId, 'opening the billing portal'); |
| const url = (result?.portal_url as string | undefined) ?? DODO_PORTAL_FALLBACK_URL; |
| return navigate(url); |
| } catch (err) { |
| if (!isAccountStillCurrent(userId)) { |
| closeReserved(); |
| return { outcome: 'account-changed' }; |
| } |
| |
| |
| |
| |
| |
| |
| |
| |
| const kind = extractBillingErrorKind(err); |
| const isNoCustomer = kind === 'NO_CUSTOMER'; |
| const level: 'warning' | 'error' = isNoCustomer ? 'warning' : 'error'; |
| const log = level === 'warning' ? console.warn : console.error; |
| log('[billing] Failed to get customer portal URL:', err); |
| const portalErr = normalizeCaughtError('openBillingPortal', err); |
| const portalTags = { |
| component: 'dodo-billing', |
| action: 'openBillingPortal', |
| ...(kind ? { billing_error_kind: kind } : {}), |
| }; |
| enqueueSentryCall((s) => s.captureException(portalErr, { tags: portalTags, level })); |
| if (isNoCustomer) { |
| closeReserved(); |
| return { outcome: 'no-customer' }; |
| } |
| return navigate(DODO_PORTAL_FALLBACK_URL); |
| } |
| } |
|
|
| |
| |
| |
|
|
| export interface BusinessSeat { |
| grantId: string; |
| inviteeEmail: string; |
| status: 'pending' | 'accepted' | 'revoked' | 'expired'; |
| createdAt: number; |
| acceptedAt: number | null; |
| expiresAt: number; |
| } |
|
|
| export interface ListBusinessSeatsResult { |
| businessSubscriptionId: string | null; |
| ownerDomain: string | null; |
| ownerIsCorporateDomain: boolean; |
| seats: BusinessSeat[]; |
| } |
|
|
| |
| export async function listBusinessSeats(): Promise<ListBusinessSeatsResult> { |
| const userId = getCurrentClerkUser()?.id; |
| if (!userId) { |
| return { businessSubscriptionId: null, ownerDomain: null, ownerIsCorporateDomain: false, seats: [] }; |
| } |
| const client = await getConvexClient(); |
| const api = await getConvexApi(); |
| if (!client || !api) { |
| return { businessSubscriptionId: null, ownerDomain: null, ownerIsCorporateDomain: false, seats: [] }; |
| } |
| if (!await waitForConvexAuthForUser(userId)) { |
| assertAccountStillCurrent(userId, 'loading Business Pro seats'); |
| throw new Error('Authentication unavailable while loading Business Pro seats. Try again.'); |
| } |
| return settleAccountOperation( |
| userId, |
| 'loading Business Pro seats', |
| () => client.query(api.payments.businessSeats.listSeats, {}), |
| ); |
| } |
|
|
| |
| export async function inviteBusinessSeats(emails: string[]): Promise<{ |
| invited: Array<{ email: string; grantId: string; status: 'created' | 'already_pending' | 'already_accepted' }>; |
| }> { |
| const userId = requireSignedInUserId('invite Business Pro seats'); |
| const client = await getConvexClient(); |
| const api = await getConvexApi(); |
| if (!client || !api) throw new Error('Convex unavailable'); |
| await requireCurrentConvexUser(userId, 'inviting Business Pro seats'); |
| return settleAccountOperation( |
| userId, |
| 'inviting Business Pro seats', |
| () => client.mutation(api.payments.businessSeats.inviteSeats, { emails }), |
| ); |
| } |
|
|
| |
| export async function removeBusinessSeat( |
| grantId: string, |
| ): Promise<{ ok: true; status: 'revoked' | 'already_inactive' }> { |
| const userId = requireSignedInUserId('remove a Business Pro seat'); |
| const client = await getConvexClient(); |
| const api = await getConvexApi(); |
| if (!client || !api) throw new Error('Convex unavailable'); |
| await requireCurrentConvexUser(userId, 'removing a Business Pro seat'); |
| return settleAccountOperation( |
| userId, |
| 'removing a Business Pro seat', |
| () => client.mutation( |
| api.payments.businessSeats.removeSeat, |
| { grantId: grantId as Id<'businessProGrants'> }, |
| ), |
| ); |
| } |
|
|
| |
| export async function acceptBusinessInvite(grantId: string, token: string): Promise<void> { |
| const userId = requireSignedInUserId('accept a Business Pro seat invite'); |
| const client = await getConvexClient(); |
| const api = await getConvexApi(); |
| if (!client || !api) throw new Error('Convex unavailable'); |
| await requireCurrentConvexUser(userId, 'accepting a Business Pro seat invite'); |
| await settleAccountOperation( |
| userId, |
| 'accepting a Business Pro seat invite', |
| () => client.mutation( |
| api.payments.businessSeats.acceptBusinessInvite, |
| { grantId: grantId as Id<'businessProGrants'>, token }, |
| ), |
| ); |
| } |
|
|