Skip to content
Snippets Groups Projects
Commit 5516ca7f authored by Stanislas Gueniffey's avatar Stanislas Gueniffey Committed by Josse Colpaert
Browse files

[ADD] l10n_es_edi_tbai: Basque country's TicketBAI


**TicketBAI features**

- Generation of XML documents for invoices
- Automatic signing of documents with certificates
- Sending XML to the appropriate tax agency's servers
- Parsing and storing their response
- Preserving the invoice signature chain integrity
- Displaying the TicketBAI QR code on the invoice PDF
- Downloading XSD files for local validation
- Test mode using tax agencies' test servers

**Supported accounting flows**

- Posting and canceling invoices
- Foreign and national customers
- Multi-currency
- Refunds (credit notes)
- Discounts
- Simplified invoicing

**Not supported accounting flows**

- Specific VAT regimes
- Handling of third-party invoices
- XML generation and EDI operations for vendor bills (Bizkaia LROE)
- Modelo 140 (Bizkaia LROE)
- Invoice batching (Bikzaia LROE)

closes odoo/odoo#109907

Signed-off-by: default avatarJosse Colpaert <jco@odoo.com>
parent 26012ea4
No related branches found
No related tags found
No related merge requests found
Showing
with 1656 additions and 0 deletions
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import models
from . import wizards
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
# Thanks to Landoo and the Spanish community
# Specially among others Aritz Olea, Luis Salvatierra, Josean Soroa
{
'name': "Spain - TicketBAI",
'version': '1.0',
'category': 'Accounting/Localizations/EDI',
'application': False,
'description': """
This module sends invoices and vendor bills to the "Diputaciones
Forales" of Araba/Álava, Bizkaia and Gipuzkoa.
Invoices and bills get converted to XML and regularly sent to the
Basque government servers which provides them with a unique identifier.
A hash chain ensures the continuous nature of the invoice/bill
sequences. QR codes are added to emitted (sent/printed) invoices,
bills and tickets to allow anyone to check they have been declared.
You need to configure your certificate and the tax agency.
""",
'depends': [
'l10n_es_edi_sii',
],
'data': [
'data/account_edi_data.xml',
'data/template_invoice.xml',
'data/template_LROE_bizkaia.xml',
'views/account_move_view.xml',
'views/report_invoice.xml',
'views/res_config_settings_views.xml',
'views/res_company_views.xml',
'wizards/account_move_reversal_views.xml',
],
'demo': [
'demo/demo_res_partner.xml',
'demo/demo_company.xml',
],
'license': 'LGPL-3',
}
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<record id="edi_es_tbai" model="account.edi.format">
<field name="name">TicketBAI (ES)</field>
<field name="code">es_tbai</field>
</record>
</data>
</odoo>
<?xml version='1.0' encoding='utf-8'?>
<!--Bizkaia uses an extra layer to send TicketBAI invoices, called LROE
see https://www.batuz.eus/es/documentacion-tecnica -->
<odoo>
<data>
<template id="template_LROE_240_main">
<lrpjfecsgap:LROEPJ240FacturasEmitidasConSGAltaPeticion
xmlns:lrpjfecsgap="https://www.batuz.eus/fitxategiak/batuz/LROE/esquemas/LROE_PJ_240_1_1_FacturasEmitidas_ConSG_AltaPeticion_V1_0_2.xsd"
t-if="is_emission"
t-call="l10n_es_edi_tbai.template_LROE_240_inner"/>
<lrpjfecsgap:LROEPJ240FacturasEmitidasConSGAnulacionPeticion
xmlns:lrpjfecsgap="https://www.batuz.eus/fitxategiak/batuz/LROE/esquemas/LROE_PJ_240_1_1_FacturasEmitidas_ConSG_AnulacionPeticion_V1_0_0.xsd"
t-else=""
t-call="l10n_es_edi_tbai.template_LROE_240_inner"/>
</template>
<template id="template_LROE_240_inner">
<Cabecera>
<Modelo>240</Modelo>
<Capitulo>1</Capitulo>
<Subcapitulo>1.1</Subcapitulo>
<Operacion t-out="'A00' if is_emission else 'AN0'"/>
<Version>1.0</Version>
<Ejercicio t-out="fiscal_year"/>
<ObligadoTributario>
<NIF t-out="sender_vat"/>
<ApellidosNombreRazonSocial t-out="sender.name"/>
</ObligadoTributario>
</Cabecera>
<FacturasEmitidas>
<FacturaEmitida t-foreach="tbai_b64_list" t-as="tbai_b64">
<TicketBai t-if="is_emission" t-out="tbai_b64"/>
<AnulacionTicketBai t-else="" t-out="tbai_b64"/>
</FacturaEmitida>
</FacturasEmitidas>
</template>
</data>
</odoo>
<?xml version='1.0' encoding='utf-8'?>
<odoo>
<data>
<template id="template_invoice_main_post">
<T:TicketBai xmlns:T="urn:ticketbai:emision">
<t t-call="l10n_es_edi_tbai.template_invoice_bundle"/>
</T:TicketBai>
</template>
<template id="template_invoice_main_cancel">
<T:AnulaTicketBai xmlns:T="urn:ticketbai:anulacion">
<t t-call="l10n_es_edi_tbai.template_invoice_bundle"/>
</T:AnulaTicketBai>
</template>
<template id="template_invoice_bundle">
<Cabecera>
<IDVersionTBAI t-out="tbai_version"/>
</Cabecera>
<Sujetos t-if="is_emission">
<t t-call="l10n_es_edi_tbai.template_invoice_sujetos"/>
</Sujetos>
<Factura t-if="is_emission">
<t t-call="l10n_es_edi_tbai.template_invoice_factura"/>
</Factura>
<IDFactura t-if="not is_emission">
<t t-call="l10n_es_edi_tbai.template_invoice_sujetos"/>
<t t-call="l10n_es_edi_tbai.template_invoice_factura"/>
</IDFactura>
<HuellaTBAI>
<EncadenamientoFacturaAnterior t-if="chain_prev_invoice">
<t t-set="seq_and_num" t-value="chain_prev_invoice._get_l10n_es_tbai_sequence_and_number()"/>
<SerieFacturaAnterior t-out="seq_and_num[0]"/>
<NumFacturaAnterior t-out="seq_and_num[1]"/>
<t t-set="sig_and_date" t-value="chain_prev_invoice._get_l10n_es_tbai_signature_and_date()"/>
<FechaExpedicionFacturaAnterior t-out="format_date(sig_and_date[1])"/>
<SignatureValueFirmaFacturaAnterior t-out="sig_and_date[0][:100]"/>
</EncadenamientoFacturaAnterior>
<Software>
<LicenciaTBAI t-out="license_number"/>
<EntidadDesarrolladora>
<NIF t-out="license_nif"/>
</EntidadDesarrolladora>
<Nombre t-out="software_name"/>
<Version t-out="software_version"/>
</Software>
<NumSerieDispositivo>TEST-DEVICE-001</NumSerieDispositivo>
</HuellaTBAI>
</template>
<template id="template_invoice_sujetos">
<Emisor>
<NIF t-out="sender_vat"/>
<ApellidosNombreRazonSocial t-out="sender.name"/>
</Emisor>
<Destinatarios t-if="is_emission and recipient">
<IDDestinatario>
<NIF t-if="recipient.get('nif')" t-out="recipient['nif']"/>
<IDOtro t-else="">
<CodigoPais t-if="recipient.get('alt_id_country')" t-out="recipient['alt_id_country']"/>
<IDType t-out="recipient['alt_id_type']"/>
<ID t-out="recipient['alt_id_number']"/>
</IDOtro>
<t t-set="partner" t-value="recipient['partner']"/>
<ApellidosNombreRazonSocial t-out="partner.name"/>
<CodigoPostal t-out="partner.zip"/>
<Direccion t-out="recipient['partner_address']"/>
</IDDestinatario>
</Destinatarios>
<VariosDestinatarios t-if="is_emission">N</VariosDestinatarios> <!-- Odoo does not support multi-recipient invoices (TBAI does)-->
<EmitidaPorTercerosODestinatario t-if="is_emission">N</EmitidaPorTercerosODestinatario>
</template>
<template id="template_invoice_factura">
<t t-set="is_simplified" t-value="invoice._is_l10n_es_tbai_simplified()"/>
<CabeceraFactura>
<t t-set="seq_and_num" t-value="invoice._get_l10n_es_tbai_sequence_and_number()"/>
<SerieFactura t-out="seq_and_num[0]"/>
<NumFactura t-out="seq_and_num[1]"/>
<t t-if="is_emission">
<FechaExpedicionFactura t-out="format_date(datetime_now)"/>
<HoraExpedicionFactura t-out="format_time(datetime_now)"/>
<FacturaSimplificada t-out="'S' if is_simplified else 'N'"/>
</t>
<FechaExpedicionFactura t-else="" t-out="format_date(invoice._get_l10n_es_tbai_signature_and_date()[1])"/>
<t t-if="is_refund">
<FacturaEmitidaSustitucionSimplificada t-out="'S' if (is_simplified and recipient) else 'N'"/>
<FacturaRectificativa>
<Codigo t-out="credit_note_code"/>
<Tipo>I</Tipo>
<!-- NOTE: could also allow credit note Tipo 'S' (optional, tipo I already supported by SII)
<ImporteRectificacionSustitutiva>
<BaseRectificada>180.00</BaseRectificada>
<CuotaRectificada>20.21</CuotaRectificada>
</ImporteRectificacionSustitutiva> -->
</FacturaRectificativa>
<FacturasRectificadasSustituidas>
<IDFacturaRectificadaSustituida>
<!-- NOTE: could support issuing a single credit note for multiple invoices (optional) -->
<t t-set="seq_and_num" t-value="credit_note_invoice._get_l10n_es_tbai_sequence_and_number()"/>
<SerieFactura t-out="seq_and_num[0]"/>
<NumFactura t-out="seq_and_num[1]"/>
<FechaExpedicionFactura t-out="format_date(credit_note_invoice._get_l10n_es_tbai_signature_and_date()[1])"/>
</IDFacturaRectificadaSustituida>
</FacturasRectificadasSustituidas>
</t>
</CabeceraFactura>
<DatosFactura t-if="is_emission">
<DescripcionFactura t-out="invoice.invoice_origin or 'manual'"/>
<DetallesFactura>
<IDDetalleFactura t-foreach="invoice_lines" t-as="line_values">
<t t-set="line" t-value="line_values['line']"/>
<DescripcionDetalle t-out="line_values['description']"/>
<Cantidad t-out="format_float(line.quantity)"/>
<ImporteUnitario t-out="format_float(line_values['unit_price'])"/>
<Descuento t-out="format_float(line_values['discount'])"/>
<ImporteTotal t-out="format_float(line_values['total'])"/>
</IDDetalleFactura>
</DetallesFactura>
<ImporteTotalFactura t-out="format_float(amount_total)"/>
<!-- <RetencionSoportada/> NOTE (potentially has to be computed/decided manually) -->
<!-- <BaseImponibleACoste/> NOTE (only applicable with ClaveRegimenIvaOpTrascendencia 06, not supported yet) -->
<Claves>
<IDClave t-foreach="regime_key" t-as="key">
<ClaveRegimenIvaOpTrascendencia t-out="key"/>
</IDClave>
</Claves>
</DatosFactura>
<TipoDesglose t-if="is_emission">
<DesgloseFactura t-if="'DesgloseFactura' in invoice_info">
<t t-call="l10n_es_edi_tbai.template_invoice_desglose">
<t t-set="desglose" t-value="invoice_info['DesgloseFactura']"/>
</t>
</DesgloseFactura>
<DesgloseTipoOperacion t-else="">
<t t-set="invoice_info" t-value="invoice_info['DesgloseTipoOperacion']"/>
<PrestacionServicios t-if="invoice_info.get('PrestacionServicios')">
<t t-call="l10n_es_edi_tbai.template_invoice_desglose">
<t t-set="desglose" t-value="invoice_info['PrestacionServicios']"/>
</t>
</PrestacionServicios>
<Entrega t-if="invoice_info.get('Entrega')">
<t t-call="l10n_es_edi_tbai.template_invoice_desglose">
<t t-set="desglose" t-value="invoice_info['Entrega']"/>
</t>
</Entrega>
</DesgloseTipoOperacion>
</TipoDesglose>
</template>
<template id="template_invoice_desglose">
<Sujeta t-if="desglose.get('Sujeta')">
<t t-set="sujeta" t-value="desglose['Sujeta']"/>
<Exenta t-if="sujeta.get('Exenta')">
<DetalleExenta t-foreach="sujeta['Exenta']['DetalleExenta']" t-as="exenta">
<CausaExencion t-out="exenta['CausaExencion']"/>
<BaseImponible t-out="format_float(exenta['BaseImponible'])"/>
</DetalleExenta>
</Exenta>
<NoExenta t-if="sujeta.get('NoExenta')">
<DetalleNoExenta t-if="desglose['S1']">
<TipoNoExenta t-out="'S1'"/>
<DesgloseIVA>
<DetalleIVA t-foreach="desglose['S1']" t-as="detalle">
<BaseImponible t-out="format_float(detalle['BaseImponible'])"/>
<TipoImpositivo t-out="format_float(detalle['TipoImpositivo'])"/>
<CuotaImpuesto t-out="format_float(detalle['CuotaRepercutida'])"/>
<TipoRecargoEquivalencia t-if="detalle.get('TipoRecargoEquivalencia')" t-out="format_float(detalle['TipoRecargoEquivalencia'])"/>
<CuotaRecargoEquivalencia t-if="detalle.get('CuotaRecargoEquivalencia')" t-out="format_float(detalle['CuotaRecargoEquivalencia'])"/>
<OperacionEnRecargoDeEquivalenciaORegimenSimplificado t-out="'S' if is_simplified else 'N'"/>
</DetalleIVA>
</DesgloseIVA>
</DetalleNoExenta>
<DetalleNoExenta t-if="desglose['S2']">
<TipoNoExenta t-out="'S2'"/>
<DesgloseIVA>
<DetalleIVA t-foreach="desglose['S2']" t-as="detalle">
<BaseImponible t-out="format_float(detalle['BaseImponible'])"/>
</DetalleIVA>
</DesgloseIVA>
</DetalleNoExenta>
</NoExenta>
</Sujeta>
<NoSujeta t-if="desglose.get('NoSujeta')">
<t t-set="no_sujeta" t-value="desglose['NoSujeta']"/>
<DetalleNoSujeta>
<Causa>RL</Causa>
<!-- NOTE: Causa should be
'OT' if 'the' ClaveRegimenIvaOpTrascendencia == 10
'RL' if 'some' ClaveRegimenIvaOpTrascendencia == 08
BUT those are not supported yet-->
<Importe t-out="no_sujeta.get('ImportePorArticulos7_14_Otros')"/>
<Importe t-out="no_sujeta.get('ImporteTAIReglasLocalizacion')"/>
</DetalleNoSujeta>
</NoSujeta>
</template>
<template id="template_digital_signature">
<ds:Signature t-att-Id="dsig['signature_id']" xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
<ds:SignedInfo>
<ds:CanonicalizationMethod Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315"/>
<ds:SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"/>
<ds:Reference t-att-Id="dsig['reference_uri']" Type="http://www.w3.org/2000/09/xmldsig#Object" URI="">
<ds:Transforms>
<ds:Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/>
</ds:Transforms>
<ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/>
<ds:DigestValue></ds:DigestValue>
</ds:Reference>
<ds:Reference Type="http://uri.etsi.org/01903#SignedProperties" t-attf-URI="##{dsig['sigproperties_id']}">
<ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/>
<ds:DigestValue></ds:DigestValue>
</ds:Reference>
<ds:Reference t-attf-URI="##{dsig['keyinfo_id']}">
<ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/>
<ds:DigestValue></ds:DigestValue>
</ds:Reference>
</ds:SignedInfo>
<ds:SignatureValue></ds:SignatureValue>
<ds:KeyInfo t-att-Id="dsig['keyinfo_id']">
<ds:X509Data>
<ds:X509Certificate t-out="dsig['x509_certificate']"/>
</ds:X509Data>
<ds:KeyValue>
<ds:RSAKeyValue>
<ds:Modulus t-out="dsig['public_modulus']"/>
<ds:Exponent t-out="dsig['public_exponent']"/>
</ds:RSAKeyValue>
</ds:KeyValue>
</ds:KeyInfo>
<ds:Object>
<xades:QualifyingProperties xmlns:xades="http://uri.etsi.org/01903/v1.3.2#" t-attf-Target="##{dsig['signature_id']}">
<xades:SignedProperties t-att-Id="dsig['sigproperties_id']">
<xades:SignedSignatureProperties>
<xades:SigningTime t-out="dsig['iso_now']"/>
<xades:SigningCertificateV2>
<xades:Cert>
<xades:CertDigest>
<ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/>
<ds:DigestValue t-out="dsig['sigcertif_digest']"/>
</xades:CertDigest>
<xades:IssuerSerial>
<ds:X509IssuerName t-out="dsig['x509_issuer_description']"/>
<ds:X509SerialNumber t-out="dsig['x509_serial_number']"/>
</xades:IssuerSerial>
</xades:Cert>
</xades:SigningCertificateV2>
<xades:SignaturePolicyIdentifier>
<xades:SignaturePolicyId>
<xades:SigPolicyId>
<xades:Identifier t-out="dsig['sigpolicy_url']"/>
</xades:SigPolicyId>
<xades:SigPolicyHash>
<ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/>
<ds:DigestValue t-out="dsig['sigpolicy_digest']"/>
</xades:SigPolicyHash>
</xades:SignaturePolicyId>
</xades:SignaturePolicyIdentifier>
</xades:SignedSignatureProperties>
</xades:SignedProperties>
</xades:QualifyingProperties>
</ds:Object>
</ds:Signature>
</template>
</data>
</odoo>
File added
File added
File added
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="l10n_es.demo_company_es" model="res.company">
<field name="l10n_es_tbai_tax_agency">gipuzkoa</field>
</record>
</odoo>
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="l10n_es_tbai_partner_spanish_filemon" model="res.partner">
<field name="name">Mortadelas Filemón (ES)</field>
<field name="is_company" eval="True"/>
<field name="street">Calle Calipso 18</field>
<field name="city">Mairena del Aljarafe</field>
<field name="zip">41927</field>
<field name="country_id" ref="base.es"/>
<field name="vat">ES86762599K</field>
</record>
<record id="l10n_es_tbai_partner_european_lagaffe" model="res.partner">
<field name="name">Frites Lagaffe (BE)</field>
<field name="is_company" eval="True"/>
<field name="street">321 Rue sans souci</field>
<field name="city">Bruxelles</field>
<field name="zip">1050</field>
<field name="country_id" ref="base.be"/>
<field name="vat">BE789923062</field>
</record>
</odoo>
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import account_edi_document
from . import account_edi_format
from . import account_move
from . import ir_attachment
from . import l10n_es_edi_tbai_certificate
from . import res_company
from . import res_config_settings
from . import xml_utils
from . import l10n_es_edi_tbai_agencies
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
class AccountEdiDocument(models.Model):
_inherit = 'account.edi.document'
def _prepare_jobs(self):
"""
If there is a job to process that may already be part of the chain (posted invoice that timeout'ed),
Re-places it at the beginning of the list.
"""
# EXTENDS account_edi
jobs = super()._prepare_jobs()
if len(jobs) > 1:
move_first_index = 0
for index, job in enumerate(jobs):
documents = job['documents']
if any(d.edi_format_id.code == 'es_tbai' and d.state == 'to_send' and d.move_id.l10n_es_tbai_chain_index for d in documents):
move_first_index = index
break
jobs = [jobs[move_first_index]] + jobs[:move_first_index] + jobs[move_first_index + 1:]
return jobs
This diff is collapsed.
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from base64 import b64decode, b64encode
from datetime import datetime
from re import sub as regex_sub
from lxml import etree
from odoo import _, api, fields, models
from odoo.addons.l10n_es_edi_tbai.models.l10n_es_edi_tbai_agencies import get_key
from odoo.exceptions import UserError
L10N_ES_TBAI_CRC8_TABLE = [
0x00, 0x07, 0x0E, 0x09, 0x1C, 0x1B, 0x12, 0x15, 0x38, 0x3F, 0x36, 0x31, 0x24, 0x23, 0x2A, 0x2D,
0x70, 0x77, 0x7E, 0x79, 0x6C, 0x6B, 0x62, 0x65, 0x48, 0x4F, 0x46, 0x41, 0x54, 0x53, 0x5A, 0x5D,
0xE0, 0xE7, 0xEE, 0xE9, 0xFC, 0xFB, 0xF2, 0xF5, 0xD8, 0xDF, 0xD6, 0xD1, 0xC4, 0xC3, 0xCA, 0xCD,
0x90, 0x97, 0x9E, 0x99, 0x8C, 0x8B, 0x82, 0x85, 0xA8, 0xAF, 0xA6, 0xA1, 0xB4, 0xB3, 0xBA, 0xBD,
0xC7, 0xC0, 0xC9, 0xCE, 0xDB, 0xDC, 0xD5, 0xD2, 0xFF, 0xF8, 0xF1, 0xF6, 0xE3, 0xE4, 0xED, 0xEA,
0xB7, 0xB0, 0xB9, 0xBE, 0xAB, 0xAC, 0xA5, 0xA2, 0x8F, 0x88, 0x81, 0x86, 0x93, 0x94, 0x9D, 0x9A,
0x27, 0x20, 0x29, 0x2E, 0x3B, 0x3C, 0x35, 0x32, 0x1F, 0x18, 0x11, 0x16, 0x03, 0x04, 0x0D, 0x0A,
0x57, 0x50, 0x59, 0x5E, 0x4B, 0x4C, 0x45, 0x42, 0x6F, 0x68, 0x61, 0x66, 0x73, 0x74, 0x7D, 0x7A,
0x89, 0x8E, 0x87, 0x80, 0x95, 0x92, 0x9B, 0x9C, 0xB1, 0xB6, 0xBF, 0xB8, 0xAD, 0xAA, 0xA3, 0xA4,
0xF9, 0xFE, 0xF7, 0xF0, 0xE5, 0xE2, 0xEB, 0xEC, 0xC1, 0xC6, 0xCF, 0xC8, 0xDD, 0xDA, 0xD3, 0xD4,
0x69, 0x6E, 0x67, 0x60, 0x75, 0x72, 0x7B, 0x7C, 0x51, 0x56, 0x5F, 0x58, 0x4D, 0x4A, 0x43, 0x44,
0x19, 0x1E, 0x17, 0x10, 0x05, 0x02, 0x0B, 0x0C, 0x21, 0x26, 0x2F, 0x28, 0x3D, 0x3A, 0x33, 0x34,
0x4E, 0x49, 0x40, 0x47, 0x52, 0x55, 0x5C, 0x5B, 0x76, 0x71, 0x78, 0x7F, 0x6A, 0x6D, 0x64, 0x63,
0x3E, 0x39, 0x30, 0x37, 0x22, 0x25, 0x2C, 0x2B, 0x06, 0x01, 0x08, 0x0F, 0x1A, 0x1D, 0x14, 0x13,
0xAE, 0xA9, 0xA0, 0xA7, 0xB2, 0xB5, 0xBC, 0xBB, 0x96, 0x91, 0x98, 0x9F, 0x8A, 0x8D, 0x84, 0x83,
0xDE, 0xD9, 0xD0, 0xD7, 0xC2, 0xC5, 0xCC, 0xCB, 0xE6, 0xE1, 0xE8, 0xEF, 0xFA, 0xFD, 0xF4, 0xF3
]
class AccountMove(models.Model):
_inherit = 'account.move'
# Stored fields
l10n_es_tbai_chain_index = fields.Integer(
string="TicketBAI chain index",
help="Invoice index in chain, set if and only if an in-chain XML was submitted and did not error",
copy=False, readonly=True,
)
# Stored XML Binaries
l10n_es_tbai_post_xml = fields.Binary(
attachment=True, readonly=True, copy=False,
string="Submission XML",
help="Submission XML sent to TicketBAI. Kept if accepted or no response (timeout), cleared otherwise.",
)
l10n_es_tbai_cancel_xml = fields.Binary(
attachment=True, readonly=True, copy=False,
string="Cancellation XML",
help="Cancellation XML sent to TicketBAI. Kept if accepted or no response (timeout), cleared otherwise.",
)
# Non-stored fields
l10n_es_tbai_is_required = fields.Boolean(
string="TicketBAI required",
help="Is the Basque EDI (TicketBAI) needed ?",
compute="_compute_l10n_es_tbai_is_required",
)
# Optional fields
l10n_es_tbai_refund_reason = fields.Selection(
selection=[
('R1', "R1: Art. 80.1, 80.2, 80.6 and rights founded error"),
('R2', "R2: Art. 80.3"),
('R3', "R3: Art. 80.4"),
('R4', "R4: Art. 80 - other"),
('R5', "R5: Factura rectificativa en facturas simplificadas"),
],
string="Invoice Refund Reason Code (TicketBai)",
help="BOE-A-1992-28740. Ley 37/1992, de 28 de diciembre, del Impuesto sobre el "
"Valor Añadido. Artículo 80. Modificación de la base imponible.",
copy=False,
)
# -------------------------------------------------------------------------
# API-DECORATED & EXTENDED METHODS
# -------------------------------------------------------------------------
@api.depends('move_type', 'company_id')
def _compute_l10n_es_tbai_is_required(self):
for move in self:
move.l10n_es_tbai_is_required = move.is_sale_document() \
and move.country_code == 'ES' \
and move.company_id.l10n_es_tbai_tax_agency
@api.depends('state', 'edi_document_ids.state')
def _compute_show_reset_to_draft_button(self):
# EXTENDS account_edi account.move
super()._compute_show_reset_to_draft_button()
for move in self:
if move.l10n_es_tbai_chain_index:
move.show_reset_to_draft_button = False
def button_draft(self):
# EXTENDS account account.move
for move in self:
if move.l10n_es_tbai_chain_index and not move.edi_state == 'cancelled':
# NOTE this last condition (state is cancelled) is there because
# _postprocess_cancel_edi_results calls button_draft before
# calling button_cancel. Draft button does not appear for user.
raise UserError(_("You cannot reset to draft an entry that has been posted to TicketBAI's chain"))
super().button_draft()
@api.ondelete(at_uninstall=False)
def _l10n_es_tbai_unlink_except_in_chain(self):
# Prevent deleting moves that are part of the TicketBAI chain
if not self._context.get('force_delete') and any(m.l10n_es_tbai_chain_index for m in self):
raise UserError(_('You cannot delete a move that has a TicketBAI chain id.'))
# -------------------------------------------------------------------------
# HELPER METHODS
# -------------------------------------------------------------------------
def _l10n_es_tbai_is_in_chain(self):
"""
True iff invoice has been posted to the chain and confirmed by govt.
Note that cancelled invoices remain part of the chain.
"""
tbai_doc_ids = self.edi_document_ids.filtered(lambda d: d.edi_format_id.code == 'es_tbai')
return self.l10n_es_tbai_is_required \
and len(tbai_doc_ids) > 0 \
and not any(tbai_doc_ids.filtered(lambda d: d.state == 'to_send'))
def _get_l10n_es_tbai_sequence_and_number(self):
"""Get the TicketBAI sequence a number values for this invoice."""
self.ensure_one()
sequence, number = self.name.rsplit('/', 1) # NOTE non-decimal characters should not appear in the number
sequence = regex_sub(r"[^0-9A-Za-z.\_\-\/]", "", sequence) # remove forbidden characters
sequence = regex_sub(r"\s+", " ", sequence) # no more than one consecutive whitespace allowed
# NOTE (optional) not recommended to use chars out of ([0123456789ABCDEFGHJKLMNPQRSTUVXYZ.\_\-\/ ])
sequence += "TEST" if self.company_id.l10n_es_edi_test_env else ""
return sequence, number
def _get_l10n_es_tbai_signature_and_date(self):
"""
Get the TicketBAI signature and registration date for this invoice.
Values are read directly from the 'post' XMLs submitted to the government \
(the 'cancel' XML is ignored).
The registration date is the date the invoice was registered into the govt's TicketBAI servers.
"""
self.ensure_one()
vals = self._get_l10n_es_tbai_values_from_xml({
'signature': r'.//{http://www.w3.org/2000/09/xmldsig#}SignatureValue',
'registration_date': r'.//CabeceraFactura//FechaExpedicionFactura'
})
# RFC2045 - Base64 Content-Transfer-Encoding (page 25)
# Any characters outside of the base64 alphabet are to be ignored in base64-encoded data.
signature = vals['signature'].replace("\n", "")
registration_date = datetime.strptime(vals['registration_date'], '%d-%m-%Y')
return signature, registration_date
def _get_l10n_es_tbai_id(self):
"""Get the TicketBAI ID (TBAID) as defined in the TicketBAI doc."""
self.ensure_one()
signature, registration_date = self._get_l10n_es_tbai_signature_and_date()
company = self.company_id
tbai_id_no_crc = '-'.join([
'TBAI',
str(company.vat[2:] if company.vat.startswith('ES') else company.vat),
datetime.strftime(registration_date, '%d%m%y'),
signature[:13],
'' # CRC
])
return tbai_id_no_crc + self._l10n_es_edi_tbai_crc8(tbai_id_no_crc)
def _get_l10n_es_tbai_qr(self):
"""Returns the URL for the invoice's QR code. We can not use url_encode because it escapes / e.g."""
self.ensure_one()
if not self._l10n_es_tbai_is_in_chain():
return ''
company = self.company_id
sequence, number = self._get_l10n_es_tbai_sequence_and_number()
tbai_qr_no_crc = get_key(company.l10n_es_tbai_tax_agency, 'qr_url_', company.l10n_es_edi_test_env) + '?' + '&'.join([
'id=' + self._get_l10n_es_tbai_id(),
's=' + sequence,
'nf=' + number,
'i=' + self._get_l10n_es_tbai_values_from_xml({'importe': r'.//ImporteTotalFactura'})['importe']
])
qr_url = tbai_qr_no_crc + '&cr=' + self._l10n_es_edi_tbai_crc8(tbai_qr_no_crc)
return qr_url
def _l10n_es_edi_tbai_crc8(self, data):
crc = 0x0
for c in data:
crc = L10N_ES_TBAI_CRC8_TABLE[(crc ^ ord(c)) & 0xFF]
return '{:03d}'.format(crc & 0xFF)
def _get_l10n_es_tbai_values_from_xml(self, xpaths):
"""
This function reads values directly from the 'post' XML submitted to the government \
(the 'cancel' XML is ignored).
"""
res = dict.fromkeys(xpaths, '')
doc_xml = self._get_l10n_es_tbai_submitted_xml()
if doc_xml is None:
return res
for key, value in xpaths.items():
res[key] = doc_xml.find(value).text
return res
def _get_l10n_es_tbai_submitted_xml(self, cancel=False):
"""Returns the XML object representing the post or cancel document."""
self.ensure_one()
self = self.with_context(bin_size=False)
doc = self.l10n_es_tbai_cancel_xml if cancel else self.l10n_es_tbai_post_xml
if not doc:
return None
return etree.fromstring(b64decode(doc))
def _update_l10n_es_tbai_submitted_xml(self, xml_doc, cancel):
"""Updates the binary data of the post or cancel document, from its XML object."""
self.ensure_one()
b64_doc = b'' if xml_doc is None else b64encode(etree.tostring(xml_doc, encoding='UTF-8'))
if cancel:
self.l10n_es_tbai_cancel_xml = b64_doc
else:
self.l10n_es_tbai_post_xml = b64_doc
def _is_l10n_es_tbai_simplified(self):
return self.commercial_partner_id == self.env.ref("l10n_es_edi_sii.partner_simplified")
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models, api
from odoo.addons.l10n_es_edi_tbai.models.l10n_es_edi_tbai_agencies import get_key
from odoo.tools import xml_utils
class IrAttachment(models.Model):
_inherit = 'ir.attachment'
@api.model
def action_download_xsd_files(self):
"""
Downloads the TicketBAI XSD validation files if they don't already exist, for the active tax agency.
"""
xml_utils.load_xsd_files_from_url(
self.env, 'https://www.w3.org/TR/xmldsig-core/xmldsig-core-schema.xsd', 'xmldsig-core-schema.xsd',
xsd_name_prefix='l10n_es_edi_tbai')
for agency in ['gipuzkoa', 'araba', 'bizkaia']:
urls = get_key(agency, 'xsd_url')
names = get_key(agency, 'xsd_name')
# For Bizkaia, one url per XSD (post/cancel)
if isinstance(urls, dict):
for move_type in ('post', 'cancel'):
xml_utils.load_xsd_files_from_url(
self.env, urls[move_type], names[move_type],
xsd_name_prefix='l10n_es_edi_tbai',
)
# For other agencies, single url to zip file (only keep the desired names)
else:
xml_utils.load_xsd_files_from_url(
self.env, urls, # NOTE: file_name discarded when XSDs bundled in ZIPs
xsd_name_prefix='l10n_es_edi_tbai',
xsd_names_filter=list(names.values()),
)
return super().action_download_xsd_files()
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
# ===== TicketBAI TAX AGENCY METADATA =====
def get_key(agency, key, is_test_env=True):
"""
Helper method to retrieve specific data about certain agencies.
Notable differences in structure, by key
- Any key ending with '_':
These keys have two variants: 'test' and 'prod'. The parameter `is_test_env` matters for those keys only.
- 'xsd_url':
Araba and Gipuzkoa each have a single URL pointing to a zip file (which may contain many XSDs)
Bizkaia has two URLs for post/cancel XSDs: in that case a dict of strings is returned (instead of a single string)
"""
urls = {
'araba': URLS_ARABA,
'bizkaia': URLS_BIZKAIA,
'gipuzkoa': URLS_GIPUZKOA,
}[agency]
if key.endswith('_'):
key += 'test' if is_test_env else 'prod'
return urls[key]
URLS_ARABA = {
'sigpolicy_url': 'https://ticketbai.araba.eus/tbai/sinadura/',
'sigpolicy_digest': '4Vk3uExj7tGn9DyUCPDsV9HRmK6KZfYdRiW3StOjcQA=',
'xsd_url': 'https://web.araba.eus/documents/105044/5608600/TicketBai12+%282%29.zip',
'xsd_name': {
'post': 'ticketBaiV1-2.xsd',
'cancel': 'Anula_ticketBaiV1-2.xsd',
},
'post_url_test': 'https://pruebas-ticketbai.araba.eus/TicketBAI/v1/facturas/',
'post_url_prod': 'https://ticketbai.araba.eus/TicketBAI/v1/facturas/',
'qr_url_test': 'https://pruebas-ticketbai.araba.eus/tbai/qrtbai/',
'qr_url_prod': 'https://ticketbai.araba.eus/tbai/qrtbai/',
'cancel_url_test': 'https://pruebas-ticketbai.araba.eus/TicketBAI/v1/anulaciones/',
'cancel_url_prod': 'https://ticketbai.araba.eus/TicketBAI/v1/anulaciones/',
}
URLS_BIZKAIA = {
'sigpolicy_url': 'https://www.batuz.eus/fitxategiak/batuz/ticketbai/sinadura_elektronikoaren_zehaztapenak_especificaciones_de_la_firma_electronica_v1_0.pdf',
'sigpolicy_digest': 'Quzn98x3PMbSHwbUzaj5f5KOpiH0u8bvmwbbbNkO9Es=',
'xsd_url': {
'post': 'https://www.batuz.eus/fitxategiak/batuz/ticketbai/ticketBaiV1-2-1.xsd',
'cancel': 'https://www.batuz.eus/fitxategiak/batuz/ticketbai/Anula_ticketBaiV1-2-1.xsd',
},
'xsd_name': {
'post': 'ticketBaiV1-2-1.xsd',
'cancel': 'Anula_ticketBaiV1-2-1.xsd',
},
'post_url_test': 'https://pruesarrerak.bizkaia.eus/N3B4000M/aurkezpena',
'post_url_prod': 'https://sarrerak.bizkaia.eus/N3B4000M/aurkezpena',
'qr_url_test': 'https://batuz.eus/QRTBAI/',
'qr_url_prod': 'https://batuz.eus/QRTBAI/',
'cancel_url_test': 'https://pruesarrerak.bizkaia.eus/N3B4000M/aurkezpena',
'cancel_url_prod': 'https://sarrerak.bizkaia.eus/N3B4000M/aurkezpena',
}
URLS_GIPUZKOA = {
'sigpolicy_url': 'https://www.gipuzkoa.eus/TicketBAI/signature',
'sigpolicy_digest': '6NrKAm60o7u62FUQwzZew24ra2ve9PRQYwC21AM6In0=',
'xsd_url': 'https://www.gipuzkoa.eus/documents/2456431/13761107/Esquemas+de+archivos+XSD+de+env%C3%ADo+y+anulaci%C3%B3n+de+factura_1_2.zip/2d116f8e-4d3a-bff0-7b03-df1cbb07ec52',
'xsd_name': {
'post': 'ticketBaiV1-2-1.xsd',
'cancel': 'Anula_ticketBaiV1-2-1.xsd',
},
'post_url_test': 'https://tbai-prep.egoitza.gipuzkoa.eus/WAS/HACI/HTBRecepcionFacturasWEB/rest/recepcionFacturas/alta',
'post_url_prod': 'https://tbai-z.egoitza.gipuzkoa.eus/sarrerak/alta',
'qr_url_test': 'https://tbai.prep.gipuzkoa.eus/qr/',
'qr_url_prod': 'https://tbai.egoitza.gipuzkoa.eus/qr/',
'cancel_url_test': 'https://tbai-prep.egoitza.gipuzkoa.eus/WAS/HACI/HTBRecepcionFacturasWEB/rest/recepcionFacturas/anulacion',
'cancel_url_prod': 'https://tbai-z.egoitza.gipuzkoa.eus/sarrerak/baja',
}
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from base64 import b64decode
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.serialization import pkcs12
from odoo import models
class Certificate(models.Model):
_inherit = 'l10n_es_edi.certificate'
# -------------------------------------------------------------------------
# HELPERS
# -------------------------------------------------------------------------
def _get_key_pair(self):
self.ensure_one()
if not self.password:
return None, None
private_key, certificate, dummy = pkcs12.load_key_and_certificates(
b64decode(self.with_context(bin_size=False).content), # Without bin_size=False, size is returned instead of content
self.password.encode(),
backend=default_backend(),
)
return private_key, certificate
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import markupsafe
from odoo import _, api, fields, models, release
# === TBAI license values ===
L10N_ES_TBAI_LICENSE_DICT = {
'production': {
'license_name': _('Production license'), # all agencies
'license_number': 'TBAIGI5A266A7CCDE1EC',
'license_nif': 'N0251909H',
'software_name': 'Odoo SA',
'software_version': release.version,
},
'araba': {
'license_name': _('Test license (Araba)'),
'license_number': 'TBAIARbjjMClHKH00849',
'license_nif': 'N0251909H',
'software_name': 'Odoo SA',
'software_version': release.version,
},
'bizkaia': {
'license_name': _('Test license (Bizkaia)'),
'license_number': 'TBAIBI00000000PRUEBA',
'license_nif': 'A99800005',
'software_name': 'SOFTWARE GARANTE TICKETBAI PRUEBA',
'software_version': '1.0',
},
'gipuzkoa': {
'license_name': _('Test license (Gipuzkoa)'),
'license_number': 'TBAIGIPRE00000000965',
'license_nif': 'N0251909H',
'software_name': 'Odoo SA',
'software_version': release.version,
},
}
class ResCompany(models.Model):
_inherit = 'res.company'
# === TBAI config ===
l10n_es_tbai_tax_agency = fields.Selection(
string="Tax Agency for TBAI",
selection=[
('araba', "Hacienda Foral de Araba"), # es-vi (region code)
('bizkaia', "Hacienda Foral de Bizkaia"), # es-bi
('gipuzkoa', "Hacienda Foral de Gipuzkoa"), # es-ss
],
)
l10n_es_tbai_license_html = fields.Html(
string="TicketBAI license",
compute='_compute_l10n_es_tbai_license_html',
)
# === TBAI CHAIN HEAD ===
l10n_es_tbai_chain_sequence_id = fields.Many2one(
comodel_name='ir.sequence',
string='TicketBai account.move chain sequence',
readonly=True,
copy=False,
)
@api.depends('country_id', 'l10n_es_edi_test_env', 'l10n_es_tbai_tax_agency')
def _compute_l10n_es_tbai_license_html(self):
for company in self:
license_dict = company._get_l10n_es_tbai_license_dict()
if license_dict:
license_dict.update({
'tr_nif': _('Licence NIF'),
'tr_number': _('Licence number'),
'tr_name': _('Software name'),
'tr_version': _('Software version')
})
company.l10n_es_tbai_license_html = markupsafe.Markup('''
<strong>{license_name}</strong><br/>
<p>
<strong>{tr_nif}: </strong>{license_nif}<br/>
<strong>{tr_number}: </strong>{license_number}<br/>
<strong>{tr_name}: </strong>{software_name}<br/>
<strong>{tr_version}: </strong>{software_version}<br/>
</p>''').format(**license_dict)
else:
company.l10n_es_tbai_license_html = markupsafe.Markup('''
<strong>{tr_no_license}</strong>''').format(tr_no_license=_('TicketBAI is not configured'))
def _get_l10n_es_tbai_license_dict(self):
self.ensure_one()
if self.country_code == 'ES' and self.l10n_es_tbai_tax_agency:
if self.l10n_es_edi_test_env: # test env: each agency has its test license
license_key = self.l10n_es_tbai_tax_agency
else: # production env: only one license
license_key = 'production'
return L10N_ES_TBAI_LICENSE_DICT[license_key]
else:
return {}
def _get_l10n_es_tbai_next_chain_index(self):
if not self.l10n_es_tbai_chain_sequence_id:
self.l10n_es_tbai_chain_sequence_id = self.env['ir.sequence'].create({
'name': f'TicketBAI account move sequence for {self.name} (id: {self.id})',
'code': f'l10n_es.edi.tbai.account.move.{self.id}',
'implementation': 'no_gap',
'company_id': self.id,
})
return self.l10n_es_tbai_chain_sequence_id.next_by_id()
def _get_l10n_es_tbai_last_posted_invoice(self, being_posted=False):
"""
Returns the last invoice posted to this company's chain.
That invoice may have been received by the govt or not (eg. in case of a timeout).
Only upon confirmed reception/refusal of that invoice can another one be posted.
:param being_posted: next invoice to be posted on the chain, ignored in search domain
"""
domain = [
('l10n_es_tbai_chain_index', '!=', 0),
('company_id', '=', self.id)
]
if being_posted:
domain.append(('l10n_es_tbai_chain_index', '!=', being_posted.l10n_es_tbai_chain_index))
# NOTE: being_posted may not have a chain index at all (if being posted for the first time)
return self.env['account.move'].search(domain, limit=1, order='l10n_es_tbai_chain_index desc')
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings'
l10n_es_tbai_tax_agency = fields.Selection(related='company_id.l10n_es_tbai_tax_agency', readonly=False)
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import hashlib
import re
from base64 import b64encode, encodebytes
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import padding
from lxml import etree
from odoo.tools.xml_utils import cleanup_xml_node
# Utility Methods for Basque Country's TicketBAI XML-related stuff.
NS_MAP = {'': 'http://www.w3.org/2000/09/xmldsig#'} # default namespace matches signature's `ds:``
def canonicalize_node(node):
"""
Returns the canonical (C14N 1.0, without comments, non exclusive) representation of node.
Speficied in: https://www.w3.org/TR/2001/REC-xml-c14n-20010315
Required for computing digests and signatures.
Returns an UTF-8 encoded bytes string.
"""
node = etree.fromstring(node) if isinstance(node, str) else node
return etree.tostring(node, method='c14n', with_comments=False, exclusive=False)
def cleanup_xml_signature(xml_sig):
"""
Cleanups the content of the provided string representation of an XML signature.
In addition, removes all line feeds for the ds:Object element.
Turns self-closing tags into regular tags (with an empty string content)
as the former may not be supported by some signature validation implementations.
Returns an etree._Element
"""
sig_elem = cleanup_xml_node(xml_sig, remove_blank_nodes=False, indent_level=-1)
etree.indent(sig_elem, space='') # removes indentation
for elem in sig_elem.find('Object', namespaces=NS_MAP).iter():
if elem.text == '\n':
elem.text = '' # keeps the signature in one line, prevents self-closing tags
elem.tail = '' # removes line feed and whitespace after the tag
return sig_elem
def get_uri(uri, reference, base_uri):
"""
Returns the content within `reference` that is identified by `uri`.
Canonicalization is used to convert node reference to an octet stream.
- The base_uri points to the whole document tree, without the signature
https://www.w3.org/TR/xmldsig-core/#sec-EnvelopedSignature
- URIs starting with # are same-document references
https://www.w3.org/TR/xmldsig-core/#sec-URI
Returns an UTF-8 encoded bytes string.
"""
node = reference.getroottree()
if uri == base_uri:
# Empty URI: whole document, without signature
return canonicalize_node(
re.sub(
r'^[^\n]*<ds:Signature.*<\/ds:Signature>', r'',
etree.tostring(node, encoding='unicode'),
flags=re.DOTALL | re.MULTILINE)
)
if uri.startswith('#'):
query = '//*[@*[local-name() = "Id" ]=$uri]' # case-sensitive 'Id'
results = node.xpath(query, uri=uri.lstrip('#'))
if len(results) == 1:
return canonicalize_node(results[0])
if len(results) > 1:
raise Exception("Ambiguous reference URI {} resolved to {} nodes".format(
uri, len(results)))
raise Exception(f"URI {uri!r} not found")
def calculate_references_digests(node, base_uri=''):
"""
Processes the references from node and computes their digest values as specified in
https://www.w3.org/TR/xmldsig-core/#sec-DigestMethod
https://www.w3.org/TR/xmldsig-core/#sec-DigestValue
"""
for reference in node.findall('Reference', namespaces=NS_MAP):
ref_node = get_uri(reference.get('URI', ''), reference, base_uri)
hash_digest = hashlib.new('sha256', ref_node).digest()
reference.find('DigestValue', namespaces=NS_MAP).text = b64encode(hash_digest)
def fill_signature(node, private_key):
"""
Uses private_key to sign the SignedInfo sub-node of `node`, as specified in:
https://www.w3.org/TR/xmldsig-core/#sec-SignatureValue
https://www.w3.org/TR/xmldsig-core/#sec-SignedInfo
"""
signed_info_xml = node.find('SignedInfo', namespaces=NS_MAP)
# During signature generation, the digest is computed over the canonical form of the document
signature = private_key.sign(
canonicalize_node(signed_info_xml),
padding.PKCS1v15(),
hashes.SHA256()
)
node.find('SignatureValue', namespaces=NS_MAP).text =\
bytes_as_block(signature)
def int_as_bytes(number):
"""
Converts an integer to an ASCII/UTF-8 byte string (with no leading zeroes).
"""
return number.to_bytes((number.bit_length() + 7) // 8, byteorder='big')
def bytes_as_block(string):
"""
Returns the passed string modified to include a line feed every `length` characters.
It may be recommended to keep length under 76:
https://www.w3.org/TR/2004/REC-xmlschema-2-20041028/#rf-maxLength
https://www.ietf.org/rfc/rfc2045.txt
"""
return encodebytes(string).decode()
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment