fix(billing): activate paid plans immediately after checkout (EVE-44) (#469)
This commit is contained in:
parent
cfc8456767
commit
2cedcda5db
@ -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=
|
||||
|
||||
@ -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
|
||||
|
||||
1
drizzle-pg/0018_drop_reddit_attributions.sql
Normal file
1
drizzle-pg/0018_drop_reddit_attributions.sql
Normal file
@ -0,0 +1 @@
|
||||
DROP TABLE "reddit_attributions" CASCADE;
|
||||
3608
drizzle-pg/meta/0018_snapshot.json
Normal file
3608
drizzle-pg/meta/0018_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
1
drizzle/0040_drop_reddit_attributions.sql
Normal file
1
drizzle/0040_drop_reddit_attributions.sql
Normal file
@ -0,0 +1 @@
|
||||
DROP TABLE `reddit_attributions`;
|
||||
3277
drizzle/meta/0040_snapshot.json
Normal file
3277
drizzle/meta/0040_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
11
src/client/features/billing/checkout-url.ts
Normal file
11
src/client/features/billing/checkout-url.ts
Normal 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();
|
||||
}
|
||||
@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
@ -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";
|
||||
|
||||
@ -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),
|
||||
],
|
||||
);
|
||||
@ -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";
|
||||
|
||||
@ -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),
|
||||
],
|
||||
);
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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 "/";
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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.",
|
||||
)
|
||||
}
|
||||
|
||||
@ -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]);
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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 };
|
||||
});
|
||||
@ -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."),
|
||||
);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user