[ADD] payment_xendit: new payment provider for Indonesia and Philippines

See the README.md file for more details.

2946329

closes odoo/odoo#141661

Signed-off-by: Antoine Vandevenne (anv) <anv@odoo.com>
This commit is contained in:
nni-odoo
2024-01-22 19:00:55 +00:00
committed by Antoine Vandevenne (anv)
parent 3eb5b3410c
commit f3d7a97f69
30 changed files with 952 additions and 5 deletions
+9
View File
@@ -1288,6 +1288,15 @@ resource_name = payment_stripe
replace_edited_strings = false
keep_translations = false
[o:odoo:p:odoo-17:r:payment_xendit]
file_filter = addons/payment_xendit/i18n/<lang>.po
source_file = addons/payment_xendit/i18n/payment_xendit.pot
type = PO
minimum_perc = 0
resource_name = payment_xendit
replace_edited_strings = false
keep_translations = false
[o:odoo:p:odoo-17:r:phone_validation]
file_filter = addons/phone_validation/i18n/<lang>.po
source_file = addons/phone_validation/i18n/phone_validation.pot
+171 -2
View File
@@ -3,6 +3,26 @@
<!-- === PRIMARY PAYMENT METHODS === -->
<record id="payment_method_7eleven" model="payment.method">
<field name="name">7Eleven</field>
<field name="code">7eleven</field>
<field name="sequence">1000</field>
<field name="active">False</field>
<field name="support_tokenization">False</field>
<field name="support_express_checkout">False</field>
<field name="support_refund">partial</field>
<field name="supported_country_ids"
eval="[Command.set([
ref('base.ph'),
])]"
/>
<field name="supported_currency_ids"
eval="[Command.set([
ref('base.PHP'),
])]"
/>
</record>
<record id="payment_method_ach_direct_debit" model="payment.method">
<field name="name">ACH Direct Debit</field>
<field name="code">ach_direct_debit</field>
@@ -396,6 +416,27 @@
<field name="support_refund"></field>
</record>
<record id="payment_method_bank_bca" model="payment.method">
<field name="name">BCA</field>
<field name="code">bank_bca</field>
<field name="sequence">1000</field>
<field name="active">False</field>
<field name="image" type="base64" file="payment/static/img/bank_bca.png"/>
<field name="support_tokenization">False</field>
<field name="support_express_checkout">False</field>
<field name="support_refund"></field>
<field name="supported_country_ids"
eval="[Command.set([
ref('base.id'),
])]"
/>
<field name="supported_currency_ids"
eval="[Command.set([
ref('base.IDR'),
])]"
/>
</record>
<record id="payment_method_bank_of_ayudhya" model="payment.method">
<field name="name">Bank of Ayudhya</field>
<field name="code">bank_of_ayudhya</field>
@@ -417,6 +458,27 @@
/>
</record>
<record id="payment_method_bank_permata" model="payment.method">
<field name="name">Bank Permata</field>
<field name="code">bank_permata</field>
<field name="sequence">1000</field>
<field name="active">False</field>
<field name="image" type="base64" file="payment/static/img/bank_permata.png"/>
<field name="support_tokenization">False</field>
<field name="support_express_checkout">False</field>
<field name="support_refund"></field>
<field name="supported_country_ids"
eval="[Command.set([
ref('base.id'),
])]"
/>
<field name="supported_currency_ids"
eval="[Command.set([
ref('base.IDR'),
])]"
/>
</record>
<record id="payment_method_bank_reference" model="payment.method">
<field name="name">Bank reference</field>
<field name="code">bank_reference</field>
@@ -619,7 +681,7 @@
<field name="code">bni</field>
<field name="sequence">1000</field>
<field name="active">False</field>
<field name="image" type="base64" file="payment/static/img/bank.png"/>
<field name="image" type="base64" file="payment/static/img/bni.png"/>
<field name="support_tokenization">False</field>
<field name="support_express_checkout">False</field>
<field name="support_refund"></field>
@@ -719,12 +781,33 @@
/>
</record>
<record id="payment_method_bri" model="payment.method">
<field name="name">BRI</field>
<field name="code">bri</field>
<field name="sequence">1000</field>
<field name="active">False</field>
<field name="image" type="base64" file="payment/static/img/bri.png"/>
<field name="support_tokenization">False</field>
<field name="support_express_checkout">False</field>
<field name="support_refund"></field>
<field name="supported_country_ids"
eval="[Command.set([
ref('base.id'),
])]"
/>
<field name="supported_currency_ids"
eval="[Command.set([
ref('base.IDR'),
])]"
/>
</record>
<record id="payment_method_bsi" model="payment.method">
<field name="name">Bank Syariah Indonesia</field>
<field name="code">bsi</field>
<field name="sequence">1000</field>
<field name="active">False</field>
<field name="image" type="base64" file="payment/static/img/bank.png"/>
<field name="image" type="base64" file="payment/static/img/bsi.png"/>
<field name="support_tokenization">False</field>
<field name="support_express_checkout">False</field>
<field name="support_refund"></field>
@@ -772,6 +855,46 @@
/>
</record>
<record id="payment_method_cashalo" model="payment.method">
<field name="name">Cashalo</field>
<field name="code">cashalo</field>
<field name="sequence">1000</field>
<field name="active">False</field>
<field name="support_tokenization">False</field>
<field name="support_express_checkout">False</field>
<field name="support_refund">partial</field>
<field name="supported_country_ids"
eval="[Command.set([
ref('base.ph'),
])]"
/>
<field name="supported_currency_ids"
eval="[Command.set([
ref('base.PHP'),
])]"
/>
</record>
<record id="payment_method_cebuana" model="payment.method">
<field name="name">Cebuana</field>
<field name="code">cebuana</field>
<field name="sequence">1000</field>
<field name="active">False</field>
<field name="support_tokenization">False</field>
<field name="support_express_checkout">False</field>
<field name="support_refund">partial</field>
<field name="supported_country_ids"
eval="[Command.set([
ref('base.ph'),
])]"
/>
<field name="supported_currency_ids"
eval="[Command.set([
ref('base.PHP'),
])]"
/>
</record>
<record id="payment_method_clearpay" model="payment.method">
<field name="name">Clearpay</field>
<field name="code">clearpay</field>
@@ -1121,12 +1244,14 @@
eval="[Command.set([
ref('base.my'),
ref('base.sg'),
ref('base.ph'),
])]"
/>
<field name="supported_currency_ids"
eval="[Command.set([
ref('base.SGD'),
ref('base.MYR'),
ref('base.PHP'),
])]"
/>
</record>
@@ -1269,6 +1394,27 @@
/>
</record>
<record id="payment_method_jeniuspay" model="payment.method">
<field name="name">JeniusPay</field>
<field name="code">jeniuspay</field>
<field name="sequence">1000</field>
<field name="active">False</field>
<field name="image" type="base64" file="payment/static/img/jeniuspay.png"/>
<field name="support_tokenization">False</field>
<field name="support_express_checkout">False</field>
<field name="support_refund">partial</field>
<field name="supported_country_ids"
eval="[Command.set([
ref('base.id'),
])]"
/>
<field name="supported_currency_ids"
eval="[Command.set([
ref('base.IDR'),
])]"
/>
</record>
<record id="payment_method_jkopay" model="payment.method">
<field name="name">Jkopay</field>
<field name="code">jkopay</field>
@@ -2782,6 +2928,29 @@
/>
</record>
<record id="payment_method_shopeepay" model="payment.method">
<field name="name">ShopeePay</field>
<field name="code">shopeepay</field>
<field name="sequence">1000</field>
<field name="active">False</field>
<field name="image" type="base64" file="payment/static/img/shopeepay.png"/>
<field name="support_tokenization">False</field>
<field name="support_express_checkout">False</field>
<field name="support_refund">partial</field>
<field name="supported_country_ids"
eval="[Command.set([
ref('base.id'),
ref('base.ph'),
])]"
/>
<field name="supported_currency_ids"
eval="[Command.set([
ref('base.IDR'),
])]"
/>
</record>
<record id="payment_method_sofort" model="payment.method">
<field name="name">Sofort</field>
<field name="code">sofort</field>
+37 -3
View File
@@ -391,10 +391,44 @@
<record id="payment_provider_transfer" model="payment.provider">
<field name="name">Wire Transfer</field>
<field name="sequence">30</field>
<field name="image_128"
type="base64"
file="payment_custom/static/description/icon.png"/>
<field name="image_128" type="base64" file="payment_custom/static/description/icon.png"/>
<field name="module_id" ref="base.module_payment_custom"/>
</record>
<record id="payment_provider_xendit" model="payment.provider">
<field name="name">Xendit</field>
<field name="image_128"
type="base64"
file="payment_xendit/static/description/icon.png"
/>
<field name="module_id" ref="base.module_payment_xendit"/>
<!-- See https://docs.xendit.co/payment-link/payment-channels for payment methods. -->
<field name="payment_method_ids"
eval="[(6, 0, [
ref('payment.payment_method_7eleven'),
ref('payment.payment_method_akulaku'),
ref('payment.payment_method_bank_bca'),
ref('payment.payment_method_bank_permata'),
ref('payment.payment_method_billease'),
ref('payment.payment_method_bni'),
ref('payment.payment_method_bri'),
ref('payment.payment_method_bsi'),
ref('payment.payment_method_card'),
ref('payment.payment_method_cashalo'),
ref('payment.payment_method_cebuana'),
ref('payment.payment_method_cimb_niaga'),
ref('payment.payment_method_dana'),
ref('payment.payment_method_gcash'),
ref('payment.payment_method_grabpay'),
ref('payment.payment_method_jeniuspay'),
ref('payment.payment_method_kredivo'),
ref('payment.payment_method_linkaja'),
ref('payment.payment_method_mandiri'),
ref('payment.payment_method_maya'),
ref('payment.payment_method_ovo'),
ref('payment.payment_method_qris'),
ref('payment.payment_method_shopeepay'),
])]"/>
</record>
</odoo>
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

+34
View File
@@ -0,0 +1,34 @@
# Xendit
## Implementation details
### Supported features
- [Payment with redirection flow](https://developers.xendit.co/api-reference/#create-invoice)
- Several payment methods such as credit cards, bank transfers, eWallets, pay later, and
[others](https://docs.xendit.co/payment-link/payment-channels).
- [Webhook](https://developers.xendit.co/api-reference/#invoice-callback).
In addition, Xendit also allows to implement tokenization.
### API and gateway
We choose to integrate with the
[Invoices API](https://developer.flutterwave.com/docs/collecting-payments/standard/) as it
is the gateway that covers the best our needs: it is a payment with redirection flow and is
compatible with `payment`'s form-based implementation of that flow.
The [Payments API](https://developers.xendit.co/api-reference/#payments-api) was considered too, but
it requires implementing a more complex direct payment flow and linking the customer account.
The version of the API implemented by this module is v2.
## Merge details
The first version of the module was specified in task
[2946329](https://www.odoo.com/web#id=2759117&model=project.task) and merged with PR
odoo/odoo#141661 in branch `17.0`.
## Testing instructions
https://developers.xendit.co/api-reference/#test-scenarios
+14
View File
@@ -0,0 +1,14 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import controllers
from . import models
from odoo.addons.payment import setup_provider, reset_payment_provider
def post_init_hook(env):
setup_provider(env, 'xendit')
def uninstall_hook(env):
reset_payment_provider(env, 'xendit')
+19
View File
@@ -0,0 +1,19 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
{
'name': "Payment Provider: Xendit",
'version': '1.0',
'category': 'Accounting/Payment Providers',
'sequence': 350,
'summary': "A payment provider for Indonesian and the Philippines.",
'depends': ['payment'],
'data': [
'views/payment_provider_views.xml',
'views/payment_xendit_templates.xml',
'data/payment_provider_data.xml', # Depends on payment_xendit_templates.xml
],
'post_init_hook': 'post_init_hook',
'uninstall_hook': 'uninstall_hook',
'license': 'LGPL-3',
}
+38
View File
@@ -0,0 +1,38 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
# The currencies supported by Xendit, in ISO 4217 format.
SUPPORTED_CURRENCIES = [
'IDR',
'PHP',
]
# The codes of the payment methods to activate when Xendit is activated.
DEFAULT_PAYMENT_METHODS_CODES = [
# Primary payment methods.
'card',
'dana',
'ovo',
'qris',
# Brand payment methods.
'visa',
'mastercard',
]
# Mapping of payment code to channel code according to Xendit API
PAYMENT_METHODS_MAPPING = {
'bank_bca': 'BCA',
'bank_permata': 'PERMATA',
'bpi': 'DD_BPI',
'card': 'CREDIT_CARD',
'maya': 'PAYMAYA',
}
# Mapping of transaction states to Xendit payment statuses.
PAYMENT_STATUS_MAPPING = {
'draft': (),
'pending': ('PENDING'),
'done': ('SUCCEEDED', 'PAID'),
'cancel': ('CANCELLED', 'EXPIRED'),
'error': ('FAILED',)
}
@@ -0,0 +1,3 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import main
+60
View File
@@ -0,0 +1,60 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
import pprint
from werkzeug.exceptions import Forbidden
from odoo import http
from odoo.exceptions import ValidationError
from odoo.http import request
from odoo.tools import consteq
_logger = logging.getLogger(__name__)
class XenditController(http.Controller):
_webhook_url = '/payment/xendit/webhook'
@http.route(_webhook_url, type='http', methods=['POST'], auth='public', csrf=False)
def xendit_webhook(self):
""" Process the notification data sent by Xendit to the webhook.
:return: The 'accepted' string to acknowledge the notification.
"""
data = request.get_json_data()
_logger.info("Notification received from Xendit with data:\n%s", pprint.pformat(data))
try:
# Check the integrity of the notification.
received_token = request.httprequest.headers.get('x-callback-token')
tx_sudo = request.env['payment.transaction'].sudo()._get_tx_from_notification_data(
'xendit', data
)
self._verify_notification_token(received_token, tx_sudo)
# Handle the notification data.
tx_sudo._handle_notification_data('xendit', data)
except ValidationError:
_logger.exception("Unable to handle notification data; skipping to acknowledge.")
return request.make_json_response(['accepted'], status=200)
def _verify_notification_token(self, received_token, tx_sudo):
""" Check that the received token matches the saved webhook token.
:param str received_token: The callback token received with the notification data.
:param payment.transaction tx_sudo: The transaction referenced by the notification data.
:return: None
:raise Forbidden: If the tokens don't match.
"""
# Check for the received token.
if not received_token:
_logger.warning("Received notification with missing token.")
raise Forbidden()
if not consteq(tx_sudo.provider_id.xendit_webhook_token, received_token):
_logger.warning("Received notification with invalid callback token %r.", received_token)
raise Forbidden()
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="payment.payment_provider_xendit" model="payment.provider">
<field name="code">xendit</field>
<field name="redirect_form_view_id" ref="redirect_form"/>
</record>
</odoo>
@@ -0,0 +1,90 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * payment_xendit
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 17.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-01-08 13:49+0000\n"
"PO-Revision-Date: 2024-01-08 13:49+0000\n"
"Last-Translator: \n"
"Language-Team: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: \n"
#. module: payment_xendit
#. odoo-python
#: code:addons/payment_xendit/models/payment_transaction.py:0
#, python-format
msgid ""
"An error occurred during the processing of your payment (status %s). Please "
"try again."
msgstr ""
#. module: payment_xendit
#: model:ir.model.fields,field_description:payment_xendit.field_payment_provider__code
msgid "Code"
msgstr ""
#. module: payment_xendit
#. odoo-python
#: code:addons/payment_xendit/models/payment_provider.py:0
#, python-format
msgid "Could not establish the connection to the API."
msgstr ""
#. module: payment_xendit
#. odoo-python
#: code:addons/payment_xendit/models/payment_transaction.py:0
#, python-format
msgid "No transaction found matching reference %s."
msgstr ""
#. module: payment_xendit
#: model:ir.model,name:payment_xendit.model_payment_provider
msgid "Payment Provider"
msgstr ""
#. module: payment_xendit
#: model:ir.model,name:payment_xendit.model_payment_transaction
msgid "Payment Transaction"
msgstr ""
#. module: payment_xendit
#. odoo-python
#: code:addons/payment_xendit/models/payment_transaction.py:0
#, python-format
msgid "Received data with missing reference."
msgstr ""
#. module: payment_xendit
#: model:ir.model.fields,field_description:payment_xendit.field_payment_provider__xendit_secret_key
msgid "Secret Key"
msgstr ""
#. module: payment_xendit
#. odoo-python
#: code:addons/payment_xendit/models/payment_provider.py:0
#, python-format
msgid ""
"The communication with the API failed. Xendit gave us the following "
"information: '%s'"
msgstr ""
#. module: payment_xendit
#: model:ir.model.fields,help:payment_xendit.field_payment_provider__code
msgid "The technical code of this payment provider."
msgstr ""
#. module: payment_xendit
#: model:ir.model.fields,field_description:payment_xendit.field_payment_provider__xendit_webhook_token
msgid "Webhook Token"
msgstr ""
#. module: payment_xendit
#: model:ir.model.fields.selection,name:payment_xendit.selection__payment_provider__code__xendit
msgid "Xendit"
msgstr ""
+4
View File
@@ -0,0 +1,4 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import payment_provider
from . import payment_transaction
@@ -0,0 +1,79 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
import pprint
import requests
from odoo import _, fields, models
from odoo.exceptions import ValidationError
from odoo.addons.payment_xendit import const
_logger = logging.getLogger(__name__)
class PaymentProvider(models.Model):
_inherit = 'payment.provider'
code = fields.Selection(
selection_add=[('xendit', "Xendit")], ondelete={'xendit': 'set default'}
)
xendit_secret_key = fields.Char(
string="Xendit Secret Key", groups='base.group_system', required_if_provider='xendit'
)
xendit_webhook_token = fields.Char(
string="Xendit Webhook Token", groups='base.group_system', required_if_provider='xendit'
)
# === BUSINESS METHODS ===#
def _get_supported_currencies(self):
""" Override of `payment` to return the supported currencies. """
supported_currencies = super()._get_supported_currencies()
if self.code == 'xendit':
supported_currencies = supported_currencies.filtered(
lambda c: c.name in const.SUPPORTED_CURRENCIES
)
return supported_currencies
def _get_default_payment_method_codes(self):
""" Override of `payment` to return the default payment method codes. """
default_codes = super()._get_default_payment_method_codes()
if self.code != 'xendit':
return default_codes
return const.DEFAULT_PAYMENT_METHODS_CODES
def _xendit_make_request(self, payload=None):
""" Make a request to Xendit API and return the JSON-formatted content of the response.
Note: self.ensure_one()
:param dict payload: The payload of the request.
:return The JSON-formatted content of the response.
:rtype: dict
:raise ValidationError: If an HTTP error occurs.
"""
self.ensure_one()
auth = (self.xendit_secret_key, '')
url = "https://api.xendit.co/v2/invoices"
try:
response = requests.post(url, json=payload, auth=auth, timeout=10)
response.raise_for_status()
except (requests.exceptions.ConnectionError, requests.exceptions.Timeout):
_logger.exception("Unable to reach endpoint at %s", url)
raise ValidationError("Xendit: " + _("Could not establish the connection to the API."))
except requests.exceptions.HTTPError as err:
error_message = err.response.json().get('message')
_logger.exception(
"Invalid API request at %s with data:\n%s", url, pprint.pformat(payload)
)
raise ValidationError(
"Xendit: " + _(
"The communication with the API failed. Xendit gave us the following"
" information: '%s'", error_message
)
)
return response.json()
@@ -0,0 +1,150 @@
# 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 ValidationError
from odoo.addons.payment_xendit import const
_logger = logging.getLogger(__name__)
class PaymentTransaction(models.Model):
_inherit = 'payment.transaction'
def _get_specific_rendering_values(self, processing_values):
""" Override of `payment` to return Xendit-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 provider-specific processing values.
:rtype: dict
"""
res = super()._get_specific_rendering_values(processing_values)
if self.provider_code != 'xendit':
return res
# Initiate the payment and retrieve the invoice data.
payload = self._xendit_prepare_invoice_request_payload()
_logger.info("Sending invoice request for link creation:\n%s", pprint.pformat(payload))
invoice_data = self.provider_id._xendit_make_request(payload)
_logger.info("Received invoice request response:\n%s", pprint.pformat(invoice_data))
# Extract the payment link URL and embed it in the redirect form.
rendering_values = {
'api_url': invoice_data.get('invoice_url')
}
return rendering_values
def _xendit_prepare_invoice_request_payload(self):
""" Create the payload for the invoice request based on the transaction values.
:return: The request payload.
:rtype: dict
"""
base_url = self.provider_id.get_base_url()
redirect_url = urls.url_join(base_url, '/payment/status')
payload = {
'external_id': self.reference,
'amount': self.amount,
'description': self.reference,
'customer': {
'given_names': self.partner_name,
},
'success_redirect_url': redirect_url,
'failure_redirect_url': redirect_url,
'payment_methods': [const.PAYMENT_METHODS_MAPPING.get(
self.payment_method_code, self.payment_method_code.upper())
],
'currency': self.currency_id.name,
}
# Extra payload values that must not be included if empty.
if self.partner_email:
payload['customer']['email'] = self.partner_id.email
if phone := self.partner_id.mobile or self.partner_id.phone:
payload['customer']['mobile_number'] = phone
address_details = {}
if self.partner_city:
address_details['city'] = self.partner_city
if self.partner_country_id.name:
address_details['country'] = self.partner_country_id.name
if self.partner_zip:
address_details['postal_code'] = self.partner_zip
if self.partner_state_id.name:
address_details['state'] = self.partner_state_id.name
if self.partner_address:
address_details['street_line1'] = self.partner_address
if address_details:
payload['customer']['addresses'] = [address_details]
return payload
def _get_tx_from_notification_data(self, provider_code, notification_data):
""" Override of `payment` to find the transaction based on the notification data.
:param str provider_code: The code of the provider that handled the transaction.
:param dict notification_data: The notification data sent by the provider.
:return: The transaction if found.
:rtype: 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_code, notification_data)
if provider_code != 'xendit' or len(tx) == 1:
return tx
reference = notification_data.get('external_id')
if not reference:
raise ValidationError("Xendit: " + _("Received data with missing reference."))
tx = self.search([('reference', '=', reference), ('provider_code', '=', 'xendit')])
if not tx:
raise ValidationError(
"Xendit: " + _("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 Xendit 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.
"""
self.ensure_one()
super()._process_notification_data(notification_data)
if self.provider_code != 'xendit':
return
# Update the provider reference.
self.provider_reference = notification_data.get('id')
# Update payment method.
payment_method_code = notification_data.get('payment_method', '')
payment_method = self.env['payment.method']._get_from_code(
payment_method_code, mapping=const.PAYMENT_METHODS_MAPPING
)
self.payment_method_id = payment_method or self.payment_method_id
# Update the payment state.
payment_status = notification_data.get('status')
if payment_status in const.PAYMENT_STATUS_MAPPING['pending']:
self._set_pending()
elif payment_status in const.PAYMENT_STATUS_MAPPING['done']:
self._set_done()
elif payment_status in const.PAYMENT_STATUS_MAPPING['cancel']:
self._set_canceled()
elif payment_status in const.PAYMENT_STATUS_MAPPING['error']:
self._set_error(_(
"An error occurred during the processing of your payment (status %s). Please try "
"again."
))
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

@@ -0,0 +1 @@
<svg width="50" height="50" viewBox="0 0 50 50" xmlns="http://www.w3.org/2000/svg"><path d="M26.936 5H15.642L4 25.012 15.642 45l2.296-3.929-9.346-16.06 9.346-16.06h6.68L26.936 5Z" fill="#446CB3"/><path d="m18.982 18.579 2.296-3.929-3.34-5.698-2.296 3.952 3.34 5.675Zm13.08-9.627 2.296 3.952-16.42 28.168-2.296-3.952 16.42-28.168Z" fill="#D24D57"/><path d="m23.087 45 2.273-3.952h6.702l9.346-16.037-9.346-16.06L34.358 5 46 25.012 34.358 45H23.087Z" fill="#446CB3"/><path d="m30.972 31.352-2.296 3.93 3.386 5.766 2.296-3.883-3.386-5.813Z" fill="#D24D57"/></svg>

After

Width:  |  Height:  |  Size: 560 B

+6
View File
@@ -0,0 +1,6 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import common
from . import test_payment_provider
from . import test_payment_transaction
from . import test_processing_flows
+34
View File
@@ -0,0 +1,34 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.addons.payment.tests.common import PaymentCommon
class XenditCommon(PaymentCommon):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.xendit = cls._prepare_provider('xendit', update_values={
'xendit_secret_key': 'xnd_secret_key',
'xendit_webhook_token': 'xnd_webhook_token',
})
cls.provider = cls.xendit
cls.webhook_notification_data = {
'amount': 1740,
'status': 'PAID',
'created': '2023-07-12T09:31:13.111Z',
'paid_at': '2023-07-12T09:31:22.830Z',
'updated': '2023-07-12T09:31:23.577Z',
'user_id': '64118d86854d7d89206e732d',
'currency': 'IDR',
'bank_code': 'BNI',
'description': cls.reference,
'external_id': cls.reference,
'paid_amount': 1740,
'merchant_name': 'Odoo',
'initial_amount': 1740,
'payment_method': 'BANK_TRANSFER',
'payment_channel': 'BNI',
'payment_destination': '880891384013',
}
@@ -0,0 +1,16 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.tests import tagged
from odoo.addons.payment_xendit.tests.common import XenditCommon
@tagged('post_install', '-at_install')
class TestPaymentProvider(XenditCommon):
def test_incompatible_with_unsupported_currencies(self):
""" Test that Xendit providers are filtered out from compatible providers when the currency
is not supported. """
compatible_providers = self.env['payment.provider']._get_compatible_providers(
self.company_id, self.partner.id, self.amount, currency_id=self.env.ref('base.AFN').id
)
self.assertNotIn(self.xendit, compatible_providers)
@@ -0,0 +1,69 @@
# 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.tests.http_common import PaymentHttpCommon
from odoo.addons.payment_xendit.tests.common import XenditCommon
@tagged('post_install', '-at_install')
class TestPaymentTransaction(PaymentHttpCommon, XenditCommon):
def test_no_item_missing_from_invoice_request_payload(self):
""" Test that the invoice request values are conform to the transaction fields. """
self.maxDiff = 10000 # Allow comparing large dicts.
tx = self._create_transaction(flow='redirect')
request_payload = tx._xendit_prepare_invoice_request_payload()
return_url = self._build_url('/payment/status')
self.assertDictEqual(request_payload, {
'external_id': tx.reference,
'amount': tx.amount,
'description': tx.reference,
'customer': {
'given_names': tx.partner_name,
'email': tx.partner_email,
'mobile_number': tx.partner_id.phone,
'addresses': [{
'city': tx.partner_city,
'country': tx.partner_country_id.name,
'postal_code': tx.partner_zip,
'street_line1': tx.partner_address,
}],
},
'success_redirect_url': return_url,
'failure_redirect_url': return_url,
'payment_methods': [self.payment_method_code.upper()],
'currency': tx.currency_id.name,
})
@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('redirect')
with patch(
'odoo.addons.payment_xendit.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_get_tx_from_notification_data_returns_tx(self):
""" Test that the transaction is found based on the notification data. """
tx = self._create_transaction('redirect')
tx_found = self.env['payment.transaction']._get_tx_from_notification_data(
'xendit', self.webhook_notification_data
)
self.assertEqual(tx, tx_found)
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('redirect')
tx._process_notification_data(self.webhook_notification_data)
self.assertEqual(tx.state, 'done')
@@ -0,0 +1,75 @@
# 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_xendit.controllers.main import XenditController
from odoo.addons.payment_xendit.tests.common import XenditCommon
@tagged('post_install', '-at_install')
class TestProcessingFlow(XenditCommon, PaymentHttpCommon):
@mute_logger('odoo.addons.payment_xendit.controllers.main')
def test_webhook_notification_triggers_processing(self):
""" Test that receiving a valid webhook notification and signature verified triggers the
processing of the notification data. """
self._create_transaction('redirect')
url = self._build_url(XenditController._webhook_url)
with patch(
'odoo.addons.payment_xendit.controllers.main.XenditController'
'._verify_notification_token'
), 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_xendit.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(XenditController._webhook_url)
with patch(
'odoo.addons.payment_xendit.controllers.main.XenditController.'
'_verify_notification_token'
) as signature_check_mock:
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,
XenditController._verify_notification_token,
XenditController,
self.provider.xendit_webhook_token,
tx,
)
@mute_logger('odoo.addons.payment_xendit.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,
XenditController._verify_notification_token,
XenditController,
None,
tx,
)
@mute_logger('odoo.addons.payment_xendit.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, XenditController._verify_notification_token, XenditController, 'dummy', tx
)
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="payment_provider_form_xendit" model="ir.ui.view">
<field name="name">Xendit Provider Form</field>
<field name="model">payment.provider</field>
<field name="inherit_id" ref="payment.payment_provider_form"/>
<field name="arch" type="xml">
<group name="provider_credentials" position="inside">
<group invisible="code != 'xendit'">
<field name="xendit_secret_key"
string="Secret Key"
required="code == 'xendit' and state != 'disabled'"
password="True"
/>
<field name="xendit_webhook_token"
string="Webhook Token"
required="code == 'xendit' and state != 'disabled'"
password="True"
/>
</group>
</group>
</field>
</record>
</odoo>
@@ -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>