feat(analytics): canonical subscription_activated revenue event ()

No PostHog event carried realized revenue, and EFT/havale activations fired
nothing at all — so total paid revenue / MRR was unmeasurable (a Stripe DWH
connector alone would also miss EFT). activateSubscription is the shared
chokepoint for both Stripe (stripe.service) and EFT/manual (billing.service)
activation, so emit one canonical subscription_activated there with PostHog
revenue props: $revenue (major TRY), currency, mrr (yearly amortised /12),
plan, plan_id, brand_count, billing_period, method (looked up from the latest
payment row), referral_credit_days. Funnel steps keep their kuruş 'amount' but
intentionally carry no $revenue, so revenue isn't double-counted.

Unblocks trial->paid, MRR/ARPU and revenue-by-plan/channel across ALL payment
methods. Injected PostHogService (PostHogModule is @Global).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-03 16:02:30 +03:00
parent c5f8288d71
commit 3bfefe6196
2 changed files with 44 additions and 3 deletions

View File

@@ -56,7 +56,8 @@ function createMockDb(overrides: Record<string, unknown> = {}) {
* Creates the service with a given mock db injected via reflection. * Creates the service with a given mock db injected via reflection.
*/ */
function createService(db: unknown): SubscriptionsService { function createService(db: unknown): SubscriptionsService {
const service = new SubscriptionsService(db as any); const posthog = { captureForUser: vi.fn(), capture: vi.fn() };
const service = new SubscriptionsService(db as any, posthog as any);
return service; return service;
} }

View File

@@ -7,11 +7,22 @@ import {
} from "@nestjs/common"; } from "@nestjs/common";
import { and, desc, eq, inArray, or } from "drizzle-orm"; import { and, desc, eq, inArray, or } from "drizzle-orm";
import { DATABASE, type Database } from "../database/database.provider"; import { DATABASE, type Database } from "../database/database.provider";
import { brands, plans, userBrands, userSubscriptions, users } from "../database/schema/core"; import {
brands,
payments,
plans,
userBrands,
userSubscriptions,
users,
} from "../database/schema/core";
import { PostHogService } from "../posthog/posthog.service";
@Injectable() @Injectable()
export class SubscriptionsService { export class SubscriptionsService {
constructor(@Inject(DATABASE) private db: Database) {} constructor(
@Inject(DATABASE) private db: Database,
private posthog: PostHogService,
) {}
/** /**
* Consumes any banked referral reward days for the user (zeroing the balance) * Consumes any banked referral reward days for the user (zeroing the balance)
@@ -152,6 +163,35 @@ export class SubscriptionsService {
} }
} }
// Canonical realized-revenue event. activateSubscription is the shared
// chokepoint for BOTH Stripe (stripe.service) and EFT/manual (billing.service)
// activation, so total paid revenue becomes measurable in PostHog regardless
// of payment method (a Stripe DWH connector alone would miss EFT/havale).
// $revenue is in major TRY (PostHog revenue convention); plan prices are kuruş.
// This is the single source of $revenue — funnel steps (payment_initiated etc.)
// intentionally do NOT carry $revenue so revenue isn't double-counted.
const priceKurus =
sub.billingPeriod === "yearly" ? (plan[0]?.priceYearly ?? 0) : (plan[0]?.priceMonthly ?? 0);
const [lastPayment] = await this.db
.select({ method: payments.method })
.from(payments)
.where(eq(payments.subscriptionId, subscriptionId))
.orderBy(desc(payments.createdAt))
.limit(1);
this.posthog.captureForUser(sub.userId, "subscription_activated", {
$revenue: priceKurus / 100,
currency: "TRY",
amount_kurus: priceKurus,
// Normalised monthly recurring revenue (yearly amortised over 12 months).
mrr: sub.billingPeriod === "yearly" ? priceKurus / 12 / 100 : priceKurus / 100,
plan: plan[0]?.name ?? null,
plan_id: sub.planId,
brand_count: plan[0]?.brandCount ?? null,
billing_period: sub.billingPeriod,
method: lastPayment?.method ?? "unknown",
referral_credit_days: creditDays,
});
return updated; return updated;
} }