Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 40 additions & 1 deletion apps/dashboard/src/actions/billing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ import { db } from '@/lib/db'
import { organization, projects, flags, member, evaluations, eq, and, sql } from '@switchflag/db'
import { requireSession } from '@/lib/auth-server'
import { getStripe, getStripePriceIds } from '@/lib/stripe'
import { parseOrgBilling, type PlanId, type OrganizationBilling } from '@switchflag/shared'
import { parseOrgBilling, PLANS, type PlanId, type OrganizationBilling } from '@switchflag/shared'
import { z } from 'zod'
import { env } from '@/env'
import { sendWaitlistNotificationEmail } from '@/lib/email'
import type { ActionResult } from './types'

const checkoutSchema = z.object({
Expand Down Expand Up @@ -63,6 +64,44 @@ export async function createCheckoutSession(
return { success: true, data: { url: checkoutSession.url } }
}

export async function joinWaitlist(planId: string, billingInterval: string): Promise<ActionResult> {
const session = await requireSession()
const organizationId = session.session.activeOrganizationId

if (!organizationId) {
return { success: false, error: 'No active organization' }
}

const parsed = checkoutSchema.safeParse({ planId, billingInterval })
if (!parsed.success) {
return { success: false, error: 'Invalid plan or billing interval' }
}

const org = await db.query.organization.findFirst({
where: eq(organization.id, organizationId),
})

if (!org) {
return { success: false, error: 'Organization not found' }
}

const plan = PLANS[parsed.data.planId]

try {
await sendWaitlistNotificationEmail({
userName: session.user.name,
userEmail: session.user.email,
organizationName: org.name,
planName: plan.name,
billingInterval: parsed.data.billingInterval,
})
} catch {
return { success: false, error: 'Failed to submit waitlist request' }
}

return { success: true, data: undefined }
}

export async function createPortalSession(): Promise<ActionResult<{ url: string }>> {
const session = await requireSession()
const organizationId = session.session.activeOrganizationId
Expand Down
150 changes: 150 additions & 0 deletions apps/dashboard/src/components/billing/billing-page-client.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach } from 'vitest'

const mockCreateCheckoutSession = vi.fn()
const mockCreatePortalSession = vi.fn()
const mockJoinWaitlist = vi.fn()

vi.mock('@/actions/billing', () => ({
createCheckoutSession: (...args: unknown[]) => mockCreateCheckoutSession(...args),
createPortalSession: (...args: unknown[]) => mockCreatePortalSession(...args),
joinWaitlist: (...args: unknown[]) => mockJoinWaitlist(...args),
}))

vi.mock('next/navigation', () => ({
useSearchParams: () => ({
get: vi.fn(() => null),
}),
}))

let mockBillingEnabled: string | undefined

vi.mock('@/env', () => ({
env: {
get NEXT_PUBLIC_BILLING_ENABLED() {
return mockBillingEnabled
},
},
}))

import { BillingPageClient } from './billing-page-client'
import type { SubscriptionInfo } from '@/actions/billing'

const defaultSubscription: SubscriptionInfo = {
plan: 'developer',
billing: { plan: 'developer' },
usage: { flags: 5, requests: 1000, members: 2 },
}

describe('BillingPageClient', () => {
beforeEach(() => {
vi.clearAllMocks()
mockBillingEnabled = undefined
})

describe('when billing is disabled (waitlist mode)', () => {
beforeEach(() => {
mockBillingEnabled = undefined
})

it('renders Join Waitlist buttons for paid plans', () => {
render(<BillingPageClient subscription={defaultSubscription} />)
const buttons = screen.getAllByRole('button', { name: 'Join Waitlist' })
expect(buttons).toHaveLength(2)
})

it('calls joinWaitlist server action when Join Waitlist is clicked', async () => {
mockJoinWaitlist.mockResolvedValue({ success: true, data: undefined })
render(<BillingPageClient subscription={defaultSubscription} />)

const buttons = screen.getAllByRole('button', { name: 'Join Waitlist' })
fireEvent.click(buttons[0]!)

await waitFor(() => {
expect(mockJoinWaitlist).toHaveBeenCalledWith('startup', 'monthly')
})
})

it('shows success banner after joining waitlist', async () => {
mockJoinWaitlist.mockResolvedValue({ success: true, data: undefined })
render(<BillingPageClient subscription={defaultSubscription} />)

const buttons = screen.getAllByRole('button', { name: 'Join Waitlist' })
fireEvent.click(buttons[0]!)

await waitFor(() => {
expect(
screen.getByText("Thanks! We'll notify you when paid plans are available.")
).toBeInTheDocument()
})
})

it('shows "You\'re on the list!" for submitted plan', async () => {
mockJoinWaitlist.mockResolvedValue({ success: true, data: undefined })
render(<BillingPageClient subscription={defaultSubscription} />)

const buttons = screen.getAllByRole('button', { name: 'Join Waitlist' })
fireEvent.click(buttons[0]!)

await waitFor(() => {
expect(screen.getByRole('button', { name: "You're on the list!" })).toBeDisabled()
})
})

it('does not call createCheckoutSession', async () => {
mockJoinWaitlist.mockResolvedValue({ success: true, data: undefined })
render(<BillingPageClient subscription={defaultSubscription} />)

const buttons = screen.getAllByRole('button', { name: 'Join Waitlist' })
fireEvent.click(buttons[0]!)

await waitFor(() => {
expect(mockJoinWaitlist).toHaveBeenCalled()
})
expect(mockCreateCheckoutSession).not.toHaveBeenCalled()
})
})

describe('when billing is enabled (Stripe mode)', () => {
beforeEach(() => {
mockBillingEnabled = 'true'
})

it('renders Upgrade buttons for paid plans', () => {
render(<BillingPageClient subscription={defaultSubscription} />)
const buttons = screen.getAllByRole('button', { name: 'Upgrade' })
expect(buttons).toHaveLength(2)
})

it('calls createCheckoutSession when Upgrade is clicked', async () => {
mockCreateCheckoutSession.mockResolvedValue({
success: true,
data: { url: 'https://checkout.stripe.com/test' },
})
render(<BillingPageClient subscription={defaultSubscription} />)

const buttons = screen.getAllByRole('button', { name: 'Upgrade' })
fireEvent.click(buttons[0]!)

await waitFor(() => {
expect(mockCreateCheckoutSession).toHaveBeenCalledWith('startup', 'monthly')
})
})

it('does not call joinWaitlist', async () => {
mockCreateCheckoutSession.mockResolvedValue({
success: true,
data: { url: 'https://checkout.stripe.com/test' },
})
render(<BillingPageClient subscription={defaultSubscription} />)

const buttons = screen.getAllByRole('button', { name: 'Upgrade' })
fireEvent.click(buttons[0]!)

await waitFor(() => {
expect(mockCreateCheckoutSession).toHaveBeenCalled()
})
expect(mockJoinWaitlist).not.toHaveBeenCalled()
})
})
})
35 changes: 31 additions & 4 deletions apps/dashboard/src/components/billing/billing-page-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@
import { useState } from 'react'
import { useSearchParams } from 'next/navigation'
import { PLANS, getPlanLimits, type PlanId } from '@switchflag/shared'
import { createCheckoutSession, createPortalSession } from '@/actions/billing'
import { createCheckoutSession, createPortalSession, joinWaitlist } from '@/actions/billing'
import type { SubscriptionInfo } from '@/actions/billing'
import { env } from '@/env'
import { UsageCard } from './usage-card'
import { PlanCard } from './plan-card'

Expand Down Expand Up @@ -38,7 +39,10 @@ export function BillingPageClient({ subscription }: BillingPageClientProps) {
const searchParams = useSearchParams()
const [billingInterval, setBillingInterval] = useState<'monthly' | 'yearly'>('monthly')
const [isLoading, setIsLoading] = useState(false)
const [waitlistedPlans, setWaitlistedPlans] = useState<Set<PlanId>>(new Set())
const [showWaitlistSuccess, setShowWaitlistSuccess] = useState(false)

const billingEnabled = env.NEXT_PUBLIC_BILLING_ENABLED === 'true'
const showSuccess = searchParams.get('success') === 'true'
const showCanceled = searchParams.get('canceled') === 'true'

Expand All @@ -48,15 +52,29 @@ export function BillingPageClient({ subscription }: BillingPageClientProps) {
async function handleSelectPlan(planId: PlanId) {
setIsLoading(true)
try {
const result = await createCheckoutSession(planId, billingInterval)
if (result.success) {
window.location.href = result.data.url
if (billingEnabled) {
await handleCheckout(planId)
} else {
await handleWaitlist(planId)
}
} finally {
setIsLoading(false)
}
}

async function handleCheckout(planId: PlanId) {
const result = await createCheckoutSession(planId, billingInterval)
if (!result.success) return
window.location.href = result.data.url
}

async function handleWaitlist(planId: PlanId) {
const result = await joinWaitlist(planId, billingInterval)
if (!result.success) return
setWaitlistedPlans((prev) => new Set(prev).add(planId))
setShowWaitlistSuccess(true)
}

async function handleManageBilling() {
setIsLoading(true)
try {
Expand Down Expand Up @@ -84,6 +102,13 @@ export function BillingPageClient({ subscription }: BillingPageClientProps) {
<p className="text-sm font-medium text-yellow-400">Checkout was canceled.</p>
</div>
)}
{showWaitlistSuccess && (
<div className="rounded-xl border border-green-500/20 bg-green-500/10 px-6 py-4">
<p className="text-sm font-medium text-green-400">
Thanks! We'll notify you when paid plans are available.
</p>
</div>
)}

{/* Page Header */}
<div>
Expand Down Expand Up @@ -175,6 +200,8 @@ export function BillingPageClient({ subscription }: BillingPageClientProps) {
billingInterval={billingInterval}
onSelect={handleSelectPlan}
isLoading={isLoading}
waitlistMode={!billingEnabled}
onWaitlist={waitlistedPlans.has(plan.id)}
/>
))}
</div>
Expand Down
70 changes: 70 additions & 0 deletions apps/dashboard/src/components/billing/plan-card.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { render, screen, fireEvent } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import { PlanCard } from './plan-card'
import { PLANS } from '@switchflag/shared'

const startupPlan = PLANS.startup
const defaultProps = {
plan: startupPlan,
currentPlan: 'developer' as const,
billingInterval: 'monthly' as const,
onSelect: vi.fn(),
isLoading: false,
}

describe('PlanCard', () => {
describe('default mode (no waitlist)', () => {
it('renders Upgrade button for upgradable plan', () => {
render(<PlanCard {...defaultProps} />)
expect(screen.getByRole('button', { name: 'Upgrade' })).toBeInTheDocument()
})

it('calls onSelect when Upgrade is clicked', () => {
const onSelect = vi.fn()
render(<PlanCard {...defaultProps} onSelect={onSelect} />)
fireEvent.click(screen.getByRole('button', { name: 'Upgrade' }))
expect(onSelect).toHaveBeenCalledWith('startup')
})
})

describe('waitlist mode', () => {
it('renders Join Waitlist button when waitlistMode is true', () => {
render(<PlanCard {...defaultProps} waitlistMode onWaitlist={false} />)
expect(screen.getByRole('button', { name: 'Join Waitlist' })).toBeInTheDocument()
})

it('calls onSelect when Join Waitlist is clicked', () => {
const onSelect = vi.fn()
render(<PlanCard {...defaultProps} onSelect={onSelect} waitlistMode onWaitlist={false} />)
fireEvent.click(screen.getByRole('button', { name: 'Join Waitlist' }))
expect(onSelect).toHaveBeenCalledWith('startup')
})

it('renders disabled "You\'re on the list!" button when onWaitlist is true', () => {
render(<PlanCard {...defaultProps} waitlistMode onWaitlist />)
const button = screen.getByRole('button', { name: "You're on the list!" })
expect(button).toBeInTheDocument()
expect(button).toBeDisabled()
})

it('does not render Join Waitlist when plan is current', () => {
render(<PlanCard {...defaultProps} currentPlan="startup" waitlistMode onWaitlist={false} />)
expect(screen.getByRole('button', { name: 'Current Plan' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Join Waitlist' })).not.toBeInTheDocument()
})

it('does not render Join Waitlist for developer (free) plan', () => {
render(
<PlanCard
{...defaultProps}
plan={PLANS.developer}
currentPlan="startup"
waitlistMode
onWaitlist={false}
/>
)
expect(screen.getByRole('button', { name: 'Free Plan' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Join Waitlist' })).not.toBeInTheDocument()
})
})
})
13 changes: 13 additions & 0 deletions apps/dashboard/src/components/billing/plan-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ interface PlanCardProps {
readonly billingInterval: 'monthly' | 'yearly'
readonly onSelect: (planId: PlanId) => void
readonly isLoading: boolean
readonly waitlistMode?: boolean
readonly onWaitlist?: boolean
}

function CheckIcon() {
Expand All @@ -30,6 +32,8 @@ export function PlanCard({
billingInterval,
onSelect,
isLoading,
waitlistMode,
onWaitlist,
}: PlanCardProps) {
const isCurrent = plan.id === currentPlan
const price = billingInterval === 'yearly' ? plan.yearlyPrice : plan.monthlyPrice
Expand Down Expand Up @@ -83,6 +87,13 @@ export function PlanCard({
>
Free Plan
</button>
) : waitlistMode && onWaitlist ? (
<button
disabled
className="block w-full cursor-default rounded-xl bg-green-600/20 px-4 py-2.5 text-center text-sm font-bold text-green-400"
>
You're on the list!
</button>
) : (
<button
onClick={() => onSelect(plan.id)}
Expand All @@ -97,6 +108,8 @@ export function PlanCard({
<span className="flex items-center justify-center gap-2">
<Spinner size="sm" /> Processing...
</span>
) : waitlistMode ? (
'Join Waitlist'
) : isUpgrade ? (
'Upgrade'
) : (
Expand Down
Loading
Loading