feat(offers): ciclo di vita dei servizi ricorrenti (v2.4 Phase 13)

Un retainer, una volta assegnato, non si poteva fermare: project_offers
aveva solo start_date e il forecast sommava il canone a ogni mese
dell'orizzonte da li in poi, per sempre. Un cliente che disdiceva
continuava a gonfiare il forecast a 12 mesi e a vedersi l'abbonamento
attivo nel portale.

- migration 0016 (gia applicata a prod): project_offers.status
  (attivo|sospeso|cessato, CHECK) + end_date. Additiva pura, default
  'attivo' cosi le righe esistenti conservano il comportamento di prima
- forecast: i retainer si fermano a end_date, sospesi e cessati escono.
  getOffersSoldBreakdown NON filtra per stato: e uno storico di vendita,
  escludere le cessate riscriverebbe il passato
- offersAcceptedTotal esclude le cessate (default del piano pagamenti)
- admin: comandi Sospendi/Riattiva/Cessa + data fine nella tab Offerte,
  solo per i ricorrenti. setProjectOfferLifecycle valida con Zod e filtra
  anche per project_id, cosi un id arbitrario non tocca altri progetti
- portale: "Attivo dal", "fino al", badge In pausa, "Canone mensile"
  invece di "Prezzo finale"; le cessate non arrivano al client
- fix: un retainer sospeso continuava a intestare i pagamenti "Totale
  Pagamento Mensile" e a sovrascrivere l'importo

Igiene nello stesso giro:
- STATUS.md riscritto: era fermo al 22 giugno e diceva che node/docker non
  sono disponibili sul server e che le migrazioni si applicano da locale
  con uno script postgres.js — il contrario della procedura reale
- rimossi ChatSection/CommentList/CommentForm, senza importatori (308 righe)
- overrides postcss>=8.5.18 e sharp>=0.35.0: 3 CVE high transitive di Next
  senza fix upstream. npm audit ora pulito, build verde

Verifica: forecast controllato sui dati veri in 5 scenari (baseline
invariata, end_date, sospeso, cessato, ripristino); portale verificato nei
4 stati; tab admin verificata con Playwright sul build di produzione
(i comandi non compaiono sulle una tantum). Dati di test ripuliti,
tabelle protette invariate 4/5/11/10.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-01 18:18:28 +02:00
parent d57b0f3e04
commit 5177a3700a
16 changed files with 616 additions and 545 deletions
+22 -3
View File
@@ -25,7 +25,7 @@ import {
clientTranscripts,
client_emails,
} from "@/db/schema";
import { eq, inArray, asc, desc, isNull, sql, and } from "drizzle-orm";
import { eq, ne, inArray, asc, desc, isNull, sql, and } from "drizzle-orm";
import { getPool } from "@/lib/taxonomy";
import { LEAD_STAGES } from "@/lib/lead-validators";
import type {
@@ -238,6 +238,9 @@ export type ProjectOfferWithMicro = {
macro_offer_type: string;
start_date: Date;
accepted_total: string | null;
// Ciclo di vita (v2.4 Phase 13): attivo | sospeso | cessato.
status: string;
end_date: Date | null;
created_at: Date;
};
@@ -684,6 +687,8 @@ export async function getProjectFullDetail(id: string): Promise<ProjectFullDetai
macro_offer_type: offer_macros.offer_type,
start_date: project_offers.start_date,
accepted_total: project_offers.accepted_total,
status: project_offers.status,
end_date: project_offers.end_date,
created_at: project_offers.created_at,
})
.from(project_offers)
@@ -759,8 +764,13 @@ export async function getProjectFullDetail(id: string): Promise<ProjectFullDetai
return true;
});
// Le offerte cessate non entrano nel totale: serve come default del piano
// pagamenti, e un abbonamento chiuso non è denaro da incassare.
const offersAcceptedTotal = (projectOffersRows as ProjectOfferWithMicro[]).reduce(
(sum, o) => sum + (o.accepted_total ? parseFloat(String(o.accepted_total)) : 0),
(sum, o) =>
o.status === "cessato"
? sum
: sum + (o.accepted_total ? parseFloat(String(o.accepted_total)) : 0),
0
);
@@ -863,6 +873,7 @@ export type ClientActiveOfferSummary = {
category: string | null; // Entry | Signature | Retainer (offer_macros.category)
offer_type: string; // una_tantum | retainer (fallback for category)
accepted_total: string | null;
status: string; // attivo | sospeso — le cessate non compaiono qui
};
export async function getClientActiveOffers(clientId: string): Promise<ClientActiveOfferSummary[]> {
@@ -887,11 +898,18 @@ export async function getClientActiveOffers(clientId: string): Promise<ClientAct
category: offer_macros.category,
offer_type: offer_macros.offer_type,
accepted_total: project_offers.accepted_total,
status: project_offers.status,
})
.from(project_offers)
.innerJoin(offer_micros, eq(project_offers.micro_id, offer_micros.id))
.innerJoin(offer_macros, eq(offer_micros.macro_id, offer_macros.id))
.where(inArray(project_offers.project_id, projectIds))
// La sezione si chiama "Offerte Attive": le cessate non ci vanno.
.where(
and(
inArray(project_offers.project_id, projectIds),
ne(project_offers.status, "cessato")
)
)
.orderBy(asc(project_offers.created_at));
return rows.map((r) => ({
@@ -904,6 +922,7 @@ export async function getClientActiveOffers(clientId: string): Promise<ClientAct
category: r.category,
offer_type: r.offer_type,
accepted_total: r.accepted_total ? String(r.accepted_total) : null,
status: r.status,
}));
}
+23 -3
View File
@@ -1,4 +1,4 @@
import { eq, inArray, asc, desc, sql } from "drizzle-orm";
import { eq, ne, and, inArray, asc, desc } from "drizzle-orm";
import { db } from "@/db";
import { clients, projects, phases, tasks, deliverables, payments, documents, notes, comments, project_offers, offer_micros, offer_micro_services, offer_services, offer_macros, offer_tier_services, services, clientTranscripts } from "@/db/schema";
@@ -59,6 +59,9 @@ export interface ClientView {
offer_type: string; // una_tantum | retainer
cumulative_price: string;
accepted_total: string | null;
status: string; // attivo | sospeso — le cessate non arrivano al cliente
start_date: string; // ISO
end_date: string | null; // ISO — null = continuativo
services: Array<{ name: string; description: string | null }>;
}>;
transcripts: Array<{
@@ -132,6 +135,9 @@ export interface ProjectView {
offer_type: string; // una_tantum | retainer
cumulative_price: string; // sum of assigned services.unit_price (new) or offer_services.price (legacy)
accepted_total: string | null;
status: string; // attivo | sospeso — le cessate non arrivano al cliente
start_date: string; // ISO
end_date: string | null; // ISO — null = continuativo
services: Array<{ name: string; description: string | null }>; // included services list
}>;
transcripts: Array<{
@@ -331,7 +337,10 @@ export async function getProjectView(projectId: string): Promise<ProjectView | n
.where(eq(notes.project_id, projectId))
.orderBy(asc(notes.created_at));
// Fetch active offers for this project (client-safe fields only — never internal_name)
// Fetch active offers for this project (client-safe fields only — never internal_name).
// Le offerte cessate sono escluse: un abbonamento chiuso non è un servizio
// attivo e il cliente non deve vederselo ancora addosso. Le sospese restano,
// marcate come tali — sapere che è in pausa è diverso dal vederlo sparire.
const projectOfferRows = await db
.select({
id: project_offers.id,
@@ -339,12 +348,20 @@ export async function getProjectView(projectId: string): Promise<ProjectView | n
offer_name: offer_macros.public_name, // macro-level public name — NEVER internal_name
offer_type: offer_macros.offer_type, // una_tantum | retainer
accepted_total: project_offers.accepted_total,
status: project_offers.status, // attivo | sospeso
start_date: project_offers.start_date,
end_date: project_offers.end_date,
micro_id: project_offers.micro_id,
})
.from(project_offers)
.innerJoin(offer_micros, eq(project_offers.micro_id, offer_micros.id))
.innerJoin(offer_macros, eq(offer_micros.macro_id, offer_macros.id))
.where(eq(project_offers.project_id, projectId));
.where(
and(
eq(project_offers.project_id, projectId),
ne(project_offers.status, "cessato")
)
);
// Cumulative price + service list per micro.
// Primary: offer_tier_services → services (Phase 12 catalog). Fallback: legacy offer_micro_services → offer_services.
@@ -420,6 +437,9 @@ export async function getProjectView(projectId: string): Promise<ProjectView | n
offer_type: o.offer_type,
cumulative_price,
accepted_total: o.accepted_total ? String(o.accepted_total) : null,
status: o.status,
start_date: o.start_date.toISOString(),
end_date: o.end_date ? o.end_date.toISOString() : null,
services: servicesList,
};
});
+28 -5
View File
@@ -1,6 +1,6 @@
import { db } from "@/db";
import { project_offers, offer_micros, offer_macros, projects } from "@/db/schema";
import { eq, sql, desc } from "drizzle-orm";
import { eq, ne, and, sql, desc } from "drizzle-orm";
export type ForecastMonth = {
year: number;
@@ -21,6 +21,11 @@ export type OffersSoldBreakdown = {
};
// Quante offerte sono state vendute (assegnate a un progetto), per offerta + tier.
//
// NON filtrare per project_offers.status: questo è un dato storico di vendita.
// Escludere le offerte cessate riscriverebbe il passato ogni volta che un
// cliente disdice, e "quante ne ho vendute" smetterebbe di essere una risposta
// stabile. Il ciclo di vita riguarda il forecast, non lo storico.
export async function getOffersSoldBreakdown(): Promise<OffersSoldBreakdown> {
const rows = await db
.select({
@@ -49,9 +54,15 @@ export async function getRevenueForecast12Months(): Promise<ForecastMonth[]> {
// Active offers only (non-archived projects). offer_type drives the math:
// - retainer: accepted_total è il CANONE MENSILE → ogni mese coperto riceve l'intero importo
// - una_tantum: accepted_total è il prezzo TOTALE → spalmato su duration_months
//
// Le offerte cessate escono dalla query: un abbonamento chiuso non è ricavo
// futuro. Prima della 0016 non esisteva modo di chiuderne uno, e un retainer
// disdetto restava nel forecast per sempre.
const offers = await db
.select({
start_date: project_offers.start_date,
end_date: project_offers.end_date,
status: project_offers.status,
duration_months: offer_micros.duration_months,
accepted_total: project_offers.accepted_total,
offer_type: offer_macros.offer_type,
@@ -60,7 +71,7 @@ export async function getRevenueForecast12Months(): Promise<ForecastMonth[]> {
.innerJoin(offer_micros, eq(project_offers.micro_id, offer_micros.id))
.innerJoin(offer_macros, eq(offer_micros.macro_id, offer_macros.id))
.innerJoin(projects, eq(project_offers.project_id, projects.id))
.where(eq(projects.archived, false));
.where(and(eq(projects.archived, false), ne(project_offers.status, "cessato")));
// Build 12-month bucket array starting from current month
const now = new Date();
@@ -83,11 +94,23 @@ export async function getRevenueForecast12Months(): Promise<ForecastMonth[]> {
const startMonthKey = start.getFullYear() * 12 + start.getMonth();
if (offer.offer_type === "retainer") {
// Canone ricorrente: contribuisce ad OGNI mese dell'orizzonte da start in poi,
// senza limite di duration_months (un retainer è continuativo).
// Un retainer sospeso non produce ricavo prevedibile: senza una data di
// ripresa non si può sapere quando ricomincia, quindi ai fini del forecast
// vale come chiuso. Scelta di prodotto, non conseguenza tecnica.
if (offer.status !== "attivo") continue;
// Canone ricorrente: contribuisce ad OGNI mese dell'orizzonte da start in
// poi, senza limite di duration_months (un retainer è continuativo) —
// ma ora si ferma a end_date se valorizzata. NULL = continuativo.
const endMonthKey = offer.end_date
? offer.end_date.getFullYear() * 12 + offer.end_date.getMonth()
: null;
for (const b of buckets) {
const bucketMonthKey = b.year * 12 + (b.month - 1);
if (bucketMonthKey >= startMonthKey) b.total += total;
if (bucketMonthKey < startMonthKey) continue;
if (endMonthKey !== null && bucketMonthKey > endMonthKey) continue;
b.total += total;
}
} else {
// Una tantum: prezzo totale ripartito su duration_months a partire da start.