[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>
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
After Width: | Height: | Size: 8.1 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
@@ -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
|
||||
@@ -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')
|
||||
@@ -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',
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 ""
|
||||
@@ -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."
|
||||
))
|
||||
|
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 |
@@ -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
|
||||
@@ -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>
|
||||