# SaaS Platform Migration — What Changed, What's Next

This app was originally built for one company ("A&B Hub") running two
brands (Homecare + Janitorial) sharing one `business_lines` split.
It's now a multi-tenant platform: any number of companies can sign up,
each gets an isolated CRM at their own subdomain, and pays you
(yearly, by card via Stripe or by a code you generate) to keep it
active. You sit above all of them as the platform owner.

## The core idea

`business_lines` was already the exact mechanism that isolated one
brand's leads/customers/appointments/etc. from the other's — every
data table already carries a `business_line_id`. Rather than build a
parallel tenancy system, **a `business_lines` row now IS a tenant
(a paying company)**. Nothing about how the rest of the CRM queries
its data had to change.

## What's new

| Piece | Where |
|---|---|
| Billing state on each company | `business_lines` — `subscription_status`, `subscription_expires_at`, `stripe_customer_id`, etc. (migration `0018`) |
| Subdomain → tenant resolution | `App\Core\Tenant`, called from `Application::resolveTenantContext()` on every request |
| "You must have a valid, unexpired tenant" gate | Folded into `AuthMiddleware` / `GuestMiddleware` (already on ~every CRM route) rather than added route-by-route |
| Public signup | `App\Controllers\Public\SignupController`, `routes/public.php`, `app/views/public/*` |
| Stripe billing | `App\Services\StripeService` (Checkout + webhook verification), `App\Controllers\Public\BillingWebhookController` |
| Subscription codes | `App\Models\SubscriptionCodeModel`, redeemed via `App\Services\SubscriptionService::redeemCode()` |
| Platform owner login (separate from tenant users) | `App\Core\PlatformAuth`, `app/Controllers/PlatformAdmin/*`, `routes/platform.php` |
| Expired-subscription wall | `App\Controllers\BillingController`, reachable even while locked out |

## How each piece of what you asked for maps to the code

- **"I want it to be for 1 company only, which can change over time"**
  → Every tenant is exactly one `business_lines` row with exactly one
  set of data. If `APP_DOMAIN` isn't configured, the app runs in
  single-tenant fallback mode automatically (see
  `Application::resolveTenantContext()`) — useful if you ever just
  want to run this for one client without the subdomain machinery.

- **"So I can sell the software to different companies"**
  → `/signup` (public, root domain) lets any company create their own
  tenant. `/platform/companies/onboard` lets you do it by hand.

- **"Company pays yearly, by card or subscription code"**
  → Stripe Checkout (`StripeService::createCheckoutSession`) creates a
  recurring yearly subscription; Stripe stores the card and renews it
  automatically — this app is only ever told about it via webhook.
  `subscription_codes` covers the code path, either tied to one
  company (renewal) or open to any new signup.

- **"Admin/owner can onboard manually and create subscription codes"**
  → `admin.<APP_DOMAIN>/platform/companies/onboard` and
  `/platform/codes`, behind `PlatformAuth` (a completely separate
  login from any tenant's CRM users).

## Setting it up for real

1. **Run the new migration**: `php bin/migrate.php` picks up
   `0018_create_saas_platform.sql` automatically.
2. **Create your own platform-admin login**:
   `php bin/create_platform_admin.php "Your Name" you@example.com`
3. **DNS + TLS**: a wildcard `A` record (`*.yoursaas.com`) and a
   wildcard TLS cert (DNS-01 validated — HTTP-01 can't issue wildcard
   certs). See `deploy/apache-vhost.conf`.
4. **Set `.env`**: `APP_DOMAIN=yoursaas.com`,
   `APP_MARKETING_URL=https://yoursaas.com` (or wherever your signup
   page/landing site lives).
5. **Stripe** (optional at first — codes and manual onboarding work
   without it):
   - Create one recurring **yearly** Price in the Stripe Dashboard.
   - Set `STRIPE_SECRET_KEY`, `STRIPE_PUBLISHABLE_KEY`,
     `STRIPE_PRICE_ID` in `.env`.
   - Add a webhook endpoint at `https://<any-host-on-your-domain>/webhooks/stripe`
     listening for `checkout.session.completed`, `invoice.paid`,
     `invoice.payment_failed`, `customer.subscription.deleted`. Put its
     signing secret in `STRIPE_WEBHOOK_SECRET`.
   - Test in Stripe **test mode** end-to-end before going live.

## Known gaps to close before relying on this in production

- **Not yet exercised against a live PHP/MySQL instance** — this was
  built and reviewed carefully against your codebase's own
  conventions, but no interpreter was available in the environment
  this was built in. Run `php -l` on every new file, then walk the
  signup → activate → login flow locally before deploying.
- **Other seeders** (`MarketingSeeder.php`, `ReferenceDataSeeder.php`,
  `OrgStructureSeeder.php`) still look up the old `homecare`/
  `janitorial` slugs and will error if you run the full `bin/seed.php`
  — harmless for production (real tenants never touch seeders), but
  worth pruning down to the single `demo` slug if you want the full
  demo dataset to work too.
- **No transactional email yet** on manual onboarding or trial-ending
  reminders — `MailService` already exists in this codebase and is
  the natural place to add "here's your login" / "your trial ends in
  3 days" emails.
- **No plan tiers** — right now there's one price point (`plan` column
  exists on `business_lines` for this, unused so far). If you want
  e.g. a Starter vs Pro tier, that's a Stripe Price per tier + reading
  `plan` when creating the Checkout Session.
- **Trial length** is hardcoded to 14 days in
  `SubscriptionService::registerTenant()` — change the `INTERVAL 14
  DAY` there if you want something else.
- **Old branding** ("A&B Hub") still shows in some CRM page titles/
  layout — cosmetic, doesn't affect tenancy or billing. Search for
  "A&B Hub" across `app/views` when you're ready to re-skin it.
- **RBAC roles are global**, not per-tenant — every company shares the
  same Admin/Sales Rep/Field Staff/Read-Only role definitions. Fine
  for most cases; if a specific tenant ever needs custom roles, the
  `roles` table would need a `business_line_id` column added.

## Files touched or added, at a glance

```
database/migrations/0018_create_saas_platform.sql   (new)
database/schema.sql                                  (SECTION 18 appended)
database/build_migrations.php                        (section list updated)
database/seeders/BusinessLineSeeder.php               (1 demo tenant, not 2)
database/seeders/AdminUserSeeder.php                   (matches new slug)

app/Core/Tenant.php                                   (new)
app/Core/PlatformAuth.php                             (new)
app/Core/Application.php                              (tenant resolution + new routes)
app/Middleware/TenantMiddleware.php                   (new)
app/Middleware/PlatformAdminMiddleware.php             (new)
app/Middleware/AuthMiddleware.php                      (tenant gate added)
app/Middleware/GuestMiddleware.php                     (tenant gate added)

app/Models/SubscriptionCodeModel.php                  (new)
app/Services/SubscriptionService.php                   (new)
app/Services/StripeService.php                         (new)

app/Controllers/BillingController.php                  (new)
app/Controllers/Public/SignupController.php             (new)
app/Controllers/Public/BillingWebhookController.php     (new)
app/Controllers/PlatformAdmin/PlatformAuthController.php (new)
app/Controllers/PlatformAdmin/PlatformCompanyController.php (new)
app/Controllers/PlatformAdmin/PlatformSubscriptionCodeController.php (new)

routes/public.php                                      (new)
routes/platform.php                                     (new)

app/views/public/*, app/views/billing/*, app/views/platform/*  (new)

config/app.php                                          (domain, marketing_url)
config/stripe.php                                        (new)
.env.example                                              (new SaaS/Stripe vars)
deploy/apache-vhost.conf                                  (wildcard subdomain example)
bin/create_platform_admin.php                             (new)
```
