[IMP] account_peppol: improve participant onboarding

This commit adds several improvements in Peppol onboarding process:
- Adds a phone number verification process
- Adds a possibility to migrate away/cancel the registration
- Adds a possibility to update contact details for the participant
- UX improvements like autosave
- Set 0208 as the default EAS code for Belgium
- Add a check for certain peppol endpoint numbers (based on Peppol schematron rules)

task-3060814

closes odoo/odoo#127092

Signed-off-by: Florian Gilbert (flg) <flg@odoo.com>
This commit is contained in:
aliya
2023-07-13 19:28:04 +02:00
parent b0668f106e
commit d3fda38f00
24 changed files with 839 additions and 144 deletions
@@ -46,7 +46,7 @@ EAS_MAPPING = {
'AT': {'9915': 'vat'},
'AU': {'0151': 'vat'},
'BA': {'9924': 'vat'},
'BE': {'9925': 'vat'},
'BE': {'9925': 'vat', '0208': 'company_registry'},
'BG': {'9926': 'vat'},
'CH': {'9927': 'vat'},
'CY': {'9928': 'vat'},
+5
View File
@@ -24,4 +24,9 @@
'wizard/account_move_send_views.xml',
],
'license': 'LGPL-3',
'assets': {
'web.assets_backend': [
'account_peppol/static/src/components/**/*',
],
}
}
@@ -3,9 +3,9 @@
import logging
from odoo import fields, models, tools, _
from odoo.addons.account_edi_proxy_client.models.account_edi_proxy_user import AccountEdiProxyError
from odoo import _, fields, models, tools
from odoo.exceptions import UserError
from odoo.addons.account_edi_proxy_client.models.account_edi_proxy_user import AccountEdiProxyError
_logger = logging.getLogger(__name__)
@@ -13,10 +13,16 @@ _logger = logging.getLogger(__name__)
class AccountEdiProxyClientUser(models.Model):
_inherit = 'account_edi_proxy_client.user'
peppol_migration_key = fields.Char(string="Migration Key")
peppol_verification_code = fields.Char(string='SMS verification code')
proxy_type = fields.Selection(
selection_add=[('peppol', 'PEPPOL')],
)
# -------------------------------------------------------------------------
# HELPER METHODS
# -------------------------------------------------------------------------
def _get_proxy_urls(self):
urls = super()._get_proxy_urls()
urls['peppol'] = {
@@ -38,6 +44,15 @@ class AccountEdiProxyClientUser(models.Model):
return super()._get_server_url(proxy_type, edi_mode)
def _get_proxy_identification(self, company):
if not company.peppol_eas or not company.peppol_endpoint:
raise UserError(_("Please fill in the EAS code and the Participant ID code."))
return f'{company.peppol_eas}:{company.peppol_endpoint}'
# -------------------------------------------------------------------------
# COMPUTE METHODS
# -------------------------------------------------------------------------
def _compute_proxy_type(self):
# Extends account_edi_proxy_client
super()._compute_proxy_type()
@@ -45,11 +60,9 @@ class AccountEdiProxyClientUser(models.Model):
if user.company_id.is_account_peppol_participant:
user.proxy_type = 'peppol'
def _get_proxy_identification(self, company):
if not company.peppol_eas or not company.peppol_endpoint:
raise UserError(
_("Please fill in the EAS code and the Participant ID code in the company's partner form."))
return f'{company.peppol_eas}:{company.peppol_endpoint}'
# -------------------------------------------------------------------------
# BUSINESS ACTIONS
# -------------------------------------------------------------------------
def _cron_peppol_get_new_documents(self):
# Retrieve all new Peppol documents for every edi user in the database
@@ -136,6 +149,7 @@ class AccountEdiProxyClientUser(models.Model):
else:
move._message_log(
body=_('Peppol document has been received successfully'))
# pylint: disable=broad-except
except Exception:
# if the invoice creation fails for any reason,
# we want to create an empty invoice with the attachment
@@ -205,7 +219,7 @@ class AccountEdiProxyClientUser(models.Model):
def _cron_peppol_get_participant_status(self):
edi_users = self.env['account_edi_proxy_client.user'].search(
[('company_id.account_peppol_proxy_state', 'in', ('pending', 'manually_approved'))])
[('company_id.account_peppol_proxy_state', '=', 'pending')])
for edi_user in edi_users:
try:
@@ -215,5 +229,5 @@ class AccountEdiProxyClientUser(models.Model):
_logger.error('Error while updating Peppol participant status: %s', e)
continue
if proxy_user['peppol_state'] != 'draft':
if proxy_user['peppol_state'] in {'active', 'rejected', 'canceled'}:
edi_user.company_id.account_peppol_proxy_state = proxy_user['peppol_state']
+148 -1
View File
@@ -1,23 +1,66 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models
import re
from stdnum import get_cc_module, ean
from odoo import _, api, fields, models
from odoo.exceptions import ValidationError
from odoo.addons.account_edi_ubl_cii.models.account_edi_common import EAS_MAPPING
try:
import phonenumbers
except ImportError:
phonenumbers = None
# at the moment, only phone numbers in European countries are accepted
PHONE_ALLOWED_COUNTRIES = set(EAS_MAPPING.keys()) - {'AU', 'SG', 'NZ'}
PEPPOL_ENDPOINT_RULES = {
'0007': ['se', 'orgnr'],
'0088': ['ean'],
'0184': ['dk', 'cvr'],
'0192': ['no', 'orgnr'],
'0208': ['be', 'vat'],
}
PEPPOL_ENDPOINT_WARNING = {
'0201': ['regex', '[0-9a-zA-Z]{6}$'],
'0210': ['it', 'codicefiscale'],
'0211': ['it', 'iva'],
'9906': ['it', 'iva'],
'9907': ['it', 'codicefiscale'],
'0151': ['au', 'abn'],
}
class ResCompany(models.Model):
_inherit = 'res.company'
# to be removed once the module is available
account_peppol_attachment_ids = fields.Many2many(
comodel_name='ir.attachment',
string='Peppol Identification Documents',
)
account_peppol_contact_email = fields.Char(
string='Primary contact email',
compute='_compute_account_peppol_contact_email', store=True, readonly=False,
help='Primary contact email for Peppol-related communication',
)
account_peppol_phone_number = fields.Char(
string='Phone number (for validation)',
help='You will receive a verification code to this phone number',
)
account_peppol_proxy_state = fields.Selection(
selection=[
('not_registered', 'Not registered'),
('not_verified', 'Not verified'),
('sent_verification', 'Verification code sent'),
('pending', 'Pending'),
('manually_approved', 'Approved'),
('active', 'Active'),
('rejected', 'Rejected'),
('canceled', 'Canceled'),
],
string='PEPPOL status',
compute='_compute_account_peppol_proxy_state', required=True, readonly=False, store=True, precompute=True,
@@ -32,6 +75,73 @@ class ResCompany(models.Model):
compute='_compute_peppol_purchase_journal_id', store=True, readonly=False,
)
# -------------------------------------------------------------------------
# HELPER METHODS
# -------------------------------------------------------------------------
def _sanitize_peppol_phone_number(self):
self.ensure_one()
error_message = _(
"Please enter the phone number in the correct international format.\n"
"For example: +32123456789, where +32 is the country code.\n"
"Currently, only European countries are supported.")
if not phonenumbers:
raise ValidationError(error_message)
phone_number = self.account_peppol_phone_number
if not phone_number.startswith('+'):
phone_number = f'+{phone_number}'
try:
phone_nbr = phonenumbers.parse(phone_number)
except phonenumbers.phonenumberutil.NumberParseException:
raise ValidationError(error_message)
country_code = phonenumbers.phonenumberutil.region_code_for_number(phone_nbr)
if country_code not in PHONE_ALLOWED_COUNTRIES or not phonenumbers.is_valid_number(phone_nbr):
raise ValidationError(error_message)
def _check_peppol_endpoint_number(self, warning=False):
self.ensure_one()
peppol_dict = PEPPOL_ENDPOINT_WARNING if warning else PEPPOL_ENDPOINT_RULES
endpoint_rule = peppol_dict.get(self.peppol_eas)
if not endpoint_rule:
return True
if endpoint_rule[0] == 'regex':
return bool(re.match(endpoint_rule[1], self.peppol_endpoint))
if endpoint_rule[0] == 'ean':
check_module = ean
else:
check_module = get_cc_module(endpoint_rule[0], endpoint_rule[1])
return check_module.is_valid(self.peppol_endpoint)
# -------------------------------------------------------------------------
# CONSTRAINTS
# -------------------------------------------------------------------------
@api.constrains('account_peppol_phone_number')
def _check_account_peppol_phone_number(self):
for company in self:
if company.account_peppol_phone_number:
company._sanitize_peppol_phone_number()
@api.constrains('peppol_endpoint')
def _check_peppol_endpoint(self):
for company in self:
if not company.peppol_endpoint:
continue
if not company._check_peppol_endpoint_number(PEPPOL_ENDPOINT_RULES):
raise ValidationError(_("The Peppol endpoint identification number is not correct."))
# -------------------------------------------------------------------------
# COMPUTE METHODS
# -------------------------------------------------------------------------
@api.depends('is_account_peppol_participant')
def _compute_account_peppol_proxy_state(self):
for company in self:
@@ -48,3 +158,40 @@ class ResCompany(models.Model):
)
else:
company.peppol_purchase_journal_id = False
@api.depends('email')
def _compute_account_peppol_contact_email(self):
for company in self:
if not company.account_peppol_contact_email:
company.account_peppol_contact_email = company.email
# -------------------------------------------------------------------------
# LOW-LEVEL METHODS
# -------------------------------------------------------------------------
@api.model
def _sanitize_peppol_endpoint(self, vals, eas=False, endpoint=False):
if 'peppol_eas' not in vals and 'peppol_endpoint' not in vals:
return vals
peppol_eas = vals['peppol_eas'] if 'peppol_eas' in vals else eas # let users remove the value
peppol_endpoint = vals['peppol_endpoint'] if 'peppol_endpoint' in vals else endpoint
if not peppol_eas or not peppol_endpoint:
return vals
if peppol_eas == '0208':
cbe_match = re.search('[0-9]{10}', peppol_endpoint)
if bool(cbe_match):
vals['peppol_endpoint'] = cbe_match.group(0)
return vals
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
vals = self._sanitize_peppol_endpoint(vals)
return super().create(vals_list)
def write(self, vals):
for company in self:
vals = self._sanitize_peppol_endpoint(vals, company.peppol_eas, company.peppol_endpoint)
return super().write(vals)
@@ -1,25 +1,43 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from base64 import b64encode
from odoo import api, fields, models, _
from odoo.addons.account_edi_proxy_client.models.account_edi_proxy_user import AccountEdiProxyError
from odoo import _, api, fields, models, modules, tools
from odoo.exceptions import UserError, ValidationError
from odoo.addons.account_edi_proxy_client.models.account_edi_proxy_user import AccountEdiProxyError
from odoo.addons.account_edi_ubl_cii.models.account_edi_common import EAS_MAPPING
# at the moment, only European countries are accepted
ALLOWED_COUNTRIES = set(EAS_MAPPING.keys()) - {'AU', 'SG', 'NZ'}
class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings'
account_peppol_edi_user = fields.Many2one(
comodel_name='account_edi_proxy_client.user',
string='EDI user',
compute='_compute_account_peppol_edi_user',
)
account_peppol_contact_email = fields.Char(related='company_id.account_peppol_contact_email', readonly=False)
account_peppol_eas = fields.Selection(related='company_id.peppol_eas', readonly=False)
account_peppol_edi_identification = fields.Char(related='account_peppol_edi_user.edi_identification')
account_peppol_endpoint = fields.Char(related='company_id.peppol_endpoint', readonly=False)
account_peppol_proxy_state = fields.Selection(related='company_id.account_peppol_proxy_state')
account_peppol_endpoint_warning = fields.Char(
string="Warning",
compute="_compute_account_peppol_endpoint_warning",
)
account_peppol_migration_key = fields.Char(related='account_peppol_edi_user.peppol_migration_key', readonly=False)
account_peppol_phone_number = fields.Char(related='company_id.account_peppol_phone_number', readonly=False)
account_peppol_proxy_state = fields.Selection(related='company_id.account_peppol_proxy_state', readonly=False)
account_peppol_purchase_journal_id = fields.Many2one(related='company_id.peppol_purchase_journal_id', readonly=False)
# to be removed once the module is available
account_peppol_attachment_ids = fields.Many2many(
comodel_name='ir.attachment',
string='Peppol Identification Documents',
related='company_id.account_peppol_attachment_ids', readonly=False,
)
account_peppol_verification_code = fields.Char(related='account_peppol_edi_user.peppol_verification_code', readonly=False)
is_account_peppol_eligible = fields.Boolean(
string='PEPPOL eligible',
compute='_compute_is_account_peppol_eligible',
@@ -30,46 +48,238 @@ class ResConfigSettings(models.TransientModel):
help='Register as a PEPPOL user',
)
# -------------------------------------------------------------------------
# HELPER METHODS
# -------------------------------------------------------------------------
def _call_peppol_proxy(self, endpoint, params, edi_user=None):
if not edi_user:
edi_user = self.company_id.account_edi_proxy_client_ids[0]
try:
response = edi_user._make_request(
f"{edi_user._get_server_url()}{endpoint}",
params=params,
)
except AccountEdiProxyError as e:
raise UserError(e.message)
if 'error' in response:
raise UserError(response['error'].get('message') or response['error']['data']['message'])
return response
# -------------------------------------------------------------------------
# COMPUTE METHODS
# -------------------------------------------------------------------------
@api.depends("company_id.country_id")
def _compute_is_account_peppol_eligible(self):
# we want to show Peppol settings only to BE and LU customers at first
# we want to show Peppol settings only to customers that are eligible for Peppol,
# except countries that are not in Europe
# but keeping an option to see them for testing purposes using a config param
for config in self:
peppol_param = config.env['ir.config_parameter'].sudo().get_param(
'account_peppol.edi.mode', False
)
config.is_account_peppol_eligible = config.company_id.country_id.code in {'BE', 'LU'} \
config.is_account_peppol_eligible = config.company_id.country_id.code in ALLOWED_COUNTRIES \
or peppol_param == 'test'
@api.depends("company_id.account_edi_proxy_client_ids")
def _compute_account_peppol_edi_user(self):
for config in self:
config.account_peppol_edi_user = config.company_id.account_edi_proxy_client_ids.filtered(
lambda u: u.proxy_type == 'peppol')
@api.depends('account_peppol_eas', 'account_peppol_endpoint')
def _compute_account_peppol_endpoint_warning(self):
for config in self:
if (
not config.account_peppol_eas
or config.company_id._check_peppol_endpoint_number(warning=True)
):
config.account_peppol_endpoint_warning = False
else:
config.account_peppol_endpoint_warning = _("The endpoint number might not be correct. "
"Please check if you entered the right identification number.")
# -------------------------------------------------------------------------
# BUSINESS ACTIONS
# -------------------------------------------------------------------------
def button_create_peppol_proxy_user(self):
"""
The first step of the Peppol onboarding.
- Creates an EDI proxy user on the iap side, then the client side
- Calls /activate_participant to mark the EDI user as peppol user
"""
self.ensure_one()
if self.account_peppol_proxy_state != 'not_registered':
raise UserError(
_('Cannot register a user with a %s application', self.account_peppol_proxy_state))
if not self.company_id.account_peppol_attachment_ids:
raise ValidationError(
_('Please upload a document that would help verifying your application'))
if not self.account_peppol_phone_number:
raise ValidationError(_("Please enter a phone number to verify your application."))
company = self.company_id
edi_proxy_client = self.env['account_edi_proxy_client.user']
edi_identification = edi_proxy_client._get_proxy_identification(self.company_id)
edi_identification = edi_proxy_client._get_proxy_identification(company)
edi_user = edi_proxy_client.sudo()._register_proxy_user(
self.company_id, 'peppol', 'prod', edi_identification)
company, 'peppol', 'prod', edi_identification)
self.account_peppol_proxy_state = 'not_verified'
params = {'documents': []}
for attachment in self.company_id.account_peppol_attachment_ids:
params['documents'].append((attachment.name, b64encode(attachment.raw).decode()))
# if there is an error when activating the participant below,
# the client side is rolled back and the edi user is deleted on the client side
# but remains on the proxy side.
# it is important to keep these two in sync, so commit before activating.
if not tools.config['test_enable'] and not modules.module.current_test:
self.env.cr.commit()
try:
response = edi_user._make_request(
f"{edi_user._get_server_url()}/api/peppol/1/activate_participant",
company_details = {
'peppol_company_name': company.display_name,
'peppol_company_vat': company.vat,
'peppol_company_street': company.street,
'peppol_company_city': company.city,
'peppol_company_zip': company.zip,
'peppol_country_code': company.country_id.code,
'peppol_phone_number': self.account_peppol_phone_number,
'peppol_contact_email': self.account_peppol_contact_email,
}
params = {
'migration_key': self.account_peppol_migration_key,
'company_details': company_details,
}
self._call_peppol_proxy(
endpoint='/api/peppol/1/activate_participant',
params=params,
edi_user=edi_user,
)
# once we sent the migration key over, we don't need it
# but we need the field for future in case the user decided to migrate away from Odoo
self.account_peppol_migration_key = False
def button_update_peppol_user_data(self):
"""
Action for the user to be able to update their contact details any time
Calls /update_user on the iap server
"""
self.ensure_one()
if not self.account_peppol_contact_email or not self.account_peppol_phone_number:
raise ValidationError(_("Contact email and phone number are required."))
params = {
'update_data': {
'peppol_phone_number': self.account_peppol_phone_number,
'peppol_contact_email': self.account_peppol_contact_email,
}
}
self._call_peppol_proxy(
endpoint='/api/peppol/1/update_user',
params=params,
)
def button_send_peppol_verification_code(self):
"""
Request user verification via SMS
Calls the /send_verification_code to send the 6-digit verification code
"""
self.ensure_one()
# update contact details in case the user made changes
self.button_update_peppol_user_data()
self._call_peppol_proxy(
endpoint='/api/peppol/1/send_verification_code',
params={'message': _("Your confirmation code is")},
)
self.account_peppol_proxy_state = 'sent_verification'
def button_check_peppol_verification_code(self):
"""
Calls /verify_phone_number to compare user's input and the
code generated on the IAP server
"""
self.ensure_one()
if len(self.account_peppol_verification_code) != 6:
raise ValidationError(_("The verification code should contain six digits."))
self._call_peppol_proxy(
endpoint='/api/peppol/1/verify_phone_number',
params={'verification_code': self.account_peppol_verification_code},
)
self.account_peppol_proxy_state = 'pending'
self.account_peppol_verification_code = False
def button_cancel_peppol_registration(self):
"""
Sets the peppol registration to canceled
- If the user is active on the SMP, we can't just cancel it.
They have to request a migration key using the `button_migrate_peppol_registration` action
- 'not_registered', 'rejected', 'canceled' proxy states mean that canceling the registration
makes no sense, so we don't do it
- Calls the IAP server first before setting the state as canceled on the client side,
in case they've been activated on the IAP side in the meantime
"""
self.ensure_one()
if self.account_peppol_proxy_state == 'active':
raise UserError(_("Can't cancel an active registration. Please request a migration instead."))
if self.account_peppol_proxy_state in {'not_registered', 'rejected', 'canceled'}:
raise UserError(_(
"Can't cancel registration with this status: %s", self.account_peppol_proxy_state
))
self._call_peppol_proxy(
endpoint='/api/peppol/1/cancel_peppol_registration',
params={},
)
self.account_peppol_proxy_state = 'canceled'
def button_reopen_peppol_registration(self):
"""
Reopen a canceled or rejected peppol application.
This means that the EDI user already exists in the DB and on the IAP proxy.
So, reopening resets the user to the stage after validating (not_verified).
The user then needs to verify their phone number, even if they verified before canceling.
"""
self.ensure_one()
if self.account_peppol_proxy_state in {'rejected', 'canceled'}:
params = {
'update_data': {
'peppol_state': 'draft',
}
}
self._call_peppol_proxy(
endpoint='/api/peppol/1/update_user',
params=params,
)
except AccountEdiProxyError as e:
raise UserError(e.message)
if 'error' in response:
raise UserError(response['error'])
self.company_id.account_peppol_proxy_state = 'pending'
# we don't need to store the attachments once they've been sent to the proxy
self.company_id.account_peppol_attachment_ids.unlink()
self.account_peppol_proxy_state = 'not_verified'
def button_migrate_peppol_registration(self):
"""
If the user is active, they need to request a migration key, generated on the IAP server.
The migration key is then displayed in Peppol settings.
Currently, reopening after migrating away is not supported.
"""
self.ensure_one()
if self.account_peppol_proxy_state != 'active':
raise UserError(_(
"Can't migrate registration with this status: %s", self.account_peppol_proxy_state
))
response = self._call_peppol_proxy(
endpoint='/api/peppol/1/migrate_peppol_registration',
params={},
)
self.account_peppol_proxy_state = 'canceled'
self.account_peppol_migration_key = response['migration_key']
+3 -1
View File
@@ -65,7 +65,9 @@ class ResPartner(models.Model):
participant = f'{self.peppol_eas}:{self.peppol_endpoint}'.lower()
hash_participant = md5(participant.lower().encode()).hexdigest()
endpoint_participant = parse.quote_plus(f"iso6523-actorid-upis::{participant}")
smp_url = f"http://B-{hash_participant}.iso6523-actorid-upis.edelivery.tech.ec.europa.eu/{endpoint_participant}"
peppol_param = self.env['ir.config_parameter'].sudo().get_param('account_peppol.edi.mode', False)
sml_zone = 'acc.edelivery' if peppol_param == 'test' else 'edelivery'
smp_url = f"http://B-{hash_participant}.iso6523-actorid-upis.{sml_zone}.tech.ec.europa.eu/{endpoint_participant}"
self.account_peppol_validity_last_check = fields.Date.context_today(self)
try:
response = requests.get(smp_url, timeout=TIMEOUT)
@@ -0,0 +1,92 @@
/** @odoo-module */
import { escape } from "@web/core/utils/strings";
import { registry } from "@web/core/registry";
import { pick } from "@web/core/utils/objects";
import { useService } from "@web/core/utils/hooks";
import { standardWidgetProps } from "@web/views/widgets/standard_widget_props";
import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog";
import { Component, markup } from "@odoo/owl";
class PeppolSettingsButtons extends Component {
static props = {
...standardWidgetProps,
};
static template = "account_peppol.ActionButtons";
setup() {
super.setup();
this.dialogService = useService("dialog");
this.notification = useService("notification");
}
get proxyState() {
return this.props.record.data.account_peppol_proxy_state;
}
async _callConfigMethod(methodName, save = false) {
if (save) {
await this._save();
}
this.env.onClickViewButton({
clickParams: {
name: methodName,
type: "object",
noSaveDialog: true,
},
getResParams: () =>
pick(this.env.model.root, "context", "evalContext", "resModel", "resId", "resIds"),
});
}
async _save () {
this.env.model.root.save({
noReload: true,
stayInEdition: true,
useSaveErrorDialog: false,
savePoint: true,
});
}
showConfirmation() {
const message = this.env._t("This will migrate your Peppol registration away from Odoo. You will not be able to send or receive Peppol documents in Odoo anymore.");
const confirmMessage = this.env._t("Are you sure you want to proceed?");
this.dialogService.add(ConfirmationDialog, {
body: markup(
`<div class="text-danger">${escape(message)}</div>
<div class="text-danger">${escape(confirmMessage)}</div>`
),
confirm: async () => {
await this._callConfigMethod("button_migrate_peppol_registration");
},
cancel: () => { },
});
}
async updateDetails() {
// avoid making users click save on the settings
// and then clicking the update button
// changes on both the client side and the iap side need to be saved within one method
await this._callConfigMethod("button_update_peppol_user_data", true);
this.notification.add(
this.env._t("Contact details were updated."),
{ type: "success" }
);
}
async checkCode() {
// avoid making users click save on the settings
// and then clicking the confirm button to check the code
await this._callConfigMethod("button_check_peppol_verification_code", true);
}
async createUser() {
await this._callConfigMethod("button_create_peppol_proxy_user", true);
}
}
registry.category("view_widgets").add("peppol_settings_buttons", {
component: PeppolSettingsButtons,
});
@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates>
<t t-name="account_peppol.ActionButtons" owl="1">
<xpath expr="//div[hasclass('action_buttons')]" position="inside">
<div class="d-flex" colspan="2">
<div class="mt-3">
<button type="button"
class="btn btn-primary"
t-on-click="createUser"
t-if="proxyState === 'not_registered'">
Validate registration
</button>
</div>
<div class="mt-3">
<button type="button"
class="btn btn-secondary me-1"
t-on-click="showConfirmation"
t-if="proxyState === 'active'">
Migrate registration to another service
</button>
</div>
<div class="mt-3">
<button type="button"
class="btn btn-primary"
t-on-click="updateDetails"
t-if="['pending', 'manually_approved', 'active'].includes(proxyState)">
Update contact details
</button>
</div>
<div class="mt-3">
<button type="button"
class="btn btn-primary"
t-on-click="checkCode"
t-if="proxyState === 'sent_verification'">
Confirm
</button>
</div>
</div>
</xpath>
</t>
</templates>
@@ -0,0 +1,76 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { standardFieldProps } from "@web/views/fields/standard_field_props";
import { Component, onMounted, useRef } from "@odoo/owl";
class VerificationCodeWidget extends Component {
static props = {
...standardFieldProps,
};
static template = "account_peppol.VerificationCodeWidget";
setup() {
super.setup();
this.inputs = [];
for (let i = 0; i < 6; i++) {
this.inputs.push(useRef(`input_${i}`));
}
/*
if the verification code was previously filled in and the user saved the page,
pre-fill the input fields with the stored value.
*/
onMounted(async () => {
const verificationCode = this.props.record.data.account_peppol_verification_code;
for (let i = 0; i < this.inputs.length; i++) {
this.inputs[i].el.value = verificationCode[i] || null;
}
});
}
/*
overrides the default paste behaviour, so that if a value is pasted
into one of the verification code fields, it's split between the input fields,
so they can easily copy and paste the code they received via SMS.
*/
onPaste(ev) {
if (!ev.clipboardData?.items) {
return;
}
ev.preventDefault();
let pastedData = ev.clipboardData.getData('text').split('');
let target = ev.target;
for (let i = target.id; i < this.inputs.length; i++) {
this.inputs[i].el.value = pastedData.shift() || null;
}
}
// switch focus to the next input box once they enter
// one digit and switch focus to the previous input box
// if they press backspace
onKeyUp(ev) {
if (ev.target.value.length === 1 && ev.target.id < 5) {
ev.target.nextElementSibling.focus();
} else if (ev.key == 'Backspace' && ev.target.value === "" && ev.target.id > 0) {
ev.target.previousElementSibling.focus();
}
}
_save() {
let verificationCode = [...this.inputs.map((i) => i.el.value)].join('');
this.props.record.update({ account_peppol_verification_code: verificationCode });
this.env.model.root.save({
noReload: true,
stayInEdition: true,
useSaveErrorDialog: true,
});
}
}
registry.category("fields").add("verification_code", {
component: VerificationCodeWidget,
supportedTypes: ["char"],
});
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<templates>
<t t-name="account_peppol.VerificationCodeWidget" owl="1">
<div class="row w-50 mt-0 pt-0" t-on-focusout="_save">
<t t-foreach="[...Array(6).keys()]" t-as="i" t-key="i">
<input class="verification_code col border border-2 rounded m-1 p-0 text-center"
type="text"
maxlength="1"
t-att-id="i"
t-ref="input_{{i}}"
t-on-keyup="onKeyUp"
t-on-paste="onPaste"/>
</t>
</div>
</t>
</templates>
@@ -65,7 +65,7 @@ class TestPeppolMessage(TestAccountMoveSendCommon):
'partner_id': cls.env.company.partner_id.id,
})
def create_move(self, partner, account_revenue=False, account_expense=False):
def create_move(self, partner):
return self.env['account.move'].create({
'move_type': 'out_invoice',
'partner_id': partner.id,
@@ -3,10 +3,11 @@
from contextlib import contextmanager
from freezegun import freeze_time
from unittest.mock import Mock, patch
from psycopg2 import IntegrityError
from odoo.exceptions import UserError, ValidationError
from odoo.exceptions import ValidationError
from odoo.tests.common import tagged, TransactionCase
from odoo.tools import file_open
from odoo.tools import mute_logger
ID_CLIENT = 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'
FAKE_UUID = 'yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy'
@@ -31,6 +32,10 @@ class TestPeppolParticipant(TransactionCase):
elif url.endswith('/iap/account_edi/2/create_user'):
response.json = lambda: {'result': {
'id_client': ID_CLIENT, 'refresh_token': FAKE_UUID}}
elif url.endswith('/api/peppol/1/send_verification_code')\
or url.endswith('/api/peppol/1/update_user')\
or url.endswith('/api/peppol/1/verify_phone_number'):
response.json = lambda: {'result': {}}
else:
raise Exception(f'Unexpected request: {url}')
@@ -60,20 +65,19 @@ class TestPeppolParticipant(TransactionCase):
'is_account_peppol_participant': True,
'account_peppol_eas': '9925',
'account_peppol_endpoint': '0000000000',
'account_peppol_phone_number': '+32483123456',
})
company = self.env.company
with file_open(PDF_FILE_PATH, 'rb') as pdf_file:
settings.account_peppol_attachment_ids = self.env['ir.attachment'].create({
'mimetype': 'application/pdf',
'name': 'peppol_id_test.pdf',
'raw': pdf_file.read(),
}).ids
settings.execute()
with self._patch_peppol_requests():
settings.button_create_peppol_proxy_user()
self.assertEqual(company.account_peppol_proxy_state, 'not_verified')
settings.button_send_peppol_verification_code()
self.assertEqual(company.account_peppol_proxy_state, 'sent_verification')
settings.account_peppol_verification_code = '123456'
settings.button_check_peppol_verification_code()
self.assertEqual(company.account_peppol_proxy_state, 'pending')
self.env['account_edi_proxy_client.user']._cron_peppol_get_participant_status()
self.assertEqual(company.account_peppol_proxy_state, 'active')
@@ -86,24 +90,19 @@ class TestPeppolParticipant(TransactionCase):
'is_account_peppol_participant': True,
'account_peppol_eas': '9925',
'account_peppol_endpoint': '0000000000',
'account_peppol_phone_number': '+32483123456',
})
company = self.env.company
with file_open(PDF_FILE_PATH, 'rb') as pdf_file:
settings.account_peppol_attachment_ids = self.env['ir.attachment'].create({
'mimetype': 'application/pdf',
'name': 'peppol_id_test.pdf',
'raw': pdf_file.read(),
}).ids
settings.execute()
with self._patch_peppol_requests(reject=True):
settings.button_create_peppol_proxy_user()
self.assertEqual(company.account_peppol_proxy_state, 'pending')
company.account_peppol_proxy_state = 'pending'
self.env['account_edi_proxy_client.user']._cron_peppol_get_participant_status()
self.assertEqual(company.account_peppol_proxy_state, 'rejected')
@mute_logger('odoo.sql_db')
def test_create_duplicate_participant(self):
# should not be possible to create a duplicate participant
settings = self.env['res.config.settings'].create({})
@@ -111,19 +110,13 @@ class TestPeppolParticipant(TransactionCase):
'is_account_peppol_participant': True,
'account_peppol_eas': '9925',
'account_peppol_endpoint': '0000000000',
'account_peppol_phone_number': '+32483123456',
})
with file_open(PDF_FILE_PATH, 'rb') as pdf_file:
settings.account_peppol_attachment_ids = self.env['ir.attachment'].create({
'mimetype': 'application/pdf',
'name': 'peppol_id_test.pdf',
'raw': pdf_file.read(),
}).ids
settings.execute()
with self._patch_peppol_requests():
settings.button_create_peppol_proxy_user()
self.assertEqual(settings.account_peppol_proxy_state, 'pending')
with self.assertRaises(UserError), self.cr.savepoint():
with self.assertRaises(IntegrityError), self.cr.savepoint():
settings.account_peppol_proxy_state = 'not_registered'
settings.button_create_peppol_proxy_user()
@@ -14,86 +14,185 @@
<field name="account_peppol_proxy_state" invisible="1"/>
<div class="o_setting_left_pane">
<field name="is_account_peppol_participant"
attrs="{'invisible': [('account_peppol_proxy_state', 'not in', (False, 'not_registered'))]}"/>
attrs="{'invisible': [('account_peppol_proxy_state', '!=', 'not_registered')]}"/>
</div>
<div class="o_setting_right_pane">
<div attrs="{'invisible': [('account_peppol_proxy_state', '!=', 'not_registered')]}">
<label for="is_account_peppol_participant" string='Use PEPPOL Invoicing'/>
<span class="fa fa-lg fa-building-o" title="Values set here are company-specific."/>
<div class="text-muted">
Once you select this option and confirm, we will review your application.
You will be able to start sending and receiving documents via PEPPOL once your participation is approved.
We will notify you once we have reviewed your application.
Our staff members will first review your request within two working days.
</div>
<div class="pt-3 pb-3"
<div class="text-muted">
Start sending and receiving documents via Peppol as soon as you receive our email notification.
</div>
<div class="alert alert-warning mt-3"
role="alert"
attrs="{'invisible': ['|',
('is_account_peppol_participant', '=', False),
('account_peppol_endpoint_warning', '=', False)
]}">
<field name="account_peppol_endpoint_warning"/>
</div>
<div class="pt-3"
attrs="{'invisible': [('is_account_peppol_participant', '=', False)]}">
<b>
Please verify the EAS code and the Endpoint (Participant ID) below
before confirming the registration!
</b>
<div class="row pt-3">
<div class="row">
<label string="Peppol EAS"
for="account_peppol_eas"
class="col-lg-3 o_light_label"/>
for="account_peppol_eas"
class="col-lg-3 o_light_label"/>
<field name="account_peppol_eas"/>
</div>
<div class="row">
<label string="Peppol Endpoint"
for="account_peppol_eas"
class="col-lg-3 o_light_label"/>
for="account_peppol_eas"
class="col-lg-3 o_light_label"/>
<field name="account_peppol_endpoint"/>
</div>
</div>
</div>
<div class="content-group"
attrs="{'invisible': ['|',
('is_account_peppol_participant', '=', False),
('account_peppol_proxy_state', 'in', ('rejected', 'not_registered'))]}">
<div class="row">
<label string="Incoming Invoices Journal"
for="account_peppol_purchase_journal_id"
<div class="row"
attrs="{'invisible': ['|',
('is_account_peppol_participant', '=', False),
('account_peppol_proxy_state', 'not in', ('not_registered', 'not_verified'))
]}">
<label string="Phone Number"
for="account_peppol_phone_number"
class="col-lg-3 o_light_label"/>
<field name="account_peppol_purchase_journal_id"
attrs="{'required': [
('is_account_peppol_participant', '=', True),
('account_peppol_proxy_state', 'not in', ('rejected', 'not_registered')),
]}"/>
</div>
<field name="account_peppol_phone_number"
attrs="{'required': [
('is_account_peppol_participant', '=', True),
('account_peppol_proxy_state', 'in', ('not_registered', 'not_verified')),
]}"/>
</div>
<div class="row"
attrs="{'invisible': ['|',
('is_account_peppol_participant', '=', False),
('account_peppol_proxy_state', 'in', ('rejected', 'canceled', 'sent_verification'))]}">
<label string="Primary contact email"
for="account_peppol_contact_email"
class="col-lg-3 o_light_label"/>
<field name="account_peppol_contact_email"
attrs="{'required': [('is_account_peppol_participant', '=', True)]}"/>
</div>
<div class="content-group pt-3"
attrs="{'invisible': ['|',
('is_account_peppol_participant', '=', False),
('account_peppol_proxy_state', '!=', 'not_registered')]}">
attrs="{'invisible': ['|',
('account_peppol_proxy_state', 'not in', ('not_registered')),
('is_account_peppol_participant', '=', False)]}">
<span>
Upload a document to verify Company information.
I want to migrate my Peppol connection to Odoo (optional):
</span>
<div class="row mt-3">
<field name="account_peppol_attachment_ids"
widget="many2many_binary"
<label string="Migration key"
for="account_peppol_migration_key"
class="col-lg-3 o_light_label"/>
<field name="account_peppol_migration_key"/>
</div>
</div>
<div class="row mb-3"
attrs="{'invisible': [('is_account_peppol_participant', '=', False)]}">
<div class="content-group mt-3"
attrs="{'invisible': [
('account_peppol_proxy_state', 'in', (
'rejected', 'not_registered', 'canceled', 'not_verified', 'sent_verification'))
]}">
<div class="row">
<label string="Incoming Invoices Journal"
for="account_peppol_purchase_journal_id"
class="col-lg-3 o_light_label"/>
<field name="account_peppol_purchase_journal_id"
attrs="{'required': [
('is_account_peppol_participant', '=', True),
('account_peppol_proxy_state', 'not in', (
'rejected', 'not_registered', 'canceled', 'not_verified', 'sent_verification')),
]}"/>
</div>
</div>
<div class="content-group mt-3"
attrs="{'invisible': [('account_peppol_proxy_state', '!=', 'sent_verification')]}">
<span class="text-muted">
We sent a verification code to
<field name="account_peppol_phone_number"
nolabel="1"
attrs="{'readonly': [
('is_account_peppol_participant', '=', True),
('account_peppol_proxy_state', '=', 'sent_verification'),
]}"/>.
</span>
<div class="row mt-1 ps-3">
<field name="account_peppol_verification_code" widget="verification_code"/>
</div>
</div>
<div class="pt-3 pb-3"
attrs="{'invisible': [('account_peppol_proxy_state', '=', 'not_registered')]}">
Application status:
<b>
<field name="account_peppol_proxy_state"
readonly="1"
decoration-danger="account_peppol_proxy_state == 'rejected'"
decoration-info="account_peppol_proxy_state == 'active'"/>
</b>
</div>
<div attrs="{'invisible': [('account_peppol_proxy_state', '!=', 'rejected')]}">
<p>
The rejection reason has been sent to you via email.
</p>
<p>
Please do not hesitate to contact our support if you need further assistance.
</p>
</div>
<div attrs="{'invisible': ['|',
('account_peppol_proxy_state', '!=', 'canceled'),
('account_peppol_migration_key', '=', False)]}">
Your migration key is
<field name="account_peppol_migration_key"
nolabel="1"
string="Upload documents"/>
attrs="{'readonly': [
('account_peppol_proxy_state', '=', 'canceled'),
('account_peppol_migration_key', '!=', False)
]}"/>
</div>
</div>
<div class="pt-3 pb-3"
attrs="{'invisible': [('account_peppol_proxy_state', 'in', (False, 'not_registered'))]}">
Application status:
<b>
<field name="account_peppol_proxy_state"
decoration-danger="account_peppol_proxy_state == 'rejected'"
decoration-info="account_peppol_proxy_state == 'active'"/>
</b>
</div>
<div class="mt-5"
attrs="{'invisible': ['|',
('is_account_peppol_participant', '=', False),
('account_peppol_proxy_state', '!=', 'not_registered')]}">
<div class="mb-3">
By clicking the "Confirm" button, I accept that Odoo may process my e-invoices.
<div attrs="{'invisible': [('account_peppol_proxy_state', '!=', 'active')]}">
Your Peppol identification is
<field name="account_peppol_edi_identification"
nolabel="1"/>
</div>
<div class="mt-5"
attrs="{'invisible': [('account_peppol_proxy_state', '!=', 'not_registered')]}">
<div class="mb-3">
I accept that Odoo may process my e-invoices.
</div>
</div>
<div class="d-flex gap-1 action_buttons" colspan="3">
<div class="mt-3"
attrs="{'invisible': [('account_peppol_proxy_state', '!=', 'not_verified')]}">
<button name="button_send_peppol_verification_code"
type="object"
string="Verify phone number"
class="btn btn-primary"/>
</div>
<widget name="peppol_settings_buttons"
attrs="{'invisible': [('account_peppol_proxy_state', 'not in', (
'not_registered', 'sent_verification', 'pending', 'manually_approved', 'active'))
]}"/>
<div class="mt-3"
attrs="{'invisible': [
('account_peppol_proxy_state', 'in', (
'not_registered', 'active', 'rejected', 'canceled'))]}">
<button name="button_cancel_peppol_registration"
type="object"
string="Cancel registration"
class="btn btn-secondary"/>
</div>
<div class="mt-3"
attrs="{'invisible': ['|',
('account_peppol_proxy_state', '!=', 'canceled'),
('account_peppol_migration_key', '!=', False)]}">
<button name="button_reopen_peppol_registration"
type="object"
string="Retry registration"
class="btn btn-secondary"/>
</div>
</div>
<button name="button_create_peppol_proxy_user"
type="object"
string="Confirm"
class="btn btn-primary"/>
</div>
</div>
</div>
@@ -146,13 +146,8 @@ class AccountMoveSend(models.Model):
if not params['documents']:
return
edi_user = self.env['account_edi_proxy_client.user'].search(
[
('company_id', '=', self.company_id.id),
('proxy_type', '=', 'peppol'),
],
limit=1,
)
edi_user = self.company_id.account_edi_proxy_client_ids.filtered(
lambda u: u.proxy_type == 'peppol')
try:
response = edi_user._make_request(
@@ -6,9 +6,9 @@
<field name="model">account.move.send</field>
<field name="inherit_id" ref="account.account_move_send_form"/>
<field name="arch" type="xml">
<xpath expr="//div[@name='standard_options']" position="before">
<xpath expr="//div[@name='warnings']" position="inside">
<field name="enable_peppol" invisible="1"/>
<div class="alert alert-warning text-center" role="alert" attrs="{
<div class="alert alert-warning" role="alert" attrs="{
'invisible': ['|',
('checkbox_send_peppol', '!=', True),
('peppol_warning', '=', False),
@@ -23,7 +23,7 @@
</cac:AdditionalDocumentReference>
<cac:AccountingSupplierParty>
<cac:Party>
<cbc:EndpointID schemeID="9925">BE0202239951</cbc:EndpointID>
<cbc:EndpointID schemeID="0208">0202239951</cbc:EndpointID>
<cac:PartyName>
<cbc:Name>partner_1</cbc:Name>
</cac:PartyName>
@@ -52,7 +52,7 @@
</cac:AccountingSupplierParty>
<cac:AccountingCustomerParty>
<cac:Party>
<cbc:EndpointID schemeID="9925">BE0477472701</cbc:EndpointID>
<cbc:EndpointID schemeID="0208">0477472701</cbc:EndpointID>
<cac:PartyName>
<cbc:Name>partner_2</cbc:Name>
</cac:PartyName>
@@ -23,7 +23,7 @@
</cac:AdditionalDocumentReference>
<cac:AccountingSupplierParty>
<cac:Party>
<cbc:EndpointID schemeID="9925">BE0202239951</cbc:EndpointID>
<cbc:EndpointID schemeID="0208">0202239951</cbc:EndpointID>
<cac:PartyName>
<cbc:Name>partner_1</cbc:Name>
</cac:PartyName>
@@ -52,7 +52,7 @@
</cac:AccountingSupplierParty>
<cac:AccountingCustomerParty>
<cac:Party>
<cbc:EndpointID schemeID="9925">BE0477472701</cbc:EndpointID>
<cbc:EndpointID schemeID="0208">0477472701</cbc:EndpointID>
<cac:PartyName>
<cbc:Name>partner_2</cbc:Name>
</cac:PartyName>
@@ -23,7 +23,7 @@
</cac:AdditionalDocumentReference>
<cac:AccountingSupplierParty>
<cac:Party>
<cbc:EndpointID schemeID="9925">BE0202239951</cbc:EndpointID>
<cbc:EndpointID schemeID="0208">0202239951</cbc:EndpointID>
<cac:PartyName>
<cbc:Name>partner_1</cbc:Name>
</cac:PartyName>
@@ -52,7 +52,7 @@
</cac:AccountingSupplierParty>
<cac:AccountingCustomerParty>
<cac:Party>
<cbc:EndpointID schemeID="9925">BE0477472701</cbc:EndpointID>
<cbc:EndpointID schemeID="0208">0477472701</cbc:EndpointID>
<cac:PartyName>
<cbc:Name>partner_2</cbc:Name>
</cac:PartyName>
@@ -20,7 +20,7 @@
</cac:AdditionalDocumentReference>
<cac:AccountingSupplierParty>
<cac:Party>
<cbc:EndpointID schemeID="9925">BE0202239951</cbc:EndpointID>
<cbc:EndpointID schemeID="0208">0202239951</cbc:EndpointID>
<cac:PartyName>
<cbc:Name>partner_1</cbc:Name>
</cac:PartyName>
@@ -49,7 +49,7 @@
</cac:AccountingSupplierParty>
<cac:AccountingCustomerParty>
<cac:Party>
<cbc:EndpointID schemeID="9925">BE0477472701</cbc:EndpointID>
<cbc:EndpointID schemeID="0208">0477472701</cbc:EndpointID>
<cac:PartyName>
<cbc:Name>partner_2</cbc:Name>
</cac:PartyName>
@@ -20,7 +20,7 @@
</cac:AdditionalDocumentReference>
<cac:AccountingSupplierParty>
<cac:Party>
<cbc:EndpointID schemeID="9925">BE0202239951</cbc:EndpointID>
<cbc:EndpointID schemeID="0208">0202239951</cbc:EndpointID>
<cac:PartyName>
<cbc:Name>partner_1</cbc:Name>
</cac:PartyName>
@@ -58,7 +58,7 @@
<cbc:CityName>Ramillies</cbc:CityName>
<cbc:PostalZone>1367</cbc:PostalZone>
<cac:Country>
<cbc:IdentificationCode>BE</cbc:IdentificationCode>
<cbc:IdentificationCode>LU</cbc:IdentificationCode>
</cac:Country>
</cac:PostalAddress>
<cac:PartyLegalEntity>
@@ -70,13 +70,14 @@
</cac:Party>
</cac:AccountingCustomerParty>
<cac:Delivery>
<cbc:ActualDeliveryDate>2017-01-01</cbc:ActualDeliveryDate>
<cac:DeliveryLocation>
<cac:Address>
<cbc:StreetName>Rue des Bourlottes 9</cbc:StreetName>
<cbc:CityName>Ramillies</cbc:CityName>
<cbc:PostalZone>1367</cbc:PostalZone>
<cac:Country>
<cbc:IdentificationCode>BE</cbc:IdentificationCode>
<cbc:IdentificationCode>LU</cbc:IdentificationCode>
</cac:Country>
</cac:Address>
</cac:DeliveryLocation>
@@ -25,7 +25,7 @@
</cac:AdditionalDocumentReference>
<cac:AccountingSupplierParty>
<cac:Party>
<cbc:EndpointID schemeID="9925">BE0202239951</cbc:EndpointID>
<cbc:EndpointID schemeID="0208">0202239951</cbc:EndpointID>
<cac:PartyName>
<cbc:Name>partner_1</cbc:Name>
</cac:PartyName>
@@ -54,7 +54,7 @@
</cac:AccountingSupplierParty>
<cac:AccountingCustomerParty>
<cac:Party>
<cbc:EndpointID schemeID="9925">BE0477472701</cbc:EndpointID>
<cbc:EndpointID schemeID="0208">0477472701</cbc:EndpointID>
<cac:PartyName>
<cbc:Name>partner_2</cbc:Name>
</cac:PartyName>
@@ -19,7 +19,7 @@
</cac:AdditionalDocumentReference>
<cac:AccountingSupplierParty>
<cac:Party>
<cbc:EndpointID schemeID="9925">BE0202239951</cbc:EndpointID>
<cbc:EndpointID schemeID="0208">0202239951</cbc:EndpointID>
<cac:PartyName>
<cbc:Name>partner_1</cbc:Name>
</cac:PartyName>
@@ -48,7 +48,7 @@
</cac:AccountingSupplierParty>
<cac:AccountingCustomerParty>
<cac:Party>
<cbc:EndpointID schemeID="9925">BE0477472701</cbc:EndpointID>
<cbc:EndpointID schemeID="0208">0477472701</cbc:EndpointID>
<cac:PartyName>
<cbc:Name>partner_2</cbc:Name>
</cac:PartyName>
@@ -17,7 +17,7 @@
</cac:OrderReference>
<cac:AccountingSupplierParty>
<cac:Party>
<cbc:EndpointID schemeID="9925">BE0202239951</cbc:EndpointID>
<cbc:EndpointID schemeID="0208">0202239951</cbc:EndpointID>
<cac:PartyName>
<cbc:Name>partner_1</cbc:Name>
</cac:PartyName>
@@ -218,10 +218,11 @@ class TestUBLBE(TestUBLCommon, TestAccountMoveSendCommon):
`peppol_endpoint`).
"""
# Setup a public admin in Luxembourg
self.partner_2.update({
self.partner_2.write({
'vat': None,
'peppol_endpoint': '00005000041',
'peppol_eas': '9938',
'country_id': self.env.ref('base.lu').id,
})
invoice = self._generate_move(