# Activation / Signup / Onboarding Hardening

**Branch:** `activation-onboarding-hardening`
**Base:** `origin/main` (post-Phase-22 merge)
**Scope:** focused, contained hardening of the activation-code / public-signup / platform-promote / onboarding flow. **Not** an architectural rewrite.

---

## Background

Two pilot incidents converged into this hardening pass:

1. **MySQL CAST incompatibility.** The signup-id generator used `CAST(SUBSTR(id, 3) AS INTEGER) DESC` to pick the next sequential `S-NNNN` id. SQLite accepts `INTEGER` as a cast type; **MySQL rejects it** (1064 syntax error — MySQL requires `SIGNED` or `UNSIGNED`). Production runs on MySQL, so every signup attempt 500'd, and 12 tests across 5 suites failed as a direct result.
2. **Missing `trial_unlimited` plan in production.** The activation-code promote flow grants new tenants the `trial_unlimited` plan. `SubscriptionService::assignPlan` throws 422 `PLAN_NOT_FOUND` when the row is missing. The `DatabaseSeeder` defines the plan, but production deploys typically run `migrate --force` only — not `db:seed`. The plan was manually inserted to resolve the "S-2056 promote 422" incident; we now ensure it via migration.

---

## What changed

### 1. MySQL signup ordering compatibility (commit `9a82c2d`)

Replaced both `orderByRaw('CAST(SUBSTR(id, 3) AS INTEGER) DESC')` callsites with a dialect-agnostic PHP-side max:

```php
$maxSeq = (int) TenantSignup::query()
    ->where('id', 'like', 'S-%')
    ->pluck('id')
    ->map(fn ($v) => (int) Str::after($v, 'S-'))
    ->max();
$next   = max(2000, $maxSeq);
$id     = 'S-' . str_pad((string) ($next + 1), 4, '0', STR_PAD_LEFT);
```

Files: `app/Http/Controllers/Auth/SignupController.php`, `app/Http/Controllers/Public/PublicSiteController.php`.

Same portability pattern as the prior Pay-module `json_each` fix (commit `0cde761`). Works on MySQL, SQLite, and Postgres identically. Cheap at any realistic scale.

### 2. trial_unlimited plan migration (commit `da19ae8`)

`database/migrations/2026_05_11_010000_ensure_trial_unlimited_plan_exists.php` — idempotent migration that inserts the `trial_unlimited` plan row + matching `public_plan_settings.is_public=false` row if absent. Preserves any tenant-customised row.

**`down()` is a deliberate no-op** — existing tenants may have `tenant_subscriptions.plan_id='trial_unlimited'` pointing at this row; removing it on rollback would orphan their subscriptions.

**Test:** `TrialUnlimitedPlanSeedTest::test_trial_unlimited_plan_exists_after_migrations` proves `migrate --force` alone is sufficient.

### 3. Frontend error handling (commit `0039a5d`)

`OnboardingDetailDrawer.jsx` — Advance and Nudge mutations now render an inline `ActionError` card on failure. Same bilingual `message_en`/`message_ar` + error-code pattern as the Phase Production-fix added to the SignupsPage promote drawer.

This was the only "silent failure" surface left in the activation/signup/onboarding flow. All other screens — `PublicSignup`, `PublicContact`, `SignupStatusPage`, `SignupsPage` — already had `ErrorBanner`/inline error display.

---

## Activation flow (current contract)

| Step | Endpoint | What it does |
|---|---|---|
| 1. Public signup | `POST /signup` (no auth) | Creates `tenant_signups` row with sequential `S-NNNN` id. If `activation_code` is supplied and valid → links `used_activation_code_id` + flags `activation_code_valid=true`. Silently drops invalid/revoked/used codes (security: no info-leak about whether codes exist). |
| 2. Activation generation | `POST /platform/activation-codes` (superadmin) | Generates a new code with `code` (auto or custom) + `expires_at` + status. |
| 3. Status check | `GET /signup-status?reference=…` (public, rate-limited) | Looks up by id or email; returns neutral payload for unknown/malformed references. |
| 4. Promote | `POST /platform/signups/{id}/promote` (superadmin) | `TenantProvisioningService::promoteSignup()` provisions company + owner user + branch + subscription. If activation code was redeemed → grants `trial_unlimited` for 90 days. Otherwise → grants `starter` (or override). |
| 5. Convert state | (internal) | `tenant_signups.status` → `converted`, `company_id` filled. Re-promoting returns 422 `SIGNUP_ALREADY_CONVERTED` cleanly. |

## Signup statuses

| Status | Meaning |
|---|---|
| `pending` | Just submitted, awaiting platform review. |
| `review` | Platform staff is inspecting. |
| `verified` | Manually verified, awaiting promote. |
| `rejected` | Rejected with an optional staff `rejection_reason`. |
| `converted` | Promoted into a real tenant. `company_id` set. |

`SignupStatusController::lookup` returns a neutral payload for any of these from the public side so unauthenticated callers can't enumerate the queue.

## Required subscription plans

| Plan id | Source of truth | Required by |
|---|---|---|
| `starter` | `DatabaseSeeder` (updateOrCreate) | Default fallback in `promoteSignup` |
| `growth` | `DatabaseSeeder` (updateOrCreate) | Public pricing page |
| `enterprise` | `DatabaseSeeder` (updateOrCreate) | Public pricing page |
| `trial_unlimited` | **Migration `2026_05_11_010000` + `DatabaseSeeder`** (both idempotent) | Activation-code promote flow |

`trial_unlimited` is also pinned `is_public=false` in `public_plan_settings` so the marketing `/pricing` page never lists it.

## MySQL compatibility status

After this branch:

- ✅ No `CAST(... AS INTEGER)` remaining in the signup/onboarding path (grep-verified).
- ✅ No `json_each` remaining anywhere (commit `0cde761` already fixed Pay module).
- ✅ No SQLite-only date/time functions (`julianday`) anywhere.
- ✅ All 64 activation/signup/onboarding tests pass on MySQL (`DB_CONNECTION=mysql`).

The full Pay-module SQL portability was completed in the earlier dine-phase-11 branch (commit `0cde761`).

## Error handling matrix (FE)

| Surface | Pre-fix | Post-fix |
|---|---|---|
| `PublicSignup` (public signup form) | ✅ `ErrorBanner` on submit error | unchanged |
| `PublicContact` (public contact form) | ✅ `ErrorBanner` on submit error | unchanged |
| `SignupStatusPage` (public status lookup) | ✅ neutral 404 + error display | unchanged |
| `SignupsPage` promote/reject drawer | ✅ inline error (Phase Production-fix) | unchanged |
| `OnboardingDetailDrawer` Advance/Nudge | ❌ silent failure | **✅ inline `ActionError`** |

## Security assumptions

- Activation codes are random opaque strings; brute-forcing space is server-side rate-limited via `SignupStatusController` throttle (10 req/min bilingual 429).
- Invalid/revoked/used codes on signup → **silently dropped** (no info-leak about whether a code exists or was previously used). Confirmed by `ActivationCodeTest::test_signup_with_*_silently_drops`.
- Promote requires superadmin (Spatie permission `platform.signups.promote`).
- Tenant isolation: every model has `BelongsToTenant` scope. Cross-tenant signup access returns 404, not 403 (no existence leak).
- Owner password handling: signup-time password is bcrypt'd into `password_hash`; promotion either uses that hash directly or generates a temp password (the temp is returned ONCE in the promote response and stored only as a bcrypt hash on the user row). Plaintext is never logged.
- Activation codes are **never logged** by the runtime-events recorder. Phase-21 emit producers in `DineSyncFailuresController` etc. use the `payload_excerpt` field with operator-supplied summaries only.

## Known limitations

- **No structured activation/onboarding events.** The Phase 21 runtime recorder captures HTTP failures + slow requests on `/signups/*/promote` automatically via the global middleware/reportable callback, but there are no purpose-built `signup.created` / `activation.succeeded` / `signup.promoted` events. Adding these would require touching every business path — out of contained scope for this pass. Logged as a follow-up.
- **No protection against staff promoting the same signup twice in a race.** The second call returns 422 `SIGNUP_ALREADY_CONVERTED` cleanly (covered by `TenantProvisioningTest::test_promoting_an_already_converted_signup_fails_cleanly`), but a hypothetical second-of-second-of-each-other concurrent promote would race. The database FK on `tenant_signups.company_id` makes that race effectively single-winner in practice; tightening to advisory-lock-level would be its own design exercise.
- **The 4 pre-existing accounting step-up fixture failures** (`AccountingTest::bills_create_and_pay_partial_then_full`, `ArApVoucherPostingTest::*`) remain. Same as in every prior phase report: HTTP 419, fixture issue, unrelated to activation/signup/onboarding, not in scope per the user's "fix only related issues" rule.

## Production deployment notes

The deploy checklist remains exactly as documented in `docs/deployment/FRONTEND_PRODUCTION_BUILD.md` for the FE, plus the standard Laravel:

```bash
cd /var/www/dashboard/current/backend
php artisan migrate --force        # ← applies the trial_unlimited migration
php artisan config:clear
php artisan cache:clear
php artisan route:clear
php artisan view:clear
php artisan config:cache
php artisan route:cache
sudo systemctl restart php8.3-fpm
```

No new env vars, no new infrastructure, no manual seeding required. The "S-2056 promote 422" scenario cannot recur post-deploy because:

1. The signup-id generator no longer touches dialect-specific SQL.
2. The migration ensures `trial_unlimited` exists. No manual `tinker` insert needed.

## Rollback notes

Each commit reverts independently:

- `git revert 0039a5d` — removes the inline ActionError on the onboarding drawer. Drawer reverts to silent-failure behaviour. **Not destructive.**
- `git revert da19ae8` — undoes the migration *but* the migration's `down()` is a deliberate no-op, so the existing plan row stays. The TrialUnlimitedPlanSeedTest is removed. **Not destructive.**
- `git revert 9a82c2d` — reverts to the SQLite-only signup-id generator. The 12 production tests start failing again. **Not destructive but breaks production on MySQL.** Only revert if rolling back to a SQLite environment, which is not a normal operational scenario.

No data loss in any rollback scenario. No tenant_subscriptions rows ever break — even the migration's `down()` is intentionally non-destructive.

## Tests run

| Suite | Result | Notes |
|---|---|---|
| `ActivationCodeTest` | **13 passed** | full activation-code lifecycle |
| `TenantProvisioningTest` | **9 passed** | end-to-end provisioning + promote |
| `PublicWebsiteTest` | **all green** | public signup + contact form |
| `SignupStatusTest` | **10 passed** | bilingual 404, rate-limit, activation flag |
| `AdversarialQATest` | **all green** | red-team scenarios |
| `TrialUnlimitedPlanSeedTest` | **1 passed** (new) | migration creates the plan |
| `php artisan test --filter='Platform\|Signup\|Activation\|Onboarding\|TrialUnlimited'` | **142 passed** (650 assertions) | broader regression |
| `php artisan test --filter=Dine` | **186 passed** (1633 assertions) | full Dine regression |
| `php artisan test --filter=DineCashSaleAccountingPostingTest` | **1 passed** (53 assertions) | cash-sale accounting integrity preserved |
| `npx vitest run` | **178 passed** across 17 files | full frontend |

## Production-ready

**Yes.** Activation, signup, public-website contact, platform promote, and onboarding flows are all production-safe on MySQL. The migration ensures the only missing prerequisite plan (`trial_unlimited`) exists after a vanilla `migrate --force`. Every error surface in the flow now displays the BE response inline.

Stopping here. No Dine Phase 23 started.
