diff --git a/odoo-bringout-oca-ocb-payment_toss_payments/payment_toss_payments/README.md b/odoo-bringout-oca-ocb-payment_toss_payments/payment_toss_payments/README.md new file mode 100644 index 00000000..b43e9deb --- /dev/null +++ b/odoo-bringout-oca-ocb-payment_toss_payments/payment_toss_payments/README.md @@ -0,0 +1,67 @@ +# Toss Payments + +## Technical details + +SDK: Toss Payments JavaScript SDK (Version 2) + +APIs (Version 2024-06-01): + +- [Payment APIs](https://docs.tosspayments.com/en/api-guide) +- [Webhook events](https://docs.tosspayments.com/en/webhooks) + +This module relies on the Toss Payments JavaScript SDK, loaded from +`https://js.tosspayments.com/v2/standard`, to open the Toss-hosted payment window and collect the +payment details in a secure way. + +For the front-end, the `PaymentForm` component is patched to: + +- Force the **direct** payment flow when a Toss Payments option is selected. +- Load the Toss Payments SDK dynamically. +- Open the Toss payment window in a modal with the transaction data (amount, order reference, + customer information, and success/failure return URLs). + +On the backend, after the customer completes the payment on Toss Payments and is redirected back to +Odoo on the success URL, a server-to-server API call is made to the +`/v1/payments/confirm` endpoint to confirm the payment. The response is then processed to update the +transaction state and store the `paymentKey` and `secret` fields returned by Toss Payments. + +Webhook notifications are used to keep the transaction state in sync with Toss Payments: + +- The webhook endpoint receives `PAYMENT_STATUS_CHANGED` events. +- The payload is matched to an Odoo transaction by reference (`orderId`). +- A signature-like check is performed by comparing the `secret` in the event with the one + stored on the transaction when the payment was confirmed. +- If the verification passes, the transaction is processed and its state updated according to the + Toss Payments status. + +## Supported features + +- Direct payment flow using the Toss-hosted payment window +- Webhook notifications for payment status changes +- Basic Authentication with secret keys for API calls +- Support for the following payment methods (via default payment method codes): + - Card + - Bank transfer + - Mobile phone payments +- Single-currency support for `KRW` + +## Not implemented features + +- Tokenization or saving payment methods +- Refunds initiated from Odoo +- Express checkout +- Less common payment methods: virtual account, gift certificates, and overseas payment + +## Testing instructions + +**Checklist** +- Change company location to South Korea and currency to KRW. +- Activate payment provider and payment methods. Client key and secret key are available at + credentials page. +- Register 'PAYMENT_STATUS_CHANGE' webhook on Toss Payments developer portal. If you're testing + locally, you need to setup tools like [ngrok](https://ngrok.com/) to expose the local server. + +**Procedure** +1. Confirm an invoice and generate payment link (the gear icon). +2. Open the payment link in incognito browser. +3. Test different payment methods. diff --git a/odoo-bringout-oca-ocb-payment_toss_payments/payment_toss_payments/__init__.py b/odoo-bringout-oca-ocb-payment_toss_payments/payment_toss_payments/__init__.py new file mode 100644 index 00000000..5f685a76 --- /dev/null +++ b/odoo-bringout-oca-ocb-payment_toss_payments/payment_toss_payments/__init__.py @@ -0,0 +1,12 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import controllers, models +from odoo.addons.payment import reset_payment_provider, setup_provider + + +def post_init_hook(env): + setup_provider(env, 'toss_payments') + + +def uninstall_hook(env): + reset_payment_provider(env, 'toss_payments') diff --git a/odoo-bringout-oca-ocb-payment_toss_payments/payment_toss_payments/__manifest__.py b/odoo-bringout-oca-ocb-payment_toss_payments/payment_toss_payments/__manifest__.py new file mode 100644 index 00000000..44514495 --- /dev/null +++ b/odoo-bringout-oca-ocb-payment_toss_payments/payment_toss_payments/__manifest__.py @@ -0,0 +1,20 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +{ + 'name': "Payment Provider: Toss Payments", + 'category': 'Accounting/Payment Providers', + 'sequence': 350, + 'summary': "A payment provider covering the South Korea market", + 'description': " ", # Non-empty string to avoid loading the README file. + 'depends': ['payment'], + 'data': [ + 'data/payment_provider_data.xml', + 'views/payment_provider_views.xml', + 'views/payment_toss_payments_templates.xml', + ], + 'post_init_hook': 'post_init_hook', + 'uninstall_hook': 'uninstall_hook', + 'assets': {'web.assets_frontend': ['payment_toss_payments/static/src/**/*']}, + 'author': 'Odoo S.A.', + 'license': 'LGPL-3', +} diff --git a/odoo-bringout-oca-ocb-payment_toss_payments/payment_toss_payments/const.py b/odoo-bringout-oca-ocb-payment_toss_payments/payment_toss_payments/const.py new file mode 100644 index 00000000..1566a3d5 --- /dev/null +++ b/odoo-bringout-oca-ocb-payment_toss_payments/payment_toss_payments/const.py @@ -0,0 +1,33 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.addons.payment.const import SENSITIVE_KEYS as PAYMENT_SENSITIVE_KEYS + +SENSITIVE_KEYS = {'secret'} +PAYMENT_SENSITIVE_KEYS.update(SENSITIVE_KEYS) + +PAYMENT_SUCCESS_RETURN_ROUTE = '/payment/toss-payments/success' +PAYMENT_FAILURE_RETURN_ROUTE = '/payment/toss-payments/failure' +WEBHOOK_ROUTE = '/payment/toss-payments/webhook' + +# The currency supported by Toss Payments, in ISO 4217 format. +SUPPORTED_CURRENCY = 'KRW' + +# The codes of the payment methods to activate when Toss Payments is activated. +DEFAULT_PAYMENT_METHOD_CODES = { + # Primary payment methods. + 'bank_transfer', + 'card', + 'mobile', +} + +# Mapping of payment method codes to Toss Payments codes. +PAYMENT_METHODS_MAPPING = {'card': 'CARD', 'bank_transfer': 'TRANSFER', 'mobile': 'MOBILE_PHONE'} + +# Mapping of transaction states to Toss Payments' payment statuses. +PAYMENT_STATUS_MAPPING = {'done': 'DONE', 'canceled': 'EXPIRED', 'error': 'ABORTED'} + +# Event statuses to skip secret key verification +VERIFICATION_EXEMPT_STATUSES = {'EXPIRED', 'ABORTED'} + +# Events that are handled by the webhook. +HANDLED_WEBHOOK_EVENTS = {'PAYMENT_STATUS_CHANGED'} diff --git a/odoo-bringout-oca-ocb-payment_toss_payments/payment_toss_payments/controllers/__init__.py b/odoo-bringout-oca-ocb-payment_toss_payments/payment_toss_payments/controllers/__init__.py new file mode 100644 index 00000000..12a7e529 --- /dev/null +++ b/odoo-bringout-oca-ocb-payment_toss_payments/payment_toss_payments/controllers/__init__.py @@ -0,0 +1 @@ +from . import main diff --git a/odoo-bringout-oca-ocb-payment_toss_payments/payment_toss_payments/controllers/main.py b/odoo-bringout-oca-ocb-payment_toss_payments/payment_toss_payments/controllers/main.py new file mode 100644 index 00000000..01be457a --- /dev/null +++ b/odoo-bringout-oca-ocb-payment_toss_payments/payment_toss_payments/controllers/main.py @@ -0,0 +1,119 @@ +import hmac +import pprint + +from werkzeug.exceptions import Forbidden + +from odoo import http +from odoo.exceptions import ValidationError +from odoo.http import request + +from odoo.addons.payment import utils as payment_utils +from odoo.addons.payment.logging import get_payment_logger +from odoo.addons.payment_toss_payments import const + +_logger = get_payment_logger(__name__, const.SENSITIVE_KEYS) + + +class TossPaymentsController(http.Controller): + @http.route(const.PAYMENT_SUCCESS_RETURN_ROUTE, type='http', auth='public', methods=['GET']) + def _toss_payments_success_return(self, **data): + """Process the payment data after redirection from successful payment. + + :param dict data: The payment data. Expected keys: orderId, paymentKey, amount. + """ + _logger.info("Handling redirection from Toss Payments with data:\n%s", pprint.pformat(data)) + tx_sudo = ( + request.env['payment.transaction'].sudo()._search_by_reference('toss_payments', data) + ) + if not tx_sudo: + return request.redirect('/payment/status') + + # Prevent tampering the amount from the client by validating it before sending the payment + # confirmation request. The payment data uses `totalAmount` to follow the format of the + # webhook data. + tx_sudo._validate_amount({'totalAmount': data.get('amount')}) + if tx_sudo.state != 'error': # The amount validation succeeded. + try: + payment_data = tx_sudo._send_api_request('POST', '/v1/payments/confirm', json=data) + except ValidationError as e: + tx_sudo._set_error(str(e)) + else: + tx_sudo._process('toss_payments', payment_data) + + return request.redirect('/payment/status') + + @http.route(const.PAYMENT_FAILURE_RETURN_ROUTE, type='http', auth='public', methods=['GET']) + def _toss_payments_failure_return(self, **data): + """Process the payment data after redirection from failed payment. + + Note: The access token is used to verify the request is coming from Toss Payments since we + don't have paymentKey in the failure return URL to verify the request via API call. + :param dict data: The payment data. Expected keys: access_token, code, message, orderId. + """ + _logger.info("Handling redirection from Toss Payments with data:\n%s", pprint.pformat(data)) + tx_sudo = ( + request.env['payment.transaction'].sudo()._search_by_reference('toss_payments', data) + ) + if not tx_sudo: + return request.redirect('/payment/status') + + access_token = data.get('access_token') + if not access_token or not payment_utils.check_access_token(access_token, tx_sudo.reference): + return request.redirect('/payment/status') + + tx_sudo._set_error(f"{data['message']} ({data['code']})") + + return request.redirect('/payment/status') + + @http.route(const.WEBHOOK_ROUTE, type='http', auth='public', methods=['POST'], csrf=False) + def _toss_payments_webhook(self): + """Process the event data sent to the webhook. + + See https://docs.tosspayments.com/reference/using-api/webhook-events#%EC%9D%B4%EB%B2%A4%ED%8A%B8-%EB%B3%B8%EB%AC%B8 + for the event message schema. + + :return: An empty string to acknowledge the notification. + :rtype: str + """ + event_data = request.get_json_data() + _logger.info( + "Webhook event received from Toss Payments with data:\n%s", pprint.pformat(event_data) + ) + event_type = event_data.get('eventType') + if event_type in const.HANDLED_WEBHOOK_EVENTS: + payment_data = event_data.get('data') + tx_sudo = ( + request.env['payment.transaction'] + .sudo() + ._search_by_reference('toss_payments', payment_data) + ) + if tx_sudo: + self._verify_signature(payment_data, tx_sudo) + tx_sudo._process('toss_payments', payment_data) + return request.make_json_response('') + + @staticmethod + def _verify_signature(payment_data, tx_sudo): + """Check that the received payment data's secret key matches the transaction's secret key. + + :param dict payment_data: The payment data. + :param payment.transaction tx_sudo: The sudoed transaction referenced by the payment data. + :rtype: None + :raise Forbidden: If the secret keys don't match. + """ + # Expired events might not have a secret if we never initiated the payment flow. Also + # aborted events have a secret, but our implementation would not capture the secret in the + # case of API call validation error (see `_toss_payments_success_return`). In these two + # cases, we skip the verification. + if payment_data.get('status') in const.VERIFICATION_EXEMPT_STATUSES: + return + + received_signature = payment_data.get('secret') + if not received_signature: + _logger.warning("Received notification with missing signature.") + raise Forbidden + + expected_signature = tx_sudo.toss_payments_payment_secret or '' + if not hmac.compare_digest(received_signature, expected_signature): + _logger.warning("Received notification with invalid signature.") + raise Forbidden diff --git a/odoo-bringout-oca-ocb-payment_toss_payments/payment_toss_payments/data/neutralize.sql b/odoo-bringout-oca-ocb-payment_toss_payments/payment_toss_payments/data/neutralize.sql new file mode 100644 index 00000000..eca45814 --- /dev/null +++ b/odoo-bringout-oca-ocb-payment_toss_payments/payment_toss_payments/data/neutralize.sql @@ -0,0 +1,3 @@ +UPDATE payment_provider + SET toss_payments_client_key = NULL, + toss_payments_secret_key = NULL; diff --git a/odoo-bringout-oca-ocb-payment_toss_payments/payment_toss_payments/data/payment_provider_data.xml b/odoo-bringout-oca-ocb-payment_toss_payments/payment_toss_payments/data/payment_provider_data.xml new file mode 100644 index 00000000..85115937 --- /dev/null +++ b/odoo-bringout-oca-ocb-payment_toss_payments/payment_toss_payments/data/payment_provider_data.xml @@ -0,0 +1,9 @@ + + + + + + toss_payments + + + diff --git a/odoo-bringout-oca-ocb-payment_toss_payments/payment_toss_payments/i18n/payment_toss_payments.pot b/odoo-bringout-oca-ocb-payment_toss_payments/payment_toss_payments/i18n/payment_toss_payments.pot new file mode 100644 index 00000000..a7db89a1 --- /dev/null +++ b/odoo-bringout-oca-ocb-payment_toss_payments/payment_toss_payments/i18n/payment_toss_payments.pot @@ -0,0 +1,112 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * payment_toss_payments +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 19.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-02-27 18:39+0000\n" +"PO-Revision-Date: 2026-02-27 18:39+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_toss_payments +#: model_terms:ir.ui.view,arch_db:payment_toss_payments.payment_provider_form +msgid "Client Key" +msgstr "" + +#. module: payment_toss_payments +#: model:ir.model.fields,field_description:payment_toss_payments.field_payment_provider__code +msgid "Code" +msgstr "" + +#. module: payment_toss_payments +#. odoo-python +#: code:addons/payment_toss_payments/models/payment_provider.py:0 +msgid "Currencies other than KRW are not supported." +msgstr "" + +#. module: payment_toss_payments +#: model:ir.model.fields,field_description:payment_toss_payments.field_payment_provider__display_name +#: model:ir.model.fields,field_description:payment_toss_payments.field_payment_transaction__display_name +msgid "Display Name" +msgstr "" + +#. module: payment_toss_payments +#: model:ir.model.fields,field_description:payment_toss_payments.field_payment_provider__id +#: model:ir.model.fields,field_description:payment_toss_payments.field_payment_transaction__id +msgid "ID" +msgstr "" + +#. module: payment_toss_payments +#: model:ir.model,name:payment_toss_payments.model_payment_provider +msgid "Payment Provider" +msgstr "" + +#. module: payment_toss_payments +#: model:ir.model,name:payment_toss_payments.model_payment_transaction +msgid "Payment Transaction" +msgstr "" + +#. module: payment_toss_payments +#. odoo-javascript +#: code:addons/payment_toss_payments/static/src/interactions/payment_form.js:0 +msgid "Payment not completed" +msgstr "" + +#. module: payment_toss_payments +#. odoo-javascript +#: code:addons/payment_toss_payments/static/src/interactions/payment_form.js:0 +msgid "Payment processing failed" +msgstr "" + +#. module: payment_toss_payments +#. odoo-python +#: code:addons/payment_toss_payments/models/payment_transaction.py:0 +msgid "Received data with invalid payment status: %s" +msgstr "" + +#. module: payment_toss_payments +#: model_terms:ir.ui.view,arch_db:payment_toss_payments.payment_provider_form +msgid "Secret Key" +msgstr "" + +#. module: payment_toss_payments +#: model:ir.model.fields,help:payment_toss_payments.field_payment_provider__code +msgid "The technical code of this payment provider." +msgstr "" + +#. module: payment_toss_payments +#: model:ir.model.fields.selection,name:payment_toss_payments.selection__payment_provider__code__toss_payments +msgid "Toss Payments" +msgstr "" + +#. module: payment_toss_payments +#: model:ir.model.fields,field_description:payment_toss_payments.field_payment_provider__toss_payments_client_key +msgid "Toss Payments Client Key" +msgstr "" + +#. module: payment_toss_payments +#: model:ir.model.fields,field_description:payment_toss_payments.field_payment_transaction__toss_payments_payment_secret +msgid "Toss Payments Payment Secret" +msgstr "" + +#. module: payment_toss_payments +#: model:ir.model.fields,field_description:payment_toss_payments.field_payment_provider__toss_payments_secret_key +msgid "Toss Payments Secret Key" +msgstr "" + +#. module: payment_toss_payments +#: model:ir.model.fields,field_description:payment_toss_payments.field_payment_provider__toss_payments_webhook_url +msgid "Toss Payments Webhook URL" +msgstr "" + +#. module: payment_toss_payments +#: model_terms:ir.ui.view,arch_db:payment_toss_payments.payment_provider_form +msgid "Webhook URL" +msgstr "" diff --git a/odoo-bringout-oca-ocb-payment_toss_payments/payment_toss_payments/models/__init__.py b/odoo-bringout-oca-ocb-payment_toss_payments/payment_toss_payments/models/__init__.py new file mode 100644 index 00000000..b04ff69c --- /dev/null +++ b/odoo-bringout-oca-ocb-payment_toss_payments/payment_toss_payments/models/__init__.py @@ -0,0 +1 @@ +from . import payment_provider, payment_transaction diff --git a/odoo-bringout-oca-ocb-payment_toss_payments/payment_toss_payments/models/payment_provider.py b/odoo-bringout-oca-ocb-payment_toss_payments/payment_toss_payments/models/payment_provider.py new file mode 100644 index 00000000..9c97cc01 --- /dev/null +++ b/odoo-bringout-oca-ocb-payment_toss_payments/payment_toss_payments/models/payment_provider.py @@ -0,0 +1,114 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import json + +from odoo import api, fields, models +from odoo.exceptions import ValidationError +from odoo.tools.urls import urljoin + +from odoo.addons.payment.logging import get_payment_logger +from odoo.addons.payment_toss_payments import const + +_logger = get_payment_logger(__name__, const.SENSITIVE_KEYS) + + +class PaymentProvider(models.Model): + _inherit = 'payment.provider' + + code = fields.Selection( + selection_add=[('toss_payments', "Toss Payments")], + ondelete={'toss_payments': 'set default'}, + ) + toss_payments_client_key = fields.Char( + string="Toss Payments Client Key", required_if_provider='toss_payments', copy=False + ) + toss_payments_secret_key = fields.Char( + string="Toss Payments Secret Key", + required_if_provider='toss_payments', + copy=False, + groups='base.group_system', + ) + toss_payments_webhook_url = fields.Char( + string="Toss Payments Webhook URL", + compute='_compute_toss_payments_webhook_url', + readonly=True, + ) + + # === COMPUTE METHODS === # + + def _get_supported_currencies(self): + """Override of `payment` to return the supported currencies.""" + supported_currencies = super()._get_supported_currencies() + if self.code == 'toss_payments': + supported_currencies = supported_currencies.filtered( + lambda c: c.name == const.SUPPORTED_CURRENCY + ) + return supported_currencies + + def _compute_toss_payments_webhook_url(self): + self.toss_payments_webhook_url = urljoin(self.get_base_url(), const.WEBHOOK_ROUTE) + + # ==== CONSTRAINT METHODS === # + + @api.constrains('available_currency_ids') + def _check_available_currency_ids_only_contains_supported_currencies(self): + for provider in self.filtered(lambda p: p.code == 'toss_payments'): + if provider.available_currency_ids != self.env.ref('base.KRW'): + raise ValidationError(self.env._("Currencies other than KRW are not supported.")) + + # === CRUD METHODS === # + + def _get_default_payment_method_codes(self): + """Override of `payment` to return the default payment method codes.""" + self.ensure_one() + if self.code != 'toss_payments': + return super()._get_default_payment_method_codes() + return const.DEFAULT_PAYMENT_METHOD_CODES + + # === BUSINESS METHODS === # + + def _toss_payments_get_inline_form_values(self, pm_code): + """Return a serialized JSON of the required values to initialize payment window. + + Note: `self.ensure_one()` + + :param str pm_code: The code of the payment method whose payment window is called. + :return: The JSON serial of the required values to initialize the payment window. + :rtype: str + """ + self.ensure_one() + + inline_form_values = { + 'client_key': self.toss_payments_client_key, + 'toss_payments_pm_code': const.PAYMENT_METHODS_MAPPING.get(pm_code, pm_code), + } + return json.dumps(inline_form_values) + + # === REQUEST HELPERS === # + + def _build_request_url(self, endpoint, **kwargs): + """Override of `payment` to build the request URL.""" + if self.code != 'toss_payments': + return super()._build_request_url(endpoint, **kwargs) + + return urljoin('https://api.tosspayments.com/', endpoint) + + def _build_request_headers(self, method, endpoint, payload, **kwargs): + """Override of `payment` to include the encoded secret key in the header.""" + if self.code != 'toss_payments': + return super()._build_request_headers(method, endpoint, payload, **kwargs) + + return {'Idempotency-Key': f'{payload.get("orderId")}:{payload.get("paymentKey")}'} + + def _build_request_auth(self, **kwargs): + """Override of `payment` to build the request Auth.""" + if self.code != 'toss_payments': + return super()._build_request_auth(**kwargs) + return self.toss_payments_secret_key, '' + + def _parse_response_error(self, response): + """Override of `payment` to parse the error message.""" + if self.code != 'toss_payments': + return super()._parse_response_error(response) + + return f'{response.json()["message"]} ({response.json()["code"]})' diff --git a/odoo-bringout-oca-ocb-payment_toss_payments/payment_toss_payments/models/payment_transaction.py b/odoo-bringout-oca-ocb-payment_toss_payments/payment_toss_payments/models/payment_transaction.py new file mode 100644 index 00000000..413bc627 --- /dev/null +++ b/odoo-bringout-oca-ocb-payment_toss_payments/payment_toss_payments/models/payment_transaction.py @@ -0,0 +1,101 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models +from odoo.tools.urls import urljoin + +from odoo.addons.payment import utils as payment_utils +from odoo.addons.payment.logging import get_payment_logger +from odoo.addons.payment_toss_payments import const + +_logger = get_payment_logger(__name__, const.SENSITIVE_KEYS) + + +class PaymentTransaction(models.Model): + _inherit = 'payment.transaction' + + toss_payments_payment_secret = fields.Char( + string="Toss Payments Payment Secret", groups='base.group_system' + ) + + @api.model + def _compute_reference(self, provider_code, prefix=None, separator='-', **kwargs): + """Override of `payment` to ensure that Toss Payments' requirements for references are + satisfied. + + Toss Payments' requirements for transaction are as follows: + - References can only be made of alphanumeric characters and/or '-' and '_'. + The prefix is generated with 'tx' as default. This prevents the prefix from being + generated based on document names that may contain non-allowed characters + (e.g., INV/2025/...). + + :param str provider_code: The code of the provider handling the transaction. + :param str prefix: The custom prefix used to compute the full reference. + :return: The unique reference for the transaction. + :rtype: str + """ + if provider_code == 'toss_payments': + prefix = payment_utils.singularize_reference_prefix() + return super()._compute_reference(provider_code, prefix=prefix, separator=separator, **kwargs) + + def _get_specific_processing_values(self, *args): + """Override of `payment` to return Toss Payments-specific processing values. + + Note: self.ensure_one() from `_get_processing_values` + + :return: The provider-specific processing values + :rtype: dict + """ + if self.provider_code != 'toss_payments': + return super()._get_specific_processing_values(*args) + + base_url = self.provider_id.get_base_url() + return { + 'order_name': self.reference, + 'partner_name': self.partner_name or "", + 'partner_email': self.partner_email or "", + 'partner_phone': self.partner_phone, + 'success_url': urljoin(base_url, const.PAYMENT_SUCCESS_RETURN_ROUTE), + 'fail_url': urljoin(base_url, f"{const.PAYMENT_FAILURE_RETURN_ROUTE}?access_token={payment_utils.generate_access_token(self.reference)}"), + } + + @api.model + def _extract_reference(self, provider_code, payment_data): + """Override of `payment` to extract the reference from the payment data.""" + if provider_code != 'toss_payments': + return super()._extract_reference(provider_code, payment_data) + + return payment_data['orderId'] + + def _extract_amount_data(self, payment_data): + """Override of `payment` to extract the amount from the payment data.""" + if self.provider_code != 'toss_payments': + return super()._extract_amount_data(payment_data) + + return { + 'amount': float(payment_data.get('totalAmount')), + 'currency_code': const.SUPPORTED_CURRENCY, + } + + def _apply_updates(self, payment_data): + """Override of `payment` to update the transaction based on the payment data.""" + if self.provider_code != 'toss_payments': + return super()._apply_updates(payment_data) + + # Update the provider reference. + self.provider_reference = payment_data['paymentKey'] + + # Save the secret key used for verifying webhook events. See `_verify_signature`. + self.toss_payments_payment_secret = payment_data.get('secret') + + # Update the payment state. + status = payment_data.get('status') + if status == const.PAYMENT_STATUS_MAPPING['done']: + self._set_done() + elif status == const.PAYMENT_STATUS_MAPPING['canceled']: + self._set_canceled() + elif status in ('CANCELED', 'PARTIAL_CANCELED') and self.state == 'done': + # Refunds are not implemented but webhook notifications are still sent on manual + # cancellation on the Toss Payments merchant dashboard. + pass + else: + self._set_error(self.env._("Received data with invalid payment status: %s", status)) diff --git a/odoo-bringout-oca-ocb-payment_toss_payments/payment_toss_payments/static/description/icon.png b/odoo-bringout-oca-ocb-payment_toss_payments/payment_toss_payments/static/description/icon.png new file mode 100644 index 00000000..564640c0 Binary files /dev/null and b/odoo-bringout-oca-ocb-payment_toss_payments/payment_toss_payments/static/description/icon.png differ diff --git a/odoo-bringout-oca-ocb-payment_toss_payments/payment_toss_payments/static/src/interactions/payment_form.js b/odoo-bringout-oca-ocb-payment_toss_payments/payment_toss_payments/static/src/interactions/payment_form.js new file mode 100644 index 00000000..3adf4dc1 --- /dev/null +++ b/odoo-bringout-oca-ocb-payment_toss_payments/payment_toss_payments/static/src/interactions/payment_form.js @@ -0,0 +1,124 @@ +/* global TossPayments */ + +import { loadJS } from '@web/core/assets'; +import { _t } from '@web/core/l10n/translation'; +import { patch } from '@web/core/utils/patch'; + +import { PaymentForm } from '@payment/interactions/payment_form'; + +patch(PaymentForm.prototype, { + + // === DOM MANIPULATION === + + /** + * Prepare the inline form of Toss Payments for direct payment. + * + * @override method from payment.payment_form + * @private + * @param {number} providerId - The id of the selected payment option's provider. + * @param {string} providerCode - The code of the selected payment option's provider. + * @param {number} paymentOptionId - The id of the selected payment option. + * @param {string} paymentMethodCode - The code of the selected payment method, if any. + * @param {string} flow - The online payment flow of the selected payment option. + * @return {void} + */ + async _prepareInlineForm(providerId, providerCode, paymentOptionId, paymentMethodCode, flow) { + if (providerCode !== 'toss_payments') { + await super._prepareInlineForm(...arguments); + return; + } + + // Overwrite the flow of the selected payment method. + this._setPaymentFlow('direct'); + }, + + // === PAYMENT FLOW === + + /** + * Process Toss Payments' implementation of the direct payment flow. + * + * @override method from payment.payment_form + * @private + * @param {string} providerCode - The code of the selected payment option's provider. + * @param {number} paymentOptionId - The id of the selected payment option. + * @param {string} paymentMethodCode - The code of the selected payment method, if any. + * @param {object} processingValues - The processing values of the transaction. + * @return {void} + */ + async _processDirectFlow(providerCode, paymentOptionId, paymentMethodCode, processingValues) { + if (providerCode !== 'toss_payments') { + await super._processDirectFlow(...arguments); + return; + } + + await this.waitFor(loadJS('https://js.tosspayments.com/v2/standard')); + + // Extract and deserialize the inline form values. + const radio = document.querySelector('input[name="o_payment_radio"]:checked'); + const inlineFormValues = JSON.parse(radio.dataset['tossPaymentsInlineFormValues']); + const clientKey = inlineFormValues.client_key; + const paymentMethod = inlineFormValues.toss_payments_pm_code; + + // Open the payment window in modal. + const paymentDetails = { + method: paymentMethod, + amount: { + currency: 'KRW', + value: processingValues.amount, + }, + orderId: processingValues.reference, + orderName: processingValues.order_name, + successUrl: processingValues.success_url, + failUrl: processingValues.fail_url, + customerName: processingValues.partner_name, + customerEmail: processingValues.partner_email, + }; + // Toss Payments SDK does not accept invalid phone numbers and will throw error. + // Note: customer name, email, and phone number are optional values that fills the optional + // data in the payment window. It is safer to pass nothing if in doubt. + const partnerPhoneSanitized = this.sanitizeKoreanPhoneNumber( + processingValues.partner_phone + ); + if (partnerPhoneSanitized) { + paymentDetails.customerMobilePhone = partnerPhoneSanitized; + } + const tossPayments = TossPayments(clientKey); + const payment = tossPayments.payment({ customerKey: TossPayments.ANONYMOUS }); + payment.requestPayment(paymentDetails).catch(error => { + this._enableButton(); + if (error.code === 'USER_CANCEL') { + this._displayErrorDialog(_t("Payment not completed"), error.message); + } + else { + this._displayErrorDialog(_t("Payment processing failed"), error.message); + } + }); + }, + + // === HELPERS === + + /** + * Sanitizes the phone number to matches the Toss Payments SDK requirements. + * @param {string} phoneNumber - The phone number to sanitize. + * @return {string} The sanitized phone number, or an empty string if the phone number is + * invalid. + */ + sanitizeKoreanPhoneNumber(phoneNumber) { + if (!phoneNumber) return ""; + + let sanitized = phoneNumber.replace(/[-\s]/g, ''); + if (sanitized.startsWith('+82')) { + sanitized = '0' + sanitized.substring(3); + } + + // - Mobile: 010, 011, 016, 017, 018, 019 followed by 7 or 8 digits. (Total 10 or 11 digits) + // - Landline: Seoul (02) followed by 7 or 8 digits. (Total 9 or 10 digits) + // - Other regions (031, 032, etc.) followed by 7 or 8 digits. (Total 10 or 11 digits) + const phoneRegex = /^(01[016789]{1}|02|0[3-9]{1}[0-9]{1})[0-9]{3,4}[0-9]{4}$/; + + if (phoneRegex.test(sanitized)) { + return sanitized; + } + return ''; + }, +}); diff --git a/odoo-bringout-oca-ocb-payment_toss_payments/payment_toss_payments/tests/__init__.py b/odoo-bringout-oca-ocb-payment_toss_payments/payment_toss_payments/tests/__init__.py new file mode 100644 index 00000000..35f35975 --- /dev/null +++ b/odoo-bringout-oca-ocb-payment_toss_payments/payment_toss_payments/tests/__init__.py @@ -0,0 +1 @@ +from . import common, test_payment_transaction, test_processing_flows diff --git a/odoo-bringout-oca-ocb-payment_toss_payments/payment_toss_payments/tests/common.py b/odoo-bringout-oca-ocb-payment_toss_payments/payment_toss_payments/tests/common.py new file mode 100644 index 00000000..b65635f1 --- /dev/null +++ b/odoo-bringout-oca-ocb-payment_toss_payments/payment_toss_payments/tests/common.py @@ -0,0 +1,38 @@ +from odoo.addons.payment.tests.common import PaymentCommon + + +class TossPaymentsCommon(PaymentCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.toss_payments = cls._prepare_provider( + 'toss_payments', + update_values={ + 'toss_payments_client_key': 'mock-client-key', + 'toss_payments_secret_key': 'mock-secret-key', + }, + ) + cls.provider = cls.toss_payments + + cls.amount = 750 + cls.currency_krw = cls._enable_currency('KRW') + cls.currency = cls.currency_krw + cls.payment_result_data = { + "orderId": cls.reference, + "paymentKey": "test-pk", + "secret": "test-secret", + "status": "DONE", + "currency": "KRW", + "totalAmount": 750, + } + cls.webhook_data = { + "eventType": "PAYMENT_STATUS_CHANGED", + "data": { + "orderId": cls.reference, + "paymentKey": "test-pk", + "secret": "test-secret", + "status": "DONE", + "currency": "KRW", + "totalAmount": 750, + }, + } diff --git a/odoo-bringout-oca-ocb-payment_toss_payments/payment_toss_payments/tests/test_payment_transaction.py b/odoo-bringout-oca-ocb-payment_toss_payments/payment_toss_payments/tests/test_payment_transaction.py new file mode 100644 index 00000000..47bf0cab --- /dev/null +++ b/odoo-bringout-oca-ocb-payment_toss_payments/payment_toss_payments/tests/test_payment_transaction.py @@ -0,0 +1,51 @@ +from odoo.tests import tagged + +from odoo.addons.payment_toss_payments.tests.common import TossPaymentsCommon + + +@tagged('post_install', '-at_install') +class TestPaymentTransaction(TossPaymentsCommon): + def test_reference_uses_only_alphanumeric_chars(self): + """The computed reference must be made of alphanumeric and symbols '-' and '_'.""" + reference = self.env['payment.transaction']._compute_reference('toss_payments') + self.assertRegex(reference, r'^[a-zA-Z0-9_-]+$') + + def test_reference_length_is_between_6_and_64_chars(self): + """The computed reference must be between 6 and 64 characters, both numbers inclusive.""" + reference = self.env['payment.transaction']._compute_reference('toss_payments') + self.assertTrue(6 <= len(reference) <= 64) + + def test_extract_reference_finds_reference(self): + """Test that the transaction reference is found in the payment data.""" + tx = self._create_transaction('direct') + reference = self.env['payment.transaction']._extract_reference( + 'toss_payments', self.payment_result_data + ) + self.assertEqual(tx.reference, reference) + + def test_extract_amount_data_returns_amount_and_currency(self): + """Test that the amount and currency are returned from the payment data.""" + tx = self._create_transaction('direct') + amount_data = tx._extract_amount_data(self.payment_result_data) + self.assertDictEqual( + amount_data, {'amount': self.amount, 'currency_code': self.currency_krw.name} + ) + + def test_apply_updates_sets_provider_reference(self): + """Test that the provider reference is set when processing the payment data.""" + tx = self._create_transaction('direct') + tx._apply_updates(self.payment_result_data) + self.assertEqual(tx.provider_reference, self.payment_result_data['paymentKey']) + + def test_apply_updates_sets_payment_secret(self): + """Test that the payment secret is set when processing the payment data.""" + tx = self._create_transaction('direct') + tx._apply_updates(self.payment_result_data) + self.assertEqual(tx.toss_payments_payment_secret, self.payment_result_data['secret']) + + def test_apply_updates_confirms_transaction(self): + """Test that the transaction state is set to 'done' when the payment data indicate a + successful payment.""" + tx = self._create_transaction('direct') + tx._apply_updates(self.payment_result_data) + self.assertEqual(tx.state, 'done') diff --git a/odoo-bringout-oca-ocb-payment_toss_payments/payment_toss_payments/tests/test_processing_flows.py b/odoo-bringout-oca-ocb-payment_toss_payments/payment_toss_payments/tests/test_processing_flows.py new file mode 100644 index 00000000..a0cb91a8 --- /dev/null +++ b/odoo-bringout-oca-ocb-payment_toss_payments/payment_toss_payments/tests/test_processing_flows.py @@ -0,0 +1,122 @@ +from unittest.mock import patch + +from werkzeug.exceptions import Forbidden + +from odoo.exceptions import ValidationError +from odoo.tests import tagged +from odoo.tools import mute_logger + +from odoo.addons.payment import utils as payment_utils +from odoo.addons.payment.tests.http_common import PaymentHttpCommon +from odoo.addons.payment_toss_payments import const +from odoo.addons.payment_toss_payments.controllers.main import TossPaymentsController +from odoo.addons.payment_toss_payments.tests.common import TossPaymentsCommon + + +@tagged('post_install', '-at_install') +class TestProcessingFlows(TossPaymentsCommon, PaymentHttpCommon): + @mute_logger('odoo.addons.payment_toss_payments.controllers.main') + def test_returning_from_successful_payment_initiation_triggers_processing(self): + """Test that successfully initiating a payment triggers the processing of the payment + data.""" + tx = self._create_transaction('direct') + redirect_success_params = {'orderId': tx.reference, 'paymentKey': 'test-pk', 'amount': 750} + url = self._build_url(const.PAYMENT_SUCCESS_RETURN_ROUTE) + with ( + patch( + 'odoo.addons.payment.models.payment_transaction.PaymentTransaction' + '._send_api_request' + ), + patch( + 'odoo.addons.payment.models.payment_transaction.PaymentTransaction._process' + ) as process_mock, + ): + self._make_http_get_request(url, params=redirect_success_params) + self.assertEqual(process_mock.call_count, 1) + + @mute_logger('odoo.addons.payment_toss_payments.controllers.main') + def test_failing_to_confirm_payment_sets_the_transaction_in_error(self): + """Test that the transaction is set in error if the payment confirmation request fails.""" + tx = self._create_transaction('direct') + redirect_success_params = {'orderId': tx.reference, 'paymentKey': "test-pk", 'amount': 750} + url = self._build_url(const.PAYMENT_SUCCESS_RETURN_ROUTE) + with patch( + 'odoo.addons.payment.models.payment_transaction.PaymentTransaction._send_api_request', + side_effect=ValidationError('dummy response'), + ): + self._make_http_get_request(url, params=redirect_success_params) + self.assertEqual(tx.state, 'error') + + @mute_logger('odoo.addons.payment_toss_payments.controllers.main') + def test_returning_from_failing_payment_initiation_sets_transaction_in_error(self): + """Test that failing to initiate a payment set the transaction in error.""" + tx = self._create_transaction('direct') + url = self._build_url(const.PAYMENT_FAILURE_RETURN_ROUTE) + # Generate DB secret to verify the request is coming from Toss Payments since we + # don't have paymentKey in the failure return URL to verify the request via API call. + access_token = payment_utils.generate_access_token(tx.reference, env=self.env) + error_data = { + 'code': 'ERR', + 'message': 'Payment refused', + 'orderId': tx.reference, + 'access_token': access_token, + } + self._make_http_get_request(url, params=error_data) + self.assertEqual(tx.state, 'error') + + @mute_logger('odoo.addons.payment_toss_payments.controllers.main') + def test_webhook_notification_triggers_processing(self): + """Test that receiving a valid webhook notification triggers the processing of the payment + data.""" + self._create_transaction('direct') + url = self._build_url(const.WEBHOOK_ROUTE) + with ( + patch( + 'odoo.addons.payment_toss_payments.controllers.main.TossPaymentsController' + '._verify_signature' + ), + patch( + 'odoo.addons.payment.models.payment_transaction.PaymentTransaction._process' + ) as process_mock, + ): + self._make_json_request(url, data=self.webhook_data) + self.assertEqual(process_mock.call_count, 1) + + @mute_logger('odoo.addons.payment_toss_payments.controllers.main') + def test_webhook_notification_triggers_signature_check(self): + """Test that receiving a webhook notification triggers a signature check.""" + self._create_transaction('direct') + url = self._build_url(const.WEBHOOK_ROUTE) + with ( + patch( + 'odoo.addons.payment_toss_payments.controllers.main.TossPaymentsController' + '._verify_signature' + ) as signature_check_mock, + patch('odoo.addons.payment.models.payment_transaction.PaymentTransaction._process'), + ): + self._make_json_request(url, data=self.webhook_data) + self.assertEqual(signature_check_mock.call_count, 1) + + def test_accept_payment_data_with_valid_signature(self): + """Test the verification of payment data with a valid signature.""" + tx = self._create_transaction('direct') + tx.write({'toss_payments_payment_secret': 'test-secret'}) + self._assert_does_not_raise( + Forbidden, TossPaymentsController._verify_signature, self.webhook_data['data'], tx + ) + + @mute_logger('odoo.addons.payment_toss_payments.controllers.main') + def test_reject_payment_data_with_missing_signature(self): + """Test the verification of payment data with a missing signature.""" + tx = self._create_transaction('direct') + tx.write({'toss_payments_payment_secret': 'test-secret'}) + webhook_data = dict(self.webhook_data['data'], status='DONE', secret=None) + self.assertRaises(Forbidden, TossPaymentsController._verify_signature, webhook_data, tx) + + @mute_logger('odoo.addons.payment_toss_payments.controllers.main') + def test_reject_payment_data_with_invalid_signature(self): + """Test the verification of payment data with an invalid signature.""" + tx = self._create_transaction('direct') + tx.write({'toss_payments_payment_secret': 'test-secret'}) + webhook_data = dict(self.webhook_data['data'], status='DONE', secret='dummy') + self.assertRaises(Forbidden, TossPaymentsController._verify_signature, webhook_data, tx) diff --git a/odoo-bringout-oca-ocb-payment_toss_payments/payment_toss_payments/views/payment_provider_views.xml b/odoo-bringout-oca-ocb-payment_toss_payments/payment_toss_payments/views/payment_provider_views.xml new file mode 100644 index 00000000..d9551cc3 --- /dev/null +++ b/odoo-bringout-oca-ocb-payment_toss_payments/payment_toss_payments/views/payment_provider_views.xml @@ -0,0 +1,33 @@ + + + + + + Toss Payments Provider Form + payment.provider + + + + + + + + + + + + + diff --git a/odoo-bringout-oca-ocb-payment_toss_payments/payment_toss_payments/views/payment_toss_payments_templates.xml b/odoo-bringout-oca-ocb-payment_toss_payments/payment_toss_payments/views/payment_toss_payments_templates.xml new file mode 100644 index 00000000..9a65f65d --- /dev/null +++ b/odoo-bringout-oca-ocb-payment_toss_payments/payment_toss_payments/views/payment_toss_payments_templates.xml @@ -0,0 +1,13 @@ + + + + + + +