| name | billing-system-expert |
| description | Expert knowledge on Stripe integration, subscription plans (Glow Up, Viral Surge, Fame Flex), trial logic, plan enforcement, webhooks, and billing synchronization. Use this skill when user asks about "subscription", "billing", "stripe", "payment", "plan limits", "trial", "upgrade", "downgrade", "webhook", or "plan enforcement". |
| allowed-tools | Read, Grep, Glob, Bash |
Billing System Expert
You are an expert in the billing and subscription system for this influencer discovery platform. This skill provides comprehensive knowledge about Stripe integration, subscription plans, trial management, plan enforcement, and webhook handling.
When To Use This Skill
This skill activates when users:
- Ask about subscription plans or pricing
- Need to debug billing issues or sync problems
- Work with Stripe webhooks or payment flows
- Implement plan limit enforcement
- Debug trial period activation or conversion
- Investigate upgrade/downgrade flows
- Need to understand payment method handling
- Troubleshoot stuck onboarding or billing states
Core Knowledge
Subscription Plans
The platform offers three paid tiers plus a free tier:
Plan Structure:
{
planKey: 'glow_up' | 'viral_surge' | 'fame_flex' | 'free',
campaignsLimit: number,
creatorsLimit: number,
features: jsonb,
priceMonthly: number,
priceYearly: number
}
Plan Limits (from /lib/services/plan-enforcement.ts):
-
Glow Up (Entry Level)
- Campaigns: 3
- Creators: 1,000/month
- Stripe Price IDs:
- Monthly:
process.env.STRIPE_GLOW_UP_MONTHLY_PRICE_ID
- Yearly:
process.env.STRIPE_GLOW_UP_YEARLY_PRICE_ID
-
Viral Surge (Pro Level)
- Campaigns: 10
- Creators: 10,000/month
- Stripe Price IDs:
- Monthly:
process.env.STRIPE_VIRAL_SURGE_MONTHLY_PRICE_ID
- Yearly:
process.env.STRIPE_VIRAL_SURGE_YEARLY_PRICE_ID
-
Fame Flex (Unlimited)
- Campaigns: Unlimited (-1)
- Creators: Unlimited (-1)
- Stripe Price IDs:
- Monthly:
process.env.STRIPE_FAME_FLEX_MONTHLY_PRICE_ID
- Yearly:
process.env.STRIPE_FAME_FLEX_YEARLY_PRICE_ID
-
Free Tier (Default)
- Campaigns: 1 (or 0, check implementation)
- Creators: 50
- No Stripe subscription required
Plan Enforcement Logic
Service: /lib/services/plan-enforcement.ts
Key Functions:
class PlanEnforcementService {
static async getPlanLimits(userId: string): Promise<PlanLimits | null>
static async getCurrentUsage(userId: string): Promise<UsageInfo | null>
static async validateCampaignCreation(userId: string): Promise<{
allowed: boolean;
reason?: string;
usage?: UsageInfo;
}>
static async validateJobCreation(userId: string, expectedCreators: number): Promise<{
allowed: boolean;
reason?: string;
usage?: UsageInfo;
adjustedLimit?: number;
}>
(: ): <>
(: , : ): <>
}
Usage Tracking:
- Campaigns: Total count (not monthly reset)
- Creators: Monthly count (resets first day of month)
Example Enforcement:
const validation = await PlanEnforcementService.validateCampaignCreation(userId);
if (!validation.allowed) {
return NextResponse.json(
{ error: validation.reason, usage: validation.usage },
{ status: 403 }
);
}
await PlanEnforcementService.trackCampaignCreated(userId);
Dev Bypass (Non-Production Only):
PLAN_VALIDATION_BYPASS=all
headers: {
'x-plan-bypass': 'all'
}
Stripe Integration
Stripe Service: /lib/stripe/stripe-service.ts
Webhook Handler: /app/api/stripe/webhook/route.ts
Key Webhook Events:
-
checkout.session.completed
- Triggered after successful checkout
- Finalizes onboarding
- Links Stripe customer to user
- Triggers trial activation
-
customer.subscription.created
- Triggered when subscription is created
- Updates user plan in database
- Sets plan limits
- Activates trial if applicable
- CRITICAL: Must resolve plan from price ID
-
customer.subscription.updated
- Triggered on plan changes or status updates
- Handles trial → paid conversion
- Updates plan limits on upgrades
- Handles cancellation scheduling
-
customer.subscription.deleted
- Triggered when subscription ends
- Resets user to free tier
- Clears plan limits
-
customer.subscription.trial_will_end
- Triggered 3 days before trial ends
- Can trigger reminder emails
-
invoice.payment_succeeded
- Triggered on successful payment
- Updates billing sync status
-
invoice.payment_failed
- Triggered on failed payment
- Can trigger dunning emails
-
setup_intent.succeeded
- Triggered when payment method is set up
- Links payment method to customer
-
payment_method.attached
- Triggered when card is added
- Stores card details (last4, brand, exp)
Price ID to Plan Mapping
Critical Logic (from webhook handler):
function getPlanFromPriceId(priceId: string): string {
const priceIdToplan = {
[process.env.STRIPE_GLOW_UP_MONTHLY_PRICE_ID!]: 'glow_up',
[process.env.STRIPE_GLOW_UP_YEARLY_PRICE_ID!]: 'glow_up',
[process.env.STRIPE_VIRAL_SURGE_MONTHLY_PRICE_ID!]: 'viral_surge',
[process.env.STRIPE_VIRAL_SURGE_YEARLY_PRICE_ID!]: 'viral_surge',
[process.env.STRIPE_FAME_FLEX_MONTHLY_PRICE_ID!]: 'fame_flex',
[process.env.STRIPE_FAME_FLEX_YEARLY_PRICE_ID!]: 'fame_flex',
};
return priceIdToplan[priceId] || 'unknown';
}
CRITICAL: Never use arbitrary fallback plans. If plan cannot be determined, throw error and retry webhook.
Trial System
Trial Logic: /lib/services/trial-status-calculator.ts
Trial States:
inactive: No trial started
active: Currently in trial period
expired: Trial ended without conversion
converted: Trial converted to paid subscription
Trial Activation:
if (subscription.trial_end && subscription.status === 'trialing') {
await updateUserProfile(userId, {
trialStatus: 'active',
trialStartDate: new Date(),
trialEndDate: new Date(subscription.trial_end * 1000),
onboardingStep: 'completed'
});
}
Trial Conversion:
if (subscription.status === 'active' && user.trialStatus === 'active') {
await updateUserProfile(userId, {
trialStatus: 'converted',
trialConversionDate: new Date()
});
}
Billing Sync States
Field: billingSyncStatus in user_profiles table
Possible Values:
webhook_subscription_created - Subscription created successfully
webhook_subscription_updated - Subscription updated
webhook_subscription_deleted - Subscription cancelled
webhook_trial_will_end - Trial ending soon
webhook_payment_succeeded - Payment successful
webhook_payment_failed - Payment failed
webhook_setup_intent_succeeded - Payment method added
webhook_payment_method_attached - Card attached
webhook_emergency_fallback - Webhook failed, used fallback
Checking Sync Status:
node scripts/inspect-user-state.js --email user@example.com
Common Patterns
Pattern 1: Enforcing Plan Limits Before Action
export async function POST(req: Request) {
const { userId } = await getAuthOrTest();
const validation = await PlanEnforcementService.validateCampaignCreation(userId);
if (!validation.allowed) {
return NextResponse.json(
{
error: validation.reason,
usage: validation.usage,
upgrade_required: true
},
{ status: 403 }
);
}
const campaign = await db.insert(campaigns).values({ });
await PlanEnforcementService.trackCampaignCreated(userId);
return NextResponse.json({ campaign });
}
When to use: Before any action that counts against limits
Pattern 2: Webhook Signature Verification
export async function POST(req: NextRequest) {
const body = await req.text();
const signature = req.headers.get('stripe-signature');
if (!signature) {
return NextResponse.json({ error: 'No signature' }, { status: 400 });
}
const event = StripeService.validateWebhookSignature(body, signature);
switch (event.type) {
case 'customer.subscription.created':
await handleSubscriptionCreated(event.data.object);
break;
}
return NextResponse.json({ received: true });
}
When to use: All Stripe webhook endpoints
Pattern 3: Resolving Plan from Subscription
async function resolvePlanFromSubscription(subscription: Stripe.Subscription): Promise<string> {
let planId = subscription.metadata.plan || subscription.metadata.planId;
if (!planId || planId === 'unknown') {
const priceId = subscription.items.data[0]?.price?.id;
if (priceId) {
planId = getPlanFromPriceId(priceId);
}
}
if (!planId || planId === 'unknown') {
throw new Error(
`Cannot determine plan for subscription ${subscription.id}. Will retry.`
);
}
return planId;
}
When to use: Processing subscription webhooks
Anti-Patterns (Avoid These)
Anti-Pattern 1: Using Arbitrary Fallback Plans
function getPlanFromPriceId(priceId: string): string {
const mapping = { };
return mapping[priceId] || 'glow_up';
}
Why it's bad: User pays for Fame Flex but gets Glow Up limits
Do this instead:
function getPlanFromPriceId(priceId: string): string {
const mapping = { };
const plan = mapping[priceId];
if (!plan) {
throw new Error(`Unknown price ID: ${priceId}. Webhook will retry.`);
}
return plan;
}
Anti-Pattern 2: Tracking Usage Before Validation
await PlanEnforcementService.trackCampaignCreated(userId);
const validation = await PlanEnforcementService.validateCampaignCreation(userId);
if (!validation.allowed) {
return NextResponse.json({ error: 'Limit exceeded' }, { status: 403 });
}
Why it's bad: Usage counter increases even when action fails
Do this instead:
const validation = await PlanEnforcementService.validateCampaignCreation(userId);
if (!validation.allowed) {
return NextResponse.json({ error: 'Limit exceeded' }, { status: 403 });
}
const campaign = await createCampaign();
await PlanEnforcementService.trackCampaignCreated(userId);
Anti-Pattern 3: Skipping Webhook Verification
export async function POST(req: Request) {
const event = await req.json();
await handleSubscriptionCreated(event.data.object);
}
Why it's bad: Anyone can forge webhooks and manipulate plans
Do this instead:
const body = await req.text();
const signature = req.headers.get('stripe-signature');
if (!signature) {
return NextResponse.json({ error: 'No signature' }, { status: 400 });
}
const event = StripeService.validateWebhookSignature(body, signature);
Troubleshooting Guide
Problem: User Plan Not Updating After Payment
Symptoms:
- User completed checkout but still shows free plan
- Stripe dashboard shows active subscription
- User cannot access paid features
Diagnosis:
- Check webhook delivery in Stripe dashboard
- Verify webhook endpoint is accessible
- Check
billing_sync_status in database
- Look for errors in webhook logs
node scripts/inspect-user-state.js --email user@example.com
grep "STRIPE-WEBHOOK" logs/app.log | grep "ERROR"
Solution:
curl -X POST http://localhost:3000/api/billing/sync-stripe \
-H "x-dev-auth: dev-bypass" \
-H "Content-Type: application/json" \
-d '{"userId": "user_xxx"}'
Problem: Plan Limits Not Enforced
Symptoms:
- User exceeds campaign limit but can create more
- Creator count not tracked
- No "upgrade required" error
Diagnosis:
- Check if validation is called before action
- Verify
PLAN_VALIDATION_BYPASS is not set in production
- Check plan limits in
subscription_plans table
- Verify usage tracking is called after action
Solution:
import { PlanEnforcementService } from '@/lib/services/plan-enforcement';
export async function POST(req: Request) {
const { userId } = await getAuthOrTest();
const validation = await PlanEnforcementService.validateCampaignCreation(userId);
if (!validation.allowed) {
return NextResponse.json({ error: validation.reason }, { status: 403 });
}
await PlanEnforcementService.trackCampaignCreated(userId);
return NextResponse.json({ success: true });
}
Problem: Trial Not Activating After Checkout
Symptoms:
- User completed checkout with trial
trial_status is inactive
onboarding_step not completed
Diagnosis:
- Check if
checkout.session.completed webhook fired
- Verify subscription has
trial_end timestamp
- Check
finalizeOnboarding was called
- Look for errors in webhook logs
Solution:
node scripts/complete-onboarding-and-activate-plan.js user_xxx
Or trigger via API:
curl -X POST http://localhost:3000/api/onboarding/complete \
-H "x-dev-auth: dev-bypass" \
-H "x-dev-user-id: user_xxx"
Problem: Webhook Failing with "Unknown Price ID"
Symptoms:
- Webhook returns 500 error
- Logs show "Cannot determine plan"
- User plan not updated
Diagnosis:
- Check if price ID exists in Stripe dashboard
- Verify
.env has all STRIPE_*_PRICE_ID variables
- Check for typos in environment variables
- Ensure webhook uses correct price ID mapping
Solution:
grep "STRIPE_.*PRICE_ID" .env.local
STRIPE_GLOW_UP_MONTHLY_PRICE_ID=price_xxx
STRIPE_GLOW_UP_YEARLY_PRICE_ID=price_yyy
If missing, add to .env.local and restart server.
Problem: User Upgraded But Still Has Old Limits
Symptoms:
- User paid for Viral Surge but has Glow Up limits
current_plan is correct but plan_campaigns_limit is wrong
- Can't create more campaigns despite upgrade
Diagnosis:
- Check
subscription.updated webhook fired
- Verify plan limits are fetched from
subscription_plans table
- Check webhook sets
planCampaignsLimit and planCreatorsLimit
Solution:
const planDetails = await db.query.subscriptionPlans.findFirst({
where: eq(subscriptionPlans.planKey, planId)
});
await updateUserProfile(userId, {
currentPlan: planId,
planCampaignsLimit: planDetails?.campaignsLimit || 0,
planCreatorsLimit: planDetails?.creatorsLimit || 0
});
Related Files
/lib/services/plan-enforcement.ts - Plan validation and usage tracking
/lib/services/billing-service.ts - Billing operations
/lib/stripe/stripe-service.ts - Stripe client wrapper
/app/api/stripe/webhook/route.ts - Webhook event handlers
/app/api/billing/status/route.ts - Get billing status
/app/api/billing/sync-stripe/route.ts - Manual sync endpoint
/app/api/campaigns/can-create/route.ts - Campaign validation endpoint
/scripts/inspect-user-state.js - Diagnostic script
/scripts/fix-user-billing-state.js - Fix script
Testing & Validation
Test Plan Enforcement:
node scripts/complete-onboarding-and-activate-plan.js user_xxx glow_up
curl -X POST http://localhost:3000/api/campaigns \
-H "x-dev-user-id: user_xxx" \
-d '{"name": "Test Campaign 1"}'
curl http://localhost:3000/api/billing/status \
-H "x-dev-user-id: user_xxx"
Test Stripe Webhooks Locally:
stripe listen --forward-to localhost:3000/api/stripe/webhook
stripe trigger customer.subscription.created
Expected Behavior:
- Webhook received and verified
- User plan updated in database
- Plan limits set correctly
- Billing sync status updated
- No errors in logs
Subscription Flow Diagram
User Checkout
↓
Stripe Checkout Session
↓
checkout.session.completed (webhook)
↓
Link Stripe Customer to User
↓
customer.subscription.created (webhook)
↓
Resolve Plan from Price ID
↓
Update user_profiles:
- current_plan
- plan_campaigns_limit
- plan_creators_limit
- stripe_subscription_id
- subscription_status
- trial_status (if trial)
↓
Finalize Onboarding
↓
User Can Access Platform
Additional Resources