Skip to main content

Overview

The subscriptions module provides functions for managing user subscriptions through both Stripe (recurring plans) and local database (lifetime plans). Files:
  • server/subscription.ts - Subscription status checking
  • server/billing.ts - Stripe subscription management

Subscription Status

getSubscriptionDetails

Get the current user’s subscription status and plan details.
server/subscription.ts:22
export async function getSubscriptionDetails(): Promise<SubscriptionInfo | null>
isActive
boolean
Whether the subscription is currently active
plan
string | null
Subscription plan name (e.g., ‘Plus’, ‘Pro’, ‘FREE’)
status
string
Subscription status (‘ACTIVE’, ‘TRIAL’, ‘CANCELLED’, etc.)
endDate
Date | null
When the subscription ends (null for lifetime)
trialEndsAt
Date | null
When the trial period ends (null if not on trial)
Example
import { getSubscriptionDetails } from '@/server/subscription'

const subscription = await getSubscriptionDetails()

if (!subscription) {
  console.log('No subscription found')
} else if (subscription.isActive) {
  console.log(`Active ${subscription.plan} plan`)
  
  if (subscription.status === 'TRIAL' && subscription.trialEndsAt) {
    console.log(`Trial ends: ${subscription.trialEndsAt}`)
  }
} else {
  console.log('Subscription expired or cancelled')
}
Special Cases:
  • Rithmic Users: Users with @rithmic.com emails automatically get Plus plan access
  • Lifetime Plans: Stored in local database, not Stripe
  • Trial Status: Only active if trial end date is in the future
This function checks user email from middleware headers first, falling back to Supabase if needed.

Stripe Subscription Management

getSubscriptionData

Get detailed subscription data including Stripe information, invoices, and payment history.
server/billing.ts:66
export async function getSubscriptionData(): Promise<SubscriptionWithPrice | null>
id
string
Subscription ID (prefixed with ‘lifetime_’ for lifetime plans)
status
string
Stripe subscription status
current_period_end
number
Unix timestamp when current period ends
current_period_start
number
Unix timestamp when current period started
created
number
Unix timestamp when subscription was created
cancel_at_period_end
boolean
Whether subscription is set to cancel at period end
plan
object
Plan details:
  • id: Price ID
  • name: Plan name
  • amount: Price in cents
  • interval: Billing interval (‘month’, ‘quarter’, ‘year’, ‘lifetime’)
promotion
object | undefined
Active promotion/coupon details (if any)
invoices
array
Payment history including invoices and one-time payments
Example
import { getSubscriptionData } from '@/server/billing'

const subscription = await getSubscriptionData()

if (!subscription) {
  console.log('No active subscription')
} else {
  console.log(`Plan: ${subscription.plan.name}`)
  console.log(`Status: ${subscription.status}`)
  console.log(`Amount: $${subscription.plan.amount / 100}`)
  console.log(`Interval: ${subscription.plan.interval}`)
  
  if (subscription.cancel_at_period_end) {
    const endDate = new Date(subscription.current_period_end * 1000)
    console.log(`Cancels on: ${endDate.toLocaleDateString()}`)
  }
  
  // Show promotion if active
  if (subscription.promotion) {
    console.log(`Discount: ${subscription.promotion.code}`)
  }
  
  // Show invoice history
  subscription.invoices?.forEach(invoice => {
    console.log(`${new Date(invoice.created * 1000).toLocaleDateString()}: $${invoice.amount_paid / 100}`)
  })
}
Subscription Priority:
  1. Lifetime plans (local database) - Highest priority
  2. Active Stripe subscriptions - Recurring plans
  3. Trialing Stripe subscriptions - Trial period
Lifetime subscriptions include payment history from Stripe (invoices, payment intents, and charges).

updateSubscription

Pause, resume, or cancel a Stripe subscription.
server/billing.ts:384
export async function updateSubscription(
  action: 'pause' | 'resume' | 'cancel',
  subscriptionId: string
): Promise<{ success: boolean; error?: string>
action
'pause' | 'resume' | 'cancel'
required
Action to perform on the subscription
subscriptionId
string
required
Stripe subscription ID
success
boolean
Whether the operation was successful
error
string | undefined
Error message if operation failed
Example
import { updateSubscription, getSubscriptionData } from '@/server/billing'

const subscription = await getSubscriptionData()

if (subscription && !subscription.id.startsWith('lifetime_')) {
  // Cancel at period end
  const result = await updateSubscription('cancel', subscription.id)
  
  if (result.success) {
    console.log('Subscription will cancel at period end')
  }
  
  // Resume a cancelled subscription
  await updateSubscription('resume', subscription.id)
}
Both ‘pause’ and ‘cancel’ actions set cancel_at_period_end: true. The subscription remains active until the current period ends.

switchSubscriptionPlan

Switch to a different subscription plan with automatic proration.
server/billing.ts:433
export async function switchSubscriptionPlan(
  newLookupKey: string
): Promise<{
  success: boolean;
  error?: string;
  requiresCheckout?: boolean;
  lookupKey?: string;
  subscription?: Stripe.Subscription;
  message?: string;
}>
newLookupKey
string
required
Stripe price lookup key for the new plan
success
boolean
Whether the plan switch was successful
error
string | undefined
Error message if operation failed
requiresCheckout
boolean | undefined
True if switching to lifetime plan (requires checkout)
subscription
Stripe.Subscription | undefined
Updated subscription object
Example
import { switchSubscriptionPlan } from '@/server/billing'

// Switch from monthly to annual
const result = await switchSubscriptionPlan('plus_annual')

if (result.requiresCheckout) {
  // Redirect to checkout for lifetime plan
  console.log('Requires checkout session')
} else if (result.success) {
  console.log('Plan switched successfully')
  console.log(result.message)
} else {
  console.error('Failed to switch plan:', result.error)
}
Behavior:
  • Same plan: Returns error if already on the requested plan
  • Lifetime upgrade: Cancels current subscription and requires checkout
  • Recurring to recurring: Switches immediately with proration
  • Discounts removed: Any active coupons are removed when switching
Stripe automatically calculates prorated charges/credits when switching between recurring plans.

collectSubscriptionFeedback

Collect user feedback about subscription events (cancellation, etc.).
server/billing.ts:404
export async function collectSubscriptionFeedback(
  event: string,
  cancellationReason?: string,
  feedback?: string
): Promise<{ success: boolean; error?: string>
event
string
required
The event type (e.g., ‘cancellation’, ‘pause’)
cancellationReason
string
Reason for cancellation
feedback
string
Additional user feedback
Example
import { collectSubscriptionFeedback } from '@/server/billing'

await collectSubscriptionFeedback(
  'cancellation',
  'too_expensive',
  'Great product but need to cut costs right now'
)

Subscription Types

SubscriptionInfo

Local subscription status (from database).
interface SubscriptionInfo {
  isActive: boolean
  plan: string | null
  status: string
  endDate: Date | null
  trialEndsAt: Date | null
}

SubscriptionWithPrice

Detailed Stripe subscription with pricing and invoices.
interface SubscriptionWithPrice {
  id: string
  status: string
  current_period_end: number
  current_period_start: number
  created: number
  cancel_at_period_end: boolean
  cancel_at: number | null
  canceled_at: number | null
  trial_end: number | null
  trial_start: number | null
  plan: {
    id: string
    name: string
    amount: number
    interval: 'month' | 'quarter' | 'year' | 'lifetime'
  }
  promotion?: {
    code: string
    amount_off: number
    percent_off: number | null
    duration: {
      duration_in_months: number | null
      duration: 'forever' | 'once' | 'repeating' | null
    }
  }
  invoices?: Array<{
    id: string
    amount_paid: number
    status: string
    created: number
    invoice_pdf: string | null
    hosted_invoice_url: string | null
 >
}

Billing Intervals

The system supports multiple billing intervals:
  • Monthly: Standard monthly billing
  • Quarterly: Every 3 months (detected by interval_count === 3)
  • Annual: Yearly billing
  • Lifetime: One-time payment, no recurring charges
const interval = price.recurring?.interval_count === 3 
  ? 'quarter' 
  : price.recurring?.interval || 'month'

Invoice History

The system aggregates payment history from multiple Stripe sources:
  1. Subscription Invoices: Regular recurring payments
  2. Payment Intents: One-time payments (e.g., lifetime purchases)
  3. Charges: Fallback for payment tracking
Duplicate detection ensures each payment appears only once. Example
import { getSubscriptionData } from '@/server/billing'

const subscription = await getSubscriptionData()

subscription?.invoices?.forEach(invoice => {
  console.log(`Date: ${new Date(invoice.created * 1000).toLocaleDateString()}`)
  console.log(`Amount: $${invoice.amount_paid / 100}`)
  console.log(`Status: ${invoice.status}`)
  
  if (invoice.hosted_invoice_url) {
    console.log(`View: ${invoice.hosted_invoice_url}`)
  }
})

Database Synchronization

When subscription changes occur in Stripe, the local database is updated:
await prisma.subscription.update({
  where: { email: user.email },
  data: {
    plan: subscriptionPlan,
    interval: interval,
    endDate: new Date(currentPeriodEnd * 1000),
    status: 'ACTIVE'
  }
})
This ensures consistency between Stripe and the application database.

Best Practices

Always check lifetime subscriptions first, then Stripe:
// Lifetime check happens first in getSubscriptionData
const localSub = await prisma.subscription.findUnique(...)
if (localSub?.status === 'ACTIVE' && localSub?.interval === 'lifetime') {
  return createLifetimeSubscriptionData(localSub, invoices)
}
// Then check Stripe
Lifetime plans require different handling:
if (newPrice.type === 'one_time') {
  // Cancel current subscription
  // Return requiresCheckout: true
  return { requiresCheckout: true, lookupKey }
}
Always validate emails before database queries:
function isValidEmail(email: string): boolean {
  return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
}

Account Management

Manage trading accounts and payouts

Authentication

User authentication and management

Build docs developers (and LLMs) love