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
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:
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user