mirror of https://github.com/kortix-ai/suna.git
Merge pull request #1597 from escapade-mckv/billing-improvements-clean
Billing improvements clean
This commit is contained in:
commit
1367e7e403
|
@ -109,14 +109,11 @@ class SubscriptionService:
|
|||
expand=['items.data.price']
|
||||
)
|
||||
|
||||
if stripe_subscription.get('price_id'):
|
||||
price_id = stripe_subscription['price_id']
|
||||
elif (stripe_subscription.get('items') and
|
||||
if (stripe_subscription.get('items') and
|
||||
len(stripe_subscription['items']['data']) > 0 and
|
||||
stripe_subscription['items']['data'][0].get('price')):
|
||||
price_id = stripe_subscription['items']['data'][0]['price']['id']
|
||||
|
||||
# Handle trial subscriptions specially
|
||||
if stripe_subscription['status'] == 'trialing' and trial_status == 'active':
|
||||
subscription_data = {
|
||||
'id': stripe_subscription['id'],
|
||||
|
|
|
@ -27,24 +27,6 @@ class TrialService:
|
|||
'message': 'Trials are not enabled'
|
||||
}
|
||||
|
||||
trial_history_result = await client.from_('trial_history')\
|
||||
.select('id, started_at, ended_at, converted_to_paid')\
|
||||
.eq('account_id', account_id)\
|
||||
.execute()
|
||||
|
||||
if trial_history_result.data and len(trial_history_result.data) > 0:
|
||||
history = trial_history_result.data[0]
|
||||
return {
|
||||
'has_trial': False,
|
||||
'trial_status': 'used',
|
||||
'message': 'You have already used your free trial',
|
||||
'trial_history': {
|
||||
'started_at': history.get('started_at'),
|
||||
'ended_at': history.get('ended_at'),
|
||||
'converted_to_paid': history.get('converted_to_paid', False)
|
||||
}
|
||||
}
|
||||
|
||||
account_result = await client.from_('credit_accounts').select(
|
||||
'tier, trial_status, trial_ends_at, stripe_subscription_id'
|
||||
).eq('account_id', account_id).execute()
|
||||
|
@ -53,14 +35,34 @@ class TrialService:
|
|||
account = account_result.data[0]
|
||||
trial_status = account.get('trial_status', 'none')
|
||||
|
||||
if trial_status and trial_status != 'none':
|
||||
if trial_status == 'active':
|
||||
return {
|
||||
'has_trial': True,
|
||||
'trial_status': trial_status,
|
||||
'trial_ends_at': account.get('trial_ends_at'),
|
||||
'tier': account.get('tier')
|
||||
}
|
||||
|
||||
if trial_status in ['expired', 'converted', 'cancelled']:
|
||||
trial_history_result = await client.from_('trial_history')\
|
||||
.select('id, started_at, ended_at, converted_to_paid')\
|
||||
.eq('account_id', account_id)\
|
||||
.execute()
|
||||
|
||||
if trial_history_result.data and len(trial_history_result.data) > 0:
|
||||
history = trial_history_result.data[0]
|
||||
return {
|
||||
'has_trial': False,
|
||||
'trial_status': 'used',
|
||||
'message': 'You have already used your free trial',
|
||||
'trial_history': {
|
||||
'started_at': history.get('started_at'),
|
||||
'ended_at': history.get('ended_at'),
|
||||
'converted_to_paid': history.get('converted_to_paid', False)
|
||||
}
|
||||
}
|
||||
|
||||
# No trial history - user can start a trial
|
||||
return {
|
||||
'has_trial': False,
|
||||
'trial_status': 'none',
|
||||
|
|
|
@ -315,7 +315,8 @@ class WebhookService:
|
|||
|
||||
await client.from_('credit_accounts').update({
|
||||
'trial_status': 'converted',
|
||||
'tier': tier_name
|
||||
'tier': tier_name,
|
||||
'stripe_subscription_id': subscription['id']
|
||||
}).eq('account_id', account_id).execute()
|
||||
|
||||
await client.from_('trial_history').update({
|
||||
|
@ -323,7 +324,7 @@ class WebhookService:
|
|||
'converted_to_paid': True
|
||||
}).eq('account_id', account_id).is_('ended_at', 'null').execute()
|
||||
|
||||
logger.info(f"[WEBHOOK] Trial conversion completed for account {account_id} to tier {tier_name}")
|
||||
logger.info(f"[WEBHOOK] Trial conversion completed for account {account_id} to tier {tier_name}, subscription: {subscription['id']}")
|
||||
|
||||
elif subscription.status == 'canceled':
|
||||
logger.info(f"[WEBHOOK] Trial cancelled for account {account_id}")
|
||||
|
@ -658,7 +659,7 @@ class WebhookService:
|
|||
account_id = customer_result.data[0]['account_id']
|
||||
|
||||
account_result = await client.from_('credit_accounts')\
|
||||
.select('tier, last_grant_date, next_credit_grant, billing_cycle_anchor, last_processed_invoice_id')\
|
||||
.select('tier, last_grant_date, next_credit_grant, billing_cycle_anchor, last_processed_invoice_id, trial_status')\
|
||||
.eq('account_id', account_id)\
|
||||
.execute()
|
||||
|
||||
|
@ -667,24 +668,24 @@ class WebhookService:
|
|||
|
||||
account = account_result.data[0]
|
||||
tier = account['tier']
|
||||
trial_status = account.get('trial_status')
|
||||
period_start_dt = datetime.fromtimestamp(period_start, tz=timezone.utc)
|
||||
|
||||
# Primary idempotency check: Have we already processed this invoice?
|
||||
if account.get('last_processed_invoice_id') == invoice_id:
|
||||
logger.info(f"[IDEMPOTENCY] Invoice {invoice_id} already processed for account {account_id}")
|
||||
return
|
||||
|
||||
# Secondary idempotency check: Skip if we already processed this renewal period
|
||||
if account.get('last_grant_date'):
|
||||
last_grant = datetime.fromisoformat(account['last_grant_date'].replace('Z', '+00:00'))
|
||||
|
||||
# Check if this renewal was already processed (within 24 hours window)
|
||||
if account.get('last_grant_date') and trial_status != 'converted':
|
||||
last_grant = datetime.fromisoformat(account['last_grant_date'].replace('Z', '+00:00'))
|
||||
|
||||
time_diff = abs((period_start_dt - last_grant).total_seconds())
|
||||
if time_diff < 86400: # 24 hours in seconds
|
||||
if time_diff < 300:
|
||||
logger.info(f"[IDEMPOTENCY] Skipping renewal for user {account_id} - "
|
||||
f"already processed at {account['last_grant_date']} "
|
||||
f"(current period_start: {period_start_dt.isoformat()}, diff: {time_diff}s)")
|
||||
return
|
||||
elif trial_status == 'converted':
|
||||
logger.info(f"[WEBHOOK] Processing first payment after trial conversion for account {account_id}")
|
||||
|
||||
monthly_credits = get_monthly_credits(tier)
|
||||
if monthly_credits > 0:
|
||||
|
@ -709,11 +710,17 @@ class WebhookService:
|
|||
|
||||
next_grant = datetime.fromtimestamp(period_end, tz=timezone.utc)
|
||||
|
||||
await client.from_('credit_accounts').update({
|
||||
update_data = {
|
||||
'last_grant_date': period_start_dt.isoformat(),
|
||||
'next_credit_grant': next_grant.isoformat(),
|
||||
'last_processed_invoice_id': invoice_id
|
||||
}).eq('account_id', account_id).execute()
|
||||
}
|
||||
|
||||
if trial_status == 'converted':
|
||||
update_data['trial_status'] = 'none'
|
||||
logger.info(f"[WEBHOOK] Clearing converted status for account {account_id}")
|
||||
|
||||
await client.from_('credit_accounts').update(update_data).eq('account_id', account_id).execute()
|
||||
|
||||
final_state = await client.from_('credit_accounts').select(
|
||||
'balance, expiring_credits, non_expiring_credits'
|
||||
|
|
|
@ -0,0 +1,2 @@
|
|||
ALTER TABLE credit_accounts
|
||||
ADD COLUMN IF NOT EXISTS last_processed_invoice_id VARCHAR(255);
|
|
@ -1,17 +1,20 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Sparkles, CreditCard, Zap, Shield, ArrowRight, CheckCircle, Loader2 } from 'lucide-react';
|
||||
import { Sparkles, CreditCard, Zap, Shield, ArrowRight, CheckCircle, Loader2, Clock, XCircle, LogOut } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useSubscription } from '@/hooks/react-query/use-billing-v2';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTrialStatus, useStartTrial } from '@/hooks/react-query/billing/use-trial-status';
|
||||
import { useSubscription } from '@/hooks/react-query/use-billing-v2';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { KortixLogo } from '@/components/sidebar/kortix-logo';
|
||||
import Link from 'next/link';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { createClient } from '@/lib/supabase/client';
|
||||
import { clearUserLocalStorage } from '@/lib/utils/clear-local-storage';
|
||||
|
||||
export default function ActivateTrialPage() {
|
||||
const router = useRouter();
|
||||
|
@ -54,6 +57,13 @@ export default function ActivateTrialPage() {
|
|||
}
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
const supabase = createClient();
|
||||
await supabase.auth.signOut();
|
||||
clearUserLocalStorage();
|
||||
router.push('/auth');
|
||||
};
|
||||
|
||||
const isLoading = isLoadingSubscription || isLoadingTrial;
|
||||
|
||||
if (isLoading) {
|
||||
|
@ -75,6 +85,17 @@ export default function ActivateTrialPage() {
|
|||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4">
|
||||
<div className="absolute top-4 right-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleLogout}
|
||||
className="gap-2"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
Log Out
|
||||
</Button>
|
||||
</div>
|
||||
<Card className="w-full max-w-2xl border-2 shadow-none bg-transparent border-none">
|
||||
<CardHeader className="text-center space-y-4">
|
||||
<div>
|
||||
|
|
|
@ -6,11 +6,13 @@ import { Badge } from '@/components/ui/badge';
|
|||
import { Button } from '@/components/ui/button';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { PricingSection } from '@/components/home/sections/pricing-section';
|
||||
import { AlertTriangle, Clock, CreditCard } from 'lucide-react';
|
||||
import { AlertTriangle, Clock, CreditCard, LogOut } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { apiClient, backendApi } from '@/lib/api-client';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { KortixLogo } from '@/components/sidebar/kortix-logo';
|
||||
import { createClient } from '@/lib/supabase/client';
|
||||
import { clearUserLocalStorage } from '@/lib/utils/clear-local-storage';
|
||||
|
||||
export default function SubscriptionRequiredPage() {
|
||||
const [isCheckingStatus, setIsCheckingStatus] = useState(true);
|
||||
|
@ -23,12 +25,18 @@ export default function SubscriptionRequiredPage() {
|
|||
|
||||
const checkBillingStatus = async () => {
|
||||
try {
|
||||
const response = await apiClient.get('/billing/check-status');
|
||||
const response = await backendApi.get('/billing/v2/subscription');
|
||||
setBillingStatus(response.data);
|
||||
if (response.data.is_trial ||
|
||||
(response.data.subscription?.tier &&
|
||||
response.data.subscription.tier !== 'none' &&
|
||||
response.data.subscription.tier !== 'free')) {
|
||||
const hasActiveSubscription = response.data.subscription &&
|
||||
response.data.subscription.status === 'active' &&
|
||||
!response.data.subscription.cancel_at_period_end;
|
||||
|
||||
const hasActiveTrial = response.data.trial_status === 'active';
|
||||
const hasActiveTier = response.data.tier &&
|
||||
response.data.tier.name !== 'none' &&
|
||||
response.data.tier.name !== 'free';
|
||||
|
||||
if ((hasActiveSubscription && hasActiveTier) || (hasActiveTrial && hasActiveTier)) {
|
||||
router.push('/dashboard');
|
||||
}
|
||||
} catch (error) {
|
||||
|
@ -44,6 +52,13 @@ export default function SubscriptionRequiredPage() {
|
|||
}, 1000);
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
const supabase = createClient();
|
||||
await supabase.auth.signOut();
|
||||
clearUserLocalStorage();
|
||||
router.push('/auth');
|
||||
};
|
||||
|
||||
if (isCheckingStatus) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-background to-muted/20 flex items-center justify-center p-4">
|
||||
|
@ -65,15 +80,30 @@ export default function SubscriptionRequiredPage() {
|
|||
}
|
||||
|
||||
const isTrialExpired = billingStatus?.trial_status === 'expired' ||
|
||||
billingStatus?.trial_status === 'cancelled';
|
||||
billingStatus?.trial_status === 'cancelled' ||
|
||||
billingStatus?.trial_status === 'used';
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-background to-muted/20 py-12 px-4">
|
||||
<div className="max-w-6xl mx-auto space-y-8">
|
||||
<div className="text-center space-y-4">
|
||||
<div className="text-2xl font-bold flex items-center justify-center gap-2">
|
||||
<KortixLogo/>
|
||||
<span>{isTrialExpired ? 'Your Trial Has Ended' : 'Subscription Required'}</span>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1" />
|
||||
<div className="text-2xl font-bold flex items-center justify-center gap-2">
|
||||
<KortixLogo/>
|
||||
<span>{isTrialExpired ? 'Your Trial Has Ended' : 'Subscription Required'}</span>
|
||||
</div>
|
||||
<div className="flex-1 flex justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleLogout}
|
||||
className="gap-2"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
Log Out
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-md text-muted-foreground max-w-2xl mx-auto">
|
||||
{isTrialExpired
|
||||
|
@ -90,8 +120,8 @@ export default function SubscriptionRequiredPage() {
|
|||
<div className="text-center text-sm text-muted-foreground -mt-10">
|
||||
<p>
|
||||
Questions? Contact us at{' '}
|
||||
<a href="mailto:support@suna.ai" className="underline hover:text-primary">
|
||||
support@suna.ai
|
||||
<a href="mailto:support@kortix.ai" className="underline hover:text-primary">
|
||||
support@kortix.ai
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
|
|
@ -303,18 +303,15 @@ function PricingTier({
|
|||
(p) => p.stripePriceId === currentSubscription?.price_id || p.yearlyStripePriceId === currentSubscription?.price_id,
|
||||
);
|
||||
|
||||
// Simple check - what plan is the user on?
|
||||
const userPlanName = currentSubscription?.plan_name || 'none';
|
||||
|
||||
// Check if this tier matches the user's current plan
|
||||
// For trial users on $20 plan, for regular users check price_id
|
||||
const isCurrentActivePlan = isAuthenticated && (
|
||||
// Regular subscription match
|
||||
currentSubscription?.price_id === priceId ||
|
||||
// Trial user on $20 plan
|
||||
(userPlanName === 'trial' && tier.price === '$20' && billingPeriod === 'monthly') ||
|
||||
// User on tier_1_20 (the $20 plan)
|
||||
(userPlanName === 'tier_1_20' && tier.price === '$20' && currentSubscription?.price_id === priceId)
|
||||
(userPlanName === 'tier_2_20' && tier.price === '$20' && billingPeriod === 'monthly') ||
|
||||
(currentSubscription?.subscription &&
|
||||
userPlanName === 'tier_2_20' &&
|
||||
tier.price === '$20' &&
|
||||
currentSubscription?.subscription?.status === 'active')
|
||||
);
|
||||
|
||||
const isScheduled = isAuthenticated && currentSubscription?.has_schedule;
|
||||
|
|
|
@ -77,6 +77,10 @@ export async function middleware(request: NextRequest) {
|
|||
.single();
|
||||
|
||||
if (!accounts) {
|
||||
// Don't redirect if already on activate-trial page
|
||||
if (pathname === '/activate-trial') {
|
||||
return supabaseResponse;
|
||||
}
|
||||
const url = request.nextUrl.clone();
|
||||
url.pathname = '/activate-trial';
|
||||
return NextResponse.redirect(url);
|
||||
|
@ -99,10 +103,18 @@ export async function middleware(request: NextRequest) {
|
|||
|
||||
if (!creditAccount) {
|
||||
if (hasUsedTrial) {
|
||||
// Don't redirect if already on subscription page
|
||||
if (pathname === '/subscription') {
|
||||
return supabaseResponse;
|
||||
}
|
||||
const url = request.nextUrl.clone();
|
||||
url.pathname = '/subscription';
|
||||
return NextResponse.redirect(url);
|
||||
} else {
|
||||
// Don't redirect if already on activate-trial page
|
||||
if (pathname === '/activate-trial') {
|
||||
return supabaseResponse;
|
||||
}
|
||||
const url = request.nextUrl.clone();
|
||||
url.pathname = '/activate-trial';
|
||||
return NextResponse.redirect(url);
|
||||
|
@ -119,15 +131,27 @@ export async function middleware(request: NextRequest) {
|
|||
|
||||
if (!hasTier && !hasActiveTrial && !trialConverted) {
|
||||
if (hasUsedTrial || trialExpired || creditAccount.trial_status === 'cancelled') {
|
||||
// Don't redirect if already on subscription page
|
||||
if (pathname === '/subscription') {
|
||||
return supabaseResponse;
|
||||
}
|
||||
const url = request.nextUrl.clone();
|
||||
url.pathname = '/subscription';
|
||||
return NextResponse.redirect(url);
|
||||
} else {
|
||||
// Don't redirect if already on activate-trial page
|
||||
if (pathname === '/activate-trial') {
|
||||
return supabaseResponse;
|
||||
}
|
||||
const url = request.nextUrl.clone();
|
||||
url.pathname = '/activate-trial';
|
||||
return NextResponse.redirect(url);
|
||||
}
|
||||
} else if ((trialExpired || trialConverted) && !hasTier) {
|
||||
// Don't redirect if already on subscription page
|
||||
if (pathname === '/subscription') {
|
||||
return supabaseResponse;
|
||||
}
|
||||
const url = request.nextUrl.clone();
|
||||
url.pathname = '/subscription';
|
||||
return NextResponse.redirect(url);
|
||||
|
|
Loading…
Reference in New Issue