From d53e4f21f301a36b9c4c68946e81ab83c5a490ff Mon Sep 17 00:00:00 2001 From: Ernad Husremovic Date: Mon, 9 Mar 2026 15:51:21 +0100 Subject: [PATCH] add payment_toss_payments from vanilla OCA/OCB 19.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Required by payment/data/payment_provider_data.xml which references icon files from all payment provider modules. 🤖 assisted by claude --- .../payment_toss_payments/README.md | 67 ++++++++++ .../payment_toss_payments/__init__.py | 12 ++ .../payment_toss_payments/__manifest__.py | 20 +++ .../payment_toss_payments/const.py | 33 +++++ .../controllers/__init__.py | 1 + .../payment_toss_payments/controllers/main.py | 119 +++++++++++++++++ .../payment_toss_payments/data/neutralize.sql | 3 + .../data/payment_provider_data.xml | 9 ++ .../i18n/payment_toss_payments.pot | 112 ++++++++++++++++ .../payment_toss_payments/models/__init__.py | 1 + .../models/payment_provider.py | 114 ++++++++++++++++ .../models/payment_transaction.py | 101 ++++++++++++++ .../static/description/icon.png | Bin 0 -> 15320 bytes .../static/src/interactions/payment_form.js | 124 ++++++++++++++++++ .../payment_toss_payments/tests/__init__.py | 1 + .../payment_toss_payments/tests/common.py | 38 ++++++ .../tests/test_payment_transaction.py | 51 +++++++ .../tests/test_processing_flows.py | 122 +++++++++++++++++ .../views/payment_provider_views.xml | 33 +++++ .../views/payment_toss_payments_templates.xml | 13 ++ 20 files changed, 974 insertions(+) create mode 100644 odoo-bringout-oca-ocb-payment_toss_payments/payment_toss_payments/README.md create mode 100644 odoo-bringout-oca-ocb-payment_toss_payments/payment_toss_payments/__init__.py create mode 100644 odoo-bringout-oca-ocb-payment_toss_payments/payment_toss_payments/__manifest__.py create mode 100644 odoo-bringout-oca-ocb-payment_toss_payments/payment_toss_payments/const.py create mode 100644 odoo-bringout-oca-ocb-payment_toss_payments/payment_toss_payments/controllers/__init__.py create mode 100644 odoo-bringout-oca-ocb-payment_toss_payments/payment_toss_payments/controllers/main.py create mode 100644 odoo-bringout-oca-ocb-payment_toss_payments/payment_toss_payments/data/neutralize.sql create mode 100644 odoo-bringout-oca-ocb-payment_toss_payments/payment_toss_payments/data/payment_provider_data.xml create mode 100644 odoo-bringout-oca-ocb-payment_toss_payments/payment_toss_payments/i18n/payment_toss_payments.pot create mode 100644 odoo-bringout-oca-ocb-payment_toss_payments/payment_toss_payments/models/__init__.py create mode 100644 odoo-bringout-oca-ocb-payment_toss_payments/payment_toss_payments/models/payment_provider.py create mode 100644 odoo-bringout-oca-ocb-payment_toss_payments/payment_toss_payments/models/payment_transaction.py create mode 100644 odoo-bringout-oca-ocb-payment_toss_payments/payment_toss_payments/static/description/icon.png create mode 100644 odoo-bringout-oca-ocb-payment_toss_payments/payment_toss_payments/static/src/interactions/payment_form.js create mode 100644 odoo-bringout-oca-ocb-payment_toss_payments/payment_toss_payments/tests/__init__.py create mode 100644 odoo-bringout-oca-ocb-payment_toss_payments/payment_toss_payments/tests/common.py create mode 100644 odoo-bringout-oca-ocb-payment_toss_payments/payment_toss_payments/tests/test_payment_transaction.py create mode 100644 odoo-bringout-oca-ocb-payment_toss_payments/payment_toss_payments/tests/test_processing_flows.py create mode 100644 odoo-bringout-oca-ocb-payment_toss_payments/payment_toss_payments/views/payment_provider_views.xml create mode 100644 odoo-bringout-oca-ocb-payment_toss_payments/payment_toss_payments/views/payment_toss_payments_templates.xml 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 0000000000000000000000000000000000000000..564640c041fb0628a4186f25b6d5df6f7eb393ea GIT binary patch literal 15320 zcmeAS@N?(olHy`uVBq!ia0y~yU}ykg4mJh`hQoG=rx_TyS*t=KN`ey06$*;-(=u~X z6-p`#QWa7wGSe6sDsJ`8$lj*1lDFmmbFDor4hlz#7!F$AYn=YxKl1yw-PLmQ*368K z{K=r`+R{{auKr)`{Q5!>)9S3(%bZOMuLQc!oD=a&=JDUz_uv27`K7k|{{EQyefdv5 zz3zSg-ll$U-2S)|ueUeu%NO~byWrE$Ni!Ng$Jp=x{YT1U$FI*0_tmY|EdO;`=-l($ z-@i8q{WqUkQ~odQrRvf6bKO_k^(RV1?w@o?`QQ5KkFP4FBtMzIe!rpDoHO$0?c?^RbBd7``F*JpV;qD|Nme0f9y5?ojRY3zZLwP zF8#G`;q%u!uD`wa*)AYGbi=ZEjbCwp(iP-l_Q-W-=q%kQ|L{-owR`=OF8Q+ky8e9G z(ex#Ai+An(aFSoW(Bs;k;^(`c&AeTGc1Ql7BD?Q@J>D&y66IyL^wXb*w|=&Ky5jLF z{ocQGH8XBrdbsdZg6aJFZ5c)s?g`rL1SH@*3@vSZ2lLtlOCe=nW)v(;rvdyiUekopw!)P+VG2i9?O8KLwp2YdZ zf1Vc3PkqaLa=-l+Z`Vn$KN&yYbN!Z5DD%vH&sTl>xZ}3Y&GRq!-YXJe&8gFydAD2J z$|imNx=G>JQ_GG#xBI;=^S6+CRr%%IzZa|AB};{C<|oU&oHgx>Va%SZ@2b4~+s*SHi%RESi_ZSN z#X0Hr+YR}dxBuEr;t0B=arfA~YzeQ#ULg~CyVq}amVegXm$u#RPPBFLQ`6%St*wV& z6#m+)krHOP+jr;GpR-TK&bhbk)aeASnSI|ye-=zK&dX1kRH$vV$>V3r#*_1upB?MZ z;VRSK^=tBQ)> zF7y{vGHuyrKGpBYyy6G>!UQw;lX*fGtmk3Y2A}6F+vZubbJx+OyB2RU((9kMf&bl?`$EJH21W^?$x+_wt3)V!wBf8{V27&zlv#``?w_k!D|01s@!mG5zD|O-yl9 zh58pH&P#ipeOFPx)b=go z^RSY(yboHl&eva>*{pCt*Ji8HQrUNt?{18G{OjKO{YJ~U7wKhub$IP8TIQO1ZO>jN zW`pbOGFi1}R|bgM-BW7sykKjvTd)7C@4JOdq-=8elZs6(h3{uB|LNq)>biMnb@vp8 zOU^eG&lD>+-gvMk-K#<6(mAJ7VYLj`bW&D6ylIuCktp}xZI(xNoW|X#Q|(v3uDxBO zU#fLx8iE4}u*nsGC7s#wXZJCo13_f1OIVtc=U`~ChK_A_jJSTf!oIc|M|b5))6 z;n)H{ZO-B&w)gDrILk-B!t`Ek<+7O7!Ibo%OW<;rLS$=Z*SSO&g$t>B6sPq+i>iI{ zE`#sJ&6yw9Ep;<~{W9~Oi{{rE)6>EqDg_@(eEIW??(VW6-{KDwwg&vMZr>&|RWhJ| zj;U9WkPx74x zUrS3}eU+N$CsG<;>daJV+48LHep_>aUCGhyW?$wxRv(MY=$xouldr=S-fS)SdTm~T zTM?79w&L{Q342>t9RK#2@tp%t<_ZVlXR|KqWytd8+vhsSTx!yF3b+{cOKTnL3f?xy ztQ}XUJTk8iNZ90;-e`Gr(O#bYCRd*bKU~_JV;=cq>e?SB?kg_Y>MZ}-=zm&J=uqY_ zhu~MKS2$QFu391aGcAeXoK>NVh5v;18|!##3Y2b0HJoPJ_L+UF^qMDv+|5jD775Cm zG_U^r=+%qTCB=)+TzDxbohZ+-WKp38i^jBm%hYNXy<=t-z zN=!RBrTtFmah*i2KuMPMzn5jtKYF3yEPL$z+kVdvnMQQQi8kn}Ph?wiGcavWhcd^4 zCRwNE*{R8ABG?ugas>Z&Q*7LH?nihhXM)-6rl!o@74z&KPjA_GaqYDwmCppKRa76F z`^lMD&k)aT+RO1*bZ%i)<7W=(7ISC@F1M;kWj0>AHVM7Bl~4^HywOqZI&|Ks_%wzE9bHO_dXosu$*OY zROIFNUP&%WWVb{`tamNXWZ7z&maiOhuicbdB=AN(so|3Orj##}XK4r~mdZ3-^#0>l z!*t^KPQibNHa>i{%K1W1#-5~WH!HUoIG112d-+&&=bqnB52s|W`pVDFd~xZ`xaUnb zS{b7yxW9R&NM;_~F>T(>Y0o>ydvZf*K*Fw{L(@SVsTBj zyf&vouOF~mY|By_IO_>#zw04`=NsJ*OWQe`ez4wPGpF@pZ+)V}k}wYbFei4s6Vj&f zKepdG{8jsvl)&-ZN6p%_KOgk=c&z(G%vwaK_J&=u#Z42QKPGR3eB#~n8-BD*S>L^! z>uEH%e%t4C{f`e$y?u~)bJc-^RhbKI(xtX}UU1hC`8KDeDW^u^rnSkHt&7t?H#ad@ zM4vyP=%${x#bMve*57?q*XVB^_c}%ymd+w=E_rC@A|2@oHef%7E`nPz~ z?PUd9^w{Q&?xFo1xKeMLcHa&~HoLQ-maW}dCm z``!DM6f#q6mBLMZ4SWlnQ!_F>s)|yBtNcQetFn_VQ&bX_Yl%Z!xlxD;%PQqrt~ zT-=~W6s4ruDrJI*XROY(~|@(bYF@`|A$mz7qd6O%LZK*6gC*X5g9l9-ZM zl89`JrLKW#h@p{{v7wc*0h%qo`6-!cl|-uc^|f-zPc8+;w5N-$QblfoUP@+)m7$@f zp-HNxnXb8+X|k?KlBK1trFlwR42JP0=@6Qo)}K>?g-tP+z! z8N^m8862G{V4uMyGb!;L+?3+{w370~qEyH9)VvZRV;`=$JR>y^UjoyI_{~Nil*M6Q zwb92242S}xGQo~Z0U{RU;%3KXqYo~aK!p^gobn zZTG94$M;)Rd~cS&_l@^V=PfS}ft)f%Js!@j;tLoSIHy=OFgC>3?Qijqx%Io6>!H2S ziPg$|@p2{~?iB1|3~DfN;Nn=;aPac#aE2Y6?%Sqa%E@G$Fm$BLzuUe>>LFW=_g zw0J$+dNpTr`@~7LN6uz{ozli}KvTiS;Z8RXCksPJ^N%l&eW#YNTQf4q=19I}-?oGy zBKLl=gi-#&S^2-#m>j;EF~8u0{w~iy##T9Y(oz#d3<6csCP)Z$Fip6$gkwpuZ922C zu5iLCeH%zkOVInv|4S zog7kRl}<6n>HJwPXkekLaJKmk&&|!z&GUXTDL8FrUogGn5?{hC?nS?CDtbG1C2yGZ zC-^Rh^c)7Yz8M@m4$V`T*7yfRloZXFKbh%~oKGTM8w5Ay3cQ+ZMAPNcxeG+e9=j5p>p!vORBlOQed8 ztKq_%J4pwB+_o>x3b)F*J8_*_zLKoneVzuBE{TQ-O-w7I+#Pifzh-e}>6pekhhxDL zO@;#>J@peVuh^@wYxDdwmuxQ0|NG_Bxe|lV>)CC8ZTDggSGR5K{N2{U;SzSaIBmDj z`I8%ex=5HYvTR^nG?^!niM8aTf?5fA$w7K}g=g(7^ z4yu%e z;PGQlI}o*mU-wlZ!-08gFSD}fxiJ>}`n-M5|IW-?g+KF`3I1i9y}{DrYC40$qEiQa z=XUC^;Nv`TI==V}kBhpi1*_mpQ4K3Lh8g#xm=>7rX3*Oz?$YdG_50+VT^Igm-rM(E z`{#;$+@3HHzNQ0V^?U9V>nfpF27kc&A`-HW^ zd%GnKOZa)hB0Mr1KK!_3v$R1Y;dKuSlXx>j#|0UO&n_>s`M$-ua=aH3-m~R@$&nlu z7m<#`6{o)2wy#gvd;4qghu%GN5B&@Gn^yTqRN>{O2|)}G1sM+Lh)mmdE#bnlSq(Cu zo_GG3R=sF@)8VB{r1xwo-!p$xW77now$!w5s~a3-zw{R{xU2p8X21LW1Ct4EY)d#? zUhPf}dn@TYyN$u}$^Xav^KV_v+V)RI=-=iSFFkyuj2AR1+7|zM5p>+df9l@_eXqan z&fNO#{*8b3>TOLcS$zI9*zT;j_H}PWW-tpVD5iZ2tonR>*Y(MHcXBU1xb-jiZ-|6c zK-lY8rS1+)~mo<35=h*#hn!?&}Nlsv{5W||*>ZZ0ziH2Ur4gtO!r=RUfI``a~ zU16p}+!xz6ZXJe5nR@x9+hyMD^}FyRVb7}cM^o#xCf)pX=;-+j7mw|izDnO)X}f05 zDbxGrO)M(53UR55K`b+te?9lso`Ip{M9O!O1BpBQe|B01zJ2X*oWHrjRVPvZcHWbt z$`ay!3|#5?@l~e}-n-4Z_I~#?;U$4N3|vLg^Q~*%?Wj3=@^gn)CCig9OkHnRth>*# ziEn`c>q4=2^}m11@B5;{aHnLsN%+Ph&lSy?DkrBrT*;=w>CwK3g_E0qLcx}uNqE2Awr8sh3!qamc?)uy3K3ka^&uv`w ztgy9V%GU2sW;N(AeEAX2?N_s%fAX3~r>4I@^lrM3@;kh<9*=e z@o+|-f~(qk6KAHkp4qsz+hE0)(2k8>RI2l zqR;b^fBoOPm+!MDOnv6je&J@mS)1VEmwz}NzD+wj@wJ1ka6QwN9eb-yyj%n}ZIlZ9 z@_IGno~>JL^*5gKTWQoQvX=9&=rU~w&E>2t3m76Qojx?{8~!@29kB4=Rr5D47Hbb^ zYT0$Y-XQi{|3!Tpuya}P$p==? zg4upo&dUC8%u?~nx@rI4RgRZzTMK3%+r0jM;#r&NKaZsE(LSMcwUEo=`Q0xu3M@JL z7aO@|8#If|`SjevbbtN->gDg5jV@nU__>Mgxhq3g-M@n`YM0E~zp2W^>&TQ@@3VYj zGuyV@N!|1O&(kw{H4Roj7|IJXzH?7BgE4 z7HDnLTiGk+^)31R^ySZgzB)Ix!-FL-s^tVvRomsp6>gt-cWr!oq5J)d|4LRV3_>ZJ z7n^mNeMx)EdeQ9A^=$3CRq5Z`o2T@$SbmP(9r2UVnQ=i^&xH8__tHD|tV?E;vFi%S zOSwF0p29mOCAK9DcP~6}QRqB=#N_wPS7ANVr~SVn$fkbQwEa+|o%Lf!=B`UCrZa8Q z&hV-IaCE=O-|6z1_SX*C7~bNR{-*T0ZDxa|psD{6^ZJ^ns)vH(JEv8ia?#uv7-A9N zu=m^Z)|H?CWk0z8o&D51xtn!=HFm$h^J))*QY`8vkDZI~lyb_RZzp{ZW$(-qjsErqIA4`?4YL z=X)dG%RAKHzIGzM_Us`p22RZszx*lMiIy_`kJI@AKVz6D@A9ndc%h%hCL5;*aEA7k|yazVF|cr)4n@ z@3cIabDU|@$=NS;<@@tQGw)rr6^~lh^1V(uDO9r?Z2mu!d4*GK#;)kNqSLX#%S$tL7e_z-Beu+eK}mGRj@hq{ zo#|J%{iky4hbDuA`zJA(+2?o}T{6xmdQ?ArDLd!U-sa$y8w5ig7N=i!yMMgVhF`bl z_(gMB=Zr^_-@oNoXJk2IYt1!7SC&CEW5$ufA4co%?>HCVTmIg<{-1wL%Ll;&ncKT0 z3s??vIqGvpUtYiO*qVQKRw)cXAlVxxCKVo6Q)ny=K4vZDxl5rd9ua|GVTIxKP)ZAB z-rAIu_v^%;dB=8tWHvt=_tV;772;TrM4Z>Iua5B%-IYDp{5kvM%=aAn z@}`;lzQ3Q<$R#9Fs;qm*{NR;s`~No`e3i|Zbmr6zuG%oB3H!GeM=$@)4*AJ^Czc#KzJIiH0D2c|0E$W?P0tg%Q5Zwm$x34dbuL;mq)6W zmTQy`{}So-HTQf^>$7(p@8#{$WhgFQ1Zr2-ZTP=9=S%LzlMx3(Zv0c6@m+h${Gu&) zrx#r33Hf$6FV*)kk7eiUKskmeRv#S!L$g554Id?R-hJGVw!5-BTXwQR3Y(1McfrIJ zcLUbWXD@d%l{yl2myaPrrhj=d_kl-o)xJz$-9$~K)C}G3tej-Jp;XP5v2poTyTuPV zCn^hsE}XDRpyH6jJw|mg#!n0Zc992Ceb(Bp-+VhxVY!yc(g!@8E^`Aa-UcM@xxI|R zkwIxIKf^Io=49>zm+WTjtX4H({`{-Lm7%Rjx#q<7(lhPBiSZFTX61W0+<9y&v!d%{ z1UKgqs|Htw6B0MJ?=v|faOA{=TK7Yn*KeJ-Z2Go1t>BaAW_$N_@T6&Uqo9Z!MATG*}t6*3EZurH|aDIQPdpmQ8#u5tA6O zQUB_(?^~8DY&tl93;z>A2SYBkg-Ip(Q^enYzuexkw238kuhyXnf3G?1bJ|%Xe|ly2 zS$`H!7Og!z%}-bv8g?4XKapO2yWkI3b+Yj3^A4H@hdI)}uMp8%&2ndpjq|pLuQc-Q zJ|2#9FPq(7%X(B`-GQ4%3{wrY{LehKpRFChIbqlDN`?$k4|XOGE~W{~7$g~`?yXo{ z-hE2QMB{Y$_ZbVb@0Oixk~(VL=eK6|AL)X~^?%wTFK@c!E4#Q-v|)Y!=2y)vGF4-h|p_q^7OP6suX&~w65dD@&3x81zqtQ4{}VCt+V zpL=>m8){er7Q9&#-^th#Fv~$|>ubM`({FWz^z$scKTW@Wh0EjIk4JHlch_dFw^>|N zxb+|_*9ETK3_V*XT%F0?z;RZ%u=?Rk+2y-GOV|=!06VIiY*&T;YTY4^Ga2Nl#`El2> z-A<2`oaXK=t~N_r65{%hB`K)j)Ps;!m$%nM z+bZRr{Tyd~#j0N7qMXuJF4qMcr*F1RW{I%bu!3Pjqs{y+Z3SlpF3l4N;|{yg_GwS` zD!sjC{FVVT52~D))!sFC%9+G-XBq_#{Cp#C+vDVUU5|Bk|4%VLEe1h1OYs;-m&Q4+ ztSaAmvvU4N9k)~Zp}$v5Zi*p)BbjF@8=$U%vT#`vZeAMwBif+|u2`P5Z zBlaXu*P1msyElbhPbx*}t)G*sYQU^hztb2ORBV-D)Oj#}PPMW#6Gw+?u^p#EH=EPU zRjX#n^;W;R@$J!^)qjm@-k3MrZ;DV_!Qr9l_gk~@6HCL!FeB+`h8qE!FDw$Q)VlCs zHe39q>-z&s&C^$1zPhb=zp<}&du?xR&(t%=`dB7S zTe3~z^^KBMmf`JEuPxi08^zCarYM*Fi?b_K-gw4OIh*T~lX~q&Muo{Y+3x3l*HU*j z^O#fg?D^iyb~)C$PiFU6Z8k4?vwiko&M9pU@9Zxvyy3@V`T1qyIFTv2QF)r)vx_q&K&WZKSai1^~tiWjN(;A>{D7c zD@XXS%8uSg%X)>n1eu+V*8Hidu@h$UW6|2e^X!O{gB?=@XXtaE@67g7t!_AV zL>H`l$F#KImy`XK_1`Zq{bv;~;gaCtJF%eV|KjyKzSKEn+}X-{E`q`8tr@dJ*VOhI zlhsQX2fm&8%g=l6m0bceWcfLF@XTM$`{c=S(`HlF&$5aP40ktNUovs)|2eljd6p(W z3sZ03X?k`2tVbd{K5))?lkMuP{d13vkrJ;!V~|2`^9=Q4I>+WoR6SE`@ObvjrS@O% z^!aLwoOi8c4cT#Q>82;&R_ooH`QGaIy5fkJ%D=DoWy;;ryrIVE!ECkV`J{Qg44o%c z7V9PW-}@(dX@c|iWB)u%zFcmftx!Gbn#}j#j@myY8a6(Zm)I=6Va8K&qxzN3x0P2F zv$%cOH_h!;xbNYfFKe<{4!W}WH7@WBU_8YTFzGx4(~;n_7o+S~E%;ihCB{>@Wxvt` zxx=+05>Cgm>YS^(9A2u+?@Ry1p3!YLdui%1p@yYvjAc0NCc8E7T|BIeU%ZHZbRZv ztye3iEZKO`_m?wwV)}R0)gK=*d_MA)_2T^hPiG$I{Is$lx_LoD%nq5Zh@am-Jgo2i zoE@lJbB%)`-Rk0ujkj$Wj;xfPF1u`Fz=CB3sT*JM*t&6@n810af9@`>%?lSSWoh=e zxpcG6{#HQx?s*q}Y;Llixx{y+y};xRm%3JjOr7-U#EkQ)=GWr>d{)>$>0q|G(aL8HYc;U%? zrj~P0mBo^|9hPstzeU`o{-OBn@0&gCuk2Z}(lJR(JHKDF%YwOR%hGF0u9)U+OJ!?X z_V?fVf@chT3@3N7ZQVO{;#2ka+r#cXJjDCct+cMOUMF>pws79!Lz=D~_dge;KX=Y} zJjX7rHtaoX37@lpx>s)g-n&PdlO2t(1pNH7U`^$#E%%oSmWfLooU$u>^It3D$>xi; z{%4U*F>csprZ?d{qfF-N*C#r^_v`$bd11!hZlkmVK2=%*m#Z_bZw|ZPX&LzZm+s@u z)rKkacGSwgsQ+&>*Fdw*(VTgagW*%Y#E;7B53agu+F(}IaTbd+JbNMZf@>qI94`$3g4UQ z4i;yZf9na9+pl!^sD$H9=d2{vIiM^y);wp~#j5}P`fJ8e)}~&*?W_;pT`!B>`1fr__c5V!Gk+dn(-GWk zl5c;b=6>|DdfS(-^%kX`+6ul*2H|2^QVaD;rEG;_U!QUF+vo2XcCu8MWBD5W312oz zI_zY&`19h|?_C99AIq+<7k=6r$G!F1uB9B#tz0G+l3Olam;GhzwdVSM!`G~N2c#TT z=Kg)~t?9-7s_fUfX%~KfuhMe3kfXUUXH)IIj?Xh^Tqu-1F1T%W0;|FtwHIz?3|CGo zHQeNQa6+Qty66%`-}u}At}D%%zWh+ZeHM|Kt}Q036%@F3*?)g<*IM54cE^2epxHOHBSJtnoRZxLR95h_SPA(Wyh{o-R#IzQA#e&sFO0^6RqcoxQo& z7=4m|s|xtNdEz+#yUC*!6(z0$E8H~Z>=J2TZ1dn@JO6wkjuR|rS2mPL^cp$GNp@uHu=)3|Lp@D$azd6BL)g8q9_h#DZr&`E zkohO`{_Be~EjJ|x?Q39Rxbs84dVQFPOas?SqiDG8#_w-n#0_=I*yGA6??2@tgy5hk}Wrz1GLuXVlBx=2jpShi1$zv^!1BQo}2)0bz{hZ~-^>d0hTlbaqJG9NQ z5Y5a}kEv}|n#|xYC#F-c@4BaG*DtyK3bXe(9^K=x`sWnZE)xb`EtwfItaI8$H_Vsq zIs7LpN@&Y6=b(%6HhV5qDZf#gApPv)^^29_4RdbgUYyG+()3|+zI|opr8&3M-R>%# z+1#+ptyNLsMBG`kgvky(nN6};x_N?IcS}xu{-O7SXBI0{!(TSenF&=^0WazwEIju`R=nwF zUZI}E*|VKiMY0;^ybXV)u73(X>3!pYe8W#sog&$YdFoBtAqJuoHXYS5spYMA)p1}k}3jefLmD%t?)b z*VgyU8ycptT)ylT_&%0j_spKJPKNh&FSRZ|=!qAp4F6n@p#dbBMYSeKXCt^Tb*C*{`#9b|M#<U(J8SryDY~7x?HRhAJ z(?rAjf(@mnGGEUnPMpsm=vQhXyuu(hF!2jFzwDkTlb>I{E$=Gc$#7+&z%z%p$_pM> zi*eqlb_-uCXY10wZr8m#Ut->HZFl82Y@aUk+f8S=T({=Cb!qqNeri=*lxE=iQNC4v zCc_lJ1h-PZxy3eFN1_ZwPkAG}og;`w^_+5dn3nCQ|} zd18n0-DfUW6qT;Ych6rf^-#50xO0i1;PRE%)6X>hu}n774p0q!`>nUBbU_!(VviXM zMNF41XI}AR^5yAY-|zTeJ6$h%@(li%D@+AtTPhB$lRV@Vw_>)Gr~NaNCb49c%iAF6p^Q)7~KIdlD8 zjTzaU4NKS!4Vyo2clMg6^xoo^^WSf;lb-f+wt*EK6(! zmUB)xduYR6eTDX~MXBp~zTLf%Gj06_aXGFl6Kj7)@OZo|*d?kVC9`bVo}UN%x9wwK z$mou{<#OVCX18L)g+DeQzrLS7p<|ujE{9vT#_iPEowfCeSo(OP;`#K5vOvMhpzh_Mo)@|JhqVTFd=7 z@hzX}fLraOw>1~0IW_ELp1FD5N1a6p(R-2(Y>a)7S<9=}x?s-t=QEh^^9RhFtL_A@jOn(#LPI9G;;!78Km}SrGka1>fTO>pwPrXnS9i^6GMf#+lAd!WL%t zFGhE7o5FeGy3w^+>$c=Gc6NAr?-P4ycYpTj`c-V420ULAy2qe$79O*e@kFQne=q-#lLacK_dJlEMo@lA~4zXcm|RIDWCbYEZxL zKXd(;pOv%HAIv=eW6riI3~!Fc?Ve+MIkk-UK!?Z8uklM~#7%trT-b(%N9pvJ1xrH~ zFJxc~tZ8A|sh*?u{Z9S$zV}QA{;+SJ=_~uecD@oyrlqS!+~U4>!n{z=>{bxdg}8=SZ`vj2`1LUvH(t6N;CT2}U1Gs4 z@5V3HT+8?K$=+GM{dD}_&(`aIT(gU`%c)+?6;PrUi)dg$KX8?`K#ZZdE5Ug^K3hTw7(kb_0f8gor=d#Wzp$PzAGizV~ literal 0 HcmV?d00001 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 @@ + + + + + + +