Tutti i case study

Gestionale per una consulente di carriera

Clienti, pratiche, fatturato, accantonamento fiscale e blog per una libera professionista in regime forfettario. In produzione, accesso solo con passkey.

Ruolo
Analisi, architettura, sviluppo, infrastruttura e deploy — autore unico
Periodo
luglio 2026 → oggi
Stato
In produzione dal 2026-08-08
  • Angular 22
  • ASP.NET Core 10
  • EF Core 10
  • SQL Server
  • WebAuthn
  • Docker
  • Cloudflare

In breve

Elisabetta Carrara è consulente di carriera, partita IVA in regime forfettario. Ho progettato e costruito il suo gestionale: clienti, pratiche, registro del fatturato, accantonamento fiscale, metriche e il CMS del blog che alimenta il suo sito pubblico.

È in produzione dal 8 agosto 2026 e la usa una professionista vera, non è una demo. Autore unico su tutta la filiera: requisiti, dominio, API, SPA, infrastruttura, backup e deploy. L'API ha 236 test verdi, la SPA circa 420, entrambe le build girano a 0 errori e 0 avvisi.

Non è un progetto sotto NDA: è mio, con il suo consenso, e nominarla dà concretezza a qualcosa che altrimenti resterebbe astratto. I dati dei suoi clienti non compaiono da nessuna parte, qui e negli screenshot.

Il contesto: due rischi che un gestionale generico non copre

Un gestionale qualunque registra fatture. I due problemi veri di una libera professionista in forfettario sono altri, e sono entrambi fiscali.

Concentrazione dei committenti. Se una fetta troppo grande dei ricavi arriva da un solo committente — soprattutto se è un ex datore di lavoro — l'Agenzia delle Entrate può riqualificare il rapporto come lavoro dipendente mascherato. Serve accorgersene mentre si fattura, non a bilancio chiuso.

Accantonamento. In forfettario le imposte si calcolano su un imponibile forfettario, non sull'utile reale, e ogni anno si paga il saldo dell'anno chiuso più l'acconto su quello in corso. Il secondo anno di attività paga quindi due annualità insieme. Farlo a mano con un foglio di calcolo è facile da sbagliare, e ancora più facile da rimandare.

Le soglie e le aliquote di questo progetto non le ho decise io: sono confermate dal suo commercialista, con la data della conferma scritta accanto alla regola. Dove il codice implementa una regola fiscale, la fonte è citata.

L'architettura, e il vincolo che l'ha decisa

Tre repository, tutti privati: l'API, la SPA del gestionale e il sito pubblico.

Flusso dei dati fra i tre repository La SPA del gestionale parla all'API con una passkey. L'API, una sola applicazione .NET con SQL Server, espone anche una superficie pubblica in sola lettura. Alla pubblicazione di un articolo l'API chiama un deploy hook che fa ripartire la build del sito vetrina, la quale produce HTML statico servito da CDN. SPA gestionale — Angular 22 API ASP.NET Core 10 + SQL Server Build del sito vetrina HTML statico su CDN zoneless · Signals · privata una sola app: blog + gestionale database solo in rete Docker interna si ferma se un contenuto non è valido nessun server a runtime passkey → JWT (60 min) deploy hook alla pubblicazione prerender di ogni articolo
La SPA parla all'API autenticandosi con una passkey. La stessa API espone una superficie pubblica anonima in sola lettura: quando un articolo viene pubblicato, chiama un deploy hook che fa ripartire la build del sito vetrina, che pubblica HTML statico su CDN. Nessun server a runtime davanti al sito pubblico.

Blog e gestionale condividono una sola applicazione .NET. Non per semplicità: quando il progetto è partito, l'hosting previsto per la cliente permetteva una sola applicazione per sito, e quella era l'unica topologia compatibile con l'infrastruttura su cui l'app doveva vivere. Il vincolo di hosting è diventato un vincolo di design, ed è ancora riconoscibile nel codice: la separazione fra i due mondi passa da cartelle, prefissi di rotta e gruppi di permessi, non da assembly separati.

Quel vincolo poi è caduto — vedi le decisioni — ma l'architettura è rimasta quella, ed è una cosa che oggi rifarei diversamente.

Concentrazione clienti: un semaforo, non un report

Il calcolo produce, per ogni cliente, la quota di ricavi sul totale e un semaforo, su quattro finestre temporali (mese corrente, anno solare, anno fino a oggi, ultimi 12 mesi) ottenute da una sola lettura del database e calcolate in memoria.

Le soglie: 50% per l'ex datore di lavoro, 80% per qualunque altra azienda, nessun tetto per i privati, con una banda di preavviso di 10 punti che accende il giallo prima del rosso. Confermate dal commercialista il 27/07/2026, e configurabili: non sono costanti nel codice.

Due dettagli che contano più delle percentuali. Il primo: la base è il fatturato emesso, non incassato — l'avviso deve arrivare prima dello sfondamento, non dopo il bonifico. Il secondo: la barra a schermo misura il cliente contro il proprio tetto, non contro il totale, perché un ex datore al 40% (tetto 50) sta peggio di un'azienda al 60% (tetto 80) — e sul totale sembrerebbe il contrario.

private decimal? SogliaFor(bool exDatore, ClientType type) =>
    exDatore ? concentrazione.SogliaExDatore
    : type == ClientType.Azienda ? concentrazione.SogliaAzienda
    : null; // privato: nessun limite di concentrazione

// Quanto può ancora fatturare a questo cliente prima della soglia. Tenendo fisso il
// fatturato degli altri (O), il tetto è T·O/(1−T); il margine è tetto − già fatturato.
// Può risultare negativo se la soglia è già superata.
private static decimal? MargineResiduo(decimal fatturato, decimal totale, decimal? soglia)
{
    if (soglia is null) return null;
    var others = totale - fatturato;
    var tetto = soglia.Value * others / (1 - soglia.Value);
    return Round(tetto - fatturato);
}

Quel T·O/(1−T) è anche la ragione per cui la validazione della configurazione rifiuta una soglia fuori da (0,1): a T ≥ 1 si dividerebbe per zero, o si otterrebbe un tetto negativo senza senso. Il controllo gira all'avvio dell'applicazione, prima della registrazione dei servizi: una configurazione sbagliata ferma il processo, non produce numeri sbagliati a schermo.

L'avviso a schermo non è un conteggio. «2 clienti vicini alla soglia» non dice quale né di quanto: la dashboard nomina il peggiore, con la cifra e le conseguenze.

Accantonamento: il 48,47% di ogni fattura

Cinque parametri per anno, nessuno hardcoded: coefficiente di redditività (78% per il suo codice ATECO), imposta sostitutiva (5% nei primi cinque periodi d'imposta, poi 15%), INPS gestione separata (26,07% nel 2026), moltiplicatore di accantonamento e soglia dei ricavi. Con i valori 2026 la quota da mettere sul conto dedicato è il 48,47% di ogni fattura.

// Quota di accantonamento della singola fattura. Il calcolo annuo è lineare
// nell'importo — reddito, imposta, INPS e moltiplicatore sono tutte moltiplicazioni —
// quindi ripartirlo per fattura è esatto, non una stima:
//
//     importo × coefficiente × (aliquota imposta + aliquota INPS) × moltiplicatore
//
// Un solo arrotondamento finale (invece dei tre di CalculateYearSummary), perciò la
// somma delle quote può discostarsi dal totale annuo di qualche centesimo. È voluto:
// la dashboard somma le quote per fattura, così ogni euro mostrato è riconducibile a
// una riga concreta e i numeri riconciliano.
public decimal QuotaAccantonamento(decimal importo, FiscalConfig config)
{
    if (importo <= 0m) return 0m;

    var moltiplicatore = config.MoltiplicatoreAccantonamento > 0
        ? config.MoltiplicatoreAccantonamento
        : 1m;

    return Round(importo
        * config.CoefficienteRedditivita
        * (config.AliquotaImposta + config.AliquotaInps)
        * moltiplicatore);
}

Il moltiplicatore è la parte interessante: vale 2 il primo anno (saldo + acconto) e 1 a regime, ma l'app lo suggerisce con 2 − imposte(N−1) / imposte(N), tagliato a [1, 2], e segnala lo scostamento solo oltre 0,1. Suggerisce e non impone: la configurazione resta l'autorità, e l'anno precedente senza configurazione fiscale non viene trattato come un anno a zero — non si propone nulla, invece di inventare un numero.

L'altra decisione: la quota teorica si ricalcola sempre, mentre l'importo davvero girato sul conto dedicato è un dato salvato per singola registrazione — con la sua data, che è il flag (nessun booleano separato che possa divergere dalla data). I due valori possono differire, e quando differiscono la lista li mostra entrambi: se il bonifico è arrotondato o parziale, la cifra vera è quella. È un'informazione, non un errore. E cambiare il moltiplicatore a gennaio non riscrive gli anni già chiusi.

Si accantona solo una registrazione incassata: il conto dedicato deve rispecchiare la banca, non le previsioni. Sbloccare come «non incassata» o cancellare una registrazione già accantonata risponde 409 finché l'accantonamento non viene annullato — altrimenti resterebbero soldi orfani sul conto.

I due assi che non coincidono

Questa è la parte che ha richiesto più conversazioni con il commercialista e meno codice.

Sorgente Cosa decide
Competenza data di emissione della fattura con quali aliquote si calcolano imposta, INPS e quota
Cassa data di incasso quando i soldi si spostano sul conto dedicato, e su quale anno pesa la soglia dei ricavi

La soglia degli 85.000 € si misura sui compensi percepiti nell'anno solare (principio di cassa, confermato il 29/07/2026). Serve perché i pagamenti veri sfondano l'anno: le agenzie pagano a 30/60 giorni data fattura fine mese, i privati a vista. Una registrazione di dicembre incassata a febbraio usa le aliquote dell'anno prima e conta sulla soglia dell'anno dopo — e la dashboard la marca «altri anni» nella riga del mese, perché era esattamente il caso che generava confusione.

Tre decisioni, con quello che ho scartato

Solo passkey. Le password non esistono. Il 7 agosto ho rimosso login con password e 2FA TOTP: sono cadute le rotte di login, «password dimenticata» e reimpostazione. Quelle superfici non sono state indurite, sono state tolte. Non c'è nemmeno un campo email nella schermata di accesso: le credenziali sono discoverable e legate al dominio, quindi una copia del sito non riesce a farsele usare. Nessun refresh token: a ogni richiesta si confronta il SecurityStamp del token con quello a database — costa una query su chiave primaria e in cambio disattivazione, cambio ruolo e revoca di una passkey valgono subito, non alla scadenza. Un codice di recupero, da solo, non apre mai una sessione: fa partire un'email, e solo il link dentro l'email produce un token di arruolamento da 10 minuti che non può fare altro che registrare una credenziale.

Produzione su hardware già acceso. Il gestionale gira su una VM Debian di un mini-PC di casa, in Docker, esposta con un tunnel Cloudflare: nessuna porta aperta sul router, nessun certificato da rinnovare, nessun reverse proxy. Il costo incrementale è zero — la macchina era già accesa. Il ragionamento che lo rende accettabile è che il gestionale è un sistema derivato: le fatture vere si emettono altrove e sono già conservate a norma, quindi un'ora di indisponibilità è un fastidio, non un danno. Scartato il VPS a 75 €/anno come punto di partenza, ma il piano B è costato e scritto — è lo strumento di lavoro di un'altra persona, non un giocattolo mio, e Docker rende il trasloco un pomeriggio.

PostgreSQL, poi SQL Server. Cinque giorni dopo aver scelto PostgreSQL sono tornato a SQL Server, a schema già scritto: è il motore che uso ogni giorno al lavoro, e non c'era ancora un database di produzione da migrare, quindi il costo del cambio era quasi nullo — e non lo sarebbe più stato una settimana dopo. La decisione ribaltata è scritta e datata accanto a quella che ha sostituito.

Come lo tengo onesto

  • Un invariante vale se vive dove non si aggira. «Un solo cliente può essere ex datore di lavoro» è imposto su tre livelli: checkbox disabilitato nella UI, 409 nel servizio, e un indice unico filtrato a database. Senza il filtro l'indice vieterebbe anche il secondo cliente marcato «no», cioè tutti.
  • Una cancellazione che porterebbe via dati risponde 409, mai in silenzio. Sei casi tabellati, ognuno col suo codice. Il caso che mi ha insegnato qualcosa: cancellare una pratica riusciva, slegando in silenzio le fatture — i documenti sopravvivevano, ma il collegamento perso falsava le tariffe orarie per canale senza nessun errore.
  • Il contratto con la UI sono i codici, non i messaggi. Ogni errore è un ProblemDetails con un code stabile in snake_case; i testi italiani cambiano, i codici no. Il 409 è l'unico stato che l'utente può risolvere da sé, e per questo è l'unico che produce un avviso persistente invece di un toast che sparisce.
  • Il registro attività è in sola lettura per chiunque, Admin compreso. Nessun endpoint di scrittura o cancellazione: un registro che chi amministra può ripulire non prova niente, e la prima riga che si vorrebbe togliere è proprio quella che interessa.
  • I test di integrazione firmano davvero le cerimonie WebAuthn. Ho scritto un autenticatore software (ECDSA P-256) che registra una passkey e firma la sfida passando dagli endpoint veri. L'alternativa — un flag che disattivava la verifica in ambiente di test — è stata scartata di proposito: avrebbe reso i test verdi smettendo di provare esattamente la cosa da proteggere.
  • I rapporti di contrasto sono uno unit test. Rilegge i token dal file .scss invece di duplicarne i valori, e confronta 34 coppie contro le soglie WCAG. Una passata axe sul sito vivo, il 4 agosto, non ha trovato violazioni automatiche residue.
  • Il restore è provato da un cron mensile, non dato per buono: scarica il backup più recente, fallisce se ha più di due giorni (è lì che si scopre un cron morto), lo decifra, lo ripristina su un contenitore usa-e-getta e verifica che il database ripristinato abbia tabelle, utenti e righe. La retention è imposta sul bucket con object lock, non nello script: così i backup non si possono cancellare nemmeno con le credenziali della macchina in mano.
  • Il deploy dell'API è a mano di proposito. La pipeline pubblica l'immagine, poi uno script sulla macchina fa il backup, si ferma se il backup fallisce, aggiorna e verifica lo stato di salute. All'avvio le migrazioni girano sul database vero: quel passo vuole un umano che guarda.

Cosa farei diversamente

Separerei la superficie pubblica. L'API unica era la risposta a un vincolo di hosting che oggi non c'è più: la lettura anonima degli articoli e il gestionale autenticato convivono nello stesso processo e nello stesso database, e non hanno lo stesso profilo di rischio né lo stesso ritmo di rilascio.

Metterei un token di concorrenza sulle entità che due utenti possono toccare insieme. Oggi l'app è multi-utente ma l'ultima scrittura vince: con due collaboratori sulla stessa pratica è un problema che si scopre tardi e in silenzio.

Terrei la scelta del database per ultima. Averla ribaltata è costato poco solo perché non c'era ancora niente in produzione: è stata fortuna di tempismo, non bravura.