# EasyForma – Architettura MVP

SaaS per enti di formazione: piano mensile che accredita crediti + ricariche una tantum, con cui si aprono aule virtuali (BigBlueButton su server privati),
caricamento discenti, registro presenze automatico, invio dati a Forma.Temp con le credenziali del cliente.

## Stack

- Laravel 13 / PHP 8.5, Inertia 3 + React 19 + TypeScript, Tailwind 4, shadcn/ui, Wayfinder (route tipizzate).
- Laravel Cashier 16 (Stripe): abbonamento ricorrente ai piani + Checkout one-shot per le ricariche di crediti.
- `littleredbutton/bigbluebutton-api-php` 6 per BBB (checksum SHA-256), `spatie/simple-excel` per import CSV/XLSX.
- DB: SQLite in sviluppo/test, MySQL o PostgreSQL in produzione (nessuna feature specifica del driver).

## Organizzazione dei modelli

`App\Models\Billing` (Package, Order, CreditGrant, CreditMovement), `App\Models\Classrooms` (Classroom, Participant, Meeting, MeetingEvent, Attendance),
`App\Models\Formatemp` (FormatempSync); `Company` e `User` alla radice. Factory in namespace speculari.

## Tenancy

Single database. `Company` è il tenant (cliente). Ogni `User` appartiene a una company (`company_id`, `role`).
I modelli `Order`, `CreditGrant`, `CreditMovement`, `Classroom`, `FormatempSync` usano il trait `BelongsToCompany`:
global scope sulla company dell'utente autenticato + `company_id` impostato automaticamente in creazione.
Jobs, comandi console e webhook girano senza utente autenticato e quindi senza scope (usano `withoutGlobalScopes()` quando serve).
`Participant`, `Meeting`, `Attendance`, `MeetingEvent` sono figli di `Classroom` e vengono autorizzati tramite `ClassroomPolicy`.

## Modello commerciale

Un solo numero per il cliente: il **credito**. Il piano in abbonamento accredita X crediti a ogni periodo pagato,
le ricariche una tantum ne vendono altri, e un'aula costa crediti in base alla dimensione che il cliente sceglie.

- **Unità del credito**: un credito è mezza giornata d'aula, cioè `easyform.credit_hours_per_credit` ore (4 di
  default) fino a `easyform.credit_participants_band` discenti (30 di default).
- **Regola di costo**: `crediti = ceil(max_hours / 4) × ceil(max_participants / 30)` (`CreditService::costFor()`).
  Le due dimensioni si arrotondano per eccesso separatamente: un'aula da 15 discenti per 8 ore costa 2 crediti,
  una da 45 discenti per le stesse 8 ore ne costa 4, una da 20 discenti per 250 ore ne costa 63. Dentro la fascia
  il tetto dichiarato sui discenti non cambia il prezzo, così l'ente può dichiararlo largo senza pagare di più.
  Nessun limite fisso è imposto dal piano: discenti e ore li decide l'utente nel form dell'aula.
  Le due costanti arrivano al frontend dalla prop Inertia condivisa `pricing`
  (`hours_per_credit`, `participants_band`), presente anche per i visitatori non autenticati.
- `Package`: catalogo unico con `credits` e `kind` (`App\Enums\PackageKind`):
    - `plan` — ricorrente con `interval` (`month`/`year`), prezzo Stripe **recurring**, accredita `credits` a ogni
      fattura pagata. Scope `Package::plans()`.
    - `topup` — ricarica una tantum, prezzo Stripe one-time. Scope `Package::topups()`.
      `packages:sync-stripe` sceglie da sé il tipo di prezzo in base a `kind`.
      Il comando **riallinea anche i prezzi già collegati**: un prezzo Stripe è immutabile, quindi quando
      l'importo, la valuta o la ricorrenza di un pacchetto non coincidono più con quelli del prezzo collegato ne
      crea uno nuovo e archivia il vecchio (`active = false`), senza bisogno di `--force`. Il confronto parte da
      `packages.stripe_price_cents` (l'importo dell'ultimo prezzo creato, così il caso normale non chiama l'API) e
      prosegue leggendo il prezzo da Stripe, che intercetta anche le modifiche fatte a mano dalla dashboard.
      `--force` ricrea comunque il prezzo. Archiviare un prezzo non tocca gli abbonamenti che lo stanno usando.
- **Contabilità a partite**: `CreditGrant` (company, order, `source` plan|topup|manual, `amount`, `remaining`,
  `expires_at`, `rolled_over_from_id`) e `CreditMovement` (storico con `amount` con segno e `kind`
  grant|consume|refund|expire|rollover|forfeit).
  `forfeit` è la parte dei crediti di un periodo che si chiude persa perché eccedeva il tetto del riporto: esce dal
  saldo come una scadenza, ma nessuno l'ha spesa, quindi resta fuori dal dato "consumati in 30 giorni".
  Il saldo è la somma dei `remaining` delle partite non scadute (`Company::availableCredits()`).
  Il consumo è FIFO per scadenza (`CreditGrant::scopeSpendable`: scadenza più vicina prima, partite senza
  scadenza per ultime) e si spalma su più partite, sotto lock: i crediti del piano se ne vanno prima di quelli
  delle ricariche, così non si sprecano al rinnovo.
- `Order`: ogni acquisto, diviso da `kind` (`App\Enums\OrderKind`):
    - `package` — Stripe Checkout (mode=payment) di una ricarica. Il webhook `checkout.session.completed`
      (listener `HandleStripeWebhook` sull'evento Cashier `WebhookReceived`) segna l'ordine pagato e crea la partita,
      con scadenza da `easyform.credits_expire_days` (null = mai).
    - `subscription` — fattura pagata di un piano. `invoice.payment_succeeded` (e `invoice.paid`) individua l'ente
      dal customer Stripe e il piano dal prezzo della riga fattura, poi `SubscriptionFulfillment::issueForInvoice`
      crea l'ordine e la partita con `expires_at` = fine periodo.
      **Riporto dei crediti del piano**: la fattura che apre un periodo _nuovo_ (non una fattura di conguaglio)
      porta avanti i crediti di piano non consumati del periodo che si chiude, fino a un tetto pari all'allowance
      mensile del piano. Il riporto è una partita nuova con `source = plan`, `expires_at` = fine del nuovo periodo
      e `rolled_over_from_id` verso la partita di origine, registrata con un movimento `rollover`; la partita nuova
      appartiene all'**ordine che apre il periodo**, non a quello di origine, così non torna a essere un'origine per
      la fattura successiva e un rimborso colpisce il periodo giusto. Le partite di origine vengono azzerate con un
      movimento `expire` per la parte riportata e `forfeit` per quella oltre il tetto, quindi rigiocare la stessa
      fattura non crea nulla.
      Contano solo le partite `source = plan` della stessa subscription: le ricariche seguono la loro scadenza e
      le partite stornate da un rimborso non si riportano. Ciò che è già stato riportato una volta può esserlo
      ancora, sempre entro il tetto di un'allowance, e non conta come allowance già accreditata per il periodo.
      **Stripe non consegna le fatture in ordine di periodo** (una fattura insoluta può essere pagata settimane
      dopo, quando quella successiva è già stata incassata), quindi si riporta solo il periodo appena chiuso: una
      fattura il cui periodo è già finito non riporta niente (accrediterebbe crediti con scadenza nel passato,
      distruggendo quelli in corso) e sono candidate origini solo le partite di un ordine il cui `period_end` cade
      entro `easyform.rollover_grace_days` (default 7) dall'inizio del nuovo periodo. Senza quest'ultimo vincolo un
      abbonamento ripreso dopo mesi resusciterebbe a valore pieno crediti scaduti da un pezzo, dato che nessun job
      ripulisce le partite scadute.
      **Idempotenza su due livelli**, perché una fattura non è un periodo: `orders.stripe_invoice_id` (univoco) fa
      sì che ogni fattura produca un solo ordine, mentre la coppia `(stripe_subscription_id, period_start)` limita
      quanto un _periodo_ può accreditare in tutto. Un cambio piano a metà periodo genera una fattura di conguaglio
      con un id nuovo, che il primo ancoraggio non intercetta: viene emessa solo la differenza positiva fra i
      crediti del nuovo piano e quelli già accreditati per quel periodo. Un upgrade integra, un downgrade non
      accredita nulla e non toglie nulla, e passare avanti e indietro fra due piani non accredita più niente.
      Il periodo di riferimento di una fattura di conguaglio è quello dell'ordine già aperto che la contiene, non
      quello (parziale) della riga di conguaglio.
      Ordine e partita sono scritti **nella stessa transazione**, e una riconsegna che trova l'ordine senza la sua
      partita la emette (self-healing): se il processo muore dopo il commit i crediti non vanno persi.
      Le fatture senza `subscription` vengono ignorate; `invoice.payment_failed` e `customer.subscription.deleted`
      non revocano i crediti già emessi.
      Un rimborso totale (`charge.refunded`) o una disputa azzerano il `remaining` delle partite dell'ordine e le
      marcano `revoked_at` (`CreditService::revokeForOrder`): i crediti già spesi su un'aula restano spesi. Sulle
      fatture di abbonamento l'ordine si risale anche da `charge.invoice`, oltre che dal payment intent.
- **Aule**: `classrooms.credits_cost` registra quanto è costata l'aula. Creandola si consumano i crediti;
  finché nessuna lezione è stata avviata (`Classroom::creditsAreStillRefundable()`) l'aula si può ridimensionare
  (differenza addebitata o rimborsata, `CreditService::adjust`) ed eliminare con rimborso
  (`CreditService::refund`: i crediti tornano sulle partite d'origine ancora valide, quelli di una partita scaduta
  finiscono su una partita nuova senza scadenza). I crediti che venivano da una **partita revocata** (`revoked_at`,
  cioè rimborsata o stornata) non tornano invece mai indietro: il pagamento che li aveva comprati non c'è più, e il
  ledger registra un movimento a zero che lo spiega. Saldo insufficiente → errore di validazione sul campo `credits`
  con quanti crediti mancano e il rimando a `/billing`.
  Le decisioni su dimensione e crediti (`CreditService::adjust`, creazione/modifica/eliminazione aula, inserimenti
  massivi di lezioni e partecipanti) prendono un `lockForUpdate` sulla riga dell'aula (`Classroom::lockRow()`), così
  la lettura del limite e la scrittura che ne dipende sono serializzate sullo stesso oggetto.
- **Abbonamento**: `Company` è `Billable`. `Company::currentPlan()` risale dal `subscription('default')` di Cashier
  al `Package` del piano tramite `stripe_price`; `Company::planRenewsAt()` legge la fine del periodo dall'ultimo
  ordine `subscription` pagato. Attivazione con `CheckoutService::subscriptionCheckout`, cambio piano con
  `CheckoutService::swapPlan` (`swapAndInvoice`, proration fatturata subito), disdetta dal portale Stripe.
  Gli hook Cashier su `Company` (`stripeName`, `stripeEmail`, `stripeAddress`, `stripePreferredLocales`,
  `stripeMetadata`) sincronizzano ragione sociale, indirizzo e dati fiscali sul customer Stripe;
  la partita IVA viene registrata come tax id `eu_vat` da `Company::syncStripeTaxId()` dopo il checkout.

Il modello precedente (`classroom_credits`: un credito = un'aula con limiti fissi) non esiste più. Lo schema nasce
già con il ledger a partite, `credit_grants` + `credit_movements`, e non c'è nessuna migrazione di conversione.

### Flusso dei webhook Stripe

Tutti i webhook Stripe entrano da `POST /stripe/webhook` (Cashier verifica la firma e sincronizza gli abbonamenti),
poi il listener `HandleStripeWebhook` intercetta `WebhookReceived`.

`STRIPE_WEBHOOK_SECRET` è obbligatorio: Cashier applica la verifica della firma solo se `cashier.webhook.secret`
è valorizzato, quindi senza segreto l'app rifiuta di avviarsi in produzione e il listener ignora ogni evento
fuori da `local`/`testing`.

L'idempotenza è su due livelli:

1. **Registro `stripe_webhook_events`** (`App\Models\Billing\StripeWebhookEvent`): prima di qualunque effetto
   collaterale l'evento viene "preso in carico" dentro una transazione con `lockForUpdate`, usando lo
   `stripe_event_id` come chiave unica. Se l'evento risulta già `processed_at` viene ignorato; se è stato preso
   in carico da meno di 5 minuti (`processing_started_at`) la consegna duplicata viene saltata perché un'altra
   è ancora in corso; se un tentativo precedente è fallito (`error` valorizzato, `processed_at` nullo) il retry
   di Stripe lo rielabora.
   **Ogni** evento viene salvato, anche quelli non gestiti, con il payload completo e, quando risolvibile,
   il collegamento polimorfico `eventable` all'`Order` (o alla `Company` per gli eventi di abbonamento).
2. **Lock di dominio** in `OrderFulfillment` e `CreditService`: la riga dell'ordine viene bloccata e la
   transizione applicata solo se l'ordine non è già nello stato di destinazione.

Se l'handler solleva un'eccezione, l'errore viene scritto sulla riga del registro e l'eccezione rilanciata:
Cashier risponde 500, Stripe riprova e la riga resta non elaborata, così il retry ripete davvero il lavoro.

Eventi gestiti:

| Evento                                                                   | Effetto                                                                                                                                                                    |
| ------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `checkout.session.completed`, `checkout.session.async_payment_succeeded` | `OrderFulfillment::markPaid`: ordine `paid`, emissione crediti, mail di conferma all'acquirente. Controllo di congruenza sull'importo (`amount_total` vs `amount_cents`).  |
| `checkout.session.async_payment_failed`, `checkout.session.expired`      | `OrderFulfillment::markFailed`: ordine `failed`.                                                                                                                           |
| `charge.refunded` (rimborso totale)                                      | `OrderFulfillment::markRefunded`: ordine `refunded` e azzeramento del `remaining` delle partite dell'ordine. I crediti già spesi su un'aula restano spesi.                 |
| `charge.refunded` (rimborso parziale)                                    | Solo log e nota nell'audit trail.                                                                                                                                          |
| `charge.dispute.created`                                                 | I crediti non ancora spesi vengono congelati e viene scritta una nota, ma lo stato resta `paid` (il denaro è stato incassato): l'esito si gestisce a mano. `Log::warning`. |
| `invoice.payment_succeeded`, `invoice.paid` (con `subscription`)         | `SubscriptionFulfillment::issueForInvoice`: ordine `subscription` pagato con `stripe_invoice_id` univoco e partita dei crediti del piano con scadenza a fine periodo.      |
| `invoice.payment_succeeded` senza `subscription`                         | Solo registrazione: le fatture dei pagamenti una tantum sono già gestite da `checkout.session.completed`.                                                                  |
| `invoice.payment_failed`                                                 | Solo registrazione + `Log::info`: lo stato dell'abbonamento lo sincronizza Cashier.                                                                                        |
| altri                                                                    | Registrati come elaborati, nessun effetto collaterale.                                                                                                                     |

Ogni transizione scrive una voce nella colonna JSON `orders.logs` tramite `Order::appendLog()`
(append-only, mai sovrascritta): `paid`, `subscription.paid`, `failed`, `refunded`, `refund.partial`, `disputed`, `amount_mismatch`.
La mail `OrderPaidMail` è `ShouldQueue` con `afterCommit = true` e viene accodata in try/catch,
così un problema di posta non fa mai fallire il webhook.

## Aule e lezioni

- `Classroom` (uuid, code, limiti) → `Participant` (allievo/docente, `join_token` personale) e `Meeting` (lezione con data/durata).
- La somma delle durate delle lezioni non può superare `max_hours`; i discenti non possono superare `max_participants`.
- `MeetingManager::start` crea la stanza su BBB (`meetingID` = `Meeting::uuid`), registra un hook bbb-webhooks e passa
  `meta_analytics-callback-url` e `meta_endCallbackUrl`. La `duration` della stanza è calcolata da
  `MeetingManager::roomDurationMinutes` (BBB la conta dalla creazione): i minuti che mancano alla fine prevista della lezione
  (`scheduled_at` + durata) più 5 di margine, mai sotto i 5, così la stanza sopravvive sempre alla lezione. Il docente (participant `trainer`) entra come MODERATOR e può avviare da solo
  la stanza nella finestra di accesso (`bbb.opening_minutes`); gli allievi entrano come VIEWER solo a stanza avviata.
- `userID` su BBB = `p-{participant_id}`: così gli eventi webhook sono riconducibili al partecipante.

## Presenze (requisiti FAD sincrona Forma.Temp: nomi reali + timestamp login/logout)

- `WebhookProcessor` salva ogni evento in `meeting_events` e apre/chiude intervalli in `attendances` su `user-joined` / `user-left`;
  `meeting-ended` chiude gli intervalli aperti.
- Fallback 1: `meetings:refresh` (scheduler ogni 3 min) interroga `getMeetingInfo` e riconcilia i presenti.
- Fallback 2: il report `analytics-callback-url` a fine meeting ricostruisce gli intervalli se non sono arrivati webhook.
- Export CSV per lezione e per aula (`AttendanceExportController`).

## Registrazioni

- Nessuna registrazione è salvata in locale: la lista viene letta da BBB al volo (`RecordingService::forClassroom`,
  che interroga `getRecordings` per ogni lezione già avviata dell'aula). L'URL di riproduzione è quello restituito da BBB;
  se manca si ricade su `bbb.recording_url` + `internalMeetingID` + `.mp4` (l'MP4 prodotto dal recording processor).
- La card "Registrazioni" compare nella scheda Lezioni dell'aula solo se `record_meetings` è attivo:
  `GET classrooms/{classroom}/recordings` (JSON) e `DELETE classrooms/{classroom}/recordings/{recordId}`.
  Prima di cancellare si verifica che la registrazione appartenga davvero a una lezione di quell'aula.
- Cancellare significa due cose: la chiamata API `deleteRecordings` (`BbbClient::deleteRecording`) e, se `bbb.ssh.host`
  è configurato, la rimozione via SSH (`phpseclib`, `sudo rm -f`) del file MP4 rimasto su disco.
- `recordings:expire` (scheduler ogni ora) elimina le registrazioni delle lezioni terminate da più di
  `bbb.recording_expire_hours` ore, per le sole aule con `record_meetings`.

## Email ai partecipanti

- `ClassroomInvitationMail` (markdown, in coda): calendario delle lezioni con il link personale di ciascuna
  (`join.enter` con il `join_token` del partecipante) e il promemoria che sul link pubblico la password è il codice fiscale.
  Il docente riceve la stessa email: il suo link personale lo fa entrare come MODERATOR.
  `POST classrooms/{classroom}/invitations` invia a tutti i partecipanti con email valida, oppure ai soli `participant_ids[]`.
- `MeetingRescheduledMail`: quando `MeetingController::update` sposta `scheduled_at`, ogni partecipante con email
  riceve vecchia data, nuova data e link personale.

## Driver BBB

`config/bbb.php` → `BBB_DRIVER=api|fake`. Il driver `fake` (`FakeBbbClient`) simula il server in cache e la pagina
`/dev/bbb/room` simula la stanza emettendo gli stessi eventi di bbb-webhooks: permette di provare l'intero flusso senza server.
Per il server reale: BBB 3.0 su Ubuntu 22.04, `bbb-webhooks` installato, secret in `BBB_SECRET`.

## Vetrina pubblica e onboarding

- Pagine marketing (`/`, `/prezzi`, `/privacy`, `/termini`) servite da `Marketing\MarketingController` con pagine Inertia
  `marketing/*` senza layout applicativo (`app.tsx` restituisce `null` per `marketing/`); layout condiviso in
  `resources/js/layouts/marketing-layout.tsx`, token `--marketing-*` in `app.css`, brief in `docs/design-system.md` e
  copy in `docs/marketing-copy.md`. I piani mostrati (prop `packages`) e le ricariche (prop `topups`) sono quelli attivi nel DB, ciascuno con i suoi `credits`; la CTA porta a
  `/register?package=<slug>` e `CreateNewUser` salva lo slug in sessione (`onboarding.package`).
- Onboarding (pattern gestionale8108, senza gate forzato): dopo la registrazione Fortify reindirizza a `/onboarding`
  (`Onboarding\OnboardingController`, wizard in 3 passi: profilo ente, credenziali Forma.Temp, scelta pacchetto, con
  "Salta"). Il progresso vive in `companies.onboarding_progress` (json) e `onboarding_completed_at`. La dashboard mostra
  la checklist derivata dai dati reali (`OnboardingChecklist`: piano attivo, crediti, aula, partecipanti, Forma.Temp)
  finché non è tutto completato, con link al wizard.

## Forma.Temp

Integrazione con FTWEB (WS APL v7.0) sui servizi di **rilevazione presenze** (cap. 6): l'ente crea in FTWEB progetto,
giornate e fasce orarie, EasyForma le legge e compila il registro presenze delle lezioni FAD sincrone.
Note tecniche in `docs/formatemp/api-ftweb.md`, piano e stato in `docs/formatemp/piano-integrazione.md`.

`App\Services\Formatemp\FormatempClient` è l'interfaccia tipizzata (`testConnection`, `fasce`, `registro`,
`attivaFascia`, `inviaPresenza`, `aggiungiPersonale`, `consolida`, `cercaDiscente`, `creaDiscente`) più un `send()`
generico ereditato dagli export neutri di `FormatempPayloadBuilder`. Due driver:

- `fake` — `FakeFormatempClient`, simulatore con stato (fasce, registri, personale, consolidamento, anagrafiche)
  tenuto in cache per company. In locale inventa una fascia per lezione pianificata e la persiste; nei test si semina
  con `seedFasce()`.
- `api` — `Ftweb\ApiFormatempClient` su `Ftweb\FtwebHttpClient`: autenticazione `GET /services/autenticationcsrftoken`
  con `SM_USER`/`SM_PASSWORD` (+ `SM_SDOMAIN` se configurato), token CSRF e `JSESSIONID` cachati per company 40 minuti
  (la sessione FTWEB dura 45), ri-autenticazione e **un solo** retry su 401/403 o `koMessages` di sessione scaduta.
  `Ftweb\FtwebResponse` legge l'involucro `{httpCode, returnCode, okMessages, koMessages}` e trasforma un
  `returnCode: KO` in `FormatempException` con i `koMessages`. DTO readonly in `Ftweb\Data`. Il login avviene sotto
  lock per company, con ricontrollo della cache: FTWEB tiene una sola sessione per utenza, e due worker che si
  ri-autenticano insieme si invaliderebbero a vicenda il `JSESSIONID`.

Il binding del driver è esaustivo: un valore diverso da `api`/`fake` solleva `InvalidArgumentException`, e `fake` è
rifiutato quando l'app gira in produzione a meno di `FORMATEMP_ALLOW_FAKE_IN_PRODUCTION=true` — il simulatore
risponde "inviato" a tutto, e in produzione sarebbe indistinguibile da un'integrazione funzionante fino all'audit.

Supporto: `FtwebFormat` centralizza date (`dd/MM/yyyy`), orari e durate (`HH:mm`) ed epoch in millisecondi;
`AttendanceToPresenzaMapper` trasforma gli intervalli join/leave di BBB in `orePresenza` / `oraFirmaInizioLezione` /
`oraFirmaFineLezione` unendo sovrapposizioni e disconnessioni sotto la soglia; `FasciaMatcher` abbina lezione e fascia
per codice progetto, data e orari con tolleranza configurabile.

Flusso: `FormatempLinker` ("Collega a Forma.Temp" sull'aula) considera solo le fasce del `codiceProgetto` dell'aula —
da lì viene anche `formatemp_project_type_id`, che è un segmento di ogni URL 6.x — e salva `meetings.formatemp_fascia_id`;
`MeetingAttendanceSender`, dietro il job `SendMeetingAttendanceToFormatemp` (`ShouldBeUnique` per lezione, backoff
30/120/300 s), attiva la fascia se chiusa, legge il registro, inserisce il docente come personale coinvolto e scrive
una presenza per discente — reinviare aggiorna le righe invece di duplicarle. Il consolidamento è irreversibile e passa
solo da `POST /meetings/{meeting}/formatemp/consolidate` con `confirm=1`; dopo, ogni altro invio risponde 409.

Una fascia appartiene a una sola lezione: `FasciaAssignment` rifiuta a scrittura una fascia già collegata a un'altra
lezione della stessa azienda, nominandola, e `meetings.formatemp_fascia_id` ha un indice unico. Una lezione già
collegata non viene mai riabbinata in silenzio: se la fascia non è più visibile, o appartiene a un altro progetto,
l'invio si ferma con un errore leggibile invece di dichiarare le stesse ore in un secondo registro. `formatemp_sent_at`
si scrive solo se almeno una presenza è stata inviata, così un registro vuoto non sblocca il consolidamento. Le tre
rotte Forma.Temp sono throttled (`10,1` per collega e invio, `6,1` per consolida): ogni chiamata passa dall'utenza
FTWEB dell'ente.

`FormatempSyncService::track()` scrive una riga `formatemp_syncs` per operazione con endpoint, stato HTTP, durata e un
riepilogo di richiesta e risposta. `FormatempCallRecorder::scrub()` maschera password, cookie, token e firme;
`prune()` fa un passo in più e tiene solo l'involucro, gli id e i conteggi, perché la 6.3 risponde con il registro
dell'intero progetto FTWEB (codici fiscali e nomi di discenti che non sono in aula). I corpi grezzi si conservano solo
con `FORMATEMP_LOG_RAW_RESPONSES=true`, per il debug su un'installazione di prova. `request` e `response` sono
`#[Hidden]` sul modello e la pagina aula riceve una proiezione esplicita (tipo, stato, errore, spiegazione, endpoint,
stato HTTP, date e il riepilogo). Le righe si cancellano dopo `formatemp.sync_retention_days` (`model:prune` giornaliero).
Un job che fallisce prima della prima chiamata (credenziali mancanti, lezione non terminata) scrive comunque la sua
riga `failed`, e un `FormatempException` non viene ritentato. Gli errori passano da `FormatempErrorExplainer` per la
spiegazione in italiano.

Configurazione in `config/formatemp.php`: due ambienti (`collaudo`, `produzione`) scelti per company
(`companies.formatemp_environment`), tolleranza di abbinamento, soglia di unione delle disconnessioni, default di
`tipoDestinatario`, piattaforma FAD e `create_missing_learners`. Le credenziali del cliente stanno in
`companies.formatemp_username/password/domain` (password cifrata con cast `encrypted`).

`SM_USER` e `SM_PASSWORD` viaggiano in header a ogni chiamata, quindi entrambi gli ambienti puntano a `https` e
`FormatempEnvironment::baseUrl()` riscrive lo schema (solo lo schema) di un URL in `http://`. Il cleartext resta
possibile soltanto su collaudo, con `FORMATEMP_ALLOW_INSECURE_TEST_URL=true`, mai mentre l'app gira in produzione, e
viene loggato come warning. L'ambiente di default resta `collaudo`, che è un sandbox: finché un'azienda ci sta sopra,
le impostazioni azienda mostrano un banner e la verifica connessione risponde con un toast di avviso.

## Calendario e metriche

La pagina `/calendario` (`Calendar\CalendarController`) è la vista di pianificazione dell'ente: mese, settimana e agenda
sulle stesse props (`view`, `date`, `range {from,to}`, `meetings[]`, `classrooms[]`, `filters`). L'intervallo è calcolato
in `Europe/Rome` (mese: dal lunedì che precede il primo giorno alla domenica che segue l'ultimo; settimana: lunedì-domenica;
agenda: 30 giorni dalla data) e `from`/`to` espliciti hanno la precedenza; `meetings` e `classrooms` sono closure, così il
partial reload di Inertia ricarica solo l'intervallo visibile quando si naviga. Il colore di ogni aula è deterministico
(`Classroom::color()`, `crc32(uuid)` su una palette di 8 tinte) e non richiede una colonna. Le metriche della dashboard
stanno in `Reporting\DashboardMetrics::for(Company)`: lezioni per stato, ore erogate (tempo reale in aula quando
BigBlueButton lo riporta, durata pianificata altrimenti) contro ore pianificate, serie delle ultime 8 settimane, consumo
ore e posti per aula attiva, saldo crediti (disponibili, in scadenza, consumati negli ultimi 30 giorni), presenza media dei discenti sulle ultime 20
lezioni terminate e sync Forma.Temp degli ultimi 30 giorni. Il servizio gira anche fuori dal ciclo di richiesta (job,
comandi), quindi ogni query rimuove il global scope di tenancy e filtra esplicitamente su `company_id`.

## Assistente AI

Tutto l'AI passa dall'interfaccia `App\Services\Ai\AiClient`, con due sole forme di chiamata: `extract()` per un oggetto
JSON (tool-use con `input_schema` uguale allo schema richiesto e `tool_choice` che forza quel tool) e `write()` per un testo
in italiano. Driver `fake` (predefinito, euristiche deterministiche: nessuna chiave, nessuna rete) e `anthropic` (Messages
API via `Http`, retry 2 con backoff su 429/5xx). `forFeature(feature, company)` restituisce una copia del client che attribuisce
la spesa: ogni chiamata reale registra token e costo stimato in `ai_usages`, e `AiBudget` blocca le funzioni oltre
`ai.monthly_budget_cents` (predefinito 500) con un 422 in italiano invece di chiamare il modello.

Il principio è che l'AI legge testo disordinato e scrive testo per umani: tutto ciò che è calcolabile resta codice normale.
`App\Support\FiscalCode` valida il codice fiscale (checksum ufficiale, omocodia) e ne ricava `birth_date`, `gender` e
`birth_place_code`, riempiti da un hook del modello `Participant` su form, import CSV e import AI; la regola
`ValidFiscalCode` rifiuta con 422 un carattere di controllo sbagliato.

Le quattro funzioni stanno in `App\Services\Ai\Features`:

- `ParticipantListParser` — `POST /classrooms/{id}/participants/ai-parse` trasforma un elenco incollato in righe modificabili
  (`{rows, warnings}`), arricchite dal codice fiscale; la conferma passa da `POST /classrooms/{id}/participants/bulk`, che
  rivalida ogni riga come il form singolo, rispetta il limite di discenti e salta i codici fiscali già iscritti.
- `SchedulePlanner` — `POST /classrooms/{id}/meetings/ai-plan` propone un calendario da una frase; le date fuori da
  `starts_on`/`ends_on` e le lezioni oltre le ore residue vengono escluse con un avviso, mai create in silenzio. La conferma
  passa da `POST /classrooms/{id}/meetings/bulk` (transazione unica).
- `MeetingReportWriter` — le anomalie (`below_threshold` sotto il 75%, `partial` 75–99%, `disconnessioni` oltre 15 minuti,
  `late_join` oltre 10 minuti, `trainer_absent`) sono calcolate dagli intervalli di `attendances`; all'AI resta solo la nota
  di registro (≤ 600 caratteri). Il risultato sta in `meetings.ai_report`. Il job `GenerateMeetingReport` (unico per lezione)
  parte da `MeetingManager::markEnded`, e `POST /meetings/{uuid}/ai-report` rigenera la relazione a richiesta.
- `FormatempErrorExplainer` — su un `FormatempSync` fallito riempie `formatemp_syncs.explanation` con il modello economico;
  un errore dell'assistente viene loggato e ignorato, l'errore originale di FTWEB resta sempre visibile.

Al modello arrivano solo il testo incollato dall'utente e i dati della lezione già nostri: niente credenziali, niente
password Forma.Temp, niente registrazioni.

## Route principali

- App: `/dashboard`, `/calendario`, `/billing`, `/classrooms/*`, `/meetings/{uuid}`, `/settings/company`.
- Assistente AI: `POST /classrooms/{uuid}/participants/ai-parse`, `POST /classrooms/{uuid}/participants/bulk`,
  `POST /classrooms/{uuid}/meetings/ai-plan`, `POST /classrooms/{uuid}/meetings/bulk`, `POST /meetings/{uuid}/ai-report`.
- Pubbliche: `/join/{token}` (pagina personale), `/join/{token}/enter/{meeting}`, `/m/{meeting}` (URL da pubblicare su Forma.Temp), `/m/{meeting}/left`.
  Su `/m/{meeting}` si entra con il codice fiscale; se l'aula ha `access_password` viene chiesta anche quella.
- Webhook: `POST /webhooks/bbb/events`, `POST /webhooks/bbb/{meeting}/analytics`, `/webhooks/bbb/{meeting}/ended`, `POST /stripe/webhook` (Cashier).
