Platform operators

Plans and billing

Platform operatorsPlans and billing

Every tenant has a plan. The plan is stored in tenants.plan (default 'free') and controls which features the tenant can use. The plan column is the source of truth — the marketing site and the signup form are decoupled from the runtime plan and do not yet write it.

Billing is not yet integrated.
Stripe Connect is wired up per-tenant for receiving payments from end customers (deposits, full payments). However, BookFlow does not yet charge the tenant a subscription fee. Every plan change you make from the admin console is free until the billing integration ships. The finance team reconciles subscription revenue by counting plan = 'pro' tenants in D1 and matching against the closed Stripe accounts.

The three plans

The PATCH /api/admin/tenants/:id endpoint accepts exactly these three plan values:

z.enum(["free", "pro", "enterprise"])

Any other value (e.g. "starter", "team", "business") is rejected with a 400.

Free

  • Price: $0 / month
  • Default for every new signup (set by the tenants DDL default)
  • Limits (enforced at the API): 3 staff, 1 location, no custom domain, no AI Ops Agent
  • Who it's for: a single-operator salon that's trying the platform, or a brand-new tenant before they upgrade

Pro

  • Price: $29 / month per tenant (internal price; the public marketing page currently shows different public-facing pricing tiers — see "Discrepancy" below)
  • Unlocks: unlimited staff and locations, custom domain (CNAME), AI Ops Agent with auto-pilot, priority support, no booking volume cap
  • Who it's for: any business actively taking bookings, especially multi-staff shops

Enterprise

  • Price: custom contract. Quoted per deal by the sales team, billed via invoice. No Stripe charge in the platform.
  • Unlocks: everything in Pro, plus SSO/SAML, audit log retention beyond 90 days, custom SLA, dedicated CSM, security review, MSA, DPA, BAA if healthcare
  • Who it's for: multi-location chains, healthcare, regulated industries, anything that needs an MSA
Discrepancy between the admin enum and the marketing pricing page.
The public pricing shows Free / Starter $29 / Pro $79. The admin API only accepts free / pro / enterprise. The two are not yet aligned. When you change a tenant's plan in the console, you are setting the internal plan value, not a public marketing tier. The marketing site is a separate React component and does not read tenants.plan. This is a known issue tracked in the platform backlog; the fix is a single source-of-truth pricing table.

Feature matrix

FeatureFreeProEnterprise
Bookings / month50UnlimitedUnlimited
Staff3UnlimitedUnlimited
Locations1UnlimitedUnlimited
Custom domain
AI Ops Agent (auto-pilot)
SMS reminders
Priority support
SSO / SAML
Custom SLA
Dedicated CSM
Audit log retention90 days1 year7 years

How to upgrade a tenant

There is no self-serve upgrade button in the dashboard today. To change a tenant's plan, you (the super admin) do it from the tenant's detail page:

  • Open the tenant.
  • In the "Change plan" panel, click pro or enterprise.
  • The console prompts for a reason (free-text). Always include a reason — it goes into audit_log.reason.
  • Click confirm. The change is instant. The audit_log gets a tenant.plan_changed row with the before/after plan values.
  • Email the tenant owner separately to tell them the change was made. The platform does not yet send a plan-change email automatically.

Equivalent API call:

curl -X PATCH https://booking-api.netwit.ca/api/admin/tenants/$TENANT_ID \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "plan": "pro", "reason": "Manual upgrade per sales agreement" }'

Revenue tracking

There is no live MRR number in the platform today. The closest equivalents:

  • Pro tenant count: SELECT COUNT(*) FROM tenants WHERE plan = 'pro' AND is_suspended = 0. Multiply by $29 for the floor MRR. The marketing site's Starter price of $29 aligns with this number, but the public Pro tier ($79) does not yet exist in the admin enum.
  • GMV (30 days): from GET /api/admin/statstotals.gmv30d. This is the gross merchandise volume that flowed through BookFlow to tenants' Stripe accounts — it is not NetWit revenue. NetWit only collects the standard Stripe processing fee (2.9% + 30¢) plus, eventually, a SaaS subscription.
  • Enterprise contracts: tracked in the shared finance spreadsheet, not in the platform. Sum the contracts manually and add to the MRR formula above.

Quick MRR formula

-- Run against D1 (bookflow)
SELECT
  (SELECT COUNT(*) FROM tenants WHERE plan = 'pro' AND is_suspended = 0) * 29
  + <manual_enterprise_total> AS mrr_usd

Plan enforcement

Plan limits are enforced at the API layer. The relevant rules live in apps/api/src/routes/services.ts, staff.ts, and tenant.ts. Free-plan tenants hitting the 3-staff cap get a 402 Payment Required with code plan_limit_reached. Custom domains and AI auto-pilot are gated the same way.

API

Plan changes go through PATCH /api/admin/tenants/:id. There is no dedicated /api/admin/plans endpoint today. Read more in the tenant detail guide.

PATCH/api/admin/tenants/:id

Set plan via the { plan: 'free' | 'pro' | 'enterprise', reason: '...' } body. Writes tenant.plan_changed to audit_log.

Sample upgrade call

curl -X PATCH https://booking-api.netwit.ca/api/admin/tenants/$TENANT_ID \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
        "plan": "enterprise",
        "reason": "Signed 12-month MSA, $499/mo, 5 locations"
      }'

Sample downgrade / revoke Pro

curl -X PATCH https://booking-api.netwit.ca/api/admin/tenants/$TENANT_ID \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
        "plan": "free",
        "reason": "Trial expired, no payment received"
      }'

Downgrades and data retention

Downgrading a Pro tenant to Free does not delete their data. If a Free tenant has 6 staff and you downgrade them, the 7th-onward staff records stay in D1 but become inaccessible (the UI hides them and the API rejects reads beyond the limit). If they re-upgrade to Pro, the staff come back. If a Free tenant is on a custom domain, the custom domain mapping is kept but the public booking page falls back to booking.netwit.ca/book?slug=....

Trials

tenants.trial_ends_at (added in migration 0003) is an explicit timestamp. While julianday('now') < julianday(trial_ends_at), the tenant is treated as Pro for limit purposes. After the trial ends, the limits revert to Free. The console exposes a date picker to set trial_ends_at manually; the auto-trial-creation flow on signup is on the roadmap.

Need a human?

Email hello@netwit.ca or call +1-604-206-8169. NetWit responds in 1 business day.