Skip to content

Commit 12ef019

Browse files
fix(billing): only reconcile Pro on checkout return, match strictly by user id
Reconciling against Dodo on every account-page load — combined with an email-based subscription match — let unrelated/new accounts resolve to an active subscription and get flipped to Pro. Now reconcile only when returning from a successful checkout (?upgrade=success) and match strictly by the keyloom_user_id stamped into checkout metadata. The webhook keeps state in sync for everything else.
1 parent 776e7f5 commit 12ef019

2 files changed

Lines changed: 21 additions & 10 deletions

File tree

apps/web/app/account/page.tsx

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,16 +11,25 @@ import { AccountClient } from "./account-client";
1111
// Always fresh — keys/usage change per request.
1212
export const dynamic = "force-dynamic";
1313

14-
export default async function AccountPage() {
14+
export default async function AccountPage({
15+
searchParams,
16+
}: {
17+
searchParams: Promise<{ upgrade?: string }>;
18+
}) {
1519
// No `ensureSignedIn` here — that redirects during render and tries to set the
1620
// PKCE cookie (forbidden in render). Instead send signed-out users to the
1721
// sign-in route handler, which can set it.
1822
const { user } = await withAuth();
1923
if (!user) redirect("/api/auth/signin");
2024
await ensureAccount(user.id, user.email);
21-
// Verify-on-return: ask Dodo if they've subscribed and flip to Pro right away,
22-
// so a successful payment shows up without waiting on a webhook.
23-
await reconcileProFromDodo(user.id, user.email);
25+
// Verify-on-return: ONLY when the user is returning from a successful Dodo
26+
// checkout (`?upgrade=success`). Running this on every load made unrelated
27+
// accounts resolve to an active Dodo subscription and get flipped to Pro.
28+
// Routine logins must never reconcile — the webhook keeps state in sync.
29+
const { upgrade } = await searchParams;
30+
if (upgrade === "success") {
31+
await reconcileProFromDodo(user.id, user.email);
32+
}
2433

2534
const [subscription, keys] = await Promise.all([
2635
getSubscription(user.id),

apps/web/lib/billing.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -82,8 +82,13 @@ export type ActiveDodoSubscription = {
8282
/**
8383
* Asks Dodo directly whether this user currently has an active/trialing
8484
* subscription. Used to reconcile account state on page load WITHOUT waiting for
85-
* a webhook (verify-on-return). Matches by the keyloom_user_id we stamp into
86-
* checkout metadata, falling back to the customer email.
85+
* a webhook (verify-on-return). Matches STRICTLY by the keyloom_user_id we stamp
86+
* into checkout metadata at `createProCheckout` time.
87+
*
88+
* We deliberately do NOT fall back to matching the customer email: emails are
89+
* not unique to a keyloom account (shared/test emails, re-used addresses) and
90+
* an email-based match would let one subscription upgrade unrelated accounts to
91+
* Pro. Identity comes only from the id we control.
8792
*/
8893
export async function findActiveDodoSubscription(opts: {
8994
userId: string;
@@ -97,11 +102,8 @@ export async function findActiveDodoSubscription(opts: {
97102
data?: DodoSubscription[];
98103
};
99104
const items = res.items ?? res.data ?? [];
100-
const email = opts.email.toLowerCase();
101105
const mine = items.filter(
102-
(s) =>
103-
s.metadata?.keyloom_user_id === opts.userId ||
104-
s.customer?.email?.toLowerCase() === email,
106+
(s) => !!opts.userId && s.metadata?.keyloom_user_id === opts.userId,
105107
);
106108
const active = mine.find(
107109
(s) => s.status === "active" || s.status === "trialing",

0 commit comments

Comments
 (0)