Auto-commit 2026-04-29 16:31
This commit is contained in:
90
packages/shared-billing/src/config/billing.config.ts
Normal file
90
packages/shared-billing/src/config/billing.config.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import Stripe from 'stripe';
|
||||
import { z } from 'zod';
|
||||
|
||||
// Environment variables
|
||||
const envSchema = z.object({
|
||||
STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
|
||||
STRIPE_WEBHOOK_SECRET: z.string().startsWith('whsec_'),
|
||||
STRIPE_PRICE_ID_BASIC: z.string().startsWith('price_'),
|
||||
STRIPE_PRICE_ID_PLUS: z.string().startsWith('price_'),
|
||||
STRIPE_PRICE_ID_PREMIUM: z.string().startsWith('price_'),
|
||||
});
|
||||
|
||||
export const billingEnv = envSchema.parse({
|
||||
STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
|
||||
STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET,
|
||||
STRIPE_PRICE_ID_BASIC: process.env.STRIPE_PRICE_ID_BASIC,
|
||||
STRIPE_PRICE_ID_PLUS: process.env.STRIPE_PRICE_ID_PLUS,
|
||||
STRIPE_PRICE_ID_PREMIUM: process.env.STRIPE_PRICE_ID_PREMIUM,
|
||||
});
|
||||
|
||||
// Initialize Stripe
|
||||
export const stripe = new Stripe(billingEnv.STRIPE_SECRET_KEY, {
|
||||
apiVersion: '2024-06-20',
|
||||
typescript: true,
|
||||
});
|
||||
|
||||
// Subscription tiers
|
||||
export enum SubscriptionTier {
|
||||
BASIC = 'basic',
|
||||
PLUS = 'plus',
|
||||
PREMIUM = 'premium',
|
||||
}
|
||||
|
||||
// Tier configuration
|
||||
export const tierConfig: Record<SubscriptionTier, {
|
||||
name: string;
|
||||
priceId: string;
|
||||
features: {
|
||||
maxWatchlistItems: number;
|
||||
maxFamilyMembers: number;
|
||||
darkWebScanFrequency: 'daily' | 'hourly' | 'realtime';
|
||||
exposureRetentionMonths: number;
|
||||
alertChannels: string[];
|
||||
};
|
||||
}> = {
|
||||
[SubscriptionTier.BASIC]: {
|
||||
name: 'Basic',
|
||||
priceId: billingEnv.STRIPE_PRICE_ID_BASIC,
|
||||
features: {
|
||||
maxWatchlistItems: 2,
|
||||
maxFamilyMembers: 1,
|
||||
darkWebScanFrequency: 'daily',
|
||||
exposureRetentionMonths: 12,
|
||||
alertChannels: ['email'],
|
||||
},
|
||||
},
|
||||
[SubscriptionTier.PLUS]: {
|
||||
name: 'Plus',
|
||||
priceId: billingEnv.STRIPE_PRICE_ID_PLUS,
|
||||
features: {
|
||||
maxWatchlistItems: 10,
|
||||
maxFamilyMembers: 3,
|
||||
darkWebScanFrequency: 'hourly',
|
||||
exposureRetentionMonths: 24,
|
||||
alertChannels: ['email', 'push'],
|
||||
},
|
||||
},
|
||||
[SubscriptionTier.PREMIUM]: {
|
||||
name: 'Premium',
|
||||
priceId: billingEnv.STRIPE_PRICE_ID_PREMIUM,
|
||||
features: {
|
||||
maxWatchlistItems: Infinity,
|
||||
maxFamilyMembers: Infinity,
|
||||
darkWebScanFrequency: 'realtime',
|
||||
exposureRetentionMonths: 60,
|
||||
alertChannels: ['email', 'push', 'sms'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Feature gating middleware
|
||||
export function getTierFeatures(tier: SubscriptionTier) {
|
||||
return tierConfig[tier].features;
|
||||
}
|
||||
|
||||
export function checkFeatureAccess(tier: SubscriptionTier, feature: string, value: number): boolean {
|
||||
const features = tierConfig[tier].features;
|
||||
const featureValue = features[feature as keyof typeof features] as number;
|
||||
return value <= featureValue;
|
||||
}
|
||||
22
packages/shared-billing/src/index.ts
Normal file
22
packages/shared-billing/src/index.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
// Config
|
||||
export {
|
||||
stripe,
|
||||
billingEnv,
|
||||
SubscriptionTier,
|
||||
tierConfig,
|
||||
getTierFeatures,
|
||||
checkFeatureAccess,
|
||||
} from './config/billing.config';
|
||||
|
||||
// Services
|
||||
export {
|
||||
SubscriptionService,
|
||||
CustomerService,
|
||||
WebhookService,
|
||||
subscriptionService,
|
||||
customerService,
|
||||
webhookService,
|
||||
} from './services/billing.services';
|
||||
|
||||
// Middleware
|
||||
export { requireTier, checkFeatureLimit } from './middleware/billing.middleware';
|
||||
68
packages/shared-billing/src/middleware/billing.middleware.ts
Normal file
68
packages/shared-billing/src/middleware/billing.middleware.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { SubscriptionTier, getTierFeatures } from '../config/billing.config';
|
||||
|
||||
/**
|
||||
* Middleware to check if user has access to required tier features
|
||||
*/
|
||||
export function requireTier(
|
||||
request: NextRequest,
|
||||
requiredTier: SubscriptionTier
|
||||
): NextResponse | null {
|
||||
const userTier = request.headers.get('x-user-tier') as SubscriptionTier;
|
||||
|
||||
if (!userTier) {
|
||||
return NextResponse.json({ error: 'User tier not found' }, { status: 401 });
|
||||
}
|
||||
|
||||
const tierOrder: Record<SubscriptionTier, number> = {
|
||||
[SubscriptionTier.BASIC]: 1,
|
||||
[SubscriptionTier.PLUS]: 2,
|
||||
[SubscriptionTier.PREMIUM]: 3,
|
||||
};
|
||||
|
||||
if (tierOrder[userTier] < tierOrder[requiredTier]) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Feature requires ${requiredTier} tier or higher`,
|
||||
currentTier: userTier,
|
||||
requiredTier,
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware to check feature limits
|
||||
*/
|
||||
export function checkFeatureLimit(
|
||||
request: NextRequest,
|
||||
feature: string,
|
||||
currentValue: number
|
||||
): NextResponse | null {
|
||||
const userTier = request.headers.get('x-user-tier') as SubscriptionTier;
|
||||
|
||||
if (!userTier) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const features = getTierFeatures(userTier);
|
||||
const featureLimit = features[feature as keyof typeof features] as number;
|
||||
|
||||
if (currentValue > featureLimit) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Feature limit exceeded`,
|
||||
feature,
|
||||
current: currentValue,
|
||||
limit: featureLimit,
|
||||
tier: userTier,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
223
packages/shared-billing/src/services/billing.services.ts
Normal file
223
packages/shared-billing/src/services/billing.services.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
import { stripe, SubscriptionTier, tierConfig } from '../config/billing.config';
|
||||
import { z } from 'zod';
|
||||
|
||||
// Subscription service
|
||||
export class SubscriptionService {
|
||||
/**
|
||||
* Create a new subscription for a customer
|
||||
*/
|
||||
async createSubscription(
|
||||
customerId: string,
|
||||
tier: SubscriptionTier,
|
||||
metadata?: Record<string, string>
|
||||
): Promise<Stripe.Subscription> {
|
||||
const priceId = tierConfig[tier].priceId;
|
||||
|
||||
const subscription = await stripe.subscriptions.create({
|
||||
customer: customerId,
|
||||
items: [{ price: priceId }],
|
||||
metadata: metadata,
|
||||
proration_behavior: 'create_prorations',
|
||||
});
|
||||
|
||||
return subscription;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a customer's subscription tier
|
||||
*/
|
||||
async updateSubscriptionTier(
|
||||
subscriptionId: string,
|
||||
newTier: SubscriptionTier
|
||||
): Promise<Stripe.Subscription> {
|
||||
const newPriceId = tierConfig[newTier].priceId;
|
||||
|
||||
const subscription = await stripe.subscriptions.update(subscriptionId, {
|
||||
items: [
|
||||
{
|
||||
price: newPriceId,
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
proration_behavior: 'create_prorations',
|
||||
});
|
||||
|
||||
return subscription;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a subscription
|
||||
*/
|
||||
async cancelSubscription(
|
||||
subscriptionId: string,
|
||||
atPeriodEnd: boolean = true
|
||||
): Promise<Stripe.Subscription> {
|
||||
const subscription = await stripe.subscriptions.update(subscriptionId, {
|
||||
cancel_at_period_end: atPeriodEnd,
|
||||
});
|
||||
|
||||
return subscription;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get subscription by ID
|
||||
*/
|
||||
async getSubscription(subscriptionId: string): Promise<Stripe.Subscription | null> {
|
||||
try {
|
||||
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
|
||||
return subscription;
|
||||
} catch (error) {
|
||||
if (error instanceof Stripe.errors.StripeInvalidRequestError) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get customer's current subscription
|
||||
*/
|
||||
async getCustomerSubscription(customerId: string): Promise<Stripe.Subscription | null> {
|
||||
const subscriptions = await stripe.subscriptions.list({
|
||||
customer: customerId,
|
||||
status: 'active',
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
return subscriptions.data[0] || null;
|
||||
}
|
||||
}
|
||||
|
||||
// Customer service
|
||||
export class CustomerService {
|
||||
/**
|
||||
* Create a new Stripe customer
|
||||
*/
|
||||
async createCustomer(
|
||||
email: string,
|
||||
name?: string,
|
||||
metadata?: Record<string, string>
|
||||
): Promise<Stripe.Customer> {
|
||||
const customer = await stripe.customers.create({
|
||||
email,
|
||||
name,
|
||||
metadata,
|
||||
});
|
||||
|
||||
return customer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create customer by email
|
||||
*/
|
||||
async getOrCreateCustomer(
|
||||
email: string,
|
||||
name?: string
|
||||
): Promise<Stripe.Customer> {
|
||||
const existingCustomers = await stripe.customers.list({
|
||||
email,
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
if (existingCustomers.data.length > 0) {
|
||||
return existingCustomers.data[0];
|
||||
}
|
||||
|
||||
return this.createCustomer(email, name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a billing portal session
|
||||
*/
|
||||
async createBillingPortalSession(
|
||||
customerId: string,
|
||||
returnUrl: string
|
||||
): Promise<Stripe.BillingPortal.Session> {
|
||||
const session = await stripe.billingPortal.sessions.create({
|
||||
customer: customerId,
|
||||
return_url: returnUrl,
|
||||
});
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get customer by ID
|
||||
*/
|
||||
async getCustomer(customerId: string): Promise<Stripe.Customer | null> {
|
||||
try {
|
||||
const customer = await stripe.customers.retrieve(customerId);
|
||||
return customer as Stripe.Customer;
|
||||
} catch (error) {
|
||||
if (error instanceof Stripe.errors.StripeInvalidRequestError) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Webhook service
|
||||
export class WebhookService {
|
||||
/**
|
||||
* Construct webhook event from raw body
|
||||
*/
|
||||
constructEvent(
|
||||
rawBody: Buffer | string,
|
||||
signature: string
|
||||
): Stripe.Event {
|
||||
return stripe.webhooks.constructEvent(
|
||||
rawBody,
|
||||
signature,
|
||||
process.env.STRIPE_WEBHOOK_SECRET!
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle webhook event
|
||||
*/
|
||||
async handleWebhook(event: Stripe.Event): Promise<void> {
|
||||
switch (event.type) {
|
||||
case 'customer.subscription.created':
|
||||
case 'customer.subscription.updated':
|
||||
await this.handleSubscriptionChange(event.data.object);
|
||||
break;
|
||||
case 'customer.subscription.deleted':
|
||||
await this.handleSubscriptionDeleted(event.data.object);
|
||||
break;
|
||||
case 'invoice.payment_succeeded':
|
||||
await this.handlePaymentSucceeded(event.data.object);
|
||||
break;
|
||||
case 'invoice.payment_failed':
|
||||
await this.handlePaymentFailed(event.data.object);
|
||||
break;
|
||||
default:
|
||||
console.log(`Unhandled event type: ${event.type}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleSubscriptionChange(subscription: Stripe.Subscription) {
|
||||
console.log(`Subscription ${subscription.id} changed to ${subscription.status}`);
|
||||
// TODO: Update local database
|
||||
}
|
||||
|
||||
private async handleSubscriptionDeleted(subscription: Stripe.Subscription) {
|
||||
console.log(`Subscription ${subscription.id} deleted`);
|
||||
// TODO: Update local database
|
||||
}
|
||||
|
||||
private async handlePaymentSucceeded(invoice: Stripe.Invoice) {
|
||||
console.log(`Payment succeeded for invoice ${invoice.id}`);
|
||||
// TODO: Update usage tracking
|
||||
}
|
||||
|
||||
private async handlePaymentFailed(invoice: Stripe.Invoice) {
|
||||
console.log(`Payment failed for invoice ${invoice.id}`);
|
||||
// TODO: Send notification to customer
|
||||
}
|
||||
}
|
||||
|
||||
// Export instances
|
||||
export const subscriptionService = new SubscriptionService();
|
||||
export const customerService = new CustomerService();
|
||||
export const webhookService = new WebhookService();
|
||||
Reference in New Issue
Block a user