Self-serve trial signup with card verification
Node · Node · intermediate · modification
Adds self-serve trial signup so sales no longer creates trial accounts by hand. One request creates the account, its owner and a trial subscription in a single Prisma transaction, so a failed signup leaves nothing half-created. The card is verified with a $1 hold placed after the owner insert, so a duplicate email gets a 409 without touching the card, and a declined card gets a 402. The owner receives the welcome email with the sign-in link; an SMTP failure is caught and logged and does not fail the signup. The interactive transaction gets a 20 s timeout because the hold can take up to 10 s.
Express 4 API (CommonJS) with Prisma 5 on PostgreSQL 16, default isolation (read committed). Existing, unchanged: `src/db.js` exports the shared `prisma` client; `validateSignup(body)` in `src/validation/signup.js` accepts any value and returns `{ value }` with trimmed strings, a lowercased email and a `plan` from the public plan list, or `{ error }` with a message safe to show; `hashPassword` (argon2id) in `src/auth/passwords.js`; `src/logger.js` is a pino logger; `publicLimits` applies a per-IP rate limit and a bot check to every `/api` route; `routes/auth.js` serves the login. `src/billing.js` exports `placeVerificationHold({ paymentMethodId, email })`, which places a $1 authorization hold through the card provider with a 10 s timeout and resolves `{ id }`, rejects with `CardDeclinedError` (exported there) when the card is declined, and with another `Error` on any other failure. `src/mail.js` exports `sendWelcome({ to, name, company })`, which sends the welcome message with the login link through a nodemailer SMTP transporter with 5 s connection, greeting and socket timeouts and rejects when sending fails; a message the SMTP server accepted cannot be recalled. Schema (unchanged): `Account {id, name}`, `User {id, accountId, name, email @unique, passwordHash, role Role}` with `enum Role { OWNER MEMBER }`, `Subscription {id, accountId, planCode, status SubscriptionStatus, cardHoldId}` with `TRIALING` among the statuses. `User.email` is the only unique constraint a signup can hit, and every writer stores it lowercased; plan codes are checked by `validateSignup`, with no foreign key.
Requirements
- `POST /api/signup` (public) takes a JSON body `{company, name, email, password, plan, paymentMethodId}`, checked and normalised by the existing `validateSignup` (400 with its message when it fails). On success it answers 201 with `{"accountId": <id>}`; the owner signs in afterwards on the login page.
- A signup is all or nothing: the account, its owner user (role `OWNER`) and a `TRIALING` subscription carrying the card hold id are created in one Prisma transaction, and after any failure none of the three exists.
- Every trial needs a verified card: a $1 verification hold on `paymentMethodId` is placed after the owner row is inserted, so an email that already has a user answers 409 without touching the card (a product decision: the form then tells the visitor to sign in instead). A declined card answers 402 `{"error": "card declined"}`. A verification hold is never captured and the provider releases it on its own, so a hold left behind by a signup that fails after it needs no cleanup and costs the customer nothing (a product decision). Any other failure answers 500 with a generic message; the detail goes to the log.
- The new owner gets the welcome email for a signup that succeeded, and never for a signup that failed: everyone who receives it must be able to sign in with the address it was sent to. Delivery is best effort: when sending fails the signup still succeeds, the error is logged with the user id, and support resends the email by hand.
Files touched
- src/app.js
- src/routes/signup.js
- src/services/signup.js
--- src/app.js
const { errorHandler } = require('./middleware/errors');
const authRouter = require('./routes/auth');
+const signupRouter = require('./routes/signup');
const meRouter = require('./routes/me');
app.use('/api', publicLimits);
app.use('/api', authRouter);
+app.use('/api', signupRouter);
app.use('/api', requireAuth, meRouter);
app.use(errorHandler);
--- src/routes/signup.js
+const express = require('express');
+const { Prisma } = require('@prisma/client');
+const { validateSignup } = require('../validation/signup');
+const { createSignup } = require('../services/signup');
+const { CardDeclinedError } = require('../billing');
+const logger = require('../logger');
+
+const router = express.Router();
+
+router.post('/signup', async (req, res) => {
+ const { value, error } = validateSignup(req.body);
+ if (error) {
+ return res.status(400).json({ error });
+ }
+
+ try {
+ const { accountId } = await createSignup(value);
+ return res.status(201).json({ accountId });