FRE-4517, FRE-4499: Complete SpamShield implementation and billing updates
- SpamFeedback table migration with timestamp index - Real-time interception engine completion - Billing service enhancements - Classifier and rule engine updates Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -52,43 +52,47 @@ export const BillingConfigSchema = z.object({
|
||||
|
||||
export type BillingConfig = z.infer<typeof BillingConfigSchema>;
|
||||
|
||||
export const loadBillingConfig = (): BillingConfig => ({
|
||||
stripe: {
|
||||
apiKey: process.env.STRIPE_API_KEY!,
|
||||
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET!,
|
||||
pricingTableId: process.env.STRIPE_PRICING_TABLE_ID,
|
||||
},
|
||||
tiers: {
|
||||
free: {
|
||||
priceId: process.env.STRIPE_FREE_TIER_PRICE_ID || 'price_free',
|
||||
monthlyPriceCents: 0,
|
||||
callMinutesLimit: 100,
|
||||
smsCountLimit: 500,
|
||||
darkWebScans: 1,
|
||||
export const loadBillingConfig = (): BillingConfig => {
|
||||
const rawConfig = {
|
||||
stripe: {
|
||||
apiKey: process.env.STRIPE_API_KEY!,
|
||||
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET!,
|
||||
pricingTableId: process.env.STRIPE_PRICING_TABLE_ID,
|
||||
},
|
||||
basic: {
|
||||
priceId: process.env.STRIPE_BASIC_TIER_PRICE_ID || 'price_basic',
|
||||
monthlyPriceCents: 999,
|
||||
callMinutesLimit: 500,
|
||||
smsCountLimit: 2000,
|
||||
darkWebScans: 12,
|
||||
tiers: {
|
||||
free: {
|
||||
priceId: process.env.STRIPE_FREE_TIER_PRICE_ID || 'price_free',
|
||||
monthlyPriceCents: 0,
|
||||
callMinutesLimit: 100,
|
||||
smsCountLimit: 500,
|
||||
darkWebScans: 1,
|
||||
},
|
||||
basic: {
|
||||
priceId: process.env.STRIPE_BASIC_TIER_PRICE_ID || 'price_basic',
|
||||
monthlyPriceCents: 999,
|
||||
callMinutesLimit: 500,
|
||||
smsCountLimit: 2000,
|
||||
darkWebScans: 12,
|
||||
},
|
||||
plus: {
|
||||
priceId: process.env.STRIPE_PLUS_TIER_PRICE_ID || 'price_plus',
|
||||
monthlyPriceCents: 1999,
|
||||
callMinutesLimit: 2000,
|
||||
smsCountLimit: 10000,
|
||||
darkWebScans: 12,
|
||||
voiceCloning: true,
|
||||
},
|
||||
premium: {
|
||||
priceId: process.env.STRIPE_PREMIUM_TIER_PRICE_ID || 'price_premium',
|
||||
monthlyPriceCents: 4999,
|
||||
callMinutesLimit: 10000,
|
||||
smsCountLimit: 50000,
|
||||
darkWebScans: 12,
|
||||
voiceCloning: true,
|
||||
homeTitleMonitor: true,
|
||||
},
|
||||
},
|
||||
plus: {
|
||||
priceId: process.env.STRIPE_PLUS_TIER_PRICE_ID || 'price_plus',
|
||||
monthlyPriceCents: 1999,
|
||||
callMinutesLimit: 2000,
|
||||
smsCountLimit: 10000,
|
||||
darkWebScans: 12,
|
||||
voiceCloning: true,
|
||||
},
|
||||
premium: {
|
||||
priceId: process.env.STRIPE_PREMIUM_TIER_PRICE_ID || 'price_premium',
|
||||
monthlyPriceCents: 4999,
|
||||
callMinutesLimit: 10000,
|
||||
smsCountLimit: 50000,
|
||||
darkWebScans: 12,
|
||||
voiceCloning: true,
|
||||
homeTitleMonitor: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return BillingConfigSchema.parse(rawConfig);
|
||||
};
|
||||
|
||||
@@ -19,23 +19,39 @@ export function requireTier(
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<void> => {
|
||||
const userTier = req.tier;
|
||||
const { userId } = req;
|
||||
|
||||
if (!userTier) {
|
||||
if (!userId) {
|
||||
res.status(401).json({ error: 'Authentication required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!allowedTiers.includes(userTier)) {
|
||||
res.status(403).json({
|
||||
error: 'Tier not authorized',
|
||||
required: allowedTiers,
|
||||
current: userTier,
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const userTier = await billingService.getUserTier(userId);
|
||||
|
||||
next();
|
||||
if (!userTier) {
|
||||
res.status(401).json({ error: 'User tier not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
req.tier = userTier;
|
||||
|
||||
if (!allowedTiers.includes(userTier)) {
|
||||
res.status(403).json({
|
||||
error: 'Tier not authorized',
|
||||
required: allowedTiers,
|
||||
current: userTier,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
error: 'Failed to verify tier',
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -123,12 +139,10 @@ export function withSubscription() {
|
||||
}
|
||||
|
||||
try {
|
||||
// Fetch subscription from database
|
||||
// TODO: Replace with actual database query
|
||||
const subscriptionId = (req as any).subscriptionId;
|
||||
const subscription = await billingService.getUserSubscription(userId);
|
||||
|
||||
if (subscriptionId) {
|
||||
req.subscriptionId = subscriptionId;
|
||||
if (subscription) {
|
||||
req.subscriptionId = subscription.id;
|
||||
}
|
||||
|
||||
next();
|
||||
|
||||
@@ -3,7 +3,19 @@ import { loadBillingConfig, SubscriptionTier } from '../config/billing.config';
|
||||
import type { Subscription, SubscriptionCreateSchema, SubscriptionUpdateSchema } from '../models/subscription.model';
|
||||
|
||||
const config = loadBillingConfig();
|
||||
const stripe = new Stripe(config.stripe.apiKey, { apiVersion: '2024-04-10' });
|
||||
const stripe = new Stripe(config.stripe.apiKey, { apiVersion: '2023-10-16' });
|
||||
|
||||
const processedEvents = new Map<string, number>();
|
||||
const IDEMPOTENCY_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
function cleanupOldEvents(): void {
|
||||
const now = Date.now();
|
||||
for (const [eventId, timestamp] of processedEvents.entries()) {
|
||||
if (now - timestamp > IDEMPOTENCY_TTL_MS) {
|
||||
processedEvents.delete(eventId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class BillingService {
|
||||
private static instance: BillingService;
|
||||
@@ -34,6 +46,51 @@ export class BillingService {
|
||||
}
|
||||
}
|
||||
|
||||
private async verifyCustomerOwnership(
|
||||
customerId: string,
|
||||
userId: string
|
||||
): Promise<void> {
|
||||
const customer = await stripe.customers.retrieve(customerId);
|
||||
const customerUserId = (customer as Stripe.Customer).metadata?.userId;
|
||||
|
||||
if (customerUserId !== userId) {
|
||||
throw new Error(
|
||||
`Customer ${customerId} does not belong to user ${userId}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async getUserTier(userId: string): Promise<SubscriptionTier | null> {
|
||||
try {
|
||||
const customers = await stripe.customers.list({
|
||||
limit: 100,
|
||||
expand: ['data.subscriptions'],
|
||||
});
|
||||
|
||||
const customer = customers.data.find(
|
||||
(c: Stripe.Customer) =>
|
||||
c.metadata?.userId === userId && c.subscriptions?.data.length && c.subscriptions.data.length > 0
|
||||
);
|
||||
|
||||
if (!customer || !customer.subscriptions) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const activeSubscription = customer.subscriptions.data.find(
|
||||
(sub: Stripe.Subscription) => sub.status === 'active'
|
||||
);
|
||||
|
||||
if (!activeSubscription) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tier = activeSubscription.metadata?.tier as SubscriptionTier;
|
||||
return tier || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async createSubscription(
|
||||
userId: string,
|
||||
tier: SubscriptionTier,
|
||||
@@ -41,21 +98,36 @@ export class BillingService {
|
||||
): Promise<{ subscription: Stripe.Subscription; customer: Stripe.Customer }> {
|
||||
const tierConfig = config.tiers[tier];
|
||||
|
||||
const customer = await this.getCustomer(customerId);
|
||||
if (!customer) {
|
||||
throw new Error(`Customer ${customerId} not found`);
|
||||
}
|
||||
|
||||
await this.verifyCustomerOwnership(customerId, userId);
|
||||
|
||||
const subscription = await stripe.subscriptions.create({
|
||||
customer: customerId,
|
||||
items: [{ price: tierConfig.priceId }],
|
||||
metadata: { userId, tier },
|
||||
});
|
||||
|
||||
const customer = await this.getCustomer(customerId);
|
||||
|
||||
return { subscription, customer: customer! };
|
||||
return { subscription, customer };
|
||||
}
|
||||
|
||||
async cancelSubscription(
|
||||
subscriptionId: string,
|
||||
userId: string,
|
||||
cancelAtPeriodEnd: boolean = false
|
||||
): Promise<Stripe.Subscription> {
|
||||
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
|
||||
const customerUserId = subscription.metadata?.userId;
|
||||
|
||||
if (customerUserId !== userId) {
|
||||
throw new Error(
|
||||
`Subscription ${subscriptionId} does not belong to user ${userId}`
|
||||
);
|
||||
}
|
||||
|
||||
if (cancelAtPeriodEnd) {
|
||||
return await stripe.subscriptions.update(subscriptionId, {
|
||||
cancel_at_period_end: true,
|
||||
@@ -66,11 +138,19 @@ export class BillingService {
|
||||
|
||||
async updateSubscription(
|
||||
subscriptionId: string,
|
||||
userId: string,
|
||||
newTier: SubscriptionTier
|
||||
): Promise<Stripe.Subscription> {
|
||||
const newTierConfig = config.tiers[newTier];
|
||||
|
||||
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
|
||||
const customerUserId = subscription.metadata?.userId;
|
||||
|
||||
if (customerUserId !== userId) {
|
||||
throw new Error(
|
||||
`Subscription ${subscriptionId} does not belong to user ${userId}`
|
||||
);
|
||||
}
|
||||
|
||||
const newTierConfig = config.tiers[newTier];
|
||||
|
||||
const updated = await stripe.subscriptions.update(subscriptionId, {
|
||||
proration_behavior: 'create_prorations',
|
||||
@@ -104,6 +184,29 @@ export class BillingService {
|
||||
}
|
||||
}
|
||||
|
||||
async getUserSubscription(userId: string): Promise<Stripe.Subscription | null> {
|
||||
try {
|
||||
const customers = await stripe.customers.list({
|
||||
limit: 100,
|
||||
expand: ['data.subscriptions'],
|
||||
});
|
||||
|
||||
for (const customer of customers.data) {
|
||||
if (customer.metadata?.userId === userId && customer.subscriptions) {
|
||||
const activeSub = customer.subscriptions.data.find(
|
||||
(sub: Stripe.Subscription) => sub.status === 'active'
|
||||
);
|
||||
if (activeSub) {
|
||||
return activeSub;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async getTierLimits(tier: SubscriptionTier) {
|
||||
return config.tiers[tier];
|
||||
}
|
||||
@@ -130,24 +233,41 @@ export class BillingService {
|
||||
description: string,
|
||||
metadata?: Record<string, string>
|
||||
): Promise<Stripe.Invoice> {
|
||||
return await stripe.invoices.create({
|
||||
const invoice = await stripe.invoices.create({
|
||||
customer: customerId,
|
||||
line_items: [
|
||||
{
|
||||
amount_data: { currency: 'usd', unit_amount: amount },
|
||||
description: description,
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
metadata: metadata,
|
||||
});
|
||||
|
||||
await stripe.invoiceItems.create({
|
||||
invoice: invoice.id,
|
||||
customer: customerId,
|
||||
price_data: {
|
||||
currency: 'usd',
|
||||
unit_amount: amount,
|
||||
product: 'default_product',
|
||||
},
|
||||
description: description,
|
||||
quantity: 1,
|
||||
});
|
||||
|
||||
return await stripe.invoices.retrieve(invoice.id);
|
||||
}
|
||||
|
||||
async handleWebhook(
|
||||
sig: string,
|
||||
body: Buffer
|
||||
): Promise<Stripe.Event> {
|
||||
return stripe.webhooks.constructEvent(body, sig, config.stripe.webhookSecret);
|
||||
): Promise<Stripe.Event | null> {
|
||||
const event = stripe.webhooks.constructEvent(body, sig, config.stripe.webhookSecret);
|
||||
|
||||
cleanupOldEvents();
|
||||
|
||||
if (processedEvents.has(event.id)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
processedEvents.set(event.id, Date.now());
|
||||
|
||||
return event;
|
||||
}
|
||||
|
||||
async getInvoiceHistory(customerId: string): Promise<Stripe.ApiList<Stripe.Invoice>> {
|
||||
|
||||
Reference in New Issue
Block a user