diff --git a/addons/payment/data/payment_acquirer_data.xml b/addons/payment/data/payment_acquirer_data.xml index 4d8bc0cbea1..307310a0ca6 100644 --- a/addons/payment/data/payment_acquirer_data.xml +++ b/addons/payment/data/payment_acquirer_data.xml @@ -115,6 +115,36 @@ ])]"/> + + Flutterwave + + + +

+ A payment gateway from Flutterwave to accept online payments. +

+
    +
  • Online Payments
  • +
  • Payment Status Tracking
  • +
  • Subscriptions
  • +
  • Save Cards
  • +
+
+ + + +
+ Mollie diff --git a/addons/payment/data/payment_icon_data.xml b/addons/payment/data/payment_icon_data.xml index 452d8a4e64f..97c44aa6b42 100644 --- a/addons/payment/data/payment_icon_data.xml +++ b/addons/payment/data/payment_icon_data.xml @@ -1,5 +1,6 @@ + 10 VISA @@ -126,4 +127,28 @@ + + 220 + M-Pesa + + + + + 230 + Airtel Money + + + + + 240 + MTN Mobile Money + + + + + 250 + Barter by Flutterwave + + + diff --git a/addons/payment/static/img/airtel-money.png b/addons/payment/static/img/airtel-money.png new file mode 100644 index 00000000000..d000b513e61 Binary files /dev/null and b/addons/payment/static/img/airtel-money.png differ diff --git a/addons/payment/static/img/barter-by-flutterwave.png b/addons/payment/static/img/barter-by-flutterwave.png new file mode 100644 index 00000000000..5db09322cc8 Binary files /dev/null and b/addons/payment/static/img/barter-by-flutterwave.png differ diff --git a/addons/payment/static/img/m-pesa.png b/addons/payment/static/img/m-pesa.png new file mode 100644 index 00000000000..7d208c7c844 Binary files /dev/null and b/addons/payment/static/img/m-pesa.png differ diff --git a/addons/payment/static/img/mtn-mobile-money.png b/addons/payment/static/img/mtn-mobile-money.png new file mode 100644 index 00000000000..2bc9ec29fb9 Binary files /dev/null and b/addons/payment/static/img/mtn-mobile-money.png differ diff --git a/addons/payment/tests/common.py b/addons/payment/tests/common.py index 167920721c3..2f673b50dda 100644 --- a/addons/payment/tests/common.py +++ b/addons/payment/tests/common.py @@ -96,6 +96,7 @@ class PaymentCommon(PaymentTestUtils): cls.acquirer = cls.dummy_acquirer cls.amount = 1111.11 cls.company = cls.env.company + cls.company_id = cls.company.id cls.currency = cls.currency_euro cls.partner = cls.default_partner cls.reference = "Test Transaction" diff --git a/addons/payment/tests/utils.py b/addons/payment/tests/utils.py index 31544ce634e..6b3c71fa4bb 100644 --- a/addons/payment/tests/utils.py +++ b/addons/payment/tests/utils.py @@ -40,10 +40,13 @@ class PaymentTestUtils(AccountTestInvoicingCommon): :rtype: dict[str:str] """ html_tree = objectify.fromstring(html_form) - action = html_tree.get('action') - inputs = {form_input.get('name'): form_input.get('value') for form_input in html_tree.input} + if hasattr(html_tree, 'input'): + inputs = {input_.get('name'): input_.get('value') for input_ in html_tree.input} + else: + inputs = {} return { - 'action': action, + 'action': html_tree.get('action'), + 'method': html_tree.get('method'), 'inputs': inputs, } diff --git a/addons/payment_flutterwave/README.md b/addons/payment_flutterwave/README.md new file mode 100644 index 00000000000..82d7b9cb226 --- /dev/null +++ b/addons/payment_flutterwave/README.md @@ -0,0 +1,31 @@ +# Flutterwave + +## Implementation details + +### Supported features + +- Payment with redirection flow +- [Tokenization](https://developer.flutterwave.com/reference/endpoints/tokenized-charge/) +- Several payment methods such as credit cards, M-Pesa, and + [others](https://developer.flutterwave.com/docs/collecting-payments/payment-methods/). +- [Webhook](https://developer.flutterwave.com/docs/integration-guides/webhooks/). + +In addition, Flutterwave also allows to implement refunds and pre-authorizations. + +### API and gateway + +We choose to integrate with +[Flutterwave standard](https://developer.flutterwave.com/docs/collecting-payments/standard/) as it +is the gateway that covers the best our needs, out of the three that Flutterwave offers as of +May 2022. See the task's dev notes for the details on the other gateways. + +The version of the API implemented by this module is v3. + +## Merge details + +The first version of the module was specified in task +[2759117](https://www.odoo.com/web#id=2759117&model=project.task) and merged with PR +odoo/odoo#84820 in `saas-15.4`. + +## Testing instructions + diff --git a/addons/payment_flutterwave/__init__.py b/addons/payment_flutterwave/__init__.py new file mode 100644 index 00000000000..14fae20949e --- /dev/null +++ b/addons/payment_flutterwave/__init__.py @@ -0,0 +1,10 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import controllers +from . import models + +from odoo.addons.payment import reset_payment_acquirer + + +def uninstall_hook(cr, registry): + reset_payment_acquirer(cr, registry, 'flutterwave') diff --git a/addons/payment_flutterwave/__manifest__.py b/addons/payment_flutterwave/__manifest__.py new file mode 100644 index 00000000000..3d090b12194 --- /dev/null +++ b/addons/payment_flutterwave/__manifest__.py @@ -0,0 +1,20 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +{ + 'name': "Payment Acquirer: Flutterwave", + 'version': '2.0', + 'category': 'Accounting/Payment Acquirers', + 'sequence': 360, + 'summary': "A Nigerian online payments provider covering several African countries and payment " + "methods.", + 'depends': ['payment'], + 'data': [ + 'views/payment_views.xml', + 'views/payment_flutterwave_templates.xml', + + 'data/payment_acquirer_data.xml', + ], + 'application': True, + 'uninstall_hook': 'uninstall_hook', + 'license': 'LGPL-3', +} diff --git a/addons/payment_flutterwave/const.py b/addons/payment_flutterwave/const.py new file mode 100644 index 00000000000..4c57e2c01ea --- /dev/null +++ b/addons/payment_flutterwave/const.py @@ -0,0 +1,47 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +# The currencies supported by Flutterwave, in ISO 4217 format. +# See https://support.flutterwave.com/en/articles/3632719-accepted-currencies. +SUPPORTED_CURRENCIES = [ + 'ARS', + 'BRL', + 'GBP', + 'CAD', + 'CVE', + 'CLP', + 'COP', + 'CDF', + 'EGP', + 'EUR', + 'GMD', + 'GHS', + 'GNF', + 'KES', + 'LRD', + 'MWK', + 'MXN', + 'MAD', + 'MZN', + 'NGN', + 'SOL', + 'RWF', + 'SLL', + 'STD', + 'ZAR', + 'TZS', + 'UGX', + 'USD', + 'XAF', + 'XOF', + 'ZMK', + 'ZMW', +] + + +# Mapping of transaction states to Flutterwave payment statuses. +PAYMENT_STATUS_MAPPING = { + 'pending': ['pending auth'], + 'done': ['successful'], + 'cancel': ['cancelled'], + 'error': ['failed'], +} diff --git a/addons/payment_flutterwave/controllers/__init__.py b/addons/payment_flutterwave/controllers/__init__.py new file mode 100644 index 00000000000..80ee4da1c5e --- /dev/null +++ b/addons/payment_flutterwave/controllers/__init__.py @@ -0,0 +1,3 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import main diff --git a/addons/payment_flutterwave/controllers/main.py b/addons/payment_flutterwave/controllers/main.py new file mode 100644 index 00000000000..8da739791c9 --- /dev/null +++ b/addons/payment_flutterwave/controllers/main.py @@ -0,0 +1,81 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import hmac +import logging +import pprint + +from werkzeug.exceptions import Forbidden + +from odoo import http +from odoo.exceptions import ValidationError +from odoo.http import request + + +_logger = logging.getLogger(__name__) + + +class FlutterwaveController(http.Controller): + _return_url = '/payment/flutterwave/return' + _webhook_url = '/payment/flutterwave/webhook' + + @http.route(_return_url, type='http', methods=['GET'], auth='public') + def flutterwave_return_from_checkout(self, **data): + """ Process the notification data sent by Flutterwave after redirection from checkout. + + :param dict data: The notification data. + """ + # Handle the notification data. + if data.get('status') != 'cancelled': + request.env['payment.transaction'].sudo()._handle_notification_data('flutterwave', data) + else: # The customer cancelled the payment by clicking on the close button. + pass # Don't try to process this case because the transaction id was not provided. + + # Redirect the user to the status page. + return request.redirect('/payment/status') + + @http.route(_webhook_url, type='http', methods=['POST'], auth='public', csrf=False) + def flutterwave_webhook(self): + """ Process the notification data sent by Flutterwave to the webhook. + + :return: An empty string to acknowledge the notification. + :rtype: str + """ + data = request.get_json_data() + _logger.info("Notification received from Flutterwave with data:\n%s", pprint.pformat(data)) + + if data['event'] == 'charge.completed': + try: + # Check the origin of the notification. + tx_sudo = request.env['payment.transaction'].sudo()._get_tx_from_notification_data( + 'flutterwave', data['data'] + ) + signature = request.httprequest.headers.get('verif-hash') + self._verify_notification_signature(signature, tx_sudo) + + # Handle the notification data. + notification_data = data['data'] + tx_sudo._handle_notification_data('flutterwave', notification_data) + except ValidationError: # Acknowledge the notification to avoid getting spammed. + _logger.exception("Unable to handle the notification data; skipping to acknowledge") + return request.make_json_response('') + + @staticmethod + def _verify_notification_signature(received_signature, tx_sudo): + """ Check that the received signature matches the expected one. + + :param dict received_signature: The signature received with the notification data. + :param recordset tx_sudo: The sudoed transaction referenced by the notification data, as a + `payment.transaction` record. + :return: None + :raise Forbidden: If the signatures don't match. + """ + # Check for the received signature. + if not received_signature: + _logger.warning("Received notification with missing signature.") + raise Forbidden() + + # Compare the received signature with the expected signature. + expected_signature = tx_sudo.acquirer_id.flutterwave_webhook_secret + if not hmac.compare_digest(received_signature, expected_signature): + _logger.warning("Received notification with invalid signature.") + raise Forbidden() diff --git a/addons/payment_flutterwave/data/payment_acquirer_data.xml b/addons/payment_flutterwave/data/payment_acquirer_data.xml new file mode 100644 index 00000000000..fc23febe681 --- /dev/null +++ b/addons/payment_flutterwave/data/payment_acquirer_data.xml @@ -0,0 +1,16 @@ + + + + + flutterwave + + True + + + + Flutterwave + flutterwave + inbound + + + diff --git a/addons/payment_flutterwave/models/__init__.py b/addons/payment_flutterwave/models/__init__.py new file mode 100644 index 00000000000..2303108a7c9 --- /dev/null +++ b/addons/payment_flutterwave/models/__init__.py @@ -0,0 +1,6 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import account_payment_method +from . import payment_acquirer +from . import payment_token +from . import payment_transaction diff --git a/addons/payment_flutterwave/models/account_payment_method.py b/addons/payment_flutterwave/models/account_payment_method.py new file mode 100644 index 00000000000..a641b6e5215 --- /dev/null +++ b/addons/payment_flutterwave/models/account_payment_method.py @@ -0,0 +1,13 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, models + + +class AccountPaymentMethod(models.Model): + _inherit = 'account.payment.method' + + @api.model + def _get_payment_method_information(self): + res = super()._get_payment_method_information() + res['flutterwave'] = {'mode': 'unique', 'domain': [('type', '=', 'bank')]} + return res diff --git a/addons/payment_flutterwave/models/payment_acquirer.py b/addons/payment_flutterwave/models/payment_acquirer.py new file mode 100644 index 00000000000..fc3ffc48143 --- /dev/null +++ b/addons/payment_flutterwave/models/payment_acquirer.py @@ -0,0 +1,115 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import logging +import pprint + +import requests +from werkzeug.urls import url_join + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError + +from odoo.addons.payment_flutterwave.const import SUPPORTED_CURRENCIES + + +_logger = logging.getLogger(__name__) + + +class PaymentAcquirer(models.Model): + _inherit = 'payment.acquirer' + + provider = fields.Selection( + selection_add=[('flutterwave', "Flutterwave")], ondelete={'flutterwave': 'set default'} + ) + flutterwave_public_key = fields.Char( + string="Flutterwave Public Key", + help="The key solely used to identify the account with Flutterwave.", + required_if_provider='flutterwave', + ) + flutterwave_secret_key = fields.Char( + string="Flutterwave Secret Key", + required_if_provider='flutterwave', + groups='base.group_system', + ) + flutterwave_webhook_secret = fields.Char( + string="Flutterwave Webhook Secret", + required_if_provider='flutterwave', + groups='base.group_system', + ) + + #=== COMPUTE METHODS ===# + + def _compute_feature_support_fields(self): + """ Override of `payment` to enable additional features. """ + super()._compute_feature_support_fields() + self.filtered(lambda acq: acq.provider == 'flutterwave').update({ + 'support_tokenization': True, + }) + + # === BUSINESS METHODS ===# + + @api.model + def _get_compatible_acquirers(self, *args, currency_id=None, is_validation=False, **kwargs): + """ Override of payment to filter out Flutterwave acquirers for unsupported currencies or + for validation operations. """ + acquirers = super()._get_compatible_acquirers( + *args, currency_id=currency_id, is_validation=is_validation, **kwargs + ) + + currency = self.env['res.currency'].browse(currency_id).exists() + if (currency and currency.name not in SUPPORTED_CURRENCIES) or is_validation: + acquirers = acquirers.filtered(lambda a: a.provider != 'flutterwave') + + return acquirers + + def _flutterwave_make_request(self, endpoint, payload=None, method='POST'): + """ Make a request to Flutterwave API at the specified endpoint. + + Note: self.ensure_one() + + :param str endpoint: The endpoint to be reached by the request. + :param dict payload: The payload of the request. + :param str method: The HTTP method of the request. + :return The JSON-formatted content of the response. + :rtype: dict + :raise ValidationError: If an HTTP error occurs. + """ + self.ensure_one() + + url = url_join('https://api.flutterwave.com/v3/', endpoint) + headers = {'Authorization': f'Bearer {self.flutterwave_secret_key}'} + try: + if method == 'GET': + response = requests.get(url, params=payload, headers=headers, timeout=10) + else: + response = requests.post(url, json=payload, headers=headers, timeout=10) + try: + response.raise_for_status() + except requests.exceptions.HTTPError: + _logger.exception( + "Invalid API request at %s with data:\n%s", url, pprint.pformat(payload), + ) + raise ValidationError("Flutterwave: " + _( + "The communication with the API failed. Flutterwave gave us the following " + "information: '%s'", response.json().get('message', '') + )) + except (requests.exceptions.ConnectionError, requests.exceptions.Timeout): + _logger.exception("Unable to reach endpoint at %s", url) + raise ValidationError( + "Flutterwave: " + _("Could not establish the connection to the API.") + ) + return response.json() + + def _get_default_payment_method_id(self): + self.ensure_one() + if self.provider != 'flutterwave': + return super()._get_default_payment_method_id() + return self.env.ref('payment_flutterwave.payment_method_flutterwave').id + + def _neutralize(self): + super()._neutralize() + self._neutralize_fields('flutterwave', [ + 'flutterwave_public_key', + 'flutterwave_secret_key', + 'flutterwave_webhook_secret', + ]) diff --git a/addons/payment_flutterwave/models/payment_token.py b/addons/payment_flutterwave/models/payment_token.py new file mode 100644 index 00000000000..d03d1e32d20 --- /dev/null +++ b/addons/payment_flutterwave/models/payment_token.py @@ -0,0 +1,11 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import fields, models + + +class PaymentToken(models.Model): + _inherit = 'payment.token' + + flutterwave_customer_email = fields.Char( + help="The email of the customer at the time the token was created.", readonly=True + ) diff --git a/addons/payment_flutterwave/models/payment_transaction.py b/addons/payment_flutterwave/models/payment_transaction.py new file mode 100644 index 00000000000..ec2625d4b0c --- /dev/null +++ b/addons/payment_flutterwave/models/payment_transaction.py @@ -0,0 +1,199 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import logging +import pprint + +from werkzeug import urls + +from odoo import _, models +from odoo.exceptions import UserError, ValidationError + +from odoo.addons.payment import utils as payment_utils +from odoo.addons.payment_flutterwave.const import PAYMENT_STATUS_MAPPING +from odoo.addons.payment_flutterwave.controllers.main import FlutterwaveController + + +_logger = logging.getLogger(__name__) + + +class PaymentTransaction(models.Model): + _inherit = 'payment.transaction' + + def _get_specific_rendering_values(self, processing_values): + """ Override of payment to return Flutterwave-specific rendering values. + + Note: self.ensure_one() from `_get_processing_values` + + :param dict processing_values: The generic and specific processing values of the transaction + :return: The dict of acquirer-specific processing values. + :rtype: dict + """ + res = super()._get_specific_rendering_values(processing_values) + if self.provider != 'flutterwave': + return res + + # Initiate the payment and retrieve the payment link data. + base_url = self.acquirer_id.get_base_url() + payload = { + 'tx_ref': self.reference, + 'amount': self.amount, + 'currency': self.currency_id.name, + 'redirect_url': urls.url_join(base_url, FlutterwaveController._return_url), + 'customer': { + 'email': self.partner_email, + 'name': self.partner_name, + 'phonenumber': self.partner_phone, + }, + 'customizations': { + 'title': self.company_id.name, + 'logo': urls.url_join(base_url, f'web/image/res.company/{self.company_id.id}/logo'), + }, + } + payment_link_data = self.acquirer_id._flutterwave_make_request('payments', payload=payload) + + # Extract the payment link URL and embed it in the redirect form. + rendering_values = { + 'api_url': payment_link_data['data']['link'], + } + return rendering_values + + def _send_payment_request(self): + """ Override of payment to send a payment request to Flutterwave. + + Note: self.ensure_one() + + :return: None + :raise UserError: If the transaction is not linked to a token. + """ + super()._send_payment_request() + if self.provider != 'flutterwave': + return + + # Prepare the payment request to Flutterwave. + if not self.token_id: + raise UserError("Flutterwave: " + _("The transaction is not linked to a token.")) + + first_name, last_name = payment_utils.split_partner_name(self.partner_name) + data = { + 'token': self.token_id.acquirer_ref, + 'email': self.token_id.flutterwave_customer_email, + 'amount': self.amount, + 'currency': self.currency_id.name, + 'country': self.company_id.country_id.code, + 'tx_ref': self.reference, + 'first_name': first_name, + 'last_name': last_name, + 'ip': payment_utils.get_customer_ip_address(), + } + + # Make the payment request to Flutterwave. + response_content = self.acquirer_id._flutterwave_make_request( + 'tokenized-charges', payload=data + ) + + # Handle the payment request response. + _logger.info( + "payment request response for transaction with reference %s:\n%s", + self.reference, pprint.pformat(response_content) + ) + self._handle_notification_data('flutterwave', response_content['data']) + + def _get_tx_from_notification_data(self, provider, notification_data): + """ Override of payment to find the transaction based on Flutterwave data. + + :param str provider: The provider of the acquirer that handled the transaction. + :param dict notification_data: The notification data sent by the provider. + :return: The transaction if found. + :rtype: recordset of `payment.transaction` + :raise ValidationError: If inconsistent data were received. + :raise ValidationError: If the data match no transaction. + """ + tx = super()._get_tx_from_notification_data(provider, notification_data) + if provider != 'flutterwave' or len(tx) == 1: + return tx + + reference = notification_data.get('tx_ref') + if not reference: + raise ValidationError("Flutterwave: " + _("Received data with missing reference.")) + + tx = self.search([('reference', '=', reference), ('provider', '=', 'flutterwave')]) + if not tx: + raise ValidationError( + "Flutterwave: " + _("No transaction found matching reference %s.", reference) + ) + return tx + + def _process_notification_data(self, notification_data): + """ Override of payment to process the transaction based on Flutterwave data. + + Note: self.ensure_one() + + :param dict notification_data: The notification data sent by the provider. + :return: None + :raise ValidationError: If inconsistent data were received. + """ + super()._process_notification_data(notification_data) + if self.provider != 'flutterwave': + return + + # Verify the notification data. + verification_response_content = self.acquirer_id._flutterwave_make_request( + 'transactions/verify_by_reference', payload={'tx_ref': self.reference}, method='GET' + ) + verified_data = verification_response_content['data'] + + # Process the verified notification data. + self.acquirer_reference = verified_data['id'] + payment_status = verified_data['status'].lower() + if payment_status in PAYMENT_STATUS_MAPPING['pending']: + self._set_pending() + elif payment_status in PAYMENT_STATUS_MAPPING['done']: + self._set_done() + has_token_data = 'token' in verified_data.get('card', {}) + if self.tokenize and has_token_data: + self._flutterwave_tokenize_from_notification_data(verified_data) + elif payment_status in PAYMENT_STATUS_MAPPING['cancel']: + self._set_canceled() + elif payment_status in PAYMENT_STATUS_MAPPING['error']: + self._set_error(_( + "An error occurred during the processing of your payment (status %s). Please try " + "again.", payment_status + )) + else: + _logger.warning( + "Received data with invalid payment status (%s) for transaction with reference %s.", + payment_status, self.reference + ) + self._set_error("Flutterwave: " + _("Unknown payment status: %s", payment_status)) + + def _flutterwave_tokenize_from_notification_data(self, notification_data): + """ Create a new token based on the notification data. + + Note: self.ensure_one() + + :param dict notification_data: The notification data sent by the provider. + :return: None + """ + self.ensure_one() + + token = self.env['payment.token'].create({ + 'acquirer_id': self.acquirer_id.id, + 'name': payment_utils.build_token_name(notification_data['card']['last_4digits']), + 'partner_id': self.partner_id.id, + 'acquirer_ref': notification_data['card']['token'], + 'flutterwave_customer_email': notification_data['customer']['email'], + 'verified': True, # The payment is confirmed, so the payment method is valid. + }) + self.write({ + 'token_id': token, + 'tokenize': False, + }) + _logger.info( + "created token with id %(token_id)s for partner with id %(partner_id)s from " + "transaction with reference %(ref)s", + { + 'token_id': token.id, + 'partner_id': self.partner_id.id, + 'ref': self.reference, + }, + ) diff --git a/addons/payment_flutterwave/static/description/icon.png b/addons/payment_flutterwave/static/description/icon.png new file mode 100644 index 00000000000..e4b7fdaea45 Binary files /dev/null and b/addons/payment_flutterwave/static/description/icon.png differ diff --git a/addons/payment_flutterwave/static/description/icon.svg b/addons/payment_flutterwave/static/description/icon.svg new file mode 100644 index 00000000000..39fa2cfe167 --- /dev/null +++ b/addons/payment_flutterwave/static/description/icon.svg @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/payment_flutterwave/static/src/img/flutterwave-logo.png b/addons/payment_flutterwave/static/src/img/flutterwave-logo.png new file mode 100644 index 00000000000..63ba8deb607 Binary files /dev/null and b/addons/payment_flutterwave/static/src/img/flutterwave-logo.png differ diff --git a/addons/payment_flutterwave/tests/__init__.py b/addons/payment_flutterwave/tests/__init__.py new file mode 100644 index 00000000000..bad6afe5061 --- /dev/null +++ b/addons/payment_flutterwave/tests/__init__.py @@ -0,0 +1,6 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import common +from . import test_payment_acquirer +from . import test_payment_transaction +from . import test_processing_flows diff --git a/addons/payment_flutterwave/tests/common.py b/addons/payment_flutterwave/tests/common.py new file mode 100644 index 00000000000..a0f449f84f8 --- /dev/null +++ b/addons/payment_flutterwave/tests/common.py @@ -0,0 +1,43 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.addons.payment.tests.common import PaymentCommon + + +class FlutterwaveCommon(PaymentCommon): + + @classmethod + def setUpClass(cls, chart_template_ref=None): + super().setUpClass(chart_template_ref=chart_template_ref) + + cls.flutterwave = cls._prepare_acquirer('flutterwave', update_values={ + 'flutterwave_public_key': 'FLWPUBK_TEST-abcdef-X', + 'flutterwave_secret_key': 'FLWSECK_TEST-123456-X', + 'flutterwave_webhook_secret': 'coincoin_motherducker', + }) + + cls.acquirer = cls.flutterwave + + cls.redirect_notification_data = { + 'status': 'successful', + 'tx_ref': cls.reference, + } + cls.webhook_notification_data = { + 'event': 'charge.completed', + 'data': { + 'tx_ref': cls.reference, + }, + } + cls.verification_data = { + 'status': 'success', + 'data': { + 'id': '123456789', + 'status': 'successful', + 'card': { + 'last_4digits': '2950', + 'token': 'flw-t1nf-f9b3bf384cd30d6fca42b6df9d27bd2f-m03k', + }, + 'customer': { + 'email': 'user@example.com', + }, + }, + } diff --git a/addons/payment_flutterwave/tests/test_payment_acquirer.py b/addons/payment_flutterwave/tests/test_payment_acquirer.py new file mode 100644 index 00000000000..b014c0b9575 --- /dev/null +++ b/addons/payment_flutterwave/tests/test_payment_acquirer.py @@ -0,0 +1,27 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.tests import tagged + +from odoo.addons.payment_flutterwave.tests.common import FlutterwaveCommon + + +@tagged('post_install', '-at_install') +class TestPaymentAcquirer(FlutterwaveCommon): + + def test_incompatible_with_unsupported_currencies(self): + compatible_acquirers = self.env['payment.acquirer']._get_compatible_acquirers( + self.company_id, self.partner.id, currency_id=self.env.ref('base.AFN').id + ) + self.assertNotIn(self.flutterwave, compatible_acquirers) + + def test_incompatible_with_validation_transactions(self): + compatible_acquirers = self.env['payment.acquirer']._get_compatible_acquirers( + self.company_id, self.partner.id, is_validation=True + ) + self.assertNotIn(self.flutterwave, compatible_acquirers) + + def test_neutralize(self): + self.env['payment.acquirer']._neutralize() + self.assertEqual(self.acquirer.flutterwave_public_key, False) + self.assertEqual(self.acquirer.flutterwave_secret_key, False) + self.assertEqual(self.acquirer.flutterwave_webhook_secret, False) diff --git a/addons/payment_flutterwave/tests/test_payment_transaction.py b/addons/payment_flutterwave/tests/test_payment_transaction.py new file mode 100644 index 00000000000..cb77d5ad527 --- /dev/null +++ b/addons/payment_flutterwave/tests/test_payment_transaction.py @@ -0,0 +1,61 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from unittest.mock import patch + +from odoo.tests import tagged +from odoo.tools import mute_logger + +from odoo.addons.payment_flutterwave.tests.common import FlutterwaveCommon + + +@tagged('post_install', '-at_install') +class TestPaymentTransaction(FlutterwaveCommon): + + def test_no_item_missing_from_rendering_values(self): + """ Test that the rendered values are conform to the transaction fields. """ + tx = self.create_transaction(flow='redirect') + with patch( + 'odoo.addons.payment_flutterwave.models.payment_acquirer.PaymentAcquirer' + '._flutterwave_make_request', return_value={'data': {'link': 'https://dummy.com'}} + ): + rendering_values = tx._get_specific_rendering_values(None) + self.assertDictEqual(rendering_values, {'api_url': 'https://dummy.com'}) + + @mute_logger('odoo.addons.payment.models.payment_transaction') + def test_no_input_missing_from_redirect_form(self): + """ Test that the `api_url` key is not omitted from the rendering values. """ + tx = self.create_transaction(flow='redirect') + with patch( + 'odoo.addons.payment_flutterwave.models.payment_transaction.PaymentTransaction' + '._get_specific_rendering_values', return_value={'api_url': 'https://dummy.com'} + ): + processing_values = tx._get_processing_values() + form_info = self._extract_values_from_html_form(processing_values['redirect_form_html']) + self.assertEqual(form_info['action'], 'https://dummy.com') + self.assertEqual(form_info['method'], 'get') + self.assertDictEqual(form_info['inputs'], {}) + + def test_processing_notification_data_confirms_transaction(self): + """ Test that the transaction state is set to 'done' when the notification data indicate a + successful payment. """ + tx = self.create_transaction(flow='redirect') + with patch( + 'odoo.addons.payment_flutterwave.models.payment_acquirer.PaymentAcquirer' + '._flutterwave_make_request', return_value=self.verification_data + ): + tx._process_notification_data(self.redirect_notification_data) + self.assertEqual(tx.state, 'done') + + def test_processing_notification_data_tokenizes_transaction(self): + """ Test that the transaction is tokenized when it was requested and the notification data + include token data. """ + tx = self.create_transaction(flow='redirect', tokenize=True) + with patch( + 'odoo.addons.payment_flutterwave.models.payment_acquirer.PaymentAcquirer' + '._flutterwave_make_request', return_value=self.verification_data + ), patch( + 'odoo.addons.payment_flutterwave.models.payment_transaction.PaymentTransaction' + '._flutterwave_tokenize_from_notification_data' + ) as tokenize_mock: + tx._process_notification_data(self.redirect_notification_data) + self.assertEqual(tokenize_mock.call_count, 1) diff --git a/addons/payment_flutterwave/tests/test_processing_flows.py b/addons/payment_flutterwave/tests/test_processing_flows.py new file mode 100644 index 00000000000..46492f03753 --- /dev/null +++ b/addons/payment_flutterwave/tests/test_processing_flows.py @@ -0,0 +1,83 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from unittest.mock import patch + +from werkzeug.exceptions import Forbidden + +from odoo.tests import tagged +from odoo.tools import mute_logger + +from odoo.addons.payment.tests.http_common import PaymentHttpCommon +from odoo.addons.payment_flutterwave.controllers.main import FlutterwaveController +from odoo.addons.payment_flutterwave.tests.common import FlutterwaveCommon + + +@tagged('post_install', '-at_install') +class TestProcessingFlows(FlutterwaveCommon, PaymentHttpCommon): + + def test_redirect_notification_triggers_processing(self): + """ Test that receiving a redirect notification triggers the processing of the notification + data. """ + self.create_transaction(flow='redirect') + url = self._build_url(FlutterwaveController._return_url) + with patch( + 'odoo.addons.payment.models.payment_transaction.PaymentTransaction' + '._handle_notification_data' + ) as handle_notification_data_mock: + self._make_http_get_request(url, params=self.redirect_notification_data) + self.assertEqual(handle_notification_data_mock.call_count, 1) + + @mute_logger('odoo.addons.payment_flutterwave.controllers.main') + def test_webhook_notification_triggers_processing(self): + """ Test that receiving a valid webhook notification triggers the processing of the + notification data. """ + self.create_transaction('redirect') + url = self._build_url(FlutterwaveController._webhook_url) + with patch( + 'odoo.addons.payment_flutterwave.controllers.main.FlutterwaveController.' + '_verify_notification_signature' + ), patch( + 'odoo.addons.payment.models.payment_transaction.PaymentTransaction' + '._handle_notification_data' + ) as handle_notification_data_mock: + self._make_json_request(url, data=self.webhook_notification_data) + self.assertEqual(handle_notification_data_mock.call_count, 1) + + @mute_logger('odoo.addons.payment_flutterwave.controllers.main') + def test_webhook_notification_triggers_signature_check(self): + """ Test that receiving a webhook notification triggers a signature check. """ + self.create_transaction('redirect') + url = self._build_url(FlutterwaveController._webhook_url) + with patch( + 'odoo.addons.payment_flutterwave.controllers.main.FlutterwaveController' + '._verify_notification_signature' + ) as signature_check_mock, patch( + 'odoo.addons.payment.models.payment_transaction.PaymentTransaction' + '._handle_notification_data' + ): + self._make_json_request(url, data=self.webhook_notification_data) + self.assertEqual(signature_check_mock.call_count, 1) + + def test_accept_webhook_notification_with_valid_signature(self): + """ Test the verification of a webhook notification with a valid signature. """ + tx = self.create_transaction('redirect') + self._assert_does_not_raise( + Forbidden, + FlutterwaveController._verify_notification_signature, + self.acquirer.flutterwave_webhook_secret, + tx, + ) + + @mute_logger('odoo.addons.payment_flutterwave.controllers.main') + def test_reject_notification_with_missing_signature(self): + """ Test the verification of a notification with a missing signature. """ + tx = self.create_transaction('redirect') + self.assertRaises(Forbidden, FlutterwaveController._verify_notification_signature, None, tx) + + @mute_logger('odoo.addons.payment_flutterwave.controllers.main') + def test_reject_notification_with_invalid_signature(self): + """ Test the verification of a notification with an invalid signature. """ + tx = self.create_transaction('redirect') + self.assertRaises( + Forbidden, FlutterwaveController._verify_notification_signature, 'dummy', tx + ) diff --git a/addons/payment_flutterwave/views/payment_flutterwave_templates.xml b/addons/payment_flutterwave/views/payment_flutterwave_templates.xml new file mode 100644 index 00000000000..096123751d1 --- /dev/null +++ b/addons/payment_flutterwave/views/payment_flutterwave_templates.xml @@ -0,0 +1,8 @@ + + + + + + diff --git a/addons/payment_flutterwave/views/payment_views.xml b/addons/payment_flutterwave/views/payment_views.xml new file mode 100644 index 00000000000..a7e06ce333b --- /dev/null +++ b/addons/payment_flutterwave/views/payment_views.xml @@ -0,0 +1,28 @@ + + + + + Flutterwave Acquirer Form + payment.acquirer + + + + + + + + + + + + +