Skip to content

Commit 3d5d472

Browse files
committed
[ADD] account_peppol: backport from 17.0
This commit adds Peppol support to 16.0. (Only FR and NL translations are included.) In 2026 Peppol becomes mandatory in Belgium. Some users won't be able to migrate in time to 17.0+. Some of the fixes for the document / UBL / XML generation are not available in 16.0 (yet). An upgrade to 18.0+ may provide a better user experience for some cases. Compared to 17.0 the phone validation was removed (since we do not do that in 18.0 either). Follow these steps to set up Peppol for a company and send a first invoice 1. Set the "mode" (demo / test / production) you want via system parameter `account_edi_proxy_client.demo`. The following values are available: - `demo` to use a locally mocked / simulated version that does not connect with any server - `test` to use the Odoo Peppol test server - `prod` to use the Odoo Peppol production server (The same mode also applies to all other EDIs / Account EDI proxy users) 2. Register the company on the Peppol network in the Accounting Settings (Section "PEPPOL Electronic Document Invoicing") 3. Wait until your registration has been validated on the Peppol server side and your status has been updated accordingly in the accounting settings (where you registered) (This is not necessary in 'demo' mode since everything is locally mocked) - The scheduled action "PEPPOL: update participant status" fetches the status from the Peppol server every 6 hours; you can manually run it to speed up the process 4. Configure a journal to use Peppol. - Select "Peppol" in the "Advanced Settings" tab under the "Electronic Data Interchange" section - This should automatically have been done during the installation of the module for journals from countries where Peppol should be used by default 5. Configure the partner to which you want to send the invoice. On the partner form view go to the "Accounting" tab and look at the "Peppol" section. There you can - Select the format in which you want to sent the invoices to the partner. It pre-selects a format based on the country. - Set up the peppol address / endpoint on which the partner receives its invoices. - A button to check whether the endpoint is valid 6. Create an invoice in the journal from step 4 with the customer from step 5. 7. Confirm the invoice; the Peppol document should be generated. - It uses the format selected for the partner in step 5. - The "EDI Documents" tab should be visible. There should be an entry which has "Peppol" in the column "Format Name" and "Sent" in the column "State" - An (XML) file should be attached to the chatter; its filename matches the name given in the EDI document entry. (The filename is based on the format selected for the parter in step 5.) - Potential errors are displayed in a warning banner at the top of the form view 8. Click on "Send & Print" to open a wizard. The "Send via PEPPOL" option should be selected by default. It controls whether the invoice will be sent via Peppol. 9. Click on the "Send & Print" / "Send" / "Print / "Send (Peppol)" button. (The name depends on which of the options are in the wizard selected. But they all send via Peppol as long as the checkbox is ticket) 10. Done; In the chatter it should say "The document has been sent to the Peppol Access Point for processing" - The "PEPPOL status" field in the invoice form view gives status information about the invoice on Peppol - The scheduled action "PEPPOL: update message status" regularly fetches information about the invoice on Peppol - The "Fetch Peppol invoice status" button on each journal in the Accounting Dashboard can be used to manually run this action. - Potential errors that are fetched are posted to the chatter. Bills from Peppol are fetched periodically via the scheduled action "PEPPOL: retrieve new documents". It can also be done manually on-demand by clicking "Fetch from Peppol" on a purchase / bill journal in the Accounting Dashboard. upgrade PR: odoo/upgrade#8621 documentation PR: odoo/documentation#14876 task-4925169
1 parent c27d978 commit 3d5d472

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

47 files changed

+7687
-14
lines changed

addons/account/i18n/account.pot

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10021,6 +10021,11 @@ msgstr ""
1002110021
msgid "P&L Accounts"
1002210022
msgstr ""
1002310023

10024+
#. module: account
10025+
#: model:ir.model.fields,field_description:account.field_res_config_settings__is_account_peppol_eligible
10026+
msgid "PEPPOL eligible"
10027+
msgstr ""
10028+
1002410029
#. module: account
1002510030
#: model_terms:ir.ui.view,arch_db:account.res_config_settings_view_form
1002610031
msgid "Package"

addons/account/i18n/fr.po

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10704,6 +10704,11 @@ msgstr "Factures en retard, date d'échéance dépassé"
1070410704
msgid "P&L Accounts"
1070510705
msgstr "Les comptes du compte de résultat "
1070610706

10707+
#. module: account
10708+
#: model:ir.model.fields,field_description:account.field_res_config_settings__is_account_peppol_eligible
10709+
msgid "PEPPOL eligible"
10710+
msgstr "Éligible PEPPOL"
10711+
1070710712
#. module: account
1070810713
#: model_terms:ir.ui.view,arch_db:account.res_config_settings_view_form
1070910714
msgid "Package"

addons/account/i18n/nl.po

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10661,6 +10661,11 @@ msgstr "Vervallen facturen, voorbij de vervaldatum"
1066110661
msgid "P&L Accounts"
1066210662
msgstr "W&V-rekeningen"
1066310663

10664+
#. module: account
10665+
#: model:ir.model.fields,field_description:account.field_res_config_settings__is_account_peppol_eligible
10666+
msgid "PEPPOL eligible"
10667+
msgstr "Geschikt voor PEPPOL"
10668+
1066410669
#. module: account
1066510670
#: model_terms:ir.ui.view,arch_db:account.res_config_settings_view_form
1066610671
msgid "Package"

addons/account/models/company.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,19 @@
3535
]
3636
DASHBOARD_ONBOARDING_STATES = ONBOARDING_STEP_STATES + [('closed', 'Closed')]
3737

38+
# List of countries where Peppol should be used by default.
39+
PEPPOL_DEFAULT_COUNTRIES = [
40+
'AT', 'BE', 'CH', 'CY', 'CZ', 'DE', 'DK', 'EE', 'ES', 'FI',
41+
'FR', 'GR', 'IE', 'IS', 'IT', 'LT', 'LU', 'LV', 'MT', 'NL',
42+
'NO', 'PL', 'PT', 'RO', 'SE', 'SI',
43+
]
44+
45+
# List of countries where Peppol is accessible.
46+
PEPPOL_LIST = PEPPOL_DEFAULT_COUNTRIES + [
47+
'AD', 'AL', 'BA', 'BG', 'GB', 'HR', 'HU', 'LI', 'MC', 'ME',
48+
'MK', 'RS', 'SK', 'SM', 'TR', 'VA',
49+
]
50+
3851

3952
class ResCompany(models.Model):
4053
_name = "res.company"

addons/account/models/partner.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -725,6 +725,20 @@ def _merge_method(self, destination, source):
725725
raise UserError(_('Partners that are used in hashed entries cannot be merged.'))
726726
return super()._merge_method(destination, source)
727727

728+
def _deduce_country_code(self):
729+
""" deduce the country code based on the information available.
730+
we have three cases:
731+
- country_code is BE but the VAT number starts with FR, the country code is FR, not BE
732+
- if a country-specific field is set (e.g. the codice_fiscale), that country is used for the country code
733+
- if the VAT number has no ISO country code, use the country_code in that case.
734+
"""
735+
self.ensure_one()
736+
737+
country_code = self.country_code
738+
if self.vat and self.vat[:2].isalpha():
739+
country_code = self.vat[:2].upper()
740+
return country_code
741+
728742
def _run_vat_test(self, vat_number, default_country, partner_is_company=True):
729743
""" Checks a VAT number syntactically to ensure its validity upon saving.
730744
A first check is made by using the first two characters of the VAT as

addons/account/models/res_config_settings.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33

44
from odoo import api, fields, models, _
55

6+
from odoo.addons.account.models.company import PEPPOL_LIST
7+
68

79
class ResConfigSettings(models.TransientModel):
810
_inherit = 'res.config.settings'
@@ -180,6 +182,19 @@ class ResConfigSettings(models.TransientModel):
180182
domain="[('deprecated', '=', False), ('company_id', '=', company_id), ('account_type', 'in', ('income', 'income_other', 'expense'))]",
181183
)
182184

185+
# PEPPOL
186+
is_account_peppol_eligible = fields.Boolean(
187+
string='PEPPOL eligible',
188+
compute='_compute_is_account_peppol_eligible',
189+
) # technical field used for showing the Peppol settings conditionally
190+
191+
@api.depends('country_code')
192+
def _compute_is_account_peppol_eligible(self):
193+
# we want to show Peppol settings only to customers that are eligible for Peppol,
194+
# except countries that are not in Europe
195+
for config in self:
196+
config.is_account_peppol_eligible = config.country_code in PEPPOL_LIST
197+
183198
def set_values(self):
184199
super().set_values()
185200
# install a chart of accounts for the given company (if required)

addons/account_edi_proxy_client/models/account_edi_proxy_user.py

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -65,9 +65,20 @@ def _get_demo_state(self):
6565
demo_state = self.env['ir.config_parameter'].sudo().get_param('account_edi_proxy_client.demo', False)
6666
return 'prod' if demo_state in ['prod', False] else 'test' if demo_state == 'test' else 'demo'
6767

68+
def _get_server_url_new(self, edi_format=None):
69+
70+
return self._get_server_url()
71+
6872
def _get_server_url(self):
6973
return DEFAULT_TEST_SERVER_URL if self._get_demo_state() == 'test' else self.env['ir.config_parameter'].sudo().get_param('account_edi_proxy_client.edi_server_url', DEFAULT_SERVER_URL)
7074

75+
def _get_route(self, action, edi_format=None):
76+
# I.e. in case we create a user we can not just use `self.edi_format` because the user is created after the request
77+
return {
78+
'create_user': '/iap/account_edi/1/create_user',
79+
'renew_token': '/iap/account_edi/1/renew_token',
80+
}[action]
81+
7182
def _make_request(self, url, params=False):
7283
''' Make a request to proxy and handle the generic elements of the reponse (errors, new refresh token).
7384
'''
@@ -113,6 +124,12 @@ def _make_request(self, url, params=False):
113124

114125
return response['result']
115126

127+
def _get_create_user_edi_params(self, company, edi_format, edi_identification):
128+
return {
129+
'edi_format_code': edi_format.code,
130+
'edi_identification': edi_identification,
131+
}
132+
116133
def _register_proxy_user(self, company, edi_format, edi_identification):
117134
''' Generate the public_key/private_key that will be used to encrypt the file, send a request to the proxy
118135
to register the user with the public key and create the user with the private key.
@@ -145,12 +162,11 @@ def _register_proxy_user(self, company, edi_format, edi_identification):
145162
else:
146163
try:
147164
# b64encode returns a bytestring, we need it as a string
148-
response = self._make_request(self._get_server_url() + '/iap/account_edi/1/create_user', params={
165+
response = self._make_request(self._get_server_url_new(edi_format=edi_format) + self._get_route('create_user', edi_format=edi_format), params={
149166
'dbuuid': company.env['ir.config_parameter'].get_param('database.uuid'),
150167
'company_id': company.id,
151-
'edi_format_code': edi_format.code,
152-
'edi_identification': edi_identification,
153-
'public_key': base64.b64encode(public_pem).decode()
168+
'public_key': base64.b64encode(public_pem).decode(),
169+
**self._get_create_user_edi_params(company, edi_format, edi_identification),
154170
})
155171
except AccountEdiProxyError as e:
156172
raise UserError(e.message)
@@ -180,7 +196,7 @@ def _renew_token(self):
180196
if e.pgcode == '55P03':
181197
return
182198
raise e
183-
response = self._make_request(self._get_server_url() + '/iap/account_edi/1/renew_token')
199+
response = self._make_request(self._get_server_url_new() + self._get_route('renew_token'))
184200
if 'error' in response:
185201
# can happen if the database was duplicated and the refresh_token was refreshed by the other database.
186202
# we don't want two database to be able to query the proxy with the same user

addons/account_edi_ubl_cii/data/account_edi_data.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
</record>
1212

1313
<record id="ubl_bis3" model="account.edi.format">
14-
<field name="name">Peppol BIS Billing 3.0</field>
14+
<field name="name">BIS Billing 3.0 (XML)</field>
1515
<field name="code">ubl_bis3</field>
1616
</record>
1717

addons/account_edi_ubl_cii/models/account_edi_xml_ubl_20.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -431,7 +431,15 @@ def _get_early_payment_discount_grouped_by_tax_rate(self, invoice):
431431
if invoice.company_id.early_pay_discount_computation != 'mixed':
432432
return {}
433433
tax_to_discount = defaultdict(lambda: 0)
434-
for line in invoice.line_ids.filtered(lambda l: l.display_type == 'epd'):
434+
currency = invoice.currency_id
435+
# There can be 'epd' lines with a zero amount. We ignore those lines since we do not output
436+
# the AllowanceTotalAmount / ChargeTotalAmount in the LegalMonetaryTotal node
437+
# if the total allowance / charge amount are 0 respectively.
438+
# So we should not create AllowanceCharge nodes for 0 amounts either.
439+
# This way we do not violate the following rules:
440+
# - https://docs.peppol.eu/poacc/billing/3.0/2024-Q2/rules/ubl-tc434/BR-CO-11/
441+
# - https://docs.peppol.eu/poacc/billing/3.0/2024-Q2/rules/ubl-tc434/BR-CO-12/
442+
for line in invoice.line_ids.filtered(lambda l: l.display_type == 'epd' and not currency.is_zero(l.amount_currency)):
435443
for tax in line.tax_ids:
436444
tax_to_discount[tax.amount] += line.amount_currency
437445
return tax_to_discount
@@ -503,7 +511,7 @@ def grouping_key_generator(base_line, tax_values):
503511
sales_order_id = 'sale_line_ids' in invoice.invoice_line_ids._fields \
504512
and ",".join(invoice.invoice_line_ids.sale_line_ids.order_id.mapped('name'))
505513
# OrderReference/ID (order_reference) is mandatory inside the OrderReference node !
506-
order_reference = invoice.ref or invoice.name if sales_order_id else invoice.ref
514+
order_reference = invoice.ref or invoice.name
507515

508516
vals = {
509517
'builder': self,

addons/account_edi_ubl_cii/models/account_edi_xml_ubl_bis3.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -115,8 +115,12 @@ def _get_partner_party_vals(self, partner, role):
115115
vals = super()._get_partner_party_vals(partner, role)
116116

117117
partner = partner.commercial_partner_id
118-
vals['endpoint_id'] = partner.vat
119-
vals['endpoint_id_attrs'] = {'schemeID': COUNTRY_EAS.get(partner.country_id.code)}
118+
if self.env['ir.module.module']._get('account_peppol').state == 'installed':
119+
vals['endpoint_id'] = partner.peppol_endpoint
120+
vals['endpoint_id_attrs'] = {'schemeID': partner.peppol_eas}
121+
else:
122+
vals['endpoint_id'] = partner.vat
123+
vals['endpoint_id_attrs'] = {'schemeID': COUNTRY_EAS.get(partner.country_id.code)}
120124

121125
if partner.country_code == 'NO':
122126
if 'l10n_no_bronnoysund_number' in partner._fields:

0 commit comments

Comments
 (0)