@donotdev/billing
v0.0.4
Published
Simplified Stripe billing integration for DoNotDev framework with Firebase custom claims
Maintainers
Readme
@donotdev/billing
Production-ready billing system with Stripe integration and smart idempotency.
Features
- ✅ One-time payments and subscriptions
- ✅ Automatic webhook processing
- ✅ Smart idempotency (auto-detects Firestore)
- ✅ Customizable hooks for business logic
- ✅ React components and templates
- ✅ Zero configuration required
Quick Start
1. Install Dependencies
bun add @donotdev/billing @donotdev/types2. Configure Your Products
Important: Frontend and backend configs are split for security and separation of concerns.
Backend Config (Functions)
// apps/your-app/functions/src/config/stripeBackConfig.ts
import type { StripeBackConfig } from '@donotdev/types';
export const stripeBackConfig: StripeBackConfig = {
web_dev_course: {
type: 'StripePayment',
name: 'Web Development Masterclass',
price: 29900, // In cents (299.00 USD)
currency: 'USD',
priceId: process.env.STRIPE_PRICE_COURSE!,
tier: 'course_access',
duration: 'lifetime',
description: 'Complete course with lifetime access',
// Custom hook - grant course access after payment
onPurchaseSuccess: async (userId, metadata) => {
await grantCourseAccess(userId, 'web-dev-masterclass');
await sendWelcomeEmail(userId);
},
},
pro_monthly: {
type: 'StripeSubscription',
name: 'Pro Plan',
price: 2900, // In cents (29.00 USD)
currency: 'USD',
priceId: process.env.STRIPE_PRICE_PRO_MONTHLY!,
tier: 'pro',
duration: '1month',
description: 'Monthly subscription with all features',
onSubscriptionCreated: async (userId, metadata) => {
await enableProFeatures(userId);
await sendWelcomeEmail(userId);
},
},
};Frontend Config (App)
// apps/your-app/src/config/stripeFrontConfig.ts
import type { StripeFrontConfig } from '@donotdev/types';
export const stripeFrontConfig: StripeFrontConfig = {
web_dev_course: {
name: 'Web Development Masterclass',
price: 299, // Display price (in currency units)
currency: 'USD',
priceId: import.meta.env.VITE_STRIPE_PRICE_COURSE || '',
description: 'Complete course with lifetime access',
features: [
'50+ hours of video content',
'Lifetime access',
'Downloadable resources',
'Community support',
],
allowPromotionCodes: true,
},
pro_monthly: {
name: 'Pro Plan',
price: 29, // Display price (in currency units)
currency: 'USD',
priceId: import.meta.env.VITE_STRIPE_PRICE_PRO_MONTHLY || '',
description: 'Monthly subscription with all features',
features: [
'All premium features',
'Priority support',
'Advanced analytics',
'Custom integrations',
],
allowPromotionCodes: true,
},
};3. Create Webhook Handler
// apps/your-app/functions/src/index.ts
import {
createCheckoutSession,
createStripeWebhook,
} from '@donotdev/functions/firebase';
import { stripeBackConfig } from './config/stripeBackConfig.js';
export const createCheckout = createCheckoutSession(stripeBackConfig);
export const handleStripeWebhook = createStripeWebhook(stripeBackConfig);4. Create Frontend Page
// apps/your-app/src/pages/PricingPage.tsx
import { PaymentTemplate } from '@donotdev/templates';
import { stripeFrontConfig } from '../config/stripeFrontConfig';
export default function PricingPage() {
return (
<PaymentTemplate
namespace="pricing"
meta={{
namespace: 'pricing',
auth: { required: true },
title: 'Pricing',
}}
billing={stripeFrontConfig}
successUrl="/success"
cancelUrl="/pricing"
/>
);
}5. Configure App
// apps/your-app/src/App.tsx
import { stripeFrontConfig } from './config/stripeFrontConfig';
const APP_CONFIG = {
// ... other config
billing: {
config: stripeFrontConfig,
functions: {
createCheckout: 'createCheckout',
webhook: 'handleStripeWebhook',
},
},
};6. Deploy
firebase deploy --only functionsThat's it! Your billing system is live. 🎉
Summary:
- Backend:
stripeBackConfig.ts(with hooks) → Functions - Frontend:
stripeFrontConfig.ts(display only) → App - Security: Frontend config has no hooks/secrets, safe to bundle
Idempotency
The framework automatically prevents duplicate webhook processing using smart idempotency.
How It Works
The framework detects your environment and chooses the best storage:
- ✅ Firestore enabled → Uses Firestore (production-ready, scales to millions)
- ⚠️ Firestore not enabled → Uses in-memory (works for development, limited for production)
Zero configuration required - it just works.
When to Enable Firestore
We recommend enabling Firestore when you have:
- 📈 > 100 transactions per day
- 🚀 Multiple function instances (auto-scaling)
- 💰 Revenue-critical operations (prevent duplicates)
How to Enable Firestore
If you don't have Firestore yet:
Enable Firestore (one-time, 2 minutes)
- Go to Firebase Console
- Click Firestore Database → Create database
- Choose your location (e.g.,
us-central1) - Select Start in production mode
- Click Enable
Add Security Rules (copy-paste, 1 minute)
Add to
firestore.rules:rules_version = '2'; service cloud.firestore { match /databases/{database}/documents { // Webhook idempotency - Functions only match /webhook_idempotency/{document=**} { allow read, write: if false; } } }Deploy Rules
firebase deploy --only firestore:rulesRedeploy Functions (automatic upgrade)
firebase deploy --only functions
Done! Framework automatically detects Firestore and upgrades to production-ready idempotency.
Cost
Firestore costs are minimal for billing:
| Transactions/Month | Firestore Cost | | ------------------ | -------------- | | 100 | < $0.01 | | 1,000 | ~$0.50 | | 10,000 | ~$2 | | 100,000 | ~$10 |
Much cheaper than alternatives (Stripe Billing: $10/mo minimum, Chargebee: $249/mo minimum).
Health Check
Check your idempotency status:
curl https://YOUR-REGION-YOUR-PROJECT.cloudfunctions.net/billingHealthResponse:
{
"status": "healthy",
"timestamp": 1234567890,
"idempotency": "firestore" // or "in-memory"
}Common Use Cases
SaaS Subscription Plans
// Backend: apps/your-app/functions/src/config/stripeBackConfig.ts
export const stripeBackConfig: StripeBackConfig = {
starter_monthly: {
type: 'StripeSubscription',
name: 'Starter',
price: 1900, // In cents (19.00 USD)
currency: 'USD',
priceId: 'price_starter_monthly',
tier: 'starter',
duration: '1month',
onSubscriptionCreated: async (userId) => {
await enableFeatures(userId, ['feature1', 'feature2']);
},
},
pro_monthly: {
type: 'StripeSubscription',
name: 'Pro',
price: 4900, // In cents (49.00 USD)
currency: 'USD',
priceId: 'price_pro_monthly',
tier: 'pro',
duration: '1month',
onSubscriptionCreated: async (userId) => {
await enableFeatures(userId, ['feature1', 'feature2', 'feature3']);
},
},
};
// Frontend: apps/your-app/src/config/stripeFrontConfig.ts
export const stripeFrontConfig: StripeFrontConfig = {
starter_monthly: {
name: 'Starter',
price: 19, // Display price
currency: 'USD',
priceId: import.meta.env.VITE_STRIPE_PRICE_STARTER_MONTHLY || '',
description: 'Perfect for getting started',
features: ['Feature 1', 'Feature 2'],
},
pro_monthly: {
name: 'Pro',
price: 49, // Display price
currency: 'USD',
priceId: import.meta.env.VITE_STRIPE_PRICE_PRO_MONTHLY || '',
description: 'For growing businesses',
features: ['Feature 1', 'Feature 2', 'Feature 3'],
},
};Digital Products (Courses, eBooks)
// Backend: functions/src/config/stripeBackConfig.ts
export const stripeBackConfig: StripeBackConfig = {
react_course: {
type: 'StripePayment',
name: 'React Masterclass',
price: 19900, // In cents (199.00 USD)
currency: 'USD',
priceId: process.env.STRIPE_PRICE_REACT_COURSE!,
tier: 'course_react',
duration: 'lifetime',
onPurchaseSuccess: async (userId, metadata) => {
await grantCourseAccess(userId, 'react-masterclass');
await sendCourseCredentials(userId);
},
},
};
// Frontend: src/config/stripeFrontConfig.ts
export const stripeFrontConfig: StripeFrontConfig = {
react_course: {
name: 'React Masterclass',
price: 199, // Display price
currency: 'USD',
priceId: import.meta.env.VITE_STRIPE_PRICE_REACT_COURSE || '',
description: 'Complete React course with lifetime access',
features: ['50+ hours', 'Downloadable resources', 'Community support'],
},
};Environment Variables
Required
# Stripe API Keys
STRIPE_SECRET_KEY=sk_test_xxx
STRIPE_WEBHOOK_SECRET=whsec_xxx
STRIPE_API_VERSION=2025-08-27.basil # REQUIRED - No fallback
# Stripe Price IDs
VITE_STRIPE_PRICE_COURSE=price_xxx
VITE_STRIPE_PRICE_PRO_MONTHLY=price_xxx
VITE_STRIPE_PRICE_PRO_YEARLY=price_xxx
# Frontend
VITE_STRIPE_PUBLISHABLE_KEY=pk_test_xxxOptional (Firebase)
If not running in Firebase Functions environment:
FIREBASE_PROJECT_ID=your-project-id
FIREBASE_CLIENT_EMAIL=firebase-adminsdk-xxx@your-project.iam.gserviceaccount.com
FIREBASE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n"Components
PurchaseButton
import { PurchaseButton } from '@donotdev/billing';
import { stripeFrontConfig } from '../config/stripeFrontConfig';
<PurchaseButton
configKey="react_course"
config={stripeFrontConfig.react_course}
successUrl="/success"
cancelUrl="/pricing"
/>SubscriptionButton
import { SubscriptionButton } from '@donotdev/billing';
import { stripeFrontConfig } from '../config/stripeFrontConfig';
<SubscriptionButton
configKey="pro_monthly"
config={stripeFrontConfig.pro_monthly}
successUrl="/success"
cancelUrl="/pricing"
/>PaymentTemplate
Auto-renders all products from your frontend config:
import { PaymentTemplate } from '@donotdev/templates';
import { stripeFrontConfig } from '../config/stripeFrontConfig';
<PaymentTemplate
namespace="pricing"
meta={{ namespace: 'pricing', auth: { required: true } }}
billing={stripeFrontConfig}
successUrl="/success"
cancelUrl="/pricing"
/>Custom Hooks
All hooks are optional and run after subscription updates:
One-Time Payments
onPurchaseSuccess?: (userId: string, metadata: any) => Promise<void>;
onPurchaseFailure?: (userId: string, metadata: any) => Promise<void>;Example:
onPurchaseSuccess: async (userId, metadata) => {
// Grant access to purchased content
await database.update('users', userId, {
hasCourseAccess: true,
purchasedAt: new Date(),
});
// Send confirmation email
await sendEmail(userId, 'purchase-confirmation');
// Track analytics
await analytics.track('purchase_completed', { userId, product: 'course' });
};Subscriptions
onSubscriptionCreated?: (userId: string, metadata: any) => Promise<void>;
onSubscriptionRenewed?: (userId: string, metadata: any) => Promise<void>;
onSubscriptionCancelled?: (userId: string, metadata: any) => Promise<void>;
onPaymentFailed?: (userId: string, metadata: any) => Promise<void>;Example:
onSubscriptionCreated: async (userId, metadata) => {
// Enable premium features
await database.update('users', userId, {
tier: 'pro',
features: ['advanced-analytics', 'priority-support'],
});
// Send welcome email
await sendEmail(userId, 'welcome-pro-plan');
};
onSubscriptionCancelled: async (userId, metadata) => {
// Schedule downgrade at period end
await database.update('users', userId, {
scheduledDowngrade: true,
downgradeTo: 'free',
});
// Send cancellation email with feedback request
await sendEmail(userId, 'subscription-cancelled');
};Hook Error Handling
Hooks are wrapped in try-catch blocks. If a hook fails:
- ✅ Error is logged (non-critical)
- ✅ Webhook continues processing
- ✅ Subscription is still updated
- ✅ User is not affected
Example:
onPurchaseSuccess: async (userId, metadata) => {
// Grant access to external service
await sendToSlack(userId); // If Slack API fails...
// Result:
// - Error logged: "Hook failed (non-critical)"
// - User subscription still updated
// - User still gets access
// - You can retry manually later
};TypeScript Types
import type {
StripeFrontConfig, // Frontend config (display only)
StripeBackConfig, // Backend config (with hooks)
StripePayment,
StripeSubscription,
} from '@donotdev/types';Note: Frontend and backend configs are separate:
- Frontend (
StripeFrontConfig): Display-only, safe to bundle in client code - Backend (
StripeBackConfig): Includes hooks and business logic, must stay in functions
Testing
Test Webhooks (Stripe CLI)
# Install Stripe CLI
brew install stripe/stripe-cli/stripe
# Login
stripe login
# Forward webhooks to local
stripe listen --forward-to localhost:5001/YOUR-PROJECT/us-central1/stripeWebhook
# Trigger test webhook
stripe trigger checkout.session.completedTest Idempotency
Send the same webhook twice:
# First time - processes successfully
stripe trigger checkout.session.completed
# Second time - logs "Already processed"
stripe trigger checkout.session.completedArchitecture
User clicks "Purchase"
↓
Frontend calls Stripe directly (no proxy needed)
↓
User completes payment on Stripe
↓
Stripe sends webhook to your endpoint
↓
Framework processes webhook:
├─ Verifies signature ✅
├─ Checks idempotency (Firestore or in-memory) ✅
├─ Updates user subscription (customClaims) ✅
├─ Calls your custom hooks ✅
└─ Marks event as processed ✅
↓
User has access ✅Troubleshooting
"Firestore not configured, using in-memory"
This is normal for development. Framework works immediately with in-memory storage.
To upgrade to production:
- Enable Firestore (see above)
- Redeploy functions
- Framework auto-detects and upgrades
"Webhook signature verification failed"
Check:
STRIPE_WEBHOOK_SECRETmatches Stripe Dashboard- Using raw request body (not parsed JSON)
- Webhook endpoint URL is correct
"Unknown billing config key"
Check:
billingConfigKeyin metadata matches yourstripeBackConfigkeys- You're passing
configKeyto components (notconfig.name) - Backend config (
stripeBackConfig) has the matching key
Duplicate Processing
With Firestore: Should never happen (production-ready)
Without Firestore: Possible on function restart (rare)
Solution: Enable Firestore for production (> 100 transactions/day)
Advanced
Custom Metadata
Pass custom data to hooks:
import { stripeFrontConfig } from '../config/stripeFrontConfig';
<PurchaseButton
configKey="react_course"
config={stripeFrontConfig.react_course}
metadata={{
referralCode: 'FRIEND20',
campaignId: 'summer-sale',
}}
/>Access in hooks:
onPurchaseSuccess: async (userId, metadata) => {
if (metadata.referralCode) {
await rewardReferrer(metadata.referralCode);
}
await trackCampaign(metadata.campaignId);
};Manual Idempotency Check
import { createIdempotencyStore } from '@donotdev/functions/firebase/billing';
const store = createIdempotencyStore();
// Check if processed
const processed = await store.isProcessed('evt_123');
// Mark as processed
await store.markProcessed('evt_123');