receipt_engine
Motore di calcolo per scontrini fiscali italiani: righe, sconti, IVA multi-aliquota, storni e resi, arrotondamenti e resto. Logica pura in Dart, senza dipendenze da Flutter, database o I/O.
Perché esiste
Nasce da un problema reale incontrato lavorando su software per registratori di cassa: il calcolo di uno scontrino sembra banale finché non ci si scontra con gli arrotondamenti. Se l'imposta si calcola riga per riga, la somma può differire di qualche centesimo dall'imposta calcolata sul totale — e uno scontrino che non quadra per un centesimo è un problema contabile, non un dettaglio estetico.
Questo pacchetto isola quella logica in un modulo testabile in millisecondi, riutilizzabile da un'app mobile, da un backend o da un tool a riga di comando.
Scelte di progetto
| Scelta | Motivo |
|---|---|
Importi in centesimi (Money), mai double |
0.1 + 0.2 != 0.3 in virgola mobile binaria. Sul denaro non è accettabile |
| IVA scorporata sul totale per aliquota, non per riga | È il requisito normativo, ed evita che gli arrotondamenti di riga si accumulino |
| Imposta derivata per differenza dall'imponibile | Garantisce l'invariante imponibile + imposta == lordo, sempre |
Receipt immutabile, ReceiptBuilder mutabile |
Un documento fiscale non si modifica: si emette e semmai si storna |
| Il reso è un secondo documento, con importi negativi | Rende sommabili scontrini e resi: il totale di giornata è la somma di tutto, senza casi particolari |
| Si rimborsa l'incassato di riga, non il totale di riga | Con uno sconto di documento le due cose differiscono, e rimborsare la seconda restituirebbe anche lo sconto |
| Resi parziali con arrotondamento cumulativo | Tre resi da un pezzo devono restituire esattamente quanto un reso da tre |
VatRate come classe e non come enum |
Le aliquote cambiano per legge e per paese: un enum imporrebbe un rilascio a ogni variazione |
Sconti polimorfi invece di uno switch |
Aggiungere un "3x2" non richiede di modificare il calcolo esistente |
| Ripartizione dello sconto come strategia sostituibile | È una scelta contabile, non un dettaglio di calcolo |
| Formattazione fuori dal value object | Cambia con la lingua e col contesto: non deve stare nel dominio |
| Il resto esce solo da mezzi che danno resto | Totale 22 €, pagati 25 con la carta: i 3 € di resto non sono in cassa. È un errore, non un resto |
| Serializzazione fuori dai modelli, e a mano | Uno scontrino non deve sapere come viaggia. json_serializable porterebbe build_runner e file generati per sei modelli che cambiano di rado |
| La rilettura non ricalcola | Un documento fiscale emesso si rilegge, non si rifà: altrimenti una correzione futura agli arrotondamenti cambierebbe scontrini già consegnati |
| Zero dipendenze a runtime | Il dominio non deve sapere che esistono Flutter o un database |
Installazione
dart pub add receipt_engine
import 'package:receipt_engine/receipt_engine.dart';
L'API completa è su pub.flutter-io.cn/documentation/receipt_engine.
Esempio
import 'package:receipt_engine/receipt_engine.dart';
void main() {
final Receipt receipt = ReceiptBuilder(id: 'T-0001')
.addLine(
description: 'Caffè',
unitPrice: Money.fromEuro(1.20),
vatRate: VatRate.reduced,
quantity: 2,
)
.addLine(
description: 'Vino',
unitPrice: Money.fromEuro(12.20),
vatRate: VatRate.standard,
discount: Discount.percent(10),
)
.applyDocumentDiscount(Discount.amount(Money.fromEuro(1)))
.close(paid: Money.fromEuro(20));
const MoneyFormatter fmt = ItalianMoneyFormatter();
print(fmt.format(receipt.total)); // 12,38 €
print(fmt.format(receipt.change)); // 7,62 €
for (final VatBreakdown v in receipt.vatSummary) {
print(v); // IVA 10%: imponibile ..., imposta ...
}
}
Il cliente riporta il vino. Lo scontrino non si tocca: si emette un reso.
final ReturnReceipt reversal =
ReturnBuilder(id: 'R-0001', original: receipt).addLine(1).close();
print(fmt.format(reversal.refund)); // 10,16 € da restituire
print(fmt.format(receipt.lines[1].total)); // 10,98 € il totale di riga
print(fmt.format(receipt.total + reversal.total)); // 2,22 € resta il caffè
Il rimborso è 10,16 e non 10,98 perché su quella riga il cliente aveva già goduto della sua quota di sconto di documento. Il riepilogo IVA del reso è quello dello scontrino con il segno cambiato, e la somma dei due torna a zero sulle righe rese — imposta compresa.
L'esempio completo, eseguibile, è in
example/receipt_engine_example.dart.
Pagamenti misti
// Totale 22 €: 15 con la carta, 10 in contanti.
final Receipt receipt = builder.closeWithPayments(<Payment>[
Payment.electronic(Money.fromEuro(15)),
Payment.cash(Money.fromEuro(10)),
]);
print(fmt.format(receipt.change)); // 3,00 €, dal cassetto
La regola che conta: il resto esce solo da mezzi che danno resto. Pagare 25 € con la
sola carta su un totale di 22 non produce 3 € di resto, solleva ChangeNotAvailableError:
la carta ha addebitato 25 e nel cassetto non è entrato niente. E non basta che ci siano dei
contanti — 23 con la carta e 5 in contanti fanno un resto di 6, e dal cassetto ne possono
uscire al massimo 5.
close(paid:) resta la scorciatoia per il caso comune, un pagamento in contanti, e si
comporta esattamente come prima. I mezzi di pagamento sono aperti come le aliquote: un
buono pasto è PaymentMethod('meal_voucher', label: 'Buono pasto'), e non dà resto a meno
di dirlo.
Fuori dal processo: JSON
Chi lo usa da un backend, o dietro una coda, deve poter scrivere uno scontrino da qualche parte e rileggerlo identico.
const ReceiptJson codec = ReceiptJson();
final String wire = jsonEncode(codec.encodeReceipt(receipt));
final Receipt again =
codec.decodeReceipt(jsonDecode(wire) as Map<String, Object?>);
Il codec produce una mappa, non una stringa: dart:convert non compare da nessuna
parte dentro il pacchetto, così la mappa si annida in un documento più grande, va a un
encoder diverso o finisce in un database che parla già di mappe.
Due cose vale la pena sapere prima di usarlo.
La rilettura non ricalcola niente. I totali di riga al netto dello sconto e il
riepilogo IVA vengono riletti dal documento, non rifatti passando per ReceiptBuilder. Un
documento fiscale emesso si rilegge: se lo si ricostruisse dal builder, una correzione
futura agli arrotondamenti cambierebbe retroattivamente scontrini già consegnati al
cliente.
Ogni documento porta uno schemaVersion. È la differenza fra un formato e uno scarico
di campi: rileggere un documento scritto da una versione più recente del pacchetto solleva
un errore invece di produrre uno scontrino monco.
È servito subito. La 0.6.0 aggiunge i pagamenti e scrive lo schema 2; un documento salvato
con la 0.5.0 si rilegge ancora, e i suoi pagamenti diventano un solo pagamento in
contanti — che è quello che paid ha sempre voluto dire.
Struttura
lib/
receipt_engine.dart API pubblica
src/
money.dart valore monetario in centesimi
vat_calculator.dart scorporo dell'imposta
discount_allocator.dart strategia di ripartizione dello sconto
vat_summary_calculator.dart ripartizione dello sconto e riepilogo per aliquota
receipt_builder.dart ciclo di vita dello scontrino
return_builder.dart ciclo di vita del reso
formatting/money_formatter.dart
serialization/receipt_json.dart lettura e scrittura JSON
models/
vat_rate.dart discount.dart receipt_line.dart
receipt.dart return_receipt.dart payment.dart
example/ programma eseguibile: emissione e storno
test/ invarianti, contratto dei sottotipi, allocazione
Le scelte architetturali e i principi applicati sono in ARCHITECTURE.md.
Test
dart pub get
dart test
dart test --coverage=coverage
La pipeline misura anche il punteggio di pub.flutter-io.cn a ogni push, con
pana, e fallisce se scende sotto 130 su 160:
un criterio scritto dove può fallire vale più dello stesso criterio scritto in un
documento. pana non gira su Windows — il suo sandbox rifiuta i percorsi con i due
punti dei dischi — quindi la CI non è una comodità, è l'unico posto dove quel numero
esiste.
I test non verificano solo i casi felici. Due esempi di invarianti verificate:
imponibile + imposta == lordoper ogni importo da 1 a 2000 centesimi e per ogni aliquota;- la somma dei lordi per aliquota è sempre uguale al totale del documento, anche in presenza di uno sconto di documento che va distribuito fra aliquote diverse;
- il contratto di
Discount— mai negativo, mai superiore alla base — vale per ogni sottotipo, incluso uno definito dentro il file di test per dimostrare che la gerarchia è davvero aperta all'estensione; - lo scorporo di un importo negativo è esattamente l'opposto di quello positivo, su ottomila combinazioni di importo e aliquota: è la proprietà su cui si regge il riepilogo IVA dei resi;
- comunque si spezzi il reso di una riga — un pezzo alla volta, in qualunque ordine — la somma dei rimborsi è esattamente quanto quella riga aveva incassato, verificato su millequattrocento combinazioni di prezzo e quantità.
In più, in test/properties, sette invarianti verificate su scontrini generati invece
che scelti: è lì che si rompono gli arrotondamenti, perché nessuno scrive a mano «2,04 € per
1,8 kg con l'8% di sconto». Hanno già trovato un difetto vero — l'ultima tranche di un reso
su merce a peso veniva rifiutata — e si sono ridotti da soli al caso minimo che lo mostra.
Il seme è fisso, così un fallimento è sempre riproducibile. Per cercare più a fondo:
PROPERTY_SEED=12345 dart test test/properties
Stato e prossimi passi
Pubblicato su pub.flutter-io.cn con 160/160 al punteggio.
Le tre cose che questa sezione elencava come mancanti — storni e resi, serializzazione JSON, pagamenti misti — sono dentro. Non ne aggiungo altre per riempire la lista: la prossima la decide chi lo usa, e le issue sono il posto dove dirlo.
Resta una cosa che non arriverà, che è diverso dal mancare. Fino alla 0.3.1 questa
sezione prometteva il «supporto ad aliquote di altri paesi»: la promessa è ritirata. Dalla 0.4.0
un'aliquota è un num, quindi il 5,5% francese o il 13,5% irlandese si calcolano senza
problemi — ma sapere quale bene sta a quale aliquota, quando vale il reverse charge e come
si numera un documento in un altro ordinamento è lavoro di dominio, non di aritmetica.
receipt_engine resta un motore a IVA italiana con i conti aperti a qualunque percentuale,
e chi ha bisogno delle regole di un altro paese sa già, leggendo questa riga, che qui non
le trova.
Licenza
MIT
Libraries
- receipt_engine
- Motore di calcolo per scontrini fiscali.