# Crediti come moneta: piani che danno crediti, aule che costano crediti

> **Superato in parte da `specs/listino-ore-aula.md` (15/09/2026).** La contabilità a partite descritta qui resta
> valida, ma la formula di costo e il listino no: un credito ora vale 4 ore d'aula fino a 30 discenti
> (`crediti = ceil(ore / 4) × ceil(discenti / 30)`), i piani sono Base 59 €/10 crediti, Pro 189 €/40 ed
> Enterprise da 499 €/150, le ricariche sono `ricarica-10` (69 €) e `ricarica-50` (249 €), e i crediti di piano
> non consumati si riportano sul periodo successivo fino a un'allowance mensile.

Status: validated (15/09/2026, modello B scelto dall'utente)

## Context

Il modello precedente (piano = N aule con limiti fissi + pacchetti una tantum di aule) era confuso. Decisione:
un solo numero per l'utente, il **credito**. Il piano in abbonamento accredita X crediti a ogni periodo pagato;
le ricariche una tantum vendono crediti extra; un'aula costa crediti in base alla sua dimensione, scelta liberamente
dall'utente (discenti massimi e ore massime), senza limiti fissi imposti dal piano.

Regola di costo (configurabile in `config/easyform.php`, `credit_participant_hours = 5`):
`crediti = ceil(max_participants × max_hours / 5)`. Esempi: 10 discenti × 2 h = 4 crediti; 15 × 24 = 72;
20 × 8 = 32; 25 × 40 = 200.

## Requirements

- `packages`: `kind` = `plan` (ricorrente, `interval` month|year) o `topup` (una tantum); `credits` (interi) al
  posto di `classrooms_count`; `max_participants`/`max_hours` non hanno più senso sui pacchetti (colonne
  rimosse o ignorate). Seed: piani Base 79 €/mese → 40 crediti, Pro 199 €/mese → 120 crediti, Enterprise
  499 €/mese → 350 crediti; ricariche 30 crediti a 59 € e 100 crediti a 179 €.
- Contabilità crediti a partite: tabella `credit_grants` (company_id, order_id nullable, amount, remaining,
  expires_at nullable, source: plan|topup|manual) e `credit_movements` (company_id, grant_id, classroom_id
  nullable, amount con segno, kind: grant|consume|refund|expire, note). Saldo = somma di `remaining` delle partite
  non scadute. Consumo FIFO per scadenza più vicina, spalmato su più partite se serve, sotto lock per company.
  Rimborso alla cancellazione dell'aula (crediti riaccreditati sulle partite d'origine se non scadute, altrimenti
  nuova partita senza scadenza). Lo schema nasce direttamente con la contabilità a partite: nessuna tabella
  `classroom_credits` e nessuna migrazione di conversione (migrazioni atomiche, una tabella per file).
- Piani: a `invoice.payment_succeeded` con abbonamento → ordine `kind = subscription` idempotente per fattura →
  partita di `credits` con `expires_at` = fine periodo. Ricariche: ordine `kind = package` → partita con scadenza
  da `easyform.credits_expire_days` (null = mai). Rimborso Stripe totale → le partite dell'ordine vengono azzerate
  per la parte non consumata.
- Aula: l'utente sceglie `max_participants` e `max_hours` nel form; il costo in crediti è calcolato e mostrato
  in tempo reale; alla creazione si consumano i crediti (`classrooms.credits_cost`); saldo insufficiente →
  errore "Ti mancano N crediti" con link alla pagina billing. Modifica dei limiti di un'aula in bozza: differenza
  di crediti addebitata o rimborsata; aula attiva: limiti non modificabili.
- Billing page: saldo crediti (disponibili, in scadenza entro 30 giorni con data, consumati nel periodo), piano
  attuale (crediti inclusi al mese, rinnovo, cambio piano, portale), ricariche, storico movimenti (ultimi 20).
- Onboarding passo 3: scelta del piano. Checklist: step "Attiva un piano". Dashboard `metrics.credits` =
  `{available, consumed_30d, expiring_soon, expiring_at}`.
- Vetrina: piani con "X crediti al mese", esempio di conversione ("un'aula da 15 discenti per 8 ore costa
  24 crediti"), ricariche, calcolatore semplice sulla pagina prezzi (discenti × ore → crediti).
- Comando `packages:sync-stripe` crea prezzi ricorrenti per i piani e una tantum per le ricariche, e li
  riallinea: se importo, valuta o ricorrenza non coincidono più con il prezzo collegato ne crea uno nuovo e
  archivia il vecchio (i prezzi Stripe sono immutabili).

## Acceptance criteria

1. `CreditService::costFor(15, 8) === 24` e `costFor(10, 2) === 4`; `POST /classrooms` con `max_participants =
15, max_hours = 8` e saldo 30 crea l'aula con `credits_cost = 24` e lascia saldo 6; con saldo 20 risponde 422
   sul campo `credits` con messaggio contenente "Ti mancano 4 crediti".
2. Evento `invoice.payment_succeeded` (abbonamento `sub_x`, cliente = `stripe_id` della company, riga con
   `price.id` del piano Pro, `period.end` tra 30 giorni) crea un ordine `kind = subscription` con
   `stripe_invoice_id = in_x` e una partita da 120 crediti con `expires_at = period.end`; lo stesso evento
   ripetuto (stesso id, o altro id con stessa fattura) non crea altre partite. Fattura senza abbonamento → nessun
   ordine.
3. Con due partite (40 crediti in scadenza tra 10 giorni, 30 senza scadenza) un'aula da 50 crediti consuma 40
   dalla prima e 10 dalla seconda; cancellando l'aula in bozza i 50 crediti tornano sulle partite d'origine.
4. `POST /billing/subscription/checkout` con `package = pro` avvia il checkout con il prezzo Stripe del piano
   (mock del servizio); con `package` di tipo `topup` risponde 422. `GET /billing` espone `credits {available,
expiring_soon, expiring_at, consumed_30d}`, `plan {current, plans, topups}` e `movements[]`.
5. GET /prezzi mostra i tre piani con "crediti al mese" e il calcolatore; GET / contiene "crediti";
   `composer test`, `npm run types:check`, `npm run check`, `npm run build` passano.

## Out of scope

Piani annuali, prova gratuita, rollover configurabile, prezzi per credito differenziati per volume oltre ai piani,
crediti per docente/ora effettiva.
