Courtesy of Transifex's translators for reporting bad/unclear sentences. closes odoo/odoo#62564 X-original-commit: 9b3b2e8d711e3be75b1faffaf6f4f8f4ec90186e Related: odoo/enterprise#15038 Signed-off-by: Martin Trigaux (mat) <mat@odoo.com>
723 lines
33 KiB
Python
723 lines
33 KiB
Python
# -*- coding: utf-8 -*-
|
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
|
|
|
import os
|
|
import requests
|
|
import uuid
|
|
from werkzeug.urls import url_join
|
|
|
|
from odoo import api, fields, models, _
|
|
from odoo.http import request
|
|
from odoo.exceptions import UserError, ValidationError
|
|
from odoo.tools import date_utils
|
|
|
|
from odoo.addons.adyen_platforms.util import AdyenProxyAuth
|
|
|
|
ADYEN_AVAILABLE_COUNTRIES = ['US', 'AT', 'AU', 'BE', 'CA', 'CH', 'CZ', 'DE', 'ES', 'FI', 'FR', 'GB', 'GR', 'HR', 'IE', 'IT', 'LT', 'LU', 'NL', 'PL', 'PT']
|
|
TIMEOUT = 60
|
|
|
|
|
|
class AdyenAddressMixin(models.AbstractModel):
|
|
_name = 'adyen.address.mixin'
|
|
_description = 'Adyen for 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()
|
|
|
|
|
|
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'
|
|
|
|
# Credentials
|
|
proxy_token = fields.Char('Proxy Token')
|
|
adyen_uuid = fields.Char('Adyen UUID')
|
|
account_holder_code = fields.Char('Account Holder Code', default=lambda self: uuid.uuid4().hex)
|
|
|
|
company_id = fields.Many2one('res.company', default=lambda self: self.env.company)
|
|
payout_ids = fields.One2many('adyen.payout', 'adyen_account_id', string='Payouts')
|
|
shareholder_ids = fields.One2many('adyen.shareholder', 'adyen_account_id', string='Shareholders')
|
|
bank_account_ids = fields.One2many('adyen.bank.account', 'adyen_account_id', string='Bank Accounts')
|
|
transaction_ids = fields.One2many('adyen.transaction', 'adyen_account_id', string='Transactions')
|
|
transactions_count = fields.Integer(compute='_compute_transactions_count')
|
|
|
|
is_business = fields.Boolean('Is a business', required=True)
|
|
|
|
# Contact Info
|
|
full_name = fields.Char(compute='_compute_full_name')
|
|
email = fields.Char('Email', required=True)
|
|
phone_number = fields.Char('Phone Number', required=True)
|
|
|
|
# Individual
|
|
first_name = fields.Char('First Name')
|
|
last_name = fields.Char('Last Name')
|
|
date_of_birth = fields.Date('Date of birth')
|
|
document_number = fields.Char('ID Number',
|
|
help="The type of ID Number required depends on the country:\n"
|
|
"US: Social Security Number (9 digits or last 4 digits)\n"
|
|
"Canada: Social Insurance Number\nItaly: Codice fiscale\n"
|
|
"Australia: Document Number")
|
|
document_type = fields.Selection(string='Document Type', selection=[
|
|
('ID', 'ID'),
|
|
('PASSPORT', 'Passport'),
|
|
('VISA', 'Visa'),
|
|
('DRIVINGLICENSE', 'Driving license'),
|
|
], default='ID')
|
|
|
|
# Business
|
|
legal_business_name = fields.Char('Legal Business Name')
|
|
doing_business_as = fields.Char('Doing Business As')
|
|
registration_number = fields.Char('Registration Number')
|
|
|
|
# KYC
|
|
kyc_status = fields.Selection(string='KYC Status', selection=[
|
|
('awaiting_data', 'Data to provide'),
|
|
('pending', 'Waiting for validation'),
|
|
('passed', 'Confirmed'),
|
|
('failed', 'Failed'),
|
|
], required=True, default='pending')
|
|
kyc_status_message = fields.Char('KYC Status Message', readonly=True)
|
|
|
|
_sql_constraints = [
|
|
('adyen_uuid_uniq', 'UNIQUE(adyen_uuid)', 'Adyen UUID should be unique'),
|
|
]
|
|
|
|
@api.depends('transaction_ids')
|
|
def _compute_transactions_count(self):
|
|
for adyen_account_id in self:
|
|
adyen_account_id.transactions_count = len(adyen_account_id.transaction_ids)
|
|
|
|
@api.depends('first_name', 'last_name', 'legal_business_name')
|
|
def _compute_full_name(self):
|
|
for adyen_account_id in self:
|
|
if adyen_account_id.is_business:
|
|
adyen_account_id.full_name = adyen_account_id.legal_business_name
|
|
else:
|
|
adyen_account_id.full_name = "%s %s" % (adyen_account_id.first_name, adyen_account_id.last_name)
|
|
|
|
@api.model
|
|
def create(self, values):
|
|
adyen_account_id = super(AdyenAccount, self).create(values)
|
|
self.env.company.adyen_account_id = adyen_account_id.id
|
|
|
|
# Create account on odoo.com, proxy and Adyen
|
|
response = adyen_account_id._adyen_rpc('create_account_holder', adyen_account_id._format_data())
|
|
|
|
# Save adyen_uuid and proxy_token, that have been generated by odoo.com and the proxy
|
|
adyen_account_id.with_context(update_from_adyen=True).write({
|
|
'adyen_uuid': response['adyen_uuid'],
|
|
'proxy_token': response['proxy_token'],
|
|
})
|
|
|
|
# A default payout is created for all adyen accounts
|
|
adyen_account_id.env['adyen.payout'].with_context(update_from_adyen=True).create({
|
|
'code': response['adyen_response']['accountCode'],
|
|
'adyen_account_id': adyen_account_id.id,
|
|
})
|
|
return adyen_account_id
|
|
|
|
def write(self, vals):
|
|
res = super(AdyenAccount, self).write(vals)
|
|
if not self.env.context.get('update_from_adyen'):
|
|
self._adyen_rpc('update_account_holder', self._format_data())
|
|
return res
|
|
|
|
def unlink(self):
|
|
for adyen_account_id in self:
|
|
adyen_account_id._adyen_rpc('close_account_holder', {
|
|
'accountHolderCode': adyen_account_id.account_holder_code,
|
|
})
|
|
return super(AdyenAccount, self).unlink()
|
|
|
|
@api.model
|
|
def action_create_redirect(self):
|
|
'''
|
|
Accessing the FormView to create an Adyen account needs to be done through this action.
|
|
The action will redirect the user to accounts.odoo.com to link an Odoo user_id to the Adyen
|
|
account. After logging in on odoo.com the user will be redirected to his DB with a token in
|
|
the URL. This token is then needed to create the Adyen account.
|
|
'''
|
|
if self.env.company.adyen_account_id:
|
|
# An account already exists, show it
|
|
return {
|
|
'name': _('Adyen Account'),
|
|
'view_mode': 'form',
|
|
'res_model': 'adyen.account',
|
|
'res_id': self.env.company.adyen_account_id.id,
|
|
'type': 'ir.actions.act_window',
|
|
}
|
|
return_url = url_join(self.env['ir.config_parameter'].sudo().get_param('web.base.url'), 'adyen_platforms/create_account')
|
|
onboarding_url = self.env['ir.config_parameter'].sudo().get_param('adyen_platforms.onboarding_url')
|
|
return {
|
|
'type': 'ir.actions.act_url',
|
|
'url': url_join(onboarding_url, 'get_creation_token?return_url=%s' % return_url),
|
|
}
|
|
|
|
def action_show_transactions(self):
|
|
return {
|
|
'name': _('Transactions'),
|
|
'view_mode': 'tree,form',
|
|
'domain': [('adyen_account_id', '=', self.id)],
|
|
'res_model': 'adyen.transaction',
|
|
'type': 'ir.actions.act_window',
|
|
'context': {'group_by': ['adyen_payout_id']}
|
|
}
|
|
|
|
def _upload_photo_id(self, document_type, content, filename):
|
|
self._adyen_rpc('upload_document', {
|
|
'documentDetail': {
|
|
'accountHolderCode': self.account_holder_code,
|
|
'documentType': document_type,
|
|
'filename': filename,
|
|
},
|
|
'documentContent': content.decode(),
|
|
})
|
|
|
|
def _format_data(self):
|
|
data = {
|
|
'accountHolderCode': self.account_holder_code,
|
|
'accountHolderDetails': {
|
|
'address': {
|
|
'country': self.country_id.code,
|
|
'stateOrProvince': self.state_id.code or None,
|
|
'city': self.city,
|
|
'postalCode': self.zip,
|
|
'street': self.street,
|
|
'houseNumberOrName': self.house_number_or_name,
|
|
},
|
|
'email': self.email,
|
|
'fullPhoneNumber': self.phone_number,
|
|
},
|
|
'legalEntity': 'Business' if self.is_business else 'Individual',
|
|
}
|
|
|
|
if self.is_business:
|
|
data['accountHolderDetails']['businessDetails'] = {
|
|
'legalBusinessName': self.legal_business_name,
|
|
'doingBusinessAs': self.doing_business_as,
|
|
'registrationNumber': self.registration_number,
|
|
}
|
|
else:
|
|
data['accountHolderDetails']['individualDetails'] = {
|
|
'name': {
|
|
'firstName': self.first_name,
|
|
'lastName': self.last_name,
|
|
'gender': 'UNKNOWN',
|
|
},
|
|
'personalData': {
|
|
'dateOfBirth': str(self.date_of_birth),
|
|
}
|
|
}
|
|
|
|
# documentData cannot be present in the data if not set
|
|
if self.document_number:
|
|
data['accountHolderDetails']['individualDetails']['personalData']['documentData'] = [{
|
|
'number': self.document_number,
|
|
'type': self.document_type,
|
|
}]
|
|
|
|
return data
|
|
|
|
def _adyen_rpc(self, operation, adyen_data={}):
|
|
if operation == 'create_account_holder':
|
|
url = self.env['ir.config_parameter'].sudo().get_param('adyen_platforms.onboarding_url')
|
|
params = {
|
|
'creation_token': request.session.get('adyen_creation_token'),
|
|
'adyen_data': adyen_data,
|
|
}
|
|
auth = None
|
|
else:
|
|
url = self.env['ir.config_parameter'].sudo().get_param('adyen_platforms.proxy_url')
|
|
params = {
|
|
'adyen_uuid': self.adyen_uuid,
|
|
'adyen_data': adyen_data,
|
|
}
|
|
auth = AdyenProxyAuth(self)
|
|
|
|
payload = {
|
|
'jsonrpc': '2.0',
|
|
'params': params,
|
|
}
|
|
try:
|
|
req = requests.post(url_join(url, operation), json=payload, auth=auth, timeout=TIMEOUT)
|
|
req.raise_for_status()
|
|
except requests.exceptions.Timeout:
|
|
raise UserError(_('A timeout occured while trying to reach the Adyen proxy.'))
|
|
except Exception as e:
|
|
raise UserError(_('The Adyen proxy is not reachable, please try again later.'))
|
|
response = req.json()
|
|
|
|
if 'error' in response:
|
|
name = response['error']['data'].get('name').rpartition('.')[-1]
|
|
if name == 'ValidationError':
|
|
raise ValidationError(response['error']['data'].get('arguments')[0])
|
|
else:
|
|
raise UserError(_("We had troubles reaching Adyen, please retry later or contact the support if the problem persists"))
|
|
|
|
result = response.get('result')
|
|
if 'verification' in result:
|
|
self._update_kyc_status(result['verification'])
|
|
|
|
return result
|
|
|
|
@api.model
|
|
def _sync_adyen_cron(self):
|
|
self._sync_adyen_kyc_status()
|
|
self.env['adyen.transaction'].sync_adyen_transactions()
|
|
self.env['adyen.payout']._process_payouts()
|
|
|
|
@api.model
|
|
def _sync_adyen_kyc_status(self):
|
|
for adyen_account_id in self.search([]):
|
|
data = adyen_account_id._adyen_rpc('get_account_holder', {
|
|
'accountHolderCode': adyen_account_id.account_holder_code,
|
|
})
|
|
adyen_account_id._update_kyc_status(data['verification'])
|
|
|
|
def _update_kyc_status(self, checks):
|
|
all_checks_status = []
|
|
|
|
# Account Holder Checks
|
|
account_holder_checks = checks.get('accountHolder', {})
|
|
account_holder_messages = []
|
|
for check in account_holder_checks.get('checks'):
|
|
all_checks_status.append(check['status'])
|
|
kyc_status_message = self._get_kyc_message(check)
|
|
if kyc_status_message:
|
|
account_holder_messages.append(kyc_status_message)
|
|
|
|
# Shareholders Checks
|
|
shareholder_checks = checks.get('shareholders', {})
|
|
shareholder_messages = []
|
|
kyc_status_message = False
|
|
for sc in shareholder_checks:
|
|
shareholder_status = []
|
|
shareholder_id = self.shareholder_ids.filtered(lambda shareholder: shareholder.shareholder_uuid == sc['shareholderCode'])
|
|
for check in sc.get('checks'):
|
|
all_checks_status.append(check['status'])
|
|
shareholder_status.append(check['status'])
|
|
kyc_status_message = self._get_kyc_message(check)
|
|
if kyc_status_message:
|
|
shareholder_messages.append('[%s] %s' % (shareholder_id.display_name, kyc_status_message))
|
|
shareholder_id.with_context(update_from_adyen=True).write({
|
|
'kyc_status': self.get_status(shareholder_status),
|
|
'kyc_status_message': kyc_status_message,
|
|
})
|
|
|
|
# Bank Account Checks
|
|
bank_account_checks = checks.get('bankAccounts', {})
|
|
bank_account_messages = []
|
|
kyc_status_message = False
|
|
for bac in bank_account_checks:
|
|
bank_account_status = []
|
|
bank_account_id = self.bank_account_ids.filtered(lambda bank_account: bank_account.bank_account_uuid == bac['bankAccountUUID'])
|
|
for check in bac.get('checks'):
|
|
all_checks_status.append(check['status'])
|
|
bank_account_status.append(check['status'])
|
|
kyc_status_message = self._get_kyc_message(check)
|
|
if kyc_status_message:
|
|
bank_account_messages.append('[%s] %s' % (bank_account_id.display_name, kyc_status_message))
|
|
bank_account_id.with_context(update_from_adyen=True).write({
|
|
'kyc_status': self.get_status(bank_account_status),
|
|
'kyc_status_message': kyc_status_message,
|
|
})
|
|
|
|
kyc_status = self.get_status(all_checks_status)
|
|
kyc_status_message = self.env['ir.qweb']._render('adyen_platforms.kyc_status_message', {
|
|
'kyc_status': dict(self._fields['kyc_status'].selection)[kyc_status],
|
|
'account_holder_messages': account_holder_messages,
|
|
'shareholder_messages': shareholder_messages,
|
|
'bank_account_messages': bank_account_messages,
|
|
})
|
|
|
|
if kyc_status_message.decode() != self.kyc_status_message:
|
|
self.sudo().message_post(body = kyc_status_message, subtype_xmlid="mail.mt_comment") # Message from Odoo Bot
|
|
|
|
self.with_context(update_from_adyen=True).write({
|
|
'kyc_status': kyc_status,
|
|
'kyc_status_message': kyc_status_message,
|
|
})
|
|
|
|
@api.model
|
|
def get_status(self, statuses):
|
|
if any(status in ['FAILED'] for status in statuses):
|
|
return 'failed'
|
|
if any(status in ['INVALID_DATA', 'RETRY_LIMIT_REACHED', 'AWAITING_DATA'] for status in statuses):
|
|
return 'awaiting_data'
|
|
if any(status in ['DATA_PROVIDED', 'PENDING'] for status in statuses):
|
|
return 'pending'
|
|
return 'passed'
|
|
|
|
@api.model
|
|
def _get_kyc_message(self, check):
|
|
if check.get('summary', {}).get('kycCheckDescription'):
|
|
return check['summary']['kycCheckDescription']
|
|
if check.get('requiredFields', {}):
|
|
return _('Missing required fields: ') + ', '.join(check.get('requiredFields'))
|
|
return ''
|
|
|
|
|
|
class AdyenShareholder(models.Model):
|
|
_name = 'adyen.shareholder'
|
|
_inherit = ['adyen.id.mixin', 'adyen.address.mixin']
|
|
_description = 'Adyen for 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],
|
|
})
|
|
return super(AdyenShareholder, self).unlink()
|
|
|
|
def _upload_photo_id(self, document_type, content, filename):
|
|
self.adyen_account_id._adyen_rpc('upload_document', {
|
|
'documentDetail': {
|
|
'accountHolderCode': self.adyen_account_id.account_holder_code,
|
|
'shareholderCode': self.shareholder_uuid,
|
|
'documentType': document_type,
|
|
'filename': filename,
|
|
},
|
|
'documentContent': content.decode(),
|
|
})
|
|
|
|
def _format_data(self):
|
|
data = {
|
|
'accountHolderCode': self.adyen_account_id.account_holder_code,
|
|
'accountHolderDetails': {
|
|
'businessDetails': {
|
|
'shareholders': [{
|
|
'shareholderCode': self.shareholder_uuid or None,
|
|
'shareholderReference': self.shareholder_reference,
|
|
'address': {
|
|
'city': self.city,
|
|
'country': self.country_code,
|
|
'houseNumberOrName': self.house_number_or_name,
|
|
'postalCode': self.zip,
|
|
'stateOrProvince': self.state_id.code or None,
|
|
'street': self.street,
|
|
},
|
|
'name': {
|
|
'firstName': self.first_name,
|
|
'lastName': self.last_name,
|
|
'gender': 'UNKNOWN'
|
|
},
|
|
'personalData': {
|
|
'dateOfBirth': str(self.date_of_birth),
|
|
}
|
|
}]
|
|
}
|
|
}
|
|
}
|
|
|
|
# documentData cannot be present in the data if not set
|
|
if self.document_number:
|
|
data['accountHolderDetails']['businessDetails']['shareholders'][0]['personalData']['documentData'] = [{
|
|
'number': self.document_number,
|
|
'type': 'ID',
|
|
}]
|
|
|
|
return data
|
|
|
|
class AdyenBankAccount(models.Model):
|
|
_name = 'adyen.bank.account'
|
|
_description = 'Adyen for 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)
|
|
iban = fields.Char('IBAN')
|
|
account_number = fields.Char('Account Number')
|
|
branch_code = fields.Char('Branch Code')
|
|
bank_code = fields.Char('Bank Code')
|
|
account_type = fields.Selection(string='Account Type', selection=[
|
|
('checking', 'Checking'),
|
|
('savings', 'Savings'),
|
|
])
|
|
owner_country_id = fields.Many2one('res.country', string='Owner Country')
|
|
owner_state_id = fields.Many2one('res.country.state', 'Owner State', domain="[('country_id', '=?', owner_country_id)]")
|
|
owner_street = fields.Char('Owner Street')
|
|
owner_city = fields.Char('Owner City')
|
|
owner_zip = fields.Char('Owner ZIP')
|
|
owner_house_number_or_name = fields.Char('Owner House Number or Name')
|
|
|
|
bank_statement = fields.Binary('Bank Statement', help="You need to provide a bank statement to allow payouts. \
|
|
The file must be a bank statement, a screenshot of your online banking environment, a letter from the bank or a cheque and must contain \
|
|
the logo of the bank or it's name in a unique font, the bank account details, the name of the account holder.\
|
|
Allowed formats: jpg, pdf, png. Maximum allowed size: 10MB.")
|
|
bank_statement_filename = fields.Char()
|
|
|
|
# KYC
|
|
kyc_status = fields.Selection(string='KYC Status', selection=[
|
|
('awaiting_data', 'Data to provide'),
|
|
('pending', 'Waiting for validation'),
|
|
('passed', 'Confirmed'),
|
|
('failed', 'Failed'),
|
|
], required=True, default='pending')
|
|
kyc_status_message = fields.Char('KYC Status Message', readonly=True)
|
|
|
|
@api.model
|
|
def create(self, values):
|
|
adyen_bank_account_id = super(AdyenBankAccount, self).create(values)
|
|
response = adyen_bank_account_id.adyen_account_id._adyen_rpc('update_account_holder', adyen_bank_account_id._format_data())
|
|
bank_accounts = response['accountHolderDetails']['bankAccountDetails']
|
|
created_bank_account = next(bank_account for bank_account in bank_accounts if bank_account['bankAccountReference'] == adyen_bank_account_id.bank_account_reference)
|
|
adyen_bank_account_id.with_context(update_from_adyen=True).write({
|
|
'bank_account_uuid': created_bank_account['bankAccountUUID'],
|
|
})
|
|
return adyen_bank_account_id
|
|
|
|
def write(self, vals):
|
|
res = super(AdyenBankAccount, self).write(vals)
|
|
if not self.env.context.get('update_from_adyen'):
|
|
self.adyen_account_id._adyen_rpc('update_account_holder', self._format_data())
|
|
if 'bank_statement' in vals:
|
|
self._upload_bank_statement(vals['bank_statement'], vals['bank_statement_filename'])
|
|
return res
|
|
|
|
def unlink(self):
|
|
for bank_account_id in self:
|
|
bank_account_id.adyen_account_id._adyen_rpc('delete_bank_accounts', {
|
|
'accountHolderCode': bank_account_id.adyen_account_id.account_holder_code,
|
|
'bankAccountUUIDs': [bank_account_id.bank_account_uuid],
|
|
})
|
|
return super(AdyenBankAccount, self).unlink()
|
|
|
|
def _format_data(self):
|
|
return {
|
|
'accountHolderCode': self.adyen_account_id.account_holder_code,
|
|
'accountHolderDetails': {
|
|
'bankAccountDetails': [{
|
|
'accountNumber': self.account_number or None,
|
|
'accountType': self.account_type or None,
|
|
'bankAccountReference': self.bank_account_reference,
|
|
'bankAccountUUID': self.bank_account_uuid or None,
|
|
'bankCode': self.bank_code or None,
|
|
'branchCode': self.branch_code or None,
|
|
'countryCode': self.country_code,
|
|
'currencyCode': self.currency_id.name,
|
|
'iban': self.iban or None,
|
|
'ownerCity': self.owner_city or None,
|
|
'ownerCountryCode': self.owner_country_id.code or None,
|
|
'ownerHouseNumberOrName': self.owner_house_number_or_name or None,
|
|
'ownerName': self.owner_name,
|
|
'ownerPostalCode': self.owner_zip or None,
|
|
'ownerState': self.owner_state_id.code or None,
|
|
'ownerStreet': self.owner_street or None,
|
|
}],
|
|
}
|
|
}
|
|
|
|
def _upload_bank_statement(self, content, filename):
|
|
file_extension = os.path.splitext(filename)[1]
|
|
file_size = len(content.encode('utf-8'))
|
|
if file_extension not in ['.jpeg', '.jpg', '.pdf', '.png']:
|
|
raise ValidationError(_('Allowed file formats for bank statements are jpeg, jpg, pdf or png'))
|
|
if file_size >> 20 > 10 or (file_size >> 10 < 10 and file_extension != '.pdf') :
|
|
raise ValidationError(_('Bank statements must be greater than 10kB (except for PDFs) and smaller than 10MB'))
|
|
|
|
self.adyen_account_id._adyen_rpc('upload_document', {
|
|
'documentDetail': {
|
|
'accountHolderCode': self.adyen_account_id.account_holder_code,
|
|
'bankAccountUUID': self.bank_account_uuid,
|
|
'documentType': 'BANK_STATEMENT',
|
|
'filename': filename,
|
|
},
|
|
'documentContent': content,
|
|
})
|
|
|
|
|
|
class AdyenPayout(models.Model):
|
|
_name = 'adyen.payout'
|
|
_description = 'Adyen for 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}
|
|
)
|
|
|
|
def _fetch_transactions(self, page=1):
|
|
response = self.adyen_account_id._adyen_rpc('get_transactions', {
|
|
'accountHolderCode': self.adyen_account_id.account_holder_code,
|
|
'transactionListsPerAccount': [{
|
|
'accountCode': self.code,
|
|
'page': page,
|
|
}]
|
|
})
|
|
transaction_list = response['accountTransactionLists'][0]
|
|
return transaction_list['transactions'], transaction_list['hasNextPage']
|