diff --git a/addons/adyen_platforms/__manifest__.py b/addons/adyen_platforms/__manifest__.py
index 91eabb20da2..f1906101d52 100644
--- a/addons/adyen_platforms/__manifest__.py
+++ b/addons/adyen_platforms/__manifest__.py
@@ -11,13 +11,17 @@
'data': [
'data/adyen_platforms_data.xml',
'security/ir.model.access.csv',
+ 'security/ir_rule.xml',
'views/adyen_account_templates.xml',
'views/adyen_account_views.xml',
+ 'views/adyen_bank_account_views.xml',
+ 'views/adyen_shareholder_views.xml',
'views/adyen_transaction_views.xml',
],
'installable': True,
'assets': {
'web.assets_backend': [
+ 'adyen_platforms/static/src/scss/**/*',
'adyen_platforms/static/src/js/**/*',
],
'web.assets_qweb': [
diff --git a/addons/adyen_platforms/controllers/adyen_platforms.py b/addons/adyen_platforms/controllers/adyen_platforms.py
index a26ca88dfff..0811f27d0c4 100644
--- a/addons/adyen_platforms/controllers/adyen_platforms.py
+++ b/addons/adyen_platforms/controllers/adyen_platforms.py
@@ -1,9 +1,15 @@
-# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
+import logging
+from pprint import pformat
+
from odoo import http
from odoo.http import request
+from odoo.addons.adyen_platforms.util import odoo_payments_proxy_control
+
+_logger = logging.getLogger(__name__)
+
class AdyenPlatformsController(http.Controller):
@@ -11,3 +17,29 @@ class AdyenPlatformsController(http.Controller):
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')
+
+ @odoo_payments_proxy_control
+ @http.route('/adyen_platforms/account_notification', type='json', auth='public', csrf=False)
+ def adyen_platforms_notification(self):
+ data = request.jsonrequest
+ _logger.debug('Account notification received: %s', pformat(data))
+
+ account = request.env['adyen.account'].sudo().search([('adyen_uuid', '=', data['adyen_uuid'])])
+ if not account:
+ _logger.error('Received notification for non-existing account: %s', data['adyen_uuid'])
+ return
+
+ account.with_context(update_from_adyen=True)._handle_notification(data)
+
+ @odoo_payments_proxy_control
+ @http.route('/adyen_platforms/transaction_notification', type='json', auth='public', csrf=False)
+ def adyen_transaction_notification(self):
+ data = request.jsonrequest
+ _logger.debug('Transaction notification received: %s', pformat(data))
+
+ account = request.env['adyen.account'].sudo().search([('adyen_uuid', '=', data['adyen_uuid'])])
+ if not account:
+ _logger.error('Received notification for non-existing account: %s', data['adyen_uuid'])
+ return
+
+ request.env['adyen.transaction'].sudo()._handle_notification(data)
diff --git a/addons/adyen_platforms/data/adyen_platforms_data.xml b/addons/adyen_platforms/data/adyen_platforms_data.xml
index c01578fe449..7914731e532 100644
--- a/addons/adyen_platforms/data/adyen_platforms_data.xml
+++ b/addons/adyen_platforms/data/adyen_platforms_data.xml
@@ -12,7 +12,7 @@
- Adyen Sync
+ Odoo Payments Sync
code
model._sync_adyen_cron()
diff --git a/addons/adyen_platforms/models/__init__.py b/addons/adyen_platforms/models/__init__.py
index 480ddb07be1..8dcf258f929 100644
--- a/addons/adyen_platforms/models/__init__.py
+++ b/addons/adyen_platforms/models/__init__.py
@@ -1,6 +1,10 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
+from . import adyen_mixins
from . import adyen_account
+from . import adyen_bank_account
+from . import adyen_kyc
+from . import adyen_shareholder
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
index 687d8a38de3..3fae17740d4 100644
--- a/addons/adyen_platforms/models/adyen_account.py
+++ b/addons/adyen_platforms/models/adyen_account.py
@@ -1,90 +1,52 @@
-# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
-import os
-import requests
+import logging
import uuid
+from datetime import timedelta
+
+import requests
+from ast import literal_eval
+from dateutil.parser import parse
+from pytz import UTC
from werkzeug.urls import url_join
-from odoo import api, fields, models, _
+from odoo import _, api, fields, models
+from odoo.exceptions import AccessError, UserError, ValidationError
from odoo.http import request
-from odoo.exceptions import UserError, ValidationError
-from odoo.tools import date_utils
+from odoo.osv import expression
+from odoo.tools import format_amount, format_datetime
-from odoo.addons.adyen_platforms.util import AdyenProxyAuth
+from odoo.addons.adyen_platforms.util import AdyenProxyAuth, to_major_currency
+
+_logger = logging.getLogger(__name__)
-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 Platforms 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)
-
- # Check file formats
- if vals.get('id_front'):
- self._check_file_requirements(vals.get('id_front'), vals.get('id_front_filename'))
- if vals.get('id_back'):
- self._check_file_requirements(vals.get('id_back'), vals.get('id_back_filename'))
-
- for adyen_account in self:
- if vals.get('id_front'):
- document_type = adyen_account.id_type
- if adyen_account.id_type in ['ID_CARD', 'DRIVING_LICENSE']:
- document_type += '_FRONT'
- adyen_account._upload_photo_id(document_type, adyen_account.id_front, adyen_account.id_front_filename)
- if vals.get('id_back') and adyen_account.id_type in ['ID_CARD', 'DRIVING_LICENSE']:
- document_type = adyen_account.id_type + '_BACK'
- adyen_account._upload_photo_id(document_type, adyen_account.id_back, adyen_account.id_back_filename)
- return res
-
- @api.model
- def _check_file_requirements(self, content, filename):
- file_extension = os.path.splitext(filename)[1]
- file_size = int(len(content) * 3/4) # Compute file_size in bytes
- 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()
+ADYEN_STATUS_MAP = {
+ 'Active': 'active',
+ 'Inactive': 'inactive',
+ 'Suspended': 'suspended',
+ 'Closed': 'closed',
+}
+ADYEN_VALIDATION_MAP = {
+ 'FAILED': 'failed',
+ 'INVALID_DATA': 'awaiting_data',
+ 'RETRY_LIMIT_REACHED': 'awaiting_data',
+ 'AWAITING_DATA': 'awaiting_data',
+ 'DATA_PROVIDED': 'data_provided',
+ 'PENDING': 'pending',
+ 'PASSED': 'passed',
+}
+ADYEN_PAYOUT_FREQUENCIES = {
+ 'daily': 'DAILY',
+ 'weekly': 'WEEKLY',
+ 'biweekly': 'BIWEEKLY_ON_1ST_AND_15TH_AT_MIDNIGHT',
+ 'monthly': 'MONTHLY',
+}
class AdyenAccount(models.Model):
_name = 'adyen.account'
_inherit = ['mail.thread', 'adyen.id.mixin', 'adyen.address.mixin']
-
_description = 'Adyen for Platforms Account'
_rec_name = 'full_name'
@@ -94,14 +56,25 @@ class AdyenAccount(models.Model):
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')
+ transaction_payout_ids = fields.One2many('adyen.transaction.payout', 'adyen_account_id')
is_business = fields.Boolean('Is a business', required=True)
+ # Payout
+ account_code = fields.Char('Account Code')
+ payout_schedule = fields.Selection([
+ ('daily', 'Daily'),
+ ('weekly', 'Weekly'),
+ ('biweekly', 'Bi-weekly'),
+ ('monthly', 'Monthly'),
+ ], default='biweekly', required=True, string='Payout Schedule')
+ next_scheduled_payout = fields.Datetime('Next Scheduled Payout', readonly=True)
+ last_sync_date = fields.Datetime(default=fields.Datetime.now)
+
# Contact Info
full_name = fields.Char(compute='_compute_full_name')
email = fields.Char('Email', required=True)
@@ -113,9 +86,9 @@ class AdyenAccount(models.Model):
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")
+ "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'),
@@ -128,19 +101,64 @@ class AdyenAccount(models.Model):
doing_business_as = fields.Char('Doing Business As')
registration_number = fields.Char('Registration Number')
+ # Adyen Account Status - internal use
+ account_status = fields.Selection(string='Internal Account Status', selection=[
+ ('active', 'Active'),
+ ('inactive', 'Inactive'),
+ ('suspended', 'Suspended'),
+ ('closed', 'Closed'),
+ ], default='inactive', readonly=True)
+ payout_allowed = fields.Boolean(readonly=True)
+
+ # Status for UX
+ state = fields.Selection([
+ ('pending', 'Pending Validation'), # Odoo Validation
+ ('awaiting_data', 'Data To Provide'),
+ ('validation', 'Data Validation'), # KYC Validation from Adyen
+ ('validated', 'Validated'),
+ ], compute='_compute_state', string='Account State')
+
# 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)
+ adyen_kyc_ids = fields.One2many('adyen.kyc', 'adyen_account_id', string='KYC Checks', readonly=True)
+ kyc_tier = fields.Integer(string='KYC Tier', default=0, readonly=True)
+ kyc_status_message = fields.Html(compute='_compute_kyc_status')
_sql_constraints = [
('adyen_uuid_uniq', 'UNIQUE(adyen_uuid)', 'Adyen UUID should be unique'),
]
+ def default_get(self, fields):
+ res = super().default_get(fields)
+ company_fields = {
+ 'country_id': 'country_id',
+ 'state_id': 'state_id',
+ 'city': 'city',
+ 'zip': 'zip',
+ 'street': 'street',
+ 'email': 'email',
+ 'phone_number': 'phone',
+ }
+
+ if self.env.company.partner_id.is_company:
+ company_fields.update({
+ 'registration_number': 'vat',
+ 'legal_business_name': 'name',
+ 'doing_business_as': 'name',
+ })
+ if 'is_business' in fields:
+ res['is_business'] = True
+
+ field_keys = company_fields.keys() & set(fields)
+ for field_name in field_keys:
+ res[field_name] = self.env.company[company_fields[field_name]]
+
+ if not self.env.company.partner_id.is_company and {'last_name', 'first_name'} & set(fields):
+ name = self.env.company.partner_id.name.split()
+ res['last_name'] = name[-1]
+ res['first_name'] = ' '.join(name[:-1])
+
+ return res
+
@api.depends('transaction_ids')
def _compute_transactions_count(self):
for adyen_account_id in self:
@@ -154,39 +172,92 @@ class AdyenAccount(models.Model):
else:
adyen_account_id.full_name = "%s %s" % (adyen_account_id.first_name, adyen_account_id.last_name)
+ @api.depends('kyc_tier', 'adyen_kyc_ids', 'account_status')
+ def _compute_state(self):
+ for account in self:
+ if account.account_status in ['inactive', 'suspended'] and account.kyc_tier == 0:
+ account.state = 'pending'
+ else:
+ if any(k.status == 'awaiting_data' for k in account.adyen_kyc_ids):
+ account.state = 'awaiting_data'
+ elif any(k.status == 'pending' for k in account.adyen_kyc_ids):
+ account.state = 'validation'
+ else:
+ account.state = 'validated'
+
+ @api.depends_context('lang')
+ @api.depends('adyen_kyc_ids')
+ def _compute_kyc_status(self):
+ self.kyc_status_message = False
+ doc_types = dict(self.env['adyen.kyc']._fields['verification_type']._description_selection(self.env))
+ for account in self.filtered('adyen_kyc_ids.status_message'):
+ checks = {}
+ for kyc in account.adyen_kyc_ids.filtered('status_message'):
+ doc_type = doc_types.get(kyc.verification_type, _('Other'))
+ checks.setdefault(doc_type, []).append({
+ 'document': kyc.document,
+ 'message': kyc.status_message,
+ })
+
+ account.kyc_status_message = self.env['ir.qweb']._render('adyen_platforms.kyc_status_message', {
+ 'checks': checks
+ })
+
@api.model
def create(self, values):
- adyen_account_id = super(AdyenAccount, self).create(values)
- self.env.company.adyen_account_id = adyen_account_id.id
+ adyen_account_id = super().create(values)
# Create account on odoo.com, proxy and Adyen
- response = adyen_account_id._adyen_rpc('create_account_holder', adyen_account_id._format_data())
+ create_data = self._format_data(values)
+ create_data['payoutSchedule'] = ADYEN_PAYOUT_FREQUENCIES.get(values.get('payout_schedule', 'biweekly'), 'BIWEEKLY_ON_1ST_AND_15TH_AT_MIDNIGHT'),
+ response = self._adyen_rpc('v1/create_account_holder', create_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({
+ 'account_code': response['adyen_response']['accountCode'],
'adyen_uuid': response['adyen_uuid'],
'proxy_token': response['proxy_token'],
})
+ self.env.company.adyen_account_id = adyen_account_id.id
- # 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())
+ adyen_fields = {
+ 'country_id', 'state_id', 'city', 'zip', 'street', 'house_number_or_name', 'email', 'phone_number',
+ 'is_business', 'legal_business_name', 'doing_business_as', 'registration_number', 'first_name',
+ 'last_name', 'date_of_birth', 'document_number', 'document_type',
+ }
+ res = super().write(vals)
+ if vals.keys() & adyen_fields and not self.env.context.get('update_from_adyen'):
+ self._adyen_rpc('v1/update_account_holder', self._format_data(vals))
+
+ if 'payout_schedule' in vals:
+ self._update_payout_schedule(vals['payout_schedule'])
+
return res
def unlink(self):
+ self.check_access_rights('unlink')
+
for adyen_account_id in self:
- adyen_account_id._adyen_rpc('close_account_holder', {
+ adyen_account_id._adyen_rpc('v1/close_account_holder', {
'accountHolderCode': adyen_account_id.account_holder_code,
})
- return super(AdyenAccount, self).unlink()
+ return super().unlink()
+
+ def _update_payout_schedule(self, payout_schedule):
+ self.ensure_one()
+
+ self._adyen_rpc('v1/update_payout_schedule', {
+ 'accountCode': self.account_code,
+ 'metadata': {
+ 'adyen_uuid': self.adyen_uuid,
+ },
+ 'payoutSchedule': {
+ 'action': 'UPDATE',
+ 'schedule': ADYEN_PAYOUT_FREQUENCIES.get(payout_schedule),
+ }
+ })
@api.model
def action_create_redirect(self):
@@ -213,75 +284,93 @@ class AdyenAccount(models.Model):
}
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']}
- }
+ action = self.env['ir.actions.actions']._for_xml_id('adyen_platforms.adyen_transaction_action')
+ action['domain'] = expression.AND([[('adyen_account_id', '=', self.id)], literal_eval(action.get('domain', '[]'))])
+ return action
def _upload_photo_id(self, document_type, content, filename):
- self._adyen_rpc('upload_document', {
+ test_mode = self.env['ir.config_parameter'].sudo().get_param('adyen_platforms.test_mode')
+ self._adyen_rpc('v1/upload_document', {
'documentDetail': {
'accountHolderCode': self.account_holder_code,
'documentType': document_type,
'filename': filename,
+ 'description': 'PASSED' if test_mode else '',
},
'documentContent': content.decode(),
})
- def _format_data(self):
+ def _format_data(self, values):
+ fields = values.keys()
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',
+ 'accountHolderCode': values.get('account_holder_code') or self.account_holder_code,
}
+ holder_details = {}
- if self.is_business:
- data['accountHolderDetails']['businessDetails'] = {
- 'legalBusinessName': self.legal_business_name,
- 'doingBusinessAs': self.doing_business_as,
- 'registrationNumber': self.registration_number,
+ # *ALL* the address fields are required if one of them changes
+ address_fields = {'country_id', 'state_id', 'city', 'zip', 'street', 'house_number_or_name'}
+ if address_fields & fields:
+ country_id = self.env['res.country'].browse(values.get('country_id')) if values.get('country_id') else self.country_id
+ state_id = self.env['res.country.state'].browse(values.get('state_id')) if values.get('state_id') else self.state_id
+ holder_details['address'] = {
+ 'country': country_id.code,
+ 'stateOrProvince': state_id.code or None,
+ 'city': values.get('city') or self.city,
+ 'postalCode': values.get('zip') or self.zip,
+ 'street': values.get('street') or self.street,
+ 'houseNumberOrName': values.get('house_number_or_name') or self.house_number_or_name,
}
- else:
- data['accountHolderDetails']['individualDetails'] = {
- 'name': {
- 'firstName': self.first_name,
- 'lastName': self.last_name,
- 'gender': 'UNKNOWN',
- },
- 'personalData': {
- 'dateOfBirth': str(self.date_of_birth),
+
+ if 'email' in values:
+ holder_details['email'] = values['email']
+
+ if 'phone_number' in values:
+ holder_details['fullPhoneNumber'] = values['phone_number']
+
+ if 'is_business' in values:
+ data['legalEntity'] = 'Business' if values['is_business'] else 'Individual'
+
+ if (values.get('is_business') or self.is_business) and {'legal_business_name', 'doing_business_as', 'registration_number'} & fields:
+ business_details = holder_details['business_details'] = {}
+ for source, dest in [
+ ('legal_business_name', 'legalBusinessName'),
+ ('doing_business_as', 'doingBusinessAs'),
+ ('registration_number', 'registrationNumber'),
+ ]:
+ if source in values:
+ business_details[dest] = values[source]
+
+ elif {'first_name', 'last_name', 'date_of_birth', 'document_number', 'document_type'} & fields:
+ holder_details['individualDetails'] = {}
+
+ if {'first_name', 'last_name'} & fields:
+ holder_details['individualDetails']['name'] = {
+ 'firstName': values.get('first_name') or self.first_name,
+ 'lastName': values.get('last_name') or self.last_name
}
- }
- # 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,
+ if 'date_of_birth' in fields:
+ holder_details['individualDetails'].setdefault('personalData', {})['dateOfBirth'] = str(values['date_of_birth'])
+
+ document_number = values.get('document_number') or self.document_number
+ if self.document_number and 'document_number' in fields:
+ holder_details['individualDetails'].setdefault('personalData', {})['documentData'] = [{
+ 'number': document_number,
+ 'type': values.get('document_type') or self.document_type,
}]
+ if holder_details:
+ data['accountHolderDetails'] = holder_details
+
return data
- def _adyen_rpc(self, operation, adyen_data={}):
- if operation == 'create_account_holder':
+ def _adyen_rpc(self, operation, adyen_data=None):
+ adyen_data = adyen_data or {}
+ if operation == 'v1/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'),
+ 'base_url': self.env['ir.config_parameter'].sudo().get_param('web.base.url'),
'adyen_data': adyen_data,
}
auth = None
@@ -301,8 +390,8 @@ class AdyenAccount(models.Model):
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 while trying to reach the Adyen proxy.'))
- except Exception as e:
+ raise UserError(_('A timeout occurred while trying to reach the Adyen proxy.'))
+ except Exception:
raise UserError(_('The Adyen proxy is not reachable, please try again later.'))
response = req.json()
@@ -311,412 +400,248 @@ class AdyenAccount(models.Model):
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"))
-
- result = response.get('result')
- if 'verification' in result:
- self._update_kyc_status(result['verification'])
-
- return result
+ _logger.warning('Proxy error: %s', response['error'])
+ 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()
+ self.env['adyen.account'].search([]).sync_transactions()
- @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,
+ def _handle_notification(self, notification_data):
+ self.ensure_one()
+
+ content = notification_data.get('content', {})
+ event_type = notification_data.get('eventType')
+
+ if event_type == 'ODOO_ACCOUNT_STATUS_CHANGE':
+ self._handle_odoo_account_status_change(content)
+ elif event_type == 'ACCOUNT_HOLDER_STATUS_CHANGE':
+ self._handle_account_holder_status_change_notification(content)
+ elif event_type == 'ACCOUNT_HOLDER_VERIFICATION':
+ self._handle_account_holder_verification_notification(content)
+ elif event_type == 'ACCOUNT_UPDATED':
+ self._handle_account_updated_notification(content)
+ elif event_type == 'ACCOUNT_HOLDER_PAYOUT':
+ self._handle_account_holder_payout(content)
+ else:
+ _logger.warning(_("Unknown eventType received: %s", event_type))
+
+ def _handle_odoo_account_status_change(self, content):
+ self.ensure_one()
+
+ new_status = content.get('newStatus')
+ if new_status == 'active' and self.account_status in ['suspended', 'inactive']:
+ self._adyen_rpc('v1/unsuspend_account_holder', {
+ 'accountHolderCode': self.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,
+ elif new_status == 'rejected':
+ self._adyen_rpc('v1/close_account_holder', {
+ 'accountHolderCode': self.account_holder_code,
})
- # 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,
+ def _handle_account_holder_status_change_notification(self, content):
+ self.ensure_one()
+
+ # Account Status
+ new_status = ADYEN_STATUS_MAP.get(content.get('newStatus', {}).get('status'))
+ if new_status and new_status != self.account_status:
+ self.account_status = new_status
+
+ # Tier
+ tier = content.get('newStatus', {}).get('processingState', {}).get('tierNumber', None)
+ if isinstance(tier, int) and tier != self.kyc_tier:
+ self.kyc_tier = tier
+
+ # Payout
+ payout_allowed = content.get('newStatus', {}).get('payoutState', {}).get('allowPayout', None)
+ if payout_allowed is not None:
+ self.payout_allowed = payout_allowed == 'true'
+
+ # Events
+ events = content.get('newStatus', {}).get('events')
+ if events:
+ reasons = []
+ for event in events:
+ account_event = event.get('AccountEvent', {}).get('reason')
+ if account_event:
+ reasons.append(account_event)
+
+ status_message = self.env['ir.qweb']._render('adyen_platforms.status_message', {
+ 'message': content.get('reason'),
+ 'reasons': reasons,
})
+ self.sudo().message_post(body=status_message, subtype_xmlid="mail.mt_comment")
- 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,
- })
+ def _handle_account_holder_verification_notification(self, content):
+ self.ensure_one()
- 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
+ status = ADYEN_VALIDATION_MAP.get(content.get('verificationStatus'))
+ document = '_'.join(content.get('verificationType', '').lower().split('_')[:-1]) # bank_account, identity, passport, etc.
+ status_message = content.get('statusSummary', {}).get('kycCheckDescription')
- self.with_context(update_from_adyen=True).write({
- 'kyc_status': kyc_status,
- 'kyc_status_message': kyc_status_message,
- })
+ bank_uuid = content.get('bankAccountUUID')
+ shareholder_uuid = content.get('shareholderCode')
- @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'
+ kyc = self.adyen_kyc_ids.filtered(lambda k: k.verification_type == document)
+ if bank_uuid:
+ kyc = kyc.filtered(lambda k: k.bank_account_id.bank_account_uuid == bank_uuid or not k.bank_account_id)
+ elif shareholder_uuid:
+ kyc = kyc.filtered(lambda k: k.shareholder_id.shareholder_uuid == shareholder_uuid or not k.shareholder_id)
+ else:
+ kyc = kyc.filtered(lambda k: not k.shareholder_id and not k.bank_account_id)
- @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 ''
+ if not kyc:
+ additional_data = {}
+ if document == 'bank_account' and bank_uuid:
+ bank_account_id = self.env['adyen.bank.account'].sudo().search([('bank_account_uuid', '=', bank_uuid)])
+ additional_data['bank_account_id'] = bank_account_id.id
+ if shareholder_uuid:
+ shareholder_id = self.env['adyen.shareholder'].sudo().search([('shareholder_uuid', '=', shareholder_uuid)])
+ additional_data['shareholder_id'] = shareholder_id.id
-
-class AdyenShareholder(models.Model):
- _name = 'adyen.shareholder'
- _inherit = ['adyen.id.mixin', 'adyen.address.mixin']
- _description = 'Adyen for Platforms 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],
+ self.env['adyen.kyc'].sudo().create({
+ 'verification_type': document,
+ 'adyen_account_id': self.id,
+ 'status': status,
+ 'status_message': status_message,
+ 'last_update': fields.Datetime.now(),
+ **additional_data
})
- return super(AdyenShareholder, self).unlink()
+ else:
+ if bank_uuid and not kyc.bank_account_id:
+ bank_account_id = self.env['adyen.bank.account'].sudo().search([('bank_account_uuid', '=', bank_uuid)])
+ kyc.bank_account_id = bank_account_id.id
+ if shareholder_uuid and not kyc.shareholder_id:
+ shareholder_id = self.env['adyen.shareholder'].sudo().search([('shareholder_uuid', '=', shareholder_uuid)])
+ kyc.shareholder_id = shareholder_id.id
- 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.decode(),
- })
+ if status != kyc.status:
+ kyc.write({
+ 'status': status,
+ 'status_message': status_message,
+ 'last_update': fields.Datetime.now(),
+ })
- 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),
- }
- }]
- }
- }
- }
+ def _handle_account_updated_notification(self, content):
+ self.ensure_one()
+ scheduled_date = content.get('payoutSchedule', {}).get('nextScheduledPayout')
+ if scheduled_date:
+ self.next_scheduled_payout = parse(scheduled_date).astimezone(UTC).replace(tzinfo=None)
- # 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',
- }]
+ def _handle_account_holder_payout(self, content):
+ self.ensure_one()
+ status = content.get('status', {}).get('statusCode')
- return data
+ if status == 'Failed':
+ status_message = _('Failed payout: %s', content['status']['message']['text'])
+ self.sudo().message_post(body=status_message, subtype_xmlid="mail.mt_comment")
-class AdyenBankAccount(models.Model):
- _name = 'adyen.bank.account'
- _description = 'Adyen for Platforms Bank Account'
+ def sync_transactions(self):
+ updated_transactions = self.env['adyen.transaction']
+ for account in self:
+ page = 1
+ next_page = True
+ max_create_date = False
- 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')
+ while next_page:
+ transactions, next_page = account._fetch_transactions(page)
+ page += 1
- 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()
+ for transaction in transactions:
+ create_date = parse(transaction.get('creationDate')).astimezone(UTC).replace(tzinfo=None)
+ if not max_create_date:
+ max_create_date = create_date
+ if create_date <= account.last_sync_date:
+ next_page = False
+ break
- # 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 Platforms 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}
- )
+ status = transaction.get('transactionStatus')
+ if status in ['Payout', 'PayoutReversed']:
+ reference = transaction.get('pspReference') or transaction.get('disputePspReference')
+ tx_sudo = account.transaction_payout_ids.sudo().filtered(lambda t: t.reference == reference)
+ if not tx_sudo:
+ self.env['adyen.transaction.payout'].sudo()._create_missing_payout(account.id, transaction)
+ else:
+ tx_sudo.status = status
+ else:
+ if status in ['Chargeback', 'ChargebackReceived']:
+ transaction['pspReference'] = transaction.get('disputePspReference')
+ tx_sudo, _reference, _capture_reference = account.transaction_ids.sudo()._get_tx_from_notification(transaction)
+ if not tx_sudo:
+ tx_sudo = self.env['adyen.transaction'].sudo()._create_missing_tx(account.id, transaction)
+ tx_sudo._update_status(status, create_date)
+ updated_transactions |= tx_sudo
+ account.last_sync_date = max_create_date
+ updated_transactions.sudo()._post_transaction_sync()
def _fetch_transactions(self, page=1):
- response = self.adyen_account_id._adyen_rpc('get_transactions', {
- 'accountHolderCode': self.adyen_account_id.account_holder_code,
+ self.ensure_one()
+ response = self._adyen_rpc('v1/get_transactions', {
+ 'accountHolderCode': self.account_holder_code,
'transactionListsPerAccount': [{
- 'accountCode': self.code,
+ 'accountCode': self.account_code,
'page': page,
}]
})
transaction_list = response['accountTransactionLists'][0]
return transaction_list['transactions'], transaction_list['hasNextPage']
+
+
+class AdyenAccountBalance(models.Model):
+ _name = 'adyen.account.balance'
+ _description = 'Adyen Account Balance'
+
+ adyen_account_id = fields.Many2one('adyen.account', required=True, ondelete='cascade')
+ currency_id = fields.Many2one('res.currency')
+ balance = fields.Float(default=0.0)
+ on_hold = fields.Float(default=0.0)
+ pending = fields.Float(default=0.0)
+
+ @api.model
+ def get_account_balance(self):
+ if not self.user_has_groups('base.group_erp_manager'):
+ raise AccessError(_("You can't access account balance."))
+
+ if not self.env.company.adyen_account_id:
+ return {}
+
+ balance_fields = {'balance': 'balance', 'onHoldBalance': 'on_hold', 'pendingBalance': 'pending'}
+ balances = self.env['adyen.account.balance'].sudo().search([
+ ('adyen_account_id', '=', self.env.company.adyen_account_id.id)
+ ])
+
+ delta = fields.Datetime.now() - timedelta(hours=1)
+ if not balances or any(b.write_date <= delta for b in balances):
+ response = {}
+ try:
+ response = self.env.company.adyen_account_id._adyen_rpc('v1/account_holder_balance', {
+ 'accountHolderCode': self.env.company.adyen_account_id.account_holder_code,
+ })
+ except UserError as e:
+ _logger.warning(_('Cannot update account balance, showing previous values: %s', e))
+
+ balances.write({
+ f: 0 for f in balance_fields.values()
+ })
+ for total_balance, adyen_balances in response.get('totalBalance', {}).items():
+ for balance in adyen_balances:
+ currency_id = self.env['res.currency'].search([('name', '=', balance.get('currency'))])
+ bal = balances.filtered(lambda b: b.currency_id == currency_id)
+ if not bal:
+ bal = self.env['adyen.account.balance'].sudo().create({
+ 'adyen_account_id': self.env.company.adyen_account_id.id,
+ 'currency_id': currency_id.id,
+ })
+ balances |= bal
+ bal[balance_fields.get(total_balance)] = to_major_currency(balance.get('value', 0), currency_id.decimal_places)
+
+ warning_delta = fields.Datetime.now() - timedelta(hours=2)
+ return [{
+ 'currency': b.currency_id.name,
+ 'balance': format_amount(self.env, b.balance, b.currency_id),
+ 'payout_date': format_datetime(self.env, self.env.company.adyen_account_id.next_scheduled_payout, dt_format='short'),
+ 'last_update_warning': b.write_date <= warning_delta,
+ 'last_update': format_datetime(self.env, b.write_date),
+ } for b in balances]
diff --git a/addons/adyen_platforms/models/adyen_bank_account.py b/addons/adyen_platforms/models/adyen_bank_account.py
new file mode 100644
index 00000000000..31a90c26274
--- /dev/null
+++ b/addons/adyen_platforms/models/adyen_bank_account.py
@@ -0,0 +1,159 @@
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import base64
+import uuid
+
+from odoo import _, api, fields, models
+from odoo.exceptions import ValidationError
+from odoo.tools.mimetypes import guess_mimetype
+
+from odoo.addons.adyen_platforms.models.adyen_kyc import ADYEN_KYC_STATUS
+from odoo.addons.adyen_platforms.models.adyen_mixins import ADYEN_AVAILABLE_COUNTRIES
+
+
+class AdyenBankAccount(models.Model):
+ _name = 'adyen.bank.account'
+ _description = 'Adyen for Platforms 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)
+ currency_name = fields.Char(related='currency_id.name', string='Currency Name')
+ iban = fields.Char('IBAN')
+ account_number = fields.Char('Account Number')
+ branch_code = fields.Char('Branch Code')
+ bank_city = fields.Char('Bank City')
+ bank_code = fields.Char('Bank Code')
+ bank_name = fields.Char('Bank Name')
+ 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
+ adyen_kyc_ids = fields.One2many('adyen.kyc', 'bank_account_id')
+ kyc_status = fields.Selection(ADYEN_KYC_STATUS, compute='_compute_kyc_status', readonly=True)
+ kyc_status_message = fields.Char(compute='_compute_kyc_status', readonly=True)
+
+ @api.depends_context('lang')
+ @api.depends('adyen_kyc_ids')
+ def _compute_kyc_status(self):
+ self.kyc_status_message = False
+ self.kyc_status = False
+ for bank_account in self.filtered('adyen_kyc_ids'):
+ kyc = bank_account.adyen_kyc_ids._sort_by_status()
+ bank_account.kyc_status = kyc[0].status
+
+ @api.model
+ def create(self, values):
+ adyen_bank_account_id = super().create(values)
+ adyen_account_id = self.env['adyen.account'].browse(values.get('adyen_account_id'))
+ response = adyen_account_id._adyen_rpc('v1/update_account_holder', self._format_data(values))
+ 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().write(vals)
+
+ if not self.env.context.get('update_from_adyen'):
+ self.adyen_account_id._adyen_rpc('v1/update_account_holder', self._format_data(vals))
+ if 'bank_statement' in vals:
+ self._upload_bank_statement(vals['bank_statement'], vals['bank_statement_filename'])
+ return res
+
+ def unlink(self):
+ self.check_access_rights('unlink')
+
+ for bank_account_id in self:
+ bank_account_id.adyen_account_id._adyen_rpc('v1/delete_bank_accounts', {
+ 'accountHolderCode': bank_account_id.adyen_account_id.account_holder_code,
+ 'bankAccountUUIDs': [bank_account_id.bank_account_uuid],
+ })
+ return super().unlink()
+
+ def name_get(self):
+ res = []
+ for bank_account in self:
+ name = f'{bank_account.owner_name} - f{bank_account.iban or bank_account.account_number}'
+ res.append((bank_account.id, name))
+ return res
+
+ def _format_data(self, values=None):
+ if values is None:
+ values = {}
+ adyen_account_id = self.env['adyen.account'].browse(values.get('adyen_account_id')) if values.get('adyen_account_id') else self.adyen_account_id
+ country_id = self.env['res.country'].browse(values.get('country_id')) if values.get('country_id') else self.country_id
+ currency_id = self.env['res.currency'].browse(values.get('currency_id')) if values.get('currency_id') else self.currency_id
+ owner_country_id = self.env['res.country'].browse(values.get('owner_country_id')) if values.get('owner_country_id') else self.owner_country_id
+ owner_state_id = self.env['res.country.state'].browse(values.get('owner_state_id')) if values.get('owner_state_id') else self.owner_state_id
+ return {
+ 'accountHolderCode': adyen_account_id.account_holder_code,
+ 'accountHolderDetails': {
+ 'bankAccountDetails': [{
+ 'accountNumber': values.get('account_number') or self.account_number or None,
+ 'accountType': values.get('account_type') or self.account_type or None,
+ 'bankAccountReference': values.get('bank_account_reference') or self.bank_account_reference,
+ 'bankAccountUUID': values.get('bank_account_uuid') or self.bank_account_uuid or None,
+ 'bankCity': values.get('bank_city') or self.bank_city or None,
+ 'bankCode': values.get('bank_code') or self.bank_code or None,
+ 'bankName': values.get('bank_name') or self.bank_name or None,
+ 'branchCode': values.get('branch_code') or self.branch_code or None,
+ 'countryCode': country_id.code,
+ 'currencyCode': currency_id.name,
+ 'iban': values.get('iban') or self.iban or None,
+ 'ownerCity': values.get('owner_city') or self.owner_city or None,
+ 'ownerCountryCode': owner_country_id.code or None,
+ 'ownerHouseNumberOrName': values.get('owner_house_number_or_name') or self.owner_house_number_or_name or None,
+ 'ownerName': values.get('owner_name') or self.owner_name,
+ 'ownerPostalCode': values.get('owner_zip') or self.owner_zip or None,
+ 'ownerState': owner_state_id.code or None,
+ 'ownerStreet': values.get('owner_street') or self.owner_street or None,
+ }],
+ }
+ }
+
+ def _upload_bank_statement(self, content, filename):
+ content_encoded = content.encode('utf8')
+ mimetype = guess_mimetype(base64.b64decode(content_encoded))
+ file_size = len(content_encoded)
+
+ # Document requirements: https://docs.adyen.com/platforms/verification-checks/bank-account-check#requirements
+ if mimetype not in ['image/jpeg', 'image/png', 'application/pdf']:
+ raise ValidationError(_('Allowed file formats for bank statements are jpeg, jpg, pdf or png. Received %r instead.', mimetype))
+ if file_size < (100 * 1024) or (file_size < 1024 and mimetype == 'application/pdf'):
+ raise ValidationError(_('Minimum allowed size for bank statements: 1 KB for PDF, 100 KB for other formats.'))
+ if file_size > (4 * 1024 * 1024):
+ raise ValidationError(_('Maximum allowed size for bank statements: 4MB.'))
+
+ test_mode = self.env['ir.config_parameter'].sudo().get_param('adyen_platforms.test_mode')
+ self.adyen_account_id._adyen_rpc('v1/upload_document', {
+ 'documentDetail': {
+ 'accountHolderCode': self.adyen_account_id.account_holder_code,
+ 'bankAccountUUID': self.bank_account_uuid,
+ 'documentType': 'BANK_STATEMENT',
+ 'filename': filename,
+ 'description': 'PASSED' if test_mode else '',
+ },
+ 'documentContent': content,
+ })
diff --git a/addons/adyen_platforms/models/adyen_kyc.py b/addons/adyen_platforms/models/adyen_kyc.py
new file mode 100644
index 00000000000..4bcc4072d4f
--- /dev/null
+++ b/addons/adyen_platforms/models/adyen_kyc.py
@@ -0,0 +1,54 @@
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import api, fields, models
+
+
+ADYEN_KYC_STATUS = [
+ ('awaiting_data', 'Data To Provide'),
+ ('pending', 'Waiting For Validation'),
+ ('data_provided', 'Data Provided'),
+ ('passed', 'Confirmed'),
+ ('failed', 'Failed'),
+]
+
+class AdyenKYC(models.Model):
+ _name = 'adyen.kyc'
+ _description = 'Adyen KYC checks'
+
+ status_message = fields.Char()
+ status = fields.Selection(string='KYC Status', selection=[
+ ('awaiting_data', 'Data To Provide'),
+ ('pending', 'Waiting For Validation'),
+ ('data_provided', 'Data Provided'),
+ ('passed', 'Confirmed'),
+ ('failed', 'Failed'),
+ ], required=True, default='pending')
+
+ adyen_account_id = fields.Many2one('adyen.account', required=True, ondelete='cascade')
+ bank_account_id = fields.Many2one(
+ 'adyen.bank.account', domain="[('adyen_account_id', '=', adyen_account_id)]", ondelete='cascade')
+ shareholder_id = fields.Many2one(
+ 'adyen.shareholder', domain="[('adyen_account_id', '=', adyen_account_id)]", ondelete='cascade')
+ document = fields.Char(compute='_compute_document')
+
+ verification_type = fields.Selection([
+ ('company', 'Company'),
+ ('identity', 'Identity'),
+ ('passport', 'Passport'),
+ ('bank_account', 'Bank Account'),
+ ('nonprofit', 'Nonprofit'),
+ ('card', 'Card'),
+ ], string='KYC Document')
+
+ last_update = fields.Datetime(string="Last Update")
+
+ def _sort_by_status(self):
+ order = ['failed', 'awaiting_data', 'pending', 'data_provided', 'passed']
+ kyc_sorted = sorted(self, key=lambda k: order.index(k.status))
+ return kyc_sorted
+
+ @api.depends('bank_account_id', 'shareholder_id')
+ def _compute_document(self):
+ self.document = False
+ for kyc in self.filtered(lambda k: k.bank_account_id or k.shareholder_id):
+ kyc.document = kyc.bank_account_id.display_name or kyc.shareholder_id.display_name
diff --git a/addons/adyen_platforms/models/adyen_mixins.py b/addons/adyen_platforms/models/adyen_mixins.py
new file mode 100644
index 00000000000..71c3a0a8c44
--- /dev/null
+++ b/addons/adyen_platforms/models/adyen_mixins.py
@@ -0,0 +1,81 @@
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import base64
+
+from odoo import _, api, fields, models
+from odoo.exceptions import ValidationError
+from odoo.tools.mimetypes import guess_mimetype
+
+ADYEN_AVAILABLE_COUNTRIES = [
+ 'US', 'AT', 'AU', 'BE', 'CA', 'CH', 'CZ', 'DE', 'ES', 'FI', 'FR', 'GB', 'GR', 'HR', 'IE', 'IT',
+ 'LT', 'LU', 'NL', 'PL', 'PT'
+]
+
+
+class AdyenAddressMixin(models.AbstractModel):
+ _name = 'adyen.address.mixin'
+ _description = 'Adyen for Platforms 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().write(vals)
+
+ # Check file formats
+ if vals.get('id_front'):
+ self._check_file_requirements(vals.get('id_front'))
+ if vals.get('id_back'):
+ self._check_file_requirements(vals.get('id_back'))
+
+ for adyen_account in self:
+ if vals.get('id_front'):
+ document_type = adyen_account.id_type
+ if adyen_account.id_type in ['ID_CARD', 'DRIVING_LICENSE']:
+ document_type += '_FRONT'
+ adyen_account._upload_photo_id(document_type, adyen_account.id_front, adyen_account.id_front_filename)
+ if vals.get('id_back') and adyen_account.id_type in ['ID_CARD', 'DRIVING_LICENSE']:
+ document_type = adyen_account.id_type + '_BACK'
+ adyen_account._upload_photo_id(document_type, adyen_account.id_back, adyen_account.id_back_filename)
+ return res
+
+ @api.model
+ def _check_file_requirements(self, content):
+ content_encoded = content.encode('utf8')
+ mimetype = guess_mimetype(base64.b64decode(content_encoded))
+ file_size = len(content_encoded)
+
+ # Document requirements: https://docs.adyen.com/platforms/verification-checks/photo-id-check#requirements
+ if mimetype not in ['image/jpeg', 'image/png', 'application/pdf']:
+ raise ValidationError(_('Allowed file formats for photo IDs are jpeg, jpg, pdf or png'))
+ if file_size < (100 * 1024) or (file_size < 1024 and mimetype == 'application/pdf'):
+ raise ValidationError(_('Minimum allowed size for photo ID: 1 KB for PDF, 100 KB for other formats.'))
+ if file_size > (4 * 1024 * 1024):
+ raise ValidationError(_('Maximum allowed size for photo ID: 4 MB.'))
+
+ 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()
diff --git a/addons/adyen_platforms/models/adyen_shareholder.py b/addons/adyen_platforms/models/adyen_shareholder.py
new file mode 100644
index 00000000000..6649d3c5166
--- /dev/null
+++ b/addons/adyen_platforms/models/adyen_shareholder.py
@@ -0,0 +1,129 @@
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import uuid
+
+from odoo import api, fields, models
+
+from odoo.addons.adyen_platforms.models.adyen_kyc import ADYEN_KYC_STATUS
+
+
+class AdyenShareholder(models.Model):
+ _name = 'adyen.shareholder'
+ _inherit = ['adyen.id.mixin', 'adyen.address.mixin']
+ _description = 'Adyen for Platforms 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")
+
+ adyen_kyc_ids = fields.One2many('adyen.kyc', 'shareholder_id')
+ kyc_status = fields.Selection(ADYEN_KYC_STATUS, compute='_compute_kyc_status', readonly=True)
+ kyc_status_message = fields.Char(compute='_compute_kyc_status', readonly=True)
+
+ @api.depends_context('lang')
+ @api.depends('adyen_kyc_ids')
+ def _compute_kyc_status(self):
+ self.kyc_status_message = False
+ self.kyc_status = False
+ for shareholder in self.filtered('adyen_kyc_ids'):
+ kyc = shareholder.adyen_kyc_ids._sort_by_status()
+ shareholder.kyc_status = kyc[0].status
+
+ @api.depends('first_name', 'last_name')
+ def _compute_full_name(self):
+ for adyen_shareholder_id in self:
+ adyen_shareholder_id.full_name = f'{adyen_shareholder_id.first_name} {adyen_shareholder_id.last_name}'
+
+ @api.model
+ def create(self, values):
+ adyen_shareholder_id = super().create(values)
+ adyen_account_id = self.env['adyen.account'].browse(values.get('adyen_account_id'))
+ response = adyen_account_id._adyen_rpc('v1/update_account_holder', self._format_data(values))
+
+ 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().write(vals)
+ if not self.env.context.get('update_from_adyen'):
+ self.adyen_account_id._adyen_rpc('v1/update_account_holder', self._format_data(vals))
+ return res
+
+ def unlink(self):
+ self.check_access_rights('unlink')
+
+ for shareholder_id in self:
+ shareholder_id.adyen_account_id._adyen_rpc('v1/delete_shareholders', {
+ 'accountHolderCode': shareholder_id.adyen_account_id.account_holder_code,
+ 'shareholderCodes': [shareholder_id.shareholder_uuid],
+ })
+ return super().unlink()
+
+ def _upload_photo_id(self, document_type, content, filename):
+ test_mode = self.env['ir.config_parameter'].sudo().get_param('adyen_platforms.test_mode')
+ self.adyen_account_id._adyen_rpc('v1/upload_document', {
+ 'documentDetail': {
+ 'accountHolderCode': self.adyen_account_id.account_holder_code,
+ 'shareholderCode': self.shareholder_uuid,
+ 'documentType': document_type,
+ 'filename': filename,
+ 'description': 'PASSED' if test_mode else '',
+ },
+ 'documentContent': content.decode(),
+ })
+
+ def _format_data(self, values):
+ adyen_account_id = self.env['adyen.account'].browse(values.get('adyen_account_id')) if values.get('adyen_account_id') else self.adyen_account_id
+ country_id = self.env['res.country'].browse(values.get('country_id')) if values.get('country_id') else self.country_id
+ state_id = self.env['res.country.state'].browse(values.get('owner_state_id')) if values.get('state_id') else self.state_id
+ data = {
+ 'accountHolderCode': adyen_account_id.account_holder_code,
+ 'accountHolderDetails': {
+ 'businessDetails': {
+ 'shareholders': [{
+ 'shareholderCode': values.get('shareholder_uuid') or self.shareholder_uuid or None,
+ 'shareholderReference': values.get('shareholder_reference') or self.shareholder_reference,
+ 'address': {
+ 'city': values.get('city') or self.city,
+ 'country': country_id.code,
+ 'houseNumberOrName': values.get('house_number_or_name') or self.house_number_or_name,
+ 'postalCode': values.get('zip') or self.zip,
+ 'stateOrProvince': state_id.code or None,
+ 'street': values.get('street') or self.street,
+ },
+ 'name': {
+ 'firstName': values.get('first_name') or self.first_name,
+ 'lastName': values.get('last_name') or self.last_name,
+ 'gender': 'UNKNOWN'
+ },
+ 'personalData': {
+ 'dateOfBirth': str(values.get('date_of_birth') or self.date_of_birth),
+ }
+ }]
+ }
+ }
+ }
+
+ # documentData cannot be present in the data if not set
+ document_number = values.get('document_number') or self.document_number
+ if document_number:
+ data['accountHolderDetails']['businessDetails']['shareholders'][0]['personalData']['documentData'] = [{
+ 'number': document_number,
+ 'type': 'ID',
+ }]
+
+ return data
diff --git a/addons/adyen_platforms/models/adyen_transaction.py b/addons/adyen_platforms/models/adyen_transaction.py
index 3cec7a7c406..a65c1161b34 100644
--- a/addons/adyen_platforms/models/adyen_transaction.py
+++ b/addons/adyen_platforms/models/adyen_transaction.py
@@ -1,25 +1,267 @@
-# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
-from datetime import datetime
+import logging
+
+from dateutil.parser import parse
+from dateutil.relativedelta import relativedelta
from pytz import UTC
-from odoo import api, fields, models
+from odoo import _, api, fields, models
+from odoo.exceptions import ValidationError
+from odoo.osv import expression
from odoo.tools import DEFAULT_SERVER_DATETIME_FORMAT
+from odoo.addons.adyen_platforms.util import to_major_currency, to_minor_currency
+
+_logger = logging.getLogger(__name__)
+
class AdyenTransaction(models.Model):
_name = 'adyen.transaction'
_description = 'Adyen for Platforms Transaction'
_order = 'date desc'
+ _rec_name = 'reference'
adyen_account_id = fields.Many2one('adyen.account')
- reference = fields.Char('Reference')
- amount = fields.Float('Amount')
+ reference = fields.Char('Reference', index=True, required=True)
+ capture_reference = fields.Char('Capture Reference')
+ total_amount = fields.Float('Customer Amount')
currency_id = fields.Many2one('res.currency', string='Currency')
+ merchant_amount = fields.Float('Merchant Amount')
+ fees = fields.Float('Fees')
+ fixed_fees = fields.Float('Fixed Fees')
+ variable_fees = fields.Float('Variable Fees')
+ fees_currency_id = fields.Many2one('res.currency', default=lambda self: self.env.ref('base.EUR'))
date = fields.Datetime('Date')
description = fields.Char('Description')
- status = fields.Selection(string='Type', selection=[
+ signature = fields.Char('Signature')
+ reason = fields.Char('Failure Reason')
+
+ status = fields.Selection(related='last_status_id.status')
+ last_status_id = fields.Many2one('adyen.transaction.status', 'Last Status', compute='_compute_last_status_id', store=True, readonly=True)
+ last_status_update = fields.Datetime(related='last_status_id.date', string='Last Status Update')
+ status_ids = fields.One2many('adyen.transaction.status', 'adyen_transaction_id', 'Status History')
+ payment_method = fields.Char('Payment Method')
+ shopper_country_id = fields.Many2one('res.country')
+ card_country_id = fields.Many2one('res.country')
+ commercial_card = fields.Selection([
+ ('yes', 'Yes'),
+ ('no', 'No'),
+ ('unknown', 'Unknown'),
+ ], default='unknown')
+ dispute_reference = fields.Char('Dispute Reference')
+
+ _sql_constraints = [
+ ('reference_unique', 'unique(reference, capture_reference)', 'A transaction with the same reference already exists.'),
+ ]
+
+ @api.depends('status_ids')
+ def _compute_last_status_id(self):
+ self.last_status_id = False
+ for transaction in self.filtered('status_ids'):
+ transaction.last_status_id = transaction.status_ids.sorted('date')[-1]
+
+ def _get_tx_from_notification(self, notification):
+ if notification.get('eventCode') in ['CAPTURE', 'REFUND']:
+ reference = notification.get('originalReference')
+ capture_reference = notification.get('pspReference')
+ else:
+ reference = notification.get('pspReference')
+ capture_reference = notification.get('capturePspReference')
+
+ domain = [('reference', '=', reference)]
+ if capture_reference:
+ domain = expression.AND([domain, [('capture_reference', '=', capture_reference)]])
+
+ if self:
+ return self.filtered_domain(domain), reference, capture_reference
+ return self.env['adyen.transaction'].search(domain), reference, capture_reference
+
+ @api.model
+ def _handle_notification(self, data):
+ adyen_uuid = data.get("additionalData", {}).get("metadata.adyen_uuid") or data.get('adyen_uuid')
+ account = self.env['adyen.account'].sudo().search([('adyen_uuid', '=', adyen_uuid)])
+ if not account:
+ _logger.warning("Received payment notification for non-existing account")
+ return
+
+ tx_sudo, reference, capture_reference = self.env['adyen.transaction']._get_tx_from_notification(data)
+ if not tx_sudo:
+ tx_sudo = self.env['adyen.transaction'].sudo().create({
+ 'adyen_account_id': account.id,
+ 'reference': reference,
+ 'capture_reference': capture_reference,
+ 'description': data.get('merchantReference'),
+ })
+
+ event_code = data.get('eventCode')
+ if event_code == "AUTHORISATION":
+ tx_sudo._handle_authorisation_notification(data)
+ elif event_code == "FEES_UPDATED":
+ tx_sudo._handle_fees_updated_notification(data)
+ elif event_code == "REFUND":
+ tx_sudo._handle_refund_notification(data)
+ elif event_code in ["CHARGEBACK", "NOTIFICATION_OF_CHARGEBACK"]:
+ tx_sudo._handle_chargeback_notification(data)
+ else:
+ _logger.warning(_("Unknown eventCode received: %s", event_code))
+
+ return tx_sudo
+
+ def _handle_authorisation_notification(self, notification_data):
+ self.ensure_one()
+ additional_data = notification_data.get('additionalData', {})
+
+ currency_id = self.env['res.currency'].search([('name', '=', notification_data.get('amount', {}).get('currency'))])
+ shopper_country_id = self.env['res.country'].search([('code', '=', additional_data.get('shopperCountry'))])
+ commercial_card = additional_data.get('isCardCommercial', 'unknown')
+ card_country_id = self.env['res.country'].search([('code', '=', additional_data.get('cardIssuingCountry', additional_data.get('issuerCountry')))])
+
+ self.write({
+ 'reference': notification_data.get('pspReference'),
+ 'total_amount': to_major_currency(notification_data['amount']['value'], currency_id.decimal_places),
+ 'currency_id': currency_id.id,
+ 'date': parse(notification_data.get('eventDate')).astimezone(UTC).strftime(DEFAULT_SERVER_DATETIME_FORMAT),
+ 'description': notification_data.get('merchantReference'),
+ 'payment_method': notification_data.get('paymentMethod'),
+ 'shopper_country_id': shopper_country_id.id,
+ 'card_country_id': card_country_id.id,
+ 'commercial_card': commercial_card if commercial_card in ('yes', 'no', 'unknown') else 'unknown',
+ })
+ self._trigger_sync()
+
+ def _handle_fees_updated_notification(self, notification_data):
+ self.ensure_one()
+
+ currency_amount = self.env['res.currency'].search([('name', '=', notification_data.get('totalAmount', {}).get('currency'))])
+ currency_fees = self.env['res.currency'].search([('name', '=', notification_data.get('totalFees', {}).get('currency'))])
+ self.capture_reference = notification_data.get('captureReference')
+ self.fees = to_major_currency(notification_data['totalFees']['value'], currency_fees.decimal_places)
+ self.fixed_fees = to_major_currency(notification_data['fixedFees']['value'], currency_fees.decimal_places)
+ self.variable_fees = to_major_currency(notification_data['variableFees']['value'], currency_fees.decimal_places)
+ self.merchant_amount = to_major_currency(notification_data['merchantAmount']['value'], currency_amount.decimal_places)
+ self.total_amount = to_major_currency(notification_data['totalAmount']['value'], currency_amount.decimal_places)
+ self.signature = notification_data.get('signature')
+
+ def _handle_refund_notification(self, notification_data):
+ self.ensure_one()
+
+ currency_id = self.env['res.currency'].search([('name', '=', notification_data.get('amount', {}).get('currency'))])
+ self.currency_id = currency_id.id
+ self.date = parse(notification_data.get('eventDate')).astimezone(UTC).strftime(DEFAULT_SERVER_DATETIME_FORMAT)
+ reason = notification_data.get('reason')
+ if reason:
+ self.reason = reason
+
+ def _handle_chargeback_notification(self, notification_data):
+ self.ensure_one()
+ self.dispute_reference = notification_data.get('pspReference')
+
+ self.adyen_account_id.message_post(
+ body=_('Transaction %s has been CHARGEBACK\'ed: %s', self.description or self.reference, notification_data.get('reason')),
+ subtype_xmlid="mail.mt_comment"
+ )
+
+ def _create_missing_tx(self, account_id, transaction, **kwargs):
+ currency_id = self.env['res.currency'].search([('name', '=', transaction.get('amount', {}).get('currency'))])
+ amount = to_major_currency(transaction['amount']['value'], currency_id.decimal_places)
+ tx = self.create({
+ 'adyen_account_id': account_id,
+ 'reference': transaction.get('pspReference'),
+ 'capture_reference': transaction.get('capturePspReference'),
+ 'merchant_amount': amount,
+ 'total_amount': amount,
+ 'currency_id': currency_id.id,
+ 'date': parse(transaction.get('creationDate')).astimezone(UTC).strftime(DEFAULT_SERVER_DATETIME_FORMAT),
+ 'description': transaction.get('description'),
+ **kwargs,
+ })
+ return tx
+
+ def _trigger_sync(self):
+ sync_cron = self.env.ref('adyen_platforms.adyen_sync_cron', raise_if_not_found=False)
+ if sync_cron:
+ sync_cron._trigger(at=fields.Datetime.now() + relativedelta(minutes=5))
+
+ def _update_status(self, new_status, date):
+ self.ensure_one()
+
+ if not self.status_ids or self.status_ids[0].date != date:
+ self.status_ids = [(0, 0, {
+ 'adyen_transaction_id': self.id,
+ 'status': new_status,
+ 'date': date,
+ })]
+
+ def _post_transaction_sync(self):
+ """ Hook defined to perform actions on transactions after they were sync'ed """
+ return
+
+ def _refund_request(self, amount=None):
+ self.ensure_one()
+
+ if amount is None:
+ amount = self.total_amount
+
+ if amount > self.total_amount:
+ raise ValidationError(_('You cannot refund more than the original amount.'))
+
+ converted_amount = to_minor_currency(amount, self.currency_id.decimal_places)
+ initial_amount = to_minor_currency(self.total_amount, self.currency_id.decimal_places)
+ fees_amount = to_minor_currency(self.fees, self.fees_currency_id.decimal_places)
+
+ refund_data = {
+ 'originalReference': self.reference,
+ 'modificationAmount': {
+ 'currency': self.currency_id.name,
+ 'value': converted_amount,
+ },
+ 'initialAmount': {
+ 'currency': self.currency_id.name,
+ 'value': initial_amount,
+ },
+ 'feesAmount': {
+ 'currency': self.fees_currency_id.name,
+ 'value': fees_amount,
+ },
+ 'date': str(self.date),
+ 'reference': 'Refund of %s' % self.description, # TODO generate unique reference
+ 'payout': self.adyen_account_id.account_code,
+ 'adyen_uuid': self.adyen_account_id.adyen_uuid,
+ 'signature': self.signature,
+ }
+ res = self.adyen_account_id._adyen_rpc('v1/refund', refund_data)
+
+ refund_tx = self.env['adyen.transaction'].sudo().create({
+ 'adyen_account_id': self.adyen_account_id.id,
+ 'reference': self.reference,
+ 'capture_reference': res['pspReference'],
+ 'description': refund_data.get('reference'),
+ 'currency_id': self.currency_id.id,
+ 'total_amount': to_major_currency(res['totalAmount']['value'], self.currency_id.decimal_places),
+ 'fees': to_major_currency(res['totalFees']['value'], self.currency_id.decimal_places),
+ 'variable_fees': to_major_currency(res['totalFees']['value'], self.currency_id.decimal_places),
+ 'merchant_amount': to_major_currency(res['merchantAmount']['value'], self.currency_id.decimal_places),
+ 'date': fields.Datetime.now(),
+ })
+ self._trigger_sync()
+
+ return refund_tx
+
+ def action_refund(self):
+ for tx in self:
+ tx._refund_request()
+
+
+class AdyenTransactionStatus(models.Model):
+ _name = 'adyen.transaction.status'
+ _description = 'Transaction Status'
+ _order = 'date desc'
+ _rec_name = 'status'
+
+ adyen_transaction_id = fields.Many2one('adyen.transaction', required=True, ondelete='cascade')
+ status = fields.Selection(string='Status', selection=[
+ ('unknown', 'Unknown'),
('PendingCredit', 'Pending Credit'),
('CreditFailed', 'Credit Failed'),
('Credited', 'Credited'),
@@ -33,51 +275,41 @@ class AdyenTransaction(models.Model):
('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')
+ date = fields.Datetime()
- @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
+class AdyenTransactionPayout(models.Model):
+ _name = 'adyen.transaction.payout'
+ _description = 'Payout Transaction'
+ _order = 'date desc'
+
+ adyen_account_id = fields.Many2one('adyen.account')
+ date = fields.Datetime()
+ amount = fields.Float('Amount', required=True)
+ currency_id = fields.Many2one('res.currency', required=True)
+ reference = fields.Char('Reference', index=True, required=True)
+ bank_account_id = fields.Many2one('adyen.bank.account')
+ status = fields.Selection(string='Type', selection=[
+ ('unknown', 'Unknown'),
+ ('Payout', 'Payout'),
+ ('PayoutReversed', 'Payout Reversed'),
+ ], default='unknown')
+
+ def _create_missing_payout(self, account_id, transaction, **kwargs):
+ currency_id = self.env['res.currency'].search([('name', '=', transaction['amount']['currency'])])
+ bank_account_id = self.env['adyen.bank.account'].search([('bank_account_uuid', '=', transaction.get('bankAccountDetail', {}).get('bankAccountUUID'))])
+ tx = self.create({
+ 'adyen_account_id': account_id,
+ 'date': parse(transaction.get('creationDate')).astimezone(UTC).strftime(DEFAULT_SERVER_DATETIME_FORMAT),
+ 'amount': to_major_currency(transaction.get('amount', {}).get('value'), currency_id.decimal_places),
+ 'currency_id': currency_id.id,
+ 'reference': transaction.get('pspReference'),
+ 'bank_account_id': bank_account_id.id,
+ 'status': transaction.get('transactionStatus'),
+ **kwargs,
+ })
+ return tx
diff --git a/addons/adyen_platforms/models/res_company.py b/addons/adyen_platforms/models/res_company.py
index c4767889f10..df1c6fed203 100644
--- a/addons/adyen_platforms/models/res_company.py
+++ b/addons/adyen_platforms/models/res_company.py
@@ -1,4 +1,3 @@
-# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
diff --git a/addons/adyen_platforms/security/ir.model.access.csv b/addons/adyen_platforms/security/ir.model.access.csv
index 62ebdc56fb1..cd5955a4712 100644
--- a/addons/adyen_platforms/security/ir.model.access.csv
+++ b/addons/adyen_platforms/security/ir.model.access.csv
@@ -2,5 +2,8 @@ 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
+access_adyen_kyc_group_erp_manager,adyen.kyc,model_adyen_kyc,base.group_erp_manager,1,0,0,0
+access_adyen_account_balance_group_erp_manager,adyen.account.balance,model_adyen_account_balance,base.group_erp_manager,1,0,0,0
+access_adyen_transaction_status_group_erp_manager,adyen.transaction.status,model_adyen_transaction_status,base.group_erp_manager,1,0,0,0
+access_adyen_transaction_payout_group_erp_manager,adyen.transaction.payout,model_adyen_transaction_payout,base.group_erp_manager,1,0,0,0
diff --git a/addons/adyen_platforms/security/ir_rule.xml b/addons/adyen_platforms/security/ir_rule.xml
new file mode 100644
index 00000000000..7b5b8adaf38
--- /dev/null
+++ b/addons/adyen_platforms/security/ir_rule.xml
@@ -0,0 +1,12 @@
+
+
+
+
+ Readonly: Closed Accounts
+ [('account_status', '!=', 'closed')]
+
+
+
+
+
+
diff --git a/addons/adyen_platforms/static/src/js/adyen_account_fields.js b/addons/adyen_platforms/static/src/js/adyen_account_fields.js
deleted file mode 100644
index 804f27eab4b..00000000000
--- a/addons/adyen_platforms/static/src/js/adyen_account_fields.js
+++ /dev/null
@@ -1,20 +0,0 @@
-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_transactions.js b/addons/adyen_platforms/static/src/js/adyen_transactions.js
deleted file mode 100644
index 3a6fe89631d..00000000000
--- a/addons/adyen_platforms/static/src/js/adyen_transactions.js
+++ /dev/null
@@ -1,33 +0,0 @@
-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/js/adyen_transactions_dashboard.js b/addons/adyen_platforms/static/src/js/adyen_transactions_dashboard.js
new file mode 100644
index 00000000000..4c952801a25
--- /dev/null
+++ b/addons/adyen_platforms/static/src/js/adyen_transactions_dashboard.js
@@ -0,0 +1,39 @@
+odoo.define('adyen_platforms.transactions.dashboard', function (require) {
+ "use strict";
+
+ var ListRenderer = require('web.ListRenderer');
+ var ListView = require('web.ListView');
+
+ var viewRegistry = require('web.view_registry');
+
+ var core = require('web.core');
+ var QWeb = core.qweb;
+
+ var AdyenTransactionsListRenderer = ListRenderer.extend({
+ _render: function () {
+ const el = this.$el.parent();
+ return this._super.apply(this, arguments).then(() => {
+ this._rpc({
+ model: 'adyen.account.balance',
+ method: 'get_account_balance',
+ content: this.context
+ }).then((result) => {
+ el.parent().find('.o_adyen_transactions_dashboard').remove();
+
+ const dash = QWeb.render('AdyenTransactions.dashboard', {
+ balances: result,
+ });
+ el.before(dash);
+ });
+ });
+ }
+ });
+
+ var AdyenTransactionsListView = ListView.extend({
+ config: _.extend({}, ListView.prototype.config, {
+ Renderer: AdyenTransactionsListRenderer
+ }),
+ });
+
+ viewRegistry.add('adyen_transactions_list', AdyenTransactionsListView);
+});
diff --git a/addons/adyen_platforms/static/src/scss/adyen_platforms.scss b/addons/adyen_platforms/static/src/scss/adyen_platforms.scss
new file mode 100644
index 00000000000..f7014742590
--- /dev/null
+++ b/addons/adyen_platforms/static/src/scss/adyen_platforms.scss
@@ -0,0 +1,35 @@
+.o_adyen_transactions_dashboard {
+ .o_adyen_account_balance_card {
+ margin: 4px;
+ flex: 1;
+ text-align: center;
+ padding: 4px;
+
+ h4 {
+ color: $o-enterprise-color;
+ font-weight: bold;
+ }
+
+ ul {
+ list-style-type: none;
+ padding: 0;
+ margin: 0;
+ }
+
+ &:not(:last-child) {
+ border-bottom: 2px solid $o-enterprise-color;
+ }
+
+ @include media-breakpoint-up(sm) {
+ &:not(:last-child) {
+ border-right: 2px solid $o-enterprise-color;
+ border-bottom: 0 !important;
+ }
+ }
+
+ .o_primary_amount {
+ color: $o-enterprise-primary-color;
+ font-size: 2rem;
+ }
+ }
+}
diff --git a/addons/adyen_platforms/static/src/xml/adyen_account_templates.xml b/addons/adyen_platforms/static/src/xml/adyen_account_templates.xml
index f37ba5e9464..d338d426117 100644
--- a/addons/adyen_platforms/static/src/xml/adyen_account_templates.xml
+++ b/addons/adyen_platforms/static/src/xml/adyen_account_templates.xml
@@ -93,14 +93,14 @@
Pricing
- - Payment and processing fees are listed on adyen.com/pricing.
- - Payouts cost 0.20€ (EU) or 0.22$ (US) each.
- - Chargebacks cost 7.5€ each.
- - Onboarding and KYC cost 5€.
+ - European consumer cards: 1.3% + 0.20€
+ - Commercial and non-EU cards: 2.8% + 0.20€
-
- I confirm I have taken notice of and accept the following terms and restrictions:
+
+
- Adyen MarketPay Terms and Conditions (click here to download and review)
- Adyen Restricted and Prohibited Products and Services list (click here to download and review)
diff --git a/addons/adyen_platforms/static/src/xml/adyen_transactions_templates.xml b/addons/adyen_platforms/static/src/xml/adyen_transactions_templates.xml
index 50552e80c6f..1616d0f6e42 100644
--- a/addons/adyen_platforms/static/src/xml/adyen_transactions_templates.xml
+++ b/addons/adyen_platforms/static/src/xml/adyen_transactions_templates.xml
@@ -1,10 +1,18 @@
-
-
-
-
+
+
diff --git a/addons/adyen_platforms/util.py b/addons/adyen_platforms/util.py
index 5352a25192b..8e70c35216b 100644
--- a/addons/adyen_platforms/util.py
+++ b/addons/adyen_platforms/util.py
@@ -2,16 +2,32 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import base64
+import decorator
import hashlib
import hmac
import json
+import logging
import requests
import time
-import werkzeug.urls
+
+from werkzeug.urls import url_decode, url_parse
+from werkzeug.exceptions import Forbidden
+
+from odoo.http import request
+from odoo.tools import float_round
+
+_logger = logging.getLogger(__name__)
+TIMEOUT = 60
+
+def to_major_currency(amount, decimal=2):
+ return float_round(amount, 0) / (10**decimal)
+
+def to_minor_currency(amount, decimal=2):
+ return int(float_round(amount, decimal) * (10**decimal))
class AdyenProxyAuth(requests.auth.AuthBase):
def __init__(self, adyen_account_id):
- super(AdyenProxyAuth, self).__init__()
+ super().__init__()
self.adyen_account_id = adyen_account_id
def __call__(self, request):
@@ -19,7 +35,7 @@ class AdyenProxyAuth(requests.auth.AuthBase):
# Craft the message (timestamp|url path|query params|body content)
msg_timestamp = int(time.time())
- parsed_url = werkzeug.urls.url_parse(request.path_url)
+ parsed_url = url_parse(request.path_url)
body = request.body
if isinstance(body, bytes):
body = body.decode('utf-8')
@@ -28,7 +44,7 @@ class AdyenProxyAuth(requests.auth.AuthBase):
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(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
@@ -40,3 +56,39 @@ class AdyenProxyAuth(requests.auth.AuthBase):
})
return request
+
+@decorator.decorator
+def odoo_payments_proxy_control(func, *args, **kwargs):
+ _logger.debug('Check notification from Odoo Payments')
+
+ adyen_uuid = request.httprequest.headers.get('oe-adyen-uuid')
+ account_id = request.env['adyen.account'].sudo().search([('adyen_uuid', '=', adyen_uuid)])
+ if not account_id:
+ raise Forbidden()
+
+ secret = account_id.proxy_token.encode('utf8')
+ msg_signature = request.httprequest.headers.get('oe-signature') # base64 encoded hmac sha256 digest
+ msg_timestamp = request.httprequest.headers.get('oe-timestamp')
+
+ if not (secret and msg_signature and msg_timestamp):
+ raise Forbidden()
+
+ if int(msg_timestamp) + TIMEOUT < int(time.time()):
+ _logger.debug('HTTP request validation failed due to invalid timestamp for route %r', request.httprequest.path)
+ raise Forbidden()
+
+ h = hmac.new(secret, digestmod=hashlib.sha256)
+ body = json.dumps(request.jsonrequest, sort_keys=True)
+
+ message = '%s|%s|%s|%s' % (
+ msg_timestamp, # timestamp
+ request.httprequest.path, # url path
+ json.dumps(url_decode(request.httprequest.query_string), sort_keys=True), # url query params sorted by key
+ body, # http request body
+ )
+
+ h.update(message.encode('utf-8'))
+
+ if not hmac.compare_digest(h.digest(), base64.b64decode(msg_signature)):
+ raise Forbidden()
+ return func(*args, **kwargs)
diff --git a/addons/adyen_platforms/views/adyen_account_templates.xml b/addons/adyen_platforms/views/adyen_account_templates.xml
index a16df255051..03e97e0e397 100644
--- a/addons/adyen_platforms/views/adyen_account_templates.xml
+++ b/addons/adyen_platforms/views/adyen_account_templates.xml
@@ -1,30 +1,30 @@
- New KYC Status:
-
- Reason(s):
-
- -
-
-
- -
- Shareholders:
-
-
- -
- Bank Accounts:
-
-
-
-
+
+
+
+
+
+
+
+
+
+ :
+
diff --git a/addons/adyen_platforms/views/adyen_account_views.xml b/addons/adyen_platforms/views/adyen_account_views.xml
index d6b650a219f..a574cd400a8 100644
--- a/addons/adyen_platforms/views/adyen_account_views.xml
+++ b/addons/adyen_platforms/views/adyen_account_views.xml
@@ -5,8 +5,15 @@
adyen.account
-
-
-
-
-
-
+
+
+
+
+
+
+
+
@@ -113,131 +156,19 @@
-
- adyen.shareholder.view.form
- adyen.shareholder
-
-
-
-
-
-
- adyen.bank.account.view.form
- adyen.bank.account
-
-
-
-
-
-
- adyen.payout.view.form
- adyen.payout
-
-
-
-
-
Create an Adyen Account
adyen.account
form
+
+
+ Account Configuration
+
+ ir.actions.server
+ code
+
+ action = model.action_create_redirect()
+
+
diff --git a/addons/adyen_platforms/views/adyen_bank_account_views.xml b/addons/adyen_platforms/views/adyen_bank_account_views.xml
new file mode 100644
index 00000000000..af72c78457f
--- /dev/null
+++ b/addons/adyen_platforms/views/adyen_bank_account_views.xml
@@ -0,0 +1,58 @@
+
+
+
+ adyen.bank.account.view.form
+ adyen.bank.account
+
+
+
+
+
diff --git a/addons/adyen_platforms/views/adyen_shareholder_views.xml b/addons/adyen_platforms/views/adyen_shareholder_views.xml
new file mode 100644
index 00000000000..5ced2f9bf37
--- /dev/null
+++ b/addons/adyen_platforms/views/adyen_shareholder_views.xml
@@ -0,0 +1,50 @@
+
+
+
+ adyen.shareholder.view.form
+ adyen.shareholder
+
+
+
+
+
diff --git a/addons/adyen_platforms/views/adyen_transaction_views.xml b/addons/adyen_platforms/views/adyen_transaction_views.xml
index 667a4d3688e..cdae7ac8f77 100644
--- a/addons/adyen_platforms/views/adyen_transaction_views.xml
+++ b/addons/adyen_platforms/views/adyen_transaction_views.xml
@@ -5,14 +5,43 @@
adyen.transaction
@@ -23,14 +52,99 @@
adyen.transaction.view.list
adyen.transaction
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ adyen.transaction
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ adyen.transaction.payout.view.list
+ adyen.transaction.payout
+ 10
+
+
+
+
+
+
+
+
+
+
+
+
+
+ adyen.transaction.payout
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Payment Transactions
+ adyen.transaction
+ tree,form
+ []
+ {}
+
+
+ There are no transactions yet.
+
+
+
+
+
+ Balance
+ adyen.transaction.payout
+ tree
+ []
+ {}
+
+
+ No payouts yet.
+
+
+
diff --git a/addons/payment/models/account_move.py b/addons/payment/models/account_move.py
index 55f664f4ef0..c86732cf7f2 100644
--- a/addons/payment/models/account_move.py
+++ b/addons/payment/models/account_move.py
@@ -30,3 +30,15 @@ class AccountMove(models.Model):
def payment_action_void(self):
self.authorized_transaction_ids._send_void_request()
+
+ def action_view_payment_transactions(self):
+ action = self.env['ir.actions.act_window']._for_xml_id('payment.action_payment_transaction')
+
+ if len(self.transaction_ids) == 1:
+ action['view_mode'] = 'form'
+ action['res_id'] = self.transaction_ids.id
+ action['views'] = []
+ else:
+ action['domain'] = [('id', 'in', self.transaction_ids.ids)]
+
+ return action
diff --git a/addons/payment/views/account_invoice_views.xml b/addons/payment/views/account_invoice_views.xml
index 843eac2a7c3..0f171bbe41c 100644
--- a/addons/payment/views/account_invoice_views.xml
+++ b/addons/payment/views/account_invoice_views.xml
@@ -22,6 +22,13 @@
confirm="Are you sure you want to void the authorized transaction? This action can't be undone."
attrs="{'invisible': ['|', '|', ('move_type', 'not in', ('out_invoice', 'out_refund', 'in_invoice', 'in_refund')), ('state', '!=', 'posted'), ('authorized_transaction_ids', '=', [])]}"/>
+
+
+
+
diff --git a/addons/payment_odoo/__manifest__.py b/addons/payment_odoo/__manifest__.py
index 299c15f287d..07de8fad857 100644
--- a/addons/payment_odoo/__manifest__.py
+++ b/addons/payment_odoo/__manifest__.py
@@ -9,6 +9,7 @@
'description': """Odoo Payments""",
'depends': ['payment', 'adyen_platforms'],
'data': [
+ 'views/adyen_transaction_views.xml',
'views/payment_views.xml',
'views/payment_odoo_templates.xml',
'data/payment_acquirer_data.xml',
diff --git a/addons/payment_odoo/controllers/main.py b/addons/payment_odoo/controllers/main.py
index 063eda29fab..cd4d5ed6147 100644
--- a/addons/payment_odoo/controllers/main.py
+++ b/addons/payment_odoo/controllers/main.py
@@ -37,19 +37,31 @@ class OdooController(http.Controller):
tx_sudo = request.env['payment.transaction'].sudo()._get_tx_from_feedback_data(
'odoo', notification_data
)
- if not self._verify_notification_signature(received_signature, tx_sudo):
+
+ # Ignore signature for REFUND, the signature will be the one of the original transaction
+ event_code = notification_data['eventCode']
+ if event_code != 'REFUND' and not self._verify_notification_signature(
+ received_signature, tx_sudo
+ ):
return
_logger.info("notification received:\n%s", pprint.pformat(notification_data))
- if notification_data['success'] != 'true':
+ if notification_data['success'] != 'true' and event_code != 'REFUND':
return # Don't handle failed events
+ request.env['adyen.transaction'].sudo()._handle_payment_notification(
+ notification_data, tx_sudo
+ )
+
# Reshape the notification data for parsing
- event_code = notification_data['eventCode']
if event_code == 'AUTHORISATION':
notification_data['resultCode'] = 'Authorised'
elif event_code == 'CANCELLATION':
notification_data['resultCode'] = 'Cancelled'
+ elif event_code in ['NOTIFICATION_OF_CHARGEBACK', 'CHARGEBACK']:
+ notification_data['resultCode'] = 'Chargeback'
+ elif event_code == 'REFUND':
+ notification_data['resultCode'] = 'Refund'
else:
return # Don't handle unsupported event codes
diff --git a/addons/payment_odoo/models/__init__.py b/addons/payment_odoo/models/__init__.py
index 22eb69117db..ba88253ca9c 100644
--- a/addons/payment_odoo/models/__init__.py
+++ b/addons/payment_odoo/models/__init__.py
@@ -1,5 +1,8 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
+from . import account_move
+from . import adyen_account
+from . import adyen_transaction
from . import payment_acquirer
from . import payment_token
from . import payment_transaction
diff --git a/addons/payment_odoo/models/account_move.py b/addons/payment_odoo/models/account_move.py
new file mode 100644
index 00000000000..34c751b3d15
--- /dev/null
+++ b/addons/payment_odoo/models/account_move.py
@@ -0,0 +1,23 @@
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import fields, models
+
+
+class AccountMove(models.Model):
+ _inherit = 'account.move'
+
+ adyen_transaction_ids = fields.One2many(related='transaction_ids.adyen_transaction_ids')
+
+ def action_view_payment_transactions(self):
+ if any(provider != 'odoo' for provider in self.transaction_ids.mapped('provider')):
+ return super().action_view_payment_transactions()
+
+ action = self.env['ir.actions.act_window']._for_xml_id('adyen_platforms.adyen_transaction_action')
+ if len(self.adyen_transaction_ids) == 1:
+ action['view_mode'] = 'form'
+ action['res_id'] = self.adyen_transaction_ids.id
+ action['views'] = []
+ else:
+ action['domain'] = [('id', 'in', self.adyen_transaction_ids.ids)]
+
+ return action
diff --git a/addons/payment_odoo/models/adyen_account.py b/addons/payment_odoo/models/adyen_account.py
new file mode 100644
index 00000000000..f61cda16c16
--- /dev/null
+++ b/addons/payment_odoo/models/adyen_account.py
@@ -0,0 +1,12 @@
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import models
+
+
+class AdyenAccount(models.Model):
+ _inherit = 'adyen.account'
+
+ def unlink(self):
+ acquirer = self.env['payment.acquirer'].search([('provider', '=', 'odoo'), ('odoo_adyen_account_id', 'in', self.ids)])
+ acquirer.state = 'disabled'
+ return super().unlink()
diff --git a/addons/payment_odoo/models/adyen_transaction.py b/addons/payment_odoo/models/adyen_transaction.py
new file mode 100644
index 00000000000..b2882a57535
--- /dev/null
+++ b/addons/payment_odoo/models/adyen_transaction.py
@@ -0,0 +1,57 @@
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import api, fields, models
+from odoo.tools.float_utils import float_is_zero
+
+
+class AdyenTransaction(models.Model):
+ _inherit = 'adyen.transaction'
+
+ payment_transaction_id = fields.Many2one(comodel_name='payment.transaction')
+
+ # payment.transaction fields
+ invoice_ids = fields.Many2many(related='payment_transaction_id.invoice_ids')
+ invoices_count = fields.Integer(related='payment_transaction_id.invoices_count')
+
+ partner_id = fields.Many2one(related='payment_transaction_id.partner_id')
+ partner_name = fields.Char(related='payment_transaction_id.partner_name')
+ partner_lang = fields.Selection(related='payment_transaction_id.partner_lang')
+ partner_email = fields.Char(related='payment_transaction_id.partner_email')
+ partner_address = fields.Char(related='payment_transaction_id.partner_address')
+ partner_zip = fields.Char(related='payment_transaction_id.partner_zip')
+ partner_city = fields.Char(related='payment_transaction_id.partner_city')
+ partner_state_id = fields.Many2one(related='payment_transaction_id.partner_state_id')
+ partner_country_id = fields.Many2one(related='payment_transaction_id.partner_country_id')
+ partner_phone = fields.Char(related='payment_transaction_id.partner_phone')
+
+ @api.model
+ def _handle_payment_notification(self, notification_data, payment_tx):
+ tx_sudo = self._handle_notification(notification_data)
+
+ if not tx_sudo.payment_transaction_id:
+ tx_sudo.payment_transaction_id = payment_tx.id
+
+ # Update fees on payment.transaction
+ if float_is_zero(payment_tx.fees, precision_digits=payment_tx.currency_id.decimal_places) and not float_is_zero(tx_sudo.fees, precision_digits=tx_sudo.fees_currency_id.decimal_places):
+ amount = tx_sudo.fees_currency_id._convert(tx_sudo.fees, payment_tx.currency_id, self.env.company, tx_sudo.date or fields.Date.today())
+ payment_tx.fees = amount
+
+ def _create_missing_tx(self, account_id, transaction, **kwargs):
+ tx = super()._create_missing_tx(account_id, transaction, **kwargs)
+ if tx.description and not tx.payment_transaction_id:
+ payment_tx = self.env['payment.transaction'].search([('reference', '=', tx.description), ('provider', '=', 'odoo')])
+ if payment_tx:
+ tx.payment_transaction_id = payment_tx.id
+
+ if not transaction.get('disputePspReference') and tx.merchant_amount > 0:
+ tx.total_amount = payment_tx.amount
+ tx.currency_id = payment_tx.currency_id
+
+ fees_amount = tx.total_amount - tx.merchant_amount
+ tx.fees = payment_tx.currency_id._convert(fees_amount, tx.fees_currency_id, self.env.company, tx.date or fields.Date.today())
+ tx.variable_fees = tx.fees
+ return tx
+
+ def action_view_invoices(self):
+ self.ensure_one()
+ return self.payment_transaction_id.action_view_invoices()
diff --git a/addons/payment_odoo/models/payment_acquirer.py b/addons/payment_odoo/models/payment_acquirer.py
index 57dd6e68522..f0ef0ee8dff 100644
--- a/addons/payment_odoo/models/payment_acquirer.py
+++ b/addons/payment_odoo/models/payment_acquirer.py
@@ -2,8 +2,7 @@
from werkzeug import urls
-from odoo import _, api, fields, models
-from odoo.exceptions import ValidationError
+from odoo import fields, models
class PaymentAcquirer(models.Model):
@@ -13,14 +12,6 @@ class PaymentAcquirer(models.Model):
selection_add=[('odoo', "Odoo Payments")], ondelete={'odoo': 'set default'})
odoo_adyen_account_id = fields.Many2one(
related='company_id.adyen_account_id', required_if_provider='odoo')
- odoo_adyen_payout_id = fields.Many2one(
- string="Adyen Payout", comodel_name='adyen.payout', required_if_provider='odoo',
- domain='[("adyen_account_id", "=", odoo_adyen_account_id)]')
-
- @api.constrains('provider', 'state')
- def _check_state_is_not_test(self):
- if any(a.provider == 'odoo' and a.state == 'test' for a in self):
- raise ValidationError(_("Odoo Payments is not available in test mode."))
def odoo_create_adyen_account(self):
return self.env['adyen.account'].action_create_redirect()
@@ -28,7 +19,8 @@ class PaymentAcquirer(models.Model):
def _odoo_get_api_url(self):
self.ensure_one()
proxy_url = self.env['ir.config_parameter'].sudo().get_param('adyen_platforms.proxy_url')
- return urls.url_join(proxy_url, 'pay_by_link')
+ url = 'v1/pay_by_link' if self.state == 'enabled' else 'v1/test_pay_by_link'
+ return urls.url_join(proxy_url, url)
def _odoo_compute_shopper_reference(self, partner_id):
""" Compute a unique reference of the partner for Adyen.
diff --git a/addons/payment_odoo/models/payment_transaction.py b/addons/payment_odoo/models/payment_transaction.py
index 8b56bed77af..d273acd37ee 100644
--- a/addons/payment_odoo/models/payment_transaction.py
+++ b/addons/payment_odoo/models/payment_transaction.py
@@ -6,7 +6,7 @@ import pprint
from werkzeug import urls
-from odoo import _, api, models
+from odoo import _, api, fields, models
from odoo.exceptions import UserError, ValidationError
from odoo.addons.payment import utils as payment_utils
@@ -19,6 +19,8 @@ _logger = logging.getLogger(__name__)
class PaymentTransaction(models.Model):
_inherit = 'payment.transaction'
+ adyen_transaction_ids = fields.One2many('adyen.transaction', 'payment_transaction_id')
+
def _get_specific_rendering_values(self, processing_values):
""" Override of payment to return Odoo-specific rendering values.
@@ -46,8 +48,6 @@ class PaymentTransaction(models.Model):
converted_amount, self.currency_id.name, self.reference
)
data = {
- 'adyen_uuid': self.acquirer_id.odoo_adyen_account_id.adyen_uuid,
- 'payout': self.acquirer_id.odoo_adyen_payout_id.code,
'amount': {
'value': converted_amount,
'currency': self.currency_id.name,
@@ -56,8 +56,8 @@ class PaymentTransaction(models.Model):
'shopperLocale': lang_code,
'shopperReference': self.acquirer_id._odoo_compute_shopper_reference(
self.partner_id.id
- ),
- 'recurringProcessingModel': 'CardOnFile',
+ ) if self.tokenize else '',
+ 'recurringProcessingModel': 'Subscription' if self.tokenize else '',
'storePaymentMethod': self.tokenize, # True by default on Adyen side
# Since the Pay by Link API redirects the customer without any payload, we use the
# /payment/status route directly as return url.
@@ -65,6 +65,8 @@ class PaymentTransaction(models.Model):
'metadata': {
'merchant_signature': signature,
'notification_url': urls.url_join(base_url, OdooController._notification_url),
+ 'adyen_uuid': self.acquirer_id.odoo_adyen_account_id.adyen_uuid,
+ 'payout': self.acquirer_id.odoo_adyen_account_id.account_code,
}, # Proxy-specific data
}
return {
@@ -96,7 +98,6 @@ class PaymentTransaction(models.Model):
converted_amount, self.currency_id.name, self.reference
)
data = {
- 'payout': self.acquirer_id.odoo_adyen_payout_id.code,
'amount': {
'value': converted_amount,
'currency': self.currency_id.name,
@@ -114,9 +115,11 @@ class PaymentTransaction(models.Model):
'metadata': {
'merchant_signature': signature,
'notification_url': urls.url_join(base_url, OdooController._notification_url),
+ 'adyen_uuid': self.acquirer_id.odoo_adyen_account_id.adyen_uuid,
+ 'payout': self.acquirer_id.odoo_adyen_account_id.account_code,
}, # Proxy-specific data
}
- response_content = self.acquirer_id.odoo_adyen_account_id._adyen_rpc('payments', data)
+ response_content = self.acquirer_id.odoo_adyen_account_id._adyen_rpc('v1/payments', data)
# Handle the payment request response
_logger.info("payment request response:\n%s", pprint.pformat(response_content))
@@ -137,7 +140,7 @@ class PaymentTransaction(models.Model):
if provider != 'odoo':
return tx
- reference = data.get('merchantReference')
+ reference = data.get('merchantReference') or data.get('additionalData', {}).get('merchantReference')
if not reference:
raise ValidationError(
"Odoo Payments: " + _("Received data with missing merchant reference")
@@ -168,8 +171,10 @@ class PaymentTransaction(models.Model):
return
# Handle the acquirer reference
- if 'pspReference' in data:
- self.acquirer_reference = data.get('pspReference')
+ if 'originalReference' in data:
+ self.acquirer_reference = data['originalReference']
+ elif 'pspReference' in data:
+ self.acquirer_reference = data['pspReference']
# Handle the payment state
payment_state = data.get('resultCode')
@@ -206,7 +211,7 @@ class PaymentTransaction(models.Model):
# Retrieve all stored payment methods for the customer from the API and match them with the
# acquirer reference of the transaction to find its payment method
response_content = self.acquirer_id.odoo_adyen_account_id._adyen_rpc(
- 'payment_methods',
+ 'v1/payment_methods',
dict(shopperReference=data['additionalData']['recurring.shopperReference']),
)
payment_methods = response_content['storedPaymentMethods']
diff --git a/addons/payment_odoo/views/adyen_transaction_views.xml b/addons/payment_odoo/views/adyen_transaction_views.xml
new file mode 100644
index 00000000000..48b914a85df
--- /dev/null
+++ b/addons/payment_odoo/views/adyen_transaction_views.xml
@@ -0,0 +1,43 @@
+
+
+
+ adyen.transaction
+
+
+
+
+
+
+
+
+
+ adyen.transaction
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/addons/payment_odoo/views/payment_views.xml b/addons/payment_odoo/views/payment_views.xml
index 4bbc4184ea9..e35145c48b8 100644
--- a/addons/payment_odoo/views/payment_views.xml
+++ b/addons/payment_odoo/views/payment_views.xml
@@ -17,11 +17,14 @@
-
+
+
+
+
+
diff --git a/addons/pos_adyen/models/adyen_account.py b/addons/pos_adyen/models/adyen_account.py
index e6621b4a03d..b3c2eb41794 100644
--- a/addons/pos_adyen/models/adyen_account.py
+++ b/addons/pos_adyen/models/adyen_account.py
@@ -47,7 +47,7 @@ class AdyenStore(models.Model):
@api.model
def create(self, values):
adyen_store_id = super(AdyenStore, self).create(values)
- response = adyen_store_id.adyen_account_id._adyen_rpc('create_store', adyen_store_id._format_data())
+ response = adyen_store_id.adyen_account_id._adyen_rpc('v1/create_store', 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({
@@ -57,7 +57,7 @@ class AdyenStore(models.Model):
def unlink(self):
for store_id in self:
- store_id.adyen_account_id._adyen_rpc('close_stores', {
+ store_id.adyen_account_id._adyen_rpc('v1/close_stores', {
'accountHolderCode': store_id.adyen_account_id.account_holder_code,
'stores': [store_id.store_uuid],
})
@@ -97,7 +97,7 @@ class AdyenTerminal(models.Model):
@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', {
+ response = adyen_store_id.adyen_account_id._adyen_rpc('v1/connected_terminals', {
'store': adyen_store_id.store_uuid,
})
terminals_in_db = set(self.search([('store_id', '=', adyen_store_id.id)]).mapped('terminal_uuid'))
diff --git a/addons/pos_adyen/models/pos_payment_method.py b/addons/pos_adyen/models/pos_payment_method.py
index 00320b25aee..31058fe9290 100644
--- a/addons/pos_adyen/models/pos_payment_method.py
+++ b/addons/pos_adyen/models/pos_payment_method.py
@@ -136,7 +136,7 @@ class PosPaymentMethod(models.Model):
def _proxy_adyen_request_odoo_proxy(self, data, operation):
try:
company_sudo = self.env.company.sudo()
- return company_sudo.adyen_account_id._adyen_rpc(operation, {
+ return company_sudo.adyen_account_id._adyen_rpc('v1/%s' % operation, {
'request_data': data,
'account_code': self.sudo().adyen_payout_id.code,
'notification_url': company_sudo.get_base_url(),
diff --git a/addons/sale_payment_odoo/__init__.py b/addons/sale_payment_odoo/__init__.py
new file mode 100644
index 00000000000..d6210b1285d
--- /dev/null
+++ b/addons/sale_payment_odoo/__init__.py
@@ -0,0 +1,3 @@
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from . import models
diff --git a/addons/sale_payment_odoo/__manifest__.py b/addons/sale_payment_odoo/__manifest__.py
new file mode 100644
index 00000000000..7c9c5fe15e6
--- /dev/null
+++ b/addons/sale_payment_odoo/__manifest__.py
@@ -0,0 +1,15 @@
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+{
+ 'name': 'Sale Odoo Payments',
+ 'version': '1.0',
+ 'category': 'Accounting/Payment Acquirers',
+ 'sequence': 330,
+ 'summary': 'Payment Acquirer: Odoo Payments',
+ 'description': """Sale Odoo Payments integration: show SO on payment transactions.""",
+ 'depends': ['payment_odoo', 'sale'],
+ 'data': [
+ 'views/adyen_transaction_views.xml',
+ ],
+ 'auto_install': True,
+}
diff --git a/addons/sale_payment_odoo/models/__init__.py b/addons/sale_payment_odoo/models/__init__.py
new file mode 100644
index 00000000000..7e667f37654
--- /dev/null
+++ b/addons/sale_payment_odoo/models/__init__.py
@@ -0,0 +1,3 @@
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from . import adyen_transaction
diff --git a/addons/sale_payment_odoo/models/adyen_transaction.py b/addons/sale_payment_odoo/models/adyen_transaction.py
new file mode 100644
index 00000000000..c98d5f78d78
--- /dev/null
+++ b/addons/sale_payment_odoo/models/adyen_transaction.py
@@ -0,0 +1,13 @@
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import fields, models
+
+
+class AdyenTransaction(models.Model):
+ _inherit = 'adyen.transaction'
+
+ sale_order_ids = fields.Many2many(related='payment_transaction_id.sale_order_ids')
+ sale_order_ids_nbr = fields.Integer(related='payment_transaction_id.sale_order_ids_nbr')
+
+ def action_view_sales_orders(self):
+ return self.payment_transaction_id.action_view_sales_orders()
diff --git a/addons/sale_payment_odoo/views/adyen_transaction_views.xml b/addons/sale_payment_odoo/views/adyen_transaction_views.xml
new file mode 100644
index 00000000000..6943946b03f
--- /dev/null
+++ b/addons/sale_payment_odoo/views/adyen_transaction_views.xml
@@ -0,0 +1,16 @@
+
+
+
+ adyen.transaction
+
+
+
+
+
+
+
+