Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 33 additions & 14 deletions docs/app/pricing/tiers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { cn } from "@/lib/fumadocs/cn";
import * as Sentry from "@sentry/nextjs";
import { track } from "@vercel/analytics";
import { CheckIcon } from "lucide-react";
import React from "react";
import React, { useState } from "react";

type Frequency = "month" | "year";

Expand Down Expand Up @@ -37,11 +37,12 @@ function TierCTAButton({
frequency: Frequency;
}) {
const { data: session } = useSession();
const [isLoading, setIsLoading] = useState(false);
let text =
tier.cta === "get-started"
? "Get Started"
: tier.cta === "buy"
? "Sign up"
? "Buy now"
: tier.cta === "contact"
? "Contact us"
: "Sign up";
Expand Down Expand Up @@ -71,6 +72,7 @@ function TierCTAButton({
!isPurple &&
!isGreen &&
"bg-white border border-stone-300 text-stone-900 hover:border-purple-300 hover:text-purple-600",
isLoading && "pointer-events-none opacity-70",
);

return (
Expand All @@ -80,18 +82,19 @@ function TierCTAButton({
return;
}

track("Signup", { tier: tier.id });
if (!session) {
Sentry.captureEvent({
message: "click-pricing-signup",
level: "info",
extra: { tier: tier.id },
});
track("click-pricing-signup", { tier: tier.id });
// Prevent repeat clicks from opening duplicate checkout sessions while
// the request is in flight.
if (isLoading) {
e.preventDefault();
e.stopPropagation();
return;
}

if (session.planType === "free") {
track("Signup", { tier: tier.id });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Remove the retired Signup analytics event for buy CTAs.

Line 93 still fires before both pay-first checkout and portal-management branches, so logged-out purchases now record both Signup and click-pricing-buy-now, while signed-in manage/update clicks are also counted as signups. Delete it or move it to a true signup-only path.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/app/pricing/tiers.tsx` at line 93, The pricing CTA handler still fires
the retired Signup analytics event in the shared click path, so both purchase
and portal-management actions are being misclassified. Update the handler in
tiers.tsx around the CTA tracking logic to remove track("Signup", { tier:
tier.id }) from the buy/manage flow, or move it into a true signup-only branch
that only runs for actual registration actions. Keep the existing
click-pricing-buy-now and related event tracking in the pay-first and
portal-management branches unchanged.

if (!session || session.planType === "free") {
// Pay-first: logged-out buyers go straight to checkout (no sign-up
// wall). They're reconciled to an account by email in the webhook and
// emailed a sign-in link (see lib/auth.ts).
Sentry.captureEvent({
message: "click-pricing-buy-now",
level: "info",
Expand All @@ -104,7 +107,16 @@ function TierCTAButton({
frequency === "year" && tier.id === "business"
? "business-yearly"
: tier.id;
await authClient.checkout({ slug: checkoutSlug });
setIsLoading(true);
try {
const ret = await authClient.checkout({ slug: checkoutSlug });
if (ret?.error) {
throw new Error(JSON.stringify(ret.error));
}
} catch (err) {
Sentry.captureException(err);
setIsLoading(false);
}
} else {
const isCurrentPlan =
tier.id === "business"
Expand All @@ -127,11 +139,18 @@ function TierCTAButton({
}
e.preventDefault();
e.stopPropagation();
await authClient.customer.portal();
setIsLoading(true);
try {
await authClient.customer.portal();
} catch (err) {
Sentry.captureException(err);
setIsLoading(false);
}
}
}}
href={tier.href ?? (session ? undefined : "/signup")}
href={tier.href ?? undefined}
aria-describedby={tier.id}
aria-disabled={isLoading}
className={buttonClasses}
>
{text}
Expand Down
93 changes: 90 additions & 3 deletions docs/lib/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,10 @@ export const auth = betterAuth({
},
],
successUrl: "/thanks",
authenticatedUsersOnly: true,
// Pay-first: allow logged-out checkout. The buyer is reconciled to a
// BlockNote account by email in the webhook below
// (resolveUserForCustomer), then emailed a sign-in link.
authenticatedUsersOnly: false,
}),
portal(),
webhooks({
Expand All @@ -230,11 +233,16 @@ export const auth = betterAuth({
case "subscription.revoked":
case "subscription.created":
case "subscription.uncanceled": {
const authContext = await auth.$context;
const userId = payload.data.customer.externalId;
// Resolve the BlockNote account for this purchase. For pay-first
// (logged-out) checkouts the customer has no externalId, so this
// creates/links an account by email and sends a sign-in link.
const userId = await resolveUserForCustomer(
payload.data.customer,
);
if (!userId) {
return;
}
const authContext = await auth.$context;
Comment on lines +236 to +245

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Don’t silently acknowledge unresolved paid subscriptions.

If resolveUserForCustomer returns null, the webhook exits successfully and the buyer’s paid plan is never provisioned or alerted on. Capture a non-PII Sentry error/message before returning, or throw if this should be retried.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/lib/auth.ts` around lines 236 - 245, The paid-subscription webhook path
in resolveUserForCustomer handling currently returns early when no userId is
found, which silently drops unresolved purchases. Update the auth.ts flow around
resolveUserForCustomer/auth.$context to either record a non-PII Sentry
error/message before returning or throw an error so the webhook can be retried,
while preserving the existing customer resolution logic.

if (payload.data.status === "active") {
const productId = payload.data.product.id;
const planType = Object.values(PRODUCTS).find(
Expand Down Expand Up @@ -296,3 +304,82 @@ export const auth = betterAuth({
}),
},
});

// For "pay-first" checkouts the buyer may not have an account yet: the Polar
// customer has no externalId because they checked out while logged out. Resolve
// the BlockNote user for a Polar customer — creating one keyed on the checkout
// email when needed — so the purchase can be provisioned and the buyer gets
// access (via a sign-in link) to the account holding their new plan.
async function resolveUserForCustomer(customer: {
id: string;
externalId?: string | null;
email?: string | null;
name?: string | null;
}): Promise<string | null> {
// Authenticated purchase: the customer is already linked to a user.
if (customer.externalId) {
return customer.externalId;
}
const email = customer.email;
if (!email) {
return null;
}

const authContext = await auth.$context;
const existing = await authContext.internalAdapter.findUserByEmail(email);
if (existing?.user) {
// Existing account, but this logged-out purchase created an unlinked Polar
// customer — link it so the buyer's subscription is found.
await linkPolarCustomer(customer.id, existing.user.id);
return existing.user.id;
}

try {
const created = await authContext.internalAdapter.createUser({
email,
name: customer.name || email,
emailVerified: false,
});
// Link the Polar customer to the new account. Subscription and
// customer-portal lookups resolve a user's customer by externalId, so
// without this the buyer couldn't access or manage the plan they bought.
await linkPolarCustomer(customer.id, created.id);
// New account → email a sign-in link so they can access the plan they
// just bought. A mail failure must not block provisioning.
try {
await auth.api.signInMagicLink({
body: { email, callbackURL: "/pricing" },
headers: new Headers(),
});
} catch (err) {
Sentry.captureException(err);
}
return created.id;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} catch (err) {
// A concurrent webhook for the same purchase may have created the user
// first (email is unique). Re-fetch instead of failing provisioning.
const retry = await authContext.internalAdapter.findUserByEmail(email);
if (retry?.user) {
await linkPolarCustomer(customer.id, retry.user.id);
return retry.user.id;
}
throw err;
}
}

// Link a Polar customer to a BlockNote user by setting the customer's
// externalId. Subscription and customer-portal lookups resolve a user's Polar
// customer by `externalId === user.id`, so a logged-out (pay-first) purchase
// must be linked here or the buyer can't access/manage their subscription.
// Best-effort: a failure is reported but must not block provisioning the plan,
// and subsequent subscription events retry the link via the same path.
async function linkPolarCustomer(customerId: string, userId: string) {
try {
await polarClient.customers.update({
id: customerId,
customerUpdate: { externalId: userId },
});
} catch (err) {
Sentry.captureException(err);
}
}
Loading