Guide: test locally, then deploy to Vercel
Commands are for Windows cmd.exe (lines starting REM are comments). Setup of Node, Postgres and the two database roles is in LOCAL_SETUP_WINDOWS.md — do that first, then come back here.
Honesty note. The automated checks below (lint, typecheck, 211 integration/unit tests, 14 browser tests) were run against a local PostgreSQL 16. Nothing has been run on Vercel, Neon/Supabase, Paystack or Resend. Those parts of this guide are written from their documentation; treat the first real deployment as the verification, and use a Paystack test key before a live one.
Part 1 — Test locally
1.1 Automated checks
npm install
npm run lint
npm run typecheck
npm test
REM browser tests (once: npx playwright install chromium)
npm run build
npm run test:e2e
npm test wipes TEST_DATABASE_URL (a database whose name contains test). Expected: 17 files / 211 tests, 14 browser tests.
1.2 Run the app
npm run db:seed
npm run dev
Open http://localhost:3000. Emails (verification, invitations, resets) print in the cmd window. PAYMENT_PROVIDER=sandbox and APP_ENV=development in .env let you pay with a fake checkout.
1.3 Manual click-through (≈20 minutes)
| # | Do this | You should see |
|---|---|---|
| 1 | Sign up → create a business, industry Pharmacy | Setup wizard completes; dashboard with pharmacy nav (Batches, Dispensing register) |
| 1b | Products → Import from Excel → download the template, fill two rows (one with an unknown unit), upload | Preview shows the bad row with a reason; fix it, re-upload, Import → products and opening stock appear |
| 2 | Products → add Paracetamol 500mg, set a generic name; add another product with the same generic name | Both saved |
| 3 | Purchasing → receive stock with batch + expiry | Received as GRN-…; batch listed under Batches |
| 4 | Receive a batch with an expired date, then try to sell it at POS | "Insufficient sellable stock" |
| 5 | POS → add item → Find substitutes → pick the generic, enter a reason → complete sale | Sale completes; entry in Dispensing register with substitution reason; CSV export downloads |
| 6 | Customers → open a customer | Purchase history; Rx/restricted items are hidden unless your role has dispensing/audit rights |
| 7 | Create a second business, industry Electronics → Compatibility: link a phone to a case → sell the phone | Accessory suggestion chip at POS |
| 8 | Settings → set a high-value threshold; sell above it as a cashier | Blocked (needs sales.high_value) |
| 9 | Repairs → file a supplier warranty claim, then record the outcome | Claim status shown on the ticket |
| 10 | Billing → choose Growth, toggle Annual → Pay | Sandbox checkout page → Pay → redirected back, plan active, invoice/receipt under Billing → Invoices |
| 11 | Settings → Security → enable MFA with an authenticator app, sign out, sign in | Code prompt; recovery codes work once |
| 12 | Make yourself platform admin (below) → /admin |
Plans, Feature flags, System (job runs, failed jobs, health) |
Make a platform admin (replace the email):
set PGPASSWORD=bmt
"C:\Program Files\PostgreSQL\16\bin\psql" -U bmt -h localhost -d bizcore -c "UPDATE \"User\" SET \"platformRole\"='ADMIN' WHERE email='you@example.com';"
Admin pages require MFA (no grace), so enrol MFA first (step 11).
Trigger the scheduled jobs by hand (needs CRON_SECRET of 16+ characters in .env):
curl -H "Authorization: Bearer YOUR_CRON_SECRET" http://localhost:3000/api/cron/jobs
Wrong/missing secret → 401; secret not configured → 503.
Part 2 — Deploy to Vercel
2.1 What Vercel can and cannot do here
- The app (Next.js) runs on Vercel. PostgreSQL does not — use a hosted Postgres (Neon, Supabase, Railway, RDS…).
- There is no always-on worker. Scheduled work runs through Vercel Cron →
/api/cron/jobs(vercel.jsonalready contains it, once a day — the Hobby-plan maximum; Pro can run more often). - Migrations run automatically on every production deploy (
scripts/vercel-build.mjsrunsnpm run db:deploy, then the build). This needsDIRECT_URL(the owner connection) set in Vercel; preview deploys never touch the database. You can still run step 2.3 by hand.
2.2 Create the database
- Create a Postgres 15/16 database with your provider.
- You need two connection strings:
- Direct (owner, used for migrations): the non-pooled URL.
- Pooled (runtime): the pooler URL (PgBouncer). Add
?pgbouncer=true&connection_limit=5to it.
- Row-level security only works when the app connects as a non-owner, non-superuser role.
npm run db:rolescreatesbmt_appfrom the password inDATABASE_URL, so the runtime URL's user must bebmt_appwith the password you choose. Some providers (Supabase pooler) append the user to the name (bmt_app.<project>); keep that form in the runtime URL, but make sure the role itself isbmt_app.
2.3 Migrate and create the app role (from your PC)
Put the production values in a temporary cmd session — never in a committed file:
set DIRECT_URL=postgresql://OWNER:PASSWORD@HOST:5432/DBNAME
set DATABASE_URL=postgresql://bmt_app:CHOSEN_APP_PASSWORD@HOST:5432/DBNAME
npx prisma migrate deploy
npm run db:roles
npm run db:seed
db:seed creates the default plans only (do not set SEED_DEMO). Re-run migrate deploy + db:roles after any release that adds a migration. This is a production database operation: take a backup/snapshot first.
2.4 Push the code and import the project
The code is on branch claude/dreamy-dirac-idduc8. In Vercel: Add New → Project → import the GitHub repository, set the production branch you want (merge or point at this branch), framework Next.js (auto-detected; vercel.json sets the build). postinstall runs prisma generate; the Prisma schema targets Vercel's runtime (rhel-openssl-3.0.x).
2.5 Environment variables (Project → Settings → Environment Variables)
| Name | Value |
|---|---|
DATABASE_URL |
pooled URL, user bmt_app (see 2.2) |
DIRECT_URL |
direct owner URL (required by the Prisma schema; the app does not use it at runtime) |
APP_URL |
https://your-domain (no trailing slash) — used in emails and payment callbacks |
APP_SECRET |
32+ random chars. Keep stable: rotating it forces everyone to re-enrol MFA |
CRON_SECRET |
16+ random chars; Vercel sends it to the cron route automatically |
PAYMENT_PROVIDER |
paystack (production) |
PAYSTACK_SECRET_KEY |
sk_test_… first, sk_live_… later |
EMAIL_PROVIDER |
resend |
RESEND_API_KEY, EMAIL_FROM |
from Resend; sender must be on a domain you verified there |
Generate secrets: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))".
Environment rule: VERCEL_ENV=production makes the app strict — the sandbox payment provider is refused. Preview deployments allow the sandbox, so use PAYMENT_PROVIDER=sandbox on Preview and paystack on Production. Never put APP_ENV=development/test on Production.
Without Resend, sign-up still works (verification isn't a hard gate) but no email is delivered, including invitations and resets — configure it before real users.
2.6 Paystack
- Dashboard → Settings → API Keys & Webhooks: copy the test secret key into
PAYSTACK_SECRET_KEY. - Set the Webhook URL to
https://your-domain/api/webhooks/billing. Paystack signs with your secret key (HMAC-SHA512); the app verifies it. - The checkout callback is
https://your-domain/app/billing/callback(the app sends it itself; the callback re-verifies the transaction with Paystack and never trusts the redirect). - Test with Paystack test cards, confirm the invoice becomes PAID and the plan activates, then switch to the live key. Currency must match the plan currency or the payment is rejected and recorded as FAILED.
2.7 Deploy and smoke-test
Deploy, then:
curl https://your-domain/api/health
curl https://your-domain/api/ready
curl -i https://your-domain/api/cron/jobs
REM expect 401 without the bearer token
curl -H "Authorization: Bearer YOUR_CRON_SECRET" https://your-domain/api/cron/jobs
/api/ready fails if the database is unreachable. Then repeat steps 1, 10, 11, 12 of the click-through on the live URL. Make the first platform admin with the SQL from 1.3 run against the production DB (direct URL) — a deliberate manual step.
2.8 After launch
- Custom domain: Project → Settings → Domains; update
APP_URLand the Paystack webhook URL, then redeploy. - The admin System page shows job runs and failed jobs (retry from there); the cron runs daily on Hobby.
- Watch Vercel function logs for webhook 4xx/5xx and
BillingEventrows withprocessedAt IS NULL. - Known gaps: no error tracker wired, no email provider other than Resend, CSP still allows inline scripts,
npm auditreports 4 high advisories in build-time tooling (see SECURITY_MODEL.md).
2.9 Troubleshooting
| Symptom | Likely cause / fix |
|---|---|
Build: Environment variable not found: DIRECT_URL |
Add DIRECT_URL to the Vercel project |
Runtime: Query engine library … not found |
binaryTargets missing — redeploy without build cache |
| Everything empty / "row-level security" errors | DATABASE_URL user is the owner/superuser, or db:roles was not run |
prepared statement … already exists |
Pooled URL missing ?pgbouncer=true |
| "Sandbox payments are disabled" | Production env with PAYMENT_PROVIDER=sandbox — set paystack |
| Cron returns 503 | CRON_SECRET unset or shorter than 16 chars |
| Cron returns 401 manually | Missing Authorization: Bearer header |
| Payment succeeded but plan not active | Check webhook URL, then Billing → Invoices; callback or webhook will settle it idempotently |