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:
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