Billing & Subscription Management
TinyKit Pro includes comprehensive Stripe integration for subscription billing, customer management, and payment processing.
TinyKit Pro includes comprehensive Stripe integration for subscription billing, customer management, and payment processing.
Billing Features
- ๐ณ Subscription Plans: Multiple tiers with monthly/yearly billing
- ๐ช Customer Portal: Stripe-powered self-service billing management
- ๐ Usage Tracking: Monitor subscription usage and limits
- ๐ Smart Proration: Accurate proration for plan changes and upgrades
- ๐ Test Clock Support: Development environment with frozen time for testing
- ๐ง Billing Notifications: Automated email alerts for billing events
- ๐ซ Promotion Codes: Discount support with automatic application
- โฐ Real-time Expiration: Intelligent subscription expiration with graceful access control
- ๐งช Trial Period Tracking: Accurate trial start/end date tracking with proper end date display
- ๐ซ Cancellation Management: Complete tracking of subscription cancellations with
cancelAtandcanceledAttimestamps - ๐ Effective End Dates: Smart display logic showing trial end dates vs billing period end dates
- ๐ Flexible Entity Types: Support for both personal and organization-level subscriptions with seamless switching
Subscription Plans
Available Plans
Personal Plans:
- Free: Basic workspace access, up to 2 organization members
- Solo: $9/month, unlimited personal workspaces, advanced features
- Premium: $19/month, everything in Solo plus analytics and API access
Organization Plans:
- Starter: $19/month or $190/year, up to 5 organization members
- Professional: $29/month or $290/year, up to 20 organization members (recommended)
- Enterprise: $39/month or $390/year, unlimited members, enterprise features
- Lifetime: $500 one-time, everything in Enterprise with lifetime access
Plan Configuration
Plans are configured in the seed configuration file:
Location: /convex/seeds/stripeProductsHelper.ts
Subscription Type Selection
The system now provides a seamless subscription type selection flow:
-
Type Selection: When signing up for a plan, users can choose:
- Personal: Tied to their individual account
- Organization: For team usage with shared billing
-
Flow Integration:
// Store selected plan with subscription type storeSelectedPlan({ planName: "Professional", priceId: "price_pro_monthly", amount: 2900, billingInterval: "month", subscriptionType: "org", // "personal" | "org" }); -
Organization Creation:
- Personal subscriptions proceed directly to checkout
- Organization subscriptions prompt for organization creation first
- New organizations automatically become the billing entity
Landing Page Plan Display
The landing page pricing display is controlled by the defaultSubscriptionType setting in the database, configurable through the admin billing settings panel:
- Personal: Shows only personal plans on the landing page
- Organization: Shows only organization plans on the landing page
- Both: Shows all plans (both personal and organization)
This is separate from the enableOrganizations feature setting (controlled via Admin > Site Settings > General), which controls UI availability. The database setting allows for marketing flexibility independent of feature availability.
Access: Admin Panel โ Billing Settings โ Landing Page Settings
interface ProductConfig {
name: string; // Internal product name
displayName: string; // User-facing product name
description: string; // Product description
active: boolean; // Availability status
type: SubscriptionType;
accessLevel: number; // Feature access level (higher = more features)
displayOrder: number; // UI display ordering
isRecommended?: boolean;
features: string[]; // Feature list for marketing
prices: ProductPrice[]; // Associated pricing options
}Smart Proration System
Test Clock Integration
The billing system uses Stripe test clocks for accurate proration testing in development:
// Environment-aware proration calculation
const isProduction = !process.env.STRIPE_SECRET_KEY?.includes("sk_test_");
if (!isProduction && subscriptionWithTestClock.test_clock) {
// Use test clock's frozen time for accurate testing
const testClock = await stripe.testHelpers.testClocks.retrieve(
subscriptionWithTestClock.test_clock,
);
prorationDate = testClock.frozen_time;
} else {
// Use actual current time for production
prorationDate = Math.floor(Date.now() / 1000);
}Proration Benefits
- Accurate Pricing: Prorations reflect actual elapsed billing periods
- Development Testing: Test clock support for consistent testing
- Fair Billing: Customers pay only for time used
- Transparent Calculations: Detailed proration breakdown in checkout
Testing Proration
# Create test clock with frozen time
stripe test_clocks create --frozen-time 1640995200 # Jan 1, 2022
# Advance test clock to simulate time passage
stripe test_clocks advance clock_1234 --frozen-time 1641600000 # Jan 8, 2022
# Check proration calculations
# Should show ~$7.50 for 1 week elapsed instead of $10Subscription Management
Subscription States
- Active: Subscription is current and billing normally
- Past Due: Payment failed, grace period active
- Canceled: Subscription ended, access restricted
- Trialing: In trial period, full access without payment
- Incomplete: Payment method needs authentication
Subscription Expiration Handling
The system includes intelligent subscription expiration handling that provides real-time access control:
Real-time Expiration Checks
- Immediate Protection: Access is checked in real-time, no waiting for webhooks or scheduled jobs
- Graceful Cancellation: Users retain access until their paid period expires
- Automatic Revocation: Access is automatically denied after
currentPeriodEnddate
Implementation Details
// Enhanced subscription activity check (uses Stripe component data)
export function isSubscriptionActive(subscription: {
status: string;
cancelAtPeriodEnd?: boolean;
currentPeriodEnd?: number;
}): boolean {
const isStatusActive =
subscription.status === "active" || subscription.status === "trialing";
// If status is not active, subscription is definitely not active
if (!isStatusActive) {
return false;
}
// If subscription is canceled at period end and has expired, it's not active
const hasExpired = subscription.currentPeriodEnd
? subscription.currentPeriodEnd * 1000 < Date.now()
: false;
if (subscription.cancelAtPeriodEnd && hasExpired) {
return false;
}
return true;
}Note: Subscription data (status, cancelAtPeriodEnd, currentPeriodEnd) is fetched from the @convex-dev/stripe component, not stored in local tables.
Access Control Flow
- Before Cancellation:
status: "active"+cancelAtPeriodEnd: falseโ โ Access granted - After Cancellation (Grace Period):
status: "active"+cancelAtPeriodEnd: true+currentPeriodEnd > Date.now()โ โ Access granted - After Period End:
status: "active"+cancelAtPeriodEnd: true+currentPeriodEnd < Date.now()โ โ Access denied
Benefits
- No Infrastructure Changes: Uses existing database fields and logic
- Backward Compatible: Existing code automatically benefits from enhanced checks
- Consistent Behavior: Both team and personal subscriptions use the same logic
- Defense in Depth: Works alongside webhook processing for robustness
Plan Changes
// Upgrade/downgrade with smart button labels
const changeSubscription = useMutation(api.billing.actions.changeSubscription);
await changeSubscription({
teamId: team._id,
newPriceId: "price_professional_monthly",
prorationBehavior: "create_prorations",
});Dynamic Button Labels
The system automatically determines appropriate action labels:
- "Upgrade": Moving to more expensive plan
- "Downgrade": Moving to less expensive plan
- "Purchase": Starting new subscription
- "Change Billing": Switching between monthly/yearly
Enhanced Subscription Tracking
Trial Period Management
The system now includes comprehensive trial period tracking to ensure accurate date display and user experience:
Data Source:
Trial and cancellation data is stored in the @convex-dev/stripe component and fetched when needed:
// Fields available from Stripe component subscription data:
// - trial_start: Trial start timestamp
// - trial_end: Trial end timestamp
// - cancel_at: Scheduled cancellation timestamp
// - canceled_at: Actual cancellation timestamp
// - current_period_end: Billing period end timestampKey Features:
- Accurate Trial Dates: Displays actual 3-day trial period instead of 30-day billing period
- Smart End Date Logic: Shows trial end date during trial, billing period end date after
- Cancellation Tracking: Tracks both scheduled and actual cancellation timestamps
- Real-time Status: Dynamically updates subscription status display
Subscription Status Display Logic
The billing information card now uses intelligent logic to show the most relevant dates:
// Priority order for displaying subscription end dates:
1. Trial period (if status === "trialing") โ show trialEnd
2. Scheduled cancellation โ show cancelAt
3. Active subscription โ show currentPeriodEnd
4. Past due โ show currentPeriodEnd with warningExample Display States:
// Trial subscription (3-day trial)
"Your Starter subscription trial ends on October 26, 2025";
// Active subscription with scheduled cancellation
"Your Starter subscription is scheduled to end on November 15, 2025";
// Regular active subscription
"Your Starter subscription renews on November 15, 2025";
// Past due subscription
"Your Starter subscription was due on November 15, 2025";Webhook Synchronization
Enhanced webhook processing ensures accurate trial and cancellation data:
// Stripe webhook events now capture:
- customer.subscription.created โ trialStart, trialEnd
- customer.subscription.updated โ cancelAt updates
- customer.subscription.deleted โ canceledAt timestampBenefits:
- User Clarity: Users see exact trial duration instead of confusing billing periods
- Accurate Status: Real-time subscription status updates from Stripe
- Better UX: Clear messaging about when subscriptions end vs renew
- Cancellation Tracking: Complete audit trail of subscription changes
Promotion Code Management
TinyKit Pro includes a comprehensive promotion code system integrated with Stripe:
Promotion Code Features
- Flexible Discounts: Support for both percentage and fixed amount discounts
- Usage Limits: Optional maximum usage count for codes
- Expiration Control: Optional expiration dates for time-limited promotions
- Stripe Integration: Automatic syncing with Stripe coupon system
- Usage Tracking: Real-time tracking of promotion code usage
Creating Promotion Codes
const createPromoCode = useMutation(api.billing.private.createPromoCode);
await createPromoCode({
code: "LAUNCH2024",
type: "percentage",
amount: 20, // 20% off
maxUses: 100,
expiresAt: new Date("2025-01-01").getTime(),
});Applying Promotion Codes
const applyPromoCode = useMutation(api.billing.private.applyPromoCode);
await applyPromoCode({
code: "LAUNCH2024",
subscriptionId: subscription._id,
});Managing Promotion Codes
Admins can manage promotion codes through the admin interface:
-
Create new promotion codes with:
- Code identifier
- Discount type (percentage/fixed)
- Discount amount
- Usage limits
- Expiration dates
-
Monitor usage statistics:
- Total uses
- Remaining uses
- Total discount amount
- Active/inactive status
-
Administrative actions:
- Deactivate codes
- Adjust usage limits
- Modify expiration dates
Implementation Details
// Promotion code schema
const promoCodes = defineTable({
code: v.string(),
type: v.union(v.literal("percentage"), v.literal("fixed")),
amount: v.number(),
maxUses: v.optional(v.number()),
useCount: v.number(),
expiresAt: v.optional(v.number()),
active: v.boolean(),
stripeCouponId: v.string(),
}).index("by_code", ["code"]);
// Check promotion code validity
export const validatePromoCode = mutation({
args: { code: v.string() },
handler: async (ctx, args) => {
const promoCode = await ctx.db
.query("promoCodes")
.withIndex("by_code", (q) => q.eq("code", args.code))
.first();
if (!promoCode) throw new ConvexError("Invalid promotion code");
if (!promoCode.active) throw new ConvexError("Promotion code is inactive");
if (promoCode.expiresAt && promoCode.expiresAt < Date.now()) {
throw new ConvexError("Promotion code has expired");
}
if (promoCode.maxUses && promoCode.useCount >= promoCode.maxUses) {
throw new ConvexError("Promotion code has reached maximum uses");
}
return promoCode;
},
});Stripe Integration
The system automatically handles:
- Creating corresponding Stripe coupons
- Applying coupons during checkout
- Tracking usage in both systems
- Handling proration with discounts
- Retrieving promotion codes with embedded coupon data for performance
// Create Stripe coupon with promotion code
const stripeCoupon = await stripe.coupons.create({
name: args.code,
percent_off: args.type === "percentage" ? args.amount : undefined,
amount_off: args.type === "fixed" ? args.amount : undefined,
currency: args.type === "fixed" ? "usd" : undefined,
max_redemptions: args.maxUses,
redeem_by: args.expiresAt ? Math.floor(args.expiresAt / 1000) : undefined,
});
// Retrieve promotion code with expanded coupon data
const promotionCode = await stripe.promotionCodes.retrieve(promoCodeId, {
expand: ["coupon"], // Includes full coupon details in response
});Performance Optimization: The promotion code retrieval system now uses Stripe's expand parameter to include coupon details directly in the API response, eliminating the need for additional API calls and improving response times.
Automatic Webhook Synchronization
The billing system automatically syncs promotion codes when Stripe coupon webhooks fire:
How It Works:
- When a coupon is created or updated in Stripe, the
coupon.createdorcoupon.updatedwebhook fires - The system calls Stripe's API to fetch ALL promotion codes that reference this coupon
- Each promotion code (with embedded coupon data) is synced to the database using fresh data from Stripe
- Admins can immediately view and manage these codes in
/admin/promotions
Benefits:
- Always Fresh Data: Uses current Stripe API data instead of potentially stale webhook payloads
- Automatic Discovery: Promotion codes created in Stripe Dashboard are automatically synced
- No Manual Sync Required: Webhook-driven synchronization happens automatically
- Coupon Deletion Handling: When coupons are deleted, all related promotion codes are marked inactive
Implementation:
// Webhook handler fetches promotion codes from Stripe API
export const handleCouponEvent = internalAction({
handler: async ({ runMutation }, { event }) => {
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {});
const coupon = event.data.object as Stripe.Coupon;
// Fetch ALL promotion codes for this coupon
const promotionCodes = await stripe.promotionCodes.list({
coupon: coupon.id,
limit: 100,
expand: ["data.coupon"], // Include full coupon data
});
// Sync each code to database
for (const promotionCode of promotionCodes.data) {
await runMutation(
internal.billing.internal.mutations.upsertPromotionCode,
{
// ... promotion code data with embedded coupon details
},
);
}
},
});Use Cases:
- Create coupons in Stripe Dashboard โ Automatically sync promotion codes
- Update coupon discount amounts โ All promotion codes reflect new values
- Delete coupons โ Related promotion codes marked inactive
- Bulk import promotion codes in Stripe โ Automatically appear in admin panel
Customer Portal Integration
Portal Features
Stripe Customer Portal provides self-service capabilities:
- Payment Methods: Add, remove, and update payment methods
- Billing History: View invoices and payment history
- Plan Management: Upgrade, downgrade, or cancel subscriptions
- Invoice Downloads: PDF invoice downloads
Portal Configuration
Important: Customer Portal must be configured in Stripe Dashboard:
- Go to Stripe Customer Portal Settings
- Click "Activate test link" or "Configure portal"
- Configure features: subscriptions, payment methods, billing history
- Add business information and support email
- Configure cancellation and plan switching options
- Click "Save" to activate
Portal Access
// Generate customer portal session
const createPortalSession = useMutation(
api.billing.actions.createCustomerPortalSession,
);
const handleManageBilling = async () => {
const { url } = await createPortalSession({
teamId: team._id,
returnUrl: window.location.href,
});
window.location.href = url;
};Webhook Integration
Webhook Events
The system handles key Stripe webhook events:
customer.subscription.created: New subscription activationcustomer.subscription.updated: Plan changes and modificationscustomer.subscription.deleted: Cancellation handlinginvoice.payment_succeeded: Successful payment processinginvoice.payment_failed: Failed payment notifications
Webhook Endpoint
Location: /convex/http.ts - handles /stripe endpoint
// Webhook signature verification
const sig = request.headers.get("stripe-signature");
const event = stripe.webhooks.constructEvent(body, sig!, webhookSecret);
// Event processing
switch (event.type) {
case "customer.subscription.created":
await handleSubscriptionCreated(event.data.object);
break;
case "customer.subscription.updated":
await handleSubscriptionUpdated(event.data.object);
break;
// ... handle other events
}Webhook Testing
# Forward webhooks to local development server
bun dev:stripe
# Test specific webhook events
stripe trigger customer.subscription.created
stripe trigger invoice.payment_succeededProduct Sync System
Smart Sync Logic
The system intelligently manages product synchronization between seed configuration, Stripe, and database:
- Check Database: Count existing products in Convex
- Check Stripe: Count existing products in Stripe account
- Determine Action: Choose sync from Stripe or create from seed
- Execute Operation: Sync products and prices to database
Sync Scenarios
- Products Exist: Show Stripe Dashboard link, disable sync
- Sync from Stripe: Import existing Stripe products to database
- Create from Seed: Create products in Stripe from configuration, then sync
Admin Interface
Product management available at /admin/products:
// Check system status and determine appropriate action
const status = useQuery(api.billing.actions.checkProductsStatus);
// Smart sync based on current state
const syncProducts = useMutation(
api.billing.public.mutations.syncProductsFromStripe,
);Billing Notifications
Automated Notifications
The system sends notifications for billing events:
- Subscription Created: Welcome message with plan details
- Subscription Updated: Plan change confirmations
- Subscription Canceled: Cancellation confirmations with reactivation options
- Payment Failed: Payment failure alerts with resolution steps
- Invoice Paid: Payment confirmations and receipt delivery
Notification Integration
// Send billing notification
await createNotification(ctx, {
userId: subscription.customerId,
type: "subscription_created",
title: "Welcome to Professional Plan!",
message: "Your subscription is now active.",
deliveryChannels: ["in-app", "email"],
emailTemplate: "SubscriptionCreatedEmail",
});Development & Testing
Test Mode Setup
- Use Test API Keys: Ensure
STRIPE_SECRET_KEYcontainssk_test_ - Test Clock: Create and use test clocks for time-based testing
- Test Cards: Use Stripe test card numbers for payments
- Webhook Testing: Use
stripe listenfor local webhook forwarding
Testing Checklist
- Subscription creation and activation
- Plan upgrades and downgrades with correct proration
- Payment failure handling and retry logic
- Customer portal access and functionality
- Webhook delivery and processing
- Billing notifications and email delivery
- Organization subscription type selection
- Organization creation during subscription flow
Debug Commands
# Monitor Stripe webhook events
stripe logs tail
# View Convex billing logs
npx convex logs --tail | grep "billing\|stripe\|subscription"
# Check subscription status in Stripe
stripe subscriptions list --limit 10
# Test proration calculations
stripe subscriptions update sub_1234 --proration-date $(date +%s)Database Schema
TinyKit Pro uses the @convex-dev/stripe component for subscription, payment, and invoice storage. The application schema contains only minimal join tables that link the component data to our application entities.
Architecture Overview
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ @convex-dev/stripe Component โ
โ (Handles subscriptions, invoices, payments, webhook events) โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ References via stripeSubscriptionId
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Application Join Tables โ
โ userSubscriptions โ orgSubscriptions โ products โ prices โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโTables Overview
| Table | Purpose | Key Fields |
|---|---|---|
userSubscriptions | Join table: links users to Stripe subs | userId, stripeSubscriptionId, stripeCustomerId |
orgSubscriptions | Join table: links orgs to Stripe subs | orgId, billingUserId, stripeSubscriptionId |
products | Synced Stripe products | stripeProductId, name, type, accessLevel |
prices | Synced Stripe prices | stripePriceId, amount, interval, trialDays |
promotionCodes | Discount codes with embedded coupon data | code, percentOff, amountOff, duration |
Note: Subscription details (status, period dates, trial info, cancellation) are stored in the Stripe component and fetched when needed - not duplicated locally.
User Subscriptions Join Table (Minimal)
// Links personal subscriptions from @convex-dev/stripe component to users
userSubscriptions: defineTable({
userId: v.string(), // Better Auth user ID
stripeSubscriptionId: v.string(), // Reference to component subscription
stripeCustomerId: v.string(), // For portal/checkout creation
})
.index("by_user", ["userId"])
.index("by_stripe_subscription", ["stripeSubscriptionId"])
.index("by_stripe_customer", ["stripeCustomerId"]);Organization Subscriptions Join Table (Minimal)
// Links organization subscriptions from @convex-dev/stripe component to orgs
orgSubscriptions: defineTable({
orgId: v.string(), // Better Auth org ID
billingUserId: v.string(), // Owner who pays (Better Auth user ID)
stripeSubscriptionId: v.string(), // Reference to component subscription
stripeCustomerId: v.string(), // For portal/checkout creation
})
.index("by_org", ["orgId"])
.index("by_billing_user", ["billingUserId"])
.index("by_stripe_subscription", ["stripeSubscriptionId"])
.index("by_stripe_customer", ["stripeCustomerId"]);Products Table
products: defineTable({
stripeProductId: v.string(),
name: v.string(),
description: v.optional(v.string()),
active: v.boolean(),
defaultPriceId: v.optional(v.string()),
features: v.optional(v.array(v.string())),
displayOrder: v.optional(v.number()), // Display order (scoped per type)
isRecommended: v.optional(v.boolean()),
accessLevel: v.optional(v.number()), // 0=free, 1=pro, 2=enterprise
type: SubscriptionTypeValidator, // "personal" | "org"
icon: v.optional(v.string()),
metadata: v.optional(v.any()),
createdAt: v.number(),
updatedAt: v.number(),
})
.index("by_stripe_id", ["stripeProductId"])
.index("by_active", ["active"])
.index("by_type", ["type"])
.index("by_type_displayOrder", ["type", "displayOrder"]);Entity-Based Subscription Design
The schema uses separate join tables for personal and organization subscriptions:
- Personal subscriptions:
userSubscriptionstable linksuserIdโ Stripe subscription - Organization subscriptions:
orgSubscriptionstable linksorgIdโ Stripe subscription - Billing responsibility:
orgSubscriptions.billingUserIdtracks the org owner who pays
This design allows:
- Users to have personal subscriptions independent of organizations
- Organizations to have their own billing separate from members
- Clear audit trail of who is financially responsible
- Subscription data to stay in the Stripe component (single source of truth)
Related Schema Files:
convex/billing/schema.ts- Join tables and product/price definitionsconvex/billing/validators.ts- Shared validators (BillingIntervalValidator, SubscriptionTypeValidator)
Troubleshooting
Common Issues
"No configuration provided" error in Customer Portal
- Solution: Configure Customer Portal in Stripe Dashboard
- Enable required features and business information
Webhook signature verification failed
- Solution: Ensure
STRIPE_WEBHOOKS_SECRETmatches webhook endpoint secret - For local development, use secret from
stripe listenoutput
Incorrect proration amounts
- Solution: Verify test clock configuration and frozen time usage
- Check that subscription is attached to test clock in development
Plan buttons showing wrong labels
- Solution: Verify product pricing is synced correctly to database
- Check that
getCurrentPlanPrice()returns accurate pricing
Products not syncing
- Solution: Check Stripe API credentials and webhook configuration
- Verify admin permissions for product management functions
Subscription type modal not showing
- Solution: Verify displayMode is set to "showcase" in PricingPlans
- Check that user is not already authenticated
Organization creation failing during signup
- Solution: Check logs for org creation errors
- Verify userId and plan data are properly stored during flow
React key collision errors in admin subscription filters
- Solution: Subscription plan filters now use unique product IDs instead of product names
- This prevents collisions when products share names across types (e.g., "Free" exists for both personal and org)
- Implementation:
getAvailablePlansreturnsproduct._idas value, filter logic looks up product by ID - Benefits: More accurate filtering, stable keys even if product names change, no React warnings
Organization Management
TinyKit Pro provides comprehensive organization management capabilities with role-based access control and organization-scoped features.
Advanced Notification System
TinyKit Pro includes a comprehensive notification system that supports both in-app and email delivery with advanced admin management capabilities.