123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038 |
- # Part of Odoo. See LICENSE file for full copyright and licensing details.
- import logging
- import pprint
- import re
- import unicodedata
- from datetime import datetime
- import psycopg2
- from dateutil import relativedelta
- from odoo import _, api, fields, models
- from odoo.exceptions import UserError, ValidationError
- from odoo.tools import consteq, format_amount, ustr
- from odoo.tools.misc import hmac as hmac_tool
- from odoo.addons.payment import utils as payment_utils
- _logger = logging.getLogger(__name__)
- class PaymentTransaction(models.Model):
- _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()
- provider_id = fields.Many2one(
- string="Provider", comodel_name='payment.provider', readonly=True, required=True)
- provider_code = fields.Selection(related='provider_id.code')
- company_id = fields.Many2one( # Indexed to speed-up ORM searches (from ir_rule or others)
- related='provider_id.company_id', store=True, index=True)
- reference = fields.Char(
- string="Reference", help="The internal reference of the transaction", readonly=True,
- required=True) # Already has an index from the UNIQUE SQL constraint.
- provider_reference = fields.Char(
- string="Provider Reference", help="The provider reference of the transaction",
- readonly=True) # This is not the same thing as the provider reference of the token.
- amount = fields.Monetary(
- string="Amount", currency_field='currency_id', readonly=True, required=True)
- currency_id = fields.Many2one(
- string="Currency", comodel_name='res.currency', readonly=True, required=True)
- fees = fields.Monetary(
- string="Fees", currency_field='currency_id',
- help="The fees amount; set by the system as it depends on the provider", readonly=True)
- token_id = fields.Many2one(
- string="Payment Token", comodel_name='payment.token', readonly=True,
- domain='[("provider_id", "=", "provider_id")]', ondelete='restrict')
- state = fields.Selection(
- string="Status",
- selection=[('draft', "Draft"), ('pending', "Pending"), ('authorized', "Authorized"),
- ('done', "Confirmed"), ('cancel', "Canceled"), ('error', "Error")],
- default='draft', readonly=True, required=True, copy=False, index=True)
- state_message = fields.Text(
- string="Message", help="The complementary information message about the state",
- readonly=True)
- last_state_change = fields.Datetime(
- string="Last State Change Date", readonly=True, default=fields.Datetime.now)
- # Fields used for traceability.
- operation = fields.Selection( # This should not be trusted if the state is draft or pending.
- string="Operation",
- selection=[
- ('online_redirect', "Online payment with redirection"),
- ('online_direct', "Online direct payment"),
- ('online_token', "Online payment by token"),
- ('validation', "Validation of the payment method"),
- ('offline', "Offline payment by token"),
- ('refund', "Refund")
- ],
- readonly=True,
- index=True,
- )
- source_transaction_id = fields.Many2one(
- string="Source Transaction",
- comodel_name='payment.transaction',
- help="The source transaction of related refund transactions",
- readonly=True,
- )
- child_transaction_ids = fields.One2many(
- string="Child Transactions",
- help="The child transactions of the source transaction.",
- comodel_name='payment.transaction',
- inverse_name='source_transaction_id',
- readonly=True,
- )
- refunds_count = fields.Integer(string="Refunds Count", compute='_compute_refunds_count')
- # Fields used for user redirection & payment post-processing
- is_post_processed = fields.Boolean(
- string="Is Post-processed", help="Has the payment been post-processed")
- tokenize = fields.Boolean(
- string="Create Token",
- help="Whether a payment token should be created when post-processing the transaction")
- landing_route = fields.Char(
- string="Landing Route",
- help="The route the user is redirected to after the transaction")
- callback_model_id = fields.Many2one(
- string="Callback Document Model", comodel_name='ir.model', groups='base.group_system')
- callback_res_id = fields.Integer(string="Callback Record ID", groups='base.group_system')
- callback_method = fields.Char(string="Callback Method", groups='base.group_system')
- # Hash for extra security on top of the callback fields' group in case a bug exposes a sudo.
- callback_hash = fields.Char(string="Callback Hash", groups='base.group_system')
- callback_is_done = fields.Boolean(
- string="Callback Done", help="Whether the callback has already been executed",
- groups="base.group_system", readonly=True)
- # Duplicated partner values allowing to keep a record of them, should they be later updated.
- partner_id = fields.Many2one(
- string="Customer", comodel_name='res.partner', readonly=True, required=True,
- ondelete='restrict')
- partner_name = fields.Char(string="Partner Name")
- partner_lang = fields.Selection(string="Language", selection=_lang_get)
- partner_email = fields.Char(string="Email")
- partner_address = fields.Char(string="Address")
- partner_zip = fields.Char(string="Zip")
- partner_city = fields.Char(string="City")
- partner_state_id = fields.Many2one(string="State", comodel_name='res.country.state')
- partner_country_id = fields.Many2one(string="Country", comodel_name='res.country')
- partner_phone = fields.Char(string="Phone")
- _sql_constraints = [
- ('reference_uniq', 'unique(reference)', "Reference must be unique!"),
- ]
- #=== COMPUTE METHODS ===#
- def _compute_refunds_count(self):
- rg_data = self.env['payment.transaction']._read_group(
- domain=[('source_transaction_id', 'in', self.ids), ('operation', '=', 'refund')],
- fields=['source_transaction_id'],
- groupby=['source_transaction_id'],
- )
- data = {x['source_transaction_id'][0]: x['source_transaction_id_count'] for x in rg_data}
- for record in self:
- record.refunds_count = data.get(record.id, 0)
- #=== CONSTRAINT METHODS ===#
- @api.constrains('state')
- def _check_state_authorized_supported(self):
- """ Check that authorization is supported for a transaction in the `authorized` state. """
- illegal_authorize_state_txs = self.filtered(
- lambda tx: tx.state == 'authorized' and not tx.provider_id.support_manual_capture
- )
- if illegal_authorize_state_txs:
- raise ValidationError(_(
- "Transaction authorization is not supported by the following payment providers: %s",
- ', '.join(set(illegal_authorize_state_txs.mapped('provider_id.name')))
- ))
- @api.constrains('token_id')
- def _check_token_is_active(self):
- """ Check that the token used to create the transaction is active. """
- if self.token_id and not self.token_id.active:
- raise ValidationError(_("Creating a transaction from an archived token is forbidden."))
- #=== CRUD METHODS ===#
- @api.model_create_multi
- def create(self, values_list):
- for values in values_list:
- provider = self.env['payment.provider'].browse(values['provider_id'])
- if not values.get('reference'):
- values['reference'] = self._compute_reference(provider.code, **values)
- # Duplicate partner values.
- partner = self.env['res.partner'].browse(values['partner_id'])
- values.update({
- # Use the parent partner as fallback if the invoicing address has no name.
- 'partner_name': partner.name or partner.parent_id.name,
- 'partner_lang': partner.lang,
- 'partner_email': partner.email,
- 'partner_address': payment_utils.format_partner_address(
- partner.street, partner.street2
- ),
- 'partner_zip': partner.zip,
- 'partner_city': partner.city,
- 'partner_state_id': partner.state_id.id,
- 'partner_country_id': partner.country_id.id,
- 'partner_phone': partner.phone,
- })
- # Compute fees. For validation transactions, fees are zero.
- if values.get('operation') == 'validation':
- values['fees'] = 0
- else:
- currency = self.env['res.currency'].browse(values.get('currency_id')).exists()
- values['fees'] = provider._compute_fees(
- values.get('amount', 0), currency, partner.country_id,
- )
- # Include provider-specific create values
- values.update(self._get_specific_create_values(provider.code, values))
- # Generate the hash for the callback if one has be configured on the tx.
- values['callback_hash'] = self._generate_callback_hash(
- values.get('callback_model_id'),
- values.get('callback_res_id'),
- values.get('callback_method'),
- )
- txs = super().create(values_list)
- # Monetary fields are rounded with the currency at creation time by the ORM. Sometimes, this
- # can lead to inconsistent string representation of the amounts sent to the providers.
- # E.g., tx.create(amount=1111.11) -> tx.amount == 1111.1100000000001
- # To ensure a proper string representation, we invalidate this request's cache values of the
- # `amount` and `fees` fields for the created transactions. This forces the ORM to read the
- # values from the DB where there were stored using `float_repr`, which produces a result
- # consistent with the format expected by providers.
- # E.g., tx.create(amount=1111.11) ; tx.invalidate_recordset() -> tx.amount == 1111.11
- txs.invalidate_recordset(['amount', 'fees'])
- return txs
- @api.model
- def _get_specific_create_values(self, provider_code, values):
- """ Complete the values of the `create` method with provider-specific values.
- For a provider to add its own create values, it must overwrite this method and return a dict
- of values. Provider-specific values take precedence over those of the dict of generic create
- values.
- :param str provider_code: The code of the provider that handled the transaction.
- :param dict values: The original create values.
- :return: The dict of provider-specific create values.
- :rtype: dict
- """
- return dict()
- #=== ACTION METHODS ===#
- def action_view_refunds(self):
- """ Return the windows action to browse the refund transactions linked to the transaction.
- Note: `self.ensure_one()`
- :return: The window action to browse the refund transactions.
- :rtype: dict
- """
- self.ensure_one()
- action = {
- 'name': _("Refund"),
- 'res_model': 'payment.transaction',
- 'type': 'ir.actions.act_window',
- }
- if self.refunds_count == 1:
- refund_tx = self.env['payment.transaction'].search([
- ('source_transaction_id', '=', self.id),
- ])[0]
- action['res_id'] = refund_tx.id
- action['view_mode'] = 'form'
- else:
- action['view_mode'] = 'tree,form'
- action['domain'] = [('source_transaction_id', '=', self.id)]
- return action
- def action_capture(self):
- """ Check the state of the transactions and request their capture. """
- if any(tx.state != 'authorized' for tx in self):
- raise ValidationError(_("Only authorized transactions can be captured."))
- payment_utils.check_rights_on_recordset(self)
- for tx in self:
- # In sudo mode because we need to be able to read on provider fields.
- tx.sudo()._send_capture_request()
- def action_void(self):
- """ Check the state of the transaction and request to have them voided. """
- if any(tx.state != 'authorized' for tx in self):
- raise ValidationError(_("Only authorized transactions can be voided."))
- payment_utils.check_rights_on_recordset(self)
- for tx in self:
- # In sudo mode because we need to be able to read on provider fields.
- tx.sudo()._send_void_request()
- def action_refund(self, amount_to_refund=None):
- """ Check the state of the transactions and request their refund.
- :param float amount_to_refund: The amount to be refunded.
- :return: None
- """
- if any(tx.state != 'done' for tx in self):
- raise ValidationError(_("Only confirmed transactions can be refunded."))
- for tx in self:
- tx._send_refund_request(amount_to_refund)
- #=== BUSINESS METHODS - PAYMENT FLOW ===#
- @api.model
- def _compute_reference(self, provider_code, prefix=None, separator='-', **kwargs):
- """ Compute a unique reference for the transaction.
- The reference corresponds to the prefix if no other transaction with that prefix already
- exists. Otherwise, it follows the pattern `{computed_prefix}{separator}{sequence_number}`
- where:
- - `{computed_prefix}` is:
- - The provided custom prefix, if any.
- - The computation result of :meth:`_compute_reference_prefix` if the custom prefix is not
- filled, but the kwargs are.
- - `'tx-{datetime}'` if neither the custom prefix nor the kwargs are filled.
- - `{separator}` is the string that separates the prefix from the sequence number.
- - `{sequence_number}` is the next integer in the sequence of references sharing the same
- prefix. The sequence starts with `1` if there is only one matching reference.
- .. example::
- - Given the custom prefix `'example'` which has no match with an existing reference, the
- full reference will be `'example'`.
- - Given the custom prefix `'example'` which matches the existing reference `'example'`,
- and the custom separator `'-'`, the full reference will be `'example-1'`.
- - Given the kwargs `{'invoice_ids': [1, 2]}`, the custom separator `'-'` and no custom
- prefix, the full reference will be `'INV1-INV2'` (or similar) if no existing reference
- has the same prefix, or `'INV1-INV2-n'` if `n` existing references have the same
- prefix.
- :param str provider_code: The code of the provider handling the transaction.
- :param str prefix: The custom prefix used to compute the full reference.
- :param str separator: The custom separator used to separate the prefix from the suffix.
- :param dict kwargs: Optional values passed to :meth:`_compute_reference_prefix` if no custom
- prefix is provided.
- :return: The unique reference for the transaction.
- :rtype: str
- """
- # Compute the prefix.
- if prefix:
- # Replace special characters by their ASCII alternative (é -> e ; ä -> a ; ...)
- prefix = unicodedata.normalize('NFKD', prefix).encode('ascii', 'ignore').decode('utf-8')
- if not prefix: # Prefix not provided or voided above, compute it based on the kwargs.
- prefix = self.sudo()._compute_reference_prefix(provider_code, separator, **kwargs)
- if not prefix: # Prefix not computed from the kwargs, fallback on time-based value
- prefix = payment_utils.singularize_reference_prefix()
- # Compute the sequence number.
- reference = prefix # The first reference of a sequence has no sequence number.
- if self.sudo().search([('reference', '=', prefix)]): # The reference already has a match
- # We now execute a second search on `payment.transaction` to fetch all the references
- # starting with the given prefix. The load of these two searches is mitigated by the
- # index on `reference`. Although not ideal, this solution allows for quickly knowing
- # whether the sequence for a given prefix is already started or not, usually not. An SQL
- # query wouldn't help either as the selector is arbitrary and doing that would be an
- # open-door to SQL injections.
- same_prefix_references = self.sudo().search(
- [('reference', 'like', f'{prefix}{separator}%')]
- ).with_context(prefetch_fields=False).mapped('reference')
- # A final regex search is necessary to figure out the next sequence number. The previous
- # search could not rely on alphabetically sorting the reference to infer the largest
- # sequence number because both the prefix and the separator are arbitrary. A given
- # prefix could happen to be a substring of the reference from a different sequence.
- # For instance, the prefix 'example' is a valid match for the existing references
- # 'example', 'example-1' and 'example-ref', in that order. Trusting the order to infer
- # the sequence number would lead to a collision with 'example-1'.
- search_pattern = re.compile(rf'^{re.escape(prefix)}{separator}(\d+)$')
- max_sequence_number = 0 # If no match is found, start the sequence with this reference.
- for existing_reference in same_prefix_references:
- search_result = re.search(search_pattern, existing_reference)
- if search_result: # The reference has the same prefix and is from the same sequence
- # Find the largest sequence number, if any.
- current_sequence = int(search_result.group(1))
- if current_sequence > max_sequence_number:
- max_sequence_number = current_sequence
- # Compute the full reference.
- reference = f'{prefix}{separator}{max_sequence_number + 1}'
- return reference
- @api.model
- def _compute_reference_prefix(self, provider_code, separator, **values):
- """ Compute the reference prefix from the transaction values.
- Note: This method should be called in sudo mode to give access to the documents (invoices,
- sales orders) referenced in the transaction values.
- :param str provider_code: The code of the provider handling the transaction.
- :param str separator: The custom separator used to separate parts of the computed
- reference prefix.
- :param dict values: The transaction values used to compute the reference prefix.
- :return: The computed reference prefix.
- :rtype: str
- """
- return ''
- @api.model
- def _generate_callback_hash(self, callback_model_id, callback_res_id, callback_method):
- """ Return the hash for the callback on the transaction.
- :param int callback_model_id: The model on which the callback method is defined, as a
- `res.model` id.
- :param int callback_res_id: The record on which the callback method must be called, as an id
- of the callback method's model.
- :param str callback_method: The name of the callback method.
- :return: The callback hash.
- :rtype: str
- """
- if callback_model_id and callback_res_id and callback_method:
- model_name = self.env['ir.model'].sudo().browse(callback_model_id).model
- token = f'{model_name}|{callback_res_id}|{callback_method}'
- callback_hash = hmac_tool(self.env(su=True), 'generate_callback_hash', token)
- return callback_hash
- return None
- def _get_processing_values(self):
- """ Return the values used to process the transaction.
- The values are returned as a dict containing entries with the following keys:
- - `provider_id`: The provider handling the transaction, as a `payment.provider` id.
- - `provider_code`: The code of the provider.
- - `reference`: The reference of the transaction.
- - `amount`: The rounded amount of the transaction.
- - `currency_id`: The currency of the transaction, as a `res.currency` id.
- - `partner_id`: The partner making the transaction, as a `res.partner` id.
- - Additional provider-specific entries.
- Note: `self.ensure_one()`
- :return: The processing values.
- :rtype: dict
- """
- self.ensure_one()
- processing_values = {
- 'provider_id': self.provider_id.id,
- 'provider_code': self.provider_code,
- 'reference': self.reference,
- 'amount': self.amount,
- 'currency_id': self.currency_id.id,
- 'partner_id': self.partner_id.id,
- }
- # Complete generic processing values with provider-specific values.
- processing_values.update(self._get_specific_processing_values(processing_values))
- _logger.info(
- "generic and provider-specific processing values for transaction with reference "
- "%(ref)s:\n%(values)s",
- {'ref': self.reference, 'values': pprint.pformat(processing_values)},
- )
- # Render the html form for the redirect flow if available.
- if self.operation in ('online_redirect', 'validation'):
- redirect_form_view = self.provider_id._get_redirect_form_view(
- is_validation=self.operation == 'validation'
- )
- if redirect_form_view: # Some provider don't need a redirect form.
- rendering_values = self._get_specific_rendering_values(processing_values)
- _logger.info(
- "provider-specific rendering values for transaction with reference "
- "%(ref)s:\n%(values)s",
- {'ref': self.reference, 'values': pprint.pformat(rendering_values)},
- )
- redirect_form_html = self.env['ir.qweb']._render(redirect_form_view.id, rendering_values)
- processing_values.update(redirect_form_html=redirect_form_html)
- return processing_values
- def _get_specific_processing_values(self, processing_values):
- """ Return a dict of provider-specific values used to process the transaction.
- For a provider to add its own processing values, it must overwrite this method and return a
- dict of provider-specific values based on the generic values returned by this method.
- Provider-specific values take precedence over those of the dict of generic processing
- values.
- :param dict processing_values: The generic processing values of the transaction.
- :return: The dict of provider-specific processing values.
- :rtype: dict
- """
- return dict()
- def _get_specific_rendering_values(self, processing_values):
- """ Return a dict of provider-specific values used to render the redirect form.
- For a provider to add its own rendering values, it must overwrite this method and return a
- dict of provider-specific values based on the processing values (provider-specific
- processing values included).
- :param dict processing_values: The processing values of the transaction.
- :return: The dict of provider-specific rendering values.
- :rtype: dict
- """
- return dict()
- def _send_payment_request(self):
- """ Request the provider handling the transaction to make the payment.
- This method is exclusively used to make payments by token, which correspond to both the
- `online_token` and the `offline` transaction's `operation` field.
- For a provider to support tokenization, it must override this method and make an API request
- to make a payment.
- Note: `self.ensure_one()`
- :return: None
- """
- self.ensure_one()
- self._ensure_provider_is_not_disabled()
- self._log_sent_message()
- def _send_refund_request(self, amount_to_refund=None):
- """ Request the provider handling the transaction to refund it.
- For a provider to support refunds, it must override this method and make an API request to
- make a refund.
- Note: `self.ensure_one()`
- :param float amount_to_refund: The amount to be refunded.
- :return: The refund transaction created to process the refund request.
- :rtype: recordset of `payment.transaction`
- """
- self.ensure_one()
- self._ensure_provider_is_not_disabled()
- refund_tx = self._create_refund_transaction(amount_to_refund=amount_to_refund)
- refund_tx._log_sent_message()
- return refund_tx
- def _create_refund_transaction(self, amount_to_refund=None, **custom_create_values):
- """ Create a new transaction with the operation `refund` and the current transaction as
- source transaction.
- :param float amount_to_refund: The strictly positive amount to refund, in the same currency
- as the source transaction.
- :return: The refund transaction.
- :rtype: recordset of `payment.transaction`
- """
- self.ensure_one()
- return self.create({
- 'provider_id': self.provider_id.id,
- 'reference': self._compute_reference(self.provider_code, prefix=f'R-{self.reference}'),
- 'amount': -(amount_to_refund or self.amount),
- 'currency_id': self.currency_id.id,
- 'token_id': self.token_id.id,
- 'operation': 'refund',
- 'source_transaction_id': self.id,
- 'partner_id': self.partner_id.id,
- **custom_create_values,
- })
- def _send_capture_request(self):
- """ Request the provider handling the transaction to capture the payment.
- For a provider to support authorization, it must override this method and make an API
- request to capture the payment.
- Note: `self.ensure_one()`
- :return: None
- """
- self.ensure_one()
- self._ensure_provider_is_not_disabled()
- def _send_void_request(self):
- """ Request the provider handling the transaction to void the payment.
- For a provider to support authorization, it must override this method and make an API
- request to void the payment.
- Note: `self.ensure_one()`
- :return: None
- """
- self.ensure_one()
- self._ensure_provider_is_not_disabled()
- def _ensure_provider_is_not_disabled(self):
- """ Ensure that the provider's state is not `disabled` before sending a request to its
- provider.
- :return: None
- :raise UserError: If the provider's state is `disabled`.
- """
- if self.provider_id.state == 'disabled':
- raise UserError(_(
- "Making a request to the provider is not possible because the provider is disabled."
- ))
- def _handle_notification_data(self, provider_code, notification_data):
- """ Match the transaction with the notification data, update its state and return it.
- :param str provider_code: The code of the provider handling the transaction.
- :param dict notification_data: The notification data sent by the provider.
- :return: The transaction.
- :rtype: recordset of `payment.transaction`
- """
- tx = self._get_tx_from_notification_data(provider_code, notification_data)
- tx._process_notification_data(notification_data)
- tx._execute_callback()
- return tx
- def _get_tx_from_notification_data(self, provider_code, notification_data):
- """ Find the transaction based on the notification data.
- For a provider to handle transaction processing, it must overwrite this method and return
- the transaction matching the notification data.
- :param str provider_code: The code of the provider handling the transaction.
- :param dict notification_data: The notification data sent by the provider.
- :return: The transaction, if found.
- :rtype: recordset of `payment.transaction`
- """
- return self
- def _process_notification_data(self, notification_data):
- """ Update the transaction state and the provider reference based on the notification data.
- This method should usually not be called directly. The correct method to call upon receiving
- notification data is :meth:`_handle_notification_data`.
- For a provider to handle transaction processing, it must overwrite this method and process
- the notification data.
- Note: `self.ensure_one()`
- :param dict notification_data: The notification data sent by the provider.
- :return: None
- """
- self.ensure_one()
- def _set_pending(self, state_message=None):
- """ Update the transactions' state to `pending`.
- :param str state_message: The reason for setting the transactions in the state `pending`.
- :return: The updated transactions.
- :rtype: recordset of `payment.transaction`
- """
- allowed_states = ('draft',)
- target_state = 'pending'
- txs_to_process = self._update_state(allowed_states, target_state, state_message)
- txs_to_process._log_received_message()
- return txs_to_process
- def _set_authorized(self, state_message=None):
- """ Update the transactions' state to `authorized`.
- :param str state_message: The reason for setting the transactions in the state `authorized`.
- :return: The updated transactions.
- :rtype: recordset of `payment.transaction`
- """
- allowed_states = ('draft', 'pending')
- target_state = 'authorized'
- txs_to_process = self._update_state(allowed_states, target_state, state_message)
- txs_to_process._log_received_message()
- return txs_to_process
- def _set_done(self, state_message=None):
- """ Update the transactions' state to `done`.
- :param str state_message: The reason for setting the transactions in the state `done`.
- :return: The updated transactions.
- :rtype: recordset of `payment.transaction`
- """
- allowed_states = ('draft', 'pending', 'authorized', 'error', 'cancel') # 'cancel' for Payulatam
- target_state = 'done'
- txs_to_process = self._update_state(allowed_states, target_state, state_message)
- txs_to_process._log_received_message()
- return txs_to_process
- def _set_canceled(self, state_message=None):
- """ Update the transactions' state to `cancel`.
- :param str state_message: The reason for setting the transactions in the state `cancel`.
- :return: The updated transactions.
- :rtype: recordset of `payment.transaction`
- """
- allowed_states = ('draft', 'pending', 'authorized', 'done') # 'done' for Authorize refunds.
- target_state = 'cancel'
- txs_to_process = self._update_state(allowed_states, target_state, state_message)
- # Cancel the existing payments.
- txs_to_process._log_received_message()
- return txs_to_process
- def _set_error(self, state_message):
- """ Update the transactions' state to `error`.
- :param str state_message: The reason for setting the transactions in the state `error`.
- :return: The updated transactions.
- :rtype: recordset of `payment.transaction`
- """
- allowed_states = ('draft', 'pending', 'authorized', 'done') # 'done' for Stripe refunds.
- target_state = 'error'
- txs_to_process = self._update_state(allowed_states, target_state, state_message)
- txs_to_process._log_received_message()
- return txs_to_process
- def _update_state(self, allowed_states, target_state, state_message):
- """ Update the transactions' state to the target state if the current state allows it.
- If the current state is the same as the target state, the transaction is skipped and a log
- with level INFO is created.
- :param tuple[str] allowed_states: The allowed source states for the target state.
- :param str target_state: The target state.
- :param str state_message: The message to set as `state_message`.
- :return: The recordset of transactions whose state was updated.
- :rtype: recordset of `payment.transaction`
- """
- def classify_by_state(transactions_):
- """ Classify the transactions according to their current state.
- For each transaction of the current recordset, if:
- - The state is an allowed state: the transaction is flagged as `to process`.
- - The state is equal to the target state: the transaction is flagged as `processed`.
- - The state matches none of above: the transaction is flagged as `in wrong state`.
- :param recordset transactions_: The transactions to classify, as a `payment.transaction`
- recordset.
- :return: A 3-items tuple of recordsets of classified transactions, in this order:
- transactions `to process`, `processed`, and `in wrong state`.
- :rtype: tuple(recordset)
- """
- txs_to_process_ = transactions_.filtered(lambda _tx: _tx.state in allowed_states)
- txs_already_processed_ = transactions_.filtered(lambda _tx: _tx.state == target_state)
- txs_wrong_state_ = transactions_ - txs_to_process_ - txs_already_processed_
- return txs_to_process_, txs_already_processed_, txs_wrong_state_
- txs_to_process, txs_already_processed, txs_wrong_state = classify_by_state(self)
- for tx in txs_already_processed:
- _logger.info(
- "tried to write on transaction with reference %s with the same value for the "
- "state: %s",
- tx.reference, tx.state,
- )
- for tx in txs_wrong_state:
- _logger.warning(
- "tried to write on transaction with reference %(ref)s with illegal value for the "
- "state (previous state: %(tx_state)s, target state: %(target_state)s, expected "
- "previous state to be in: %(allowed_states)s)",
- {
- 'ref': tx.reference,
- 'tx_state': tx.state,
- 'target_state': target_state,
- 'allowed_states': allowed_states,
- },
- )
- txs_to_process.write({
- 'state': target_state,
- 'state_message': state_message,
- 'last_state_change': fields.Datetime.now(),
- })
- return txs_to_process
- def _execute_callback(self):
- """ Execute the callbacks defined on the transactions.
- Callbacks that have already been executed are silently ignored. For example, the callback is
- called twice when a transaction is first authorized then confirmed.
- Only successful callbacks are marked as done. This allows callbacks to reschedule
- themselves, should the conditions be unmet in the present call.
- :return: None
- """
- for tx in self.filtered(lambda t: not t.sudo().callback_is_done):
- # Only use sudo to check, not to execute.
- tx_sudo = tx.sudo()
- model_sudo = tx_sudo.callback_model_id
- res_id = tx_sudo.callback_res_id
- method = tx_sudo.callback_method
- callback_hash = tx_sudo.callback_hash
- if not (model_sudo and res_id and method):
- continue # Skip transactions with unset (or not properly defined) callbacks.
- valid_callback_hash = self._generate_callback_hash(model_sudo.id, res_id, method)
- if not consteq(ustr(valid_callback_hash), callback_hash):
- _logger.warning(
- "invalid callback signature for transaction with reference %s", tx.reference
- )
- continue # Ignore tampered callbacks.
- record = self.env[model_sudo.model].browse(res_id).exists()
- if not record:
- _logger.warning(
- "invalid callback record %(model)s.%(record_id)s for transaction with "
- "reference %(ref)s",
- {
- 'model': model_sudo.model,
- 'record_id': res_id,
- 'ref': tx.reference,
- }
- )
- continue # Ignore invalidated callbacks.
- success = getattr(record, method)(tx) # Execute the callback.
- tx_sudo.callback_is_done = success or success is None # Missing returns are successful.
- #=== BUSINESS METHODS - POST-PROCESSING ===#
- def _get_post_processing_values(self):
- """ Return a dict of values used to display the status of the transaction.
- For a provider to handle transaction status display, it must override this method and
- return a dict of values. Provider-specific values take precedence over those of the dict of
- generic post-processing values.
- The returned dict contains the following entries:
- - `provider_code`: The code of the provider.
- - `reference`: The reference of the transaction.
- - `amount`: The rounded amount of the transaction.
- - `currency_id`: The currency of the transaction, as a `res.currency` id.
- - `state`: The transaction state: `draft`, `pending`, `authorized`, `done`, `cancel`, or
- `error`.
- - `state_message`: The information message about the state.
- - `operation`: The operation of the transaction.
- - `is_post_processed`: Whether the transaction has already been post-processed.
- - `landing_route`: The route the user is redirected to after the transaction.
- - Additional provider-specific entries.
- Note: `self.ensure_one()`
- :return: The dict of processing values.
- :rtype: dict
- """
- self.ensure_one()
- post_processing_values = {
- 'provider_code': self.provider_code,
- 'reference': self.reference,
- 'amount': self.amount,
- 'currency_code': self.currency_id.name,
- 'state': self.state,
- 'state_message': self.state_message,
- 'operation': self.operation,
- 'is_post_processed': self.is_post_processed,
- 'landing_route': self.landing_route,
- }
- _logger.debug(
- "post-processing values of transaction with reference %s for provider with id %s:\n%s",
- self.reference, self.provider_id.id, pprint.pformat(post_processing_values)
- ) # DEBUG level because this can get spammy with transactions in non-final states
- return post_processing_values
- def _cron_finalize_post_processing(self):
- """ Finalize the post-processing of recently done transactions not handled by the client.
- :return: None
- """
- txs_to_post_process = self
- if not txs_to_post_process:
- # Let the client post-process transactions so that they remain available in the portal
- client_handling_limit_date = datetime.now() - relativedelta.relativedelta(minutes=10)
- # Don't try forever to post-process a transaction that doesn't go through. Set the limit
- # to 4 days because some providers (PayPal) need that much for the payment verification.
- retry_limit_date = datetime.now() - relativedelta.relativedelta(days=4)
- # Retrieve all transactions matching the criteria for post-processing
- txs_to_post_process = self.search([
- ('state', '=', 'done'),
- ('is_post_processed', '=', False),
- '|', ('last_state_change', '<=', client_handling_limit_date),
- ('operation', '=', 'refund'),
- ('last_state_change', '>=', retry_limit_date),
- ])
- for tx in txs_to_post_process:
- try:
- tx._finalize_post_processing()
- self.env.cr.commit()
- except psycopg2.OperationalError:
- self.env.cr.rollback() # Rollback and try later.
- except Exception as e:
- _logger.exception(
- "encountered an error while post-processing transaction with reference %s:\n%s",
- tx.reference, e
- )
- self.env.cr.rollback()
- def _finalize_post_processing(self):
- """ Trigger the final post-processing tasks and mark the transactions as post-processed.
- :return: None
- """
- self.filtered(lambda tx: tx.operation != 'validation')._reconcile_after_done()
- self.is_post_processed = True
- def _reconcile_after_done(self):
- """ Perform compute-intensive operations on related documents.
- For a provider to handle transaction post-processing, it must overwrite this method and
- execute its compute-intensive operations on documents linked to confirmed transactions.
- :return: None
- """
- return
- #=== BUSINESS METHODS - LOGGING ===#
- def _log_sent_message(self):
- """ Log that the transactions have been initiated in the chatter of relevant documents.
- :return: None
- """
- for tx in self:
- message = tx._get_sent_message()
- tx._log_message_on_linked_documents(message)
- def _log_received_message(self):
- """ Log that the transactions have been received in the chatter of relevant documents.
- A transaction is 'received' when a payment status is received from the provider handling the
- transaction.
- :return: None
- """
- for tx in self:
- message = tx._get_received_message()
- tx._log_message_on_linked_documents(message)
- def _log_message_on_linked_documents(self, message):
- """ Log a message on the records linked to the transaction.
- For a module to implement payments and link documents to a transaction, it must override
- this method and call it, then log the message on documents linked to the transaction.
- Note: `self.ensure_one()`
- :param str message: The message to log.
- :return: None
- """
- self.ensure_one()
- #=== BUSINESS METHODS - GETTERS ===#
- def _get_sent_message(self):
- """ Return the message stating that the transaction has been requested.
- Note: `self.ensure_one()`
- :return: The 'transaction sent' message.
- :rtype: str
- """
- self.ensure_one()
- # Choose the message based on the payment flow.
- if self.operation in ('online_redirect', 'online_direct'):
- message = _(
- "A transaction with reference %(ref)s has been initiated (%(provider_name)s).",
- ref=self.reference, provider_name=self.provider_id.name
- )
- elif self.operation == 'refund':
- formatted_amount = format_amount(self.env, -self.amount, self.currency_id)
- message = _(
- "A refund request of %(amount)s has been sent. The payment will be created soon. "
- "Refund transaction reference: %(ref)s (%(provider_name)s).",
- amount=formatted_amount, ref=self.reference, provider_name=self.provider_id.name
- )
- elif self.operation in ('online_token', 'offline'):
- message = _(
- "A transaction with reference %(ref)s has been initiated using the payment method "
- "%(token)s (%(provider_name)s).",
- ref=self.reference,
- token=self.token_id._build_display_name(),
- provider_name=self.provider_id.name
- )
- else: # 'validation'
- message = _(
- "A transaction with reference %(ref)s has been initiated to save a new payment "
- "method (%(provider_name)s)",
- ref=self.reference,
- provider_name=self.provider_id.name,
- )
- return message
- def _get_received_message(self):
- """ Return the message stating that the transaction has been received by the provider.
- Note: `self.ensure_one()`
- :return: The 'transaction received' message.
- :rtype: str
- """
- self.ensure_one()
- formatted_amount = format_amount(self.env, self.amount, self.currency_id)
- if self.state == 'pending':
- message = _(
- ("The transaction with reference %(ref)s for %(amount)s "
- "is pending (%(provider_name)s)."),
- ref=self.reference,
- amount=formatted_amount,
- provider_name=self.provider_id.name
- )
- elif self.state == 'authorized':
- message = _(
- "The transaction with reference %(ref)s for %(amount)s has been authorized "
- "(%(provider_name)s).", ref=self.reference, amount=formatted_amount,
- provider_name=self.provider_id.name
- )
- elif self.state == 'done':
- message = _(
- "The transaction with reference %(ref)s for %(amount)s has been confirmed "
- "(%(provider_name)s).", ref=self.reference, amount=formatted_amount,
- provider_name=self.provider_id.name
- )
- elif self.state == 'error':
- message = _(
- "The transaction with reference %(ref)s for %(amount)s encountered an error"
- " (%(provider_name)s).",
- ref=self.reference, amount=formatted_amount, provider_name=self.provider_id.name
- )
- if self.state_message:
- message += "<br />" + _("Error: %s", self.state_message)
- else:
- message = _(
- ("The transaction with reference %(ref)s for %(amount)s is canceled "
- "(%(provider_name)s)."),
- ref=self.reference,
- amount=formatted_amount,
- provider_name=self.provider_id.name
- )
- if self.state_message:
- message += "<br />" + _("Reason: %s", self.state_message)
- return message
- def _get_last(self):
- """ Return the last transaction of the recordset.
- :return: The last transaction of the recordset, sorted by id.
- :rtype: recordset of `payment.transaction`
- """
- return self.filtered(lambda t: t.state != 'draft').sorted()[:1]
|