mirror of https://github.com/kortix-ai/suna.git
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:
parent
6b137e4fc8
commit
a7b142ed74
File diff suppressed because it is too large
Load Diff
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
|
||||
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
}
|
|
@ -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';
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
|
|
Loading…
Reference in New Issue