summaryrefslogtreecommitdiff
path: root/addons/payment/models
diff options
context:
space:
mode:
authorstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
committerstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
commit3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch)
treea44932296ef4a9b71d5f010906253d8c53727726 /addons/payment/models
parent0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff)
initial commit 2
Diffstat (limited to 'addons/payment/models')
-rw-r--r--addons/payment/models/__init__.py9
-rw-r--r--addons/payment/models/account_invoice.py94
-rw-r--r--addons/payment/models/account_payment.py123
-rw-r--r--addons/payment/models/chart_template.py13
-rw-r--r--addons/payment/models/ir_http.py13
-rw-r--r--addons/payment/models/payment_acquirer.py1244
-rw-r--r--addons/payment/models/res_company.py31
-rw-r--r--addons/payment/models/res_partner.py19
8 files changed, 1546 insertions, 0 deletions
diff --git a/addons/payment/models/__init__.py b/addons/payment/models/__init__.py
new file mode 100644
index 00000000..8491af34
--- /dev/null
+++ b/addons/payment/models/__init__.py
@@ -0,0 +1,9 @@
+# -*- coding: utf-8 -*-
+
+from . import payment_acquirer
+from . import account_invoice
+from . import res_partner
+from . import account_payment
+from . import chart_template
+from . import ir_http
+from . import res_company
diff --git a/addons/payment/models/account_invoice.py b/addons/payment/models/account_invoice.py
new file mode 100644
index 00000000..89fdcfd4
--- /dev/null
+++ b/addons/payment/models/account_invoice.py
@@ -0,0 +1,94 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import api, fields, models, _
+from odoo.exceptions import ValidationError
+
+
+class AccountMove(models.Model):
+ _inherit = 'account.move'
+
+ transaction_ids = fields.Many2many('payment.transaction', 'account_invoice_transaction_rel', 'invoice_id', 'transaction_id',
+ string='Transactions', copy=False, readonly=True)
+ authorized_transaction_ids = fields.Many2many('payment.transaction', compute='_compute_authorized_transaction_ids',
+ string='Authorized Transactions', copy=False, readonly=True)
+
+ @api.depends('transaction_ids')
+ def _compute_authorized_transaction_ids(self):
+ for trans in self:
+ trans.authorized_transaction_ids = trans.transaction_ids.filtered(lambda t: t.state == 'authorized')
+
+ def get_portal_last_transaction(self):
+ self.ensure_one()
+ return self.with_context(active_test=False).transaction_ids.get_last_transaction()
+
+ def _create_payment_transaction(self, vals):
+ '''Similar to self.env['payment.transaction'].create(vals) but the values are filled with the
+ current invoices fields (e.g. the partner or the currency).
+ :param vals: The values to create a new payment.transaction.
+ :return: The newly created payment.transaction record.
+ '''
+ # Ensure the currencies are the same.
+ currency = self[0].currency_id
+ if any(inv.currency_id != currency for inv in self):
+ raise ValidationError(_('A transaction can\'t be linked to invoices having different currencies.'))
+
+ # Ensure the partner are the same.
+ partner = self[0].partner_id
+ if any(inv.partner_id != partner for inv in self):
+ raise ValidationError(_('A transaction can\'t be linked to invoices having different partners.'))
+
+ # Try to retrieve the acquirer. However, fallback to the token's acquirer.
+ acquirer_id = vals.get('acquirer_id')
+ acquirer = None
+ payment_token_id = vals.get('payment_token_id')
+
+ if payment_token_id:
+ payment_token = self.env['payment.token'].sudo().browse(payment_token_id)
+
+ # Check payment_token/acquirer matching or take the acquirer from token
+ if acquirer_id:
+ acquirer = self.env['payment.acquirer'].browse(acquirer_id)
+ if payment_token and payment_token.acquirer_id != acquirer:
+ raise ValidationError(_('Invalid token found! Token acquirer %s != %s') % (
+ payment_token.acquirer_id.name, acquirer.name))
+ if payment_token and payment_token.partner_id != partner:
+ raise ValidationError(_('Invalid token found! Token partner %s != %s') % (
+ payment_token.partner.name, partner.name))
+ else:
+ acquirer = payment_token.acquirer_id
+
+ # Check an acquirer is there.
+ if not acquirer_id and not acquirer:
+ raise ValidationError(_('A payment acquirer is required to create a transaction.'))
+
+ if not acquirer:
+ acquirer = self.env['payment.acquirer'].browse(acquirer_id)
+
+ # Check a journal is set on acquirer.
+ if not acquirer.journal_id:
+ raise ValidationError(_('A journal must be specified for the acquirer %s.', acquirer.name))
+
+ if not acquirer_id and acquirer:
+ vals['acquirer_id'] = acquirer.id
+
+ vals.update({
+ 'amount': sum(self.mapped('amount_residual')),
+ 'currency_id': currency.id,
+ 'partner_id': partner.id,
+ 'invoice_ids': [(6, 0, self.ids)],
+ })
+
+ transaction = self.env['payment.transaction'].create(vals)
+
+ # Process directly if payment_token
+ if transaction.payment_token_id:
+ transaction.s2s_do_transaction()
+
+ return transaction
+
+ def payment_action_capture(self):
+ self.authorized_transaction_ids.s2s_capture_transaction()
+
+ def payment_action_void(self):
+ self.authorized_transaction_ids.s2s_void_transaction()
diff --git a/addons/payment/models/account_payment.py b/addons/payment/models/account_payment.py
new file mode 100644
index 00000000..0a417478
--- /dev/null
+++ b/addons/payment/models/account_payment.py
@@ -0,0 +1,123 @@
+# coding: utf-8
+
+import datetime
+
+from odoo import _, api, fields, models
+from odoo.exceptions import ValidationError
+
+
+class AccountPayment(models.Model):
+ _inherit = 'account.payment'
+
+ payment_transaction_id = fields.Many2one('payment.transaction', string='Payment Transaction', readonly=True)
+ payment_token_id = fields.Many2one(
+ 'payment.token', string="Saved payment token",
+ domain="""[
+ (payment_method_code == 'electronic', '=', 1),
+ ('company_id', '=', company_id),
+ ('acquirer_id.capture_manually', '=', False),
+ ('acquirer_id.journal_id', '=', journal_id),
+ ('partner_id', 'in', related_partner_ids),
+ ]""",
+ help="Note that tokens from acquirers set to only authorize transactions (instead of capturing the amount) are not available.")
+ related_partner_ids = fields.Many2many('res.partner', compute='_compute_related_partners')
+
+ def _get_payment_chatter_link(self):
+ self.ensure_one()
+ return '<a href=# data-oe-model=account.payment data-oe-id=%d>%s</a>' % (self.id, self.name)
+
+ @api.depends('partner_id.commercial_partner_id.child_ids')
+ def _compute_related_partners(self):
+ for p in self:
+ p.related_partner_ids = (
+ p.partner_id
+ | p.partner_id.commercial_partner_id
+ | p.partner_id.commercial_partner_id.child_ids
+ )._origin
+
+ @api.onchange('partner_id', 'payment_method_id', 'journal_id')
+ def _onchange_set_payment_token_id(self):
+ if not (self.payment_method_code == 'electronic' and self.partner_id and self.journal_id):
+ self.payment_token_id = False
+ return
+
+ self.payment_token_id = self.env['payment.token'].search([
+ ('partner_id', 'in', self.related_partner_ids.ids),
+ ('acquirer_id.capture_manually', '=', False),
+ ('acquirer_id.journal_id', '=', self.journal_id.id),
+ ], limit=1)
+
+ def _prepare_payment_transaction_vals(self):
+ self.ensure_one()
+ return {
+ 'amount': self.amount,
+ 'reference': self.ref,
+ 'currency_id': self.currency_id.id,
+ 'partner_id': self.partner_id.id,
+ 'partner_country_id': self.partner_id.country_id.id,
+ 'payment_token_id': self.payment_token_id.id,
+ 'acquirer_id': self.payment_token_id.acquirer_id.id,
+ 'payment_id': self.id,
+ 'type': 'server2server',
+ }
+
+ def _create_payment_transaction(self, vals=None):
+ for pay in self:
+ if pay.payment_transaction_id:
+ raise ValidationError(_('A payment transaction already exists.'))
+ elif not pay.payment_token_id:
+ raise ValidationError(_('A token is required to create a new payment transaction.'))
+
+ transactions = self.env['payment.transaction']
+ for pay in self:
+ transaction_vals = pay._prepare_payment_transaction_vals()
+
+ if vals:
+ transaction_vals.update(vals)
+
+ transaction = self.env['payment.transaction'].create(transaction_vals)
+ transactions += transaction
+
+ # Link the transaction to the payment.
+ pay.payment_transaction_id = transaction
+
+ return transactions
+
+ def action_validate_invoice_payment(self):
+ res = super(AccountPayment, self).action_validate_invoice_payment()
+ self.mapped('payment_transaction_id').filtered(lambda x: x.state == 'done' and not x.is_processed)._post_process_after_done()
+ return res
+
+ def action_post(self):
+ # Post the payments "normally" if no transactions are needed.
+ # If not, let the acquirer updates the state.
+ # __________ ______________
+ # | Payments | | Transactions |
+ # |__________| |______________|
+ # || | |
+ # || | |
+ # || | |
+ # __________ no s2s required __\/______ s2s required | | s2s_do_transaction()
+ # | Posted |<-----------------| post() |---------------- |
+ # |__________| |__________|<----- |
+ # | |
+ # OR---------------
+ # __________ __________ |
+ # | Cancelled|<-----------------| cancel() |<-----
+ # |__________| |__________|
+
+ payments_need_trans = self.filtered(lambda pay: pay.payment_token_id and not pay.payment_transaction_id)
+ transactions = payments_need_trans._create_payment_transaction()
+
+ res = super(AccountPayment, self - payments_need_trans).action_post()
+
+ transactions.s2s_do_transaction()
+
+ # Post payments for issued transactions.
+ transactions._post_process_after_done()
+ payments_trans_done = payments_need_trans.filtered(lambda pay: pay.payment_transaction_id.state == 'done')
+ super(AccountPayment, payments_trans_done).action_post()
+ payments_trans_not_done = payments_need_trans.filtered(lambda pay: pay.payment_transaction_id.state != 'done')
+ payments_trans_not_done.action_cancel()
+
+ return res
diff --git a/addons/payment/models/chart_template.py b/addons/payment/models/chart_template.py
new file mode 100644
index 00000000..0e7ef826
--- /dev/null
+++ b/addons/payment/models/chart_template.py
@@ -0,0 +1,13 @@
+# -*- coding: utf-8 -*-
+
+from odoo import api, fields, models, _
+
+
+class AccountChartTemplate(models.Model):
+ _inherit = 'account.chart.template'
+
+ def _create_bank_journals(self, company, acc_template_ref):
+ res = super(AccountChartTemplate, self)._create_bank_journals(company, acc_template_ref)
+
+ # Try to generate the missing journals
+ return res + self.env['payment.acquirer']._create_missing_journal_for_acquirers(company=company)
diff --git a/addons/payment/models/ir_http.py b/addons/payment/models/ir_http.py
new file mode 100644
index 00000000..ad6bddf8
--- /dev/null
+++ b/addons/payment/models/ir_http.py
@@ -0,0 +1,13 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import models
+
+
+class IrHttp(models.AbstractModel):
+ _inherit = 'ir.http'
+
+ @classmethod
+ def _get_translation_frontend_modules_name(cls):
+ mods = super(IrHttp, cls)._get_translation_frontend_modules_name()
+ return mods + ['payment']
diff --git a/addons/payment/models/payment_acquirer.py b/addons/payment/models/payment_acquirer.py
new file mode 100644
index 00000000..1ec53b71
--- /dev/null
+++ b/addons/payment/models/payment_acquirer.py
@@ -0,0 +1,1244 @@
+# coding: utf-8
+from collections import defaultdict
+import hashlib
+import hmac
+import logging
+from datetime import datetime
+from dateutil import relativedelta
+import pprint
+import psycopg2
+
+from odoo import api, exceptions, fields, models, _, SUPERUSER_ID
+from odoo.tools import consteq, float_round, image_process, ustr
+from odoo.exceptions import ValidationError
+from odoo.tools.misc import DEFAULT_SERVER_DATETIME_FORMAT
+from odoo.tools.misc import formatLang
+from odoo.http import request
+from odoo.osv import expression
+
+_logger = logging.getLogger(__name__)
+
+
+def _partner_format_address(address1=False, address2=False):
+ return ' '.join((address1 or '', address2 or '')).strip()
+
+
+def _partner_split_name(partner_name):
+ return [' '.join(partner_name.split()[:-1]), ' '.join(partner_name.split()[-1:])]
+
+
+def create_missing_journal_for_acquirers(cr, registry):
+ env = api.Environment(cr, SUPERUSER_ID, {})
+ env['payment.acquirer']._create_missing_journal_for_acquirers()
+
+
+class PaymentAcquirer(models.Model):
+ """ Acquirer Model. Each specific acquirer can extend the model by adding
+ its own fields, using the acquirer_name as a prefix for the new fields.
+ Using the required_if_provider='<name>' attribute on fields it is possible
+ to have required fields that depend on a specific acquirer.
+
+ Each acquirer has a link to an ir.ui.view record that is a template of
+ a button used to display the payment form. See examples in ``payment_ingenico``
+ and ``payment_paypal`` modules.
+
+ Methods that should be added in an acquirer-specific implementation:
+
+ - ``<name>_form_generate_values(self, reference, amount, currency,
+ partner_id=False, partner_values=None, tx_custom_values=None)``:
+ method that generates the values used to render the form button template.
+ - ``<name>_get_form_action_url(self):``: method that returns the url of
+ the button form. It is used for example in ecommerce application if you
+ want to post some data to the acquirer.
+ - ``<name>_compute_fees(self, amount, currency_id, country_id)``: computes
+ the fees of the acquirer, using generic fields defined on the acquirer
+ model (see fields definition).
+
+ Each acquirer should also define controllers to handle communication between
+ OpenERP and the acquirer. It generally consists in return urls given to the
+ button form and that the acquirer uses to send the customer back after the
+ transaction, with transaction details given as a POST request.
+ """
+ _name = 'payment.acquirer'
+ _description = 'Payment Acquirer'
+ _order = 'module_state, state, sequence, name'
+
+ def _valid_field_parameter(self, field, name):
+ return name == 'required_if_provider' or super()._valid_field_parameter(field, name)
+
+ def _get_default_view_template_id(self):
+ return self.env.ref('payment.default_acquirer_button', raise_if_not_found=False)
+
+ name = fields.Char('Name', required=True, translate=True)
+ color = fields.Integer('Color', compute='_compute_color', store=True)
+ display_as = fields.Char('Displayed as', translate=True, help="How the acquirer is displayed to the customers.")
+ description = fields.Html('Description')
+ sequence = fields.Integer('Sequence', default=10, help="Determine the display order")
+ provider = fields.Selection(
+ selection=[('manual', 'Custom Payment Form')], string='Provider',
+ default='manual', required=True)
+ company_id = fields.Many2one(
+ 'res.company', 'Company',
+ default=lambda self: self.env.company.id, required=True)
+ view_template_id = fields.Many2one(
+ 'ir.ui.view', 'Form Button Template',
+ default=_get_default_view_template_id,
+ help="This template renders the acquirer button with all necessary values.\n"
+ "It is rendered with qWeb with the following evaluation context:\n"
+ "tx_url: transaction URL to post the form\n"
+ "acquirer: payment.acquirer browse record\n"
+ "user: current user browse record\n"
+ "reference: the transaction reference number\n"
+ "currency: the transaction currency browse record\n"
+ "amount: the transaction amount, a float\n"
+ "partner: the buyer partner browse record, not necessarily set\n"
+ "partner_values: specific values about the buyer, for example coming from a shipping form\n"
+ "tx_values: transaction values\n"
+ "context: the current context dictionary")
+ registration_view_template_id = fields.Many2one(
+ 'ir.ui.view', 'S2S Form Template', domain=[('type', '=', 'qweb')],
+ help="Template for method registration")
+ state = fields.Selection([
+ ('disabled', 'Disabled'),
+ ('enabled', 'Enabled'),
+ ('test', 'Test Mode')], required=True, default='disabled', copy=False,
+ help="""In test mode, a fake payment is processed through a test
+ payment interface. This mode is advised when setting up the
+ acquirer. Watch out, test and production modes require
+ different credentials.""")
+ capture_manually = fields.Boolean(string="Capture Amount Manually",
+ help="Capture the amount from Odoo, when the delivery is completed.")
+ journal_id = fields.Many2one(
+ 'account.journal', 'Payment Journal', domain="[('type', 'in', ['bank', 'cash']), ('company_id', '=', company_id)]",
+ help="""Journal where the successful transactions will be posted""")
+ check_validity = fields.Boolean(string="Verify Card Validity",
+ help="""Trigger a transaction of 1 currency unit and its refund to check the validity of new credit cards entered in the customer portal.
+ Without this check, the validity will be verified at the very first transaction.""")
+ country_ids = fields.Many2many(
+ 'res.country', 'payment_country_rel',
+ 'payment_id', 'country_id', 'Countries',
+ help="This payment gateway is available for selected countries. If none is selected it is available for all countries.")
+
+ pre_msg = fields.Html(
+ 'Help Message', translate=True,
+ help='Message displayed to explain and help the payment process.')
+ auth_msg = fields.Html(
+ 'Authorize Message', translate=True,
+ default=lambda s: _('Your payment has been authorized.'),
+ help='Message displayed if payment is authorized.')
+ pending_msg = fields.Html(
+ 'Pending Message', translate=True,
+ default=lambda s: _('Your payment has been successfully processed but is waiting for approval.'),
+ help='Message displayed, if order is in pending state after having done the payment process.')
+ done_msg = fields.Html(
+ 'Done Message', translate=True,
+ default=lambda s: _('Your payment has been successfully processed. Thank you!'),
+ help='Message displayed, if order is done successfully after having done the payment process.')
+ cancel_msg = fields.Html(
+ 'Cancel Message', translate=True,
+ default=lambda s: _('Your payment has been cancelled.'),
+ help='Message displayed, if order is cancel during the payment process.')
+ save_token = fields.Selection([
+ ('none', 'Never'),
+ ('ask', 'Let the customer decide'),
+ ('always', 'Always')],
+ string='Save Cards', default='none',
+ help="This option allows customers to save their credit card as a payment token and to reuse it for a later purchase. "
+ "If you manage subscriptions (recurring invoicing), you need it to automatically charge the customer when you "
+ "issue an invoice.")
+ token_implemented = fields.Boolean('Saving Card Data supported', compute='_compute_feature_support', search='_search_is_tokenized')
+ authorize_implemented = fields.Boolean('Authorize Mechanism Supported', compute='_compute_feature_support')
+ fees_implemented = fields.Boolean('Fees Computation Supported', compute='_compute_feature_support')
+ fees_active = fields.Boolean('Add Extra Fees')
+ fees_dom_fixed = fields.Float('Fixed domestic fees')
+ fees_dom_var = fields.Float('Variable domestic fees (in percents)')
+ fees_int_fixed = fields.Float('Fixed international fees')
+ fees_int_var = fields.Float('Variable international fees (in percents)')
+ qr_code = fields.Boolean('Enable QR Codes', help="Enable the use of QR-codes for payments made on this provider.")
+
+ # TDE FIXME: remove that brol
+ module_id = fields.Many2one('ir.module.module', string='Corresponding Module')
+ module_state = fields.Selection(string='Installation State', related='module_id.state', store=True)
+ module_to_buy = fields.Boolean(string='Odoo Enterprise Module', related='module_id.to_buy', readonly=True, store=False)
+
+ image_128 = fields.Image("Image", max_width=128, max_height=128)
+
+ payment_icon_ids = fields.Many2many('payment.icon', string='Supported Payment Icons')
+ payment_flow = fields.Selection(selection=[('form', 'Redirection to the acquirer website'),
+ ('s2s','Payment from Odoo')],
+ default='form', required=True, string='Payment Flow',
+ help="""Note: Subscriptions does not take this field in account, it uses server to server by default.""")
+ inbound_payment_method_ids = fields.Many2many('account.payment.method', related='journal_id.inbound_payment_method_ids', readonly=False)
+
+ @api.onchange('payment_flow')
+ def _onchange_payment_flow(self):
+ electronic = self.env.ref('payment.account_payment_method_electronic_in')
+ if self.token_implemented and self.payment_flow == 's2s':
+ if electronic not in self.inbound_payment_method_ids:
+ self.inbound_payment_method_ids = [(4, electronic.id)]
+ elif electronic in self.inbound_payment_method_ids:
+ self.inbound_payment_method_ids = [(2, electronic.id)]
+
+ @api.onchange('state')
+ def onchange_state(self):
+ """Disable dashboard display for test acquirer journal."""
+ self.journal_id.update({'show_on_dashboard': self.state == 'enabled'})
+
+ def _search_is_tokenized(self, operator, value):
+ tokenized = self._get_feature_support()['tokenize']
+ if (operator, value) in [('=', True), ('!=', False)]:
+ return [('provider', 'in', tokenized)]
+ return [('provider', 'not in', tokenized)]
+
+ @api.depends('provider')
+ def _compute_feature_support(self):
+ feature_support = self._get_feature_support()
+ for acquirer in self:
+ acquirer.fees_implemented = acquirer.provider in feature_support['fees']
+ acquirer.authorize_implemented = acquirer.provider in feature_support['authorize']
+ acquirer.token_implemented = acquirer.provider in feature_support['tokenize']
+
+ @api.depends('state', 'module_state')
+ def _compute_color(self):
+ for acquirer in self:
+ if acquirer.module_id and not acquirer.module_state == 'installed':
+ acquirer.color = 4 # blue
+ elif acquirer.state == 'disabled':
+ acquirer.color = 3 # yellow
+ elif acquirer.state == 'test':
+ acquirer.color = 2 # orange
+ elif acquirer.state == 'enabled':
+ acquirer.color = 7 # green
+
+ def _check_required_if_provider(self):
+ """ If the field has 'required_if_provider="<provider>"' attribute, then it
+ required if record.provider is <provider>. """
+ field_names = []
+ enabled_acquirers = self.filtered(lambda acq: acq.state in ['enabled', 'test'])
+ for k, f in self._fields.items():
+ provider = getattr(f, 'required_if_provider', None)
+ if provider and any(
+ acquirer.provider == provider and not acquirer[k]
+ for acquirer in enabled_acquirers
+ ):
+ ir_field = self.env['ir.model.fields']._get(self._name, k)
+ field_names.append(ir_field.field_description)
+ if field_names:
+ raise ValidationError(_("Required fields not filled: %s") % ", ".join(field_names))
+
+ def get_base_url(self):
+ self.ensure_one()
+ # priority is always given to url_root
+ # from the request
+ url = ''
+ if request:
+ url = request.httprequest.url_root
+
+ if not url and 'website_id' in self and self.website_id:
+ url = self.website_id._get_http_domain()
+
+ return url or self.env['ir.config_parameter'].sudo().get_param('web.base.url')
+
+ def _get_feature_support(self):
+ """Get advanced feature support by provider.
+
+ Each provider should add its technical in the corresponding
+ key for the following features:
+ * fees: support payment fees computations
+ * authorize: support authorizing payment (separates
+ authorization and capture)
+ * tokenize: support saving payment data in a payment.tokenize
+ object
+ """
+ return dict(authorize=[], tokenize=[], fees=[])
+
+ def _prepare_account_journal_vals(self):
+ '''Prepare the values to create the acquirer's journal.
+ :return: a dictionary to create a account.journal record.
+ '''
+ self.ensure_one()
+ account_vals = self.company_id.chart_template_id._prepare_transfer_account_for_direct_creation(self.name, self.company_id)
+ account = self.env['account.account'].create(account_vals)
+ inbound_payment_method_ids = []
+ if self.token_implemented and self.payment_flow == 's2s':
+ inbound_payment_method_ids.append((4, self.env.ref('payment.account_payment_method_electronic_in').id))
+ return {
+ 'name': self.name,
+ 'code': self.name.upper(),
+ 'sequence': 999,
+ 'type': 'bank',
+ 'company_id': self.company_id.id,
+ 'default_account_id': account.id,
+ # Show the journal on dashboard if the acquirer is published on the website.
+ 'show_on_dashboard': self.state == 'enabled',
+ # Don't show payment methods in the backend.
+ 'inbound_payment_method_ids': inbound_payment_method_ids,
+ 'outbound_payment_method_ids': [],
+ }
+
+ def _get_acquirer_journal_domain(self):
+ """Returns a domain for finding a journal corresponding to an acquirer"""
+ self.ensure_one()
+ code_cutoff = self.env['account.journal']._fields['code'].size
+ return [
+ ('name', '=', self.name),
+ ('code', '=', self.name.upper()[:code_cutoff]),
+ ('company_id', '=', self.company_id.id),
+ ]
+
+ @api.model
+ def _create_missing_journal_for_acquirers(self, company=None):
+ '''Create the journal for active acquirers.
+ We want one journal per acquirer. However, we can't create them during the 'create' of the payment.acquirer
+ because every acquirers are defined on the 'payment' module but is active only when installing their own module
+ (e.g. payment_paypal for Paypal). We can't do that in such modules because we have no guarantee the chart template
+ is already installed.
+ '''
+ # Search for installed acquirers modules that have no journal for the current company.
+ # If this method is triggered by a post_init_hook, the module is 'to install'.
+ # If the trigger comes from the chart template wizard, the modules are already installed.
+ company = company or self.env.company
+ acquirers = self.env['payment.acquirer'].search([
+ ('module_state', 'in', ('to install', 'installed')),
+ ('journal_id', '=', False),
+ ('company_id', '=', company.id),
+ ])
+
+ # Here we will attempt to first create the journal since the most common case (first
+ # install) is to successfully to create the journal for the acquirer, in the case of a
+ # reinstall (least common case), the creation will fail because of a unique constraint
+ # violation, this is ok as we catch the error and then perform a search if need be
+ # and assign the existing journal to our reinstalled acquirer. It is better to ask for
+ # forgiveness than to ask for permission as this saves us the overhead of doing a select
+ # that would be useless in most cases.
+ Journal = journals = self.env['account.journal']
+ for acquirer in acquirers.filtered(lambda l: not l.journal_id and l.company_id.chart_template_id):
+ try:
+ with self.env.cr.savepoint():
+ journal = Journal.create(acquirer._prepare_account_journal_vals())
+ except psycopg2.IntegrityError as e:
+ if e.pgcode == psycopg2.errorcodes.UNIQUE_VIOLATION:
+ journal = Journal.search(acquirer._get_acquirer_journal_domain(), limit=1)
+ else:
+ raise
+ acquirer.journal_id = journal
+ journals += journal
+ return journals
+
+ @api.model
+ def create(self, vals):
+ record = super(PaymentAcquirer, self).create(vals)
+ record._check_required_if_provider()
+ return record
+
+ def write(self, vals):
+ result = super(PaymentAcquirer, self).write(vals)
+ self._check_required_if_provider()
+ return result
+
+ def get_acquirer_extra_fees(self, amount, currency_id, country_id):
+ extra_fees = {
+ 'currency_id': currency_id
+ }
+ acquirers = self.filtered(lambda x: x.fees_active)
+ for acq in acquirers:
+ custom_method_name = '%s_compute_fees' % acq.provider
+ if hasattr(acq, custom_method_name):
+ fees = getattr(acq, custom_method_name)(amount, currency_id, country_id)
+ extra_fees[acq] = fees
+ return extra_fees
+
+ def get_form_action_url(self):
+ """ Returns the form action URL, for form-based acquirer implementations. """
+ if hasattr(self, '%s_get_form_action_url' % self.provider):
+ return getattr(self, '%s_get_form_action_url' % self.provider)()
+ return False
+
+ def _get_available_payment_input(self, partner=None, company=None):
+ """ Generic (model) method that fetches available payment mechanisms
+ to use in all portal / eshop pages that want to use the payment form.
+
+ It contains
+
+ * acquirers: record set of both form and s2s acquirers;
+ * pms: record set of stored credit card data (aka payment.token)
+ connected to a given partner to allow customers to reuse them """
+ if not company:
+ company = self.env.company
+ if not partner:
+ partner = self.env.user.partner_id
+
+ domain = expression.AND([
+ ['&', ('state', 'in', ['enabled', 'test']), ('company_id', '=', company.id)],
+ ['|', ('country_ids', '=', False), ('country_ids', 'in', [partner.country_id.id])]
+ ])
+ active_acquirers = self.search(domain)
+ acquirers = active_acquirers.filtered(lambda acq: (acq.payment_flow == 'form' and acq.view_template_id) or
+ (acq.payment_flow == 's2s' and acq.registration_view_template_id))
+ return {
+ 'acquirers': acquirers,
+ 'pms': self.env['payment.token'].search([
+ ('partner_id', '=', partner.id),
+ ('acquirer_id', 'in', acquirers.ids)]),
+ }
+
+ def render(self, reference, amount, currency_id, partner_id=False, values=None):
+ """ Renders the form template of the given acquirer as a qWeb template.
+ :param string reference: the transaction reference
+ :param float amount: the amount the buyer has to pay
+ :param currency_id: currency id
+ :param dict partner_id: optional partner_id to fill values
+ :param dict values: a dictionary of values for the transction that is
+ given to the acquirer-specific method generating the form values
+
+ All templates will receive:
+
+ - acquirer: the payment.acquirer browse record
+ - user: the current user browse record
+ - currency_id: id of the transaction currency
+ - amount: amount of the transaction
+ - reference: reference of the transaction
+ - partner_*: partner-related values
+ - partner: optional partner browse record
+ - 'feedback_url': feedback URL, controler that manage answer of the acquirer (without base url) -> FIXME
+ - 'return_url': URL for coming back after payment validation (wihout base url) -> FIXME
+ - 'cancel_url': URL if the client cancels the payment -> FIXME
+ - 'error_url': URL if there is an issue with the payment -> FIXME
+ - context: Odoo context
+
+ """
+ if values is None:
+ values = {}
+
+ if not self.view_template_id:
+ return None
+
+ values.setdefault('return_url', '/payment/process')
+ # reference and amount
+ values.setdefault('reference', reference)
+ amount = float_round(amount, 2)
+ values.setdefault('amount', amount)
+
+ # currency id
+ currency_id = values.setdefault('currency_id', currency_id)
+ if currency_id:
+ currency = self.env['res.currency'].browse(currency_id)
+ else:
+ currency = self.env.company.currency_id
+ values['currency'] = currency
+
+ # Fill partner_* using values['partner_id'] or partner_id argument
+ partner_id = values.get('partner_id', partner_id)
+ billing_partner_id = values.get('billing_partner_id', partner_id)
+ if partner_id:
+ partner = self.env['res.partner'].browse(partner_id)
+ if partner_id != billing_partner_id:
+ billing_partner = self.env['res.partner'].browse(billing_partner_id)
+ else:
+ billing_partner = partner
+ values.update({
+ 'partner': partner,
+ 'partner_id': partner_id,
+ 'partner_name': partner.name,
+ 'partner_lang': partner.lang,
+ 'partner_email': partner.email,
+ 'partner_zip': partner.zip,
+ 'partner_city': partner.city,
+ 'partner_address': _partner_format_address(partner.street, partner.street2),
+ 'partner_country_id': partner.country_id.id or self.env['res.company']._company_default_get().country_id.id,
+ 'partner_country': partner.country_id,
+ 'partner_phone': partner.phone,
+ 'partner_state': partner.state_id,
+ 'billing_partner': billing_partner,
+ 'billing_partner_id': billing_partner_id,
+ 'billing_partner_name': billing_partner.name,
+ 'billing_partner_commercial_company_name': billing_partner.commercial_company_name,
+ 'billing_partner_lang': billing_partner.lang,
+ 'billing_partner_email': billing_partner.email,
+ 'billing_partner_zip': billing_partner.zip,
+ 'billing_partner_city': billing_partner.city,
+ 'billing_partner_address': _partner_format_address(billing_partner.street, billing_partner.street2),
+ 'billing_partner_country_id': billing_partner.country_id.id,
+ 'billing_partner_country': billing_partner.country_id,
+ 'billing_partner_phone': billing_partner.phone,
+ 'billing_partner_state': billing_partner.state_id,
+ })
+ if values.get('partner_name'):
+ values.update({
+ 'partner_first_name': _partner_split_name(values.get('partner_name'))[0],
+ 'partner_last_name': _partner_split_name(values.get('partner_name'))[1],
+ })
+ if values.get('billing_partner_name'):
+ values.update({
+ 'billing_partner_first_name': _partner_split_name(values.get('billing_partner_name'))[0],
+ 'billing_partner_last_name': _partner_split_name(values.get('billing_partner_name'))[1],
+ })
+
+ # Fix address, country fields
+ if not values.get('partner_address'):
+ values['address'] = _partner_format_address(values.get('partner_street', ''), values.get('partner_street2', ''))
+ if not values.get('partner_country') and values.get('partner_country_id'):
+ values['country'] = self.env['res.country'].browse(values.get('partner_country_id'))
+ if not values.get('billing_partner_address'):
+ values['billing_address'] = _partner_format_address(values.get('billing_partner_street', ''), values.get('billing_partner_street2', ''))
+ if not values.get('billing_partner_country') and values.get('billing_partner_country_id'):
+ values['billing_country'] = self.env['res.country'].browse(values.get('billing_partner_country_id'))
+
+ # compute fees
+ fees_method_name = '%s_compute_fees' % self.provider
+ if hasattr(self, fees_method_name):
+ fees = getattr(self, fees_method_name)(values['amount'], values['currency_id'], values.get('partner_country_id'))
+ values['fees'] = float_round(fees, 2)
+
+ # call <name>_form_generate_values to update the tx dict with acqurier specific values
+ cust_method_name = '%s_form_generate_values' % (self.provider)
+ if hasattr(self, cust_method_name):
+ method = getattr(self, cust_method_name)
+ values = method(values)
+
+ values.update({
+ 'tx_url': self._context.get('tx_url', self.get_form_action_url()),
+ 'submit_class': self._context.get('submit_class', 'btn btn-link'),
+ 'submit_txt': self._context.get('submit_txt'),
+ 'acquirer': self,
+ 'user': self.env.user,
+ 'context': self._context,
+ 'type': values.get('type') or 'form',
+ })
+
+ _logger.info('payment.acquirer.render: <%s> values rendered for form payment:\n%s', self.provider, pprint.pformat(values))
+ return self.view_template_id._render(values, engine='ir.qweb')
+
+ def get_s2s_form_xml_id(self):
+ if self.registration_view_template_id:
+ model_data = self.env['ir.model.data'].search([('model', '=', 'ir.ui.view'), ('res_id', '=', self.registration_view_template_id.id)])
+ return ('%s.%s') % (model_data.module, model_data.name)
+ return False
+
+ def s2s_process(self, data):
+ cust_method_name = '%s_s2s_form_process' % (self.provider)
+ if not self.s2s_validate(data):
+ return False
+ if hasattr(self, cust_method_name):
+ # As this method may be called in JSON and overridden in various addons
+ # let us raise interesting errors before having stranges crashes
+ if not data.get('partner_id'):
+ raise ValueError(_('Missing partner reference when trying to create a new payment token'))
+ method = getattr(self, cust_method_name)
+ return method(data)
+ return True
+
+ def s2s_validate(self, data):
+ cust_method_name = '%s_s2s_form_validate' % (self.provider)
+ if hasattr(self, cust_method_name):
+ method = getattr(self, cust_method_name)
+ return method(data)
+ return True
+
+ def button_immediate_install(self):
+ # TDE FIXME: remove that brol
+ if self.module_id and self.module_state != 'installed':
+ self.module_id.button_immediate_install()
+ return {
+ 'type': 'ir.actions.client',
+ 'tag': 'reload',
+ }
+
+class PaymentIcon(models.Model):
+ _name = 'payment.icon'
+ _description = 'Payment Icon'
+
+ name = fields.Char(string='Name')
+ acquirer_ids = fields.Many2many('payment.acquirer', string="Acquirers", help="List of Acquirers supporting this payment icon.")
+ image = fields.Binary(
+ "Image", help="This field holds the image used for this payment icon, limited to 1024x1024px")
+
+ image_payment_form = fields.Binary(
+ "Image displayed on the payment form", attachment=True)
+
+ @api.model_create_multi
+ def create(self, vals_list):
+ for vals in vals_list:
+ if 'image' in vals:
+ image = ustr(vals['image'] or '').encode('utf-8')
+ vals['image_payment_form'] = image_process(image, size=(45,30))
+ vals['image'] = image_process(image, size=(64,64))
+ return super(PaymentIcon, self).create(vals_list)
+
+ def write(self, vals):
+ if 'image' in vals:
+ image = ustr(vals['image'] or '').encode('utf-8')
+ vals['image_payment_form'] = image_process(image, size=(45,30))
+ vals['image'] = image_process(image, size=(64,64))
+ return super(PaymentIcon, self).write(vals)
+
+class PaymentTransaction(models.Model):
+ """ Transaction Model. Each specific acquirer can extend the model by adding
+ its own fields.
+
+ Methods that can be added in an acquirer-specific implementation:
+
+ - ``<name>_create``: method receiving values used when creating a new
+ transaction and that returns a dictionary that will update those values.
+ This method can be used to tweak some transaction values.
+
+ Methods defined for convention, depending on your controllers:
+
+ - ``<name>_form_feedback(self, data)``: method that handles the data coming
+ from the acquirer after the transaction. It will generally receives data
+ posted by the acquirer after the transaction.
+ """
+ _name = 'payment.transaction'
+ _description = 'Payment Transaction'
+ _order = 'id desc'
+ _rec_name = 'reference'
+
+ @api.model
+ def _lang_get(self):
+ return self.env['res.lang'].get_installed()
+
+ @api.model
+ def _get_default_partner_country_id(self):
+ return self.env.company.country_id.id
+
+ date = fields.Datetime('Validation Date', readonly=True)
+ acquirer_id = fields.Many2one('payment.acquirer', string='Acquirer', readonly=True, required=True)
+ provider = fields.Selection(string='Provider', related='acquirer_id.provider', readonly=True)
+ type = fields.Selection([
+ ('validation', 'Validation of the bank card'),
+ ('server2server', 'Server To Server'),
+ ('form', 'Form'),
+ ('form_save', 'Form with tokenization')], 'Type',
+ default='form', required=True, readonly=True)
+ state = fields.Selection([
+ ('draft', 'Draft'),
+ ('pending', 'Pending'),
+ ('authorized', 'Authorized'),
+ ('done', 'Done'),
+ ('cancel', 'Canceled'),
+ ('error', 'Error'),],
+ string='Status', copy=False, default='draft', required=True, readonly=True)
+ state_message = fields.Text(string='Message', readonly=True,
+ help='Field used to store error and/or validation messages for information')
+ amount = fields.Monetary(string='Amount', currency_field='currency_id', required=True, readonly=True)
+ fees = fields.Monetary(string='Fees', currency_field='currency_id', readonly=True,
+ help='Fees amount; set by the system because depends on the acquirer')
+ currency_id = fields.Many2one('res.currency', 'Currency', required=True, readonly=True)
+ reference = fields.Char(string='Reference', required=True, readonly=True, index=True,
+ help='Internal reference of the TX')
+ acquirer_reference = fields.Char(string='Acquirer Reference', readonly=True, help='Reference of the TX as stored in the acquirer database')
+ # duplicate partner / transaction data to store the values at transaction time
+ partner_id = fields.Many2one('res.partner', 'Customer')
+ partner_name = fields.Char('Partner Name')
+ partner_lang = fields.Selection(_lang_get, 'Language', default=lambda self: self.env.lang)
+ partner_email = fields.Char('Email')
+ partner_zip = fields.Char('Zip')
+ partner_address = fields.Char('Address')
+ partner_city = fields.Char('City')
+ partner_country_id = fields.Many2one('res.country', 'Country', default=_get_default_partner_country_id, required=True)
+ partner_phone = fields.Char('Phone')
+ html_3ds = fields.Char('3D Secure HTML')
+
+ callback_model_id = fields.Many2one('ir.model', 'Callback Document Model', groups="base.group_system")
+ callback_res_id = fields.Integer('Callback Document ID', groups="base.group_system")
+ callback_method = fields.Char('Callback Method', groups="base.group_system")
+ callback_hash = fields.Char('Callback Hash', groups="base.group_system")
+
+ # Fields used for user redirection & payment post processing
+ return_url = fields.Char('Return URL after payment')
+ is_processed = fields.Boolean('Has the payment been post processed', default=False)
+
+ # Fields used for payment.transaction traceability.
+
+ payment_token_id = fields.Many2one('payment.token', 'Payment Token', readonly=True,
+ domain="[('acquirer_id', '=', acquirer_id)]")
+
+ payment_id = fields.Many2one('account.payment', string='Payment', readonly=True)
+ invoice_ids = fields.Many2many('account.move', 'account_invoice_transaction_rel', 'transaction_id', 'invoice_id',
+ string='Invoices', copy=False, readonly=True,
+ domain=[('move_type', 'in', ('out_invoice', 'out_refund', 'in_invoice', 'in_refund'))])
+ invoice_ids_nbr = fields.Integer(compute='_compute_invoice_ids_nbr', string='# of Invoices')
+
+ _sql_constraints = [
+ ('reference_uniq', 'unique(reference)', 'Reference must be unique!'),
+ ]
+
+ @api.depends('invoice_ids')
+ def _compute_invoice_ids_nbr(self):
+ for trans in self:
+ trans.invoice_ids_nbr = len(trans.invoice_ids)
+
+ def _create_payment(self, add_payment_vals={}):
+ ''' Create an account.payment record for the current payment.transaction.
+ If the transaction is linked to some invoices, the reconciliation will be done automatically.
+ :param add_payment_vals: Optional additional values to be passed to the account.payment.create method.
+ :return: An account.payment record.
+ '''
+ self.ensure_one()
+
+ payment_vals = {
+ 'amount': self.amount,
+ 'payment_type': 'inbound' if self.amount > 0 else 'outbound',
+ 'currency_id': self.currency_id.id,
+ 'partner_id': self.partner_id.commercial_partner_id.id,
+ 'partner_type': 'customer',
+ 'journal_id': self.acquirer_id.journal_id.id,
+ 'company_id': self.acquirer_id.company_id.id,
+ 'payment_method_id': self.env.ref('payment.account_payment_method_electronic_in').id,
+ 'payment_token_id': self.payment_token_id and self.payment_token_id.id or None,
+ 'payment_transaction_id': self.id,
+ 'ref': self.reference,
+ **add_payment_vals,
+ }
+ payment = self.env['account.payment'].create(payment_vals)
+ payment.action_post()
+
+ # Track the payment to make a one2one.
+ self.payment_id = payment
+
+ if self.invoice_ids:
+ self.invoice_ids.filtered(lambda move: move.state == 'draft')._post()
+
+ (payment.line_ids + self.invoice_ids.line_ids)\
+ .filtered(lambda line: line.account_id == payment.destination_account_id and not line.reconciled)\
+ .reconcile()
+
+ return payment
+
+ def get_last_transaction(self):
+ transactions = self.filtered(lambda t: t.state != 'draft')
+ return transactions and transactions[0] or transactions
+
+ def _get_processing_info(self):
+ """ Extensible method for providers if they need specific fields/info regarding a tx in the payment processing page. """
+ return dict()
+
+ def _get_payment_transaction_sent_message(self):
+ self.ensure_one()
+ if self.payment_token_id:
+ message = _('A transaction %s with %s initiated using %s credit card.')
+ message_vals = (self.reference, self.acquirer_id.name, self.payment_token_id.name)
+ elif self.provider in ('manual', 'transfer'):
+ message = _('The customer has selected %s to pay this document.')
+ message_vals = (self.acquirer_id.name)
+ else:
+ message = _('A transaction %s with %s initiated.')
+ message_vals = (self.reference, self.acquirer_id.name)
+ if self.provider not in ('manual', 'transfer'):
+ message += ' ' + _('Waiting for payment confirmation...')
+ return message % message_vals
+
+ def _get_payment_transaction_received_message(self):
+ self.ensure_one()
+ amount = formatLang(self.env, self.amount, currency_obj=self.currency_id)
+ message_vals = [self.reference, self.acquirer_id.name, amount]
+ if self.state == 'pending':
+ message = _('The transaction %s with %s for %s is pending.')
+ elif self.state == 'authorized':
+ message = _('The transaction %s with %s for %s has been authorized. Waiting for capture...')
+ elif self.state == 'done':
+ message = _('The transaction %s with %s for %s has been confirmed. The related payment is posted: %s')
+ message_vals.append(self.payment_id._get_payment_chatter_link())
+ elif self.state == 'cancel' and self.state_message:
+ message = _('The transaction %s with %s for %s has been cancelled with the following message: %s')
+ message_vals.append(self.state_message)
+ elif self.state == 'error' and self.state_message:
+ message = _('The transaction %s with %s for %s has return failed with the following error message: %s')
+ message_vals.append(self.state_message)
+ else:
+ message = _('The transaction %s with %s for %s has been cancelled.')
+ return message % tuple(message_vals)
+
+ def _log_payment_transaction_sent(self):
+ '''Log the message saying the transaction has been sent to the remote server to be
+ processed by the acquirer.
+ '''
+ for trans in self:
+ post_message = trans._get_payment_transaction_sent_message()
+ for inv in trans.invoice_ids:
+ inv.message_post(body=post_message)
+
+ def _log_payment_transaction_received(self):
+ '''Log the message saying a response has been received from the remote server and some
+ additional informations like the old/new state, the reference of the payment... etc.
+ :param old_state: The state of the transaction before the response.
+ :param add_messages: Optional additional messages to log like the capture status.
+ '''
+ for trans in self.filtered(lambda t: t.provider not in ('manual', 'transfer')):
+ post_message = trans._get_payment_transaction_received_message()
+ for inv in trans.invoice_ids:
+ inv.message_post(body=post_message)
+
+ def _filter_transaction_state(self, allowed_states, target_state):
+ """Divide a set of transactions according to their state.
+
+ :param tuple(string) allowed_states: tuple of allowed states for the target state (strings)
+ :param string target_state: target state for the filtering
+ :return: tuple of transactions divided by their state, in that order
+ tx_to_process: tx that were in the allowed states
+ tx_already_processed: tx that were already in the target state
+ tx_wrong_state: tx that were not in the allowed state for the transition
+ :rtype: tuple(recordset)
+ """
+ tx_to_process = self.filtered(lambda tx: tx.state in allowed_states)
+ tx_already_processed = self.filtered(lambda tx: tx.state == target_state)
+ tx_wrong_state = self -tx_to_process - tx_already_processed
+ return (tx_to_process, tx_already_processed, tx_wrong_state)
+
+ def _set_transaction_pending(self):
+ '''Move the transaction to the pending state(e.g. Wire Transfer).'''
+ allowed_states = ('draft',)
+ target_state = 'pending'
+ (tx_to_process, tx_already_processed, tx_wrong_state) = self._filter_transaction_state(allowed_states, target_state)
+ for tx in tx_already_processed:
+ _logger.info('Trying to write the same state twice on tx (ref: %s, state: %s' % (tx.reference, tx.state))
+ for tx in tx_wrong_state:
+ _logger.warning('Processed tx with abnormal state (ref: %s, target state: %s, previous state %s, expected previous states: %s)' % (tx.reference, target_state, tx.state, allowed_states))
+
+ tx_to_process.write({
+ 'state': target_state,
+ 'date': fields.Datetime.now(),
+ 'state_message': '',
+ })
+ tx_to_process._log_payment_transaction_received()
+
+ def _set_transaction_authorized(self):
+ '''Move the transaction to the authorized state(e.g. Authorize).'''
+ allowed_states = ('draft', 'pending')
+ target_state = 'authorized'
+ (tx_to_process, tx_already_processed, tx_wrong_state) = self._filter_transaction_state(allowed_states, target_state)
+ for tx in tx_already_processed:
+ _logger.info('Trying to write the same state twice on tx (ref: %s, state: %s' % (tx.reference, tx.state))
+ for tx in tx_wrong_state:
+ _logger.warning('Processed tx with abnormal state (ref: %s, target state: %s, previous state %s, expected previous states: %s)' % (tx.reference, target_state, tx.state, allowed_states))
+ tx_to_process.write({
+ 'state': target_state,
+ 'date': fields.Datetime.now(),
+ 'state_message': '',
+ })
+ tx_to_process._log_payment_transaction_received()
+
+ def _set_transaction_done(self):
+ '''Move the transaction's payment to the done state(e.g. Paypal).'''
+ allowed_states = ('draft', 'authorized', 'pending', 'error')
+ target_state = 'done'
+ (tx_to_process, tx_already_processed, tx_wrong_state) = self._filter_transaction_state(allowed_states, target_state)
+ for tx in tx_already_processed:
+ _logger.info('Trying to write the same state twice on tx (ref: %s, state: %s' % (tx.reference, tx.state))
+ for tx in tx_wrong_state:
+ _logger.warning('Processed tx with abnormal state (ref: %s, target state: %s, previous state %s, expected previous states: %s)' % (tx.reference, target_state, tx.state, allowed_states))
+
+ tx_to_process.write({
+ 'state': target_state,
+ 'date': fields.Datetime.now(),
+ 'state_message': '',
+ })
+
+ def _reconcile_after_transaction_done(self):
+ # Validate invoices automatically upon the transaction is posted.
+ invoices = self.mapped('invoice_ids').filtered(lambda inv: inv.state == 'draft')
+ invoices._post()
+
+ # Create & Post the payments.
+ for trans in self:
+ if trans.payment_id:
+ continue
+
+ trans._create_payment()
+
+ def _set_transaction_cancel(self):
+ '''Move the transaction's payment to the cancel state(e.g. Paypal).'''
+ allowed_states = ('draft', 'authorized')
+ target_state = 'cancel'
+ (tx_to_process, tx_already_processed, tx_wrong_state) = self._filter_transaction_state(allowed_states, target_state)
+ for tx in tx_already_processed:
+ _logger.info('Trying to write the same state twice on tx (ref: %s, state: %s' % (tx.reference, tx.state))
+ for tx in tx_wrong_state:
+ _logger.warning('Processed tx with abnormal state (ref: %s, target state: %s, previous state %s, expected previous states: %s)' % (tx.reference, target_state, tx.state, allowed_states))
+
+ # Cancel the existing payments.
+ tx_to_process.mapped('payment_id').action_cancel()
+
+ tx_to_process.write({'state': target_state, 'date': fields.Datetime.now()})
+ tx_to_process._log_payment_transaction_received()
+
+ def _set_transaction_error(self, msg):
+ '''Move the transaction to the error state (Third party returning error e.g. Paypal).'''
+ allowed_states = ('draft', 'authorized', 'pending')
+ target_state = 'error'
+ (tx_to_process, tx_already_processed, tx_wrong_state) = self._filter_transaction_state(allowed_states, target_state)
+ for tx in tx_already_processed:
+ _logger.info('Trying to write the same state twice on tx (ref: %s, state: %s' % (tx.reference, tx.state))
+ for tx in tx_wrong_state:
+ _logger.warning('Processed tx with abnormal state (ref: %s, target state: %s, previous state %s, expected previous states: %s)' % (tx.reference, target_state, tx.state, allowed_states))
+
+ tx_to_process.write({
+ 'state': target_state,
+ 'date': fields.Datetime.now(),
+ 'state_message': msg,
+ })
+ tx_to_process._log_payment_transaction_received()
+
+ def _post_process_after_done(self):
+ self._reconcile_after_transaction_done()
+ self._log_payment_transaction_received()
+ self.write({'is_processed': True})
+ return True
+
+ def _cron_post_process_after_done(self):
+ if not self:
+ ten_minutes_ago = datetime.now() - relativedelta.relativedelta(minutes=10)
+ # we don't want to forever try to process a transaction that doesn't go through
+ retry_limit_date = datetime.now() - relativedelta.relativedelta(days=2)
+ # we retrieve all the payment tx that need to be post processed
+ self = self.search([('state', '=', 'done'),
+ ('is_processed', '=', False),
+ ('date', '<=', ten_minutes_ago),
+ ('date', '>=', retry_limit_date),
+ ])
+ for tx in self:
+ try:
+ tx._post_process_after_done()
+ self.env.cr.commit()
+ except Exception as e:
+ _logger.exception("Transaction post processing failed")
+ self.env.cr.rollback()
+
+ @api.model
+ def _compute_reference_prefix(self, values):
+ if values and values.get('invoice_ids'):
+ invoices = self.new({'invoice_ids': values['invoice_ids']}).invoice_ids
+ return ','.join(invoices.mapped('name'))
+ return None
+
+ @api.model
+ def _compute_reference(self, values=None, prefix=None):
+ '''Compute a unique reference for the transaction.
+ If prefix:
+ prefix-\d+
+ If some invoices:
+ <inv_number_0>.number,<inv_number_1>,...,<inv_number_n>-x
+ If some sale orders:
+ <so_name_0>.number,<so_name_1>,...,<so_name_n>-x
+ Else:
+ tx-\d+
+ :param values: values used to create a new transaction.
+ :param prefix: custom transaction prefix.
+ :return: A unique reference for the transaction.
+ '''
+ if not prefix:
+ prefix = self._compute_reference_prefix(values)
+ if not prefix:
+ prefix = 'tx'
+
+ # Fetch the last reference
+ # E.g. If the last reference is SO42-5, this query will return '-5'
+ self._cr.execute('''
+ SELECT CAST(SUBSTRING(reference FROM '-\d+$') AS INTEGER) AS suffix
+ FROM payment_transaction WHERE reference LIKE %s ORDER BY suffix
+ ''', [prefix + '-%'])
+ query_res = self._cr.fetchone()
+ if query_res:
+ # Increment the last reference by one
+ suffix = '%s' % (-query_res[0] + 1)
+ else:
+ # Start a new indexing from 1
+ suffix = '1'
+
+ return '%s-%s' % (prefix, suffix)
+
+ def action_view_invoices(self):
+ action = {
+ 'name': _('Invoices'),
+ 'type': 'ir.actions.act_window',
+ 'res_model': 'account.move',
+ 'target': 'current',
+ }
+ invoice_ids = self.invoice_ids.ids
+ if len(invoice_ids) == 1:
+ invoice = invoice_ids[0]
+ action['res_id'] = invoice
+ action['view_mode'] = 'form'
+ form_view = [(self.env.ref('account.view_move_form').id, 'form')]
+ if 'views' in action:
+ action['views'] = form_view + [(state,view) for state,view in action['views'] if view != 'form']
+ else:
+ action['views'] = form_view
+ else:
+ action['view_mode'] = 'tree,form'
+ action['domain'] = [('id', 'in', invoice_ids)]
+ return action
+
+ @api.constrains('state', 'acquirer_id')
+ def _check_authorize_state(self):
+ failed_tx = self.filtered(lambda tx: tx.state == 'authorized' and tx.acquirer_id.provider not in self.env['payment.acquirer']._get_feature_support()['authorize'])
+ if failed_tx:
+ raise exceptions.ValidationError(_('The %s payment acquirers are not allowed to manual capture mode!', failed_tx.mapped('acquirer_id.name')))
+
+ @api.model
+ def create(self, values):
+ # call custom create method if defined
+ acquirer = self.env['payment.acquirer'].browse(values['acquirer_id'])
+ if values.get('partner_id'):
+ partner = self.env['res.partner'].browse(values['partner_id'])
+
+ values.update({
+ 'partner_name': partner.name,
+ 'partner_lang': partner.lang or self.env.user.lang,
+ 'partner_email': partner.email,
+ 'partner_zip': partner.zip,
+ 'partner_address': _partner_format_address(partner.street or '', partner.street2 or ''),
+ 'partner_city': partner.city,
+ 'partner_country_id': partner.country_id.id or self._get_default_partner_country_id(),
+ 'partner_phone': partner.phone,
+ })
+
+ # compute fees
+ custom_method_name = '%s_compute_fees' % acquirer.provider
+ if hasattr(acquirer, custom_method_name):
+ fees = getattr(acquirer, custom_method_name)(
+ values.get('amount', 0.0), values.get('currency_id'), values.get('partner_country_id', self._get_default_partner_country_id()))
+ values['fees'] = fees
+
+ # custom create
+ custom_method_name = '%s_create' % acquirer.provider
+ if hasattr(self, custom_method_name):
+ values.update(getattr(self, custom_method_name)(values))
+
+ if not values.get('reference'):
+ values['reference'] = self._compute_reference(values=values)
+
+ # Default value of reference is
+ tx = super(PaymentTransaction, self).create(values)
+
+ # Generate callback hash if it is configured on the tx; avoid generating unnecessary stuff
+ # (limited sudo env for checking callback presence, must work for manual transactions too)
+ tx_sudo = tx.sudo()
+ if tx_sudo.callback_model_id and tx_sudo.callback_res_id and tx_sudo.callback_method:
+ tx.write({'callback_hash': tx._generate_callback_hash()})
+
+ return tx
+
+ def _generate_callback_hash(self):
+ self.ensure_one()
+ secret = self.env['ir.config_parameter'].sudo().get_param('database.secret')
+ token = '%s%s%s' % (self.callback_model_id.model,
+ self.callback_res_id,
+ self.sudo().callback_method)
+ return hmac.new(secret.encode('utf-8'), token.encode('utf-8'), hashlib.sha256).hexdigest()
+
+ # --------------------------------------------------
+ # FORM RELATED METHODS
+ # --------------------------------------------------
+
+ @api.model
+ def form_feedback(self, data, acquirer_name):
+ invalid_parameters, tx = None, None
+
+ tx_find_method_name = '_%s_form_get_tx_from_data' % acquirer_name
+ if hasattr(self, tx_find_method_name):
+ tx = getattr(self, tx_find_method_name)(data)
+
+ # TDE TODO: form_get_invalid_parameters from model to multi
+ invalid_param_method_name = '_%s_form_get_invalid_parameters' % acquirer_name
+ if hasattr(self, invalid_param_method_name):
+ invalid_parameters = getattr(tx, invalid_param_method_name)(data)
+
+ if invalid_parameters:
+ _error_message = '%s: incorrect tx data:\n' % (acquirer_name)
+ for item in invalid_parameters:
+ _error_message += '\t%s: received %s instead of %s\n' % (item[0], item[1], item[2])
+ _logger.error(_error_message)
+ return False
+
+ # TDE TODO: form_validate from model to multi
+ feedback_method_name = '_%s_form_validate' % acquirer_name
+ if hasattr(self, feedback_method_name):
+ return getattr(tx, feedback_method_name)(data)
+
+ return True
+
+ # --------------------------------------------------
+ # SERVER2SERVER RELATED METHODS
+ # --------------------------------------------------
+
+ def s2s_do_transaction(self, **kwargs):
+ custom_method_name = '%s_s2s_do_transaction' % self.acquirer_id.provider
+ for trans in self:
+ trans._log_payment_transaction_sent()
+ if hasattr(trans, custom_method_name):
+ return getattr(trans, custom_method_name)(**kwargs)
+
+ def s2s_do_refund(self, **kwargs):
+ custom_method_name = '%s_s2s_do_refund' % self.acquirer_id.provider
+ if hasattr(self, custom_method_name):
+ return getattr(self, custom_method_name)(**kwargs)
+
+ def s2s_capture_transaction(self, **kwargs):
+ custom_method_name = '%s_s2s_capture_transaction' % self.acquirer_id.provider
+ if hasattr(self, custom_method_name):
+ return getattr(self, custom_method_name)(**kwargs)
+
+ def s2s_void_transaction(self, **kwargs):
+ custom_method_name = '%s_s2s_void_transaction' % self.acquirer_id.provider
+ if hasattr(self, custom_method_name):
+ return getattr(self, custom_method_name)(**kwargs)
+
+ def s2s_get_tx_status(self):
+ """ Get the tx status. """
+ invalid_param_method_name = '_%s_s2s_get_tx_status' % self.acquirer_id.provider
+ if hasattr(self, invalid_param_method_name):
+ return getattr(self, invalid_param_method_name)()
+ return True
+
+ def execute_callback(self):
+ res = None
+ for transaction in self:
+ # limited sudo env, only for checking callback presence, not for running it!
+ # manual transactions have no callback, and can pass without being run by admin user
+ tx_sudo = transaction.sudo()
+ if not (tx_sudo.callback_model_id and tx_sudo.callback_res_id and tx_sudo.callback_method):
+ continue
+
+ valid_token = transaction._generate_callback_hash()
+ if not consteq(ustr(valid_token), transaction.callback_hash):
+ _logger.warning("Invalid callback signature for transaction %d" % (transaction.id))
+ continue
+
+ record = self.env[transaction.callback_model_id.model].browse(transaction.callback_res_id).exists()
+ if record:
+ res = getattr(record, transaction.callback_method)(transaction)
+ else:
+ _logger.warning("Did not found record %s.%s for callback of transaction %d" % (transaction.callback_model_id.model, transaction.callback_res_id, transaction.id))
+ return res
+
+ def action_capture(self):
+ if any(t.state != 'authorized' for t in self):
+ raise ValidationError(_('Only transactions having the authorized status can be captured.'))
+ for tx in self:
+ tx.s2s_capture_transaction()
+
+ def action_void(self):
+ if any(t.state != 'authorized' for t in self):
+ raise ValidationError(_('Only transactions having the capture status can be voided.'))
+ for tx in self:
+ tx.s2s_void_transaction()
+
+
+class PaymentToken(models.Model):
+ _name = 'payment.token'
+ _order = 'partner_id, id desc'
+ _description = 'Payment Token'
+
+ name = fields.Char('Name', help='Name of the payment token')
+ short_name = fields.Char('Short name', compute='_compute_short_name')
+ partner_id = fields.Many2one('res.partner', 'Partner', required=True)
+ acquirer_id = fields.Many2one('payment.acquirer', 'Acquirer Account', required=True)
+ company_id = fields.Many2one(related='acquirer_id.company_id', store=True, index=True)
+ acquirer_ref = fields.Char('Acquirer Ref.', required=True)
+ active = fields.Boolean('Active', default=True)
+ payment_ids = fields.One2many('payment.transaction', 'payment_token_id', 'Payment Transactions')
+ verified = fields.Boolean(string='Verified', default=False)
+
+ @api.model
+ def create(self, values):
+ # call custom create method if defined
+ if values.get('acquirer_id'):
+ acquirer = self.env['payment.acquirer'].browse(values['acquirer_id'])
+
+ # custom create
+ custom_method_name = '%s_create' % acquirer.provider
+ if hasattr(self, custom_method_name):
+ values.update(getattr(self, custom_method_name)(values))
+ # remove all non-model fields used by (provider)_create method to avoid warning
+ fields_wl = set(self._fields) & set(values)
+ values = {field: values[field] for field in fields_wl}
+ return super(PaymentToken, self).create(values)
+ """
+ @TBE: stolen shamelessly from there https://www.paypal.com/us/selfhelp/article/why-is-there-a-$1.95-charge-on-my-card-statement-faq554
+ Most of them are ~1.50€s
+ """
+ VALIDATION_AMOUNTS = {
+ 'CAD': 2.45,
+ 'EUR': 1.50,
+ 'GBP': 1.00,
+ 'JPY': 200,
+ 'AUD': 2.00,
+ 'NZD': 3.00,
+ 'CHF': 3.00,
+ 'HKD': 15.00,
+ 'SEK': 15.00,
+ 'DKK': 12.50,
+ 'PLN': 6.50,
+ 'NOK': 15.00,
+ 'HUF': 400.00,
+ 'CZK': 50.00,
+ 'BRL': 4.00,
+ 'MYR': 10.00,
+ 'MXN': 20.00,
+ 'ILS': 8.00,
+ 'PHP': 100.00,
+ 'TWD': 70.00,
+ 'THB': 70.00
+ }
+
+ @api.model
+ def validate(self, **kwargs):
+ """
+ This method allow to verify if this payment method is valid or not.
+ It does this by withdrawing a certain amount and then refund it right after.
+ """
+ currency = self.partner_id.currency_id
+
+ if self.VALIDATION_AMOUNTS.get(currency.name):
+ amount = self.VALIDATION_AMOUNTS.get(currency.name)
+ else:
+ # If we don't find the user's currency, then we set the currency to EUR and the amount to 1€50.
+ currency = self.env['res.currency'].search([('name', '=', 'EUR')])
+ amount = 1.5
+
+ if len(currency) != 1:
+ _logger.error("Error 'EUR' currency not found for payment method validation!")
+ return False
+
+ reference = "VALIDATION-%s-%s" % (self.id, datetime.now().strftime('%y%m%d_%H%M%S'))
+ tx = self.env['payment.transaction'].sudo().create({
+ 'amount': amount,
+ 'acquirer_id': self.acquirer_id.id,
+ 'type': 'validation',
+ 'currency_id': currency.id,
+ 'reference': reference,
+ 'payment_token_id': self.id,
+ 'partner_id': self.partner_id.id,
+ 'partner_country_id': self.partner_id.country_id.id,
+ 'state_message': _('This Transaction was automatically processed & refunded in order to validate a new credit card.'),
+ })
+
+ kwargs.update({'3d_secure': True})
+ tx.s2s_do_transaction(**kwargs)
+
+ # if 3D secure is called, then we do not refund right now
+ if not tx.html_3ds:
+ tx.s2s_do_refund()
+
+ return tx
+
+ @api.depends('name')
+ def _compute_short_name(self):
+ for token in self:
+ token.short_name = token.name.replace('XXXXXXXXXXXX', '***')
+
+ def get_linked_records(self):
+ """ This method returns a dict containing all the records linked to the payment.token (e.g Subscriptions),
+ the key is the id of the payment.token and the value is an array that must follow the scheme below.
+
+ {
+ token_id: [
+ 'description': The model description (e.g 'Sale Subscription'),
+ 'id': The id of the record,
+ 'name': The name of the record,
+ 'url': The url to access to this record.
+ ]
+ }
+ """
+ return {r.id:[] for r in self}
diff --git a/addons/payment/models/res_company.py b/addons/payment/models/res_company.py
new file mode 100644
index 00000000..b63d6680
--- /dev/null
+++ b/addons/payment/models/res_company.py
@@ -0,0 +1,31 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import api, fields, models
+
+
+class ResCompany(models.Model):
+ _inherit = 'res.company'
+
+ payment_acquirer_onboarding_state = fields.Selection([('not_done', "Not done"), ('just_done', "Just done"), ('done', "Done")], string="State of the onboarding payment acquirer step", default='not_done')
+ # YTI FIXME: Check if it's really needed on the company. Should be enough on the wizard
+ payment_onboarding_payment_method = fields.Selection([
+ ('paypal', "PayPal"),
+ ('stripe', "Stripe"),
+ ('manual', "Manual"),
+ ('other', "Other"),
+ ], string="Selected onboarding payment method")
+
+ @api.model
+ def action_open_payment_onboarding_payment_acquirer(self):
+ """ Called by onboarding panel above the customer invoice list."""
+ # Fail if there are no existing accounts
+ self.env.company.get_chart_of_accounts_or_fail()
+
+ action = self.env["ir.actions.actions"]._for_xml_id("payment.action_open_payment_onboarding_payment_acquirer_wizard")
+ return action
+
+ def get_account_invoice_onboarding_steps_states_names(self):
+ """ Override. """
+ steps = super(ResCompany, self).get_account_invoice_onboarding_steps_states_names()
+ return steps + ['payment_acquirer_onboarding_state']
diff --git a/addons/payment/models/res_partner.py b/addons/payment/models/res_partner.py
new file mode 100644
index 00000000..b24dba39
--- /dev/null
+++ b/addons/payment/models/res_partner.py
@@ -0,0 +1,19 @@
+# coding: utf-8
+
+from odoo import api, fields, models
+
+
+class res_partner(models.Model):
+ _name = 'res.partner'
+ _inherit = 'res.partner'
+
+ payment_token_ids = fields.One2many('payment.token', 'partner_id', 'Payment Tokens')
+ payment_token_count = fields.Integer('Count Payment Token', compute='_compute_payment_token_count')
+
+ @api.depends('payment_token_ids')
+ def _compute_payment_token_count(self):
+ payment_data = self.env['payment.token'].read_group([
+ ('partner_id', 'in', self.ids)], ['partner_id'], ['partner_id'])
+ mapped_data = dict([(payment['partner_id'][0], payment['partner_id_count']) for payment in payment_data])
+ for partner in self:
+ partner.payment_token_count = mapped_data.get(partner.id, 0)