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=
# POSTHOG_PUBLIC_KEY=
# POSTHOG_HOST=
# REDDIT_PIXEL_ID=
# REDDIT_CONVERSIONS_ACCESS_TOKEN=
# OPENROUTER_API_KEY=
# OPENROUTER_MODEL=

View File

@ -280,10 +280,6 @@ const dataEnv = {
),
POSTHOG_PUBLIC_KEY: optionalVar("POSTHOG_PUBLIC_KEY"),
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_SITE_KEY: optionalVar("TURNSTILE_SITE_KEY"),
// 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,
"tag": "0017_ga4_connections",
"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,
"tag": "0039_ga4_connections",
"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", () => {
// hasManagedAccess is true for essentially every hosted customer: the free
// plan is the Autumn default and grants managed_service_access too.
const base = {
hasSession: true,
isCustomerLoading: false,
isCustomerError: false,
hasManagedAccess: false,
hasManagedAccess: true,
planStatus: "free" as const,
isUpgradeFlow: false,
checkoutCompleted: false,
finalizingTimedOut: false,
};
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", () => {
expect(getSubscribeRouteState({ ...base, hasManagedAccess: true })).toBe(
"redirectToApp",
it("redirects free-plan users into the app outside the upgrade flow", () => {
expect(getSubscribeRouteState(base)).toBe("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", () => {
expect(
getSubscribeRouteState({
...base,
hasManagedAccess: true,
isUpgradeFlow: true,
}),
).toBe("showPaywall");
});
it("finalizes instead of re-showing the paywall right after checkout", () => {
it("finalizes after checkout even though managed access would redirect", () => {
// Regression: managed access is granted by the free plan, so checking it
// before checkoutCompleted sent just-paid users into the app as "free".
expect(getSubscribeRouteState({ ...base, checkoutCompleted: true })).toBe(
"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", () => {
expect(getSubscribeRouteState(base)).toBe("showPaywall");
expect(getSubscribeRouteState({ ...base, hasManagedAccess: false })).toBe(
"showPaywall",
);
});
});

View File

@ -25,11 +25,20 @@ export function getSubscribeRouteState(args: {
planStatus: PlanStatus;
isUpgradeFlow: boolean;
checkoutCompleted: boolean;
finalizingTimedOut: boolean;
}) {
if (!args.hasSession || args.isCustomerLoading) {
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) {
return "error" as const;
}
@ -38,17 +47,20 @@ export function getSubscribeRouteState(args: {
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,
// not on the paywall.
if (args.hasManagedAccess && !args.isUpgradeFlow) {
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;
}

View File

@ -8,6 +8,8 @@ import {
type ResolveToolLabel,
} from "@/client/components/chat/ChatMessage";
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 { FREE_ONBOARDING_QUESTION_LIMIT } from "@/shared/onboardingChat";
import {
@ -110,20 +112,19 @@ export function OnboardingChatConversation({
setIsStartingCheckout(true);
try {
captureClientEvent("billing:checkout_start");
// After payment, re-enter onboarding at the GSC step (not back into 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");
// After payment, re-enter onboarding at the GSC step (not back into
// this chat) so the user finishes connecting Search Console + MCP.
await customerQuery.attach({
planId: AUTUMN_PAID_PLAN_ID,
redirectMode: "always",
successUrl: successUrl.toString(),
successUrl: buildCheckoutSuccessUrl("/onboarding?step=3"),
});
} catch (checkoutErr) {
console.error("Failed to start checkout", checkoutErr);
setCheckoutError(
"We couldn't start checkout. Please refresh and try again.",
getStandardErrorMessage(
checkoutErr,
"We couldn't start checkout. Please refresh and try again.",
),
);
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 "../ga4.schema";
export * from "../gsc.schema";
export * from "../reddit-attribution.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 "./ga4.schema";
export * from "./gsc.schema";
export * from "./reddit-attribution.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 sqliteGa4 from "./ga4.schema";
import * as sqliteGsc from "./gsc.schema";
import * as sqliteReddit from "./reddit-attribution.schema";
import * as sqliteTelemetry from "./telemetry.schema";
import * as pgApp from "./pg/app.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 pgGa4 from "./pg/ga4.schema";
import * as pgGsc from "./pg/gsc.schema";
import * as pgReddit from "./pg/reddit-attribution.schema";
import * as pgTelemetry from "./pg/telemetry.schema";
// Guards the ONE structural artifact `db:generate` does not regenerate: the
@ -150,7 +148,6 @@ const sqliteAppTables = tablesFrom(
sqliteBilling,
sqliteGa4,
sqliteGsc,
sqliteReddit,
sqliteTelemetry,
);
const pgAppTables = tablesFrom(
@ -160,7 +157,6 @@ const pgAppTables = tablesFrom(
pgBilling,
pgGa4,
pgGsc,
pgReddit,
pgTelemetry,
);
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 sqliteGa4 from "./ga4.schema";
import * as sqliteGsc from "./gsc.schema";
import * as sqliteReddit from "./reddit-attribution.schema";
import * as sqliteTelemetry from "./telemetry.schema";
import * as pgApp from "./pg/app.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 pgGa4 from "./pg/ga4.schema";
import * as pgGsc from "./pg/gsc.schema";
import * as pgReddit from "./pg/reddit-attribution.schema";
import * as pgTelemetry from "./pg/telemetry.schema";
// Canonical schema barrel. Repositories import their tables from here and the
@ -35,7 +33,6 @@ type AppSchema = typeof sqliteApp &
typeof sqliteBilling &
typeof sqliteGa4 &
typeof sqliteGsc &
typeof sqliteReddit &
typeof sqliteTelemetry;
const runtimeSchema =
@ -48,7 +45,6 @@ const runtimeSchema =
...pgBilling,
...pgGa4,
...pgGsc,
...pgReddit,
...pgTelemetry,
}
: {
@ -59,7 +55,6 @@ const runtimeSchema =
...sqliteBilling,
...sqliteGa4,
...sqliteGsc,
...sqliteReddit,
...sqliteTelemetry,
};
@ -96,6 +91,5 @@ export const {
billingCustomerStatus,
ga4Connections,
gscConnections,
redditAttributions,
telemetryState,
} = schema;

View File

@ -40,6 +40,11 @@ describe("auth redirect helpers", () => {
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", () => {
expect(getSignInHref("/")).toBe("/sign-in");
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"];
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 "/";
}

View File

@ -20,13 +20,6 @@ import {
startAnalyticsCapture,
stopAnalyticsCapture,
} from "@/client/lib/posthog";
import {
captureRedditAttributionFromLocation,
getStoredRedditAttribution,
hasMarkedRedditSignupConversion,
markRedditSignupConversion,
unmarkRedditSignupConversion,
} from "@/client/lib/reddit-attribution";
import { NotFound } from "@/client/components/NotFound";
import appCss from "@/client/styles/app.css?url";
import { useSession } from "@/lib/auth-client";
@ -34,7 +27,6 @@ import { isHostedClientAuthMode } from "@/lib/auth-mode";
import { Toaster } from "sonner";
import { queryClient } from "@/client/tanstack-db";
import { getActiveOrganizationId } from "@/lib/auth-session";
import { captureRedditConversionEvent } from "@/serverFunctions/redditConversions";
export const Route = createRootRoute({
head: () => ({
@ -108,11 +100,6 @@ function PostHogBootstrap() {
const optedOut = session?.user?.analyticsOptedOut === true;
const organizationId = getActiveOrganizationId(session);
const previousUserIdRef = React.useRef<string | null>(null);
const redditSignupInFlightRef = React.useRef(false);
React.useEffect(() => {
captureRedditAttributionFromLocation();
}, []);
React.useEffect(() => {
if (!isHostedMode || isSessionPending) {
@ -131,32 +118,6 @@ function PostHogBootstrap() {
}
}, [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;
}

View File

@ -1,10 +1,11 @@
import { createFileRoute, notFound } from "@tanstack/react-router";
import { useCustomer } from "autumn-js/react";
import { useEffect, useState } from "react";
import { useState } from "react";
import { useSession } from "@/lib/auth-client";
import { isHostedClientAuthMode } from "@/lib/auth-mode";
import { captureClientEvent } from "@/client/lib/posthog";
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 { BillingFeatureBreakdown } from "@/client/features/billing/BillingFeatureBreakdown";
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 {
AUTUMN_PAID_PLAN_ID,
BILLING_ROUTE,
AUTUMN_SEO_DATA_BALANCE_FEATURE_ID,
LOW_CREDITS_THRESHOLD_USD,
AUTUMN_SEO_DATA_CREDITS_PER_USD,
@ -19,7 +21,6 @@ import {
AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID,
autumnSeoDataCreditsToUsd,
} from "@/shared/billing";
import { captureRedditConversionEvent } from "@/serverFunctions/redditConversions";
export const Route = createFileRoute("/_app/billing")({
beforeLoad: () => {
@ -63,20 +64,6 @@ function BillingPage() {
const { isValid: isValidTopUp, parsed: parsedTopUpAmount } =
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") {
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(
callback: () => Promise<unknown>,
fallbackMessage: string,
@ -207,12 +203,7 @@ function BillingPage() {
disabled={isPending}
onClick={() =>
void runAction(
() =>
customerQuery.attach({
planId: AUTUMN_PAID_PLAN_ID,
redirectMode: "always",
successUrl: `${window.location.origin}${window.location.pathname}?checkout=success`,
}),
startUpgradeCheckout,
"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 { ThemePreferenceMenuItems } from "@/client/components/ThemePreferenceMenuItems";
import { captureClientEvent } from "@/client/lib/posthog";
import { getStoredRedditAttribution } from "@/client/lib/reddit-attribution";
import { signOutAndRedirect, useSession } from "@/lib/auth-client";
import { isHostedClientAuthMode } from "@/lib/auth-mode";
import { getStandardErrorMessage } from "@/client/lib/error-messages";
@ -15,7 +14,6 @@ import {
AUTUMN_MANAGED_ACCESS_FEATURE_ID,
AUTUMN_PAID_PLAN_ID,
} from "@/shared/billing";
import { captureRedditConversionEvent } from "@/serverFunctions/redditConversions";
const SUPPORT_EMAIL = "ben@openseo.so";
@ -26,29 +24,33 @@ const PLAN_FEATURES = [
"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")({
validateSearch: (
search: Record<string, unknown>,
): { upgrade?: true; redirect?: string } => ({
): { upgrade?: true; redirect?: string; checkout?: "success" } => ({
upgrade:
search.upgrade === true || search.upgrade === "true" ? true : undefined,
redirect:
typeof search.redirect === "string"
? normalizeAuthRedirect(search.redirect)
: undefined,
checkout: search.checkout === "success" ? "success" : undefined,
}),
component: SubscribePage,
});
function SubscribePage() {
const navigate = useNavigate();
const { upgrade: isUpgradeFlow, redirect } = Route.useSearch();
const { upgrade: isUpgradeFlow, redirect, checkout } = Route.useSearch();
const { data: session } = useSession();
const [isAttaching, setIsAttaching] = useState(false);
const [error, setError] = useState<string | null>(null);
const checkoutCompleted =
typeof window !== "undefined" &&
new URLSearchParams(window.location.search).get("checkout") === "success";
const [finalizingTimedOut, setFinalizingTimedOut] = useState(false);
const checkoutCompleted = checkout === "success";
const hasSession = Boolean(session?.user?.id);
const customerQuery = useCustomer({
@ -74,43 +76,39 @@ function SubscribePage() {
planStatus,
isUpgradeFlow: isUpgradeFlow === true,
checkoutCompleted,
finalizingTimedOut,
});
// 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.
const isFinalizing = subscribeRouteState === "finalizing";
const { refetch: refetchCustomer } = customerQuery;
useEffect(() => {
if (!isFinalizing) return;
const interval = setInterval(() => {
void customerQuery.refetch();
void refetchCustomer();
}, 2000);
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(() => {
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) {
captureClientEvent("billing:checkout_success");
const attribution = getStoredRedditAttribution();
if (attribution) {
void captureRedditConversionEvent({
data: { attribution, eventType: "PURCHASE" },
}).finally(goToApp);
return;
}
}
goToApp();
void navigate({ href: redirect ?? "/", replace: true });
}
}, [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."),
);
}