diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d7f730..f7eed5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,20 @@ Tutte le modifiche rilevanti a EasyPIVA vengono tracciate in questo file. Nessuna modifica in attesa di rilascio. +## [1.1.1] - 2026-08-08 + +- corretto il calcolo INPS 2026 per Artigiani e Commercianti distinguendo il massimale di 93.707€ per chi possiede anzianità contributiva al 31 dicembre 1995 dal massimale di 122.295€ applicabile negli altri casi, con scelta esplicita nei tre simulatori e nel confronto regimi; +- documentate le due fasce di massimale nell'ADR fiscale e nella pagina informativa, ora collegata direttamente alle fonti normative primarie; +- corrette la data predefinita del preventivo affinché usi il giorno locale e la persistenza dell'ultima modifica ancora in debounce alla chiusura della pagina; +- limitato l'upload del logo a PNG, JPEG e WebP entro 1 MB, sanificando anche le bozze già archiviate e mostrando messaggi accessibili in caso di errore; +- aggiunto un messaggio accessibile quando la generazione PDF fallisce; +- migliorata l'accessibilità con skip link, titoli documento per route, menu mobile modale con gestione nativa di focus ed Escape, etichetta testuale del grafico del calcolatore e contrasto corretto nella hero; +- eliminato il layout shift del primo caricamento rendendo eager la piccola home e mantenendo il lazy loading per le route applicative più pesanti; +- aggiunti test axe end-to-end sulle otto route pubbliche e test unitari per massimali INPS, data locale, logo e gestione errori; +- aggiunti `robots.txt`, `sitemap.xml` e `llms.txt` statici per rendere esplicite le route pubbliche e la natura informativa del progetto; +- riorganizzato il README in modo product-first, chiarito l'ambito delle issue e aggiunta una social preview GitHub dedicata con provenienza documentata; +- aggiornate le dipendenze transitive interessate dagli advisory npm; `npm audit` non rileva vulnerabilità note. + ## [1.1.0] - 2026-08-02 - rimosse le devDependencies inutilizzate `@types/express` (nessun uso di Express nel progetto) e `autoprefixer` (il progetto non ha una pipeline PostCSS: Tailwind CSS v4 passa da `@tailwindcss/vite`), per un totale di 14 pacchetti in meno nell'albero di installazione; diff --git a/CITATION.cff b/CITATION.cff index 0dd9096..883da0a 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -6,6 +6,6 @@ authors: given-names: Michael url: 'https://github.com/TheStreamCode/easypiva' repository-code: 'https://github.com/TheStreamCode/easypiva' -version: '1.1.0' -date-released: '2026-08-02' +version: '1.1.1' +date-released: '2026-08-08' license: MIT diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 86ac65c..070c855 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,14 +5,15 @@ EasyPIVA è un repository pubblico con licenza MIT, ma il codice viene gestito c ## Ambito della policy - Le pull request esterne non fanno parte del processo di manutenzione ufficiale. -- Issue, discussion e richieste di supporto pubblico non sono il canale operativo del progetto. +- Sono benvenute issue per bug riproducibili, problemi di accessibilità o privacy e assunzioni fiscali corredate da fonti primarie verificabili. +- Issue e discussion non sono canali di consulenza fiscale, assistenza personalizzata o supporto operativo. - Fork e riuso del codice restano consentiti nei limiti della licenza MIT. ## Segnalazioni - Per vulnerabilità o problemi di privacy, consulta [`SECURITY.md`](SECURITY.md). - Per contatti operativi non legati alla sicurezza, scrivi a `info@mikesoft.it`. -- Le issue pubbliche, quando abilitate, devono usare i template in `.github/ISSUE_TEMPLATE/` e includere passaggi riproducibili o fonti primarie. +- Le issue pubbliche devono usare i template in `.github/ISSUE_TEMPLATE/` e includere passaggi riproducibili o fonti primarie. - Le pull request devono usare `.github/pull_request_template.md` e riportare i comandi di verifica eseguiti. ## Workflow interno dei maintainer diff --git a/README.md b/README.md index 6fd4f79..0331e13 100644 --- a/README.md +++ b/README.md @@ -3,56 +3,64 @@ [![CI](https://github.com/TheStreamCode/easypiva/actions/workflows/ci.yml/badge.svg)](https://github.com/TheStreamCode/easypiva/actions/workflows/ci.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) -Versione corrente del repository: `1.1.0`. +![EasyPIVA — Simulazioni fiscali indicative per la Partita IVA italiana](docs/assets/easypiva-social-preview.png) -EasyPIVA è una web app client-side per simulazioni fiscali indicative dedicate alla Partita IVA italiana. Copre regime forfettario, contributi INPS, confronto tra regimi, pianificazione dei ricavi e generazione di preventivi con export PDF. +EasyPIVA è una web app open source per simulazioni fiscali indicative dedicate alla Partita IVA italiana. Riunisce calcolo del regime forfettario, contributi INPS, confronto con il regime ordinario, pianificazione dei ricavi e generazione di preventivi con export PDF. -Tutti i calcoli vengono eseguiti localmente nel browser. Il progetto non richiede account e non usa un backend applicativo. +**[Apri EasyPIVA](https://easypiva.vercel.app)** · **[Consulta le fonti fiscali](https://easypiva.vercel.app/informativa)** · **[Leggi il changelog](CHANGELOG.md)** -**App online:** [easypiva.vercel.app](https://easypiva.vercel.app) +- Tutti i calcoli vengono eseguiti localmente nel browser. +- Non richiede account, backend applicativo o telemetria. +- Le assunzioni fiscali 2026 sono centralizzate, testate e documentate con fonti primarie. +- Il codice è pubblico e distribuito con licenza MIT. -![Screenshot della dashboard EasyPIVA con strumenti per simulazioni fiscali Partita IVA 2026](docs/assets/easypiva-dashboard.png) +> I risultati sono stime indicative e non costituiscono consulenza fiscale, legale o contabile. Per decisioni professionali è necessario rivolgersi a un consulente abilitato. -## Branding e packaging +## Funzionalità -- Brand prodotto: `EasyPIVA`. -- Maintainer e autore del repository: `Michael Gasperini (Mikesoft)`. -- Packaging supportato: applicazione web statica buildata con Vite. -- Questo repository non è una VS Code extension: non usa `vsce`, non genera `.vsix` e non richiede icone separate per Activity Bar, sidebar o Marketplace. +- Calcolo del regime forfettario 2026. +- Confronto indicativo tra regime forfettario e ordinario. +- Simulazione dei contributi INPS per Gestione Separata, Artigiani e Commercianti. +- Calcolo inverso del fatturato necessario per raggiungere un netto obiettivo. +- Pianificazione mensile dei ricavi rispetto alle soglie del regime. +- Preventivo locale con anteprima A4, autosalvataggio nel browser ed export PDF. -## Release 1.1.0 +## Attendibilità e limiti -- Allinea i contributi INPS 2026 (minimale, contributi fissi, aliquota aggiuntiva +1% e massimale) alle Circolari INPS 8/2026 e 14/2026. -- Migliora il confronto con il regime ordinario introducendo la detrazione per redditi di lavoro autonomo. -- Aggiorna runtime e toolchain: Node.js 24 LTS, TypeScript 6, ESLint 10, `react-router` 8. -- Elimina la richiesta runtime a Google Fonts self-hostando i font nel bundle, in coerenza con l'architettura local-first. -- Ripulisce le dipendenze di sviluppo non utilizzate e allinea metadata, citazione e documentazione. +EasyPIVA rende verificabili le proprie assunzioni senza presentarsi come sostituto di un professionista: -Il dettaglio completo è nel [changelog](CHANGELOG.md). +- soglie, aliquote e coefficienti sono centralizzati in `src/lib/fiscal-data.ts`; +- la logica fiscale è isolata in funzioni pure sotto `src/lib/calculations/`; +- le assunzioni e le fonti normative sono documentate nell'[ADR fiscale](docs/ADRs/0001-fiscal-assumptions.md) e nell'[informativa pubblica](https://easypiva.vercel.app/informativa); +- test unitari, UI ed end-to-end proteggono i principali casi di regressione; +- ogni cambiamento fiscale richiede aggiornamenti coordinati di codice, test, documentazione e copy pubblica. -## Funzionalità principali +## Privacy -- Calcolatore del regime forfettario 2026. -- Confronto tra regime forfettario e ordinario. -- Simulatore contributi INPS per Gestione Separata, Artigiani e Commercianti. -- Calcolo inverso del fatturato necessario per raggiungere un netto obiettivo. -- Pianificazione mensile dei ricavi rispetto alle soglie del regime. -- Preventivo locale con anteprima A4, autosalvataggio della bozza nel browser ed export PDF. +Il progetto segue un'architettura local-first: non invia a un server i valori inseriti nei simulatori o nel preventivo. Tema, consenso al disclaimer e bozza del preventivo possono essere salvati nel `localStorage` del browser. Il dettaglio è in [Privacy e storage locale](docs/privacy-and-storage.md). + +## Release 1.1.1 + +La versione corrente del repository è `1.1.1`. + +- Corregge la scelta del massimale INPS 2026 per Artigiani e Commercianti in base all'anzianità contributiva. +- Rafforza accessibilità, navigazione da tastiera e gestione del menu mobile. +- Rende più robusti upload del logo, autosalvataggio ed export PDF del preventivo. +- Aggiunge controlli axe end-to-end, metadata per i crawler e aggiornamenti di sicurezza delle dipendenze. + +Il dettaglio completo è nel [changelog](CHANGELOG.md). ## Stack -- React 19, React Router 8, TypeScript 6, Vite 6. +- React 19, React Router 8, TypeScript 6 e Vite 6. - Tailwind CSS v4 e componenti shadcn/ui. -- Zustand per lo stato client-side. -- React Hook Form e Zod per i form. -- Recharts, motion e jsPDF per visualizzazione ed export. +- Zustand, React Hook Form e Zod. +- Recharts, motion e jsPDF. +- Vitest con jsdom e Playwright con Chromium. -## Requisiti locali +## Sviluppo locale -- Node.js 24 LTS e npm 11, in linea con `.nvmrc`, `package.json` e CI. -- npm come package manager canonico. - -## Avvio locale +Richiede Node.js 24 LTS, npm 11 e npm come package manager canonico. ```bash git clone https://github.com/TheStreamCode/easypiva.git @@ -61,20 +69,18 @@ npm ci npm run dev ``` -L'app viene servita in sviluppo su `http://127.0.0.1:3000`. +L'app viene servita su `http://127.0.0.1:3000`. -## Script principali +### Verifiche -- `npm run dev` avvia il server di sviluppo. -- `npm run dev:e2e` avvia Vite su `http://127.0.0.1:4173` per Playwright. -- `npm run typecheck` esegue il controllo TypeScript. -- `npm run lint` esegue ESLint. -- `npm run test` esegue la suite Vitest. -- `npm run test:e2e` esegue la suite Playwright. -- `npm run build` genera la build di produzione in `dist/`. -- `npm run ci` esegue il flusso completo usato dalla CI: format check, typecheck, lint, Vitest, build e Playwright. +```bash +npm run ci +npm audit +``` -Per gli end-to-end in locale può servire una sola volta `npx playwright install chromium`. +`npm run ci` esegue formattazione, typecheck, lint, test Vitest, build di produzione e test Playwright. Se Chromium non è ancora installato, esegui una volta `npx playwright install chromium`. + +Gli script disponibili sono documentati in `package.json`; i principali sono `dev`, `dev:e2e`, `typecheck`, `lint`, `test`, `test:e2e`, `build` e `ci`. ## Struttura del repository @@ -85,53 +91,36 @@ src/ lib/ calculations/ # Logica fiscale pura e testata quote/ # Modello preventivo, paginazione ed export PDF - fiscal-data.ts # Costanti fiscali 2026 (soglie, aliquote, coefficienti) - store/ # Store Zustand (disclaimer, tema) + fiscal-data.ts # Costanti fiscali 2026 + store/ # Store Zustand components/ui/ # Primitivi UI condivisi tests/e2e/ # Test end-to-end Playwright -docs/ # Architettura, privacy, ADR fiscale, governance +docs/ # Architettura, privacy, ADR e governance ``` ## Deployment -- La build statica prodotta da `npm run build` in `dist/` viene pubblicata su Vercel: . -- `vercel.json` applica la rewrite `/(.*) → /index.html` necessaria al routing client-side e gli header HTTP di sicurezza (CSP, anti-framing, MIME sniffing, referrer e permissions policy). -- Non esiste un backend applicativo né una configurazione di deploy alternativa nel repository. +La build statica prodotta in `dist/` viene pubblicata su [Vercel](https://easypiva.vercel.app) dal branch `main`. `vercel.json` gestisce il routing client-side e gli header HTTP di sicurezza. Il progetto non utilizza un backend applicativo né variabili d'ambiente runtime. ## Documentazione - [Architettura](docs/architecture.md) -- [Provenienza dell'asset dashboard](docs/asset-provenance.md) - [Privacy e storage locale](docs/privacy-and-storage.md) - [Assunzioni fiscali](docs/ADRs/0001-fiscal-assumptions.md) - [Governance repository](docs/repository-governance.md) -- [Changelog](CHANGELOG.md) +- [Provenienza degli asset](docs/asset-provenance.md) - [Contribution policy](CONTRIBUTING.md) -- [Code of Conduct](CODE_OF_CONDUCT.md) - [Security policy](SECURITY.md) +- [Code of Conduct](CODE_OF_CONDUCT.md) -## Workflow del repository - -Il repository è pubblico e rilasciato con licenza MIT, ma la manutenzione del codice segue un workflow `maintainers-only`. La policy completa è documentata in [`CONTRIBUTING.md`](CONTRIBUTING.md). - -## Governance GitHub - -- La CI principale è in `.github/workflows/ci.yml` e usa permessi minimi in sola lettura. -- Playwright usa una porta dedicata per evitare collisioni con server locali su `3000`. -- Dependabot apre solo aggiornamenti di sicurezza; gli aggiornamenti di versione ordinari sono disabilitati per evitare rumore e vengono pianificati manualmente. -- Le pull request eseguono anche `Dependency Review` per intercettare vulnerabilità introdotte da cambi di dipendenze. -- Le GitHub Actions sono fissate a commit SHA verificati e gli install script npm sono limitati a una allowlist esplicita. -- Issue e pull request usano template strutturati in `.github/` per rendere il triage riproducibile. -- Le vulnerabilità vanno segnalate privatamente seguendo [`SECURITY.md`](SECURITY.md), non tramite issue pubbliche. - -## Supporta il progetto +## Governance e segnalazioni -Se EasyPIVA ti aiuta nelle simulazioni fiscali o nella pianificazione della Partita IVA, puoi supportare lo sviluppo continuo tramite GitHub Sponsors: [github.com/sponsors/TheStreamCode](https://github.com/sponsors/TheStreamCode). +Il repository è pubblico, ma la manutenzione segue un workflow `maintainers-only`. Sono benvenute issue riproducibili e segnalazioni fiscali corredate da fonti primarie; il repository non fornisce consulenza fiscale o supporto personalizzato. Consulta [CONTRIBUTING.md](CONTRIBUTING.md) prima di aprire una segnalazione e [SECURITY.md](SECURITY.md) per comunicare privatamente una vulnerabilità. -## Disclaimer +La CI usa permessi minimi, le GitHub Actions sono fissate a commit SHA e le pull request eseguono anche Dependency Review. Il branch `main` richiede review, check obbligatori, conversazioni risolte e storia lineare. -I risultati sono stime indicative basate sulle assunzioni fiscali documentate nel repository. Non costituiscono consulenza fiscale, legale o contabile e non sostituiscono il parere di un professionista abilitato. +## Progetto -## Licenza +EasyPIVA è mantenuto da **Michael Gasperini (Mikesoft)**. Se il progetto ti è utile, puoi [supportarne lo sviluppo tramite GitHub Sponsors](https://github.com/sponsors/TheStreamCode). Distribuito sotto licenza [MIT](LICENSE). diff --git a/docs/ADRs/0001-fiscal-assumptions.md b/docs/ADRs/0001-fiscal-assumptions.md index 5fc0257..e86c96a 100644 --- a/docs/ADRs/0001-fiscal-assumptions.md +++ b/docs/ADRs/0001-fiscal-assumptions.md @@ -27,7 +27,8 @@ EasyPIVA fornisce simulazioni fiscali indicative per utenti con Partita IVA ital - Gestione Separata: 26,07% (Circ. INPS 8/2026), entro il massimale di 122.295€. - Artigiani: minimale reddito 18.808€, contributo fisso 4.521,36€, aliquota 24% oltre il minimale. - Commercianti: minimale reddito 18.808€, contributo fisso 4.611,64€, aliquota 24,48% oltre il minimale. -- Aliquota aggiuntiva +1% sul reddito oltre 56.224€ (25% / 25,48%), fino al massimale 122.295€ (Circ. INPS 14/2026). +- Aliquota aggiuntiva +1% sul reddito oltre 56.224€ (25% / 25,48%), fino al massimale applicabile (Circ. INPS 14/2026). +- Massimale Artigiani/Commercianti: 93.707€ per chi possiede anzianità contributiva al 31 dicembre 1995; 122.295€ per chi è privo di anzianità a tale data. L'interfaccia richiede questa informazione e usa il secondo caso come default compatibile con le simulazioni precedenti. - Riduzione 35% per forfettari (artigiani/commercianti). ## Regime Ordinario (confronto) diff --git a/docs/asset-provenance.md b/docs/asset-provenance.md index 1836867..eea8bec 100644 --- a/docs/asset-provenance.md +++ b/docs/asset-provenance.md @@ -9,6 +9,14 @@ La cronologia del repository non documenta ulteriori informazioni sull'origine dell'immagine. +## `docs/assets/easypiva-social-preview.png` + +- Cover PNG creata per la presentazione del repository e per la social preview GitHub, senza modificare l'asset dashboard esistente. +- Generata il 2026-08-08 con lo strumento ImageGen integrato di OpenAI, usando la dashboard esclusivamente come riferimento per palette, wordmark e linguaggio visivo. +- Il prompt richiedeva una composizione editoriale minimalista, il payoff prudente “Simulazioni fiscali indicative per la Partita IVA italiana” e l'assenza di claim di precisione o consulenza. +- Ridimensionata deterministicamente a 1280 × 640 px; dimensione file: 751.341 byte. +- SHA-256 del file corrente: `2d5f0c1d291d8211e52593732878c0bd6d46895ba7d0c923e110a7c4bd2377c9`. + ## Titolarità e riferimenti di terzi I maintainer rivendicano titolarità esclusivamente sui materiali originali EasyPIVA. La licenza MIT disciplina esclusivamente tali materiali originali messi a disposizione nel repository; questo record non attribuisce titolarità né una licenza separata all'immagine della dashboard. diff --git a/docs/assets/easypiva-social-preview.png b/docs/assets/easypiva-social-preview.png new file mode 100644 index 0000000..b1de457 Binary files /dev/null and b/docs/assets/easypiva-social-preview.png differ diff --git a/package-lock.json b/package-lock.json index 2b9741a..7b6077a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "easypiva", - "version": "1.1.0", + "version": "1.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "easypiva", - "version": "1.1.0", + "version": "1.1.1", "license": "MIT", "dependencies": { "@base-ui/react": "^1.6.0", @@ -32,6 +32,7 @@ "zustand": "^5.0.14" }, "devDependencies": { + "@axe-core/playwright": "^4.12.1", "@eslint/js": "^10.0.1", "@playwright/test": "^1.60.0", "@testing-library/dom": "^10.4.1", @@ -120,6 +121,19 @@ "dev": true, "license": "MIT" }, + "node_modules/@axe-core/playwright": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.12.1.tgz", + "integrity": "sha512-rMd7xriptqKpP+w5265i4Hdkv2X5kbu6uiBi/B2I7uf3hieRBM3qDCfaKPtxfiYb2mKXfF+yLODJwIx+Jv1GDw==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "axe-core": "~4.12.1" + }, + "peerDependencies": { + "playwright-core": ">= 1.0.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -3795,6 +3809,16 @@ "node": ">=10.12.0" } }, + "node_modules/axe-core": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.12.1.tgz", + "integrity": "sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -3876,9 +3900,9 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", - "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { @@ -4699,9 +4723,9 @@ "license": "MIT" }, "node_modules/dompurify": { - "version": "3.4.12", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.12.tgz", - "integrity": "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==", + "version": "3.4.13", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz", + "integrity": "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==", "license": "(MPL-2.0 OR Apache-2.0)", "optional": true, "optionalDependencies": { @@ -5511,9 +5535,9 @@ } }, "node_modules/fast-uri": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", - "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", "dev": true, "funding": [ { @@ -5948,9 +5972,9 @@ } }, "node_modules/hono": { - "version": "4.12.32", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.32.tgz", - "integrity": "sha512-XcuyW9qE2kJn07PkecMOBd5Vq/hMy7mmGw+idz1yblbg9N17ijJODrvPkn7/dwL3Kulj8LcRJ69DLOWf91dRUg==", + "version": "4.13.1", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.1.tgz", + "integrity": "sha512-kdJoFVv2xmayw6cY09H7AbMJMt8Jn5jdlEdXsP7AGBdF2DIptVlKlOLKXP41yPip4/a3yQPv9gVcJYI8YY04dw==", "dev": true, "license": "MIT", "engines": { @@ -6379,9 +6403,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", - "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", "dev": true, "funding": [ { @@ -7136,9 +7160,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.16", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", - "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "funding": [ { "type": "github", @@ -9169,9 +9193,9 @@ } }, "node_modules/undici": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", - "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", "dev": true, "license": "MIT", "engines": { diff --git a/package.json b/package.json index a92421f..711d5cb 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "easypiva", "private": true, - "version": "1.1.0", + "version": "1.1.1", "type": "module", "packageManager": "npm@11.16.0", "engines": { @@ -71,6 +71,7 @@ "zustand": "^5.0.14" }, "devDependencies": { + "@axe-core/playwright": "^4.12.1", "@eslint/js": "^10.0.1", "@playwright/test": "^1.60.0", "@testing-library/dom": "^10.4.1", diff --git a/public/llms.txt b/public/llms.txt new file mode 100644 index 0000000..d8e7b41 --- /dev/null +++ b/public/llms.txt @@ -0,0 +1,15 @@ +# EasyPIVA + +EasyPIVA è una web app open source e local-first per simulazioni fiscali indicative dedicate alla Partita IVA italiana. Non offre consulenza fiscale, legale o contabile. + +## Pagine principali + +- [Dashboard](https://easypiva.vercel.app/): panoramica degli strumenti. +- [Calcolatore forfettario](https://easypiva.vercel.app/calcolatore): stima imposta sostitutiva, contributi e netto. +- [Confronto regimi](https://easypiva.vercel.app/confronto): confronto semplificato tra forfettario e ordinario. +- [Contributi INPS](https://easypiva.vercel.app/contributi): simulazione 2026 per Gestione Separata, Artigiani e Commercianti. +- [Informativa e fonti](https://easypiva.vercel.app/informativa): disclaimer, privacy, storage locale e riferimenti normativi. + +## Codice sorgente + +- [Repository GitHub](https://github.com/TheStreamCode/easypiva) diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..33502c6 --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,4 @@ +User-agent: * +Allow: / + +Sitemap: https://easypiva.vercel.app/sitemap.xml diff --git a/public/sitemap.xml b/public/sitemap.xml new file mode 100644 index 0000000..537a64e --- /dev/null +++ b/public/sitemap.xml @@ -0,0 +1,11 @@ + + + https://easypiva.vercel.app/ + https://easypiva.vercel.app/calcolatore + https://easypiva.vercel.app/confronto + https://easypiva.vercel.app/contributi + https://easypiva.vercel.app/quanto-fatturare + https://easypiva.vercel.app/pianificazione + https://easypiva.vercel.app/preventivo + https://easypiva.vercel.app/informativa + diff --git a/src/App.tsx b/src/App.tsx index 426506c..eb829f4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,9 +2,9 @@ import { lazy, Suspense } from 'react'; import { BrowserRouter as Router, Routes, Route } from 'react-router'; import Layout from './components/Layout'; import NotFound from './pages/NotFound'; +import Home from './pages/Home'; import { DisclaimerModal } from './components/DisclaimerModal'; -const Home = lazy(() => import('./pages/Home')); const Calculator = lazy(() => import('./pages/Calculator')); const Comparison = lazy(() => import('./pages/Comparison')); const Contributions = lazy(() => import('./pages/Contributions')); @@ -23,14 +23,7 @@ export default function App() { }> - }> - - - } - /> + } /> void; +}) { + return ( +
+
+ +

+ Determina il massimale annuo per Artigiani e Commercianti. +

+
+ +
+ ); +} diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index e6392f7..e193da1 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -14,12 +14,13 @@ import { Home, Github, } from 'lucide-react'; -import { useState, type MouseEvent } from 'react'; +import { useEffect, useState, type MouseEvent } from 'react'; import { flushSync } from 'react-dom'; import { Button } from '@/components/ui/button'; import { motion, AnimatePresence } from 'motion/react'; import { getThemeRevealRadius } from '@/lib/theme'; import { useThemeStore } from '@/store/useThemeStore'; +import { Dialog, DialogClose, DialogContent, DialogTitle } from '@/components/ui/dialog'; const navigation = [ { name: 'Dashboard', href: '/', icon: Home }, @@ -50,6 +51,11 @@ export default function Layout() { const { mode, toggleThemeMode } = useThemeStore(); const isDark = mode === 'dark'; + useEffect(() => { + const currentPage = navigation.find((item) => item.href === location.pathname)?.name; + document.title = currentPage ? `${currentPage} | EasyPIVA` : 'Pagina non trovata | EasyPIVA'; + }, [location.pathname]); + const toggleTheme = (event: MouseEvent) => { const prefersReducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches; @@ -81,61 +87,51 @@ export default function Layout() { return (
+ + Vai al contenuto principale + {/* Mobile sidebar */} - - {sidebarOpen && ( - setSidebarOpen(false)} - > - e.stopPropagation()} + + + Menu di navigazione +
+ + } > -
- -
+
- -
-
- )} -
+ + {item.name} + + ); + })} + + + {/* Desktop sidebar */}
@@ -200,6 +196,8 @@ export default function Layout() {
diff --git a/src/components/quote/QuoteForm.tsx b/src/components/quote/QuoteForm.tsx index c5cd71f..b361b13 100644 --- a/src/components/quote/QuoteForm.tsx +++ b/src/components/quote/QuoteForm.tsx @@ -1,4 +1,4 @@ -import { useRef, type ChangeEvent, type ReactNode } from 'react'; +import { useRef, useState, type ChangeEvent, type ReactNode } from 'react'; import { useFieldArray, useFormContext } from 'react-hook-form'; import { Button } from '@/components/ui/button'; @@ -13,6 +13,7 @@ import { } from '@/components/ui/select'; import { quotePlaceholder } from '@/lib/quote/placeholders'; import type { QuoteVatMode } from '@/lib/quote/types'; +import { ALLOWED_LOGO_MIME_TYPES, isSafeLogoDataUrl } from '@/lib/quote/logo'; import { Plus, Trash2, Upload } from 'lucide-react'; import type { QuoteFormValues } from './quoteFormSchema'; @@ -81,6 +82,7 @@ export function QuoteForm({ const { fields, append, remove } = useFieldArray({ name: 'lineItems' }); const fileInputRef = useRef(null); const itemIdRef = useRef(100); + const [logoError, setLogoError] = useState(null); const vatMode = watch('vatMode') as QuoteVatMode; const logoDataUrl = watch('logoDataUrl'); @@ -91,26 +93,35 @@ export function QuoteForm({ const file = event.target.files?.[0]; if (!file) return; + setLogoError(null); + if (!ALLOWED_LOGO_MIME_TYPES.includes(file.type as (typeof ALLOWED_LOGO_MIME_TYPES)[number])) { + setLogoError('Formato non supportato. Usa un file PNG, JPEG o WebP.'); + event.target.value = ''; + return; + } + if (file.size > MAX_LOGO_FILE_SIZE_BYTES) { - setValue('logoDataUrl', '', { shouldValidate: false }); - if (fileInputRef.current) { - fileInputRef.current.value = ''; - } + setLogoError('Il logo supera il limite massimo di 1 MB.'); + event.target.value = ''; return; } const reader = new FileReader(); reader.onload = () => { const result = reader.result; - if (typeof result === 'string') { + if (typeof result === 'string' && isSafeLogoDataUrl(result)) { setValue('logoDataUrl', result, { shouldValidate: false }); + } else { + setLogoError('Impossibile leggere il logo selezionato.'); } }; + reader.onerror = () => setLogoError('Impossibile leggere il logo selezionato.'); reader.readAsDataURL(file); }; const handleRemoveLogo = () => { setValue('logoDataUrl', '', { shouldValidate: false }); + setLogoError(null); if (fileInputRef.current) { fileInputRef.current.value = ''; } @@ -228,19 +239,24 @@ export function QuoteForm({
) : null} -
+ {logoError ? ( +

+ {logoError} +

+ ) : null} diff --git a/src/components/quote/QuoteItemsTable.tsx b/src/components/quote/QuoteItemsTable.tsx index ebb33ee..5936b94 100644 --- a/src/components/quote/QuoteItemsTable.tsx +++ b/src/components/quote/QuoteItemsTable.tsx @@ -32,7 +32,7 @@ export function QuoteItemsTable({ return (
{repeatedHeader ? ( -

+

Dettaglio voci - continua

) : null} @@ -67,7 +67,7 @@ export function QuoteItemsTable({
{row.description} {row.continuation ? ( - + continua ) : null} diff --git a/src/components/quote/QuotePage.tsx b/src/components/quote/QuotePage.tsx index c5116a3..20b4a58 100644 --- a/src/components/quote/QuotePage.tsx +++ b/src/components/quote/QuotePage.tsx @@ -9,7 +9,7 @@ type QuotePageProps = { export function QuotePage({ children, pageNumber, totalPages }: QuotePageProps) { return (
-
+
Pagina {pageNumber} / {totalPages}
@@ -19,7 +19,7 @@ export function QuotePage({ children, pageNumber, totalPages }: QuotePageProps)
Documento generato con EasyPIVA by Mikesoft.it - EasyPIVA + EasyPIVA
); diff --git a/src/components/quote/__tests__/QuoteForm.test.tsx b/src/components/quote/__tests__/QuoteForm.test.tsx index 9071980..a41957a 100644 --- a/src/components/quote/__tests__/QuoteForm.test.tsx +++ b/src/components/quote/__tests__/QuoteForm.test.tsx @@ -208,7 +208,7 @@ describe('QuoteForm', () => { const fileInput = document.querySelector('input[type="file"]'); expect(fileInput).toBeInTheDocument(); - expect(fileInput).toHaveAttribute('accept', 'image/*'); + expect(fileInput).toHaveAttribute('accept', 'image/png,image/jpeg,image/webp'); }); it('starts with one line item row', () => { @@ -363,6 +363,26 @@ describe('QuoteForm', () => { globalThis.FileReader = originalFileReader; }); + it('rejects active image formats with an accessible error', async () => { + render(); + + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; + const file = new File([''], 'logo.svg', { type: 'image/svg+xml' }); + fireEvent.change(fileInput, { target: { files: [file] } }); + + expect(await screen.findByRole('alert')).toHaveTextContent(/formato non supportato/i); + }); + + it('reports logos larger than 1 MB without removing an existing logo', async () => { + render(); + + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; + const file = new File([new Uint8Array(1_000_001)], 'large.png', { type: 'image/png' }); + fireEvent.change(fileInput, { target: { files: [file] } }); + + expect(await screen.findByRole('alert')).toHaveTextContent(/limite massimo di 1 MB/i); + }); + it('does not break form assumptions when optional sections are empty', () => { const onSubmit = vi.fn(); render(); diff --git a/src/components/quote/quoteFormSchema.ts b/src/components/quote/quoteFormSchema.ts index 906a990..73a136a 100644 --- a/src/components/quote/quoteFormSchema.ts +++ b/src/components/quote/quoteFormSchema.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { isSafeLogoDataUrl } from '@/lib/quote/logo'; export const quoteFormSchema = z.object({ providerName: z.string().min(1, 'Ragione sociale obbligatoria'), @@ -39,7 +40,7 @@ export const quoteFormSchema = z.object({ paymentInstructions: z.string(), causale: z.string(), notes: z.string(), - logoDataUrl: z.string(), + logoDataUrl: z.string().refine(isSafeLogoDataUrl, 'Formato logo non supportato'), }); export type QuoteFormValues = z.infer; diff --git a/src/components/quote/renderPageContent.tsx b/src/components/quote/renderPageContent.tsx index c079e41..8e3f9e5 100644 --- a/src/components/quote/renderPageContent.tsx +++ b/src/components/quote/renderPageContent.tsx @@ -54,7 +54,7 @@ export function renderPageContent(
)}
-

+

Preventivo

diff --git a/src/components/quote/renderPageContentParts.tsx b/src/components/quote/renderPageContentParts.tsx index 6dc31c4..fe05dbd 100644 --- a/src/components/quote/renderPageContentParts.tsx +++ b/src/components/quote/renderPageContentParts.tsx @@ -79,11 +79,11 @@ export function QuoteMetaBlock({ quote }: { quote: QuoteDraft }) {

-

Oggetto

+

Oggetto

{quote.title}

-

Validita offerta

+

Validita offerta

{quote.offerValidity || '-'}

diff --git a/src/lib/__tests__/local-date.test.ts b/src/lib/__tests__/local-date.test.ts new file mode 100644 index 0000000..0b62e79 --- /dev/null +++ b/src/lib/__tests__/local-date.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from 'vitest'; +import { formatLocalDateInput } from '../local-date'; + +describe('formatLocalDateInput', () => { + it('uses the local calendar date instead of UTC', () => { + const date = new Date(2026, 7, 8, 23, 59, 59); + + expect(formatLocalDateInput(date)).toBe('2026-08-08'); + }); +}); diff --git a/src/lib/calculations/__tests__/comparison.test.ts b/src/lib/calculations/__tests__/comparison.test.ts index 2b220b9..d1e58b0 100644 --- a/src/lib/calculations/__tests__/comparison.test.ts +++ b/src/lib/calculations/__tests__/comparison.test.ts @@ -29,6 +29,22 @@ describe('compareRegimes', () => { expect(result.ordinario.inps).toBeCloseTo(9607.44, 2); }); + it('propagates contribution seniority to the ordinary-regime INPS estimate', () => { + const baseInput = { + ricavi: 150000, + costiReali: 0, + atecoId: '8', + nuovaAttivita: false, + tipoInps: 'commercianti' as const, + riduzioneInps: false, + }; + + const pre1996 = compareRegimes({ ...baseInput, contributionHistory: 'pre1996' }); + const post1995 = compareRegimes({ ...baseInput, contributionHistory: 'post1995' }); + + expect(pre1996.ordinario.inps).toBeLessThan(post1995.ordinario.inps); + }); + it('marks the forfettario as unavailable above the immediate exit threshold', () => { const result = compareRegimes({ ricavi: 100001, diff --git a/src/lib/calculations/__tests__/inps.test.ts b/src/lib/calculations/__tests__/inps.test.ts index c463e54..2159f0d 100644 --- a/src/lib/calculations/__tests__/inps.test.ts +++ b/src/lib/calculations/__tests__/inps.test.ts @@ -35,7 +35,7 @@ describe('calculateInps', () => { expect(result.totale).toBeCloseTo(16945.2, 2); }); - it('caps the taxable income at the massimale (122.295 €)', () => { + it('defaults to the post-1995 massimale (122.295 €)', () => { const result = calculateInps(130000, 'commercianti'); // income capped at 122295: base band 24,48% + surcharge band 25,48% @@ -43,6 +43,14 @@ describe('calculateInps', () => { expect(result.totale).toBeCloseTo(30605.9676, 2); }); + it('uses the lower massimale for contribution seniority at 31 December 1995', () => { + const result = calculateInps(130000, 'commercianti', false, 'pre1996'); + + // income capped at 93,707: base band 24.48% + surcharge band 25.48% + expect(result.variabile).toBeCloseTo(18710.1052, 2); + expect(result.totale).toBeCloseTo(23321.7452, 2); + }); + it('caps Gestione Separata at the massimale', () => { const result = calculateInps(150000, 'gestioneSeparata'); diff --git a/src/lib/calculations/comparison.ts b/src/lib/calculations/comparison.ts index ebcaa0b..60b826e 100644 --- a/src/lib/calculations/comparison.ts +++ b/src/lib/calculations/comparison.ts @@ -59,6 +59,7 @@ export function compareRegimes(input: ComparisonInput): ComparisonResult { nuovaAttivita: input.nuovaAttivita, tipoInps, riduzioneInps, + contributionHistory: input.contributionHistory, speseDipendenti: 0, redditoDipendente: 0, }); @@ -67,7 +68,7 @@ export function compareRegimes(input: ComparisonInput): ComparisonResult { const nettoForf = forfettarioResult.nettoStimato; const redditoLordoOrd = Math.max(0, input.ricavi - input.costiReali); - const inpsOrd = calculateInps(redditoLordoOrd, tipoInps, false).totale; + const inpsOrd = calculateInps(redditoLordoOrd, tipoInps, false, input.contributionHistory).totale; const imponibileOrd = Math.max(0, redditoLordoOrd - inpsOrd); const irpefLordaOrd = calculateIrpef(imponibileOrd); const detrazioneOrd = calcolaDetrazioneLavoroAutonomo(imponibileOrd); diff --git a/src/lib/calculations/forfettario.ts b/src/lib/calculations/forfettario.ts index 66f0025..f59b973 100644 --- a/src/lib/calculations/forfettario.ts +++ b/src/lib/calculations/forfettario.ts @@ -6,7 +6,12 @@ export function calculateForfettario(input: ForfettarioInput): ForfettarioResult const coefficiente = getAtecoCoefficient(input.atecoId) / 100; const ricaviRagguagliati = input.mesiAttivita > 0 ? (input.ricavi / input.mesiAttivita) * 12 : 0; const redditoLordo = input.ricavi * coefficiente; - const inps = calculateInps(redditoLordo, input.tipoInps, input.riduzioneInps); + const inps = calculateInps( + redditoLordo, + input.tipoInps, + input.riduzioneInps, + input.contributionHistory, + ); const contributiConsiderati = input.contributiVersati > 0 ? input.contributiVersati : inps.totale; const redditoNettoImponibile = Math.max(0, redditoLordo - contributiConsiderati); const aliquotaImposta = input.nuovaAttivita ? 0.05 : 0.15; diff --git a/src/lib/calculations/inps.ts b/src/lib/calculations/inps.ts index e256918..cdb68fc 100644 --- a/src/lib/calculations/inps.ts +++ b/src/lib/calculations/inps.ts @@ -1,10 +1,11 @@ -import { INPS_RATES, type InpsType } from '../fiscal-data'; +import { INPS_RATES, type ContributionHistory, type InpsType } from '../fiscal-data'; import type { InpsCalculation } from './types'; export function calculateInps( redditoLordo: number, tipoInps: InpsType, riduzioneInps = false, + contributionHistory: ContributionHistory = 'post1995', ): InpsCalculation { const imponibile = Math.max(0, redditoLordo); @@ -20,12 +21,14 @@ export function calculateInps( } const rates = INPS_RATES[tipoInps]; + const massimale = + contributionHistory === 'pre1996' ? rates.massimalePre1996 : rates.massimalePost1995; const riduzione = riduzioneInps ? 0.65 : 1; const fisso = rates.minimalContribution * riduzione; // Il contributo variabile si calcola sull'eccedenza oltre il minimale, fino al massimale. // Oltre la soglia surchargeThreshold l'aliquota IVS aumenta di surchargeRate (+1%). - const cappedIncome = Math.min(imponibile, rates.massimale); + const cappedIncome = Math.min(imponibile, massimale); let variabile = 0; if (cappedIncome > rates.minimalIncome) { const baseBand = Math.min(cappedIncome, rates.surchargeThreshold) - rates.minimalIncome; diff --git a/src/lib/calculations/targetNet.ts b/src/lib/calculations/targetNet.ts index 085b92c..a99f5e2 100644 --- a/src/lib/calculations/targetNet.ts +++ b/src/lib/calculations/targetNet.ts @@ -10,9 +10,10 @@ function calculateNetForRevenue( aliquotaImposta: number, tipoInps: TargetNetInput['tipoInps'], riduzioneInps: boolean, + contributionHistory: TargetNetInput['contributionHistory'], ) { const redditoLordo = ricavi * coefficiente; - const inps = calculateInps(redditoLordo, tipoInps, riduzioneInps); + const inps = calculateInps(redditoLordo, tipoInps, riduzioneInps, contributionHistory); const tasse = Math.max(0, redditoLordo - inps.totale) * aliquotaImposta; return { @@ -70,6 +71,7 @@ export function calculateTargetNet(input: TargetNetInput): TargetNetResult { aliquotaImposta, input.tipoInps, input.riduzioneInps, + input.contributionHistory, ); return { @@ -86,8 +88,14 @@ export function calculateTargetNet(input: TargetNetInput): TargetNetResult { let high = Math.max(nettoAnnuo, 1); while ( - calculateNetForRevenue(high, coefficiente, aliquotaImposta, input.tipoInps, input.riduzioneInps) - .netto < nettoAnnuo + calculateNetForRevenue( + high, + coefficiente, + aliquotaImposta, + input.tipoInps, + input.riduzioneInps, + input.contributionHistory, + ).netto < nettoAnnuo ) { high *= 2; } @@ -100,6 +108,7 @@ export function calculateTargetNet(input: TargetNetInput): TargetNetResult { aliquotaImposta, input.tipoInps, input.riduzioneInps, + input.contributionHistory, ); if (detail.netto < nettoAnnuo) { @@ -116,6 +125,7 @@ export function calculateTargetNet(input: TargetNetInput): TargetNetResult { aliquotaImposta, input.tipoInps, input.riduzioneInps, + input.contributionHistory, ); return { diff --git a/src/lib/calculations/types.ts b/src/lib/calculations/types.ts index 83c1c6c..dfdce05 100644 --- a/src/lib/calculations/types.ts +++ b/src/lib/calculations/types.ts @@ -1,4 +1,4 @@ -import type { InpsType } from '../fiscal-data'; +import type { ContributionHistory, InpsType } from '../fiscal-data'; export type WarningSeverity = 'warning' | 'critical'; @@ -21,6 +21,7 @@ export type ForfettarioInput = { nuovaAttivita: boolean; tipoInps: InpsType; riduzioneInps: boolean; + contributionHistory?: ContributionHistory; speseDipendenti: number; redditoDipendente: number; }; @@ -53,6 +54,7 @@ export type ComparisonInput = { nuovaAttivita: boolean; tipoInps?: InpsType; riduzioneInps?: boolean; + contributionHistory?: ContributionHistory; }; export type RegimeResult = { @@ -77,6 +79,7 @@ export type TargetNetInput = { nuovaAttivita: boolean; tipoInps: InpsType; riduzioneInps: boolean; + contributionHistory?: ContributionHistory; }; export type TargetNetResult = { diff --git a/src/lib/fiscal-data.ts b/src/lib/fiscal-data.ts index 0fb1e56..02564b2 100644 --- a/src/lib/fiscal-data.ts +++ b/src/lib/fiscal-data.ts @@ -5,6 +5,7 @@ export type AtecoCategory = { }; export type InpsType = 'gestioneSeparata' | 'artigiani' | 'commercianti' | 'nessuno'; +export type ContributionHistory = 'pre1996' | 'post1995'; export const ATECO_CATEGORIES: AtecoCategory[] = [ { id: '1', name: 'Industrie alimentari e delle bevande (10-11)', coefficient: 40 }, @@ -32,7 +33,7 @@ export const ATECO_CATEGORIES: AtecoCategory[] = [ // Valori 2026 — Circ. INPS 8/2026 (Gestione Separata) e 14/2026 (Artigiani/Commercianti). // surchargeThreshold: oltre questa soglia di reddito l'aliquota IVS sale di +1% (25% / 25,48%). -// massimale: reddito massimo annuo imponibile (iscritti dal 1996). +// I massimali Artigiani/Commercianti dipendono dall'anzianità contributiva al 31/12/1995. export const INPS_RATES = { gestioneSeparata: { rate: 0.2607, @@ -44,7 +45,8 @@ export const INPS_RATES = { rateOverMinimal: 0.24, surchargeThreshold: 56224, surchargeRate: 0.01, - massimale: 122295, + massimalePre1996: 93707, + massimalePost1995: 122295, }, commercianti: { minimalIncome: 18808, @@ -52,7 +54,8 @@ export const INPS_RATES = { rateOverMinimal: 0.2448, surchargeThreshold: 56224, surchargeRate: 0.01, - massimale: 122295, + massimalePre1996: 93707, + massimalePost1995: 122295, }, } as const; diff --git a/src/lib/local-date.ts b/src/lib/local-date.ts new file mode 100644 index 0000000..ef44792 --- /dev/null +++ b/src/lib/local-date.ts @@ -0,0 +1,7 @@ +export function formatLocalDateInput(date = new Date()) { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + + return `${year}-${month}-${day}`; +} diff --git a/src/lib/quote/__tests__/logo.test.ts b/src/lib/quote/__tests__/logo.test.ts new file mode 100644 index 0000000..1ec7078 --- /dev/null +++ b/src/lib/quote/__tests__/logo.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest'; +import { isSafeLogoDataUrl } from '../logo'; + +describe('isSafeLogoDataUrl', () => { + it('accepts empty and raster image data URLs', () => { + expect(isSafeLogoDataUrl('')).toBe(true); + expect(isSafeLogoDataUrl('data:image/png;base64,aGVsbG8=')).toBe(true); + expect(isSafeLogoDataUrl('data:image/jpeg;base64,aGVsbG8=')).toBe(true); + expect(isSafeLogoDataUrl('data:image/webp;base64,aGVsbG8=')).toBe(true); + }); + + it('rejects SVG and non-image data URLs', () => { + expect(isSafeLogoDataUrl('data:image/svg+xml;base64,PHN2Zz4=')).toBe(false); + expect(isSafeLogoDataUrl('data:text/html;base64,PGgxPkJvb208L2gxPg==')).toBe(false); + }); +}); diff --git a/src/lib/quote/logo.ts b/src/lib/quote/logo.ts new file mode 100644 index 0000000..73b9e8a --- /dev/null +++ b/src/lib/quote/logo.ts @@ -0,0 +1,7 @@ +export const ALLOWED_LOGO_MIME_TYPES = ['image/png', 'image/jpeg', 'image/webp'] as const; + +const SAFE_LOGO_DATA_URL = /^data:image\/(?:png|jpeg|webp);base64,[a-z0-9+/=]+$/i; + +export function isSafeLogoDataUrl(value: string) { + return value === '' || SAFE_LOGO_DATA_URL.test(value); +} diff --git a/src/pages/Calculator.tsx b/src/pages/Calculator.tsx index b636d9f..8b334b9 100644 --- a/src/pages/Calculator.tsx +++ b/src/pages/Calculator.tsx @@ -30,6 +30,7 @@ import { calculateForfettario } from '@/lib/calculations'; import { formatCurrency } from '@/lib/format'; import { parseNonNegativeNumber } from '@/lib/number-input'; import { warningCopy } from '@/lib/public-copy'; +import { ContributionHistorySelect } from '../components/ContributionHistorySelect'; const formSchema = z.object({ atecoId: z.string().min(1, 'Seleziona una categoria ATECO'), ricavi: z.number().min(0, 'I ricavi non possono essere negativi'), @@ -40,6 +41,7 @@ const formSchema = z.object({ redditoDipendente: z.number().min(0), tipoInps: z.enum(['gestioneSeparata', 'artigiani', 'commercianti', 'nessuno']), riduzioneInps: z.boolean().optional(), + contributionHistory: z.enum(['pre1996', 'post1995']).optional(), }); type FormValues = z.infer; export default function Calculator() { @@ -57,6 +59,7 @@ export default function Calculator() { redditoDipendente: 0, tipoInps: 'gestioneSeparata', riduzioneInps: false, + contributionHistory: 'post1995', }, }); const { watch, setValue, trigger } = form; @@ -66,7 +69,12 @@ export default function Calculator() { if (step === 1) { valid = await trigger(['atecoId', 'ricavi', 'mesiAttivita', 'nuovaAttivita']); } else if (step === 2) { - valid = await trigger(['tipoInps', 'riduzioneInps', 'contributiVersati']); + valid = await trigger([ + 'tipoInps', + 'riduzioneInps', + 'contributionHistory', + 'contributiVersati', + ]); } else if (step === 3) { valid = await trigger(['speseDipendenti', 'redditoDipendente']); } @@ -299,24 +307,30 @@ export default function Calculator() { {' '}

{' '} {(values.tipoInps === 'artigiani' || values.tipoInps === 'commercianti') && ( -
- {' '} -
+
+ setValue('contributionHistory', value)} + /> +
{' '} - {' '} -

+

{' '} - Hai richiesto la riduzione per forfettari?{' '} -

{' '} -
{' '} - setValue('riduzioneInps', checked)} - />{' '} + {' '} +

+ {' '} + Hai richiesto la riduzione per forfettari?{' '} +

{' '} +
{' '} + setValue('riduzioneInps', checked)} + />{' '} +
)}{' '}
@@ -507,7 +521,11 @@ export default function Calculator() { {' '} Ripartizione{' '} {' '} -
+
{' '} ('gestioneSeparata'); const [riduzioneInps, setRiduzioneInps] = useState(false); + const [contributionHistory, setContributionHistory] = useState('post1995'); const result = compareRegimes({ ricavi, @@ -41,6 +43,7 @@ export default function Comparison() { nuovaAttivita, tipoInps, riduzioneInps, + contributionHistory, }); const chartData = [ { @@ -142,20 +145,26 @@ export default function Comparison() {
{(tipoInps === 'artigiani' || tipoInps === 'commercianti') && ( -
-
- -

- Applicabile su richiesta per i forfettari. -

-
- + +
+
+ +

+ Applicabile su richiesta per i forfettari. +

+
+ +
)} diff --git a/src/pages/Contributions.tsx b/src/pages/Contributions.tsx index f195f62..48f1be2 100644 --- a/src/pages/Contributions.tsx +++ b/src/pages/Contributions.tsx @@ -13,14 +13,16 @@ import { motion } from 'motion/react'; import { calculateInps } from '@/lib/calculations'; import { formatCurrency } from '@/lib/format'; import { parseNonNegativeNumber } from '@/lib/number-input'; -import type { InpsType } from '@/lib/fiscal-data'; +import type { ContributionHistory, InpsType } from '@/lib/fiscal-data'; +import { ContributionHistorySelect } from '../components/ContributionHistorySelect'; export default function Contributions() { const [redditoLordo, setRedditoLordo] = useState(30000); const [tipoInps, setTipoInps] = useState('gestioneSeparata'); const [riduzioneInps, setRiduzioneInps] = useState(false); + const [contributionHistory, setContributionHistory] = useState('post1995'); - const result = calculateInps(redditoLordo, tipoInps, riduzioneInps); + const result = calculateInps(redditoLordo, tipoInps, riduzioneInps, contributionHistory); const containerVariants = { hidden: { opacity: 0 }, @@ -90,18 +92,24 @@ export default function Contributions() {
{(tipoInps === 'artigiani' || tipoInps === 'commercianti') && ( -
-
- -

Solo per regime forfettario

-
- + +
+
+ +

Solo per regime forfettario

+
+ +
)}
diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index 8750e4b..a31564b 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -66,8 +66,8 @@ export default function Home() { transition={{ duration: 0.5, ease: 'easeOut' }} className="text-4xl md:text-6xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-50 max-w-3xl text-balance" > - Simulazioni fiscali precise e aggiornate,
- + Simulazioni fiscali indicative e aggiornate,
+ gratuito, senza registrazione, open source. @@ -75,7 +75,7 @@ export default function Home() {
-

+

Strumenti disponibili

@@ -119,7 +119,7 @@ export default function Home() {

Novità 2026

-

+

Ultimi aggiornamenti normativi integrati nei calcolatori.

diff --git a/src/pages/QuoteBuilder.tsx b/src/pages/QuoteBuilder.tsx index ddc909d..eba0e78 100644 --- a/src/pages/QuoteBuilder.tsx +++ b/src/pages/QuoteBuilder.tsx @@ -5,6 +5,8 @@ import { Download } from 'lucide-react'; import { motion } from 'motion/react'; import { Button } from '@/components/ui/button'; +import { formatLocalDateInput } from '@/lib/local-date'; +import { isSafeLogoDataUrl } from '@/lib/quote/logo'; import { QuoteForm, type QuoteFormValues } from '../components/quote/QuoteForm'; import { QuotePreview } from '../components/quote/QuotePreview'; import { QuoteExportPages } from '../components/quote/QuoteExportPages'; @@ -35,7 +37,7 @@ function defaultFormValues(): QuoteFormValues { clientTaxCode: '', clientEmail: '', quoteNumber: '', - issueDate: new Date().toISOString().slice(0, 10), + issueDate: formatLocalDateInput(), title: '', offerValidity: '', deliveryTiming: '', @@ -123,7 +125,9 @@ function sanitizeDraft(input: Partial): QuoteFormValues { paymentInstructions: String(input.paymentInstructions ?? defaults.paymentInstructions), causale: String(input.causale ?? defaults.causale), notes: String(input.notes ?? defaults.notes), - logoDataUrl: String(input.logoDataUrl ?? defaults.logoDataUrl), + logoDataUrl: isSafeLogoDataUrl(String(input.logoDataUrl ?? '')) + ? String(input.logoDataUrl ?? '') + : defaults.logoDataUrl, }; } @@ -179,7 +183,9 @@ function formValuesToDraft(values: QuoteFormValues): QuoteDraft { export default function QuoteBuilder() { const [isExporting, setIsExporting] = useState(false); const [hasDraftSaveError, setHasDraftSaveError] = useState(false); + const [exportError, setExportError] = useState(null); const debounceRef = useRef | null>(null); + const pendingDraftRef = useRef(null); const form = useForm({ resolver: zodResolver(quoteFormSchema), @@ -193,6 +199,7 @@ export default function QuoteBuilder() { // Persist draft on change (debounced) useEffect(() => { const subscription = form.watch((values) => { + pendingDraftRef.current = values; if (debounceRef.current) { clearTimeout(debounceRef.current); } @@ -200,6 +207,7 @@ export default function QuoteBuilder() { debounceRef.current = setTimeout(() => { const didSave = writeStorageItem(STORAGE_KEY, JSON.stringify(values)); setHasDraftSaveError(!didSave); + pendingDraftRef.current = null; }, DEBOUNCE_MS); }); @@ -208,6 +216,10 @@ export default function QuoteBuilder() { clearTimeout(debounceRef.current); } + if (pendingDraftRef.current) { + writeStorageItem(STORAGE_KEY, JSON.stringify(pendingDraftRef.current)); + } + subscription.unsubscribe(); }; }, [form]); @@ -216,6 +228,7 @@ export default function QuoteBuilder() { const handleExport = useCallback(async (values: QuoteFormValues) => { setIsExporting(true); + setExportError(null); try { const draft = formValuesToDraft(values); const { exportQuoteToPdf } = await import('@/lib/quote/export-pdf'); @@ -224,6 +237,7 @@ export default function QuoteBuilder() { }); } catch (error) { console.error('PDF export failed:', error); + setExportError('Non è stato possibile generare il PDF. Riprova tra qualche istante.'); } finally { setIsExporting(false); } @@ -254,6 +268,14 @@ export default function QuoteBuilder() { spazio.

) : null} + {exportError ? ( +

+ {exportError} +

+ ) : null}
{/* Mobile actions bar — visible on all sizes, primary CTA on mobile */} diff --git a/src/pages/Sources.tsx b/src/pages/Sources.tsx index 81eda48..f40d446 100644 --- a/src/pages/Sources.tsx +++ b/src/pages/Sources.tsx @@ -164,25 +164,34 @@ export default function Sources() { {' '} Gestione Separata 26,07%; Artigiani e Commercianti con minimale 2026 di 18.808€, aliquota aggiuntiva +1% oltre 56.224€ ed eventuale riduzione del 35% per i - forfettari. + forfettari. Per Artigiani e Commercianti il massimale è 93.707€ in presenza di + anzianità contributiva al 31 dicembre 1995 e 122.295€ negli altri casi. - diff --git a/src/pages/TargetNet.tsx b/src/pages/TargetNet.tsx index 5ddbf4e..db3ffb3 100644 --- a/src/pages/TargetNet.tsx +++ b/src/pages/TargetNet.tsx @@ -15,7 +15,8 @@ import { motion } from 'motion/react'; import { calculateTargetNet } from '@/lib/calculations'; import { formatCurrency } from '@/lib/format'; import { parseNonNegativeNumber } from '@/lib/number-input'; -import type { InpsType } from '@/lib/fiscal-data'; +import type { ContributionHistory, InpsType } from '@/lib/fiscal-data'; +import { ContributionHistorySelect } from '../components/ContributionHistorySelect'; export default function TargetNet() { const [nettoMensile, setNettoMensile] = useState(2000); @@ -23,6 +24,7 @@ export default function TargetNet() { const [nuovaAttivita, setNuovaAttivita] = useState(false); const [tipoInps, setTipoInps] = useState('gestioneSeparata'); const [riduzioneInps, setRiduzioneInps] = useState(false); + const [contributionHistory, setContributionHistory] = useState('post1995'); const result = calculateTargetNet({ nettoMensile, @@ -30,6 +32,7 @@ export default function TargetNet() { nuovaAttivita, tipoInps, riduzioneInps, + contributionHistory, }); const containerVariants = { @@ -139,15 +142,21 @@ export default function TargetNet() {
{(tipoInps === 'artigiani' || tipoInps === 'commercianti') && ( -
- - + +
+ + +
)}
diff --git a/src/pages/__tests__/Contributions.test.tsx b/src/pages/__tests__/Contributions.test.tsx index 892d69c..07454ac 100644 --- a/src/pages/__tests__/Contributions.test.tsx +++ b/src/pages/__tests__/Contributions.test.tsx @@ -12,6 +12,14 @@ describe('Contributions page', () => { await user.click(screen.getByLabelText(/Gestione INPS/i)); await user.click(await screen.findByRole('option', { name: /Artigiani/i })); + const contributionHistory = screen.getByLabelText(/Anzianità contributiva/i); + await user.click(contributionHistory); + expect( + await screen.findByRole('option', { name: /Prima iscrizione dal 1° gennaio 1996/i }), + ).toBeInTheDocument(); + expect( + screen.getByRole('option', { name: /Anzianità contributiva al 31 dicembre 1995/i }), + ).toBeInTheDocument(); expect(await screen.findByText('18 Maggio 2026')).toBeInTheDocument(); expect(screen.getByText('20 Agosto 2026')).toBeInTheDocument(); expect(screen.getByText('16 Novembre 2026')).toBeInTheDocument(); diff --git a/src/pages/__tests__/QuoteBuilder.test.tsx b/src/pages/__tests__/QuoteBuilder.test.tsx index 02d4ee7..03632c6 100644 --- a/src/pages/__tests__/QuoteBuilder.test.tsx +++ b/src/pages/__tests__/QuoteBuilder.test.tsx @@ -106,6 +106,17 @@ describe('QuoteBuilder draft persistence', () => { expect(parsed.logoDataUrl).toBe('data:image/png;base64,abc123'); }); }); + + test('discards unsafe logo data restored from storage', () => { + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ logoDataUrl: 'data:image/svg+xml;base64,PHN2Zz48L3N2Zz4=' }), + ); + + render(); + + expect(screen.queryByAltText('Logo aziendale')).not.toBeInTheDocument(); + }); }); describe('QuoteBuilder responsive layout', () => { @@ -247,6 +258,37 @@ describe('QuoteBuilder safe defaults', () => { expect(screen.getByText(/nome cliente obbligatorio/i)).toBeInTheDocument(); }); + test('shows an accessible message when PDF export fails', async () => { + const { exportQuoteToPdf } = await import('@/lib/quote/export-pdf'); + vi.mocked(exportQuoteToPdf).mockRejectedValueOnce(new Error('render failed')); + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ + providerName: 'Studio Test', + providerAddress: 'Via Roma 1', + providerCity: 'Roma', + providerVatNumber: 'IT12345678901', + providerEmail: 'studio@example.com', + clientName: 'Cliente Test', + clientAddress: 'Via Milano 2', + clientEmail: 'cliente@example.com', + quoteNumber: 'TEST-001', + issueDate: '2026-08-08', + title: 'Preventivo test', + lineItems: [{ id: '1', description: 'Servizio', quantity: 1, unitPrice: 100 }], + discount: 0, + vatMode: 'none', + logoDataUrl: '', + }), + ); + + const user = userEvent.setup(); + render(); + await user.click(screen.getByTestId('quote-export-button')); + + expect(await screen.findByRole('alert')).toHaveTextContent(/non è stato possibile generare/i); + }); + test('shows a persistence warning when autosave fails', async () => { const user = userEvent.setup(); const setItem = vi.fn(() => { diff --git a/tests/e2e/accessibility.spec.ts b/tests/e2e/accessibility.spec.ts new file mode 100644 index 0000000..ca6117c --- /dev/null +++ b/tests/e2e/accessibility.spec.ts @@ -0,0 +1,55 @@ +import AxeBuilder from '@axe-core/playwright'; +import { expect, test } from '@playwright/test'; + +const routes = [ + '/', + '/calcolatore', + '/confronto', + '/contributi', + '/quanto-fatturare', + '/pianificazione', + '/preventivo', + '/informativa', +] as const; + +test.beforeEach(async ({ page }) => { + await page.emulateMedia({ reducedMotion: 'reduce' }); + await page.addInitScript(() => { + localStorage.setItem( + 'easypiva-disclaimer-storage', + JSON.stringify({ state: { hasAcceptedDisclaimer: true }, version: 0 }), + ); + }); +}); + +for (const route of routes) { + test(`${route} has no serious or critical automated accessibility violations`, async ({ + page, + }) => { + await page.goto(route); + await expect(page.locator('main h1').first()).toBeVisible(); + await expect(page.getByText('Caricamento...')).toBeHidden(); + await page.waitForTimeout(1_600); + + const results = await new AxeBuilder({ page }).analyze(); + const blockingViolations = results.violations.filter(({ impact }) => + ['serious', 'critical'].includes(impact ?? ''), + ); + + expect(blockingViolations).toEqual([]); + }); +} + +test('mobile navigation behaves as a modal dialog', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto('/'); + + const trigger = page.getByRole('button', { name: 'Apri menu' }); + await trigger.click(); + const dialog = page.getByRole('dialog', { name: 'Menu di navigazione' }); + await expect(dialog).toBeVisible(); + + await page.keyboard.press('Escape'); + await expect(dialog).toBeHidden(); + await expect(trigger).toBeFocused(); +}); diff --git a/tests/e2e/quote-builder-complete.spec.ts b/tests/e2e/quote-builder-complete.spec.ts index 2dbd6c1..59541e6 100644 --- a/tests/e2e/quote-builder-complete.spec.ts +++ b/tests/e2e/quote-builder-complete.spec.ts @@ -36,10 +36,11 @@ test('completes a preventivo with logo and exports a pdf', async ({ page }) => { const logoInput = page.locator('input[type="file"]'); await logoInput.setInputFiles({ - name: 'logo.svg', - mimeType: 'image/svg+xml', + name: 'logo.png', + mimeType: 'image/png', buffer: Buffer.from( - 'G', + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', + 'base64', ), });