mirror of
https://github.com/bringout/oca-ocb-core.git
synced 2026-04-18 03:32:08 +02:00
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:
parent
76b5d3ab84
commit
d53e4f21f3
20 changed files with 974 additions and 0 deletions
|
|
@ -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.
|
||||
|
|
@ -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')
|
||||
|
|
@ -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',
|
||||
}
|
||||
|
|
@ -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'}
|
||||
|
|
@ -0,0 +1 @@
|
|||
from . import main
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
UPDATE payment_provider
|
||||
SET toss_payments_client_key = NULL,
|
||||
toss_payments_secret_key = NULL;
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 ""
|
||||
|
|
@ -0,0 +1 @@
|
|||
from . import payment_provider, payment_transaction
|
||||
|
|
@ -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"]})'
|
||||
|
|
@ -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 |
|
|
@ -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 '';
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1 @@
|
|||
from . import common, test_payment_transaction, test_processing_flows
|
||||
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
|
|
@ -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')
|
||||
|
|
@ -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)
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue