auth-sso-pricing.ts110 lines · main
1/**
2 * Phase 5.7 — per-connection SSO pricing hooks.
3 *
4 * Emits usage_events so Polar (when meters are configured) can bill
5 * enterprise SSO: active connection count (gauge) + sign-in volume (delta).
6 * Never throws into the SSO sign-in path — fire-and-forget from callers.
7 */
8
9import { sql } from 'drizzle-orm';
10
11import { newId } from '@briven/shared';
12
13import { getDb } from '../db/client.js';
14import { usageEvents, type UsageMetric } from '../db/schema.js';
15import { log } from '../lib/logger.js';
16import { runInProjectDatabase } from '../db/data-plane.js';
17
18/** UTC hour bucket start for usage_events.period_start. */
19export function currentUtcHourStart(now: Date = new Date()): Date {
20 return new Date(
21 Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), now.getUTCHours(), 0, 0, 0),
22 );
23}
24
25/**
26 * Increment the hourly SSO sign-in counter for a project (after a successful
27 * IdP login). Safe to call with await void; swallows errors.
28 */
29export async function recordSsoSignInUsage(
30 projectId: string,
31 connectionId: string,
32): Promise<void> {
33 try {
34 const periodStart = currentUtcHourStart();
35 const db = getDb();
36 await db
37 .insert(usageEvents)
38 .values({
39 id: newId('au'),
40 projectId,
41 metric: 'auth_sso_signins' as UsageMetric,
42 periodStart,
43 value: '1',
44 polarPushStatus: 'pending',
45 })
46 .onConflictDoUpdate({
47 target: [usageEvents.projectId, usageEvents.periodStart, usageEvents.metric],
48 set: {
49 value: sql`(${usageEvents.value}::bigint + 1)::text`,
50 polarPushStatus: 'pending',
51 polarPushedAt: null,
52 },
53 });
54 log.info('auth_sso_signin_metered', { projectId, connectionId });
55 } catch (err) {
56 log.warn('auth_sso_signin_meter_failed', {
57 projectId,
58 connectionId,
59 message: err instanceof Error ? err.message : String(err),
60 });
61 }
62}
63
64/**
65 * Snapshot how many active (non-deactivated) SSO connections a project has.
66 * Called from the hourly usage aggregator and after connection create/delete.
67 */
68export async function countActiveSsoConnections(projectId: string): Promise<number> {
69 return runInProjectDatabase(projectId, async (tx) => {
70 const rows = (await tx.unsafe(
71 `SELECT count(*)::int AS c FROM "_briven_auth_sso_connections" WHERE deactivated_at IS NULL`,
72 )) as Array<{ c: number }>;
73 return Number(rows[0]?.c ?? 0);
74 });
75}
76
77export async function recordSsoConnectionGauge(
78 projectId: string,
79 count?: number,
80): Promise<void> {
81 try {
82 const value = count ?? (await countActiveSsoConnections(projectId));
83 const periodStart = currentUtcHourStart();
84 const db = getDb();
85 await db
86 .insert(usageEvents)
87 .values({
88 id: newId('au'),
89 projectId,
90 metric: 'auth_sso_connections' as UsageMetric,
91 periodStart,
92 value: String(value),
93 polarPushStatus: 'pending',
94 })
95 .onConflictDoUpdate({
96 target: [usageEvents.projectId, usageEvents.periodStart, usageEvents.metric],
97 set: {
98 value: String(value),
99 polarPushStatus: 'pending',
100 polarPushedAt: null,
101 },
102 });
103 log.info('auth_sso_connections_metered', { projectId, count: value });
104 } catch (err) {
105 log.warn('auth_sso_connections_meter_failed', {
106 projectId,
107 message: err instanceof Error ? err.message : String(err),
108 });
109 }
110}