Implementing Custom Subscription Plans
Overview
This tutorial shows you how to implement custom subscription plans with features like usage-based billing, add-ons, and tiered pricing.
What you’ll learn:
- Creating custom Stripe price IDs
- Implementing tiered subscription plans
- Adding usage-based billing
- Managing plan add-ons
- Handling plan upgrades/downgrades
Time to complete: ~60 minutes
Prerequisites
- Completed Getting Started Guide
- Stripe account with test API keys
- Understanding of Stripe Products and Prices
- Working Fireact.dev application
Step 1: Design Your Pricing Model
Define Your Plans
Fireact supports multiple Stripe price IDs per plan, allowing you to combine different pricing components (base subscription + usage-based pricing, add-ons, etc.) in a single plan. This is a unique and flexible feature.
Update your src/config/stripe.config.json:
{
"stripe": {
"public_api_key": "pk_test_YOUR_STRIPE_PUBLIC_KEY",
"plans": [
{
"id": "starter",
"titleKey": "plans.starter.title",
"popular": false,
"priceIds": [
"price_starter_monthly"
],
"currency": "$",
"price": 9.99,
"frequency": "month",
"descriptionKeys": [
"plans.starter.feature1",
"plans.starter.feature2",
"plans.starter.feature3",
"plans.starter.feature4"
],
"free": false,
"legacy": false
},
{
"id": "professional",
"titleKey": "plans.professional.title",
"popular": true,
"priceIds": [
"price_pro_monthly"
],
"currency": "$",
"price": 29.99,
"frequency": "month",
"descriptionKeys": [
"plans.professional.feature1",
"plans.professional.feature2",
"plans.professional.feature3",
"plans.professional.feature4"
],
"free": false,
"legacy": false
},
{
"id": "enterprise",
"titleKey": "plans.enterprise.title",
"popular": false,
"priceIds": [
"price_enterprise_monthly",
"price_enterprise_usage_based"
],
"currency": "$",
"price": 99.99,
"frequency": "month",
"descriptionKeys": [
"plans.enterprise.feature1",
"plans.enterprise.feature2",
"plans.enterprise.feature3",
"plans.enterprise.feature4"
],
"free": false,
"legacy": false
}
]
}
}
Note: The priceIds array can contain multiple Stripe price IDs. This allows you to combine:
- Base subscription price
- Usage-based pricing (metered billing)
- Add-on prices
- Multiple components in a single plan
For example, the Enterprise plan has two price IDs:
price_enterprise_monthly: Base subscription ($99.99/month)price_enterprise_usage_based: Metered API usage pricing
Add Localized Plan Descriptions
Update your language files (TypeScript, not JSON):
src/i18n/en.ts:
export default {
// ... other translations
plans: {
title: "Subscription Plans",
mostPopular: "Most popular",
starter: {
title: "Starter",
feature1: "Up to 5 team members",
feature2: "10 GB storage",
feature3: "Basic support",
feature4: "1,000 API calls/month"
},
professional: {
title: "Professional",
feature1: "Up to 25 team members",
feature2: "100 GB storage",
feature3: "Priority support",
feature4: "10,000 API calls/month"
},
enterprise: {
title: "Enterprise",
feature1: "Unlimited team members",
feature2: "1 TB storage",
feature3: "24/7 dedicated support",
feature4: "Unlimited API calls + usage-based pricing"
}
},
// ... other translations
};
Note: Fireact uses TypeScript (.ts) files for translations, not JSON (.json). The structure is nested and exported as a default object.
Step 2: Create Stripe Products and Prices
Using Stripe Dashboard
Create Products:
- Go to Stripe Dashboard → Products
- Click “Add Product”
- Name: “Starter Plan”
- Add pricing: $9.99/month
- Copy the Price ID
Create Prices for Each Plan:
# Or use Stripe CLI stripe products create --name="Starter Plan" stripe prices create \ --product=prod_starter \ --unit-amount=999 \ --currency=usd \ --recurring[interval]=month
Using Stripe API
Create a Cloud Function to manage products:
// functions/src/functions/admin/createStripePrices.ts
import * as functions from 'firebase-functions';
import Stripe from 'stripe';
const stripe = new Stripe(functions.config().stripe.secret_key, {
apiVersion: '2023-10-16',
});
export const createStripePrices = functions.https.onCall(
async (data, context) => {
// Verify admin access
if (!context.auth || !context.auth.token.admin) {
throw new functions.https.HttpsError(
'permission-denied',
'Admin access required'
);
}
const plans = [
{ name: 'Starter', amount: 999 },
{ name: 'Professional', amount: 2999 },
{ name: 'Enterprise', amount: 9999 },
];
const results = [];
for (const plan of plans) {
// Create product
const product = await stripe.products.create({
name: `${plan.name} Plan`,
description: `${plan.name} subscription plan`,
});
// Create price
const price = await stripe.prices.create({
product: product.id,
unit_amount: plan.amount,
currency: 'usd',
recurring: {
interval: 'month',
},
});
results.push({
plan: plan.name,
productId: product.id,
priceId: price.id,
});
}
return { success: true, results };
}
);
Step 3: Implement Usage-Based Billing
Track Usage in Firestore
Create usage tracking structure:
// functions/src/functions/usage/trackUsage.ts
import * as functions from 'firebase-functions';
import * as admin from 'firebase-admin';
interface UsageRecord {
subscriptionId: string;
metricType: 'api_calls' | 'storage' | 'compute';
amount: number;
timestamp: admin.firestore.Timestamp;
}
export const trackUsage = functions.https.onCall(
async (data: { subscriptionId: string; metricType: string; amount: number }, context) => {
if (!context.auth) {
throw new functions.https.HttpsError('unauthenticated', 'Must be logged in');
}
const { subscriptionId, metricType, amount } = data;
// Verify user has access to subscription
const userSubscriptionRef = admin.firestore()
.collection('users')
.doc(context.auth.uid)
.collection('subscriptions')
.doc(subscriptionId);
const userSubscriptionDoc = await userSubscriptionRef.get();
if (!userSubscriptionDoc.exists) {
throw new functions.https.HttpsError('permission-denied', 'No access');
}
// Record usage
const usageRef = admin.firestore()
.collection('subscriptions')
.doc(subscriptionId)
.collection('usage')
.doc();
await usageRef.set({
metricType,
amount,
userId: context.auth.uid,
timestamp: admin.firestore.FieldValue.serverTimestamp(),
});
// Update current period usage
const subscriptionRef = admin.firestore()
.collection('subscriptions')
.doc(subscriptionId);
await subscriptionRef.update({
[`currentUsage.${metricType}`]: admin.firestore.FieldValue.increment(amount),
});
return { success: true };
}
);
Report Usage to Stripe
// functions/src/functions/usage/reportUsageToStripe.ts
import * as functions from 'firebase-functions';
import * as admin from 'firebase-admin';
import Stripe from 'stripe';
const stripe = new Stripe(functions.config().stripe.secret_key, {
apiVersion: '2023-10-16',
});
export const reportUsageToStripe = functions.pubsub
.schedule('0 0 * * *') // Daily at midnight
.onRun(async (context) => {
const subscriptionsSnapshot = await admin.firestore()
.collection('subscriptions')
.where('status', '==', 'active')
.where('billingType', '==', 'usage-based')
.get();
for (const subscriptionDoc of subscriptionsSnapshot.docs) {
const subscription = subscriptionDoc.data();
const usageSnapshot = await subscriptionDoc.ref
.collection('usage')
.where('reported', '==', false)
.get();
if (usageSnapshot.empty) continue;
// Aggregate usage
const totalUsage = usageSnapshot.docs.reduce((sum, doc) => {
return sum + doc.data().amount;
}, 0);
// Report to Stripe
await stripe.subscriptionItems.createUsageRecord(
subscription.stripeSubscriptionItemId,
{
quantity: totalUsage,
timestamp: Math.floor(Date.now() / 1000),
}
);
// Mark usage as reported
const batch = admin.firestore().batch();
usageSnapshot.docs.forEach(doc => {
batch.update(doc.ref, { reported: true });
});
await batch.commit();
}
return null;
});
Step 4: Implement Plan Add-Ons
Add-On Selection Component
// src/components/AddOnSelector.tsx
import React, { useState } from 'react';
import { httpsCallable } from 'firebase/functions';
import { useConfig } from '../hooks/useConfig';
interface AddOn {
id: string;
name: string;
stripePriceId: string;
amount: number;
description: string;
}
export const AddOnSelector: React.FC<{ subscriptionId: string }> = ({ subscriptionId }) => {
const { firebaseApp } = useConfig();
const [selectedAddOns, setSelectedAddOns] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const addOns: AddOn[] = [
{
id: 'extra_storage',
name: 'Extra Storage',
stripePriceId: 'price_extra_storage',
amount: 500,
description: '100 GB additional storage',
},
{
id: 'extra_members',
name: 'Extra Team Members',
stripePriceId: 'price_extra_members',
amount: 300,
description: '10 additional team members',
},
];
const toggleAddOn = (addOnId: string) => {
setSelectedAddOns(prev =>
prev.includes(addOnId)
? prev.filter(id => id !== addOnId)
: [...prev, addOnId]
);
};
const handleApplyAddOns = async () => {
setLoading(true);
try {
const functions = firebaseApp.functions();
const updateAddOns = httpsCallable(functions, 'updateSubscriptionAddOns');
await updateAddOns({
subscriptionId,
addOnIds: selectedAddOns,
});
alert('Add-ons updated successfully!');
} catch (error) {
console.error('Error updating add-ons:', error);
} finally {
setLoading(false);
}
};
return (
<div className="space-y-4">
<h3 className="text-lg font-medium">Available Add-Ons</h3>
{addOns.map(addOn => (
<div key={addOn.id} className="border rounded-lg p-4">
<div className="flex items-start justify-between">
<div className="flex-1">
<h4 className="font-medium">{addOn.name}</h4>
<p className="text-sm text-gray-600">{addOn.description}</p>
<p className="text-sm font-medium mt-2">
${(addOn.amount / 100).toFixed(2)}/month
</p>
</div>
<input
type="checkbox"
checked={selectedAddOns.includes(addOn.id)}
onChange={() => toggleAddOn(addOn.id)}
className="mt-1"
/>
</div>
</div>
))}
<button
onClick={handleApplyAddOns}
disabled={loading}
className="w-full bg-blue-600 text-white px-4 py-2 rounded-lg"
>
{loading ? 'Applying...' : 'Apply Add-Ons'}
</button>
</div>
);
};
Cloud Function to Update Add-Ons
// functions/src/functions/subscription/updateSubscriptionAddOns.ts
import * as functions from 'firebase-functions';
import * as admin from 'firebase-admin';
import Stripe from 'stripe';
const stripe = new Stripe(functions.config().stripe.secret_key, {
apiVersion: '2023-10-16',
});
export const updateSubscriptionAddOns = functions.https.onCall(
async (data: { subscriptionId: string; addOnIds: string[] }, context) => {
if (!context.auth) {
throw new functions.https.HttpsError('unauthenticated', 'Must be logged in');
}
const { subscriptionId, addOnIds } = data;
// Verify ownership
const subscriptionDoc = await admin.firestore()
.collection('subscriptions')
.doc(subscriptionId)
.get();
if (!subscriptionDoc.exists) {
throw new functions.https.HttpsError('not-found', 'Subscription not found');
}
const subscription = subscriptionDoc.data()!;
const stripeSubscription = await stripe.subscriptions.retrieve(
subscription.stripeSubscriptionId
);
// Remove existing add-ons
for (const item of stripeSubscription.items.data) {
if (item.price.metadata.type === 'addon') {
await stripe.subscriptionItems.del(item.id);
}
}
// Add new add-ons
const addOnsConfig = require('../config/plans.config.json').addOns;
for (const addOnId of addOnIds) {
const addOn = addOnsConfig.find((a: any) => a.id === addOnId);
if (addOn) {
await stripe.subscriptionItems.create({
subscription: subscription.stripeSubscriptionId,
price: addOn.stripePriceId,
});
}
}
// Update Firestore
await subscriptionDoc.ref.update({
addOns: addOnIds,
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
});
return { success: true };
}
);
Step 5: Handle Plan Upgrades and Downgrades
Plan Comparison Component
// src/components/PlanComparison.tsx
import React from 'react';
export const PlanComparison: React.FC = () => {
const plans = [
{
id: 'starter',
name: 'Starter',
price: 9.99,
features: ['5 team members', '10 GB storage', 'Basic support'],
},
{
id: 'professional',
name: 'Professional',
price: 29.99,
features: ['25 team members', '100 GB storage', 'Priority support', 'Analytics'],
popular: true,
},
{
id: 'enterprise',
name: 'Enterprise',
price: 99.99,
features: ['Unlimited members', '1 TB storage', '24/7 support', 'Custom integrations'],
},
];
return (
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{plans.map(plan => (
<div
key={plan.id}
className={`border rounded-lg p-6 ${
plan.popular ? 'border-blue-600 ring-2 ring-blue-600' : 'border-gray-200'
}`}
>
{plan.popular && (
<span className="bg-blue-600 text-white text-xs px-2 py-1 rounded">
Most Popular
</span>
)}
<h3 className="text-xl font-bold mt-2">{plan.name}</h3>
<p className="text-3xl font-bold mt-4">
${plan.price}
<span className="text-sm font-normal text-gray-600">/month</span>
</p>
<ul className="mt-6 space-y-3">
{plan.features.map((feature, i) => (
<li key={i} className="flex items-start">
<svg className="w-5 h-5 text-green-500 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" />
</svg>
{feature}
</li>
))}
</ul>
<button className="w-full mt-6 bg-blue-600 text-white px-4 py-2 rounded-lg">
Choose Plan
</button>
</div>
))}
</div>
);
};
Step 6: Implement Proration
Handle Proration in Plan Changes
// functions/src/functions/subscription/changeSubscriptionPlan.ts
export const changeSubscriptionPlan = functions.https.onCall(
async (data: { subscriptionId: string; newPlanId: string }, context) => {
if (!context.auth) {
throw new functions.https.HttpsError('unauthenticated', 'Must be logged in');
}
const { subscriptionId, newPlanId } = data;
// Get current subscription
const subscriptionDoc = await admin.firestore()
.collection('subscriptions')
.doc(subscriptionId)
.get();
const subscription = subscriptionDoc.data()!;
// Get new plan configuration (with multiple priceIds)
const stripeConfig = require('../config/stripe.config.json');
const newPlan = stripeConfig.stripe.plans.find((p: any) => p.id === newPlanId);
if (!newPlan) {
throw new functions.https.HttpsError('not-found', 'Plan not found');
}
// Retrieve current Stripe subscription
const stripeSubscription = await stripe.subscriptions.retrieve(
subscription.stripeSubscriptionId
);
// Remove all existing subscription items
const itemsToDelete = stripeSubscription.items.data.map(item => ({
id: item.id,
deleted: true,
}));
// Add new subscription items for each price ID in the plan
const itemsToAdd = newPlan.priceIds.map((priceId: string) => ({
price: priceId,
}));
// Update subscription with new items and proration
await stripe.subscriptions.update(
subscription.stripeSubscriptionId,
{
items: [
...itemsToDelete,
...itemsToAdd,
],
proration_behavior: 'create_prorations', // Options: 'create_prorations', 'none', 'always_invoice'
}
);
// Update Firestore
await subscriptionDoc.ref.update({
planId: newPlanId,
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
});
return {
success: true,
message: 'Plan updated with proration',
newPlanId: newPlanId,
priceIdsCount: newPlan.priceIds.length,
};
}
);
Step 7: Testing
Test Different Scenarios
- Test plan creation in Stripe
- Test subscription with different plans
- Test add-on selection
- Test plan upgrades (verify proration)
- Test plan downgrades
- Test usage tracking
Best Practices
1. Always Use Test Mode
Use Stripe test keys during development:
{
"secretKey": "sk_test_...",
"publishableKey": "pk_test_..."
}
2. Handle Edge Cases
- Plan doesn’t exist
- Payment method fails
- Proration calculations
- Usage limits exceeded
3. Communicate Changes Clearly
Show users:
- New plan features
- Proration amounts
- Effective date of changes
- What happens to their data
Next Steps
- Add trial periods
- Implement annual billing with discounts
- Add coupon/discount codes
- Create custom billing portal
- Implement metered billing for APIs
Resources
Feedback
Was this page helpful?
Glad to hear it! Please tell us how we can improve.
Sorry to hear that. Please tell us how we can improve.