[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:
Kevin Baptiste
2021-06-02 14:12:40 +00:00
co-authored by Antoine Prieels
parent 8e33f7cfaa
commit 208a82f344
44 changed files with 1940 additions and 852 deletions
+4
View File
@@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_adyen_account_group_erp_manager adyen.account model_adyen_account base.group_erp_manager 1 1 1 1
3 access_adyen_shareholder_group_erp_manager adyen.shareholder model_adyen_shareholder base.group_erp_manager 1 1 1 1
4 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
5 access_adyen_transaction_group_erp_manager adyen.transaction model_adyen_transaction base.group_erp_manager 1 0 0 0
6 access_adyen_kyc_group_erp_manager adyen.kyc model_adyen_kyc base.group_erp_manager 1 0 0 0
7 access_adyen_account_balance_group_erp_manager adyen.account.balance model_adyen_account_balance base.group_erp_manager 1 0 0 0
8 access_adyen_transaction_status_group_erp_manager adyen.transaction.status model_adyen_transaction_status base.group_erp_manager 1 0 0 0
9 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>
+56 -4
View File
@@ -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', '&lt;=', 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>
+12
View File
@@ -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>
+1
View File
@@ -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',
+15 -3
View File
@@ -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
+3
View File
@@ -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()
+3 -11
View File
@@ -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>
+5 -2
View File
@@ -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>
+3 -3
View File
@@ -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(),
+3
View File
@@ -0,0 +1,3 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import models
+15
View File
@@ -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>