diff --git a/apps/web/app/api/projects/route.ts b/apps/web/app/api/projects/route.ts index 3206b5d..bd9f7a9 100644 --- a/apps/web/app/api/projects/route.ts +++ b/apps/web/app/api/projects/route.ts @@ -1,4 +1,5 @@ import { FREE_TIER_LIMIT, PRO_TIER_LIMIT } from "@/lib/constants" +import { ensureMetronomeCustomer, ingestUsageEvent } from "@/lib/metronome" import { createAuthServerClient, createProject, @@ -87,6 +88,9 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: "User email not found" }, { status: 400 }) } + // Ensure a Metronome customer record exists for this user (fire-and-forget) + ensureMetronomeCustomer(user.id, email).catch(() => {}) + // Check project const { data: existingProjects, error: countError } = await supabase .from("projects") @@ -157,6 +161,12 @@ export async function POST(request: NextRequest) { console.log(`🎉 Project created: ${project.id} by user ${user.id}`) + // Ingest usage event into Metronome (fire-and-forget) + ingestUsageEvent(user.id, "project_created", { + project_id: project.id, + template_type, + }).catch(() => {}) + const response = NextResponse.json({ projectId: project.id, // Add projectId for frontend compatibility project: { diff --git a/apps/web/lib/metronome.ts b/apps/web/lib/metronome.ts new file mode 100644 index 0000000..12292b6 --- /dev/null +++ b/apps/web/lib/metronome.ts @@ -0,0 +1,88 @@ +/** + * Metronome usage-based billing API client. + * https://docs.metronome.com + * + * All functions are no-ops when METRONOME_API_KEY is not set, so the + * integration is safe to deploy before the key is provisioned. + */ + +const METRONOME_BASE = "https://api.metronome.com/v1" + +function getApiKey(): string | undefined { + return process.env.METRONOME_API_KEY +} + +/** + * Idempotently ensure a Metronome customer exists for the given Supabase user. + * Uses the Supabase user ID as an `ingest_alias` so events can be attributed + * without separately storing the Metronome customer UUID. + * + * A 409 response is treated as success (customer already exists). + */ +export async function ensureMetronomeCustomer(userId: string, email: string): Promise { + const apiKey = getApiKey() + if (!apiKey) return + + try { + const res = await fetch(`${METRONOME_BASE}/customers`, { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + name: email, + ingest_aliases: [userId], + }), + }) + + if (!res.ok && res.status !== 409) { + console.error(`Metronome: ensureCustomer failed [${res.status}]`, await res.text()) + } + } catch (err) { + console.error("Metronome: ensureCustomer error", err) + } +} + +/** + * Ingest a usage event for the given user (identified by Supabase user ID, + * which must have been registered as an ingest_alias via ensureMetronomeCustomer). + * + * Failures are logged but never thrown — billing should never block the + * primary request path. + */ +export async function ingestUsageEvent( + userId: string, + eventType: string, + properties: Record = {}, +): Promise { + const apiKey = getApiKey() + if (!apiKey) return + + try { + const res = await fetch(`${METRONOME_BASE}/ingest`, { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + events: [ + { + transaction_id: `${userId}-${eventType}-${Date.now()}`, + customer_id: userId, + event_type: eventType, + timestamp: new Date().toISOString(), + properties, + }, + ], + }), + }) + + if (!res.ok) { + console.error(`Metronome: ingest failed [${res.status}] for ${eventType}`, await res.text()) + } + } catch (err) { + console.error(`Metronome: ingest error for ${eventType}`, err) + } +} diff --git a/env.example b/env.example index 5e07bcd..a264639 100644 --- a/env.example +++ b/env.example @@ -38,6 +38,11 @@ NETLIFY_ACCESS_TOKEN=your-netlify-access-token # 21st.dev Subscription API (for subscription checks) SUBSCRIPTION_API_KEY=your-21st-dev-api-key +# Metronome Usage-Based Billing (https://metronome.com) +# Tracks usage events (project_created, etc.) per customer. +# Leave unset to disable Metronome integration. +METRONOME_API_KEY=your-metronome-api-key + # Development: Skip subscription check (set to 'true' to bypass subscription validation in dev) SKIP_SUBSCRIPTION_CHECK=false