Add Dub referral conversion tracking (leads + sales) (#531)
This commit is contained in:
parent
7a611a7bad
commit
ac7ebfe13a
@ -32,6 +32,11 @@ DATABASE_PROVIDER=postgres
|
|||||||
# Billing, analytics, agents.
|
# Billing, analytics, agents.
|
||||||
# AUTUMN_SECRET_KEY=
|
# AUTUMN_SECRET_KEY=
|
||||||
# AUTUMN_WEBHOOK_SECRET=
|
# AUTUMN_WEBHOOK_SECRET=
|
||||||
|
# Dub referral conversion tracking (leads + sales). Also requires, in the Dub
|
||||||
|
# dashboard: openseo.so allowlisted under workspace Tracking settings, partner
|
||||||
|
# link destinations kept on openseo.so, and the native Stripe integration
|
||||||
|
# disconnected (sales are reported by the app; both would double count).
|
||||||
|
# DUB_API_KEY=
|
||||||
# Long random secret shared only with scripts/erase-user-data.ts.
|
# Long random secret shared only with scripts/erase-user-data.ts.
|
||||||
# GDPR_ERASURE_SECRET=
|
# GDPR_ERASURE_SECRET=
|
||||||
# POSTHOG_PUBLIC_KEY=
|
# POSTHOG_PUBLIC_KEY=
|
||||||
|
|||||||
@ -271,6 +271,7 @@ const dataEnv = {
|
|||||||
OPENROUTER_MODEL: optionalVar("OPENROUTER_MODEL"),
|
OPENROUTER_MODEL: optionalVar("OPENROUTER_MODEL"),
|
||||||
AUTUMN_SECRET_KEY: optionalSecret("AUTUMN_SECRET_KEY"),
|
AUTUMN_SECRET_KEY: optionalSecret("AUTUMN_SECRET_KEY"),
|
||||||
AUTUMN_WEBHOOK_SECRET: optionalSecret("AUTUMN_WEBHOOK_SECRET"),
|
AUTUMN_WEBHOOK_SECRET: optionalSecret("AUTUMN_WEBHOOK_SECRET"),
|
||||||
|
DUB_API_KEY: optionalSecret("DUB_API_KEY"),
|
||||||
GDPR_ERASURE_SECRET: optionalSecret("GDPR_ERASURE_SECRET"),
|
GDPR_ERASURE_SECRET: optionalSecret("GDPR_ERASURE_SECRET"),
|
||||||
LOOPS_API_KEY: optionalSecret("LOOPS_API_KEY"),
|
LOOPS_API_KEY: optionalSecret("LOOPS_API_KEY"),
|
||||||
LOOPS_TRANSACTIONAL_VERIFY_EMAIL_ID: optionalVar(
|
LOOPS_TRANSACTIONAL_VERIFY_EMAIL_ID: optionalVar(
|
||||||
|
|||||||
3
src/env.d.ts
vendored
3
src/env.d.ts
vendored
@ -35,6 +35,9 @@ declare namespace Cloudflare {
|
|||||||
LOOPS_TRANSACTIONAL_RESET_PASSWORD_ID?: string;
|
LOOPS_TRANSACTIONAL_RESET_PASSWORD_ID?: string;
|
||||||
AUTUMN_SECRET_KEY?: string;
|
AUTUMN_SECRET_KEY?: string;
|
||||||
AUTUMN_WEBHOOK_SECRET?: string;
|
AUTUMN_WEBHOOK_SECRET?: string;
|
||||||
|
// Dub referral conversion tracking (hosted only); all Dub code no-ops
|
||||||
|
// when unset.
|
||||||
|
DUB_API_KEY?: string;
|
||||||
// HMAC secret for the operator-only GDPR storage-erasure endpoint.
|
// HMAC secret for the operator-only GDPR storage-erasure endpoint.
|
||||||
GDPR_ERASURE_SECRET?: string;
|
GDPR_ERASURE_SECRET?: string;
|
||||||
|
|
||||||
|
|||||||
@ -19,6 +19,7 @@ import {
|
|||||||
hasHostedTurnstileConfig,
|
hasHostedTurnstileConfig,
|
||||||
} from "@/lib/auth-turnstile";
|
} from "@/lib/auth-turnstile";
|
||||||
import { getOrCreateDefaultHostedOrganization } from "@/server/auth/default-hosted-organization";
|
import { getOrCreateDefaultHostedOrganization } from "@/server/auth/default-hosted-organization";
|
||||||
|
import { captureDubReferralSignup } from "@/server/referrals/dub";
|
||||||
import {
|
import {
|
||||||
sendHostedPasswordResetEmail,
|
sendHostedPasswordResetEmail,
|
||||||
sendHostedVerificationEmail,
|
sendHostedVerificationEmail,
|
||||||
@ -126,8 +127,11 @@ function createAuth() {
|
|||||||
}
|
}
|
||||||
return { data: user };
|
return { data: user };
|
||||||
},
|
},
|
||||||
after: async (user) => {
|
after: async (user, ctx) => {
|
||||||
await syncHostedSignupContact(user);
|
await syncHostedSignupContact(user);
|
||||||
|
if (isHostedAuthMode(env.AUTH_MODE)) {
|
||||||
|
await captureDubReferralSignup(user.id, ctx?.request);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@ -23,6 +23,7 @@ import {
|
|||||||
AUTUMN_WEBHOOK_PATH,
|
AUTUMN_WEBHOOK_PATH,
|
||||||
handleAutumnWebhookRequest,
|
handleAutumnWebhookRequest,
|
||||||
} from "@/server/billing/autumn-webhook";
|
} from "@/server/billing/autumn-webhook";
|
||||||
|
import { sweepDubReferredOrganizations } from "@/server/referrals/dub";
|
||||||
import { maybeSendSelfHostHeartbeat } from "@/server/lib/self-host-telemetry";
|
import { maybeSendSelfHostHeartbeat } from "@/server/lib/self-host-telemetry";
|
||||||
import { handleGdprStorageErasure } from "@/server/gdpr/storage-erasure";
|
import { handleGdprStorageErasure } from "@/server/gdpr/storage-erasure";
|
||||||
import { GDPR_STORAGE_ERASURE_PATH } from "@/shared/gdpr-erasure";
|
import { GDPR_STORAGE_ERASURE_PATH } from "@/shared/gdpr-erasure";
|
||||||
@ -209,6 +210,14 @@ export default {
|
|||||||
// persistent incomplete scan means the keyspace outgrew the batch.
|
// persistent incomplete scan means the keyspace outgrew the batch.
|
||||||
console.warn("[mcp-oauth] purge did not cover the full keyspace");
|
console.warn("[mcp-oauth] purge did not cover the full keyspace");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Daily referral-sale sweep: catches paid Autumn invoices the
|
||||||
|
// billing.updated webhook path misses (renewals, one-time top-ups).
|
||||||
|
try {
|
||||||
|
await sweepDubReferredOrganizations();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[cron] Dub referral sale sweep failed:", err);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { AuthRepository } from "@/server/auth/repositories/AuthRepository";
|
import { AuthRepository } from "@/server/auth/repositories/AuthRepository";
|
||||||
|
import { markDubReferredOrganization } from "@/server/referrals/dub";
|
||||||
import { slugify, toHex } from "./org-slug";
|
import { slugify, toHex } from "./org-slug";
|
||||||
|
|
||||||
type HostedUser = {
|
type HostedUser = {
|
||||||
@ -68,13 +69,23 @@ export async function getOrCreateDefaultHostedOrganization(
|
|||||||
userId: string,
|
userId: string,
|
||||||
createOrganization: HostedOrganizationCreator,
|
createOrganization: HostedOrganizationCreator,
|
||||||
) {
|
) {
|
||||||
const existingOrganizationId =
|
let organizationId =
|
||||||
await AuthRepository.findFirstOrganizationIdForUser(userId);
|
await AuthRepository.findFirstOrganizationIdForUser(userId);
|
||||||
|
|
||||||
if (existingOrganizationId) {
|
if (!organizationId) {
|
||||||
return existingOrganizationId;
|
const hostedUser = await getHostedUser(userId);
|
||||||
|
organizationId = await createDefaultHostedOrganization(
|
||||||
|
hostedUser,
|
||||||
|
createOrganization,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const hostedUser = await getHostedUser(userId);
|
// On every session, not just org creation: the signup-time referral pin can
|
||||||
return createDefaultHostedOrganization(hostedUser, createOrganization);
|
// land after the org exists (email verification from another location, or
|
||||||
|
// BYPASS_EMAIL_VERIFICATION creating the session inside the signup
|
||||||
|
// transaction before user.create.after hooks flush), so later logins repair
|
||||||
|
// the org pin. No-ops without a user pin.
|
||||||
|
await markDubReferredOrganization(userId);
|
||||||
|
|
||||||
|
return organizationId;
|
||||||
}
|
}
|
||||||
|
|||||||
113
src/server/auth/repositories/AuthRepository.query.test.ts
Normal file
113
src/server/auth/repositories/AuthRepository.query.test.ts
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
import { createClient, type Client } from "@libsql/client";
|
||||||
|
import { drizzle } from "drizzle-orm/libsql";
|
||||||
|
import {
|
||||||
|
afterAll,
|
||||||
|
beforeAll,
|
||||||
|
beforeEach,
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it,
|
||||||
|
vi,
|
||||||
|
} from "vitest";
|
||||||
|
import type * as AuthRepositoryModule from "./AuthRepository";
|
||||||
|
|
||||||
|
// Real in-memory SQLite: findFirstFoundedOrganizationIdForUser's NOT EXISTS
|
||||||
|
// subquery is the referral-abuse gate (an invitee promoted to owner must not
|
||||||
|
// count as founding the org), which a mocked builder chain can't verify.
|
||||||
|
|
||||||
|
vi.mock("cloudflare:workers", () => ({ env: { DATABASE_PROVIDER: "d1" } }));
|
||||||
|
|
||||||
|
let client: Client;
|
||||||
|
let AuthRepository: typeof AuthRepositoryModule.AuthRepository;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
client = createClient({ url: "file::memory:" });
|
||||||
|
const testDb = drizzle(client);
|
||||||
|
// testDb only exists at runtime, so the module under test must load after
|
||||||
|
// these mocks — the one sanctioned use of doMock + dynamic import.
|
||||||
|
vi.doMock("@/db", () => ({ db: testDb }));
|
||||||
|
vi.doMock("@/db/d1/client", () => ({ d1Db: testDb }));
|
||||||
|
vi.doMock("@/db/pg/client", () => ({ pgDb: null }));
|
||||||
|
|
||||||
|
await client.executeMultiple(`
|
||||||
|
CREATE TABLE organization (id TEXT PRIMARY KEY, name TEXT NOT NULL, slug TEXT, logo TEXT, created_at INTEGER, metadata TEXT);
|
||||||
|
CREATE TABLE user (id TEXT PRIMARY KEY, name TEXT, email TEXT);
|
||||||
|
CREATE TABLE member (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
organization_id TEXT NOT NULL,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL DEFAULT 'member',
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
({ AuthRepository } = await import("./AuthRepository"));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
client.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await client.execute("DELETE FROM member");
|
||||||
|
});
|
||||||
|
|
||||||
|
async function insertMember(input: {
|
||||||
|
organizationId: string;
|
||||||
|
userId: string;
|
||||||
|
role: string;
|
||||||
|
createdAt: number;
|
||||||
|
}) {
|
||||||
|
await client.execute({
|
||||||
|
sql: "INSERT INTO member (id, organization_id, user_id, role, created_at) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
args: [
|
||||||
|
`${input.organizationId}:${input.userId}`,
|
||||||
|
input.organizationId,
|
||||||
|
input.userId,
|
||||||
|
input.role,
|
||||||
|
input.createdAt,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("findFirstFoundedOrganizationIdForUser", () => {
|
||||||
|
it("returns the org where the user is the founding owner", async () => {
|
||||||
|
await insertMember({
|
||||||
|
organizationId: "org_own",
|
||||||
|
userId: "u1",
|
||||||
|
role: "owner",
|
||||||
|
createdAt: 1000,
|
||||||
|
});
|
||||||
|
await insertMember({
|
||||||
|
organizationId: "org_own",
|
||||||
|
userId: "u2",
|
||||||
|
role: "member",
|
||||||
|
createdAt: 2000,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await AuthRepository.findFirstFoundedOrganizationIdForUser("u1"),
|
||||||
|
).toBe("org_own");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("excludes an org the user joined by invite and was later promoted to owner in", async () => {
|
||||||
|
await insertMember({
|
||||||
|
organizationId: "org_theirs",
|
||||||
|
userId: "founder",
|
||||||
|
role: "owner",
|
||||||
|
createdAt: 1000,
|
||||||
|
});
|
||||||
|
await insertMember({
|
||||||
|
organizationId: "org_theirs",
|
||||||
|
userId: "promoted_invitee",
|
||||||
|
role: "owner",
|
||||||
|
createdAt: 2000,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await AuthRepository.findFirstFoundedOrganizationIdForUser(
|
||||||
|
"promoted_invitee",
|
||||||
|
),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { asc, eq } from "drizzle-orm";
|
import { aliasedTable, and, asc, eq, lt, notExists } from "drizzle-orm";
|
||||||
import { db } from "@/db";
|
import { db } from "@/db";
|
||||||
import { member, organization, user as authUser } from "@/db/schema";
|
import { member, organization, user as authUser } from "@/db/schema";
|
||||||
|
|
||||||
@ -39,6 +39,36 @@ async function findFirstOrganizationIdForUser(userId: string) {
|
|||||||
return existingMembership?.organizationId ?? null;
|
return existingMembership?.organizationId ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Founded = the user is the org's earliest member (its creator), not merely
|
||||||
|
// an owner: a later-promoted invitee must never count as founding an org.
|
||||||
|
async function findFirstFoundedOrganizationIdForUser(userId: string) {
|
||||||
|
const earlierMember = aliasedTable(member, "earlier_member");
|
||||||
|
const [foundedMembership] = await db
|
||||||
|
.select({ organizationId: member.organizationId })
|
||||||
|
.from(member)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(member.userId, userId),
|
||||||
|
eq(member.role, "owner"),
|
||||||
|
notExists(
|
||||||
|
db
|
||||||
|
.select({ id: earlierMember.id })
|
||||||
|
.from(earlierMember)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(earlierMember.organizationId, member.organizationId),
|
||||||
|
lt(earlierMember.createdAt, member.createdAt),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.orderBy(asc(member.createdAt))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
return foundedMembership?.organizationId ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
async function getHostedUser(userId: string) {
|
async function getHostedUser(userId: string) {
|
||||||
return db.query.user.findFirst({
|
return db.query.user.findFirst({
|
||||||
columns: {
|
columns: {
|
||||||
@ -53,5 +83,6 @@ async function getHostedUser(userId: string) {
|
|||||||
export const AuthRepository = {
|
export const AuthRepository = {
|
||||||
upsertDelegatedOrganization,
|
upsertDelegatedOrganization,
|
||||||
findFirstOrganizationIdForUser,
|
findFirstOrganizationIdForUser,
|
||||||
|
findFirstFoundedOrganizationIdForUser,
|
||||||
getHostedUser,
|
getHostedUser,
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { getRequiredEnvValue } from "@/server/lib/runtime-env";
|
import { getRequiredEnvValue } from "@/server/lib/runtime-env";
|
||||||
import { captureServerError } from "@/server/lib/posthog";
|
import { captureServerError } from "@/server/lib/posthog";
|
||||||
|
import { trackDubSalesForOrganization } from "@/server/referrals/dub";
|
||||||
import { syncAutumnCustomerStatus } from "./customer-status-sync";
|
import { syncAutumnCustomerStatus } from "./customer-status-sync";
|
||||||
import { verifySvixSignature } from "./svix";
|
import { verifySvixSignature } from "./svix";
|
||||||
|
|
||||||
@ -67,6 +68,10 @@ export async function handleAutumnWebhookRequest(request: Request) {
|
|||||||
});
|
});
|
||||||
return json({ error: "Webhook processing failed" }, 500);
|
return json({ error: "Webhook processing failed" }, 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Referral revenue attribution; internally fire-and-forget and can never
|
||||||
|
// fail the webhook response.
|
||||||
|
await trackDubSalesForOrganization(customerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
return json({ received: true });
|
return json({ received: true });
|
||||||
|
|||||||
@ -46,6 +46,8 @@ export const autumn = {
|
|||||||
customers: {
|
customers: {
|
||||||
getOrCreate: (...args: Parameters<Autumn["customers"]["getOrCreate"]>) =>
|
getOrCreate: (...args: Parameters<Autumn["customers"]["getOrCreate"]>) =>
|
||||||
loadAutumn().then((client) => client.customers.getOrCreate(...args)),
|
loadAutumn().then((client) => client.customers.getOrCreate(...args)),
|
||||||
|
get: (...args: Parameters<Autumn["customers"]["get"]>) =>
|
||||||
|
loadAutumn().then((client) => client.customers.get(...args)),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -3,6 +3,10 @@ import { getAuditScratchpad } from "@/server/features/audit/AuditScratchpad";
|
|||||||
import type { OnboardingChatAgent } from "@/server/features/onboarding/OnboardingChatAgent";
|
import type { OnboardingChatAgent } from "@/server/features/onboarding/OnboardingChatAgent";
|
||||||
import type { SamChatAgent } from "@/server/features/sam/SamChatAgent";
|
import type { SamChatAgent } from "@/server/features/sam/SamChatAgent";
|
||||||
import { captureServerError } from "@/server/lib/posthog";
|
import { captureServerError } from "@/server/lib/posthog";
|
||||||
|
import {
|
||||||
|
DUB_REFERRED_ORG_KV_PREFIX,
|
||||||
|
DUB_REFERRED_USER_KV_PREFIX,
|
||||||
|
} from "@/server/referrals/dub";
|
||||||
import {
|
import {
|
||||||
AI_SEARCH_PROMPT_CACHE_NAMESPACE,
|
AI_SEARCH_PROMPT_CACHE_NAMESPACE,
|
||||||
cacheObjectPrefix,
|
cacheObjectPrefix,
|
||||||
@ -211,6 +215,14 @@ async function eraseStorage(env: Env, payload: GdprStorageErasurePayload) {
|
|||||||
// TTL, and clearing them would let an in-flight authenticated request
|
// TTL, and clearing them would let an in-flight authenticated request
|
||||||
// re-create the Autumn customer before the Postgres delete lands.
|
// re-create the Autumn customer before the Postgres delete lands.
|
||||||
|
|
||||||
|
// Dub referral pins key on user/org ids and store a referral click
|
||||||
|
// identifier. The Dub-side customer record (pseudonymous external id) is
|
||||||
|
// removed via the erasure runbook.
|
||||||
|
await env.KV.delete(`${DUB_REFERRED_USER_KV_PREFIX}${payload.userId}`);
|
||||||
|
for (const organizationId of payload.organizationIds) {
|
||||||
|
await env.KV.delete(`${DUB_REFERRED_ORG_KV_PREFIX}${organizationId}`);
|
||||||
|
}
|
||||||
|
|
||||||
for (let index = 0; index < payload.r2Keys.length; index += 1_000) {
|
for (let index = 0; index < payload.r2Keys.length; index += 1_000) {
|
||||||
await env.R2.delete(payload.r2Keys.slice(index, index + 1_000));
|
await env.R2.delete(payload.r2Keys.slice(index, index + 1_000));
|
||||||
}
|
}
|
||||||
|
|||||||
74
src/server/referrals/dub-sale.test.ts
Normal file
74
src/server/referrals/dub-sale.test.ts
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { AUTUMN_SEO_DATA_TOP_UP_PLAN_ID } from "@/shared/billing";
|
||||||
|
import { type AutumnInvoice, buildDubSaleRequest } from "./dub-sale";
|
||||||
|
|
||||||
|
function invoice(overrides: Partial<AutumnInvoice> = {}): AutumnInvoice {
|
||||||
|
return {
|
||||||
|
planIds: ["base-plan"],
|
||||||
|
stripeId: "in_123",
|
||||||
|
processorType: "stripe",
|
||||||
|
status: "paid",
|
||||||
|
total: 10,
|
||||||
|
currency: "USD",
|
||||||
|
createdAt: 1756000000000,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("buildDubSaleRequest", () => {
|
||||||
|
it("converts Autumn's dollar total to cents and keys on the Stripe invoice id", () => {
|
||||||
|
expect(buildDubSaleRequest(invoice(), "org_1")).toEqual({
|
||||||
|
invoiceId: "in_123",
|
||||||
|
amount: 1000,
|
||||||
|
currency: "usd",
|
||||||
|
eventName: "Invoice paid",
|
||||||
|
paymentProcessor: "stripe",
|
||||||
|
metadata: { type: "subscription" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("labels top-up invoices via their plan id", () => {
|
||||||
|
const sale = buildDubSaleRequest(
|
||||||
|
invoice({ planIds: [AUTUMN_SEO_DATA_TOP_UP_PLAN_ID] }),
|
||||||
|
"org_1",
|
||||||
|
);
|
||||||
|
expect(sale?.metadata).toEqual({ type: "top_up" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rounds fractional dollar totals to whole cents", () => {
|
||||||
|
expect(
|
||||||
|
buildDubSaleRequest(invoice({ total: 19.99 }), "org_1")?.amount,
|
||||||
|
).toBe(1999);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips unpaid invoices", () => {
|
||||||
|
expect(
|
||||||
|
buildDubSaleRequest(invoice({ status: "open" }), "org_1"),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips zero-total invoices", () => {
|
||||||
|
expect(buildDubSaleRequest(invoice({ total: 0 }), "org_1")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips non-USD invoices", () => {
|
||||||
|
vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
expect(
|
||||||
|
buildDubSaleRequest(invoice({ currency: "eur" }), "org_1"),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips implausibly large totals that would signal a unit mismatch", () => {
|
||||||
|
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
expect(buildDubSaleRequest(invoice({ total: 1900 }), "org_1")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to a stable synthetic key when stripeId is missing", () => {
|
||||||
|
const sale = buildDubSaleRequest(
|
||||||
|
invoice({ stripeId: "", processorType: "revenuecat" }),
|
||||||
|
"org_1",
|
||||||
|
);
|
||||||
|
expect(sale?.invoiceId).toBe("org_1:1756000000000:10");
|
||||||
|
expect(sale?.paymentProcessor).toBe("revenuecat");
|
||||||
|
});
|
||||||
|
});
|
||||||
66
src/server/referrals/dub-sale.ts
Normal file
66
src/server/referrals/dub-sale.ts
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
import { AUTUMN_SEO_DATA_TOP_UP_PLAN_ID } from "@/shared/billing";
|
||||||
|
|
||||||
|
// Leaf module (no cloudflare:workers import) so the mapping is unit-testable.
|
||||||
|
|
||||||
|
export type AutumnInvoice = {
|
||||||
|
planIds: Array<string>;
|
||||||
|
stripeId: string;
|
||||||
|
processorType: string;
|
||||||
|
status: string;
|
||||||
|
total: number;
|
||||||
|
currency: string;
|
||||||
|
createdAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// The largest legitimate invoice today is the $10/mo plan plus a $99 top-up;
|
||||||
|
// anything near this cap means the dollars-vs-cents assumption below broke.
|
||||||
|
const MAX_PLAUSIBLE_INVOICE_TOTAL_USD = 1000;
|
||||||
|
|
||||||
|
/** Maps a paid Autumn invoice to a Dub /track/sale body, or null when the
|
||||||
|
* invoice shouldn't be tracked. Autumn's `total` is in major units (dollars);
|
||||||
|
* Dub wants minor units. Prices are USD-only, so skip anything else rather
|
||||||
|
* than carrying a per-currency minor-unit table.
|
||||||
|
*
|
||||||
|
* Refunds are not clawed back automatically: Autumn keeps a refunded
|
||||||
|
* invoice's status as "paid" and this response doesn't expose refund
|
||||||
|
* amounts, so refunded referral commissions must be adjusted manually in
|
||||||
|
* the Dub dashboard. */
|
||||||
|
export function buildDubSaleRequest(
|
||||||
|
invoice: AutumnInvoice,
|
||||||
|
organizationId: string,
|
||||||
|
) {
|
||||||
|
if (invoice.status !== "paid" || invoice.total <= 0) return null;
|
||||||
|
if (invoice.currency.toLowerCase() !== "usd") {
|
||||||
|
console.warn("Skipping non-USD invoice for Dub sale tracking", {
|
||||||
|
organizationId,
|
||||||
|
currency: invoice.currency,
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (invoice.total > MAX_PLAUSIBLE_INVOICE_TOTAL_USD) {
|
||||||
|
console.error("Skipping implausibly large invoice for Dub sale tracking", {
|
||||||
|
organizationId,
|
||||||
|
total: invoice.total,
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
invoiceId:
|
||||||
|
invoice.stripeId ||
|
||||||
|
`${organizationId}:${invoice.createdAt}:${invoice.total}`,
|
||||||
|
amount: Math.round(invoice.total * 100),
|
||||||
|
currency: "usd",
|
||||||
|
eventName: "Invoice paid",
|
||||||
|
paymentProcessor:
|
||||||
|
invoice.processorType === "stripe" ||
|
||||||
|
invoice.processorType === "revenuecat"
|
||||||
|
? invoice.processorType
|
||||||
|
: "custom",
|
||||||
|
metadata: {
|
||||||
|
type: invoice.planIds.includes(AUTUMN_SEO_DATA_TOP_UP_PLAN_ID)
|
||||||
|
? "top_up"
|
||||||
|
: "subscription",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
289
src/server/referrals/dub.ts
Normal file
289
src/server/referrals/dub.ts
Normal file
@ -0,0 +1,289 @@
|
|||||||
|
import { env, waitUntil } from "cloudflare:workers";
|
||||||
|
import { parseCookies } from "better-auth/cookies";
|
||||||
|
import { AuthRepository } from "@/server/auth/repositories/AuthRepository";
|
||||||
|
import { autumn } from "@/server/billing/autumn";
|
||||||
|
import { captureServerError } from "@/server/lib/posthog";
|
||||||
|
import { buildDubSaleRequest } from "./dub-sale";
|
||||||
|
|
||||||
|
// Dub referral attribution (hosted only). Flow:
|
||||||
|
// 1. links.openseo.so/<partner> redirects to openseo.so/?dub_id=<clickId>;
|
||||||
|
// the marketing site persists it as a `dub_id` cookie on `.openseo.so`.
|
||||||
|
// 2. On signup we send a Dub lead and pin `referred-user:<userId>` in KV.
|
||||||
|
// The lead creates a pseudonymous Dub customer record (random name +
|
||||||
|
// our user id) — GDPR erasure deletes the KV pins here and the Dub-side
|
||||||
|
// record via the erasure runbook.
|
||||||
|
// 3. The pin is copied to `referred-org:<orgId>` (= the Autumn customer id)
|
||||||
|
// on every session, so billing events need no member lookup.
|
||||||
|
// 4. Paid Autumn invoices for referred orgs are sent as Dub sales — from the
|
||||||
|
// billing webhook for promptness, and from a daily cron sweep because
|
||||||
|
// Autumn's `billing.updated` isn't documented to fire for renewals or
|
||||||
|
// one-time top-up purchases.
|
||||||
|
// Every entry point no-ops without DUB_API_KEY, so self-host and previews
|
||||||
|
// are inert.
|
||||||
|
|
||||||
|
const DUB_API_URL = "https://api.dub.co";
|
||||||
|
const DUB_COOKIE_NAME = "dub_id";
|
||||||
|
export const DUB_REFERRED_USER_KV_PREFIX = "dub:referred-user:";
|
||||||
|
export const DUB_REFERRED_ORG_KV_PREFIX = "dub:referred-org:";
|
||||||
|
const TRACKED_SALE_KV_PREFIX = "dub:sale:";
|
||||||
|
// User pin: matches Dub's 90-day attribution cookie. Org pin: refreshed on
|
||||||
|
// every session while the user pin lives, then ages out so the daily sweep
|
||||||
|
// doesn't grow forever.
|
||||||
|
const USER_PIN_TTL_SECONDS = 90 * 24 * 60 * 60;
|
||||||
|
const ORG_PIN_TTL_SECONDS = 400 * 24 * 60 * 60;
|
||||||
|
// Dub can answer "customer: null" for a referred org whose lead hasn't
|
||||||
|
// materialized yet; a short suppression window lets the next webhook or the
|
||||||
|
// daily cron retry. Dub's invoiceId idempotency makes re-sends safe.
|
||||||
|
const NOT_REFERRED_RETRY_TTL_SECONDS = 60 * 60;
|
||||||
|
// Only sweep recent invoices: everything older has either been tracked (and
|
||||||
|
// permanently marked) or has failed long enough that retrying is pointless.
|
||||||
|
// Keeps per-org KV reads and Dub calls bounded.
|
||||||
|
const SALE_SWEEP_WINDOW_MS = 45 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
function getDubApiKey() {
|
||||||
|
const value: unknown = Reflect.get(env, "DUB_API_KEY");
|
||||||
|
const trimmed = typeof value === "string" ? value.trim() : "";
|
||||||
|
return trimmed || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postDub(apiKey: string, path: string, body: unknown) {
|
||||||
|
return fetch(`${DUB_API_URL}${path}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${apiKey}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
signal: AbortSignal.timeout(10_000),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Records a Dub lead and pins the referral in KV when the signup request
|
||||||
|
* carries a `dub_id` cookie. Must never fail the signup. */
|
||||||
|
export async function captureDubReferralSignup(
|
||||||
|
userId: string,
|
||||||
|
request: Request | null | undefined,
|
||||||
|
) {
|
||||||
|
const apiKey = getDubApiKey();
|
||||||
|
const cookieHeader = request?.headers.get("cookie");
|
||||||
|
if (!apiKey || !cookieHeader) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const clickId = parseCookies(cookieHeader).get(DUB_COOKIE_NAME)?.trim();
|
||||||
|
if (!clickId) return;
|
||||||
|
|
||||||
|
await env.KV.put(`${DUB_REFERRED_USER_KV_PREFIX}${userId}`, clickId, {
|
||||||
|
expirationTtl: USER_PIN_TTL_SECONDS,
|
||||||
|
});
|
||||||
|
|
||||||
|
waitUntil(
|
||||||
|
(async () => {
|
||||||
|
// One retry: leads dedupe Dub-side on customerExternalId+eventName,
|
||||||
|
// and a lost lead leaves the org's sales unattributable. mode "wait"
|
||||||
|
// materializes the customer before a near-immediate checkout's sale.
|
||||||
|
let lastError: unknown;
|
||||||
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||||
|
try {
|
||||||
|
const response = await postDub(apiKey, "/track/lead", {
|
||||||
|
clickId,
|
||||||
|
eventName: "Sign up",
|
||||||
|
mode: "wait",
|
||||||
|
// Deliberately no name/email: keeps the Dub record pseudonymous.
|
||||||
|
customerExternalId: userId,
|
||||||
|
});
|
||||||
|
if (response.ok) return;
|
||||||
|
lastError = new Error(
|
||||||
|
`Dub lead tracking failed (${response.status})`,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastError;
|
||||||
|
})().catch(async (error: unknown) => {
|
||||||
|
console.error("Dub lead tracking failed", { userId, error });
|
||||||
|
await captureServerError(error, {
|
||||||
|
source: "dub_lead",
|
||||||
|
user_id: userId,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Dub referral capture failed", { userId, error });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Copies a user's referral pin onto the organization they FOUNDED, keyed by
|
||||||
|
* org id (= Autumn customer id) so billing events resolve it directly.
|
||||||
|
* Founded, not merely owned or joined: commission is only for net-new users
|
||||||
|
* creating their own workspace — an invitee (even one later promoted to
|
||||||
|
* owner) must never attribute an existing org's revenue to their referrer.
|
||||||
|
* Called on every session (only touches the DB when a user pin exists);
|
||||||
|
* must never fail session creation. */
|
||||||
|
export async function markDubReferredOrganization(userId: string) {
|
||||||
|
if (!getDubApiKey()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const clickId = await env.KV.get(`${DUB_REFERRED_USER_KV_PREFIX}${userId}`);
|
||||||
|
if (!clickId) return;
|
||||||
|
|
||||||
|
const organizationId =
|
||||||
|
await AuthRepository.findFirstFoundedOrganizationIdForUser(userId);
|
||||||
|
if (!organizationId) return;
|
||||||
|
|
||||||
|
await env.KV.put(`${DUB_REFERRED_ORG_KV_PREFIX}${organizationId}`, userId, {
|
||||||
|
expirationTtl: ORG_PIN_TTL_SECONDS,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Dub referred-org marking failed", { userId, error });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function trackDubSale(
|
||||||
|
apiKey: string,
|
||||||
|
userId: string,
|
||||||
|
sale: NonNullable<ReturnType<typeof buildDubSaleRequest>>,
|
||||||
|
): Promise<"tracked" | "not_referred" | "failed"> {
|
||||||
|
const response = await postDub(apiKey, "/track/sale", {
|
||||||
|
...sale,
|
||||||
|
customerExternalId: userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const detail = await response.text().catch(() => "");
|
||||||
|
console.error("Dub sale tracking failed", {
|
||||||
|
status: response.status,
|
||||||
|
invoiceId: sale.invoiceId,
|
||||||
|
detail,
|
||||||
|
});
|
||||||
|
return "failed";
|
||||||
|
}
|
||||||
|
|
||||||
|
// An unknown customer (the lead never landed, or hasn't materialized yet)
|
||||||
|
// returns 200 with `customer: null` and records nothing.
|
||||||
|
const payload = await response.json<{ customer: unknown }>();
|
||||||
|
return payload.customer ? "tracked" : "not_referred";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAutumnNotFound(error: unknown) {
|
||||||
|
return (
|
||||||
|
typeof error === "object" &&
|
||||||
|
error !== null &&
|
||||||
|
"statusCode" in error &&
|
||||||
|
(error as { statusCode: unknown }).statusCode === 404
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sweepDubSalesForOrganization(
|
||||||
|
organizationId: string,
|
||||||
|
userId: string,
|
||||||
|
) {
|
||||||
|
const apiKey = getDubApiKey();
|
||||||
|
if (!apiKey) return;
|
||||||
|
|
||||||
|
let customer;
|
||||||
|
try {
|
||||||
|
customer = await autumn.customers.get({
|
||||||
|
customerId: organizationId,
|
||||||
|
expand: ["invoices"],
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// Autumn customers are created lazily on first billing interaction, so a
|
||||||
|
// referred org that never used a paid feature (or was GDPR-erased)
|
||||||
|
// doesn't exist there. Not an error.
|
||||||
|
if (isAutumnNotFound(error)) return;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldestSweepable = Date.now() - SALE_SWEEP_WINDOW_MS;
|
||||||
|
for (const invoice of customer.invoices ?? []) {
|
||||||
|
if (invoice.createdAt < oldestSweepable) continue;
|
||||||
|
|
||||||
|
const sale = buildDubSaleRequest(invoice, organizationId);
|
||||||
|
if (!sale) continue;
|
||||||
|
|
||||||
|
// One bad invoice must not abort the org's remaining invoices.
|
||||||
|
try {
|
||||||
|
const trackedKey = `${TRACKED_SALE_KV_PREFIX}${sale.invoiceId}`;
|
||||||
|
if (await env.KV.get(trackedKey)) continue;
|
||||||
|
|
||||||
|
const result = await trackDubSale(apiKey, userId, sale);
|
||||||
|
// "failed" is left unmarked so the next webhook or cron sweep retries.
|
||||||
|
if (result === "tracked") {
|
||||||
|
await env.KV.put(trackedKey, "1");
|
||||||
|
} else if (result === "not_referred") {
|
||||||
|
await env.KV.put(trackedKey, "1", {
|
||||||
|
expirationTtl: NOT_REFERRED_RETRY_TTL_SECONDS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Dub sale tracking errored", {
|
||||||
|
organizationId,
|
||||||
|
invoiceId: sale.invoiceId,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Billing-webhook entry point: fire-and-forget sale sweep for referred orgs.
|
||||||
|
* Must never fail the webhook response. */
|
||||||
|
export async function trackDubSalesForOrganization(organizationId: string) {
|
||||||
|
try {
|
||||||
|
if (!getDubApiKey()) return;
|
||||||
|
|
||||||
|
const userId = await env.KV.get(
|
||||||
|
`${DUB_REFERRED_ORG_KV_PREFIX}${organizationId}`,
|
||||||
|
);
|
||||||
|
if (!userId) return;
|
||||||
|
|
||||||
|
waitUntil(
|
||||||
|
sweepDubSalesForOrganization(organizationId, userId).catch(
|
||||||
|
async (error: unknown) => {
|
||||||
|
console.error("Dub sale sweep failed", { organizationId, error });
|
||||||
|
await captureServerError(error, {
|
||||||
|
source: "dub_sale_sweep",
|
||||||
|
organization_id: organizationId,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Dub sale tracking skipped", { organizationId, error });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Daily cron: sweep every referred org. Catches revenue the webhook path
|
||||||
|
* misses (renewals, one-time top-ups, invoices that were still open). */
|
||||||
|
export async function sweepDubReferredOrganizations() {
|
||||||
|
if (!getDubApiKey()) return;
|
||||||
|
|
||||||
|
let cursor: string | undefined;
|
||||||
|
do {
|
||||||
|
const page = await env.KV.list({
|
||||||
|
prefix: DUB_REFERRED_ORG_KV_PREFIX,
|
||||||
|
cursor,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const key of page.keys) {
|
||||||
|
const organizationId = key.name.slice(DUB_REFERRED_ORG_KV_PREFIX.length);
|
||||||
|
try {
|
||||||
|
const userId = await env.KV.get(key.name);
|
||||||
|
if (userId) {
|
||||||
|
await sweepDubSalesForOrganization(organizationId, userId);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Dub cron sweep failed for org", {
|
||||||
|
organizationId,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
await captureServerError(error, {
|
||||||
|
source: "dub_cron_sweep",
|
||||||
|
organization_id: organizationId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cursor = page.list_complete ? undefined : page.cursor;
|
||||||
|
} while (cursor);
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@ -58,6 +58,18 @@ function RootDocument({ children }: { children: React.ReactNode }) {
|
|||||||
<html lang="en" suppressHydrationWarning>
|
<html lang="en" suppressHydrationWarning>
|
||||||
<head>
|
<head>
|
||||||
<HeadContent />
|
<HeadContent />
|
||||||
|
{/* Dub referral attribution: partner links land here with ?dub_id=
|
||||||
|
(and ?via=). Load Dub's script only for those visits — it persists
|
||||||
|
the click id as a `dub_id` cookie on `.openseo.so` so the app at
|
||||||
|
app.openseo.so can attribute the signup. Injected during the
|
||||||
|
initial HTML parse (not idle-deferred like Plausible below) so the
|
||||||
|
cookie lands before the visitor navigates to the app. */}
|
||||||
|
<script
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html:
|
||||||
|
"(function(){var q=new URLSearchParams(window.location.search);if(!q.has('dub_id')&&!q.has('via'))return;var s=document.createElement('script');s.src='https://www.dubcdn.com/analytics/script.js';s.dataset.domains='{\"refer\":\"links.openseo.so\"}';s.dataset.cookieOptions='{\"domain\":\".openseo.so\"}';document.head.appendChild(s)})();",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<script
|
<script
|
||||||
dangerouslySetInnerHTML={{
|
dangerouslySetInnerHTML={{
|
||||||
__html:
|
__html:
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user