From a7b142ed748c7de706a5dd81b0836ed5eecf15cd Mon Sep 17 00:00:00 2001 From: mykonos-ibiza <222371740+mykonos-ibiza@users.noreply.github.com> Date: Thu, 31 Jul 2025 02:16:08 +0530 Subject: [PATCH] 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. --- backend/services/billing.py | 833 ++++++++++++++---- backend/utils/config.py | 29 + .../settings/billing/page.tsx | 54 +- .../[accountSlug]/settings/billing/page.tsx | 57 +- .../billing/account-billing-status.tsx | 30 +- .../billing/subscription-management-modal.tsx | 236 +++++ .../subscription-status-management.tsx | 343 ++++++++ .../home/sections/pricing-section.tsx | 253 +++--- .../accounts/use-account-by-slug.ts | 22 + frontend/src/hooks/react-query/index.ts | 2 + .../hooks/react-query/subscriptions/keys.ts | 1 + .../subscriptions/use-subscriptions.ts | 12 + frontend/src/lib/api.ts | 270 +++++- frontend/src/lib/config.ts | 208 ++++- frontend/src/lib/home.tsx | 28 +- 15 files changed, 1983 insertions(+), 395 deletions(-) create mode 100644 frontend/src/components/billing/subscription-management-modal.tsx create mode 100644 frontend/src/components/billing/subscription-status-management.tsx create mode 100644 frontend/src/hooks/react-query/accounts/use-account-by-slug.ts diff --git a/backend/services/billing.py b/backend/services/billing.py index dff9dd6d..731290fe 100644 --- a/backend/services/billing.py +++ b/backend/services/billing.py @@ -7,7 +7,7 @@ stripe listen --forward-to localhost:8000/api/billing/webhook from fastapi import APIRouter, HTTPException, Depends, Request from typing import Optional, Dict, Tuple import stripe -from datetime import datetime, timezone +from datetime import datetime, timezone, timedelta from utils.logger import logger from utils.config import config, EnvMode from services.supabase import DBConnection @@ -26,6 +26,72 @@ TOKEN_PRICE_MULTIPLIER = 1.5 # Initialize router router = APIRouter(prefix="/billing", tags=["billing"]) +# Plan validation functions +def get_plan_info(price_id: str) -> dict: + """Get plan information including tier level and type.""" + # Production plans mapping + PLAN_TIERS = { + # Monthly plans + config.STRIPE_TIER_2_20_ID: {'tier': 1, 'type': 'monthly', 'name': '2h/$20'}, + config.STRIPE_TIER_6_50_ID: {'tier': 2, 'type': 'monthly', 'name': '6h/$50'}, + config.STRIPE_TIER_12_100_ID: {'tier': 3, 'type': 'monthly', 'name': '12h/$100'}, + config.STRIPE_TIER_25_200_ID: {'tier': 4, 'type': 'monthly', 'name': '25h/$200'}, + config.STRIPE_TIER_50_400_ID: {'tier': 5, 'type': 'monthly', 'name': '50h/$400'}, + config.STRIPE_TIER_125_800_ID: {'tier': 6, 'type': 'monthly', 'name': '125h/$800'}, + config.STRIPE_TIER_200_1000_ID: {'tier': 7, 'type': 'monthly', 'name': '200h/$1000'}, + + # Yearly plans + config.STRIPE_TIER_2_20_YEARLY_ID: {'tier': 1, 'type': 'yearly', 'name': '2h/$204/year'}, + config.STRIPE_TIER_6_50_YEARLY_ID: {'tier': 2, 'type': 'yearly', 'name': '6h/$510/year'}, + config.STRIPE_TIER_12_100_YEARLY_ID: {'tier': 3, 'type': 'yearly', 'name': '12h/$1020/year'}, + config.STRIPE_TIER_25_200_YEARLY_ID: {'tier': 4, 'type': 'yearly', 'name': '25h/$2040/year'}, + config.STRIPE_TIER_50_400_YEARLY_ID: {'tier': 5, 'type': 'yearly', 'name': '50h/$4080/year'}, + config.STRIPE_TIER_125_800_YEARLY_ID: {'tier': 6, 'type': 'yearly', 'name': '125h/$8160/year'}, + config.STRIPE_TIER_200_1000_YEARLY_ID: {'tier': 7, 'type': 'yearly', 'name': '200h/$10200/year'}, + + # Yearly commitment plans + config.STRIPE_TIER_2_17_YEARLY_COMMITMENT_ID: {'tier': 1, 'type': 'yearly_commitment', 'name': '2h/$17/month'}, + config.STRIPE_TIER_6_42_YEARLY_COMMITMENT_ID: {'tier': 2, 'type': 'yearly_commitment', 'name': '6h/$42.50/month'}, + config.STRIPE_TIER_25_170_YEARLY_COMMITMENT_ID: {'tier': 4, 'type': 'yearly_commitment', 'name': '25h/$170/month'}, + } + + return PLAN_TIERS.get(price_id, {'tier': 0, 'type': 'unknown', 'name': 'Unknown'}) + +def is_plan_change_allowed(current_price_id: str, new_price_id: str) -> tuple[bool, str]: + """ + Validate if a plan change is allowed based on business rules. + + Returns: + Tuple of (is_allowed, reason_if_not_allowed) + """ + current_plan = get_plan_info(current_price_id) + new_plan = get_plan_info(new_price_id) + + # Allow if same plan + if current_price_id == new_price_id: + return True, "" + + # Restriction 1: Don't allow downgrade from monthly to lower monthly + if current_plan['type'] == 'monthly' and new_plan['type'] == 'monthly' and new_plan['tier'] < current_plan['tier']: + return False, "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 current_plan['type'] == 'yearly_commitment' and new_plan['type'] == 'monthly': + return False, "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 current_plan['type'] == 'yearly_commitment' and new_plan['type'] == 'yearly_commitment' and new_plan['tier'] < current_plan['tier']: + return False, "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 current_plan['type'] == 'monthly' and new_plan['type'] == 'yearly_commitment' and new_plan['tier'] < current_plan['tier']: + return False, "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 True, "" + +# Simplified yearly commitment logic - no subscription schedules needed + def get_model_pricing(model: str) -> tuple[float, float] | None: """ Get pricing for a model. Returns (input_cost_per_million, output_cost_per_million) or None. @@ -59,6 +125,10 @@ SUBSCRIPTION_TIERS = { config.STRIPE_TIER_50_400_YEARLY_ID: {'name': 'tier_50_400', 'minutes': 3000, 'cost': 400 + 5}, # 50 hours/month, $4080/year config.STRIPE_TIER_125_800_YEARLY_ID: {'name': 'tier_125_800', 'minutes': 7500, 'cost': 800 + 5}, # 125 hours/month, $8160/year config.STRIPE_TIER_200_1000_YEARLY_ID: {'name': 'tier_200_1000', 'minutes': 12000, 'cost': 1000 + 5}, # 200 hours/month, $10200/year + # Yearly commitment tiers (15% discount, monthly payments with 12-month commitment via schedules) + config.STRIPE_TIER_2_17_YEARLY_COMMITMENT_ID: {'name': 'tier_2_17_yearly_commitment', 'minutes': 120, 'cost': 20 + 5}, # 2 hours/month, $17/month (12-month commitment) + config.STRIPE_TIER_6_42_YEARLY_COMMITMENT_ID: {'name': 'tier_6_42_yearly_commitment', 'minutes': 360, 'cost': 50 + 5}, # 6 hours/month, $42.50/month (12-month commitment) + config.STRIPE_TIER_25_170_YEARLY_COMMITMENT_ID: {'name': 'tier_25_170_yearly_commitment', 'minutes': 1500, 'cost': 200 + 5}, # 25 hours/month, $170/month (12-month commitment) } # Pydantic models for request/response validation @@ -67,6 +137,7 @@ class CreateCheckoutSessionRequest(BaseModel): success_url: str cancel_url: str tolt_referral: Optional[str] = None + commitment_type: Optional[str] = "monthly" # "monthly", "yearly", or "yearly_commitment" class CreatePortalSessionRequest(BaseModel): return_url: str @@ -86,6 +157,9 @@ class SubscriptionStatus(BaseModel): scheduled_plan_name: Optional[str] = None scheduled_price_id: Optional[str] = None # Added scheduled price ID scheduled_change_date: Optional[datetime] = None + # Subscription data for frontend components + subscription_id: Optional[str] = None + subscription: Optional[Dict] = None # Helper functions async def get_stripe_customer_id(client, user_id: str) -> Optional[str]: @@ -102,7 +176,7 @@ async def get_stripe_customer_id(client, user_id: str) -> Optional[str]: async def create_stripe_customer(client, user_id: str, email: str) -> str: """Create a new Stripe customer for a user.""" # Create customer in Stripe - customer = stripe.Customer.create( + customer = await stripe.Customer.create_async( email=email, metadata={"user_id": user_id} ) @@ -129,7 +203,7 @@ async def get_user_subscription(user_id: str) -> Optional[Dict]: return None # Get all active subscriptions for the customer - subscriptions = stripe.Subscription.list( + subscriptions = await stripe.Subscription.list_async( customer=customer_id, status='active' ) @@ -142,26 +216,23 @@ async def get_user_subscription(user_id: str) -> Optional[Dict]: # Filter subscriptions to only include our product's subscriptions our_subscriptions = [] for sub in subscriptions['data']: - # Get the first subscription item - if sub.get('items') and sub['items'].get('data') and len(sub['items']['data']) > 0: - item = sub['items']['data'][0] - if item.get('price') and item['price'].get('id') in [ + # Check if subscription items contain any of our price IDs + for item in sub.get('items', {}).get('data', []): + price_id = item.get('price', {}).get('id') + if price_id in [ config.STRIPE_FREE_TIER_ID, - config.STRIPE_TIER_2_20_ID, - config.STRIPE_TIER_6_50_ID, - config.STRIPE_TIER_12_100_ID, - config.STRIPE_TIER_25_200_ID, - config.STRIPE_TIER_50_400_ID, - config.STRIPE_TIER_125_800_ID, + config.STRIPE_TIER_2_20_ID, config.STRIPE_TIER_6_50_ID, config.STRIPE_TIER_12_100_ID, + config.STRIPE_TIER_25_200_ID, config.STRIPE_TIER_50_400_ID, config.STRIPE_TIER_125_800_ID, config.STRIPE_TIER_200_1000_ID, # Yearly tiers - config.STRIPE_TIER_2_20_YEARLY_ID, - config.STRIPE_TIER_6_50_YEARLY_ID, - config.STRIPE_TIER_12_100_YEARLY_ID, - config.STRIPE_TIER_25_200_YEARLY_ID, - config.STRIPE_TIER_50_400_YEARLY_ID, - config.STRIPE_TIER_125_800_YEARLY_ID, - config.STRIPE_TIER_200_1000_YEARLY_ID + config.STRIPE_TIER_2_20_YEARLY_ID, config.STRIPE_TIER_6_50_YEARLY_ID, + config.STRIPE_TIER_12_100_YEARLY_ID, config.STRIPE_TIER_25_200_YEARLY_ID, + config.STRIPE_TIER_50_400_YEARLY_ID, config.STRIPE_TIER_125_800_YEARLY_ID, + config.STRIPE_TIER_200_1000_YEARLY_ID, + # Yearly commitment tiers (monthly payments with 12-month commitment) + config.STRIPE_TIER_2_17_YEARLY_COMMITMENT_ID, + config.STRIPE_TIER_6_42_YEARLY_COMMITMENT_ID, + config.STRIPE_TIER_25_170_YEARLY_COMMITMENT_ID ]: our_subscriptions.append(sub) @@ -179,7 +250,7 @@ async def get_user_subscription(user_id: str) -> Optional[Dict]: for sub in our_subscriptions: if sub['id'] != most_recent['id']: try: - stripe.Subscription.modify( + await stripe.Subscription.modify_async( sub['id'], cancel_at_period_end=True ) @@ -504,6 +575,87 @@ async def check_billing_status(client, user_id: str) -> Tuple[bool, str, Optiona return True, "OK", subscription +async def check_subscription_commitment(subscription_id: str) -> dict: + """ + Check if a subscription has an active yearly commitment that prevents cancellation. + Simple logic: commitment lasts 1 year from subscription creation date. + """ + try: + subscription = await stripe.Subscription.retrieve_async(subscription_id) + + # Get the price ID from subscription items + price_id = None + if subscription.get('items') and subscription['items'].get('data') and len(subscription['items']['data']) > 0: + price_id = subscription['items']['data'][0]['price']['id'] + + # Check if subscription has commitment metadata OR uses a yearly commitment price ID + commitment_type = subscription.metadata.get('commitment_type') + + # Yearly commitment price IDs + yearly_commitment_price_ids = [ + config.STRIPE_TIER_2_17_YEARLY_COMMITMENT_ID, + config.STRIPE_TIER_6_42_YEARLY_COMMITMENT_ID, + config.STRIPE_TIER_25_170_YEARLY_COMMITMENT_ID + ] + + is_yearly_commitment = ( + commitment_type == 'yearly_commitment' or + price_id in yearly_commitment_price_ids + ) + + if is_yearly_commitment: + # Calculate commitment period: 1 year from subscription creation + subscription_start = subscription.created + current_time = int(time.time()) + start_date = datetime.fromtimestamp(subscription_start, tz=timezone.utc) + commitment_end_date = start_date.replace(year=start_date.year + 1) + commitment_end_timestamp = int(commitment_end_date.timestamp()) + + if current_time < commitment_end_timestamp: + # Still in commitment period + current_date = datetime.fromtimestamp(current_time, tz=timezone.utc) + months_remaining = (commitment_end_date.year - current_date.year) * 12 + (commitment_end_date.month - current_date.month) + if current_date.day > commitment_end_date.day: + months_remaining -= 1 + months_remaining = max(0, months_remaining) + + logger.info(f"Subscription {subscription_id} has active yearly commitment: {months_remaining} months remaining") + + return { + 'has_commitment': True, + 'commitment_type': 'yearly_commitment', + 'months_remaining': months_remaining, + 'can_cancel': False, + 'commitment_end_date': commitment_end_date.isoformat(), + 'subscription_start_date': start_date.isoformat(), + 'price_id': price_id + } + else: + # Commitment period has ended + logger.info(f"Subscription {subscription_id} yearly commitment period has ended") + return { + 'has_commitment': False, + 'commitment_type': 'yearly_commitment', + 'commitment_completed': True, + 'can_cancel': True, + 'subscription_start_date': start_date.isoformat(), + 'price_id': price_id + } + + # No commitment + return { + 'has_commitment': False, + 'can_cancel': True, + 'price_id': price_id + } + + except Exception as e: + logger.error(f"Error checking subscription commitment: {str(e)}", exc_info=True) + return { + 'has_commitment': False, + 'can_cancel': True + } + # API endpoints @router.post("/create-checkout-session") async def create_checkout_session( @@ -527,7 +679,7 @@ async def create_checkout_session( # Get the target price and product ID try: - price = stripe.Price.retrieve(request.price_id, expand=['product']) + price = await stripe.Price.retrieve_async(request.price_id, expand=['product']) product_id = price['product']['id'] except stripe.error.InvalidRequestError: raise HTTPException(status_code=400, detail=f"Invalid price ID: {request.price_id}") @@ -561,14 +713,134 @@ async def create_checkout_session( } } + # Validate plan change restrictions + is_allowed, restriction_reason = is_plan_change_allowed(current_price_id, request.price_id) + if not is_allowed: + raise HTTPException( + status_code=400, + detail=f"Plan change not allowed: {restriction_reason}" + ) + + # Check current subscription's commitment status + commitment_info = await check_subscription_commitment(subscription_id) + # Get current and new price details - current_price = stripe.Price.retrieve(current_price_id) + current_price = await stripe.Price.retrieve_async(current_price_id) new_price = price # Already retrieved - is_upgrade = new_price['unit_amount'] > current_price['unit_amount'] + + # Determine if this is an upgrade + # Consider yearly plans as upgrades regardless of unit price (due to discounts) + current_interval = current_price.get('recurring', {}).get('interval', 'month') + new_interval = new_price.get('recurring', {}).get('interval', 'month') + + is_upgrade = ( + new_price['unit_amount'] > current_price['unit_amount'] or # Traditional price upgrade + (current_interval == 'month' and new_interval == 'year') # Monthly to yearly upgrade + ) + + logger.info(f"Price comparison: current={current_price['unit_amount']}, new={new_price['unit_amount']}, " + f"intervals: {current_interval}->{new_interval}, is_upgrade={is_upgrade}") + + # For commitment subscriptions, handle differently + if commitment_info.get('has_commitment'): + if is_upgrade: + # Allow upgrades for commitment subscriptions immediately + logger.info(f"Upgrading commitment subscription {subscription_id}") + + # Regular subscription modification for upgrades + updated_subscription = await stripe.Subscription.modify_async( + subscription_id, + items=[{ + 'id': subscription_item['id'], + 'price': request.price_id, + }], + proration_behavior='always_invoice', # Prorate and charge immediately + billing_cycle_anchor='now', # Reset billing cycle + metadata={ + **existing_subscription.get('metadata', {}), + 'commitment_type': request.commitment_type or 'monthly' + } + ) + + # Update active status in database + await client.schema('basejump').from_('billing_customers').update( + {'active': True} + ).eq('id', customer_id).execute() + logger.info(f"Updated customer {customer_id} active status to TRUE after subscription upgrade") + + # Force immediate payment for upgrades + latest_invoice = None + if updated_subscription.latest_invoice: + latest_invoice_id = updated_subscription.latest_invoice + latest_invoice = await stripe.Invoice.retrieve_async(latest_invoice_id) + + try: + logger.info(f"Latest invoice {latest_invoice_id} status: {latest_invoice.status}") + + # If invoice is in draft status, finalize it to trigger immediate payment + if latest_invoice.status == 'draft': + finalized_invoice = stripe.Invoice.finalize_invoice(latest_invoice_id) + logger.info(f"Finalized invoice {latest_invoice_id} for immediate payment") + latest_invoice = finalized_invoice + + # Pay the invoice immediately if it's still open + if finalized_invoice.status == 'open': + paid_invoice = stripe.Invoice.pay(latest_invoice_id) + logger.info(f"Paid invoice {latest_invoice_id} immediately, status: {paid_invoice.status}") + latest_invoice = paid_invoice + elif latest_invoice.status == 'open': + # Invoice is already finalized but not paid, pay it + paid_invoice = stripe.Invoice.pay(latest_invoice_id) + logger.info(f"Paid existing open invoice {latest_invoice_id}, status: {paid_invoice.status}") + latest_invoice = paid_invoice + else: + logger.info(f"Invoice {latest_invoice_id} is in status {latest_invoice.status}, no action needed") + + except Exception as invoice_error: + logger.error(f"Error processing invoice for immediate payment: {str(invoice_error)}") + # Don't fail the entire operation if invoice processing fails + + return { + "subscription_id": updated_subscription.id, + "status": "updated", + "message": f"Subscription upgraded successfully", + "details": { + "is_upgrade": True, + "effective_date": "immediate", + "current_price": round(current_price['unit_amount'] / 100, 2) if current_price.get('unit_amount') else 0, + "new_price": round(new_price['unit_amount'] / 100, 2) if new_price.get('unit_amount') else 0, + "invoice": { + "id": latest_invoice['id'] if latest_invoice else None, + "status": latest_invoice['status'] if latest_invoice else None, + "amount_due": round(latest_invoice['amount_due'] / 100, 2) if latest_invoice else 0, + "amount_paid": round(latest_invoice['amount_paid'] / 100, 2) if latest_invoice else 0 + } if latest_invoice else None + } + } + else: + # Downgrade for commitment subscription - must wait until commitment ends + if not commitment_info.get('can_cancel'): + return { + "subscription_id": subscription_id, + "status": "commitment_blocks_downgrade", + "message": f"Cannot downgrade during commitment period. {commitment_info.get('months_remaining', 0)} months remaining.", + "details": { + "is_upgrade": False, + "effective_date": commitment_info.get('commitment_end_date'), + "current_price": round(current_price['unit_amount'] / 100, 2) if current_price.get('unit_amount') else 0, + "new_price": round(new_price['unit_amount'] / 100, 2) if new_price.get('unit_amount') else 0, + "commitment_end_date": commitment_info.get('commitment_end_date'), + "months_remaining": commitment_info.get('months_remaining', 0) + } + } + # If commitment allows cancellation, proceed with normal downgrade logic + else: + # Regular subscription without commitment - use existing logic + pass if is_upgrade: # --- Handle Upgrade --- Immediate modification - updated_subscription = stripe.Subscription.modify( + updated_subscription = await stripe.Subscription.modify_async( subscription_id, items=[{ 'id': subscription_item['id'], @@ -585,11 +857,39 @@ async def create_checkout_session( logger.info(f"Updated customer {customer_id} active status to TRUE after subscription upgrade") latest_invoice = None - if updated_subscription.get('latest_invoice'): - latest_invoice = stripe.Invoice.retrieve(updated_subscription['latest_invoice']) + if updated_subscription.latest_invoice: + latest_invoice_id = updated_subscription.latest_invoice + latest_invoice = await stripe.Invoice.retrieve_async(latest_invoice_id) + + # Force immediate payment for upgrades + try: + logger.info(f"Latest invoice {latest_invoice_id} status: {latest_invoice.status}") + + # If invoice is in draft status, finalize it to trigger immediate payment + if latest_invoice.status == 'draft': + finalized_invoice = stripe.Invoice.finalize_invoice(latest_invoice_id) + logger.info(f"Finalized invoice {latest_invoice_id} for immediate payment") + latest_invoice = finalized_invoice # Update reference + + # Pay the invoice immediately if it's still open + if finalized_invoice.status == 'open': + paid_invoice = stripe.Invoice.pay(latest_invoice_id) + logger.info(f"Paid invoice {latest_invoice_id} immediately, status: {paid_invoice.status}") + latest_invoice = paid_invoice # Update reference + elif latest_invoice.status == 'open': + # Invoice is already finalized but not paid, pay it + paid_invoice = stripe.Invoice.pay(latest_invoice_id) + logger.info(f"Paid existing open invoice {latest_invoice_id}, status: {paid_invoice.status}") + latest_invoice = paid_invoice # Update reference + else: + logger.info(f"Invoice {latest_invoice_id} is in status {latest_invoice.status}, no action needed") + + except Exception as invoice_error: + logger.error(f"Error processing invoice for immediate payment: {str(invoice_error)}") + # Don't fail the entire operation if invoice processing fails return { - "subscription_id": updated_subscription['id'], + "subscription_id": updated_subscription.id, "status": "updated", "message": "Subscription upgraded successfully", "details": { @@ -606,174 +906,62 @@ async def create_checkout_session( } } else: - # --- Handle Downgrade --- Use Subscription Schedule - try: - current_period_end_ts = subscription_item['current_period_end'] - - # Retrieve the subscription again to get the schedule ID if it exists - # This ensures we have the latest state before creating/modifying schedule - sub_with_schedule = stripe.Subscription.retrieve(subscription_id) - schedule_id = sub_with_schedule.get('schedule') - - # Get the current phase configuration from the schedule or subscription - if schedule_id: - schedule = stripe.SubscriptionSchedule.retrieve(schedule_id) - # Find the current phase in the schedule - # This logic assumes simple schedules; might need refinement for complex ones - current_phase = None - for phase in reversed(schedule['phases']): - if phase['start_date'] <= datetime.now(timezone.utc).timestamp(): - current_phase = phase - break - if not current_phase: # Fallback if logic fails - current_phase = schedule['phases'][-1] - else: - # If no schedule, the current subscription state defines the current phase - current_phase = { - 'items': existing_subscription['items']['data'], # Use original items data - 'start_date': existing_subscription['current_period_start'], # Use sub start if no schedule - # Add other relevant fields if needed for create/modify - } - - # Prepare the current phase data for the update/create - # Ensure items is formatted correctly for the API - current_phase_items_for_api = [] - for item in current_phase.get('items', []): - price_data = item.get('price') - quantity = item.get('quantity') - price_id = None - - # Safely extract price ID whether it's an object or just the ID string - if isinstance(price_data, dict): - price_id = price_data.get('id') - elif isinstance(price_data, str): - price_id = price_data - - if price_id and quantity is not None: - current_phase_items_for_api.append({'price': price_id, 'quantity': quantity}) - else: - logger.warning(f"Skipping item in current phase due to missing price ID or quantity: {item}") - - if not current_phase_items_for_api: - raise ValueError("Could not determine valid items for the current phase.") - - current_phase_update_data = { - 'items': current_phase_items_for_api, - 'start_date': current_phase['start_date'], # Preserve original start date - 'end_date': current_period_end_ts, # End this phase at period end - 'proration_behavior': 'none' - # Include other necessary fields from current_phase if modifying? - # e.g., 'billing_cycle_anchor', 'collection_method'? Usually inherited. + # --- Handle Downgrade --- Simple downgrade at period end + updated_subscription = await stripe.Subscription.modify_async( + subscription_id, + items=[{ + 'id': subscription_item['id'], + 'price': request.price_id, + }], + proration_behavior='none', # No proration for downgrades + billing_cycle_anchor='unchanged' # Keep current billing cycle + ) + + # Update active status in database + await client.schema('basejump').from_('billing_customers').update( + {'active': True} + ).eq('id', customer_id).execute() + logger.info(f"Updated customer {customer_id} active status to TRUE after subscription downgrade") + + return { + "subscription_id": updated_subscription.id, + "status": "updated", + "message": "Subscription downgraded successfully", + "details": { + "is_upgrade": False, + "effective_date": "immediate", + "current_price": round(current_price['unit_amount'] / 100, 2) if current_price.get('unit_amount') else 0, + "new_price": round(new_price['unit_amount'] / 100, 2) if new_price.get('unit_amount') else 0, } - - # Define the new (downgrade) phase - new_downgrade_phase_data = { - 'items': [{'price': request.price_id, 'quantity': 1}], - 'start_date': current_period_end_ts, # Start immediately after current phase ends - 'proration_behavior': 'none' - # iterations defaults to 1, meaning it runs for one billing cycle - # then schedule ends based on end_behavior - } - - # Update or Create Schedule - if schedule_id: - # Update existing schedule, replacing all future phases - # print(f"Updating existing schedule {schedule_id}") - logger.info(f"Updating existing schedule {schedule_id} for subscription {subscription_id}") - logger.debug(f"Current phase data: {current_phase_update_data}") - logger.debug(f"New phase data: {new_downgrade_phase_data}") - updated_schedule = stripe.SubscriptionSchedule.modify( - schedule_id, - phases=[current_phase_update_data, new_downgrade_phase_data], - end_behavior='release' - ) - logger.info(f"Successfully updated schedule {updated_schedule['id']}") - else: - # Create a new schedule using the defined phases - print(f"Creating new schedule for subscription {subscription_id}") - logger.info(f"Creating new schedule for subscription {subscription_id}") - # Deep debug logging - write subscription details to help diagnose issues - logger.debug(f"Subscription details: {subscription_id}, current_period_end_ts: {current_period_end_ts}") - logger.debug(f"Current price: {current_price_id}, New price: {request.price_id}") - - try: - updated_schedule = stripe.SubscriptionSchedule.create( - from_subscription=subscription_id, - phases=[ - { - 'start_date': current_phase['start_date'], - 'end_date': current_period_end_ts, - 'proration_behavior': 'none', - 'items': [ - { - 'price': current_price_id, - 'quantity': 1 - } - ] - }, - { - 'start_date': current_period_end_ts, - 'proration_behavior': 'none', - 'items': [ - { - 'price': request.price_id, - 'quantity': 1 - } - ] - } - ], - end_behavior='release' - ) - # Don't try to link the schedule - that's handled by from_subscription - logger.info(f"Created new schedule {updated_schedule['id']} from subscription {subscription_id}") - # print(f"Created new schedule {updated_schedule['id']} from subscription {subscription_id}") - - # Verify the schedule was created correctly - fetched_schedule = stripe.SubscriptionSchedule.retrieve(updated_schedule['id']) - logger.info(f"Schedule verification - Status: {fetched_schedule.get('status')}, Phase Count: {len(fetched_schedule.get('phases', []))}") - logger.debug(f"Schedule details: {fetched_schedule}") - except Exception as schedule_error: - logger.exception(f"Failed to create schedule: {str(schedule_error)}") - raise schedule_error # Re-raise to be caught by the outer try-except - - return { - "subscription_id": subscription_id, - "schedule_id": updated_schedule['id'], - "status": "scheduled", - "message": "Subscription downgrade scheduled", - "details": { - "is_upgrade": False, - "effective_date": "end_of_period", - "current_price": round(current_price['unit_amount'] / 100, 2) if current_price.get('unit_amount') else 0, - "new_price": round(new_price['unit_amount'] / 100, 2) if new_price.get('unit_amount') else 0, - "effective_at": datetime.fromtimestamp(current_period_end_ts, tz=timezone.utc).isoformat() - } - } - except Exception as e: - logger.exception(f"Error handling subscription schedule for sub {subscription_id}: {str(e)}") - raise HTTPException(status_code=500, detail=f"Error handling subscription schedule: {str(e)}") + } except Exception as e: logger.exception(f"Error updating subscription {existing_subscription.get('id') if existing_subscription else 'N/A'}: {str(e)}") raise HTTPException(status_code=500, detail=f"Error updating subscription: {str(e)}") else: - - session = stripe.checkout.Session.create( + # Create regular subscription with commitment metadata if specified + session = await stripe.checkout.Session.create_async( customer=customer_id, payment_method_types=['card'], - line_items=[{'price': request.price_id, 'quantity': 1}], + line_items=[{'price': request.price_id, 'quantity': 1}], mode='subscription', + subscription_data={ + 'metadata': { + 'commitment_type': request.commitment_type or 'monthly', + 'user_id': current_user_id + } + }, success_url=request.success_url, cancel_url=request.cancel_url, metadata={ - 'user_id': current_user_id, - 'product_id': product_id, - 'tolt_referral': request.tolt_referral + 'user_id': current_user_id, + 'product_id': product_id, + 'tolt_referral': request.tolt_referral, + 'commitment_type': request.commitment_type or 'monthly' }, allow_promotion_codes=True ) # Update customer status to potentially active (will be confirmed by webhook) - # This ensures customer is marked as active once payment is completed await client.schema('basejump').from_('billing_customers').update( {'active': True} ).eq('id', customer_id).execute() @@ -809,7 +997,7 @@ async def create_portal_session( # Ensure the portal configuration has subscription_update enabled try: # First, check if we have a configuration that already enables subscription update - configurations = stripe.billing_portal.Configuration.list(limit=100) + configurations = await stripe.billing_portal.Configuration.list_async(limit=100) active_config = None # Look for a configuration with subscription_update enabled @@ -828,7 +1016,7 @@ async def create_portal_session( default_config = configurations['data'][0] logger.info(f"Updating default portal configuration: {default_config['id']} to enable subscription_update") - active_config = stripe.billing_portal.Configuration.update( + active_config = await stripe.billing_portal.Configuration.update_async( default_config['id'], features={ 'subscription_update': { @@ -845,7 +1033,7 @@ async def create_portal_session( else: # Create a new configuration with subscription_update enabled logger.info("Creating new portal configuration with subscription_update enabled") - active_config = stripe.billing_portal.Configuration.create( + active_config = await stripe.billing_portal.Configuration.create_async( business_profile={ 'headline': 'Subscription Management', 'privacy_policy_url': config.FRONTEND_URL + '/privacy', @@ -883,7 +1071,7 @@ async def create_portal_session( portal_params["configuration"] = active_config['id'] # Create the session - session = stripe.billing_portal.Session.create(**portal_params) + session = await stripe.billing_portal.Session.create_async(**portal_params) return {"url": session.url} @@ -925,8 +1113,8 @@ async def get_subscription( current_tier_info = SUBSCRIPTION_TIERS.get(current_price_id) if not current_tier_info: # Fallback if somehow subscribed to an unknown price within our product - logger.warning(f"User {current_user_id} subscribed to unknown price {current_price_id}. Defaulting info.") - current_tier_info = {'name': 'unknown', 'minutes': 0} + logger.warning(f"User {current_user_id} subscribed to unknown price {current_price_id}. Defaulting info.") + current_tier_info = {'name': 'unknown', 'minutes': 0} status_response = SubscriptionStatus( status=subscription['status'], # 'active', 'trialing', etc. @@ -938,14 +1126,22 @@ async def get_subscription( minutes_limit=current_tier_info['minutes'], cost_limit=current_tier_info['cost'], current_usage=current_usage, - has_schedule=False # Default + has_schedule=False, # Default + subscription_id=subscription['id'], + subscription={ + 'id': subscription['id'], + 'status': subscription['status'], + 'cancel_at_period_end': subscription['cancel_at_period_end'], + 'cancel_at': subscription.get('cancel_at'), + 'current_period_end': current_item['current_period_end'] + } ) # Check for an attached schedule (indicates pending downgrade) schedule_id = subscription.get('schedule') if schedule_id: try: - schedule = stripe.SubscriptionSchedule.retrieve(schedule_id) + schedule = await stripe.SubscriptionSchedule.retrieve_async(schedule_id) # Find the *next* phase after the current one next_phase = None current_phase_end = current_item['current_period_end'] @@ -1015,9 +1211,12 @@ async def stripe_webhook(request: Request): event = stripe.Webhook.construct_event( payload, sig_header, webhook_secret ) + logger.info(f"Received Stripe webhook: {event.type} - Event ID: {event.id}") except ValueError as e: + logger.error(f"Invalid webhook payload: {str(e)}") raise HTTPException(status_code=400, detail="Invalid payload") except stripe.error.SignatureVerificationError as e: + logger.error(f"Invalid webhook signature: {str(e)}") raise HTTPException(status_code=400, detail="Invalid signature") # Handle the event @@ -1034,7 +1233,15 @@ async def stripe_webhook(request: Request): db = DBConnection() client = await db.client - if event.type == 'customer.subscription.created' or event.type == 'customer.subscription.updated': + if event.type == 'customer.subscription.created': + # Update customer active status for new subscriptions + if subscription.get('status') in ['active', 'trialing']: + await client.schema('basejump').from_('billing_customers').update( + {'active': True} + ).eq('id', customer_id).execute() + logger.info(f"Webhook: Updated customer {customer_id} active status to TRUE based on {event.type}") + + elif event.type == 'customer.subscription.updated': # Check if subscription is active if subscription.get('status') in ['active', 'trialing']: # Update customer's active status to true @@ -1045,7 +1252,7 @@ async def stripe_webhook(request: Request): else: # Subscription is not active (e.g., past_due, canceled, etc.) # Check if customer has any other active subscriptions before updating status - has_active = len(stripe.Subscription.list( + has_active = len(await stripe.Subscription.list_async( customer=customer_id, status='active', limit=1 @@ -1059,11 +1266,11 @@ async def stripe_webhook(request: Request): elif event.type == 'customer.subscription.deleted': # Check if customer has any other active subscriptions - has_active = len(stripe.Subscription.list( + has_active = len((await stripe.Subscription.list_async( customer=customer_id, status='active', limit=1 - ).get('data', [])) > 0 + )).get('data', [])) > 0 if not has_active: # If no active subscriptions left, set active to false @@ -1225,7 +1432,7 @@ async def get_available_models( if input_cost_per_token is not None and output_cost_per_token is not None: pricing_info = { "input_cost_per_million_tokens": input_cost_per_token * TOKEN_PRICE_MULTIPLIER, - "output_cost_per_million_tokens": output_cost_per_token * TOKEN_PRICE_MULTIPLIER, + "output_cost_per_million_tokens": output_cost_per_million * TOKEN_PRICE_MULTIPLIER, "max_tokens": None # cost_per_token doesn't provide max_tokens info } else: @@ -1298,4 +1505,242 @@ async def get_usage_logs_endpoint( raise except Exception as e: logger.error(f"Error getting usage logs: {str(e)}") - raise HTTPException(status_code=500, detail=f"Error getting usage logs: {str(e)}") \ No newline at end of file + raise HTTPException(status_code=500, detail=f"Error getting usage logs: {str(e)}") + +@router.get("/subscription-commitment/{subscription_id}") +async def get_subscription_commitment( + subscription_id: str, + current_user_id: str = Depends(get_current_user_id_from_jwt) +): + """Get commitment status for a subscription.""" + try: + # Verify the subscription belongs to the current user + db = DBConnection() + client = await db.client + + # Get user's subscription to verify ownership + user_subscription = await get_user_subscription(current_user_id) + if not user_subscription or user_subscription.get('id') != subscription_id: + raise HTTPException(status_code=404, detail="Subscription not found or access denied") + + commitment_info = await check_subscription_commitment(subscription_id) + return commitment_info + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error getting subscription commitment: {str(e)}") + raise HTTPException(status_code=500, detail="Error retrieving commitment information") + +@router.get("/subscription-details") +async def get_subscription_details( + current_user_id: str = Depends(get_current_user_id_from_jwt) +): + """Get detailed subscription information including commitment status.""" + try: + subscription = await get_user_subscription(current_user_id) + if not subscription: + return { + "subscription": None, + "commitment": {"has_commitment": False, "can_cancel": True} + } + + # Get commitment information + commitment_info = await check_subscription_commitment(subscription['id']) + + # Enhanced subscription details + subscription_details = { + "id": subscription.get('id'), + "status": subscription.get('status'), + "current_period_end": subscription.get('current_period_end'), + "current_period_start": subscription.get('current_period_start'), + "cancel_at_period_end": subscription.get('cancel_at_period_end'), + "items": subscription.get('items', {}).get('data', []), + "metadata": subscription.get('metadata', {}) + } + + return { + "subscription": subscription_details, + "commitment": commitment_info + } + + except Exception as e: + logger.error(f"Error getting subscription details: {str(e)}") + raise HTTPException(status_code=500, detail="Error retrieving subscription details") + +@router.post("/cancel-subscription") +async def cancel_subscription( + current_user_id: str = Depends(get_current_user_id_from_jwt) +): + """Cancel subscription with yearly commitment handling.""" + try: + # Get user's current subscription + subscription = await get_user_subscription(current_user_id) + if not subscription: + raise HTTPException(status_code=404, detail="No active subscription found") + + subscription_id = subscription['id'] + + # Check commitment status + commitment_info = await check_subscription_commitment(subscription_id) + + # If subscription has yearly commitment and still in commitment period + if commitment_info.get('has_commitment') and not commitment_info.get('can_cancel'): + # Schedule cancellation at the end of the commitment period (1 year anniversary) + commitment_end_date = datetime.fromisoformat(commitment_info.get('commitment_end_date').replace('Z', '+00:00')) + cancel_at_timestamp = int(commitment_end_date.timestamp()) + + # Update subscription to cancel at the commitment end date + updated_subscription = await stripe.Subscription.modify_async( + subscription_id, + cancel_at=cancel_at_timestamp, + metadata={ + **subscription.get('metadata', {}), + 'cancelled_by_user': 'true', + 'cancellation_date': str(int(datetime.now(timezone.utc).timestamp())), + 'scheduled_cancel_at_commitment_end': 'true' + } + ) + + logger.info(f"Subscription {subscription_id} scheduled for cancellation at commitment end: {commitment_end_date}") + + return { + "success": True, + "status": "scheduled_for_commitment_end", + "message": f"Subscription will be cancelled at the end of your yearly commitment period. {commitment_info.get('months_remaining', 0)} months remaining.", + "details": { + "subscription_id": subscription_id, + "cancellation_effective_date": commitment_end_date.isoformat(), + "months_remaining": commitment_info.get('months_remaining', 0), + "access_until": commitment_end_date.strftime("%B %d, %Y"), + "commitment_end_date": commitment_info.get('commitment_end_date') + } + } + + # For non-commitment subscriptions or commitment period has ended, cancel at period end + updated_subscription = await stripe.Subscription.modify_async( + subscription_id, + cancel_at_period_end=True, + metadata={ + **subscription.get('metadata', {}), + 'cancelled_by_user': 'true', + 'cancellation_date': str(int(datetime.now(timezone.utc).timestamp())) + } + ) + + logger.info(f"Subscription {subscription_id} marked for cancellation at period end") + + # Calculate when the subscription will actually end + current_period_end = updated_subscription.current_period_end or subscription.get('current_period_end') + + # If still no period end, fetch fresh subscription data from Stripe + if not current_period_end: + logger.warning(f"No current_period_end found in cached data for subscription {subscription_id}, fetching fresh data from Stripe") + try: + fresh_subscription = await stripe.Subscription.retrieve_async(subscription_id) + current_period_end = fresh_subscription.current_period_end + except Exception as fetch_error: + logger.error(f"Failed to fetch fresh subscription data: {fetch_error}") + + if not current_period_end: + logger.error(f"No current_period_end found in subscription {subscription_id} even after fresh fetch") + raise HTTPException(status_code=500, detail="Unable to determine subscription period end") + + period_end_date = datetime.fromtimestamp(current_period_end, timezone.utc) + + return { + "success": True, + "status": "cancelled_at_period_end", + "message": "Subscription will be cancelled at the end of your current billing period.", + "details": { + "subscription_id": subscription_id, + "cancellation_effective_date": period_end_date.isoformat(), + "current_period_end": current_period_end, + "access_until": period_end_date.strftime("%B %d, %Y") + } + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error cancelling subscription: {str(e)}") + raise HTTPException(status_code=500, detail="Error processing cancellation request") + +@router.post("/reactivate-subscription") +async def reactivate_subscription( + current_user_id: str = Depends(get_current_user_id_from_jwt) +): + """Reactivate a subscription that was marked for cancellation.""" + try: + # Get user's current subscription + subscription = await get_user_subscription(current_user_id) + if not subscription: + raise HTTPException(status_code=404, detail="No subscription found") + + subscription_id = subscription['id'] + + # Check if subscription is marked for cancellation (either cancel_at_period_end or cancel_at) + is_cancelled = subscription.get('cancel_at_period_end') or subscription.get('cancel_at') + if not is_cancelled: + return { + "success": False, + "status": "not_cancelled", + "message": "Subscription is not marked for cancellation." + } + + # Prepare the modification parameters + modify_params = { + 'cancel_at_period_end': False, + 'metadata': { + **subscription.get('metadata', {}), + 'reactivated_by_user': 'true', + 'reactivation_date': str(int(datetime.now(timezone.utc).timestamp())) + } + } + + # If subscription has cancel_at set (yearly commitment), clear it + if subscription.get('cancel_at'): + modify_params['cancel_at'] = None + + # Reactivate the subscription + updated_subscription = await stripe.Subscription.modify_async( + subscription_id, + **modify_params + ) + + logger.info(f"Subscription {subscription_id} reactivated by user") + + # Get the current period end safely + current_period_end = updated_subscription.current_period_end or subscription.get('current_period_end') + + # If still no period end, fetch fresh subscription data from Stripe + if not current_period_end: + logger.warning(f"No current_period_end found in cached data for subscription {subscription_id}, fetching fresh data from Stripe") + try: + fresh_subscription = await stripe.Subscription.retrieve_async(subscription_id) + current_period_end = fresh_subscription.current_period_end + except Exception as fetch_error: + logger.error(f"Failed to fetch fresh subscription data: {fetch_error}") + + if not current_period_end: + logger.error(f"No current_period_end found in subscription {subscription_id} even after fresh fetch") + raise HTTPException(status_code=500, detail="Unable to determine subscription period end") + + return { + "success": True, + "status": "reactivated", + "message": "Subscription has been reactivated and will continue billing normally.", + "details": { + "subscription_id": subscription_id, + "next_billing_date": datetime.fromtimestamp( + current_period_end, + timezone.utc + ).strftime("%B %d, %Y") + } + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error reactivating subscription: {str(e)}") + raise HTTPException(status_code=500, detail="Error processing reactivation request") diff --git a/backend/utils/config.py b/backend/utils/config.py index d245e9df..4cfc6d38 100644 --- a/backend/utils/config.py +++ b/backend/utils/config.py @@ -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 diff --git a/frontend/src/app/(dashboard)/(personalAccount)/settings/billing/page.tsx b/frontend/src/app/(dashboard)/(personalAccount)/settings/billing/page.tsx index 65b17feb..057a2abc 100644 --- a/frontend/src/app/(dashboard)/(personalAccount)/settings/billing/page.tsx +++ b/frontend/src/app/(dashboard)/(personalAccount)/settings/billing/page.tsx @@ -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 ( + + Error + + {error instanceof Error ? error.message : 'Failed to load account data'} + + + ); + } + + if (isLoading) { + return ( +
+ + +
+ ); + } + + if (!personalAccount) { + return ( + + Account Not Found + + Your personal account could not be found. + + + ); + } + return (
(null); - const [error, setError] = React.useState(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" > Error - {error} + + {error instanceof Error ? error.message : 'Failed to load account data'} + ); } - if (!teamAccount) { - return
Loading...
; + if (isLoading) { + return ( +
+ + +
+ ); } - if (teamAccount.account_role !== 'owner') { + if (!teamAccount) { + return ( + + Account Not Found + + The requested team account could not be found. + + + ); + } + + if (teamAccount.role !== 'owner') { return ( (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 */} -
+
{/* Manage Subscription Button */}
@@ -185,15 +191,21 @@ export default function AccountBillingStatus({ accountId, returnUrl }: Props) { View Model Pricing
)} + + ); -} +} \ No newline at end of file diff --git a/frontend/src/components/billing/subscription-management-modal.tsx b/frontend/src/components/billing/subscription-management-modal.tsx new file mode 100644 index 00000000..2c52a544 --- /dev/null +++ b/frontend/src/components/billing/subscription-management-modal.tsx @@ -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(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 ( + + + + Subscription Management + +
+

+ Running in local development mode - billing features are disabled +

+

+ Agent usage limits are not enforced in this environment +

+
+
+
+ ); + } + + // Show loading state + if (isLoading || authLoading) { + return ( + + + + Subscription Management + +
+ + + +
+
+
+ ); + } + + // Show error state + if (error || subscriptionQueryError) { + return ( + + + + Subscription Management + +
+

+ Error loading billing status:{' '} + {error || subscriptionQueryError.message} +

+
+
+
+ ); + } + + const isPlan = (planId?: string) => { + return subscriptionData?.plan_name === planId; + }; + + const planName = isPlan('free') + ? 'Free' + : isPlan('base') + ? 'Pro' + : isPlan('extra') + ? 'Enterprise' + : 'Unknown'; + + return ( + + + + + + Subscription Management + + + +
+ {subscriptionData ? ( + <> + {/* Quick Actions */} + + + Quick Links + + +
+ + + + + +
+
+
+ + {/* Subscription Details */} + { + // Trigger a refetch of subscription data + window.location.reload(); + }} + className="w-full" + /> + + ) : ( + <> +
+

Upgrade Your Plan

+ +
+ + )} +
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/billing/subscription-status-management.tsx b/frontend/src/components/billing/subscription-status-management.tsx new file mode 100644 index 00000000..8e1508fe --- /dev/null +++ b/frontend/src/components/billing/subscription-status-management.tsx @@ -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 ( + + +
+
+ Loading subscription status... +
+ + + ); + } + + // 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 ( + + + + + Subscription Status + + + + {/* Current Status */} +
+ Status + + {isAlreadyCancelled + ? subscription.cancel_at + ? 'Cancelling at commitment end' + : 'Cancelling at period end' + : 'Active'} + +
+ + {/* Commitment Information */} + {commitmentInfo?.has_commitment && ( + <> +
+ Commitment Type + + 12-Month Commitment + +
+ + {commitmentInfo.months_remaining !== undefined && commitmentInfo.months_remaining > 0 && ( +
+ Time Remaining +
+ + {commitmentInfo.months_remaining} months +
+
+ )} + + {commitmentInfo.commitment_end_date && ( +
+ Commitment Ends + + {formatEndDate(commitmentInfo.commitment_end_date)} + +
+ )} + + )} + + {/* Commitment Warning for Active Subscriptions */} + {hasCommitment && !isAlreadyCancelled && ( + + + + 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. + + + )} + + {/* Cannot Cancel Warning */} + {hasCommitment && !commitmentInfo?.can_cancel && !isAlreadyCancelled && ( + + + + 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. + + + )} + + {/* Already Cancelled Status */} + {isAlreadyCancelled && ( + + + + {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. + + )} + + + )} + + {/* Commitment Completed Message */} + {commitmentInfo?.has_commitment && commitmentInfo?.can_cancel && !isAlreadyCancelled && ( +
+ + Commitment period completed - you can now cancel anytime +
+ )} + + {/* Action Buttons */} +
+ {canCancel && ( + + + + + + + + {hasCommitment ? 'Schedule Cancellation' : 'Cancel Subscription'} + + + {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)}). + + )} + + + + + + + + + )} + + {canReactivate && ( + + )} + + {!canCancel && !canReactivate && !hasCommitment && ( +
+ + Subscription is active +
+ )} +
+ + {/* Help Text */} +
+ Need help? Contact support if you have questions + about your subscription or need assistance with changes. +
+
+
+ ); +} \ 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 2531e414..ec331955 100644 --- a/frontend/src/components/home/sections/pricing-section.tsx +++ b/frontend/src/components/home/sections/pricing-section.tsx @@ -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 (
-
setBillingPeriod(billingPeriod === 'monthly' ? 'yearly' : 'monthly')} - > +
-
+
setBillingPeriod('monthly')} + > Monthly
-
+
setBillingPeriod('yearly_commitment')} + > Yearly 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 ); - } 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 )} - {/* 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))) && ( - - Recommended - - )} + {/* Show upgrade badge for yearly commitment plans when user is on monthly */} {isAuthenticated && statusBadge}

- {billingPeriod === 'yearly' && tier.yearlyPrice && displayPrice !== '$0' ? ( + {billingPeriod === 'yearly_commitment' && tier.monthlyCommitmentStripePriceId ? ( +
+
+ + + ${tier.price.slice(1)} + +
+
+ /month + for one year +
+
+ ) : billingPeriod === 'yearly' && tier.yearlyPrice && displayPrice !== '$0' ? (
@@ -475,7 +516,11 @@ function PricingTier({

{tier.description}

- {billingPeriod === 'yearly' && tier.yearlyPrice && tier.discountPercentage ? ( + {billingPeriod === 'yearly_commitment' && tier.monthlyCommitmentStripePriceId ? ( +
+ Save ${Math.round((parseFloat(tier.price.slice(1)) - parseFloat(displayPrice.slice(1))) * 12)} per year +
+ ) : billingPeriod === 'yearly' && tier.yearlyPrice && tier.discountPercentage ? (
Save ${Math.round(parseFloat(tier.originalYearlyPrice?.slice(1) || '0') - parseFloat(tier.yearlyPrice.slice(1)))} per year
@@ -512,7 +557,7 @@ function PricingTier({ insideDialog ? "px-3 pt-1 pb-3" : "px-4 pt-2 pb-4" )}> @@ -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>({}); // 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({
- + ); } diff --git a/frontend/src/hooks/react-query/accounts/use-account-by-slug.ts b/frontend/src/hooks/react-query/accounts/use-account-by-slug.ts new file mode 100644 index 00000000..029442ca --- /dev/null +++ b/frontend/src/hooks/react-query/accounts/use-account-by-slug.ts @@ -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, + }); +} \ No newline at end of file diff --git a/frontend/src/hooks/react-query/index.ts b/frontend/src/hooks/react-query/index.ts index 715f5294..05d0eb1c 100644 --- a/frontend/src/hooks/react-query/index.ts +++ b/frontend/src/hooks/react-query/index.ts @@ -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'; diff --git a/frontend/src/hooks/react-query/subscriptions/keys.ts b/frontend/src/hooks/react-query/subscriptions/keys.ts index 763e3a62..cfe25cae 100644 --- a/frontend/src/hooks/react-query/subscriptions/keys.ts +++ b/frontend/src/hooks/react-query/subscriptions/keys.ts @@ -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({ diff --git a/frontend/src/hooks/react-query/subscriptions/use-subscriptions.ts b/frontend/src/hooks/react-query/subscriptions/use-subscriptions.ts index 0711e3fb..a4617d06 100644 --- a/frontend/src/hooks/react-query/subscriptions/use-subscriptions.ts +++ b/frontend/src/hooks/react-query/subscriptions/use-subscriptions.ts @@ -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, diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index c8f6a810..218f2231 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -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 => { } }; +export const getSubscriptionCommitment = async (subscriptionId: string): Promise => { + 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 => { try { const supabase = createClient(); @@ -1933,6 +2061,86 @@ export const checkBillingStatus = async (): Promise => { } }; +export const cancelSubscription = async (): Promise => { + 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 => { + 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; diff --git a/frontend/src/lib/config.ts b/frontend/src/lib/config.ts index 0ee80e9c..d5d4a7f3 100644 --- a/frontend/src/lib/config.ts +++ b/frontend/src/lib/config.ts @@ -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; diff --git a/frontend/src/lib/home.tsx b/frontend/src/lib/home.tsx index 82495a2a..bcc0ebdb 100644 --- a/frontend/src/lib/home.tsx +++ b/frontend/src/lib/home.tsx @@ -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,