Skip to main content
Deltalytix uses Stripe for subscription management, supporting multiple plan types including monthly, quarterly, annual, and lifetime subscriptions.

Stripe Setup

Create Stripe Account

  1. Sign up at https://stripe.com
  2. Complete account verification
  3. Access your Dashboard

API Keys

Get your API keys from Developers > API keys:
.env
# Publishable key (safe for client-side)
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY="pk_test_..."

# Secret key (server-side only)
STRIPE_SECRET_KEY="sk_test_..."
Use test keys during development (pk_test_ and sk_test_). Switch to live keys (pk_live_ and sk_live_) only in production after thorough testing.

Initialize Stripe Client

The Stripe client is configured in server/stripe.ts:
server/stripe.ts
import Stripe from "stripe";

export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY as string, {
  apiVersion: "2025-10-29.clover",
});

Product Configuration

Create Products

In Stripe Dashboard, go to Products > Add Product:
  1. Basic Plan
    • Name: Basic
    • Description: Essential features for individual traders
    • Pricing: Create multiple prices (monthly, quarterly, annual)
  2. Pro Plan
    • Name: Pro
    • Description: Advanced features and analytics
    • Pricing: Create multiple prices with different intervals
  3. Lifetime Plan
    • Name: Lifetime
    • Description: One-time payment for lifetime access
    • Pricing: One-time payment (not recurring)

Price Configuration

For each recurring price:
Billing Period
string
required
  • Monthly: Standard monthly billing
  • Quarterly: Set interval to month with interval count 3
  • Annual: Yearly billing
Lookup Key
string
required
Unique identifier for programmatic access (e.g., basic_monthly, pro_annual, lifetime_single)
Quarterly plans are detected by checking if interval_count === 3 in the code (see server/billing.ts:268).

Subscription Types

Deltalytix supports three subscription levels:

Individual Subscriptions

Standard user subscriptions stored in the Subscription model:
prisma/schema.prisma:50
model Subscription {
  id          String    @id @unique @default(uuid())
  email       String    @unique
  plan        String
  createdAt   DateTime  @default(now())
  userId      String    @unique
  endDate     DateTime?
  status      String    @default("ACTIVE")
  trialEndsAt DateTime?
  interval    String?
  user        User      @relation(fields: [userId], references: [id], onDelete: Cascade)
}

Team Subscriptions

Shared subscriptions for teams:
prisma/schema.prisma:86
model TeamSubscription {
  id          String    @id @unique @default(uuid())
  email       String    @unique
  plan        String
  createdAt   DateTime  @default(now())
  userId      String    @unique
  user        User      @relation(fields: [userId], references: [id], onDelete: Cascade)
  team        Team      @relation(fields: [teamId], references: [id], onDelete: Cascade)
  teamId      String    @unique
  endDate     DateTime?
  status      String    @default("ACTIVE")
  trialEndsAt DateTime?
  interval    String?
}

Business Subscriptions

Enterprise-level subscriptions:
prisma/schema.prisma:66
model BusinessSubscription {
  id          String    @id @unique @default(uuid())
  email       String    @unique
  plan        String
  createdAt   DateTime  @default(now())
  userId      String    @unique
  user        User      @relation(fields: [userId], references: [id], onDelete: Cascade)
  business    Business  @relation(fields: [businessId], references: [id], onDelete: Cascade)
  businessId  String    @unique
  endDate     DateTime?
  status      String    @default("ACTIVE")
  trialEndsAt DateTime?
  interval    String?
}

Webhook Setup

Create Webhook Endpoint

  1. In Stripe Dashboard, go to Developers > Webhooks
  2. Click Add endpoint
  3. Enter your endpoint URL:
    • Development: http://localhost:3000/api/stripe/webhooks
    • Production: https://yourdomain.com/api/stripe/webhooks
  4. Select events to listen for:
    • checkout.session.completed
    • customer.subscription.created
    • customer.subscription.updated
    • customer.subscription.deleted
    • invoice.payment_failed
    • payment_intent.succeeded
    • payment_intent.payment_failed
    • customer.subscription.trial_will_end
  5. Copy the Signing secret

Configure Webhook Secret

.env
STRIPE_WEBHOOK_SECRET="whsec_..."
The webhook secret is critical for security. It verifies that webhook events are genuinely from Stripe.

Testing Webhooks Locally

Use Stripe CLI to forward webhooks to localhost:
  1. Install Stripe CLI:
    # macOS
    brew install stripe/stripe-cli/stripe
    
    # Windows
    scoop install stripe
    
    # Linux
    wget https://github.com/stripe/stripe-cli/releases/download/v1.19.4/stripe_1.19.4_linux_x86_64.tar.gz
    tar -xvf stripe_1.19.4_linux_x86_64.tar.gz
    
  2. Login:
    stripe login
    
  3. Forward events:
    stripe listen --forward-to localhost:3000/api/stripe/webhooks
    
  4. Copy the webhook signing secret from the output and add to .env
  5. Trigger test events:
    stripe trigger checkout.session.completed
    

Webhook Implementation

The webhook handler is located at app/api/stripe/webhooks/route.ts:

Event Verification

app/api/stripe/webhooks/route.ts:39
export async function POST(req: Request) {
  let event: Stripe.Event | undefined;
  try {
    event = stripe.webhooks.constructEvent(
      await (await req.blob()).text(),
      req.headers.get("stripe-signature") as string,
      process.env.STRIPE_WEBHOOK_SECRET as string,
    );
  } catch (err) {
    return NextResponse.json(
      { message: `Webhook Error: ${err}` },
      { status: 400 },
    );
  }
  // Handle event...
}

Checkout Session Completed

Handles both recurring and one-time payments:
app/api/stripe/webhooks/route.ts:81
case "checkout.session.completed":
  data = event.data.object as Stripe.Checkout.Session;

  if (data.mode === 'subscription') {
    // Handle recurring subscription
    const subscription = await stripe.subscriptions.retrieve(
      data.subscription as string
    );
    
    const priceId = subscription.items.data[0]?.price.id;
    const price = await stripe.prices.retrieve(priceId, {
      expand: ['product'],
    });
    
    const productName = (price.product as Stripe.Product).name;
    const subscriptionPlan = productName.toUpperCase();
    const interval = price.recurring?.interval_count === 3 
      ? 'quarter' 
      : price.recurring?.interval || 'month';

    await prisma.subscription.upsert({
      where: { email: data.customer_details?.email as string },
      update: {
        plan: subscriptionPlan,
        endDate: new Date(subscription.current_period_end * 1000),
        status: 'ACTIVE',
        interval: interval,
      },
      create: {
        email: data.customer_details?.email as string,
        plan: subscriptionPlan,
        user: { connect: { id: user?.id } },
        endDate: new Date(subscription.current_period_end * 1000),
        status: 'ACTIVE',
        interval: interval,
      }
    });
  } else if (data.mode === 'payment') {
    // Handle one-time payment (lifetime)
    const lifetimeEndDate = new Date();
    lifetimeEndDate.setFullYear(lifetimeEndDate.getFullYear() + 100);
    
    await prisma.subscription.upsert({
      where: { email: data.customer_details?.email as string },
      update: {
        plan: subscriptionPlan,
        endDate: lifetimeEndDate,
        status: 'ACTIVE',
        interval: 'lifetime',
      },
      create: { /* ... */ }
    });
  }
  break;

Subscription Updated

Handles plan changes, cancellations, and status updates:
app/api/stripe/webhooks/route.ts:260
case "customer.subscription.updated":
  data = event.data.object as Stripe.Subscription;
  
  // Map Stripe status to internal status
  let subscriptionStatus: string;
  switch (data.status) {
    case 'active': subscriptionStatus = 'ACTIVE'; break;
    case 'trialing': subscriptionStatus = 'TRIAL'; break;
    case 'canceled': subscriptionStatus = 'CANCELLED'; break;
    case 'past_due': subscriptionStatus = 'PAST_DUE'; break;
    case 'unpaid': subscriptionStatus = 'UNPAID'; break;
    default: subscriptionStatus = 'INACTIVE';
  }
  
  if (data.cancel_at_period_end) {
    // Subscription scheduled for cancellation
    await prisma.subscription.update({
      where: { email: customerData.email },
      data: {
        status: "SCHEDULED_CANCELLATION",
        endDate: new Date(data.current_period_end * 1000)
      }
    });
    
    // Collect cancellation feedback
    const cancellationDetails = data.cancellation_details;
    if (cancellationDetails?.feedback) {
      await prisma.subscriptionFeedback.create({
        data: {
          email: customerData.email,
          event: "SCHEDULED_CANCELLATION",
          cancellationReason: cancellationDetails.feedback,
          feedback: cancellationDetails.comment
        }
      });
    }
  }
  break;

Subscription Deleted

app/api/stripe/webhooks/route.ts:240
case "customer.subscription.deleted":
  data = event.data.object as Stripe.Subscription;
  const customerData = await stripe.customers.retrieve(
    data.customer as string
  );

  if (customerData.email) {
    await prisma.subscription.update({
      where: { email: customerData.email },
      data: {
        plan: 'FREE',
        status: "CANCELLED",
        endDate: new Date(data.ended_at! * 1000)
      }
    });
  }
  break;

Subscription Management

Get Current Subscription

server/billing.ts:66
export async function getSubscriptionData() {
  const supabase = await createClient()
  const { data: { user } } = await supabase.auth.getUser()
  if (!user?.email) throw new Error('User not found')

  // Check for lifetime subscription first
  const localSubscription = await prisma.subscription.findUnique({
    where: { email: user.email },
  })

  if (localSubscription?.status === 'ACTIVE' && localSubscription.interval === 'lifetime') {
    return createLifetimeSubscriptionData(localSubscription, invoices)
  }

  // Check for active recurring subscription
  const customers = await stripe.customers.list({
    email: user.email,
    limit: 1,
  })

  const subscriptions = await stripe.subscriptions.list({
    customer: customer.id,
    status: 'active',
    expand: ['data.plan', 'data.items.data.price', 'data.discounts.coupon'],
  })

  const subscription = subscriptions.data[0]
  // Return subscription data...
}

Cancel Subscription

server/billing.ts:384
export async function updateSubscription(
  action: 'pause' | 'resume' | 'cancel', 
  subscriptionId: string
) {
  try {
    if (action === 'cancel') {
      await stripe.subscriptions.update(subscriptionId, {
        cancel_at_period_end: true
      })
    } else if (action === 'resume') {
      await stripe.subscriptions.update(subscriptionId, {
        cancel_at_period_end: false
      })
    }
    return { success: true }
  } catch (error) {
    return { success: false, error: 'Failed to update subscription' }
  }
}

Switch Plans

server/billing.ts:433
export async function switchSubscriptionPlan(newLookupKey: string) {
  // Get new price by lookup key
  const prices = await stripe.prices.list({
    lookup_keys: [newLookupKey],
    expand: ['data.product'],
  })
  const newPrice = prices.data[0]

  // Get current subscription
  const subscriptions = await stripe.subscriptions.list({
    customer: customer.id,
    status: 'active',
  })
  const currentSubscription = subscriptions.data[0]

  // Update subscription with prorated charges
  const updatedSubscription = await stripe.subscriptions.update(
    currentSubscription.id, 
    {
      items: [{
        id: currentSubscription.items.data[0].id,
        price: newPrice.id,
      }],
      proration_behavior: 'create_prorations',
    }
  )

  // Update local database
  await prisma.subscription.update({
    where: { email: user.email },
    data: {
      plan: productName.toUpperCase(),
      interval: interval,
      endDate: new Date(updatedSubscription.current_period_end * 1000),
    }
  })
}

Checkout Session Creation

Basic Checkout

import { stripe } from '@/server/stripe'

const session = await stripe.checkout.sessions.create({
  mode: 'subscription',
  customer_email: user.email,
  line_items: [{
    price: priceId,
    quantity: 1,
  }],
  success_url: `${websiteURL}dashboard?session_id={CHECKOUT_SESSION_ID}`,
  cancel_url: `${websiteURL}pricing`,
  metadata: {
    referral_code: referralCode, // Optional
  },
})

Lifetime Plan Checkout

const session = await stripe.checkout.sessions.create({
  mode: 'payment', // One-time payment
  customer_email: user.email,
  line_items: [{
    price: lifetimePriceId,
    quantity: 1,
  }],
  success_url: `${websiteURL}dashboard?session_id={CHECKOUT_SESSION_ID}`,
  cancel_url: `${websiteURL}pricing`,
})

Trial Periods

Configure trial periods in Stripe Dashboard when creating prices, or programmatically:
const session = await stripe.checkout.sessions.create({
  mode: 'subscription',
  subscription_data: {
    trial_period_days: 14,
  },
  // ... other options
})
Trial status is tracked in the trialEndsAt field and reflected in the status field:
  • TRIAL - Active trial period
  • ACTIVE - Paid subscription

Coupons and Promotions

Create Coupon

  1. In Stripe Dashboard, go to Products > Coupons
  2. Create coupon with:
    • Percentage off (e.g., 20%)
    • Fixed amount off (e.g., $10.00)
    • Duration: once, repeating, forever

Apply Coupon at Checkout

const session = await stripe.checkout.sessions.create({
  mode: 'subscription',
  discounts: [{
    coupon: 'COUPON_ID',
  }],
  // ... other options
})

Promotion Code

Allow customers to enter promotion codes:
const session = await stripe.checkout.sessions.create({
  mode: 'subscription',
  allow_promotion_codes: true,
  // ... other options
})

Referral Tracking

Referral codes are applied via checkout metadata:
app/api/stripe/webhooks/route.ts:141
// In checkout.session.completed handler
if (data.metadata?.referral_code && user?.id) {
  try {
    const { getReferralBySlug, addReferredUser } = await import('@/server/referral')
    const referral = await getReferralBySlug(data.metadata.referral_code)
    if (referral && referral.userId !== user.id) {
      if (!referral.referredUserIds.includes(user.id)) {
        await addReferredUser(referral.id, user.id)
      }
    }
  } catch (error) {
    console.error('Error applying referral code:', error)
  }
}

Subscription Statuses

Deltalytix maps Stripe statuses to internal statuses:
Stripe StatusInternal StatusDescription
activeACTIVESubscription is active and paid
trialingTRIALIn trial period
canceledCANCELLEDSubscription ended
past_duePAST_DUEPayment failed, retry in progress
unpaidUNPAIDPayment failed, no retry
incompletePAYMENT_PENDINGInitial payment pending
incomplete_expiredEXPIREDInitial payment failed
-SCHEDULED_CANCELLATIONWill cancel at period end
-PAYMENT_FAILEDInvoice payment failed

Troubleshooting

Webhook Not Receiving Events

Symptoms: Subscriptions not updating, checkouts not processing Solutions:
  1. Verify webhook endpoint URL is correct
  2. Check webhook is enabled in Stripe Dashboard
  3. Ensure endpoint is publicly accessible (use ngrok for local testing)
  4. Review webhook logs in Stripe Dashboard

”No signatures found” Error

Cause: Missing or invalid STRIPE_WEBHOOK_SECRET Solution:
  • Verify secret in .env matches Stripe Dashboard
  • Check for extra whitespace or quotes
  • For local testing, use Stripe CLI secret

Subscription Not Syncing

Cause: Webhook event not processed or database update failed Solution:
  1. Check webhook logs: Developers > Webhooks > Events
  2. Review application logs for errors
  3. Manually trigger webhook replay in Stripe Dashboard
  4. Verify database connection and permissions

Customer Not Found

Cause: User email doesn’t match Stripe customer Solution:
  • Ensure checkout uses correct email
  • Check for email mismatches (case sensitivity)
  • Create customer explicitly before checkout

Security Best Practices

Critical Security Measures:
  • Never expose STRIPE_SECRET_KEY in client-side code
  • Always verify webhook signatures
  • Use HTTPS in production
  • Validate user permissions before subscription changes
  • Log all subscription events for audit trail

Prevent Unauthorized Access

export async function updateSubscription(action: string, subscriptionId: string) {
  // Verify user owns this subscription
  const supabase = await createClient()
  const { data: { user } } = await supabase.auth.getUser()
  
  const subscription = await prisma.subscription.findUnique({
    where: { userId: user.id }
  })
  
  if (!subscription || subscription.id !== subscriptionId) {
    throw new Error('Unauthorized')
  }
  
  // Proceed with update...
}

Next Steps

Build docs developers (and LLMs) love