Add Dub referral conversion tracking (leads + sales) (#531)

This commit is contained in:
Ben Senescu 2026-08-25 21:49:08 -04:00 committed by GitHub
parent 7a611a7bad
commit ac7ebfe13a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 648 additions and 11 deletions

View File

@ -32,6 +32,11 @@ DATABASE_PROVIDER=postgres
# Billing, analytics, agents.
# AUTUMN_SECRET_KEY=
# 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.
# GDPR_ERASURE_SECRET=
# POSTHOG_PUBLIC_KEY=

View File

@ -271,6 +271,7 @@ const dataEnv = {
OPENROUTER_MODEL: optionalVar("OPENROUTER_MODEL"),
AUTUMN_SECRET_KEY: optionalSecret("AUTUMN_SECRET_KEY"),
AUTUMN_WEBHOOK_SECRET: optionalSecret("AUTUMN_WEBHOOK_SECRET"),
DUB_API_KEY: optionalSecret("DUB_API_KEY"),
GDPR_ERASURE_SECRET: optionalSecret("GDPR_ERASURE_SECRET"),
LOOPS_API_KEY: optionalSecret("LOOPS_API_KEY"),
LOOPS_TRANSACTIONAL_VERIFY_EMAIL_ID: optionalVar(

3
src/env.d.ts vendored
View File

@ -35,6 +35,9 @@ declare namespace Cloudflare {
LOOPS_TRANSACTIONAL_RESET_PASSWORD_ID?: string;
AUTUMN_SECRET_KEY?: 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.
GDPR_ERASURE_SECRET?: string;

View File

@ -19,6 +19,7 @@ import {
hasHostedTurnstileConfig,
} from "@/lib/auth-turnstile";
import { getOrCreateDefaultHostedOrganization } from "@/server/auth/default-hosted-organization";
import { captureDubReferralSignup } from "@/server/referrals/dub";
import {
sendHostedPasswordResetEmail,
sendHostedVerificationEmail,
@ -126,8 +127,11 @@ function createAuth() {
}
return { data: user };
},
after: async (user) => {
after: async (user, ctx) => {
await syncHostedSignupContact(user);
if (isHostedAuthMode(env.AUTH_MODE)) {
await captureDubReferralSignup(user.id, ctx?.request);
}
},
},
},

View File

@ -23,6 +23,7 @@ import {
AUTUMN_WEBHOOK_PATH,
handleAutumnWebhookRequest,
} from "@/server/billing/autumn-webhook";
import { sweepDubReferredOrganizations } from "@/server/referrals/dub";
import { maybeSendSelfHostHeartbeat } from "@/server/lib/self-host-telemetry";
import { handleGdprStorageErasure } from "@/server/gdpr/storage-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.
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;
}

View File

@ -1,4 +1,5 @@
import { AuthRepository } from "@/server/auth/repositories/AuthRepository";
import { markDubReferredOrganization } from "@/server/referrals/dub";
import { slugify, toHex } from "./org-slug";
type HostedUser = {
@ -68,13 +69,23 @@ export async function getOrCreateDefaultHostedOrganization(
userId: string,
createOrganization: HostedOrganizationCreator,
) {
const existingOrganizationId =
let organizationId =
await AuthRepository.findFirstOrganizationIdForUser(userId);
if (existingOrganizationId) {
return existingOrganizationId;
if (!organizationId) {
const hostedUser = await getHostedUser(userId);
organizationId = await createDefaultHostedOrganization(
hostedUser,
createOrganization,
);
}
const hostedUser = await getHostedUser(userId);
return createDefaultHostedOrganization(hostedUser, createOrganization);
// On every session, not just org creation: the signup-time referral pin can
// 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;
}

View 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();
});
});

View File

@ -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 { member, organization, user as authUser } from "@/db/schema";
@ -39,6 +39,36 @@ async function findFirstOrganizationIdForUser(userId: string) {
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) {
return db.query.user.findFirst({
columns: {
@ -53,5 +83,6 @@ async function getHostedUser(userId: string) {
export const AuthRepository = {
upsertDelegatedOrganization,
findFirstOrganizationIdForUser,
findFirstFoundedOrganizationIdForUser,
getHostedUser,
} as const;

View File

@ -1,6 +1,7 @@
import { z } from "zod";
import { getRequiredEnvValue } from "@/server/lib/runtime-env";
import { captureServerError } from "@/server/lib/posthog";
import { trackDubSalesForOrganization } from "@/server/referrals/dub";
import { syncAutumnCustomerStatus } from "./customer-status-sync";
import { verifySvixSignature } from "./svix";
@ -67,6 +68,10 @@ export async function handleAutumnWebhookRequest(request: Request) {
});
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 });

View File

@ -46,6 +46,8 @@ export const autumn = {
customers: {
getOrCreate: (...args: Parameters<Autumn["customers"]["getOrCreate"]>) =>
loadAutumn().then((client) => client.customers.getOrCreate(...args)),
get: (...args: Parameters<Autumn["customers"]["get"]>) =>
loadAutumn().then((client) => client.customers.get(...args)),
},
};

View File

@ -3,6 +3,10 @@ import { getAuditScratchpad } from "@/server/features/audit/AuditScratchpad";
import type { OnboardingChatAgent } from "@/server/features/onboarding/OnboardingChatAgent";
import type { SamChatAgent } from "@/server/features/sam/SamChatAgent";
import { captureServerError } from "@/server/lib/posthog";
import {
DUB_REFERRED_ORG_KV_PREFIX,
DUB_REFERRED_USER_KV_PREFIX,
} from "@/server/referrals/dub";
import {
AI_SEARCH_PROMPT_CACHE_NAMESPACE,
cacheObjectPrefix,
@ -211,6 +215,14 @@ async function eraseStorage(env: Env, payload: GdprStorageErasurePayload) {
// TTL, and clearing them would let an in-flight authenticated request
// 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) {
await env.R2.delete(payload.r2Keys.slice(index, index + 1_000));
}

View 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");
});
});

View 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
View 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

View File

@ -58,6 +58,18 @@ function RootDocument({ children }: { children: React.ReactNode }) {
<html lang="en" suppressHydrationWarning>
<head>
<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
dangerouslySetInnerHTML={{
__html: