diff --git a/src/lib/kiloclaw/stripe-handlers.ts b/src/lib/kiloclaw/stripe-handlers.ts index d55e020d6..5224ef2c1 100644 --- a/src/lib/kiloclaw/stripe-handlers.ts +++ b/src/lib/kiloclaw/stripe-handlers.ts @@ -9,11 +9,15 @@ import { kiloclaw_subscriptions, kiloclaw_instances, kiloclaw_email_log, + kilocode_users, } from '@kilocode/db/schema'; import type { KiloClawSubscriptionStatus } from '@kilocode/db/schema-types'; import { getClawPlanForStripePriceId } from '@/lib/kiloclaw/stripe-price-ids.server'; import { sentryLogger } from '@/lib/utils.server'; import { KiloClawInternalClient } from '@/lib/kiloclaw/kiloclaw-internal-client'; +import PostHogClient from '@/lib/posthog'; +import { after } from 'next/server'; +import { IS_IN_AUTOMATED_TEST } from '@/lib/config.server'; const logInfo = sentryLogger('kiloclaw-stripe', 'info'); const logWarning = sentryLogger('kiloclaw-stripe', 'warning'); @@ -485,3 +489,58 @@ export async function handleKiloClawScheduleEvent(params: { user_id: row.user_id, }); } + +/** + * Handle invoice.paid for KiloClaw subscriptions. + * Fires a claw_transaction PostHog event for revenue tracking. + */ +export function handleKiloClawInvoicePaid(params: { + eventId: string; + invoice: Stripe.Invoice; +}): void { + const { eventId, invoice } = params; + const subDetails = invoice.parent?.subscription_details; + const kiloUserId = subDetails?.metadata?.kiloUserId ?? null; + const plan = subDetails?.metadata?.plan ?? null; + const stripeSubscriptionId = + typeof subDetails?.subscription === 'string' ? subDetails.subscription : null; + + if (!kiloUserId) { + logWarning('KiloClaw invoice.paid missing kiloUserId in subscription metadata', { + stripe_event_id: eventId, + stripe_invoice_id: invoice.id, + }); + return; + } + + if (IS_IN_AUTOMATED_TEST) return; + + after(async () => { + const [user] = await db + .select({ email: kilocode_users.google_user_email }) + .from(kilocode_users) + .where(eq(kilocode_users.id, kiloUserId)) + .limit(1); + + if (!user) { + logWarning('KiloClaw invoice.paid user not found', { + stripe_event_id: eventId, + kilo_user_id: kiloUserId, + }); + return; + } + + PostHogClient().capture({ + distinctId: user.email, + event: 'claw_transaction', + properties: { + user_id: kiloUserId, + plan: plan ?? 'unknown', + amount_cents: invoice.amount_paid, + currency: invoice.currency, + stripe_invoice_id: invoice.id, + stripe_subscription_id: stripeSubscriptionId, + }, + }); + }); +} diff --git a/src/lib/stripe.ts b/src/lib/stripe.ts index 825d1ea38..9ee748932 100644 --- a/src/lib/stripe.ts +++ b/src/lib/stripe.ts @@ -52,7 +52,9 @@ import { handleKiloClawSubscriptionUpdated, handleKiloClawSubscriptionDeleted, handleKiloClawScheduleEvent, + handleKiloClawInvoicePaid, } from '@/lib/kiloclaw/stripe-handlers'; +import { invoiceLooksLikeKiloClawByPriceId } from '@/lib/kiloclaw/stripe-invoice-classifier.server'; import { STRIPE_ENTERPRISE_SUBSCRIPTION_PRODUCT_ID, STRIPE_TEAMS_SUBSCRIPTION_PRODUCT_ID, @@ -627,6 +629,12 @@ export async function processStripePaymentEventHook(event: Stripe.Event) { break; } + // KiloClaw subscription invoice — fire claw_transaction for revenue tracking. + if (invoiceLooksLikeKiloClawByPriceId(invoice) && invoice.amount_paid > 0) { + handleKiloClawInvoicePaid({ eventId: event.id, invoice }); + break; + } + // Handle auto-topup (user or organization) const isUserAutoTopup = invoice.metadata?.type === 'auto-topup'; const isOrgAutoTopup = invoice.metadata?.type === 'org-auto-topup'; diff --git a/src/routers/kiloclaw-router.ts b/src/routers/kiloclaw-router.ts index 62182834d..999a19358 100644 --- a/src/routers/kiloclaw-router.ts +++ b/src/routers/kiloclaw-router.ts @@ -47,6 +47,7 @@ import { KILOCLAW_TRIAL_DURATION_DAYS, } from '@/lib/kiloclaw/constants'; import type { ClawBillingStatus } from '@/app/(app)/claw/components/billing/billing-types'; +import PostHogClient from '@/lib/posthog'; /** * Error codes whose messages may contain raw internal details (e.g. filesystem @@ -306,7 +307,7 @@ async function fetchKiloClawServiceDegraded(): Promise { * Earlybird is checked first so earlybird purchasers never get an accidental * trial row, and expired earlybird users cannot regain access by provisioning. */ -async function ensureProvisionAccess(userId: string): Promise { +async function ensureProvisionAccess(userId: string, userEmail: string): Promise { // Check earlybird before anything else — active earlybird grants access, // expired earlybird must not fall through to the trial bootstrap. const [earlybird] = await db @@ -336,7 +337,7 @@ async function ensureProvisionAccess(userId: string): Promise { // don't fail on the unique user_id constraint. const now = new Date(); const trialEndsAt = new Date(now.getTime() + KILOCLAW_TRIAL_DURATION_DAYS * 86_400_000); - await db + const [inserted] = await db .insert(kiloclaw_subscriptions) .values({ user_id: userId, @@ -345,7 +346,20 @@ async function ensureProvisionAccess(userId: string): Promise { trial_started_at: now.toISOString(), trial_ends_at: trialEndsAt.toISOString(), }) - .onConflictDoNothing({ target: kiloclaw_subscriptions.user_id }); + .onConflictDoNothing({ target: kiloclaw_subscriptions.user_id }) + .returning({ id: kiloclaw_subscriptions.id }); + + if (inserted) { + PostHogClient().capture({ + distinctId: userEmail, + event: 'claw_trial_started', + properties: { + user_id: userId, + plan: 'trial', + trial_ends_at: trialEndsAt.toISOString(), + }, + }); + } return; } @@ -435,7 +449,7 @@ export const kiloclawRouter = createTRPCRouter({ // Explicit lifecycle APIs provision: baseProcedure.input(updateConfigSchema).mutation(async ({ ctx, input }) => { - await ensureProvisionAccess(ctx.user.id); + await ensureProvisionAccess(ctx.user.id, ctx.user.google_user_email); return provisionInstance(ctx.user, input); }), @@ -448,7 +462,7 @@ export const kiloclawRouter = createTRPCRouter({ // Backward-compatible alias — uses the same trial-bootstrap flow as provision // so first-time callers can create a trial row (clawAccessProcedure would reject them). updateConfig: baseProcedure.input(updateConfigSchema).mutation(async ({ ctx, input }) => { - await ensureProvisionAccess(ctx.user.id); + await ensureProvisionAccess(ctx.user.id, ctx.user.google_user_email); return provisionInstance(ctx.user, input); }),