# Phase 13 — Ciclo di vita dei servizi ricorrenti **Milestone:** v2.4 Post-vendita · **Stato:** ✅ in produzione, verificata end-to-end **Consegnata:** 2026-08-01 · **Commit:** `5177a37` · **Migration:** `0016_project_offer_lifecycle.sql` > **Ricostruito a posteriori il 2026-08-08** dal commit, dalla migration e da > `STATUS.md`. La fase è stata eseguita fuori dal ciclo GSD: non esiste un PLAN. > Phase 13 nasce congelata in v2.1 (giugno) e riaperta come milestone v2.4. ## Il problema Un retainer, una volta assegnato, non si poteva fermare. `project_offers` aveva solo `start_date`, e il ramo retainer di `src/lib/forecast-queries.ts` sommava il canone a **ogni mese** dell'orizzonte da lì in poi, per sempre. Un cliente che disdiceva continuava a gonfiare il forecast a 12 mesi e a vedersi l'abbonamento attivo nel proprio portale. ## Cosa è stato fatto **Schema** — migration `0016`, additiva pura, applicata a prod **prima** del push: `project_offers.status` (`attivo|sospeso|cessato`, CHECK `NOT VALID` per evitare un lock lungo) e `project_offers.end_date` (nullable, NULL = continuativo). Default `'attivo'` così ogni riga esistente conserva esattamente il comportamento precedente. Nessun DROP, nessun TRUNCATE. Idempotente. **Forecast** — i retainer si fermano a `end_date`; sospesi e cessati escono dal calcolo. `getOffersSoldBreakdown` **non** filtra per stato di proposito: è uno storico di vendita, ed escludere le cessate riscriverebbe il passato. `offersAcceptedTotal` esclude le cessate (default del piano pagamenti). **Admin** — comandi Sospendi / Riattiva / Cessa più data di fine nella tab Offerte, mostrati solo per i ricorrenti. `setProjectOfferLifecycle` valida con Zod e filtra **anche per `project_id`**, così un id arbitrario non può toccare un altro progetto. **Portale cliente** — "Attivo dal", "fino al", badge *In pausa*, "Canone mensile" al posto di "Prezzo finale". Le offerte cessate non arrivano mai al client. **Fix collaterale** — un retainer sospeso continuava a intestare i pagamenti "Totale Pagamento Mensile" e a sovrascriverne l'importo. ## Decisioni - **Storico di vendita ≠ forecast.** Due letture diverse degli stessi dati: il breakdown del venduto ignora lo stato, il forecast lo rispetta. Unificarle avrebbe fatto sparire fatturato già incassato dai report. - **Default `'attivo'` invece di NULL.** Rende la migration a comportamento invariato senza una backfill separata. - **Filtro per `project_id` nell'action**, non solo per id dell'offerta: difesa in profondità contro un id manipolato. ## Fuori scope, rimandato Tracciamento dei canoni mese per mese (agosto pagato / settembre no). Serve una tabella nuova: `payments` è protetta dai vincoli di Data Safety e la sua riscalatura è pensata per i piani una tantum. Voce di backlog in `REQUIREMENTS.md`.