# EasyForma

SaaS per enti di formazione: aule virtuali BigBlueButton a pacchetti (Stripe), registro presenze, invio dati a Forma.Temp.
Disegno del sistema in `docs/ARCHITECTURE.md`. UI in italiano.

## Harness

<!-- managed by /innovatic:onboard — edit values freely, keep the keys -->

```yaml
stack: laravel 13 + inertia react 19 (typescript, tailwind 4, shadcn, wayfinder) + cashier stripe + bigbluebutton
install: composer install && npm install
dev: composer run dev
test: composer test
lint: composer run lint:check && npm run check && npm run types:check
lint_fix: composer run lint && npm run check:fix
security: composer audit && npm audit
build: npm run build
deploy: manual
dev_url: http://localhost:8000
branch: main
workflow: trunk
skills: [laravel-specialist, laravel-patterns, frontend-design, agent-browser]
notes: 'PHP 8.4+ required (Pest/Laravel 13); on this Mac the default php is 8.3, prefix commands with `export PATH=/usr/local/opt/php/bin:$PATH` (PHP 8.5). `composer test` runs pint --test, phpstan (level 7, larastan) and phpunit; tests use SQLite in memory and withoutVite(). `composer test` fails on TS/lint issues only via `composer ci:check`. Seed with `php artisan migrate:fresh --seed` (demo@easyforma.test / password). BBB_DRIVER=fake and FORMATEMP_DRIVER=fake by default: no external services needed locally. Stripe webhooks: `stripe listen --forward-to localhost:8000/stripe/webhook`. Deploy target not decided yet.'
```

## Convenzioni

- Modelli divisi per dominio: `App\Models\Billing` (Package, Order, CreditGrant, CreditMovement, StripeWebhookEvent), `App\Models\Classrooms` (Classroom, Participant, Meeting, MeetingEvent, Attendance), `App\Models\Formatemp` (FormatempSync), `App\Models\Ai` (AiUsage); `Company` e `User` alla radice. Le factory rispecchiano i namespace (`Database\Factories\Billing\...`).
- Modelli con attributi `#[Fillable]`, `casts()` e docblock `@property`; relazioni annotate con generics (`HasMany<X, $this>`) per larastan.
- Tenancy single-DB: trait `BelongsToCompany` (global scope + company_id automatico). Nei job/comandi usare `withoutGlobalScopes()`.
- Crediti come moneta: un credito è mezza giornata d'aula (`credit_hours_per_credit` = 4 ore fino a `credit_participants_band` = 30 discenti) e un'aula costa `CreditService::costFor(discenti, ore)` = `ceil(ore / 4) × ceil(discenti / 30)`. I `Package` vendono `credits` e sono divisi da `kind` (`plan` ricorrente che li accredita a ogni fattura pagata, `topup` una tantum); il saldo è una contabilità a partite (`CreditGrant` + `CreditMovement`, consumo FIFO per scadenza); i crediti di piano non usati si riportano sul periodo successivo fino a un'allowance mensile (movimento `rollover`, `credit_grants.rolled_over_from_id`, partita intestata all'ordine che apre il periodo; si riporta solo il periodo appena chiuso, entro `easyform.rollover_grace_days`, e l'eccedenza oltre il tetto è un movimento `forfeit`); il piano attivo si legge da `Company::currentPlan()`. Le due costanti arrivano al frontend dalla prop Inertia condivisa `pricing`.
- Servizi esterni dietro interfacce: `App\Services\Bbb\BbbClient` (driver `api`/`fake`), `App\Services\Formatemp\FormatempClient` (driver `api` su FTWEB cap. 6 / `fake` simulatore con stato). Forma.Temp: sessione CSRF+`JSESSIONID` cachata per company con un solo retry, ambiente per azienda (`collaudo`/`produzione`), ogni chiamata in `formatemp_syncs` con password/cookie/token mascherati da `FormatempCallRecorder`, consolidamento fascia irreversibile e solo con `confirm=1`.
- Frontend: pagine Inertia in `resources/js/pages`, route tipizzate Wayfinder (`@/routes/...`, `@/actions/...`), niente URL hardcoded. Rigenerare con `php artisan wayfinder:generate` dopo aver toccato le route.
- Webhook Stripe: ogni evento passa dal registro `stripe_webhook_events` (idempotenza su `stripe_event_id` + lock di dominio); le transizioni dell'ordine finiscono in `orders.logs` via `Order::appendLog()` e le mail transazionali sono `ShouldQueue` con `afterCommit = true` (impostato nel costruttore, non come default di proprietà).
- Scheduler (`routes/console.php`): `meetings:refresh` ogni 3 minuti, `recordings:expire` ogni ora. Cancellazione registrazioni via API BBB + SSH (`phpseclib`) solo se `BBB_SSH_HOST` è configurato.
- Onboarding: wizard `/onboarding` (progresso in `companies.onboarding_progress`, nessun middleware bloccante) + checklist dashboard derivata dai dati (`OnboardingChecklist`). Vetrina: pagine `marketing/*` senza layout app; palette unica (i `--marketing-*` sono alias dei `--brand-*` di `app.css`) e classe `.marketing-scope` sul layout per tenerle chiare anche in tema scuro. Vedi `docs/design-system.md` §2 bis.
- AI dietro `App\Services\Ai\AiClient` (`extract` per JSON, `write` per testo), mai chiamata diretta all'API: `AI_DRIVER=fake` in locale e nei test, `anthropic` in produzione. Il deterministico (codice fiscale, ore, limiti) resta codice normale.
- Migrazioni atomiche: sempre generate con `php artisan make:migration` (timestamp reale `Y_m_d_His`), una sola tabella o una sola modifica per file, mai più tabelle nello stesso file. I file `create_` contengono la forma definitiva della tabella; si aggiunge un file `alter` solo per tabelle di framework o starter kit che non possiamo riscrivere (es. `users`).
- Toast: `Inertia::flash('toast', ['type' => ..., 'message' => ...])`.
