spree_gmo_pg 1.0.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.
Files changed (35) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +5 -0
  3. data/Gemfile +5 -0
  4. data/LICENSE +673 -0
  5. data/README.md +58 -0
  6. data/app/controllers/spree/gmo_pg_callback_controller.rb +37 -0
  7. data/app/controllers/spree/payment_sources_controller.rb +47 -0
  8. data/app/controllers/spree_gmo_pg/checkout_controller_decorator.rb +40 -0
  9. data/app/javascript/spree_gmo_pg/controllers/gmo_pg_payment_controller.js +295 -0
  10. data/app/javascript/spree_gmo_pg/index.js +11 -0
  11. data/app/models/spree/payment_method/gmo_pg.rb +418 -0
  12. data/app/models/spree_gmo_pg/order_updater_decorator.rb +78 -0
  13. data/app/overrides/hide_preference_fields_for_payment_method_gmo_pg.rb +12 -0
  14. data/app/overrides/spree/gmo_pg_javascript_import.rb +28 -0
  15. data/app/views/spree/admin/payment_methods/custom_form_fields/_gmo_pg_form_fields.html.erb +63 -0
  16. data/app/views/spree/checkout/payment/_gmo_pg.html.erb +154 -0
  17. data/app/views/spree/checkout/payment/_saved_cards.html.erb +36 -0
  18. data/app/views/spree/gmo_pg_callback/callback.html.erb +51 -0
  19. data/app/views/spree/shared/_error.html.erb +3 -0
  20. data/config/importmap.rb +9 -0
  21. data/config/locales/en.yml +29 -0
  22. data/config/locales/ja.yml +29 -0
  23. data/config/routes.rb +10 -0
  24. data/lib/spree/payment_redirect_required.rb +23 -0
  25. data/lib/spree_gmo_pg/engine.rb +44 -0
  26. data/lib/spree_gmo_pg/version.rb +5 -0
  27. data/lib/spree_gmo_pg.rb +16 -0
  28. data/spec/controllers/spree/gmo_pg_callback_controller_spec.rb +46 -0
  29. data/spec/controllers/spree/payment_sources_controller_spec.rb +75 -0
  30. data/spec/factories/payment_method_factory.rb +19 -0
  31. data/spec/models/spree/order_updater_decorator_spec.rb +221 -0
  32. data/spec/models/spree/payment_method/gmo_pg_spec.rb +860 -0
  33. data/spec/spec_helper.rb +25 -0
  34. data/spree_gmo_pg.gemspec +32 -0
  35. metadata +152 -0
data/README.md ADDED
@@ -0,0 +1,58 @@
1
+ # SpreeGmoPg
2
+
3
+ `spree_gmo_pg` is a Spree extension that adds credit card payments through
4
+ [GMO Payment Gateway](https://www.gmo-pg.com/) (GMO-PG), one of the largest payment
5
+ providers in Japan.
6
+
7
+ Card details are tokenized in the browser by GMO's own JavaScript SDK, so raw card
8
+ numbers never reach your application.
9
+
10
+ ## Features
11
+
12
+ - Credit card payment via GMO-PG
13
+ - Tokenization in the browser using the GMO-PG Token SDK
14
+ - 3-D Secure 2.0, including the redirect-and-return flow
15
+ - Optional member management (saved cards) using GMO-PG site credentials
16
+ - Automatic capture for digital-only orders, via
17
+ [`spree_auto_capture_digital`](https://github.com/be-agile/spree_auto_capture_digital)
18
+ - Test mode that points at GMO's staging endpoints
19
+
20
+ ## Installation
21
+
22
+ Add this line to your application's Gemfile:
23
+
24
+ ```ruby
25
+ gem 'spree_gmo_pg'
26
+ ```
27
+
28
+ Then run:
29
+
30
+ ```bash
31
+ bundle install
32
+ bin/rails g spree_gmo_pg:install
33
+ ```
34
+
35
+ ## Configuration
36
+
37
+ Add the payment method from the Spree admin
38
+ (**Settings → Payment Methods → New**, provider `Spree::PaymentMethod::GmoPg`)
39
+ and fill in the credentials issued by GMO-PG:
40
+
41
+ | Setting | Meaning |
42
+ |---|---|
43
+ | Shop ID / Shop Password | Identifies your shop. Required |
44
+ | Site ID / Site Password | Only needed if you want to store cards for members |
45
+ | Test mode | On by default. Uses GMO's staging endpoints and SDK |
46
+
47
+ Leave test mode on until GMO has approved your production account.
48
+
49
+ ## Requirements
50
+
51
+ - Spree 5.3.6
52
+ - [`active_merchant_gmo_pg`](https://github.com/be-agile/active_merchant_gmo_pg) —
53
+ the ActiveMerchant gateway this extension builds on
54
+ - A GMO-PG merchant account
55
+
56
+ ## Licence
57
+
58
+ AGPL-3.0-or-later. See [LICENSE](LICENSE).
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spree
4
+ class GmoPgCallbackController < StoreController
5
+ skip_before_action :verify_authenticity_token, only: [ :callback ]
6
+
7
+ def callback
8
+ @order = Spree::Order.find_by(token: params[:token])
9
+
10
+ unless @order
11
+ Rails.logger.error "[GMO-PG 3DS Callback] Order not found: #{params[:token]}"
12
+ redirect_to spree.root_path, alert: 'ご注文が見つかりませんでした'
13
+ return
14
+ end
15
+
16
+ payment = @order.payments.valid.last
17
+
18
+ unless payment
19
+ Rails.logger.error "[GMO-PG 3DS Callback] Payment not found for order: #{@order.number}"
20
+ redirect_to spree.checkout_state_path(@order.state, token: @order.token)
21
+ return
22
+ end
23
+
24
+ # GMO-PGから返ってきたAccessIDを検証
25
+ gmo_pg_data = payment.private_metadata[:gmo_pg] || {}
26
+
27
+ if params[:AccessID].present? && gmo_pg_data[:access_id] != params[:AccessID]
28
+ Rails.logger.warn "[GMO-PG 3DS Callback] AccessID mismatch! Expected: #{gmo_pg_data[:access_id]}, Got: #{params[:AccessID]}"
29
+ end
30
+
31
+ Rails.logger.info "[GMO-PG 3DS Callback] Received callback for order: #{@order.number}"
32
+
33
+ # CheckoutController#updateに自動submitするHTMLを返す
34
+ render :callback, layout: false
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spree
4
+ class PaymentSourcesController < Spree::StoreController
5
+ before_action :authenticate_user!
6
+ before_action :load_payment_source
7
+
8
+ def destroy
9
+ # 権限チェック: 自分のカードのみ削除可能
10
+ unless @payment_source.user_id == spree_current_user.id
11
+ return render turbo_stream: turbo_stream.update(
12
+ "saved-cards-#{@payment_source.payment_method_id}",
13
+ partial: 'spree/shared/error',
14
+ locals: { message: Spree.t('gmo_pg.unauthorized') }
15
+ ), status: :forbidden
16
+ end
17
+
18
+ payment_method = @payment_source.payment_method
19
+
20
+ begin
21
+ # disable_customer_profileでGMO-PG削除+DB削除を実行
22
+ payment_method.disable_customer_profile(@payment_source)
23
+
24
+ # 更新後のカード一覧を返す
25
+ @payment_sources = spree_current_user.payment_sources.where(payment_method: payment_method)
26
+
27
+ render turbo_stream: turbo_stream.update(
28
+ "saved-cards-#{payment_method.id}",
29
+ partial: 'spree/checkout/payment/saved_cards',
30
+ locals: { payment_method: payment_method, payment_sources: @payment_sources }
31
+ )
32
+ rescue Spree::Core::GatewayError => e
33
+ render turbo_stream: turbo_stream.update(
34
+ "saved-cards-#{payment_method.id}",
35
+ partial: 'spree/shared/error',
36
+ locals: { message: e.message }
37
+ ), status: :unprocessable_entity
38
+ end
39
+ end
40
+
41
+ private
42
+
43
+ def load_payment_source
44
+ @payment_source = Spree::CreditCard.includes(:payment_method).find(params[:id])
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpreeGmoPg
4
+ module CheckoutControllerDecorator
5
+ # かつては他 engine と Spree::CheckoutControllerDecorator という同一定数を共有しており、
6
+ # ここで include + ActiveSupport::Concern を使うと共有モジュール全体が本体より後ろに入り、
7
+ # 他 engine の prepend(load_order 等)を無効化してしまうため prepend が必須だった。
8
+ # #1307 で engine ごとの名前空間に分離したのでその制約はなくなったが、他 engine の
9
+ # decorator と形を揃えて prepend + prepended フックのままにしている。
10
+ # @see https://github.com/be-agile/giga-repeat/issues/1307
11
+ def self.prepended(base)
12
+ # 3DSリダイレクトをハンドリング
13
+ base.around_action :handle_payment_redirect
14
+ end
15
+
16
+ private
17
+
18
+ def handle_payment_redirect
19
+ yield # 通常のアクション実行
20
+ rescue Spree::PaymentRedirectRequired => e
21
+ # 3DSリダイレクトが必要な場合
22
+ Rails.logger.info "[CheckoutController] Redirecting to 3DS authentication: #{e.redirect_url}"
23
+
24
+ # process_payments! の with_lock トランザクションは本例外の伝播で既にロールバック済み。
25
+ # コールバックでの再処理に必要な Access 情報を、ここ(トランザクション外)で永続化する。
26
+ Spree::PaymentMethod::GmoPg.persist_3ds_redirect_data(e.payment, e.gmo_pg_data)
27
+
28
+ respond_to do |format|
29
+ format.html { redirect_to e.redirect_url, allow_other_host: true }
30
+ format.turbo_stream do
31
+ # Turboリクエストの場合はカスタムredirectアクションを使う
32
+ escaped_url = ERB::Util.html_escape(e.redirect_url)
33
+ render turbo_stream: "<turbo-stream action=\"redirect\" url=\"#{escaped_url}\"></turbo-stream>".html_safe
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
39
+
40
+ Spree::CheckoutController.prepend(SpreeGmoPg::CheckoutControllerDecorator)
@@ -0,0 +1,295 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["numberInput", "expiryInput", "cvvInput", "nameInput", "token", "maskedCard", "errorContainer", "existingCardRadio", "newCardRadio", "newCardForm"]
5
+ static values = {
6
+ shopId: String,
7
+ testMode: { type: Boolean, default: true },
8
+ errorMessages: Object
9
+ }
10
+
11
+ connect() {
12
+ // GMO-PG SDK初期化
13
+ if (typeof Multipayment !== 'undefined') {
14
+ // テストモードの設定
15
+ if (this.testModeValue) {
16
+ Multipayment.init(this.shopIdValue)
17
+ console.log('GMO-PG SDK initialized (TEST MODE) with shop_id:', this.shopIdValue)
18
+ } else {
19
+ Multipayment.init(this.shopIdValue)
20
+ console.log('GMO-PG SDK initialized (PRODUCTION MODE) with shop_id:', this.shopIdValue)
21
+ }
22
+ } else {
23
+ console.error('Multipayment SDK not loaded')
24
+ }
25
+
26
+ // フォーム送信をインターセプト
27
+ // bind した関数を保持しないと removeEventListener が効かないため this に退避する。
28
+ // capture phase(第3引数 true)で登録することで、同じ form の submit を listen する
29
+ // turbo-stream-form コントローラより先に実行され、トークン未取得時に
30
+ // stopImmediatePropagation で turbo-stream-form の送信を確実に抑止できる。
31
+ this.boundHandleSubmit = this.handleSubmit.bind(this)
32
+ const form = this.element.closest('form')
33
+ if (form) {
34
+ form.addEventListener('submit', this.boundHandleSubmit, true)
35
+ }
36
+
37
+ // 初期状態でカード選択状態を処理
38
+ this.handleCardSelection()
39
+ }
40
+
41
+ disconnect() {
42
+ const form = this.element.closest('form')
43
+ if (form && this.boundHandleSubmit) {
44
+ form.removeEventListener('submit', this.boundHandleSubmit, true)
45
+ }
46
+ }
47
+
48
+ handleCardSelection() {
49
+ // 新規カード選択のラジオボタンが存在しない場合は何もしない
50
+ if (!this.hasNewCardRadioTarget) {
51
+ return
52
+ }
53
+
54
+ const newCardSelected = this.newCardRadioTarget.checked
55
+
56
+ if (newCardSelected) {
57
+ this.showNewCardForm()
58
+ } else {
59
+ this.hideNewCardForm()
60
+ }
61
+ }
62
+
63
+ showNewCardForm() {
64
+ if (this.hasNewCardFormTarget) {
65
+ this.newCardFormTarget.style.display = 'block'
66
+ this.enableNewCardInputs()
67
+ }
68
+ }
69
+
70
+ hideNewCardForm() {
71
+ if (this.hasNewCardFormTarget) {
72
+ this.newCardFormTarget.style.display = 'none'
73
+ this.disableNewCardInputs()
74
+ }
75
+ }
76
+
77
+ enableNewCardInputs() {
78
+ if (this.hasNewCardFormTarget) {
79
+ const inputs = this.newCardFormTarget.querySelectorAll('input')
80
+ inputs.forEach(input => {
81
+ input.disabled = false
82
+ if (input.hasAttribute('data-original-required')) {
83
+ input.required = true
84
+ }
85
+ })
86
+ }
87
+ }
88
+
89
+ disableNewCardInputs() {
90
+ if (this.hasNewCardFormTarget) {
91
+ const inputs = this.newCardFormTarget.querySelectorAll('input')
92
+ inputs.forEach(input => {
93
+ input.disabled = true
94
+ if (input.required) {
95
+ input.setAttribute('data-original-required', 'true')
96
+ input.required = false
97
+ }
98
+ })
99
+ }
100
+ }
101
+
102
+ handleSubmit(event) {
103
+ // このコントローラーの要素が非表示の場合(他の支払い方法が選択されている)は何もしない
104
+ if (this.element.offsetParent === null) {
105
+ return true
106
+ }
107
+
108
+ // 既存カードが選択されている場合はトークン取得をスキップ
109
+ if (this.hasExistingCardRadioTarget) {
110
+ const existingCardSelected = Array.from(this.existingCardRadioTargets || []).some(radio => radio.checked)
111
+ if (existingCardSelected) {
112
+ return true
113
+ }
114
+ }
115
+
116
+ // カード情報が入力されているか確認
117
+ const hasCardInput = this.hasNumberInputTarget && this.numberInputTarget.value.trim() !== ''
118
+
119
+ // トークンが既にセットされていて、かつカード番号が空(既にクリア済み)なら送信続行
120
+ if (this.hasTokenTarget && this.tokenTarget.value && !hasCardInput) {
121
+ return true
122
+ }
123
+
124
+ // トークン未取得、またはカード情報が変更された場合はトークンを再取得
125
+ // stopImmediatePropagation で、同じ form の submit を listen する
126
+ // turbo-stream-form コントローラがトークン未取得のまま送信してしまうのを防ぐ。
127
+ // (capture phase で登録しているため turbo-stream-form より先に実行される)
128
+ event.preventDefault()
129
+ event.stopImmediatePropagation()
130
+
131
+ // エラーメッセージを非表示
132
+ this.hideError()
133
+
134
+ this.getToken()
135
+ return false
136
+ }
137
+
138
+ getToken() {
139
+ // 有効期限をMM/YYYY → YYMMに変換
140
+ const expiry = this.expiryInputTarget.value
141
+ const [month, year] = expiry.split('/')
142
+ const yymm = year ? year.slice(-2) + month : ''
143
+
144
+ const cardData = {
145
+ cardno: this.numberInputTarget.value.replace(/\s/g, ''),
146
+ expire: yymm,
147
+ securitycode: this.cvvInputTarget.value,
148
+ holdername: this.nameInputTarget.value,
149
+ tokennumber: '1'
150
+ }
151
+
152
+ console.log('=== GMO-PG Token Request Debug ===')
153
+ console.log('Original expiry input:', expiry)
154
+ console.log('Converted expire (YYMM):', yymm)
155
+ console.log('Card data being sent:', {
156
+ cardno: cardData.cardno.replace(/./g, '*'), // マスク表示
157
+ expire: cardData.expire,
158
+ securitycode: '***',
159
+ holdername: cardData.holdername,
160
+ tokennumber: cardData.tokennumber
161
+ })
162
+ console.log('================================')
163
+
164
+ // GMO-PG SDKはbind()を使ったコールバックを受け付けないため、
165
+ // thisを保持するためにアロー関数でラップする
166
+ const self = this
167
+ Multipayment.getToken(cardData, (result) => {
168
+ self.handleTokenResponse(result)
169
+ })
170
+ }
171
+
172
+ handleTokenResponse(result) {
173
+ console.log('Token response:', result)
174
+
175
+ // resultCodeは数値または文字列で返ってくる可能性があるため、文字列に変換して比較
176
+ const resultCodeStr = String(result.resultCode)
177
+ console.log('resultCodeStr:', resultCodeStr, 'type:', typeof resultCodeStr)
178
+
179
+ if (resultCodeStr !== '000') {
180
+ console.log('Error detected! resultCode:', resultCodeStr)
181
+ const errorMessage = this.getErrorMessage(result.resultCode)
182
+ console.log('Error message:', errorMessage)
183
+ this.showError(errorMessage)
184
+ return
185
+ }
186
+
187
+ // トークンとマスク済みカード番号をセット
188
+ this.tokenTarget.value = result.tokenObject.token
189
+
190
+ // マスク済みカード番号から下4桁を抽出
191
+ const maskedCard = result.tokenObject.maskedCardNo
192
+ const lastDigits = maskedCard.replace(/\*/g, '').slice(-4)
193
+ this.maskedCardTarget.value = lastDigits
194
+
195
+ console.log('Token acquired successfully')
196
+
197
+ // 生カード情報をクリア(セキュリティ対策)
198
+ this.numberInputTarget.value = ''
199
+ this.cvvInputTarget.value = ''
200
+
201
+ // フォーム送信
202
+ // トークン取得が完了したのでフォームを送信する。
203
+ // form.submit() はネイティブ送信で submit イベントを発火しないため、
204
+ // capture phase で登録した handleSubmit が再度呼ばれて二重にトークン取得を
205
+ // 試みることがなく、確実に1回だけ送信できる。
206
+ const form = this.element.closest('form')
207
+ if (form) {
208
+ form.submit()
209
+ }
210
+ }
211
+
212
+ getErrorMessage(resultCode) {
213
+ // GMO-PGエラーコードに応じたメッセージ(i18n対応)
214
+ const codeStr = String(resultCode)
215
+ const prefix = codeStr.substring(0, 3)
216
+ const messages = this.errorMessagesValue
217
+ return messages[prefix] || `${messages['default']} (${codeStr})`
218
+ }
219
+
220
+ showError(message) {
221
+ // エラーメッセージをフォーム上部に表示
222
+ if (this.hasErrorContainerTarget) {
223
+ this.errorContainerTarget.textContent = message
224
+ this.errorContainerTarget.style.display = 'block'
225
+
226
+ // フォーム上部にスムーズスクロール
227
+ this.errorContainerTarget.scrollIntoView({
228
+ behavior: 'smooth',
229
+ block: 'nearest'
230
+ })
231
+ } else {
232
+ // フォールバック: エラーコンテナがない場合はalertを使用
233
+ console.error('Error container not found! Using alert fallback.')
234
+ alert(message)
235
+ }
236
+ }
237
+
238
+ hideError() {
239
+ // エラーメッセージを非表示
240
+ if (this.hasErrorContainerTarget) {
241
+ this.errorContainerTarget.style.display = 'none'
242
+ this.errorContainerTarget.textContent = ''
243
+ }
244
+ }
245
+
246
+ deleteCard(event) {
247
+ // カード削除処理
248
+ event.preventDefault()
249
+
250
+ const button = event.currentTarget
251
+ const url = button.dataset.url
252
+ const turboFrame = button.dataset.turboFrame
253
+ const confirmMessage = button.dataset.turboConfirm
254
+
255
+ // 確認ダイアログを表示
256
+ if (!confirm(confirmMessage)) {
257
+ console.log('Card deletion cancelled by user')
258
+ return
259
+ }
260
+
261
+ console.log('Deleting card:', url)
262
+
263
+ // CSRFトークンを取得
264
+ const csrfToken = document.querySelector('[name="csrf-token"]')?.content
265
+
266
+ // DELETEリクエストを送信
267
+ fetch(url, {
268
+ method: 'DELETE',
269
+ headers: {
270
+ 'X-CSRF-Token': csrfToken,
271
+ 'Accept': 'text/vnd.turbo-stream.html'
272
+ },
273
+ credentials: 'same-origin'
274
+ })
275
+ .then(response => {
276
+ if (!response.ok) {
277
+ throw new Error(`HTTP error! status: ${response.status}`)
278
+ }
279
+ return response.text()
280
+ })
281
+ .then(html => {
282
+ // Turbo Streamレスポンスを処理してDOMを更新
283
+ if (typeof Turbo !== 'undefined' && Turbo.renderStreamMessage) {
284
+ Turbo.renderStreamMessage(html)
285
+ console.log('Card deleted successfully')
286
+ } else {
287
+ console.error('Turbo is not available')
288
+ }
289
+ })
290
+ .catch(error => {
291
+ console.error('Card deletion failed:', error)
292
+ alert('カードの削除に失敗しました。もう一度お試しください。')
293
+ })
294
+ }
295
+ }
@@ -0,0 +1,11 @@
1
+ import { lazyLoadControllersFromManifest } from "spree/storefront/helpers/lazy_load_controllers_with_manifest"
2
+
3
+ const controllers = ["gmo-pg-payment"]
4
+ const manifest = {
5
+ "gmo-pg-payment": "spree_gmo_pg/controllers/gmo_pg_payment_controller"
6
+ }
7
+
8
+ document.addEventListener('turbo:load', function() {
9
+ const application = window.Stimulus
10
+ lazyLoadControllersFromManifest(controllers, null, application, manifest)
11
+ });