add payment_toss_payments from vanilla OCA/OCB 19.0

Required by payment/data/payment_provider_data.xml which references
icon files from all payment provider modules.

🤖 assisted by claude
This commit is contained in:
Ernad Husremovic 2026-03-09 15:51:21 +01:00
parent 76b5d3ab84
commit d53e4f21f3
20 changed files with 974 additions and 0 deletions

View file

@ -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.

View file

@ -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')

View file

@ -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',
}

View file

@ -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'}

View file

@ -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

View file

@ -0,0 +1,3 @@
UPDATE payment_provider
SET toss_payments_client_key = NULL,
toss_payments_secret_key = NULL;

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="payment.payment_provider_toss_payments" model="payment.provider">
<field name="code">toss_payments</field>
</record>
</odoo>

View file

@ -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 ""

View file

@ -0,0 +1 @@
from . import payment_provider, payment_transaction

View file

@ -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"]})'

View file

@ -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))

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View file

@ -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 '';
},
});

View file

@ -0,0 +1 @@
from . import common, test_payment_transaction, test_processing_flows

View file

@ -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,
},
}

View file

@ -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')

View file

@ -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)

View file

@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="payment_provider_form" model="ir.ui.view">
<field name="name">Toss Payments Provider Form</field>
<field name="model">payment.provider</field>
<field name="inherit_id" ref="payment.payment_provider_form"/>
<field name="arch" type="xml">
<group name="provider_credentials" position="inside">
<group invisible="code != 'toss_payments'">
<field
string="Client Key"
name="toss_payments_client_key"
required="code == 'toss_payments' and state != 'disabled'"
/>
<field
string="Secret Key"
name="toss_payments_secret_key"
required="code == 'toss_payments' and state != 'disabled'"
password="True"
/>
<field
string="Webhook URL"
name="toss_payments_webhook_url"
widget="CopyClipboardChar"
/>
</group>
</group>
</field>
</record>
</odoo>

View file

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<template id="payment_toss_payments.method_form" inherit_id="payment.method_form">
<input name="o_payment_radio" position="attributes">
<attribute name="t-att-data-toss-payments-inline-form-values">
provider_sudo._toss_payments_get_inline_form_values(pm_sudo.code)
</attribute>
</input>
</template>
</odoo>