From eeeea7df5f2c93f9e28574ff9c79cd0ca4ab5227 Mon Sep 17 00:00:00 2001 From: Saumya Date: Wed, 10 Sep 2025 01:16:39 +0530 Subject: [PATCH 1/2] fix time tracking between events --- backend/billing/subscription_service.py | 5 +-- backend/billing/webhook_service.py | 31 ++++++++++++------- .../20250909184956_last_invoice_tracking.sql | 2 ++ .../home/sections/pricing-section.tsx | 13 +++----- 4 files changed, 27 insertions(+), 24 deletions(-) create mode 100644 backend/supabase/migrations/20250909184956_last_invoice_tracking.sql diff --git a/backend/billing/subscription_service.py b/backend/billing/subscription_service.py index 2f0a8600..abbb1de5 100644 --- a/backend/billing/subscription_service.py +++ b/backend/billing/subscription_service.py @@ -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'], diff --git a/backend/billing/webhook_service.py b/backend/billing/webhook_service.py index c2c73e89..02370eb2 100644 --- a/backend/billing/webhook_service.py +++ b/backend/billing/webhook_service.py @@ -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' diff --git a/backend/supabase/migrations/20250909184956_last_invoice_tracking.sql b/backend/supabase/migrations/20250909184956_last_invoice_tracking.sql new file mode 100644 index 00000000..8981e575 --- /dev/null +++ b/backend/supabase/migrations/20250909184956_last_invoice_tracking.sql @@ -0,0 +1,2 @@ +ALTER TABLE credit_accounts +ADD COLUMN IF NOT EXISTS last_processed_invoice_id VARCHAR(255); \ No newline at end of file diff --git a/frontend/src/components/home/sections/pricing-section.tsx b/frontend/src/components/home/sections/pricing-section.tsx index 886c46db..7a9e5e59 100644 --- a/frontend/src/components/home/sections/pricing-section.tsx +++ b/frontend/src/components/home/sections/pricing-section.tsx @@ -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; From 113be724a17a1f25b878bafdab3a6e238dfc19ed Mon Sep 17 00:00:00 2001 From: Saumya Date: Wed, 10 Sep 2025 01:45:56 +0530 Subject: [PATCH 2/2] fix ui issues --- backend/billing/trial_service.py | 40 +++++++++-------- frontend/src/app/activate-trial/page.tsx | 29 ++++++++++-- frontend/src/app/subscription/page.tsx | 56 ++++++++++++++++++------ frontend/src/middleware.ts | 24 ++++++++++ 4 files changed, 113 insertions(+), 36 deletions(-) diff --git a/backend/billing/trial_service.py b/backend/billing/trial_service.py index fc1246ed..d212e487 100644 --- a/backend/billing/trial_service.py +++ b/backend/billing/trial_service.py @@ -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', diff --git a/frontend/src/app/activate-trial/page.tsx b/frontend/src/app/activate-trial/page.tsx index 71e188f3..de5ba854 100644 --- a/frontend/src/app/activate-trial/page.tsx +++ b/frontend/src/app/activate-trial/page.tsx @@ -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 (
+
+ +
diff --git a/frontend/src/app/subscription/page.tsx b/frontend/src/app/subscription/page.tsx index ec1e19eb..864db9a6 100644 --- a/frontend/src/app/subscription/page.tsx +++ b/frontend/src/app/subscription/page.tsx @@ -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 (
@@ -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 (
-
- - {isTrialExpired ? 'Your Trial Has Ended' : 'Subscription Required'} +
+
+
+ + {isTrialExpired ? 'Your Trial Has Ended' : 'Subscription Required'} +
+
+ +

{isTrialExpired @@ -90,8 +120,8 @@ export default function SubscriptionRequiredPage() {

Questions? Contact us at{' '} - - support@suna.ai + + support@kortix.ai

diff --git a/frontend/src/middleware.ts b/frontend/src/middleware.ts index 23cfddfd..e2558e47 100644 --- a/frontend/src/middleware.ts +++ b/frontend/src/middleware.ts @@ -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);