Add tier-based scan scheduler and webhook triggers (FRE-4498)

- ScanScheduler: tier-based scheduling (BASIC=24h, PLUS=6h, PREMIUM=1h)
- WebhookHandler: HMAC-verified webhook ingestion with SCAN_TRIGGER support
- API routes: /scheduler and /webhooks endpoints under /api/v1/darkwatch
- Jobs: scheduled scan checker + webhook retry processor via BullMQ
- Schema: ScanSchedule, WebhookEvent models; ScanJob.scheduledBy field
- Types: ScheduleStatus, WebhookEventType, WebhookTriggerInput
- Tests: scheduler lifecycle + webhook signature/processing tests

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
2026-04-30 10:57:56 -04:00
parent 76d431e1ec
commit 9fb5379b7a
43 changed files with 7819 additions and 93 deletions

View File

@@ -0,0 +1,23 @@
{
"name": "@shieldai/shared-billing",
"version": "1.0.0",
"main": "src/index.ts",
"types": "src/index.ts",
"scripts": {
"build": "tsc",
"test": "jest",
"lint": "eslint src/"
},
"dependencies": {
"stripe": "^15.0.0",
"zod": "^3.22.0",
"express": "^4.18.0"
},
"devDependencies": {
"@types/express": "^4.17.0",
"typescript": "^5.0.0"
},
"peerDependencies": {
"typescript": "^5.0.0"
}
}

View File

@@ -0,0 +1,94 @@
import { z } from 'zod';
export const SubscriptionTier = {
FREE: 'free',
BASIC: 'basic',
PLUS: 'plus',
PREMIUM: 'premium',
} as const;
export type SubscriptionTier = typeof SubscriptionTier[keyof typeof SubscriptionTier];
export const BillingConfigSchema = z.object({
stripe: z.object({
apiKey: z.string().min(1, 'STRIPE_API_KEY required'),
webhookSecret: z.string().min(1, 'STRIPE_WEBHOOK_SECRET required'),
pricingTableId: z.string().optional(),
}),
tiers: z.object({
free: z.object({
priceId: z.string(),
monthlyPriceCents: z.number().default(0),
callMinutesLimit: z.number().default(100),
smsCountLimit: z.number().default(500),
darkWebScans: z.number().default(1),
}),
basic: z.object({
priceId: z.string(),
monthlyPriceCents: z.number().default(999),
callMinutesLimit: z.number().default(500),
smsCountLimit: z.number().default(2000),
darkWebScans: z.number().default(12),
}),
plus: z.object({
priceId: z.string(),
monthlyPriceCents: z.number().default(1999),
callMinutesLimit: z.number().default(2000),
smsCountLimit: z.number().default(10000),
darkWebScans: z.number().default(12),
voiceCloning: z.boolean().default(true),
}),
premium: z.object({
priceId: z.string(),
monthlyPriceCents: z.number().default(4999),
callMinutesLimit: z.number().default(10000),
smsCountLimit: z.number().default(50000),
darkWebScans: z.number().default(12),
voiceCloning: z.boolean().default(true),
homeTitleMonitor: z.boolean().default(true),
}),
}),
});
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,
},
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!,
monthlyPriceCents: 1999,
callMinutesLimit: 2000,
smsCountLimit: 10000,
darkWebScans: 12,
voiceCloning: true,
},
premium: {
priceId: process.env.STRIPE_PREMIUM_TIER_PRICE_ID!,
monthlyPriceCents: 4999,
callMinutesLimit: 10000,
smsCountLimit: 50000,
darkWebScans: 12,
voiceCloning: true,
homeTitleMonitor: true,
},
},
});

View File

@@ -0,0 +1,10 @@
export { BillingService } from './services/billing.service';
export { loadBillingConfig, SubscriptionTier } from './config/billing.config';
export {
requireTier,
checkUsageLimit,
withUsageTracking,
requireSubscription,
} from './middleware/billing.middleware';
export * from './models/subscription.model';

View File

@@ -0,0 +1,137 @@
import { Request, Response, NextFunction } from 'express';
import { BillingService } from '../services/billing.service';
import { SubscriptionTier } from '../config/billing.config';
const billingService = BillingService.getInstance();
export interface AuthenticatedRequest extends Request {
userId?: string;
tier?: SubscriptionTier;
usage?: { current: number; limit: number; remaining: number };
}
export function requireTier(
allowedTiers: SubscriptionTier[]
) {
return async (
req: AuthenticatedRequest,
res: Response,
next: NextFunction
): Promise<void> => {
const userTier = req.tier;
if (!userTier) {
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;
}
next();
};
}
export function checkUsageLimit(
feature: 'callMinutes' | 'smsCount'
) {
return async (
req: AuthenticatedRequest,
res: Response,
next: NextFunction
): Promise<void> => {
const { userId, tier, usage } = req;
if (!userId || !tier || !usage) {
next();
return;
}
if (!usage.withinLimit) {
res.status(429).json({
error: 'Usage limit exceeded',
feature,
limit: usage.limit,
current: usage.current,
remaining: usage.remaining,
});
return;
}
next();
};
}
export function withUsageTracking(
feature: 'callMinutes' | 'smsCount',
increment: number = 1
) {
return async (
req: AuthenticatedRequest,
res: Response,
next: NextFunction
): Promise<void> => {
const { userId, tier } = req;
if (!userId || !tier) {
next();
return;
}
try {
const limits = await billingService.getTierLimits(tier);
const limit = feature === 'callMinutes' ? limits.callMinutesLimit : limits.smsCountLimit;
// Get current usage from request context or database
const currentUsage = (req as any).currentUsage || 0;
req.usage = {
current: currentUsage + increment,
limit,
remaining: Math.max(0, limit - currentUsage - increment),
withinLimit: currentUsage + increment <= limit,
};
next();
} catch (error) {
res.status(500).json({
error: 'Failed to check usage',
message: error instanceof Error ? error.message : 'Unknown error',
});
}
};
}
export function requireSubscription() {
return async (
req: AuthenticatedRequest,
res: Response,
next: NextFunction
): Promise<void> => {
const { userId } = req;
if (!userId) {
res.status(401).json({ error: 'Authentication required' });
return;
}
// Check if user has active subscription
// This would typically query the database
const hasSubscription = (req as any).subscriptionId != null;
if (!hasSubscription) {
res.status(402).json({
error: 'Active subscription required',
});
return;
}
next();
};
}

View File

@@ -0,0 +1,35 @@
import { z } from 'zod';
import { SubscriptionTier } from '../config/billing.config';
export const SubscriptionModel = z.object({
id: z.string(),
userId: z.string(),
stripeSubscriptionId: z.string(),
stripeCustomerId: z.string(),
tier: z.nativeEnum(SubscriptionTier),
status: z.enum(['active', 'canceled', 'in_trial', 'past_due', 'unpaid', 'incomplete']),
currentPeriodStart: z.date(),
currentPeriodEnd: z.date(),
cancelAtPeriodEnd: z.boolean().default(false),
createdAt: z.date(),
updatedAt: z.date(),
});
export type Subscription = z.infer<typeof SubscriptionModel>;
export const SubscriptionCreateSchema = z.object({
userId: z.string(),
tier: z.nativeEnum(SubscriptionTier),
stripeCustomerId: z.string(),
stripeSubscriptionId: z.string(),
currentPeriodStart: z.date(),
currentPeriodEnd: z.date(),
});
export const SubscriptionUpdateSchema = z.object({
tier: z.nativeEnum(SubscriptionTier).optional(),
status: z.enum(['active', 'canceled', 'in_trial', 'past_due', 'unpaid', 'incomplete']).optional(),
cancelAtPeriodEnd: z.boolean().optional(),
currentPeriodStart: z.date().optional(),
currentPeriodEnd: z.date().optional(),
});

View File

@@ -0,0 +1,152 @@
import Stripe from 'stripe';
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' });
export class BillingService {
private static instance: BillingService;
private constructor() {}
static getInstance(): BillingService {
if (!BillingService.instance) {
BillingService.instance = new BillingService();
}
return BillingService.instance;
}
async createCustomer(email: string, userId: string): Promise<Stripe.Customer> {
const customer = await stripe.customers.create({
email,
metadata: { userId },
});
return customer;
}
async getCustomer(customerId: string): Promise<Stripe.Customer | null> {
try {
const customer = await stripe.customers.retrieve(customerId);
return customer as Stripe.Customer;
} catch {
return null;
}
}
async createSubscription(
userId: string,
tier: SubscriptionTier,
customerId: string
): Promise<{ subscription: Stripe.Subscription; customer: Stripe.Customer }> {
const tierConfig = config.tiers[tier];
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! };
}
async cancelSubscription(
subscriptionId: string,
cancelAtPeriodEnd: boolean = false
): Promise<Stripe.Subscription> {
if (cancelAtPeriodEnd) {
return await stripe.subscriptions.update(subscriptionId, {
cancel_at_period_end: true,
});
}
return await stripe.subscriptions.cancel(subscriptionId);
}
async updateSubscription(
subscriptionId: string,
newTier: SubscriptionTier
): Promise<Stripe.Subscription> {
const newTierConfig = config.tiers[newTier];
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
const updated = await stripe.subscriptions.update(subscriptionId, {
proration_behavior: 'create_prorations',
items: [
{
id: subscription.items.data[0]?.id,
price: newTierConfig.priceId,
},
],
});
return updated;
}
async createCustomerPortalSession(
customerId: string,
returnUrl: string
): Promise<Stripe.BillingPortal.Session> {
return await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: returnUrl,
});
}
async getSubscription(subscriptionId: string): Promise<Stripe.Subscription | null> {
try {
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
return subscription;
} catch {
return null;
}
}
async getTierLimits(tier: SubscriptionTier) {
return config.tiers[tier];
}
async checkUsageAgainstLimit(
userId: string,
tier: SubscriptionTier,
currentUsage: number
): Promise<{ withinLimit: boolean; remaining: number; limit: number }> {
const tierConfig = config.tiers[tier];
const limit = tierConfig.callMinutesLimit;
const remaining = Math.max(0, limit - currentUsage);
return {
withinLimit: currentUsage <= limit,
remaining,
limit,
};
}
async createInvoice(
customerId: string,
amount: number,
description: string,
metadata?: Record<string, string>
): Promise<Stripe.Invoice> {
return await stripe.invoices.create({
customer: customerId,
metadata: { ...metadata, description },
});
}
async handleWebhook(
sig: string,
body: Buffer
): Promise<Stripe.Event> {
return stripe.webhooks.constructEvent(body, sig, config.stripe.webhookSecret);
}
async getInvoiceHistory(customerId: string): Promise<Stripe.ApiList<Stripe.Invoice>> {
return await stripe.invoices.list({
customer: customerId,
limit: 100,
});
}
}

View File

@@ -0,0 +1,12 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}