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

@@ -96,6 +96,63 @@ export class MetaCapiService {
this.logger.warn(`Meta CAPI CompleteRegistration error: ${(err as Error).message}`);
}
}
/**
* Send a server-side `Purchase` — the realized-revenue signal. This is what lets
* Meta optimize toward (and build lookalikes of) accounts that actually PAY, not
* just trial — the single highest-value signal for a B2B funnel where trials are
* cheap but paid is rare. Fired from the activation chokepoint (Stripe webhook +
* EFT/manual), so all paid revenue is sent regardless of method. The webhook has
* no browser context (no fbp/fbc/ip), but hashed-email Advanced Matching still
* lets Meta attribute. event_id = purchase_<subscriptionId> dedupes a browser
* Purchase. Fail-open: never throws.
*/
async sendPurchase(input: {
userId: string;
email?: string | null;
subscriptionId: string;
valueKurus: number;
currency?: string;
}): Promise<void> {
if (!this.enabled) return;
try {
const userData: Record<string, unknown> = {};
if (input.email) userData.em = [sha256(input.email.trim().toLowerCase())];
const body = {
data: [
{
event_name: "Purchase",
event_time: Math.floor(Date.now() / 1000),
event_id: `purchase_${input.subscriptionId}`,
action_source: "website",
user_data: userData,
custom_data: {
currency: input.currency ?? "TRY",
value: input.valueKurus / 100,
},
},
],
...(this.testEventCode ? { test_event_code: this.testEventCode } : {}),
};
const res = await fetch(
`https://graph.facebook.com/${GRAPH_VERSION}/${this.pixelId}/events?access_token=${this.accessToken}`,
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
signal: AbortSignal.timeout(5000),
},
);
if (!res.ok) {
const text = await res.text().catch(() => "");
this.logger.warn(`Meta CAPI Purchase failed (${res.status}): ${text.slice(0, 300)}`);
}
} catch (err) {
this.logger.warn(`Meta CAPI Purchase error: ${(err as Error).message}`);
}
}
}
function sha256(value: string): string {

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.