feat(capi): send Meta Conversions API Purchase on subscription activation
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled

The existing CAPI only sent CompleteRegistration (signup). For a B2B funnel where
trials are cheap (~25 TRY) but paid is rare (~1% of trials), the highest-value
signal Meta can optimize on is the realized-revenue Purchase. Add
MetaCapiService.sendPurchase and fire it from activateSubscription — the shared
chokepoint for BOTH Stripe (webhook) and EFT/manual activation — so all paid
revenue is sent regardless of method. Hashed-email Advanced Matching (no browser
fbp/fbc in the webhook); event_id = purchase_<subscriptionId> dedupes a browser
Purchase. Awaited so it ships before the short request returns; fail-open.

This is the "teach Meta to find payers, not end-users" lever from adsOpt.md Phase 0.
Still gated on activating CAPI in prod (merge + META_CAPI_PIXEL_ID/ACCESS_TOKEN env).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-09 17:59:37 +03:00
parent 42f8036b22
commit 8e732628bc
3 changed files with 83 additions and 2 deletions

View File

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

View File

@@ -15,6 +15,7 @@ import {
userSubscriptions,
users,
} from "../database/schema/core";
import { MetaCapiService } from "../meta-capi/meta-capi.service";
import { PostHogService } from "../posthog/posthog.service";
@Injectable()
@@ -22,6 +23,7 @@ export class SubscriptionsService {
constructor(
@Inject(DATABASE) private db: Database,
private posthog: PostHogService,
private metaCapi: MetaCapiService,
) {}
/**
@@ -192,6 +194,23 @@ export class SubscriptionsService {
referral_credit_days: creditDays,
});
// Realized-revenue signal to Meta (server-side Purchase) so the ad algorithm
// optimizes toward — and builds lookalikes of — accounts that actually PAY,
// not just trial. Shared chokepoint → covers Stripe + EFT. Awaited so the
// event ships before the short webhook/request returns. Fail-open inside.
const [capiUser] = await this.db
.select({ email: users.email })
.from(users)
.where(eq(users.id, sub.userId))
.limit(1);
await this.metaCapi.sendPurchase({
userId: sub.userId,
email: capiUser?.email,
subscriptionId,
valueKurus: priceKurus,
currency: "TRY",
});
// Await delivery: activateSubscription is the shared chokepoint for Stripe
// (webhook) AND EFT/manual activation, both of which run in short requests
// whose fire-and-forget flush was dropping this revenue event.