diff options
| author | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
|---|---|---|
| committer | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
| commit | 3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch) | |
| tree | a44932296ef4a9b71d5f010906253d8c53727726 /addons/adyen_platforms/models | |
| parent | 0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff) | |
initial commit 2
Diffstat (limited to 'addons/adyen_platforms/models')
| -rw-r--r-- | addons/adyen_platforms/models/__init__.py | 6 | ||||
| -rw-r--r-- | addons/adyen_platforms/models/adyen_account.py | 722 | ||||
| -rw-r--r-- | addons/adyen_platforms/models/adyen_transaction.py | 83 | ||||
| -rw-r--r-- | addons/adyen_platforms/models/res_company.py | 10 |
4 files changed, 821 insertions, 0 deletions
diff --git a/addons/adyen_platforms/models/__init__.py b/addons/adyen_platforms/models/__init__.py new file mode 100644 index 00000000..480ddb07 --- /dev/null +++ b/addons/adyen_platforms/models/__init__.py @@ -0,0 +1,6 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import adyen_account +from . import adyen_transaction +from . import res_company diff --git a/addons/adyen_platforms/models/adyen_account.py b/addons/adyen_platforms/models/adyen_account.py new file mode 100644 index 00000000..fa03a09d --- /dev/null +++ b/addons/adyen_platforms/models/adyen_account.py @@ -0,0 +1,722 @@ +# -*- 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'] diff --git a/addons/adyen_platforms/models/adyen_transaction.py b/addons/adyen_platforms/models/adyen_transaction.py new file mode 100644 index 00000000..3cec7a7c --- /dev/null +++ b/addons/adyen_platforms/models/adyen_transaction.py @@ -0,0 +1,83 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from datetime import datetime +from pytz import UTC + +from odoo import api, fields, models +from odoo.tools import DEFAULT_SERVER_DATETIME_FORMAT + + +class AdyenTransaction(models.Model): + _name = 'adyen.transaction' + _description = 'Adyen for Platforms Transaction' + _order = 'date desc' + + adyen_account_id = fields.Many2one('adyen.account') + reference = fields.Char('Reference') + amount = fields.Float('Amount') + currency_id = fields.Many2one('res.currency', string='Currency') + date = fields.Datetime('Date') + description = fields.Char('Description') + status = fields.Selection(string='Type', selection=[ + ('PendingCredit', 'Pending Credit'), + ('CreditFailed', 'Credit Failed'), + ('Credited', 'Credited'), + ('Converted', 'Converted'), + ('PendingDebit', 'Pending Debit'), + ('DebitFailed', 'Debit Failed'), + ('Debited', 'Debited'), + ('DebitReversedReceived', 'Debit Reversed Received'), + ('DebitedReversed', 'Debit Reversed'), + ('ChargebackReceived', 'Chargeback Received'), + ('Chargeback', 'Chargeback'), + ('ChargebackReversedReceived', 'Chargeback Reversed Received'), + ('ChargebackReversed', 'Chargeback Reversed'), + ('Payout', 'Payout'), + ('PayoutReversed', 'Payout Reversed'), + ('FundTransfer', 'Fund Transfer'), + ('PendingFundTransfer', 'Pending Fund Transfer'), + ('ManualCorrected', 'Manual Corrected'), + ]) + adyen_payout_id = fields.Many2one('adyen.payout') + + @api.model + def sync_adyen_transactions(self): + ''' Method called by cron to sync transactions from Adyen. + Updates the status of pending transactions and create missing ones. + ''' + for payout_id in self.env['adyen.payout'].search([]): + page = 1 + has_next_page = True + new_transactions = True + pending_statuses = ['PendingCredit', 'PendingDebit', 'DebitReversedReceived', 'ChargebackReceived', 'ChargebackReversedReceived', 'PendingFundTransfer'] + pending_transaction_ids = payout_id.transaction_ids.filtered(lambda tr: tr.status in pending_statuses) + + while has_next_page and (new_transactions or pending_transaction_ids): + # Fetch next transaction page + transactions, has_next_page = payout_id._fetch_transactions(page) + for transaction in transactions: + transaction_reference = transaction.get('paymentPspReference') or transaction.get('pspReference') + transaction_id = payout_id.transaction_ids.filtered(lambda tr: tr.reference == transaction_reference) + if transaction_id: + new_transactions = False + if transaction_id in pending_transaction_ids: + # Update transaction status + transaction_id.sudo().write({ + 'status': transaction['transactionStatus'], + }) + pending_transaction_ids -= transaction_id + else: + currency_id = self.env['res.currency'].search([('name', '=', transaction['amount']['currency'])]) + # New transaction + self.env['adyen.transaction'].sudo().create({ + 'adyen_account_id': payout_id.adyen_account_id.id, + 'reference': transaction_reference, + 'amount': transaction['amount']['value'] / (10 ** currency_id.decimal_places), + 'currency_id': currency_id.id, + 'date': datetime.strptime(transaction['creationDate'], '%Y-%m-%dT%H:%M:%S%z').astimezone(UTC).strftime(DEFAULT_SERVER_DATETIME_FORMAT), + 'description': transaction.get('description'), + 'status': transaction['transactionStatus'], + 'adyen_payout_id': payout_id.id, + }) + page += 1 diff --git a/addons/adyen_platforms/models/res_company.py b/addons/adyen_platforms/models/res_company.py new file mode 100644 index 00000000..c4767889 --- /dev/null +++ b/addons/adyen_platforms/models/res_company.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import fields, models + + +class ResCompany(models.Model): + _inherit = "res.company" + + adyen_account_id = fields.Many2one('adyen.account', string='Adyen Account', readonly=True) |
