Deltalytix uses Stripe for subscription management, supporting multiple plan types including monthly, quarterly, annual, and lifetime subscriptions.
Stripe Setup
Create Stripe Account
- Sign up at https://stripe.com
- Complete account verification
- Access your Dashboard
API Keys
Get your API keys from Developers > API keys:
# 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:
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:
-
Basic Plan
- Name:
Basic
- Description: Essential features for individual traders
- Pricing: Create multiple prices (monthly, quarterly, annual)
-
Pro Plan
- Name:
Pro
- Description: Advanced features and analytics
- Pricing: Create multiple prices with different intervals
-
Lifetime Plan
- Name:
Lifetime
- Description: One-time payment for lifetime access
- Pricing: One-time payment (not recurring)
Price Configuration
For each recurring price:
- Monthly: Standard monthly billing
- Quarterly: Set interval to
month with interval count 3
- Annual: Yearly billing
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:
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:
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:
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
-
In Stripe Dashboard, go to Developers > Webhooks
-
Click Add endpoint
-
Enter your endpoint URL:
- Development:
http://localhost:3000/api/stripe/webhooks
- Production:
https://yourdomain.com/api/stripe/webhooks
-
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
-
Copy the Signing secret
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:
-
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
-
Login:
-
Forward events:
stripe listen --forward-to localhost:3000/api/stripe/webhooks
-
Copy the webhook signing secret from the output and add to
.env
-
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
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
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
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
Create Coupon
- In Stripe Dashboard, go to Products > Coupons
- 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
})
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 Status | Internal Status | Description |
|---|
active | ACTIVE | Subscription is active and paid |
trialing | TRIAL | In trial period |
canceled | CANCELLED | Subscription ended |
past_due | PAST_DUE | Payment failed, retry in progress |
unpaid | UNPAID | Payment failed, no retry |
incomplete | PAYMENT_PENDING | Initial payment pending |
incomplete_expired | EXPIRED | Initial payment failed |
| - | SCHEDULED_CANCELLATION | Will cancel at period end |
| - | PAYMENT_FAILED | Invoice payment failed |
Troubleshooting
Webhook Not Receiving Events
Symptoms: Subscriptions not updating, checkouts not processing
Solutions:
- Verify webhook endpoint URL is correct
- Check webhook is enabled in Stripe Dashboard
- Ensure endpoint is publicly accessible (use ngrok for local testing)
- 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:
- Check webhook logs: Developers > Webhooks > Events
- Review application logs for errors
- Manually trigger webhook replay in Stripe Dashboard
- 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