Initial commit: L10N_Europe packages

This commit is contained in:
Ernad Husremovic 2025-08-29 15:20:52 +02:00
commit 9803722600
2377 changed files with 380711 additions and 0 deletions

View file

@ -0,0 +1,55 @@
# Spain - TicketBAI
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.
## Installation
```bash
pip install odoo-bringout-oca-ocb-l10n_es_edi_tbai
```
## Dependencies
This addon depends on:
- l10n_es_edi_sii
## Manifest Information
- **Name**: Spain - TicketBAI
- **Version**: 1.0
- **Category**: Accounting/Localizations/EDI
- **License**: LGPL-3
- **Installable**: False
## Source
Based on [OCA/OCB](https://github.com/OCA/OCB) branch 16.0, addon `l10n_es_edi_tbai`.
## License
This package maintains the original LGPL-3 license from the upstream Odoo project.
## Documentation
- Overview: doc/OVERVIEW.md
- Architecture: doc/ARCHITECTURE.md
- Models: doc/MODELS.md
- Controllers: doc/CONTROLLERS.md
- Wizards: doc/WIZARDS.md
- Install: doc/INSTALL.md
- Usage: doc/USAGE.md
- Configuration: doc/CONFIGURATION.md
- Dependencies: doc/DEPENDENCIES.md
- Troubleshooting: doc/TROUBLESHOOTING.md
- FAQ: doc/FAQ.md

View file

@ -0,0 +1,32 @@
# Architecture
```mermaid
flowchart TD
U[Users] -->|HTTP| V[Views and QWeb Templates]
V --> C[Controllers]
V --> W[Wizards Transient Models]
C --> M[Models and ORM]
W --> M
M --> R[Reports]
DX[Data XML] --> M
S[Security ACLs and Groups] -. enforces .-> M
subgraph L10n_es_edi_tbai Module - l10n_es_edi_tbai
direction LR
M:::layer
W:::layer
C:::layer
V:::layer
R:::layer
S:::layer
DX:::layer
end
classDef layer fill:#eef8ff,stroke:#6ea8fe,stroke-width:1px
```
Notes
- Views include tree/form/kanban templates and report templates.
- Controllers provide website/portal routes when present.
- Wizards are UI flows implemented with `models.TransientModel`.
- Data XML loads data/demo records; Security defines groups and access.

View file

@ -0,0 +1,3 @@
# Configuration
Refer to Odoo settings for l10n_es_edi_tbai. Configure related models, access rights, and options as needed.

View file

@ -0,0 +1,3 @@
# Controllers
This module does not define custom HTTP controllers.

View file

@ -0,0 +1,5 @@
# Dependencies
This addon depends on:
- [l10n_es_edi_sii](../../odoo-bringout-oca-ocb-l10n_es_edi_sii)

View file

@ -0,0 +1,4 @@
# FAQ
- Q: Which Odoo version? A: 16.0 (OCA/OCB packaged).
- Q: How to enable? A: Start server with --addon l10n_es_edi_tbai or install in UI.

View file

@ -0,0 +1,7 @@
# Install
```bash
pip install odoo-bringout-oca-ocb-l10n_es_edi_tbai"
# or
uv pip install odoo-bringout-oca-ocb-l10n_es_edi_tbai"
```

View file

@ -0,0 +1,18 @@
# Models
Detected core models and extensions in l10n_es_edi_tbai.
```mermaid
classDiagram
class account_edi_document
class account_edi_format
class account_move
class ir_attachment
class l10n_es_edi_certificate
class res_company
class res_config_settings
```
Notes
- Classes show model technical names; fields omitted for brevity.
- Items listed under _inherit are extensions of existing models.

View file

@ -0,0 +1,6 @@
# Overview
Packaged Odoo addon: l10n_es_edi_tbai. Provides features documented in upstream Odoo 16 under this addon.
- Source: OCA/OCB 16.0, addon l10n_es_edi_tbai
- License: LGPL-3

View file

@ -0,0 +1,3 @@
# Reports
This module does not define custom reports.

View file

@ -0,0 +1,8 @@
# Security
This module does not define custom security rules or access controls beyond Odoo defaults.
Default Odoo security applies:
- Base user access through standard groups
- Model access inherited from dependencies
- No custom row-level security rules

View file

@ -0,0 +1,5 @@
# Troubleshooting
- Ensure Python and Odoo environment matches repo guidance.
- Check database connectivity and logs if startup fails.
- Validate that dependent addons listed in DEPENDENCIES.md are installed.

View file

@ -0,0 +1,7 @@
# Usage
Start Odoo including this addon (from repo root):
```bash
python3 scripts/nix_odoo_web_server.py --db-name mydb --addon l10n_es_edi_tbai
```

View file

@ -0,0 +1,3 @@
# Wizards
This module does not include UI wizards.

View file

@ -0,0 +1,5 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import models
from . import wizards

View file

@ -0,0 +1,44 @@
# -*- 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',
}

View file

@ -0,0 +1,9 @@
<?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>

View file

@ -0,0 +1,144 @@
<?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>
<template id="template_LROE_240_main_recibidas">
<lrpjframp:LROEPJ240FacturasRecibidasAltaModifPeticion
xmlns:lrpjframp="https://www.batuz.eus/fitxategiak/batuz/LROE/esquemas/LROE_PJ_240_2_FacturasRecibidas_AltaModifPeticion_V1_0_1.xsd"
t-if="is_emission"
t-call="l10n_es_edi_tbai.template_LROE_240_inner_recibidas"/>
<lrpjfrap:LROEPJ240FacturasRecibidasAnulacionPeticion
xmlns:lrpjfrap="https://www.batuz.eus/fitxategiak/batuz/LROE/esquemas/LROE_PJ_240_2_FacturasRecibidas_AnulacionPeticion_V1_0_0.xsd"
t-else=""
t-call="l10n_es_edi_tbai.template_LROE_240_inner_recibidas"/>
</template>
<template id="template_LROE_240_inner_recibidas">
<Cabecera>
<Modelo>240</Modelo>
<Capitulo>2</Capitulo>
<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>
<FacturasRecibidas>
<FacturaRecibida>
<t t-if="not is_emission"> <!-- cancel case -->
<IDRecibida>
<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]"/>
<FechaExpedicionFactura t-out="format_date(invoice.invoice_date)"/>
<EmisorFacturaRecibida>
<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>
</EmisorFacturaRecibida>
</IDRecibida>
</t>
<t t-else="">
<EmisorFacturaRecibida>
<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"/>
</EmisorFacturaRecibida>
<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]"/>
<FechaExpedicionFactura t-out="format_date(invoice.invoice_date)"/>
<FechaRecepcion t-out="format_date(invoice.date)"/>
<TipoFactura t-out="tipofactura"/>
<t t-if="is_refund">
<FacturaRectificativa>
<Codigo t-out="credit_note_code"/>
<Tipo>I</Tipo>
</FacturaRectificativa>
<FacturasRectificadasSustituidas t-if="credit_note_invoice">
<IDFacturaRectificadaSustituida>
<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.invoice_date)"/>
</IDFacturaRectificadaSustituida>
</FacturasRectificadasSustituidas>
</t>
</CabeceraFactura>
<DatosFactura>
<DescripcionOperacion t-out="invoice.ref"/>
<Claves>
<IDClave t-foreach="regime_key" t-as="key">
<ClaveRegimenIvaOpTrascendencia t-out="key"/>
</IDClave>
</Claves>
<ImporteTotalFactura t-out="format_float(amount_total)"/>
</DatosFactura>
<IVA>
<DetalleIVA t-foreach="iva_values" t-as="tax">
<CompraBienesCorrientesGastosBienesInversion t-out="tax['code']"/>
<InversionSujetoPasivo t-out="'N' if tax['rec'].l10n_es_type != 'sujeto_isp' else 'S'"/>
<BaseImponible t-out="format_float(tax['base'])"/>
<t t-if="tax['rec'].l10n_es_type == 'sujeto_agricultura'">
<PorcentajeCompensacionREAGYP t-out="tax['rec'].amount"/>
<ImporteCompensacionREAGYP t-out="format_float(tax['tax'])"/>
</t>
<t t-else="">
<TipoImpositivo t-out="tax['rec'].amount"/>
<CuotaIVASoportada t-out="format_float(tax['tax'])"/>
<CuotaIVADeducible t-out="format_float(tax['tax']) if tax['rec'].l10n_es_type != 'no_deducible' else '0.00'"/>
</t>
</DetalleIVA>
</IVA>
</t>
</FacturaRecibida>
</FacturasRecibidas>
</template>
</data>
</odoo>

View file

@ -0,0 +1,264 @@
<?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>
<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.l10n_es_tbai_post_xml and credit_note_invoice._get_l10n_es_tbai_signature_and_date()[1] or credit_note_invoice.invoice_date)"/>
</IDFacturaRectificadaSustituida>
</FacturasRectificadasSustituidas>
</t>
</CabeceraFactura>
<DatosFactura t-if="is_emission">
<DescripcionFactura t-out="invoice.invoice_origin and invoice.invoice_origin[:250] 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 t-if="amount_retention != 0.0" t-out="format_float(amount_retention)"/>
<!-- <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'])"/>
</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 t-out="nosujeto_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>

View file

@ -0,0 +1,6 @@
<?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>

View file

@ -0,0 +1,21 @@
<?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>

View file

@ -0,0 +1,451 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * l10n_es_edi_tbai
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 16.0+e\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-01-28 11:10+0000\n"
"PO-Revision-Date: 2025-01-28 11:13+0000\n"
"Last-Translator: Jairo Llopis <jairo@moduon.team>\n"
"Language-Team: \n"
"Language: es\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Generator: Poedit 3.4.4\n"
#. module: l10n_es_edi_tbai
#: model_terms:ir.ui.view,arch_db:l10n_es_edi_tbai.template_LROE_240_inner
#: model_terms:ir.ui.view,arch_db:l10n_es_edi_tbai.template_LROE_240_inner_recibidas
msgid "1.0"
msgstr "1.0"
#. module: l10n_es_edi_tbai
#: model_terms:ir.ui.view,arch_db:l10n_es_edi_tbai.template_LROE_240_inner
msgid "1.1"
msgstr "1.1"
#. module: l10n_es_edi_tbai
#: model_terms:ir.ui.view,arch_db:l10n_es_edi_tbai.template_LROE_240_inner
#: model_terms:ir.ui.view,arch_db:l10n_es_edi_tbai.template_LROE_240_inner_recibidas
msgid "240"
msgstr "240"
#. module: l10n_es_edi_tbai
#: model_terms:ir.ui.view,arch_db:l10n_es_edi_tbai.res_config_settings_view_form
msgid ""
"<span class=\"o_form_label\">Registro de Libros connection "
"SII/TicketBAI</span>"
msgstr ""
"<span class=\"o_form_label\">Conexión con el Registro de Libros mediante "
"SII/TicketBAI</span>"
#. module: l10n_es_edi_tbai
#: model:ir.model,name:l10n_es_edi_tbai.model_account_move_reversal
msgid "Account Move Reversal"
msgstr "Reversión de movimiento de cuenta"
#. module: l10n_es_edi_tbai
#: model:ir.model,name:l10n_es_edi_tbai.model_ir_attachment
msgid "Attachment"
msgstr "Archivo adjunto"
#. module: l10n_es_edi_tbai
#: model:ir.model.fields,help:l10n_es_edi_tbai.field_account_bank_statement_line__l10n_es_tbai_refund_reason
#: model:ir.model.fields,help:l10n_es_edi_tbai.field_account_move__l10n_es_tbai_refund_reason
#: model:ir.model.fields,help:l10n_es_edi_tbai.field_account_move_reversal__l10n_es_tbai_refund_reason
#: model:ir.model.fields,help:l10n_es_edi_tbai.field_account_payment__l10n_es_tbai_refund_reason
msgid ""
"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."
msgstr ""
"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."
#. module: l10n_es_edi_tbai
#: model:ir.model.fields,field_description:l10n_es_edi_tbai.field_account_bank_statement_line__l10n_es_tbai_cancel_xml
#: model:ir.model.fields,field_description:l10n_es_edi_tbai.field_account_move__l10n_es_tbai_cancel_xml
#: model:ir.model.fields,field_description:l10n_es_edi_tbai.field_account_payment__l10n_es_tbai_cancel_xml
msgid "Cancellation XML"
msgstr "XML de cancelación"
#. module: l10n_es_edi_tbai
#: model:ir.model.fields,help:l10n_es_edi_tbai.field_account_bank_statement_line__l10n_es_tbai_cancel_xml
#: model:ir.model.fields,help:l10n_es_edi_tbai.field_account_move__l10n_es_tbai_cancel_xml
#: model:ir.model.fields,help:l10n_es_edi_tbai.field_account_payment__l10n_es_tbai_cancel_xml
msgid ""
"Cancellation XML sent to TicketBAI. Kept if accepted or no response "
"(timeout), cleared otherwise."
msgstr ""
"XML de cancelación enviado a TicketBAI. Se mantiene si se acepta o no se "
"obtiene respuesta (tiempo de espera), de lo contrario, se borra."
#. module: l10n_es_edi_tbai
#: model:ir.model,name:l10n_es_edi_tbai.model_res_company
msgid "Companies"
msgstr "Compañías"
#. module: l10n_es_edi_tbai
#: model:ir.model,name:l10n_es_edi_tbai.model_res_config_settings
msgid "Config Settings"
msgstr "Ajustes de configuración"
#. module: l10n_es_edi_tbai
#: model:ir.model,name:l10n_es_edi_tbai.model_account_edi_format
msgid "EDI format"
msgstr "Formato EDI"
#. module: l10n_es_edi_tbai
#: model:ir.model,name:l10n_es_edi_tbai.model_account_edi_document
msgid "Electronic Document for an account.move"
msgstr "Documento electrónico de un account.move"
#. module: l10n_es_edi_tbai
#: model:ir.model.fields.selection,name:l10n_es_edi_tbai.selection__res_company__l10n_es_tbai_tax_agency__araba
msgid "Hacienda Foral de Araba"
msgstr "Hacienda Foral de la Diputación de Álava"
#. module: l10n_es_edi_tbai
#: model:ir.model.fields.selection,name:l10n_es_edi_tbai.selection__res_company__l10n_es_tbai_tax_agency__bizkaia
msgid "Hacienda Foral de Bizkaia"
msgstr "Hacienda Foral de Bizkaia"
#. module: l10n_es_edi_tbai
#: model:ir.model.fields.selection,name:l10n_es_edi_tbai.selection__res_company__l10n_es_tbai_tax_agency__gipuzkoa
msgid "Hacienda Foral de Gipuzkoa"
msgstr "Diputación Foral de Gipuzkoa"
#. module: l10n_es_edi_tbai
#. odoo-python
#: code:addons/l10n_es_edi_tbai/models/account_edi_format.py:0
#, python-format
msgid ""
"In case of a foreign customer, you need to configure the tax scope on taxes:\n"
"%s"
msgstr ""
"En el caso de un cliente extranjero, es necesario configurar el ámbito fiscal en impuestos:\n"
"%s"
#. module: l10n_es_edi_tbai
#: model:ir.model.fields,field_description:l10n_es_edi_tbai.field_account_bank_statement_line__l10n_es_tbai_refund_reason
#: model:ir.model.fields,field_description:l10n_es_edi_tbai.field_account_move__l10n_es_tbai_refund_reason
#: model:ir.model.fields,field_description:l10n_es_edi_tbai.field_account_move_reversal__l10n_es_tbai_refund_reason
#: model:ir.model.fields,field_description:l10n_es_edi_tbai.field_account_payment__l10n_es_tbai_refund_reason
msgid "Invoice Refund Reason Code (TicketBai)"
msgstr "Código de motivo de reembolso de la factura (TicketBai)"
#. module: l10n_es_edi_tbai
#: model:ir.model.fields,help:l10n_es_edi_tbai.field_account_bank_statement_line__l10n_es_tbai_chain_index
#: model:ir.model.fields,help:l10n_es_edi_tbai.field_account_move__l10n_es_tbai_chain_index
#: model:ir.model.fields,help:l10n_es_edi_tbai.field_account_payment__l10n_es_tbai_chain_index
msgid ""
"Invoice index in chain, set if and only if an in-chain XML was submitted and"
" did not error"
msgstr ""
"Índice de facturas en cadena que se establece si y solo si se envió un XML y"
" no se produjo un error."
#. module: l10n_es_edi_tbai
#: model:ir.model.fields,field_description:l10n_es_edi_tbai.field_account_move_reversal__l10n_es_tbai_is_required
msgid "Is TicketBai required for this reversal"
msgstr "¿Se necesita TicketBai para realizar una reversión?"
#. module: l10n_es_edi_tbai
#: model:ir.model.fields,help:l10n_es_edi_tbai.field_account_bank_statement_line__l10n_es_tbai_is_required
#: model:ir.model.fields,help:l10n_es_edi_tbai.field_account_move__l10n_es_tbai_is_required
#: model:ir.model.fields,help:l10n_es_edi_tbai.field_account_payment__l10n_es_tbai_is_required
msgid "Is the Basque EDI (TicketBAI) needed ?"
msgstr "¿Se necesita el EDI vasco (TicketBai)?"
#. module: l10n_es_edi_tbai
#: model:ir.model,name:l10n_es_edi_tbai.model_account_move
msgid "Journal Entry"
msgstr "Asiento contable"
#. module: l10n_es_edi_tbai
#. odoo-python
#: code:addons/l10n_es_edi_tbai/models/res_company.py:0
#, python-format
msgid "Licence NIF"
msgstr "Licencia de NIF"
#. module: l10n_es_edi_tbai
#. odoo-python
#: code:addons/l10n_es_edi_tbai/models/res_company.py:0
#, python-format
msgid "Licence number"
msgstr "Número de licencia"
#. module: l10n_es_edi_tbai
#: model:ir.ui.menu,name:l10n_es_edi_tbai.menu_l10n_es_edi_tbai_license
msgid "Licenses (TicketBAI)"
msgstr "Licencias (TicketBai)"
#. module: l10n_es_edi_tbai
#. odoo-python
#: code:addons/l10n_es_edi_tbai/models/account_edi_format.py:0
#, python-format
msgid "No XML response received from LROE."
msgstr "No se ha recibido respuesta XML de LROE."
#. module: l10n_es_edi_tbai
#: model_terms:ir.ui.view,arch_db:l10n_es_edi_tbai.res_config_settings_view_form
msgid "No tax agency selected: TicketBAI not activated."
msgstr ""
"No se ha seleccionado ninguna agencia tributaria: TicketBai no activado."
#. module: l10n_es_edi_tbai
#: model:ir.model,name:l10n_es_edi_tbai.model_l10n_es_edi_certificate
msgid "Personal Digital Certificate"
msgstr "Certificado digital de persona física"
#. module: l10n_es_edi_tbai
#. odoo-python
#: code:addons/l10n_es_edi_tbai/models/account_edi_format.py:0
#, python-format
msgid "Please configure the Tax ID on your company for TicketBAI."
msgstr "Configure el NIF en su compañía para TicketBai."
#. module: l10n_es_edi_tbai
#. odoo-python
#: code:addons/l10n_es_edi_tbai/models/account_edi_format.py:0
#, python-format
msgid "Please configure the certificate for TicketBAI/SII."
msgstr "Configure el certificado para TicketBai/SII."
#. module: l10n_es_edi_tbai
#. odoo-python
#: code:addons/l10n_es_edi_tbai/models/account_edi_format.py:0
#, python-format
msgid "Please specify a tax agency on your company for TicketBAI."
msgstr "Especifique una agencia tributaria para su compañía en TicketBai."
#. module: l10n_es_edi_tbai
#. odoo-python
#: code:addons/l10n_es_edi_tbai/models/res_company.py:0
#, python-format
msgid "Production license"
msgstr "Licencia de producción"
#. module: l10n_es_edi_tbai
#: model:ir.model.fields.selection,name:l10n_es_edi_tbai.selection__account_move__l10n_es_tbai_refund_reason__r1
#: model:ir.model.fields.selection,name:l10n_es_edi_tbai.selection__account_move_reversal__l10n_es_tbai_refund_reason__r1
msgid "R1: Art. 80.1, 80.2, 80.6 and rights founded error"
msgstr "R1: Art. 80.1, 80.2, 80.6 y por error fundado de derecho"
#. module: l10n_es_edi_tbai
#: model:ir.model.fields.selection,name:l10n_es_edi_tbai.selection__account_move__l10n_es_tbai_refund_reason__r2
#: model:ir.model.fields.selection,name:l10n_es_edi_tbai.selection__account_move_reversal__l10n_es_tbai_refund_reason__r2
msgid "R2: Art. 80.3"
msgstr "R2: Art. 80.3"
#. module: l10n_es_edi_tbai
#: model:ir.model.fields.selection,name:l10n_es_edi_tbai.selection__account_move__l10n_es_tbai_refund_reason__r3
#: model:ir.model.fields.selection,name:l10n_es_edi_tbai.selection__account_move_reversal__l10n_es_tbai_refund_reason__r3
msgid "R3: Art. 80.4"
msgstr "R3: Art. 80.4"
#. module: l10n_es_edi_tbai
#: model:ir.model.fields.selection,name:l10n_es_edi_tbai.selection__account_move__l10n_es_tbai_refund_reason__r4
#: model:ir.model.fields.selection,name:l10n_es_edi_tbai.selection__account_move_reversal__l10n_es_tbai_refund_reason__r4
msgid "R4: Art. 80 - other"
msgstr "R4: Art. 80 - resto"
#. module: l10n_es_edi_tbai
#: model:ir.model.fields.selection,name:l10n_es_edi_tbai.selection__account_move__l10n_es_tbai_refund_reason__r5
#: model:ir.model.fields.selection,name:l10n_es_edi_tbai.selection__account_move_reversal__l10n_es_tbai_refund_reason__r5
msgid "R5: Factura rectificativa en facturas simplificadas"
msgstr "R5: Factura rectificativa en facturas simplificadas"
#. module: l10n_es_edi_tbai
#. odoo-python
#: code:addons/l10n_es_edi_tbai/models/account_edi_format.py:0
#, python-format
msgid "Refund reason cannot be R5 for non-simplified invoices (TicketBAI)"
msgstr ""
"El motivo de reembolso no puede ser R5 para facturas no simplificadas "
"(TicketBai)"
#. module: l10n_es_edi_tbai
#. odoo-python
#: code:addons/l10n_es_edi_tbai/models/account_edi_format.py:0
#, python-format
msgid "Refund reason must be R5 for simplified invoices (TicketBAI)"
msgstr ""
"El motivo de reembolso debe ser R5 para facturas simplificadas (TicketBai)"
#. module: l10n_es_edi_tbai
#. odoo-python
#: code:addons/l10n_es_edi_tbai/models/account_edi_format.py:0
#, python-format
msgid "Refund reason must be specified (TicketBAI)"
msgstr "Se debe especificar el motivo del reembolso (TicketBai)"
#. module: l10n_es_edi_tbai
#: model:ir.model,name:l10n_es_edi_tbai.model_account_resequence_wizard
msgid "Remake the sequence of Journal Entries."
msgstr "Remake the sequence of Journal Entries."
#. module: l10n_es_edi_tbai
#. odoo-python
#: code:addons/l10n_es_edi_tbai/models/res_company.py:0
#, python-format
msgid "Software name"
msgstr "Nombre del software"
#. module: l10n_es_edi_tbai
#. odoo-python
#: code:addons/l10n_es_edi_tbai/models/res_company.py:0
#, python-format
msgid "Software version"
msgstr "Versión del software"
#. module: l10n_es_edi_tbai
#: model:ir.model.fields,field_description:l10n_es_edi_tbai.field_account_bank_statement_line__l10n_es_tbai_post_xml
#: model:ir.model.fields,field_description:l10n_es_edi_tbai.field_account_move__l10n_es_tbai_post_xml
#: model:ir.model.fields,field_description:l10n_es_edi_tbai.field_account_payment__l10n_es_tbai_post_xml
msgid "Submission XML"
msgstr "XML de envío"
#. module: l10n_es_edi_tbai
#: model:ir.model.fields,help:l10n_es_edi_tbai.field_account_bank_statement_line__l10n_es_tbai_post_xml
#: model:ir.model.fields,help:l10n_es_edi_tbai.field_account_move__l10n_es_tbai_post_xml
#: model:ir.model.fields,help:l10n_es_edi_tbai.field_account_payment__l10n_es_tbai_post_xml
msgid ""
"Submission XML sent to TicketBAI. Kept if accepted or no response (timeout),"
" cleared otherwise."
msgstr ""
"XML enviado a TicketBai. Se mantiene si se acepta o no se obtiene respuesta "
"(tiempo de espera), de lo contrario, se borra."
#. module: l10n_es_edi_tbai
#: model_terms:ir.ui.view,arch_db:l10n_es_edi_tbai.template_invoice_bundle
msgid "TEST-DEVICE-001"
msgstr "TEST-DEVICE-001"
#. module: l10n_es_edi_tbai
#: model:ir.model.fields,field_description:l10n_es_edi_tbai.field_res_company__l10n_es_tbai_tax_agency
#: model:ir.model.fields,field_description:l10n_es_edi_tbai.field_res_config_settings__l10n_es_tbai_tax_agency
msgid "Tax Agency for TBAI"
msgstr "Agencia tributaria para TBAI"
#. module: l10n_es_edi_tbai
#: model_terms:ir.ui.view,arch_db:l10n_es_edi_tbai.res_config_settings_view_form
msgid "Tax agency selected: TicketBAI is activated."
msgstr "Agencia tributaria seleccionada: TicketBai está activado."
#. module: l10n_es_edi_tbai
#. odoo-python
#: code:addons/l10n_es_edi_tbai/models/res_company.py:0
#, python-format
msgid "Test license (Araba)"
msgstr "Licencia de prueba (Araba)"
#. module: l10n_es_edi_tbai
#. odoo-python
#: code:addons/l10n_es_edi_tbai/models/res_company.py:0
#, python-format
msgid "Test license (Bizkaia)"
msgstr "Licencia de prueba (Bizkaia)"
#. module: l10n_es_edi_tbai
#. odoo-python
#: code:addons/l10n_es_edi_tbai/models/res_company.py:0
#, python-format
msgid "Test license (Gipuzkoa)"
msgstr "Licencia de prueba (Gipuzkoa)"
#. module: l10n_es_edi_tbai
#: model_terms:ir.ui.view,arch_db:l10n_es_edi_tbai.view_move_form_inherit_l10n_es_edi_tbai
msgid "TicketBAI"
msgstr "TicketBAI"
#. module: l10n_es_edi_tbai
#: model:ir.model.fields,field_description:l10n_es_edi_tbai.field_account_bank_statement_line__l10n_es_tbai_chain_index
#: model:ir.model.fields,field_description:l10n_es_edi_tbai.field_account_move__l10n_es_tbai_chain_index
#: model:ir.model.fields,field_description:l10n_es_edi_tbai.field_account_payment__l10n_es_tbai_chain_index
msgid "TicketBAI chain index"
msgstr "Índice de TicketBai en cadena"
#. module: l10n_es_edi_tbai
#. odoo-python
#: code:addons/l10n_es_edi_tbai/models/res_company.py:0
#, python-format
msgid "TicketBAI is not configured"
msgstr "TicketBai no está configurado"
#. module: l10n_es_edi_tbai
#: model:ir.model.fields,field_description:l10n_es_edi_tbai.field_res_company__l10n_es_tbai_license_html
msgid "TicketBAI license"
msgstr "Licencia de TicketBai"
#. module: l10n_es_edi_tbai
#: model:ir.model.fields,field_description:l10n_es_edi_tbai.field_account_bank_statement_line__l10n_es_tbai_is_required
#: model:ir.model.fields,field_description:l10n_es_edi_tbai.field_account_move__l10n_es_tbai_is_required
#: model:ir.model.fields,field_description:l10n_es_edi_tbai.field_account_payment__l10n_es_tbai_is_required
msgid "TicketBAI required"
msgstr "Se requiere TicketBai"
#. module: l10n_es_edi_tbai
#. odoo-python
#: code:addons/l10n_es_edi_tbai/models/account_edi_format.py:0
#, python-format
msgid "TicketBAI: Cannot post a refund without source documents"
msgstr "TicketBAI: No se puede publicar un reembolso sin documentos de origen"
#. module: l10n_es_edi_tbai
#. odoo-python
#: code:addons/l10n_es_edi_tbai/models/account_edi_format.py:0
#, python-format
msgid ""
"TicketBAI: Cannot post a reversal move if its source documents (%s) have not"
" been posted"
msgstr ""
"TicketBAI: No se puede publicar un asiento de reversión mientras no se hayan"
" publicado sus documentos de origen (%s)"
#. module: l10n_es_edi_tbai
#. odoo-python
#: code:addons/l10n_es_edi_tbai/models/account_edi_format.py:0
#, python-format
msgid ""
"TicketBAI: Cannot post invoice while chain head (%s) has not been posted"
msgstr ""
"TicketBAI: No se puede publicar la factura mientras no se haya publicado la "
"cabeza de cadena (%s)"
#. module: l10n_es_edi_tbai
#: model:ir.model.fields,field_description:l10n_es_edi_tbai.field_res_company__l10n_es_tbai_chain_sequence_id
msgid "TicketBai account.move chain sequence"
msgstr "Secuencia en cadena del account.move de TicketBai"
#. module: l10n_es_edi_tbai
#. odoo-python
#: code:addons/l10n_es_edi_tbai/models/account_move.py:0
#, python-format
msgid "You cannot delete a move that has a TicketBAI chain id."
msgstr "No puede eliminar un asiento que tiene un ID en cadena de TicketBai."
#. module: l10n_es_edi_tbai
#. odoo-python
#: code:addons/l10n_es_edi_tbai/models/account_move.py:0
#, python-format
msgid ""
"You cannot reset to draft an entry that has been posted to TicketBAI's chain"
msgstr ""
"No puede restablecer a borrador un asiento que se ha publicado en la cadena "
"de TicketBai."
#. module: l10n_es_edi_tbai
#. odoo-python
#: code:addons/l10n_es_edi_tbai/models/account_edi_format.py:0
#, python-format
msgid ""
"You need to fill in the Reference field as the invoice number from your "
"vendor."
msgstr ""
"Debe rellenar el campo de referencia con el número de factura de su "
"proveedor."

View file

@ -0,0 +1,425 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * l10n_es_edi_tbai
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 16.0+e\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-01-28 11:10+0000\n"
"PO-Revision-Date: 2025-01-28 11:10+0000\n"
"Last-Translator: \n"
"Language-Team: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: \n"
#. module: l10n_es_edi_tbai
#: model_terms:ir.ui.view,arch_db:l10n_es_edi_tbai.template_LROE_240_inner
#: model_terms:ir.ui.view,arch_db:l10n_es_edi_tbai.template_LROE_240_inner_recibidas
msgid "1.0"
msgstr ""
#. module: l10n_es_edi_tbai
#: model_terms:ir.ui.view,arch_db:l10n_es_edi_tbai.template_LROE_240_inner
msgid "1.1"
msgstr ""
#. module: l10n_es_edi_tbai
#: model_terms:ir.ui.view,arch_db:l10n_es_edi_tbai.template_LROE_240_inner
#: model_terms:ir.ui.view,arch_db:l10n_es_edi_tbai.template_LROE_240_inner_recibidas
msgid "240"
msgstr ""
#. module: l10n_es_edi_tbai
#: model_terms:ir.ui.view,arch_db:l10n_es_edi_tbai.res_config_settings_view_form
msgid ""
"<span class=\"o_form_label\">Registro de Libros connection "
"SII/TicketBAI</span>"
msgstr ""
#. module: l10n_es_edi_tbai
#: model:ir.model,name:l10n_es_edi_tbai.model_account_move_reversal
msgid "Account Move Reversal"
msgstr ""
#. module: l10n_es_edi_tbai
#: model:ir.model,name:l10n_es_edi_tbai.model_ir_attachment
msgid "Attachment"
msgstr ""
#. module: l10n_es_edi_tbai
#: model:ir.model.fields,help:l10n_es_edi_tbai.field_account_bank_statement_line__l10n_es_tbai_refund_reason
#: model:ir.model.fields,help:l10n_es_edi_tbai.field_account_move__l10n_es_tbai_refund_reason
#: model:ir.model.fields,help:l10n_es_edi_tbai.field_account_move_reversal__l10n_es_tbai_refund_reason
#: model:ir.model.fields,help:l10n_es_edi_tbai.field_account_payment__l10n_es_tbai_refund_reason
msgid ""
"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."
msgstr ""
#. module: l10n_es_edi_tbai
#: model:ir.model.fields,field_description:l10n_es_edi_tbai.field_account_bank_statement_line__l10n_es_tbai_cancel_xml
#: model:ir.model.fields,field_description:l10n_es_edi_tbai.field_account_move__l10n_es_tbai_cancel_xml
#: model:ir.model.fields,field_description:l10n_es_edi_tbai.field_account_payment__l10n_es_tbai_cancel_xml
msgid "Cancellation XML"
msgstr ""
#. module: l10n_es_edi_tbai
#: model:ir.model.fields,help:l10n_es_edi_tbai.field_account_bank_statement_line__l10n_es_tbai_cancel_xml
#: model:ir.model.fields,help:l10n_es_edi_tbai.field_account_move__l10n_es_tbai_cancel_xml
#: model:ir.model.fields,help:l10n_es_edi_tbai.field_account_payment__l10n_es_tbai_cancel_xml
msgid ""
"Cancellation XML sent to TicketBAI. Kept if accepted or no response "
"(timeout), cleared otherwise."
msgstr ""
#. module: l10n_es_edi_tbai
#: model:ir.model,name:l10n_es_edi_tbai.model_res_company
msgid "Companies"
msgstr ""
#. module: l10n_es_edi_tbai
#: model:ir.model,name:l10n_es_edi_tbai.model_res_config_settings
msgid "Config Settings"
msgstr ""
#. module: l10n_es_edi_tbai
#: model:ir.model,name:l10n_es_edi_tbai.model_account_edi_format
msgid "EDI format"
msgstr ""
#. module: l10n_es_edi_tbai
#: model:ir.model,name:l10n_es_edi_tbai.model_account_edi_document
msgid "Electronic Document for an account.move"
msgstr ""
#. module: l10n_es_edi_tbai
#: model:ir.model.fields.selection,name:l10n_es_edi_tbai.selection__res_company__l10n_es_tbai_tax_agency__araba
msgid "Hacienda Foral de Araba"
msgstr ""
#. module: l10n_es_edi_tbai
#: model:ir.model.fields.selection,name:l10n_es_edi_tbai.selection__res_company__l10n_es_tbai_tax_agency__bizkaia
msgid "Hacienda Foral de Bizkaia"
msgstr ""
#. module: l10n_es_edi_tbai
#: model:ir.model.fields.selection,name:l10n_es_edi_tbai.selection__res_company__l10n_es_tbai_tax_agency__gipuzkoa
msgid "Hacienda Foral de Gipuzkoa"
msgstr ""
#. module: l10n_es_edi_tbai
#. odoo-python
#: code:addons/l10n_es_edi_tbai/models/account_edi_format.py:0
#, python-format
msgid ""
"In case of a foreign customer, you need to configure the tax scope on taxes:\n"
"%s"
msgstr ""
#. module: l10n_es_edi_tbai
#: model:ir.model.fields,field_description:l10n_es_edi_tbai.field_account_bank_statement_line__l10n_es_tbai_refund_reason
#: model:ir.model.fields,field_description:l10n_es_edi_tbai.field_account_move__l10n_es_tbai_refund_reason
#: model:ir.model.fields,field_description:l10n_es_edi_tbai.field_account_move_reversal__l10n_es_tbai_refund_reason
#: model:ir.model.fields,field_description:l10n_es_edi_tbai.field_account_payment__l10n_es_tbai_refund_reason
msgid "Invoice Refund Reason Code (TicketBai)"
msgstr ""
#. module: l10n_es_edi_tbai
#: model:ir.model.fields,help:l10n_es_edi_tbai.field_account_bank_statement_line__l10n_es_tbai_chain_index
#: model:ir.model.fields,help:l10n_es_edi_tbai.field_account_move__l10n_es_tbai_chain_index
#: model:ir.model.fields,help:l10n_es_edi_tbai.field_account_payment__l10n_es_tbai_chain_index
msgid ""
"Invoice index in chain, set if and only if an in-chain XML was submitted and"
" did not error"
msgstr ""
#. module: l10n_es_edi_tbai
#: model:ir.model.fields,field_description:l10n_es_edi_tbai.field_account_move_reversal__l10n_es_tbai_is_required
msgid "Is TicketBai required for this reversal"
msgstr ""
#. module: l10n_es_edi_tbai
#: model:ir.model.fields,help:l10n_es_edi_tbai.field_account_bank_statement_line__l10n_es_tbai_is_required
#: model:ir.model.fields,help:l10n_es_edi_tbai.field_account_move__l10n_es_tbai_is_required
#: model:ir.model.fields,help:l10n_es_edi_tbai.field_account_payment__l10n_es_tbai_is_required
msgid "Is the Basque EDI (TicketBAI) needed ?"
msgstr ""
#. module: l10n_es_edi_tbai
#: model:ir.model,name:l10n_es_edi_tbai.model_account_move
msgid "Journal Entry"
msgstr ""
#. module: l10n_es_edi_tbai
#. odoo-python
#: code:addons/l10n_es_edi_tbai/models/res_company.py:0
#, python-format
msgid "Licence NIF"
msgstr ""
#. module: l10n_es_edi_tbai
#. odoo-python
#: code:addons/l10n_es_edi_tbai/models/res_company.py:0
#, python-format
msgid "Licence number"
msgstr ""
#. module: l10n_es_edi_tbai
#: model:ir.ui.menu,name:l10n_es_edi_tbai.menu_l10n_es_edi_tbai_license
msgid "Licenses (TicketBAI)"
msgstr ""
#. module: l10n_es_edi_tbai
#. odoo-python
#: code:addons/l10n_es_edi_tbai/models/account_edi_format.py:0
#, python-format
msgid "No XML response received from LROE."
msgstr ""
#. module: l10n_es_edi_tbai
#: model_terms:ir.ui.view,arch_db:l10n_es_edi_tbai.res_config_settings_view_form
msgid "No tax agency selected: TicketBAI not activated."
msgstr ""
#. module: l10n_es_edi_tbai
#: model:ir.model,name:l10n_es_edi_tbai.model_l10n_es_edi_certificate
msgid "Personal Digital Certificate"
msgstr ""
#. module: l10n_es_edi_tbai
#. odoo-python
#: code:addons/l10n_es_edi_tbai/models/account_edi_format.py:0
#, python-format
msgid "Please configure the Tax ID on your company for TicketBAI."
msgstr ""
#. module: l10n_es_edi_tbai
#. odoo-python
#: code:addons/l10n_es_edi_tbai/models/account_edi_format.py:0
#, python-format
msgid "Please configure the certificate for TicketBAI/SII."
msgstr ""
#. module: l10n_es_edi_tbai
#. odoo-python
#: code:addons/l10n_es_edi_tbai/models/account_edi_format.py:0
#, python-format
msgid "Please specify a tax agency on your company for TicketBAI."
msgstr ""
#. module: l10n_es_edi_tbai
#. odoo-python
#: code:addons/l10n_es_edi_tbai/models/res_company.py:0
#, python-format
msgid "Production license"
msgstr ""
#. module: l10n_es_edi_tbai
#: model:ir.model.fields.selection,name:l10n_es_edi_tbai.selection__account_move__l10n_es_tbai_refund_reason__r1
#: model:ir.model.fields.selection,name:l10n_es_edi_tbai.selection__account_move_reversal__l10n_es_tbai_refund_reason__r1
msgid "R1: Art. 80.1, 80.2, 80.6 and rights founded error"
msgstr ""
#. module: l10n_es_edi_tbai
#: model:ir.model.fields.selection,name:l10n_es_edi_tbai.selection__account_move__l10n_es_tbai_refund_reason__r2
#: model:ir.model.fields.selection,name:l10n_es_edi_tbai.selection__account_move_reversal__l10n_es_tbai_refund_reason__r2
msgid "R2: Art. 80.3"
msgstr ""
#. module: l10n_es_edi_tbai
#: model:ir.model.fields.selection,name:l10n_es_edi_tbai.selection__account_move__l10n_es_tbai_refund_reason__r3
#: model:ir.model.fields.selection,name:l10n_es_edi_tbai.selection__account_move_reversal__l10n_es_tbai_refund_reason__r3
msgid "R3: Art. 80.4"
msgstr ""
#. module: l10n_es_edi_tbai
#: model:ir.model.fields.selection,name:l10n_es_edi_tbai.selection__account_move__l10n_es_tbai_refund_reason__r4
#: model:ir.model.fields.selection,name:l10n_es_edi_tbai.selection__account_move_reversal__l10n_es_tbai_refund_reason__r4
msgid "R4: Art. 80 - other"
msgstr ""
#. module: l10n_es_edi_tbai
#: model:ir.model.fields.selection,name:l10n_es_edi_tbai.selection__account_move__l10n_es_tbai_refund_reason__r5
#: model:ir.model.fields.selection,name:l10n_es_edi_tbai.selection__account_move_reversal__l10n_es_tbai_refund_reason__r5
msgid "R5: Factura rectificativa en facturas simplificadas"
msgstr ""
#. module: l10n_es_edi_tbai
#. odoo-python
#: code:addons/l10n_es_edi_tbai/models/account_edi_format.py:0
#, python-format
msgid "Refund reason cannot be R5 for non-simplified invoices (TicketBAI)"
msgstr ""
#. module: l10n_es_edi_tbai
#. odoo-python
#: code:addons/l10n_es_edi_tbai/models/account_edi_format.py:0
#, python-format
msgid "Refund reason must be R5 for simplified invoices (TicketBAI)"
msgstr ""
#. module: l10n_es_edi_tbai
#. odoo-python
#: code:addons/l10n_es_edi_tbai/models/account_edi_format.py:0
#, python-format
msgid "Refund reason must be specified (TicketBAI)"
msgstr ""
#. module: l10n_es_edi_tbai
#: model:ir.model,name:l10n_es_edi_tbai.model_account_resequence_wizard
msgid "Remake the sequence of Journal Entries."
msgstr ""
#. module: l10n_es_edi_tbai
#. odoo-python
#: code:addons/l10n_es_edi_tbai/models/res_company.py:0
#, python-format
msgid "Software name"
msgstr ""
#. module: l10n_es_edi_tbai
#. odoo-python
#: code:addons/l10n_es_edi_tbai/models/res_company.py:0
#, python-format
msgid "Software version"
msgstr ""
#. module: l10n_es_edi_tbai
#: model:ir.model.fields,field_description:l10n_es_edi_tbai.field_account_bank_statement_line__l10n_es_tbai_post_xml
#: model:ir.model.fields,field_description:l10n_es_edi_tbai.field_account_move__l10n_es_tbai_post_xml
#: model:ir.model.fields,field_description:l10n_es_edi_tbai.field_account_payment__l10n_es_tbai_post_xml
msgid "Submission XML"
msgstr ""
#. module: l10n_es_edi_tbai
#: model:ir.model.fields,help:l10n_es_edi_tbai.field_account_bank_statement_line__l10n_es_tbai_post_xml
#: model:ir.model.fields,help:l10n_es_edi_tbai.field_account_move__l10n_es_tbai_post_xml
#: model:ir.model.fields,help:l10n_es_edi_tbai.field_account_payment__l10n_es_tbai_post_xml
msgid ""
"Submission XML sent to TicketBAI. Kept if accepted or no response (timeout),"
" cleared otherwise."
msgstr ""
#. module: l10n_es_edi_tbai
#: model_terms:ir.ui.view,arch_db:l10n_es_edi_tbai.template_invoice_bundle
msgid "TEST-DEVICE-001"
msgstr ""
#. module: l10n_es_edi_tbai
#: model:ir.model.fields,field_description:l10n_es_edi_tbai.field_res_company__l10n_es_tbai_tax_agency
#: model:ir.model.fields,field_description:l10n_es_edi_tbai.field_res_config_settings__l10n_es_tbai_tax_agency
msgid "Tax Agency for TBAI"
msgstr ""
#. module: l10n_es_edi_tbai
#: model_terms:ir.ui.view,arch_db:l10n_es_edi_tbai.res_config_settings_view_form
msgid "Tax agency selected: TicketBAI is activated."
msgstr ""
#. module: l10n_es_edi_tbai
#. odoo-python
#: code:addons/l10n_es_edi_tbai/models/res_company.py:0
#, python-format
msgid "Test license (Araba)"
msgstr ""
#. module: l10n_es_edi_tbai
#. odoo-python
#: code:addons/l10n_es_edi_tbai/models/res_company.py:0
#, python-format
msgid "Test license (Bizkaia)"
msgstr ""
#. module: l10n_es_edi_tbai
#. odoo-python
#: code:addons/l10n_es_edi_tbai/models/res_company.py:0
#, python-format
msgid "Test license (Gipuzkoa)"
msgstr ""
#. module: l10n_es_edi_tbai
#: model_terms:ir.ui.view,arch_db:l10n_es_edi_tbai.view_move_form_inherit_l10n_es_edi_tbai
msgid "TicketBAI"
msgstr ""
#. module: l10n_es_edi_tbai
#: model:ir.model.fields,field_description:l10n_es_edi_tbai.field_account_bank_statement_line__l10n_es_tbai_chain_index
#: model:ir.model.fields,field_description:l10n_es_edi_tbai.field_account_move__l10n_es_tbai_chain_index
#: model:ir.model.fields,field_description:l10n_es_edi_tbai.field_account_payment__l10n_es_tbai_chain_index
msgid "TicketBAI chain index"
msgstr ""
#. module: l10n_es_edi_tbai
#. odoo-python
#: code:addons/l10n_es_edi_tbai/models/res_company.py:0
#, python-format
msgid "TicketBAI is not configured"
msgstr ""
#. module: l10n_es_edi_tbai
#: model:ir.model.fields,field_description:l10n_es_edi_tbai.field_res_company__l10n_es_tbai_license_html
msgid "TicketBAI license"
msgstr ""
#. module: l10n_es_edi_tbai
#: model:ir.model.fields,field_description:l10n_es_edi_tbai.field_account_bank_statement_line__l10n_es_tbai_is_required
#: model:ir.model.fields,field_description:l10n_es_edi_tbai.field_account_move__l10n_es_tbai_is_required
#: model:ir.model.fields,field_description:l10n_es_edi_tbai.field_account_payment__l10n_es_tbai_is_required
msgid "TicketBAI required"
msgstr ""
#. module: l10n_es_edi_tbai
#. odoo-python
#: code:addons/l10n_es_edi_tbai/models/account_edi_format.py:0
#, python-format
msgid "TicketBAI: Cannot post a refund without source documents"
msgstr ""
#. module: l10n_es_edi_tbai
#. odoo-python
#: code:addons/l10n_es_edi_tbai/models/account_edi_format.py:0
#, python-format
msgid ""
"TicketBAI: Cannot post a reversal move if its source documents (%s) have not"
" been posted"
msgstr ""
#. module: l10n_es_edi_tbai
#. odoo-python
#: code:addons/l10n_es_edi_tbai/models/account_edi_format.py:0
#, python-format
msgid ""
"TicketBAI: Cannot post invoice while chain head (%s) has not been posted"
msgstr ""
#. module: l10n_es_edi_tbai
#: model:ir.model.fields,field_description:l10n_es_edi_tbai.field_res_company__l10n_es_tbai_chain_sequence_id
msgid "TicketBai account.move chain sequence"
msgstr ""
#. module: l10n_es_edi_tbai
#. odoo-python
#: code:addons/l10n_es_edi_tbai/models/account_move.py:0
#, python-format
msgid "You cannot delete a move that has a TicketBAI chain id."
msgstr ""
#. module: l10n_es_edi_tbai
#. odoo-python
#: code:addons/l10n_es_edi_tbai/models/account_move.py:0
#, python-format
msgid ""
"You cannot reset to draft an entry that has been posted to TicketBAI's chain"
msgstr ""
#. module: l10n_es_edi_tbai
#. odoo-python
#: code:addons/l10n_es_edi_tbai/models/account_edi_format.py:0
#, python-format
msgid ""
"You need to fill in the Reference field as the invoice number from your "
"vendor."
msgstr ""

View file

@ -0,0 +1,12 @@
# -*- 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

View file

@ -0,0 +1,25 @@
# -*- 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

View file

@ -0,0 +1,755 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import gzip
import json
from base64 import b64encode
from datetime import datetime
from re import sub as regex_sub
from uuid import uuid4
from markupsafe import Markup, escape
import requests
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.x509.oid import NameOID
from lxml import etree
from pytz import timezone
from requests.exceptions import RequestException
from odoo import _, models, release
from odoo.addons.l10n_es_edi_sii.models.account_edi_format import PatchedHTTPAdapter
from odoo.addons.l10n_es_edi_tbai.models.l10n_es_edi_tbai_agencies import get_key
from odoo.addons.l10n_es_edi_tbai.models.xml_utils import (
NS_MAP, bytes_as_block, calculate_references_digests,
cleanup_xml_signature, fill_signature, int_as_bytes)
from odoo.exceptions import UserError, ValidationError
from odoo.tools import get_lang
from odoo.tools.float_utils import float_repr
from odoo.tools.xml_utils import cleanup_xml_node, validate_xml_from_attachment
class AccountEdiFormat(models.Model):
_inherit = 'account.edi.format'
# -------------------------------------------------------------------------
# OVERRIDES & EXTENSIONS
# -------------------------------------------------------------------------
def _needs_web_services(self):
# EXTENDS account_edi
return self.code == 'es_tbai' or super()._needs_web_services()
def _is_enabled_by_default_on_journal(self, journal):
""" Disable SII by default on a new journal when tbai is installed"""
if self.code != 'es_sii':
return super()._is_enabled_by_default_on_journal(journal)
return False
def _is_compatible_with_journal(self, journal):
# EXTENDS account_edi
if self.code != 'es_tbai':
return super()._is_compatible_with_journal(journal)
return journal.country_code == 'ES' and journal.type in ('sale', 'purchase')
def _get_move_applicability(self, move):
# EXTENDS account_edi
self.ensure_one()
if self.code != 'es_tbai' or move.country_code != 'ES' or not move.l10n_es_tbai_is_required:
return super()._get_move_applicability(move)
return {
'post': self._l10n_es_tbai_post_invoice_edi,
'cancel': self._l10n_es_tbai_cancel_invoice_edi,
'edi_content': self._l10n_es_tbai_get_invoice_content_edi,
}
def _check_move_configuration(self, invoice):
# EXTENDS account_edi
errors = super()._check_move_configuration(invoice)
if self.code != 'es_tbai' or invoice.country_code != 'ES':
return errors
if invoice.is_purchase_document() and not invoice.ref:
errors.append(_("You need to fill in the Reference field as the invoice number from your vendor."))
# Ensure a certificate is available.
if not invoice.company_id.l10n_es_edi_certificate_id:
errors.append(_("Please configure the certificate for TicketBAI/SII."))
# Ensure a tax agency is available.
if not invoice.company_id.mapped('l10n_es_tbai_tax_agency')[0]:
errors.append(_("Please specify a tax agency on your company for TicketBAI."))
# Ensure a vat is available.
if not invoice.company_id.vat:
errors.append(_("Please configure the Tax ID on your company for TicketBAI."))
# Check the refund reason
if invoice.move_type == 'out_refund':
if not invoice.l10n_es_tbai_refund_reason:
raise ValidationError(_('Refund reason must be specified (TicketBAI)'))
if invoice._is_l10n_es_tbai_simplified():
if invoice.l10n_es_tbai_refund_reason != 'R5':
raise ValidationError(_('Refund reason must be R5 for simplified invoices (TicketBAI)'))
else:
if invoice.l10n_es_tbai_refund_reason == 'R5':
raise ValidationError(_('Refund reason cannot be R5 for non-simplified invoices (TicketBAI)'))
return errors
def _l10n_es_tbai_refunded_invoices(self, invoice):
return invoice.reversed_entry_id
def _l10n_es_tbai_post_invoice_edi(self, invoice):
# EXTENDS account_edi
if self.code != 'es_tbai':
return super()._post_invoice_edi(invoice)
if invoice.is_purchase_document():
inv_xml = False # For Ticketbai Batuz vendor bills, we get the values later as it does not need chaining, ...
else:
# Chain integrity check: chain head must have been REALLY posted (not timeout'ed)
# - If called from a cron, then the re-ordering of jobs should prevent this from triggering
# - If called manually, then the user will see this error pop up when it triggers
chain_head = invoice.company_id._get_l10n_es_tbai_last_posted_invoice()
error_msg = ''
if chain_head and chain_head != invoice and not chain_head._l10n_es_tbai_is_in_chain():
error_msg = _("TicketBAI: Cannot post invoice while chain head (%s) has not been posted", chain_head.name)
if invoice.move_type == 'out_refund':
refunded_invoices = self._l10n_es_tbai_refunded_invoices(invoice)
if not refunded_invoices:
error_msg = _("TicketBAI: Cannot post a refund without source documents")
else:
invalid_refunds = refunded_invoices.filtered(lambda inv:
not inv._l10n_es_tbai_is_in_chain()
and inv.edi_document_ids.filtered(lambda d: d.edi_format_id.code == 'es_tbai') # avoid imported ones
)
if invalid_refunds:
error_msg = _(
"TicketBAI: Cannot post a reversal move if its source documents (%s) have not been posted",
', '.join(invalid_refunds.mapped('name'))
)
# Tax configuration check: In case of foreign customer we need the tax scope to be set
com_partner = invoice.commercial_partner_id
if (com_partner.country_id.code not in ('ES', False) or (com_partner.vat or '').startswith("ESN")) and\
invoice.line_ids.tax_ids.filtered(lambda t: not t.tax_scope):
error_msg = _(
"In case of a foreign customer, you need to configure the tax scope on taxes:\n%s",
"\n".join(invoice.line_ids.tax_ids.mapped('name'))
)
if error_msg:
return {
invoice: {
'error': error_msg,
'blocking_level': 'error',
}
}
# Generate the XML values.
inv_dict = self._get_l10n_es_tbai_invoice_xml(invoice)
if 'error' in inv_dict[invoice]:
return inv_dict # XSD validation failed, return result dict
# Store the XML as attachment to ensure it is never lost (even in case of timeout error)
inv_xml = inv_dict[invoice]['xml_file']
invoice._update_l10n_es_tbai_submitted_xml(xml_doc=inv_xml, cancel=False)
# Assign unique 'chain index' from dedicated sequence
if not invoice.l10n_es_tbai_chain_index:
invoice.l10n_es_tbai_chain_index = invoice.company_id._get_l10n_es_tbai_next_chain_index()
# Call the web service and get response
res = self._l10n_es_tbai_post_to_web_service(invoice, inv_xml)
# SUCCESS
if res[invoice].get('success'):
# Create attachment
attachment = self.env['ir.attachment'].create({
'name': invoice.name + '_post.xml',
'datas': invoice.l10n_es_tbai_post_xml,
'mimetype': 'application/xml',
'res_id': invoice.id,
'res_model': 'account.move',
})
# Post attachment to chatter and save it as EDI document
test_suffix = '(test mode)' if invoice.company_id.l10n_es_edi_test_env else ''
invoice.with_context(no_new_invoice=True).message_post(
body=Markup("<pre>TicketBAI: posted emission XML {test_suffix}\n{message}</pre>").format(
test_suffix=test_suffix, message=res[invoice]['message']
),
attachment_ids=[attachment.id],
)
res[invoice]['attachment'] = attachment
# FAILURE
# NOTE: 'warning' means timeout so absolutely keep the XML and chain index
elif res[invoice].get('blocking_level') == 'error':
invoice._update_l10n_es_tbai_submitted_xml(xml_doc=None, cancel=False) # deletes XML
# delete index (avoids re-trying same XML and chaining off of it)
invoice.l10n_es_tbai_chain_index = False
return res
def _l10n_es_tbai_cancel_invoice_edi(self, invoice):
# EXTENDS account_edi
if self.code != 'es_tbai':
return super()._cancel_invoice_edi(invoice)
if invoice.is_purchase_document():
cancel_xml = False # Batuz specific
else:
# Generate the XML values.
cancel_dict = self._get_l10n_es_tbai_invoice_xml(invoice, cancel=True)
if 'error' in cancel_dict[invoice]:
return cancel_dict # XSD validation failed, return result dict
# Store the XML as attachment to ensure it is never lost (even in case of timeout error)
cancel_xml = cancel_dict[invoice]['xml_file']
invoice._update_l10n_es_tbai_submitted_xml(xml_doc=cancel_xml, cancel=True)
# Call the web service and get response
res = self._l10n_es_tbai_post_to_web_service(invoice, cancel_xml, cancel=True)
# SUCCESS
if res[invoice].get('success'):
# Create attachment
attachment = self.env['ir.attachment'].create({
'name': invoice.name + '_cancel.xml',
'datas': invoice.l10n_es_tbai_cancel_xml,
'mimetype': 'application/xml',
'res_id': invoice.id,
'res_model': 'account.move',
})
# Post attachment to chatter
test_suffix = '(test mode)' if invoice.company_id.l10n_es_edi_test_env else ''
invoice.with_context(no_new_invoice=True).message_post(
body=Markup("<pre>TicketBAI: posted cancellation XML {test_suffix}\n{message}</pre>").format(
test_suffix=test_suffix, message=res[invoice]['message']
),
attachment_ids=[attachment.id],
)
# FAILURE
# NOTE: 'warning' means timeout so absolutely keep the XML and chain index
elif res[invoice].get('blocking_level') == 'error':
invoice._update_l10n_es_tbai_submitted_xml(xml_doc=None, cancel=True) # will need to be re-created
return res
# -------------------------------------------------------------------------
# XML DOCUMENT
# -------------------------------------------------------------------------
def _l10n_es_tbai_validate_xml_with_xsd(self, xml_doc, cancel, tax_agency):
xsd_name = get_key(tax_agency, 'xsd_name')['cancel' if cancel else 'post']
try:
validate_xml_from_attachment(self.env, xml_doc, xsd_name, prefix='l10n_es_edi_tbai')
except UserError as e:
return {'error': escape(str(e)), 'blocking_level': 'error'}
return {}
def _l10n_es_tbai_get_invoice_content_edi(self, invoice):
cancel = invoice.edi_state in ('to_cancel', 'cancelled')
if invoice.is_purchase_document():
lroe_values = self._l10n_es_tbai_prepare_values_bi(invoice, False, cancel=cancel)
xml_str = self.env['ir.qweb']._render('l10n_es_edi_tbai.template_LROE_240_main_recibidas', lroe_values).encode()
else:
xml_tree = self._get_l10n_es_tbai_invoice_xml(invoice, cancel)[invoice]['xml_file']
xml_str = etree.tostring(xml_tree)
return xml_str
def _get_l10n_es_tbai_invoice_xml(self, invoice, cancel=False):
# If previously generated XML was posted and not rejected (success or timeout), reuse it
doc = invoice._get_l10n_es_tbai_submitted_xml(cancel)
if doc is not None:
return {invoice: {'xml_file': doc}}
# Otherwise, generate a new XML
values = {
**invoice.company_id._get_l10n_es_tbai_license_dict(),
**self._l10n_es_tbai_get_header_values(invoice),
**self._l10n_es_tbai_get_subject_values(invoice, cancel),
**self._l10n_es_tbai_get_invoice_values(invoice, cancel),
**self._l10n_es_tbai_get_trail_values(invoice, cancel),
'is_emission': not cancel,
'datetime_now': datetime.now(tz=timezone('Europe/Madrid')),
'format_date': lambda d: datetime.strftime(d, '%d-%m-%Y'),
'format_time': lambda d: datetime.strftime(d, '%H:%M:%S'),
'format_float': lambda f: float_repr(f, precision_digits=2),
}
template_name = 'l10n_es_edi_tbai.template_invoice_main' + ('_cancel' if cancel else '_post')
xml_str = self.env['ir.qweb']._render(template_name, values)
xml_doc = cleanup_xml_node(xml_str, remove_blank_nodes=False)
xml_doc = self._l10n_es_tbai_sign_invoice(invoice, xml_doc)
res = {invoice: {'xml_file': xml_doc}}
# Optional check using the XSD
res[invoice].update(self._l10n_es_tbai_validate_xml_with_xsd(xml_doc, cancel, invoice.company_id.l10n_es_tbai_tax_agency))
return res
def _l10n_es_tbai_get_header_values(self, invoice):
return {
'tbai_version': self.L10N_ES_TBAI_VERSION,
'odoo_version': release.version,
}
def _l10n_es_tbai_get_subject_values(self, invoice, cancel):
# === SENDER (EMISOR) ===
sender = invoice.company_id
values = {
'sender_vat': sender.vat[2:] if sender.vat.startswith('ES') else sender.vat,
'sender': sender,
}
if cancel:
return values # cancellation invoices do not specify recipients (they stay the same)
# NOTE: TicketBai supports simplified invoices WITH recipients but we don't for now (we should for POS)
# NOTE: TicketBAI credit notes for simplified invoices are ALWAYS simplified BUT can have a recipient even if invoice doesn't
if invoice._is_l10n_es_tbai_simplified():
return values # do not set 'recipient' unless there is an actual recipient (used as condition in template)
# === RECIPIENTS (DESTINATARIOS) ===
nif = False
alt_id_country = False
partner = invoice.commercial_partner_id
alt_id_number = partner.vat or 'NO_DISPONIBLE'
alt_id_type = ""
if (not partner.country_id or partner.country_id.code == 'ES') and partner.vat:
# ES partner with VAT.
nif = partner.vat[2:] if partner.vat.startswith('ES') else partner.vat
elif partner.country_id.code in self.env.ref('base.europe').country_ids.mapped('code'):
# European partner
alt_id_type = '02'
else:
# Non-european partner
if partner.vat:
alt_id_type = '04'
else:
alt_id_type = '06'
if partner.country_id:
alt_id_country = partner.country_id.code
values_dest = {
'nif': nif,
'alt_id_country': alt_id_country,
'alt_id_number': alt_id_number,
'alt_id_type': alt_id_type,
'partner': partner,
'partner_address': ', '.join(filter(None, [partner.street, partner.street2, partner.city])),
}
values.update({
'recipient': values_dest,
})
return values
def _l10n_es_tbai_get_invoice_values(self, invoice, cancel):
# Header
values = {'invoice': invoice}
if cancel:
return values
# Credit notes (factura rectificativa)
# NOTE values below would have to be adapted for purchase invoices (Bizkaia LROE)
values['is_refund'] = invoice.move_type == 'out_refund'
if values['is_refund']:
values['credit_note_code'] = invoice.l10n_es_tbai_refund_reason
values['credit_note_invoice'] = invoice.reversed_entry_id
# Lines (detalle)
refund_sign = (1 if values['is_refund'] else -1)
invoice_lines = []
for line in invoice.invoice_line_ids.filtered(lambda line: line.display_type not in ('line_section', 'line_note')):
if line.discount == 100.0:
inverse_currency_rate = abs(line.move_id.amount_total_signed / line.move_id.amount_total) if line.move_id.amount_total else 1
balance_before_discount = - line.price_unit * line.quantity * inverse_currency_rate
else:
balance_before_discount = line.balance / (1 - line.discount / 100)
discount = (balance_before_discount - line.balance)
line_price_total = self._l10n_es_tbai_get_invoice_line_price_total(line)
if not any([t.l10n_es_type == 'sujeto_isp' for t in line.tax_ids]):
total = line_price_total * abs(line.balance / line.amount_currency if line.amount_currency != 0 else 1) * -refund_sign
else:
total = abs(line.balance) * -refund_sign * (-1 if line_price_total < 0 else 1)
invoice_lines.append({
'line': line,
'discount': -discount,
'unit_price': -(line.balance + discount) / line.quantity if line.quantity else 0.0,
'total': total,
'description': regex_sub(r'[^0-9a-zA-Z ]', '', line.name or '')[:250]
})
values['invoice_lines'] = invoice_lines
# Tax details (desglose)
importe_total, desglose, amount_retention = self._l10n_es_tbai_get_importe_desglose(invoice)
values['amount_total'] = importe_total
values['invoice_info'] = desglose
values['amount_retention'] = amount_retention * refund_sign if amount_retention != 0.0 else 0.0
# Regime codes (ClaveRegimenEspecialOTrascendencia)
# NOTE there's 11 more codes to implement, also there can be up to 3 in total
# See https://www.gipuzkoa.eus/documents/2456431/13761128/Anexo+I.pdf/2ab0116c-25b4-f16a-440e-c299952d683d
com_partner = invoice.commercial_partner_id
# If an invoice line contains an OSS tax, the invoice is considered as an OSS operation
is_oss = self._has_oss_taxes(invoice)
if is_oss:
values['regime_key'] = ['17']
elif not com_partner.country_id or com_partner.country_id.code in self.env.ref('base.europe').country_ids.mapped('code'):
values['regime_key'] = ['01']
else:
values['regime_key'] = ['02']
values['nosujeto_causa'] = 'IE' if is_oss else 'RL'
return values
def _l10n_es_tbai_get_invoice_line_price_total(self, invoice_line):
price_total = invoice_line.price_total
retention_tax_lines = invoice_line.tax_ids.filtered(lambda t: t.l10n_es_type == "retencion")
if retention_tax_lines:
line_discount_price_unit = invoice_line.price_unit * (1 - (invoice_line.discount / 100.0))
tax_lines_no_retention = invoice_line.tax_ids - retention_tax_lines
if tax_lines_no_retention:
taxes_res = tax_lines_no_retention.compute_all(line_discount_price_unit,
quantity=invoice_line.quantity,
currency=invoice_line.currency_id,
product=invoice_line.product_id,
partner=invoice_line.move_id.partner_id,
is_refund=invoice_line.is_refund)
price_total = taxes_res['total_included']
return price_total
def _l10n_es_tbai_get_importe_desglose(self, invoice):
com_partner = invoice.commercial_partner_id
sign = -1 if invoice.move_type in ('out_refund', 'in_refund') else 1
if (com_partner.country_id.code in ('ES', False) and not (com_partner.vat or '').startswith("ESN")) \
or invoice._is_l10n_es_tbai_simplified():
tax_details_info_vals = self._l10n_es_edi_get_invoices_tax_details_info(invoice)
tax_amount_retention = tax_details_info_vals['tax_amount_retention']
desglose = {'DesgloseFactura': tax_details_info_vals['tax_details_info']}
desglose['DesgloseFactura'].update({'S1': tax_details_info_vals['S1_list'],
'S2': tax_details_info_vals['S2_list']})
importe_total = round(sign * (
tax_details_info_vals['tax_details']['base_amount']
+ tax_details_info_vals['tax_details']['tax_amount']
- tax_amount_retention
), 2)
else:
tax_details_info_service_vals = self._l10n_es_edi_get_invoices_tax_details_info(
invoice,
filter_invl_to_apply=lambda x: any(t.tax_scope == 'service' for t in x.tax_ids)
)
tax_details_info_consu_vals = self._l10n_es_edi_get_invoices_tax_details_info(
invoice,
filter_invl_to_apply=lambda x: any(t.tax_scope == 'consu' for t in x.tax_ids)
)
service_retention = tax_details_info_service_vals['tax_amount_retention']
consu_retention = tax_details_info_consu_vals['tax_amount_retention']
desglose = {}
if tax_details_info_service_vals['tax_details_info']:
desglose.setdefault('DesgloseTipoOperacion', {})
desglose['DesgloseTipoOperacion']['PrestacionServicios'] = tax_details_info_service_vals['tax_details_info']
desglose['DesgloseTipoOperacion']['PrestacionServicios'].update(
{'S1': tax_details_info_service_vals['S1_list'],
'S2': tax_details_info_service_vals['S2_list']})
if tax_details_info_consu_vals['tax_details_info']:
desglose.setdefault('DesgloseTipoOperacion', {})
desglose['DesgloseTipoOperacion']['Entrega'] = tax_details_info_consu_vals['tax_details_info']
desglose['DesgloseTipoOperacion']['Entrega'].update(
{'S1': tax_details_info_consu_vals['S1_list'],
'S2': tax_details_info_consu_vals['S2_list']})
importe_total = round(sign * (
tax_details_info_service_vals['tax_details']['base_amount']
+ tax_details_info_service_vals['tax_details']['tax_amount']
- service_retention
+ tax_details_info_consu_vals['tax_details']['base_amount']
+ tax_details_info_consu_vals['tax_details']['tax_amount']
- consu_retention
), 2)
tax_amount_retention = service_retention + consu_retention
return importe_total, desglose, tax_amount_retention
def _l10n_es_tbai_get_trail_values(self, invoice, cancel):
prev_invoice = invoice.company_id._get_l10n_es_tbai_last_posted_invoice(invoice)
# NOTE: assumtion that last posted == previous works because XML is generated on post
if prev_invoice and not cancel:
return {
'chain_prev_invoice': prev_invoice
}
else:
return {}
def _l10n_es_tbai_sign_invoice(self, invoice, xml_root):
company = invoice.company_id
cert_private, cert_public = (
company.l10n_es_edi_certificate_id.sudo()._get_key_pair()
)
public_key = cert_public.public_key()
# Identifiers
document_id = "Document-" + str(uuid4())
signature_id = "Signature-" + document_id
keyinfo_id = "KeyInfo-" + document_id
sigproperties_id = "SignatureProperties-" + document_id
# Render digital signature scaffold from QWeb
common_name = cert_public.issuer.get_attributes_for_oid(NameOID.COMMON_NAME)[0].value
org_unit = cert_public.issuer.get_attributes_for_oid(NameOID.ORGANIZATIONAL_UNIT_NAME)[0].value
org_name = cert_public.issuer.get_attributes_for_oid(NameOID.ORGANIZATION_NAME)[0].value
country_name = cert_public.issuer.get_attributes_for_oid(NameOID.COUNTRY_NAME)[0].value
values = {
'dsig': {
'document_id': document_id,
'x509_certificate': bytes_as_block(cert_public.public_bytes(encoding=serialization.Encoding.DER)),
'public_modulus': bytes_as_block(int_as_bytes(public_key.public_numbers().n)),
'public_exponent': bytes_as_block(int_as_bytes(public_key.public_numbers().e)),
'iso_now': datetime.now().isoformat(),
'keyinfo_id': keyinfo_id,
'signature_id': signature_id,
'sigproperties_id': sigproperties_id,
'reference_uri': "Reference-" + document_id,
'sigpolicy_url': get_key(company.l10n_es_tbai_tax_agency, 'sigpolicy_url'),
'sigpolicy_digest': get_key(company.l10n_es_tbai_tax_agency, 'sigpolicy_digest'),
'sigcertif_digest': b64encode(cert_public.fingerprint(hashes.SHA256())).decode(),
'x509_issuer_description': 'CN={}, OU={}, O={}, C={}'.format(common_name, org_unit, org_name, country_name),
'x509_serial_number': cert_public.serial_number,
}
}
xml_sig_str = self.env['ir.qweb']._render('l10n_es_edi_tbai.template_digital_signature', values)
xml_sig = cleanup_xml_signature(xml_sig_str)
# Complete document with signature template
xml_root.append(xml_sig)
# Compute digest values for references
calculate_references_digests(xml_sig.find("SignedInfo", namespaces=NS_MAP))
# Sign (writes into SignatureValue)
fill_signature(xml_sig, cert_private)
return xml_root
# -------------------------------------------------------------------------
# WEB SERVICE CALLS
# -------------------------------------------------------------------------
def _l10n_es_tbai_post_to_web_service(self, invoice, invoice_xml, cancel=False):
company = invoice.company_id
try:
# Call the web service, retrieve and parse response
success, message, response_xml = self._l10n_es_tbai_post_to_agency(
self.env, company.l10n_es_tbai_tax_agency, invoice, invoice_xml, cancel)
except (ValueError, RequestException) as e:
# In case of timeout / request exception, return warning
return {invoice: {
'error': str(e),
'blocking_level': 'warning',
'response': None,
}}
if success:
return {invoice: {
'success': True,
'message': message,
'response': response_xml,
}}
else:
return {invoice: {
'error': message,
'blocking_level': 'error',
'response': response_xml,
}}
# -------------------------------------------------------------------------
# WEB SERVICE METHODS
# -------------------------------------------------------------------------
# Provides helper methods for interacting with the Basque country's TicketBai servers.
L10N_ES_TBAI_VERSION = 1.2
def _l10n_es_tbai_post_to_agency(self, env, agency, invoice, invoice_xml, cancel=False):
if agency in ('araba', 'gipuzkoa'):
post_method, process_method = self._l10n_es_tbai_prepare_post_params_ar_gi, self._l10n_es_tbai_process_post_response_ar_gi
elif agency == 'bizkaia':
post_method, process_method = self._l10n_es_tbai_prepare_post_params_bi, self._l10n_es_tbai_process_post_response_bi
params = post_method(env, agency, invoice, invoice_xml, cancel)
response = self._l10n_es_tbai_send_request_to_agency(timeout=10, **params)
return process_method(env, response)
def _l10n_es_tbai_send_request_to_agency(self, *args, **kwargs):
session = requests.Session()
session.cert = kwargs.pop('pkcs12_data')
session.mount("https://", PatchedHTTPAdapter())
return session.request('post', *args, **kwargs)
def _l10n_es_tbai_prepare_post_params_ar_gi(self, env, agency, invoice, invoice_xml, cancel=False):
"""Web service parameters for Araba and Gipuzkoa."""
company = invoice.company_id
return {
'url': get_key(agency, 'cancel_url_' if cancel else 'post_url_', company.l10n_es_edi_test_env),
'headers': {"Content-Type": "application/xml; charset=utf-8"},
'pkcs12_data': company.l10n_es_edi_certificate_id,
'data': etree.tostring(invoice_xml, encoding='UTF-8'),
}
def _l10n_es_tbai_process_post_response_ar_gi(self, env, response):
"""Government response processing for Araba and Gipuzkoa."""
try:
response_xml = etree.fromstring(response.content)
except etree.XMLSyntaxError as e:
return False, e, None
# Error management
message = ''
already_received = False
# Get message in basque if env is in basque
msg_node_name = 'Azalpena' if get_lang(env).code == 'eu_ES' else 'Descripcion'
for xml_res_node in response_xml.findall(r'.//ResultadosValidacion'):
message_code = xml_res_node.find('Codigo').text
message += message_code + ": " + xml_res_node.find(msg_node_name).text + "\n"
if message_code in ('005', '019'):
already_received = True # error codes 5/19 mean XML was already received with that sequence
response_code = int(response_xml.find(r'.//Estado').text)
response_success = (response_code == 0) or already_received
return response_success, message, response_xml
def _l10n_es_tbai_get_in_invoice_values_batuz(self, invoice):
""" For the vendor bills for Bizkaia, the structure is different than the regular Ticketbai XML (LROE)"""
values = {
**self._l10n_es_tbai_get_subject_values(invoice, False),
**self._l10n_es_tbai_get_header_values(invoice),
**invoice._get_vendor_bill_tax_values(),
'invoice': invoice,
'datetime_now': datetime.now(tz=timezone('Europe/Madrid')),
'format_date': lambda d: datetime.strftime(d, '%d-%m-%Y'),
'format_time': lambda d: datetime.strftime(d, '%H:%M:%S'),
'format_float': lambda f: float_repr(f, precision_digits=2),
}
# Check if intracom
mod_303_10 = self.env.ref('l10n_es.mod_303_10')
mod_303_11 = self.env.ref('l10n_es.mod_303_11')
tax_tags = invoice.invoice_line_ids.tax_ids.invoice_repartition_line_ids.tag_ids
intracom = bool(tax_tags & (mod_303_10 + mod_303_11))
# Special regime for agriculture, livestock and fishing https://sede.agenciatributaria.gob.es/Sede/iva/regimenes-tributacion-iva/regimen-especial-agricultura-ganaderia-pesca.html
reagyp = invoice.invoice_line_ids.tax_ids.filtered(lambda t: t.l10n_es_type == 'sujeto_agricultura')
if intracom:
values['regime_key'] = ['09']
elif reagyp:
values['regime_key'] = ['02']
else:
values['regime_key'] = ['01']
# Credit notes (factura rectificativa)
values['is_refund'] = invoice.move_type == 'in_refund'
if values['is_refund']:
values['credit_note_code'] = invoice.l10n_es_tbai_refund_reason
values['credit_note_invoice'] = invoice.reversed_entry_id
if reagyp:
values['tipofactura'] = 'F6'
else:
values['tipofactura'] = 'F1'
return values
def _l10n_es_tbai_prepare_values_bi(self, invoice, invoice_xml, cancel=False):
sender = invoice.company_id
lroe_values = {
'is_emission': not cancel,
'sender': sender,
'sender_vat': sender.vat[2:] if sender.vat.startswith('ES') else sender.vat,
'fiscal_year': str(invoice.date.year),
}
if invoice.is_sale_document():
lroe_values.update({'tbai_b64_list': [b64encode(etree.tostring(invoice_xml, encoding="UTF-8")).decode()]})
else:
lroe_values.update(self._l10n_es_tbai_get_in_invoice_values_batuz(invoice))
return lroe_values
def _l10n_es_tbai_prepare_post_params_bi(self, env, agency, invoice, invoice_xml, cancel=False):
"""Web service parameters for Bizkaia."""
lroe_values = self._l10n_es_tbai_prepare_values_bi(invoice, invoice_xml, cancel=cancel)
if invoice.is_purchase_document():
lroe_str = env['ir.qweb']._render('l10n_es_edi_tbai.template_LROE_240_main_recibidas', lroe_values)
if cancel:
invoice.l10n_es_tbai_cancel_xml = b64encode(lroe_str.encode())
else:
invoice.l10n_es_tbai_post_xml = b64encode(lroe_str.encode())
else:
lroe_str = env['ir.qweb']._render('l10n_es_edi_tbai.template_LROE_240_main', lroe_values)
lroe_xml = cleanup_xml_node(lroe_str)
lroe_str = etree.tostring(lroe_xml, encoding="UTF-8")
lroe_bytes = gzip.compress(lroe_str)
company = invoice.company_id
return {
'url': get_key(agency, 'cancel_url_' if cancel else 'post_url_', company.l10n_es_edi_test_env),
'headers': {
'Accept-Encoding': 'gzip',
'Content-Encoding': 'gzip',
'Content-Length': str(len(lroe_str)),
'Content-Type': 'application/octet-stream',
'eus-bizkaia-n3-version': '1.0',
'eus-bizkaia-n3-content-type': 'application/xml',
'eus-bizkaia-n3-data': json.dumps({
'con': 'LROE',
'apa': '1.1' if invoice.is_sale_document() else '2',
'inte': {
'nif': lroe_values['sender_vat'],
'nrs': invoice.company_id.name,
},
'drs': {
'mode': '240',
# NOTE: modelo 140 for freelancers (in/out invoices)
# modelo 240 for legal entities (lots of account moves ?)
'ejer': str(invoice.date.year),
}
}),
},
'pkcs12_data': invoice.company_id.l10n_es_edi_certificate_id,
'data': lroe_bytes,
}
def _l10n_es_tbai_process_post_response_bi(self, env, response):
"""Government response processing for Bizkaia."""
# GLOBAL STATUS (LROE)
response_messages = []
response_success = True
if response.headers['eus-bizkaia-n3-tipo-respuesta'] != "Correcto":
code = response.headers['eus-bizkaia-n3-codigo-respuesta']
response_messages.append(code + ': ' + response.headers['eus-bizkaia-n3-mensaje-respuesta'])
response_success = False
response_data = response.content
response_xml = None
if response_data:
try:
response_xml = etree.fromstring(response_data)
except etree.XMLSyntaxError as e:
response_success = False
response_messages.append(str(e))
else:
response_success = False
response_messages.append(_('No XML response received from LROE.'))
# INVOICE STATUS (only one in batch)
# Get message in basque if env is in basque
if response_xml is not None:
msg_node_name = 'DescripcionErrorRegistro' + ('EU' if get_lang(env).code == 'eu_ES' else 'ES')
invoice_success = response_xml.find(r'.//EstadoRegistro').text == "Correcto"
if not invoice_success:
invoice_code = response_xml.find(r'.//CodigoErrorRegistro').text
if invoice_code == "B4_2000003": # already received
invoice_success = True
response_messages.append(invoice_code + ": " + (response_xml.find(rf'.//{msg_node_name}').text or ''))
return response_success and invoice_success, '<br/>'.join(response_messages), response_xml

View file

@ -0,0 +1,278 @@
# -*- 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 collections import defaultdict
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() or move.is_purchase_document() and move.company_id.l10n_es_tbai_tax_agency == 'bizkaia'
and not any(t.l10n_es_type == 'ignore' for t in move.invoice_line_ids.tax_ids))\
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()
if self.is_purchase_document(): # Batuz
# Check if we are cancelling or not
doc = self.env['account.edi.document'].search([('state', '=', 'to_cancel'),
('edi_format_id.code', '=', 'es_tbai')], limit=1)
if doc and self.l10n_es_tbai_post_xml:
vals = self._get_l10n_es_tbai_values_from_xml({
'sequence': './/CabeceraFactura/SerieFactura',
'number': './/CabeceraFactura/NumFactura',
})
if vals['sequence'] and vals['number']:
return vals['sequence'], vals['number']
number = self.ref
sequence = "TEST" if self.company_id.l10n_es_edi_test_env else ""
else:
sequence = self.sequence_prefix.rstrip('/')
# NOTE non-decimal characters should not appear in the number
seq_length = self._get_sequence_format_param(self.name)[1]['seq_length']
number = f"{self.sequence_number:0{seq_length}d}"
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()
if not self._l10n_es_tbai_is_in_chain():
return ''
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")
def _get_vendor_bill_tax_values(self):
self.ensure_one()
results = defaultdict(lambda: {'base_amount': 0.0, 'tax_amount': 0.0})
amount_total = 0.0
for line in self.line_ids.filtered(lambda l: l.display_type in ('product', 'tax')):
if any(t.l10n_es_type == 'ignore' for t in line.tax_ids) or line.tax_line_id.l10n_es_type == 'ignore':
continue
if line.tax_line_id.l10n_es_type != 'retencion':
amount_total += line.balance
for tax in line.tax_ids.filtered(lambda t: t.l10n_es_type not in ('recargo', 'retencion')):
results[tax]['base_amount'] += line.balance
tax = line.tax_line_id
if (tax and tax.l10n_es_type not in ('recargo', 'retencion') and
line.tax_repartition_line_id.factor_percent != -100.0):
results[tax]['tax_amount'] += line.balance
iva_values = []
for tax in results:
code = "C" # Bienes Corrientes
if tax.l10n_es_bien_inversion:
code = "I" # Investment Goods
if tax.tax_scope == 'service':
code = 'G' # Gastos
iva_values.append({'base': results[tax]['base_amount'],
'code': code,
'tax': results[tax]['tax_amount'],
'rec': tax})
return {'iva_values': iva_values,
'amount_total': amount_total}

View file

@ -0,0 +1,38 @@
# -*- 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()

View file

@ -0,0 +1,76 @@
# -*- 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',
}

View file

@ -0,0 +1,28 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from base64 import b64decode
from odoo import models
from odoo.addons.account.tools.certificate import load_key_and_certificates
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 = 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(),
)
return private_key, certificate

View file

@ -0,0 +1,124 @@
# -*- 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_sudo = self.sudo()
self_sudo.l10n_es_tbai_chain_sequence_id = self_sudo.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')

View file

@ -0,0 +1,10 @@
# -*- 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)

View file

@ -0,0 +1,118 @@
# -*- 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()

View file

@ -0,0 +1,6 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import test_edi_web_services
from . import test_edi_xml
from . import test_resequence

View file

@ -0,0 +1,471 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import base64
from datetime import datetime
from odoo.addons.account_edi.tests.common import AccountEdiTestCommon
from odoo.tools import misc
from pytz import timezone
class TestEsEdiTbaiCommon(AccountEdiTestCommon):
@classmethod
def setUpClass(cls, chart_template_ref='l10n_es.account_chart_template_full', edi_format_ref='l10n_es_edi_tbai.edi_es_tbai'):
super().setUpClass(chart_template_ref=chart_template_ref, edi_format_ref=edi_format_ref)
cls.frozen_today = datetime(year=2025, month=1, day=1, hour=0, minute=0, second=0, tzinfo=timezone('utc'))
# Allow to see the full result of AssertionError.
cls.maxDiff = None
# ==== Config ====
cls.company_data['company'].write({
'name': 'EUS Company',
'country_id': cls.env.ref('base.es').id,
'state_id': cls.env.ref('base.state_es_ss').id,
'vat': 'ES09760433S',
'l10n_es_edi_test_env': True,
})
cls.certificate = None
cls._set_tax_agency('gipuzkoa')
# ==== Business ====
cls.partner_a.write({
'name': "&@àÁ$£€èêÈÊöÔÇç¡⅛™³", # special characters should be escaped appropriately
'vat': 'BE0477472701',
'country_id': cls.env.ref('base.be').id,
'street': 'Rue Sans Souci 1',
'zip': 93071,
})
cls.partner_b.write({
'vat': 'ESF35999705',
})
cls.product_t = cls.env["product.product"].create(
{"name": "Test product"})
cls.partner_t = cls.env["res.partner"].create({"name": "Test partner", "vat": "ESF35999705"})
@classmethod
def _set_tax_agency(cls, agency):
if agency == "araba":
cert_name = 'araba_1234.p12'
cert_password = '1234'
elif agency == 'bizkaia':
cert_name = 'Bizkaia-IZDesa2025.p12'
cert_password = 'IZDesa2025'
elif agency == 'gipuzkoa':
cert_name = 'gipuzkoa_Iz3np32024.p12'
cert_password = 'Iz3np32024'
else:
raise ValueError("Unknown tax agency: " + agency)
cls.certificate = cls.env['l10n_es_edi.certificate'].sudo().create({
'content': base64.encodebytes(
misc.file_open("l10n_es_edi_tbai/demo/certificates/" + cert_name, 'rb').read()),
'password': cert_password,
})
cls.company_data['company'].sudo().write({
'l10n_es_tbai_tax_agency': agency,
'l10n_es_edi_certificate_id': cls.certificate.id,
})
@classmethod
def _get_tax_by_xml_id(cls, trailing_xml_id):
""" Helper to retrieve a tax easily.
:param trailing_xml_id: The trailing tax's xml id.
:return: An account.tax record
"""
return cls.env.ref(f'l10n_es.{cls.env.company.id}_account_tax_template_{trailing_xml_id}')
@classmethod
def create_invoice(cls, **kwargs):
return cls.env['account.move'].with_context(edi_test_mode=True).create({
'move_type': 'out_invoice',
'partner_id': cls.partner_a.id,
'invoice_date': '2025-01-01',
'date': '2025-01-01',
**kwargs,
'invoice_line_ids': [(0, 0, {
'product_id': cls.product_a.id,
'price_unit': 1000.0,
**line_vals,
}) for line_vals in kwargs.get('invoice_line_ids', [])],
})
L10N_ES_TBAI_SAMPLE_XML_POST = """<?xml version='1.0' encoding='UTF-8'?>
<T:TicketBai xmlns:etsi="http://uri.etsi.org/01903/v1.3.2#" xmlns:T="urn:ticketbai:emision" xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
<Cabecera>
<IDVersionTBAI>1.2</IDVersionTBAI>
</Cabecera>
<Sujetos>
<Emisor>
<NIF>___ignore___</NIF>
<ApellidosNombreRazonSocial>EUS Company</ApellidosNombreRazonSocial>
</Emisor>
<Destinatarios>
<IDDestinatario>
<IDOtro>
<IDType>02</IDType>
<ID>BE0477472701</ID>
</IDOtro>
<ApellidosNombreRazonSocial>&amp;@&#224;&#193;$&#163;&#8364;&#232;&#234;&#200;&#202;&#246;&#212;&#199;&#231;&#161;&#8539;&#8482;&#179;</ApellidosNombreRazonSocial>
<CodigoPostal>___ignore___</CodigoPostal>
<Direccion>___ignore___</Direccion>
</IDDestinatario>
</Destinatarios>
<VariosDestinatarios>N</VariosDestinatarios>
<EmitidaPorTercerosODestinatario>N</EmitidaPorTercerosODestinatario>
</Sujetos>
<Factura>
<CabeceraFactura>
<SerieFactura>INVTEST</SerieFactura>
<NumFactura>01</NumFactura>
<FechaExpedicionFactura>01-01-2025</FechaExpedicionFactura>
<HoraExpedicionFactura>___ignore___</HoraExpedicionFactura>
<FacturaSimplificada>N</FacturaSimplificada>
</CabeceraFactura>
<DatosFactura>
<DescripcionFactura>manual</DescripcionFactura>
<DetallesFactura>
<IDDetalleFactura>
<DescripcionDetalle>producta</DescripcionDetalle>
<Cantidad>5.00</Cantidad>
<ImporteUnitario>1000.00</ImporteUnitario>
<Descuento>1000.00</Descuento>
<ImporteTotal>4840.00</ImporteTotal>
</IDDetalleFactura>
</DetallesFactura>
<ImporteTotalFactura>4840.00</ImporteTotalFactura>
<Claves>
<IDClave>
<ClaveRegimenIvaOpTrascendencia>01</ClaveRegimenIvaOpTrascendencia>
</IDClave>
</Claves>
</DatosFactura>
<TipoDesglose>
<DesgloseTipoOperacion>
<Entrega>
<Sujeta>
<NoExenta>
<DetalleNoExenta>
<TipoNoExenta>S1</TipoNoExenta>
<DesgloseIVA>
<DetalleIVA>
<BaseImponible>4000.00</BaseImponible>
<TipoImpositivo>21.00</TipoImpositivo>
<CuotaImpuesto>840.00</CuotaImpuesto>
</DetalleIVA>
</DesgloseIVA>
</DetalleNoExenta>
</NoExenta>
</Sujeta>
</Entrega>
</DesgloseTipoOperacion>
</TipoDesglose>
</Factura>
<HuellaTBAI>
<Software>
<LicenciaTBAI>___ignore___</LicenciaTBAI>
<EntidadDesarrolladora>
<NIF>___ignore___</NIF>
</EntidadDesarrolladora>
<Nombre>___ignore___</Nombre>
<Version>___ignore___</Version>
</Software>
<NumSerieDispositivo>___ignore___</NumSerieDispositivo>
</HuellaTBAI>
</T:TicketBai>
""".encode("utf-8")
L10N_ES_TBAI_SAMPLE_XML_CANCEL = """<T:AnulaTicketBai xmlns:T="urn:ticketbai:anulacion">
<Cabecera>
<IDVersionTBAI>1.2</IDVersionTBAI>
</Cabecera>
<IDFactura>
<Emisor>
<NIF>09760433S</NIF>
<ApellidosNombreRazonSocial>EUS Company</ApellidosNombreRazonSocial>
</Emisor>
<CabeceraFactura>
<SerieFactura>INVTEST</SerieFactura>
<NumFactura>01</NumFactura>
<FechaExpedicionFactura>01-01-2025</FechaExpedicionFactura>
</CabeceraFactura>
</IDFactura>
<HuellaTBAI>
<Software>
<LicenciaTBAI>___ignore___</LicenciaTBAI>
<EntidadDesarrolladora>
<NIF>___ignore___</NIF>
</EntidadDesarrolladora>
<Nombre>___ignore___</Nombre>
<Version>___ignore___</Version>
</Software>
<NumSerieDispositivo>___ignore___</NumSerieDispositivo>
</HuellaTBAI>
</T:AnulaTicketBai>""".encode("utf-8")
L10N_ES_TBAI_SAMPLE_XML_POST_IN = """
<lrpjframp:LROEPJ240FacturasRecibidasAltaModifPeticion xmlns:lrpjframp="https://www.batuz.eus/fitxategiak/batuz/LROE/esquemas/LROE_PJ_240_2_FacturasRecibidas_AltaModifPeticion_V1_0_1.xsd">
<Cabecera>
<Modelo>240</Modelo>
<Capitulo>2</Capitulo>
<Operacion>A00</Operacion>
<Version>1.0</Version>
<Ejercicio>2025</Ejercicio>
<ObligadoTributario>
<NIF>09760433S</NIF>
<ApellidosNombreRazonSocial>EUS Company</ApellidosNombreRazonSocial>
</ObligadoTributario>
</Cabecera>
<FacturasRecibidas>
<FacturaRecibida>
<EmisorFacturaRecibida>
<IDOtro>
<IDType>02</IDType>
<ID>BE0477472701</ID>
</IDOtro>
<ApellidosNombreRazonSocial>&amp;@àÁ$£èêÈÊöÔÇ硳</ApellidosNombreRazonSocial>
</EmisorFacturaRecibida>
<CabeceraFactura>
<SerieFactura>TEST</SerieFactura>
<NumFactura>INV/5234</NumFactura>
<FechaExpedicionFactura>01-01-2025</FechaExpedicionFactura>
<FechaRecepcion>01-01-2025</FechaRecepcion>
<TipoFactura>F1</TipoFactura>
</CabeceraFactura>
<DatosFactura>
<DescripcionOperacion>INV/5234</DescripcionOperacion>
<Claves>
<IDClave>
<ClaveRegimenIvaOpTrascendencia>01</ClaveRegimenIvaOpTrascendencia>
</IDClave>
</Claves>
<ImporteTotalFactura>4840.00</ImporteTotalFactura>
</DatosFactura>
<IVA>
<DetalleIVA>
<CompraBienesCorrientesGastosBienesInversion>C</CompraBienesCorrientesGastosBienesInversion>
<InversionSujetoPasivo>N</InversionSujetoPasivo>
<BaseImponible>4000.00</BaseImponible>
<TipoImpositivo>21.0</TipoImpositivo>
<CuotaIVASoportada>840.00</CuotaIVASoportada>
<CuotaIVADeducible>840.00</CuotaIVADeducible>
</DetalleIVA>
</IVA>
</FacturaRecibida>
</FacturasRecibidas>
</lrpjframp:LROEPJ240FacturasRecibidasAltaModifPeticion>"""
L10N_ES_TBAI_SAMPLE_XML_POST_IN_ND = """
<lrpjframp:LROEPJ240FacturasRecibidasAltaModifPeticion xmlns:lrpjframp="https://www.batuz.eus/fitxategiak/batuz/LROE/esquemas/LROE_PJ_240_2_FacturasRecibidas_AltaModifPeticion_V1_0_1.xsd">
<Cabecera>
<Modelo>240</Modelo>
<Capitulo>2</Capitulo>
<Operacion>A00</Operacion>
<Version>1.0</Version>
<Ejercicio>2025</Ejercicio>
<ObligadoTributario>
<NIF>09760433S</NIF>
<ApellidosNombreRazonSocial>EUS Company</ApellidosNombreRazonSocial>
</ObligadoTributario>
</Cabecera>
<FacturasRecibidas>
<FacturaRecibida>
<EmisorFacturaRecibida>
<IDOtro>
<IDType>02</IDType>
<ID>BE0477472701</ID>
</IDOtro>
<ApellidosNombreRazonSocial>&amp;@àÁ$£èêÈÊöÔÇ硳</ApellidosNombreRazonSocial>
</EmisorFacturaRecibida>
<CabeceraFactura>
<SerieFactura>TEST</SerieFactura>
<NumFactura>INV/5234</NumFactura>
<FechaExpedicionFactura>01-01-2025</FechaExpedicionFactura>
<FechaRecepcion>01-01-2025</FechaRecepcion>
<TipoFactura>F1</TipoFactura>
</CabeceraFactura>
<DatosFactura>
<DescripcionOperacion>INV/5234</DescripcionOperacion>
<Claves>
<IDClave>
<ClaveRegimenIvaOpTrascendencia>01</ClaveRegimenIvaOpTrascendencia>
</IDClave>
</Claves>
<ImporteTotalFactura>1100.00</ImporteTotalFactura>
</DatosFactura>
<IVA>
<DetalleIVA>
<CompraBienesCorrientesGastosBienesInversion>C</CompraBienesCorrientesGastosBienesInversion>
<InversionSujetoPasivo>N</InversionSujetoPasivo>
<BaseImponible>1000.00</BaseImponible>
<TipoImpositivo>10.0</TipoImpositivo>
<CuotaIVASoportada>100.00</CuotaIVASoportada>
<CuotaIVADeducible>0.00</CuotaIVADeducible>
</DetalleIVA>
</IVA>
</FacturaRecibida>
</FacturasRecibidas>
</lrpjframp:LROEPJ240FacturasRecibidasAltaModifPeticion>"""
L10N_ES_TBAI_SAMPLE_XML_POST_IN_IC = """
<lrpjframp:LROEPJ240FacturasRecibidasAltaModifPeticion xmlns:lrpjframp="https://www.batuz.eus/fitxategiak/batuz/LROE/esquemas/LROE_PJ_240_2_FacturasRecibidas_AltaModifPeticion_V1_0_1.xsd">
<Cabecera>
<Modelo>240</Modelo>
<Capitulo>2</Capitulo>
<Operacion>A00</Operacion>
<Version>1.0</Version>
<Ejercicio>2025</Ejercicio>
<ObligadoTributario>
<NIF>09760433S</NIF>
<ApellidosNombreRazonSocial>EUS Company</ApellidosNombreRazonSocial>
</ObligadoTributario>
</Cabecera>
<FacturasRecibidas>
<FacturaRecibida>
<EmisorFacturaRecibida>
<NIF>F35999705</NIF>
<ApellidosNombreRazonSocial>partner_b</ApellidosNombreRazonSocial>
</EmisorFacturaRecibida>
<CabeceraFactura>
<SerieFactura>TEST</SerieFactura>
<NumFactura>INV/5234</NumFactura>
<FechaExpedicionFactura>01-01-2025</FechaExpedicionFactura>
<FechaRecepcion>01-01-2025</FechaRecepcion>
<TipoFactura>F1</TipoFactura>
</CabeceraFactura>
<DatosFactura>
<DescripcionOperacion>INV/5234</DescripcionOperacion>
<Claves>
<IDClave>
<ClaveRegimenIvaOpTrascendencia>09</ClaveRegimenIvaOpTrascendencia>
</IDClave>
</Claves>
<ImporteTotalFactura>12000.00</ImporteTotalFactura>
</DatosFactura>
<IVA>
<DetalleIVA>
<CompraBienesCorrientesGastosBienesInversion>C</CompraBienesCorrientesGastosBienesInversion>
<InversionSujetoPasivo>N</InversionSujetoPasivo>
<BaseImponible>4000.00</BaseImponible>
<TipoImpositivo>21.0</TipoImpositivo>
<CuotaIVASoportada>840.00</CuotaIVASoportada>
<CuotaIVADeducible>840.00</CuotaIVADeducible>
</DetalleIVA><DetalleIVA>
<CompraBienesCorrientesGastosBienesInversion>G</CompraBienesCorrientesGastosBienesInversion>
<InversionSujetoPasivo>N</InversionSujetoPasivo>
<BaseImponible>8000.00</BaseImponible>
<TipoImpositivo>21.0</TipoImpositivo>
<CuotaIVASoportada>1680.00</CuotaIVASoportada>
<CuotaIVADeducible>1680.00</CuotaIVADeducible>
</DetalleIVA>
</IVA>
</FacturaRecibida>
</FacturasRecibidas>
</lrpjframp:LROEPJ240FacturasRecibidasAltaModifPeticion>
"""
L10N_ES_TBAI_CREDIT_NOTE_XML_POST = """<?xml version='1.0'?>
<T:TicketBai xmlns:etsi="http://uri.etsi.org/01903/v1.3.2#" xmlns:T="urn:ticketbai:emision" xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
<Cabecera>
<IDVersionTBAI>1.2</IDVersionTBAI>
</Cabecera>
<Sujetos>
<Emisor>
<NIF>___ignore___</NIF>
<ApellidosNombreRazonSocial>EUS Company</ApellidosNombreRazonSocial>
</Emisor>
<Destinatarios>
<IDDestinatario>
<IDOtro>
<IDType>02</IDType>
<ID>BE0477472701</ID>
</IDOtro>
<ApellidosNombreRazonSocial>&amp;@&#224;&#193;$&#163;&#8364;&#232;&#234;&#200;&#202;&#246;&#212;&#199;&#231;&#161;&#8539;&#8482;&#179;</ApellidosNombreRazonSocial>
<CodigoPostal>___ignore___</CodigoPostal>
<Direccion>___ignore___</Direccion>
</IDDestinatario>
</Destinatarios>
<VariosDestinatarios>N</VariosDestinatarios>
<EmitidaPorTercerosODestinatario>N</EmitidaPorTercerosODestinatario>
</Sujetos>
<Factura>
<CabeceraFactura>
<SerieFactura>___ignore___</SerieFactura>
<NumFactura>00001</NumFactura>
<FechaExpedicionFactura>01-01-2025</FechaExpedicionFactura>
<HoraExpedicionFactura>___ignore___</HoraExpedicionFactura>
<FacturaSimplificada>N</FacturaSimplificada>
<FacturaEmitidaSustitucionSimplificada>N</FacturaEmitidaSustitucionSimplificada>
<FacturaRectificativa>
<Codigo>R1</Codigo>
<Tipo>I</Tipo>
</FacturaRectificativa>
<FacturasRectificadasSustituidas>
<IDFacturaRectificadaSustituida>
<SerieFactura>INVTEST</SerieFactura>
<NumFactura>01</NumFactura>
<FechaExpedicionFactura>___ignore___</FechaExpedicionFactura>
</IDFacturaRectificadaSustituida>
</FacturasRectificadasSustituidas>
</CabeceraFactura>
<DatosFactura>
<DescripcionFactura>manual</DescripcionFactura>
<DetallesFactura>
<IDDetalleFactura>
<DescripcionDetalle>producta</DescripcionDetalle>
<Cantidad>5.00</Cantidad>
<ImporteUnitario>-1000.00</ImporteUnitario>
<Descuento>-1000.00</Descuento>
<ImporteTotal>-4840.00</ImporteTotal>
</IDDetalleFactura>
</DetallesFactura>
<ImporteTotalFactura>-4840.00</ImporteTotalFactura>
<Claves>
<IDClave>
<ClaveRegimenIvaOpTrascendencia>01</ClaveRegimenIvaOpTrascendencia>
</IDClave>
</Claves>
</DatosFactura>
<TipoDesglose>
<DesgloseTipoOperacion>
<Entrega>
<Sujeta>
<NoExenta>
<DetalleNoExenta>
<TipoNoExenta>S1</TipoNoExenta>
<DesgloseIVA>
<DetalleIVA>
<BaseImponible>-4000.00</BaseImponible>
<TipoImpositivo>21.00</TipoImpositivo>
<CuotaImpuesto>-840.00</CuotaImpuesto>
</DetalleIVA>
</DesgloseIVA>
</DetalleNoExenta>
</NoExenta>
</Sujeta>
</Entrega>
</DesgloseTipoOperacion>
</TipoDesglose>
</Factura>
<HuellaTBAI>
<Software>
<LicenciaTBAI>___ignore___</LicenciaTBAI>
<EntidadDesarrolladora>
<NIF>___ignore___</NIF>
</EntidadDesarrolladora>
<Nombre>___ignore___</Nombre>
<Version>___ignore___</Version>
</Software>
<NumSerieDispositivo>___ignore___</NumSerieDispositivo>
</HuellaTBAI>
</T:TicketBai>
"""

View file

@ -0,0 +1,91 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from datetime import datetime
from odoo import fields
from odoo.tests import tagged
from .common import TestEsEdiTbaiCommon
@tagged('external_l10n', 'post_install', '-at_install', '-standard', 'external')
class TestEdiTbaiWebServices(TestEsEdiTbaiCommon):
@classmethod
def setUpClass(cls, chart_template_ref='l10n_es.account_chart_template_full', edi_format_ref='l10n_es_edi_tbai.edi_es_tbai'):
super().setUpClass(chart_template_ref=chart_template_ref, edi_format_ref=edi_format_ref)
# Operations tested here should be available to a billing user
cls.env.user.groups_id = cls.env.ref("account.group_account_invoice")
# Invoice name are tracked by the web-services so this constant tries to get a new unique invoice name at each
# execution.
cls.today = datetime.now()
cls.time_name = cls.today.strftime('%H%M%S')
cls.out_invoice = cls.env['account.move'].create({
'name': f'INV/{cls.time_name}',
'move_type': 'out_invoice',
'partner_id': cls.partner_a.id,
'invoice_line_ids': [(0, 0, {
'product_id': cls.product_a.id,
'price_unit': 1000.0,
'quantity': 5,
'discount': 20.0,
'tax_ids': [(6, 0, cls._get_tax_by_xml_id('s_iva21b').ids)],
})],
})
cls.out_invoice.action_post()
cls.in_invoice = cls.env['account.move'].create({
'name': f'BILL{cls.time_name}',
'ref': f'REFBILL{cls.time_name}',
'move_type': 'in_invoice',
'partner_id': cls.partner_a.id,
'invoice_date': fields.Date.to_string(cls.today.date()),
'invoice_line_ids': [(0, 0, {
'product_id': cls.product_a.id,
'price_unit': 1000.0,
'quantity': 5,
'discount': 20.0,
'tax_ids': [(6, 0, cls._get_tax_by_xml_id('p_iva10_bc').ids)],
})],
})
cls.in_invoice.action_post()
cls.moves = cls.out_invoice + cls.in_invoice
def test_edi_gipuzkoa(self):
self._set_tax_agency('gipuzkoa')
self.moves.action_process_edi_web_services(with_commit=False)
generated_files = self._process_documents_web_services(self.moves, {'es_tbai'})
self.assertTrue(generated_files)
self.assertRecordValues(self.out_invoice, [{'edi_state': 'sent'}])
def test_edi_cancellation(self):
self._set_tax_agency("gipuzkoa")
# Post the invoices
self.moves.action_process_edi_web_services(with_commit=False)
generated_files = self._process_documents_web_services(self.moves, {"es_tbai"})
self.assertTrue(generated_files)
self.assertRecordValues(
self.moves,
[
{"edi_state": "sent", "state": "posted"},
{"edi_state": False, "state": "posted"},
],
)
# Cancel the invoices
self.moves.invalidate_recordset(["l10n_es_tbai_post_xml"])
self.moves.button_cancel_posted_moves()
self.moves.action_process_edi_web_services(with_commit=False)
generated_files = self._process_documents_web_services(self.moves, {"es_tbai"})
# self.assertTrue(generated_files)
self.assertRecordValues(
self.moves,
[
{"edi_state": "cancelled", "state": "cancel"},
{"edi_state": False, "state": "cancel"},
],
)

View file

@ -0,0 +1,243 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from base64 import b64encode
from datetime import datetime
from freezegun import freeze_time
from lxml import etree
from odoo.addons.l10n_es_edi_tbai.models.xml_utils import NS_MAP
from odoo.tests import tagged
from .common import TestEsEdiTbaiCommon
@tagged('post_install', '-at_install', 'post_install_l10n')
class TestEdiTbaiXmls(TestEsEdiTbaiCommon):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.out_invoice = cls.env['account.move'].create({
'name': 'INV/01',
'move_type': 'out_invoice',
'invoice_date': datetime.now(),
'partner_id': cls.partner_a.id,
'invoice_line_ids': [(0, 0, {
'product_id': cls.product_a.id,
'price_unit': 1000.0,
'quantity': 5,
'discount': 20.0,
'tax_ids': [(6, 0, cls._get_tax_by_xml_id('s_iva21b').ids)],
})],
})
cls.edi_format = cls.env.ref('l10n_es_edi_tbai.edi_es_tbai')
def test_xml_tree_post(self):
"""Test of Customer Invoice XML"""
with freeze_time(self.frozen_today):
xml_doc = self.edi_format._get_l10n_es_tbai_invoice_xml(self.out_invoice, cancel=False)[self.out_invoice]['xml_file']
xml_doc.remove(xml_doc.find("Signature", namespaces=NS_MAP))
xml_expected = etree.fromstring(super().L10N_ES_TBAI_SAMPLE_XML_POST)
self.assertXmlTreeEqual(xml_doc, xml_expected)
def test_xml_tree_post_generic_sequence(self):
"""Test TBAI on moves whose sequence does not contain a '/'"""
with freeze_time(self.frozen_today):
invoice = self.out_invoice.copy({
'name': 'INV01',
})
xml_doc = self.edi_format._get_l10n_es_tbai_invoice_xml(invoice, cancel=False)[invoice]['xml_file']
xml_doc.remove(xml_doc.find("Signature", namespaces=NS_MAP))
xml_expected = etree.fromstring(super().L10N_ES_TBAI_SAMPLE_XML_POST)
self.assertXmlTreeEqual(xml_doc, xml_expected)
def test_xml_tree_post_multicurrency(self):
"""Test of Customer Invoice XML. The invoice is not in company currency and has a line with a 100% discount"""
currency_usd = self.env.ref('base.USD')
currency_usd.active = True
date = str(self.out_invoice.invoice_date)
self.env['res.currency.rate'].create({
'name': date,
'company_id': self.company_data['company'].id,
'currency_id': currency_usd.id,
'rate': 0.5})
invoice = self.env['account.move'].create({
'name': 'INV/01',
'move_type': 'out_invoice',
'invoice_date': date,
'partner_id': self.partner_a.id,
'currency_id': currency_usd.id,
'invoice_line_ids': [
(0, 0, {
'product_id': self.product_a.id,
'price_unit': 123.00,
'quantity': 5,
'discount': 20.0,
'tax_ids': [(6, 0, self._get_tax_by_xml_id('s_iva21b').ids)],
}),
(0, 0, {
'product_id': self.product_a.id,
'price_unit': 123.00,
'quantity': 5,
'discount': 100.0,
'tax_ids': [(6, 0, self._get_tax_by_xml_id('s_iva21b').ids)],
}),
],
})
with freeze_time(self.frozen_today):
xml_doc = self.edi_format._get_l10n_es_tbai_invoice_xml(invoice, cancel=False)[invoice]['xml_file']
xml_doc.remove(xml_doc.find("Signature", namespaces=NS_MAP))
xml_expected_base = etree.fromstring(super().L10N_ES_TBAI_SAMPLE_XML_POST)
xpath = """
<xpath expr="//DetallesFactura" position="replace">
<DetallesFactura>
<IDDetalleFactura>
<DescripcionDetalle>producta</DescripcionDetalle>
<Cantidad>5.00</Cantidad>
<ImporteUnitario>246.00</ImporteUnitario>
<Descuento>246.00</Descuento>
<ImporteTotal>1190.64</ImporteTotal>
</IDDetalleFactura>
<IDDetalleFactura>
<DescripcionDetalle>producta</DescripcionDetalle>
<Cantidad>5.00</Cantidad>
<ImporteUnitario>246.00</ImporteUnitario>
<Descuento>1230.00</Descuento>
<ImporteTotal>0.00</ImporteTotal>
</IDDetalleFactura>
</DetallesFactura>
</xpath>
<xpath expr="//ImporteTotalFactura" position="replace">
<ImporteTotalFactura>1190.64</ImporteTotalFactura>
</xpath>
<xpath expr="//DesgloseIVA" position="replace">
<DesgloseIVA>
<DetalleIVA>
<BaseImponible>984.00</BaseImponible>
<TipoImpositivo>21.00</TipoImpositivo>
<CuotaImpuesto>206.64</CuotaImpuesto>
</DetalleIVA>
</DesgloseIVA>
</xpath>
"""
xml_expected = self.with_applied_xpath(xml_expected_base, xpath)
self.assertXmlTreeEqual(xml_doc, xml_expected)
def test_xml_tree_post_retention(self):
self.out_invoice.invoice_line_ids.tax_ids = [(4, self._get_tax_by_xml_id('s_irpf15').id)]
with freeze_time(self.frozen_today):
xml_doc = self.edi_format._get_l10n_es_tbai_invoice_xml(self.out_invoice, cancel=False)[self.out_invoice]['xml_file']
xml_doc.remove(xml_doc.find("Signature", namespaces=NS_MAP))
xml_expected_base = etree.fromstring(super().L10N_ES_TBAI_SAMPLE_XML_POST)
xpath = """
<xpath expr="//ImporteTotalFactura" position="after">
<RetencionSoportada>600.00</RetencionSoportada>
</xpath>
"""
xml_expected = self.with_applied_xpath(xml_expected_base, xpath)
self.assertXmlTreeEqual(xml_doc, xml_expected)
def test_xml_tree_in_post(self):
"""Test XML of vendor bill for LROE Batuz"""
with freeze_time(self.frozen_today):
self.in_invoice = self.env['account.move'].create({
'name': 'INV/01',
'move_type': 'in_invoice',
'ref': 'INV/5234',
'invoice_date': datetime.now(),
'partner_id': self.partner_a.id,
'invoice_line_ids': [(0, 0, {
'product_id': self.product_a.id,
'price_unit': 1000.0,
'quantity': 5,
'discount': 20.0,
'tax_ids': [(6, 0, self._get_tax_by_xml_id('p_iva21_bc').ids)],
})],
})
xml_doc = etree.fromstring(self.edi_format._l10n_es_tbai_get_invoice_content_edi(self.in_invoice))
xml_expected = etree.fromstring(super().L10N_ES_TBAI_SAMPLE_XML_POST_IN)
self.assertXmlTreeEqual(xml_doc, xml_expected)
def test_xml_tree_no_deducible_tax(self):
"""Test XML of vendor bill with non deductible tax"""
with freeze_time(self.frozen_today):
self.in_invoice = self.env['account.move'].create({
'name': 'INV/01',
'move_type': 'in_invoice',
'ref': 'INV/5234',
'invoice_date': datetime.now(),
'partner_id': self.partner_a.id,
'invoice_line_ids': [(0, 0, {
'product_id': self.product_a.id,
'price_unit': 1000.0,
'quantity': 1,
'tax_ids': [(6, 0, self._get_tax_by_xml_id('p_iva10_nd').ids)],
})],
})
xml_doc = etree.fromstring(self.edi_format._l10n_es_tbai_get_invoice_content_edi(self.in_invoice))
xml_expected = etree.fromstring(super().L10N_ES_TBAI_SAMPLE_XML_POST_IN_ND)
self.assertXmlTreeEqual(xml_doc, xml_expected)
def test_xml_tree_in_ic_post(self):
"""Test XML of vendor bill for LROE Batuz intra-community"""
with freeze_time(self.frozen_today):
self.in_invoice = self.env['account.move'].create({
'name': 'INV/01',
'move_type': 'in_invoice',
'ref': 'INV/5234',
'invoice_date': datetime.now(),
'partner_id': self.partner_b.id,
'invoice_line_ids': [(0, 0, {
'product_id': self.product_a.id,
'price_unit': 1000.0,
'quantity': 5,
'discount': 20.0,
'tax_ids': [(6, 0, self._get_tax_by_xml_id('p_iva21_ic_bc').ids)],
}), (0, 0, {
'product_id': self.product_b.id,
'price_unit': 2000.0,
'quantity': 5,
'discount': 20.0,
'tax_ids': [(6, 0, self._get_tax_by_xml_id('p_iva21_sp_in').ids)],
})],
})
xml_doc = etree.fromstring(self.edi_format._l10n_es_tbai_get_invoice_content_edi(self.in_invoice))
xml_expected = etree.fromstring(super().L10N_ES_TBAI_SAMPLE_XML_POST_IN_IC)
self.assertXmlTreeEqual(xml_doc, xml_expected)
def test_xml_tree_cancel(self):
self.out_invoice.l10n_es_tbai_post_xml = b64encode(b"""<TicketBAI>
<CabeceraFactura><FechaExpedicionFactura>01-01-2025</FechaExpedicionFactura></CabeceraFactura>
<ds:SignatureValue xmlns:ds="http://www.w3.org/2000/09/xmldsig#">TEXT</ds:SignatureValue>
</TicketBAI>""") # hack to set out_invoice's registration date
xml_doc = self.edi_format._get_l10n_es_tbai_invoice_xml(self.out_invoice, cancel=True)[self.out_invoice]['xml_file']
xml_doc.remove(xml_doc.find("Signature", namespaces=NS_MAP))
xml_expected = etree.fromstring(super().L10N_ES_TBAI_SAMPLE_XML_CANCEL)
self.assertXmlTreeEqual(xml_doc, xml_expected)
def test_xml_tree_credit_note_post(self):
"""Test of Customer Credit Note XML"""
self.out_invoice.action_post()
move_reversal = self.env['account.move.reversal'].with_context(
active_model="account.move",
active_ids=self.out_invoice.ids
).create({
'date': str(self.out_invoice.invoice_date),
'reason': 'no reason',
'l10n_es_tbai_refund_reason': 'R1',
'refund_method': 'cancel',
'journal_id': self.out_invoice.journal_id.id,
})
reversal = move_reversal.reverse_moves()
reverse_move = self.env['account.move'].browse(reversal['res_id'])
with freeze_time(self.frozen_today):
xml_doc = self.edi_format._get_l10n_es_tbai_invoice_xml(reverse_move, cancel=False)[reverse_move]['xml_file']
xml_doc.remove(xml_doc.find("Signature", namespaces=NS_MAP))
xml_expected = etree.fromstring(super().L10N_ES_TBAI_CREDIT_NOTE_XML_POST)
self.assertXmlTreeEqual(xml_doc, xml_expected)

View file

@ -0,0 +1,13 @@
from odoo.addons.l10n_es_edi_sii.tests import test_resequence
class TestResequenceTbai(test_resequence.TestResequenceSII):
@classmethod
def setUpClass(
cls,
chart_template_ref="l10n_es.account_chart_template_full",
edi_format_ref="l10n_es_edi_sii.edi_es_sii",
):
super().setUpClass(
chart_template_ref=chart_template_ref, edi_format_ref=edi_format_ref
)

View file

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<record id="view_move_form_inherit_l10n_es_edi_tbai" model="ir.ui.view">
<field name="name">account.move.form.inherit.l10n_es_edi_tbai</field>
<field name="model">account.move</field>
<field name="inherit_id" ref="account.view_move_form"/>
<field name="arch" type="xml">
<xpath expr="//group[@id='other_tab_group']/group[last()]" position='after'>
<group id="ticketbai_group" string="TicketBAI" attrs="{'invisible': [('l10n_es_tbai_is_required', '=', False)]}">
<field name="l10n_es_tbai_is_required" invisible="1"/>
<field name="l10n_es_tbai_chain_index" groups="base.group_no_one"/>
<field name="l10n_es_tbai_refund_reason"
attrs="{
'readonly': [('state', '!=', 'draft')],
'invisible': [('move_type', 'not in', ('in_refund', 'out_refund'))]
}"/>
<field name="reversed_entry_id" attrs="{'invisible': [('move_type', '!=', 'in_refund')]}"/>
</group>
</xpath>
</field>
</record>
</data>
</odoo>

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<template id="l10n_es_tbai_external_layout_standard" inherit_id="account.report_invoice_document">
<xpath expr="//div[@id='qrcode']" position="after">
<div name="l10n_es_tbai_qrcode" t-if="o._l10n_es_tbai_is_in_chain()" style="page-break-inside: avoid">
<div t-out="o._get_l10n_es_tbai_id()"/>
<img
t-att-src="'/report/barcode/?barcode_type=%s&amp;value=%s&amp;width=%s&amp;height=%s&amp;barLevel=%s'%('QR', quote_plus(o._get_l10n_es_tbai_qr()), 125, 125, 'M')"/>
<!-- NOTE: Sizes assume a 90 dpi resolution to meet requirements (between 30 and 40 mm) -->
</div>
</xpath>
</template>
</odoo>

View file

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="res_company_form_l10n_es_edi_tbai" model="ir.ui.view">
<field name="name">res.company.form</field>
<field name="model">res.company</field>
<field name="inherit_id" ref="account.view_company_form"/>
<field name="arch" type="xml">
<xpath expr="//page[@name='general_info']/group/group[last()]" position="after">
<group>
<field name="l10n_es_tbai_license_html" type="object"
attrs="{'invisible': [('country_code', '!=', 'ES')]}"/>
</group>
</xpath>
</field>
</record>
<!-- TicketBAI specifies there needs to be a menu link to display the license information -->
<menuitem id="menu_l10n_es_edi_tbai_license"
name="Licenses (TicketBAI)"
action="base.action_res_company_form"
sequence="90"
parent="l10n_es_edi_sii.menu_l10n_es_edi_root"
groups="account.group_account_manager">
</menuitem>
</odoo>

View file

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="res_config_settings_view_form" model="ir.ui.view">
<field name="name">res.config.settings.view.form.inherit.l10n.es</field>
<field name="model">res.config.settings</field>
<field name="inherit_id" ref="account.res_config_settings_view_form"/>
<field name="arch" type="xml">
<xpath expr="//div[@data-key='account']/div[@name='spain_localization']//div[hasclass('o_setting_right_pane')]/span[hasclass('o_form_label')]" position="replace">
<span class="o_form_label">Registro de Libros connection SII/TicketBAI</span>
</xpath>
<xpath expr="//div[@data-key='account']/div[@name='spain_localization']//div[hasclass('o_setting_right_pane')]//div[hasclass('mt16')]/*" position="before">
<label for="l10n_es_tbai_tax_agency" class="o_light_label"/>
<field name="l10n_es_tbai_tax_agency"/>
<div class="text-muted" attrs="{'invisible': [('l10n_es_tbai_tax_agency', '!=', False)]}">
No tax agency selected: TicketBAI not activated.
</div>
<div class="text-muted" attrs="{'invisible': [('l10n_es_tbai_tax_agency', '=', False)]}">
Tax agency selected: TicketBAI is activated.
</div>
<br/>
</xpath>
</field>
</record>
</odoo>

View file

@ -0,0 +1,5 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import account_move_reversal
from . import account_resequence_wizard

View file

@ -0,0 +1,43 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models, api
from odoo.exceptions import UserError
class AccountMoveReversal(models.TransientModel):
_inherit = 'account.move.reversal'
l10n_es_tbai_is_required = fields.Boolean(
compute="_compute_l10n_es_tbai_is_required", readonly=True,
string="Is TicketBai required for this reversal",
)
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.",
)
@api.depends('move_ids')
def _compute_l10n_es_tbai_is_required(self):
for wizard in self:
moves_tbai_required = set(m.l10n_es_tbai_is_required for m in wizard.move_ids)
if len(moves_tbai_required) > 1:
raise UserError("Reversals mixing invoices with and without TicketBAI are not allowed.")
wizard.l10n_es_tbai_is_required = moves_tbai_required.pop()
def _prepare_default_reversal(self, move):
# OVERRIDE
values = super()._prepare_default_reversal(move)
if move.company_id.country_id.code == "ES" and move.l10n_es_tbai_is_required:
values.update({
'l10n_es_tbai_refund_reason': self.l10n_es_tbai_refund_reason,
})
return values

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_account_move_reversal" model="ir.ui.view">
<field name="name">account.move.reversal.form.inherit.l10n_es_edi_tbai</field>
<field name="model">account.move.reversal</field>
<field name="inherit_id" ref="account.view_account_move_reversal"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='journal_id']" position="after">
<field name="l10n_es_tbai_is_required" invisible="1"/>
<field attrs="{'invisible': [('l10n_es_tbai_is_required', '=', False)]}" name="l10n_es_tbai_refund_reason" widget="selection"/>
</xpath>
</field>
</record>
</odoo>

View file

@ -0,0 +1,14 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
class AccountResequenceWizard(models.TransientModel):
_inherit = "account.resequence.wizard"
def _frozen_edi_documents(self):
docs = super()._frozen_edi_documents()
# TicketBAI/Batuz vendor bills are sent with ref, so they can be resequenced
return docs.filtered(
lambda doc: doc.edi_format_id.code != "es_tbai"
or doc.move_id.is_sale_document()
)

View file

@ -0,0 +1,42 @@
[project]
name = "odoo-bringout-oca-ocb-l10n_es_edi_tbai"
version = "16.0.0"
description = "Spain - TicketBAI - Odoo addon"
authors = [
{ name = "Ernad Husremovic", email = "hernad@bring.out.ba" }
]
dependencies = [
"odoo-bringout-oca-ocb-l10n_es_edi_sii>=16.0.0",
"requests>=2.25.1"
]
readme = "README.md"
requires-python = ">= 3.11"
classifiers = [
"Development Status :: 5 - Production/Stable",
"Intended Audience :: Developers",
"License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3)",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Topic :: Office/Business",
]
[project.urls]
homepage = "https://github.com/bringout/0"
repository = "https://github.com/bringout/0"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.metadata]
allow-direct-references = true
[tool.hatch.build.targets.wheel]
packages = ["l10n_es_edi_tbai"]
[tool.rye]
managed = true
dev-dependencies = [
"pytest>=8.4.1",
]