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

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