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:
SentryAgent.ai Developer
2026-04-02 10:51:36 +00:00
parent fefbf1e3ea
commit 26a56f84e1
18 changed files with 1647 additions and 17 deletions

View File

@@ -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.

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

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

View File

@@ -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],
});

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

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

View 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],
);
}
}

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