diff --git a/apps/dashboard/src/actions/billing.ts b/apps/dashboard/src/actions/billing.ts index 9ac018e..05020fc 100644 --- a/apps/dashboard/src/actions/billing.ts +++ b/apps/dashboard/src/actions/billing.ts @@ -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({ @@ -63,6 +64,44 @@ export async function createCheckoutSession( return { success: true, data: { url: checkoutSession.url } } } +export async function joinWaitlist(planId: string, billingInterval: string): Promise { + 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> { const session = await requireSession() const organizationId = session.session.activeOrganizationId diff --git a/apps/dashboard/src/components/billing/billing-page-client.test.tsx b/apps/dashboard/src/components/billing/billing-page-client.test.tsx new file mode 100644 index 0000000..ed4f20a --- /dev/null +++ b/apps/dashboard/src/components/billing/billing-page-client.test.tsx @@ -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() + 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() + + 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() + + 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() + + 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() + + 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() + 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() + + 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() + + const buttons = screen.getAllByRole('button', { name: 'Upgrade' }) + fireEvent.click(buttons[0]!) + + await waitFor(() => { + expect(mockCreateCheckoutSession).toHaveBeenCalled() + }) + expect(mockJoinWaitlist).not.toHaveBeenCalled() + }) + }) +}) diff --git a/apps/dashboard/src/components/billing/billing-page-client.tsx b/apps/dashboard/src/components/billing/billing-page-client.tsx index c5ba29c..cb3e815 100644 --- a/apps/dashboard/src/components/billing/billing-page-client.tsx +++ b/apps/dashboard/src/components/billing/billing-page-client.tsx @@ -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' @@ -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>(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' @@ -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 { @@ -84,6 +102,13 @@ export function BillingPageClient({ subscription }: BillingPageClientProps) {

Checkout was canceled.

)} + {showWaitlistSuccess && ( +
+

+ Thanks! We'll notify you when paid plans are available. +

+
+ )} {/* Page Header */}
@@ -175,6 +200,8 @@ export function BillingPageClient({ subscription }: BillingPageClientProps) { billingInterval={billingInterval} onSelect={handleSelectPlan} isLoading={isLoading} + waitlistMode={!billingEnabled} + onWaitlist={waitlistedPlans.has(plan.id)} /> ))}
diff --git a/apps/dashboard/src/components/billing/plan-card.test.tsx b/apps/dashboard/src/components/billing/plan-card.test.tsx new file mode 100644 index 0000000..ae8eb56 --- /dev/null +++ b/apps/dashboard/src/components/billing/plan-card.test.tsx @@ -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() + expect(screen.getByRole('button', { name: 'Upgrade' })).toBeInTheDocument() + }) + + it('calls onSelect when Upgrade is clicked', () => { + const onSelect = vi.fn() + render() + fireEvent.click(screen.getByRole('button', { name: 'Upgrade' })) + expect(onSelect).toHaveBeenCalledWith('startup') + }) + }) + + describe('waitlist mode', () => { + it('renders Join Waitlist button when waitlistMode is true', () => { + render() + expect(screen.getByRole('button', { name: 'Join Waitlist' })).toBeInTheDocument() + }) + + it('calls onSelect when Join Waitlist is clicked', () => { + const onSelect = vi.fn() + render() + 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() + 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() + 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( + + ) + expect(screen.getByRole('button', { name: 'Free Plan' })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Join Waitlist' })).not.toBeInTheDocument() + }) + }) +}) diff --git a/apps/dashboard/src/components/billing/plan-card.tsx b/apps/dashboard/src/components/billing/plan-card.tsx index a9a574d..dcd15b8 100644 --- a/apps/dashboard/src/components/billing/plan-card.tsx +++ b/apps/dashboard/src/components/billing/plan-card.tsx @@ -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() { @@ -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 @@ -83,6 +87,13 @@ export function PlanCard({ > Free Plan + ) : waitlistMode && onWaitlist ? ( + ) : (