fix(billing): activate paid plans immediately after checkout (EVE-44) (#469)

This commit is contained in:
Ben Senescu 2026-08-08 13:06:45 -04:00 committed by GitHub
parent cfc8456767
commit 2cedcda5db
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 7030 additions and 516 deletions

View File

@ -34,7 +34,5 @@ DATABASE_PROVIDER=postgres
# AUTUMN_WEBHOOK_SECRET= # AUTUMN_WEBHOOK_SECRET=
# POSTHOG_PUBLIC_KEY= # POSTHOG_PUBLIC_KEY=
# POSTHOG_HOST= # POSTHOG_HOST=
# REDDIT_PIXEL_ID=
# REDDIT_CONVERSIONS_ACCESS_TOKEN=
# OPENROUTER_API_KEY= # OPENROUTER_API_KEY=
# OPENROUTER_MODEL= # OPENROUTER_MODEL=

View File

@ -280,10 +280,6 @@ const dataEnv = {
), ),
POSTHOG_PUBLIC_KEY: optionalVar("POSTHOG_PUBLIC_KEY"), POSTHOG_PUBLIC_KEY: optionalVar("POSTHOG_PUBLIC_KEY"),
POSTHOG_HOST: optionalVar("POSTHOG_HOST"), POSTHOG_HOST: optionalVar("POSTHOG_HOST"),
REDDIT_PIXEL_ID: optionalSecret("REDDIT_PIXEL_ID"),
REDDIT_CONVERSIONS_ACCESS_TOKEN: optionalSecret(
"REDDIT_CONVERSIONS_ACCESS_TOKEN",
),
TURNSTILE_SECRET_KEY: optionalSecret("TURNSTILE_SECRET_KEY"), TURNSTILE_SECRET_KEY: optionalSecret("TURNSTILE_SECRET_KEY"),
TURNSTILE_SITE_KEY: optionalVar("TURNSTILE_SITE_KEY"), TURNSTILE_SITE_KEY: optionalVar("TURNSTILE_SITE_KEY"),
// Alchemy reconciles worker vars on every deploy, so the telemetry opt-out // Alchemy reconciles worker vars on every deploy, so the telemetry opt-out

View File

@ -0,0 +1 @@
DROP TABLE "reddit_attributions" CASCADE;

File diff suppressed because it is too large Load Diff

View File

@ -127,6 +127,13 @@
"when": 1786066279773, "when": 1786066279773,
"tag": "0017_ga4_connections", "tag": "0017_ga4_connections",
"breakpoints": true "breakpoints": true
},
{
"idx": 18,
"version": "7",
"when": 1786208325786,
"tag": "0018_drop_reddit_attributions",
"breakpoints": true
} }
] ]
} }

View File

@ -0,0 +1 @@
DROP TABLE `reddit_attributions`;

File diff suppressed because it is too large Load Diff

View File

@ -281,6 +281,13 @@
"when": 1786066274811, "when": 1786066274811,
"tag": "0039_ga4_connections", "tag": "0039_ga4_connections",
"breakpoints": true "breakpoints": true
},
{
"idx": 40,
"version": "6",
"when": 1786208324064,
"tag": "0040_drop_reddit_attributions",
"breakpoints": true
} }
] ]
} }

View File

@ -0,0 +1,11 @@
import { SUBSCRIBE_ROUTE } from "@/shared/billing";
// Success URL for plan checkouts: land on /subscribe's finalizing screen,
// which polls until Autumn reflects the new subscription, then forwards to
// redirectTo.
export function buildCheckoutSuccessUrl(redirectTo: string) {
const url = new URL(SUBSCRIBE_ROUTE, window.location.origin);
url.searchParams.set("checkout", "success");
url.searchParams.set("redirect", redirectTo);
return url.toString();
}

View File

@ -46,14 +46,17 @@ describe("getBillingRouteState", () => {
}); });
describe("getSubscribeRouteState", () => { describe("getSubscribeRouteState", () => {
// hasManagedAccess is true for essentially every hosted customer: the free
// plan is the Autumn default and grants managed_service_access too.
const base = { const base = {
hasSession: true, hasSession: true,
isCustomerLoading: false, isCustomerLoading: false,
isCustomerError: false, isCustomerError: false,
hasManagedAccess: false, hasManagedAccess: true,
planStatus: "free" as const, planStatus: "free" as const,
isUpgradeFlow: false, isUpgradeFlow: false,
checkoutCompleted: false, checkoutCompleted: false,
finalizingTimedOut: false,
}; };
it("shows an error state on billing lookup failures", () => { it("shows an error state on billing lookup failures", () => {
@ -74,29 +77,47 @@ describe("getSubscribeRouteState", () => {
); );
}); });
it("redirects grandfathered free-plan users into the app outside the upgrade flow", () => { it("redirects free-plan users into the app outside the upgrade flow", () => {
expect(getSubscribeRouteState({ ...base, hasManagedAccess: true })).toBe( expect(getSubscribeRouteState(base)).toBe("redirectToApp");
"redirectToApp", });
it("shows the paywall to free-plan users in the upgrade flow", () => {
expect(getSubscribeRouteState({ ...base, isUpgradeFlow: true })).toBe(
"showPaywall",
); );
}); });
it("shows the paywall to grandfathered users in the upgrade flow", () => { it("finalizes after checkout even though managed access would redirect", () => {
expect( // Regression: managed access is granted by the free plan, so checking it
getSubscribeRouteState({ // before checkoutCompleted sent just-paid users into the app as "free".
...base,
hasManagedAccess: true,
isUpgradeFlow: true,
}),
).toBe("showPaywall");
});
it("finalizes instead of re-showing the paywall right after checkout", () => {
expect(getSubscribeRouteState({ ...base, checkoutCompleted: true })).toBe( expect(getSubscribeRouteState({ ...base, checkoutCompleted: true })).toBe(
"finalizing", "finalizing",
); );
}); });
it("lets the user through once the finalizing window runs out", () => {
expect(
getSubscribeRouteState({
...base,
checkoutCompleted: true,
finalizingTimedOut: true,
}),
).toBe("redirectToApp");
// Even a poll error must not extend the wait past the deadline.
expect(
getSubscribeRouteState({
...base,
checkoutCompleted: true,
finalizingTimedOut: true,
isCustomerError: true,
}),
).toBe("redirectToApp");
});
it("shows the paywall to users without managed access", () => { it("shows the paywall to users without managed access", () => {
expect(getSubscribeRouteState(base)).toBe("showPaywall"); expect(getSubscribeRouteState({ ...base, hasManagedAccess: false })).toBe(
"showPaywall",
);
}); });
}); });

View File

@ -25,11 +25,20 @@ export function getSubscribeRouteState(args: {
planStatus: PlanStatus; planStatus: PlanStatus;
isUpgradeFlow: boolean; isUpgradeFlow: boolean;
checkoutCompleted: boolean; checkoutCompleted: boolean;
finalizingTimedOut: boolean;
}) { }) {
if (!args.hasSession || args.isCustomerLoading) { if (!args.hasSession || args.isCustomerLoading) {
return "loading" as const; return "loading" as const;
} }
// Hard stop for the post-checkout wait: once the finalizing window runs
// out, let the user through even if the last poll errored — entitlements
// are enforced server-side, so the worst case is briefly-stale free-plan
// UI, not a paid user stranded on a spinner or an error screen.
if (args.checkoutCompleted && args.finalizingTimedOut) {
return "redirectToApp" as const;
}
if (args.isCustomerError) { if (args.isCustomerError) {
return "error" as const; return "error" as const;
} }
@ -38,17 +47,20 @@ export function getSubscribeRouteState(args: {
return "redirectToApp" as const; return "redirectToApp" as const;
} }
// Back from Stripe but Autumn hasn't reflected the subscription yet — poll
// instead of showing the paywall again (whose only CTA is paying twice).
// This must win over the managed-access redirect below: free-plan customers
// have managed access too, so checking it first would bounce a just-paid
// user into the app still marked as free.
if (args.checkoutCompleted) {
return "finalizing" as const;
}
// Free-plan users landing here outside the upgrade flow belong in the app, // Free-plan users landing here outside the upgrade flow belong in the app,
// not on the paywall. // not on the paywall.
if (args.hasManagedAccess && !args.isUpgradeFlow) { if (args.hasManagedAccess && !args.isUpgradeFlow) {
return "redirectToApp" as const; return "redirectToApp" as const;
} }
// Back from Stripe but Autumn hasn't reflected the subscription yet — poll
// instead of showing the paywall again (whose only CTA is paying twice).
if (args.checkoutCompleted) {
return "finalizing" as const;
}
return "showPaywall" as const; return "showPaywall" as const;
} }

View File

@ -8,6 +8,8 @@ import {
type ResolveToolLabel, type ResolveToolLabel,
} from "@/client/components/chat/ChatMessage"; } from "@/client/components/chat/ChatMessage";
import { captureClientEvent } from "@/client/lib/posthog"; import { captureClientEvent } from "@/client/lib/posthog";
import { getStandardErrorMessage } from "@/client/lib/error-messages";
import { buildCheckoutSuccessUrl } from "@/client/features/billing/checkout-url";
import { AUTUMN_PAID_PLAN_ID } from "@/shared/billing"; import { AUTUMN_PAID_PLAN_ID } from "@/shared/billing";
import { FREE_ONBOARDING_QUESTION_LIMIT } from "@/shared/onboardingChat"; import { FREE_ONBOARDING_QUESTION_LIMIT } from "@/shared/onboardingChat";
import { import {
@ -110,20 +112,19 @@ export function OnboardingChatConversation({
setIsStartingCheckout(true); setIsStartingCheckout(true);
try { try {
captureClientEvent("billing:checkout_start"); captureClientEvent("billing:checkout_start");
// After payment, re-enter onboarding at the GSC step (not back into this // After payment, re-enter onboarding at the GSC step (not back into
// chat) so the user finishes connecting Search Console + MCP. // this chat) so the user finishes connecting Search Console + MCP.
const successUrl = new URL("/onboarding", window.location.origin);
successUrl.searchParams.set("step", "3");
successUrl.searchParams.set("checkout", "success");
await customerQuery.attach({ await customerQuery.attach({
planId: AUTUMN_PAID_PLAN_ID, planId: AUTUMN_PAID_PLAN_ID,
redirectMode: "always", redirectMode: "always",
successUrl: successUrl.toString(), successUrl: buildCheckoutSuccessUrl("/onboarding?step=3"),
}); });
} catch (checkoutErr) { } catch (checkoutErr) {
console.error("Failed to start checkout", checkoutErr);
setCheckoutError( setCheckoutError(
getStandardErrorMessage(
checkoutErr,
"We couldn't start checkout. Please refresh and try again.", "We couldn't start checkout. Please refresh and try again.",
),
); );
setIsStartingCheckout(false); setIsStartingCheckout(false);
} }

View File

@ -1,83 +0,0 @@
import type { RedditAttributionInput } from "@/shared/reddit-attribution";
import { redditAttributionSchema } from "@/shared/reddit-attribution";
const STORAGE_KEY = "openseo:reddit-attribution";
const SIGNUP_SENT_KEY = "openseo:reddit-signup-conversion-sent";
function readCookie(name: string) {
const prefix = `${name}=`;
return document.cookie
.split(";")
.map((part) => part.trim())
.find((part) => part.startsWith(prefix))
?.slice(prefix.length);
}
function firstSearchValue(searchParams: URLSearchParams, names: string[]) {
for (const name of names) {
const value = searchParams.get(name)?.trim();
if (value) return value;
}
return undefined;
}
export function captureRedditAttributionFromLocation() {
if (typeof window === "undefined") return;
const url = new URL(window.location.href);
const current = getStoredRedditAttribution();
const next = redditAttributionSchema.parse({
clickId:
firstSearchValue(url.searchParams, ["rdt_cid", "reddit_click_id"]) ??
current?.clickId,
uuid: readCookie("_rdt_uuid") ?? current?.uuid,
landingPage: current?.landingPage ?? url.toString(),
referrer: (current?.referrer ?? document.referrer) || undefined,
utmSource:
firstSearchValue(url.searchParams, ["utm_source"]) ?? current?.utmSource,
utmMedium:
firstSearchValue(url.searchParams, ["utm_medium"]) ?? current?.utmMedium,
utmCampaign:
firstSearchValue(url.searchParams, ["utm_campaign"]) ??
current?.utmCampaign,
utmTerm:
firstSearchValue(url.searchParams, ["utm_term"]) ?? current?.utmTerm,
utmContent:
firstSearchValue(url.searchParams, ["utm_content"]) ??
current?.utmContent,
});
if (!next.clickId && next.utmSource?.toLowerCase() !== "reddit") return;
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
}
export function getStoredRedditAttribution(): RedditAttributionInput | null {
if (typeof window === "undefined") return null;
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
try {
const parsed: unknown = JSON.parse(raw);
const result = redditAttributionSchema.safeParse(parsed);
return result.success ? result.data : null;
} catch {
return null;
}
}
export function hasMarkedRedditSignupConversion(userId: string) {
if (typeof window === "undefined") return true;
return window.localStorage.getItem(SIGNUP_SENT_KEY) === userId;
}
export function markRedditSignupConversion(userId: string) {
if (typeof window === "undefined") return;
window.localStorage.setItem(SIGNUP_SENT_KEY, userId);
}
export function unmarkRedditSignupConversion() {
if (typeof window === "undefined") return;
window.localStorage.removeItem(SIGNUP_SENT_KEY);
}

View File

@ -8,5 +8,4 @@ export * from "../better-auth-schema";
export * from "../billing.schema"; export * from "../billing.schema";
export * from "../ga4.schema"; export * from "../ga4.schema";
export * from "../gsc.schema"; export * from "../gsc.schema";
export * from "../reddit-attribution.schema";
export * from "../telemetry.schema"; export * from "../telemetry.schema";

View File

@ -1,36 +0,0 @@
import { sql } from "drizzle-orm";
import { index, pgTable, text, uniqueIndex } from "drizzle-orm/pg-core";
import { organization, user } from "./better-auth-schema";
// See src/db/pg/app.schema.ts for why timestamps are ISO-8601 UTC text.
const isoNow = sql`to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')`;
export const redditAttributions = pgTable(
"reddit_attributions",
{
id: text("id").primaryKey(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
organizationId: text("organization_id")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
clickId: text("click_id"),
uuid: text("uuid"),
landingPage: text("landing_page"),
referrer: text("referrer"),
utmSource: text("utm_source"),
utmMedium: text("utm_medium"),
utmCampaign: text("utm_campaign"),
utmTerm: text("utm_term"),
utmContent: text("utm_content"),
signupSentAt: text("signup_sent_at"),
purchaseSentAt: text("purchase_sent_at"),
createdAt: text("created_at").notNull().default(isoNow),
updatedAt: text("updated_at").notNull().default(isoNow),
},
(table) => [
uniqueIndex("reddit_attributions_user_idx").on(table.userId),
index("reddit_attributions_organization_idx").on(table.organizationId),
],
);

View File

@ -5,5 +5,4 @@ export * from "./better-auth-schema";
export * from "./billing.schema"; export * from "./billing.schema";
export * from "./ga4.schema"; export * from "./ga4.schema";
export * from "./gsc.schema"; export * from "./gsc.schema";
export * from "./reddit-attribution.schema";
export * from "./telemetry.schema"; export * from "./telemetry.schema";

View File

@ -1,37 +0,0 @@
import { sql } from "drizzle-orm";
import { index, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
import { organization, user } from "./better-auth-schema";
export const redditAttributions = sqliteTable(
"reddit_attributions",
{
id: text("id").primaryKey(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
organizationId: text("organization_id")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
clickId: text("click_id"),
uuid: text("uuid"),
landingPage: text("landing_page"),
referrer: text("referrer"),
utmSource: text("utm_source"),
utmMedium: text("utm_medium"),
utmCampaign: text("utm_campaign"),
utmTerm: text("utm_term"),
utmContent: text("utm_content"),
signupSentAt: text("signup_sent_at"),
purchaseSentAt: text("purchase_sent_at"),
createdAt: text("created_at")
.notNull()
.default(sql`(current_timestamp)`),
updatedAt: text("updated_at")
.notNull()
.default(sql`(current_timestamp)`),
},
(table) => [
uniqueIndex("reddit_attributions_user_idx").on(table.userId),
index("reddit_attributions_organization_idx").on(table.organizationId),
],
);

View File

@ -11,7 +11,6 @@ import * as sqliteAuth from "./better-auth-schema";
import * as sqliteBilling from "./billing.schema"; import * as sqliteBilling from "./billing.schema";
import * as sqliteGa4 from "./ga4.schema"; import * as sqliteGa4 from "./ga4.schema";
import * as sqliteGsc from "./gsc.schema"; import * as sqliteGsc from "./gsc.schema";
import * as sqliteReddit from "./reddit-attribution.schema";
import * as sqliteTelemetry from "./telemetry.schema"; import * as sqliteTelemetry from "./telemetry.schema";
import * as pgApp from "./pg/app.schema"; import * as pgApp from "./pg/app.schema";
import * as pgAudit from "./pg/audit.schema"; import * as pgAudit from "./pg/audit.schema";
@ -20,7 +19,6 @@ import * as pgAuth from "./pg/better-auth-schema";
import * as pgBilling from "./pg/billing.schema"; import * as pgBilling from "./pg/billing.schema";
import * as pgGa4 from "./pg/ga4.schema"; import * as pgGa4 from "./pg/ga4.schema";
import * as pgGsc from "./pg/gsc.schema"; import * as pgGsc from "./pg/gsc.schema";
import * as pgReddit from "./pg/reddit-attribution.schema";
import * as pgTelemetry from "./pg/telemetry.schema"; import * as pgTelemetry from "./pg/telemetry.schema";
// Guards the ONE structural artifact `db:generate` does not regenerate: the // Guards the ONE structural artifact `db:generate` does not regenerate: the
@ -150,7 +148,6 @@ const sqliteAppTables = tablesFrom(
sqliteBilling, sqliteBilling,
sqliteGa4, sqliteGa4,
sqliteGsc, sqliteGsc,
sqliteReddit,
sqliteTelemetry, sqliteTelemetry,
); );
const pgAppTables = tablesFrom( const pgAppTables = tablesFrom(
@ -160,7 +157,6 @@ const pgAppTables = tablesFrom(
pgBilling, pgBilling,
pgGa4, pgGa4,
pgGsc, pgGsc,
pgReddit,
pgTelemetry, pgTelemetry,
); );
const sqliteAuthTables = tablesFrom(sqliteAuth); const sqliteAuthTables = tablesFrom(sqliteAuth);

View File

@ -6,7 +6,6 @@ import * as sqliteAuth from "./better-auth-schema";
import * as sqliteBilling from "./billing.schema"; import * as sqliteBilling from "./billing.schema";
import * as sqliteGa4 from "./ga4.schema"; import * as sqliteGa4 from "./ga4.schema";
import * as sqliteGsc from "./gsc.schema"; import * as sqliteGsc from "./gsc.schema";
import * as sqliteReddit from "./reddit-attribution.schema";
import * as sqliteTelemetry from "./telemetry.schema"; import * as sqliteTelemetry from "./telemetry.schema";
import * as pgApp from "./pg/app.schema"; import * as pgApp from "./pg/app.schema";
import * as pgAudit from "./pg/audit.schema"; import * as pgAudit from "./pg/audit.schema";
@ -15,7 +14,6 @@ import * as pgAuth from "./pg/better-auth-schema";
import * as pgBilling from "./pg/billing.schema"; import * as pgBilling from "./pg/billing.schema";
import * as pgGa4 from "./pg/ga4.schema"; import * as pgGa4 from "./pg/ga4.schema";
import * as pgGsc from "./pg/gsc.schema"; import * as pgGsc from "./pg/gsc.schema";
import * as pgReddit from "./pg/reddit-attribution.schema";
import * as pgTelemetry from "./pg/telemetry.schema"; import * as pgTelemetry from "./pg/telemetry.schema";
// Canonical schema barrel. Repositories import their tables from here and the // Canonical schema barrel. Repositories import their tables from here and the
@ -35,7 +33,6 @@ type AppSchema = typeof sqliteApp &
typeof sqliteBilling & typeof sqliteBilling &
typeof sqliteGa4 & typeof sqliteGa4 &
typeof sqliteGsc & typeof sqliteGsc &
typeof sqliteReddit &
typeof sqliteTelemetry; typeof sqliteTelemetry;
const runtimeSchema = const runtimeSchema =
@ -48,7 +45,6 @@ const runtimeSchema =
...pgBilling, ...pgBilling,
...pgGa4, ...pgGa4,
...pgGsc, ...pgGsc,
...pgReddit,
...pgTelemetry, ...pgTelemetry,
} }
: { : {
@ -59,7 +55,6 @@ const runtimeSchema =
...sqliteBilling, ...sqliteBilling,
...sqliteGa4, ...sqliteGa4,
...sqliteGsc, ...sqliteGsc,
...sqliteReddit,
...sqliteTelemetry, ...sqliteTelemetry,
}; };
@ -96,6 +91,5 @@ export const {
billingCustomerStatus, billingCustomerStatus,
ga4Connections, ga4Connections,
gscConnections, gscConnections,
redditAttributions,
telemetryState, telemetryState,
} = schema; } = schema;

View File

@ -40,6 +40,11 @@ describe("auth redirect helpers", () => {
expect(normalizeAuthRedirect("//evil.test")).toBe("/"); expect(normalizeAuthRedirect("//evil.test")).toBe("/");
}); });
it("rejects backslash redirects that URL parsers treat as slashes", () => {
expect(normalizeAuthRedirect("/\\evil.test")).toBe("/");
expect(normalizeAuthRedirect("/path\\..\\evil")).toBe("/");
});
it("builds sign-in links with the redirect query only when needed", () => { it("builds sign-in links with the redirect query only when needed", () => {
expect(getSignInHref("/")).toBe("/sign-in"); expect(getSignInHref("/")).toBe("/sign-in");
expect(getSignInHref("/oauth-consent?client_id=abc")).toBe( expect(getSignInHref("/oauth-consent?client_id=abc")).toBe(

View File

@ -3,7 +3,15 @@ const OAUTH_SIGNED_QUERY_END = "sig";
const OAUTH_AUTHORIZE_MARKERS = ["response_type", "client_id", "redirect_uri"]; const OAUTH_AUTHORIZE_MARKERS = ["response_type", "client_id", "redirect_uri"];
export function normalizeAuthRedirect(value: string | null | undefined) { export function normalizeAuthRedirect(value: string | null | undefined) {
if (!value || !value.startsWith("/") || value.startsWith("//")) { // Backslashes are rejected because URL parsers treat them as slashes:
// "/\evil.com" resolves cross-origin, an open redirect via
// window.location sinks.
if (
!value ||
!value.startsWith("/") ||
value.startsWith("//") ||
value.includes("\\")
) {
return "/"; return "/";
} }

View File

@ -20,13 +20,6 @@ import {
startAnalyticsCapture, startAnalyticsCapture,
stopAnalyticsCapture, stopAnalyticsCapture,
} from "@/client/lib/posthog"; } from "@/client/lib/posthog";
import {
captureRedditAttributionFromLocation,
getStoredRedditAttribution,
hasMarkedRedditSignupConversion,
markRedditSignupConversion,
unmarkRedditSignupConversion,
} from "@/client/lib/reddit-attribution";
import { NotFound } from "@/client/components/NotFound"; import { NotFound } from "@/client/components/NotFound";
import appCss from "@/client/styles/app.css?url"; import appCss from "@/client/styles/app.css?url";
import { useSession } from "@/lib/auth-client"; import { useSession } from "@/lib/auth-client";
@ -34,7 +27,6 @@ import { isHostedClientAuthMode } from "@/lib/auth-mode";
import { Toaster } from "sonner"; import { Toaster } from "sonner";
import { queryClient } from "@/client/tanstack-db"; import { queryClient } from "@/client/tanstack-db";
import { getActiveOrganizationId } from "@/lib/auth-session"; import { getActiveOrganizationId } from "@/lib/auth-session";
import { captureRedditConversionEvent } from "@/serverFunctions/redditConversions";
export const Route = createRootRoute({ export const Route = createRootRoute({
head: () => ({ head: () => ({
@ -108,11 +100,6 @@ function PostHogBootstrap() {
const optedOut = session?.user?.analyticsOptedOut === true; const optedOut = session?.user?.analyticsOptedOut === true;
const organizationId = getActiveOrganizationId(session); const organizationId = getActiveOrganizationId(session);
const previousUserIdRef = React.useRef<string | null>(null); const previousUserIdRef = React.useRef<string | null>(null);
const redditSignupInFlightRef = React.useRef(false);
React.useEffect(() => {
captureRedditAttributionFromLocation();
}, []);
React.useEffect(() => { React.useEffect(() => {
if (!isHostedMode || isSessionPending) { if (!isHostedMode || isSessionPending) {
@ -131,32 +118,6 @@ function PostHogBootstrap() {
} }
}, [isHostedMode, isSessionPending, optedOut, organizationId, userId]); }, [isHostedMode, isSessionPending, optedOut, organizationId, userId]);
React.useEffect(() => {
if (!isHostedMode || isSessionPending || !userId) return;
if (hasMarkedRedditSignupConversion(userId)) return;
const attribution = getStoredRedditAttribution();
if (!attribution) return;
if (redditSignupInFlightRef.current) return;
redditSignupInFlightRef.current = true;
void captureRedditConversionEvent({
data: { attribution, eventType: "SIGN_UP" },
})
.then((result) => {
if (result.status === "sent" || result.status === "already_sent") {
markRedditSignupConversion(userId);
}
})
.catch(() => {
// The server deduplicates this event; allow a future session to retry.
unmarkRedditSignupConversion();
})
.finally(() => {
redditSignupInFlightRef.current = false;
});
}, [isHostedMode, isSessionPending, userId]);
return null; return null;
} }

View File

@ -1,10 +1,11 @@
import { createFileRoute, notFound } from "@tanstack/react-router"; import { createFileRoute, notFound } from "@tanstack/react-router";
import { useCustomer } from "autumn-js/react"; import { useCustomer } from "autumn-js/react";
import { useEffect, useState } from "react"; import { useState } from "react";
import { useSession } from "@/lib/auth-client"; import { useSession } from "@/lib/auth-client";
import { isHostedClientAuthMode } from "@/lib/auth-mode"; import { isHostedClientAuthMode } from "@/lib/auth-mode";
import { captureClientEvent } from "@/client/lib/posthog";
import { getStandardErrorMessage } from "@/client/lib/error-messages"; import { getStandardErrorMessage } from "@/client/lib/error-messages";
import { getStoredRedditAttribution } from "@/client/lib/reddit-attribution"; import { buildCheckoutSuccessUrl } from "@/client/features/billing/checkout-url";
import { BillingUsageChart } from "@/client/features/billing/BillingUsageChart"; import { BillingUsageChart } from "@/client/features/billing/BillingUsageChart";
import { BillingFeatureBreakdown } from "@/client/features/billing/BillingFeatureBreakdown"; import { BillingFeatureBreakdown } from "@/client/features/billing/BillingFeatureBreakdown";
import { parseTopUpAmount } from "@/client/features/billing/HostedBillingContentUtils"; import { parseTopUpAmount } from "@/client/features/billing/HostedBillingContentUtils";
@ -12,6 +13,7 @@ import { getBillingRouteState } from "@/client/features/billing/route-state";
import { getCustomerPlanStatus } from "@/client/features/billing/plan-detection"; import { getCustomerPlanStatus } from "@/client/features/billing/plan-detection";
import { import {
AUTUMN_PAID_PLAN_ID, AUTUMN_PAID_PLAN_ID,
BILLING_ROUTE,
AUTUMN_SEO_DATA_BALANCE_FEATURE_ID, AUTUMN_SEO_DATA_BALANCE_FEATURE_ID,
LOW_CREDITS_THRESHOLD_USD, LOW_CREDITS_THRESHOLD_USD,
AUTUMN_SEO_DATA_CREDITS_PER_USD, AUTUMN_SEO_DATA_CREDITS_PER_USD,
@ -19,7 +21,6 @@ import {
AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID, AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID,
autumnSeoDataCreditsToUsd, autumnSeoDataCreditsToUsd,
} from "@/shared/billing"; } from "@/shared/billing";
import { captureRedditConversionEvent } from "@/serverFunctions/redditConversions";
export const Route = createFileRoute("/_app/billing")({ export const Route = createFileRoute("/_app/billing")({
beforeLoad: () => { beforeLoad: () => {
@ -63,20 +64,6 @@ function BillingPage() {
const { isValid: isValidTopUp, parsed: parsedTopUpAmount } = const { isValid: isValidTopUp, parsed: parsedTopUpAmount } =
parseTopUpAmount(topUpAmount); parseTopUpAmount(topUpAmount);
const checkoutCompleted =
typeof window !== "undefined" &&
new URLSearchParams(window.location.search).get("checkout") === "success";
useEffect(() => {
if (!checkoutCompleted || billingRouteState !== "ready") return;
const attribution = getStoredRedditAttribution();
if (!attribution) return;
void captureRedditConversionEvent({
data: { attribution, eventType: "PURCHASE" },
});
}, [billingRouteState, checkoutCompleted]);
if (billingRouteState === "loading") { if (billingRouteState === "loading") {
return null; return null;
@ -105,6 +92,15 @@ function BillingPage() {
); );
} }
function startUpgradeCheckout() {
captureClientEvent("billing:checkout_start");
return customerQuery.attach({
planId: AUTUMN_PAID_PLAN_ID,
redirectMode: "always",
successUrl: buildCheckoutSuccessUrl(BILLING_ROUTE),
});
}
async function runAction( async function runAction(
callback: () => Promise<unknown>, callback: () => Promise<unknown>,
fallbackMessage: string, fallbackMessage: string,
@ -207,12 +203,7 @@ function BillingPage() {
disabled={isPending} disabled={isPending}
onClick={() => onClick={() =>
void runAction( void runAction(
() => startUpgradeCheckout,
customerQuery.attach({
planId: AUTUMN_PAID_PLAN_ID,
redirectMode: "always",
successUrl: `${window.location.origin}${window.location.pathname}?checkout=success`,
}),
"We couldn't start the checkout. Please try again.", "We couldn't start the checkout. Please try again.",
) )
} }

View File

@ -4,7 +4,6 @@ import { useEffect, useState } from "react";
import { ArrowRight, Settings, User } from "lucide-react"; import { ArrowRight, Settings, User } from "lucide-react";
import { ThemePreferenceMenuItems } from "@/client/components/ThemePreferenceMenuItems"; import { ThemePreferenceMenuItems } from "@/client/components/ThemePreferenceMenuItems";
import { captureClientEvent } from "@/client/lib/posthog"; import { captureClientEvent } from "@/client/lib/posthog";
import { getStoredRedditAttribution } from "@/client/lib/reddit-attribution";
import { signOutAndRedirect, useSession } from "@/lib/auth-client"; import { signOutAndRedirect, useSession } from "@/lib/auth-client";
import { isHostedClientAuthMode } from "@/lib/auth-mode"; import { isHostedClientAuthMode } from "@/lib/auth-mode";
import { getStandardErrorMessage } from "@/client/lib/error-messages"; import { getStandardErrorMessage } from "@/client/lib/error-messages";
@ -15,7 +14,6 @@ import {
AUTUMN_MANAGED_ACCESS_FEATURE_ID, AUTUMN_MANAGED_ACCESS_FEATURE_ID,
AUTUMN_PAID_PLAN_ID, AUTUMN_PAID_PLAN_ID,
} from "@/shared/billing"; } from "@/shared/billing";
import { captureRedditConversionEvent } from "@/serverFunctions/redditConversions";
const SUPPORT_EMAIL = "ben@openseo.so"; const SUPPORT_EMAIL = "ben@openseo.so";
@ -26,29 +24,33 @@ const PLAN_FEATURES = [
"Includes $10.00 of Usage Credits each month", "Includes $10.00 of Usage Credits each month",
]; ];
// How long the post-checkout "finalizing" screen polls Autumn before giving
// up and letting the user through anyway.
const FINALIZING_TIMEOUT_MS = 30_000;
export const Route = createFileRoute("/_authenticated/subscribe")({ export const Route = createFileRoute("/_authenticated/subscribe")({
validateSearch: ( validateSearch: (
search: Record<string, unknown>, search: Record<string, unknown>,
): { upgrade?: true; redirect?: string } => ({ ): { upgrade?: true; redirect?: string; checkout?: "success" } => ({
upgrade: upgrade:
search.upgrade === true || search.upgrade === "true" ? true : undefined, search.upgrade === true || search.upgrade === "true" ? true : undefined,
redirect: redirect:
typeof search.redirect === "string" typeof search.redirect === "string"
? normalizeAuthRedirect(search.redirect) ? normalizeAuthRedirect(search.redirect)
: undefined, : undefined,
checkout: search.checkout === "success" ? "success" : undefined,
}), }),
component: SubscribePage, component: SubscribePage,
}); });
function SubscribePage() { function SubscribePage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { upgrade: isUpgradeFlow, redirect } = Route.useSearch(); const { upgrade: isUpgradeFlow, redirect, checkout } = Route.useSearch();
const { data: session } = useSession(); const { data: session } = useSession();
const [isAttaching, setIsAttaching] = useState(false); const [isAttaching, setIsAttaching] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const checkoutCompleted = const [finalizingTimedOut, setFinalizingTimedOut] = useState(false);
typeof window !== "undefined" && const checkoutCompleted = checkout === "success";
new URLSearchParams(window.location.search).get("checkout") === "success";
const hasSession = Boolean(session?.user?.id); const hasSession = Boolean(session?.user?.id);
const customerQuery = useCustomer({ const customerQuery = useCustomer({
@ -74,43 +76,39 @@ function SubscribePage() {
planStatus, planStatus,
isUpgradeFlow: isUpgradeFlow === true, isUpgradeFlow: isUpgradeFlow === true,
checkoutCompleted, checkoutCompleted,
finalizingTimedOut,
}); });
// Autumn can lag Stripe by a few seconds after checkout; poll until the // Autumn can lag Stripe by a few seconds after checkout; poll until the
// subscription shows up so the just-paid user isn't shown the paywall again. // subscription shows up so the just-paid user isn't shown the paywall again.
const isFinalizing = subscribeRouteState === "finalizing"; const isFinalizing = subscribeRouteState === "finalizing";
const { refetch: refetchCustomer } = customerQuery;
useEffect(() => { useEffect(() => {
if (!isFinalizing) return; if (!isFinalizing) return;
const interval = setInterval(() => { const interval = setInterval(() => {
void customerQuery.refetch(); void refetchCustomer();
}, 2000); }, 2000);
return () => clearInterval(interval); return () => clearInterval(interval);
}, [customerQuery, isFinalizing]); }, [refetchCustomer, isFinalizing]);
// Armed once on landing with checkout=success (not on the finalizing state,
// which a transient poll error can leave and re-enter) so the deadline is a
// hard bound from arrival.
useEffect(() => {
if (!checkoutCompleted || finalizingTimedOut) return;
const timeout = setTimeout(
() => setFinalizingTimedOut(true),
FINALIZING_TIMEOUT_MS,
);
return () => clearTimeout(timeout);
}, [checkoutCompleted, finalizingTimedOut]);
useEffect(() => { useEffect(() => {
if (subscribeRouteState === "redirectToApp") { if (subscribeRouteState === "redirectToApp") {
const destination = redirect ?? "/";
const [destinationPath, destinationQuery] = destination.split("?");
const destinationSearch: Record<string, string> = destinationQuery
? Object.fromEntries(new URLSearchParams(destinationQuery))
: {};
const goToApp = () =>
void navigate({
to: destinationPath,
search: destinationSearch,
replace: true,
});
if (checkoutCompleted) { if (checkoutCompleted) {
captureClientEvent("billing:checkout_success"); captureClientEvent("billing:checkout_success");
const attribution = getStoredRedditAttribution();
if (attribution) {
void captureRedditConversionEvent({
data: { attribution, eventType: "PURCHASE" },
}).finally(goToApp);
return;
} }
} void navigate({ href: redirect ?? "/", replace: true });
goToApp();
} }
}, [checkoutCompleted, navigate, redirect, subscribeRouteState]); }, [checkoutCompleted, navigate, redirect, subscribeRouteState]);

View File

@ -1,169 +0,0 @@
import { env } from "cloudflare:workers";
import { eq } from "drizzle-orm";
import { db } from "@/db";
import { redditAttributions } from "@/db/schema";
import {
hasRedditAttribution,
type RedditAttributionInput,
} from "@/shared/reddit-attribution";
type RedditConversionType = "SIGN_UP" | "PURCHASE";
type CaptureRedditConversionArgs = {
attribution: RedditAttributionInput;
conversionId: string;
email: string;
eventType: RedditConversionType;
organizationId: string;
userId: string;
valueDecimal?: number;
currency?: string;
};
function getEnv(name: string) {
const value: unknown = Reflect.get(env, name);
return typeof value === "string" ? value.trim() : "";
}
async function sha256(value: string) {
const bytes = new TextEncoder().encode(value.trim().toLowerCase());
const hash = await crypto.subtle.digest("SHA-256", bytes);
return [...new Uint8Array(hash)]
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("");
}
function getRedditConfig() {
const pixelId = getEnv("REDDIT_PIXEL_ID");
const accessToken = getEnv("REDDIT_CONVERSIONS_ACCESS_TOKEN");
if (!pixelId || !accessToken) return null;
return { accessToken, pixelId };
}
async function upsertAttribution(args: CaptureRedditConversionArgs) {
const existing = await db.query.redditAttributions.findFirst({
where: eq(redditAttributions.userId, args.userId),
});
const now = new Date().toISOString();
if (existing) {
await db
.update(redditAttributions)
.set({
clickId: existing.clickId ?? args.attribution.clickId,
uuid: existing.uuid ?? args.attribution.uuid,
landingPage: existing.landingPage ?? args.attribution.landingPage,
referrer: existing.referrer ?? args.attribution.referrer,
utmSource: existing.utmSource ?? args.attribution.utmSource,
utmMedium: existing.utmMedium ?? args.attribution.utmMedium,
utmCampaign: existing.utmCampaign ?? args.attribution.utmCampaign,
utmTerm: existing.utmTerm ?? args.attribution.utmTerm,
utmContent: existing.utmContent ?? args.attribution.utmContent,
updatedAt: now,
})
.where(eq(redditAttributions.userId, args.userId));
} else {
await db.insert(redditAttributions).values({
id: crypto.randomUUID(),
userId: args.userId,
organizationId: args.organizationId,
clickId: args.attribution.clickId,
uuid: args.attribution.uuid,
landingPage: args.attribution.landingPage,
referrer: args.attribution.referrer,
utmSource: args.attribution.utmSource,
utmMedium: args.attribution.utmMedium,
utmCampaign: args.attribution.utmCampaign,
utmTerm: args.attribution.utmTerm,
utmContent: args.attribution.utmContent,
createdAt: now,
updatedAt: now,
});
}
return args.eventType === "SIGN_UP"
? Boolean(existing?.signupSentAt)
: Boolean(existing?.purchaseSentAt);
}
async function markConversionSent(args: CaptureRedditConversionArgs) {
const now = new Date().toISOString();
const sentColumn =
args.eventType === "SIGN_UP" ? "signupSentAt" : "purchaseSentAt";
await db
.update(redditAttributions)
.set({
[sentColumn]: now,
updatedAt: now,
})
.where(eq(redditAttributions.userId, args.userId));
}
export async function captureRedditConversion(
args: CaptureRedditConversionArgs,
) {
if (!hasRedditAttribution(args.attribution)) return "skipped" as const;
const alreadySent = await upsertAttribution(args);
if (alreadySent) return "already_sent" as const;
const config = getRedditConfig();
if (!config) return "stored" as const;
const metadata: Record<string, unknown> = {
conversion_id: args.conversionId,
};
if (args.valueDecimal !== undefined) {
metadata.currency = args.currency ?? "USD";
metadata.item_count = 1;
metadata.value = args.valueDecimal;
}
const payload = {
data: {
events: [
{
action_source: "WEBSITE",
click_id: args.attribution.clickId,
event_at: Date.now(),
metadata,
type: {
tracking_type: args.eventType,
},
user: {
email: await sha256(args.email),
external_id: await sha256(args.userId),
uuid: args.attribution.uuid,
},
},
],
},
};
const response = await fetch(
`https://ads-api.reddit.com/api/v3/pixels/${config.pixelId}/conversion_events`,
{
method: "POST",
headers: {
Authorization: `Bearer ${config.accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
},
);
if (!response.ok) {
console.error("reddit conversion capture failed", {
status: response.status,
eventType: args.eventType,
userId: args.userId,
});
return "failed" as const;
}
await markConversionSent(args);
return "sent" as const;
}

View File

@ -1,28 +0,0 @@
import { createServerFn } from "@tanstack/react-start";
import { z } from "zod";
import { captureRedditConversion } from "@/server/lib/reddit-conversions";
import { redditAttributionSchema } from "@/shared/reddit-attribution";
import { requireAuthenticatedContext } from "@/serverFunctions/middleware";
const conversionInputSchema = z.object({
attribution: redditAttributionSchema,
eventType: z.enum(["SIGN_UP", "PURCHASE"]),
});
export const captureRedditConversionEvent = createServerFn({ method: "POST" })
.middleware(requireAuthenticatedContext)
.validator(conversionInputSchema)
.handler(async ({ data, context }) => {
const status = await captureRedditConversion({
attribution: data.attribution,
conversionId: `${data.eventType === "SIGN_UP" ? "signup" : "purchase"}:${context.userId}`,
email: context.userEmail,
eventType: data.eventType,
organizationId: context.organizationId,
userId: context.userId,
valueDecimal: data.eventType === "PURCHASE" ? 10 : undefined,
currency: data.eventType === "PURCHASE" ? "USD" : undefined,
});
return { status };
});

View File

@ -1,24 +0,0 @@
import { z } from "zod";
export const redditAttributionSchema = z.object({
clickId: z.string().trim().min(1).max(256).optional(),
uuid: z.string().trim().min(1).max(256).optional(),
landingPage: z.string().trim().min(1).max(2048).optional(),
referrer: z.string().trim().max(2048).optional(),
utmSource: z.string().trim().min(1).max(256).optional(),
utmMedium: z.string().trim().min(1).max(256).optional(),
utmCampaign: z.string().trim().min(1).max(256).optional(),
utmTerm: z.string().trim().min(1).max(256).optional(),
utmContent: z.string().trim().min(1).max(256).optional(),
});
export type RedditAttributionInput = z.infer<typeof redditAttributionSchema>;
export function hasRedditAttribution(input: RedditAttributionInput) {
return Boolean(
input.clickId ||
input.uuid ||
input.utmSource?.toLowerCase() === "reddit" ||
input.referrer?.toLowerCase().includes("reddit."),
);
}