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 @@
+
+
+
+
+
+
+
+ provider_sudo._toss_payments_get_inline_form_values(pm_sudo.code)
+
+
+
+
+