diff --git a/AGENTS.md b/AGENTS.md index beba20f..2e9b37e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -265,8 +265,8 @@ La nota *"se i numeri sono questi cosa devo fare per crescere?"* suggerisce un l | **3** | Motore di riclassificazione + indici (da `docs/MODELLO_FINANZIARIO.md`), **import Excel §11.1** | Import di un vero file cliente → Conto Economico riclassificato corretto | 🟡 **Quasi**: motore, indici e import fatti e verificati. Manca il file di un cliente **con i saldi**: quello consegnato è un modello vuoto (vedi `docs/MODELLO_FINANZIARIO.md` §8-bis) | | **4** | UI Business: Panoramica + Conto Economico + Stato Patrimoniale (§10.2-10.4) | Le 3 schermate con dati reali importati | ✅ **Fatta** (sessione 1) — menu laterale e grafici compresi | | **5** | UI Capitale Circolante + Tesoreria/Cash Flow + Scadenziario (§10.5-10.6) | Previsione di cassa funzionante su dati reali | ✅ **Fatta** (sessione 2) | -| **6** | UI Banche e Finanziamenti (§10.7) + collegamento rate→Cash Flow | Fidi/finanziamenti con impatto visibile in Tesoreria | ⬜ Prossima | -| **7** | Analisi & Simulazioni (§10.8) | Scenario what-if salvabile e confrontabile | ⬜ | +| **6** | UI Banche e Finanziamenti (§10.7) + collegamento rate→Cash Flow | Fidi/finanziamenti con impatto visibile in Tesoreria | ✅ **Fatta** (sessione 2) | +| **7** | Analisi & Simulazioni (§10.8) | Scenario what-if salvabile e confrontabile | ⬜ Prossima | | **8** | Sync Consulente↔Azienda via Tailscale (§6) + status bar (§7) | Due installazioni reali che si scambiano dati | ⬜ | | **9** | Import Excel avanzato: tolleranza a varianti di formato tra clienti/periodi (§11.1) | Import robusto su più file Excel reali diversi tra loro | ⬜ | | **10** | Installer offline (electron-builder) per Consulente e Azienda | `.exe` funzionanti, Tailscale bundled | 🟡 **Parziale**: `.exe` installabile, portable e demo funzionanti. Mancano le due varianti separate e Tailscale bundled, che hanno senso solo dopo la Fase 8. **Decisione del cliente (2026-09-16): nessuna release fino a codice finito; alla fine una sola release con tre eseguibili — installer, portable, demo** | @@ -470,7 +470,41 @@ La **Panoramica** ora mostra liquidità di oggi, cash flow a 30 giorni e il graf **Verifiche**: 54 test (`npm run test`), fra cui la previsione di cassa calcolata a mano giorno per giorno; `npm run verify:schema` passa da 22 a 35 controlli con i vincoli della 004; percorso completo provato nell'app: incasso parziale che aggiorna liquidità e previsione, fattura "60 giorni fine mese" con scadenza calcolata, aggiornamento di un database demo della 0.0.4. -**Da fare in Fase 6**: Banche e Finanziamenti (§10.7), con le rate che entrano nella previsione come `source = 'finanziamento'`, gli affidamenti disponibili nella Tesoreria e il DSCR finalmente calcolabile. +**Fase 6 — Banche e Finanziamenti (§10.7)** + +Migrazione `005_banks`: + +| Tabella | Cosa tiene | +|---|---| +| `banks` | Gli istituti dell'azienda, società di leasing comprese. Nome univoco per azienda, senza distinguere le maiuscole | +| `credit_lines` | Linee a revoca: fido di cassa, anticipo fatture/SBF, carte, altre. Accordato e utilizzato a una data; l'utilizzato può superare l'accordato (uno sconfinamento è un dato, non un errore) | +| `loans` | Mutui, finanziamenti e leasing: **i parametri del piano, non le rate** | + +**Le rate non si copiano da nessuna parte.** Il piano di ammortamento si ricalcola sempre dagli stessi parametri (`src/shared/engine/loans.ts`) e le rate entrano nella previsione di cassa come movimenti `source = 'finanziamento'` generati al volo. Una sola fonte: modificare un mutuo aggiorna subito la tesoreria, senza righe orfane. Per lo stesso motivo la demo non ha più la "rata del mutuo" come previsione manuale: un database demo della Fase 5 la perde al primo avvio, altrimenti la rata si conterebbe due volte. + +Il modello del consulente non tratta i piani di ammortamento: la matematica è quella standard, con queste scelte dichiarate. + +1. **Francese** (rata costante) o **italiano** (quota capitale costante), con **preammortamento** di soli interessi. +2. **Tasso nominale annuo** diviso per il numero di rate dell'anno, come nei piani bancari italiani. +3. **Leasing**: rata costante con **riscatto** pagato una rata dopo l'ultima; il valore attuale di rate e riscatto dà il capitale. Un maxi-canone iniziale si registra come movimento a parte. +4. **Arrotondamento al centesimo** rata per rata; l'ultima assorbe i residui e chiude il debito esattamente (a zero, o al riscatto). +5. **Le rate già scadute si considerano pagate**: in Italia sono quasi sempre addebitate in automatico. Il debito residuo è quello dopo l'ultima rata scaduta. + +Verificato con i numeri da manuale: 12.000 € al 6% in 12 rate mensili danno la rata francese di 1.032,80 €. + +**DSCR finalmente calcolato** (§5): EBITDA degli ultimi 12 mesi ÷ rate dei 12 mesi successivi alla **fine del periodo analizzato**, riscatti compresi. Legarlo alla fine del periodo e non a oggi rende il valore di un periodo stabile, qualunque giorno lo si guardi. Senza finanziamenti resta un trattino. + +**Situazione per istituto**: per i finanziamenti accordato e utilizzato coincidono col debito residuo, come nella Centrale Rischi. **Disponibile e percentuale di utilizzo guardano solo le linee a revoca**: un finanziamento è utilizzato al 100% per definizione, e una società di leasing sarebbe sempre "in allarme". Il disponibile si somma linea per linea: lo sconfinamento di una carta non toglie disponibilità al fido di un'altra linea. + +**Collegamenti con le altre viste**: la Tesoreria mostra gli affidamenti disponibili (senza contarli come liquidità) e le rate nella categoria "Rate finanziamenti"; la Panoramica avvisa quando gli affidamenti sono utilizzati **dall'80%** in su (rosso dal 95%) — l'avviso "Affidamenti utilizzati oltre soglia" dei mockup, con una soglia che il modello non fissa; lo Stato Patrimoniale mostra il DSCR con la soglia di 1,25x. + +**Il modulo di inserimento mostra la rata mentre lo si compila**, calcolata dallo stesso motore del server: è il modo più rapido per accorgersi di un tasso o di un numero di rate sbagliato. Ogni finanziamento ha il suo piano completo consultabile. + +**Dati della demo**: tre istituti, quattro linee (una quasi esaurita, da rinegoziare), un mutuo francese, un leasing col riscatto e un finanziamento trimestrale all'italiana ancora in preammortamento, tutti con date relative al primo avvio. + +**Verifiche**: 68 test (14 sul motore dei finanziamenti); `npm run verify:schema` a 48 controlli; nell'app: finanziamento creato e modificato dal modulo con la rata ricalcolata (leasing 24.000 € al 6,1% con riscatto 2.400 € → 520,47 €), rate dentro la previsione di cassa, DSCR sullo stato patrimoniale, aggiornamento di un database demo della Fase 5. + +**Da fare in Fase 7**: Analisi & Simulazioni (§10.8), che ora ha tutti i motori sotto: conto economico, circolante, tesoreria e finanziamenti. --- diff --git a/README.md b/README.md index 8c32a99..be7dace 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Un gestionale desktop per consulenti finanziari aziendali: riclassifica il bilan calcola gli indici che contano, previene le tensioni di cassa prima che arrivino, e risponde a "cosa succede se" senza toccare un foglio Excel. -![stato](https://img.shields.io/badge/stato-in%20sviluppo%20%C2%B7%20fase%205%2F10-3ddbff) +![stato](https://img.shields.io/badge/stato-in%20sviluppo%20%C2%B7%20fase%206%2F10-3ddbff) ![piattaforma](https://img.shields.io/badge/Windows-x64-3ddbff) [![licenza](https://img.shields.io/badge/licenza-MIT-5cff9d)](LICENSE) @@ -157,6 +157,11 @@ la scadenza si calcola da sola), e le voci che si ripetono — stipendi, affitto di cassa. Quando una fattura viene pagata, anche solo in parte, lo si registra con un clic, e la liquidità di oggi si aggiorna. +**Banche e finanziamenti, a mano.** Per ogni istituto si registrano i fidi (quanto è +accordato, quanto è usato) e i finanziamenti: importo, tasso, numero di rate. Il piano +di ammortamento si calcola da solo, e le rate finiscono nella previsione di cassa senza +doverle scrivere una per una. + --- ## A che punto siamo @@ -166,7 +171,9 @@ loro aziende, importa il piano dei conti da Excel e ne calcola il bilancio riclassificato con tutti gli indici, e lo mostra in cinque schermate: panoramica con avvisi automatici, conto economico, stato patrimoniale, capitale circolante con le sue note automatiche, e tesoreria con scadenziario e previsione di cassa a sei mesi. -Mancano banche e finanziamenti, le simulazioni e il collegamento fra i due programmi. +C'è anche la parte bancaria: fidi, mutui e leasing con il loro piano di ammortamento, +le rate che entrano da sole nella previsione di cassa. Mancano le simulazioni e il +collegamento fra i due programmi. | | Fase | Stato | |---|---|---| @@ -176,8 +183,8 @@ Mancano banche e finanziamenti, le simulazioni e il collegamento fra i due progr | 3 | Riclassificazione, indici, import Excel | 🟡 Quasi — serve un file cliente **con i saldi** per la verifica finale | | 4 | Le prime tre schermate di analisi | ✅ Fatta | | 5 | Capitale circolante, tesoreria, scadenziario | ✅ Fatta | -| 6 | Banche e finanziamenti | ⬜ Prossima | -| 7 | Analisi e simulazioni | ⬜ | +| 6 | Banche e finanziamenti | ✅ Fatta | +| 7 | Analisi e simulazioni | ⬜ Prossima | | 8-9 | Collegamento fra i due programmi | ⬜ | | 10 | I tre eseguibili finali: installer, portable, demo | 🟡 Già provati, si pubblicano a codice finito | @@ -188,8 +195,8 @@ La roadmap completa, con il dettaglio di cosa c'è dentro ogni fase, è in Il modo più rapido per vederlo all'opera è la **versione demo**: `DaProdFinanza-Demo-x.y.z-portable.exe`. Si lancia senza installare niente e parte -già con una pizzeria di esempio, due anni di bilanci, lo scadenziario e le previsioni -di cassa. Si entra come +già con una pizzeria di esempio, due anni di bilanci, lo scadenziario, le previsioni +di cassa, le banche e i finanziamenti. Si entra come consulente con **`cammo` / `1234`**, oppure come azienda con **`Pizzeria DaProd` / `1234`**. I suoi dati restano in una cartella a parte e non si mescolano mai con quelli di un'installazione vera. diff --git a/docs/MODELLO_FINANZIARIO.md b/docs/MODELLO_FINANZIARIO.md index 7ba2df3..1695a8e 100644 --- a/docs/MODELLO_FINANZIARIO.md +++ b/docs/MODELLO_FINANZIARIO.md @@ -279,7 +279,7 @@ L'implementazione usa `Gross Profit = (Ricavi Operativi + Rimanenze Finali) − - **Acquisti**, che serve al DPO (§6): nel file esiste la colonna ma nessuna sezione la marca. L'implementazione usa `Costi Materie Prime + Costi Produzione`. - **Debiti finanziari**, che servono a PFN e Debt/Equity (§5): non esiste un tag che li distingua. L'implementazione li ricava per differenza, togliendo dai debiti totali le voci che finanziarie non sono (fornitori, enti previdenziali, TFR). Un tag esplicito sarebbe più solido. -Il **DSCR** resta non calcolabile finché non esiste il modulo Banche (`AGENTS.md` §10.7): richiede le rate attese nei 12 mesi successivi. L'app mostra un trattino, non uno zero. +Il **DSCR** richiede le rate attese nei 12 mesi successivi: dalla Fase 6 le calcola il modulo Banche dai piani di ammortamento, a partire dalla fine del periodo analizzato (`AGENTS.md` §13-ter). Senza finanziamenti registrati l'app mostra un trattino, non uno zero. ### d) Il file contiene più informazione di questo documento diff --git a/scripts/verify-schema.cjs b/scripts/verify-schema.cjs index e7c5cb2..2875e0d 100644 --- a/scripts/verify-schema.cjs +++ b/scripts/verify-schema.cjs @@ -213,6 +213,62 @@ app.whenReady().then(() => { check('soglia minima negativa', () => insertSettings(null, null, -1), 'rifiutato') check('impostazioni valide', () => insertSettings(5000, `${ANNO}-01-01`), 'accettato') check('seconde impostazioni per la stessa azienda', () => insertSettings(null, null), 'rifiutato') + + console.log('\n Banche e finanziamenti') + const bankUuid = randomUUID() + const insertBank = (uuid, name) => + db + .prepare( + `INSERT INTO banks (uuid, company_uuid, name, created_at, updated_at, synced, deleted) + VALUES (?, ?, ?, ?, ?, 0, 0)` + ) + .run(uuid, tempCompany, name, now, now) + check('istituto', () => insertBank(bankUuid, 'Banca di prova'), 'accettato') + check('stesso istituto con le maiuscole diverse', () => insertBank(randomUUID(), 'BANCA DI PROVA'), 'rifiutato') + + const insertLine = (kind, granted, used, bank = bankUuid) => + db + .prepare( + `INSERT INTO credit_lines (uuid, company_uuid, bank_uuid, kind, label, granted_cents, used_cents, + created_at, updated_at, synced, deleted) + VALUES (?, ?, ?, ?, 'linea', ?, ?, ?, ?, 0, 0)` + ) + .run(randomUUID(), tempCompany, bank, kind, granted, used, now, now) + check('fido di cassa', () => insertLine('fido_cassa', 3000000, 500000), 'accettato') + check('sconfinamento (utilizzato oltre l’accordato)', () => insertLine('carta', 100000, 150000), 'accettato') + check('tipo di linea inventato', () => insertLine('mutuo', 100000, 0), 'rifiutato') + check('linea su un istituto inesistente', () => insertLine('fido_cassa', 100000, 0, randomUUID()), 'rifiutato') + + const insertLoan = (fields) => { + const row = { + kind: 'mutuo', + principal: 10000000, + rate: 4.2, + installments: 96, + frequency: 'monthly', + grace: 0, + amortization: 'francese', + balloon: 0, + ...fields + } + db.prepare( + `INSERT INTO loans (uuid, company_uuid, bank_uuid, kind, label, principal_cents, + annual_rate_percent, first_due_date, installments, frequency, + grace_installments, amortization, balloon_cents, + created_at, updated_at, synced, deleted) + VALUES (?, ?, ?, ?, 'prestito', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0)` + ).run( + randomUUID(), tempCompany, bankUuid, row.kind, row.principal, row.rate, `${ANNO}-01-31`, + row.installments, row.frequency, row.grace, row.amortization, row.balloon, now, now + ) + } + check('mutuo', () => insertLoan({}), 'accettato') + check('leasing con riscatto', () => insertLoan({ kind: 'leasing', balloon: 1000000 }), 'accettato') + check('preammortamento lungo quanto tutto il piano', () => insertLoan({ grace: 96 }), 'rifiutato') + check('riscatto pari al capitale', () => insertLoan({ balloon: 10000000 }), 'rifiutato') + check('periodicità inventata', () => insertLoan({ frequency: 'weekly' }), 'rifiutato') + check('tasso negativo', () => insertLoan({ rate: -1 }), 'rifiutato') + check('ammortamento inventato', () => insertLoan({ amortization: 'tedesco' }), 'rifiutato') } finally { db.exec('ROLLBACK') db.close() diff --git a/src/main/db/demo-banks.ts b/src/main/db/demo-banks.ts new file mode 100644 index 0000000..f7ea93e --- /dev/null +++ b/src/main/db/demo-banks.ts @@ -0,0 +1,71 @@ +import { addDays, addMonths, formatDate, parseDate } from '@shared/engine' +import { saveBank, saveLine, saveLoan } from '../server/services/banks.service' +import { todayLocal } from '../server/services/treasury.service' + +/** + * Banche e finanziamenti della Pizzeria DaProd dimostrativa (Fase 6). + * + * Come per la tesoreria, le date sono relative al primo avvio: un mutuo con + * la prima rata fissa nel 2024 sarebbe finito, in una demo aperta fra qualche + * anno. Tre istituti, le linee tipiche di un ristorante (fido, anticipo delle + * fatture di catering, carta aziendale) e tre piani diversi: un mutuo + * francese, un leasing col riscatto e un finanziamento con preammortamento. + */ + +const euro = (value: number): number => Math.round(value * 100) + +/** Il 28 del mese, `months` mesi prima (o dopo) di oggi. */ +function il28(today: string, months: number): string { + const t = new Date(parseDate(addMonths(today, months))) + return formatDate(Date.UTC(t.getUTCFullYear(), t.getUTCMonth(), 28)) +} + +export function seedDemoBanks(companyUuid: string, today = todayLocal()): void { + const vesuvio = saveBank(companyUuid, { + name: 'Banca del Vesuvio', + branch: 'Filiale di Pizzolandia centro', + contact: 'Dott.ssa Esposito' + }) + const partenopeo = saveBank(companyUuid, { name: 'Credito Partenopeo', branch: 'Agenzia 12' }) + const leasing = saveBank(companyUuid, { name: 'Sud Leasing', notes: 'Società di leasing strumentale' }) + + saveLine(companyUuid, { + bank_uuid: vesuvio.uuid, kind: 'fido_cassa', label: 'Fido di cassa c/c', + granted_cents: euro(30_000), used_cents: euro(8_500), used_as_of: addDays(today, -2), + annual_rate_percent: 6.9, expiry_date: addMonths(today, 7) + }) + saveLine(companyUuid, { + bank_uuid: vesuvio.uuid, kind: 'anticipo_fatture', label: 'Anticipo fatture catering', + granted_cents: euro(20_000), used_cents: euro(4_000), used_as_of: addDays(today, -2), + annual_rate_percent: 5.4, expiry_date: addMonths(today, 7) + }) + saveLine(companyUuid, { + bank_uuid: partenopeo.uuid, kind: 'carta', label: 'Carta aziendale', + granted_cents: euro(5_000), used_cents: euro(1_200), used_as_of: addDays(today, -2) + }) + saveLine(companyUuid, { + bank_uuid: partenopeo.uuid, kind: 'fido_cassa', label: 'Fido di cassa c/c', + granted_cents: euro(15_000), used_cents: euro(12_900), used_as_of: addDays(today, -2), + annual_rate_percent: 7.4, expiry_date: addMonths(today, 3), + notes: 'Utilizzato quasi per intero: da rinegoziare.' + }) + + saveLoan(companyUuid, { + bank_uuid: vesuvio.uuid, kind: 'mutuo', label: 'Mutuo ristrutturazione del locale', + principal_cents: euro(130_000), annual_rate_percent: 4.2, + first_due_date: il28(today, -32), installments: 96, frequency: 'monthly', + grace_installments: 0, amortization: 'francese', balloon_cents: 0 + }) + saveLoan(companyUuid, { + bank_uuid: leasing.uuid, kind: 'leasing', label: 'Leasing forno a legna e abbattitore', + principal_cents: euro(38_000), annual_rate_percent: 5.5, + first_due_date: il28(today, -14), installments: 60, frequency: 'monthly', + grace_installments: 0, amortization: 'francese', balloon_cents: euro(3_800) + }) + saveLoan(companyUuid, { + bank_uuid: partenopeo.uuid, kind: 'finanziamento', label: 'Finanziamento liquidità garantito', + principal_cents: euro(40_000), annual_rate_percent: 3.9, + first_due_date: il28(today, -5), installments: 20, frequency: 'quarterly', + grace_installments: 4, amortization: 'italiano', balloon_cents: 0 + }) +} diff --git a/src/main/db/demo-treasury.ts b/src/main/db/demo-treasury.ts index bf12817..637b78a 100644 --- a/src/main/db/demo-treasury.ts +++ b/src/main/db/demo-treasury.ts @@ -10,7 +10,8 @@ import { createItem, openingCash, todayLocal, updateSettings } from '../server/s * aperta fra sei mesi con tutte le scadenze nel passato mostrerebbe solo * arretrati. Ci sono di proposito un credito scaduto da oltre 60 giorni, un * incasso parziale e un pagamento scaduto: sono i casi che la vista deve saper - * raccontare. + * raccontare. Le rate dei finanziamenti non stanno qui: le genera il piano di + * ammortamento (vedi demo-banks.ts). */ const euro = (value: number): number => Math.round(value * 100) @@ -136,10 +137,6 @@ export function seedDemoTreasury(companyUuid: string, today = todayLocal()): voi description: 'Altri acquisti di ingredienti non ancora fatturati (stima)', due_date: giornoDelMese(today, 28), amount_cents: euro(14_000), recurrence: 'monthly' }, - { - direction: 'out', category: 'Rate finanziamenti', description: 'Rata mutuo ristrutturazione', - due_date: giornoDelMese(today, 28), amount_cents: euro(1_850), recurrence: 'monthly' - }, // Una tantum. { direction: 'in', category: 'Altre entrate', description: 'Rimborso assicurazione per il guasto al forno', diff --git a/src/main/db/migrations/005_banks.ts b/src/main/db/migrations/005_banks.ts new file mode 100644 index 0000000..e18762b --- /dev/null +++ b/src/main/db/migrations/005_banks.ts @@ -0,0 +1,82 @@ +import type { Database } from 'better-sqlite3-multiple-ciphers' + +/** + * Fase 6 — banche e finanziamenti (AGENTS.md §10.7). + * + * - `banks`: gli istituti con cui l'azienda lavora, società di leasing + * comprese. + * - `credit_lines`: le linee a revoca o autoliquidanti — fido di cassa, + * anticipo fatture/SBF, carte — con accordato e utilizzato a una data. + * - `loans`: mutui, finanziamenti e leasing. Si memorizzano i **parametri** + * del piano, non le rate: il piano si ricalcola sempre dagli stessi dati + * (`@shared/engine/loans`), e le rate entrano nella previsione di cassa senza + * essere copiate in `treasury_items`. Una sola fonte, niente disallineamenti. + * + * Importi in centesimi, tassi in percentuale nominale annua. + */ +export function up(db: Database): void { + db.exec(` + CREATE TABLE banks ( + uuid TEXT PRIMARY KEY, + company_uuid TEXT NOT NULL REFERENCES companies(uuid), + name TEXT NOT NULL, + branch TEXT, + contact TEXT, + notes TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + synced INTEGER NOT NULL DEFAULT 0 CHECK (synced IN (0, 1)), + deleted INTEGER NOT NULL DEFAULT 0 CHECK (deleted IN (0, 1)) + ); + + CREATE UNIQUE INDEX idx_banks_company_name + ON banks(company_uuid, name COLLATE NOCASE) WHERE deleted = 0; + + CREATE TABLE credit_lines ( + uuid TEXT PRIMARY KEY, + company_uuid TEXT NOT NULL REFERENCES companies(uuid), + bank_uuid TEXT NOT NULL REFERENCES banks(uuid), + kind TEXT NOT NULL CHECK (kind IN ('fido_cassa', 'anticipo_fatture', 'carta', 'altro')), + label TEXT NOT NULL, + granted_cents INTEGER NOT NULL CHECK (granted_cents >= 0), + -- Può superare l'accordato: uno sconfinamento è un dato, non un errore. + used_cents INTEGER NOT NULL DEFAULT 0 CHECK (used_cents >= 0), + used_as_of TEXT, + annual_rate_percent REAL CHECK (annual_rate_percent IS NULL OR annual_rate_percent BETWEEN 0 AND 50), + expiry_date TEXT, + notes TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + synced INTEGER NOT NULL DEFAULT 0 CHECK (synced IN (0, 1)), + deleted INTEGER NOT NULL DEFAULT 0 CHECK (deleted IN (0, 1)) + ); + + CREATE INDEX idx_credit_lines_company ON credit_lines(company_uuid) WHERE deleted = 0; + + CREATE TABLE loans ( + uuid TEXT PRIMARY KEY, + company_uuid TEXT NOT NULL REFERENCES companies(uuid), + bank_uuid TEXT NOT NULL REFERENCES banks(uuid), + kind TEXT NOT NULL CHECK (kind IN ('mutuo', 'finanziamento', 'leasing')), + label TEXT NOT NULL, + principal_cents INTEGER NOT NULL CHECK (principal_cents > 0), + annual_rate_percent REAL NOT NULL CHECK (annual_rate_percent BETWEEN 0 AND 50), + first_due_date TEXT NOT NULL, + installments INTEGER NOT NULL CHECK (installments BETWEEN 1 AND 600), + frequency TEXT NOT NULL CHECK (frequency IN ('monthly', 'quarterly', 'semiannual', 'annual')), + grace_installments INTEGER NOT NULL DEFAULT 0 CHECK (grace_installments >= 0), + amortization TEXT NOT NULL DEFAULT 'francese' CHECK (amortization IN ('francese', 'italiano')), + balloon_cents INTEGER NOT NULL DEFAULT 0 CHECK (balloon_cents >= 0), + notes TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + synced INTEGER NOT NULL DEFAULT 0 CHECK (synced IN (0, 1)), + deleted INTEGER NOT NULL DEFAULT 0 CHECK (deleted IN (0, 1)), + + CHECK (grace_installments < installments), + CHECK (balloon_cents < principal_cents) + ); + + CREATE INDEX idx_loans_company ON loans(company_uuid) WHERE deleted = 0; + `) +} diff --git a/src/main/db/migrations/index.ts b/src/main/db/migrations/index.ts index b20a217..08ec07d 100644 --- a/src/main/db/migrations/index.ts +++ b/src/main/db/migrations/index.ts @@ -3,6 +3,7 @@ import { up as up001 } from './001_initial' import { up as up002 } from './002_financial_model' import { up as up003 } from './003_import_documents_no_unique_hash' import { up as up004 } from './004_treasury' +import { up as up005 } from './005_banks' export interface Migration { version: number @@ -19,7 +20,8 @@ export const MIGRATIONS: Migration[] = [ { version: 1, name: '001_initial', up: up001 }, { version: 2, name: '002_financial_model', up: up002 }, { version: 3, name: '003_import_documents_no_unique_hash', up: up003 }, - { version: 4, name: '004_treasury', up: up004 } + { version: 4, name: '004_treasury', up: up004 }, + { version: 5, name: '005_banks', up: up005 } ] export function runMigrations(db: Database): number { diff --git a/src/main/db/seed.ts b/src/main/db/seed.ts index 34c3a10..04e29e4 100644 --- a/src/main/db/seed.ts +++ b/src/main/db/seed.ts @@ -4,6 +4,7 @@ import { createCompany } from '../server/services/companies.service' import { insertUser } from '../server/services/auth.service' import { DEMO_BUILD } from '../build-flags' import { seedDemoFinancials } from './demo-data' +import { seedDemoBanks } from './demo-banks' import { seedDemoTreasury } from './demo-treasury' import { getDatabase } from './index' @@ -75,6 +76,8 @@ export function seedDemoData(): void { seedDemoFinancials(company.uuid) // Scadenziario e previsioni di cassa, con date relative a oggi. seedDemoTreasury(company.uuid) + // Istituti, linee di credito e finanziamenti. + seedDemoBanks(company.uuid) insertUser( { @@ -122,4 +125,19 @@ function aggiornaDemoEsistente(): void { seedDemoTreasury(azienda.uuid) console.log('[db] seed dimostrativo: aggiunta la tesoreria della Pizzeria DaProd') } + + const banche = db + .prepare('SELECT count(*) AS n FROM banks WHERE company_uuid = ?') + .get(azienda.uuid) as { n: number } + if (banche.n === 0) { + // Fino alla Fase 5 la rata del mutuo era una previsione manuale: ora la + // genera il piano di ammortamento, e tenerle entrambe la conterebbe due volte. + db.prepare( + `UPDATE treasury_items SET deleted = 1, updated_at = ?, synced = 0 + WHERE company_uuid = ? AND source = 'manuale' AND deleted = 0 + AND description = 'Rata mutuo ristrutturazione'` + ).run(new Date().toISOString(), azienda.uuid) + seedDemoBanks(azienda.uuid) + console.log('[db] seed dimostrativo: aggiunti banche e finanziamenti della Pizzeria DaProd') + } } diff --git a/src/main/server/routes/banks.routes.ts b/src/main/server/routes/banks.routes.ts new file mode 100644 index 0000000..0602cf1 --- /dev/null +++ b/src/main/server/routes/banks.routes.ts @@ -0,0 +1,66 @@ +import { Router, type Request } from 'express' +import { requireAuth, requireRole } from '../middleware/auth' +import { HttpError } from '../http-error' +import { + bankingView, + deleteBank, + deleteLine, + deleteLoan, + saveBank, + saveLine, + saveLoan +} from '../services/banks.service' +import { todayLocal } from '../services/treasury.service' + +/** + * Banche e finanziamenti di un'azienda — AGENTS.md §10.7. Come per la + * tesoreria, l'operatore Azienda legge e il Consulente scrive. + */ +export const banksRouter: Router = Router({ mergeParams: true }) + +banksRouter.use(requireAuth) + +function param(req: Request, name: string): string { + const value = req.params[name] + return Array.isArray(value) ? (value[0] ?? '') : (value ?? '') +} + +banksRouter.get('/banking', (req, res) => { + const companyUuid = param(req, 'uuid') + if (req.auth!.role === 'company' && req.auth!.company_uuid !== companyUuid) { + throw new HttpError(403, 'Operazione non consentita per questo ruolo.') + } + res.json(bankingView(companyUuid, todayLocal())) +}) + +const scrittura = requireRole('consultant') + +banksRouter.post('/banks', scrittura, (req, res) => { + res.status(201).json(saveBank(param(req, 'uuid'), req.body ?? {})) +}) +banksRouter.put('/banks/:id', scrittura, (req, res) => { + res.json(saveBank(param(req, 'uuid'), req.body ?? {}, param(req, 'id'))) +}) +banksRouter.delete('/banks/:id', scrittura, (req, res) => { + res.json(deleteBank(param(req, 'uuid'), param(req, 'id'))) +}) + +banksRouter.post('/credit-lines', scrittura, (req, res) => { + res.status(201).json(saveLine(param(req, 'uuid'), req.body ?? {})) +}) +banksRouter.put('/credit-lines/:id', scrittura, (req, res) => { + res.json(saveLine(param(req, 'uuid'), req.body ?? {}, param(req, 'id'))) +}) +banksRouter.delete('/credit-lines/:id', scrittura, (req, res) => { + res.json(deleteLine(param(req, 'uuid'), param(req, 'id'))) +}) + +banksRouter.post('/loans', scrittura, (req, res) => { + res.status(201).json(saveLoan(param(req, 'uuid'), req.body ?? {})) +}) +banksRouter.put('/loans/:id', scrittura, (req, res) => { + res.json(saveLoan(param(req, 'uuid'), req.body ?? {}, param(req, 'id'))) +}) +banksRouter.delete('/loans/:id', scrittura, (req, res) => { + res.json(deleteLoan(param(req, 'uuid'), param(req, 'id'))) +}) diff --git a/src/main/server/routes/companies.routes.ts b/src/main/server/routes/companies.routes.ts index 718273b..dc21edd 100644 --- a/src/main/server/routes/companies.routes.ts +++ b/src/main/server/routes/companies.routes.ts @@ -9,6 +9,7 @@ import { } from '../services/companies.service' import { requireAuth, requireRole } from '../middleware/auth' import { analysisRouter } from './analysis.routes' +import { banksRouter } from './banks.routes' import { treasuryRouter } from './treasury.routes' /** Anagrafica Aziende — AGENTS.md §10.1. */ @@ -19,6 +20,7 @@ companiesRouter.use(requireAuth) // Periodi, analisi e import vivono sotto la singola azienda. companiesRouter.use('/:uuid', analysisRouter) companiesRouter.use('/:uuid', treasuryRouter) +companiesRouter.use('/:uuid', banksRouter) function uuidParam(req: Request): string { const value = req.params['uuid'] diff --git a/src/main/server/services/analysis.service.ts b/src/main/server/services/analysis.service.ts index 73b6ca9..58533f4 100644 --- a/src/main/server/services/analysis.service.ts +++ b/src/main/server/services/analysis.service.ts @@ -13,6 +13,7 @@ import type { Analysis, IncomeComparison, SeriesPoint } from '@shared/analysis' import type { FiscalPeriod, Scenario } from '@shared/types' import { getDatabase } from '../../db' import { HttpError } from '../http-error' +import { debtServiceFor } from './banks.service' import { getCompany } from './companies.service' /** @@ -44,6 +45,13 @@ export function listPeriods(companyUuid: string): FiscalPeriod[] { })) } +/** Ultimo giorno di un periodo contabile, `YYYY-MM-DD`. */ +export function periodEnd(year: number, month: number | null): string { + const m = month ?? 12 + const d = month === null ? 31 : periodDays(year, month) + return `${year}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}` +} + export function getPeriod(companyUuid: string, periodUuid: string): FiscalPeriod { const period = getDatabase() .prepare('SELECT * FROM fiscal_periods WHERE uuid = ? AND company_uuid = ? AND deleted = 0') @@ -152,7 +160,7 @@ function ebitdaUltimi12Mesi( export function analyse( companyUuid: string, periodUuid: string, - options: { scenario?: Scenario; scheme?: Scheme; debtServiceCents?: number | null } = {} + options: { scenario?: Scenario; scheme?: Scheme } = {} ): Analysis { getCompany(companyUuid) const period = getPeriod(companyUuid, periodUuid) @@ -180,7 +188,10 @@ export function analyse( income: statement.aggregates, balance, days: periodDays(period.year, period.month), - debtServiceCents: options.debtServiceCents ?? null, + // §5: le rate attese nei 12 mesi successivi — alla fine del periodo + // analizzato, così il DSCR di un periodo non cambia col giorno in cui + // lo si guarda. + debtServiceCents: debtServiceFor(companyUuid, periodEnd(period.year, period.month)), ebitdaLtmCents: ebitdaUltimi12Mesi(companyUuid, period, scenario) }), comparison: comparison(companyUuid, period, scenario, statement.aggregates) diff --git a/src/main/server/services/banks.service.ts b/src/main/server/services/banks.service.ts new file mode 100644 index 0000000..b701bde --- /dev/null +++ b/src/main/server/services/banks.service.ts @@ -0,0 +1,370 @@ +import { + CREDIT_LINE_LABELS, + creditSummary, + debtServiceNext12Months, + LOAN_LABELS, + loanStatus, + loanTreasuryItems, + schedule, + validateLoan, + type CreditLineKind, + type LoanInput, + type LoanKind +} from '@shared/engine' +import type { BankingView } from '@shared/analysis' +import type { Bank, CreditLine, Loan } from '@shared/types' +import { getDatabase } from '../../db' +import { newUuid, nowIso } from '../../lib/ids' +import { HttpError } from '../http-error' +import { getCompany } from './companies.service' + +/** + * Banche e finanziamenti — AGENTS.md §10.7. + * + * I finanziamenti si salvano come parametri: il piano di ammortamento lo + * ricalcola sempre il motore, e le rate entrano nella previsione di cassa da + * lì (vedi `loanTreasuryItemsFor`). + */ + +type Row = Record + +const DATA = /^\d{4}-\d{2}-\d{2}$/ + +function testo(value: unknown): string | null { + if (typeof value !== 'string') return null + const t = value.trim() + return t ? t : null +} + +function data(value: unknown, campo: string, obbligatoria = false): string | null { + if (value === null || value === undefined || value === '') { + if (obbligatoria) throw new HttpError(400, `${campo}: data obbligatoria.`) + return null + } + if (typeof value !== 'string' || !DATA.test(value) || Number.isNaN(Date.parse(value))) { + throw new HttpError(400, `${campo}: data non valida (atteso AAAA-MM-GG).`) + } + return value +} + +function intero(value: unknown, campo: string, min = 0): number { + if (typeof value !== 'number' || !Number.isInteger(value) || value < min) { + throw new HttpError(400, `${campo}: valore non valido.`) + } + return value +} + +function tasso(value: unknown, campo: string, obbligatorio: boolean): number | null { + if (value === null || value === undefined || value === '') { + if (obbligatorio) throw new HttpError(400, `${campo}: obbligatorio.`) + return null + } + if (typeof value !== 'number' || !Number.isFinite(value) || value < 0 || value > 50) { + throw new HttpError(400, `${campo}: deve stare fra 0 e 50%.`) + } + return value +} + +// --- istituti ------------------------------------------------------------------ + +export function listBanks(companyUuid: string): Bank[] { + return getDatabase() + .prepare('SELECT * FROM banks WHERE company_uuid = ? AND deleted = 0 ORDER BY name COLLATE NOCASE') + .all(companyUuid) as Bank[] +} + +function getBank(companyUuid: string, uuid: string): Bank { + const bank = getDatabase() + .prepare('SELECT * FROM banks WHERE uuid = ? AND company_uuid = ? AND deleted = 0') + .get(uuid, companyUuid) as Bank | undefined + if (!bank) throw new HttpError(404, 'Istituto non trovato.') + return bank +} + +export function saveBank(companyUuid: string, input: Row, uuid?: string): Bank { + getCompany(companyUuid) + const current = uuid ? getBank(companyUuid, uuid) : null + const merged = { ...current, ...input } + const name = testo(merged.name) + if (!name) throw new HttpError(400, "Il nome dell'istituto è obbligatorio.") + + const doppione = getDatabase() + .prepare( + `SELECT uuid FROM banks WHERE company_uuid = ? AND name = ? COLLATE NOCASE + AND deleted = 0 AND uuid <> ?` + ) + .get(companyUuid, name, uuid ?? '') as { uuid: string } | undefined + if (doppione) throw new HttpError(409, `"${name}" è già fra gli istituti di questa azienda.`) + + const row = { + name, + branch: testo(merged.branch), + contact: testo(merged.contact), + notes: testo(merged.notes), + now: nowIso() + } + const db = getDatabase() + if (current) { + db.prepare( + `UPDATE banks SET name = @name, branch = @branch, contact = @contact, notes = @notes, + updated_at = @now, synced = 0 WHERE uuid = @uuid` + ).run({ ...row, uuid: current.uuid }) + return getBank(companyUuid, current.uuid) + } + const nuovo = newUuid() + db.prepare( + `INSERT INTO banks (uuid, company_uuid, name, branch, contact, notes, created_at, updated_at, synced, deleted) + VALUES (@uuid, @company, @name, @branch, @contact, @notes, @now, @now, 0, 0)` + ).run({ ...row, uuid: nuovo, company: companyUuid }) + return getBank(companyUuid, nuovo) +} + +export function deleteBank(companyUuid: string, uuid: string): { uuid: string } { + getBank(companyUuid, uuid) + const db = getDatabase() + const legati = db + .prepare( + `SELECT (SELECT count(*) FROM credit_lines WHERE bank_uuid = ? AND deleted = 0) + + (SELECT count(*) FROM loans WHERE bank_uuid = ? AND deleted = 0) AS n` + ) + .get(uuid, uuid) as { n: number } + if (legati.n > 0) { + throw new HttpError(409, "L'istituto ha ancora linee di credito o finanziamenti: eliminali prima.") + } + db.prepare('UPDATE banks SET deleted = 1, updated_at = ?, synced = 0 WHERE uuid = ?').run(nowIso(), uuid) + return { uuid } +} + +// --- linee di credito ------------------------------------------------------------ + +const LINE_KINDS = Object.keys(CREDIT_LINE_LABELS) as CreditLineKind[] + +export function listLines(companyUuid: string): CreditLine[] { + return getDatabase() + .prepare('SELECT * FROM credit_lines WHERE company_uuid = ? AND deleted = 0 ORDER BY kind, label') + .all(companyUuid) as CreditLine[] +} + +function getLine(companyUuid: string, uuid: string): CreditLine { + const line = getDatabase() + .prepare('SELECT * FROM credit_lines WHERE uuid = ? AND company_uuid = ? AND deleted = 0') + .get(uuid, companyUuid) as CreditLine | undefined + if (!line) throw new HttpError(404, 'Linea di credito non trovata.') + return line +} + +export function saveLine(companyUuid: string, input: Row, uuid?: string): CreditLine { + getCompany(companyUuid) + const current = uuid ? getLine(companyUuid, uuid) : null + const merged = { ...current, ...input } + + const bankUuid = testo(merged.bank_uuid) + if (!bankUuid) throw new HttpError(400, "Scegli l'istituto.") + getBank(companyUuid, bankUuid) + const kind = merged.kind as CreditLineKind + if (!LINE_KINDS.includes(kind)) throw new HttpError(400, 'Tipo di linea non valido.') + + const row = { + bank_uuid: bankUuid, + kind, + label: testo(merged.label) ?? CREDIT_LINE_LABELS[kind], + granted_cents: intero(merged.granted_cents, 'Accordato'), + used_cents: intero(merged.used_cents ?? 0, 'Utilizzato'), + used_as_of: data(merged.used_as_of, 'Utilizzo alla data'), + annual_rate_percent: tasso(merged.annual_rate_percent, 'Tasso', false), + expiry_date: data(merged.expiry_date, 'Scadenza'), + notes: testo(merged.notes), + now: nowIso() + } + + const db = getDatabase() + if (current) { + db.prepare( + `UPDATE credit_lines SET bank_uuid = @bank_uuid, kind = @kind, label = @label, + granted_cents = @granted_cents, used_cents = @used_cents, used_as_of = @used_as_of, + annual_rate_percent = @annual_rate_percent, expiry_date = @expiry_date, notes = @notes, + updated_at = @now, synced = 0 + WHERE uuid = @uuid` + ).run({ ...row, uuid: current.uuid }) + return getLine(companyUuid, current.uuid) + } + const nuovo = newUuid() + db.prepare( + `INSERT INTO credit_lines (uuid, company_uuid, bank_uuid, kind, label, granted_cents, used_cents, + used_as_of, annual_rate_percent, expiry_date, notes, + created_at, updated_at, synced, deleted) + VALUES (@uuid, @company, @bank_uuid, @kind, @label, @granted_cents, @used_cents, + @used_as_of, @annual_rate_percent, @expiry_date, @notes, @now, @now, 0, 0)` + ).run({ ...row, uuid: nuovo, company: companyUuid }) + return getLine(companyUuid, nuovo) +} + +export function deleteLine(companyUuid: string, uuid: string): { uuid: string } { + getLine(companyUuid, uuid) + getDatabase() + .prepare('UPDATE credit_lines SET deleted = 1, updated_at = ?, synced = 0 WHERE uuid = ?') + .run(nowIso(), uuid) + return { uuid } +} + +// --- finanziamenti --------------------------------------------------------------- + +const LOAN_KINDS = Object.keys(LOAN_LABELS) as LoanKind[] + +export function listLoans(companyUuid: string): Loan[] { + return getDatabase() + .prepare('SELECT * FROM loans WHERE company_uuid = ? AND deleted = 0 ORDER BY first_due_date') + .all(companyUuid) as Loan[] +} + +function getLoan(companyUuid: string, uuid: string): Loan { + const loan = getDatabase() + .prepare('SELECT * FROM loans WHERE uuid = ? AND company_uuid = ? AND deleted = 0') + .get(uuid, companyUuid) as Loan | undefined + if (!loan) throw new HttpError(404, 'Finanziamento non trovato.') + return loan +} + +export function saveLoan(companyUuid: string, input: Row, uuid?: string): Loan { + getCompany(companyUuid) + const current = uuid ? getLoan(companyUuid, uuid) : null + const merged = { ...current, ...input } + + const bankUuid = testo(merged.bank_uuid) + if (!bankUuid) throw new HttpError(400, "Scegli l'istituto.") + getBank(companyUuid, bankUuid) + const kind = merged.kind as LoanKind + if (!LOAN_KINDS.includes(kind)) throw new HttpError(400, 'Tipo di finanziamento non valido.') + + const row = { + bank_uuid: bankUuid, + kind, + label: testo(merged.label) ?? LOAN_LABELS[kind], + principal_cents: intero(merged.principal_cents, 'Importo', 1), + annual_rate_percent: tasso(merged.annual_rate_percent, 'Tasso annuo', true)!, + first_due_date: data(merged.first_due_date, 'Prima rata', true)!, + installments: intero(merged.installments, 'Numero di rate', 1), + frequency: merged.frequency as LoanInput['frequency'], + grace_installments: intero(merged.grace_installments ?? 0, 'Preammortamento'), + amortization: (merged.amortization ?? 'francese') as LoanInput['amortization'], + balloon_cents: intero(merged.balloon_cents ?? 0, 'Riscatto'), + notes: testo(merged.notes), + now: nowIso() + } + if (row.amortization !== 'francese' && row.amortization !== 'italiano') { + throw new HttpError(400, 'Tipo di ammortamento non valido.') + } + // Il motore controlla la coerenza del piano: le stesse regole valgono ovunque. + const check = validateLoan({ ...row, uuid: 'verifica' }) + if (!check.ok) throw new HttpError(400, check.error!) + + const db = getDatabase() + if (current) { + db.prepare( + `UPDATE loans SET bank_uuid = @bank_uuid, kind = @kind, label = @label, + principal_cents = @principal_cents, annual_rate_percent = @annual_rate_percent, + first_due_date = @first_due_date, installments = @installments, frequency = @frequency, + grace_installments = @grace_installments, amortization = @amortization, + balloon_cents = @balloon_cents, notes = @notes, updated_at = @now, synced = 0 + WHERE uuid = @uuid` + ).run({ ...row, uuid: current.uuid }) + return getLoan(companyUuid, current.uuid) + } + const nuovo = newUuid() + db.prepare( + `INSERT INTO loans (uuid, company_uuid, bank_uuid, kind, label, principal_cents, + annual_rate_percent, first_due_date, installments, frequency, + grace_installments, amortization, balloon_cents, notes, + created_at, updated_at, synced, deleted) + VALUES (@uuid, @company, @bank_uuid, @kind, @label, @principal_cents, + @annual_rate_percent, @first_due_date, @installments, @frequency, + @grace_installments, @amortization, @balloon_cents, @notes, @now, @now, 0, 0)` + ).run({ ...row, uuid: nuovo, company: companyUuid }) + return getLoan(companyUuid, nuovo) +} + +export function deleteLoan(companyUuid: string, uuid: string): { uuid: string } { + getLoan(companyUuid, uuid) + getDatabase() + .prepare('UPDATE loans SET deleted = 1, updated_at = ?, synced = 0 WHERE uuid = ?') + .run(nowIso(), uuid) + return { uuid } +} + +// --- letture per gli altri moduli ----------------------------------------------- + +/** Le rate future di tutti i finanziamenti, come movimenti di tesoreria. */ +export function loanTreasuryItemsFor(companyUuid: string, today: string) { + return listLoans(companyUuid).flatMap((loan) => loanTreasuryItems(loan, today)) +} + +/** Rate dei 12 mesi successivi a una data: il denominatore del DSCR. */ +export function debtServiceFor(companyUuid: string, from: string): number | null { + const loans = listLoans(companyUuid) + if (loans.length === 0) return null + return debtServiceNext12Months(loans, from) +} + +export function creditLinesSummary(companyUuid: string) { + return creditSummary(listLines(companyUuid)) +} + +// --- vista ----------------------------------------------------------------------- + +export function bankingView(companyUuid: string, today: string): BankingView { + getCompany(companyUuid) + const banks = listBanks(companyUuid) + const lines = listLines(companyUuid) + const loans = listLoans(companyUuid).map((loan) => { + const plan = schedule(loan) + return { ...loan, schedule: plan, status: loanStatus(loan, today, plan) } + }) + + // Per istituto si leggono i finanziamenti come fa la Centrale Rischi: per i + // "rischi a scadenza" accordato e utilizzato coincidono col debito residuo. + const perBanca = banks.map((bank) => { + const proprie = lines.filter((l) => l.bank_uuid === bank.uuid) + const residuo = loans + .filter((l) => l.bank_uuid === bank.uuid) + .reduce((s, l) => s + l.status.residual, 0) + const linee = creditSummary(proprie) + const accordato = linee.accordato + residuo + const utilizzato = linee.utilizzato + residuo + return { + bank_uuid: bank.uuid, + name: bank.name, + accordato, + utilizzato, + disponibile: linee.disponibile, + // L'utilizzo guarda solo le linee a revoca: un finanziamento è per + // definizione utilizzato al 100%, e farebbe scattare un avviso finto. + utilizzoPercent: linee.utilizzoPercent + } + }) + + const perTipologia = [ + ...LINE_KINDS.map((kind) => ({ + kind, + label: CREDIT_LINE_LABELS[kind], + utilizzato: lines.filter((l) => l.kind === kind).reduce((s, l) => s + l.used_cents, 0) + })), + ...LOAN_KINDS.map((kind) => ({ + kind, + label: LOAN_LABELS[kind], + utilizzato: loans.filter((l) => l.kind === kind).reduce((s, l) => s + l.status.residual, 0) + })) + ].filter((t) => t.utilizzato > 0) + + return { + today, + banks, + lines, + loans, + affidamenti: creditSummary(lines), + rataMensile: loans.reduce((s, l) => s + l.status.monthlyEquivalent, 0), + debitoResiduo: loans.reduce((s, l) => s + l.status.residual, 0), + rate12Mesi: loans.length > 0 ? debtServiceNext12Months(loans, today) : 0, + perBanca, + perTipologia + } +} diff --git a/src/main/server/services/treasury.service.ts b/src/main/server/services/treasury.service.ts index 917c2c4..5922ef6 100644 --- a/src/main/server/services/treasury.service.ts +++ b/src/main/server/services/treasury.service.ts @@ -2,7 +2,6 @@ import { balanceSheet, dueDate, forecast, - periodDays, receivablesAging, type OpeningCash, type PaymentTerms @@ -12,7 +11,8 @@ import type { TreasuryItem, TreasuryItemInput, TreasurySettings } from '@shared/ import { getDatabase } from '../../db' import { newUuid, nowIso } from '../../lib/ids' import { HttpError } from '../http-error' -import { engineAccounts, listPeriods, series } from './analysis.service' +import { engineAccounts, listPeriods, periodEnd, series } from './analysis.service' +import { creditLinesSummary, loanTreasuryItemsFor } from './banks.service' import { getCompany } from './companies.service' /** @@ -78,7 +78,12 @@ function getItem(companyUuid: string, uuid: string): TreasuryItem { } /** Valida e completa una riga: da un input parziale a una riga coerente. */ -function normalize(input: TreasuryItemInput, base?: TreasuryItem): Omit { +type ItemRow = Omit< + TreasuryItem, + 'uuid' | 'company_uuid' | 'created_at' | 'updated_at' | 'synced' | 'deleted' +> + +function normalize(input: TreasuryItemInput, base?: TreasuryItem): ItemRow { const merged = { ...base, ...input } const direction = merged.direction @@ -288,13 +293,6 @@ export function updateSettings(companyUuid: string, input: Partial somma + f.valore, 0) return ( @@ -225,7 +231,7 @@ export function CompositionePie({ fette }: { fette: FettaCosto[] }): React.JSX.E
- Totale costi + {etichetta} {euro(totale)}
diff --git a/src/renderer/src/pages/CompanyPage.tsx b/src/renderer/src/pages/CompanyPage.tsx index d3ee6c1..f639c1f 100644 --- a/src/renderer/src/pages/CompanyPage.tsx +++ b/src/renderer/src/pages/CompanyPage.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useState } from 'react' import type { Analysis, + BankingView as BankingPayload, SeriesPoint, TreasuryView as TreasuryPayload, WorkingCapitalView as WorkingCapitalPayload @@ -11,6 +12,7 @@ import { api } from '../lib/api' import type { Vista } from '../components/Sidebar' import { Alert, Button, Card, EmptyState, Select } from '../components/ui' import { BalanceSheetView } from './business/BalanceSheetView' +import { BanksView } from './business/BanksView' import { ImportPanel, TemplateButton } from './business/ImportPanel' import { IncomeStatementView } from './business/IncomeStatementView' import { OverviewView } from './business/OverviewView' @@ -50,6 +52,7 @@ export function CompanyPage({ const [serie, setSerie] = useState([]) const [tesoreria, setTesoreria] = useState(null) const [circolante, setCircolante] = useState(null) + const [banche, setBanche] = useState(null) const [error, setError] = useState(null) const [loading, setLoading] = useState(true) @@ -119,6 +122,18 @@ export function CompanyPage({ if (vista === 'tesoreria' || vista === 'panoramica') caricaTesoreria() }, [vista, caricaTesoreria]) + const caricaBanche = useCallback(async () => { + try { + setBanche(await api.get(`/api/companies/${company.uuid}/banking`)) + } catch (err) { + setError(err instanceof Error ? err.message : 'Banche non disponibili.') + } + }, [company.uuid]) + + useEffect(() => { + if (vista === 'banche') caricaBanche() + }, [vista, caricaBanche]) + useEffect(() => { if (vista !== 'capitale-circolante' || !periodUuid) return let annullato = false @@ -140,8 +155,8 @@ export function CompanyPage({ }, [company.uuid, vista, periodUuid, scenario]) const conDati = analysis !== null && analysis.accountCount > 0 - // Import e tesoreria non dipendono dal periodo scelto. - const senzaPeriodo = vista === 'import' || vista === 'tesoreria' + // Import, tesoreria e banche non dipendono dal periodo scelto. + const senzaPeriodo = vista === 'import' || vista === 'tesoreria' || vista === 'banche' return (
@@ -218,6 +233,18 @@ export function CompanyPage({

Caricamento…

))} + {vista === 'banche' && + (banche ? ( + + ) : ( +

Caricamento…

+ ))} + {!senzaPeriodo && (loading ? (

Caricamento…

diff --git a/src/renderer/src/pages/business/BalanceSheetView.tsx b/src/renderer/src/pages/business/BalanceSheetView.tsx index a7ef36c..599808b 100644 --- a/src/renderer/src/pages/business/BalanceSheetView.tsx +++ b/src/renderer/src/pages/business/BalanceSheetView.tsx @@ -167,7 +167,16 @@ export function BalanceSheetView({ analysis }: { analysis: Analysis }): React.JS - +
diff --git a/src/renderer/src/pages/business/BankForms.tsx b/src/renderer/src/pages/business/BankForms.tsx new file mode 100644 index 0000000..4c9df54 --- /dev/null +++ b/src/renderer/src/pages/business/BankForms.tsx @@ -0,0 +1,405 @@ +import { useMemo, useState } from 'react' +import { + CREDIT_LINE_LABELS, + FREQUENCY_LABELS, + LOAN_LABELS, + schedule, + validateLoan, + type CreditLineKind, + type Frequency, + type LoanKind +} from '@shared/engine' +import type { Bank, CreditLine, Loan } from '@shared/types' +import { api } from '../../lib/api' +import { euro, euroInput, parseEuro } from '../../lib/format' +import { Alert, Button, Field, Modal, Select, TextInput } from '../../components/ui' +import { oggiLocale } from './TreasuryForms' + +/** + * Moduli del modulo Banche: istituto, linea di credito, finanziamento. + * Il finanziamento mostra la rata mentre lo si compila: è il modo più rapido + * per accorgersi di un tasso o di un numero di rate sbagliato. + */ + +function Footer({ busy, onClose, label }: { busy: boolean; onClose: () => void; label: string }): React.JSX.Element { + return ( +
+ + +
+ ) +} + +function useSalva(onSaved: () => void) { + const [error, setError] = useState(null) + const [busy, setBusy] = useState(false) + const salva = async (fn: () => Promise): Promise => { + setBusy(true) + setError(null) + try { + await fn() + onSaved() + } catch (err) { + setError(err instanceof Error ? err.message : 'Salvataggio non riuscito.') + setBusy(false) + } + } + return { error, setError, busy, salva } +} + +/** "4,2" → 4.2; null se vuoto o non numerico. */ +function parseTasso(text: string): number | null { + const t = text.trim().replace('%', '').replace(',', '.') + if (!t) return null + const n = Number(t) + return Number.isFinite(n) ? n : null +} + +export function BankModal({ + companyUuid, + bank, + onClose, + onSaved +}: { + companyUuid: string + bank: Bank | null + onClose: () => void + onSaved: () => void +}): React.JSX.Element { + const [name, setName] = useState(bank?.name ?? '') + const [branch, setBranch] = useState(bank?.branch ?? '') + const [contact, setContact] = useState(bank?.contact ?? '') + const [notes, setNotes] = useState(bank?.notes ?? '') + const { error, busy, salva } = useSalva(onSaved) + + const invia = (event: React.FormEvent): void => { + event.preventDefault() + const body = { name, branch, contact, notes } + void salva(() => + bank + ? api.put(`/api/companies/${companyUuid}/banks/${bank.uuid}`, body) + : api.post(`/api/companies/${companyUuid}/banks`, body) + ) + } + + return ( + +
+
+ {error && ( +
+ {error} +
+ )} +
+ + setName(e.target.value)} autoFocus /> + +
+ + setBranch(e.target.value)} /> + + + setContact(e.target.value)} /> + +
+ + setNotes(e.target.value)} /> + +
+
+