diff --git a/.tx/config b/.tx/config index 0d7c1745f8d..ea2491f247e 100644 --- a/.tx/config +++ b/.tx/config @@ -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/.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/.po source_file = addons/phone_validation/i18n/phone_validation.pot diff --git a/addons/payment/data/payment_method_data.xml b/addons/payment/data/payment_method_data.xml index 7610bec608b..ffc9817a4a4 100644 --- a/addons/payment/data/payment_method_data.xml +++ b/addons/payment/data/payment_method_data.xml @@ -3,6 +3,26 @@ + + 7Eleven + 7eleven + 1000 + False + False + False + partial + + + + ACH Direct Debit ach_direct_debit @@ -396,6 +416,27 @@ + + BCA + bank_bca + 1000 + False + + False + False + + + + + Bank of Ayudhya bank_of_ayudhya @@ -417,6 +458,27 @@ /> + + Bank Permata + bank_permata + 1000 + False + + False + False + + + + + Bank reference bank_reference @@ -619,7 +681,7 @@ bni 1000 False - + False False @@ -719,12 +781,33 @@ /> + + BRI + bri + 1000 + False + + False + False + + + + + Bank Syariah Indonesia bsi 1000 False - + False False @@ -772,6 +855,46 @@ /> + + Cashalo + cashalo + 1000 + False + False + False + partial + + + + + + Cebuana + cebuana + 1000 + False + False + False + partial + + + + Clearpay clearpay @@ -1121,12 +1244,14 @@ eval="[Command.set([ ref('base.my'), ref('base.sg'), + ref('base.ph'), ])]" /> @@ -1269,6 +1394,27 @@ /> + + JeniusPay + jeniuspay + 1000 + False + + False + False + partial + + + + Jkopay jkopay @@ -2782,6 +2928,29 @@ /> + + ShopeePay + shopeepay + 1000 + False + + False + False + partial + + + + Sofort sofort diff --git a/addons/payment/data/payment_provider_data.xml b/addons/payment/data/payment_provider_data.xml index 4d128dd3444..430daaf70ce 100644 --- a/addons/payment/data/payment_provider_data.xml +++ b/addons/payment/data/payment_provider_data.xml @@ -391,10 +391,44 @@ Wire Transfer 30 - + + + Xendit + + + + + + diff --git a/addons/payment/static/img/bank_bca.png b/addons/payment/static/img/bank_bca.png new file mode 100644 index 00000000000..a3b0ce34367 Binary files /dev/null and b/addons/payment/static/img/bank_bca.png differ diff --git a/addons/payment/static/img/bank_permata.png b/addons/payment/static/img/bank_permata.png new file mode 100644 index 00000000000..07a1a1c6297 Binary files /dev/null and b/addons/payment/static/img/bank_permata.png differ diff --git a/addons/payment/static/img/bni.png b/addons/payment/static/img/bni.png new file mode 100644 index 00000000000..c26a93b8c7d Binary files /dev/null and b/addons/payment/static/img/bni.png differ diff --git a/addons/payment/static/img/bri.png b/addons/payment/static/img/bri.png new file mode 100644 index 00000000000..6a6afc14d69 Binary files /dev/null and b/addons/payment/static/img/bri.png differ diff --git a/addons/payment/static/img/bsi.png b/addons/payment/static/img/bsi.png new file mode 100644 index 00000000000..d7f215c3de1 Binary files /dev/null and b/addons/payment/static/img/bsi.png differ diff --git a/addons/payment/static/img/jeniuspay.png b/addons/payment/static/img/jeniuspay.png new file mode 100644 index 00000000000..d3977a4f2dc Binary files /dev/null and b/addons/payment/static/img/jeniuspay.png differ diff --git a/addons/payment/static/img/shopeepay.png b/addons/payment/static/img/shopeepay.png new file mode 100644 index 00000000000..0e4c6bd5e05 Binary files /dev/null and b/addons/payment/static/img/shopeepay.png differ diff --git a/addons/payment_xendit/README.md b/addons/payment_xendit/README.md new file mode 100644 index 00000000000..7ce60571f04 --- /dev/null +++ b/addons/payment_xendit/README.md @@ -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 \ No newline at end of file diff --git a/addons/payment_xendit/__init__.py b/addons/payment_xendit/__init__.py new file mode 100644 index 00000000000..9fa28e8543f --- /dev/null +++ b/addons/payment_xendit/__init__.py @@ -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') diff --git a/addons/payment_xendit/__manifest__.py b/addons/payment_xendit/__manifest__.py new file mode 100644 index 00000000000..b7b259560ce --- /dev/null +++ b/addons/payment_xendit/__manifest__.py @@ -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', +} diff --git a/addons/payment_xendit/const.py b/addons/payment_xendit/const.py new file mode 100644 index 00000000000..db7446b8197 --- /dev/null +++ b/addons/payment_xendit/const.py @@ -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',) +} diff --git a/addons/payment_xendit/controllers/__init__.py b/addons/payment_xendit/controllers/__init__.py new file mode 100644 index 00000000000..80ee4da1c5e --- /dev/null +++ b/addons/payment_xendit/controllers/__init__.py @@ -0,0 +1,3 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import main diff --git a/addons/payment_xendit/controllers/main.py b/addons/payment_xendit/controllers/main.py new file mode 100644 index 00000000000..a45d8612b62 --- /dev/null +++ b/addons/payment_xendit/controllers/main.py @@ -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() diff --git a/addons/payment_xendit/data/payment_provider_data.xml b/addons/payment_xendit/data/payment_provider_data.xml new file mode 100644 index 00000000000..44286ed3b71 --- /dev/null +++ b/addons/payment_xendit/data/payment_provider_data.xml @@ -0,0 +1,9 @@ + + + + + xendit + + + + diff --git a/addons/payment_xendit/i18n/payment_xendit.pot b/addons/payment_xendit/i18n/payment_xendit.pot new file mode 100644 index 00000000000..9200f11d669 --- /dev/null +++ b/addons/payment_xendit/i18n/payment_xendit.pot @@ -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 "" diff --git a/addons/payment_xendit/models/__init__.py b/addons/payment_xendit/models/__init__.py new file mode 100644 index 00000000000..08dfb8af342 --- /dev/null +++ b/addons/payment_xendit/models/__init__.py @@ -0,0 +1,4 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import payment_provider +from . import payment_transaction diff --git a/addons/payment_xendit/models/payment_provider.py b/addons/payment_xendit/models/payment_provider.py new file mode 100644 index 00000000000..a5d9f226753 --- /dev/null +++ b/addons/payment_xendit/models/payment_provider.py @@ -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() diff --git a/addons/payment_xendit/models/payment_transaction.py b/addons/payment_xendit/models/payment_transaction.py new file mode 100644 index 00000000000..dc3ad40a586 --- /dev/null +++ b/addons/payment_xendit/models/payment_transaction.py @@ -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." + )) diff --git a/addons/payment_xendit/static/description/icon.png b/addons/payment_xendit/static/description/icon.png new file mode 100644 index 00000000000..199f8492659 Binary files /dev/null and b/addons/payment_xendit/static/description/icon.png differ diff --git a/addons/payment_xendit/static/description/icon.svg b/addons/payment_xendit/static/description/icon.svg new file mode 100644 index 00000000000..2aedb238e73 --- /dev/null +++ b/addons/payment_xendit/static/description/icon.svg @@ -0,0 +1 @@ + diff --git a/addons/payment_xendit/tests/__init__.py b/addons/payment_xendit/tests/__init__.py new file mode 100644 index 00000000000..4a36db00650 --- /dev/null +++ b/addons/payment_xendit/tests/__init__.py @@ -0,0 +1,6 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import common +from . import test_payment_provider +from . import test_payment_transaction +from . import test_processing_flows diff --git a/addons/payment_xendit/tests/common.py b/addons/payment_xendit/tests/common.py new file mode 100644 index 00000000000..244153052b0 --- /dev/null +++ b/addons/payment_xendit/tests/common.py @@ -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', + } diff --git a/addons/payment_xendit/tests/test_payment_provider.py b/addons/payment_xendit/tests/test_payment_provider.py new file mode 100644 index 00000000000..a078715618f --- /dev/null +++ b/addons/payment_xendit/tests/test_payment_provider.py @@ -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) diff --git a/addons/payment_xendit/tests/test_payment_transaction.py b/addons/payment_xendit/tests/test_payment_transaction.py new file mode 100644 index 00000000000..62d6617bc9d --- /dev/null +++ b/addons/payment_xendit/tests/test_payment_transaction.py @@ -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') diff --git a/addons/payment_xendit/tests/test_processing_flows.py b/addons/payment_xendit/tests/test_processing_flows.py new file mode 100644 index 00000000000..f535ba5c518 --- /dev/null +++ b/addons/payment_xendit/tests/test_processing_flows.py @@ -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 + ) diff --git a/addons/payment_xendit/views/payment_provider_views.xml b/addons/payment_xendit/views/payment_provider_views.xml new file mode 100644 index 00000000000..3500d362b7a --- /dev/null +++ b/addons/payment_xendit/views/payment_provider_views.xml @@ -0,0 +1,26 @@ + + + + + Xendit Provider Form + payment.provider + + + + + + + + + + + + diff --git a/addons/payment_xendit/views/payment_xendit_templates.xml b/addons/payment_xendit/views/payment_xendit_templates.xml new file mode 100644 index 00000000000..096123751d1 --- /dev/null +++ b/addons/payment_xendit/views/payment_xendit_templates.xml @@ -0,0 +1,8 @@ + + + + + +