[ADD] payment_flutterwave: integrate with "Flutterwave standard" gateway

See README for technical details.

task-2759117

Part-of: odoo/odoo#85514
This commit is contained in:
Antoine Vandevenne (anv)
2022-05-31 16:02:08 +02:00
parent 9e6d585f75
commit 94bddadf4e
30 changed files with 950 additions and 3 deletions
@@ -115,6 +115,36 @@
])]"/>
</record>
<record id="payment_acquirer_flutterwave" model="payment.acquirer">
<field name="name">Flutterwave</field>
<field name="image_128"
type="base64"
file="payment_flutterwave/static/src/img/flutterwave-logo.png"/>
<field name="module_id" ref="base.module_payment_flutterwave"/>
<field name="description" type="html">
<p>
A payment gateway from Flutterwave to accept online payments.
</p>
<ul class="list-inline">
<li class="list-inline-item"><i class="fa fa-check"/>Online Payments</li>
<li class="list-inline-item"><i class="fa fa-check"/>Payment Status Tracking</li>
<li class="list-inline-item"><i class="fa fa-check"/>Subscriptions</li>
<li class="list-inline-item"><i class="fa fa-check"/>Save Cards</li>
</ul>
</field>
<!-- https://developer.flutterwave.com/docs/collecting-payments/payment-methods/ -->
<field name="payment_icon_ids" eval="[(6, 0, [
ref('payment.payment_icon_cc_visa'),
ref('payment.payment_icon_cc_mastercard'),
ref('payment.payment_icon_cc_american_express'),
ref('payment.payment_icon_mpesa'),
ref('payment.payment_icon_airtel_money'),
ref('payment.payment_icon_mtn_mobile_money'),
ref('payment.payment_icon_barter_by_flutterwave'),
])]"/>
</record>
<record id="payment_acquirer_mollie" model="payment.acquirer">
<field name="name">Mollie</field>
<field name="image_128" type="base64" file="payment_mollie/static/src/img/mollie_icon.png"/>
+25
View File
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="payment_icon_cc_visa" model="payment.icon">
<field name="sequence">10</field>
<field name="name">VISA</field>
@@ -126,4 +127,28 @@
<field name="image" type="base64" file="payment/static/img/kbc.png"/>
</record>
<record id="payment_icon_mpesa" model="payment.icon">
<field name="sequence">220</field>
<field name="name">M-Pesa</field>
<field name="image" type="base64" file="payment/static/img/m-pesa.png"/>
</record>
<record id="payment_icon_airtel_money" model="payment.icon">
<field name="sequence">230</field>
<field name="name">Airtel Money</field>
<field name="image" type="base64" file="payment/static/img/airtel-money.png"/>
</record>
<record id="payment_icon_mtn_mobile_money" model="payment.icon">
<field name="sequence">240</field>
<field name="name">MTN Mobile Money</field>
<field name="image" type="base64" file="payment/static/img/mtn-mobile-money.png"/>
</record>
<record id="payment_icon_barter_by_flutterwave" model="payment.icon">
<field name="sequence">250</field>
<field name="name">Barter by Flutterwave</field>
<field name="image" type="base64" file="payment/static/img/barter-by-flutterwave.png"/>
</record>
</odoo>
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

+1
View File
@@ -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"
+6 -3
View File
@@ -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,
}
+31
View File
@@ -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
+10
View File
@@ -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')
@@ -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',
}
+47
View File
@@ -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'],
}
@@ -0,0 +1,3 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import main
@@ -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()
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="payment.payment_acquirer_flutterwave" model="payment.acquirer">
<field name="provider">flutterwave</field>
<field name="redirect_form_view_id" ref="redirect_form"/>
<field name="allow_tokenization">True</field>
</record>
<record id="payment_method_flutterwave" model="account.payment.method">
<field name="name">Flutterwave</field>
<field name="code">flutterwave</field>
<field name="payment_type">inbound</field>
</record>
</odoo>
@@ -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
@@ -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
@@ -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',
])
@@ -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
)
@@ -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,
},
)
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

@@ -0,0 +1,80 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 26.2.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 70 70" style="enable-background:new 0 0 70 70;" xml:space="preserve">
<style type="text/css">
.st0{filter:url(#Adobe_OpacityMaskFilter);}
.st1{fill-rule:evenodd;clip-rule:evenodd;fill:#FFFFFF;}
.st2{mask:url(#b_00000056428700427525433750000007737325548001924286_);}
.st3{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_1_);}
.st4{fill-rule:evenodd;clip-rule:evenodd;fill:#FFFFFF;fill-opacity:0.383;}
.st5{opacity:0.324;fill-rule:evenodd;clip-rule:evenodd;fill:#393939;enable-background:new ;}
.st6{fill-rule:evenodd;clip-rule:evenodd;fill-opacity:0.383;}
.st7{opacity:0.4;}
.st8{fill-rule:evenodd;clip-rule:evenodd;}
.st9{fill:#FFFFFF;}
</style>
<defs>
<filter id="Adobe_OpacityMaskFilter" filterUnits="userSpaceOnUse" x="0" y="0" width="70" height="70">
<feColorMatrix type="matrix" values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 1 0"/>
</filter>
</defs>
<mask maskUnits="userSpaceOnUse" x="0" y="0" width="70" height="70" id="b_00000056428700427525433750000007737325548001924286_">
<g class="st0">
<path id="a_00000138559125790419082090000007347613071121435547_" class="st1" d="M4,0h61c4,0,5,1,5,5v60c0,4-1,5-5,5H4
c-3,0-4-1-4-5V5C0,1,1,0,4,0z"/>
</g>
</mask>
<g class="st2">
<linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="-2234.6001" y1="1879.5037" x2="-2235.6001" y2="1878.5037" gradientTransform="matrix(70 0 0 -70 156492 131565.2656)">
<stop offset="0" style="stop-color:#94B6C8"/>
<stop offset="1" style="stop-color:#6A9EBA"/>
</linearGradient>
<path class="st3" d="M0,0h70v70H0V0z"/>
<path class="st4" d="M4,1h61c2.7,0,4.3,0.7,5,2V0H0v3C0.7,1.7,2,1,4,1z"/>
<path class="st5" d="M4,69c-2,0-4-1-4-4V31.9l12.6-12.5c5.9-2.4,10.9-1.3,15.3,3.3c10-7.1,15.8-7.6,11.3,8l7.5-7.4L56.7,24
l-0.5,23.9L35.2,69H4z"/>
<path class="st6" d="M4,69h61c2.7,0,4.3-1,5-3v4H0v-4C0.7,68,2,69,4,69z"/>
<g class="st7">
<polygon points="19.7,55.6 18.7,55.6 18.7,55 21.5,55 21.5,55.6 20.5,55.6 20.5,58.5 19.7,58.5 19.7,55.6 "/>
<polygon points="21.7,55 22.7,55 23.6,57.4 23.6,57.4 24.3,55 25.4,55 25.4,58.5 24.7,58.5 24.7,56 24.7,56 23.8,58.5 23.2,58.5
22.4,56 22.4,56 22.4,58.5 21.7,58.5 "/>
<path d="M53.3,24.7h-9.9c0,0.9-0.2,1.8-0.4,2.7h10c0.2,0,0.3,0.2,0.3,0.3V31H41.6c-0.9,1.9-2.2,3.9-3.8,5.8c0,0.2,0,0.5,0.1,0.7
h15.4v9.7c0,0.1,0,0.2-0.1,0.2c-0.1,0.1-0.1,0.1-0.2,0.1H26.6c-0.1,0-0.2,0-0.2-0.1c-0.1-0.1-0.1-0.1-0.1-0.2v-1
c-0.3-0.1-0.7-0.2-1-0.3c-0.6,0.2-1.2,0.3-1.7,0.4v1.2c0,1.5,1.2,2.7,2.7,2.7h27.1c1.5,0,2.7-1.2,2.7-2.7V27.4
C56,25.9,54.8,24.7,53.3,24.7z"/>
<path id="path0_00000051376682181676208090000007751481062063305623_" class="st8" d="M34.3,18.7c-2.7,0.2-5.8,1.6-8.7,3.8
c-0.4,0.3-0.3,0.3-0.8-0.1c-6.2-4.5-12.3-4.9-14.2-1c-1.6,3.4,0.3,8.9,4.8,14c0.3,0.4,0.4,0.4,0.3,0.5c0,0-0.1,0.3-0.1,0.6
c-1,5.7,3.1,8.5,9.2,6.4c0.3-0.1,0.5-0.2,0.5-0.1c0,0,0.3,0.1,0.6,0.2c6.1,2.1,10.1-1,8.9-6.8l-0.1-0.3l0.3-0.3
c4.5-5,6.5-10.8,4.9-14.2C38.9,19.5,36.8,18.4,34.3,18.7 M16.5,21.2c1.9,0.3,4.5,1.5,6.6,3l0.2,0.1L23,24.5
c-2.7,2.4-5,5.6-6.3,8.4c-0.1,0.3-0.1,0.3-0.3,0c-3.5-4.4-4.9-8.9-3.3-10.9C13.7,21.3,14.9,21,16.5,21.2 M35.6,21.2
c1.1,0.2,1.8,0.7,2.1,1.6c0.8,2-0.4,5.6-2.9,9.1c-0.5,0.6-1,1.4-1.1,1.3c0,0-0.1-0.2-0.2-0.4c-1.2-2.6-3.4-5.6-5.8-7.9
c-0.3-0.3-0.5-0.5-0.5-0.5s0.2-0.2,0.4-0.3C30.6,21.9,33.6,20.9,35.6,21.2 M25.5,26.2c2.7,2.4,5,5.6,6.2,8.4
c0.2,0.6,0.3,0.5-0.2,1c-1.7,1.7-3.8,3.2-5.7,4.2c-0.6,0.3-0.5,0.3-1.2-0.1c-1.7-0.9-3.3-2-4.9-3.4c-0.6-0.5-1.2-1.1-1.2-1.2
c0,0,0-0.2,0.1-0.3c1-2.5,2.7-5.1,5-7.5c0.5-0.6,1.5-1.5,1.6-1.5C25.2,25.9,25.3,26,25.5,26.2 M18.4,38.5c1,0.9,2.2,1.8,3.3,2.4
c0.3,0.2,0.3,0.2-0.3,0.2c-2.2,0.2-3.4-0.8-3.5-2.8l0-0.3l0.1,0.1C18,38.1,18.2,38.3,18.4,38.5 M32.5,38.4c-0.1,2.1-1.4,3-3.8,2.7
c-0.2,0-0.2,0,0.2-0.2c1-0.6,2.3-1.6,3.3-2.5c0.2-0.2,0.3-0.3,0.3-0.3C32.5,38.1,32.5,38.2,32.5,38.4"/>
</g>
<g>
<polygon class="st9" points="21.7,53.6 20.7,53.6 20.7,53 23.5,53 23.5,53.6 22.5,53.6 22.5,56.5 21.7,56.5 21.7,53.6 "/>
<polygon class="st9" points="23.7,53 24.7,53 25.6,55.4 25.6,55.4 26.3,53 27.4,53 27.4,56.5 26.7,56.5 26.7,54 26.7,54
25.8,56.5 25.2,56.5 24.4,54 24.4,54 24.4,56.5 23.7,56.5 "/>
<path class="st9" d="M55.3,22.7h-9.9c0,0.9-0.2,1.8-0.4,2.7h10c0.2,0,0.3,0.2,0.3,0.3V29H43.6c-0.9,1.9-2.2,3.9-3.8,5.8
c0,0.2,0,0.5,0.1,0.7h15.4v9.7c0,0.1,0,0.2-0.1,0.2c-0.1,0.1-0.1,0.1-0.2,0.1H28.6c-0.1,0-0.2,0-0.2-0.1c-0.1-0.1-0.1-0.1-0.1-0.2
v-1c-0.3-0.1-0.7-0.2-1-0.3c-0.6,0.2-1.2,0.3-1.7,0.4v1.2c0,1.5,1.2,2.7,2.7,2.7h27.1c1.5,0,2.7-1.2,2.7-2.7V25.4
C58,23.9,56.8,22.7,55.3,22.7z"/>
<path id="path0_00000123403326104996206480000014033077542406494338_" class="st1" d="M36.3,16.7c-2.7,0.2-5.8,1.6-8.7,3.8
c-0.4,0.3-0.3,0.3-0.8-0.1c-6.2-4.5-12.3-4.9-14.2-1c-1.6,3.4,0.3,8.9,4.8,14c0.3,0.4,0.4,0.4,0.3,0.5c0,0-0.1,0.3-0.1,0.6
c-1,5.7,3.1,8.5,9.2,6.4c0.3-0.1,0.5-0.2,0.5-0.1c0,0,0.3,0.1,0.6,0.2c6.1,2.1,10.1-1,8.9-6.8l-0.1-0.3l0.3-0.3
c4.5-5,6.5-10.8,4.9-14.2C40.9,17.5,38.8,16.4,36.3,16.7 M18.5,19.2c1.9,0.3,4.5,1.5,6.6,3l0.2,0.1L25,22.5
c-2.7,2.4-5,5.6-6.3,8.4c-0.1,0.3-0.1,0.3-0.3,0c-3.5-4.4-4.9-8.9-3.3-10.9C15.7,19.3,16.9,19,18.5,19.2 M37.6,19.2
c1.1,0.2,1.8,0.7,2.1,1.6c0.8,2-0.4,5.6-2.9,9.1c-0.5,0.6-1,1.4-1.1,1.3c0,0-0.1-0.2-0.2-0.4c-1.2-2.6-3.4-5.6-5.8-7.9
c-0.3-0.3-0.5-0.5-0.5-0.5c0,0,0.2-0.2,0.4-0.3C32.6,19.9,35.6,18.9,37.6,19.2 M27.5,24.2c2.7,2.4,5,5.6,6.2,8.4
c0.2,0.6,0.3,0.5-0.2,1c-1.7,1.7-3.8,3.2-5.7,4.2c-0.6,0.3-0.5,0.3-1.2-0.1c-1.7-0.9-3.3-2-4.9-3.4c-0.6-0.5-1.2-1.1-1.2-1.2
c0,0,0-0.2,0.1-0.3c1-2.5,2.7-5.1,5-7.5c0.5-0.6,1.5-1.5,1.6-1.5C27.2,23.9,27.3,24,27.5,24.2 M20.4,36.5c1,0.9,2.2,1.8,3.3,2.4
c0.3,0.2,0.3,0.2-0.3,0.2c-2.2,0.2-3.4-0.8-3.5-2.8l0-0.3l0.1,0.1C20,36.1,20.2,36.3,20.4,36.5 M34.5,36.4c-0.1,2.1-1.4,3-3.8,2.7
c-0.2,0-0.2,0,0.2-0.2c1-0.6,2.3-1.6,3.3-2.5c0.2-0.2,0.3-0.3,0.3-0.3C34.5,36.1,34.5,36.2,34.5,36.4"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

@@ -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
@@ -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',
},
},
}
@@ -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)
@@ -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)
@@ -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
)
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="redirect_form">
<form t-att-action="api_url" method="get"/>
</template>
</odoo>
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="payment_acquirer_form" model="ir.ui.view">
<field name="name">Flutterwave Acquirer Form</field>
<field name="model">payment.acquirer</field>
<field name="inherit_id" ref="payment.payment_acquirer_form"/>
<field name="arch" type="xml">
<xpath expr="//group[@name='acquirer']" position="inside">
<group attrs="{'invisible': [('provider', '!=', 'flutterwave')]}"
name="flutterwave_credentials">
<field name="flutterwave_public_key"
string="Public Key"
attrs="{'required':[('provider', '=', 'flutterwave'), ('state', '!=', 'disabled')]}"/>
<field name="flutterwave_secret_key"
string="Secret Key"
attrs="{'required':[('provider', '=', 'flutterwave'), ('state', '!=', 'disabled')]}"
password="True"/>
<field name="flutterwave_webhook_secret"
string="Webhook Secret"
attrs="{'required':[('provider', '=', 'flutterwave'), ('state', '!=', 'disabled')]}"
password="True"/>
</group>
</xpath>
</field>
</record>
</odoo>