2016-09-16 13:08:52 +02:00
|
|
|
# -*- coding: utf-8 -*-
|
|
|
|
# The COPYRIGHT file at the top level of this repository contains the full
|
|
|
|
# copyright notices and license terms.
|
|
|
|
import logging
|
|
|
|
import os
|
|
|
|
import re
|
2021-09-13 17:09:07 +02:00
|
|
|
import base64
|
|
|
|
import random
|
|
|
|
import xmlsig
|
|
|
|
import hashlib
|
|
|
|
import datetime
|
2016-09-16 13:08:52 +02:00
|
|
|
from decimal import Decimal
|
|
|
|
from jinja2 import Environment, FileSystemLoader
|
|
|
|
from lxml import etree
|
|
|
|
from operator import attrgetter
|
|
|
|
from tempfile import NamedTemporaryFile
|
2021-09-13 17:09:07 +02:00
|
|
|
from cryptography.hazmat.primitives import serialization
|
|
|
|
from cryptography.hazmat.primitives.serialization import pkcs12
|
2016-09-16 13:08:52 +02:00
|
|
|
from trytond.model import ModelView, fields
|
|
|
|
from trytond.pool import Pool, PoolMeta
|
|
|
|
from trytond.pyson import Bool, Eval
|
|
|
|
from trytond.transaction import Transaction
|
|
|
|
from trytond.wizard import Wizard, StateView, StateTransition, Button
|
2018-07-08 19:30:21 +02:00
|
|
|
from trytond import backend
|
2019-01-10 16:17:41 +01:00
|
|
|
from trytond.i18n import gettext
|
|
|
|
from trytond.exceptions import UserError
|
2016-09-16 13:08:52 +02:00
|
|
|
|
2022-11-11 11:31:29 +01:00
|
|
|
# Get from XSD scheme of Facturae 3.2.2
|
|
|
|
# http://www.facturae.gob.es/formato/Versiones/Facturaev3_2_2.xml
|
2016-09-16 13:08:52 +02:00
|
|
|
RECTIFICATIVE_REASON_CODES = [
|
|
|
|
("01", "Invoice number", "Número de la factura"),
|
|
|
|
("02", "Invoice serial number", "Serie de la factura"),
|
|
|
|
("03", "Issue date", "Fecha expedición"),
|
|
|
|
("04", "Name and surnames/Corporate name-Issuer (Sender)",
|
|
|
|
"Nombre y apellidos/Razón Social-Emisor"),
|
|
|
|
("05", "Name and surnames/Corporate name-Receiver",
|
|
|
|
"Nombre y apellidos/Razón Social-Receptor"),
|
|
|
|
("06", "Issuer's Tax Identification Number",
|
|
|
|
"Identificación fiscal Emisor/obligado"),
|
|
|
|
("07", "Receiver's Tax Identification Number",
|
|
|
|
"Identificación fiscal Receptor"),
|
|
|
|
("08", "Issuer's address", "Domicilio Emisor/Obligado"),
|
|
|
|
("09", "Receiver's address", "Domicilio Receptor"),
|
|
|
|
("10", "Item line", "Detalle Operación"),
|
|
|
|
("11", "Applicable Tax Rate", "Porcentaje impositivo a aplicar"),
|
|
|
|
("12", "Applicable Tax Amount", "Cuota tributaria a aplicar"),
|
|
|
|
("13", "Applicable Date/Period", "Fecha/Periodo a aplicar"),
|
|
|
|
("14", "Invoice Class", "Clase de factura"),
|
|
|
|
("15", "Legal literals", "Literales legales"),
|
|
|
|
("16", "Taxable Base", "Base imponible"),
|
|
|
|
("80", "Calculation of tax outputs", "Cálculo de cuotas repercutidas"),
|
|
|
|
("81", "Calculation of tax inputs", "Cálculo de cuotas retenidas"),
|
|
|
|
("82",
|
|
|
|
"Taxable Base modified due to return of packages and packaging "
|
|
|
|
"materials",
|
|
|
|
"Base imponible modificada por devolución de envases / embalajes"),
|
|
|
|
("83", "Taxable Base modified due to discounts and rebates",
|
|
|
|
"Base imponible modificada por descuentos y bonificaciones"),
|
|
|
|
("84",
|
|
|
|
"Taxable Base modified due to firm court ruling or administrative "
|
|
|
|
"decision",
|
|
|
|
"Base imponible modificada por resolución firme, judicial o "
|
|
|
|
"administrativa"),
|
|
|
|
("85",
|
|
|
|
"Taxable Base modified due to unpaid outputs where there is a "
|
|
|
|
"judgement opening insolvency proceedings",
|
|
|
|
"Base imponible modificada cuotas repercutidas no satisfechas. Auto "
|
|
|
|
"de declaración de concurso"),
|
|
|
|
]
|
|
|
|
# UoM Type from UN/CEFACT
|
|
|
|
UOM_CODE2TYPE = {
|
|
|
|
'u': '01',
|
|
|
|
'h': '02',
|
|
|
|
'kg': '03',
|
|
|
|
'g': '21',
|
|
|
|
's': '34',
|
|
|
|
'm': '25',
|
|
|
|
'km': '22',
|
|
|
|
'cm': '16',
|
|
|
|
'mm': '26',
|
|
|
|
'm³': '33',
|
|
|
|
'l': '04',
|
|
|
|
}
|
|
|
|
# Missing types in product/uom.xml
|
|
|
|
# "06", Boxes-BX
|
|
|
|
# "07", Trays, one layer no cover, plastic-DS
|
|
|
|
# "08", Barrels-BA
|
|
|
|
# "09", Jerricans, cylindrical-JY
|
|
|
|
# "10", Bags-BG
|
|
|
|
# "11", Carboys, non-protected-CO
|
|
|
|
# "12", Bottles, non-protected, cylindrical-BO
|
|
|
|
# "13", Canisters-CI
|
|
|
|
# "14", Tetra Briks
|
|
|
|
# "15", Centiliters-CLT
|
|
|
|
# "17", Bins-BI
|
|
|
|
# "18", Dozens
|
|
|
|
# "19", Cases-CS
|
|
|
|
# "20", Demijohns, non-protected-DJ
|
|
|
|
# "23", Cans, rectangular-CA
|
|
|
|
# "24", Bunches-BH
|
|
|
|
# "27", 6-Packs
|
|
|
|
# "28", Packages-PK
|
|
|
|
# "29", Portions
|
|
|
|
# "30", Rolls-RO
|
|
|
|
# "31", Envelopes-EN
|
|
|
|
# "32", Tubs-TB
|
|
|
|
# "35", Watt-WTT
|
2018-05-18 09:40:43 +02:00
|
|
|
FACe_REQUIRED_FIELDS = ['facturae_person_type', 'facturae_residence_type']
|
2016-09-16 13:08:52 +02:00
|
|
|
|
|
|
|
_slugify_strip_re = re.compile(r'[^\w\s-]')
|
|
|
|
_slugify_hyphenate_re = re.compile(r'[-\s]+')
|
|
|
|
|
2022-11-11 11:31:29 +01:00
|
|
|
DEFAULT_FACTURAE_TEMPLATE = 'template_facturae_3.2.2.xml'
|
|
|
|
DEFAULT_FACTURAE_SCHEMA = 'Facturaev3_2_2-offline.xml'
|
2018-09-13 13:21:30 +02:00
|
|
|
|
2016-09-16 13:08:52 +02:00
|
|
|
|
|
|
|
def slugify(value):
|
2018-08-18 23:55:49 +02:00
|
|
|
if not isinstance(value, str):
|
|
|
|
value = str(value)
|
|
|
|
value = str(_slugify_strip_re.sub('', value).strip().lower())
|
2016-09-16 13:08:52 +02:00
|
|
|
return _slugify_hyphenate_re.sub('-', value)
|
|
|
|
|
|
|
|
|
|
|
|
def module_path():
|
|
|
|
return os.path.dirname(os.path.abspath(__file__))
|
|
|
|
|
|
|
|
|
2018-08-18 23:55:49 +02:00
|
|
|
class Invoice(metaclass=PoolMeta):
|
2016-09-16 13:08:52 +02:00
|
|
|
__name__ = 'account.invoice'
|
|
|
|
credited_invoices = fields.Function(fields.One2Many('account.invoice',
|
|
|
|
None, 'Credited Invoices'),
|
|
|
|
'get_credited_invoices', searcher='search_credited_invoices')
|
|
|
|
rectificative_reason_code = fields.Selection(
|
|
|
|
[(None, "")] + [(x[0], x[1]) for x in RECTIFICATIVE_REASON_CODES],
|
|
|
|
'Rectificative Reason Code', sort=False,
|
|
|
|
states={
|
|
|
|
'invisible': ~Bool(Eval('credited_invoices')),
|
|
|
|
'required': (Bool(Eval('credited_invoices'))
|
|
|
|
& (Eval('state').in_(['posted', 'paid']))),
|
|
|
|
}, depends=['credited_invoices'])
|
|
|
|
invoice_facturae = fields.Binary('Factura-e',
|
2021-11-28 23:06:38 +01:00
|
|
|
filename='invoice_facturae_filename')
|
2016-09-16 13:08:52 +02:00
|
|
|
invoice_facturae_filename = fields.Function(fields.Char(
|
2018-05-18 09:40:43 +02:00
|
|
|
'Factura-e filename'), 'get_invoice_facturae_filename')
|
2016-09-16 13:08:52 +02:00
|
|
|
|
|
|
|
@classmethod
|
|
|
|
def __setup__(cls):
|
|
|
|
super(Invoice, cls).__setup__()
|
2021-03-23 00:39:03 +01:00
|
|
|
cls._check_modify_exclude.add('invoice_facturae')
|
2016-09-16 13:08:52 +02:00
|
|
|
cls._buttons.update({
|
|
|
|
'generate_facturae_wizard': {
|
|
|
|
'invisible': ((Eval('type') != 'out')
|
|
|
|
| ~Eval('state').in_(['posted', 'paid'])),
|
|
|
|
'readonly': Bool(Eval('invoice_facturae')),
|
|
|
|
}
|
|
|
|
})
|
|
|
|
|
2021-09-13 17:09:07 +02:00
|
|
|
@classmethod
|
|
|
|
def copy(cls, invoices, default=None):
|
|
|
|
if default is None:
|
|
|
|
default = {}
|
|
|
|
else:
|
|
|
|
default = default.copy()
|
|
|
|
default.setdefault('invoice_facturae', None)
|
|
|
|
default.setdefault('rectificative_reason_code', None)
|
|
|
|
return super(Invoice, cls).copy(invoices, default=default)
|
|
|
|
|
2016-09-16 13:08:52 +02:00
|
|
|
def get_credited_invoices(self, name):
|
|
|
|
pool = Pool()
|
|
|
|
InvoiceLine = pool.get('account.invoice.line')
|
|
|
|
invoices = set()
|
|
|
|
for line in self.lines:
|
|
|
|
if isinstance(line.origin, InvoiceLine) and line.origin.invoice:
|
|
|
|
invoices.add(line.origin.invoice.id)
|
|
|
|
return list(invoices)
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
def search_credited_invoices(cls, name, clause):
|
|
|
|
return [('lines.origin.invoice',) + tuple(clause[1:3])
|
|
|
|
+ ('account.invoice.line',) + tuple(clause[3:])]
|
|
|
|
|
|
|
|
def get_invoice_facturae_filename(self, name):
|
|
|
|
return 'facturae-%s.xsig' % slugify(self.number)
|
|
|
|
|
|
|
|
@property
|
|
|
|
def rectificative_reason_spanish_description(self):
|
|
|
|
if self.rectificative_reason_code:
|
|
|
|
for code, _, spanish_description in RECTIFICATIVE_REASON_CODES:
|
|
|
|
if code == self.rectificative_reason_code:
|
|
|
|
return spanish_description
|
|
|
|
|
|
|
|
@property
|
|
|
|
def taxes_outputs(self):
|
|
|
|
"""Return list of 'impuestos repecutidos'"""
|
|
|
|
return [inv_tax for inv_tax in self.taxes
|
|
|
|
if inv_tax.tax and inv_tax.tax.rate >= Decimal(0)]
|
|
|
|
|
|
|
|
@property
|
|
|
|
def taxes_withheld(self):
|
|
|
|
"""Return list of 'impuestos retenidos'"""
|
|
|
|
return [inv_tax for inv_tax in self.taxes
|
|
|
|
if inv_tax.tax and inv_tax.tax.rate < Decimal(0)]
|
|
|
|
|
|
|
|
@property
|
|
|
|
def payment_details(self):
|
|
|
|
return sorted([ml for ml in self.move.lines
|
2019-04-28 01:04:14 +02:00
|
|
|
if ml.account.type.receivable],
|
2016-09-16 13:08:52 +02:00
|
|
|
key=attrgetter('maturity_date'))
|
|
|
|
|
2020-06-26 18:12:00 +02:00
|
|
|
def _credit(self, **values):
|
2021-01-21 16:26:50 +01:00
|
|
|
credit = super(Invoice, self)._credit(**values)
|
2016-09-16 13:08:52 +02:00
|
|
|
rectificative_reason_code = Transaction().context.get(
|
|
|
|
'rectificative_reason_code')
|
|
|
|
if rectificative_reason_code:
|
2021-01-21 16:26:50 +01:00
|
|
|
credit.rectificative_reason_code = rectificative_reason_code
|
|
|
|
return credit
|
2016-09-16 13:08:52 +02:00
|
|
|
|
|
|
|
@classmethod
|
|
|
|
@ModelView.button_action(
|
|
|
|
'account_invoice_facturae.wizard_generate_signed_facturae')
|
|
|
|
def generate_facturae_wizard(cls, invoices):
|
|
|
|
pass
|
|
|
|
|
|
|
|
@classmethod
|
2018-05-18 09:40:43 +02:00
|
|
|
def generate_facturae_default(cls, invoices, certificate_password):
|
2018-09-13 13:21:30 +02:00
|
|
|
to_write = ([],)
|
2016-09-16 13:08:52 +02:00
|
|
|
for invoice in invoices:
|
|
|
|
if invoice.invoice_facturae:
|
|
|
|
continue
|
|
|
|
facturae_content = invoice.get_facturae()
|
|
|
|
invoice._validate_facturae(facturae_content)
|
2019-12-11 10:46:07 +01:00
|
|
|
if backend.name != 'sqlite':
|
2018-09-13 13:21:30 +02:00
|
|
|
invoice_facturae = invoice._sign_facturae(
|
2018-07-08 19:30:21 +02:00
|
|
|
facturae_content, certificate_password)
|
2018-09-13 13:21:30 +02:00
|
|
|
else:
|
|
|
|
invoice_facturae = facturae_content
|
|
|
|
to_write[0].append(invoice)
|
|
|
|
to_write += ({'invoice_facturae': invoice_facturae},)
|
2023-02-20 09:44:26 +01:00
|
|
|
if to_write[0]:
|
2018-09-13 13:21:30 +02:00
|
|
|
cls.write(*to_write)
|
2016-09-16 13:08:52 +02:00
|
|
|
|
|
|
|
def get_facturae(self):
|
2018-09-13 13:21:30 +02:00
|
|
|
jinja_env = Environment(
|
|
|
|
loader=FileSystemLoader(module_path()),
|
|
|
|
trim_blocks=True,
|
|
|
|
lstrip_blocks=True,
|
|
|
|
)
|
|
|
|
template = DEFAULT_FACTURAE_TEMPLATE
|
|
|
|
return self._get_jinja_template(jinja_env, template).render(
|
|
|
|
self._get_content_to_render(), ).encode('utf-8')
|
|
|
|
|
|
|
|
def _get_jinja_template(self, jinja_env, template):
|
|
|
|
return jinja_env.get_template(template)
|
|
|
|
|
|
|
|
def _get_content_to_render(self):
|
|
|
|
"""Return the content to render in factura-e XML file"""
|
2016-09-16 13:08:52 +02:00
|
|
|
pool = Pool()
|
|
|
|
Currency = pool.get('currency.currency')
|
|
|
|
Date = pool.get('ir.date')
|
|
|
|
Rate = pool.get('currency.currency.rate')
|
|
|
|
|
|
|
|
if self.type != 'out':
|
|
|
|
return
|
|
|
|
if self.state not in ('posted', 'paid'):
|
|
|
|
return
|
|
|
|
|
|
|
|
# These are an assert because it shouldn't happen
|
|
|
|
assert self.invoice_date <= Date.today(), (
|
|
|
|
"Invoice date of invoice %s is in the future" % self.id)
|
|
|
|
assert len(self.credited_invoices) < 2, (
|
|
|
|
"Too much credited invoices for invoice %s" % self.id)
|
|
|
|
assert not self.credited_invoices or self.rectificative_reason_code, (
|
|
|
|
"Missing rectificative_reason_code for invoice %s with credited "
|
|
|
|
"invoices" % self.id)
|
|
|
|
assert len(self.taxes_outputs) > 0, (
|
|
|
|
"Missing some tax in invoice %s" % self.id)
|
|
|
|
|
2018-05-18 09:40:43 +02:00
|
|
|
for field in FACe_REQUIRED_FIELDS:
|
|
|
|
for party in [self.party, self.company.party]:
|
|
|
|
if not getattr(party, field):
|
2019-01-10 16:17:41 +01:00
|
|
|
raise UserError(gettext(
|
2021-11-25 09:40:26 +01:00
|
|
|
'account_invoice_facturae.party_facturae_fields',
|
2019-01-10 16:17:41 +01:00
|
|
|
party=party.rec_name,
|
|
|
|
invoice=self.rec_name,
|
|
|
|
field=field))
|
2018-07-08 19:30:21 +02:00
|
|
|
if (not self.company.party.tax_identifier
|
|
|
|
or len(self.company.party.tax_identifier.code) < 3
|
|
|
|
or len(self.company.party.tax_identifier.code) > 30):
|
2019-01-10 16:17:41 +01:00
|
|
|
raise UserError(gettext(
|
2021-11-25 09:40:26 +01:00
|
|
|
'account_invoice_facturae.company_vat_identifier',
|
2019-01-10 16:17:41 +01:00
|
|
|
party=self.company.party.rec_name))
|
2018-07-08 19:30:21 +02:00
|
|
|
|
2022-10-31 13:28:06 +01:00
|
|
|
company_address = self.company.party.address_get(type='invoice')
|
|
|
|
if (not company_address
|
|
|
|
or not company_address.street
|
|
|
|
or not company_address.postal_code
|
|
|
|
or not company_address.city
|
|
|
|
or not company_address.subdivision
|
|
|
|
or not company_address.country):
|
2019-01-10 16:17:41 +01:00
|
|
|
raise UserError(gettext(
|
2021-11-25 09:40:26 +01:00
|
|
|
'account_invoice_facturae.company_address_fields',
|
2019-01-10 16:17:41 +01:00
|
|
|
party=self.company.party.rec_name))
|
2016-09-16 13:08:52 +02:00
|
|
|
|
2018-07-08 19:30:21 +02:00
|
|
|
if (not self.party.tax_identifier
|
|
|
|
or len(self.party.tax_identifier.code) < 3
|
|
|
|
or len(self.party.tax_identifier.code) > 30):
|
2019-01-10 16:17:41 +01:00
|
|
|
raise UserError(gettext(
|
2021-11-25 09:40:26 +01:00
|
|
|
'account_invoice_facturae.party_vat_identifier',
|
2019-01-10 16:17:41 +01:00
|
|
|
party=self.party.rec_name,
|
|
|
|
invoice=self.rec_name))
|
2016-09-16 13:08:52 +02:00
|
|
|
if (self.party.facturae_person_type == 'F'
|
|
|
|
and len(self.party.name.split(' ', 2)) < 2):
|
2019-01-10 16:17:41 +01:00
|
|
|
raise UserError(gettext(
|
2021-11-25 09:40:26 +01:00
|
|
|
'account_invoice_facturae.party_name_surname',
|
2019-01-10 16:17:41 +01:00
|
|
|
party=self.party.rec_name,
|
|
|
|
invoice=self.rec_name))
|
2016-09-16 13:08:52 +02:00
|
|
|
if (not self.invoice_address.street
|
2021-04-01 16:37:27 +02:00
|
|
|
or not self.invoice_address.postal_code
|
2016-09-16 13:08:52 +02:00
|
|
|
or not self.invoice_address.city
|
|
|
|
or not self.invoice_address.subdivision
|
|
|
|
or not self.invoice_address.country):
|
2019-01-10 16:17:41 +01:00
|
|
|
raise UserError(gettext(
|
2021-11-25 09:40:26 +01:00
|
|
|
'account_invoice_facturae.invoice_address_fields',
|
2019-01-10 16:17:41 +01:00
|
|
|
invoice=self.rec_name))
|
2016-09-16 13:08:52 +02:00
|
|
|
|
2021-09-13 17:09:07 +02:00
|
|
|
euro, = Currency.search([('code', '=', 'EUR')], limit=1)
|
2016-09-16 13:08:52 +02:00
|
|
|
if self.currency != euro:
|
|
|
|
assert (euro.rate == Decimal(1)
|
|
|
|
or self.currency.rate == Decimal(1)), (
|
|
|
|
"Euro currency or the currency of invoice %s must to be the "
|
|
|
|
"base currency" % self.id)
|
|
|
|
if euro.rate == Decimal(1):
|
|
|
|
rates = Rate.search([
|
|
|
|
('currency', '=', self.currency),
|
|
|
|
('date', '<=', self.invoice_date),
|
|
|
|
], limit=1, order=[('date', 'DESC')])
|
|
|
|
if not rates:
|
2019-01-10 16:17:41 +01:00
|
|
|
raise UserError(gettext(
|
2021-11-25 09:40:26 +01:00
|
|
|
'account_invoice_facturae.no_rate',
|
2019-01-10 16:17:41 +01:00
|
|
|
currency=self.currenc.name,
|
|
|
|
date=self.invoice_date.strftime('%d/%m/%Y')))
|
2016-09-16 13:08:52 +02:00
|
|
|
exchange_rate = rates[0].rate
|
|
|
|
exchange_rate_date = rates[0].date
|
|
|
|
else:
|
|
|
|
rates = Rate.search([
|
|
|
|
('currency', '=', euro),
|
|
|
|
('date', '<=', self.invoice_date),
|
|
|
|
], limit=1, order=[('date', 'DESC')])
|
|
|
|
if not rates:
|
2019-01-10 16:17:41 +01:00
|
|
|
raise UserError(gettext(
|
2021-11-25 09:40:26 +01:00
|
|
|
'account_invoice_facturae.no_rate',
|
2019-01-10 16:17:41 +01:00
|
|
|
currency=euro.name,
|
|
|
|
date=self.invoice_date.strftime('%d/%m/%Y')))
|
2016-09-16 13:08:52 +02:00
|
|
|
exchange_rate = Decimal(1) / rates[0].rate
|
|
|
|
exchange_rate_date = rates[0].date
|
|
|
|
else:
|
|
|
|
exchange_rate = exchange_rate_date = None
|
|
|
|
|
|
|
|
for invoice_tax in self.taxes:
|
|
|
|
assert invoice_tax.tax, 'Empty tax in invoice %s' % self.id
|
|
|
|
assert (invoice_tax.tax.type == 'percentage'), (
|
|
|
|
'Unsupported non percentage tax %s of invoice %s'
|
|
|
|
% (invoice_tax.tax.id, self.id))
|
|
|
|
|
|
|
|
for move_line in self.payment_details:
|
|
|
|
if not move_line.payment_type:
|
2019-01-10 16:17:41 +01:00
|
|
|
raise UserError(gettext(
|
|
|
|
'account_invoice_facturae.missing_payment_type',
|
|
|
|
invoice=self.rec_name))
|
2016-09-16 13:08:52 +02:00
|
|
|
if not move_line.payment_type.facturae_type:
|
2019-01-10 16:17:41 +01:00
|
|
|
raise UserError(gettext(
|
2021-11-25 09:40:26 +01:00
|
|
|
'account_invoice_facturae.'
|
|
|
|
'missing_payment_type_facturae_type',
|
2019-01-10 16:17:41 +01:00
|
|
|
payment_type=move_line.payment_type.rec_name,
|
|
|
|
invoice=self.rec_name))
|
2016-09-16 13:08:52 +02:00
|
|
|
if move_line.payment_type.facturae_type in ('02', '04'):
|
|
|
|
if not hasattr(move_line, 'account_bank'):
|
2019-01-10 16:17:41 +01:00
|
|
|
raise UserError(gettext(
|
2021-11-25 09:40:26 +01:00
|
|
|
'account_invoice_facturae.'
|
|
|
|
'missing_account_bank_module',
|
2019-01-10 16:17:41 +01:00
|
|
|
payment_type=move_line.payment_type.rec_name,
|
|
|
|
invoice=self.rec_name))
|
2016-09-16 13:08:52 +02:00
|
|
|
if not move_line.bank_account:
|
2019-01-10 16:17:41 +01:00
|
|
|
raise UserError(gettext(
|
2021-11-25 09:40:26 +01:00
|
|
|
'account_invoice_facturae.missing_bank_account',
|
2019-01-10 16:17:41 +01:00
|
|
|
invoice=self.rec_name))
|
2016-09-16 13:08:52 +02:00
|
|
|
if not [n for n in move_line.bank_account.numbers
|
|
|
|
if n.type == 'iban']:
|
2019-01-10 16:17:41 +01:00
|
|
|
raise UserError(gettext(
|
|
|
|
'account_invoice_facturae.missing_iban',
|
|
|
|
bank_account=move_line.bank_account.rec_name,
|
|
|
|
invoice=self.rec_name))
|
2016-09-16 13:08:52 +02:00
|
|
|
|
2018-09-13 13:21:30 +02:00
|
|
|
return {
|
2018-09-13 18:34:03 +02:00
|
|
|
'invoice': self,
|
|
|
|
'Decimal': Decimal,
|
|
|
|
'Currency': Currency,
|
2016-09-16 13:08:52 +02:00
|
|
|
'euro': euro,
|
|
|
|
'exchange_rate': exchange_rate,
|
|
|
|
'exchange_rate_date': exchange_rate_date,
|
|
|
|
'UOM_CODE2TYPE': UOM_CODE2TYPE,
|
2018-09-13 13:21:30 +02:00
|
|
|
}
|
2016-09-16 13:08:52 +02:00
|
|
|
|
2018-09-13 18:34:03 +02:00
|
|
|
def _validate_facturae(self, xml_string, schema_file_path=None):
|
2016-09-16 13:08:52 +02:00
|
|
|
"""
|
|
|
|
Inspired by https://github.com/pedrobaeza/l10n-spain/blob/d01d049934db55130471e284012be7c860d987eb/l10n_es_facturae/wizard/create_facturae.py
|
|
|
|
"""
|
|
|
|
logger = logging.getLogger('account_invoice_facturae')
|
|
|
|
|
2018-09-13 13:21:30 +02:00
|
|
|
if not schema_file_path:
|
|
|
|
schema_file_path = os.path.join(
|
|
|
|
module_path(),
|
|
|
|
DEFAULT_FACTURAE_SCHEMA)
|
2018-09-13 18:34:03 +02:00
|
|
|
with open(schema_file_path, encoding='utf-8') as schema_file:
|
2016-09-16 13:08:52 +02:00
|
|
|
facturae_schema = etree.XMLSchema(file=schema_file)
|
2018-09-13 13:21:30 +02:00
|
|
|
logger.debug("%s loaded" % schema_file_path)
|
2016-09-16 13:08:52 +02:00
|
|
|
try:
|
|
|
|
facturae_schema.assertValid(etree.fromstring(xml_string))
|
|
|
|
logger.debug("Factura-e XML of invoice %s validated",
|
|
|
|
self.rec_name)
|
2018-09-13 18:34:03 +02:00
|
|
|
except Exception as e:
|
2016-09-16 13:08:52 +02:00
|
|
|
logger.warning("Error validating generated Factura-e file",
|
|
|
|
exc_info=True)
|
2018-05-18 09:40:43 +02:00
|
|
|
logger.debug(xml_string)
|
2019-01-10 16:17:41 +01:00
|
|
|
raise UserError(gettext(
|
2021-11-25 09:40:26 +01:00
|
|
|
'account_invoice_facturae.invalid_factura_xml_file',
|
2019-01-10 16:17:41 +01:00
|
|
|
invoice=self.rec_name, message=e))
|
2016-09-16 13:08:52 +02:00
|
|
|
return True
|
|
|
|
|
|
|
|
def _sign_facturae(self, xml_string, certificate_password):
|
|
|
|
"""
|
|
|
|
Inspired by https://github.com/pedrobaeza/l10n-spain/blob/d01d049934db55130471e284012be7c860d987eb/l10n_es_facturae/wizard/create_facturae.py
|
|
|
|
"""
|
2018-05-18 09:40:43 +02:00
|
|
|
if not self.company.facturae_certificate:
|
2019-01-10 16:17:41 +01:00
|
|
|
raise UserError(gettext(
|
|
|
|
'account_invoice_facturae.missing_certificate',
|
|
|
|
company=self.company.rec_name))
|
2016-09-16 13:08:52 +02:00
|
|
|
|
|
|
|
logger = logging.getLogger('account_invoice_facturae')
|
|
|
|
|
|
|
|
unsigned_file = NamedTemporaryFile(suffix='.xml', delete=False)
|
|
|
|
unsigned_file.write(xml_string)
|
|
|
|
unsigned_file.close()
|
|
|
|
|
|
|
|
cert_file = NamedTemporaryFile(suffix='.pfx', delete=False)
|
|
|
|
cert_file.write(self.company.facturae_certificate)
|
|
|
|
cert_file.close()
|
|
|
|
|
2021-09-13 17:09:07 +02:00
|
|
|
def _sign_file(cert, password, request):
|
2022-09-20 12:29:17 +02:00
|
|
|
# get key and certificates from PCK12 file
|
2021-09-13 17:09:07 +02:00
|
|
|
(
|
|
|
|
private_key,
|
|
|
|
certificate,
|
|
|
|
additional_certificates,
|
|
|
|
) = pkcs12.load_key_and_certificates(cert, password)
|
2022-09-20 12:29:17 +02:00
|
|
|
|
2021-09-13 17:09:07 +02:00
|
|
|
# DER is an ASN.1 encoding type
|
|
|
|
crt = certificate.public_bytes(serialization.Encoding.DER)
|
|
|
|
|
2022-09-20 12:29:17 +02:00
|
|
|
# Set variables values
|
2021-09-13 17:09:07 +02:00
|
|
|
rand_min = 1
|
|
|
|
rand_max = 99999
|
|
|
|
signature_id = "Signature%05d" % random.randint(rand_min, rand_max)
|
|
|
|
signed_properties_id = (
|
|
|
|
signature_id
|
|
|
|
+ "-SignedProperties%05d" % random.randint(rand_min, rand_max)
|
|
|
|
)
|
|
|
|
key_info_id = "KeyInfo%05d" % random.randint(rand_min, rand_max)
|
|
|
|
reference_id = "Reference%05d" % random.randint(rand_min, rand_max)
|
|
|
|
object_id = "Object%05d" % random.randint(rand_min, rand_max)
|
|
|
|
etsi = "http://uri.etsi.org/01903/v1.3.2#"
|
|
|
|
sig_policy_identifier = (
|
|
|
|
"http://www.facturae.es/"
|
|
|
|
"politica_de_firma_formato_facturae/"
|
|
|
|
"politica_de_firma_formato_facturae_v3_1"
|
|
|
|
".pdf"
|
|
|
|
)
|
|
|
|
sig_policy_hash_value = "Ohixl6upD6av8N7pEvDABhEL6hM="
|
2022-09-20 12:29:17 +02:00
|
|
|
|
|
|
|
# Get XML file to edit
|
2021-09-13 17:09:07 +02:00
|
|
|
root = etree.fromstring(request)
|
2022-09-20 12:29:17 +02:00
|
|
|
|
|
|
|
# Create a signature template for RSA-SHA1 enveloped signature.
|
2021-09-13 17:09:07 +02:00
|
|
|
sign = xmlsig.template.create(
|
|
|
|
c14n_method=xmlsig.constants.TransformInclC14N,
|
|
|
|
sign_method=xmlsig.constants.TransformRsaSha1,
|
|
|
|
name=signature_id,
|
|
|
|
ns="ds",
|
|
|
|
)
|
2022-09-20 12:29:17 +02:00
|
|
|
assert sign is not None
|
2021-09-13 17:09:07 +02:00
|
|
|
|
2022-09-20 12:29:17 +02:00
|
|
|
# Add the <ds:Signature/> node to the document.
|
|
|
|
root.append(sign)
|
|
|
|
|
|
|
|
# Add the <ds:Reference/> node to the signature template.
|
|
|
|
ref = xmlsig.template.add_reference(
|
|
|
|
sign, xmlsig.constants.TransformSha1, name=reference_id, uri=""
|
|
|
|
)
|
|
|
|
|
|
|
|
# Add the enveloped transform descriptor.
|
|
|
|
xmlsig.template.add_transform(ref,
|
|
|
|
xmlsig.constants.TransformEnveloped)
|
|
|
|
|
|
|
|
# Add 2 new <ds:Reference/> node to the signature template.
|
2021-09-13 17:09:07 +02:00
|
|
|
xmlsig.template.add_reference(
|
|
|
|
sign,
|
|
|
|
xmlsig.constants.TransformSha1,
|
|
|
|
uri="#" + signed_properties_id,
|
|
|
|
uri_type="http://uri.etsi.org/01903#SignedProperties",
|
|
|
|
)
|
|
|
|
xmlsig.template.add_reference(
|
|
|
|
sign, xmlsig.constants.TransformSha1, uri="#" + key_info_id
|
|
|
|
)
|
2022-09-20 12:29:17 +02:00
|
|
|
|
|
|
|
# Add the <ds:KeyInfo/> and <ds:KeyName/> nodes.
|
|
|
|
key_info = xmlsig.template.ensure_key_info(sign, name=key_info_id)
|
|
|
|
x509_data = xmlsig.template.add_x509_data(key_info)
|
|
|
|
xmlsig.template.x509_data_add_certificate(x509_data)
|
|
|
|
|
|
|
|
# Set the certificate values
|
|
|
|
ctx = xmlsig.SignatureContext()
|
|
|
|
ctx.private_key = private_key
|
|
|
|
ctx.x509 = certificate
|
|
|
|
ctx.ca_certificates = additional_certificates
|
|
|
|
ctx.public_key = certificate.public_key()
|
|
|
|
|
|
|
|
# Set the footer validation
|
2021-09-13 17:09:07 +02:00
|
|
|
object_node = etree.SubElement(
|
|
|
|
sign,
|
|
|
|
etree.QName(xmlsig.constants.DSigNs, "Object"),
|
|
|
|
nsmap={"etsi": etsi},
|
|
|
|
attrib={xmlsig.constants.ID_ATTR: object_id},
|
|
|
|
)
|
|
|
|
qualifying_properties = etree.SubElement(
|
|
|
|
object_node,
|
|
|
|
etree.QName(etsi, "QualifyingProperties"),
|
|
|
|
attrib={"Target": "#" + signature_id},
|
|
|
|
)
|
|
|
|
signed_properties = etree.SubElement(
|
|
|
|
qualifying_properties,
|
|
|
|
etree.QName(etsi, "SignedProperties"),
|
|
|
|
attrib={xmlsig.constants.ID_ATTR: signed_properties_id},
|
|
|
|
)
|
|
|
|
signed_signature_properties = etree.SubElement(
|
2021-11-25 09:40:26 +01:00
|
|
|
signed_properties, etree.QName(etsi,
|
|
|
|
"SignedSignatureProperties")
|
2021-09-13 17:09:07 +02:00
|
|
|
)
|
|
|
|
now = datetime.datetime.now()
|
|
|
|
etree.SubElement(
|
|
|
|
signed_signature_properties, etree.QName(etsi, "SigningTime")
|
|
|
|
).text = now.isoformat()
|
|
|
|
signing_certificate = etree.SubElement(
|
2021-11-25 09:40:26 +01:00
|
|
|
signed_signature_properties, etree.QName(etsi,
|
|
|
|
"SigningCertificate")
|
2021-09-13 17:09:07 +02:00
|
|
|
)
|
|
|
|
signing_certificate_cert = etree.SubElement(
|
|
|
|
signing_certificate, etree.QName(etsi, "Cert")
|
|
|
|
)
|
|
|
|
cert_digest = etree.SubElement(
|
|
|
|
signing_certificate_cert, etree.QName(etsi, "CertDigest")
|
|
|
|
)
|
|
|
|
etree.SubElement(
|
|
|
|
cert_digest,
|
|
|
|
etree.QName(xmlsig.constants.DSigNs, "DigestMethod"),
|
|
|
|
attrib={"Algorithm": "http://www.w3.org/2000/09/xmldsig#sha1"},
|
|
|
|
)
|
|
|
|
|
|
|
|
hash_cert = hashlib.sha1(crt)
|
|
|
|
etree.SubElement(
|
2021-11-25 09:40:26 +01:00
|
|
|
cert_digest, etree.QName(xmlsig.constants.DSigNs,
|
|
|
|
"DigestValue")
|
2021-09-13 17:09:07 +02:00
|
|
|
).text = base64.b64encode(hash_cert.digest())
|
|
|
|
|
|
|
|
issuer_serial = etree.SubElement(
|
|
|
|
signing_certificate_cert, etree.QName(etsi, "IssuerSerial")
|
|
|
|
)
|
|
|
|
etree.SubElement(
|
2021-11-25 09:40:26 +01:00
|
|
|
issuer_serial, etree.QName(xmlsig.constants.DSigNs,
|
|
|
|
"X509IssuerName")
|
2021-09-13 17:09:07 +02:00
|
|
|
).text = xmlsig.utils.get_rdns_name(certificate.issuer.rdns)
|
|
|
|
etree.SubElement(
|
2021-11-25 09:40:26 +01:00
|
|
|
issuer_serial, etree.QName(xmlsig.constants.DSigNs,
|
|
|
|
"X509SerialNumber")
|
2021-09-13 17:09:07 +02:00
|
|
|
).text = str(certificate.serial_number)
|
|
|
|
|
|
|
|
signature_policy_identifier = etree.SubElement(
|
|
|
|
signed_signature_properties,
|
|
|
|
etree.QName(etsi, "SignaturePolicyIdentifier"),
|
|
|
|
)
|
|
|
|
signature_policy_id = etree.SubElement(
|
2021-11-25 09:40:26 +01:00
|
|
|
signature_policy_identifier, etree.QName(etsi,
|
|
|
|
"SignaturePolicyId")
|
2021-09-13 17:09:07 +02:00
|
|
|
)
|
|
|
|
sig_policy_id = etree.SubElement(
|
|
|
|
signature_policy_id, etree.QName(etsi, "SigPolicyId")
|
|
|
|
)
|
|
|
|
etree.SubElement(
|
|
|
|
sig_policy_id, etree.QName(etsi, "Identifier")
|
|
|
|
).text = sig_policy_identifier
|
|
|
|
etree.SubElement(
|
|
|
|
sig_policy_id, etree.QName(etsi, "Description")
|
|
|
|
).text = "Política de Firma FacturaE v3.1"
|
|
|
|
sig_policy_hash = etree.SubElement(
|
|
|
|
signature_policy_id, etree.QName(etsi, "SigPolicyHash")
|
|
|
|
)
|
|
|
|
etree.SubElement(
|
|
|
|
sig_policy_hash,
|
|
|
|
etree.QName(xmlsig.constants.DSigNs, "DigestMethod"),
|
|
|
|
attrib={"Algorithm": "http://www.w3.org/2000/09/xmldsig#sha1"},
|
|
|
|
)
|
|
|
|
hash_value = sig_policy_hash_value
|
|
|
|
etree.SubElement(
|
2021-11-25 09:40:26 +01:00
|
|
|
sig_policy_hash, etree.QName(xmlsig.constants.DSigNs,
|
|
|
|
"DigestValue")
|
2021-09-13 17:09:07 +02:00
|
|
|
).text = hash_value
|
|
|
|
signer_role = etree.SubElement(
|
|
|
|
signed_signature_properties, etree.QName(etsi, "SignerRole")
|
|
|
|
)
|
|
|
|
claimed_roles = etree.SubElement(
|
|
|
|
signer_role, etree.QName(etsi, "ClaimedRoles")
|
|
|
|
)
|
|
|
|
etree.SubElement(
|
|
|
|
claimed_roles, etree.QName(etsi, "ClaimedRole")
|
|
|
|
).text = "supplier"
|
|
|
|
signed_data_object_properties = etree.SubElement(
|
2021-11-25 09:40:26 +01:00
|
|
|
signed_properties, etree.QName(etsi,
|
|
|
|
"SignedDataObjectProperties")
|
2021-09-13 17:09:07 +02:00
|
|
|
)
|
|
|
|
data_object_format = etree.SubElement(
|
|
|
|
signed_data_object_properties,
|
|
|
|
etree.QName(etsi, "DataObjectFormat"),
|
|
|
|
attrib={"ObjectReference": "#" + reference_id},
|
|
|
|
)
|
|
|
|
etree.SubElement(
|
|
|
|
data_object_format, etree.QName(etsi, "Description")
|
|
|
|
).text = "Factura"
|
2022-09-20 12:29:17 +02:00
|
|
|
data_object_format_identifier = etree.SubElement(
|
|
|
|
data_object_format, etree.QName(etsi, "ObjectIdentifier")
|
|
|
|
)
|
|
|
|
etree.SubElement(
|
|
|
|
data_object_format_identifier,
|
|
|
|
etree.QName(etsi, "Identifier"),
|
|
|
|
attrib={"Qualifier": "OIDAsURN"}
|
|
|
|
).text = "urn:oid:1.2.840.10003.5.109.10"
|
|
|
|
etree.SubElement(
|
|
|
|
data_object_format_identifier, etree.QName(etsi, "Description")
|
|
|
|
)
|
2021-09-13 17:09:07 +02:00
|
|
|
etree.SubElement(
|
|
|
|
data_object_format, etree.QName(etsi, "MimeType")
|
|
|
|
).text = "text/xml"
|
2022-09-20 12:29:17 +02:00
|
|
|
|
|
|
|
# Sign the file and verify the sign.
|
2021-09-13 17:09:07 +02:00
|
|
|
ctx.sign(sign)
|
2022-09-20 12:29:17 +02:00
|
|
|
ctx.verify(sign)
|
|
|
|
|
2021-09-13 17:09:07 +02:00
|
|
|
return etree.tostring(root, xml_declaration=True, encoding="UTF-8")
|
|
|
|
|
|
|
|
signed_file_content = _sign_file(
|
|
|
|
self.company.facturae_certificate,
|
|
|
|
certificate_password.encode(),
|
|
|
|
xml_string,
|
|
|
|
)
|
2019-01-10 16:17:41 +01:00
|
|
|
|
2016-09-16 13:08:52 +02:00
|
|
|
logger.info("Factura-e for invoice %s (%s) generated and signed",
|
|
|
|
self.rec_name, self.id)
|
|
|
|
|
|
|
|
return signed_file_content
|
|
|
|
|
|
|
|
|
2018-08-18 23:55:49 +02:00
|
|
|
class InvoiceLine(metaclass=PoolMeta):
|
2016-09-16 13:08:52 +02:00
|
|
|
__name__ = 'account.invoice.line'
|
|
|
|
|
|
|
|
@property
|
|
|
|
def taxes_outputs(self):
|
|
|
|
"""Return list of 'impuestos repecutidos'"""
|
|
|
|
return [inv_tax for inv_tax in self.invoice_taxes
|
|
|
|
if inv_tax.tax and inv_tax.tax.rate >= Decimal(0)]
|
|
|
|
|
|
|
|
@property
|
|
|
|
def taxes_withheld(self):
|
|
|
|
"""Return list of 'impuestos retenidos'"""
|
|
|
|
return [inv_tax for inv_tax in self.invoice_taxes
|
|
|
|
if inv_tax.tax and inv_tax.tax.rate < Decimal(0)]
|
|
|
|
|
|
|
|
@property
|
|
|
|
def taxes_additional_line_item_information(self):
|
|
|
|
res = {}
|
|
|
|
for inv_tax in self.invoice_taxes:
|
|
|
|
if inv_tax.tax and (not inv_tax.tax.report_type
|
|
|
|
or inv_tax.tax.report_type == '05'):
|
|
|
|
key = (inv_tax.tax.rate * 100, inv_tax.base, inv_tax.amount)
|
|
|
|
res.setdefault('05', []).append((key, inv_tax.description))
|
|
|
|
elif inv_tax.tax and inv_tax.tax.report_description:
|
|
|
|
res[inv_tax.tax.report_type] = inv_tax.tax.report_description
|
|
|
|
if '05' in res:
|
|
|
|
if len(res['05']) == 1:
|
|
|
|
res['05'] = res['05'][0]
|
|
|
|
else:
|
|
|
|
for key, tax_description in res['05']:
|
|
|
|
res['05 %s %s %s' % key] = tax_description
|
|
|
|
del res['05']
|
|
|
|
return res
|
|
|
|
|
|
|
|
|
2018-08-18 23:55:49 +02:00
|
|
|
class CreditInvoiceStart(metaclass=PoolMeta):
|
2016-09-16 13:08:52 +02:00
|
|
|
__name__ = 'account.invoice.credit.start'
|
|
|
|
rectificative_reason_code = fields.Selection(
|
|
|
|
[(x[0], x[1]) for x in RECTIFICATIVE_REASON_CODES],
|
|
|
|
'Rectificative Reason Code', required=True, sort=False)
|
|
|
|
|
|
|
|
|
2018-08-18 23:55:49 +02:00
|
|
|
class CreditInvoice(metaclass=PoolMeta):
|
2016-09-16 13:08:52 +02:00
|
|
|
__name__ = 'account.invoice.credit'
|
|
|
|
|
|
|
|
def do_credit(self, action):
|
|
|
|
with Transaction().set_context(
|
|
|
|
rectificative_reason_code=self.start.rectificative_reason_code
|
|
|
|
):
|
|
|
|
return super(CreditInvoice, self).do_credit(action)
|
|
|
|
|
|
|
|
|
2018-05-18 09:40:43 +02:00
|
|
|
class GenerateFacturaeStart(ModelView):
|
|
|
|
'Generate Factura-e file - Start'
|
|
|
|
__name__ = 'account.invoice.generate_facturae.start'
|
|
|
|
service = fields.Selection([
|
|
|
|
('default', 'Default'),
|
|
|
|
], 'Service', required=True)
|
|
|
|
certificate_password = fields.Char('Certificate Password',
|
|
|
|
states={
|
|
|
|
'required': Eval('service') == 'default',
|
|
|
|
'invisible': Eval('service') != 'default',
|
|
|
|
}, depends=['service'])
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
def default_service():
|
|
|
|
return 'default'
|
2016-09-16 13:08:52 +02:00
|
|
|
|
|
|
|
|
2018-05-18 09:40:43 +02:00
|
|
|
class GenerateFacturae(Wizard):
|
|
|
|
'Generate Factura-e file'
|
2016-09-16 13:08:52 +02:00
|
|
|
__name__ = 'account.invoice.generate_facturae'
|
2018-05-18 09:40:43 +02:00
|
|
|
start = StateView('account.invoice.generate_facturae.start',
|
|
|
|
'account_invoice_facturae.generate_facturae_start_view_form', [
|
2016-09-16 13:08:52 +02:00
|
|
|
Button('Cancel', 'end', 'tryton-cancel'),
|
2019-09-05 14:46:22 +02:00
|
|
|
Button('Generate', 'generate', 'tryton-launch', default=True),
|
2016-09-16 13:08:52 +02:00
|
|
|
])
|
|
|
|
generate = StateTransition()
|
|
|
|
|
|
|
|
def transition_generate(self):
|
2018-05-18 09:40:43 +02:00
|
|
|
Invoice = Pool().get('account.invoice')
|
|
|
|
|
|
|
|
invoices = Invoice.browse(Transaction().context['active_ids'])
|
|
|
|
service = 'generate_facturae_%s' % self.start.service
|
|
|
|
getattr(Invoice, service)(invoices, self.start.certificate_password)
|
2016-09-16 13:08:52 +02:00
|
|
|
return 'end'
|