Last active
November 14, 2023 21:26
-
-
Save 3v1n0/52d98344272077679cb0fcabf5487bf1 to your computer and use it in GitHub Desktop.
Fattura Elettronica and Python playground
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env python3 | |
# -*- coding: utf-8 -*- | |
# | |
# 2019-2021 - Marco Trevisan | |
# | |
# Fattura Elettronica and python Playground | |
# | |
# This program is free software: you can redistribute it and/or modify | |
# it under the terms of the GNU General Public License as published by | |
# the Free Software Foundation, either version 3 of the License, or | |
# (at your option) any later version. | |
# | |
# This program is distributed in the hope that it will be useful, | |
# but WITHOUT ANY WARRANTY; without even the implied warranty of | |
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |
# GNU General Public License for more details. | |
# | |
# You should have received a copy of the GNU General Public License | |
# along with this program. If not, see <http://www.gnu.org/licenses/>. | |
import calendar | |
import datetime | |
import xml.etree.cElementTree as ET | |
import xml.dom.minidom | |
from enum import Enum | |
class ITElectronicInvoice(object): | |
'''Spec at https://www.fatturapa.gov.it/export/documenti/Specifiche_tecniche_del_formato_FatturaPA_V1.3.1.pdf''' | |
BASE_XML = '''<?xml version="1.0" encoding="UTF-8" standalone="yes"?> | |
<ns2:FatturaElettronica versione="FPR12" xmlns:ns2="http://ivaservizi.agenziaentrate.gov.it/docs/xsd/fatture/v1.2"> | |
</ns2:FatturaElettronica> | |
''' | |
class Regime(Enum): | |
ORDINARY = 'RF01' # Ordinario | |
MINIMUM_CONTRIBUTORS = 'RF02' # Contribuenti minimi (art.1, c.96-117, L. 244/07) | |
NEW_VENTURES = 'RF03' # Nuove iniziative produttive (art.13, L. 388/00) | |
AGRICULTURE_FISHING = 'RF04' # Agricoltura e attività connesse e pesca (artt.34 e 34-bis, DPR 633/72) | |
TOBACCO_SELL = 'RF05' # Vendita sali e tabacchi (art.74, c.1, DPR. 633/72) | |
MATCHSTICK_TRADING = 'RF06' # Commercio fiammiferi (art.74, c.1, DPR 633/72) | |
EDITORS = 'RF07' # Editoria (art.74, c.1, DPR 633/72) | |
PUBLIC_TELEPHONY = 'RF08' # Gestione servizi telefonia pubblica (art.74, c.1, DPR 633/72) | |
PUBLIC_TRANSPORT = 'RF09' # Rivendita documenti di trasporto pubblico e di sosta (art.74, c.1, DPR 633/72) | |
GAMING = 'RF10' # Intrattenimenti, giochi e altre attività di cui alla tariffa allegata al DPR 640/72 (art.74, c.6, DPR 633/72) | |
TRAVEL_AGENTS = 'RF11' # Agenzie viaggi e turismo (art.74-ter, DPR 633/72) | |
AGRITURISM = 'RF12' # Agriturismo (art.5, c.2, L. 413/91) | |
HOME_DELIVERY = 'RF13' # Vendite a domicilio (art.25-bis, c.6, DPR 600/73) | |
SECONDHAND_SELL = 'RF14' # Rivendita beni usati, oggetti d’arte, d’antiquariato o da collezione (art.36, DL 41/95) | |
BID_AUCTION = 'RF15' # Agenzie di vendite all’asta di oggetti d’arte, antiquariato o da collezione (art.40-bis, DL 41/95) | |
PUBLIC_ADMINISTRATION_CASH_ACCOUNTING = 'RF16' # IVA per cassa P.A. (art.6, c.5, DPR 633/72) | |
CASH_ACCOUNTING = 'RF17' # IVA per cassa (art. 32-bis, DL 83/2012) | |
OTHER = 'RF18' # Altro | |
FLAT_REGIME = 'RF19' # Regime forfettario (art.1, c.54-89, L. 190/2014) | |
class TaxType(Enum): | |
NORMAL = None | |
EXCLUDED_ART_15 = 'N1' | |
# NOT_APPLIED = 'N2' | |
NOT_APPLIED_DPR_633_72 = 'N2.1' | |
NOT_APPLIED_OTHERS = 'N2.2' | |
# NOT_APPLIABLE = 'N3' | |
NOT_APPLIABLE_EXPORT = 'N3.1' | |
NOT_APPLIABLE_TRANSFER_EU = 'N3.2' | |
NOT_APPLIABLE_SAN_MARINO = 'N3.3' | |
NOT_APPLIABLE_TRANSFER_EXPORT = 'N3.4' | |
NOT_APPLIABLE_INTENT = 'N3.5' | |
NOT_APPLIABLE_OTHERS = 'N3.6' | |
EXEMPT = 'N4' | |
MARGIN_REGIME = 'N5' | |
# REVERSE_CHARGE = 'N6' | |
REVERSE_CHARGE_N6_1 = 'N6.1' | |
REVERSE_CHARGE_N6_2 = 'N6.2' | |
REVERSE_CHARGE_N6_3 = 'N6.3' | |
REVERSE_CHARGE_N6_4 = 'N6.4' | |
REVERSE_CHARGE_N6_5 = 'N6.5' | |
REVERSE_CHARGE_N6_6 = 'N6.6' | |
REVERSE_CHARGE_N6_7 = 'N6.7' | |
REVERSE_CHARGE_N6_8 = 'N6.8' | |
REVERSE_CHARGE_N6_9 = 'N6.9' | |
class PensionType(Enum): | |
LAWYERS = 'TC01' | |
ACCOUNTANTS = 'TC02' | |
SURVEYORS = 'TC03' | |
ENGINEERS_AND_ARCHITECTS = 'TC04' | |
NOTARIES = 'TC05' | |
COMMERCIAL_ACCOUNTANTS = 'TC06' | |
ENASARCO = 'TC07' | |
ENPACL = 'TC08' | |
ENPAM = 'TC09' | |
ENPAF = 'TC10' | |
ENPAV = 'TC11' | |
ENPAIA = 'TC12' | |
POSTMEN = 'TC13' | |
INPGI = 'TC14' | |
ONAOSI = 'TC15' | |
CASAGIT = 'TC16' | |
EPPI = 'TC17' | |
EPAP = 'TC18' | |
ENPAB = 'TC19' | |
ENPAPI = 'TC20' | |
ENPAP = 'TC21' | |
INPS = 'TC22' | |
class PaymentCondition(Enum): | |
INSTALLMENTS = 'TP01' | |
FULL = 'TP02' | |
PREPAID = 'TP03' | |
class PaymentMode(Enum): | |
CASH = 'MP01' | |
CHECK = 'MP02' | |
CASHIER_CHECK = 'MP03' | |
CONTANTI_PRESSO_TESORERIA = 'MP04' | |
BANK_TRANSFER = 'MP05' | |
VAGLIA_CAMBIARIO = 'MP06' | |
BOLLETTINO_BANCARIO = 'MP07' | |
CREDIT_CARD = 'MP08' | |
RID = 'MP09' | |
RID_UTENZE = 'MP10' | |
RID_VELOCE = 'MP11' | |
RIBA = 'MP12' | |
MAV = 'MP13' | |
QUIETANZA_ERARIO_STATO = 'MP14' | |
GIROCONTO_SU_CONTI_DI_CONTABILITÀ_SPECIALE = 'MP15' | |
DOMICILIAZIONE_BANCARIA = 'MP16' | |
DOMICILIAZIONE_POSTALE = 'MP17' | |
class Address(object): | |
def __init__(self, street, no, zip_code, city, province, country_id): | |
self.Indirizzo = street | |
self.NumeroCivico = no | |
self.CAP = zip_code | |
self.Comune = city | |
self.Provincia = province | |
self.Nazione = country_id | |
class Contacts(object): | |
def __init__(self, email, phone=None): | |
self.Telefono = phone | |
self.Email = email | |
class FiscalContacts(object): | |
def __init__(self, country_id, vat_no): | |
self.IdPaese = country_id | |
self.IdCodice = vat_no | |
class Business(object): | |
def __init__(self, country_id, vat_no, nin, regime, name, address, contacts=None): | |
self.DatiAnagrafici = { | |
'IdFiscaleIVA': ITElectronicInvoice.FiscalContacts(country_id, vat_no), | |
'CodiceFiscale': nin, | |
'Anagrafica': { | |
'Denominazione': name | |
}, | |
'RegimeFiscale': regime, | |
} | |
self.Sede = address | |
self.Contatti = contacts | |
class Pension(object): | |
def __init__(self, pension_type, amount, rate, vat=22, tax_type=None): | |
self.TipoCassa = pension_type | |
self.AlCassa = float(rate) | |
self.ImportoContributoCassa = float(amount) | |
self.AliquotaIVA = float(vat) | |
self.Natura = tax_type | |
class PaymentDetails(object): | |
def __init__(self, beneficiary, payment_mode, amount, iban, date_limit, | |
abi=None, cab=None, bic=None, institute=None): | |
assert payment_mode == ITElectronicInvoice.PaymentMode.BANK_TRANSFER | |
self.Beneficiario = beneficiary | |
self.ModalitaPagamento = payment_mode | |
self.DataScadenzaPagamento = date_limit | |
self.ImportoPagamento = float(amount) | |
self.IstitutoFinanziario = institute | |
self.IBAN = iban | |
self.ABI = int(abi) if abi else None | |
self.CAB = int(cab) if cab else None | |
self.BIC = bic | |
class Item(object): | |
def __init__(self, description, amount, vat=22, tax_type=None, | |
start=None, end=None, number=1): | |
self.Descrizione = description | |
self.DataInizioPeriodo = start | |
self.DataFinePeriodo = end | |
self.PrezzoUnitario = float(amount) | |
self.PrezzoTotale = float(amount * number) | |
self.AliquotaIVA = float(vat) | |
self.Natura = tax_type | |
def __init__(self, id, date, sender, emitter, receiver, receiver_code, receiver_pec=None): | |
self.fe = ET.fromstring(ITElectronicInvoice.BASE_XML) | |
self.id = id | |
self.date = date | |
self.sender = sender | |
self.emitter = emitter | |
self.receiver = receiver | |
self.receiver_code = receiver_code | |
self.receiver_pec = receiver_pec | |
self.items = [] | |
self.tax_rules = {} | |
self.beni = None | |
self.pension = None | |
self.payment = None | |
def add_item(self, item): | |
if item.PrezzoTotale != 0: | |
self.items.append(item) | |
return item | |
def add_expense(self, amount, reason): | |
self.add_item(ITElectronicInvoice.Item(reason, amount, vat=0, | |
tax_type=ITElectronicInvoice.TaxType.EXCLUDED_ART_15)) | |
def set_pension(self, description, pension_type, amount, rate, vat, tax_type): | |
item = self.add_item(ITElectronicInvoice.Item(description, amount, | |
vat=vat, tax_type=tax_type)) | |
self.pension = ITElectronicInvoice.Pension(pension_type, amount, rate, | |
vat, tax_type) | |
self.pension._item = item | |
def set_payment_details(self, conditions, details): | |
self.payment = { | |
'CondizioniPagamento': conditions, | |
'DettaglioPagamento': details, | |
} | |
def _xmlfy_obj(self, parent, d): | |
if isinstance(d, Enum): | |
self._xmlfy_obj(parent, d.value) | |
elif isinstance(d, dict): | |
p = None | |
for k, v in d.items(): | |
if v is None: | |
continue | |
p = ET.SubElement(parent, k) | |
if isinstance(v, tuple) or isinstance(v, list): | |
for vv in v: | |
self._xmlfy_obj(p, vv) | |
else: | |
self._xmlfy_obj(p, v) | |
if len(d.items()) == 1 and p != None: | |
parent = p | |
elif isinstance(d, tuple) or isinstance(d, list): | |
p = ET.SubElement(parent, parent.tag) | |
for v in d: | |
self._xmlfy_obj(p, v) | |
elif isinstance(d, object) and hasattr(d, '__dict__'): | |
self._xmlfy_obj(parent, { k: v for k, v in d.__dict__.items() | |
if not k.startswith('_') }) | |
elif isinstance(d, float): | |
parent.text = '{0:.2f}'.format(round(d, 2)) | |
elif isinstance(d, datetime.datetime): | |
parent.text = '{:%Y-%m-%d}'.format(d) | |
else: | |
parent.text = str(d) | |
return parent | |
def _build_header(self): | |
return self._xmlfy_obj(self.fe, { | |
'FatturaElettronicaHeader': { | |
'DatiTrasmissione': { | |
'IdTrasmittente': self.sender, | |
'ProgressivoInvio': 1, | |
'FormatoTrasmissione': 'FPR12', | |
'CodiceDestinatario': self.receiver_code, | |
'PECDestinatario': self.receiver_pec | |
}, | |
'CedentePrestatore': self.emitter, | |
'CessionarioCommittente': self.receiver, | |
} | |
}) | |
def _build_body(self): | |
body = self._xmlfy_obj(self.fe, { | |
'FatturaElettronicaBody': { | |
'DatiGenerali': { | |
'DatiGeneraliDocumento': { | |
'TipoDocumento': 'TD01', | |
'Divisa': 'EUR', | |
'Data': self.date, | |
'Numero': self.id, | |
'DatiCassaPrevidenziale': self.pension, | |
'ImportoTotaleDocumento': self.total_amount(), | |
} | |
} | |
} | |
}) | |
if len(self.items): | |
self.beni = self._xmlfy_obj(body, { 'DatiBeniServizi': [] }) | |
self._xmlfy_obj(body, {'DatiPagamento': self.payment}) | |
return body | |
def total_amount(self): | |
return sum([x.PrezzoTotale for x in self.items]) | |
def _compute_amount_by_tax(self, tax_type): | |
return sum([x.PrezzoTotale for x in self.items if x.Natura == tax_type]) | |
def _add_items(self): | |
idx = 1 | |
for item in self.items: | |
if self.pension and self.pension._item == item: | |
continue | |
detail = self._xmlfy_obj(self.beni, { | |
'DettaglioLinee': { | |
'NumeroLinea': idx, | |
} | |
}) | |
self._xmlfy_obj(detail, item) | |
idx += 1 | |
def _add_summary_line(self, kind, amount, vat_percent=0, vat=0): | |
if amount <= 0: | |
return None | |
return self._xmlfy_obj(self.beni, { | |
'DatiRiepilogo': { | |
'AliquotaIVA': float(vat_percent), | |
'Natura': kind, | |
'ImponibileImporto': float(amount), | |
'Imposta': float(vat), | |
} | |
}) | |
def _compute_tax_summary(self): | |
for tax_type in ITElectronicInvoice.TaxType: | |
summary = self._add_summary_line(tax_type, | |
self._compute_amount_by_tax(tax_type)) | |
if tax_type in self.tax_rules: | |
self._xmlfy_obj(summary, | |
{ 'RiferimentoNormativo': self.tax_rules[tax_type] }) | |
def generate(self, path): | |
self._build_header() | |
self._build_body() | |
self._add_items() | |
self._compute_tax_summary() | |
with open(path, 'w') as file: | |
xmlstr = ET.tostring(self.fe, encoding='unicode', method='xml') | |
pretty = xml.dom.minidom.parseString(xmlstr).toprettyxml(indent=' ') | |
xmlstr = '\n'.join(list(filter(lambda x: len(x.strip()), pretty.split('\n')))) | |
file.write(xmlstr) | |
class CanonicalInvoice(ITElectronicInvoice): | |
def __init__(self, id, date, amount, sender, emitter): | |
canonical = ITElectronicInvoice.Business('GB', '003232247', None, None, | |
'Canonical Services Limited', ITElectronicInvoice.Address( | |
'991TT, Finch Road', '12-14', '00000', 'Douglas', None, 'IM')) | |
super().__init__(id, date, sender, emitter, canonical, receiver_code='XXXXXXX') | |
self.first_day = datetime.date(year=date.year, month=date.month, day=1) | |
self.last_day = datetime.date(year=date.year, month=date.month, | |
day=calendar.monthrange(date.year, date.month)[1]) | |
description = 'Development in {} {}'.format('{:%B}'.format(date), date.year) | |
self.add_item(ITElectronicInvoice.Item(description, amount, vat=0, | |
start=self.first_day, end=self.last_day, | |
tax_type=ITElectronicInvoice.TaxType.NOT_APPLIED_DPR_633_72)) | |
self.tax_rules[ITElectronicInvoice.TaxType.NOT_APPLIED_DPR_633_72] = \ | |
'art. 7, DPR 633/72 - Operazione di prestazione di servizi non soggetta' | |
def add_bonus(self, amount, reason): | |
self.add_item(ITElectronicInvoice.Item(reason, amount, vat=0, | |
tax_type=ITElectronicInvoice.TaxType.NOT_APPLIED_DPR_633_72)) | |
def add_expense(self, amount, reason): | |
super().add_expense(amount, reason) | |
if amount > 0: | |
print('Remember to attach the Expensify PDF for `{}` when ' | |
'uploading the invoice'.format( | |
reason if len(reason) else 'the {} expense'.format(amount))) | |
if __name__ == "__main__": | |
vat_no = '012345678901' | |
me_person = ITElectronicInvoice.FiscalContacts('IT', 'TRVMRC12B345678Z') | |
me = ITElectronicInvoice.Business('IT', vat_no, me_person.IdCodice, | |
ITElectronicInvoice.Regime.ORDINARY, 'SiProgramma.it', | |
ITElectronicInvoice.Address('Via lì', 2, 12345, 'Laggiù', 'FI', 'IT'), | |
ITElectronicInvoice.Contacts('foo@bar.baz', '3287654321')) | |
id = 321 | |
date = datetime.datetime.now() | |
total_amount = 10000000 | |
bonus = 5000 | |
pension_revenge_rate = 4.0 | |
expense = 3000 | |
amount = ((total_amount - expense) / (1.0 + pension_revenge_rate/100)) - bonus | |
inps = total_amount - amount - bonus - expense | |
fe = CanonicalInvoice(id, date, amount, me_person, me) | |
fe.add_expense(expense/2, 'Alcool') | |
fe.add_expense(expense/2, 'Fun') | |
fe.add_bonus(bonus, 'Super spotlight prize') | |
fe.set_pension('Rivalsa INPS 4% (national insurance)', | |
ITElectronicInvoice.PensionType.INPS, inps, | |
rate=pension_revenge_rate, vat=0, | |
tax_type=ITElectronicInvoice.TaxType.NOT_APPLIED_DPR_633_72) | |
fe.set_payment_details(ITElectronicInvoice.PaymentCondition.FULL, | |
ITElectronicInvoice.PaymentDetails('Marco Trevisan', | |
ITElectronicInvoice.PaymentMode.BANK_TRANSFER, | |
amount=total_amount, date_limit=date, | |
iban='ES6621000418401234567891', institute='Caixa bank')) | |
path = '/tmp/IT{}_draft.xml'.format(vat_no) | |
fe.generate(path) | |
print('File saved as', path) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment