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.
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,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/gem_tasks'
4
+ task default: %i[]
@@ -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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tosspayments2
4
+ module Rails
5
+ VERSION = '0.2.0'
6
+ end
7
+ 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