From ac7ebfe13a81f5aba1a2a48fb997e98e97eee6d9 Mon Sep 17 00:00:00 2001 From: Ben Senescu <44480372+bensenescu@users.noreply.github.com> Date: Tue, 25 Aug 2026 21:49:08 -0400 Subject: [PATCH] Add Dub referral conversion tracking (leads + sales) (#531) --- .env.production.example | 5 + alchemy.run.ts | 1 + src/env.d.ts | 3 + src/lib/auth.ts | 6 +- src/server.ts | 9 + .../auth/default-hosted-organization.ts | 21 +- .../repositories/AuthRepository.query.test.ts | 113 +++++++ .../auth/repositories/AuthRepository.ts | 33 +- src/server/billing/autumn-webhook.ts | 5 + src/server/billing/autumn.ts | 2 + src/server/gdpr/storage-erasure.ts | 12 + src/server/referrals/dub-sale.test.ts | 74 +++++ src/server/referrals/dub-sale.ts | 66 ++++ src/server/referrals/dub.ts | 289 ++++++++++++++++++ web/content/legal/privacy.md | 8 +- web/src/routes/__root.tsx | 12 + 16 files changed, 648 insertions(+), 11 deletions(-) create mode 100644 src/server/auth/repositories/AuthRepository.query.test.ts create mode 100644 src/server/referrals/dub-sale.test.ts create mode 100644 src/server/referrals/dub-sale.ts create mode 100644 src/server/referrals/dub.ts diff --git a/.env.production.example b/.env.production.example index 7460a8a..f238481 100644 --- a/.env.production.example +++ b/.env.production.example @@ -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= diff --git a/alchemy.run.ts b/alchemy.run.ts index a415770..3ea11ff 100644 --- a/alchemy.run.ts +++ b/alchemy.run.ts @@ -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( diff --git a/src/env.d.ts b/src/env.d.ts index 71e46e5..c0199f1 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -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; diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 98cfe1a..2561e31 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -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); + } }, }, }, diff --git a/src/server.ts b/src/server.ts index c838647..00c828d 100644 --- a/src/server.ts +++ b/src/server.ts @@ -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; } diff --git a/src/server/auth/default-hosted-organization.ts b/src/server/auth/default-hosted-organization.ts index 1818b51..0b8a247 100644 --- a/src/server/auth/default-hosted-organization.ts +++ b/src/server/auth/default-hosted-organization.ts @@ -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; } diff --git a/src/server/auth/repositories/AuthRepository.query.test.ts b/src/server/auth/repositories/AuthRepository.query.test.ts new file mode 100644 index 0000000..42b9dd7 --- /dev/null +++ b/src/server/auth/repositories/AuthRepository.query.test.ts @@ -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(); + }); +}); diff --git a/src/server/auth/repositories/AuthRepository.ts b/src/server/auth/repositories/AuthRepository.ts index d2158da..ff90098 100644 --- a/src/server/auth/repositories/AuthRepository.ts +++ b/src/server/auth/repositories/AuthRepository.ts @@ -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; diff --git a/src/server/billing/autumn-webhook.ts b/src/server/billing/autumn-webhook.ts index 15687fb..432487e 100644 --- a/src/server/billing/autumn-webhook.ts +++ b/src/server/billing/autumn-webhook.ts @@ -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 }); diff --git a/src/server/billing/autumn.ts b/src/server/billing/autumn.ts index b92119f..8f3ebe8 100644 --- a/src/server/billing/autumn.ts +++ b/src/server/billing/autumn.ts @@ -46,6 +46,8 @@ export const autumn = { customers: { getOrCreate: (...args: Parameters) => loadAutumn().then((client) => client.customers.getOrCreate(...args)), + get: (...args: Parameters) => + loadAutumn().then((client) => client.customers.get(...args)), }, }; diff --git a/src/server/gdpr/storage-erasure.ts b/src/server/gdpr/storage-erasure.ts index 19603d9..e9005fd 100644 --- a/src/server/gdpr/storage-erasure.ts +++ b/src/server/gdpr/storage-erasure.ts @@ -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)); } diff --git a/src/server/referrals/dub-sale.test.ts b/src/server/referrals/dub-sale.test.ts new file mode 100644 index 0000000..f127566 --- /dev/null +++ b/src/server/referrals/dub-sale.test.ts @@ -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 { + 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"); + }); +}); diff --git a/src/server/referrals/dub-sale.ts b/src/server/referrals/dub-sale.ts new file mode 100644 index 0000000..d5903c7 --- /dev/null +++ b/src/server/referrals/dub-sale.ts @@ -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; + 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", + }, + }; +} diff --git a/src/server/referrals/dub.ts b/src/server/referrals/dub.ts new file mode 100644 index 0000000..8d9c777 --- /dev/null +++ b/src/server/referrals/dub.ts @@ -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/ redirects to openseo.so/?dub_id=; +// 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:` 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:` (= 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>, +): 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); +} diff --git a/web/content/legal/privacy.md b/web/content/legal/privacy.md index 4097827..95dc7d9 100644 --- a/web/content/legal/privacy.md +++ b/web/content/legal/privacy.md @@ -3,7 +3,7 @@ title: Privacy Policy description: How OpenSEO collects, uses, and stores personal data. --- -_Last updated: August 15, 2026_ +_Last updated: August 24, 2026_ Every App, Inc PRIVACY POLICY @@ -23,7 +23,7 @@ We collect “Non-Personal Information” and “Personal Information.” **Non- 1\. _Information collected via Technology_ To receive marketing communications from us, you only need to submit your email address. To use the hosted version of the Service thereafter, you may need to submit further Personal Information, such as your name, email address, password, organization information, billing or customer identifiers, and other information you choose to provide through the Service. However, in an effort to improve the quality of the Service, we track information provided to us by your browser or by our software application when you view or use the Service, such as the website you came from (known as the “referring URL”), the pages you visit, the type of browser you use, the device from which you connected to the Service, your operating system, approximate location derived from IP address, the time and date of access, and other information related to how you use the Site or Service. In the authenticated hosted Service, PostHog may also collect session replay recordings to help us diagnose bugs and improve usability; form inputs and designated sensitive text are masked before recording. -The public Site uses privacy-friendly analytics through Plausible Analytics, which is configured as cookieless analytics and proxied through our domain. In the hosted version of the Service, we may use cookies or similar technologies that are necessary to authenticate users, maintain secure sessions, and protect accounts. +The public Site uses privacy-friendly analytics through Plausible Analytics, which is configured as cookieless analytics and proxied through our domain. If you arrive at the Site through a referral or partner link, we also set a first-party cookie (provided by Dub) that stores a referral click identifier for up to 90 days so that signups and purchases can be attributed to the referring partner. In the hosted version of the Service, we may use cookies or similar technologies that are necessary to authenticate users, maintain secure sessions, and protect accounts. Where cookies are used in the hosted version of the Service, the Company may use both persistent and session cookies; persistent cookies remain on your computer after you close your session and until you delete them, while session cookies expire when you close your browser. 2\. _Information you provide us by registering for an account_ In addition to the information provided automatically by your browser when you visit the Site, to use the hosted version of the Service you may need to create a personal profile. You can create a profile by registering with the Service and entering your name, email address, and password. If you provide your email address to receive marketing communications from us without creating an account, we collect and use that email address for that purpose. By registering, subscribing, or otherwise providing your information, you are authorizing us to collect, store and use your information in accordance with this Privacy Policy. @@ -33,7 +33,7 @@ The Site and the Service are not directed to anyone under the age of 13\. As sta II. HOW WE USE AND SHARE INFORMATION _Personal Information:_ -Except as otherwise stated in this Privacy Policy, we do not sell, trade, rent or otherwise share for marketing purposes your Personal Information with third parties without your consent. We do share Personal Information with vendors who are performing services for the Company, such as providers that help us send email communications, operate analytics, measure advertising conversions, process billing, provide data requested through the Service, and host or support the infrastructure behind the Site and Service. These vendors may include Loops for email communications, Plausible for public-site analytics, PostHog for hosted analytics, session replay, and error monitoring, Reddit Ads for advertising conversion measurement, Autumn and Stripe for hosted billing and payment processing, Cloudflare for hosting, storage, and access controls, DataForSEO for data requested through the Service, and OpenRouter and OpenAI for AI model access when you use AI assistant features of the Service. Those vendors use your Personal Information only at our direction and in accordance with our Privacy Policy. +Except as otherwise stated in this Privacy Policy, we do not sell, trade, rent or otherwise share for marketing purposes your Personal Information with third parties without your consent. We do share Personal Information with vendors who are performing services for the Company, such as providers that help us send email communications, operate analytics, measure advertising conversions, process billing, provide data requested through the Service, and host or support the infrastructure behind the Site and Service. These vendors may include Loops for email communications, Plausible for public-site analytics, PostHog for hosted analytics, session replay, and error monitoring, Dub for referral link and conversion attribution, Autumn and Stripe for hosted billing and payment processing, Cloudflare for hosting, storage, and access controls, DataForSEO for data requested through the Service, and OpenRouter and OpenAI for AI model access when you use AI assistant features of the Service. Those vendors use your Personal Information only at our direction and in accordance with our Privacy Policy. In general, the Personal Information you provide to us is used to help us communicate with you and operate the Service. For example, we use Personal Information to create and secure accounts, authenticate users, provide technical support, process billing, send administrative or transactional emails, contact users in response to questions, solicit feedback from users, and inform users about product updates and promotional offers. We may share Personal Information with outside parties if we have a good-faith belief that access, use, preservation or disclosure of the information is reasonably necessary to meet any applicable legal process or enforceable governmental request; to enforce applicable Terms of Service, including investigation of potential violations; address fraud, security or technical concerns; or to protect against harm to the rights, property, or safety of our users or the public as required or permitted by law. @@ -70,6 +70,6 @@ The Company reserves the right to change this policy and our Terms of Service at VIII. CONTACT US If you have any questions regarding this Privacy Policy or the practices of this Site, please contact us by sending an email to ben@openseo.so. -Last Updated: This Privacy Policy was last updated on August 15, 2026. +Last Updated: This Privacy Policy was last updated on August 24, 2026. [image1]: data:image/png;base64,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 diff --git a/web/src/routes/__root.tsx b/web/src/routes/__root.tsx index 9c8e0cb..cb89324 100644 --- a/web/src/routes/__root.tsx +++ b/web/src/routes/__root.tsx @@ -58,6 +58,18 @@ function RootDocument({ children }: { children: React.ReactNode }) { + {/* 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. */} +