@@ -217,7 +217,10 @@ export const auth = betterAuth({
217217 } ,
218218 ] ,
219219 successUrl : "/thanks" ,
220- authenticatedUsersOnly : true ,
220+ // Pay-first: allow logged-out checkout. The buyer is reconciled to a
221+ // BlockNote account by email in the webhook below
222+ // (resolveUserForCustomer), then emailed a sign-in link.
223+ authenticatedUsersOnly : false ,
221224 } ) ,
222225 portal ( ) ,
223226 webhooks ( {
@@ -230,11 +233,16 @@ export const auth = betterAuth({
230233 case "subscription.revoked" :
231234 case "subscription.created" :
232235 case "subscription.uncanceled" : {
233- const authContext = await auth . $context ;
234- const userId = payload . data . customer . externalId ;
236+ // Resolve the BlockNote account for this purchase. For pay-first
237+ // (logged-out) checkouts the customer has no externalId, so this
238+ // creates/links an account by email and sends a sign-in link.
239+ const userId = await resolveUserForCustomer (
240+ payload . data . customer ,
241+ ) ;
235242 if ( ! userId ) {
236243 return ;
237244 }
245+ const authContext = await auth . $context ;
238246 if ( payload . data . status === "active" ) {
239247 const productId = payload . data . product . id ;
240248 const planType = Object . values ( PRODUCTS ) . find (
@@ -296,3 +304,82 @@ export const auth = betterAuth({
296304 } ) ,
297305 } ,
298306} ) ;
307+
308+ // For "pay-first" checkouts the buyer may not have an account yet: the Polar
309+ // customer has no externalId because they checked out while logged out. Resolve
310+ // the BlockNote user for a Polar customer — creating one keyed on the checkout
311+ // email when needed — so the purchase can be provisioned and the buyer gets
312+ // access (via a sign-in link) to the account holding their new plan.
313+ async function resolveUserForCustomer ( customer : {
314+ id : string ;
315+ externalId ?: string | null ;
316+ email ?: string | null ;
317+ name ?: string | null ;
318+ } ) : Promise < string | null > {
319+ // Authenticated purchase: the customer is already linked to a user.
320+ if ( customer . externalId ) {
321+ return customer . externalId ;
322+ }
323+ const email = customer . email ;
324+ if ( ! email ) {
325+ return null ;
326+ }
327+
328+ const authContext = await auth . $context ;
329+ const existing = await authContext . internalAdapter . findUserByEmail ( email ) ;
330+ if ( existing ?. user ) {
331+ // Existing account, but this logged-out purchase created an unlinked Polar
332+ // customer — link it so the buyer's subscription is found.
333+ await linkPolarCustomer ( customer . id , existing . user . id ) ;
334+ return existing . user . id ;
335+ }
336+
337+ try {
338+ const created = await authContext . internalAdapter . createUser ( {
339+ email,
340+ name : customer . name || email ,
341+ emailVerified : false ,
342+ } ) ;
343+ // Link the Polar customer to the new account. Subscription and
344+ // customer-portal lookups resolve a user's customer by externalId, so
345+ // without this the buyer couldn't access or manage the plan they bought.
346+ await linkPolarCustomer ( customer . id , created . id ) ;
347+ // New account → email a sign-in link so they can access the plan they
348+ // just bought. A mail failure must not block provisioning.
349+ try {
350+ await auth . api . signInMagicLink ( {
351+ body : { email, callbackURL : "/pricing" } ,
352+ headers : new Headers ( ) ,
353+ } ) ;
354+ } catch ( err ) {
355+ Sentry . captureException ( err ) ;
356+ }
357+ return created . id ;
358+ } catch ( err ) {
359+ // A concurrent webhook for the same purchase may have created the user
360+ // first (email is unique). Re-fetch instead of failing provisioning.
361+ const retry = await authContext . internalAdapter . findUserByEmail ( email ) ;
362+ if ( retry ?. user ) {
363+ await linkPolarCustomer ( customer . id , retry . user . id ) ;
364+ return retry . user . id ;
365+ }
366+ throw err ;
367+ }
368+ }
369+
370+ // Link a Polar customer to a BlockNote user by setting the customer's
371+ // externalId. Subscription and customer-portal lookups resolve a user's Polar
372+ // customer by `externalId === user.id`, so a logged-out (pay-first) purchase
373+ // must be linked here or the buyer can't access/manage their subscription.
374+ // Best-effort: a failure is reported but must not block provisioning the plan,
375+ // and subsequent subscription events retry the link via the same path.
376+ async function linkPolarCustomer ( customerId : string , userId : string ) {
377+ try {
378+ await polarClient . customers . update ( {
379+ id : customerId ,
380+ customerUpdate : { externalId : userId } ,
381+ } ) ;
382+ } catch ( err ) {
383+ Sentry . captureException ( err ) ;
384+ }
385+ }
0 commit comments