Implement yearly commitment subscription plans and enhance billing logic

- Added support for yearly commitment plans with associated pricing tiers in the billing service.
- Introduced validation functions to manage plan changes and restrictions based on business rules.
- Updated configuration to include new yearly commitment pricing in both production and staging environments.
- Enhanced frontend components to handle subscription management, including cancellation and reactivation features.
- Refactored billing-related hooks and API calls to accommodate new subscription types and improve error handling.
This commit is contained in:
mykonos-ibiza 2025-07-31 02:16:08 +05:30
parent 6b137e4fc8
commit a7b142ed74
15 changed files with 1983 additions and 395 deletions

File diff suppressed because it is too large Load Diff

View File

@ -57,6 +57,11 @@ class Configuration:
STRIPE_TIER_50_400_YEARLY_ID_PROD: str = 'price_1ReH9fG6l1KZGqIrsPtu5KIA'
STRIPE_TIER_125_800_YEARLY_ID_PROD: str = 'price_1ReH9GG6l1KZGqIrfgqaJyat'
STRIPE_TIER_200_1000_YEARLY_ID_PROD: str = 'price_1ReH8qG6l1KZGqIrK1akY90q'
# Yearly commitment prices - Production (15% discount, monthly payments with 12-month commitment via schedules)
STRIPE_TIER_2_17_YEARLY_COMMITMENT_ID_PROD: str = 'price_1RqYGaG6l1KZGqIrIzcdPzeQ' # $17/month
STRIPE_TIER_6_42_YEARLY_COMMITMENT_ID_PROD: str = 'price_1RqYH1G6l1KZGqIrWDKh8xIU' # $42.50/month
STRIPE_TIER_25_170_YEARLY_COMMITMENT_ID_PROD: str = 'price_1RqYHbG6l1KZGqIrAUVf8KpG' # $170/month
# Subscription tier IDs - Staging
STRIPE_FREE_TIER_ID_STAGING: str = 'price_1RIGvuG6l1KZGqIrw14abxeL'
@ -76,6 +81,11 @@ class Configuration:
STRIPE_TIER_50_400_YEARLY_ID_STAGING: str = 'price_1ReGmgG6l1KZGqIrn5nBc7e5'
STRIPE_TIER_125_800_YEARLY_ID_STAGING: str = 'price_1ReGmMG6l1KZGqIrvE2ycrAX'
STRIPE_TIER_200_1000_YEARLY_ID_STAGING: str = 'price_1ReGlXG6l1KZGqIrlgurP5GU'
# Yearly commitment prices - Staging (15% discount, monthly payments with 12-month commitment via schedules)
STRIPE_TIER_2_17_YEARLY_COMMITMENT_ID_STAGING: str = 'price_1RqYGaG6l1KZGqIrIzcdPzeQ' # $17/month
STRIPE_TIER_6_42_YEARLY_COMMITMENT_ID_STAGING: str = 'price_1RqYH1G6l1KZGqIrWDKh8xIU' # $42.50/month
STRIPE_TIER_25_170_YEARLY_COMMITMENT_ID_STAGING: str = 'price_1RqYHbG6l1KZGqIrAUVf8KpG' # $170/month
# Computed subscription tier IDs based on environment
@property
@ -169,6 +179,25 @@ class Configuration:
return self.STRIPE_TIER_200_1000_YEARLY_ID_STAGING
return self.STRIPE_TIER_200_1000_YEARLY_ID_PROD
# Yearly commitment prices computed properties
@property
def STRIPE_TIER_2_17_YEARLY_COMMITMENT_ID(self) -> str:
if self.ENV_MODE == EnvMode.STAGING:
return self.STRIPE_TIER_2_17_YEARLY_COMMITMENT_ID_STAGING
return self.STRIPE_TIER_2_17_YEARLY_COMMITMENT_ID_PROD
@property
def STRIPE_TIER_6_42_YEARLY_COMMITMENT_ID(self) -> str:
if self.ENV_MODE == EnvMode.STAGING:
return self.STRIPE_TIER_6_42_YEARLY_COMMITMENT_ID_STAGING
return self.STRIPE_TIER_6_42_YEARLY_COMMITMENT_ID_PROD
@property
def STRIPE_TIER_25_170_YEARLY_COMMITMENT_ID(self) -> str:
if self.ENV_MODE == EnvMode.STAGING:
return self.STRIPE_TIER_25_170_YEARLY_COMMITMENT_ID_STAGING
return self.STRIPE_TIER_25_170_YEARLY_COMMITMENT_ID_PROD
# LLM API keys
ANTHROPIC_API_KEY: Optional[str] = None
OPENAI_API_KEY: Optional[str] = None

View File

@ -1,14 +1,58 @@
import { createClient } from '@/lib/supabase/server';
'use client';
import { useMemo } from 'react';
import AccountBillingStatus from '@/components/billing/account-billing-status';
import { useAccounts } from '@/hooks/use-accounts';
import { Skeleton } from '@/components/ui/skeleton';
import { Alert, AlertTitle, AlertDescription } from '@/components/ui/alert';
const returnUrl = process.env.NEXT_PUBLIC_URL as string;
export default async function PersonalAccountBillingPage() {
const supabaseClient = await createClient();
const { data: personalAccount } = await supabaseClient.rpc(
'get_personal_account',
export default function PersonalAccountBillingPage() {
const { data: accounts, isLoading, error } = useAccounts();
const personalAccount = useMemo(
() => accounts?.find((account) => account.personal_account),
[accounts],
);
if (error) {
return (
<Alert
variant="destructive"
className="border-red-300 dark:border-red-800 rounded-xl"
>
<AlertTitle>Error</AlertTitle>
<AlertDescription>
{error instanceof Error ? error.message : 'Failed to load account data'}
</AlertDescription>
</Alert>
);
}
if (isLoading) {
return (
<div className="space-y-6">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-64 w-full" />
</div>
);
}
if (!personalAccount) {
return (
<Alert
variant="destructive"
className="border-red-300 dark:border-red-800 rounded-xl"
>
<AlertTitle>Account Not Found</AlertTitle>
<AlertDescription>
Your personal account could not be found.
</AlertDescription>
</Alert>
);
}
return (
<div>
<AccountBillingStatus

View File

@ -1,9 +1,10 @@
'use client';
import React from 'react';
import { createClient } from '@/lib/supabase/server';
import AccountBillingStatus from '@/components/billing/account-billing-status';
import { Alert, AlertTitle, AlertDescription } from '@/components/ui/alert';
import { Skeleton } from '@/components/ui/skeleton';
import { useAccountBySlug } from '@/hooks/react-query';
const returnUrl = process.env.NEXT_PUBLIC_URL as string;
@ -19,26 +20,11 @@ export default function TeamBillingPage({
const unwrappedParams = React.use(params);
const { accountSlug } = unwrappedParams;
// Use an effect to load team account data
const [teamAccount, setTeamAccount] = React.useState<any>(null);
const [error, setError] = React.useState<string | null>(null);
React.useEffect(() => {
async function loadData() {
try {
const supabaseClient = await createClient();
const { data } = await supabaseClient.rpc('get_account_by_slug', {
slug: accountSlug,
});
setTeamAccount(data);
} catch (err) {
setError('Failed to load account data');
console.error(err);
}
}
loadData();
}, [accountSlug]);
const {
data: teamAccount,
isLoading,
error
} = useAccountBySlug(accountSlug);
if (error) {
return (
@ -47,16 +33,37 @@ export default function TeamBillingPage({
className="border-red-300 dark:border-red-800 rounded-xl"
>
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
<AlertDescription>
{error instanceof Error ? error.message : 'Failed to load account data'}
</AlertDescription>
</Alert>
);
}
if (!teamAccount) {
return <div>Loading...</div>;
if (isLoading) {
return (
<div className="space-y-6">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-64 w-full" />
</div>
);
}
if (teamAccount.account_role !== 'owner') {
if (!teamAccount) {
return (
<Alert
variant="destructive"
className="border-red-300 dark:border-red-800 rounded-xl"
>
<AlertTitle>Account Not Found</AlertTitle>
<AlertDescription>
The requested team account could not be found.
</AlertDescription>
</Alert>
);
}
if (teamAccount.role !== 'owner') {
return (
<Alert
variant="destructive"

View File

@ -7,9 +7,10 @@ import { isLocalMode } from '@/lib/config';
import { createPortalSession } from '@/lib/api';
import { useAuth } from '@/components/AuthProvider';
import { Skeleton } from '@/components/ui/skeleton';
import { useSubscription } from '@/hooks/react-query';
import { useSubscription, useSubscriptionCommitment } from '@/hooks/react-query';
import Link from 'next/link';
import { OpenInNewWindowIcon } from '@radix-ui/react-icons';
import SubscriptionManagementModal from './subscription-management-modal';
type Props = {
accountId: string;
@ -20,12 +21,18 @@ export default function AccountBillingStatus({ accountId, returnUrl }: Props) {
const { session, isLoading: authLoading } = useAuth();
const [error, setError] = useState<string | null>(null);
const [isManaging, setIsManaging] = useState(false);
const [showModal, setShowModal] = useState(false);
const {
data: subscriptionData,
isLoading,
error: subscriptionQueryError,
} = useSubscription();
const {
data: commitmentInfo,
isLoading: commitmentLoading,
} = useSubscriptionCommitment(subscriptionData?.subscription_id);
const handleManageSubscription = async () => {
try {
setIsManaging(true);
@ -127,7 +134,7 @@ export default function AccountBillingStatus({ accountId, returnUrl }: Props) {
{/* Plans Comparison */}
<PricingSection returnUrl={returnUrl} showTitleAndTabs={false} insideDialog={true} />
<div className="mt-20"></div>
<div className="mt-8"></div>
{/* Manage Subscription Button */}
<div className='flex justify-center items-center gap-4'>
<Button
@ -139,11 +146,10 @@ export default function AccountBillingStatus({ accountId, returnUrl }: Props) {
</Link>
</Button>
<Button
onClick={handleManageSubscription}
disabled={isManaging}
onClick={() => setShowModal(true)}
className="bg-primary hover:bg-primary/90 shadow-md hover:shadow-lg transition-all"
>
{isManaging ? 'Loading...' : 'Manage Subscription'}
Manage Subscription
</Button>
</div>
</>
@ -185,15 +191,21 @@ export default function AccountBillingStatus({ accountId, returnUrl }: Props) {
View Model Pricing
</Button>
<Button
onClick={handleManageSubscription}
disabled={isManaging}
onClick={() => setShowModal(true)}
className="w-full bg-primary text-white hover:bg-primary/90 shadow-md hover:shadow-lg transition-all"
>
{isManaging ? 'Loading...' : 'Manage Subscription'}
Manage Subscription
</Button>
</div>
</>
)}
<SubscriptionManagementModal
open={showModal}
onOpenChange={setShowModal}
accountId={accountId}
returnUrl={returnUrl}
/>
</div>
);
}
}

View File

@ -0,0 +1,236 @@
'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { PricingSection } from '@/components/home/sections/pricing-section';
import { isLocalMode } from '@/lib/config';
import { createPortalSession } from '@/lib/api';
import { useAuth } from '@/components/AuthProvider';
import { Skeleton } from '@/components/ui/skeleton';
import { useSubscription } from '@/hooks/react-query';
import Link from 'next/link';
import { CreditCard, Settings, HelpCircle } from 'lucide-react';
import { OpenInNewWindowIcon } from '@radix-ui/react-icons';
import SubscriptionStatusManagement from './subscription-status-management';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
type Props = {
open: boolean;
onOpenChange: (open: boolean) => void;
accountId: string;
returnUrl: string;
};
export default function SubscriptionManagementModal({
open,
onOpenChange,
accountId,
returnUrl
}: Props) {
const { session, isLoading: authLoading } = useAuth();
const [error, setError] = useState<string | null>(null);
const [isManaging, setIsManaging] = useState(false);
const [isManagingPayment, setIsManagingPayment] = useState(false);
const {
data: subscriptionData,
isLoading,
error: subscriptionQueryError,
} = useSubscription();
const handleManageSubscription = async () => {
try {
setIsManaging(true);
const { url } = await createPortalSession({ return_url: returnUrl });
window.location.href = url;
} catch (err) {
console.error('Failed to create portal session:', err);
setError(
err instanceof Error ? err.message : 'Failed to create portal session',
);
} finally {
setIsManaging(false);
}
};
const handleManagePaymentMethods = async () => {
try {
setIsManagingPayment(true);
const { url } = await createPortalSession({
return_url: returnUrl
});
window.location.href = url;
} catch (err) {
console.error('Failed to create payment portal session:', err);
setError(
err instanceof Error ? err.message : 'Failed to open payment methods',
);
} finally {
setIsManagingPayment(false);
}
};
// In local development mode, show a simplified component
if (isLocalMode()) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Subscription Management</DialogTitle>
</DialogHeader>
<div className="p-4 mb-4 bg-muted/30 border border-border rounded-lg text-center">
<p className="text-sm text-muted-foreground">
Running in local development mode - billing features are disabled
</p>
<p className="text-xs text-muted-foreground mt-2">
Agent usage limits are not enforced in this environment
</p>
</div>
</DialogContent>
</Dialog>
);
}
// Show loading state
if (isLoading || authLoading) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Subscription Management</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<Skeleton className="h-20 w-full" />
<Skeleton className="h-40 w-full" />
<Skeleton className="h-10 w-full" />
</div>
</DialogContent>
</Dialog>
);
}
// Show error state
if (error || subscriptionQueryError) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Subscription Management</DialogTitle>
</DialogHeader>
<div className="p-4 mb-4 bg-destructive/10 border border-destructive/20 rounded-lg text-center">
<p className="text-sm text-destructive">
Error loading billing status:{' '}
{error || subscriptionQueryError.message}
</p>
</div>
</DialogContent>
</Dialog>
);
}
const isPlan = (planId?: string) => {
return subscriptionData?.plan_name === planId;
};
const planName = isPlan('free')
? 'Free'
: isPlan('base')
? 'Pro'
: isPlan('extra')
? 'Enterprise'
: 'Unknown';
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-5xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Settings className="h-5 w-5" />
Subscription Management
</DialogTitle>
</DialogHeader>
<div className="space-y-6">
{subscriptionData ? (
<>
{/* Quick Actions */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Quick Links</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<Button
variant="outline"
className="flex items-center gap-2 h-auto p-4 justify-start"
asChild
>
<Link href="/model-pricing">
<HelpCircle className="h-4 w-4" />
<div className="text-left">
<div className="font-medium">Model Pricing</div>
</div>
</Link>
</Button>
<Button
onClick={handleManagePaymentMethods}
disabled={isManagingPayment}
variant="outline"
className="flex items-center gap-2 h-auto p-4 justify-start"
>
<CreditCard className="h-4 w-4" />
<div className="text-left">
<div className="font-medium">Payment Methods</div>
</div>
</Button>
<Button
onClick={handleManageSubscription}
disabled={isManaging}
variant="outline"
className="flex items-center gap-2 h-auto p-4 justify-start"
>
<Settings className="h-4 w-4" />
<div className="text-left">
<div className="font-medium">Billing Portal</div>
</div>
</Button>
</div>
</CardContent>
</Card>
{/* Subscription Details */}
<SubscriptionStatusManagement
subscription={subscriptionData?.subscription}
subscriptionId={subscriptionData?.subscription_id}
onSubscriptionUpdate={() => {
// Trigger a refetch of subscription data
window.location.reload();
}}
className="w-full"
/>
</>
) : (
<>
<div>
<h3 className="text-lg font-semibold mb-4">Upgrade Your Plan</h3>
<PricingSection returnUrl={returnUrl} showTitleAndTabs={false} insideDialog={true} />
</div>
</>
)}
</div>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,343 @@
'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import {
AlertTriangle,
Shield,
CheckCircle,
RotateCcw,
Clock
} from 'lucide-react';
import { toast } from 'sonner';
import { cancelSubscription, reactivateSubscription } from '@/lib/api';
import { useSubscriptionCommitment } from '@/hooks/react-query';
interface SubscriptionStatusManagementProps {
subscription?: {
id: string;
status: string;
cancel_at_period_end: boolean;
cancel_at?: number;
current_period_end: number;
};
subscriptionId?: string;
onSubscriptionUpdate?: () => void;
className?: string;
}
export default function SubscriptionStatusManagement({
subscription,
subscriptionId,
onSubscriptionUpdate,
className,
}: SubscriptionStatusManagementProps) {
const [isLoading, setIsLoading] = useState(false);
const [showCancelDialog, setShowCancelDialog] = useState(false);
const {
data: commitmentInfo,
isLoading: commitmentLoading,
error: commitmentError
} = useSubscriptionCommitment(subscriptionId || subscription?.id);
const formatDate = (timestamp: number) => {
return new Date(timestamp * 1000).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
};
// Get the effective cancellation date (could be period end or cancel_at for yearly commitments)
const getEffectiveCancellationDate = () => {
if (subscription.cancel_at) {
// Yearly commitment cancellation - use cancel_at timestamp
return formatDate(subscription.cancel_at);
}
// Regular cancellation - use current period end
return formatDate(subscription.current_period_end);
};
const formatEndDate = (dateString: string) => {
try {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
} catch {
return dateString;
}
};
const handleCancel = async () => {
setIsLoading(true);
try {
const response = await cancelSubscription();
if (response.success) {
toast.success(response.message);
setShowCancelDialog(false);
onSubscriptionUpdate?.();
} else {
toast.error(response.message);
}
} catch (error: any) {
console.error('Error cancelling subscription:', error);
toast.error(error.message || 'Failed to cancel subscription');
} finally {
setIsLoading(false);
}
};
const handleReactivate = async () => {
setIsLoading(true);
try {
const response = await reactivateSubscription();
if (response.success) {
toast.success(response.message);
onSubscriptionUpdate?.();
} else {
toast.error(response.message);
}
} catch (error: any) {
console.error('Error reactivating subscription:', error);
toast.error(error.message || 'Failed to reactivate subscription');
} finally {
setIsLoading(false);
}
};
// Loading state
if (commitmentLoading) {
return (
<Card className={className}>
<CardContent className="p-4">
<div className="flex items-center space-x-2">
<div className="animate-spin h-4 w-4 border-2 border-primary border-t-transparent rounded-full" />
<span className="text-sm text-muted-foreground">Loading subscription status...</span>
</div>
</CardContent>
</Card>
);
}
// Don't render if no subscription
if (!subscription) {
return null;
}
const hasCommitment = commitmentInfo?.has_commitment && !commitmentInfo?.can_cancel;
// Check both cancel_at_period_end (regular cancellation) and cancel_at (yearly commitment cancellation)
const isAlreadyCancelled = subscription.cancel_at_period_end || !!subscription.cancel_at;
const canCancel = !isAlreadyCancelled;
const canReactivate = isAlreadyCancelled;
return (
<Card className={className}>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg">
<Shield className="h-5 w-5 text-blue-600" />
Subscription Status
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Current Status */}
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Status</span>
<Badge variant={isAlreadyCancelled ? 'destructive' : 'secondary'}>
{isAlreadyCancelled
? subscription.cancel_at
? 'Cancelling at commitment end'
: 'Cancelling at period end'
: 'Active'}
</Badge>
</div>
{/* Commitment Information */}
{commitmentInfo?.has_commitment && (
<>
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Commitment Type</span>
<Badge variant="secondary" className="bg-blue-50 text-blue-700 border-blue-200">
12-Month Commitment
</Badge>
</div>
{commitmentInfo.months_remaining !== undefined && commitmentInfo.months_remaining > 0 && (
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Time Remaining</span>
<div className="flex items-center gap-1 text-sm text-muted-foreground">
<Clock className="h-3 w-3" />
{commitmentInfo.months_remaining} months
</div>
</div>
)}
{commitmentInfo.commitment_end_date && (
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Commitment Ends</span>
<span className="text-sm text-muted-foreground">
{formatEndDate(commitmentInfo.commitment_end_date)}
</span>
</div>
)}
</>
)}
{/* Commitment Warning for Active Subscriptions */}
{hasCommitment && !isAlreadyCancelled && (
<Alert>
<Shield className="h-4 w-4" />
<AlertDescription className="text-sm">
You have {commitmentInfo?.months_remaining} months remaining in
your yearly commitment. If you cancel, your subscription will end
on{' '}
{commitmentInfo?.commitment_end_date
? formatEndDate(commitmentInfo.commitment_end_date)
: 'your commitment end date'}{' '}
and you'll continue to have access until then.
</AlertDescription>
</Alert>
)}
{/* Cannot Cancel Warning */}
{hasCommitment && !commitmentInfo?.can_cancel && !isAlreadyCancelled && (
<Alert className="border-blue-200 bg-blue-50">
<Shield className="h-4 w-4 text-blue-600" />
<AlertDescription className="text-sm text-blue-800">
Your subscription cannot be cancelled during the commitment period, but you can schedule
it to end when your commitment expires. You can upgrade to a higher plan at any time.
</AlertDescription>
</Alert>
)}
{/* Already Cancelled Status */}
{isAlreadyCancelled && (
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertDescription className="text-sm">
{subscription.cancel_at ? (
<>
Your subscription is scheduled to end on{' '}
{getEffectiveCancellationDate()}{' '}
(end of commitment period). You'll continue to have access until then.
</>
) : (
<>
Your subscription will end on{' '}
{getEffectiveCancellationDate()}. You'll continue
to have access until then.
</>
)}
</AlertDescription>
</Alert>
)}
{/* Commitment Completed Message */}
{commitmentInfo?.has_commitment && commitmentInfo?.can_cancel && !isAlreadyCancelled && (
<div className="flex items-center gap-2 text-sm text-green-600">
<CheckCircle className="h-4 w-4" />
<span>Commitment period completed - you can now cancel anytime</span>
</div>
)}
{/* Action Buttons */}
<div className="flex gap-2 pt-2">
{canCancel && (
<Dialog open={showCancelDialog} onOpenChange={setShowCancelDialog}>
<DialogTrigger asChild>
<Button variant="destructive" size="sm">
{hasCommitment ? 'Schedule Cancellation' : 'Cancel Subscription'}
</Button>
</DialogTrigger>
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{hasCommitment ? 'Schedule Cancellation' : 'Cancel Subscription'}
</DialogTitle>
<DialogDescription className="text-sm leading-relaxed">
{hasCommitment ? (
<>
Are you sure you want to schedule your subscription for cancellation?
Since you have a yearly commitment, your subscription will be
scheduled to end on{' '}
{commitmentInfo?.commitment_end_date
? formatEndDate(commitmentInfo.commitment_end_date)
: 'your commitment end date'}
. You'll continue to have full access until then.
</>
) : (
<>
Are you sure you want to cancel your subscription?
You'll continue to have access until the end of your
current billing period (
{formatDate(subscription.current_period_end)}).
</>
)}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowCancelDialog(false)}
disabled={isLoading}
>
Keep Subscription
</Button>
<Button
variant="destructive"
onClick={handleCancel}
disabled={isLoading}
>
{isLoading ? 'Processing...' : hasCommitment ? 'Yes, Schedule Cancellation' : 'Yes, Cancel'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
{canReactivate && (
<Button
variant="default"
size="sm"
onClick={handleReactivate}
disabled={isLoading}
className="flex items-center gap-2"
>
<RotateCcw className="h-4 w-4" />
{isLoading ? 'Reactivating...' : 'Reactivate Subscription'}
</Button>
)}
{!canCancel && !canReactivate && !hasCommitment && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<CheckCircle className="h-4 w-4" />
Subscription is active
</div>
)}
</div>
{/* Help Text */}
<div className="text-xs text-muted-foreground mt-4 p-3 bg-muted/50 rounded-md">
<strong>Need help?</strong> Contact support if you have questions
about your subscription or need assistance with changes.
</div>
</CardContent>
</Card>
);
}

View File

@ -5,7 +5,7 @@ import type { PricingTier } from '@/lib/home';
import { siteConfig } from '@/lib/home';
import { cn } from '@/lib/utils';
import { motion } from 'motion/react';
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useCallback } from 'react';
import { CheckIcon } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
@ -14,8 +14,8 @@ import {
CreateCheckoutSessionResponse,
} from '@/lib/api';
import { toast } from 'sonner';
import { isLocalMode } from '@/lib/config';
import { useSubscription } from '@/hooks/react-query';
import { isLocalMode, isYearlyCommitmentDowngrade, isPlanChangeAllowed, getPlanInfo } from '@/lib/config';
import { useSubscription, useSubscriptionCommitment } from '@/hooks/react-query';
// Constants
export const SUBSCRIPTION_PLANS = {
@ -56,7 +56,7 @@ interface PricingTierProps {
isAuthenticated?: boolean;
returnUrl: string;
insideDialog?: boolean;
billingPeriod?: 'monthly' | 'yearly';
billingPeriod?: 'monthly' | 'yearly' | 'yearly_commitment';
}
// Components
@ -128,28 +128,31 @@ function BillingPeriodToggle({
billingPeriod,
setBillingPeriod
}: {
billingPeriod: 'monthly' | 'yearly';
setBillingPeriod: (period: 'monthly' | 'yearly') => void;
billingPeriod: 'monthly' | 'yearly' | 'yearly_commitment';
setBillingPeriod: (period: 'monthly' | 'yearly' | 'yearly_commitment') => void;
}) {
return (
<div className="flex items-center justify-center gap-3">
<div
className="relative bg-muted rounded-full p-1 cursor-pointer"
onClick={() => setBillingPeriod(billingPeriod === 'monthly' ? 'yearly' : 'monthly')}
>
<div className="relative bg-muted rounded-full p-1">
<div className="flex">
<div className={cn("px-3 py-1 rounded-full text-xs font-medium transition-all duration-200",
billingPeriod === 'monthly'
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground'
)}>
<div
className={cn("px-3 py-1 rounded-full text-xs font-medium transition-all duration-200 cursor-pointer",
billingPeriod === 'monthly'
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
)}
onClick={() => setBillingPeriod('monthly')}
>
Monthly
</div>
<div className={cn("px-3 py-1 rounded-full text-xs font-medium transition-all duration-200 flex items-center gap-1",
billingPeriod === 'yearly'
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground'
)}>
<div
className={cn("px-3 py-1 rounded-full text-xs font-medium transition-all duration-200 flex items-center gap-1 cursor-pointer",
billingPeriod === 'yearly_commitment'
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
)}
onClick={() => setBillingPeriod('yearly_commitment')}
>
Yearly
<span className="bg-green-600 text-green-50 dark:bg-green-500 dark:text-green-50 text-[10px] px-1.5 py-0.5 rounded-full font-semibold whitespace-nowrap">
15% off
@ -173,8 +176,37 @@ function PricingTier({
isAuthenticated = false,
returnUrl,
insideDialog = false,
billingPeriod = 'monthly',
billingPeriod = 'monthly' as 'monthly' | 'yearly' | 'yearly_commitment',
}: PricingTierProps) {
// Determine the price to display based on billing period
const getDisplayPrice = () => {
if (billingPeriod === 'yearly_commitment' && tier.monthlyCommitmentStripePriceId) {
// Calculate the yearly commitment price (15% off regular monthly)
const regularPrice = parseFloat(tier.price.slice(1));
const discountedPrice = Math.round(regularPrice * 0.85);
return `$${discountedPrice}`;
} else if (billingPeriod === 'yearly' && tier.yearlyPrice) {
// Legacy yearly plans (hidden from UI but still accessible)
return tier.yearlyPrice;
}
return tier.price;
};
// Get the price ID to use based on billing period
const getPriceId = () => {
if (billingPeriod === 'yearly_commitment' && tier.monthlyCommitmentStripePriceId) {
return tier.monthlyCommitmentStripePriceId;
} else if (billingPeriod === 'yearly' && tier.yearlyStripePriceId) {
// Legacy yearly plans (hidden from UI but still accessible)
return tier.yearlyStripePriceId;
}
return tier.stripePriceId;
};
const displayPrice = getDisplayPrice();
const priceId = getPriceId();
// Auto-select the correct plan only on initial load - simplified since no more Custom tier
const handleSubscribe = async (planStripePriceId: string) => {
if (!isAuthenticated) {
@ -189,11 +221,17 @@ function PricingTier({
try {
onPlanSelect?.(planStripePriceId);
// Determine commitment type based on billing period
const commitmentType = billingPeriod === 'yearly_commitment' ? 'yearly_commitment' :
billingPeriod === 'yearly' ? 'yearly' :
'monthly';
const response: CreateCheckoutSessionResponse =
await createCheckoutSession({
price_id: planStripePriceId,
success_url: returnUrl,
cancel_url: returnUrl,
commitment_type: commitmentType,
});
console.log('Subscription action response:', response);
@ -201,11 +239,12 @@ function PricingTier({
switch (response.status) {
case 'new':
case 'checkout_created':
case 'commitment_created':
if (response.url) {
window.location.href = response.url;
} else {
console.error(
"Error: Received status 'checkout_created' but no checkout URL.",
"Error: Received status but no checkout URL.",
);
toast.error('Failed to initiate subscription. Please try again.');
}
@ -218,6 +257,9 @@ function PricingTier({
toast.success(upgradeMessage);
if (onSubscriptionUpdate) onSubscriptionUpdate();
break;
case 'commitment_blocks_downgrade':
toast.warning(response.message || 'Cannot downgrade during commitment period');
break;
case 'downgrade_scheduled':
case 'scheduled':
const effectiveDate = response.effective_date
@ -256,24 +298,17 @@ function PricingTier({
}
};
const tierPriceId = billingPeriod === 'yearly' && tier.yearlyStripePriceId
? tier.yearlyStripePriceId
: tier.stripePriceId;
const displayPrice = billingPeriod === 'yearly' && tier.yearlyPrice
? tier.yearlyPrice
: tier.price;
// Find the current tier (moved outside conditional for JSX access)
const currentTier = siteConfig.cloudPricingItems.find(
(p) => p.stripePriceId === currentSubscription?.price_id || p.yearlyStripePriceId === currentSubscription?.price_id,
);
const isCurrentActivePlan =
isAuthenticated && currentSubscription?.price_id === tierPriceId;
isAuthenticated && currentSubscription?.price_id === priceId;
const isScheduled = isAuthenticated && currentSubscription?.has_schedule;
const isScheduledTargetPlan =
isScheduled && currentSubscription?.scheduled_price_id === tierPriceId;
const isPlanLoading = isLoading[tierPriceId];
isScheduled && currentSubscription?.scheduled_price_id === priceId;
const isPlanLoading = isLoading[priceId];
let buttonText = isAuthenticated ? 'Select Plan' : 'Start Free';
let buttonDisabled = isPlanLoading;
@ -281,6 +316,11 @@ function PricingTier({
let ringClass = '';
let statusBadge = null;
let buttonClassName = '';
// Check plan change restrictions using comprehensive validation
const planChangeValidation = (isAuthenticated && currentSubscription?.price_id)
? isPlanChangeAllowed(currentSubscription.price_id, priceId)
: { allowed: true };
if (isAuthenticated) {
if (isCurrentActivePlan) {
@ -308,7 +348,7 @@ function PricingTier({
Scheduled
</span>
);
} else if (isScheduled && currentSubscription?.price_id === tierPriceId) {
} else if (isScheduled && currentSubscription?.price_id === priceId) {
buttonText = 'Change Scheduled';
buttonVariant = 'secondary';
ringClass = isCompact ? 'ring-1 ring-primary' : 'ring-2 ring-primary';
@ -322,7 +362,7 @@ function PricingTier({
const currentPriceString = currentSubscription
? currentTier?.price || '$0'
: '$0';
const selectedPriceString = tier.price;
const selectedPriceString = displayPrice;
const currentAmount =
currentPriceString === '$0'
? 0
@ -332,13 +372,22 @@ function PricingTier({
? 0
: parseFloat(selectedPriceString.replace(/[^\d.]/g, '') || '0') * 100;
// Check if current subscription is monthly and target is yearly for same tier
// Check if current subscription is monthly and target is yearly commitment for same tier
const currentIsMonthly = currentTier && currentSubscription?.price_id === currentTier.stripePriceId;
const currentIsYearly = currentTier && currentSubscription?.price_id === currentTier.yearlyStripePriceId;
const targetIsMonthly = tier.stripePriceId === tierPriceId;
const targetIsYearly = tier.yearlyStripePriceId === tierPriceId;
const isSameTierDifferentBilling = currentTier && currentTier.name === tier.name &&
((currentIsMonthly && targetIsYearly) || (currentIsYearly && targetIsMonthly));
const currentIsYearlyCommitment = currentTier && currentSubscription?.price_id === currentTier.monthlyCommitmentStripePriceId;
const targetIsMonthly = priceId === tier.stripePriceId;
const targetIsYearly = priceId === tier.yearlyStripePriceId;
const targetIsYearlyCommitment = priceId === tier.monthlyCommitmentStripePriceId;
const isSameTierUpgradeToLongerTerm = currentTier && currentTier.name === tier.name &&
((currentIsMonthly && (targetIsYearly || targetIsYearlyCommitment)) ||
(currentIsYearlyCommitment && targetIsYearly));
const isSameTierDowngradeToShorterTerm = currentTier && currentTier.name === tier.name &&
((currentIsYearly && targetIsMonthly) ||
(currentIsYearlyCommitment && targetIsMonthly));
// Use the plan change validation already computed above
if (
currentAmount === 0 &&
@ -349,18 +398,25 @@ function PricingTier({
buttonDisabled = true;
buttonVariant = 'secondary';
buttonClassName = 'bg-primary/5 hover:bg-primary/10 text-primary';
} else if (!planChangeValidation.allowed) {
// Plan change not allowed due to business rules
buttonText = 'Not Available';
buttonDisabled = true;
buttonVariant = 'secondary';
buttonClassName = 'opacity-50 cursor-not-allowed bg-muted text-muted-foreground';
} else {
if (targetAmount > currentAmount || (currentIsMonthly && targetIsYearly && targetAmount >= currentAmount)) {
// Allow upgrade to higher tier OR switch from monthly to yearly at same/higher tier
// But prevent yearly to monthly switches even if target amount is higher
if (currentIsYearly && targetIsMonthly) {
buttonText = '-';
buttonDisabled = true;
buttonVariant = 'secondary';
buttonClassName =
'opacity-50 cursor-not-allowed bg-muted text-muted-foreground';
} else if (currentIsMonthly && targetIsYearly && targetAmount === currentAmount) {
buttonText = 'Switch to Yearly';
if (targetAmount > currentAmount || isSameTierUpgradeToLongerTerm) {
// Allow upgrade to higher tier OR upgrade to longer term on same tier
if (currentIsMonthly && targetIsYearlyCommitment && targetAmount <= currentAmount) {
buttonText = 'Subscribe for one year';
buttonVariant = 'default';
buttonClassName = 'bg-green-600 hover:bg-green-700 text-white';
} else if (currentIsMonthly && targetIsYearly && targetAmount <= currentAmount) {
buttonText = 'Switch to Legacy Yearly';
buttonVariant = 'default';
buttonClassName = 'bg-green-600 hover:bg-green-700 text-white';
} else if (currentIsYearlyCommitment && targetIsYearly && currentTier?.name === tier.name) {
buttonText = 'Switch to Legacy Yearly';
buttonVariant = 'default';
buttonClassName = 'bg-green-600 hover:bg-green-700 text-white';
} else {
@ -368,36 +424,16 @@ function PricingTier({
buttonVariant = tier.buttonColor as ButtonVariant;
buttonClassName = 'bg-primary hover:bg-primary/90 text-primary-foreground';
}
} else if (targetAmount < currentAmount && !(currentIsYearly && targetIsMonthly && targetAmount === currentAmount)) {
buttonText = '-';
} else if (targetAmount < currentAmount || isSameTierDowngradeToShorterTerm) {
// Prevent downgrades and downgrades to shorter terms
buttonText = 'Not Available';
buttonDisabled = true;
buttonVariant = 'secondary';
buttonClassName =
'opacity-50 cursor-not-allowed bg-muted text-muted-foreground';
} else if (isSameTierDifferentBilling) {
// Allow switching between monthly and yearly for same tier
if (currentIsMonthly && targetIsYearly) {
buttonText = 'Switch to Yearly';
buttonVariant = 'default';
buttonClassName = 'bg-green-600 hover:bg-green-700 text-white';
} else if (currentIsYearly && targetIsMonthly) {
// Prevent downgrade from yearly to monthly
buttonText = '-';
buttonDisabled = true;
buttonVariant = 'secondary';
buttonClassName =
'opacity-50 cursor-not-allowed bg-muted text-muted-foreground';
} else {
buttonText = 'Select Plan';
buttonVariant = tier.buttonColor as ButtonVariant;
buttonClassName =
'bg-primary hover:bg-primary/90 text-primary-foreground';
}
buttonClassName = 'opacity-50 cursor-not-allowed bg-muted text-muted-foreground';
} else {
buttonText = 'Select Plan';
buttonVariant = tier.buttonColor as ButtonVariant;
buttonClassName =
'bg-primary hover:bg-primary/90 text-primary-foreground';
buttonClassName = 'bg-primary hover:bg-primary/90 text-primary-foreground';
}
}
}
@ -439,19 +475,24 @@ function PricingTier({
Popular
</span>
)}
{/* Show upgrade badge for yearly plans when user is on monthly */}
{!tier.isPopular && isAuthenticated && currentSubscription && billingPeriod === 'yearly' &&
currentTier && currentSubscription.price_id === currentTier.stripePriceId &&
tier.yearlyStripePriceId && (currentTier.name === tier.name ||
parseFloat(tier.price.slice(1)) >= parseFloat(currentTier.price.slice(1))) && (
<span className="bg-green-500/10 text-green-700 text-[10px] font-medium px-1.5 py-0.5 rounded-full">
Recommended
</span>
)}
{/* Show upgrade badge for yearly commitment plans when user is on monthly */}
{isAuthenticated && statusBadge}
</p>
<div className="flex items-baseline mt-2">
{billingPeriod === 'yearly' && tier.yearlyPrice && displayPrice !== '$0' ? (
{billingPeriod === 'yearly_commitment' && tier.monthlyCommitmentStripePriceId ? (
<div className="flex flex-col">
<div className="flex items-baseline gap-2">
<PriceDisplay price={displayPrice} isCompact={insideDialog} />
<span className="text-xs line-through text-muted-foreground">
${tier.price.slice(1)}
</span>
</div>
<div className="flex items-center gap-1 mt-1">
<span className="text-xs text-muted-foreground">/month</span>
<span className="text-xs text-muted-foreground">for one year</span>
</div>
</div>
) : billingPeriod === 'yearly' && tier.yearlyPrice && displayPrice !== '$0' ? (
<div className="flex flex-col">
<div className="flex items-baseline gap-2">
<PriceDisplay price={`$${Math.round(parseFloat(tier.yearlyPrice.slice(1)) / 12)}`} isCompact={insideDialog} />
@ -475,7 +516,11 @@ function PricingTier({
</div>
<p className="hidden text-sm mt-2">{tier.description}</p>
{billingPeriod === 'yearly' && tier.yearlyPrice && tier.discountPercentage ? (
{billingPeriod === 'yearly_commitment' && tier.monthlyCommitmentStripePriceId ? (
<div className="inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold bg-green-50 border-green-200 text-green-700 w-fit">
Save ${Math.round((parseFloat(tier.price.slice(1)) - parseFloat(displayPrice.slice(1))) * 12)} per year
</div>
) : billingPeriod === 'yearly' && tier.yearlyPrice && tier.discountPercentage ? (
<div className="inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold bg-green-50 border-green-200 text-green-700 w-fit">
Save ${Math.round(parseFloat(tier.originalYearlyPrice?.slice(1) || '0') - parseFloat(tier.yearlyPrice.slice(1)))} per year
</div>
@ -512,7 +557,7 @@ function PricingTier({
insideDialog ? "px-3 pt-1 pb-3" : "px-4 pt-2 pb-4"
)}>
<Button
onClick={() => handleSubscribe(tierPriceId)}
onClick={() => handleSubscribe(priceId)}
disabled={buttonDisabled}
variant={buttonVariant || 'default'}
className={cn(
@ -521,6 +566,7 @@ function PricingTier({
buttonClassName,
isPlanLoading && 'animate-pulse',
)}
title={!planChangeValidation.allowed ? planChangeValidation.reason : undefined}
>
{buttonText}
</Button>
@ -546,43 +592,49 @@ export function PricingSection({
'cloud',
);
const { data: subscriptionData, isLoading: isFetchingPlan, error: subscriptionQueryError, refetch: refetchSubscription } = useSubscription();
const subCommitmentQuery = useSubscriptionCommitment(subscriptionData?.subscription_id);
// Derive authentication and subscription status from the hook data
const isAuthenticated = !!subscriptionData && subscriptionQueryError === null;
const currentSubscription = subscriptionData || null;
// Determine default billing period based on user's current subscription
const getDefaultBillingPeriod = (): 'monthly' | 'yearly' => {
const getDefaultBillingPeriod = useCallback((): 'monthly' | 'yearly' | 'yearly_commitment' => {
if (!isAuthenticated || !currentSubscription) {
// Default to yearly for non-authenticated users or users without subscription
return 'yearly';
// Default to yearly_commitment for non-authenticated users (the new yearly plans)
return 'yearly_commitment';
}
// Find current tier to determine if user is on monthly or yearly plan
// Find current tier to determine if user is on monthly, yearly, or yearly commitment plan
const currentTier = siteConfig.cloudPricingItems.find(
(p) => p.stripePriceId === currentSubscription.price_id || p.yearlyStripePriceId === currentSubscription.price_id,
(p) => p.stripePriceId === currentSubscription.price_id ||
p.yearlyStripePriceId === currentSubscription.price_id ||
p.monthlyCommitmentStripePriceId === currentSubscription.price_id,
);
if (currentTier) {
// Check if current subscription is yearly
if (currentTier.yearlyStripePriceId === currentSubscription.price_id) {
// Check if current subscription is yearly commitment (new yearly)
if (currentTier.monthlyCommitmentStripePriceId === currentSubscription.price_id) {
return 'yearly_commitment';
} else if (currentTier.yearlyStripePriceId === currentSubscription.price_id) {
// Legacy yearly plans
return 'yearly';
} else if (currentTier.stripePriceId === currentSubscription.price_id) {
return 'monthly';
}
}
// Default to yearly if we can't determine current plan type
return 'yearly';
};
// Default to yearly_commitment (new yearly) if we can't determine current plan type
return 'yearly_commitment';
}, [isAuthenticated, currentSubscription]);
const [billingPeriod, setBillingPeriod] = useState<'monthly' | 'yearly'>(getDefaultBillingPeriod());
const [billingPeriod, setBillingPeriod] = useState<'monthly' | 'yearly' | 'yearly_commitment'>(getDefaultBillingPeriod());
const [planLoadingStates, setPlanLoadingStates] = useState<Record<string, boolean>>({});
// Update billing period when subscription data changes
useEffect(() => {
setBillingPeriod(getDefaultBillingPeriod());
}, [isAuthenticated, currentSubscription?.price_id]);
}, [getDefaultBillingPeriod]);
const handlePlanSelect = (planId: string) => {
setPlanLoadingStates((prev) => ({ ...prev, [planId]: true }));
@ -590,6 +642,7 @@ export function PricingSection({
const handleSubscriptionUpdate = () => {
refetchSubscription();
subCommitmentQuery.refetch();
// The useSubscription hook will automatically refetch, so we just need to clear loading states
setTimeout(() => {
setPlanLoadingStates({});
@ -699,6 +752,6 @@ export function PricingSection({
</div>
</section>
);
}

View File

@ -0,0 +1,22 @@
import { useQuery } from '@tanstack/react-query';
import { createClient } from '@/lib/supabase/client';
export function useAccountBySlug(slug: string) {
const supabaseClient = createClient();
return useQuery({
queryKey: ['account', 'by-slug', slug],
queryFn: async () => {
const { data, error } = await supabaseClient.rpc('get_account_by_slug', {
slug,
});
if (error) {
throw new Error(error.message);
}
return data;
},
enabled: !!slug && !!supabaseClient,
});
}

View File

@ -17,6 +17,8 @@ export * from './files/use-sandbox-mutations';
export * from './subscriptions/use-subscriptions';
export * from './subscriptions/use-billing';
export * from './accounts/use-account-by-slug';
export * from './dashboard/use-initiate-agent';
export * from './usage/use-health';

View File

@ -7,6 +7,7 @@ const usageKeysBase = ['usage'] as const;
export const subscriptionKeys = createQueryKeys({
all: subscriptionKeysBase,
details: () => [...subscriptionKeysBase, 'details'] as const,
commitment: (subscriptionId: string) => [...subscriptionKeysBase, 'commitment', subscriptionId] as const,
});
export const modelKeys = createQueryKeys({

View File

@ -3,8 +3,10 @@
import { createMutationHook, createQueryHook } from '@/hooks/use-query';
import {
getSubscription,
getSubscriptionCommitment,
createPortalSession,
SubscriptionStatus,
CommitmentInfo,
} from '@/lib/api';
import { subscriptionKeys } from './keys';
import { useQuery } from '@tanstack/react-query';
@ -63,6 +65,16 @@ export const useCreatePortalSession = createMutationHook(
},
);
export const useSubscriptionCommitment = (subscriptionId?: string) => {
return useQuery({
queryKey: subscriptionKeys.commitment(subscriptionId || ''),
queryFn: () => getSubscriptionCommitment(subscriptionId!),
enabled: !!subscriptionId,
staleTime: 1000 * 60 * 5, // 5 minutes
refetchOnWindowFocus: false,
});
};
export const isPlan = (
subscriptionData: SubscriptionStatus | null | undefined,
planId?: string,

View File

@ -1589,6 +1589,7 @@ export interface CreateCheckoutSessionRequest {
success_url: string;
cancel_url: string;
referral_id?: string;
commitment_type?: 'monthly' | 'yearly' | 'yearly_commitment';
}
export interface CreatePortalSessionRequest {
@ -1598,19 +1599,97 @@ export interface CreatePortalSessionRequest {
export interface SubscriptionStatus {
status: string; // Includes 'active', 'trialing', 'past_due', 'scheduled_downgrade', 'no_subscription'
plan_name?: string;
price_id?: string; // Added
current_period_end?: string; // ISO Date string
cancel_at_period_end: boolean;
trial_end?: string; // ISO Date string
price_id?: string;
current_period_end?: string; // ISO datetime string
cancel_at_period_end?: boolean;
trial_end?: string; // ISO datetime string
minutes_limit?: number;
cost_limit?: number;
current_usage?: number;
// Fields for scheduled changes
has_schedule: boolean;
has_schedule?: boolean;
scheduled_plan_name?: string;
scheduled_price_id?: string; // Added
scheduled_change_date?: string; // ISO Date string - Deprecate? Check backend usage
schedule_effective_date?: string; // ISO Date string - Added for consistency
scheduled_price_id?: string;
scheduled_change_date?: string; // ISO datetime string
// Subscription data for frontend components
subscription_id?: string;
subscription?: {
id: string;
status: string;
cancel_at_period_end: boolean;
current_period_end: number; // timestamp
};
}
export interface CommitmentInfo {
has_commitment: boolean;
commitment_type?: string;
months_remaining?: number;
can_cancel: boolean;
commitment_end_date?: string;
}
// Interface for user subscription details from Stripe
export interface UserSubscriptionResponse {
subscription?: {
id: string;
status: string;
current_period_end: number;
current_period_start: number;
cancel_at_period_end: boolean;
cancel_at?: number;
items: {
data: Array<{
id: string;
price: {
id: string;
unit_amount: number;
currency: string;
recurring: {
interval: string;
interval_count: number;
};
};
quantity: number;
}>;
};
metadata: {
[key: string]: string;
};
};
price_id?: string;
plan_name?: string;
status?: string;
has_schedule?: boolean;
scheduled_price_id?: string;
current_period_end?: number;
current_period_start?: number;
cancel_at_period_end?: boolean;
cancel_at?: number;
customer_email?: string;
usage?: {
total_usage: number;
limit: number;
};
}
// Usage log entry interface
export interface UsageLogEntry {
id: string;
user_id: string;
model: string;
input_tokens: number;
output_tokens: number;
cost_usd: number;
timestamp: string;
session_type?: string;
}
// Usage logs response interface
export interface UsageLogsResponse {
logs: UsageLogEntry[];
has_more: boolean;
message?: string;
}
export interface BillingStatusResponse {
@ -1640,28 +1719,6 @@ export interface AvailableModelsResponse {
total_models: number;
}
export interface UsageLogEntry {
message_id: string;
thread_id: string;
created_at: string;
content: {
usage: {
prompt_tokens: number;
completion_tokens: number;
};
model: string;
};
total_tokens: number;
estimated_cost: number;
project_id: string;
}
export interface UsageLogsResponse {
logs: UsageLogEntry[];
has_more: boolean;
message?: string;
}
export interface CreateCheckoutSessionResponse {
status:
| 'upgraded'
@ -1670,7 +1727,9 @@ export interface CreateCheckoutSessionResponse {
| 'no_change'
| 'new'
| 'updated'
| 'scheduled';
| 'scheduled'
| 'commitment_created'
| 'commitment_blocks_downgrade';
subscription_id?: string;
schedule_id?: string;
session_id?: string;
@ -1682,6 +1741,8 @@ export interface CreateCheckoutSessionResponse {
effective_date?: string;
current_price?: number;
new_price?: number;
commitment_end_date?: string;
months_remaining?: number;
invoice?: {
id: string;
status: string;
@ -1691,6 +1752,31 @@ export interface CreateCheckoutSessionResponse {
};
}
export interface CancelSubscriptionResponse {
success: boolean;
status: 'cancelled_at_period_end' | 'commitment_prevents_cancellation';
message: string;
details?: {
subscription_id?: string;
cancellation_effective_date?: string;
current_period_end?: number;
access_until?: string;
months_remaining?: number;
commitment_end_date?: string;
can_cancel_after?: string;
};
}
export interface ReactivateSubscriptionResponse {
success: boolean;
status: 'reactivated' | 'not_cancelled';
message: string;
details?: {
subscription_id?: string;
next_billing_date?: string;
};
}
// Billing API Functions
export const createCheckoutSession = async (
request: CreateCheckoutSessionRequest,
@ -1849,6 +1935,48 @@ export const getSubscription = async (): Promise<SubscriptionStatus> => {
}
};
export const getSubscriptionCommitment = async (subscriptionId: string): Promise<CommitmentInfo> => {
try {
const supabase = createClient();
const {
data: { session },
} = await supabase.auth.getSession();
if (!session?.access_token) {
throw new NoAccessTokenAvailableError();
}
const response = await fetch(`${API_URL}/billing/subscription-commitment/${subscriptionId}`, {
headers: {
Authorization: `Bearer ${session.access_token}`,
},
});
if (!response.ok) {
const errorText = await response
.text()
.catch(() => 'No error details available');
console.error(
`Error getting subscription commitment: ${response.status} ${response.statusText}`,
errorText,
);
throw new Error(
`Error getting subscription commitment: ${response.statusText} (${response.status})`,
);
}
return response.json();
} catch (error) {
if (error instanceof NoAccessTokenAvailableError) {
throw error;
}
console.error('Failed to get subscription commitment:', error);
handleApiError(error, { operation: 'load subscription commitment', resource: 'commitment information' });
throw error;
}
};
export const getAvailableModels = async (): Promise<AvailableModelsResponse> => {
try {
const supabase = createClient();
@ -1933,6 +2061,86 @@ export const checkBillingStatus = async (): Promise<BillingStatusResponse> => {
}
};
export const cancelSubscription = async (): Promise<CancelSubscriptionResponse> => {
try {
const supabase = createClient();
const {
data: { session },
} = await supabase.auth.getSession();
if (!session?.access_token) {
throw new NoAccessTokenAvailableError();
}
const response = await fetch(`${API_URL}/billing/cancel-subscription`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${session.access_token}`,
},
});
if (!response.ok) {
const errorText = await response
.text()
.catch(() => 'No error details available');
console.error(
`Error cancelling subscription: ${response.status} ${response.statusText}`,
errorText,
);
throw new Error(
`Error cancelling subscription: ${response.statusText} (${response.status})`,
);
}
return response.json();
} catch (error) {
console.error('Failed to cancel subscription:', error);
handleApiError(error, { operation: 'cancel subscription', resource: 'subscription' });
throw error;
}
};
export const reactivateSubscription = async (): Promise<ReactivateSubscriptionResponse> => {
try {
const supabase = createClient();
const {
data: { session },
} = await supabase.auth.getSession();
if (!session?.access_token) {
throw new NoAccessTokenAvailableError();
}
const response = await fetch(`${API_URL}/billing/reactivate-subscription`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${session.access_token}`,
},
});
if (!response.ok) {
const errorText = await response
.text()
.catch(() => 'No error details available');
console.error(
`Error reactivating subscription: ${response.status} ${response.statusText}`,
errorText,
);
throw new Error(
`Error reactivating subscription: ${response.statusText} (${response.status})`,
);
}
return response.json();
} catch (error) {
console.error('Failed to reactivate subscription:', error);
handleApiError(error, { operation: 'reactivate subscription', resource: 'subscription' });
throw error;
}
};
// Transcription API Types
export interface TranscriptionResponse {
text: string;

View File

@ -29,6 +29,10 @@ export interface SubscriptionTiers {
TIER_50_400_YEARLY: SubscriptionTierData;
TIER_125_800_YEARLY: SubscriptionTierData;
TIER_200_1000_YEARLY: SubscriptionTierData;
// Yearly commitment plans (15% discount, monthly payments with 12-month commitment)
TIER_2_17_YEARLY_COMMITMENT: SubscriptionTierData;
TIER_6_42_YEARLY_COMMITMENT: SubscriptionTierData;
TIER_25_170_YEARLY_COMMITMENT: SubscriptionTierData;
}
// Configuration object
@ -72,34 +76,47 @@ const PROD_TIERS: SubscriptionTiers = {
priceId: 'price_1RILb3G6l1KZGqIrmauYPOiN',
name: '200h/$1000',
},
// Yearly plans with 15% discount (12x monthly price with 15% off)
// Legacy yearly plans with 15% discount (12x monthly price with 15% off)
TIER_2_20_YEARLY: {
priceId: 'price_1ReHB5G6l1KZGqIrD70I1xqM',
name: '2h/$204/year',
name: '2h/$204/year (legacy)',
},
TIER_6_50_YEARLY: {
priceId: 'price_1ReHAsG6l1KZGqIrlAog487C',
name: '6h/$510/year',
name: '6h/$510/year (legacy)',
},
TIER_12_100_YEARLY: {
priceId: 'price_1ReHAWG6l1KZGqIrBHer2PQc',
name: '12h/$1020/year',
name: '12h/$1020/year (legacy)',
},
TIER_25_200_YEARLY: {
priceId: 'price_1ReH9uG6l1KZGqIrsvMLHViC',
name: '25h/$2040/year',
name: '25h/$2040/year (legacy)',
},
TIER_50_400_YEARLY: {
priceId: 'price_1ReH9fG6l1KZGqIrsPtu5KIA',
name: '50h/$4080/year',
name: '50h/$4080/year (legacy)',
},
TIER_125_800_YEARLY: {
priceId: 'price_1ReH9GG6l1KZGqIrfgqaJyat',
name: '125h/$8160/year',
name: '125h/$8160/year (legacy)',
},
TIER_200_1000_YEARLY: {
priceId: 'price_1ReH8qG6l1KZGqIrK1akY90q',
name: '200h/$10200/year',
name: '200h/$10200/year (legacy)',
},
// Yearly commitment plans (15% discount, monthly payments with 12-month commitment)
TIER_2_17_YEARLY_COMMITMENT: {
priceId: 'price_1RqYGaG6l1KZGqIrIzcdPzeQ',
name: '2h/$17/month (yearly)',
},
TIER_6_42_YEARLY_COMMITMENT: {
priceId: 'price_1RqYH1G6l1KZGqIrWDKh8xIU',
name: '6h/$42.50/month (yearly)',
},
TIER_25_170_YEARLY_COMMITMENT: {
priceId: 'price_1RqYHbG6l1KZGqIrAUVf8KpG',
name: '25h/$170/month (yearly)',
},
} as const;
@ -137,34 +154,47 @@ const STAGING_TIERS: SubscriptionTiers = {
priceId: 'price_1RIKQ2G6l1KZGqIrum9n8SI7',
name: '200h/$1000',
},
// Yearly plans with 15% discount (12x monthly price with 15% off)
// Legacy yearly plans with 15% discount (12x monthly price with 15% off)
TIER_2_20_YEARLY: {
priceId: 'price_1ReGogG6l1KZGqIrEyBTmtPk',
name: '2h/$204/year',
name: '2h/$204/year (legacy)',
},
TIER_6_50_YEARLY: {
priceId: 'price_1ReGoJG6l1KZGqIr0DJWtoOc',
name: '6h/$510/year',
name: '6h/$510/year (legacy)',
},
TIER_12_100_YEARLY: {
priceId: 'price_1ReGnZG6l1KZGqIr0ThLEl5S',
name: '12h/$1020/year',
name: '12h/$1020/year (legacy)',
},
TIER_25_200_YEARLY: {
priceId: 'price_1ReGmzG6l1KZGqIre31mqoEJ',
name: '25h/$2040/year',
name: '25h/$2040/year (legacy)',
},
TIER_50_400_YEARLY: {
priceId: 'price_1ReGmgG6l1KZGqIrn5nBc7e5',
name: '50h/$4080/year',
name: '50h/$4080/year (legacy)',
},
TIER_125_800_YEARLY: {
priceId: 'price_1ReGmMG6l1KZGqIrvE2ycrAX',
name: '125h/$8160/year',
name: '125h/$8160/year (legacy)',
},
TIER_200_1000_YEARLY: {
priceId: 'price_1ReGlXG6l1KZGqIrlgurP5GU',
name: '200h/$10200/year',
name: '200h/$10200/year (legacy)',
},
// Yearly commitment plans (15% discount, monthly payments with 12-month commitment)
TIER_2_17_YEARLY_COMMITMENT: {
priceId: 'price_1RqYGaG6l1KZGqIrIzcdPzeQ',
name: '2h/$17/month (yearly)',
},
TIER_6_42_YEARLY_COMMITMENT: {
priceId: 'price_1RqYH1G6l1KZGqIrWDKh8xIU',
name: '6h/$42.50/month (yearly)',
},
TIER_25_170_YEARLY_COMMITMENT: {
priceId: 'price_1RqYHbG6l1KZGqIrAUVf8KpG',
name: '25h/$170/month (yearly)',
},
} as const;
@ -213,5 +243,151 @@ export const isLocalMode = (): boolean => {
return config.IS_LOCAL;
};
// Yearly commitment plan mappings with tier levels (higher number = higher tier)
const YEARLY_COMMITMENT_PLANS = {
'price_1RqYGaG6l1KZGqIrIzcdPzeQ': { tier: 1, name: '2h/$17/month (yearly)' }, // TIER_2_17_YEARLY_COMMITMENT
'price_1RqYH1G6l1KZGqIrWDKh8xIU': { tier: 2, name: '6h/$42.50/month (yearly)' }, // TIER_6_42_YEARLY_COMMITMENT
'price_1RqYHbG6l1KZGqIrAUVf8KpG': { tier: 3, name: '25h/$170/month (yearly)' }, // TIER_25_170_YEARLY_COMMITMENT
} as const;
// Helper functions for yearly commitment plans
export const isYearlyCommitmentPlan = (priceId: string): boolean => {
return priceId in YEARLY_COMMITMENT_PLANS;
};
export const getYearlyCommitmentTier = (priceId: string): number => {
return YEARLY_COMMITMENT_PLANS[priceId as keyof typeof YEARLY_COMMITMENT_PLANS]?.tier ?? 0;
};
export const isYearlyCommitmentDowngrade = (currentPriceId: string, newPriceId: string): boolean => {
// Check if both are yearly commitment plans
if (!isYearlyCommitmentPlan(currentPriceId) || !isYearlyCommitmentPlan(newPriceId)) {
return false;
}
const currentTier = getYearlyCommitmentTier(currentPriceId);
const newTier = getYearlyCommitmentTier(newPriceId);
return newTier < currentTier;
};
// Plan type identification functions
export const isMonthlyPlan = (priceId: string): boolean => {
const allTiers = config.SUBSCRIPTION_TIERS;
const monthlyTiers = [
allTiers.TIER_2_20, allTiers.TIER_6_50, allTiers.TIER_12_100,
allTiers.TIER_25_200, allTiers.TIER_50_400, allTiers.TIER_125_800,
allTiers.TIER_200_1000
];
return monthlyTiers.some(tier => tier.priceId === priceId);
};
export const isYearlyPlan = (priceId: string): boolean => {
const allTiers = config.SUBSCRIPTION_TIERS;
const yearlyTiers = [
allTiers.TIER_2_20_YEARLY, allTiers.TIER_6_50_YEARLY, allTiers.TIER_12_100_YEARLY,
allTiers.TIER_25_200_YEARLY, allTiers.TIER_50_400_YEARLY, allTiers.TIER_125_800_YEARLY,
allTiers.TIER_200_1000_YEARLY
];
return yearlyTiers.some(tier => tier.priceId === priceId);
};
// Tier level mappings for all plan types
const PLAN_TIERS = {
// Monthly plans
[PROD_TIERS.TIER_2_20.priceId]: { tier: 1, type: 'monthly', name: '2h/$20' },
[PROD_TIERS.TIER_6_50.priceId]: { tier: 2, type: 'monthly', name: '6h/$50' },
[PROD_TIERS.TIER_12_100.priceId]: { tier: 3, type: 'monthly', name: '12h/$100' },
[PROD_TIERS.TIER_25_200.priceId]: { tier: 4, type: 'monthly', name: '25h/$200' },
[PROD_TIERS.TIER_50_400.priceId]: { tier: 5, type: 'monthly', name: '50h/$400' },
[PROD_TIERS.TIER_125_800.priceId]: { tier: 6, type: 'monthly', name: '125h/$800' },
[PROD_TIERS.TIER_200_1000.priceId]: { tier: 7, type: 'monthly', name: '200h/$1000' },
// Yearly plans
[PROD_TIERS.TIER_2_20_YEARLY.priceId]: { tier: 1, type: 'yearly', name: '2h/$204/year' },
[PROD_TIERS.TIER_6_50_YEARLY.priceId]: { tier: 2, type: 'yearly', name: '6h/$510/year' },
[PROD_TIERS.TIER_12_100_YEARLY.priceId]: { tier: 3, type: 'yearly', name: '12h/$1020/year' },
[PROD_TIERS.TIER_25_200_YEARLY.priceId]: { tier: 4, type: 'yearly', name: '25h/$2040/year' },
[PROD_TIERS.TIER_50_400_YEARLY.priceId]: { tier: 5, type: 'yearly', name: '50h/$4080/year' },
[PROD_TIERS.TIER_125_800_YEARLY.priceId]: { tier: 6, type: 'yearly', name: '125h/$8160/year' },
[PROD_TIERS.TIER_200_1000_YEARLY.priceId]: { tier: 7, type: 'yearly', name: '200h/$10200/year' },
// Yearly commitment plans
[PROD_TIERS.TIER_2_17_YEARLY_COMMITMENT.priceId]: { tier: 1, type: 'yearly_commitment', name: '2h/$17/month' },
[PROD_TIERS.TIER_6_42_YEARLY_COMMITMENT.priceId]: { tier: 2, type: 'yearly_commitment', name: '6h/$42.50/month' },
[PROD_TIERS.TIER_25_170_YEARLY_COMMITMENT.priceId]: { tier: 4, type: 'yearly_commitment', name: '25h/$170/month' },
// Staging plans
[STAGING_TIERS.TIER_2_20.priceId]: { tier: 1, type: 'monthly', name: '2h/$20' },
[STAGING_TIERS.TIER_6_50.priceId]: { tier: 2, type: 'monthly', name: '6h/$50' },
[STAGING_TIERS.TIER_12_100.priceId]: { tier: 3, type: 'monthly', name: '12h/$100' },
[STAGING_TIERS.TIER_25_200.priceId]: { tier: 4, type: 'monthly', name: '25h/$200' },
[STAGING_TIERS.TIER_50_400.priceId]: { tier: 5, type: 'monthly', name: '50h/$400' },
[STAGING_TIERS.TIER_125_800.priceId]: { tier: 6, type: 'monthly', name: '125h/$800' },
[STAGING_TIERS.TIER_200_1000.priceId]: { tier: 7, type: 'monthly', name: '200h/$1000' },
[STAGING_TIERS.TIER_2_20_YEARLY.priceId]: { tier: 1, type: 'yearly', name: '2h/$204/year' },
[STAGING_TIERS.TIER_6_50_YEARLY.priceId]: { tier: 2, type: 'yearly', name: '6h/$510/year' },
[STAGING_TIERS.TIER_12_100_YEARLY.priceId]: { tier: 3, type: 'yearly', name: '12h/$1020/year' },
[STAGING_TIERS.TIER_25_200_YEARLY.priceId]: { tier: 4, type: 'yearly', name: '25h/$2040/year' },
[STAGING_TIERS.TIER_50_400_YEARLY.priceId]: { tier: 5, type: 'yearly', name: '50h/$4080/year' },
[STAGING_TIERS.TIER_125_800_YEARLY.priceId]: { tier: 6, type: 'yearly', name: '125h/$8160/year' },
[STAGING_TIERS.TIER_200_1000_YEARLY.priceId]: { tier: 7, type: 'yearly', name: '200h/$10200/year' },
[STAGING_TIERS.TIER_2_17_YEARLY_COMMITMENT.priceId]: { tier: 1, type: 'yearly_commitment', name: '2h/$17/month' },
[STAGING_TIERS.TIER_6_42_YEARLY_COMMITMENT.priceId]: { tier: 2, type: 'yearly_commitment', name: '6h/$42.50/month' },
[STAGING_TIERS.TIER_25_170_YEARLY_COMMITMENT.priceId]: { tier: 4, type: 'yearly_commitment', name: '25h/$170/month' },
} as const;
export const getPlanInfo = (priceId: string) => {
return PLAN_TIERS[priceId as keyof typeof PLAN_TIERS] || { tier: 0, type: 'unknown', name: 'Unknown' };
};
// Plan change validation function
export const isPlanChangeAllowed = (currentPriceId: string, newPriceId: string): { allowed: boolean; reason?: string } => {
const currentPlan = getPlanInfo(currentPriceId);
const newPlan = getPlanInfo(newPriceId);
// Allow if same plan
if (currentPriceId === newPriceId) {
return { allowed: true };
}
// Restriction 1: Don't allow downgrade from monthly to lower monthly
if (currentPlan.type === 'monthly' && newPlan.type === 'monthly' && newPlan.tier < currentPlan.tier) {
return {
allowed: false,
reason: 'Downgrading to a lower monthly plan is not allowed. You can only upgrade to a higher tier or switch to yearly billing.'
};
}
// Restriction 2: Don't allow downgrade from yearly commitment to monthly
if (currentPlan.type === 'yearly_commitment' && newPlan.type === 'monthly') {
return {
allowed: false,
reason: 'Downgrading from yearly commitment to monthly is not allowed. You can only upgrade within yearly commitment plans.'
};
}
// Restriction 2b: Don't allow downgrade within yearly commitment plans
if (currentPlan.type === 'yearly_commitment' && newPlan.type === 'yearly_commitment' && newPlan.tier < currentPlan.tier) {
return {
allowed: false,
reason: 'Downgrading to a lower yearly commitment plan is not allowed. You can only upgrade to higher commitment tiers.'
};
}
// Restriction 3: Only allow upgrade from monthly to yearly commitment on same level or above
if (currentPlan.type === 'monthly' && newPlan.type === 'yearly_commitment' && newPlan.tier < currentPlan.tier) {
return {
allowed: false,
reason: 'You can only upgrade to yearly commitment plans at the same tier level or higher.'
};
}
// Allow all other changes (upgrades, yearly to yearly, yearly commitment upgrades, etc.)
return { allowed: true };
};
// Export subscription tier type for typing elsewhere
export type SubscriptionTier = keyof typeof PROD_TIERS;

View File

@ -49,6 +49,7 @@ export interface PricingTier {
features: string[];
stripePriceId: string;
yearlyStripePriceId?: string; // Add yearly price ID support
monthlyCommitmentStripePriceId?: string; // Add monthly commitment with yearly commitment support
upgradePlans: UpgradePlan[];
hidden?: boolean; // Optional property to hide plans from display while keeping them in code
billingPeriod?: 'monthly' | 'yearly'; // Add billing period support
@ -150,6 +151,7 @@ export const siteConfig = {
],
stripePriceId: config.SUBSCRIPTION_TIERS.TIER_2_20.priceId,
yearlyStripePriceId: config.SUBSCRIPTION_TIERS.TIER_2_20_YEARLY.priceId,
monthlyCommitmentStripePriceId: config.SUBSCRIPTION_TIERS.TIER_2_17_YEARLY_COMMITMENT.priceId,
upgradePlans: [],
},
{
@ -172,6 +174,7 @@ export const siteConfig = {
],
stripePriceId: config.SUBSCRIPTION_TIERS.TIER_6_50.priceId,
yearlyStripePriceId: config.SUBSCRIPTION_TIERS.TIER_6_50_YEARLY.priceId,
monthlyCommitmentStripePriceId: config.SUBSCRIPTION_TIERS.TIER_6_42_YEARLY_COMMITMENT.priceId,
upgradePlans: [],
},
{
@ -202,9 +205,9 @@ export const siteConfig = {
yearlyPrice: '$2040',
originalYearlyPrice: '$2400',
discountPercentage: 15,
description: 'For power users and teams',
description: 'For power users',
buttonText: 'Start Free',
buttonColor: 'bg-primary text-white dark:text-black',
buttonColor: 'bg-secondary text-white',
isPopular: false,
hours: '25 hours',
features: [
@ -215,6 +218,7 @@ export const siteConfig = {
],
stripePriceId: config.SUBSCRIPTION_TIERS.TIER_25_200.priceId,
yearlyStripePriceId: config.SUBSCRIPTION_TIERS.TIER_25_200_YEARLY.priceId,
monthlyCommitmentStripePriceId: config.SUBSCRIPTION_TIERS.TIER_25_170_YEARLY_COMMITMENT.priceId,
upgradePlans: [],
},
{
@ -223,7 +227,7 @@ export const siteConfig = {
yearlyPrice: '$4080',
originalYearlyPrice: '$4800',
discountPercentage: 15,
description: 'For large organizations',
description: 'For large teams',
buttonText: 'Start Free',
buttonColor: 'bg-secondary text-white',
isPopular: false,
@ -232,10 +236,8 @@ export const siteConfig = {
'$400 AI token credits/month',
'Private projects',
'Premium AI Models',
'Full Suna AI access',
'Community support',
'Priority support',
'Custom integrations',
'Dedicated account manager',
],
stripePriceId: config.SUBSCRIPTION_TIERS.TIER_50_400.priceId,
yearlyStripePriceId: config.SUBSCRIPTION_TIERS.TIER_50_400_YEARLY.priceId,
@ -248,7 +250,7 @@ export const siteConfig = {
yearlyPrice: '$8160',
originalYearlyPrice: '$9600',
discountPercentage: 15,
description: 'For scaling enterprises',
description: 'For scaling teams',
buttonText: 'Start Free',
buttonColor: 'bg-secondary text-white',
isPopular: false,
@ -257,11 +259,9 @@ export const siteConfig = {
'$800 AI token credits/month',
'Private projects',
'Premium AI Models',
'Full Suna AI access',
'Community support',
'Priority support',
'Custom integrations',
'Dedicated account manager',
'Custom SLA',
],
stripePriceId: config.SUBSCRIPTION_TIERS.TIER_125_800.priceId,
yearlyStripePriceId: config.SUBSCRIPTION_TIERS.TIER_125_800_YEARLY.priceId,
@ -269,12 +269,12 @@ export const siteConfig = {
hidden: true,
},
{
name: 'Premium',
name: 'Max',
price: '$1000',
yearlyPrice: '$10200',
originalYearlyPrice: '$12000',
discountPercentage: 15,
description: 'For maximum scale and performance',
description: 'Maximum performance',
buttonText: 'Start Free',
buttonColor: 'bg-secondary text-white',
isPopular: false,
@ -283,12 +283,10 @@ export const siteConfig = {
'$1000 AI token credits/month',
'Private projects',
'Premium AI Models',
'Full Suna AI access',
'Priority support',
'Custom integrations',
'Dedicated account manager',
'Custom SLA',
'White-label options',
'Custom deployment',
],
stripePriceId: config.SUBSCRIPTION_TIERS.TIER_200_1000.priceId,
yearlyStripePriceId: config.SUBSCRIPTION_TIERS.TIER_200_1000_YEARLY.priceId,