feat(phase-4): WS6 — Billing & Usage Metering (Stripe, free tier enforcement)
- DB migration 023: tenant_subscriptions and usage_events tables - UsageMeteringMiddleware: in-memory counters, 60s flush to DB via UPSERT - FreeTierEnforcementMiddleware: 10 agents / 1,000 calls/day limits, Redis cache - UsageService: getDailyUsage and getActiveAgentCount - BillingService: Stripe checkout sessions, webhook verification, subscription status - POST /billing/checkout, POST /billing/webhook, GET /billing/usage endpoints - BILLING_ENABLED=false disables enforcement without breaking metering - Dashboard: Usage tab with Free Tier/Pro badges and metric cards - 19 unit tests passing across billing services and middleware Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
33
src/app.ts
33
src/app.ts
@@ -8,6 +8,7 @@ import express, { Application } from 'express';
|
||||
import helmet from 'helmet';
|
||||
import cors from 'cors';
|
||||
import morgan from 'morgan';
|
||||
import Stripe from 'stripe';
|
||||
|
||||
import { getPool } from './db/pool.js';
|
||||
import { getRedisClient } from './cache/redis.js';
|
||||
@@ -21,6 +22,8 @@ import { OrgRepository } from './repositories/OrgRepository.js';
|
||||
import { AuditService } from './services/AuditService.js';
|
||||
import { AgentService } from './services/AgentService.js';
|
||||
import { MarketplaceService } from './services/MarketplaceService.js';
|
||||
import { BillingService } from './services/BillingService.js';
|
||||
import { UsageService } from './services/UsageService.js';
|
||||
import { CredentialService } from './services/CredentialService.js';
|
||||
import { OAuth2Service } from './services/OAuth2Service.js';
|
||||
import { OrgService } from './services/OrgService.js';
|
||||
@@ -35,6 +38,7 @@ import { createKafkaProducer } from './adapters/KafkaAdapter.js';
|
||||
|
||||
import { AgentController } from './controllers/AgentController.js';
|
||||
import { MarketplaceController } from './controllers/MarketplaceController.js';
|
||||
import { BillingController } from './controllers/BillingController.js';
|
||||
import { OIDCTrustPolicyController } from './controllers/OIDCTrustPolicyController.js';
|
||||
import { OIDCTokenExchangeController } from './controllers/OIDCTokenExchangeController.js';
|
||||
import { TokenController } from './controllers/TokenController.js';
|
||||
@@ -49,6 +53,7 @@ import { ComplianceController } from './controllers/ComplianceController.js';
|
||||
|
||||
import { createAgentsRouter } from './routes/agents.js';
|
||||
import { createMarketplaceRouter } from './routes/marketplace.js';
|
||||
import { createBillingRouter } from './routes/billing.js';
|
||||
import { createOIDCTrustPoliciesRouter } from './routes/oidcTrustPolicies.js';
|
||||
import { createOIDCTokenExchangeRouter } from './routes/oidcTokenExchange.js';
|
||||
import { OIDCTrustPolicyService } from './services/OIDCTrustPolicyService.js';
|
||||
@@ -69,6 +74,8 @@ import { createOpaMiddleware } from './middleware/opa.js';
|
||||
import { metricsMiddleware } from './middleware/metrics.js';
|
||||
import { createOrgContextMiddleware } from './middleware/orgContext.js';
|
||||
import { authMiddleware } from './middleware/auth.js';
|
||||
import { createUsageMeteringMiddleware, startUsageMeteringFlush } from './middleware/usageMeteringMiddleware.js';
|
||||
import { createFreeTierEnforcementMiddleware } from './middleware/freeTierEnforcementMiddleware.js';
|
||||
import { tlsEnforcementMiddleware } from './middleware/TLSEnforcementMiddleware.js';
|
||||
import { createVaultClientFromEnv } from './vault/VaultClient.js';
|
||||
import { getEncryptionService } from './services/EncryptionService.js';
|
||||
@@ -232,6 +239,17 @@ export async function createApp(): Promise<Application> {
|
||||
const webhookController = new WebhookController(webhookService);
|
||||
const marketplaceController = new MarketplaceController(marketplaceService);
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Billing & Usage Metering (WS6)
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
const stripe = new Stripe(process.env['STRIPE_SECRET_KEY'] ?? '', { apiVersion: '2026-03-25.dahlia' });
|
||||
const billingService = new BillingService(pool, stripe);
|
||||
const usageService = new UsageService(pool);
|
||||
const billingController = new BillingController(billingService, usageService);
|
||||
|
||||
// Start periodic flush of in-memory usage counters to DB (every 60s)
|
||||
startUsageMeteringFlush(pool);
|
||||
|
||||
// OIDC trust policy management + GitHub Actions token exchange
|
||||
const oidcTrustPolicyService = new OIDCTrustPolicyService(pool);
|
||||
const oidcTrustPolicyController = new OIDCTrustPolicyController(oidcTrustPolicyService);
|
||||
@@ -254,6 +272,18 @@ export async function createApp(): Promise<Application> {
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
app.use(createOrgContextMiddleware(pool));
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Usage metering — records per-tenant API call counts in-memory
|
||||
// Applied after auth middleware so req.user is populated.
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
app.use(createUsageMeteringMiddleware(pool));
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Free tier enforcement — rejects requests exceeding free plan limits
|
||||
// Applied after usage metering and before routes.
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
app.use(createFreeTierEnforcementMiddleware(pool, redis as RedisClientType));
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Routes
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
@@ -287,6 +317,9 @@ export async function createApp(): Promise<Application> {
|
||||
app.use(`${API_BASE}`, createComplianceRouter(complianceController));
|
||||
app.use(`${API_BASE}/marketplace`, createMarketplaceRouter(marketplaceController));
|
||||
|
||||
// Billing & Usage Metering — checkout, webhook, usage summary
|
||||
app.use(`${API_BASE}/billing`, createBillingRouter(billingController, authMiddleware));
|
||||
|
||||
// OIDC trust-policy management (authenticated) and token exchange (unauthenticated)
|
||||
// Both routers mount under ${API_BASE}/oidc — trust-policy routes use /trust-policies prefix,
|
||||
// token exchange uses /token, so there are no path conflicts.
|
||||
|
||||
141
src/controllers/BillingController.ts
Normal file
141
src/controllers/BillingController.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Billing controller for SentryAgent.ai AgentIdP.
|
||||
* Handles Stripe checkout session creation, webhook processing, and usage queries.
|
||||
*/
|
||||
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { BillingService } from '../services/BillingService.js';
|
||||
import { UsageService } from '../services/UsageService.js';
|
||||
import { ValidationError, AuthorizationError } from '../utils/errors.js';
|
||||
|
||||
/**
|
||||
* Controller for billing and usage endpoints.
|
||||
* Delegates all business logic to BillingService and UsageService.
|
||||
*/
|
||||
export class BillingController {
|
||||
/**
|
||||
* @param billingService - The billing service for Stripe operations.
|
||||
* @param usageService - The usage metering service.
|
||||
*/
|
||||
constructor(
|
||||
private readonly billingService: BillingService,
|
||||
private readonly usageService: UsageService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Handles POST /billing/checkout — creates a Stripe Checkout Session.
|
||||
* Reads the tenant ID from the authenticated user's organizationId.
|
||||
* Returns { checkoutUrl } with HTTP 201.
|
||||
*
|
||||
* @param req - Express request. Must have req.user populated.
|
||||
* @param res - Express response.
|
||||
* @param next - Express next function.
|
||||
*/
|
||||
createCheckoutSession = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
throw new AuthorizationError();
|
||||
}
|
||||
|
||||
const tenantId = req.user.organization_id;
|
||||
if (!tenantId) {
|
||||
throw new ValidationError('organization_id is required in token.');
|
||||
}
|
||||
|
||||
const body = req.body as { successUrl?: unknown; cancelUrl?: unknown };
|
||||
|
||||
const successUrl =
|
||||
typeof body.successUrl === 'string' && body.successUrl.length > 0
|
||||
? body.successUrl
|
||||
: `${req.protocol}://${req.hostname}/dashboard?billing=success`;
|
||||
|
||||
const cancelUrl =
|
||||
typeof body.cancelUrl === 'string' && body.cancelUrl.length > 0
|
||||
? body.cancelUrl
|
||||
: `${req.protocol}://${req.hostname}/dashboard?billing=cancel`;
|
||||
|
||||
const checkoutUrl = await this.billingService.createCheckoutSession(
|
||||
tenantId,
|
||||
successUrl,
|
||||
cancelUrl,
|
||||
);
|
||||
|
||||
res.status(201).json({ checkoutUrl });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles POST /billing/webhook — processes Stripe webhook events.
|
||||
* Reads the raw body buffer and Stripe-Signature header.
|
||||
* Returns HTTP 200 { received: true } on success.
|
||||
* Returns HTTP 400 if the signature header is missing.
|
||||
*
|
||||
* @param req - Express request. Body must be a raw Buffer.
|
||||
* @param res - Express response.
|
||||
* @param next - Express next function.
|
||||
*/
|
||||
handleWebhook = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
const sig = req.headers['stripe-signature'];
|
||||
if (!sig || typeof sig !== 'string') {
|
||||
throw new ValidationError('Missing Stripe-Signature header.');
|
||||
}
|
||||
|
||||
const webhookSecret = process.env['STRIPE_WEBHOOK_SECRET'];
|
||||
if (!webhookSecret) {
|
||||
throw new Error('STRIPE_WEBHOOK_SECRET environment variable is required.');
|
||||
}
|
||||
|
||||
// req.body is a raw Buffer when express.raw() middleware is applied
|
||||
const rawBody = req.body as Buffer;
|
||||
|
||||
await this.billingService.handleWebhookEvent(rawBody, sig, webhookSecret);
|
||||
|
||||
res.status(200).json({ received: true });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles GET /billing/usage — returns today's usage summary for the tenant.
|
||||
* Returns combined { tenantId, date, apiCalls, agentCount, subscriptionStatus }.
|
||||
*
|
||||
* @param req - Express request. Must have req.user populated.
|
||||
* @param res - Express response.
|
||||
* @param next - Express next function.
|
||||
*/
|
||||
getUsage = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
throw new AuthorizationError();
|
||||
}
|
||||
|
||||
const tenantId = req.user.organization_id;
|
||||
if (!tenantId) {
|
||||
throw new ValidationError('organization_id is required in token.');
|
||||
}
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10); // YYYY-MM-DD
|
||||
|
||||
const [usage, subscription] = await Promise.all([
|
||||
this.usageService.getDailyUsage(tenantId, today),
|
||||
this.billingService.getSubscriptionStatus(tenantId),
|
||||
]);
|
||||
|
||||
res.status(200).json({
|
||||
tenantId: usage.tenantId,
|
||||
date: usage.date,
|
||||
apiCalls: usage.apiCalls,
|
||||
agentCount: usage.agentCount,
|
||||
subscriptionStatus: subscription.status,
|
||||
currentPeriodEnd: subscription.currentPeriodEnd,
|
||||
stripeSubscriptionId: subscription.stripeSubscriptionId,
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
};
|
||||
}
|
||||
43
src/db/migrations/023_add_billing.sql
Normal file
43
src/db/migrations/023_add_billing.sql
Normal file
@@ -0,0 +1,43 @@
|
||||
-- Migration 023: Add billing and usage metering tables
|
||||
-- Phase 4, WS6 — Billing & Usage Metering
|
||||
|
||||
-- ── Tenant subscriptions ────────────────────────────────────────────────────────
|
||||
-- Tracks the Stripe subscription status for each tenant/organization.
|
||||
-- One row per tenant. When no row exists, the tenant is on the free tier.
|
||||
CREATE TABLE IF NOT EXISTS tenant_subscriptions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES organizations(org_id) ON DELETE CASCADE,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'free',
|
||||
stripe_customer_id VARCHAR(255),
|
||||
stripe_subscription_id VARCHAR(255),
|
||||
current_period_end TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- One subscription row per tenant (UPSERT target)
|
||||
ALTER TABLE tenant_subscriptions
|
||||
ADD CONSTRAINT uq_tenant_subscriptions_tenant_id UNIQUE (tenant_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tenant_subscriptions_tenant_id
|
||||
ON tenant_subscriptions(tenant_id);
|
||||
|
||||
-- ── Usage events ─────────────────────────────────────────────────────────────────
|
||||
-- Daily usage counters per tenant and metric type.
|
||||
-- Uses UPSERT with ON CONFLICT to accumulate counts without duplicates.
|
||||
CREATE TABLE IF NOT EXISTS usage_events (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
date DATE NOT NULL DEFAULT CURRENT_DATE,
|
||||
metric_type VARCHAR(64) NOT NULL,
|
||||
count INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Unique constraint enables UPSERT: ON CONFLICT (tenant_id, date, metric_type)
|
||||
ALTER TABLE usage_events
|
||||
ADD CONSTRAINT uq_usage_events_tenant_date_metric
|
||||
UNIQUE (tenant_id, date, metric_type);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_usage_events_tenant_date
|
||||
ON usage_events(tenant_id, date);
|
||||
@@ -159,3 +159,16 @@ export const tenantApiCallsTotal = new Counter({
|
||||
labelNames: ['tenant_id'] as const,
|
||||
registers: [metricsRegistry],
|
||||
});
|
||||
|
||||
/**
|
||||
* Total number of requests rejected due to free tier billing limits.
|
||||
* Labels: tenant_id, limit_type ('agent_limit' | 'api_limit')
|
||||
*
|
||||
* WS6 — Billing & Usage Metering.
|
||||
*/
|
||||
export const billingLimitRejectionsTotal = new Counter({
|
||||
name: 'agentidp_billing_limit_rejections_total',
|
||||
help: 'Total number of requests rejected due to free tier billing limits.',
|
||||
labelNames: ['tenant_id', 'limit_type'] as const,
|
||||
registers: [metricsRegistry],
|
||||
});
|
||||
|
||||
189
src/middleware/freeTierEnforcementMiddleware.ts
Normal file
189
src/middleware/freeTierEnforcementMiddleware.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* Free tier enforcement middleware for SentryAgent.ai AgentIdP.
|
||||
* Rejects requests that exceed free-tier usage limits (agents or API calls).
|
||||
* Skipped entirely when BILLING_ENABLED=false.
|
||||
*/
|
||||
|
||||
import { Request, Response, NextFunction, RequestHandler } from 'express';
|
||||
import { Pool } from 'pg';
|
||||
import type { RedisClientType } from 'redis';
|
||||
import { SentryAgentError } from '../utils/errors.js';
|
||||
import { billingLimitRejectionsTotal } from '../metrics/registry.js';
|
||||
import { UsageService } from '../services/UsageService.js';
|
||||
|
||||
/** Free tier: maximum number of active (non-decommissioned) agents per tenant. */
|
||||
const FREE_TIER_MAX_AGENTS = 10;
|
||||
|
||||
/** Free tier: maximum number of API calls per day per tenant. */
|
||||
const FREE_TIER_MAX_API_CALLS = 1_000;
|
||||
|
||||
/** Redis cache TTL for billing usage checks, in seconds. */
|
||||
const CACHE_TTL_SECONDS = 60;
|
||||
|
||||
/**
|
||||
* Thrown when a free-tier tenant exceeds the agent creation limit.
|
||||
* HTTP 429 with code FREE_TIER_AGENT_LIMIT.
|
||||
*/
|
||||
class FreeTierAgentLimitError extends SentryAgentError {
|
||||
constructor(current: number) {
|
||||
super(
|
||||
`Free tier limit of ${FREE_TIER_MAX_AGENTS} active agents reached. Upgrade to create more agents.`,
|
||||
'FREE_TIER_AGENT_LIMIT',
|
||||
429,
|
||||
{ limit: FREE_TIER_MAX_AGENTS, current },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown when a free-tier tenant exceeds the daily API call limit.
|
||||
* HTTP 429 with code FREE_TIER_API_LIMIT.
|
||||
*/
|
||||
class FreeTierApiLimitError extends SentryAgentError {
|
||||
constructor(current: number) {
|
||||
super(
|
||||
`Free tier daily API call limit of ${FREE_TIER_MAX_API_CALLS} reached. Upgrade for unlimited calls.`,
|
||||
'FREE_TIER_API_LIMIT',
|
||||
429,
|
||||
{ limit: FREE_TIER_MAX_API_CALLS, current },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when the tenant is on the free plan.
|
||||
* A tenant is considered free when no row exists in tenant_subscriptions,
|
||||
* or the existing row has status = 'free'.
|
||||
*
|
||||
* @param pool - PostgreSQL connection pool.
|
||||
* @param tenantId - The tenant UUID.
|
||||
* @returns True if the tenant is on the free tier.
|
||||
*/
|
||||
async function isFreeTenant(pool: Pool, tenantId: string): Promise<boolean> {
|
||||
const result = await pool.query<{ status: string }>(
|
||||
`SELECT status FROM tenant_subscriptions WHERE tenant_id = $1 LIMIT 1`,
|
||||
[tenantId],
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) return true;
|
||||
return result.rows[0].status === 'free';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the cached API call count for a tenant on a given date.
|
||||
* Returns null when the cache is cold.
|
||||
*
|
||||
* @param redis - Redis client.
|
||||
* @param tenantId - The tenant UUID.
|
||||
* @param date - Date string in YYYY-MM-DD format.
|
||||
* @returns Cached count or null.
|
||||
*/
|
||||
async function getCachedApiCalls(
|
||||
redis: RedisClientType,
|
||||
tenantId: string,
|
||||
date: string,
|
||||
): Promise<number | null> {
|
||||
const cacheKey = `billing:usage:${tenantId}:${date}`;
|
||||
const cached = await redis.get(cacheKey);
|
||||
if (cached === null) return null;
|
||||
return parseInt(cached, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the API call count to the Redis cache with a TTL.
|
||||
*
|
||||
* @param redis - Redis client.
|
||||
* @param tenantId - The tenant UUID.
|
||||
* @param date - Date string in YYYY-MM-DD format.
|
||||
* @param count - The API call count to cache.
|
||||
*/
|
||||
async function setCachedApiCalls(
|
||||
redis: RedisClientType,
|
||||
tenantId: string,
|
||||
date: string,
|
||||
count: number,
|
||||
): Promise<void> {
|
||||
const cacheKey = `billing:usage:${tenantId}:${date}`;
|
||||
await redis.set(cacheKey, String(count), { EX: CACHE_TTL_SECONDS });
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the free tier enforcement middleware.
|
||||
*
|
||||
* Behaviour:
|
||||
* - When BILLING_ENABLED env var is 'false' (string), calls next() immediately.
|
||||
* - On agent creation (POST on an agents path): checks active agent count.
|
||||
* If >= FREE_TIER_MAX_AGENTS and tenant is free → rejects with HTTP 429 FREE_TIER_AGENT_LIMIT.
|
||||
* - On every authenticated request: checks daily API call count.
|
||||
* If >= FREE_TIER_MAX_API_CALLS and tenant is free → rejects with HTTP 429 FREE_TIER_API_LIMIT.
|
||||
* - Uses Redis to cache usage lookups (TTL: 60s) to minimise DB queries.
|
||||
*
|
||||
* @param pool - PostgreSQL connection pool.
|
||||
* @param redis - Redis client.
|
||||
* @returns Express RequestHandler.
|
||||
*/
|
||||
export function createFreeTierEnforcementMiddleware(
|
||||
pool: Pool,
|
||||
redis: RedisClientType,
|
||||
): RequestHandler {
|
||||
const usageService = new UsageService(pool);
|
||||
|
||||
return (req: Request, _res: Response, next: NextFunction): void => {
|
||||
// Skip if billing is disabled
|
||||
if (process.env['BILLING_ENABLED'] === 'false') {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
// Only enforce for authenticated requests
|
||||
if (!req.user?.organization_id) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
const tenantId = req.user.organization_id;
|
||||
const today = new Date().toISOString().slice(0, 10); // YYYY-MM-DD
|
||||
|
||||
void (async (): Promise<void> => {
|
||||
try {
|
||||
const free = await isFreeTenant(pool, tenantId);
|
||||
|
||||
if (!free) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
// ── API call limit check ────────────────────────────────────────────
|
||||
let apiCalls = await getCachedApiCalls(redis, tenantId, today);
|
||||
|
||||
if (apiCalls === null) {
|
||||
const summary = await usageService.getDailyUsage(tenantId, today);
|
||||
apiCalls = summary.apiCalls;
|
||||
await setCachedApiCalls(redis, tenantId, today, apiCalls);
|
||||
}
|
||||
|
||||
if (apiCalls >= FREE_TIER_MAX_API_CALLS) {
|
||||
billingLimitRejectionsTotal.inc({ tenant_id: tenantId, limit_type: 'api_limit' });
|
||||
throw new FreeTierApiLimitError(apiCalls);
|
||||
}
|
||||
|
||||
// ── Agent creation limit check ──────────────────────────────────────
|
||||
const isAgentCreation =
|
||||
req.method === 'POST' && /\/agents(\/?)$/.test(req.path);
|
||||
|
||||
if (isAgentCreation) {
|
||||
const agentCount = await usageService.getActiveAgentCount(tenantId);
|
||||
|
||||
if (agentCount >= FREE_TIER_MAX_AGENTS) {
|
||||
billingLimitRejectionsTotal.inc({ tenant_id: tenantId, limit_type: 'agent_limit' });
|
||||
throw new FreeTierAgentLimitError(agentCount);
|
||||
}
|
||||
}
|
||||
|
||||
next();
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
})();
|
||||
};
|
||||
}
|
||||
86
src/middleware/usageMeteringMiddleware.ts
Normal file
86
src/middleware/usageMeteringMiddleware.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Usage metering middleware for SentryAgent.ai AgentIdP.
|
||||
* Tracks per-tenant API call counts in-memory and periodically flushes to the DB.
|
||||
*/
|
||||
|
||||
import { Request, Response, NextFunction, RequestHandler } from 'express';
|
||||
import { Pool } from 'pg';
|
||||
|
||||
/** In-memory counter: tenantId → accumulated API call count since last flush. */
|
||||
const tenantCounters = new Map<string, number>();
|
||||
|
||||
/** Flush interval in milliseconds. */
|
||||
const FLUSH_INTERVAL_MS = 60_000;
|
||||
|
||||
/**
|
||||
* Increments the in-memory API call counter for a tenant.
|
||||
*
|
||||
* @param tenantId - The tenant UUID.
|
||||
*/
|
||||
function incrementCounter(tenantId: string): void {
|
||||
const current = tenantCounters.get(tenantId) ?? 0;
|
||||
tenantCounters.set(tenantId, current + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Flushes all in-memory counters to the `usage_events` table via UPSERT.
|
||||
* Clears the in-memory counters after a successful flush.
|
||||
* Uses ON CONFLICT to accumulate counts rather than overwrite.
|
||||
*
|
||||
* @param pool - PostgreSQL connection pool.
|
||||
*/
|
||||
async function flushCounters(pool: Pool): Promise<void> {
|
||||
if (tenantCounters.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Snapshot and clear before async DB work to avoid double-counting on slow flushes
|
||||
const snapshot = new Map(tenantCounters);
|
||||
tenantCounters.clear();
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10); // YYYY-MM-DD
|
||||
|
||||
for (const [tenantId, count] of snapshot) {
|
||||
if (count <= 0) continue;
|
||||
|
||||
await pool.query(
|
||||
`INSERT INTO usage_events (tenant_id, date, metric_type, count)
|
||||
VALUES ($1, $2, 'api_calls', $3)
|
||||
ON CONFLICT (tenant_id, date, metric_type)
|
||||
DO UPDATE SET count = usage_events.count + EXCLUDED.count`,
|
||||
[tenantId, today, count],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the usage metering middleware.
|
||||
* On every authenticated request (where `req.user` is populated),
|
||||
* increments the in-memory counter for the tenant identified by `organizationId`.
|
||||
*
|
||||
* @param _pool - PostgreSQL connection pool (used internally by flushCounters).
|
||||
* @returns Express RequestHandler.
|
||||
*/
|
||||
export function createUsageMeteringMiddleware(_pool: Pool): RequestHandler {
|
||||
return (req: Request, _res: Response, next: NextFunction): void => {
|
||||
if (req.user?.organization_id) {
|
||||
incrementCounter(req.user.organization_id);
|
||||
}
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the periodic flush interval that writes in-memory counters to the DB.
|
||||
* Call once at application startup (after the pool is ready).
|
||||
*
|
||||
* @param pool - PostgreSQL connection pool.
|
||||
* @returns The NodeJS.Timeout handle (can be used to stop the interval in tests).
|
||||
*/
|
||||
export function startUsageMeteringFlush(pool: Pool): NodeJS.Timeout {
|
||||
return setInterval(() => {
|
||||
flushCounters(pool).catch((err: unknown) => {
|
||||
console.error('[UsageMetering] Failed to flush usage counters:', err);
|
||||
});
|
||||
}, FLUSH_INTERVAL_MS);
|
||||
}
|
||||
52
src/routes/billing.ts
Normal file
52
src/routes/billing.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Billing routes for SentryAgent.ai AgentIdP.
|
||||
* Provides Stripe checkout, webhook processing, and usage reporting endpoints.
|
||||
*/
|
||||
|
||||
import { Router, RequestHandler } from 'express';
|
||||
import express from 'express';
|
||||
import { BillingController } from '../controllers/BillingController.js';
|
||||
import { asyncHandler } from '../utils/asyncHandler.js';
|
||||
|
||||
/**
|
||||
* Creates and returns the Express router for billing endpoints.
|
||||
*
|
||||
* Routes:
|
||||
* POST /billing/checkout — authenticated; creates a Stripe Checkout Session
|
||||
* POST /billing/webhook — unauthenticated; receives Stripe webhook events (raw body)
|
||||
* GET /billing/usage — authenticated; returns today's usage summary
|
||||
*
|
||||
* @param controller - The billing controller instance.
|
||||
* @param authMiddleware - The JWT authentication middleware for protected endpoints.
|
||||
* @returns Configured Express router.
|
||||
*/
|
||||
export function createBillingRouter(
|
||||
controller: BillingController,
|
||||
authMiddleware: RequestHandler,
|
||||
): Router {
|
||||
const router = Router();
|
||||
|
||||
// POST /billing/checkout — authenticated; creates a Stripe Checkout Session
|
||||
router.post(
|
||||
'/checkout',
|
||||
authMiddleware,
|
||||
asyncHandler(controller.createCheckoutSession.bind(controller)),
|
||||
);
|
||||
|
||||
// POST /billing/webhook — unauthenticated; Stripe sends raw JSON body
|
||||
// express.raw() must be applied HERE so the body is a Buffer (required for signature verification)
|
||||
router.post(
|
||||
'/webhook',
|
||||
express.raw({ type: 'application/json' }),
|
||||
asyncHandler(controller.handleWebhook.bind(controller)),
|
||||
);
|
||||
|
||||
// GET /billing/usage — authenticated; returns usage summary for today
|
||||
router.get(
|
||||
'/usage',
|
||||
authMiddleware,
|
||||
asyncHandler(controller.getUsage.bind(controller)),
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
187
src/services/BillingService.ts
Normal file
187
src/services/BillingService.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* Billing service for SentryAgent.ai AgentIdP.
|
||||
* Manages Stripe checkout sessions, webhook processing, and subscription status.
|
||||
*/
|
||||
|
||||
import { Pool } from 'pg';
|
||||
import Stripe from 'stripe';
|
||||
|
||||
/**
|
||||
* Current subscription status for a tenant.
|
||||
*/
|
||||
export interface ISubscriptionStatus {
|
||||
/** The tenant (organization) UUID. */
|
||||
tenantId: string;
|
||||
/** Subscription status: 'free', 'active', 'past_due', 'canceled', etc. */
|
||||
status: string;
|
||||
/** End of the current billing period, or null if on free tier. */
|
||||
currentPeriodEnd: Date | null;
|
||||
/** Stripe subscription ID, or null if on free tier. */
|
||||
stripeSubscriptionId: string | null;
|
||||
}
|
||||
|
||||
/** DB row shape for tenant_subscriptions queries. */
|
||||
interface ISubscriptionRow {
|
||||
status: string;
|
||||
current_period_end: Date | null;
|
||||
stripe_subscription_id: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service for managing Stripe billing integration.
|
||||
* Handles checkout session creation, webhook event processing,
|
||||
* and subscription status retrieval.
|
||||
*/
|
||||
export class BillingService {
|
||||
/**
|
||||
* @param pool - PostgreSQL connection pool.
|
||||
* @param stripe - Configured Stripe client instance.
|
||||
*/
|
||||
constructor(
|
||||
private readonly pool: Pool,
|
||||
private readonly stripe: Stripe,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Creates a Stripe Checkout Session for a tenant to subscribe.
|
||||
* Returns the URL the tenant should be redirected to complete payment.
|
||||
*
|
||||
* @param tenantId - The tenant UUID (used as client_reference_id).
|
||||
* @param successUrl - URL to redirect to on successful checkout.
|
||||
* @param cancelUrl - URL to redirect to if checkout is cancelled.
|
||||
* @returns The Stripe Checkout Session URL.
|
||||
* @throws Error if the session URL is not returned by Stripe.
|
||||
*/
|
||||
async createCheckoutSession(
|
||||
tenantId: string,
|
||||
successUrl: string,
|
||||
cancelUrl: string,
|
||||
): Promise<string> {
|
||||
const priceId = process.env['STRIPE_PRICE_ID'];
|
||||
|
||||
const session = await this.stripe.checkout.sessions.create({
|
||||
mode: 'subscription',
|
||||
client_reference_id: tenantId,
|
||||
line_items: priceId
|
||||
? [{ price: priceId, quantity: 1 }]
|
||||
: undefined,
|
||||
success_url: successUrl,
|
||||
cancel_url: cancelUrl,
|
||||
});
|
||||
|
||||
if (!session.url) {
|
||||
throw new Error('Stripe did not return a checkout session URL.');
|
||||
}
|
||||
|
||||
return session.url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies and processes an incoming Stripe webhook event.
|
||||
* Handles subscription created, updated, and deleted events
|
||||
* by upserting the tenant_subscriptions table.
|
||||
*
|
||||
* @param rawBody - The raw request body buffer (required for signature verification).
|
||||
* @param sig - The value of the Stripe-Signature request header.
|
||||
* @param webhookSecret - The Stripe webhook endpoint secret (whsec_...).
|
||||
* @throws Error if the webhook signature is invalid.
|
||||
*/
|
||||
async handleWebhookEvent(
|
||||
rawBody: Buffer,
|
||||
sig: string,
|
||||
webhookSecret: string,
|
||||
): Promise<void> {
|
||||
const event = this.stripe.webhooks.constructEvent(rawBody, sig, webhookSecret);
|
||||
|
||||
if (
|
||||
event.type === 'customer.subscription.created' ||
|
||||
event.type === 'customer.subscription.updated' ||
|
||||
event.type === 'customer.subscription.deleted'
|
||||
) {
|
||||
const subscription = event.data.object as Stripe.Subscription;
|
||||
await this.upsertSubscription(subscription);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current subscription status for a tenant.
|
||||
* When no subscription row exists, returns a free-tier status.
|
||||
*
|
||||
* @param tenantId - The tenant UUID.
|
||||
* @returns The current ISubscriptionStatus.
|
||||
*/
|
||||
async getSubscriptionStatus(tenantId: string): Promise<ISubscriptionStatus> {
|
||||
const result = await this.pool.query<ISubscriptionRow>(
|
||||
`SELECT status, current_period_end, stripe_subscription_id
|
||||
FROM tenant_subscriptions
|
||||
WHERE tenant_id = $1
|
||||
LIMIT 1`,
|
||||
[tenantId],
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return {
|
||||
tenantId,
|
||||
status: 'free',
|
||||
currentPeriodEnd: null,
|
||||
stripeSubscriptionId: null,
|
||||
};
|
||||
}
|
||||
|
||||
const row = result.rows[0];
|
||||
return {
|
||||
tenantId,
|
||||
status: row.status,
|
||||
currentPeriodEnd: row.current_period_end ?? null,
|
||||
stripeSubscriptionId: row.stripe_subscription_id ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Upserts a Stripe subscription into tenant_subscriptions.
|
||||
* Resolves the tenant from the subscription's customer.
|
||||
* Requires the Stripe customer metadata to contain a `tenant_id` field,
|
||||
* OR a checkout session client_reference_id to have been stored.
|
||||
* Falls back to fetching the customer record to find metadata.tenant_id.
|
||||
*
|
||||
* @param subscription - The Stripe Subscription object.
|
||||
*/
|
||||
private async upsertSubscription(subscription: Stripe.Subscription): Promise<void> {
|
||||
// Fetch the customer to retrieve tenant_id from metadata
|
||||
const customerId =
|
||||
typeof subscription.customer === 'string'
|
||||
? subscription.customer
|
||||
: subscription.customer.id;
|
||||
|
||||
const customer = await this.stripe.customers.retrieve(customerId);
|
||||
if (customer.deleted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tenantId = (customer as Stripe.Customer).metadata?.['tenant_id'];
|
||||
if (!tenantId) {
|
||||
// Cannot map to a tenant — skip gracefully
|
||||
return;
|
||||
}
|
||||
|
||||
// billing_cycle_anchor gives the next billing date (used as period end proxy).
|
||||
// ended_at is populated when the subscription is canceled.
|
||||
const periodTimestamp = subscription.ended_at ?? subscription.billing_cycle_anchor;
|
||||
const currentPeriodEnd = new Date(periodTimestamp * 1000);
|
||||
const status = subscription.status;
|
||||
|
||||
await this.pool.query(
|
||||
`INSERT INTO tenant_subscriptions
|
||||
(tenant_id, status, stripe_customer_id, stripe_subscription_id, current_period_end, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, NOW())
|
||||
ON CONFLICT (tenant_id)
|
||||
DO UPDATE SET
|
||||
status = EXCLUDED.status,
|
||||
stripe_customer_id = EXCLUDED.stripe_customer_id,
|
||||
stripe_subscription_id = EXCLUDED.stripe_subscription_id,
|
||||
current_period_end = EXCLUDED.current_period_end,
|
||||
updated_at = NOW()`,
|
||||
[tenantId, status, customerId, subscription.id, currentPeriodEnd],
|
||||
);
|
||||
}
|
||||
}
|
||||
73
src/services/UsageService.ts
Normal file
73
src/services/UsageService.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Usage metering service for SentryAgent.ai AgentIdP.
|
||||
* Provides daily usage summaries and active agent counts per tenant.
|
||||
*/
|
||||
|
||||
import { Pool } from 'pg';
|
||||
|
||||
/**
|
||||
* Daily usage summary for a tenant.
|
||||
*/
|
||||
export interface IUsageSummary {
|
||||
/** The tenant (organization) UUID. */
|
||||
tenantId: string;
|
||||
/** Date string in YYYY-MM-DD format. */
|
||||
date: string;
|
||||
/** Number of API calls made on the given date. */
|
||||
apiCalls: number;
|
||||
/** Number of active (non-decommissioned) agents for the tenant. */
|
||||
agentCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service for retrieving per-tenant usage data.
|
||||
* Reads from the `usage_events` and `agents` tables.
|
||||
*/
|
||||
export class UsageService {
|
||||
/**
|
||||
* @param pool - PostgreSQL connection pool.
|
||||
*/
|
||||
constructor(private readonly pool: Pool) {}
|
||||
|
||||
/**
|
||||
* Returns the daily usage summary for a tenant on a given date.
|
||||
* If no usage row exists for the date, apiCalls defaults to 0.
|
||||
*
|
||||
* @param tenantId - The tenant UUID.
|
||||
* @param date - Date string in 'YYYY-MM-DD' format.
|
||||
* @returns A resolved IUsageSummary with api_calls and agent count.
|
||||
*/
|
||||
async getDailyUsage(tenantId: string, date: string): Promise<IUsageSummary> {
|
||||
const usageResult = await this.pool.query<{ count: string }>(
|
||||
`SELECT COALESCE(SUM(count), 0) AS count
|
||||
FROM usage_events
|
||||
WHERE tenant_id = $1
|
||||
AND date = $2
|
||||
AND metric_type = 'api_calls'`,
|
||||
[tenantId, date],
|
||||
);
|
||||
|
||||
const agentCount = await this.getActiveAgentCount(tenantId);
|
||||
const apiCalls = parseInt(usageResult.rows[0]?.count ?? '0', 10);
|
||||
|
||||
return { tenantId, date, apiCalls, agentCount };
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the number of non-decommissioned agents for a tenant.
|
||||
*
|
||||
* @param tenantId - The tenant UUID.
|
||||
* @returns The count of active agents (status != 'decommissioned').
|
||||
*/
|
||||
async getActiveAgentCount(tenantId: string): Promise<number> {
|
||||
const result = await this.pool.query<{ count: string }>(
|
||||
`SELECT COUNT(*) AS count
|
||||
FROM agents
|
||||
WHERE organization_id = $1
|
||||
AND status != 'decommissioned'`,
|
||||
[tenantId],
|
||||
);
|
||||
|
||||
return parseInt(result.rows[0]?.count ?? '0', 10);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user