From 184f735871691983df34fbf137a6ef85a152b677 Mon Sep 17 00:00:00 2001 From: Stanislas Gueniffey Date: Wed, 26 Apr 2023 13:51:11 +0000 Subject: [PATCH] [ADD] l10n_es_edi_tbai: Basque country's TicketBAI **TicketBAI features** - Generation of XML documents for invoices - Automatic signing of documents with certificates - Sending XML to the appropriate tax agency's servers - Parsing and storing their response - Preserving the invoice signature chain integrity - Displaying the TicketBAI QR code on the invoice PDF - Downloading XSD files for local validation - Test mode using tax agencies' test servers **Supported accounting flows** - Posting and canceling invoices - Foreign and national customers - Multi-currency - Refunds (credit notes) - Discounts - Simplified invoicing **Not supported accounting flows** - Specific VAT regimes - Handling of third-party invoices - XML generation and EDI operations for vendor bills (Bizkaia LROE) - Modelo 140 (Bizkaia LROE) - Invoice batching (Bikzaia LROE) closes odoo/odoo#122691 X-original-commit: f8cad00a6cd5765b6f962909cf849acb1a8aa18b Signed-off-by: Josse Colpaert --- addons/l10n_es_edi_tbai/__init__.py | 5 + addons/l10n_es_edi_tbai/__manifest__.py | 43 ++ .../data/account_edi_data.xml | 9 + .../data/template_LROE_bizkaia.xml | 38 ++ .../data/template_invoice.xml | 266 ++++++++ .../demo/certificates/Bizkaia-IZDesa2021.p12 | Bin 0 -> 4834 bytes .../demo/certificates/araba_1234.p12 | Bin 0 -> 7326 bytes .../demo/certificates/gipuzkoa_IZDesa2021.p12 | Bin 0 -> 4834 bytes addons/l10n_es_edi_tbai/demo/demo_company.xml | 6 + .../demo/demo_res_partner.xml | 21 + addons/l10n_es_edi_tbai/models/__init__.py | 12 + .../models/account_edi_document.py | 25 + .../models/account_edi_format.py | 612 ++++++++++++++++++ .../l10n_es_edi_tbai/models/account_move.py | 223 +++++++ .../l10n_es_edi_tbai/models/ir_attachment.py | 38 ++ .../models/l10n_es_edi_tbai_agencies.py | 76 +++ .../models/l10n_es_edi_tbai_certificate.py | 30 + addons/l10n_es_edi_tbai/models/res_company.py | 123 ++++ .../models/res_config_settings.py | 10 + addons/l10n_es_edi_tbai/models/xml_utils.py | 118 ++++ addons/l10n_es_edi_tbai/tests/__init__.py | 5 + addons/l10n_es_edi_tbai/tests/common.py | 216 +++++++ .../tests/test_edi_web_services.py | 61 ++ addons/l10n_es_edi_tbai/tests/test_edi_xml.py | 54 ++ .../views/account_move_view.xml | 23 + .../l10n_es_edi_tbai/views/report_invoice.xml | 14 + .../views/res_company_views.xml | 25 + .../views/res_config_settings_views.xml | 24 + addons/l10n_es_edi_tbai/wizards/__init__.py | 4 + .../wizards/account_move_reversal.py | 43 ++ .../wizards/account_move_reversal_views.xml | 14 + 31 files changed, 2138 insertions(+) create mode 100644 addons/l10n_es_edi_tbai/__init__.py create mode 100644 addons/l10n_es_edi_tbai/__manifest__.py create mode 100644 addons/l10n_es_edi_tbai/data/account_edi_data.xml create mode 100644 addons/l10n_es_edi_tbai/data/template_LROE_bizkaia.xml create mode 100644 addons/l10n_es_edi_tbai/data/template_invoice.xml create mode 100644 addons/l10n_es_edi_tbai/demo/certificates/Bizkaia-IZDesa2021.p12 create mode 100644 addons/l10n_es_edi_tbai/demo/certificates/araba_1234.p12 create mode 100644 addons/l10n_es_edi_tbai/demo/certificates/gipuzkoa_IZDesa2021.p12 create mode 100644 addons/l10n_es_edi_tbai/demo/demo_company.xml create mode 100644 addons/l10n_es_edi_tbai/demo/demo_res_partner.xml create mode 100644 addons/l10n_es_edi_tbai/models/__init__.py create mode 100644 addons/l10n_es_edi_tbai/models/account_edi_document.py create mode 100644 addons/l10n_es_edi_tbai/models/account_edi_format.py create mode 100644 addons/l10n_es_edi_tbai/models/account_move.py create mode 100644 addons/l10n_es_edi_tbai/models/ir_attachment.py create mode 100644 addons/l10n_es_edi_tbai/models/l10n_es_edi_tbai_agencies.py create mode 100644 addons/l10n_es_edi_tbai/models/l10n_es_edi_tbai_certificate.py create mode 100644 addons/l10n_es_edi_tbai/models/res_company.py create mode 100644 addons/l10n_es_edi_tbai/models/res_config_settings.py create mode 100644 addons/l10n_es_edi_tbai/models/xml_utils.py create mode 100644 addons/l10n_es_edi_tbai/tests/__init__.py create mode 100644 addons/l10n_es_edi_tbai/tests/common.py create mode 100644 addons/l10n_es_edi_tbai/tests/test_edi_web_services.py create mode 100644 addons/l10n_es_edi_tbai/tests/test_edi_xml.py create mode 100644 addons/l10n_es_edi_tbai/views/account_move_view.xml create mode 100644 addons/l10n_es_edi_tbai/views/report_invoice.xml create mode 100644 addons/l10n_es_edi_tbai/views/res_company_views.xml create mode 100644 addons/l10n_es_edi_tbai/views/res_config_settings_views.xml create mode 100644 addons/l10n_es_edi_tbai/wizards/__init__.py create mode 100644 addons/l10n_es_edi_tbai/wizards/account_move_reversal.py create mode 100644 addons/l10n_es_edi_tbai/wizards/account_move_reversal_views.xml diff --git a/addons/l10n_es_edi_tbai/__init__.py b/addons/l10n_es_edi_tbai/__init__.py new file mode 100644 index 00000000000..3d0197513d5 --- /dev/null +++ b/addons/l10n_es_edi_tbai/__init__.py @@ -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 diff --git a/addons/l10n_es_edi_tbai/__manifest__.py b/addons/l10n_es_edi_tbai/__manifest__.py new file mode 100644 index 00000000000..3f72eb5f579 --- /dev/null +++ b/addons/l10n_es_edi_tbai/__manifest__.py @@ -0,0 +1,43 @@ +# -*- 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', + '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', +} diff --git a/addons/l10n_es_edi_tbai/data/account_edi_data.xml b/addons/l10n_es_edi_tbai/data/account_edi_data.xml new file mode 100644 index 00000000000..8d21a99f448 --- /dev/null +++ b/addons/l10n_es_edi_tbai/data/account_edi_data.xml @@ -0,0 +1,9 @@ + + + + + TicketBAI (ES) + es_tbai + + + diff --git a/addons/l10n_es_edi_tbai/data/template_LROE_bizkaia.xml b/addons/l10n_es_edi_tbai/data/template_LROE_bizkaia.xml new file mode 100644 index 00000000000..318862b0dcd --- /dev/null +++ b/addons/l10n_es_edi_tbai/data/template_LROE_bizkaia.xml @@ -0,0 +1,38 @@ + + + + + + + + + diff --git a/addons/l10n_es_edi_tbai/data/template_invoice.xml b/addons/l10n_es_edi_tbai/data/template_invoice.xml new file mode 100644 index 00000000000..0e280e9fb30 --- /dev/null +++ b/addons/l10n_es_edi_tbai/data/template_invoice.xml @@ -0,0 +1,266 @@ + + + + + + + + + + + + + + + + + + diff --git a/addons/l10n_es_edi_tbai/demo/certificates/Bizkaia-IZDesa2021.p12 b/addons/l10n_es_edi_tbai/demo/certificates/Bizkaia-IZDesa2021.p12 new file mode 100644 index 0000000000000000000000000000000000000000..ad2d5239630ec816df96ada589f4abb02788da1d GIT binary patch literal 4834 zcmZWr2Q*yY-o3*h7&XHXy+;Y7M(=`X(Mv=b%tVM@qKhtw5}hCk(QDL1@1jNoi7q;$ zi_SOk{_nl-Ti-hCl;7U_?0wFGAY3#&2zMRRmvIUI*##j0VdVsYx8XtH4LB_>!M|aQIWhQFpvY4( zMZ3UTSXj3Ja1glHg)5Zol7K`a*nPJ1LERHieILV={@RKItr1!VhXQrzW#M2 zWK?GKV!S|tT+8N`fAY!Re$zekSqXY5pdv$tG9Y*}gt88YWxLsp&Bs^9c;nRScmMvi zzRw_g7@2pkO5fjQ_v!tg*~Ll);fYeX6jmfJ@z!-GdPh6`lX@Dr85Vbnh_1g8&dC;h z@?iza%mA(Dki~+KbtUg#Lo>rh!TMg4j<$>v7ii1T;nYf*Oq%UD z5!Yqnl&ns9iMNsXJo>o5eiZ5F?!C+IH7ZhTdrjKX&Q%P{rZ3$*@Hy>aS~pp>CLm17 z=rfPUR+D+bgCO576iQ~#*`%u6Q(sj_P2~CevP3DTQL40&p-3lxmfWJfvz30nW+e{B z-q~vj!AY}17Dp)l8t-IKj2#f>^YW_PDRxL@TB{31FeMB*lLLT6;1WfEsYMx~3BrdP==`-7NB{I^!2P`P%$Re!px=sJfK&F8?6 zczyk!9!@*#YQ0Dg*0huTw+6U;reRsuujxCWgEp1hR6o2NKh*d}ILpl{sd`0xkmEY6 zPbu@$!!8-xG5O}>`26BhnPd4oU69KAL}=5#RIZw?=lW(SRI-*OV#;PFn|YEn6pYm})@ z8ZCb%b_TLdi~PC!zGd8={0mygWi~_NspEHfrDEM<5X{MZG}%PLM`E`&4_iiN-7`uu z(M34>K>WFhTlESg|C?Grtje`XWQMOT%uGh$oOA7=s93r%dgKpYklnJVzqW$I%;^(G z&OaF?kc5qk@C=~3gojFtIBXqw7J&fg*k~V)S z{+hK5CS6U4Rc`QwcvrWBkEnFk(slTfL1yDE`MU$Qw)AqY^9M-MveB9nz-35Q!Py4e zuU$l7@b?t~qt5Zk*=`$;okvrPz)bbxiQ+y~QEO6w5hO%D^v50%*^_!UH7z4rl`U zfCeCcgGga8ML+?w)&=eXV*jh%2ShMCcmPHWqX|d>4=_pvK<{Q$1+*}@+&{GeruwT> z#;E@0sAHNIX4bq>D`IqtH@w1)k1Akt!#%=`f4zAzJns#!g~?OKRGOF!6U_WKOY5dm z{Eyq;Q~bS&6ku?p_}960f%||cAc#3JKLE$9RsQkQ$LLG|TL6XW_JBJk&l|IH2V4L* z!14xl!PJ%*{2rz&05&&_Er7(JRv6qGaKzx&7@QGs#f%sDYPfd>%={O94};pPMpI$ardCr?6-3U$$cxJf!v_MYIa(5ZuEO z>p$_qF}8}-jgjLQ1{SPm-tDrV`O@!it%z2i^j!H&(dVx$Mg=EvgIyfym#KG)!=xAE zcm6`bk4PE9t=B_lE!^{58TMtwYm+A> zBi3A{kVy@F3F&}%i)!PhwmFEyZGdfr3F=X)!wUi4&-Y@aMJ+NTI8%jD)#(&vQ_m!6c>hD->Z$2_k1=3+jA^7xWcgkOCC$x^v*cqjp8roEPUj?TM z2f$teEblr^*iC&W-({q{F6H=X-u*Gx-=))(7wrY6jUImJ5=Jq~XNchQ!2g|Gv|bqgyiF=3mdY$}|0eN?E-=iQ`0a5wi3gKq!sS3+a=U`g<| zs)bYzQj#({(Q=$)7xXW>%D5gxl#W@XAh?m;8a%&p{{_Tizv~!BXVvIM9&G;d0Hs6D zA|Ce1>{#NBhHvBq8lgQ@e9)2h{H1ejA5SZZ`+gQhwzWo|RsT_WTI>)EcA8}6)$U8o z8J4{~+XhuIn#?s}WIc&|hHYXx8Eq965?Do##__EWk<-zynU5g<*h`eQlvq2FW}K*O zSPAd2RXWhT4zvb`Fhj=e&|<*g?K3B9g}eGBvKbHohQ)$g#FjnN#< zN}uFlpIP5LMu{5pK?5O?+H##v7oKv7uNIZ_6>EyF5kDQjw;97&z4! z{8+;~`+!t~zF}BS&do;pmc&t|v3hi+S=?(flRP-J>Ulldd%Rbsb)n;jBzo)m1OLIc za`r=E&S$;N9{x%jr9G$2<;2_NU{h1%O49B>=0I}?(fi)8(|K@S`y2At%IKa37DjHvc6kKS&8AbZ{( zYae;w9Vt>Rt$T0iAKGu6M&+XY(vzNe|KfebwAHS@$x|X=YljZhZRMicSoWOU&!Hq$ zLE?0t`MU)Cy!AfKbZd*4NK2pwp=rJPbZAlnPMS2cKGk%aW-QFfx!{cS1Z;DEuEt;i`=vJw1!OiBR<#tcwTjTBM>Lnw^LLz zB3^-q%N9!byhbdquz4iZN9ML(PhG$2ri)>0X_zvlCH?jc9QMHdg)c1r_5g|>=_iH% zPBvKM@mS~=JG#18u92wr^j%WCIDJ@!CS`j!CstznxvO>Z(#jwZE!1E^7H;{7&Ecfz zkp#>8GmkHnwZ>Fy@816!x!SkZE<{V8t*$Ls67CbK_@l}!vUb){$JAebGC?R~-*6Uu@ngi%riKwv>{-@C_7Do8Hwlbm()S z>l39uZyIo%0YV0Z?d1X6d*@ys6Pw8*!>VDTVf~o9-awt#1{}LK#n3NvJwGd; zub}G=FL+$?yCbtql?#BxEf<>T>$I^H4oA*{sH~_vO|u z?b_iqc5*seWYA7d^(=QlK+}gH%GcMgOoAJqha|sh`uy6TMi)wMp=VRe2MB*|Ln~Ap z^A$-+Z_tQdLtkZU?S-(8;+N=lW@hirsC(8=i&$l&$G857-}Z?N{Mv#RFkdO4k7HbK z*wjC@xeI40cuOaIB%w)-k0&^1GRRVE;5|(|hnFbywU^h5cIm=%z<=(p4H$TYCg-z% z{0{({e z9$MJJ;>rgGQ?Z_Q?ZI*WpqjW^!g^sz5ndG@VQW_941zuoeZiL@n^UKjO(scvws;HF zsv@HnbS>V?TL2sQZgPQ73(1q0!OjoqihX+8prb7$z4Wq}+J@1t@ts9G(Hg>#?zvrU za=`mhco)sQJk0M-SQm4Vld+cbkXNP zF*ZUGgUg)$xPEfy+EN!XR^7swP#006_e1OQTKkOTw_Q-KS5?Bzo&AIG`)~$09xe_y zF*ep6Fw~MjH(`WSc=;t;QQzut5mV=;Mgy4i+EiCx=-I+QI0%XO8!XHUK>++83jQ1p z0)Na3fj@Y&9R$?>?m|NW!sI~UURWV;53D2zDE|Y$iDSSa{iYSnnj;>Pw4S%zjxRbG4yQUdu=MC$~uvj)j z)K6UWj*A#C+c~VqX@B58LgL^9zEAP&q2#)s74cFBh#kfBm^PxRD~nE=P}FBT=v1)X z$zw{y@t#edC=U5T&5ai)MyOQdcKi{RiHcbPchl&c)Xz z-Q6dOtnrST)Dq*02^TMfYRBaYYbkiJ$4+YG)wH2lXEV?Ji8YrQc$r>Iyo~Kn(_^a= zC{Cu#8#HT}-+sTav{^dls>xqM!+ft`G9xpZUDA+m%`15W9SBW$$GW2gQp%u_S>D6* zrlgXB+smAJf0Ob>y9534o}HhiGRo;Gyfzu7T?K!PwNL4@NayjlN+SoLk*0^?Z1>w$$JCh>@H$8e_kk)X1b`)pBOe@aFa zx!;>qd(~A6R(4btDlmg+qA8&jU6ojd{)6Z$bmANt4cCrk2_hO)_vuw_J>%T*h`fip zp^6`1=(THNyP2)fWIcFcQ^9JW{rOg4I^S7TOl5KOYL3dcWK+T;}kv9e7a@aeVC(|4O2 zTem|$)r7z|PL*g>Y#2Quae&u?uaKUtM=_Da2u87j3% z@5>oza+rG&?c=od9xyAd!HC!H->tllVRfCIqg&CK7% zQ2^B5{6C02z~U_ve~Y~Uj&GR5TkiZez!6~iZx;nX`QJMCx6JfykTWX;kNZFOM?}bh z;L*Q9G%!HG+d=xj4DkOi{;2THDE%XJtA7@MAO!cUS}zlyZ|W-JK`i8>W~UEt3YEXY z$B4P(g09U4e##Gv2YejQUIwFPR*SI7${&%0n?I!gjUS7hrTR$Ur-|r`H0!-K?2`2@ z=0p5xjMzY3$8?H@#)q6Q_mP%JxZCk#ws)--#Q_o;noO-vTO_jZtwK3{ulL%t6%g(b za|O)4p4vmdD0=<=IZ=HeT)5#pvCx~$u#Y!^M92t}9sBvlRBGKaV-hu;oUzSKf^> z+vq@1wZXkChDFb?#lK79x;Tk`H05yUC)zCGGw(w)Di(@_JL#)yivDb%^5?kZdZc*w z*#}YxKQ}dot{(W)vVX<1Z8(LWGt+Q4lvI(_e;L2VzxIgAXd)(Joa`V?@UN z8D%7=X_g;KmEJ$#Ekk=7Q|{@b^P}ra)cl1n0YApD0w{?6%_Y2&ko)q6QN|qQgRYK# zg|etq6~x}n`rqt$Y4j&@)@X1%UNQSFZ}gGHX%4ocHD4|%h3&ZW^7sy6_%wmq;?b8@ z%mq1mNh;!4vCWENJ0U@p38TL$F@-4mM=MEP`?7!4`(}pMx?j%>3bY(?F?B%`>R%)@PF67D-`l8p}g*H9eVC1D#GGX`zfV8W~rN6CfiDu z;5HfX@i6LBJUEwS0M}EG{F8KU8Be(VxL;}XBS|uFXhVHpTRr1%N!A?m`pGzLya~o; zx_5r&iQq^QiSjUH)9qT2m-VW8hHTQ7_Eyd}&_S;?)sj5&jGH@!dHm4#Mh0U6Z>h3R zL!jG-3|UDCi9o{sOPoUy)iaZ(-A@H%u@AIG)WcWe{yG~=pmM_n6`BmQ%+32D_-pK^ zAJliaTouvYSdsKfzB#+SBxeN598VjjP@!2sgtiRu+t`fJ_pq3+$+=Iu zutID-b*q=r8^0594b?sR1mHf3gyChWKphQhPa|b$@No0C3?1doxZB8^D60)p60vlE z$reuEB&rJ#%A6$4A4T)_yq$iElUc(S4%2P07ftD>1d*MzUR5q}drWfV%m^n{vpMRkqAj`@$(xhflq4k+WI9Q0pGK z-gfQZ=atZOCoI&hggFE47)=@Ih{De@g^_0alo2lZ_Oy2axHE$B+azU*>xTL`E6k6` z-nAZzd*S}-RxmI~W7MtH`gtB{5Ql)k((xBJ@|~5XfXRqWkbXp}NEs&!-DJ@H#Qsne zI=&~Ywz6Tb9ydcR|IJ9ENcSVIkE-pwCCvvvx39{-YZZ7;?=kAf$RUw`Hv;74hk=BI zb*SOa9GmY5x7eOePA7>uV(Q1vFN^wgg55>8elaWF?$s&W(XzWF#cEScBPLViCWzdk z->m5cP)P-@#=lnr(4w0d#T>Pal4BSQMp0Y8_p+bxb#Y9hT?0ZjOb5ypu-xM3CNF?blTi z8l*6`5`o#@iq9Gxp0sMHIqL1Q1||WbK8Q+_PPo=AAfv+(qCOa+615+ep1=B|f%Nn= z>v&rwV?~5UFHWm0l@+*fDbX|Jc9(wp@y2Ec+DJz${Uv$bAlKD$VjUE7Kn!yYqMz*e}cx|;Y`*uuk%7$h(TuL1o+c}4b2~M)|v z5`bmw$l-9Czi*PDWP@3iF_?P2v}2st=0r8;0GRyAeW#|+HWo~UzqXwV6zvmgxGqC&PTA5d9!l% z<4`}55}^Y_w}KU0zgTBPn3KuUDVy1!&F+^GqpiGoeK0cURgE9)vW9SmE;qxpq4E(> zSGj$M(HM?kj`6GbL#o3v(buu+9+H!5LvCN(XMcSapkZ-yMp1)Wp-q)6ib?)LWY$ci zci)BU^`3=O1T7~Kv)$*)PjbaRdA_WfLLKWLr8T6(vP&kP8+Z&%bNbh-S}Q`fRq0pX zVRlYdxCc2!Hzo8KV14wJtc`s~XqPuTzr`ymk=)-$a}ett`{cM3u|{*^z6)s!3rCMr zY7TonFO>ZE2qDHo%{F~7_acbnU5Mz?h@54Mw`#x`zd?_*?p)kX^fRB{4FcZmscT8QW zhX(J4tam|txv48~*p3ZqPZqT8c zNp77aL=`wl(@`x|jr5nGH-jyTpefg_ihnTc8j9W47~Yr}URmFmfzzHw`_Q+fQYW5s z=zsW~8DkDZUn-OXNVpleZ%QD_g%abHHAhKhj?1)~T`Dv(api#gl-`@6?d!pbh_0+3iThujZWfrxI#OTPlk!-0pAGkG%2q6x0(o zfAxL)J<_KBWCEA|Gf5@!R(*jc&NnY@G(1@nb!Ba3>F%JY?F7Zz$3pj94d03AVlbWc z6SFCyS|-E59H|_^E_45$Q-16>g6pN*dvx`qp18pWSD~$4`uoY(80#KHx#ucg(CM}JgnT45=D?|NWsS(R8AI!SUAJ&R{>rJ=Pj2s0 zh4Vn{`k*FPZlo5q=q~P%BUsBKqwo*_^ruNFV}JB54eh$~+%tyR^$d5&bM7K_VCBq- z!EXMkUdLSLw@`*4FBexwZ3g(0Iv`eS!zBG+HJ4$48gLbd!>c;_$^ElB*dtf~S@kQr z>z^e(J7(P|rn~ZS=|*JET2e2BSJUF;*S33&ZN8u;lShW^)|2yH>9DX5+j*Rexq9qI zRAXH7k1Qfj$^G~K$nXvD!}HpQ_Zc-7frq|LM!ApI@1Iu?_|b{?@o8?ROV2RV;&P--)fbGAxSvE<<}SgR@? zw@Z<$I^nUsvWlEYU+tKmAt1V!f=HkJ z&Ll|SXZbeLOtZwY-uCu#M0#ElQZLb~Hh1ByV;VVIbXh~)920>9xTg~{zLbXy%jx?a zGATV-Ngi?vB|Dhhn6#}Z?X8el%r+bhr_*au0fp+D zN@tHDBo;_yyP@8MrXFiKvK;d=9jDO2;sz9}ypl{8V9mGf*`D~asntWi*!k)s*T4ayN9w)Zb$ zqOdzRQZ{mYFZl3?F?OPEy`tE7N6bPmG2Z;x~>^Tr6mS146;shR@GvVcNo*%)Vc zuqW9{yVjbc;u_64L+`5jgC;_5jmJ`F3$3|IZ(T@(MD>IW-HK7%%vyKJ)RMk&Bqkd> z_srM)c%6iNP@ZyV@rWV=O6b$Kj>+aEZ7GWO@e}t^|ty#Tx&JZx9X1S*hA1$$)N81P8`!$w^c>uCf4n`(V6U9RYwnQYDTF4XfWqFv_Bi7>=7IcsvkDddu_GCr{^{*RjmChN7Fj;41 zl!?fX<44WxfcYL*L8r$-(pt04gfdZg z*JR|j?8f?-@G}aI6I074vl!}SZT!m;AEFNQEU(?(d3>fhwm#3-#S3M*< zN9`XjDWxZQ5y=VoAsBs#H=GhBW|lp~gQcHa>+E8gjdrnVc9-p4Zf+E=9I#MYaBjRx zDk@=fuFnumUCMqxej(nOzBgJ-j|=_Agh;M(RFFCKBjzM_vG@>U)GAswjXH)JCEaxX z<*x>?O^WRi2G7LSjaatxCqXE!u>A?22u(8w&jh)M3|8=ue5O3oo=$HF*O0*q)p4Z2fcYdT&8}&g;0=3T3?SxSdUSB z@&Ze|&%JJyIEnoWNwE$2i*QY&8_7GyU>#cBE+O+2v9&&j3l?<%($XNQ(nEhRv9co9 z*AJiOY7eM#wk8+*VUYm#paC*6@Ua@-q{c1?kTAuN)PDW3CcDKhtmd=iUO3%N5v~83JiD!tu~ zfSAz5LM~jc6G4RO`CXVXAEz0?B4q1}js#9|Lu!@AbkSC883Hm7{Qyh4`}(#Cs@ci) zaZvZ4V%aZF9J@J0;fS9m@LDr~FCb$b2ry_a!y<*xJD8R*It6rM{F`KCRqM*MLGd1E zRfogb3%BoWi+E_sFlKTbHP6-Ggv z6r;?&nqbnqAuPl-b_p#$i^TL(dE0vW)$iEODk}`2%Zr~z*9D0;KJ8vF;vrWn))1_D z1W=YQ)2AtgNQ|Nz*Kftslh>0BPGM*!k3CfRdS$v3iB!whkh&oOH8$9TR;1PR)6p?j z9?~>dSa3*uZE%>-VHr4r#pw_QHyHR5V@^7aM?$Lqw^C$iE1}9Ge!r&Jw8FKhSRH3u zpgVJ2tDovP#dUW*T8^kpd}ket*keN123A-Lo8qfYuNyf3&_EU-n@hI=%Kom@2Y}OE zze!#}De3$iBhIF7|K!&@qyK~MPAY4mN7)@j`+cnF81}d=74u`)o||zxOBJ!Hx&YsGAY3#&2zMRRmvIUI*##j0VdVsYx8XtH4LB_>!M|aQIWhQFpvY4( zMZ3UTSXj3Ja1glHg)5Zol7K`a*nPJ1LERHieILV={@RKItr1!VhXQrzW#M2 zWK?GKV!S|tT+8N`fAY!Re$zekSqXY5pdv$tG9Y*}gt88YWxLsp&Bs^9c;nRScmMvi zzRw_g7@2pkO5fjQ_v!tg*~Ll);fYeX6jmfJ@z!-GdPh6`lX@Dr85Vbnh_1g8&dC;h z@?iza%mA(Dki~+KbtUg#Lo>rh!TMg4j<$>v7ii1T;nYf*Oq%UD z5!Yqnl&ns9iMNsXJo>o5eiZ5F?!C+IH7ZhTdrjKX&Q%P{rZ3$*@Hy>aS~pp>CLm17 z=rfPUR+D+bgCO576iQ~#*`%u6Q(sj_P2~CevP3DTQL40&p-3lxmfWJfvz30nW+e{B z-q~vj!AY}17Dp)l8t-IKj2#f>^YW_PDRxL@TB{31FeMB*lLLT6;1WfEsYMx~3BrdP==`-7NB{I^!2P`P%$Re!px=sJfK&F8?6 zczyk!9!@*#YQ0Dg*0huTw+6U;reRsuujxCWgEp1hR6o2NKh*d}ILpl{sd`0xkmEY6 zPbu@$!!8-xG5O}>`26BhnPd4oU69KAL}=5#RIZw?=lW(SRI-*OV#;PFn|YEn6pYm})@ z8ZCb%b_TLdi~PC!zGd8={0mygWi~_NspEHfrDEM<5X{MZG}%PLM`E`&4_iiN-7`uu z(M34>K>WFhTlESg|C?Grtje`XWQMOT%uGh$oOA7=s93r%dgKpYklnJVzqW$I%;^(G z&OaF?kc5qk@C=~3gojFtIBXqw7J&fg*k~V)S z{+hK5CS6U4Rc`QwcvrWBkEnFk(slTfL1yDE`MU$Qw)AqY^9M-MveB9nz-35Q!Py4e zuU$l7@b?t~qt5Zk*=`$;okvrPz)bbxiQ+y~QEO6w5hO%D^v50%*^_!UH7z4rl`U zfCeCcgGga8ML+?w)&=eXV*jh%2ShMCcmPHWqX|d>4=_pvK<{Q$1+*}@+&{GeruwT> z#;E@0sAHNIX4bq>D`IqtH@w1)k1Akt!#%=`f4zAzJns#!g~?OKRGOF!6U_WKOY5dm z{Eyq;Q~bS&6ku?p_}960f%||cAc#3JKLE$9RsQkQ$LLG|TL6XW_JBJk&l|IH2V4L* z!14xl!PJ%*{2rz&05&&_Er7(JRv6qGaKzx&7@QGs#f%sDYPfd>%={O94};pPMpI$ardCr?6-3U$$cxJf!v_MYIa(5ZuEO z>p$_qF}8}-jgjLQ1{SPm-tDrV`O@!it%z2i^j!H&(dVx$Mg=EvgIyfym#KG)!=xAE zcm6`bk4PE9t=B_lE!^{58TMtwYm+A> zBi3A{kVy@F3F&}%i)!PhwmFEyZGdfr3F=X)!wUi4&-Y@aMJ+NTI8%jD)#(&vQ_m!6c>hD->Z$2_k1=3+jA^7xWcgkOCC$x^v*cqjp8roEPUj?TM z2f$teEblr^*iC&W-({q{F6H=X-u*Gx-=))(7wrY6jUImJ5=Jq~XNchQ!2g|Gv|bqgyiF=3mdY$}|0eN?E-=iQ`0a5wi3gKq!sS3+a=U`g<| zs)bYzQj#({(Q=$)7xXW>%D5gxl#W@XAh?m;8a%&p{{_Tizv~!BXVvIM9&G;d0Hs6D zA|Ce1>{#NBhHvBq8lgQ@e9)2h{H1ejA5SZZ`+gQhwzWo|RsT_WTI>)EcA8}6)$U8o z8J4{~+XhuIn#?s}WIc&|hHYXx8Eq965?Do##__EWk<-zynU5g<*h`eQlvq2FW}K*O zSPAd2RXWhT4zvb`Fhj=e&|<*g?K3B9g}eGBvKbHohQ)$g#FjnN#< zN}uFlpIP5LMu{5pK?5O?+H##v7oKv7uNIZ_6>EyF5kDQjw;97&z4! z{8+;~`+!t~zF}BS&do;pmc&t|v3hi+S=?(flRP-J>Ulldd%Rbsb)n;jBzo)m1OLIc za`r=E&S$;N9{x%jr9G$2<;2_NU{h1%O49B>=0I}?(fi)8(|K@S`y2At%IKa37DjHvc6kKS&8AbZ{( zYae;w9Vt>Rt$T0iAKGu6M&+XY(vzNe|KfebwAHS@$x|X=YljZhZRMicSoWOU&!Hq$ zLE?0t`MU)Cy!AfKbZd*4NK2pwp=rJPbZAlnPMS2cKGk%aW-QFfx!{cS1Z;DEuEt;i`=vJw1!OiBR<#tcwTjTBM>Lnw^LLz zB3^-q%N9!byhbdquz4iZN9ML(PhG$2ri)>0X_zvlCH?jc9QMHdg)c1r_5g|>=_iH% zPBvKM@mS~=JG#18u92wr^j%WCIDJ@!CS`j!CstznxvO>Z(#jwZE!1E^7H;{7&Ecfz zkp#>8GmkHnwZ>Fy@816!x!SkZE<{V8t*$Ls67CbK_@l}!vUb){$JAebGC?R~-*6Uu@ngi%riKwv>{-@C_7Do8Hwlbm()S z>l39uZyIo%0YV0Z?d1X6d*@ys6Pw8*!>VDTVf~o9-awt#1{}LK#n3NvJwGd; zub}G=FL+$?yCbtql?#BxEf<>T>$I^H4oA*{sH~_vO|u z?b_iqc5*seWYA7d^(=QlK+}gH%GcMgOoAJqha|sh`uy6TMi)wMp=VRe2MB*|Ln~Ap z^A$-+Z_tQdLtkZU?S-(8;+N=lW@hirsC(8=i&$l&$G857-}Z?N{Mv#RFkdO4k7HbK z*wjC@xeI40cuOaIB%w)-k0&^1GRRVE;5|(|hnFbywU^h5cIm=%z<=(p4H$TYCg-z% z{0{({e z9$MJJ;>rgGQ?Z_Q?ZI*WpqjW^!g^sz5ndG@VQW_941zuoeZiL@n^UKjO(scvws;HF zsv@HnbS>V?TL2sQZgPQ73(1q0!OjoqihX+8prb7$z4Wq}+J@1t@ts9G(Hg>#?zvrU za=`mhco)sQJk0M-SQm4Vld+cbkXNP zF*ZUGgUg)$xPEfy+EN!XR^7swP#006_e1OQTKkOTw_Q-KS5?Bzo&AIG`)~$09xe_y zF*ep6Fw~MjH(`WSc=;t;QQzut5mV=;Mgy4i+E + + + gipuzkoa + + diff --git a/addons/l10n_es_edi_tbai/demo/demo_res_partner.xml b/addons/l10n_es_edi_tbai/demo/demo_res_partner.xml new file mode 100644 index 00000000000..aff6913db4c --- /dev/null +++ b/addons/l10n_es_edi_tbai/demo/demo_res_partner.xml @@ -0,0 +1,21 @@ + + + + Mortadelas Filemón (ES) + + Calle Calipso 18 + Mairena del Aljarafe + 41927 + + ES86762599K + + + Frites Lagaffe (BE) + + 321 Rue sans souci + Bruxelles + 1050 + + BE789923062 + + diff --git a/addons/l10n_es_edi_tbai/models/__init__.py b/addons/l10n_es_edi_tbai/models/__init__.py new file mode 100644 index 00000000000..61e9ecea3cb --- /dev/null +++ b/addons/l10n_es_edi_tbai/models/__init__.py @@ -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 diff --git a/addons/l10n_es_edi_tbai/models/account_edi_document.py b/addons/l10n_es_edi_tbai/models/account_edi_document.py new file mode 100644 index 00000000000..22f215e0352 --- /dev/null +++ b/addons/l10n_es_edi_tbai/models/account_edi_document.py @@ -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 diff --git a/addons/l10n_es_edi_tbai/models/account_edi_format.py b/addons/l10n_es_edi_tbai/models/account_edi_format.py new file mode 100644 index 00000000000..0c44b0e0d5e --- /dev/null +++ b/addons/l10n_es_edi_tbai/models/account_edi_format.py @@ -0,0 +1,612 @@ +# -*- 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 == 'sale' + + 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 \ + or move.move_type not in ('out_invoice', 'out_refund'): + 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 + + # 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.")) + + # 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_post_invoice_edi(self, invoice): + # EXTENDS account_edi + if self.code != 'es_tbai': + return super()._post_invoice_edi(invoice) + + # 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() + if chain_head and chain_head != invoice and not chain_head._l10n_es_tbai_is_in_chain(): + raise UserError(f"TicketBAI: Cannot post invoice while chain head ({chain_head.name}) has not been posted") + + # 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("
TicketBAI: posted emission XML {test_suffix}\n{message}
").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) + + # 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("
TicketBAI: posted cancellation XML {test_suffix}\n{message}
").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') + xml_tree = self._get_l10n_es_tbai_invoice_xml(invoice, cancel)[invoice]['xml_file'] + return etree.tostring(xml_tree) + + 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) + values['invoice_lines'] = [{ + 'line': line, + 'discount': (discount := (line.balance / (1 - line.discount / 100) * line.discount / 100)) * refund_sign, + 'unit_price': (line.balance + discount) / line.quantity * refund_sign, + 'total': line.price_total * abs(line.balance / line.amount_currency if line.amount_currency != 0 else 1) * -refund_sign if not any( + [t.l10n_es_type == 'sujeto_isp' for t in line.tax_ids]) else abs(line.balance) * -refund_sign * (-1 if line.price_total < 0 else 1), + 'description': regex_sub(r'[^0-9a-zA-Z ]', '', line.name)[:250], # only keep characters allowed in description + } for line in invoice.invoice_line_ids.filtered(lambda line: line.display_type not in ('line_section', 'line_note'))] + + # Tax details (desglose) + importe_total, desglose = self._l10n_es_tbai_get_importe_desglose(invoice) + values['amount_total'] = importe_total + values['invoice_info'] = desglose + + # 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 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'] + + if invoice._is_l10n_es_tbai_simplified(): + values['regime_key'].append(52) # code for simplified invoices + + return values + + 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"): + tax_details_info_vals = self._l10n_es_edi_get_invoices_tax_details_info(invoice) + 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_details_info_vals['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) + ) + 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['TipoDesglose']['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'] + - tax_details_info_service_vals['tax_amount_retention'] + + tax_details_info_consu_vals['tax_details']['base_amount'] + + tax_details_info_consu_vals['tax_details']['tax_amount'] + - tax_details_info_consu_vals['tax_amount_retention'] + ), 2) + return importe_total, desglose + + 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._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_prepare_post_params_bi(self, env, agency, invoice, invoice_xml, cancel=False): + """Web service parameters for Bizkaia.""" + 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, + 'tbai_b64_list': [b64encode(etree.tostring(invoice_xml, encoding="UTF-8")).decode()], + 'fiscal_year': str(invoice.date.year), + } + 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', + 'inte': { + 'nif': lroe_values['sender_vat'], + 'nrs': sender.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, '
'.join(response_messages), response_xml diff --git a/addons/l10n_es_edi_tbai/models/account_move.py b/addons/l10n_es_edi_tbai/models/account_move.py new file mode 100644 index 00000000000..f5be1beab4f --- /dev/null +++ b/addons/l10n_es_edi_tbai/models/account_move.py @@ -0,0 +1,223 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from base64 import b64decode, b64encode +from datetime import datetime +from re import sub as regex_sub + +from lxml import etree +from odoo import _, api, fields, models +from odoo.addons.l10n_es_edi_tbai.models.l10n_es_edi_tbai_agencies import get_key +from odoo.exceptions import UserError + +L10N_ES_TBAI_CRC8_TABLE = [ + 0x00, 0x07, 0x0E, 0x09, 0x1C, 0x1B, 0x12, 0x15, 0x38, 0x3F, 0x36, 0x31, 0x24, 0x23, 0x2A, 0x2D, + 0x70, 0x77, 0x7E, 0x79, 0x6C, 0x6B, 0x62, 0x65, 0x48, 0x4F, 0x46, 0x41, 0x54, 0x53, 0x5A, 0x5D, + 0xE0, 0xE7, 0xEE, 0xE9, 0xFC, 0xFB, 0xF2, 0xF5, 0xD8, 0xDF, 0xD6, 0xD1, 0xC4, 0xC3, 0xCA, 0xCD, + 0x90, 0x97, 0x9E, 0x99, 0x8C, 0x8B, 0x82, 0x85, 0xA8, 0xAF, 0xA6, 0xA1, 0xB4, 0xB3, 0xBA, 0xBD, + 0xC7, 0xC0, 0xC9, 0xCE, 0xDB, 0xDC, 0xD5, 0xD2, 0xFF, 0xF8, 0xF1, 0xF6, 0xE3, 0xE4, 0xED, 0xEA, + 0xB7, 0xB0, 0xB9, 0xBE, 0xAB, 0xAC, 0xA5, 0xA2, 0x8F, 0x88, 0x81, 0x86, 0x93, 0x94, 0x9D, 0x9A, + 0x27, 0x20, 0x29, 0x2E, 0x3B, 0x3C, 0x35, 0x32, 0x1F, 0x18, 0x11, 0x16, 0x03, 0x04, 0x0D, 0x0A, + 0x57, 0x50, 0x59, 0x5E, 0x4B, 0x4C, 0x45, 0x42, 0x6F, 0x68, 0x61, 0x66, 0x73, 0x74, 0x7D, 0x7A, + 0x89, 0x8E, 0x87, 0x80, 0x95, 0x92, 0x9B, 0x9C, 0xB1, 0xB6, 0xBF, 0xB8, 0xAD, 0xAA, 0xA3, 0xA4, + 0xF9, 0xFE, 0xF7, 0xF0, 0xE5, 0xE2, 0xEB, 0xEC, 0xC1, 0xC6, 0xCF, 0xC8, 0xDD, 0xDA, 0xD3, 0xD4, + 0x69, 0x6E, 0x67, 0x60, 0x75, 0x72, 0x7B, 0x7C, 0x51, 0x56, 0x5F, 0x58, 0x4D, 0x4A, 0x43, 0x44, + 0x19, 0x1E, 0x17, 0x10, 0x05, 0x02, 0x0B, 0x0C, 0x21, 0x26, 0x2F, 0x28, 0x3D, 0x3A, 0x33, 0x34, + 0x4E, 0x49, 0x40, 0x47, 0x52, 0x55, 0x5C, 0x5B, 0x76, 0x71, 0x78, 0x7F, 0x6A, 0x6D, 0x64, 0x63, + 0x3E, 0x39, 0x30, 0x37, 0x22, 0x25, 0x2C, 0x2B, 0x06, 0x01, 0x08, 0x0F, 0x1A, 0x1D, 0x14, 0x13, + 0xAE, 0xA9, 0xA0, 0xA7, 0xB2, 0xB5, 0xBC, 0xBB, 0x96, 0x91, 0x98, 0x9F, 0x8A, 0x8D, 0x84, 0x83, + 0xDE, 0xD9, 0xD0, 0xD7, 0xC2, 0xC5, 0xCC, 0xCB, 0xE6, 0xE1, 0xE8, 0xEF, 0xFA, 0xFD, 0xF4, 0xF3 +] + +class AccountMove(models.Model): + _inherit = 'account.move' + + # Stored fields + l10n_es_tbai_chain_index = fields.Integer( + string="TicketBAI chain index", + help="Invoice index in chain, set if and only if an in-chain XML was submitted and did not error", + copy=False, readonly=True, + ) + + # Stored XML Binaries + l10n_es_tbai_post_xml = fields.Binary( + attachment=True, readonly=True, copy=False, + string="Submission XML", + help="Submission XML sent to TicketBAI. Kept if accepted or no response (timeout), cleared otherwise.", + ) + l10n_es_tbai_cancel_xml = fields.Binary( + attachment=True, readonly=True, copy=False, + string="Cancellation XML", + help="Cancellation XML sent to TicketBAI. Kept if accepted or no response (timeout), cleared otherwise.", + ) + + # Non-stored fields + l10n_es_tbai_is_required = fields.Boolean( + string="TicketBAI required", + help="Is the Basque EDI (TicketBAI) needed ?", + compute="_compute_l10n_es_tbai_is_required", + ) + + # Optional fields + l10n_es_tbai_refund_reason = fields.Selection( + selection=[ + ('R1', "R1: Art. 80.1, 80.2, 80.6 and rights founded error"), + ('R2', "R2: Art. 80.3"), + ('R3', "R3: Art. 80.4"), + ('R4', "R4: Art. 80 - other"), + ('R5', "R5: Factura rectificativa en facturas simplificadas"), + ], + string="Invoice Refund Reason Code (TicketBai)", + help="BOE-A-1992-28740. Ley 37/1992, de 28 de diciembre, del Impuesto sobre el " + "Valor Añadido. Artículo 80. Modificación de la base imponible.", + copy=False, + ) + + # ------------------------------------------------------------------------- + # API-DECORATED & EXTENDED METHODS + # ------------------------------------------------------------------------- + + @api.depends('move_type', 'company_id') + def _compute_l10n_es_tbai_is_required(self): + for move in self: + move.l10n_es_tbai_is_required = move.is_sale_document() \ + and move.country_code == 'ES' \ + and move.company_id.l10n_es_tbai_tax_agency + + @api.depends('state', 'edi_document_ids.state') + def _compute_show_reset_to_draft_button(self): + # EXTENDS account_edi account.move + super()._compute_show_reset_to_draft_button() + + for move in self: + if move.l10n_es_tbai_chain_index: + move.show_reset_to_draft_button = False + + def button_draft(self): + # EXTENDS account account.move + for move in self: + if move.l10n_es_tbai_chain_index and not move.edi_state == 'cancelled': + # NOTE this last condition (state is cancelled) is there because + # _postprocess_cancel_edi_results calls button_draft before + # calling button_cancel. Draft button does not appear for user. + raise UserError(_("You cannot reset to draft an entry that has been posted to TicketBAI's chain")) + super().button_draft() + + @api.ondelete(at_uninstall=False) + def _l10n_es_tbai_unlink_except_in_chain(self): + # Prevent deleting moves that are part of the TicketBAI chain + if not self._context.get('force_delete') and any(m.l10n_es_tbai_chain_index for m in self): + raise UserError(_('You cannot delete a move that has a TicketBAI chain id.')) + + # ------------------------------------------------------------------------- + # HELPER METHODS + # ------------------------------------------------------------------------- + + def _l10n_es_tbai_is_in_chain(self): + """ + True iff invoice has been posted to the chain and confirmed by govt. + Note that cancelled invoices remain part of the chain. + """ + tbai_doc_ids = self.edi_document_ids.filtered(lambda d: d.edi_format_id.code == 'es_tbai') + return self.l10n_es_tbai_is_required \ + and len(tbai_doc_ids) > 0 \ + and not any(tbai_doc_ids.filtered(lambda d: d.state == 'to_send')) + + def _get_l10n_es_tbai_sequence_and_number(self): + """Get the TicketBAI sequence a number values for this invoice.""" + self.ensure_one() + sequence, number = self.name.rsplit('/', 1) # NOTE non-decimal characters should not appear in the number + sequence = regex_sub(r"[^0-9A-Za-z.\_\-\/]", "", sequence) # remove forbidden characters + sequence = regex_sub(r"\s+", " ", sequence) # no more than one consecutive whitespace allowed + # NOTE (optional) not recommended to use chars out of ([0123456789ABCDEFGHJKLMNPQRSTUVXYZ.\_\-\/ ]) + sequence += "TEST" if self.company_id.l10n_es_edi_test_env else "" + return sequence, number + + def _get_l10n_es_tbai_signature_and_date(self): + """ + Get the TicketBAI signature and registration date for this invoice. + Values are read directly from the 'post' XMLs submitted to the government \ + (the 'cancel' XML is ignored). + The registration date is the date the invoice was registered into the govt's TicketBAI servers. + """ + self.ensure_one() + vals = self._get_l10n_es_tbai_values_from_xml({ + 'signature': r'.//{http://www.w3.org/2000/09/xmldsig#}SignatureValue', + 'registration_date': r'.//CabeceraFactura//FechaExpedicionFactura' + }) + # RFC2045 - Base64 Content-Transfer-Encoding (page 25) + # Any characters outside of the base64 alphabet are to be ignored in base64-encoded data. + signature = vals['signature'].replace("\n", "") + registration_date = datetime.strptime(vals['registration_date'], '%d-%m-%Y') + return signature, registration_date + + def _get_l10n_es_tbai_id(self): + """Get the TicketBAI ID (TBAID) as defined in the TicketBAI doc.""" + self.ensure_one() + signature, registration_date = self._get_l10n_es_tbai_signature_and_date() + company = self.company_id + tbai_id_no_crc = '-'.join([ + 'TBAI', + str(company.vat[2:] if company.vat.startswith('ES') else company.vat), + datetime.strftime(registration_date, '%d%m%y'), + signature[:13], + '' # CRC + ]) + return tbai_id_no_crc + self._l10n_es_edi_tbai_crc8(tbai_id_no_crc) + + def _get_l10n_es_tbai_qr(self): + """Returns the URL for the invoice's QR code. We can not use url_encode because it escapes / e.g.""" + self.ensure_one() + if not self._l10n_es_tbai_is_in_chain(): + return '' + + company = self.company_id + sequence, number = self._get_l10n_es_tbai_sequence_and_number() + tbai_qr_no_crc = get_key(company.l10n_es_tbai_tax_agency, 'qr_url_', company.l10n_es_edi_test_env) + '?' + '&'.join([ + 'id=' + self._get_l10n_es_tbai_id(), + 's=' + sequence, + 'nf=' + number, + 'i=' + self._get_l10n_es_tbai_values_from_xml({'importe': r'.//ImporteTotalFactura'})['importe'] + ]) + qr_url = tbai_qr_no_crc + '&cr=' + self._l10n_es_edi_tbai_crc8(tbai_qr_no_crc) + return qr_url + + def _l10n_es_edi_tbai_crc8(self, data): + crc = 0x0 + for c in data: + crc = L10N_ES_TBAI_CRC8_TABLE[(crc ^ ord(c)) & 0xFF] + return '{:03d}'.format(crc & 0xFF) + + def _get_l10n_es_tbai_values_from_xml(self, xpaths): + """ + This function reads values directly from the 'post' XML submitted to the government \ + (the 'cancel' XML is ignored). + """ + res = dict.fromkeys(xpaths, '') + doc_xml = self._get_l10n_es_tbai_submitted_xml() + if doc_xml is None: + return res + for key, value in xpaths.items(): + res[key] = doc_xml.find(value).text + return res + + def _get_l10n_es_tbai_submitted_xml(self, cancel=False): + """Returns the XML object representing the post or cancel document.""" + self.ensure_one() + self = self.with_context(bin_size=False) + doc = self.l10n_es_tbai_cancel_xml if cancel else self.l10n_es_tbai_post_xml + if not doc: + return None + return etree.fromstring(b64decode(doc)) + + def _update_l10n_es_tbai_submitted_xml(self, xml_doc, cancel): + """Updates the binary data of the post or cancel document, from its XML object.""" + self.ensure_one() + b64_doc = b'' if xml_doc is None else b64encode(etree.tostring(xml_doc, encoding='UTF-8')) + if cancel: + self.l10n_es_tbai_cancel_xml = b64_doc + else: + self.l10n_es_tbai_post_xml = b64_doc + + def _is_l10n_es_tbai_simplified(self): + return self.commercial_partner_id == self.env.ref("l10n_es_edi_sii.partner_simplified") diff --git a/addons/l10n_es_edi_tbai/models/ir_attachment.py b/addons/l10n_es_edi_tbai/models/ir_attachment.py new file mode 100644 index 00000000000..4d88cfaff30 --- /dev/null +++ b/addons/l10n_es_edi_tbai/models/ir_attachment.py @@ -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() diff --git a/addons/l10n_es_edi_tbai/models/l10n_es_edi_tbai_agencies.py b/addons/l10n_es_edi_tbai/models/l10n_es_edi_tbai_agencies.py new file mode 100644 index 00000000000..fe98ea40b1b --- /dev/null +++ b/addons/l10n_es_edi_tbai/models/l10n_es_edi_tbai_agencies.py @@ -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', +} diff --git a/addons/l10n_es_edi_tbai/models/l10n_es_edi_tbai_certificate.py b/addons/l10n_es_edi_tbai/models/l10n_es_edi_tbai_certificate.py new file mode 100644 index 00000000000..4d8e9d7cf44 --- /dev/null +++ b/addons/l10n_es_edi_tbai/models/l10n_es_edi_tbai_certificate.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from base64 import b64decode + +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives.serialization import pkcs12 +from odoo import models + + +class Certificate(models.Model): + _inherit = 'l10n_es_edi.certificate' + + # ------------------------------------------------------------------------- + # HELPERS + # ------------------------------------------------------------------------- + + def _get_key_pair(self): + self.ensure_one() + + if not self.password: + return None, None + + private_key, certificate, dummy = pkcs12.load_key_and_certificates( + b64decode(self.with_context(bin_size=False).content), # Without bin_size=False, size is returned instead of content + self.password.encode(), + backend=default_backend(), + ) + + return private_key, certificate diff --git a/addons/l10n_es_edi_tbai/models/res_company.py b/addons/l10n_es_edi_tbai/models/res_company.py new file mode 100644 index 00000000000..306fc39ee51 --- /dev/null +++ b/addons/l10n_es_edi_tbai/models/res_company.py @@ -0,0 +1,123 @@ +# -*- 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(''' +{license_name}
+

+{tr_nif}: {license_nif}
+{tr_number}: {license_number}
+{tr_name}: {software_name}
+{tr_version}: {software_version}
+

''').format(**license_dict) + else: + company.l10n_es_tbai_license_html = markupsafe.Markup(''' +{tr_no_license}''').format(tr_no_license=_('TicketBAI is not configured')) + + def _get_l10n_es_tbai_license_dict(self): + self.ensure_one() + if self.country_code == 'ES' and self.l10n_es_tbai_tax_agency: + if self.l10n_es_edi_test_env: # test env: each agency has its test license + license_key = self.l10n_es_tbai_tax_agency + else: # production env: only one license + license_key = 'production' + return L10N_ES_TBAI_LICENSE_DICT[license_key] + else: + return {} + + def _get_l10n_es_tbai_next_chain_index(self): + if not self.l10n_es_tbai_chain_sequence_id: + self.l10n_es_tbai_chain_sequence_id = self.env['ir.sequence'].create({ + 'name': f'TicketBAI account move sequence for {self.name} (id: {self.id})', + 'code': f'l10n_es.edi.tbai.account.move.{self.id}', + 'implementation': 'no_gap', + 'company_id': self.id, + }) + return self.l10n_es_tbai_chain_sequence_id.next_by_id() + + def _get_l10n_es_tbai_last_posted_invoice(self, being_posted=False): + """ + Returns the last invoice posted to this company's chain. + That invoice may have been received by the govt or not (eg. in case of a timeout). + Only upon confirmed reception/refusal of that invoice can another one be posted. + :param being_posted: next invoice to be posted on the chain, ignored in search domain + """ + domain = [ + ('l10n_es_tbai_chain_index', '!=', 0), + ('company_id', '=', self.id) + ] + if being_posted: + domain.append(('l10n_es_tbai_chain_index', '!=', being_posted.l10n_es_tbai_chain_index)) + # NOTE: being_posted may not have a chain index at all (if being posted for the first time) + return self.env['account.move'].search(domain, limit=1, order='l10n_es_tbai_chain_index desc') diff --git a/addons/l10n_es_edi_tbai/models/res_config_settings.py b/addons/l10n_es_edi_tbai/models/res_config_settings.py new file mode 100644 index 00000000000..66fe05e33f0 --- /dev/null +++ b/addons/l10n_es_edi_tbai/models/res_config_settings.py @@ -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) diff --git a/addons/l10n_es_edi_tbai/models/xml_utils.py b/addons/l10n_es_edi_tbai/models/xml_utils.py new file mode 100644 index 00000000000..39507e1937d --- /dev/null +++ b/addons/l10n_es_edi_tbai/models/xml_utils.py @@ -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]*', 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() diff --git a/addons/l10n_es_edi_tbai/tests/__init__.py b/addons/l10n_es_edi_tbai/tests/__init__.py new file mode 100644 index 00000000000..ab3391c443a --- /dev/null +++ b/addons/l10n_es_edi_tbai/tests/__init__.py @@ -0,0 +1,5 @@ +# -*- 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 diff --git a/addons/l10n_es_edi_tbai/tests/common.py b/addons/l10n_es_edi_tbai/tests/common.py new file mode 100644 index 00000000000..e16f5701226 --- /dev/null +++ b/addons/l10n_es_edi_tbai/tests/common.py @@ -0,0 +1,216 @@ +# -*- 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.tests import tagged +from odoo.tools import misc +from pytz import timezone + + +@tagged('post_install_l10n', 'post_install', '-at_install') +class TestEsEdiTbaiCommon(AccountEdiTestCommon): + + @classmethod + def setUpClass(cls, chart_template_ref='es_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=2022, 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_111111.p12' + cert_password = '111111' + elif agency == 'gipuzkoa': + cert_name = 'gipuzkoa_IZDesa2021.p12' + cert_password = 'IZDesa2021' + else: + raise ValueError("Unknown tax agency: " + agency) + + cls.certificate = cls.env['l10n_es_edi.certificate'].create({ + 'content': base64.encodebytes( + misc.file_open("l10n_es_edi_tbai/demo/certificates/" + cert_name, 'rb').read()), + 'password': cert_password, + }) + cls.company_data['company'].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'account.{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': '2022-01-01', + 'date': '2022-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 = """ + + + 1.2 + + + + ___ignore___ + EUS Company + + + + + 02 + BE0477472701 + + &@àÁ$£€èêÈÊöÔÇç¡⅛™³ + ___ignore___ + ___ignore___ + + + N + N + + + + INVTEST + 01 + 01-01-2022 + ___ignore___ + N + + + manual + + + producta + 5.00 + 1000.00 + 1000.00 + 4840.00 + + + 4840.00 + + + 01 + + + + + + + + + + S1 + + + 4000.00 + 21.00 + 840.00 + N + + + + + + + + + + + + ___ignore___ + + ___ignore___ + + ___ignore___ + ___ignore___ + + ___ignore___ + + +""".encode("utf-8") + + L10N_ES_TBAI_SAMPLE_XML_CANCEL = """ + + 1.2 + + + + 09760433S + EUS Company + + + INVTEST + 01 + 01-01-2022 + + + + + ___ignore___ + + ___ignore___ + + ___ignore___ + ___ignore___ + + ___ignore___ + +""".encode("utf-8") diff --git a/addons/l10n_es_edi_tbai/tests/test_edi_web_services.py b/addons/l10n_es_edi_tbai/tests/test_edi_web_services.py new file mode 100644 index 00000000000..c20348e3942 --- /dev/null +++ b/addons/l10n_es_edi_tbai/tests/test_edi_web_services.py @@ -0,0 +1,61 @@ +# -*- 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', '-standard', 'external') +class TestEdiTbaiWebServices(TestEsEdiTbaiCommon): + + @classmethod + def setUpClass(cls, chart_template_ref='es_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) + + # 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'}]) diff --git a/addons/l10n_es_edi_tbai/tests/test_edi_xml.py b/addons/l10n_es_edi_tbai/tests/test_edi_xml.py new file mode 100644 index 00000000000..334f9ed6c4e --- /dev/null +++ b/addons/l10n_es_edi_tbai/tests/test_edi_xml.py @@ -0,0 +1,54 @@ +# -*- 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): + 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_cancel(self): + self.out_invoice.l10n_es_tbai_post_xml = b64encode(b""" +01-01-2022 +TEXT +""") # 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) diff --git a/addons/l10n_es_edi_tbai/views/account_move_view.xml b/addons/l10n_es_edi_tbai/views/account_move_view.xml new file mode 100644 index 00000000000..f7c6320f4c3 --- /dev/null +++ b/addons/l10n_es_edi_tbai/views/account_move_view.xml @@ -0,0 +1,23 @@ + + + + + account.move.form.inherit.l10n_es_edi_tbai + account.move + + + + + + + + + + + + + diff --git a/addons/l10n_es_edi_tbai/views/report_invoice.xml b/addons/l10n_es_edi_tbai/views/report_invoice.xml new file mode 100644 index 00000000000..53381b1b5ea --- /dev/null +++ b/addons/l10n_es_edi_tbai/views/report_invoice.xml @@ -0,0 +1,14 @@ + + + + diff --git a/addons/l10n_es_edi_tbai/views/res_company_views.xml b/addons/l10n_es_edi_tbai/views/res_company_views.xml new file mode 100644 index 00000000000..31b2236f829 --- /dev/null +++ b/addons/l10n_es_edi_tbai/views/res_company_views.xml @@ -0,0 +1,25 @@ + + + + res.company.form + res.company + + + + + + + + + + + + + + diff --git a/addons/l10n_es_edi_tbai/views/res_config_settings_views.xml b/addons/l10n_es_edi_tbai/views/res_config_settings_views.xml new file mode 100644 index 00000000000..e39e9b6c192 --- /dev/null +++ b/addons/l10n_es_edi_tbai/views/res_config_settings_views.xml @@ -0,0 +1,24 @@ + + + + res.config.settings.view.form.inherit.l10n.es + res.config.settings + + + + Registro de Libros connection SII/TicketBAI + + + + + + diff --git a/addons/l10n_es_edi_tbai/wizards/__init__.py b/addons/l10n_es_edi_tbai/wizards/__init__.py new file mode 100644 index 00000000000..46cf10a81f5 --- /dev/null +++ b/addons/l10n_es_edi_tbai/wizards/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import account_move_reversal diff --git a/addons/l10n_es_edi_tbai/wizards/account_move_reversal.py b/addons/l10n_es_edi_tbai/wizards/account_move_reversal.py new file mode 100644 index 00000000000..52eab04e37c --- /dev/null +++ b/addons/l10n_es_edi_tbai/wizards/account_move_reversal.py @@ -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 diff --git a/addons/l10n_es_edi_tbai/wizards/account_move_reversal_views.xml b/addons/l10n_es_edi_tbai/wizards/account_move_reversal_views.xml new file mode 100644 index 00000000000..8ff2043a86f --- /dev/null +++ b/addons/l10n_es_edi_tbai/wizards/account_move_reversal_views.xml @@ -0,0 +1,14 @@ + + + + account.move.reversal.form.inherit.l10n_es_edi_tbai + account.move.reversal + + + + + + + + +