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

Files touched

--- 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 });

Review this PR

Node practice