Auto-commit 2026-04-29 16:31

This commit is contained in:
2026-04-29 16:31:27 -04:00
parent e8687bb6b2
commit 0495ee5bd2
19691 changed files with 3272886 additions and 138 deletions

View 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;
}

View 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';

View 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;
}

View 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();