Skip to content

Instantly share code, notes, and snippets.

@thraizz
Last active April 24, 2025 09:09
Show Gist options
  • Save thraizz/e680da25e1e3bfa470df7ba61c26551b to your computer and use it in GitHub Desktop.
Save thraizz/e680da25e1e3bfa470df7ba61c26551b to your computer and use it in GitHub Desktop.
Stripe + Firebase Implementation
// In my case, this goes into functions/src/functions/stripe-webhook.ts
import * as logger from 'firebase-functions/logger';
import { onRequest } from 'firebase-functions/v2/https';
import Stripe from 'stripe';
import { handleSubscriptionUpdated } from '../services/stripe';
export const stripeWebhook = onRequest(async (request, response) => {
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || '', {
apiVersion: '2025-02-24.acacia',
});
const sig = request.headers['stripe-signature'];
if (!sig) {
response.status(400).send('Missing stripe-signature header');
return;
}
try {
const event = stripe.webhooks.constructEvent(request.rawBody, sig, process.env.STRIPE_WEBHOOK_SECRET || '');
// Handle the event
switch (event.type) {
case 'customer.subscription.created':
case 'customer.subscription.updated':
case 'customer.subscription.deleted':
case 'customer.subscription.paused':
case 'customer.subscription.resumed':
await handleSubscriptionUpdated(event);
break;
default:
logger.info(`Unhandled event type ${event.type}`);
}
response.json({ received: true });
} catch (err: unknown) {
logger.error('Error processing webhook:', err);
response.status(400).send(`Webhook Error: ${err instanceof Error ? err.message : 'Unknown error'}`);
}
});
// In my case, this goes into functions/src/services/stripe.ts
import type Stripe from 'stripe';
import * as Sentry from '@sentry/google-cloud-serverless';
import { getFirestore } from 'firebase-admin/firestore';
import * as logger from 'firebase-functions/logger';
import type { CustomerSubscription } from '../types/subscription';
import { getSubscriptionPlans } from '../config/constants';
const db = getFirestore();
export async function createStripeCustomer(stripe: Stripe, userId: string, email: string): Promise<string> {
try {
const customer = await stripe.customers.create({
email,
metadata: {
firebaseUID: userId,
},
});
await db.collection('users').doc(userId).set(
{
stripeCustomerId: customer.id,
},
{ merge: true },
);
return customer.id;
} catch (error) {
logger.error('Error creating Stripe customer:', error);
Sentry.captureException(error);
throw error;
}
}
export async function getOrCreateStripeCustomer(stripe: Stripe, userId: string, email: string): Promise<string> {
const userDoc = await db.collection('users').doc(userId).get();
const userData = userDoc.data();
if (userData?.stripeCustomerId) {
return userData.stripeCustomerId;
}
return createStripeCustomer(stripe, userId, email);
}
export async function createCheckoutSession(
stripe: Stripe,
customerId: string,
priceId: string,
successUrl: string,
cancelUrl: string,
): Promise<string> {
const session = await stripe.checkout.sessions.create({
customer: customerId,
payment_method_types: ['card'],
line_items: [
{
price: priceId,
quantity: 1,
},
],
mode: 'subscription',
success_url: successUrl,
cancel_url: cancelUrl,
});
return session.url || '';
}
export async function createPortalSession(stripe: Stripe, customerId: string, returnUrl: string): Promise<string> {
const session = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: returnUrl,
});
return session.url;
}
export async function getCustomerSubscription(
stripe: Stripe,
customerId: string,
): Promise<CustomerSubscription | null> {
try {
const subscriptions = await stripe.subscriptions.list({
customer: customerId,
status: 'active',
expand: ['data.default_payment_method'],
});
if (subscriptions.data.length === 0) {
return null;
}
const subscription = subscriptions.data[0];
const priceId = subscription.items.data[0].price.id;
// Find the plan that matches the price ID
const plan = Object.values(getSubscriptionPlans).find((p) => p.id === priceId);
if (!plan) {
logger.error(`No matching plan found for price ID: ${priceId}`);
return null;
}
return {
id: subscription.id,
status: subscription.status,
plan,
currentPeriodEnd: new Date(subscription.current_period_end * 1000).toISOString(),
cancelAtPeriodEnd: subscription.cancel_at_period_end,
};
} catch (error) {
logger.error('Error fetching customer subscription:', error);
Sentry.captureException(error);
return null;
}
}
export async function handleSubscriptionUpdated(event: Stripe.Event): Promise<void> {
const subscription = event.data.object as Stripe.Subscription;
const customerId = subscription.customer as string;
const status = subscription.status;
const priceId = subscription.items.data[0].price.id;
// Find user with this Stripe customer ID
const usersSnapshot = await db.collection('users').where('stripeCustomerId', '==', customerId).limit(1).get();
if (usersSnapshot.empty) {
logger.error(`No user found for Stripe customer ID: ${customerId}`);
Sentry.captureException(new Error(`No user found for Stripe customer ID: ${customerId}`));
return;
}
const userId = usersSnapshot.docs[0].id;
// Update subscription status in Firestore
await db
.collection('users')
.doc(userId)
.set(
{
subscription: {
id: subscription.id,
status,
priceId,
currentPeriodEnd: new Date(subscription.current_period_end * 1000).toISOString(),
cancelAtPeriodEnd: subscription.cancel_at_period_end,
},
},
{ merge: true },
);
}
import { getAuth } from 'firebase-admin/auth';
import { getFirestore } from 'firebase-admin/firestore';
import * as logger from 'firebase-functions/logger';
import { onRequest, HttpsError, onCall } from 'firebase-functions/v2/https';
import Stripe from 'stripe';
import type { CreateCheckoutSessionRequest, ManageSubscriptionRequest } from '../types/subscription';
import {
createCheckoutSession,
createPortalSession,
getCustomerSubscription,
getOrCreateStripeCustomer,
} from '../services/stripe';
export const getSubscriptionPlans = (proMonthlyId: string, proYearlyId: string) => {
return {
PRO_MONTHLY: {
id: proMonthlyId,
name: 'Pro Plan',
description: 'This could be your pro plan description.',
price: 9.99,
interval: 'month',
features: [
'Advanced Analytics Dashboard',
'AI-Powered Insights',
'Performance Trend Tracking',
'Unlimited Session Analysis',
],
},
PRO_YEARLY: {
id: proYearlyId,
name: 'Pro Plan (Annual)',
description: 'This could be your annual pro plan description.',
price: 99,
interval: 'year',
features: ['All Pro Plan Features', '20% Savings vs Monthly'],
},
};
};
export const TEST_SUBSCRIPTION_PLANS = getSubscriptionPlans(
'price_foo1',
'price_foo2',
);
export const LIVE_SUBSCRIPTION_PLANS = getSubscriptionPlans(
'price_foo3',
'price_foo4',
);
const db = getFirestore();
// Create a Stripe Checkout session for subscription
export const createSubscriptionCheckout = onCall(async (request) => {
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || '', {
apiVersion: '2025-02-24.acacia',
});
try {
const { priceId, successUrl, cancelUrl } = request.data as CreateCheckoutSessionRequest;
const auth = getAuth();
const user = await auth.getUser(request.auth?.uid || '');
if (!user.email) {
throw new HttpsError('failed-precondition', 'User must have an email to create a subscription');
}
// Get or create Stripe customer
const customerId = await getOrCreateStripeCustomer(stripe, user.uid, user.email);
// Create checkout session
const checkoutUrl = await createCheckoutSession(stripe, customerId, priceId, successUrl, cancelUrl);
return { url: checkoutUrl };
} catch (error: unknown) {
logger.error('Error creating subscription checkout:', error);
throw new HttpsError(
'internal',
error instanceof Error ? error.message : 'An error occurred while creating the checkout session',
);
}
});
// Get customer's subscription details
export const getSubscription = onCall(async (request, _response) => {
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || '', {
apiVersion: '2025-02-24.acacia',
});
try {
const auth = getAuth();
const user = await auth.getUser(request.auth?.uid || '');
if (!user.email) {
throw new HttpsError('failed-precondition', 'User must have an email to get subscription details');
}
// Get user document to check trial status
const userDoc = await db.collection('users').doc(user.uid).get();
const userData = userDoc.data();
// Calculate trial information
let trialInfo = undefined;
if (userData?.createdAt) {
const createdAt = userData.createdAt.toDate();
const trialEndDate = new Date(createdAt.getTime() + 14 * 24 * 60 * 60 * 1000);
const now = new Date();
const daysRemaining = Math.max(0, Math.ceil((trialEndDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)));
if (now < trialEndDate) {
trialInfo = {
isTrialing: true,
endDate: trialEndDate.toISOString(),
daysRemaining,
};
}
}
// Get or create Stripe customer
const customerId = await getOrCreateStripeCustomer(stripe, user.uid, user.email);
// Get subscription details
const subscription = await getCustomerSubscription(stripe, customerId);
console.log('subscription', subscription);
console.log('customerId', customerId);
if (!subscription) {
return {
subscription: {
id: null,
status: trialInfo ? 'trialing' : 'active',
plan: {
id: 'free',
name: 'Free Trial',
description: 'Free trial period',
price: 0,
interval: null,
},
currentPeriodEnd: trialInfo?.endDate || null,
cancelAtPeriodEnd: false,
trial: trialInfo,
},
};
}
// Add trial info to active subscription if applicable
return {
subscription: {
...subscription,
trial: trialInfo,
},
};
} catch (error: unknown) {
logger.error('Error getting subscription:', error);
throw new HttpsError(
'internal',
error instanceof Error ? error.message : 'An error occurred while fetching the subscription',
);
}
});
// Create a Stripe Customer Portal session
export const createCustomerPortal = onCall(async (request) => {
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || '', {
apiVersion: '2025-02-24.acacia',
});
try {
const { returnUrl } = request.data as ManageSubscriptionRequest;
const auth = getAuth();
const user = await auth.getUser(request.auth?.uid || '');
if (!user.email) {
throw new HttpsError('failed-precondition', 'User must have an email to access the customer portal');
}
// Get or create Stripe customer
const customerId = await getOrCreateStripeCustomer(stripe, user.uid, user.email);
// Create portal session
const portalUrl = await createPortalSession(stripe, customerId, returnUrl);
return { url: portalUrl };
} catch (error: unknown) {
logger.error('Error creating customer portal session:', error);
throw new HttpsError(
'internal',
error instanceof Error ? error.message : 'An error occurred while creating the portal session',
);
}
});
export const getStripeProducts = onRequest((_request, response) => {
response.json({
products: getSubscriptionPlans,
});
});
// In my case, this goes into functions/src/types/subscription.ts
export interface SubscriptionPlan {
id: string;
name: string;
description: string;
price: number;
interval?: 'month' | 'year' | null;
features: string[];
}
export interface CustomerSubscription {
id: string;
status: 'active' | 'canceled' | 'past_due' | 'incomplete' | 'incomplete_expired' | 'trialing' | 'unpaid' | 'paused';
plan: SubscriptionPlan;
currentPeriodEnd: string;
cancelAtPeriodEnd: boolean;
trial?: {
isTrialing: boolean;
endDate: string;
daysRemaining: number;
};
}
export interface CreateCheckoutSessionRequest {
priceId: string;
successUrl: string;
cancelUrl: string;
}
export interface ManageSubscriptionRequest {
returnUrl: string;
}
export interface SubscriptionUpdateEvent {
customerId: string;
subscriptionId: string;
status: string;
priceId: string;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment