Skip to content

← All guides

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.json already contains it, once a day — the Hobby-plan maximum; Pro can run more often).
  • Migrations run automatically on every production deploy (scripts/vercel-build.mjs runs npm run db:deploy, then the build). This needs DIRECT_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

  1. Create a Postgres 15/16 database with your provider.
  2. 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=5 to it.
  3. Row-level security only works when the app connects as a non-owner, non-superuser role. npm run db:roles creates bmt_app from the password in DATABASE_URL, so the runtime URL's user must be bmt_app with 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 is bmt_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

  1. Dashboard → Settings → API Keys & Webhooks: copy the test secret key into PAYSTACK_SECRET_KEY.
  2. Set the Webhook URL to https://your-domain/api/webhooks/billing. Paystack signs with your secret key (HMAC-SHA512); the app verifies it.
  3. 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).
  4. 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_URL and 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 BillingEvent rows with processedAt IS NULL.
  • Known gaps: no error tracker wired, no email provider other than Resend, CSP still allows inline scripts, npm audit reports 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

Proventory documentation