admin-revenue.ts227 lines · main
| 1 | import { desc, inArray, isNull, sql } from 'drizzle-orm'; |
| 2 | |
| 3 | import { getDb } from '../db/client.js'; |
| 4 | import { organizations, projects, subscriptions, usageEvents } from '../db/schema.js'; |
| 5 | import { env } from '../env.js'; |
| 6 | |
| 7 | /** |
| 8 | * DEEP admin revenue. CONTRACT with the web revenue page. Every number comes |
| 9 | * from REAL tables (subscriptions + usage_events); MRR is null until live |
| 10 | * payments flow through Mavi Pay (Polar). meteredUsage + monthlyTimeline are |
| 11 | * the numbers that WILL bill once the payment layer is wired — this is the |
| 12 | * honest "what's about to be charged" view, not fabricated revenue. |
| 13 | */ |
| 14 | export interface AdminRevenue { |
| 15 | connected: boolean; |
| 16 | currency: 'EUR'; |
| 17 | mrr: number | null; |
| 18 | planMix: { free: number; pro: number; team: number }; |
| 19 | activeSubscriptions: Array<{ |
| 20 | orgId: string; |
| 21 | orgName: string | null; |
| 22 | tier: string; |
| 23 | status: string; |
| 24 | since: string | null; |
| 25 | currentPeriodEnd: string | null; |
| 26 | }>; |
| 27 | meteredUsage: Array<{ |
| 28 | metric: string; |
| 29 | period: string; |
| 30 | quantity: number; |
| 31 | unit: string; |
| 32 | pushStatus: string; |
| 33 | }>; |
| 34 | monthlyTimeline: Array<{ month: string; invocations: number; storageRows: number }>; |
| 35 | note: string; |
| 36 | } |
| 37 | |
| 38 | const NOTE = |
| 39 | 'live payment capture runs through Mavi Pay, not yet connected — these are the metered numbers that will bill once it is.'; |
| 40 | |
| 41 | /** Human unit label per usage metric — for the "what will bill" table. */ |
| 42 | function unitFor(metric: string): string { |
| 43 | switch (metric) { |
| 44 | case 'invocations': |
| 45 | return 'invocation'; |
| 46 | case 'storage_bytes': |
| 47 | return 'byte'; |
| 48 | case 'connection_seconds': |
| 49 | return 'second'; |
| 50 | case 'auth_mau': |
| 51 | return 'monthly-active-user'; |
| 52 | case 'auth_sso_connections': |
| 53 | return 'sso-connection'; |
| 54 | case 'auth_sso_signins': |
| 55 | return 'sso-sign-in'; |
| 56 | default: |
| 57 | return 'unit'; |
| 58 | } |
| 59 | } |
| 60 | |
| 61 | /** Mavi Pay (Polar) is "connected" only when a token AND a product are set. */ |
| 62 | function isPaymentConnected(): boolean { |
| 63 | return Boolean( |
| 64 | env.BRIVEN_POLAR_ACCESS_TOKEN && |
| 65 | (env.BRIVEN_POLAR_PRO_PRODUCT_ID || env.BRIVEN_POLAR_TEAM_PRODUCT_ID), |
| 66 | ); |
| 67 | } |
| 68 | |
| 69 | /** Live count of non-deleted projects grouped by tier. */ |
| 70 | async function planMixFromProjects(): Promise<{ free: number; pro: number; team: number }> { |
| 71 | const db = getDb(); |
| 72 | const rows = await db |
| 73 | .select({ tier: projects.tier, count: sql<number>`count(*)::int` }) |
| 74 | .from(projects) |
| 75 | .where(isNull(projects.deletedAt)) |
| 76 | .groupBy(projects.tier); |
| 77 | const mix = { free: 0, pro: 0, team: 0 }; |
| 78 | for (const r of rows) { |
| 79 | if (r.tier === 'free' || r.tier === 'pro' || r.tier === 'team') mix[r.tier] = r.count; |
| 80 | } |
| 81 | return mix; |
| 82 | } |
| 83 | |
| 84 | export async function getAdminRevenue(): Promise<AdminRevenue> { |
| 85 | const db = getDb(); |
| 86 | |
| 87 | // Active subscriptions (anything not canceled), joined to org name. |
| 88 | const subRows = await db |
| 89 | .select({ |
| 90 | orgId: subscriptions.orgId, |
| 91 | tier: subscriptions.tier, |
| 92 | status: subscriptions.status, |
| 93 | since: subscriptions.createdAt, |
| 94 | currentPeriodEnd: subscriptions.currentPeriodEnd, |
| 95 | }) |
| 96 | .from(subscriptions) |
| 97 | .orderBy(desc(subscriptions.createdAt)); |
| 98 | const activeSubs = subRows.filter((s) => s.status !== 'canceled'); |
| 99 | |
| 100 | const orgIds = [...new Set(activeSubs.map((s) => s.orgId))]; |
| 101 | const orgNameById = new Map<string, string>(); |
| 102 | if (orgIds.length > 0) { |
| 103 | const orgRows = await db |
| 104 | .select({ id: organizations.id, name: organizations.name }) |
| 105 | .from(organizations) |
| 106 | .where(inArray(organizations.id, orgIds)); |
| 107 | for (const o of orgRows) orgNameById.set(o.id, o.name); |
| 108 | } |
| 109 | |
| 110 | // Metered usage — the last 100 rows the meter-push worker tracks. This is |
| 111 | // literally "what WILL bill" once Mavi Pay is connected. |
| 112 | const usageRows = await db |
| 113 | .select({ |
| 114 | metric: usageEvents.metric, |
| 115 | periodStart: usageEvents.periodStart, |
| 116 | value: usageEvents.value, |
| 117 | pushStatus: usageEvents.polarPushStatus, |
| 118 | }) |
| 119 | .from(usageEvents) |
| 120 | .orderBy(desc(usageEvents.periodStart)) |
| 121 | .limit(100); |
| 122 | |
| 123 | const planMix = await planMixFromProjects(); |
| 124 | |
| 125 | // MRR = active paid subscriptions × their tier's published monthly price. |
| 126 | // Prices MUST match the pricing page (apps/web/.../pricing-section.tsx): |
| 127 | // free €0 · pro €29 · team €99 (approved by Jürgen 2026-06-15). €0 when |
| 128 | // there are no paid subs yet; null only when payments aren't connected. |
| 129 | const TIER_MRR_EUR: Record<string, number> = { free: 0, pro: 29, team: 99 }; |
| 130 | const mrrValue = activeSubs.reduce((sum, s) => sum + (TIER_MRR_EUR[s.tier] ?? 0), 0); |
| 131 | |
| 132 | return { |
| 133 | connected: isPaymentConnected(), |
| 134 | currency: 'EUR', |
| 135 | mrr: isPaymentConnected() ? mrrValue : null, |
| 136 | planMix, |
| 137 | // Null-tolerant mapping throughout: the LIVE table can predate the |
| 138 | // schema's notNull constraints (migrations don't run on deploy), and one |
| 139 | // legacy null must degrade to a placeholder — never crash the cockpit. |
| 140 | activeSubscriptions: activeSubs.map((s) => ({ |
| 141 | orgId: s.orgId, |
| 142 | orgName: orgNameById.get(s.orgId) ?? null, |
| 143 | tier: s.tier ?? 'free', |
| 144 | status: s.status ?? 'active', |
| 145 | since: s.since ? s.since.toISOString() : null, |
| 146 | currentPeriodEnd: s.currentPeriodEnd ? s.currentPeriodEnd.toISOString() : null, |
| 147 | })), |
| 148 | meteredUsage: usageRows.map((u) => ({ |
| 149 | metric: u.metric ?? 'unknown', |
| 150 | period: u.periodStart ? u.periodStart.toISOString() : '', |
| 151 | quantity: Number(u.value) || 0, |
| 152 | unit: unitFor(u.metric ?? ''), |
| 153 | pushStatus: u.pushStatus ?? 'pending', |
| 154 | })), |
| 155 | monthlyTimeline: await monthlyUsageTimeline(), |
| 156 | note: NOTE, |
| 157 | }; |
| 158 | } |
| 159 | |
| 160 | /** |
| 161 | * usage_events grouped by month for the last 6 months, zero-filled. |
| 162 | * invocations = SUM(value) for the 'invocations' metric; storageRows is the |
| 163 | * latest storage sample per month (storage is a gauge, so a SUM would be |
| 164 | * meaningless — we take MAX in-month as the representative figure). |
| 165 | * |
| 166 | * GOTCHA: raw drizzle sql`` templates CRASH on JS Date params under Bun — |
| 167 | * pass .toISOString() strings + ::timestamptz casts (see function-logs.ts). |
| 168 | */ |
| 169 | async function monthlyUsageTimeline(): Promise< |
| 170 | Array<{ month: string; invocations: number; storageRows: number }> |
| 171 | > { |
| 172 | const db = getDb(); |
| 173 | // Start of the month 5 months ago → 6 buckets including the current month. |
| 174 | const start = new Date(); |
| 175 | start.setUTCDate(1); |
| 176 | start.setUTCHours(0, 0, 0, 0); |
| 177 | start.setUTCMonth(start.getUTCMonth() - 5); |
| 178 | const since = start.toISOString(); |
| 179 | |
| 180 | const rows = (await db.execute(sql` |
| 181 | WITH months AS ( |
| 182 | SELECT generate_series( |
| 183 | date_trunc('month', ${since}::timestamptz), |
| 184 | date_trunc('month', now()), |
| 185 | interval '1 month' |
| 186 | ) AS month |
| 187 | ), |
| 188 | usage_by_month AS ( |
| 189 | SELECT |
| 190 | date_trunc('month', period_start) AS month, |
| 191 | coalesce(sum((value::numeric)) FILTER (WHERE metric = 'invocations'), 0) AS invocations, |
| 192 | coalesce(max((value::numeric)) FILTER (WHERE metric = 'storage_bytes'), 0) AS storage_rows |
| 193 | FROM usage_events |
| 194 | WHERE period_start >= ${since}::timestamptz |
| 195 | GROUP BY 1 |
| 196 | ) |
| 197 | SELECT |
| 198 | months.month AS month, |
| 199 | coalesce(usage_by_month.invocations, 0) AS invocations, |
| 200 | coalesce(usage_by_month.storage_rows, 0) AS storage_rows |
| 201 | FROM months |
| 202 | LEFT JOIN usage_by_month ON usage_by_month.month = months.month |
| 203 | ORDER BY months.month |
| 204 | `)) as Array<{ |
| 205 | month: string | Date; |
| 206 | invocations: number | string; |
| 207 | storage_rows: number | string; |
| 208 | }>; |
| 209 | |
| 210 | // Some drivers return the result rows directly, others wrap them in |
| 211 | // `{ rows }` — accept both, and drop any bucket whose month won't parse |
| 212 | // rather than letting toISOString() throw the whole endpoint down. |
| 213 | const list = Array.isArray(rows) |
| 214 | ? rows |
| 215 | : ((rows as unknown as { rows?: typeof rows }).rows ?? []); |
| 216 | return list.flatMap((r) => { |
| 217 | const d = r.month instanceof Date ? r.month : new Date(r.month); |
| 218 | if (Number.isNaN(d.getTime())) return []; |
| 219 | return [ |
| 220 | { |
| 221 | month: d.toISOString().slice(0, 7), |
| 222 | invocations: Number(r.invocations) || 0, |
| 223 | storageRows: Number(r.storage_rows) || 0, |
| 224 | }, |
| 225 | ]; |
| 226 | }); |
| 227 | } |