[IMP] adyen_platforms,payment_odoo: add notification support
- Improved onboarding / KYC flow
- Display KYC stage for each "document" (identity, bank account,
shareholder, etc.)
- Only update to the API what's been updated by the user to prevent
undergoing another full round of data verification
- Show new pricing
- Support refund of adyen.transaction
- Simplify payouts (let Adyen automatically handle them)
- They are now automatically handled by Adyen and not manually
triggered by a cron
- "Balance" dashboard
- Show the balance per currency
- List of payouts and their status
- Handle account/transaction notifications
- Show split of fees
- Details on payment method (card country, card type, etc.) used
- Display details from the linked payment.transaction
- Verify proxy signature for notifications
- Notifications are now signed and authenticity verified
TaskID: 2129218
Closes odoo/odoo#68952
Co-authored-by: Antoine Prieels <anp@odoo.com>
This commit is contained in:
co-authored by
Antoine Prieels
parent
8e33f7cfaa
commit
208a82f344
@@ -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': [
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
</record>
|
||||
|
||||
<record id="adyen_sync_cron" model="ir.cron">
|
||||
<field name="name">Adyen Sync</field>
|
||||
<field name="name">Odoo Payments Sync</field>
|
||||
<field name="model_id" ref="model_adyen_account"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model._sync_adyen_cron()</field>
|
||||
|
||||
@@ -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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
})
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
@@ -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
|
||||
|
||||
|
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo noupdate="1">
|
||||
<record id="adyen_platforms_account_closed" model="ir.rule">
|
||||
<field name="model_id" ref="model_adyen_account"/>
|
||||
<field name="name">Readonly: Closed Accounts</field>
|
||||
<field name="domain_force">[('account_status', '!=', 'closed')]</field>
|
||||
<field name="perm_read" eval="False"/>
|
||||
<field name="perm_write" eval="True"/>
|
||||
<field name="perm_create" eval="True"/>
|
||||
<field name="perm_unlink" eval="True"/>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -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);
|
||||
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -93,14 +93,14 @@
|
||||
|
||||
<h3>Pricing</h3>
|
||||
<ul>
|
||||
<li>Payment and processing fees are listed on <a href="http://www.adyen.com/pricing">adyen.com/pricing</a>.</li>
|
||||
<li>Payouts cost 0.20€ (EU) or 0.22$ (US) each.</li>
|
||||
<li>Chargebacks cost 7.5€ each.</li>
|
||||
<li>Onboarding and KYC cost 5€.</li>
|
||||
<li>European consumer cards: 1.3% + 0.20€</li>
|
||||
<li>Commercial and non-EU cards: 2.8% + 0.20€</li>
|
||||
</ul>
|
||||
|
||||
<input type="checkbox" class="opt_in_checkbox"/>
|
||||
I confirm I have taken notice of and accept the following terms and restrictions:
|
||||
<input type="checkbox" class="opt_in_checkbox" id="optin"/>
|
||||
<label for="optin">
|
||||
I confirm I have taken notice of and accept the following terms and restrictions:
|
||||
</label>
|
||||
<ul>
|
||||
<li>Adyen MarketPay Terms and Conditions (click <a href="https://www.odoo.com/odoo_adyen/static/src/pdf/marketpay_terms_and_conditions.pdf">here</a> to download and review)</li>
|
||||
<li>Adyen Restricted and Prohibited Products and Services list (click <a href="https://www.odoo.com/odoo_adyen/static/src/pdf/marketpay_prohibited_products_and_services.pdf">here</a> to download and review)</li>
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates>
|
||||
<t t-extend="ListView.buttons" t-name="AdyenTransactionsListView.buttons">
|
||||
<t t-jquery="button.o_list_button_discard" t-operation="after">
|
||||
<button type="button" class="btn btn-primary o_button_sync_transactions">
|
||||
Sync Transactions
|
||||
</button>
|
||||
</t>
|
||||
<t t-name="AdyenTransactions.dashboard">
|
||||
<div class="o_adyen_transactions_dashboard">
|
||||
<div class="d-flex justify-content-around flex-column flex-sm-row">
|
||||
<t t-foreach="balances" t-as="balance">
|
||||
<div class="o_adyen_account_balance_card">
|
||||
<h4 t-attf-title="Last updated on {{balance.last_update}}"><t t-esc="balance.currency" /><t t-if="balance.last_update_warning">*</t> - Balance</h4>
|
||||
<ul>
|
||||
<li class="o_primary_amount"><t t-esc="balance.balance" /></li>
|
||||
<li t-if="balance.payout_date">Next Payout Date: <t t-esc="balance.payout_date" /></li>
|
||||
</ul>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,30 +1,30 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<template id="kyc_status_message">
|
||||
<p>New KYC Status: <t- t-esc="kyc_status"/></p>
|
||||
<p t-if="account_holder_messages + shareholder_messages + bank_account_messages">
|
||||
Reason(s):
|
||||
<ul>
|
||||
<li t-if="account_holder_messages" t-foreach="account_holder_messages" t-as="message">
|
||||
<t t-esc="message"/>
|
||||
</li>
|
||||
<li t-if="shareholder_messages">
|
||||
Shareholders:
|
||||
<ul>
|
||||
<li t-foreach="shareholder_messages" t-as="message">
|
||||
<t t-esc="message"/>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li t-if="bank_account_messages">
|
||||
Bank Accounts:
|
||||
<ul>
|
||||
<li t-foreach="bank_account_messages" t-as="message">
|
||||
<t t-esc="message"/>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</p>
|
||||
<p t-if="message" t-esc="message" />
|
||||
<t t-if="checks">
|
||||
<t t-foreach="checks" t-as="verification_type">
|
||||
<ul>
|
||||
<li><strong><t t-esc="verification_type" />:</strong>
|
||||
<ul>
|
||||
<t t-foreach="checks[verification_type]" t-as="check">
|
||||
<li>
|
||||
<strong t-if="check['document']"><t t-esc="check['document']" />: </strong><t t-esc="check['message']" />
|
||||
</li>
|
||||
</t>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</t>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<template id="status_message">
|
||||
<p t-if="message"><t t-esc="message" />:</p>
|
||||
<ul>
|
||||
<li t-foreach="reasons" t-as="reason">
|
||||
<t t-esc="reason" />
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
</odoo>
|
||||
|
||||
@@ -5,8 +5,15 @@
|
||||
<field name="model">adyen.account</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Adyen Account" create="false" js_class="adyen_account_form">
|
||||
<field name="account_code" invisible="1" />
|
||||
<field name="account_holder_code" invisible="1" />
|
||||
<field name="account_status" invisible="1" />
|
||||
<field name="adyen_uuid" invisible="1"/>
|
||||
<field name="kyc_tier" invisible="1" />
|
||||
<field name="payout_allowed" invisible="1" />
|
||||
<header>
|
||||
<field name="kyc_status" widget="statusbar" statusbar_visible="awaiting_data,pending,passed"/>
|
||||
<button name="sync_transactions" string="Sync Transactions" type="object" attrs="{'invisible': [('account_code', '=', False)]}" groups="base.group_no_one" />
|
||||
<field name="state" widget="statusbar" />
|
||||
</header>
|
||||
<sheet>
|
||||
<div class="oe_button_box" name="button_box">
|
||||
@@ -19,15 +26,21 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-danger" role="alert" attrs="{'invisible': [('account_status', '!=', 'closed')]}">
|
||||
<p>Your account is Closed.</p>
|
||||
</div>
|
||||
<div class="alert alert-warning" role="alert" attrs="{'invisible': [('kyc_status_message', '=', False)]}">
|
||||
<field name="kyc_status_message" readonly="1" />
|
||||
</div>
|
||||
<div class="alert alert-warning" role="alert" attrs="{'invisible': ['|', '|', ('account_code', '=', False), ('state', '=', 'pending'), ('bank_account_ids', '!=', [])]}">
|
||||
<p>A bank account is required to perform payouts.</p>
|
||||
</div>
|
||||
<group>
|
||||
<field name="adyen_uuid" invisible="1"/>
|
||||
|
||||
<group>
|
||||
<field name="is_business" attrs="{'readonly': [('id', '!=', False)]}"/>
|
||||
</group>
|
||||
|
||||
<group>
|
||||
<field name="company_id" readonly="1"/>
|
||||
<field name="company_id" readonly="1" groups="base.group_multi_company" />
|
||||
</group>
|
||||
|
||||
<group string="Individual" attrs="{'invisible': [('is_business', '=', True)]}">
|
||||
@@ -38,14 +51,15 @@
|
||||
<field name="date_of_birth"
|
||||
attrs="{'required': [('is_business', '=', False)]}"/>
|
||||
<field name="document_number"
|
||||
attrs="{'invisible': [('country_code', 'not in', ['AU', 'CA', 'GR', 'IT', 'US'])], 'required': [('is_business', '=', False), ('country_id', 'in', ['AU', 'CA', 'GR', 'IT', 'US'])]}"/>
|
||||
attrs="{'invisible': [('country_code', 'not in', ['AU', 'CA', 'GR', 'IT', 'US'])], 'required': [('is_business', '=', False), ('country_code', 'in', ['AU', 'CA', 'GR', 'IT', 'US'])]}"/>
|
||||
<field name="document_type"
|
||||
attrs="{'invisible': [('country_code', '!=', 'AU')], 'required': [('is_business', '=', False), ('country_id', '=', 'AU')]}"/>
|
||||
<field name="id_type" attrs="{'invisible': [('adyen_uuid', '=', False)]}"/>
|
||||
<field name="id_front" filename="id_front_filename" attrs="{'invisible': [('adyen_uuid', '=', False)]}"/>
|
||||
attrs="{'invisible': [('country_code', '!=', 'AU')], 'required': [('is_business', '=', False), ('country_code', '=', 'AU')]}"/>
|
||||
<field name="id_type" attrs="{'invisible': [('adyen_uuid', '=', False)], 'required': [('is_business', '=', False), ('kyc_tier', '>', 1)]}"/>
|
||||
<field name="id_front" filename="id_front_filename" attrs="{'invisible': [('adyen_uuid', '=', False)], 'required': [('is_business', '=', False), ('kyc_tier', '>', 1)]}"/>
|
||||
<field name="id_front_filename" invisible="1"/>
|
||||
<field name="id_back" filename="id_back_filename"
|
||||
attrs="{'invisible': [('id_type', 'not in', ['ID_CARD', 'DRIVING_LICENSE'])]}"/>
|
||||
attrs="{'invisible': [('id_type', 'not in', ['ID_CARD', 'DRIVING_LICENSE'])],
|
||||
'required': [('id_type', 'in', ['ID_CARD', 'DRIVING_LICENSE']), ('kyc_tier', '>=', 1)]}"/>
|
||||
<field name="id_back_filename" invisible="1"/>
|
||||
</group>
|
||||
|
||||
@@ -76,12 +90,33 @@
|
||||
</group>
|
||||
</group>
|
||||
<notebook attrs="{'invisible': [('adyen_uuid', '=', False)]}">
|
||||
<page string="KYC">
|
||||
<field name="adyen_kyc_ids">
|
||||
<tree no_open="1">
|
||||
<field name="verification_type" />
|
||||
<field name="document" />
|
||||
<field name="status" widget="badge"
|
||||
decoration-warning="status == 'awaiting_data'"
|
||||
decoration-info="status == 'pending'"
|
||||
decoration-success="status in ['data_provided', 'passed']"
|
||||
decoration-danger="status == 'failed'"
|
||||
/>
|
||||
<field name="status_message" />
|
||||
<field name="last_update" />
|
||||
</tree>
|
||||
</field>
|
||||
</page>
|
||||
<page string="Shareholders" attrs="{'invisible': [('is_business', '=', False)], 'required': [('is_business', '=', True)]}">
|
||||
<field name="shareholder_ids">
|
||||
<tree>
|
||||
<field name="first_name"/>
|
||||
<field name="last_name"/>
|
||||
<field name="kyc_status" widget="adyen_kyc_status_tag"/>
|
||||
<field name="kyc_status" widget="badge" attrs="{'invisible': [('kyc_status', '=', False)]}"
|
||||
decoration-warning="kyc_status == 'awaiting_data'"
|
||||
decoration-info="kyc_status == 'pending'"
|
||||
decoration-success="kyc_status in ['data_provided', 'passed']"
|
||||
decoration-danger="kyc_status == 'failed'"
|
||||
/>
|
||||
</tree>
|
||||
</field>
|
||||
</page>
|
||||
@@ -91,17 +126,25 @@
|
||||
<field name="owner_name"/>
|
||||
<field name="iban"/>
|
||||
<field name="account_number"/>
|
||||
<field name="kyc_status" widget="adyen_kyc_status_tag"/>
|
||||
<field name="currency_id"/>
|
||||
<field name="kyc_status" widget="badge" attrs="{'invisible': [('kyc_status', '=', False)]}"
|
||||
decoration-warning="kyc_status == 'awaiting_data'"
|
||||
decoration-info="kyc_status == 'pending'"
|
||||
decoration-success="kyc_status in ['data_provided', 'passed']"
|
||||
decoration-danger="kyc_status == 'failed'"
|
||||
/>
|
||||
</tree>
|
||||
</field>
|
||||
</page>
|
||||
<page string="Payouts">
|
||||
<field name="payout_ids">
|
||||
<tree>
|
||||
<field name="name"/>
|
||||
<field name="payout_schedule"/>
|
||||
</tree>
|
||||
</field>
|
||||
<group>
|
||||
<group>
|
||||
<field name="payout_schedule" attrs="{'readonly': [('payout_allowed', '=', False)]}" />
|
||||
</group>
|
||||
<group>
|
||||
<field name="next_scheduled_payout" attrs="{'invisible': [('next_scheduled_payout', '=', False)]}" />
|
||||
</group>
|
||||
</group>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
@@ -113,131 +156,19 @@
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="adyen_shareholder_view_form" model="ir.ui.view">
|
||||
<field name="name">adyen.shareholder.view.form</field>
|
||||
<field name="model">adyen.shareholder</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Adyen Shareholder">
|
||||
<header>
|
||||
<field name="kyc_status" widget="statusbar" statusbar_visible="awaiting_data,pending,passed"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<div class="alert alert-warning" role="alert"
|
||||
attrs="{'invisible': [('kyc_status_message', '=', False)]}">
|
||||
<field name="kyc_status_message"/>
|
||||
</div>
|
||||
<group>
|
||||
<field name="shareholder_uuid" invisible="1"/>
|
||||
<group>
|
||||
<field name="first_name"/>
|
||||
<field name="last_name"/>
|
||||
<field name="date_of_birth"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="document_number"
|
||||
attrs="{'invisible': [('country_code', 'not in', ['IT', 'US'])], 'required': [('country_code', 'in', ['IT', 'US'])]}"/>
|
||||
<field name="id_type" attrs="{'invisible': [('shareholder_uuid', '=', False)]}"/>
|
||||
<field name="id_front" filename="id_front_filename" attrs="{'invisible': [('shareholder_uuid', '=', False)]}"/>
|
||||
<field name="id_front_filename" invisible="1"/>
|
||||
<field name="id_back" filename="id_back_filename"
|
||||
attrs="{'required': [('id_type', 'in', ['ID_CARD', 'DRIVING_LICENSE'])], 'invisible': [('id_type', 'not in', ['ID_CARD', 'DRIVING_LICENSE'])]}"/>
|
||||
<field name="id_back_filename" invisible="1"/>
|
||||
</group>
|
||||
<group>
|
||||
<label for="street" string="Address"/>
|
||||
<div class="o_address_format">
|
||||
<field name="street" placeholder="Street" class="o_address_street"/>
|
||||
<field name="house_number_or_name" placeholder="House number or name" class="o_address_street"/>
|
||||
<field name="city" placeholder="City" class="o_address_city"/>
|
||||
<field name="state_id" class="o_address_state" placeholder="State" options="{'no_open': True, 'no_quick_create': True}"
|
||||
attrs="{'required': [('country_code', 'in', ['AU', 'CA', 'IT', 'US'])]}"/>
|
||||
<field name="zip" placeholder="ZIP" class="o_address_zip"/>
|
||||
<field name="country_id" placeholder="Country" class="o_address_country" options="{'no_open': True, 'no_create': True}"/>
|
||||
<field name="country_code" invisible="1"/>
|
||||
</div>
|
||||
</group>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="adyen_bank_account_view_form" model="ir.ui.view">
|
||||
<field name="name">adyen.bank.account.view.form</field>
|
||||
<field name="model">adyen.bank.account</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Adyen Bank Account">
|
||||
<header>
|
||||
<field name="kyc_status" widget="statusbar" statusbar_visible="awaiting_data,pending,passed"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<div class="alert alert-warning" role="alert"
|
||||
attrs="{'invisible': [('kyc_status_message', '=', False)]}">
|
||||
<field name="kyc_status_message"/>
|
||||
</div>
|
||||
<group>
|
||||
<field name="bank_account_uuid" invisible="1"/>
|
||||
<group>
|
||||
<field name="country_id" options='{"no_open": True, "no_create": True}'/>
|
||||
<field name="country_code" invisible="1"/>
|
||||
<field name="currency_id"/>
|
||||
<field name="iban"
|
||||
attrs="{'invisible': [('country_code', 'not in', ['AT', 'BE', 'CH', 'CZ', 'DE', 'ES', 'FI', 'FR', 'GB', 'GR', 'HR', 'IE', 'IT', 'LT', 'LU', 'NL', 'PL', 'PT'])],
|
||||
'required': [('country_code', 'in', ['AT', 'BE', 'CH', 'CZ', 'DE', 'ES', 'FI', 'FR', 'GB', 'GR', 'HR', 'IE', 'IT', 'LT', 'LU', 'NL', 'PL', 'PT'])]}"/>
|
||||
<field name="account_type"
|
||||
attrs="{'invisible': [('country_code', '!=', 'US')], 'required': [('country_code', '=', 'US')]}"/>
|
||||
<field name="account_number"
|
||||
attrs="{'invisible': [('country_code', 'not in', ['AU', 'CA', 'US'])], 'required': [('country_code', 'in', ['AU', 'CA', 'US'])]}"/>
|
||||
<field name="branch_code"
|
||||
attrs="{'invisible': [('country_code', 'not in', ['AU', 'CA', 'US'])], 'required': [('country_code', 'in', ['AU', 'CA', 'US'])]}"/>
|
||||
<field name="bank_code"
|
||||
attrs="{'invisible': [('country_code', '!=', 'CA')], 'required': [('country_code', '=', 'CA')]}"/>
|
||||
<field name="bank_statement" filename="bank_statement_filename" attrs="{'invisible': [('bank_account_uuid', '=', False)]}"/>
|
||||
<field name="bank_statement_filename" invisible="1"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="owner_name"/>
|
||||
<label for="owner_street" string="Owner Address" attrs="{'invisible': [('country_code', 'not in', ['CA', 'US'])]}"/>
|
||||
<div class="o_address_format" attrs="{'invisible': [('country_code', 'not in', ['CA', 'US'])]}">
|
||||
<field name="owner_street" placeholder="Street" class="o_address_street" attrs="{'required': [('country_code', 'in', ['CA', 'US'])]}"/>
|
||||
<field name="owner_house_number_or_name" placeholder="House number or name" class="o_address_street" attrs="{'required': [('country_code', 'in', ['CA', 'US'])]}"/>
|
||||
<field name="owner_city" placeholder="City" class="o_address_city" attrs="{'required': [('country_code', 'in', ['CA', 'US'])]}"/>
|
||||
<field name="owner_state_id" class="o_address_state" placeholder="State" options="{'no_open': True, 'no_quick_create': True}"
|
||||
attrs="{'required': [('country_code', 'in', ['CA', 'US'])]}"/>
|
||||
<field name="owner_zip" placeholder="ZIP" class="o_address_zip" attrs="{'required': [('country_code', 'in', ['CA', 'US'])]}"/>
|
||||
<field name="owner_country_id" placeholder="Country" class="o_address_country" options='{"no_open": True, "no_create": True}'
|
||||
attrs="{'required': [('country_code', 'in', ['CA', 'US'])]}"/>
|
||||
</div>
|
||||
</group>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="adyen_payout_view_form" model="ir.ui.view">
|
||||
<field name="name">adyen.payout.view.form</field>
|
||||
<field name="model">adyen.payout</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Adyen Payout">
|
||||
<header>
|
||||
<button name="send_payout_request" string="Request a payout now" class="oe_highlight" type="object" attrs="{'invisible': [('code', '=', False)]}"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<group>
|
||||
<field name="code" invisible="1"/>
|
||||
<field name="name"/>
|
||||
<field name="payout_schedule"/>
|
||||
<field name="adyen_bank_account_id"/>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="adyen_account_action_create" model="ir.actions.act_window">
|
||||
<field name="name">Create an Adyen Account</field>
|
||||
<field name="res_model">adyen.account</field>
|
||||
<field name="view_mode">form</field>
|
||||
</record>
|
||||
|
||||
<record id="adyen_account_action" model="ir.actions.server">
|
||||
<field name="name">Account Configuration</field>
|
||||
<field name="model_id" ref="adyen_platforms.model_adyen_account" />
|
||||
<field name="type">ir.actions.server</field>
|
||||
<field name="state">code</field>
|
||||
<field name="code">
|
||||
action = model.action_create_redirect()
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="adyen_bank_account_view_form" model="ir.ui.view">
|
||||
<field name="name">adyen.bank.account.view.form</field>
|
||||
<field name="model">adyen.bank.account</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Adyen Bank Account">
|
||||
<field name="bank_account_reference" invisible="1" />
|
||||
<field name="bank_account_uuid" invisible="1"/>
|
||||
<field name="bank_statement_filename" invisible="1"/>
|
||||
<field name="currency_name" invisible="1" />
|
||||
<field name="country_code" invisible="1"/>
|
||||
<sheet>
|
||||
<div class="alert alert-warning" role="alert"
|
||||
attrs="{'invisible': [('kyc_status_message', '=', False)]}">
|
||||
<field name="kyc_status_message"/>
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
<field name="country_id" options='{"no_open": True, "no_create": True}'/>
|
||||
<field name="currency_id" options="{'no_open': True, 'no_create': True}"/>
|
||||
<field name="iban"
|
||||
attrs="{'invisible': [('country_code', 'not in', ['AT', 'BE', 'CH', 'CZ', 'DE', 'ES', 'FI', 'FR', 'GB', 'GR', 'HR', 'IE', 'IT', 'LT', 'LU', 'NL', 'PL', 'PT'])],
|
||||
'required': [('country_code', 'in', ['AT', 'BE', 'CH', 'CZ', 'DE', 'ES', 'FI', 'FR', 'GB', 'GR', 'HR', 'IE', 'IT', 'LT', 'LU', 'NL', 'PL', 'PT'])]}"/>
|
||||
<field name="account_type"
|
||||
attrs="{'invisible': [('country_code', '!=', 'US')], 'required': [('country_code', '=', 'US')]}"/>
|
||||
<field name="account_number"
|
||||
attrs="{'invisible': [('country_code', 'not in', ['AU', 'CA', 'US'])], 'required': [('country_code', 'in', ['AU', 'CA', 'US'])]}"/>
|
||||
<field name="branch_code"
|
||||
attrs="{'invisible': [('country_code', 'not in', ['AU', 'CA', 'US'])], 'required': [('country_code', 'in', ['AU', 'CA', 'US'])]}"/>
|
||||
<field name="bank_city"
|
||||
attrs="{'invisible': [('currency_name', '!=', 'USD')], 'required': [('currency_name', '=', 'USD')]}"/>
|
||||
<field name="bank_code"
|
||||
attrs="{'invisible': [('country_code', '!=', 'CA')], 'required': [('country_code', '=', 'CA')]}"/>
|
||||
<field name="bank_name"
|
||||
attrs="{'invisible': [('currency_name', '!=', 'USD')], 'required': [('currency_name', '=', 'USD')]}"/>
|
||||
<field name="bank_statement" filename="bank_statement_filename" attrs="{'invisible': [('bank_account_uuid', '=', False)]}"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="owner_name"/>
|
||||
<label for="owner_street" string="Owner Address" attrs="{'invisible': [('country_code', 'not in', ['CA', 'US'])]}"/>
|
||||
<div class="o_address_format" attrs="{'invisible': [('currency_name', '!=', 'USD')]}">
|
||||
<field name="owner_street" placeholder="Street" class="o_address_street" attrs="{'required': [('country_code', 'in', ['CA', 'US'])]}"/>
|
||||
<field name="owner_house_number_or_name" placeholder="House number or name" class="o_address_street" attrs="{'required': [('country_code', 'in', ['CA', 'US'])]}"/>
|
||||
<field name="owner_city" placeholder="City" class="o_address_city" attrs="{'required': [('currency_name', '=', 'USD')]}"/>
|
||||
<field name="owner_state_id" class="o_address_state" placeholder="State" options="{'no_open': True, 'no_quick_create': True}"
|
||||
attrs="{'required': [('country_code', 'in', ['CA', 'US'])]}"/>
|
||||
<field name="owner_zip" placeholder="ZIP" class="o_address_zip" attrs="{'required': [('country_code', 'in', ['CA', 'US'])]}"/>
|
||||
<field name="owner_country_id" placeholder="Country" class="o_address_country" options='{"no_open": True, "no_create": True}'
|
||||
attrs="{'required': [('country_code', 'in', ['CA', 'US'])]}"/>
|
||||
</div>
|
||||
</group>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -0,0 +1,50 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="adyen_shareholder_view_form" model="ir.ui.view">
|
||||
<field name="name">adyen.shareholder.view.form</field>
|
||||
<field name="model">adyen.shareholder</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Adyen Shareholder">
|
||||
<field name="adyen_kyc_ids" invisible="1" />
|
||||
<field name="shareholder_uuid" invisible="1"/>
|
||||
<field name="shareholder_reference" invisible="1" />
|
||||
<sheet>
|
||||
<field name="kyc_status" />
|
||||
<div class="alert alert-warning" role="alert" attrs="{'invisible': [('kyc_status_message', '=', False)]}">
|
||||
<field name="kyc_status_message"/>
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
<field name="first_name"/>
|
||||
<field name="last_name"/>
|
||||
<field name="date_of_birth"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="document_number"
|
||||
attrs="{'invisible': [('country_code', 'not in', ['IT', 'US'])], 'required': [('country_code', 'in', ['IT', 'US'])]}"/>
|
||||
<field name="id_type" attrs="{'invisible': [('shareholder_uuid', '=', False)]}"/>
|
||||
<field name="id_front" filename="id_front_filename" attrs="{'invisible': [('shareholder_uuid', '=', False)]}"/>
|
||||
<field name="id_front_filename" invisible="1"/>
|
||||
<field name="id_back" filename="id_back_filename"
|
||||
attrs="{'required': [('id_type', 'in', ['ID_CARD', 'DRIVING_LICENSE'])], 'invisible': [('id_type', 'not in', ['ID_CARD', 'DRIVING_LICENSE'])]}"/>
|
||||
<field name="id_back_filename" invisible="1"/>
|
||||
</group>
|
||||
<group>
|
||||
<label for="street" string="Address"/>
|
||||
<div class="o_address_format">
|
||||
<field name="street" placeholder="Street" class="o_address_street"/>
|
||||
<field name="house_number_or_name" placeholder="House number or name" class="o_address_street"/>
|
||||
<field name="city" placeholder="City" class="o_address_city"/>
|
||||
<field name="state_id" class="o_address_state" placeholder="State" options="{'no_open': True, 'no_quick_create': True}"
|
||||
attrs="{'required': [('country_code', 'in', ['AU', 'CA', 'IT', 'US'])]}"/>
|
||||
<field name="zip" placeholder="ZIP" class="o_address_zip"/>
|
||||
<field name="country_id" placeholder="Country" class="o_address_country" options="{'no_open': True, 'no_create': True}"/>
|
||||
<field name="country_code" invisible="1"/>
|
||||
</div>
|
||||
</group>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -5,14 +5,43 @@
|
||||
<field name="model">adyen.transaction</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<field name="fees_currency_id" invisible="1" />
|
||||
<field name="currency_id" invisible="1"/>
|
||||
<field name="signature" invisible="1"/>
|
||||
<header>
|
||||
<button string="Refund" type="object" name="action_refund" attrs="{'invisible': ['|', ('total_amount', '<=', 0), ('signature', '=', False)]}" />
|
||||
</header>
|
||||
<sheet>
|
||||
<div class="oe_button_box" name="button_box" />
|
||||
<group>
|
||||
<field name="reference"/>
|
||||
<field name="amount" widget='monetary'/>
|
||||
<field name="currency_id" invisible="1"/>
|
||||
<field name="date"/>
|
||||
<field name="description"/>
|
||||
<field name="status"/>
|
||||
<group>
|
||||
<field name="date"/>
|
||||
<field name="description"/>
|
||||
<field name="shopper_country_id"/>
|
||||
<field name="commercial_card"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="reference"/>
|
||||
<field name="status"/>
|
||||
<field name="total_amount" widget='monetary'/>
|
||||
<field name="merchant_amount" widget='monetary'/>
|
||||
<field name="fixed_fees" widget="monetary" options="{'currency_field': 'fees_currency_id'}" groups="base.group_no_one" />
|
||||
<field name="variable_fees" widget="monetary" options="{'currency_field': 'fees_currency_id'}" groups="base.group_no_one" />
|
||||
<field name="fees" widget="monetary" options="{'currency_field': 'fees_currency_id'}" />
|
||||
<field name="reason" attrs="{'invisible': [('reason', '=', False)]}" />
|
||||
</group>
|
||||
</group>
|
||||
<group>
|
||||
<group name="additional_info">
|
||||
</group>
|
||||
<group>
|
||||
<field name="status_ids" nolabel="1">
|
||||
<tree>
|
||||
<field name="status" />
|
||||
<field name="date" />
|
||||
</tree>
|
||||
</field>
|
||||
</group>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
@@ -23,14 +52,99 @@
|
||||
<field name="name">adyen.transaction.view.list</field>
|
||||
<field name="model">adyen.transaction</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree js_class="adyen_transactions_tree">
|
||||
<field name="reference"/>
|
||||
<field name="amount" widget='monetary'/>
|
||||
<field name="currency_id" invisible="1"/>
|
||||
<field name="date"/>
|
||||
<tree decoration-danger="reason != False">
|
||||
<field name="adyen_account_id" invisible="1" />
|
||||
<field name="reason" invisible="1" />
|
||||
<field name="currency_id" invisible="1" />
|
||||
<field name="fees_currency_id" invisible="1" />
|
||||
<field name="description"/>
|
||||
<field name="reference" optional="show" />
|
||||
<field name="payment_method" optional="show" />
|
||||
<field name="shopper_country_id" optional="show" />
|
||||
<field name="card_country_id" optional="show" />
|
||||
<field name="commercial_card" optional="hide" />
|
||||
<field name="total_amount" widget="monetary" />
|
||||
<field name="merchant_amount" widget="monetary" />
|
||||
<field name="fixed_fees" widget="monetary" options="{'currency_field': 'fees_currency_id'}" groups="base.group_no_one" optional="hide" />
|
||||
<field name="variable_fees" widget="monetary" options="{'currency_field': 'fees_currency_id'}" groups="base.group_no_one" optional="hide" />
|
||||
<field name="fees" widget="monetary" options="{'currency_field': 'fees_currency_id'}" optional="show" />
|
||||
<field name="date"/>
|
||||
<field name="status"/>
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="adyen_transaction_view_search" model="ir.ui.view">
|
||||
<field name="model">adyen.transaction</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Transactions">
|
||||
<field name="description" />
|
||||
<field name="reference" />
|
||||
<field name="status" />
|
||||
<filter name="filter_date" date="date" string="Transaction Date" />
|
||||
<filter name="filter_last_status_update" date="date" string="Last Status Update" />
|
||||
<group>
|
||||
<filter string="Currency" name="groupby_currency_id" context="{'group_by': 'currency_id'}"/>
|
||||
<filter string="Date" name="groupby_date" context="{'group_by': 'date'}" />
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="adyen_transaction_payout_view_list" model="ir.ui.view">
|
||||
<field name="name">adyen.transaction.payout.view.list</field>
|
||||
<field name="model">adyen.transaction.payout</field>
|
||||
<field name="priority">10</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree js_class="adyen_transactions_list" expand="1">
|
||||
<field name="currency_id" invisible="1" />
|
||||
<field name="reference" optional="show" />
|
||||
<field name="bank_account_id" />
|
||||
<field name="amount" string="Amount" widget="monetary" />
|
||||
<field name="date"/>
|
||||
<field name="status"/>
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="adyen_transaction_payout_view_search" model="ir.ui.view">
|
||||
<field name="model">adyen.transaction.payout</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Payouts">
|
||||
<field name="reference" />
|
||||
<field name="status" />
|
||||
<filter name="filter_date" date="date" string="Transaction Date" />
|
||||
<group>
|
||||
<filter string="Currency" name="groupby_currency_id" context="{'group_by': 'currency_id'}"/>
|
||||
<filter string="Date" name="groupby_date" context="{'group_by': 'date'}" />
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="adyen_transaction_action" model="ir.actions.act_window">
|
||||
<field name="name">Payment Transactions</field>
|
||||
<field name="res_model">adyen.transaction</field>
|
||||
<field name="view_mode">tree,form</field>
|
||||
<field name="domain">[]</field>
|
||||
<field name="context">{}</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
There are no transactions yet.
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="adyen_balance_action" model="ir.actions.act_window">
|
||||
<field name="name">Balance</field>
|
||||
<field name="res_model">adyen.transaction.payout</field>
|
||||
<field name="view_mode">tree</field>
|
||||
<field name="domain">[]</field>
|
||||
<field name="context">{}</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
No payouts yet.
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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', '=', [])]}"/>
|
||||
</xpath>
|
||||
<xpath expr="//div[@name='button_box']" position="inside">
|
||||
<field name="transaction_ids" invisible="1" />
|
||||
<button name="action_view_payment_transactions" type="object"
|
||||
class="oe_stat_button" icon="fa-money"
|
||||
string="Payment Transaction"
|
||||
attrs="{'invisible': [('transaction_ids', '=', [])]}" />
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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.
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="adyen_transaction_view_list" model="ir.ui.view">
|
||||
<field name="model">adyen.transaction</field>
|
||||
<field name="inherit_id" ref="adyen_platforms.adyen_transaction_view_list"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='description']" position="after">
|
||||
<field name="payment_transaction_id" optional="show" />
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="adyen_transaction_view_form" model="ir.ui.view">
|
||||
<field name="model">adyen.transaction</field>
|
||||
<field name="inherit_id" ref="adyen_platforms.adyen_transaction_view_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='description']" position="after">
|
||||
<field name="payment_transaction_id" attrs="{'invisible': [('payment_transaction_id', '=', False)]}" />
|
||||
</xpath>
|
||||
<xpath expr="//group[@name='additional_info']" position="inside">
|
||||
<field name="partner_id" widget="res_partner_many2one" attrs="{'invisible': [('partner_id', '=', False)]}"/>
|
||||
<label for="partner_address" string="Address" attrs="{'invisible': [('partner_id', '=', False)]}"/>
|
||||
<div class="o_address_format" attrs="{'invisible': [('partner_id', '=', False)]}">
|
||||
<field name="partner_address" placeholder="Address" class="o_address_street"/>
|
||||
<field name="partner_city" placeholder="City" class="o_address_city"/>
|
||||
<field name="partner_state_id" placeholder="State" class="o_address_state" options="{'no_open': True}"/>
|
||||
<field name="partner_zip" placeholder="ZIP" class="o_address_zip"/>
|
||||
<field name="partner_country_id" placeholder="Country" class="o_address_country" options="{'no_open': True}"/>
|
||||
</div>
|
||||
<field name="partner_email" widget="email" attrs="{'invisible': [('partner_id', '=', False)]}"/>
|
||||
<field name="partner_phone" widget="phone" attrs="{'invisible': [('partner_id', '=', False)]}"/>
|
||||
<field name="partner_lang" attrs="{'invisible': [('partner_id', '=', False)]}"/>
|
||||
</xpath>
|
||||
<xpath expr="//div[@name='button_box']" position="inside">
|
||||
<button name="action_view_invoices" type="object"
|
||||
class="oe_stat_button" icon="fa-money"
|
||||
attrs="{'invisible': [('invoices_count', '=', 0)]}">
|
||||
<field name="invoices_count" widget="statinfo" string="Invoice(s)"/>
|
||||
</button>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -17,11 +17,14 @@
|
||||
<field name="odoo_adyen_account_id"
|
||||
readonly="1"
|
||||
attrs="{'invisible': [('odoo_adyen_account_id', '=', False)]}"/>
|
||||
<field name="odoo_adyen_payout_id"
|
||||
attrs="{'invisible': [('odoo_adyen_account_id', '=', False)], 'required':[('provider', '=', 'odoo'), ('state', '!=', 'disabled')]}"/>
|
||||
</group>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<menuitem id="root_adyen_menu" name="Odoo Payments" parent="account.root_payment_menu" sequence="10" />
|
||||
<menuitem id="menu_adyen_transaction" parent="root_adyen_menu" action="adyen_platforms.adyen_transaction_action"/>
|
||||
<menuitem id="menu_adyen_balance" parent="root_adyen_menu" action="adyen_platforms.adyen_balance_action"/>
|
||||
<menuitem id="menu_adyen_account" parent="root_adyen_menu" action="adyen_platforms.adyen_account_action"/>
|
||||
|
||||
</odoo>
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import models
|
||||
@@ -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,
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import adyen_transaction
|
||||
@@ -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()
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="adyen_transaction_view_form" model="ir.ui.view">
|
||||
<field name="model">adyen.transaction</field>
|
||||
<field name="inherit_id" ref="adyen_platforms.adyen_transaction_view_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//div[@name='button_box']" position="inside">
|
||||
<button name="action_view_sales_orders" type="object"
|
||||
class="oe_stat_button" icon="fa-money"
|
||||
attrs="{'invisible': [('sale_order_ids_nbr', '=', 0)]}">
|
||||
<field name="sale_order_ids_nbr" widget="statinfo" string="Sales Order(s)"/>
|
||||
</button>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
Reference in New Issue
Block a user