# CYDM Automated Subdomain Onboarding & Provisioning

## Onboarding Flow Overview

```
┌─────────────┐    ┌─────────────┐    ┌─────────────┐    ┌─────────────┐    ┌─────────────┐
│   PUBLIC    │───▶│  SUBDOMAIN  │───▶│  BRANDING   │───▶│  PRODUCTS   │───▶│   GO-LIVE   │
│  SIGNUP     │    │  SELECTION  │    │  WIZARD     │    │  & USERS    │    │             │
└─────────────┘    └─────────────┘    └─────────────┘    └─────────────┘    └─────────────┘
     Step 1           Step 2            Step 3            Step 4            Step 5
```

---

## Step 1: Public Signup (Landing Page)

### Route: `GET /join` → `POST /join`

**Form Fields:**
- Institution legal name (required)
- License type: Category A / Category B / Tier 2 / Tier 4 (required)
- License number (required, validated against BoT/TCDC registry API)
- Contact person: Name, Phone, Email (required)
- Preferred subdomain slug (required, checked real-time)
- Plan selection: Starter / Growth / Enterprise

**Validation:**
- Slug: `^[a-z0-9-]{3,50}$`, not reserved, not taken
- Email: Business domain preferred (not gmail/yahoo)
- License: Format check + async API verification
- reCAPTCHA v3 / Turnstile

**On Submit:**
1. Create `Tenant` record with `status='pending_verification'`
2. Create `User` (owner) with `email_verified_at=null`
3. Send verification email with magic link (expires 24h)
4. Send SMS to contact phone with OTP
5. Redirect to `/join/verify?tenant={slug}`

---

## Step 2: Subdomain Selection & Verification

### Real-Time Slug Availability
```typescript
// resources/js/Pages/Join/SubdomainSelector.tsx
export function SubdomainSelector({ onSelect }: { onSelect: (slug: string) => void }) {
  const [slug, setSlug] = useState('');
  const [status, setStatus] = useState<'idle' | 'checking' | 'available' | 'taken' | 'invalid'>('idle');
  const [suggestions, setSuggestions] = useState<string[]>([]);
  
  useEffect(() => {
    const timer = setTimeout(async () => {
      if (!slug || slug.length < 3) { setStatus('idle'); return; }
      if (!/^[a-z0-9-]+$/.test(slug)) { setStatus('invalid'); return; }
      
      setStatus('checking');
      const res = await api.get('/api/tenant/check-slug', { params: { slug } });
      setStatus(res.data.available ? 'available' : 'taken');
      if (!res.data.available) setSuggestions(res.data.suggestions);
    }, 300);
    return () => clearTimeout(timer);
  }, [slug]);
  
  return (
    <div className="space-y-3">
      <label className="block text-sm font-medium">Your Subdomain</label>
      <div className="relative flex items-center">
        <input
          type="text"
          value={slug}
          onChange={(e) => setSlug(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ''))}
          placeholder="my-mfi"
          className="pr-32 w-full"
          maxLength={50}
        />
        <span className="absolute right-28 text-muted-foreground">.cydm.co.tz</span>
        <StatusBadge status={status} suggestions={suggestions} />
      </div>
    </div>
  );
}
```

### Reserved Slugs (System Routes)
```php
// config/tenancy.php
'reserved_slugs' => [
    'www', 'mail', 'ftp', 'admin', 'api', 'app', 'dashboard',
    'login', 'register', 'password', 'verify', 'join', 'onboarding',
    'settings', 'billing', 'support', 'help', 'docs', 'blog',
    'status', 'health', 'metrics', 'webhook', 'callback',
    'cydm', 'cycdm', 'microfinance', 'saccos', 'bank',
];
```

---

## Step 3: Branding Wizard (Detailed in BRANDING_THEMING.md)

**Steps:** Colors → Logo → Strings → Preview → Confirm

**Data Stored:** `tenant.branding` JSONB column

---

## Step 4: Products & Users Configuration

### Product Setup Wizard
```php
// app/Services/Tenancy/TenantProvisioningService.php
public function seedDefaultProducts(Tenant $tenant): void {
    // Loan Products
    $loanProducts = [
        ['name' => 'Business Loan', 'code' => 'BIZ', 'min_amount' => 50000, 'max_amount' => 5000000, 'max_term_months' => 24, 'interest_rate' => 2.5, 'repayment_frequency' => 'monthly'],
        ['name' => 'Agriculture Loan', 'code' => 'AGRI', 'min_amount' => 100000, 'max_amount' => 10000000, 'max_term_months' => 36, 'interest_rate' => 2.0, 'repayment_frequency' => 'quarterly'],
        ['name' => 'Emergency Loan', 'code' => 'EMER', 'min_amount' => 10000, 'max_amount' => 500000, 'max_term_months' => 6, 'interest_rate' => 3.5, 'repayment_frequency' => 'weekly'],
        ['name' => 'Asset Financing', 'code' => 'ASSET', 'min_amount' => 500000, 'max_amount' => 50000000, 'max_term_months' => 60, 'interest_rate' => 2.2, 'repayment_frequency' => 'monthly'],
        ['name' => 'Group Loan', 'code' => 'GRP', 'min_amount' => 100000, 'max_amount' => 20000000, 'max_term_months' => 18, 'interest_rate' => 2.5, 'repayment_frequency' => 'weekly'],
    ];
    
    // Savings Products
    $savingsProducts = [
        ['name' => 'Regular Savings', 'code' => 'REG', 'min_balance' => 1000, 'interest_rate' => 4.0, 'calculation_method' => 'daily_balance'],
        ['name' => 'Target Savings', 'code' => 'TGT', 'min_balance' => 5000, 'interest_rate' => 5.0, 'calculation_method' => 'daily_balance'],
        ['name' => 'Fixed Deposit 90 Days', 'code' => 'FD90', 'min_balance' => 100000, 'interest_rate' => 8.0, 'term_days' => 90],
        ['name' => 'Fixed Deposit 180 Days', 'code' => 'FD180', 'min_balance' => 100000, 'interest_rate' => 9.5, 'term_days' => 180],
        ['name' => 'Fixed Deposit 365 Days', 'code' => 'FD365', 'min_balance' => 100000, 'interest_rate' => 11.0, 'term_days' => 365],
    ];
    
    // Share Products
    $shareProducts = [
        ['name' => 'Membership Shares', 'code' => 'MEM', 'par_value' => 10000, 'min_shares' => 10, 'max_shares' => 10000, 'withdrawable' => false],
        ['name' => 'Voluntary Shares', 'code' => 'VOL', 'par_value' => 10000, 'min_shares' => 1, 'max_shares' => 50000, 'withdrawable' => true],
    ];
    
    foreach ($loanProducts as $p) LoanProduct::create(array_merge($p, ['tenant_id' => $tenant->id]));
    foreach ($savingsProducts as $p) SavingsProduct::create(array_merge($p, ['tenant_id' => $tenant->id]));
    foreach ($shareProducts as $p) ShareProduct::create(array_merge($p, ['tenant_id' => $tenant->id]));
}
```

### User Invitation Flow
```php
// Invite staff after products configured
public function inviteStaff(Tenant $tenant, array $invitations): void {
    foreach ($invitations as $invite) {
        $user = User::create([
            'name' => $invite['name'],
            'email' => $invite['email'],
            'phone' => $invite['phone'],
            'tenant_id' => $tenant->id,
            'role' => $invite['role'], // admin, loan_officer, teller, accountant, manager
            'invited_at' => now(),
        ]);
        
        // Assign Spatie permissions based on role
        $user->assignRole($invite['role']);
        
        // Send invitation email (magic link + set password)
        $user->sendInvitationNotification($tenant);
    }
}
```

---

## Step 5: Go-Live & DNS Provisioning

### Automated Provisioning Job
```php
// app/Jobs/ProvisionTenantJob.php
class ProvisionTenantJob implements ShouldQueue, TenantAware {
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
    
    public function __construct(
        public string $tenantId,
        public bool $isCustomDomain = false,
        public ?string $customDomain = null
    ) {}
    
    public function handle(TenantProvisioningService $provisioning): void {
        $tenant = Tenant::findOrFail($this->tenantId);
        
        try {
            // 1. Run tenant migrations
            $provisioning->runMigrations($tenant);
            
            // 2. Seed default data (products, roles, settings)
            $provisioning->seedDefaults($tenant);
            
            // 3. Configure subdomain DNS (wildcard already exists)
            $provisioning->configureSubdomain($tenant);
            
            // 4. Configure custom domain if provided
            if ($this->isCustomDomain && $this->customDomain) {
                $provisioning->configureCustomDomain($tenant, $this->customDomain);
            }
            
            // 5. Provision SSL (Caddy/Let's Encrypt automatic)
            $provisioning->ensureSsl($tenant);
            
            // 6. Create storage bucket prefix
            $provisioning->configureStorage($tenant);
            
            // 7. Initialize search indexes (Meilisearch)
            $provisioning->configureSearch($tenant);
            
            // 8. Send go-live notification
            $tenant->update(['status' => 'active']);
            Notification::send($tenant->owner, new TenantGoLiveNotification($tenant));
            
        } catch (\Throwable $e) {
            $tenant->update(['status' => 'provisioning_failed', 'provisioning_error' => $e->getMessage()]);
            Notification::send(AdminUser::all(), new TenantProvisioningFailedNotification($tenant, $e));
            throw $e;
        }
    }
}
```

### DNS Configuration (Wildcard + Custom)

**Infrastructure: Caddy (Automatic HTTPS)**
```caddy
# Caddyfile
{
    admin off
    email admin@cydm.co.tz
    acme_ca https://acme-v02.api.letsencrypt.org/directory
}

*.cydm.co.tz {
    reverse_proxy laravel-octane:8000 {
        header_up Host {host}
        header_up X-Real-IP {remote_host}
        header_up X-Forwarded-For {remote_host}
        header_up X-Forwarded-Proto {scheme}
    }
    
    # Tenant-specific config via middleware
    @tenantA host tenantA.cydm.co.tz
    handle @tenantA {
        reverse_proxy laravel-octane:8000
    }
}

# Custom domains handled by middleware lookup
```

**Custom Domain Onboarding:**
```php
// Tenant adds custom domain in Settings → Domains
public function addCustomDomain(Tenant $tenant, string $domain): void {
    // 1. Validate domain format
    // 2. Check not already used
    // 3. Verify DNS: CNAME → cydm.co.tz (or A → LB IP)
    // 4. Store in domains table
    Domain::create([
        'tenant_id' => $tenant->id,
        'domain' => $domain,
        'is_primary' => false,
        'verified_at' => null,
        'ssl_status' => 'pending',
    ]);
    
    // 5. Queue SSL provisioning
    ProvisionCustomDomainSslJob::dispatch($tenant->id, $domain);
    
    // 6. Instructions for tenant
    return [
        'cname_target' => 'cydm.co.tz',
        'txt_verification' => 'cydm-verify=' . Str::random(32),
        'instructions' => 'Add CNAME record pointing to cydm.co.tz. Add TXT record for verification.',
    ];
}
```

---

## Trial & Subscription Management

### Trial Period (14 Days)
```php
// Tenant model
protected $casts = [
    'trial_ends_at' => 'datetime',
    'subscription_ends_at' => 'datetime',
];

// On creation
$tenant->trial_ends_at = now()->addDays(14);

// Middleware check
class EnsureTenantAccess {
    public function handle(Request $request, Closure $next) {
        $tenant = $request->attributes->get('tenant');
        
        if ($tenant->status === 'trial' && $tenant->trial_ends_at->isPast()) {
            if (!$tenant->hasActiveSubscription()) {
                return redirect()->route('tenant.billing.upgrade')
                    ->with('warning', 'Your trial has ended. Please upgrade to continue.');
            }
        }
        
        return $next($request);
    }
}
```

### Stripe Integration (Per-Tenant Billing)
```php
// Tenant model uses Billable
class Tenant extends Model {
    use Billable;
    
    public function stripeName(): string { return $this->name; }
    public function stripeEmail(): string { return $this->owner->email; }
    
    // Plan mapping
    public function planFeatures(): array {
        return match($this->plan) {
            'starter' => ['members' => 500, 'loans' => 1000, 'storage_gb' => 1, 'api_calls' => 10000],
            'growth' => ['members' => 5000, 'loans' => 20000, 'storage_gb' => 10, 'api_calls' => 100000],
            'enterprise' => ['members' => null, 'loans' => null, 'storage_gb' => 100, 'api_calls' => null],
        };
    }
}
```

### Usage Metering & Limits
```php
// Middleware: Check limits on key actions
class EnforcePlanLimits {
    public function handle(Request $request, Closure $next) {
        $tenant = $request->attributes->get('tenant');
        $limits = $tenant->planFeatures();
        
        // Check member count on member creation
        if ($request->is('api/members*') && $request->isMethod('POST')) {
            $count = Member::where('tenant_id', $tenant->id)->count();
            if ($limits['members'] && $count >= $limits['members']) {
                return response()->json([
                    'message' => 'Member limit reached. Upgrade your plan.',
                    'limit' => 'members',
                    'current' => $count,
                    'max' => $limits['members'],
                ], 402);
            }
        }
        
        return $next($request);
    }
}
```

---

## Super-Admin Tenant Management

### Admin Panel: `/admin/tenants`

**Features:**
- List all tenants with filters (status, plan, trial ending, license type)
- Create tenant manually (for sales-assisted onboarding)
- Impersonate tenant (read-only or full access)
- View usage metrics (members, loans, API calls, storage)
- Manage subscription (upgrade/downgrade/cancel)
- Suspend/Reactivate/Delete (with 30-day soft delete)
- View audit log for tenant
- Trigger re-provisioning (migrations, SSL, DNS)

### Bulk Operations
```php
// app/Http/Controllers/Admin/TenantController.php
public function bulkAction(Request $request): JsonResponse {
    $action = $request->input('action'); // suspend, reactivate, extend_trial, send_notification
    $tenantIds = $request->input('tenant_ids', []);
    
    $tenants = Tenant::whereIn('id', $tenantIds)->get();
    
    foreach ($tenants as $tenant) {
        match($action) {
            'suspend' => $tenant->update(['status' => 'suspended']),
            'reactivate' => $tenant->update(['status' => 'active']),
            'extend_trial' => $tenant->update(['trial_ends_at' => now()->addDays(14)]),
            'send_notification' => Notification::send($tenant->owner, new CustomAdminNotification($request->input('message'))),
            default => null,
        };
    }
    
    return response()->json(['processed' => $tenants->count()]);
}
```

---

## Monitoring & Alerting

### Key Metrics (Prometheus/Grafana)
| Metric | Alert Threshold |
|--------|-----------------|
| `tenant_provisioning_duration_seconds` | > 300s (5 min) |
| `tenant_provisioning_failures_total` | > 0 in 5 min |
| `tenant_trial_ending_24h` | Gauge > 0 |
| `tenant_trial_ending_7d` | Gauge > 0 |
| `tenant_subscription_expiring_7d` | Gauge > 0 |
| `tenant_custom_domain_ssl_expiring_30d` | Gauge > 0 |

### Health Checks
```php
// routes/health.php
Route::get('/health/tenant/{slug}', function (string $slug) {
    $tenant = Tenant::where('slug', $slug)->firstOrFail();
    
    $checks = [
        'database' => DB::connection()->getPdo() ? 'ok' : 'fail',
        'redis' => Cache::store('redis')->get('health-check') ? 'ok' : 'fail',
        'storage' => Storage::disk('s3')->exists("tenants/{$tenant->id}/.health") ? 'ok' : 'fail',
        'ssl' => $tenant->ssl_status === 'active' ? 'ok' : 'degraded',
        'dns' => checkDnsResolution($tenant->full_domain) ? 'ok' : 'fail',
    ];
    
    $status = collect($checks)->contains('fail') ? 503 : 200;
    return response()->json(['tenant' => $tenant->slug, 'checks' => $checks], $status);
});
```

---

## Rollback & Disaster Recovery

### Provisioning Rollback
```php
// If provisioning fails at any step:
public function rollbackProvisioning(Tenant $tenant): void {
    // 1. Delete tenant database data (truncate all tenant tables)
    $this->truncateTenantData($tenant);
    
    // 2. Remove DNS records (if created)
    $this->cleanupDns($tenant);
    
    // 3. Remove SSL certificates
    $this->cleanupSsl($tenant);
    
    // 4. Remove storage prefix
    Storage::disk('s3')->deleteDirectory("tenants/{$tenant->id}");
    
    // 5. Remove search indexes
    $this->cleanupSearch($tenant);
    
    // 6. Reset tenant status
    $tenant->update(['status' => 'pending_verification', 'provisioning_error' => null]);
}
```

### Tenant Data Export (GDPR / Migration)
```php
// app/Jobs/ExportTenantDataJob.php
public function handle(): void {
    $tenant = Tenant::find($this->tenantId);
    $export = new TenantDataExport($tenant);
    
    // Stream to S3 as ZIP
    $export->streamToStorage("tenants/{$tenant->id}/exports/tenant-export-{$tenant->id}-{now()->format('Ymd')}.zip");
    
    // Notify admin + tenant owner
    Notification::send([$tenant->owner, ...AdminUser::all()], new TenantExportReadyNotification($tenant));
}
```

---

## Testing Checklist

- [ ] Slug validation: reserved, taken, invalid chars, length
- [ ] Email verification flow (magic link expiry, resend)
- [ ] Branding wizard: all steps, validation, preview accuracy
- [ ] Product seeding: all default products created correctly
- [ ] User invitations: roles, permissions, magic links work
- [ ] Subdomain DNS: resolves, SSL issued, middleware catches
- [ ] Custom domain: CNAME verification, SSL provisioning
- [ ] Trial expiry: redirect to billing, grace period
- [ ] Plan limits: enforced on create actions, 402 responses
- [ ] Admin impersonation: read-only vs full, audit logged
- [ ] Provisioning job: success, failure, rollback, idempotency
- [ ] Health checks: all components, custom domain SSL
- [ ] Data export: complete, valid JSON/CSV, downloadable