Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions src/lib/kiloclaw/stripe-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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,
},
});
});
}
8 changes: 8 additions & 0 deletions src/lib/stripe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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';
Expand Down
24 changes: 19 additions & 5 deletions src/routers/kiloclaw-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -306,7 +307,7 @@ async function fetchKiloClawServiceDegraded(): Promise<boolean> {
* 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<void> {
async function ensureProvisionAccess(userId: string, userEmail: string): Promise<void> {
// Check earlybird before anything else — active earlybird grants access,
// expired earlybird must not fall through to the trial bootstrap.
const [earlybird] = await db
Expand Down Expand Up @@ -336,7 +337,7 @@ async function ensureProvisionAccess(userId: string): Promise<void> {
// 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,
Expand All @@ -345,7 +346,20 @@ async function ensureProvisionAccess(userId: string): Promise<void> {
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;
}

Expand Down Expand Up @@ -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);
}),

Expand All @@ -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);
}),

Expand Down
Loading