tosspayments2-rails 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.claude/agents/kfc/spec-design.md +157 -0
- data/.claude/agents/kfc/spec-impl.md +38 -0
- data/.claude/agents/kfc/spec-judge.md +124 -0
- data/.claude/agents/kfc/spec-requirements.md +122 -0
- data/.claude/agents/kfc/spec-system-prompt-loader.md +37 -0
- data/.claude/agents/kfc/spec-tasks.md +182 -0
- data/.claude/agents/kfc/spec-test.md +107 -0
- data/.claude/settings/kfc-settings.json +24 -0
- data/.claude/system-prompts/spec-workflow-starter.md +306 -0
- data/.vscode/mcp.json +28 -0
- data/CHANGELOG.md +23 -0
- data/README.md +272 -0
- data/Rakefile +4 -0
- data/lib/tosspayments2/rails/client.rb +106 -0
- data/lib/tosspayments2/rails/configuration.rb +32 -0
- data/lib/tosspayments2/rails/controller_concern.rb +16 -0
- data/lib/tosspayments2/rails/engine.rb +31 -0
- data/lib/tosspayments2/rails/script_tag_helper.rb +19 -0
- data/lib/tosspayments2/rails/version.rb +7 -0
- data/lib/tosspayments2/rails/webhook_verifier.rb +50 -0
- data/lib/tosspayments2/rails.rb +23 -0
- data/sig/tosspayments2/rails.rbs +53 -0
- metadata +87 -0
data/README.md
ADDED
@@ -0,0 +1,272 @@
|
|
1
|
+
# tosspayments2-rails
|
2
|
+
|
3
|
+
English section follows Korean (Scroll down for English usage guide).
|
4
|
+
|
5
|
+
Rails 7 & 8용 TossPayments v2 (통합 SDK / 결제위젯) 간편 연동 지원 젬.
|
6
|
+
|
7
|
+
* View helper (`tosspayments_script_tag`) 로 SDK `<script>` 주입
|
8
|
+
* 환경설정 (`Tosspayments2::Rails.configure`) 으로 client/secret 키 관리
|
9
|
+
* 서버사이드 결제 승인(confirm) & 취소(cancel) API 래퍼 `Tosspayments2::Rails::Client`
|
10
|
+
* Controller concern (`Tosspayments2::Rails::ControllerConcern`) 으로 `toss_client` 제공
|
11
|
+
* (Placeholder) Webhook 검증 클래스 (향후 사양 반영 예정)
|
12
|
+
|
13
|
+
## 설치 (Installation)
|
14
|
+
|
15
|
+
Gemfile:
|
16
|
+
```ruby
|
17
|
+
gem 'tosspayments2-rails'
|
18
|
+
```
|
19
|
+
설치 후:
|
20
|
+
```bash
|
21
|
+
bundle install
|
22
|
+
```
|
23
|
+
|
24
|
+
아직 Rubygems 공개 전이라면 Git 소스 사용:
|
25
|
+
```ruby
|
26
|
+
gem 'tosspayments2-rails', git: 'https://github.com/luciuschoi/tosspayments2-rails'
|
27
|
+
```
|
28
|
+
|
29
|
+
## 초기 설정 (Configuration)
|
30
|
+
`config/initializers/tosspayments2.rb` 생성:
|
31
|
+
```ruby
|
32
|
+
Tosspayments2::Rails.configure do |c|
|
33
|
+
c.client_key = ENV.fetch('TOSSPAYMENTS_CLIENT_KEY')
|
34
|
+
c.secret_key = ENV.fetch('TOSSPAYMENTS_SECRET_KEY')
|
35
|
+
# c.widget_version = 'v2' # 기본값
|
36
|
+
# c.api_base = 'https://api.tosspayments.com' # 기본값
|
37
|
+
end
|
38
|
+
```
|
39
|
+
|
40
|
+
또는 `config/application.rb` 등에서:
|
41
|
+
```ruby
|
42
|
+
config.tosspayments2.client_key = ENV['TOSSPAYMENTS_CLIENT_KEY']
|
43
|
+
config.tosspayments2.secret_key = ENV['TOSSPAYMENTS_SECRET_KEY']
|
44
|
+
```
|
45
|
+
|
46
|
+
## View에서 SDK 스크립트 추가
|
47
|
+
레이아웃 (`app/views/layouts/application.html.erb`):
|
48
|
+
```erb
|
49
|
+
<head>
|
50
|
+
<%= csrf_meta_tags %>
|
51
|
+
<%= csp_meta_tag %>
|
52
|
+
<%= tosspayments_script_tag %>
|
53
|
+
</head>
|
54
|
+
```
|
55
|
+
|
56
|
+
커스텀 버전/옵션:
|
57
|
+
```erb
|
58
|
+
<%= tosspayments_script_tag(async: true, defer: true, version: 'v2') %>
|
59
|
+
```
|
60
|
+
|
61
|
+
## 프론트엔드 예시 (Stimulus / ES module 없이 단순)
|
62
|
+
```erb
|
63
|
+
<div id="payment-methods"></div>
|
64
|
+
<div id="agreement"></div>
|
65
|
+
<button id="pay">결제하기</button>
|
66
|
+
<script>
|
67
|
+
document.addEventListener('DOMContentLoaded', async () => {
|
68
|
+
const clientKey = '<%= ENV['TOSSPAYMENTS_CLIENT_KEY'] %>';
|
69
|
+
const customerKey = 'customer_123'; // 구매자 식별 값
|
70
|
+
const tosspayments = TossPayments(clientKey);
|
71
|
+
const widgets = tosspayments.widgets({ customerKey });
|
72
|
+
성공 리다이렉트에서 요청 파라미터(`paymentKey`, `orderId`, `amount`)를 저장된 주문과 비교하려면 `CallbackVerifier` 사용:
|
73
|
+
await widgets.renderPaymentMethods({ selector: '#payment-methods' });
|
74
|
+
await widgets.renderAgreement({ selector: '#agreement' });
|
75
|
+
|
76
|
+
document.getElementById('pay').addEventListener('click', async () => {
|
77
|
+
try {
|
78
|
+
await widgets.requestPayment({
|
79
|
+
orderId: 'ORDER-' + Date.now(),
|
80
|
+
orderName: '테스트 주문',
|
81
|
+
customerName: '홍길동',
|
82
|
+
successUrl: '<%= success_payments_url %>',
|
83
|
+
failUrl: '<%= fail_payments_url %>'
|
84
|
+
});
|
85
|
+
} catch (e) {
|
86
|
+
console.error(e);
|
87
|
+
}
|
88
|
+
});
|
89
|
+
});
|
90
|
+
</script>
|
91
|
+
```
|
92
|
+
|
93
|
+
## 성공/실패 리다이렉트 컨트롤러 예시
|
94
|
+
```ruby
|
95
|
+
class PaymentsController < ApplicationController
|
96
|
+
include Tosspayments2::Rails::ControllerConcern
|
97
|
+
|
98
|
+
def success
|
99
|
+
# TossPayments에서 쿼리로 전달: paymentKey, orderId, amount
|
100
|
+
payment_key = params[:paymentKey]
|
101
|
+
order_id = params[:orderId]
|
102
|
+
amount = params[:amount].to_i
|
103
|
+
result = toss_client.confirm(payment_key: payment_key, order_id: order_id, amount: amount)
|
104
|
+
# 비즈니스 로직 (주문 상태 업데이트 등)
|
105
|
+
@payment = result
|
106
|
+
rescue Tosspayments2::Rails::APIError => e
|
107
|
+
logger.error("Toss confirm error status=#{e.status} code=#{e.code} body=#{e.body.inspect}")
|
108
|
+
redirect_to root_path, alert: '결제 승인 실패'
|
109
|
+
end
|
110
|
+
|
111
|
+
def fail
|
112
|
+
# errorCode, errorMessage 등 전달
|
113
|
+
flash[:alert] = "결제 실패: #{params[:message] || params[:errorMessage]}"
|
114
|
+
redirect_to root_path
|
115
|
+
end
|
116
|
+
end
|
117
|
+
```
|
118
|
+
|
119
|
+
라우팅 (`config/routes.rb`):
|
120
|
+
```ruby
|
121
|
+
resource :payments, only: [] do
|
122
|
+
get :success
|
123
|
+
get :fail
|
124
|
+
end
|
125
|
+
```
|
126
|
+
|
127
|
+
## 서버사이드 결제 취소
|
128
|
+
```ruby
|
129
|
+
toss_client.cancel(payment_key: payment.payment_key, cancel_reason: '고객요청')
|
130
|
+
```
|
131
|
+
|
132
|
+
## 콜백 파라미터 검증 (선택)
|
133
|
+
```ruby
|
134
|
+
verifier = Tosspayments2::Rails::CallbackVerifier.new
|
135
|
+
verifier.match_amount?(order_id: params[:orderId], amount: params[:amount].to_i) do |order_id|
|
136
|
+
Order.find_by!(uuid: order_id).amount
|
137
|
+
end
|
138
|
+
```
|
139
|
+
불일치 시 `Tosspayments2::Rails::VerificationError` 예외 발생.
|
140
|
+
|
141
|
+
## Webhook 검증
|
142
|
+
```ruby
|
143
|
+
verifier = Tosspayments2::Rails::WebhookVerifier.new
|
144
|
+
raw_body = request.raw_post
|
145
|
+
signature = request.headers['X-TossPayments-Signature']
|
146
|
+
unless verifier.verify?(raw_body, signature)
|
147
|
+
head :unauthorized and return
|
148
|
+
end
|
149
|
+
payload = JSON.parse(raw_body)
|
150
|
+
# 이벤트 처리
|
151
|
+
```
|
152
|
+
|
153
|
+
## Rails Generator
|
154
|
+
빠른 초기 세팅:
|
155
|
+
```bash
|
156
|
+
bin/rails generate tosspayments2:install
|
157
|
+
```
|
158
|
+
생성물:
|
159
|
+
* `config/initializers/tosspayments2.rb`
|
160
|
+
* 예시 컨트롤러 `app/controllers/payments_controller.rb` (옵션, 덮어쓰기 여부 질의)
|
161
|
+
* 안내 주석
|
162
|
+
|
163
|
+
## RSpec 설정 & 테스트 실행
|
164
|
+
프로젝트 루트에서(엔진 개발 환경):
|
165
|
+
```bash
|
166
|
+
bundle exec rspec
|
167
|
+
```
|
168
|
+
커버리지 보고서는 `coverage/` (SimpleCov) 생성.
|
169
|
+
|
170
|
+
## YARD 문서 생성
|
171
|
+
```bash
|
172
|
+
bundle exec yard doc
|
173
|
+
open doc/index.html
|
174
|
+
```
|
175
|
+
|
176
|
+
## RBS (타입 시그니처)
|
177
|
+
`sig/` 디렉토리에 기본 시그니처가 포함되어 있습니다. 필요 시 확장하세요.
|
178
|
+
|
179
|
+
## 테스트 / 개발
|
180
|
+
```bash
|
181
|
+
bin/console
|
182
|
+
```
|
183
|
+
```ruby
|
184
|
+
Tosspayments2::Rails.configure { |c| c.secret_key = 'sk_test_xxx' }
|
185
|
+
client = Tosspayments2::Rails::Client.new(secret_key: 'sk_test_xxx')
|
186
|
+
# Stub 네트워크 후 confirm 호출 등
|
187
|
+
```
|
188
|
+
|
189
|
+
## 라이선스
|
190
|
+
MIT
|
191
|
+
|
192
|
+
---
|
193
|
+
|
194
|
+
## English
|
195
|
+
|
196
|
+
### Overview
|
197
|
+
Helpers & a lightweight engine to integrate TossPayments (Payment Widget v2) with Rails 7 & 8.
|
198
|
+
|
199
|
+
Features:
|
200
|
+
* Script tag helper (`toss_payments_script_tag` / actually `tosspayments_script_tag`)
|
201
|
+
* Configuration block (`Tosspayments2::Rails.configure`)
|
202
|
+
* Server-side confirm & cancel client
|
203
|
+
* Controller concern for quick access (`toss_client`)
|
204
|
+
* Callback parameter verification (order id & amount)
|
205
|
+
* Webhook HMAC (SHA256 + Base64) verification helper
|
206
|
+
|
207
|
+
### Installation
|
208
|
+
```ruby
|
209
|
+
gem 'tosspayments2-rails'
|
210
|
+
```
|
211
|
+
```bash
|
212
|
+
bundle install
|
213
|
+
```
|
214
|
+
|
215
|
+
### Configuration
|
216
|
+
```ruby
|
217
|
+
Tosspayments2::Rails.configure do |c|
|
218
|
+
c.client_key = ENV['TOSSPAYMENTS_CLIENT_KEY']
|
219
|
+
c.secret_key = ENV['TOSSPAYMENTS_SECRET_KEY']
|
220
|
+
end
|
221
|
+
```
|
222
|
+
|
223
|
+
### Script Tag
|
224
|
+
In layout head:
|
225
|
+
```erb
|
226
|
+
<%= tosspayments_script_tag %>
|
227
|
+
```
|
228
|
+
|
229
|
+
### Success Redirect Controller
|
230
|
+
```ruby
|
231
|
+
class PaymentsController < ApplicationController
|
232
|
+
include Tosspayments2::Rails::ControllerConcern
|
233
|
+
def success
|
234
|
+
result = toss_client.confirm(payment_key: params[:paymentKey], order_id: params[:orderId], amount: params[:amount].to_i)
|
235
|
+
@payment = result
|
236
|
+
rescue Tosspayments2::Rails::APIError => e
|
237
|
+
Rails.logger.error("Confirm error status=#{e.status} code=#{e.code}")
|
238
|
+
redirect_to root_path, alert: 'Payment confirm failed'
|
239
|
+
end
|
240
|
+
end
|
241
|
+
```
|
242
|
+
|
243
|
+
### Callback Verification
|
244
|
+
```ruby
|
245
|
+
Tosspayments2::Rails::CallbackVerifier.new.match_amount?(order_id: params[:orderId], amount: params[:amount].to_i) do |oid|
|
246
|
+
Order.find_by!(uuid: oid).amount
|
247
|
+
end
|
248
|
+
```
|
249
|
+
|
250
|
+
### Webhook Verification
|
251
|
+
```ruby
|
252
|
+
verifier = Tosspayments2::Rails::WebhookVerifier.new
|
253
|
+
if verifier.verify?(request.raw_post, request.headers['X-TossPayments-Signature'])
|
254
|
+
# process JSON.parse(request.raw_post)
|
255
|
+
else
|
256
|
+
head :unauthorized
|
257
|
+
end
|
258
|
+
```
|
259
|
+
|
260
|
+
### Cancel
|
261
|
+
```ruby
|
262
|
+
toss_client.cancel(payment_key: 'pay_123', cancel_reason: 'customer request')
|
263
|
+
```
|
264
|
+
|
265
|
+
### Tests
|
266
|
+
Run RSpec: `bundle exec rspec`
|
267
|
+
|
268
|
+
### License
|
269
|
+
MIT
|
270
|
+
|
271
|
+
## 기여
|
272
|
+
PR / 이슈 환영: https://github.com/luciuschoi/tosspayments2-rails
|
data/Rakefile
ADDED
@@ -0,0 +1,106 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'net/http'
|
4
|
+
require 'json'
|
5
|
+
require 'base64'
|
6
|
+
|
7
|
+
module Tosspayments2
|
8
|
+
module Rails
|
9
|
+
# Lightweight client for TossPayments REST endpoints (confirm/cancel only).
|
10
|
+
class Client
|
11
|
+
# @param secret_key [String,nil]
|
12
|
+
# @param api_base [String,nil]
|
13
|
+
# @param timeout [Integer,nil] network timeout seconds
|
14
|
+
# @param retries [Integer] transient network retry attempts
|
15
|
+
# @param backoff [Float] linear backoff base seconds
|
16
|
+
def initialize(secret_key: nil, api_base: nil, timeout: nil, retries: 2, backoff: 0.2)
|
17
|
+
cfg = ::Tosspayments2::Rails.configuration
|
18
|
+
@secret_key = secret_key || cfg.secret_key
|
19
|
+
@api_base = api_base || cfg.api_base
|
20
|
+
@timeout = timeout || cfg.timeout
|
21
|
+
@retries = retries
|
22
|
+
@backoff = backoff
|
23
|
+
return if @secret_key
|
24
|
+
|
25
|
+
raise ::Tosspayments2::Rails::ConfigurationError,
|
26
|
+
'secret_key required – configure with Tosspayments2::Rails.configure'
|
27
|
+
end
|
28
|
+
|
29
|
+
# Confirm payment after success redirect.
|
30
|
+
# @return [Hash] parsed JSON
|
31
|
+
# @raise [APIError]
|
32
|
+
def confirm(payment_key:, order_id:, amount:)
|
33
|
+
post_json('/v1/payments/confirm', {
|
34
|
+
paymentKey: payment_key,
|
35
|
+
orderId: order_id,
|
36
|
+
amount: amount
|
37
|
+
})
|
38
|
+
end
|
39
|
+
|
40
|
+
# Cancel payment (full or partial).
|
41
|
+
# @return [Hash]
|
42
|
+
# @raise [APIError]
|
43
|
+
def cancel(payment_key:, cancel_reason:, amount: nil)
|
44
|
+
body = { cancelReason: cancel_reason }
|
45
|
+
body[:cancelAmount] = amount if amount
|
46
|
+
post_json("/v1/payments/#{payment_key}/cancel", body)
|
47
|
+
end
|
48
|
+
|
49
|
+
private
|
50
|
+
|
51
|
+
def post_json(path, body)
|
52
|
+
uri = URI.join(@api_base, path)
|
53
|
+
req = Net::HTTP::Post.new(uri)
|
54
|
+
req['Authorization'] = "Basic #{Base64.strict_encode64("#{@secret_key}:")}"
|
55
|
+
req['Content-Type'] = 'application/json'
|
56
|
+
req.body = JSON.dump(body)
|
57
|
+
perform(uri, req)
|
58
|
+
end
|
59
|
+
|
60
|
+
def perform(uri, req)
|
61
|
+
attempts = 0
|
62
|
+
loop do
|
63
|
+
attempts += 1
|
64
|
+
res = execute_http(uri, req)
|
65
|
+
return parse_body(res) if res.is_a?(Net::HTTPSuccess)
|
66
|
+
raise build_api_error(res) unless retryable?(res, attempts)
|
67
|
+
|
68
|
+
sleep(@backoff * attempts)
|
69
|
+
end
|
70
|
+
rescue Timeout::Error, Errno::ECONNRESET, Errno::ETIMEDOUT, SocketError => e
|
71
|
+
retry if (attempts += 1) <= @retries
|
72
|
+
raise ::Tosspayments2::Rails::APIError.new("Network error: #{e.class}", status: 0, body: { error: e.message })
|
73
|
+
end
|
74
|
+
|
75
|
+
def execute_http(uri, req)
|
76
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
77
|
+
http.use_ssl = uri.scheme == 'https'
|
78
|
+
http.read_timeout = @timeout
|
79
|
+
http.open_timeout = @timeout
|
80
|
+
http.request(req)
|
81
|
+
end
|
82
|
+
|
83
|
+
def parse_body(res)
|
84
|
+
JSON.parse(res.body, symbolize_names: true)
|
85
|
+
rescue JSON::ParserError
|
86
|
+
{ raw: res.body }
|
87
|
+
end
|
88
|
+
|
89
|
+
def build_api_error(res)
|
90
|
+
parsed = parse_body(res)
|
91
|
+
request_id = res['X-Request-Id'] || res['X-Request-ID']
|
92
|
+
meta = parsed.is_a?(Hash) ? parsed : { raw: parsed }
|
93
|
+
::Tosspayments2::Rails::APIError.new(
|
94
|
+
"TossPayments API error #{res.code}",
|
95
|
+
status: res.code.to_i,
|
96
|
+
body: meta,
|
97
|
+
request_id: request_id
|
98
|
+
)
|
99
|
+
end
|
100
|
+
|
101
|
+
def retryable?(res, attempts)
|
102
|
+
attempts <= @retries && res.code.to_i >= 500
|
103
|
+
end
|
104
|
+
end
|
105
|
+
end
|
106
|
+
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'singleton'
|
4
|
+
require 'logger'
|
5
|
+
|
6
|
+
module Tosspayments2
|
7
|
+
module Rails
|
8
|
+
class Configuration
|
9
|
+
include Singleton
|
10
|
+
|
11
|
+
attr_accessor :client_key, :secret_key, :widget_version, :api_base, :timeout, :logger
|
12
|
+
|
13
|
+
def initialize
|
14
|
+
@widget_version = 'v2'
|
15
|
+
@api_base = 'https://api.tosspayments.com'
|
16
|
+
@timeout = 10
|
17
|
+
@logger = defined?(::Rails) ? ::Rails.logger : Logger.new($stdout)
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
class << self
|
22
|
+
def configuration
|
23
|
+
Configuration.instance
|
24
|
+
end
|
25
|
+
|
26
|
+
def configure
|
27
|
+
yield(configuration) if block_given?
|
28
|
+
configuration
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'active_support/concern'
|
4
|
+
module Tosspayments2
|
5
|
+
module Rails
|
6
|
+
module ControllerConcern
|
7
|
+
extend ActiveSupport::Concern
|
8
|
+
|
9
|
+
private
|
10
|
+
|
11
|
+
def toss_client
|
12
|
+
@toss_client ||= ::Tosspayments2::Rails::Client.new
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Tosspayments2
|
4
|
+
module Rails
|
5
|
+
# Rails engine for auto-loading helpers and configuration.
|
6
|
+
class Engine < ::Rails::Engine
|
7
|
+
isolate_namespace Tosspayments2::Rails
|
8
|
+
|
9
|
+
initializer 'tosspayments2.configure' do |app|
|
10
|
+
app.config.tosspayments2 ||= ActiveSupport::OrderedOptions.new
|
11
|
+
app_cfg = app.config.tosspayments2
|
12
|
+
::Tosspayments2::Rails.configure do |c|
|
13
|
+
c.client_key ||= app_cfg.client_key || ENV.fetch('TOSSPAYMENTS_CLIENT_KEY', nil)
|
14
|
+
c.secret_key ||= app_cfg.secret_key || ENV.fetch('TOSSPAYMENTS_SECRET_KEY', nil)
|
15
|
+
c.widget_version ||= app_cfg.widget_version || 'v2'
|
16
|
+
c.api_base ||= app_cfg.api_base || 'https://api.tosspayments.com'
|
17
|
+
c.timeout ||= app_cfg.timeout || 10
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
initializer 'tosspayments2.helpers' do
|
22
|
+
ActiveSupport.on_load :action_view do
|
23
|
+
include ::Tosspayments2::Rails::ScriptTagHelper
|
24
|
+
end
|
25
|
+
ActiveSupport.on_load :action_controller do
|
26
|
+
helper ::Tosspayments2::Rails::ScriptTagHelper
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'erb'
|
4
|
+
|
5
|
+
module Tosspayments2
|
6
|
+
module Rails
|
7
|
+
module ScriptTagHelper
|
8
|
+
def tosspayments_script_tag(async: true, defer: true, version: nil)
|
9
|
+
v = version || ::Tosspayments2::Rails.configuration.widget_version
|
10
|
+
src = "https://js.tosspayments.com/#{v}/standard"
|
11
|
+
attrs = []
|
12
|
+
attrs << 'async' if async
|
13
|
+
attrs << 'defer' if defer
|
14
|
+
attrs << %(src="#{ERB::Util.html_escape(src)}")
|
15
|
+
"<script #{attrs.join(' ')}></script>".html_safe
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,50 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'openssl'
|
4
|
+
require 'base64'
|
5
|
+
|
6
|
+
module Tosspayments2
|
7
|
+
module Rails
|
8
|
+
# Verifies incoming webhook using HMAC-SHA256 signature (Base64 encoded).
|
9
|
+
# Assumes TossPayments sends header 'X-TossPayments-Signature'.
|
10
|
+
class WebhookVerifier
|
11
|
+
HEADER_NAME = 'X-TossPayments-Signature'
|
12
|
+
|
13
|
+
def initialize(secret_key: nil)
|
14
|
+
@secret_key = secret_key || ::Tosspayments2::Rails.configuration.secret_key
|
15
|
+
return if @secret_key
|
16
|
+
|
17
|
+
raise ::Tosspayments2::Rails::ConfigurationError,
|
18
|
+
'secret_key required for webhook verification'
|
19
|
+
end
|
20
|
+
|
21
|
+
# @param body [String] raw request body
|
22
|
+
# @param signature [String,nil] header signature value (Base64)
|
23
|
+
# @return [Boolean]
|
24
|
+
def verify?(body, signature)
|
25
|
+
return false unless body && signature
|
26
|
+
|
27
|
+
expected = compute_signature(body)
|
28
|
+
secure_compare?(signature, expected)
|
29
|
+
end
|
30
|
+
|
31
|
+
# Compute signature for a given body
|
32
|
+
def compute_signature(body)
|
33
|
+
digest = OpenSSL::HMAC.digest('sha256', @secret_key, body)
|
34
|
+
Base64.strict_encode64(digest)
|
35
|
+
end
|
36
|
+
|
37
|
+
private
|
38
|
+
|
39
|
+
# Constant-time compare
|
40
|
+
def secure_compare?(given_sig, expected_sig)
|
41
|
+
return false unless given_sig.bytesize == expected_sig.bytesize
|
42
|
+
|
43
|
+
l = given_sig.unpack('C*')
|
44
|
+
res = 0
|
45
|
+
expected_sig.each_byte { |byte| res |= byte ^ l.shift }
|
46
|
+
res.zero?
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'rails/version'
|
4
|
+
require_relative 'rails/configuration'
|
5
|
+
require_relative 'rails/errors'
|
6
|
+
require_relative 'rails/client'
|
7
|
+
require_relative 'rails/script_tag_helper'
|
8
|
+
require_relative 'rails/controller_concern'
|
9
|
+
require_relative 'rails/webhook_verifier'
|
10
|
+
require_relative 'rails/callback_verifier'
|
11
|
+
require_relative 'rails/engine' if defined?(Rails)
|
12
|
+
|
13
|
+
module Tosspayments2
|
14
|
+
module Rails
|
15
|
+
class Error < StandardError; end
|
16
|
+
|
17
|
+
def self.configure(&block)
|
18
|
+
configuration = ::Tosspayments2::Rails.configuration
|
19
|
+
yield(configuration) if block
|
20
|
+
configuration
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1,53 @@
|
|
1
|
+
module Tosspayments2
|
2
|
+
module Rails
|
3
|
+
VERSION: String
|
4
|
+
module ScriptTagHelper
|
5
|
+
def tosspayments_script_tag: (?async: bool, ?defer: bool, ?version: String) -> String
|
6
|
+
end
|
7
|
+
|
8
|
+
class Configuration
|
9
|
+
@client_key: String?
|
10
|
+
@secret_key: String?
|
11
|
+
@widget_version: String
|
12
|
+
@api_base: String
|
13
|
+
@timeout: Integer
|
14
|
+
@logger: untyped
|
15
|
+
|
16
|
+
attr_accessor client_key: String?
|
17
|
+
attr_accessor secret_key: String?
|
18
|
+
attr_accessor widget_version: String
|
19
|
+
attr_accessor api_base: String
|
20
|
+
attr_accessor timeout: Integer
|
21
|
+
attr_accessor logger: untyped
|
22
|
+
end
|
23
|
+
|
24
|
+
class Client
|
25
|
+
def initialize: (?secret_key: String, ?api_base: String, ?timeout: Integer) -> void
|
26
|
+
def confirm: (payment_key: String, order_id: String, amount: Integer) -> Hash[Symbol, untyped]
|
27
|
+
def cancel: (payment_key: String, cancel_reason: String, ?amount: Integer) -> Hash[Symbol, untyped]
|
28
|
+
end
|
29
|
+
class CallbackVerifier
|
30
|
+
def match_amount?: (order_id: String, amount: Integer) { (String) -> Integer } -> bool
|
31
|
+
end
|
32
|
+
class APIError < StandardError
|
33
|
+
@status: Integer
|
34
|
+
@body: Hash[Symbol, untyped]?
|
35
|
+
@code: String?
|
36
|
+
attr_reader status: Integer
|
37
|
+
attr_reader body: Hash[Symbol, untyped]?
|
38
|
+
attr_reader code: String?
|
39
|
+
end
|
40
|
+
class VerificationError < StandardError; end
|
41
|
+
class ConfigurationError < StandardError; end
|
42
|
+
|
43
|
+
module ControllerConcern
|
44
|
+
end
|
45
|
+
|
46
|
+
class WebhookVerifier
|
47
|
+
def initialize: (?secret_key: String) -> void
|
48
|
+
def verify?: (untyped, untyped) -> bool
|
49
|
+
end
|
50
|
+
|
51
|
+
def self.configure: () { (Configuration) -> void } -> Configuration
|
52
|
+
end
|
53
|
+
end
|