DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Our signup creates a Stripe customer before it creates the user, and checks for a duplicate account twice

PubTrivia's signup form asks for three things: a name, an email and a password. The page is deliberately plain, because the interesting part is not the form, it is the five things that have to happen behind it and the order they happen in.

For a normal signup, in order:

  1. refuse if the site is in maintenance mode
  2. rate limit, 10 per 15 minutes per IP
  3. select from our users_table to see if the email is taken
  4. create the account with the auth provider
  5. create a Stripe customer
  6. insert a users_table row
  7. insert a venues row
  8. insert a venue_members row making the signup its owner

Steps 5 to 8 are inside one try. Nothing here is a transaction, and two of these systems are not our database.

The duplicate check that looks duplicated

Step 3 is a plain select:

const existingDBUser = await db.select({ id: usersTable.id })
    .from(usersTable)
    .where(eq(usersTable.email, data.email))

if (existingDBUser.length > 0) {
    return { message: "An account with this email already exists. Please login instead.", success: false }
}
Enter fullscreen mode Exit fullscreen mode

Then, after the provider call, this:

if (signUpError) {
    if (signUpError.message.includes("already registered")) {
        return { message: "An account with this email already exists. Please login instead.", success: false }
    }
    return { message: signUpError.message, success: false }
}
Enter fullscreen mode Exit fullscreen mode

Same sentence, two sources of truth, one of them a substring match on a message another team can reword in a minor release. The obvious review comment is: delete one.

Keeping both is deliberate, because they do not describe the same state. The select reads our table. The provider's error reads the provider's table. Those two can disagree, and the exact way they disagree is the failure mode of everything below.

An account whose steps 5 to 8 fell over exists with the auth provider and does not exist in users_table. The select finds nothing, so it waves that person through, and the only thing standing between them and a second provider account is the string "already registered" coming back from a system that already has them. The first check is the fast, cheap, well-worded path for the normal case. The second is the one that catches drift. A substring match on a foreign error message is an unpleasant thing to depend on, so the honest summary is that this is a known weak joint, written down here so the next person does not tidy it away.

Both messages do confirm to a stranger that an address has an account. That is a trade we made with eyes open: the alternative is the "if an account exists you will receive an email" wording, which for a product whose signups are venue owners sitting in their own pub turns a solved problem into a support ticket. The reset flow, which is the thing worth enumerating, does not confirm anything.

Why Stripe goes first

Step 5 before step 6 looks backwards. The customer is created at the payment processor before the row exists in our own database:

const stripeID = await createStripeCustomer(signUpData.user.id, signUpData.user.email!, data.name)

await db.insert(usersTable).values({
    id: signUpData.user.id,
    name: data.name,
    email: signUpData.user.email!,
    stripe_id: stripeID,
    plan: null,
})
Enter fullscreen mode Exit fullscreen mode

The reason is that stripe_id is a column on the row being inserted. Insert first and you need a second statement to fill it in, and now there is a window where a row claims no payment identity. Create the customer first and the row is complete the moment it exists.

What that buys is one consistent shape in the database and one recovery path instead of two. What it costs is a Stripe customer with no user, every time something after it throws. Those are free, invisible, and never billed, and they are the cheapest piece of garbage in the system. The same call is also made with metadata: { supabase_id: id }, so an orphan is identifiable later rather than anonymous.

Notice also that plan: null. A different post of ours is entirely about how null in that column once meant "unlimited" to one piece of code and "no plan" to another, which is a good illustration of why I now write out what a nullable column means before I add it.

What the catch actually leaves behind

} catch (err) {
    console.error("Error in signup:", err instanceof Error ? err.message : err)
    return { message: "Failed to setup user account", success: false }
}
Enter fullscreen mode Exit fullscreen mode

One message for every failure in four steps. The provider account survives, because this catch has no way to delete it and would not be trusted to try.

Following that account forward is the useful exercise. The person confirms their email, signs in successfully, and arrives at a dashboard that calls getUserVenue(), which returns null, which the page handles:

{venue?.name ?? 'Welcome'}
Enter fullscreen mode Exit fullscreen mode

So they see a working, empty dashboard with no venue, no tables and no way to start a quiz. And if they try to pay, the checkout route looks their email up in users_table, finds nothing, and answers:

if (!userRecord) {
    return NextResponse.json({ error: 'User not found' }, { status: 400 })
}
Enter fullscreen mode Exit fullscreen mode

"User not found", to somebody who is signed in. Every one of those screens is individually defensive and correct. Together they describe an account that cannot explain what is wrong with it, which is worse than an error at signup would have been.

There is a recovery helper for the adjacent problem, ensureStripeCustomer, which repairs a stripe_id that Stripe no longer recognises. It cannot help here, because it starts from the row that is missing.

The branch for invited hosts

Ultimate is five host accounts on one subscription, and an invited host signs up through this same action with a token in the URL. That path inserts the user row and stops:

const nextPath = data.inviteToken
    ? `/auth/invite?token=${data.inviteToken}`
    : '/dashboard'
Enter fullscreen mode Exit fullscreen mode

No venue, no membership row. They get both by accepting the invite after confirming their email, and the token rides through the provider's confirmation redirect as a query parameter so the confirm link lands back on the accept route. One signup action, two shapes of account, and the difference is whether step 7 runs.

What I would do differently

Not much, which is the uncomfortable part. The ordering is defensible, the orphan is cheap, and the two duplicate checks cover genuinely different states.

The missing piece is not better code in signup, it is a reconciliation job: one query that lists auth accounts with no users_table row, run on a schedule, with a repair that creates the row and the venue rather than asking the customer to sign up again. Every system that writes to three stores without a transaction needs one of those eventually, and the honest version of this post is that we have the three stores and not yet the job.

If you are building this exact flow, the one thing to copy is the ordering rule rather than the order: cheap local checks before anything consumable, and the foreign identifier before the row that is supposed to hold it.

Top comments (1)

Collapse
 
elijahbrown profile image
Elijah Brown •

One more state where the second check earns its place. The select compares the raw form input, but the row stores signUpData.user.email, which comes back from the provider. If the provider lowercases addresses, someone who signed up as jo@pub.com and comes back typing Jo@Pub.com gets straight past the select, because Postgres equality is case sensitive, and only the "already registered" string stops them. Trimming and lowercasing data.email before the select, or making the column citext, would let the cheap check catch that too and leave the substring match for the real drift case.