# Multi-Tenant Subdomain Storefronts — Implementation Plan

> Status: **approved direction, not yet built.** Drafted 2026-05-29.
> Decisions locked: **Stripe Connect** for payments · **wildcard subdomains only** · **tenants author their own courses that list on BOTH their storefront and the main UTC marketplace** (UTC = aggregator marketplace).

## 1. Objective

Let every company with `company_type IN ('training_company','hybrid')` run a branded storefront on `their-slug.<apex>`:

- Their logo / colours / hero / tagline.
- A course list = **their own (authored) courses** + a **curated subset of the shared UTC catalog** (`tenant_featured_courses`).
- Checkout that pays **the course owner's** Stripe (Connect) account, with a UTC platform fee.
- Self-service settings (no sys-admin needed) for branding, subdomain, courses, featured picks, Stripe onboarding, content, and publish.

Tenant-authored courses also appear on the **main UTC marketplace**; when bought there, payment still routes to the owning tenant.

## 2. Current state (what we reuse vs build)

| Capability | Status | Anchor |
|---|---|---|
| `company_branding` (subdomain, logo, 6 colours, hero, tagline, `stripe_account_id`, `is_active`) | ✅ reuse | `migrations/2026_01_01_000020`, `Models/CompanyBranding.php` |
| `tenant_featured_courses`, `tenant_posts`, `tenant_testimonials` | ✅ reuse | `...000170/000420/000430` |
| Stripe **Connect**: `forCompany()`, platform fee, `manageCompanyStripe` onboarding, Connect-aware webhook | ✅ reuse | `Services/StripeService.php`, `StripeController` |
| Admin branding/stripe endpoints; `company_manager` role; `trainingCompanyUpdateSelf` (scoped self-edit) | ✅ reuse | `routes/api.php:345-366`, `PublicController.php:535` |
| React `TenantContext` (hex→HSL theming, `?tenant=` dev override), `LocktelLayout`, generic `TenantHome.tsx`, `CompanyBrandingPanel.tsx` (live preview) | ✅ reuse/generalise | `resources/js/contexts/TenantContext.tsx`, `components/locktel/*`, `components/admin/CompanyBrandingPanel.tsx` |
| Generic tenant API: `marketplaceTenantCourses`, `tenantBranding` | ✅ reuse/extend | `PublicController` |
| **Server-side** subdomain→company resolution / tenant middleware | ❌ build | — (today: client-only, hardcoded `domainMap`) |
| Tenant-aware shared marketplace (`/courses`, `/course/{slug}`, `/cart`, checkout) | ❌ build | `CourseDetail.tsx` has a `useTenant` **stub** = `isTenantMode:false` |
| Server-enforced scoping + payment-by-owner | ❌ build | checkout trusts client `company_id`; routes Stripe by buyer, not owner |
| Tenant **course editor** + `owner_company_id` | ❌ build | courses are a flat global catalog today |
| `company_stripe_config` (BYO keys) | ⛔ **remove** | dead code; superseded by Connect |

## 3. Architecture decisions

1. **Server-side tenant resolution.** A `ResolveTenant` middleware maps `Host` → company + branding, binds a `CurrentTenant` singleton, and shares it through Inertia. Backbone for correct attribution, payment routing, scoping, SEO. Client `TenantContext` reads the server prop instead of re-deriving from `window.location`.
2. **Payments = Stripe Connect only.** Remove `company_stripe_config` and its endpoints. Onboarding is the existing `manageCompanyStripe` (Standard accounts + account links).
3. **Payment routes by course owner.** Payee Stripe account = `owner_company.company_branding.stripe_account_id`; `owner_company_id IS NULL` → platform (UTC). UTC application fee on connected charges. (Buyer `company_id` is unchanged and used only for invoicing/credit/attribution.)
4. **Wildcard subdomains only.** `*.<apex>` DNS + one wildcard TLS cert. No `custom_domain` column. (Locktel's standalone domain becomes a redirect to its subdomain, or a one-off host map kept in config.)
5. **Aggregator catalog.** `courses.owner_company_id` distinguishes UTC-owned (NULL) from tenant-authored. Main marketplace lists all active (+approved) courses; tenant storefront lists own + featured-shared.

## 4. Data model changes (migrations)

1. `add_owner_company_id_to_courses`
   - `owner_company_id` CHAR(36) NULL, FK → `training_companies(id)` ON DELETE SET NULL, indexed. Backfill existing rows to NULL (UTC-owned).
   - `marketplace_status` ENUM/string default `'approved'` for UTC rows; tenant-created rows default `'pending'` if moderation is ON (see §7). Controls main-marketplace visibility only (always visible on owner's storefront).
2. `add_provider_company_id_to_course_orders` (denormalised payout/reporting key)
   - `provider_company_id` CHAR(36) NULL, FK → `training_companies(id)`, indexed. Set at order creation = course `owner_company_id`. Lets `trainingCompanyProviderOrders` stop being heuristic.
3. `branding_public_projection` — no schema change; fix the leak in §6.
4. `drop_company_stripe_config` — drop table + remove model/endpoints (decision #2). Do this last, after confirming no runtime references.
5. (Optional) `add_seo_to_company_branding` — `favicon_url`, `meta_description`, `og_image_url`.

> `tenant_featured_courses`, `company_services`, `company_branding.subdomain` already exist — no new tenancy tables needed.

## 5. Phased delivery

Each phase is independently shippable; ship in order.

### Phase 0 — Foundations
- Env: set `STRIPE_CONNECT_CLIENT_ID`, `STRIPE_WEBHOOK_SECRET`, `STRIPE_PLATFORM_FEE_PERCENT` (today empty/0).
- Ops (staging first): wildcard `*.<apex>` DNS + wildcard TLS; point subdomain docroots at the same Laravel `public/`. **Re-test the known cPanel `public/$1` + trailing-slash `/public` leak** under a subdomain host (documented footgun in deploy notes).
- Config: `config/tenancy.php` → `apex_domains`, `reserved_subdomains` (`www, app, admin, api, mail, ftp, staging, …`).
- Security: fix `tenantBranding` secret leak (§6).

### Phase 1 — Server tenant resolution
- `App\Http\Middleware\ResolveTenant` (on `web`; an `api` variant for tenant-host API calls):
  - `host = $request->getHost()`; strip apex; derive subdomain label; ignore reserved labels.
  - Resolve active `company_branding` (`is_active=true`) → `TrainingCompany` with `company_type IN (training_company,hybrid)`.
  - Bind `CurrentTenant` singleton + `tenant()` helper. No match → normal UTC marketplace (unchanged).
- `HandleInertiaRequests::share()` → add `tenant` (id, name, subdomain, **public** branding only).
- Frontend: `TenantContext` reads the shared `tenant` prop; delete the hardcoded `domainMap`; keep `?tenant=` dev override and `/api/tenant/branding` as fallback.
- **Acceptance:** `acme.<apex>` resolves server-side and themes globally; apex marketplace unchanged; reserved/unknown subdomains fall through to UTC.

### Phase 2 — Branded storefront (frontend)
- Generalise `LocktelLayout` → `TenantLayout`; add generic `TenantNavbar`/`TenantFooter` reading `branding.logo_url`, tagline, contact.
- Remove the `useTenant` **stub** in `CourseDetail.tsx`; wrap `/courses`, `/course/{slug}`, `/cart`, checkout in `TenantLayout` when `isTenantMode`.
- Namespace the cart: `utc_cart_v1` → `utc_cart_v1::<subdomain>` (today one global cart leaks across tenants).
- Tenant course list endpoint = `owner_company_id = tenant OR id IN tenant_featured_courses(tenant)`, active+approved. (Generalise `marketplaceTenantCourses`.)
- **Acceptance:** tenant subdomain shows only its courses + curated picks, fully branded; course detail + cart work under the brand.

### Phase 3 — Tenant checkout & payment-by-owner
- In `createPaymentIntent` / `createBulkPaymentIntent` / `companyCheckout`: derive payee Stripe account from **course `owner_company_id`** (→ owner branding `stripe_account_id`), not buyer `company_id`. NULL owner → platform.
- Set `course_orders.provider_company_id = owner_company_id` on insert.
- On a tenant host, force buyer context from `CurrentTenant` where applicable; validate every cart `course_id` is sellable in this context (owned or featured) — reject cross-tenant IDs.
- **Mixed-owner carts:** group cart lines by payee account; one PaymentIntent per owner (extend the existing "N orders / 1 intent" bulk path to "per payee group"). Invoice/credit lines unaffected (no Stripe).
- Gate selling on a **verified Connect account** for the owner.
- Confirm the existing webhook (`checkout.session.completed`, `payment_intent.*`, `charge.refunded`) fires correctly for connected accounts once `STRIPE_WEBHOOK_SECRET` is set.
- **Acceptance:** buying a tenant-owned course (on either the tenant site OR the main UTC site) creates an order with `provider_company_id` = tenant and charges the tenant's Connect account with the UTC platform fee; UTC-owned courses charge the platform account.

### Phase 4 — Self-service manager portal
Authorise with `company_manager` **scoped to the company** (template: `trainingCompanyUpdateSelf`). Do NOT reuse the sys-admin `/api/admin/*` group. Build under the existing `CompanyPortal` page:
- **Branding editor** — reuse `CompanyBrandingPanel.tsx` (already has live preview).
- **Subdomain claim** — validate DNS-safe + reserved list + uniqueness (column is already unique).
- **Course editor** — CRUD on courses where `owner_company_id = my company`; forced owner; cannot touch UTC/other-owner courses. Reuse `CoursesController` logic + `Admin/CourseForm` UI behind a manager-scoped route. Scope trainer/venue assignment to the company's own `trainer_companies` / `venues.company_id`.
- **Featured-course curation** — CRUD over `tenant_featured_courses` (pick from shared catalog).
- **Stripe Connect** — "Connect Stripe" button via `manageCompanyStripe`; show onboarding status.
- **Content CMS** — posts/testimonials (manager-scoped variants of `/api/admin/locktel-cms/*`).
- **Publish toggle** — `company_branding.is_active`, gated on "Stripe connected".
- **Acceptance:** a `company_manager` configures branding, claims a subdomain, authors a course, connects Stripe, and publishes — entirely self-service.

### Phase 5 — Retire Locktel hardcoding
- Migrate Locktel onto the generic path (it's already a `training_company` with branding). Move hardcoded copy from `Pages/Locktel/*` into `tenant_posts`/branding fields.
- Keep `/locktel/*` routes as redirects/aliases to the generic storefront. Remove the hardcoded `companyName: "Locktel Academy"` special-case.

### Phase 6 — Hardening
- Cross-tenant scoping tests (course list, course detail, checkout `course_id` validation, manager-portal authorisation).
- Per-tenant SEO: `<title>`/meta from branding, per-subdomain `robots`, sitemap.
- Cache resolved branding per host (bust on save — mirror the `pages.catalog` cache pattern).
- Rate-limit public tenant endpoints; observability on payment routing + webhook.

## 6. Security checklist
- **Secret leak:** `PublicController::tenantBranding` does `select('company_branding.*')` → exposes `stripe_account_id`. Replace with an explicit public projection (no `stripe_account_id`, no future secrets). Apply the same projection to the Inertia `tenant` share.
- **Server-enforced scoping:** never trust client `company_id`/`course_id` for what a tenant may show or sell — derive from `CurrentTenant` and validate membership server-side.
- **Payee integrity:** payment account derives from server-side course `owner_company_id`, never from the request.
- **Manager authorisation:** every manager-portal write asserts the actor is `company_manager` of that exact company.
- **Reserved subdomains** blocked at claim time.

## 7. Open defaults (chosen; easy to flip)
- **Moderation of tenant courses on the MAIN marketplace:** default = `marketplace_status` starts `pending` for tenant-authored courses and a sys-admin approves before they appear on the main UTC site (they always show on the owner's storefront). Flip to auto-approve by defaulting to `approved`.
- **Mixed-owner carts:** IMPLEMENTED as single-payee-per-checkout — a cart spanning multiple payees is rejected with a clear "check out each provider separately" message (safe; never mis-routes funds). The single-course path (the primary storefront purchase) always routes to that course's owner. Future upgrade for true multi-provider carts = separate-charges-and-transfers with UTC as merchant of record.
- **Reseller commission:** when a tenant sells a *UTC-owned* featured course on their site, default = UTC is paid (no tenant commission). Revisit if tenants should earn a cut.

## 8a. Build status — DELIVERED (2026-05-29)

All six phases are implemented and tested (37 tenancy assertions across 4 test
classes; full suite green except 5 pre-existing Breeze scaffold tests unrelated
to this work). Key code:

- **Phase 0/1:** `config/tenancy.php`; `App\Support\CurrentTenant` (cached, secret-free share); `App\Http\Middleware\ResolveTenant` (web+api, forces tenant root URL under https); Inertia `tenant` prop; migrations `..._add_owner_company_to_courses`, `..._add_provider_company_to_course_orders`; `tenantBranding` secret-leak fixed.
- **Phase 2:** server-side storefront scoping in `PublicController` (owned+featured; main-marketplace moderation gate); generic `TenantLayout`/`TenantNavbar`/`TenantFooter`; `Navbar`/`Footer` delegate when `isTenantMode`; `Index`→`TenantHome`; `CourseDetail` de-stubbed; per-subdomain cart namespacing.
- **Phase 3:** payment routes to course **owner** (`StripeController::payeeForCourse`); `provider_company_id` persisted; storefront sellability guard (403); single-payee carts (422 on mixed); onboarding gate; refunds hit the provider account.
- **Phase 4:** `EnsureCompanyManager` + `CompanySiteController` + `/api/company-site/{company}/*`; `pages/CompanySite/Index.tsx` (Branding / My Courses / Featured / Payments & Publish); publish gated on subdomain + connected Stripe.
- **Phase 5:** migration publishes Locktel as a generic tenant when eligible; `/locktel/*` 301 → `locktel-academy.<host>` storefront (host-derived).
- **Phase 6:** per-tenant `<title>`/meta/favicon in `app.blade.php`; cached resolution with bust-on-save; `throttle:120,1` on `/api/tenant/branding`; payment-routing log.

**Remaining manual / ops steps (not code):**
1. Production env: set `STRIPE_CONNECT_CLIENT_ID`, `STRIPE_WEBHOOK_SECRET`, `STRIPE_PLATFORM_FEE_PERCENT`, and `TENANCY_APEX_DOMAINS`.
2. DNS wildcard `*.<apex>` + wildcard TLS; point subdomain docroots at the same Laravel `public/` (re-test the `/public` trailing-slash leak on staging).
3. To make a company a storefront: set its `company_type` to `training_company`/`hybrid`, then use **/company-site** to claim a subdomain, connect Stripe, and publish. (This dev DB's "Locktel Ltd" is a `customer_company`, so it is not a storefront until promoted.)
4. Optional cleanup: the now-unrendered `pages/Locktel/*` + `components/locktel/*` are dead and can be deleted; tests run against a MySQL `app_test` DB (see `phpunit.xml`).

## 8. Top risks
- Cross-tenant data leakage if scoping stays client-side → Phase 1 must land first.
- Selling before Stripe is connected → gate publish on verified Connect account.
- cPanel wildcard rewrite reproducing the `/public` trailing-slash leak → validate on staging.
- Removing `company_stripe_config` while something still references it → grep + remove endpoints before dropping the table.
