diff options
35 files changed, 1810 insertions, 65 deletions
diff --git a/addons/adyen_platforms/__init__.py b/addons/adyen_platforms/__init__.py new file mode 100644 index 00000000000..7d34c7c054a --- /dev/null +++ b/addons/adyen_platforms/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import controllers +from . import models diff --git a/addons/adyen_platforms/__manifest__.py b/addons/adyen_platforms/__manifest__.py new file mode 100644 index 00000000000..189ff6b8204 --- /dev/null +++ b/addons/adyen_platforms/__manifest__.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +{ + 'name': 'Adyen for Platforms', + 'version': '1.0', + 'category': '', + 'summary': 'Base Module for Adyen for Platforms', + 'description': 'Base Module for Adyen for Platforms, used in eCommerce and PoS', + 'depends': ['mail', 'web'], + 'data': [ + 'data/adyen_platforms_data.xml', + 'security/ir.model.access.csv', + 'views/adyen_account_templates.xml', + 'views/adyen_account_views.xml', + 'views/adyen_transaction_views.xml', + 'views/assets.xml', + ], + 'qweb': [ + "static/src/xml/adyen_account_templates.xml", + "static/src/xml/adyen_transactions_templates.xml", + ], + 'installable': True, +} diff --git a/addons/adyen_platforms/controllers/__init__.py b/addons/adyen_platforms/controllers/__init__.py new file mode 100644 index 00000000000..7ea350e3385 --- /dev/null +++ b/addons/adyen_platforms/controllers/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import adyen_platforms diff --git a/addons/adyen_platforms/controllers/adyen_platforms.py b/addons/adyen_platforms/controllers/adyen_platforms.py new file mode 100644 index 00000000000..a26ca88dfff --- /dev/null +++ b/addons/adyen_platforms/controllers/adyen_platforms.py @@ -0,0 +1,13 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import http +from odoo.http import request + + +class AdyenPlatformsController(http.Controller): + + @http.route('/adyen_platforms/create_account', type='http', auth='user', website=True) + def adyen_platforms_create_account(self, creation_token): + request.session['adyen_creation_token'] = creation_token + return request.redirect('/web?#action=adyen_platforms.adyen_account_action_create') diff --git a/addons/adyen_platforms/data/adyen_platforms_data.xml b/addons/adyen_platforms/data/adyen_platforms_data.xml new file mode 100644 index 00000000000..c01578fe449 --- /dev/null +++ b/addons/adyen_platforms/data/adyen_platforms_data.xml @@ -0,0 +1,26 @@ +<?xml version="1.0" encoding="utf-8"?> +<odoo> + <data noupdate="1"> + <record model="ir.config_parameter" id="adyen_platforms_proxy_url"> + <field name="key">adyen_platforms.proxy_url</field> + <field name="value">https://payment-adyen.odoo.com/payment_proxy_adyen/</field> + </record> + + <record model="ir.config_parameter" id="adyen_platforms_onboarding_url"> + <field name="key">adyen_platforms.onboarding_url</field> + <field name="value">https://www.odoo.com/odoo_adyen/</field> + </record> + + <record id="adyen_sync_cron" model="ir.cron"> + <field name="name">Adyen Sync</field> + <field name="model_id" ref="model_adyen_account"/> + <field name="state">code</field> + <field name="code">model._sync_adyen_cron()</field> + <field name="user_id" ref="base.user_root"/> + <field name="interval_number">1</field> + <field name="interval_type">days</field> + <field name="numbercall">-1</field> + <field name="doall" eval="False"/> + </record> + </data> +</odoo> diff --git a/addons/adyen_platforms/models/__init__.py b/addons/adyen_platforms/models/__init__.py new file mode 100644 index 00000000000..480ddb07be1 --- /dev/null +++ b/addons/adyen_platforms/models/__init__.py @@ -0,0 +1,6 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import adyen_account +from . import adyen_transaction +from . import res_company diff --git a/addons/adyen_platforms/models/adyen_account.py b/addons/adyen_platforms/models/adyen_account.py new file mode 100644 index 00000000000..434f9f3bc06 --- /dev/null +++ b/addons/adyen_platforms/models/adyen_account.py @@ -0,0 +1,711 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import os +import requests +import uuid +from werkzeug.urls import url_join + +from odoo import api, fields, models, _ +from odoo.http import request +from odoo.exceptions import UserError, ValidationError +from odoo.tools import date_utils + +from odoo.addons.adyen_platforms.util import AdyenProxyAuth + +ADYEN_AVAILABLE_COUNTRIES = ['US', 'AT', 'AU', 'BE', 'CA', 'CH', 'CZ', 'DE', 'ES', 'FI', 'FR', 'GB', 'GR', 'HR', 'IE', 'IT', 'LT', 'LU', 'NL', 'PL', 'PT'] +TIMEOUT = 60 + + +class AdyenAddressMixin(models.AbstractModel): + _name = 'adyen.address.mixin' + _description = 'Adyen for Plaforms Address Mixin' + + country_id = fields.Many2one('res.country', string='Country', domain=[('code', 'in', ADYEN_AVAILABLE_COUNTRIES)], required=True) + country_code = fields.Char(related='country_id.code') + state_id = fields.Many2one('res.country.state', string='State', domain="[('country_id', '=?', country_id)]") + state_code = fields.Char(related='state_id.code') + city = fields.Char('City', required=True) + zip = fields.Char('ZIP', required=True) + street = fields.Char('Street', required=True) + house_number_or_name = fields.Char('House Number Or Name', required=True) + + +class AdyenIDMixin(models.AbstractModel): + _name = 'adyen.id.mixin' + _description = 'Adyen for Platforms ID Mixin' + + id_type = fields.Selection(string='Photo ID type', selection=[ + ('PASSPORT', 'Passport'), + ('ID_CARD', 'ID Card'), + ('DRIVING_LICENSE', 'Driving License'), + ]) + id_front = fields.Binary('Photo ID Front', help="Allowed formats: jpg, pdf, png. Maximum allowed size: 4MB.") + id_front_filename = fields.Char() + id_back = fields.Binary('Photo ID Back', help="Allowed formats: jpg, pdf, png. Maximum allowed size: 4MB.") + id_back_filename = fields.Char() + + def write(self, vals): + res = super(AdyenIDMixin, self).write(vals) + if vals.get('id_front'): + document_type = self.id_type + if self.id_type in ['ID_CARD', 'DRIVING_LICENSE']: + document_type += '_FRONT' + self._check_file_requirements(self.id_front, self.id_front_filename) + self._upload_photo_id(document_type, self.id_front, self.id_front_filename) + if vals.get('id_back'): + document_type = self.id_type + '_BACK' + self._check_file_requirements(self.id_back, self.id_back_filename) + self._upload_photo_id(document_type, self.id_back, self.id_back_filename) + return res + + def _check_file_requirements(self, content, filename): + file_extension = os.path.splitext(filename)[1] + file_size = len(content) + if file_extension not in ['.jpeg', '.jpg', '.pdf', '.png']: + raise ValidationError(_('Allowed file formats for photo IDs are jpeg, jpg, pdf or png')) + if file_size >> 20 > 4 or (file_size >> 10 < 1 and file_extension == '.pdf') or (file_size >> 10 < 100 and file_extension != '.pdf') : + raise ValidationError(_('Photo ID file size must be between 100kB (1kB for PDFs) and 4MB')) + + def _upload_photo_id(self, document_type, content, filename): + # The request to be sent to Adyen will be different for Individuals, + # Shareholders, etc. This method should be implemented by the models + # inheriting this mixin + raise NotImplementedError() + + +class AdyenAccount(models.Model): + _name = 'adyen.account' + _inherit = ['mail.thread', 'adyen.id.mixin', 'adyen.address.mixin'] + + _description = 'Adyen for Plaforms Account' + _rec_name = 'full_name' + + # Credentials + proxy_token = fields.Char('Proxy Token') + adyen_uuid = fields.Char('Adyen UUID') + account_holder_code = fields.Char('Account Holder Code', default=lambda self: uuid.uuid4().hex) + + company_id = fields.Many2one('res.company', default=lambda self: self.env.company) + payout_ids = fields.One2many('adyen.payout', 'adyen_account_id', string='Payouts') + shareholder_ids = fields.One2many('adyen.shareholder', 'adyen_account_id', string='Shareholders') + bank_account_ids = fields.One2many('adyen.bank.account', 'adyen_account_id', string='Bank Accounts') + transaction_ids = fields.One2many('adyen.transaction', 'adyen_account_id', string='Transactions') + transactions_count = fields.Integer(compute='_compute_transactions_count') + + is_business = fields.Boolean('Is a business', required=True) + + # Contact Info + full_name = fields.Char(compute='_compute_full_name') + email = fields.Char('Email', required=True) + phone_number = fields.Char('Phone Number', required=True) + + # Individual + first_name = fields.Char('First Name') + last_name = fields.Char('Last Name') + date_of_birth = fields.Date('Date of birth') + document_number = fields.Char('ID Number', + help="The type of ID Number required depends on the country:\n" + "US: Social Security Number (9 digits or last 4 digits)\n" + "Canada: Social Insurance Number\nItaly: Codice fiscale\n" + "Australia: Document Number") + document_type = fields.Selection(string='Document Type', selection=[ + ('ID', 'ID'), + ('PASSPORT', 'Passport'), + ('VISA', 'Visa'), + ('DRIVINGLICENSE', 'Driving license'), + ], default='ID') + + # Business + legal_business_name = fields.Char('Legal Business Name') + doing_business_as = fields.Char('Doing Business As') + registration_number = fields.Char('Registration Number') + + # KYC + kyc_status = fields.Selection(string='KYC Status', selection=[ + ('awaiting_data', 'Data to provide'), + ('pending', 'Waiting for validation'), + ('passed', 'Confirmed'), + ('failed', 'Failed'), + ], required=True, default='pending') + kyc_status_message = fields.Char('KYC Status Message', readonly=True) + + _sql_constraints = [ + ('adyen_uuid_uniq', 'UNIQUE(adyen_uuid)', 'Adyen UUID should be unique'), + ] + + @api.depends('transaction_ids') + def _compute_transactions_count(self): + for adyen_account_id in self: + adyen_account_id.transactions_count = len(adyen_account_id.transaction_ids) + + @api.depends('first_name', 'last_name', 'legal_business_name') + def _compute_full_name(self): + for adyen_account_id in self: + if adyen_account_id.is_business: + adyen_account_id.full_name = adyen_account_id.legal_business_name + else: + adyen_account_id.full_name = "%s %s" % (adyen_account_id.first_name, adyen_account_id.last_name) + + @api.model + def create(self, values): + adyen_account_id = super(AdyenAccount, self).create(values) + self.env.company.adyen_account_id = adyen_account_id.id + + # Create account on odoo.com, proxy and Adyen + response = adyen_account_id._adyen_rpc('create_account_holder', adyen_account_id._format_data()) + + # Save adyen_uuid and proxy_token, that have been generated by odoo.com and the proxy + adyen_account_id.with_context(update_from_adyen=True).write({ + 'adyen_uuid': response['adyen_uuid'], + 'proxy_token': response['proxy_token'], + }) + + # A default payout is created for all adyen accounts + adyen_account_id.env['adyen.payout'].with_context(update_from_adyen=True).create({ + 'code': response['adyen_response']['accountCode'], + 'adyen_account_id': adyen_account_id.id, + }) + return adyen_account_id + + def write(self, vals): + res = super(AdyenAccount, self).write(vals) + if not self.env.context.get('update_from_adyen'): + self._adyen_rpc('update_account_holder', self._format_data()) + return res + + def unlink(self): + for adyen_account_id in self: + adyen_account_id._adyen_rpc('close_account_holder', { + 'accountHolderCode': adyen_account_id.account_holder_code, + }) + return super(AdyenAccount, self).unlink() + + @api.model + def action_create_redirect(self): + ''' + Accessing the FormView to create an Adyen account needs to be done through this action. + The action will redirect the user to accounts.odoo.com to link an Odoo user_id to the Adyen + account. After logging in on odoo.com the user will be redirected to his DB with a token in + the URL. This token is then needed to create the Adyen account. + ''' + if self.env.company.adyen_account_id: + # An account already exists, show it + return { + 'name': _('Adyen Account'), + 'view_mode': 'form', + 'res_model': 'adyen.account', + 'res_id': self.env.company.adyen_account_id.id, + 'type': 'ir.actions.act_window', + } + return_url = url_join(self.env['ir.config_parameter'].sudo().get_param('web.base.url'), 'adyen_platforms/create_account') + onboarding_url = self.env['ir.config_parameter'].sudo().get_param('adyen_platforms.onboarding_url') + return { + 'type': 'ir.actions.act_url', + 'url': url_join(onboarding_url, 'get_creation_token?return_url=%s' % return_url), + } + + def action_show_transactions(self): + return { + 'name': _('Transactions'), + 'view_mode': 'tree,form', + 'domain': [('adyen_account_id', '=', self.id)], + 'res_model': 'adyen.transaction', + 'type': 'ir.actions.act_window', + 'context': {'group_by': ['adyen_payout_id']} + } + + def _upload_photo_id(self, document_type, content, filename): + self._adyen_rpc('upload_document', { + 'documentDetail': { + 'accountHolderCode': self.account_holder_code, + 'documentType': document_type, + 'filename': filename, + }, + 'documentContent': content, + }) + + def _format_data(self): + data = { + 'accountHolderCode': self.account_holder_code, + 'accountHolderDetails': { + 'address': { + 'country': self.country_id.code, + 'stateOrProvince': self.state_id.code or None, + 'city': self.city, + 'postalCode': self.zip, + 'street': self.street, + 'houseNumberOrName': self.house_number_or_name, + }, + 'email': self.email, + 'fullPhoneNumber': self.phone_number, + }, + 'legalEntity': 'Business' if self.is_business else 'Individual', + } + + if self.is_business: + data['accountHolderDetails']['businessDetails'] = { + 'legalBusinessName': self.legal_business_name, + 'doingBusinessAs': self.doing_business_as, + 'registrationNumber': self.registration_number, + } + else: + data['accountHolderDetails']['individualDetails'] = { + 'name': { + 'firstName': self.first_name, + 'lastName': self.last_name, + 'gender': 'UNKNOWN', + }, + 'personalData': { + 'dateOfBirth': str(self.date_of_birth), + } + } + + # documentData cannot be present in the data if not set + if self.document_number: + data['accountHolderDetails']['individualDetails']['personalData']['documentData'] = [{ + 'number': self.document_number, + 'type': self.document_type, + }] + + return data + + def _adyen_rpc(self, operation, adyen_data={}): + if operation == 'create_account_holder': + url = self.env['ir.config_parameter'].sudo().get_param('adyen_platforms.onboarding_url') + params = { + 'creation_token': request.session.get('adyen_creation_token'), + 'adyen_data': adyen_data, + } + auth = None + else: + url = self.env['ir.config_parameter'].sudo().get_param('adyen_platforms.proxy_url') + params = { + 'adyen_uuid': self.adyen_uuid, + 'adyen_data': adyen_data, + } + auth = AdyenProxyAuth(self) + + payload = { + 'jsonrpc': '2.0', + 'params': params, + } + try: + req = requests.post(url_join(url, operation), json=payload, auth=auth, timeout=TIMEOUT) + req.raise_for_status() + except requests.exceptions.Timeout: + raise UserError(_('A timeout occured whil trying to reach the Adyen proxy.')) + except Exception as e: + raise UserError(_('The Adyen proxy is not reachable, please try again later.')) + response = req.json() + + if 'error' in response: + name = response['error']['data'].get('name').rpartition('.')[-1] + if name == 'ValidationError': + raise ValidationError(response['error']['data'].get('arguments')[0]) + else: + raise UserError(_("We had troubles reaching Adyen, please retry later or contact the support if the problem persists")) + + return response.get('result') + + @api.model + def _sync_adyen_cron(self): + self._sync_adyen_kyc_status() + self.env['adyen.transaction'].sync_adyen_transactions() + self.env['adyen.payout']._process_payouts() + + @api.model + def _sync_adyen_kyc_status(self): + for adyen_account_id in self.search([]): + data = adyen_account_id._adyen_rpc('get_account_holder', { + 'accountHolderCode': adyen_account_id.account_holder_code, + }) + adyen_account_id._update_kyc_status(data['verification']) + + def _update_kyc_status(self, checks): + all_checks_status = [] + + # Account Holder Checks + account_holder_checks = checks.get('accountHolder', {}) + account_holder_messages = [] + for check in account_holder_checks.get('checks'): + all_checks_status.append(check['status']) + kyc_status_message = self._get_kyc_message(check) + if kyc_status_message: + account_holder_messages.append(kyc_status_message) + + # Shareholders Checks + shareholder_checks = checks.get('shareholders', {}) + shareholder_messages = [] + kyc_status_message = False + for sc in shareholder_checks: + shareholder_status = [] + shareholder_id = self.shareholder_ids.filtered(lambda shareholder: shareholder.shareholder_uuid == sc['shareholderCode']) + for check in sc.get('checks'): + all_checks_status.append(check['status']) + shareholder_status.append(check['status']) + kyc_status_message = self._get_kyc_message(check) + if kyc_status_message: + shareholder_messages.append('[%s] %s' % (shareholder_id.display_name, kyc_status_message)) + shareholder_id.with_context(update_from_adyen=True).write({ + 'kyc_status': self.get_status(shareholder_status), + 'kyc_status_message': kyc_status_message, + }) + + # Bank Account Checks + bank_account_checks = checks.get('bankAccounts', {}) + bank_account_messages = [] + kyc_status_message = False + for bac in bank_account_checks: + bank_account_status = [] + bank_account_id = self.bank_account_ids.filtered(lambda bank_account: bank_account.bank_account_uuid == bac['bankAccountUUID']) + for check in bac.get('checks'): + all_checks_status.append(check['status']) + bank_account_status.append(check['status']) + kyc_status_message = self._get_kyc_message(check) + if kyc_status_message: + bank_account_messages.append('[%s] %s' % (bank_account_id.display_name, kyc_status_message)) + bank_account_id.with_context(update_from_adyen=True).write({ + 'kyc_status': self.get_status(bank_account_status), + 'kyc_status_message': kyc_status_message, + }) + + kyc_status = self.get_status(all_checks_status) + kyc_status_message = self.env['ir.qweb']._render('adyen_platforms.kyc_status_message', { + 'kyc_status': dict(self._fields['kyc_status'].selection)[kyc_status], + 'account_holder_messages': account_holder_messages, + 'shareholder_messages': shareholder_messages, + 'bank_account_messages': bank_account_messages, + }) + + if kyc_status_message.decode() != self.kyc_status_message: + self.sudo().message_post(body = kyc_status_message, subtype_xmlid="mail.mt_comment") # Message from Odoo Bot + + self.with_context(update_from_adyen=True).write({ + 'kyc_status': kyc_status, + 'kyc_status_message': kyc_status_message, + }) + + @api.model + def get_status(self, statuses): + if any(status in ['FAILED'] for status in statuses): + return 'failed' + if any(status in ['INVALID_DATA', 'RETRY_LIMIT_REACHED', 'AWAITING_DATA'] for status in statuses): + return 'awaiting_data' + if any(status in ['DATA_PROVIDED', 'PENDING'] for status in statuses): + return 'pending' + return 'passed' + + @api.model + def _get_kyc_message(self, check): + if check.get('summary', {}).get('kycCheckDescription'): + return check['summary']['kycCheckDescription'] + if check.get('requiredFields', {}): + return _('Missing required fields: ') + ', '.join(check.get('requiredFields')) + return '' + + +class AdyenShareholder(models.Model): + _name = 'adyen.shareholder' + _inherit = ['adyen.id.mixin', 'adyen.address.mixin'] + _description = 'Adyen for Plaforms Shareholder' + _rec_name = 'full_name' + + adyen_account_id = fields.Many2one('adyen.account', ondelete='cascade') + shareholder_reference = fields.Char('Reference', default=lambda self: uuid.uuid4().hex) + shareholder_uuid = fields.Char('UUID') # Given by Adyen + first_name = fields.Char('First Name', required=True) + last_name = fields.Char('Last Name', required=True) + full_name = fields.Char(compute='_compute_full_name') + date_of_birth = fields.Date('Date of birth', required=True) + document_number = fields.Char('ID Number', + help="The type of ID Number required depends on the country:\n" + "US: Social Security Number (9 digits or last 4 digits)\n" + "Canada: Social Insurance Number\nItaly: Codice fiscale\n" + "Australia: Document Number") + + # KYC + kyc_status = fields.Selection(string='KYC Status', selection=[ + ('awaiting_data', 'Data to provide'), + ('pending', 'Waiting for validation'), + ('passed', 'Confirmed'), + ('failed', 'Failed'), + ], required=True, default='pending') + kyc_status_message = fields.Char('KYC Status Message', readonly=True) + + @api.depends('first_name', 'last_name') + def _compute_full_name(self): + for adyen_shareholder_id in self: + adyen_shareholder_id.full_name = '%s %s' % (adyen_shareholder_id.first_name, adyen_shareholder_id.last_name) + + @api.model + def create(self, values): + adyen_shareholder_id = super(AdyenShareholder, self).create(values) + response = adyen_shareholder_id.adyen_account_id._adyen_rpc('update_account_holder', adyen_shareholder_id._format_data()) + shareholders = response['accountHolderDetails']['businessDetails']['shareholders'] + created_shareholder = next(shareholder for shareholder in shareholders if shareholder['shareholderReference'] == adyen_shareholder_id.shareholder_reference) + adyen_shareholder_id.with_context(update_from_adyen=True).write({ + 'shareholder_uuid': created_shareholder['shareholderCode'], + }) + return adyen_shareholder_id + + def write(self, vals): + res = super(AdyenShareholder, self).write(vals) + if not self.env.context.get('update_from_adyen'): + self.adyen_account_id._adyen_rpc('update_account_holder', self._format_data()) + return res + + def unlink(self): + for shareholder_id in self: + shareholder_id.adyen_account_id._adyen_rpc('delete_shareholders', { + 'accountHolderCode': shareholder_id.adyen_account_id.account_holder_code, + 'shareholderCodes': [shareholder_id.shareholder_uuid], + }) + return super(AdyenShareholder, self).unlink() + + def _upload_photo_id(self, document_type, content, filename): + self.adyen_account_id._adyen_rpc('upload_document', { + 'documentDetail': { + 'accountHolderCode': self.adyen_account_id.account_holder_code, + 'shareholderCode': self.shareholder_uuid, + 'documentType': document_type, + 'filename': filename, + }, + 'documentContent': content, + }) + + def _format_data(self): + data = { + 'accountHolderCode': self.adyen_account_id.account_holder_code, + 'accountHolderDetails': { + 'businessDetails': { + 'shareholders': [{ + 'shareholderCode': self.shareholder_uuid or None, + 'shareholderReference': self.shareholder_reference, + 'address': { + 'city': self.city, + 'country': self.country_code, + 'houseNumberOrName': self.house_number_or_name, + 'postalCode': self.zip, + 'stateOrProvince': self.state_id.code or None, + 'street': self.street, + }, + 'name': { + 'firstName': self.first_name, + 'lastName': self.last_name, + 'gender': 'UNKNOWN' + }, + 'personalData': { + 'dateOfBirth': str(self.date_of_birth), + } + }] + } + } + } + + # documentData cannot be present in the data if not set + if self.document_number: + data['accountHolderDetails']['businessDetails']['shareholders'][0]['personalData']['documentData'] = [{ + 'number': self.document_number, + 'type': 'ID', + }] + + return data + +class AdyenBankAccount(models.Model): + _name = 'adyen.bank.account' + _description = 'Adyen for Plaforms Bank Account' + + adyen_account_id = fields.Many2one('adyen.account', ondelete='cascade') + bank_account_reference = fields.Char('Reference', default=lambda self: uuid.uuid4().hex) + bank_account_uuid = fields.Char('UUID') # Given by Adyen + owner_name = fields.Char('Owner Name', required=True) + country_id = fields.Many2one('res.country', string='Country', domain=[('code', 'in', ADYEN_AVAILABLE_COUNTRIES)], required=True) + country_code = fields.Char(related='country_id.code') + currency_id = fields.Many2one('res.currency', string='Currency', required=True) + iban = fields.Char('IBAN') + account_number = fields.Char('Account Number') + branch_code = fields.Char('Branch Code') + bank_code = fields.Char('Bank Code') + account_type = fields.Selection(string='Account Type', selection=[ + ('checking', 'Checking'), + ('savings', 'Savings'), + ]) + owner_country_id = fields.Many2one('res.country', string='Owner Country') + owner_state_id = fields.Many2one('res.country.state', 'Owner State', domain="[('country_id', '=?', owner_country_id)]") + owner_street = fields.Char('Owner Street') + owner_city = fields.Char('Owner City') + owner_zip = fields.Char('Owner ZIP') + owner_house_number_or_name = fields.Char('Owner House Number or Name') + + bank_statement = fields.Binary('Bank Statement', help="You need to provide a bank statement to allow payouts. \ + The file must be a bank statement, a screenshot of your online banking environment, a letter from the bank or a cheque and must contain \ + the logo of the bank or it's name in a unique font, the bank account details, the name of the account holder.\ + Allowed formats: jpg, pdf, png. Maximum allowed size: 10MB.") + bank_statement_filename = fields.Char() + + # KYC + kyc_status = fields.Selection(string='KYC Status', selection=[ + ('awaiting_data', 'Data to provide'), + ('pending', 'Waiting for validation'), + ('passed', 'Confirmed'), + ('failed', 'Failed'), + ], required=True, default='pending') + kyc_status_message = fields.Char('KYC Status Message', readonly=True) + + @api.model + def create(self, values): + adyen_bank_account_id = super(AdyenBankAccount, self).create(values) + response = adyen_bank_account_id.adyen_account_id._adyen_rpc('update_account_holder', adyen_bank_account_id._format_data()) + bank_accounts = response['accountHolderDetails']['bankAccountDetails'] + created_bank_account = next(bank_account for bank_account in bank_accounts if bank_account['bankAccountReference'] == adyen_bank_account_id.bank_account_reference) + adyen_bank_account_id.with_context(update_from_adyen=True).write({ + 'bank_account_uuid': created_bank_account['bankAccountUUID'], + }) + return adyen_bank_account_id + + def write(self, vals): + res = super(AdyenBankAccount, self).write(vals) + if not self.env.context.get('update_from_adyen'): + self.adyen_account_id._adyen_rpc('update_account_holder', self._format_data()) + if 'bank_statement' in vals: + self._upload_bank_statement(vals['bank_statement'], vals['bank_statement_filename']) + return res + + def unlink(self): + for bank_account_id in self: + bank_account_id.adyen_account_id._adyen_rpc('delete_bank_accounts', { + 'accountHolderCode': bank_account_id.adyen_account_id.account_holder_code, + 'bankAccountUUIDs': [bank_account_id.bank_account_uuid], + }) + return super(AdyenBankAccount, self).unlink() + + def _format_data(self): + return { + 'accountHolderCode': self.adyen_account_id.account_holder_code, + 'accountHolderDetails': { + 'bankAccountDetails': [{ + 'accountNumber': self.account_number or None, + 'accountType': self.account_type or None, + 'bankAccountReference': self.bank_account_reference, + 'bankAccountUUID': self.bank_account_uuid or None, + 'bankCode': self.bank_code or None, + 'branchCode': self.branch_code or None, + 'countryCode': self.country_code, + 'currencyCode': self.currency_id.name, + 'iban': self.iban or None, + 'ownerCity': self.owner_city or None, + 'ownerCountryCode': self.owner_country_id.code or None, + 'ownerHouseNumberOrName': self.owner_house_number_or_name or None, + 'ownerName': self.owner_name, + 'ownerPostalCode': self.owner_zip or None, + 'ownerState': self.owner_state_id.code or None, + 'ownerStreet': self.owner_street or None, + }], + } + } + + def _upload_bank_statement(self, content, filename): + file_extension = os.path.splitext(filename)[1] + file_size = len(content.encode('utf-8')) + if file_extension not in ['.jpeg', '.jpg', '.pdf', '.png']: + raise ValidationError(_('Allowed file formats for bank statements are jpeg, jpg, pdf or png')) + if file_size >> 20 > 10 or (file_size >> 10 < 10 and file_extension != '.pdf') : + raise ValidationError(_('Bank statements must be greater than 10kB (except for PDFs) and smaller than 10MB')) + + self.adyen_account_id._adyen_rpc('upload_document', { + 'documentDetail': { + 'accountHolderCode': self.adyen_account_id.account_holder_code, + 'bankAccountUUID': self.bank_account_uuid, + 'documentType': 'BANK_STATEMENT', + 'filename': filename, + }, + 'documentContent': content, + }) + + +class AdyenPayout(models.Model): + _name = 'adyen.payout' + _description = 'Adyen for Plaforms Payout' + + @api.depends('payout_schedule') + def _compute_next_scheduled_payout(self): + today = fields.date.today() + for adyen_payout_id in self: + adyen_payout_id.next_scheduled_payout = date_utils.end_of(today, adyen_payout_id.payout_schedule) + + adyen_account_id = fields.Many2one('adyen.account', ondelete='cascade') + adyen_bank_account_id = fields.Many2one('adyen.bank.account', string='Bank Account', + help='The bank account to which the payout is to be made. If left blank, a bank account is automatically selected') + name = fields.Char('Name', default='Default', required=True) + code = fields.Char('Account Code') + payout_schedule = fields.Selection(string='Schedule', selection=[ + ('day', 'Daily'), + ('week', 'Weekly'), + ('month', 'Monthly'), + ], default='week', required=True) + next_scheduled_payout = fields.Date('Next scheduled payout', compute=_compute_next_scheduled_payout, store=True) + transaction_ids = fields.One2many('adyen.transaction', 'adyen_payout_id', string='Transactions') + + @api.model + def create(self, values): + adyen_payout_id = super(AdyenPayout, self).create(values) + if not adyen_payout_id.env.context.get('update_from_adyen'): + response = adyen_payout_id.adyen_account_id._adyen_rpc('create_payout', { + 'accountHolderCode': adyen_payout_id.adyen_account_id.account_holder_code, + }) + adyen_payout_id.with_context(update_from_adyen=True).write({ + 'code': response['accountCode'], + }) + return adyen_payout_id + + def unlink(self): + for adyen_payout_id in self: + adyen_payout_id.adyen_account_id._adyen_rpc('close_payout', { + 'accountCode': adyen_payout_id.code, + }) + return super(AdyenPayout, self).unlink() + + @api.model + def _process_payouts(self): + for adyen_payout_id in self.search([('next_scheduled_payout', '<', fields.Date.today())]): + adyen_payout_id.send_payout_request(notify=False) + adyen_payout_id._compute_next_scheduled_payout() + + def send_payout_request(self, notify=True): + response = self.adyen_account_id._adyen_rpc('account_holder_balance', { + 'accountHolderCode': self.adyen_account_id.account_holder_code, + }) + balances = next(account_balance['detailBalance']['balance'] for account_balance in response['balancePerAccount'] if account_balance['accountCode'] == self.code) + if notify and not balances: + self.env['bus.bus'].sendone( + (self._cr.dbname, 'res.partner', self.env.user.partner_id.id), + {'type': 'simple_notification', 'title': _('No pending balance'), 'message': _('No balance is currently awaitng payout.')} + ) + for balance in balances: + response = self.adyen_account_id._adyen_rpc('payout_request', { + 'accountCode': self.code, + 'accountHolderCode': self.adyen_account_id.account_holder_code, + 'bankAccountUUID': self.adyen_bank_account_id.bank_account_uuid or None, + 'amount': balance, + }) + if notify and response['resultCode'] == 'Received': + currency_id = self.env['res.currency'].search([('name', '=', balance['currency'])]) + value = round(balance['value'] / (10 ** currency_id.decimal_places), 2) # Convert from minor units + amount = str(value) + currency_id.symbol if currency_id.position == 'after' else currency_id.symbol + str(value) + message = _('Successfully sent payout request for %s', amount) + self.env['bus.bus'].sendone( + (self._cr.dbname, 'res.partner', self.env.user.partner_id.id), + {'type': 'simple_notification', 'title': _('Payout Request sent'), 'message': message} + ) + + def _fetch_transactions(self, page=1): + response = self.adyen_account_id._adyen_rpc('get_transactions', { + 'accountHolderCode': self.adyen_account_id.account_holder_code, + 'transactionListsPerAccount': [{ + 'accountCode': self.code, + 'page': page, + }] + }) + transaction_list = response['accountTransactionLists'][0] + return transaction_list['transactions'], transaction_list['hasNextPage'] diff --git a/addons/adyen_platforms/models/adyen_transaction.py b/addons/adyen_platforms/models/adyen_transaction.py new file mode 100644 index 00000000000..a04d8e33cd2 --- /dev/null +++ b/addons/adyen_platforms/models/adyen_transaction.py @@ -0,0 +1,83 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from datetime import datetime +from pytz import UTC + +from odoo import api, fields, models +from odoo.tools import DEFAULT_SERVER_DATETIME_FORMAT + + +class AdyenTransaction(models.Model): + _name = 'adyen.transaction' + _description = 'Adyen for Plaforms Transaction' + _order = 'date desc' + + adyen_account_id = fields.Many2one('adyen.account') + reference = fields.Char('Reference') + amount = fields.Float('Amount') + currency_id = fields.Many2one('res.currency', string='Currency') + date = fields.Datetime('Date') + description = fields.Char('Description') + status = fields.Selection(string='Type', selection=[ + ('PendingCredit', 'Pending Credit'), + ('CreditFailed', 'Credit Failed'), + ('Credited', 'Credited'), + ('Converted', 'Converted'), + ('PendingDebit', 'Pending Debit'), + ('DebitFailed', 'Debit Failed'), + ('Debited', 'Debited'), + ('DebitReversedReceived', 'Debit Reversed Received'), + ('DebitedReversed', 'Debit Reversed'), + ('ChargebackReceived', 'Chargeback Received'), + ('Chargeback', 'Chargeback'), + ('ChargebackReversedReceived', 'Chargeback Reversed Received'), + ('ChargebackReversed', 'Chargeback Reversed'), + ('Payout', 'Payout'), + ('PayoutReversed', 'Payout Reversed'), + ('FundTransfer', 'Fund Transfer'), + ('PendingFundTransfer', 'Pending Fund Transfer'), + ('ManualCorrected', 'Manual Corrected'), + ]) + adyen_payout_id = fields.Many2one('adyen.payout') + + @api.model + def sync_adyen_transactions(self): + ''' Method called by cron to sync transactions from Adyen. + Updates the status of pending transactions and create missing ones. + ''' + for payout_id in self.env['adyen.payout'].search([]): + page = 1 + has_next_page = True + new_transactions = True + pending_statuses = ['PendingCredit', 'PendingDebit', 'DebitReversedReceived', 'ChargebackReceived', 'ChargebackReversedReceived', 'PendingFundTransfer'] + pending_transaction_ids = payout_id.transaction_ids.filtered(lambda tr: tr.status in pending_statuses) + + while has_next_page and (new_transactions or pending_transaction_ids): + # Fetch next transaction page + transactions, has_next_page = payout_id._fetch_transactions(page) + for transaction in transactions: + transaction_reference = transaction.get('paymentPspReference') or transaction.get('pspReference') + transaction_id = payout_id.transaction_ids.filtered(lambda tr: tr.reference == transaction_reference) + if transaction_id: + new_transactions = False + if transaction_id in pending_transaction_ids: + # Update transaction status + transaction_id.sudo().write({ + 'status': transaction['transactionStatus'], + }) + pending_transaction_ids -= transaction_id + else: + currency_id = self.env['res.currency'].search([('name', '=', transaction['amount']['currency'])]) + # New transaction + self.env['adyen.transaction'].sudo().create({ + 'adyen_account_id': payout_id.adyen_account_id.id, + 'reference': transaction_reference, + 'amount': transaction['amount']['value'] / (10 ** currency_id.decimal_places), + 'currency_id': currency_id.id, + 'date': datetime.strptime(transaction['creationDate'], '%Y-%m-%dT%H:%M:%S%z').astimezone(UTC).strftime(DEFAULT_SERVER_DATETIME_FORMAT), + 'description': transaction.get('description'), + 'status': transaction['transactionStatus'], + 'adyen_payout_id': payout_id.id, + }) + page += 1 diff --git a/addons/adyen_platforms/models/res_company.py b/addons/adyen_platforms/models/res_company.py new file mode 100644 index 00000000000..c4767889f10 --- /dev/null +++ b/addons/adyen_platforms/models/res_company.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 ResCompany(models.Model): + _inherit = "res.company" + + adyen_account_id = fields.Many2one('adyen.account', string='Adyen Account', readonly=True) diff --git a/addons/adyen_platforms/security/ir.model.access.csv b/addons/adyen_platforms/security/ir.model.access.csv new file mode 100644 index 00000000000..62ebdc56fb1 --- /dev/null +++ b/addons/adyen_platforms/security/ir.model.access.csv @@ -0,0 +1,6 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_adyen_account_group_erp_manager,adyen.account,model_adyen_account,base.group_erp_manager,1,1,1,1 +access_adyen_shareholder_group_erp_manager,adyen.shareholder,model_adyen_shareholder,base.group_erp_manager,1,1,1,1 +access_adyen_bank_account_group_erp_manager,adyen.bank.account,model_adyen_bank_account,base.group_erp_manager,1,1,1,1 +access_adyen_payout_group_erp_manager,adyen.payout,model_adyen_payout,base.group_erp_manager,1,1,1,1 +access_adyen_transaction_group_erp_manager,adyen.transaction,model_adyen_transaction,base.group_erp_manager,1,0,0,0 diff --git a/addons/adyen_platforms/static/src/js/adyen_account_fields.js b/addons/adyen_platforms/static/src/js/adyen_account_fields.js new file mode 100644 index 00000000000..804f27eab4b --- /dev/null +++ b/addons/adyen_platforms/static/src/js/adyen_account_fields.js @@ -0,0 +1,20 @@ +odoo.define('adyen_platforms.fields', function (require) { +"use strict"; + +var core = require('web.core'); +var FieldSelection = require('web.relational_fields').FieldSelection; +var field_registry = require('web.field_registry'); + +var qweb = core.qweb; + +var AdyenKYCStatusTag = FieldSelection.extend({ + _render: function () { + this.$el.append(qweb.render('AdyenKYCStatusTag', { + value: this.value, + })); + }, +}); + +field_registry.add("adyen_kyc_status_tag", AdyenKYCStatusTag); + +}); diff --git a/addons/adyen_platforms/static/src/js/adyen_account_views.js b/addons/adyen_platforms/static/src/js/adyen_account_views.js new file mode 100644 index 00000000000..8921a993590 --- /dev/null +++ b/addons/adyen_platforms/static/src/js/adyen_account_views.js @@ -0,0 +1,63 @@ +odoo.define('adyen_platforms.account_views', function (require) { +"use strict"; + +var core = require('web.core'); +var Dialog = require('web.Dialog'); +var FormController = require('web.FormController'); +var FormView = require('web.FormView'); +var viewRegistry = require('web.view_registry'); + +var _t = core._t; +var QWeb = core.qweb; + +var AdyenAccountFormController = FormController.extend({ + _saveRecord: function (recordID, options) { + if(this.model.isNew(this.handle) && this.canBeSaved()) { + var _super = this._super.bind(this, recordID, options); + var buttons = [ + { + text: _t("Create"), + classes: 'btn-primary o_adyen_confirm', + close: true, + disabled: true, + click: function () { + this.close(); + _super(); + }, + }, + { + text: _t("Cancel"), + close: true, + } + ]; + + var dialog = new Dialog(this, { + size: 'extra-large', + buttons: buttons, + title: _t("Confirm your Adyen Account Creation"), + $content: QWeb.render('AdyenAccountCreationConfirmation', { + data: this.model.get(this.handle).data, + }), + }); + + dialog.open().opened(function () { + dialog.$el.on('change', '.opt_in_checkbox', function (ev) { + ev.preventDefault(); + dialog.$footer.find('.o_adyen_confirm')[0].disabled = !ev.currentTarget.checked; + }); + }); + } else if (!this.model.isNew(this.handle)) { + return this._super.apply(this, arguments); + } + }, +}); + +var AdyenAccountFormView = FormView.extend({ + config: _.extend({}, FormView.prototype.config, { + Controller: AdyenAccountFormController, + }), +}); + +viewRegistry.add('adyen_account_form', AdyenAccountFormView); + +}); diff --git a/addons/adyen_platforms/static/src/js/adyen_transactions.js b/addons/adyen_platforms/static/src/js/adyen_transactions.js new file mode 100644 index 00000000000..3a6fe89631d --- /dev/null +++ b/addons/adyen_platforms/static/src/js/adyen_transactions.js @@ -0,0 +1,33 @@ +odoo.define('adyen_platforms.transactions', function (require) { +"use strict"; + +var ListController = require('web.ListController'); +var ListView = require('web.ListView'); +var viewRegistry = require('web.view_registry'); + +var TransactionsListController = ListController.extend({ + buttons_template: 'AdyenTransactionsListView.buttons', + events: _.extend({}, ListController.prototype.events, { + 'click .o_button_sync_transactions': '_onTransactionsSync', + }), + + _onTransactionsSync: function () { + var self = this; + this._rpc({ + model: 'adyen.transaction', + method: 'sync_adyen_transactions', + args: [], + }).then(function () { + self.trigger_up('reload'); + }); + } +}); + +var TransactionsListView = ListView.extend({ + config: _.extend({}, ListView.prototype.config, { + Controller: TransactionsListController, + }), +}); + +viewRegistry.add('adyen_transactions_tree', TransactionsListView); +}); diff --git a/addons/adyen_platforms/static/src/xml/adyen_account_templates.xml b/addons/adyen_platforms/static/src/xml/adyen_account_templates.xml new file mode 100644 index 00000000000..4bec48cdaa3 --- /dev/null +++ b/addons/adyen_platforms/static/src/xml/adyen_account_templates.xml @@ -0,0 +1,125 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates> + <t t-name="AdyenAccountCreationConfirmation"> + <main> + <div class="alert alert-info"> + You can start processing payments as soon as your application is sent.<br/> + Payouts will be blocked until your application has been accepted.<br/> + We will notify you when the status of the review changes or if additionnal data is required.<br/> + </div> + + <h3>Submitted data</h3> + <table class="table table-sm"> + <t t-if="data.is_business"> + <tr> + <td>Legal Entity</td> + <td>Business</td> + </tr> + <tr> + <td>Legal Business Name</td> + <td t-esc="data.legal_business_name"/> + </tr> + <tr> + <td>Doing Business As</td> + <td t-esc="data.doing_business_as"/> + </tr> + <tr> + <td>Registration Number</td> + <td t-esc="data.registration_number"/> + </tr> + </t> + <t t-else=""> + <tr> + <td>Legal Entity</td> + <td>Individual</td> + </tr> + <tr> + <td>First Name</td> + <td t-esc="data.first_name"/> + </tr> + <tr> + <td>Last Name</td> + <td t-esc="data.last_name"/> + </tr> + <tr> + <td>Date of Birth</td> + <td t-esc="data.date_of_birth.format('YYYY-MM-DD')"/> + </tr> + </t> + <tr> + <td>Email</td> + <td t-esc="data.email"/> + </tr> + <tr> + <td>Phone Number</td> + <td t-esc="data.phone_number"/> + </tr> + <tr> + <td>Address</td> + <td> + <t t-esc="data.street"/> <t t-esc="data.house_number_or_name"/>, + <t t-esc="data.city"/> <t t-if="data.state_code" t-esc="data.state_code"/> <t t-esc="data.zip"/>, + <t t-esc="data.country_code"/> + </td> + </tr> + </table> + + <h3>Disclaimers</h3> + <p>WARNING: Please note that the right to use the payment services is only for + sales in your own name. You may not resell, hire or on any other basis allow third + parties to use the payment services to enable such third parties to be paid for their + services. You may not use the payment services for different types of product and + services than as registered with your application. In particular you confirm that you + will not use the payment services for any type of product or service appearing in the + <a href="https://www.odoo.com/odoo_adyen/static/src/pdf/marketpay_prohibited_products_and_services.pdf"> + Prohibited Products and Services List of our Processor</a>. If we or our + Processor at any time discover that the information you provided about your + business is incorrect or has changed without informing us or if you violate any of + these conditions, the services may be suspended and/or terminated with + immediate effect and fines may be applied by the Credit Card Schemes and/or the + authorities for unregistered or inappropriate use of payment services which will in + such case be payable by you.</p> + + <h3>Contractual Relationship</h3> + <p>The payment processing services ordered by you by placing this order will be + provided to you by Adyen N.V. (hereafter “Processor”), with which you are + entering into a direct agreement by confirming this order. Odoo S.A. + (hereafter “We /Us”) will assist and support you in your use of the services to be + provided by the Processor and we will provide you first line assistance with and + enable you to connect to the systems of Processor to be able to use its services. + For this purpose, you hereby instruct Processor to provide us access to your data + and setting in Processor’s systems which are used by Processor to provide the + services and authorise us to manage these on your behalf.</p> + + <h3>Pricing</h3> + <ul> + <li>Payment and processing fees are listed on <a href="http://www.adyen.com/pricing">adyen.com/pricing</a>.</li> + <li>Payouts cost 0.20€ (EU) or 0.22$ (US) each.</li> + <li>Chargebacks cost 7.5€ each.</li> + <li>Onboarding and KYC cost 5€.</li> + </ul> + + <input type="checkbox" class="opt_in_checkbox"/> + I confirm I have taken notice of and accept the following terms and restrictions: + <ul> + <li>Adyen MarketPay Terms and Conditions (click <a href="https://www.odoo.com/odoo_adyen/static/src/pdf/marketpay_terms_and_conditions.pdf">here</a> to download and review)</li> + <li>Adyen Restricted and Prohibited Products and Services list (click <a href="https://www.odoo.com/odoo_adyen/static/src/pdf/marketpay_prohibited_products_and_services.pdf">here</a> to download and review)</li> + </ul> + </main> + </t> + + <t t-name="AdyenKYCStatusTag"> + <t t-if="value === 'awaiting_data'"> + <span class="badge badge-pill badge-warning">Data to provide</span> + </t> + <t t-if="value === 'pending'"> + <span class="badge badge-pill badge-info">Waiting for validation</span> + </t> + <t t-if="value === 'passed'"> + <span class="badge badge-pill badge-success">Confirmed</span> + </t> + <t t-if="value === 'failed'"> + <span class="badge badge-pill badge-danger">Failed</span> + </t> + </t> +</templates> diff --git a/addons/adyen_platforms/static/src/xml/adyen_transactions_templates.xml b/addons/adyen_platforms/static/src/xml/adyen_transactions_templates.xml new file mode 100644 index 00000000000..50552e80c6f --- /dev/null +++ b/addons/adyen_platforms/static/src/xml/adyen_transactions_templates.xml @@ -0,0 +1,10 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates> + <t t-extend="ListView.buttons" t-name="AdyenTransactionsListView.buttons"> + <t t-jquery="button.o_list_button_discard" t-operation="after"> + <button type="button" class="btn btn-primary o_button_sync_transactions"> + Sync Transactions + </button> + </t> + </t> +</templates> diff --git a/addons/adyen_platforms/util.py b/addons/adyen_platforms/util.py new file mode 100644 index 00000000000..5352a25192b --- /dev/null +++ b/addons/adyen_platforms/util.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import base64 +import hashlib +import hmac +import json +import requests +import time +import werkzeug.urls + +class AdyenProxyAuth(requests.auth.AuthBase): + def __init__(self, adyen_account_id): + super(AdyenProxyAuth, self).__init__() + self.adyen_account_id = adyen_account_id + + def __call__(self, request): + h = hmac.new(self.adyen_account_id.proxy_token.encode('utf-8'), digestmod=hashlib.sha256) + + # Craft the message (timestamp|url path|query params|body content) + msg_timestamp = int(time.time()) + parsed_url = werkzeug.urls.url_parse(request.path_url) + body = request.body + if isinstance(body, bytes): + body = body.decode('utf-8') + body = json.loads(body) + + message = '%s|%s|%s|%s' % ( + msg_timestamp, # timestamp + parsed_url.path, # url path + json.dumps(werkzeug.urls.url_decode(parsed_url.query), sort_keys=True), # url query params sorted by key + json.dumps(body, sort_keys=True)) # request body + + h.update(message.encode('utf-8')) # digest the message + + request.headers.update({ + 'oe-adyen-uuid': self.adyen_account_id.adyen_uuid, + 'oe-signature': base64.b64encode(h.digest()), + 'oe-timestamp': msg_timestamp, + }) + + return request diff --git a/addons/adyen_platforms/views/adyen_account_templates.xml b/addons/adyen_platforms/views/adyen_account_templates.xml new file mode 100644 index 00000000000..a16df255051 --- /dev/null +++ b/addons/adyen_platforms/views/adyen_account_templates.xml @@ -0,0 +1,30 @@ +<?xml version="1.0" encoding="utf-8"?> +<odoo> + <template id="kyc_status_message"> + <p>New KYC Status: <t- t-esc="kyc_status"/></p> + <p t-if="account_holder_messages + shareholder_messages + bank_account_messages"> + Reason(s): + <ul> + <li t-if="account_holder_messages" t-foreach="account_holder_messages" t-as="message"> + <t t-esc="message"/> + </li> + <li t-if="shareholder_messages"> + Shareholders: + <ul> + <li t-foreach="shareholder_messages" t-as="message"> + <t t-esc="message"/> + </li> + </ul> + </li> + <li t-if="bank_account_messages"> + Bank Accounts: + <ul> + <li t-foreach="bank_account_messages" t-as="message"> + <t t-esc="message"/> + </li> + </ul> + </li> + </ul> + </p> + </template> +</odoo> diff --git a/addons/adyen_platforms/views/adyen_account_views.xml b/addons/adyen_platforms/views/adyen_account_views.xml new file mode 100644 index 00000000000..0eb10fb6752 --- /dev/null +++ b/addons/adyen_platforms/views/adyen_account_views.xml @@ -0,0 +1,243 @@ +<?xml version="1.0" encoding="utf-8"?> +<odoo> + <record id="adyen_account_view_form" model="ir.ui.view"> + <field name="name">adyen.account.view.form</field> + <field name="model">adyen.account</field> + <field name="arch" type="xml"> + <form string="Adyen Account" create="false" js_class="adyen_account_form"> + <header> + <field name="kyc_status" widget="statusbar" statusbar_visible="awaiting_data,pending,passed"/> + </header> + <sheet> + <div class="oe_button_box" name="button_box"> + <button name="action_show_transactions" type="object" + class="oe_stat_button" icon="fa-credit-card"> + <div class="o_stat_info"> + <field name="transactions_count" class="o_stat_value"/> + <span class="o_stat_text"> Transactions</span> + </div> + </button> + </div> + + <group> + <field name="adyen_uuid" invisible="1"/> + + <group> + <field name="is_business" attrs="{'readonly': [('id', '!=', False)]}"/> + </group> + + <group> + <field name="company_id" readonly="1"/> + </group> + + <group string="Individual" attrs="{'invisible': [('is_business', '=', True)]}"> + <field name="first_name" + attrs="{'required': [('is_business', '=', False)]}"/> + <field name="last_name" + attrs="{'required': [('is_business', '=', False)]}"/> + <field name="date_of_birth" + attrs="{'required': [('is_business', '=', False)]}"/> + <field name="document_number" + attrs="{'invisible': [('country_code', 'not in', ['AU', 'CA', 'GR', 'IT', 'US'])], 'required': [('is_business', '=', False), ('country_id', 'in', ['AU', 'CA', 'GR', 'IT', 'US'])]}"/> + <field name="document_type" + attrs="{'invisible': [('country_code', '!=', 'AU')], 'required': [('is_business', '=', False), ('country_id', '=', 'AU')]}"/> + <field name="id_type" attrs="{'invisible': [('adyen_uuid', '=', False)]}"/> + <field name="id_front" filename="id_front_filename" attrs="{'invisible': [('adyen_uuid', '=', False)]}"/> + <field name="id_front_filename" invisible="1"/> + <field name="id_back" filename="id_back_filename" + attrs="{'invisible': [('id_type', 'not in', ['ID_CARD', 'DRIVING_LICENSE'])]}"/> + <field name="id_back_filename" invisible="1"/> + </group> + + <group string="Business" attrs="{'invisible': [('is_business', '=', False)]}"> + <field name="legal_business_name" + attrs="{'required': [('is_business', '=', True)]}"/> + <field name="doing_business_as" + attrs="{'required': [('is_business', '=', True)]}"/> + <field name="registration_number" + attrs="{'required': [('is_business', '=', True)]}"/> + </group> + + <group string="Contact"> + <field name="email" widget="email"/> + <field name="phone_number"/> + <label for="street" string="Address"/> + <div class="o_address_format"> + <field name="street" placeholder="Street" class="o_address_street"/> + <field name="house_number_or_name" placeholder="House number or name" class="o_address_street"/> + <field name="city" placeholder="City" class="o_address_city"/> + <field name="state_id" class="o_address_state" placeholder="State" options="{'no_open': True, 'no_quick_create': True}" + attrs="{'required': [('country_code', 'in', ['AU', 'CA', 'IT', 'US'])]}"/> + <field name="state_code" invisible="1"/> + <field name="zip" placeholder="ZIP" class="o_address_zip"/> + <field name="country_id" placeholder="Country" class="o_address_country" options="{'no_open': True, 'no_create': True}"/> + <field name="country_code" invisible="1"/> + </div> + </group> + </group> + <notebook attrs="{'invisible': [('adyen_uuid', '=', False)]}"> + <page string="Shareholders" attrs="{'invisible': [('is_business', '=', False)], 'required': [('is_business', '=', True)]}"> + <field name="shareholder_ids"> + <tree> + <field name="first_name"/> + <field name="last_name"/> + <field name="kyc_status" widget="adyen_kyc_status_tag"/> + </tree> + </field> + </page> + <page string="Bank Accounts"> + <field name="bank_account_ids"> + <tree> + <field name="owner_name"/> + <field name="iban"/> + <field name="account_number"/> + <field name="kyc_status" widget="adyen_kyc_status_tag"/> + </tree> + </field> + </page> + <page string="Payouts"> + <field name="payout_ids"> + <tree> + <field name="name"/> + <field name="payout_schedule"/> + </tree> + </field> + </page> + </notebook> + </sheet> + <div class="oe_chatter"> + <field name="message_follower_ids" widget="mail_followers"/> + <field name="message_ids" widget="mail_thread"/> + </div> + </form> + </field> + </record> + + <record id="adyen_shareholder_view_form" model="ir.ui.view"> + <field name="name">adyen.shareholder.view.form</field> + <field name="model">adyen.shareholder</field> + <field name="arch" type="xml"> + <form string="Adyen Shareholder"> + <header> + <field name="kyc_status" widget="statusbar" statusbar_visible="awaiting_data,pending,passed"/> + </header> + <sheet> + <div class="alert alert-warning" role="alert" + attrs="{'invisible': [('kyc_status_message', '=', False)]}"> + <field name="kyc_status_message"/> + </div> + <group> + <field name="shareholder_uuid" invisible="1"/> + <group> + <field name="first_name"/> + <field name="last_name"/> + <field name="date_of_birth"/> + </group> + <group> + <field name="document_number" + attrs="{'invisible': [('country_code', 'not in', ['IT', 'US'])], 'required': [('country_code', 'in', ['IT', 'US'])]}"/> + <field name="id_type" attrs="{'invisible': [('shareholder_uuid', '=', False)]}"/> + <field name="id_front" filename="id_front_filename" attrs="{'invisible': [('shareholder_uuid', '=', False)]}"/> + <field name="id_front_filename" invisible="1"/> + <field name="id_back" filename="id_back_filename" + attrs="{'required': [('id_type', 'in', ['ID_CARD', 'DRIVING_LICENSE'])], 'invisible': [('id_type', 'not in', ['ID_CARD', 'DRIVING_LICENSE'])]}"/> + <field name="id_back_filename" invisible="1"/> + </group> + <group> + <label for="street" string="Address"/> + <div class="o_address_format"> + <field name="street" placeholder="Street" class="o_address_street"/> + <field name="house_number_or_name" placeholder="House number or name" class="o_address_street"/> + <field name="city" placeholder="City" class="o_address_city"/> + <field name="state_id" class="o_address_state" placeholder="State" options="{'no_open': True, 'no_quick_create': True}" + attrs="{'required': [('country_code', 'in', ['AU', 'CA', 'IT', 'US'])]}"/> + <field name="zip" placeholder="ZIP" class="o_address_zip"/> + <field name="country_id" placeholder="Country" class="o_address_country" options="{'no_open': True, 'no_create': True}"/> + <field name="country_code" invisible="1"/> + </div> + </group> + </group> + </sheet> + </form> + </field> + </record> + + <record id="adyen_bank_account_view_form" model="ir.ui.view"> + <field name="name">adyen.bank.account.view.form</field> + <field name="model">adyen.bank.account</field> + <field name="arch" type="xml"> + <form string="Adyen Bank Account"> + <header> + <field name="kyc_status" widget="statusbar" statusbar_visible="awaiting_data,pending,passed"/> + </header> + <sheet> + <div class="alert alert-warning" role="alert" + attrs="{'invisible': [('kyc_status_message', '=', False)]}"> + <field name="kyc_status_message"/> + </div> + <group> + <field name="bank_account_uuid" invisible="1"/> + <group> + <field name="country_id" options='{"no_open": True, "no_create": True}'/> + <field name="country_code" invisible="1"/> + <field name="currency_id"/> + <field name="iban" + attrs="{'invisible': [('country_code', 'not in', ['AT', 'BE', 'CH', 'CZ', 'DE', 'ES', 'FI', 'FR', 'GB', 'GR', 'HR', 'IE', 'IT', 'LT', 'LU', 'NL', 'PL', 'PT'])], + 'required': [('country_code', 'in', ['AT', 'BE', 'CH', 'CZ', 'DE', 'ES', 'FI', 'FR', 'GB', 'GR', 'HR', 'IE', 'IT', 'LT', 'LU', 'NL', 'PL', 'PT'])]}"/> + <field name="account_type" + attrs="{'invisible': [('country_code', '!=', 'US')], 'required': [('country_code', '=', 'US')]}"/> + <field name="account_number" + attrs="{'invisible': [('country_code', 'not in', ['AU', 'CA', 'US'])], 'required': [('country_code', 'in', ['AU', 'CA', 'US'])]}"/> + <field name="branch_code" + attrs="{'invisible': [('country_code', 'not in', ['AU', 'CA', 'US'])], 'required': [('country_code', 'in', ['AU', 'CA', 'US'])]}"/> + <field name="bank_code" + attrs="{'invisible': [('country_code', '!=', 'CA')], 'required': [('country_code', '=', 'CA')]}"/> + <field name="bank_statement" filename="bank_statement_filename" attrs="{'invisible': [('bank_account_uuid', '=', False)]}"/> + <field name="bank_statement_filename" invisible="1"/> + </group> + <group> + <field name="owner_name"/> + <label for="owner_street" string="Owner Address" attrs="{'invisible': [('country_code', 'not in', ['CA', 'US'])]}"/> + <div class="o_address_format" attrs="{'invisible': [('country_code', 'not in', ['CA', 'US'])]}"> + <field name="owner_street" placeholder="Street" class="o_address_street" attrs="{'required': [('country_code', 'in', ['CA', 'US'])]}"/> + <field name="owner_house_number_or_name" placeholder="House number or name" class="o_address_street" attrs="{'required': [('country_code', 'in', ['CA', 'US'])]}"/> + <field name="owner_city" placeholder="City" class="o_address_city" attrs="{'required': [('country_code', 'in', ['CA', 'US'])]}"/> + <field name="owner_state_id" class="o_address_state" placeholder="State" options="{'no_open': True, 'no_quick_create': True}" + attrs="{'required': [('country_code', 'in', ['CA', 'US'])]}"/> + <field name="owner_zip" placeholder="ZIP" class="o_address_zip" attrs="{'required': [('country_code', 'in', ['CA', 'US'])]}"/> + <field name="owner_country_id" placeholder="Country" class="o_address_country" options='{"no_open": True, "no_create": True}' + attrs="{'required': [('country_code', 'in', ['CA', 'US'])]}"/> + </div> + </group> + </group> + </sheet> + </form> + </field> + </record> + + <record id="adyen_payout_view_form" model="ir.ui.view"> + <field name="name">adyen.payout.view.form</field> + <field name="model">adyen.payout</field> + <field name="arch" type="xml"> + <form string="Adyen Payout"> + <header> + <button name="send_payout_request" string="Request a payout now" class="oe_highlight" type="object" attrs="{'invisible': [('code', '=', False)]}"/> + </header> + <sheet> + <group> + <field name="code" invisible="1"/> + <field name="name"/> + <field name="payout_schedule"/> + <field name="adyen_bank_account_id"/> + </group> + </sheet> + </form> + </field> + </record> + + <record id="adyen_account_action_create" model="ir.actions.act_window"> + <field name="name">Create an Adyen Account</field> + <field name="res_model">adyen.account</field> + <field name="view_mode">form</field> + </record> +</odoo> diff --git a/addons/adyen_platforms/views/adyen_transaction_views.xml b/addons/adyen_platforms/views/adyen_transaction_views.xml new file mode 100644 index 00000000000..667a4d3688e --- /dev/null +++ b/addons/adyen_platforms/views/adyen_transaction_views.xml @@ -0,0 +1,36 @@ +<?xml version="1.0" encoding="utf-8"?> +<odoo> + <record id="adyen_transaction_view_form" model="ir.ui.view"> + <field name="name">adyen.transaction.view.form</field> + <field name="model">adyen.transaction</field> + <field name="arch" type="xml"> + <form> + <sheet> + <group> + <field name="reference"/> + <field name="amount" widget='monetary'/> + <field name="currency_id" invisible="1"/> + <field name="date"/> + <field name="description"/> + <field name="status"/> + </group> + </sheet> + </form> + </field> + </record> + + <record id="adyen_transaction_view_list" model="ir.ui.view"> + <field name="name">adyen.transaction.view.list</field> + <field name="model">adyen.transaction</field> + <field name="arch" type="xml"> + <tree js_class="adyen_transactions_tree"> + <field name="reference"/> + <field name="amount" widget='monetary'/> + <field name="currency_id" invisible="1"/> + <field name="date"/> + <field name="description"/> + <field name="status"/> + </tree> + </field> + </record> +</odoo> diff --git a/addons/adyen_platforms/views/assets.xml b/addons/adyen_platforms/views/assets.xml new file mode 100644 index 00000000000..e1d29146b66 --- /dev/null +++ b/addons/adyen_platforms/views/assets.xml @@ -0,0 +1,10 @@ +<?xml version="1.0" encoding="utf-8"?> +<odoo> + <template id="assets_backend" inherit_id="web.assets_backend" name="Adyen for Platforms Backend Assets"> + <xpath expr="." position="inside"> + <script type="text/javascript" src="/adyen_platforms/static/src/js/adyen_account_fields.js"></script> + <script type="text/javascript" src="/adyen_platforms/static/src/js/adyen_account_views.js"></script> + <script type="text/javascript" src="/adyen_platforms/static/src/js/adyen_transactions.js"></script> + </xpath> + </template> +</odoo> diff --git a/addons/point_of_sale/views/res_config_settings_views.xml b/addons/point_of_sale/views/res_config_settings_views.xml index 2e43ee4a10f..e9e49c0a538 100644 --- a/addons/point_of_sale/views/res_config_settings_views.xml +++ b/addons/point_of_sale/views/res_config_settings_views.xml @@ -91,6 +91,19 @@ <h2>Payment Terminals</h2> <div class="row mt16 o_settings_container" id="o_settings_use_payment_terminals"> <div class="col-12 col-lg-6 o_setting_box" + id="adyen_payment_terminal_setting" + title="The transactions are processed by Adyen. Set your Adyen credentials on the related payment method."> + <div class="o_setting_left_pane"> + <field name="module_pos_adyen"/> + </div> + <div class="o_setting_right_pane"> + <label for="module_pos_adyen" string="Adyen"/> + <div class="text-muted"> + Accept payments with an Adyen payment terminal + </div> + </div> + </div> + <div class="col-12 col-lg-6 o_setting_box" id="vantiv_payment_terminal_setting" title="The transactions are processed by Vantiv. Set your Vantiv credentials on the related payment method."> <div class="o_setting_left_pane"> @@ -108,19 +121,6 @@ </div> </div> </div> - <div class="col-12 col-lg-6 o_setting_box" - id="adyen_payment_terminal_setting" - title="The transactions are processed by Adyen. Set your Adyen credentials on the related payment method."> - <div class="o_setting_left_pane"> - <field name="module_pos_adyen"/> - </div> - <div class="o_setting_right_pane"> - <label for="module_pos_adyen" string="Adyen"/> - <div class="text-muted"> - Accept payments with an Adyen payment terminal - </div> - </div> - </div> <div class="col-12 col-lg-6 o_setting_box" title="The transactions are processed by Six. Set the IP address of the terminal on the related payment method."> <div class="o_setting_left_pane"> <field name="module_pos_six"/> diff --git a/addons/pos_adyen/__manifest__.py b/addons/pos_adyen/__manifest__.py index b291bdaabc2..d34a34d5b47 100644 --- a/addons/pos_adyen/__manifest__.py +++ b/addons/pos_adyen/__manifest__.py @@ -8,12 +8,14 @@ 'summary': 'Integrate your POS with an Adyen payment terminal', 'description': '', 'data': [ + 'security/ir.model.access.csv', + 'views/adyen_account_views.xml', 'views/pos_config_views.xml', 'views/pos_payment_method_views.xml', 'views/point_of_sale_assets.xml', + 'views/res_config_settings_views.xml', ], - 'depends': ['point_of_sale'], + 'depends': ['adyen_platforms', 'point_of_sale'], 'qweb': ['static/src/xml/pos.xml'], 'installable': True, - 'license': 'OEEL-1', } diff --git a/addons/pos_adyen/models/__init__.py b/addons/pos_adyen/models/__init__.py index 77e4db1fdaf..d8557bb1c89 100644 --- a/addons/pos_adyen/models/__init__.py +++ b/addons/pos_adyen/models/__init__.py @@ -1,5 +1,7 @@ # coding: utf-8 # Part of Odoo. See LICENSE file for full copyright and licensing details. +from . import adyen_account from . import pos_config from . import pos_payment_method +from . import res_config_settings diff --git a/addons/pos_adyen/models/adyen_account.py b/addons/pos_adyen/models/adyen_account.py new file mode 100644 index 00000000000..2db95110ccb --- /dev/null +++ b/addons/pos_adyen/models/adyen_account.py @@ -0,0 +1,107 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import uuid +from werkzeug.urls import url_join + +from odoo import api, fields, models, _ +from odoo.exceptions import ValidationError + + +class AdyenAccount(models.Model): + _inherit = 'adyen.account' + + store_ids = fields.One2many('adyen.store', 'adyen_account_id') + terminal_ids = fields.One2many('adyen.terminal', 'adyen_account_id') + + def action_order_terminal(self): + if not self.store_ids: + raise ValidationError(_('Please create a store first.')) + + store_uuids = ','.join(self.store_ids.mapped('store_uuid')) + onboarding_url = self.env['ir.config_parameter'].sudo().get_param('adyen_platforms.onboarding_url') + return { + 'type': 'ir.actions.act_url', + 'target': 'new', + 'url': url_join(onboarding_url, 'order_terminals?store_uuids=%s' % store_uuids), + } + + +class AdyenStore(models.Model): + _name = 'adyen.store' + _inherit = ['adyen.address.mixin'] + _description = 'Adyen for Plaforms Store' + + adyen_account_id = fields.Many2one('adyen.account', ondelete='cascade') + store_reference = fields.Char('Reference', default=lambda self: uuid.uuid4().hex) + store_uuid = fields.Char('UUID', readonly=True) # Given by Adyen + name = fields.Char('Name', required=True) + phone_number = fields.Char('Phone Number', required=True) + terminal_ids = fields.One2many('adyen.terminal', 'store_id', string='Payment Terminals', readonly=True) + + @api.model + def create(self, values): + adyen_store_id = super(AdyenStore, self).create(values) + response = adyen_store_id.adyen_account_id._adyen_rpc('update_account_holder', adyen_store_id._format_data()) + stores = response['accountHolderDetails']['storeDetails'] + created_store = next(store for store in stores if store['storeReference'] == adyen_store_id.store_reference) + adyen_store_id.with_context(update_from_adyen=True).sudo().write({ + 'store_uuid': created_store['store'], + }) + return adyen_store_id + + def unlink(self): + for store_id in self: + store_id.adyen_account_id._adyen_rpc('close_stores', { + 'accountHolderCode': store_id.adyen_account_id.account_holder_code, + 'stores': [store_id.store_uuid], + }) + return super(AdyenStore, self).unlink() + + def _format_data(self): + return { + 'accountHolderCode': self.adyen_account_id.account_holder_code, + 'accountHolderDetails': { + 'storeDetails': [{ + 'storeReference': self.store_reference, + 'storeName': self.name, + 'merchantAccount': 'OdooMP_POS', + 'merchantCategoryCode': '7999', + 'address': { + 'city': self.city, + 'country': self.country_id.code, + 'houseNumberOrName': self.house_number_or_name, + 'postalCode': self.zip, + 'stateOrProvince': self.state_id.code or None, + 'street': self.street, + }, + 'fullPhoneNumber': self.phone_number, + }], + } + } + + +class AdyenTerminal(models.Model): + _name = 'adyen.terminal' + _description = 'Adyen for Plaforms Terminal' + _rec_name = 'terminal_uuid' + + adyen_account_id = fields.Many2one('adyen.account', ondelete='cascade') + store_id = fields.Many2one('adyen.store') + terminal_uuid = fields.Char('Terminal ID') + + @api.model + def _sync_adyen_terminals(self): + for adyen_store_id in self.env['adyen.store'].search([]): + response = adyen_store_id.adyen_account_id._adyen_rpc('connected_terminals', { + 'store': adyen_store_id.store_uuid, + }) + terminals_in_db = set(self.search([('store_id', '=', adyen_store_id.id)]).mapped('terminal_uuid')) + + # Added terminals + for terminal in set(response.get('uniqueTerminalIds')) - terminals_in_db: + self.sudo().create({ + 'adyen_account_id': adyen_store_id.adyen_account_id.id, + 'store_id': adyen_store_id.id, + 'terminal_uuid': terminal, + }) diff --git a/addons/pos_adyen/models/pos_payment_method.py b/addons/pos_adyen/models/pos_payment_method.py index ddc0ab395b9..73c09287bb3 100644 --- a/addons/pos_adyen/models/pos_payment_method.py +++ b/addons/pos_adyen/models/pos_payment_method.py @@ -6,6 +6,7 @@ import pprint import random import requests import string +from werkzeug.exceptions import Forbidden from odoo import fields, models, api, _ from odoo.exceptions import ValidationError @@ -16,11 +17,18 @@ class PosPaymentMethod(models.Model): _inherit = 'pos.payment.method' def _get_payment_terminal_selection(self): - return super(PosPaymentMethod, self)._get_payment_terminal_selection() + [('adyen', 'Adyen')] + return super(PosPaymentMethod, self)._get_payment_terminal_selection() + [('odoo_adyen', 'Odoo Payments by Adyen'), ('adyen', 'Adyen')] + # Adyen adyen_api_key = fields.Char(string="Adyen API key", help='Used when connecting to Adyen: https://docs.adyen.com/user-management/how-to-get-the-api-key/#description', copy=False) adyen_terminal_identifier = fields.Char(help='[Terminal model]-[Serial number], for example: P400Plus-123456789', copy=False) adyen_test_mode = fields.Boolean(help='Run transactions in the test environment.') + + # Odoo Payments by Adyen + adyen_account_id = fields.Many2one('adyen.account', related='company_id.adyen_account_id') + adyen_payout_id = fields.Many2one('adyen.payout', string='Adyen Payout', domain="[('adyen_account_id', '=', adyen_account_id)]") + adyen_terminal_id = fields.Many2one('adyen.terminal', string='Adyen Terminal', domain="[('adyen_account_id', '=', adyen_account_id)]") + adyen_latest_response = fields.Char(help='Technical field used to buffer the latest asynchronous notification from Adyen.', copy=False, groups='base.group_erp_manager') adyen_latest_diagnosis = fields.Char(help='Technical field used to determine if the terminal is still connected.', copy=False, groups='base.group_erp_manager') @@ -36,6 +44,17 @@ class PosPaymentMethod(models.Model): raise ValidationError(_('Terminal %s is already used on payment method %s.') % (payment_method.adyen_terminal_identifier, existing_payment_method.display_name)) + def _get_adyen_endpoints(self): + return { + 'terminal_request': 'https://terminal-api-%s.adyen.com/async', + } + + @api.onchange('adyen_terminal_id') + def onchange_use_payment_terminal(self): + for payment_method in self: + if payment_method.use_payment_terminal == 'odoo_adyen' and payment_method.adyen_terminal_id: + payment_method.adyen_terminal_identifier = payment_method.adyen_terminal_id.terminal_uuid + def _is_write_forbidden(self, fields): whitelisted_fields = set(('adyen_latest_response', 'adyen_latest_diagnosis')) return super(PosPaymentMethod, self)._is_write_forbidden(fields - whitelisted_fields) @@ -70,34 +89,35 @@ class PosPaymentMethod(models.Model): latest_response = self.sudo().adyen_latest_response latest_response = json.loads(latest_response) if latest_response else False - self.adyen_latest_response = '' # avoid handling old responses multiple times + self.sudo().adyen_latest_response = '' # avoid handling old responses multiple times return { 'latest_response': latest_response, - 'last_received_diagnosis_id': self.adyen_latest_diagnosis, + 'last_received_diagnosis_id': self.sudo().adyen_latest_diagnosis, } - def proxy_adyen_request(self, data, test_endpoint=False, live_endpoint=False): + def proxy_adyen_request(self, data, operation=False): ''' Necessary because Adyen's endpoints don't have CORS enabled ''' - self.ensure_one() - TIMEOUT = 10 + if not operation: + operation = 'terminal_request' - if not test_endpoint: - test_endpoint = 'https://terminal-api-test.adyen.com/async' - if not live_endpoint: - live_endpoint = 'https://terminal-api-live.adyen.com/async' + if self.use_payment_terminal == 'odoo_adyen': + return self._proxy_adyen_request_odoo_proxy(data, operation) + else: + return self._proxy_adyen_request_direct(data, operation) - endpoint = live_endpoint - if test_mode: - endpoint = test_endpoint + def _proxy_adyen_request_direct(self, data, operation): + self.ensure_one() + TIMEOUT = 10 _logger.info('request to adyen\n%s', pprint.pformat(data)) + + environment = 'test' if self.adyen_test_mode else 'live' + endpoint = self._get_adyen_endpoints()[operation] % environment headers = { 'x-api-key': self.adyen_api_key, - 'Content-Type': 'application/json' } - req = requests.post(endpoint, data=json.dumps(data), headers=headers, timeout=TIMEOUT) - _logger.info('response from adyen (HTTP status %s):\n%s', req.status_code, req.text) + req = requests.post(endpoint, json=data, headers=headers, timeout=TIMEOUT) # Authentication error doesn't return JSON if req.status_code == 401: @@ -113,9 +133,16 @@ class PosPaymentMethod(models.Model): return req.json() - @api.onchange('use_payment_terminal') - def _onchange_use_payment_terminal(self): - super(PosPaymentMethod, self)._onchange_use_payment_terminal() - if self.use_payment_terminal != 'adyen': - self.adyen_api_key = False - self.adyen_terminal_identifier = False + def _proxy_adyen_request_odoo_proxy(self, data, operation): + try: + return self.env.company.sudo().adyen_account_id._adyen_rpc(operation, { + 'request_data': data, + 'account_code': self.sudo().adyen_payout_id.code, + 'notification_url': self.env['ir.config_parameter'].sudo().get_param('web.base.url'), + }) + except Forbidden: + return { + 'error': { + 'status_code': 401, + } + } diff --git a/addons/pos_adyen/models/res_config_settings.py b/addons/pos_adyen/models/res_config_settings.py new file mode 100644 index 00000000000..4e3b7102c94 --- /dev/null +++ b/addons/pos_adyen/models/res_config_settings.py @@ -0,0 +1,13 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models + + +class ResConfigSettings(models.TransientModel): + _inherit = 'res.config.settings' + + adyen_account_id = fields.Many2one(string='Adyen Account', related='company_id.adyen_account_id') + + def create_adyen_account(self): + return self.env['adyen.account'].action_create_redirect() diff --git a/addons/pos_adyen/security/ir.model.access.csv b/addons/pos_adyen/security/ir.model.access.csv new file mode 100644 index 00000000000..302878ed368 --- /dev/null +++ b/addons/pos_adyen/security/ir.model.access.csv @@ -0,0 +1,4 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_adyen_store_group_erp_manager,adyen.store,model_adyen_store,base.group_erp_manager,1,0,1,1 +access_adyen_payout_group_pos_manager,adyen.payout,adyen_platforms.model_adyen_payout,point_of_sale.group_pos_manager,1,0,0,0 +access_adyen_terminal_group_pos_manager,adyen.terminal,model_adyen_terminal,point_of_sale.group_pos_manager,1,0,0,0 diff --git a/addons/pos_adyen/static/src/js/models.js b/addons/pos_adyen/static/src/js/models.js index f2734e00a94..c022ea7ba2b 100644 --- a/addons/pos_adyen/static/src/js/models.js +++ b/addons/pos_adyen/static/src/js/models.js @@ -3,5 +3,6 @@ var models = require('point_of_sale.models'); var PaymentAdyen = require('pos_adyen.payment'); models.register_payment_method('adyen', PaymentAdyen); +models.register_payment_method('odoo_adyen', PaymentAdyen); models.load_fields('pos.payment.method', 'adyen_terminal_identifier'); }); diff --git a/addons/pos_adyen/static/src/js/payment_adyen.js b/addons/pos_adyen/static/src/js/payment_adyen.js index 82c6a9f91ea..7e0350f8328 100644 --- a/addons/pos_adyen/static/src/js/payment_adyen.js +++ b/addons/pos_adyen/static/src/js/payment_adyen.js @@ -43,12 +43,11 @@ var PaymentAdyen = PaymentInterface.extend({ return Promise.reject(data); // prevent subsequent onFullFilled's from being called }, - _call_adyen: function (data, test_endpoint, live_endpoint) { - var self = this; + _call_adyen: function (data, operation) { return rpc.query({ model: 'pos.payment.method', method: 'proxy_adyen_request', - args: [[this.payment_method.id], data, test_endpoint, live_endpoint], + args: [[this.payment_method.id], data, operation], }, { // When a payment terminal is disconnected it takes Adyen // a while to return an error (~6s). So wait 10 seconds @@ -121,6 +120,7 @@ var PaymentAdyen = PaymentInterface.extend({ }, _adyen_cancel: function (ignore_error) { + var self = this; var previous_service_id = this.most_recent_service_id; var header = _.extend(this._adyen_common_message_header(), { 'MessageCategory': 'Abort', @@ -133,7 +133,6 @@ var PaymentAdyen = PaymentInterface.extend({ 'AbortReason': 'MerchantAbort', 'MessageReference': { 'MessageCategory': 'Payment', - 'SaleID': header.SaleID, 'ServiceID': previous_service_id, } }, @@ -144,7 +143,7 @@ var PaymentAdyen = PaymentInterface.extend({ // Only valid response is a 200 OK HTTP response which is // represented by true. - if (! ignore_error && data !== true) { + if (! ignore_error && data !== "ok") { self._show_error(_('Cancelling the payment failed. Please cancel it manually on the payment terminal.')); } }); @@ -238,7 +237,7 @@ var PaymentAdyen = PaymentInterface.extend({ if (message.startsWith('108 ')) { resolve(false); } else { - line.set_payment_status('force_done'); + line.set_payment_status('retry'); reject(); } } diff --git a/addons/pos_adyen/views/adyen_account_views.xml b/addons/pos_adyen/views/adyen_account_views.xml new file mode 100644 index 00000000000..c31771d9d97 --- /dev/null +++ b/addons/pos_adyen/views/adyen_account_views.xml @@ -0,0 +1,68 @@ +<?xml version="1.0" encoding="utf-8"?> +<odoo> + <record id="adyen_account_view_form" model="ir.ui.view"> + <field name="name">adyen.account.view.form.inherit.pos.adyen</field> + <field name="model">adyen.account</field> + <field name="inherit_id" ref="adyen_platforms.adyen_account_view_form"/> + <field name="arch" type="xml"> + <xpath expr="//header" position="inside"> + <button name="action_order_terminal" string="Order a Terminal" class="oe_highlight" type="object" attrs="{'invisible': [('adyen_uuid', '=', False)]}"/> + </xpath> + <xpath expr="//notebook" position="inside"> + <page string="Stores"> + <field name="store_ids"> + <tree> + <field name="name"/> + <field name="store_uuid"/> + </tree> + </field> + </page> + </xpath> + </field> + </record> + + + <record id="adyen_store_view_form" model="ir.ui.view"> + <field name="name">adyen.store.view.form</field> + <field name="model">adyen.store</field> + <field name="arch" type="xml"> + <form string="Adyen Store"> + <sheet> + <group> + <field name="store_uuid" attrs="{'invisible': [('id', '=', False)]}"/> + <field name="name" attrs="{'readonly': [('id', '!=', False)]}"/> + <field name="phone_number" attrs="{'readonly': [('id', '!=', False)]}"/> + <label for="street" string="Address"/> + <div class="o_address_format"> + <field name="street" placeholder="Street" class="o_address_street" attrs="{'readonly': [('id', '!=', False)]}"/> + <field name="house_number_or_name" placeholder="House number or name" class="o_address_street" attrs="{'readonly': [('id', '!=', False)]}"/> + <field name="city" placeholder="City" class="o_address_city" attrs="{'readonly': [('id', '!=', False)]}"/> + <field name="state_id" class="o_address_state" placeholder="State" options="{'no_open': True, 'no_quick_create': True}" + attrs="{'required': [('country_code', 'in', ['AU', 'CA', 'IT', 'US'])], 'readonly': [('id', '!=', False)]}"/> + <field name="zip" placeholder="ZIP" class="o_address_zip" attrs="{'readonly': [('id', '!=', False)]}"/> + <field name="country_id" placeholder="Country" class="o_address_country" options="{'no_open': True, 'no_create': True}" attrs="{'readonly': [('id', '!=', False)]}"/> + <field name="country_code" invisible="1"/> + <field name="terminal_ids" attrs="{'invisible': [('terminal_ids', '=', [])]}"/> + </div> + </group> + </sheet> + </form> + </field> + </record> + + <record id="action_pos_adyen_account" model="ir.actions.server"> + <field name="name">Adyen Account</field> + <field name="model_id" ref="model_adyen_account"/> + <field name="state">code</field> + <field name="code"> + action = model.action_create_redirect() + </field> + </record> + + <menuitem id="menu_pos_adyen_account" + parent="point_of_sale.menu_point_config_product" + action="action_pos_adyen_account" + sequence="5" + groups="point_of_sale.group_pos_manager,point_of_sale.group_pos_user"/> + +</odoo> diff --git a/addons/pos_adyen/views/pos_payment_method_views.xml b/addons/pos_adyen/views/pos_payment_method_views.xml index 7c1d28597f0..fe6ce37dae2 100644 --- a/addons/pos_adyen/views/pos_payment_method_views.xml +++ b/addons/pos_adyen/views/pos_payment_method_views.xml @@ -1,16 +1,21 @@ <?xml version="1.0" encoding="utf-8"?> <odoo> <record id="pos_payment_method_view_form_inherit_pos_adyen" model="ir.ui.view"> - <field name="name">pos.payment.method.form.inherit.adyen</field> - <field name="model">pos.payment.method</field> - <field name="inherit_id" ref="point_of_sale.pos_payment_method_view_form"/> - <field name="arch" type="xml"> - <xpath expr="//field[@name='use_payment_terminal']" position="after"> - <field name="adyen_api_key" - attrs="{'invisible': [('use_payment_terminal', '!=', 'adyen')], 'required': [('use_payment_terminal', '=', 'adyen')]}"/> + <field name="name">pos.payment.method.form.inherit.adyen</field> + <field name="model">pos.payment.method</field> + <field name="inherit_id" ref="point_of_sale.pos_payment_method_view_form"/> + <field name="arch" type="xml"> + <xpath expr="//field[@name='use_payment_terminal']" position="after"> + <!-- Adyen --> + <field name="adyen_api_key" attrs="{'invisible': [('use_payment_terminal', '!=', 'adyen')], 'required': [('use_payment_terminal', '=', 'adyen')]}"/> <field name="adyen_terminal_identifier" attrs="{'invisible': [('use_payment_terminal', '!=', 'adyen')], 'required': [('use_payment_terminal', '=', 'adyen')]}"/> - <field name="adyen_test_mode" attrs="{'invisible': [('use_payment_terminal', '!=', 'adyen')]}"/> - </xpath> - </field> + <field name="adyen_test_mode" attrs="{'invisible': [('use_payment_terminal', '!=', 'adyen')], 'required': [('use_payment_terminal', '=', 'adyen')]}"/> + + <!-- Odoo Payments by Adyen --> + <field name="adyen_account_id" invisible="1"/> + <field name="adyen_terminal_id" attrs="{'invisible': [('use_payment_terminal', '!=', 'odoo_adyen')], 'required': [('use_payment_terminal', '=', 'odoo_adyen')]}"/> + <field name="adyen_payout_id" attrs="{'invisible': [('use_payment_terminal', '!=', 'odoo_adyen')], 'required': [('use_payment_terminal', '=', 'odoo_adyen')]}" options="{'no_create': True}"/> + </xpath> + </field> </record> </odoo> diff --git a/addons/pos_adyen/views/res_config_settings_views.xml b/addons/pos_adyen/views/res_config_settings_views.xml new file mode 100644 index 00000000000..4d9995072ef --- /dev/null +++ b/addons/pos_adyen/views/res_config_settings_views.xml @@ -0,0 +1,20 @@ +<?xml version="1.0" encoding="utf-8"?> +<odoo> + <record id="res_config_settings_view_form" model="ir.ui.view"> + <field name="name">res.config.settings.view.form.inherit.point_of_sale</field> + <field name="model">res.config.settings</field> + <field name="priority" eval="95"/> + <field name="inherit_id" ref="base.res_config_settings_view_form" /> + <field name="arch" type="xml"> + <xpath expr="//div[@id='adyen_payment_terminal_setting']/div[hasclass('o_setting_right_pane')]" position="inside"> + <span attrs="{'invisible': [('adyen_account_id', '=', False)]}"> + <label for="adyen_account_id" string="Account"/> + <span class="fa fa-lg fa-building-o mr8" title="Values set here are company-specific." aria-label="Values set here are company-specific." groups="base.group_multi_company" role="img"/> + <field name="adyen_account_id"/> + </span> + <button name="create_adyen_account" icon="fa-arrow-right" type="object" class="btn-link" string="Create an account in 1 minute" + attrs="{'invisible': [('adyen_account_id', '!=', False)]}"/> + </xpath> + </field> + </record> +</odoo> diff --git a/addons/pos_restaurant_adyen/models/pos_payment.py b/addons/pos_restaurant_adyen/models/pos_payment.py index 5f38d0159f3..4c62cacf50c 100644 --- a/addons/pos_restaurant_adyen/models/pos_payment.py +++ b/addons/pos_restaurant_adyen/models/pos_payment.py @@ -29,10 +29,4 @@ class PosPayment(models.Model): 'merchantAccount': self.payment_method_id.adyen_merchant_account, } - return self.payment_method_id.proxy_adyen_request( - data, - self.payment_method_id.adyen_test_mode, - self.payment_method_id.adyen_api_key, - 'https://pal-test.adyen.com/pal/servlet/Payment/v52/capture', - 'https://pal-live.adyen.com/pal/servlet/Payment/v52/capture', - ) + return self.payment_method_id.proxy_adyen_request(data, 'capture') diff --git a/addons/pos_restaurant_adyen/models/pos_payment_method.py b/addons/pos_restaurant_adyen/models/pos_payment_method.py index 9f928363379..e40b6e08f3e 100644 --- a/addons/pos_restaurant_adyen/models/pos_payment_method.py +++ b/addons/pos_restaurant_adyen/models/pos_payment_method.py @@ -8,3 +8,10 @@ class PosPaymentMethod(models.Model): _inherit = 'pos.payment.method' adyen_merchant_account = fields.Char(help='The POS merchant account code used in Adyen') + + def _get_adyen_endpoints(self): + return { + **super(PosPaymentMethod, self).get_adyen_endpoints(), + 'adjust': 'https://pal-%s.adyen.com/pal/servlet/Payment/v52/adjustAuthorisation', + 'capture': 'https://pal-%s.adyen.com/pal/servlet/Payment/v52/capture', + } diff --git a/addons/pos_restaurant_adyen/static/src/js/payment_adyen.js b/addons/pos_restaurant_adyen/static/src/js/payment_adyen.js index d94e990fdf3..6bdb845c92b 100644 --- a/addons/pos_restaurant_adyen/static/src/js/payment_adyen.js +++ b/addons/pos_restaurant_adyen/static/src/js/payment_adyen.js @@ -32,11 +32,7 @@ odoo.define('pos_restaurant_adyen.payment', function (require) { }, }; - return this._call_adyen( - data, - 'https://pal-test.adyen.com/pal/servlet/Payment/v52/adjustAuthorisation', - 'https://pal-live.adyen.com/pal/servlet/Payment/v52/adjustAuthorisation', - ); + return this._call_adyen(data, 'adjust'); }, canBeAdjusted: function (cid) { |
