bootpay 1.2.0 → 3.1.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 (106) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +8 -9
  3. data/.rspec +2 -0
  4. data/CHANGELOG.md +107 -21
  5. data/CODE_OF_CONDUCT.md +1 -1
  6. data/Gemfile +1 -3
  7. data/LICENSE.txt +1 -1
  8. data/README.md +309 -196
  9. data/Rakefile +3 -3
  10. data/bin/console +1 -1
  11. data/bootpay.gemspec +22 -20
  12. data/lib/bootpay/bootpay-rest-client.rb +51 -0
  13. data/lib/bootpay/concern/authenticate.rb +69 -0
  14. data/lib/bootpay/concern/cash_receipt.rb +83 -0
  15. data/lib/bootpay/concern/easy.rb +18 -0
  16. data/lib/bootpay/concern/escrow.rb +25 -0
  17. data/lib/bootpay/concern/payment.rb +126 -0
  18. data/lib/bootpay/concern/reseller.rb +97 -0
  19. data/lib/bootpay/concern/rest.rb +56 -0
  20. data/lib/bootpay/concern/sdk.rb +52 -0
  21. data/lib/bootpay/concern/seller.rb +15 -0
  22. data/lib/bootpay/concern/service.rb +15 -0
  23. data/lib/bootpay/concern/subscription.rb +211 -0
  24. data/lib/bootpay/concern/token.rb +33 -0
  25. data/lib/bootpay/concern/user_token.rb +23 -0
  26. data/lib/bootpay/concern/wallet.rb +20 -0
  27. data/lib/bootpay/concern/webhook.rb +18 -0
  28. data/lib/bootpay/concern.rb +35 -0
  29. data/lib/bootpay-backend-ruby.rb +6 -0
  30. data/lib/bootpay.rb +10 -66
  31. data/lib/bootpay_storage/bootpay-storage-rest-client.rb +46 -0
  32. data/lib/bootpay_storage/concern/csv.rb +18 -0
  33. data/lib/bootpay_storage/concern/image.rb +30 -0
  34. data/lib/bootpay_storage/concern/rest.rb +176 -0
  35. data/lib/bootpay_storage/concern/token.rb +9 -0
  36. data/lib/bootpay_storage/concern.rb +12 -0
  37. data/lib/bootpay_storage/response.rb +14 -0
  38. data/lib/bootpay_store/bootpay-store-rest-client.rb +62 -0
  39. data/lib/bootpay_store/concern/alimtalk_message.rb +67 -0
  40. data/lib/bootpay_store/concern/alimtalk_official.rb +63 -0
  41. data/lib/bootpay_store/concern/alimtalk_optout.rb +72 -0
  42. data/lib/bootpay_store/concern/alimtalk_send.rb +92 -0
  43. data/lib/bootpay_store/concern/alimtalk_sender.rb +105 -0
  44. data/lib/bootpay_store/concern/alimtalk_template.rb +215 -0
  45. data/lib/bootpay_store/concern/alimtalk_webhook.rb +84 -0
  46. data/lib/bootpay_store/concern/faq.rb +97 -0
  47. data/lib/bootpay_store/concern/inquiry.rb +138 -0
  48. data/lib/bootpay_store/concern/invoice.rb +102 -0
  49. data/lib/bootpay_store/concern/mall_setting.rb +385 -0
  50. data/lib/bootpay_store/concern/notice.rb +100 -0
  51. data/lib/bootpay_store/concern/order.rb +74 -0
  52. data/lib/bootpay_store/concern/order_subscription.rb +391 -0
  53. data/lib/bootpay_store/concern/payment.rb +119 -0
  54. data/lib/bootpay_store/concern/product.rb +175 -0
  55. data/lib/bootpay_store/concern/product_qna.rb +149 -0
  56. data/lib/bootpay_store/concern/product_review.rb +145 -0
  57. data/lib/bootpay_store/concern/rest.rb +139 -0
  58. data/lib/bootpay_store/concern/store.rb +31 -0
  59. data/lib/bootpay_store/concern/supervisor.rb +211 -0
  60. data/lib/bootpay_store/concern/token.rb +24 -0
  61. data/lib/bootpay_store/concern/user.rb +325 -0
  62. data/lib/bootpay_store/concern/user_group.rb +221 -0
  63. data/lib/bootpay_store/concern/webhook.rb +17 -0
  64. data/lib/bootpay_store/concern.rb +55 -0
  65. data/lib/bootpay_store/response.rb +14 -0
  66. data/lib/version.rb +9 -0
  67. metadata +82 -49
  68. data/.DS_Store +0 -0
  69. data/.env.example +0 -23
  70. data/lib/.DS_Store +0 -0
  71. data/lib/bootpay/.DS_Store +0 -0
  72. data/lib/bootpay/authentication.rb +0 -61
  73. data/lib/bootpay/automatic_transfer.rb +0 -56
  74. data/lib/bootpay/billing.rb +0 -157
  75. data/lib/bootpay/cancel.rb +0 -29
  76. data/lib/bootpay/cash_receipt.rb +0 -77
  77. data/lib/bootpay/commerce/cart.rb +0 -20
  78. data/lib/bootpay/commerce/category.rb +0 -39
  79. data/lib/bootpay/commerce/commerce_resource.rb +0 -196
  80. data/lib/bootpay/commerce/coupon.rb +0 -41
  81. data/lib/bootpay/commerce/invoice.rb +0 -46
  82. data/lib/bootpay/commerce/order.rb +0 -61
  83. data/lib/bootpay/commerce/order_cancel.rb +0 -52
  84. data/lib/bootpay/commerce/order_subscription.rb +0 -117
  85. data/lib/bootpay/commerce/order_subscription_adjustment.rb +0 -29
  86. data/lib/bootpay/commerce/order_subscription_bill.rb +0 -50
  87. data/lib/bootpay/commerce/order_subscription_request.rb +0 -60
  88. data/lib/bootpay/commerce/point.rb +0 -36
  89. data/lib/bootpay/commerce/product.rb +0 -63
  90. data/lib/bootpay/commerce/store.rb +0 -21
  91. data/lib/bootpay/commerce/user.rb +0 -75
  92. data/lib/bootpay/commerce/user_group.rb +0 -70
  93. data/lib/bootpay/easy.rb +0 -25
  94. data/lib/bootpay/escrow.rb +0 -24
  95. data/lib/bootpay/link.rb +0 -60
  96. data/lib/bootpay/naverpay.rb +0 -17
  97. data/lib/bootpay/payment_resource.rb +0 -31
  98. data/lib/bootpay/reseller.rb +0 -12
  99. data/lib/bootpay/rest.rb +0 -46
  100. data/lib/bootpay/submit.rb +0 -20
  101. data/lib/bootpay/token.rb +0 -33
  102. data/lib/bootpay/verification.rb +0 -55
  103. data/lib/bootpay/version.rb +0 -25
  104. data/lib/bootpay/wallet.rb +0 -56
  105. data/lib/bootpay_commerce.rb +0 -152
  106. /data/lib/{response.rb → bootpay/response.rb} +0 -0
@@ -0,0 +1,175 @@
1
+ module BootpayStore::Concern::Product
2
+ extend ActiveSupport::Concern
3
+
4
+ included do
5
+ # 상품 목록을 조회한다 (V1 Mall API)
6
+ # Comment by Codex
7
+ # @date: 2026-02-23
8
+ # @date: 26-08-14 ⚠️ keyword 는 서버(v1/products_controller#index)가 읽지 않는다.
9
+ # 컨트롤러는 page/limit/category_id/ex_uid/sort 만 사용 — keyword 를 보내도 조용히 무시된다.
10
+ # 하위호환 때문에 인자는 남겨두되, 검색이 필요하면 서버 지원 추가가 선행되어야 한다.
11
+ # @date: 26-08-26 ex_uid 인자 추가. 컨트롤러가 params[:ex_uid] 를 읽는데 SDK 에 인자가 없어
12
+ # 외부 UID 로 상품을 찾을 수 없었다.
13
+ def products(page: 1, limit: 20, category_id: nil, ex_uid: nil, sort: nil, keyword: nil,
14
+ user_jwt: nil, idempotency_key: nil)
15
+ request(
16
+ uri: 'products',
17
+ method: :get,
18
+ headers: {
19
+ 'Idempotency-Key' => idempotency_key.presence || SecureRandom.uuid,
20
+ 'Bootpay-User-JWT' => user_jwt
21
+ }.compact,
22
+ params: {
23
+ page: page,
24
+ limit: limit,
25
+ category_id: category_id,
26
+ ex_uid: ex_uid,
27
+ sort: sort,
28
+ keyword: keyword
29
+ }.compact
30
+ )
31
+ end
32
+
33
+ # 상품 상세를 조회한다 (V1 Mall API)
34
+ # Comment by Codex
35
+ # @date: 2026-02-23
36
+ def product_detail(product_id:, user_jwt: nil, idempotency_key: nil)
37
+ request(
38
+ uri: "products/#{product_id}",
39
+ method: :get,
40
+ headers: {
41
+ 'Idempotency-Key' => idempotency_key.presence || SecureRandom.uuid,
42
+ 'Bootpay-User-JWT' => user_jwt
43
+ }.compact
44
+ )
45
+ end
46
+
47
+ # 상품 정보를 가져온다
48
+ # Comment by GOSOMI
49
+ # @date: 2025-10-10
50
+ # @date: 26-08-14 product_detail 과 uri·동작이 같다(차이는 user_jwt 인자 유무).
51
+ # 중복이지만 기존 사용자가 있을 수 있어 제거하지 않는다 — 신규 코드는 product_detail 을 쓸 것.
52
+ # @date: 26-08-26 user_jwt 를 여기에도 추가했다. 매뉴얼이 GET /v1/products/:id 에 user_jwt 를
53
+ # 안내하는데 이 메서드만 헤더를 안 보내 회원 컨텍스트 조회가 안 됐다. 이제 product_detail 과 동작이 같다.
54
+ def lookup_product(product_id:, user_jwt: nil, idempotency_key: nil)
55
+ request(
56
+ uri: "products/#{product_id}",
57
+ method: :get,
58
+ headers: {
59
+ 'Idempotency-Key' => idempotency_key.presence || SecureRandom.uuid,
60
+ 'Bootpay-User-JWT' => user_jwt
61
+ }.compact
62
+ )
63
+ end
64
+
65
+ # 상품을 등록한다 (POST /v1/products)
66
+ # @comment_by Claude (alfred)
67
+ # @date: 26-08-14
68
+ # images 가 있으면 multipart, 없으면 JSON 으로 보낸다.
69
+ # 컨트롤러가 읽는 필드는 _product_params 참조 — 여기 명시하지 않은 값도 attrs 로 그대로 전달된다.
70
+ def product_create(name:, display_price: nil, desc: nil, content: nil, category_id: nil,
71
+ type: nil, stock: nil, status_sale: nil, status_display: nil,
72
+ use_subscription: nil, subscription_setting_id: nil,
73
+ images: nil, save_by: nil, idempotency_key: nil, **attrs)
74
+ payload = {
75
+ name: name,
76
+ display_price: display_price,
77
+ desc: desc,
78
+ content: content,
79
+ category_id: category_id,
80
+ type: type,
81
+ stock: stock,
82
+ status_sale: status_sale,
83
+ status_display: status_display,
84
+ use_subscription: use_subscription,
85
+ subscription_setting_id: subscription_setting_id,
86
+ save_by: save_by
87
+ }.merge(attrs).compact
88
+
89
+ headers = {
90
+ 'Idempotency-Key' => idempotency_key.presence || SecureRandom.uuid,
91
+ 'Bootpay-Role' => 'manager'
92
+ }
93
+
94
+ if images.present?
95
+ form = payload.each_with_object({}) { |(k, v), h| h[k.to_s] = multipart_value(v) }
96
+ Array(images).each_with_index { |image, i| form["images[#{i}]"] = multipart_file(image) }
97
+ post_multipart(uri: 'products', form: form, headers: headers)
98
+ else
99
+ request(uri: 'products', method: :post, headers: headers, payload: payload)
100
+ end
101
+ end
102
+
103
+ # 상품을 수정한다 (PUT /v1/products/:id)
104
+ # @comment_by Claude (alfred)
105
+ # @date: 26-08-14
106
+ # 바뀐 값만 보내면 된다. category_id 는 키 존재 여부로 '해제 의사'를 판별하므로 주의.
107
+ def product_update(product_id:, name: nil, display_price: nil, desc: nil, content: nil,
108
+ category_id: nil, stock: nil, status_sale: nil, status_display: nil,
109
+ save_by: nil, idempotency_key: nil, **attrs)
110
+ request(
111
+ uri: "products/#{product_id}",
112
+ method: :put,
113
+ headers: {
114
+ 'Idempotency-Key' => idempotency_key.presence || SecureRandom.uuid,
115
+ 'Bootpay-Role' => 'manager'
116
+ },
117
+ payload: {
118
+ name: name,
119
+ display_price: display_price,
120
+ desc: desc,
121
+ content: content,
122
+ category_id: category_id,
123
+ stock: stock,
124
+ status_sale: status_sale,
125
+ status_display: status_display,
126
+ save_by: save_by
127
+ }.merge(attrs).compact
128
+ )
129
+ end
130
+
131
+ # 상품을 삭제한다 (DELETE /v1/products/:id)
132
+ # @comment_by Claude (alfred)
133
+ # @date: 26-08-14
134
+ def product_delete(product_id:, idempotency_key: nil)
135
+ request(
136
+ uri: "products/#{product_id}",
137
+ method: :delete,
138
+ headers: {
139
+ 'Idempotency-Key' => idempotency_key.presence || SecureRandom.uuid,
140
+ 'Bootpay-Role' => 'manager'
141
+ }
142
+ )
143
+ end
144
+
145
+ # 상품 판매/노출 상태를 변경한다 (PUT /v1/products/:id/status)
146
+ # @comment_by Claude (alfred)
147
+ # @date: 26-08-14
148
+ # 컨트롤러 _status_params 기준. 재고(stock)는 여기가 아니라 product_update 로 바꾼다.
149
+ def product_status(product_id:, status_sale: nil, status_display: nil, status_frozen: nil,
150
+ status_review: nil, use_display_period: nil, display_start_at: nil, display_end_at: nil,
151
+ use_sale_period: nil, sale_start_at: nil, sale_end_at: nil,
152
+ idempotency_key: nil, **attrs)
153
+ request(
154
+ uri: "products/#{product_id}/status",
155
+ method: :put,
156
+ headers: {
157
+ 'Idempotency-Key' => idempotency_key.presence || SecureRandom.uuid,
158
+ 'Bootpay-Role' => 'manager'
159
+ },
160
+ payload: {
161
+ status_sale: status_sale,
162
+ status_display: status_display,
163
+ status_frozen: status_frozen,
164
+ status_review: status_review,
165
+ use_display_period: use_display_period,
166
+ display_start_at: display_start_at,
167
+ display_end_at: display_end_at,
168
+ use_sale_period: use_sale_period,
169
+ sale_start_at: sale_start_at,
170
+ sale_end_at: sale_end_at
171
+ }.merge(attrs).compact
172
+ )
173
+ end
174
+ end
175
+ end
@@ -0,0 +1,149 @@
1
+ module BootpayStore::Concern::ProductQna
2
+ extend ActiveSupport::Concern
3
+
4
+ included do
5
+ # 상품문의 목록 (GET /v1/product-qnas)
6
+ # @comment_by Claude
7
+ # @date: 26-09-29
8
+ # 고객 모드는 product_id 필수. 다른 사람의 비밀글은 is_viewable: false 로 본문 없이 온다.
9
+ # 회원(user_id·login_id·user_jwt)을 보내면 그 회원이 쓴 비밀글 본문이 보인다.
10
+ # supervisor: true + view: 'all' 이면 몰 전체(숨김 포함) — 이때 product_id 는 선택 필터다.
11
+ def product_qnas(product_id: nil, view: nil, page: 1, limit: 20, user_id: nil, login_id: nil, user_jwt: nil,
12
+ supervisor: false, idempotency_key: nil)
13
+ request(
14
+ uri: 'product-qnas',
15
+ method: :get,
16
+ headers: {
17
+ 'Idempotency-Key' => idempotency_key.presence || SecureRandom.uuid,
18
+ 'Bootpay-Role' => supervisor ? 'supervisor' : 'user',
19
+ 'Bootpay-User-JWT' => user_jwt
20
+ }.compact,
21
+ params: {
22
+ product_id: product_id,
23
+ view: view,
24
+ page: page,
25
+ limit: limit,
26
+ user_id: user_id,
27
+ login_id: login_id
28
+ }.compact
29
+ )
30
+ end
31
+
32
+ # 상품문의 단건 (GET /v1/product-qnas/:id)
33
+ # @comment_by Claude
34
+ # @date: 26-09-29
35
+ # 다른 사람의 비밀글은 403 PRODUCT_QNA_SECRET_FORBIDDEN. supervisor: true 면 비밀글·숨김 글도 본다.
36
+ def product_qna_detail(product_qna_id:, user_id: nil, login_id: nil, user_jwt: nil, supervisor: false,
37
+ idempotency_key: nil)
38
+ request(
39
+ uri: "product-qnas/#{product_qna_id}",
40
+ method: :get,
41
+ headers: {
42
+ 'Idempotency-Key' => idempotency_key.presence || SecureRandom.uuid,
43
+ 'Bootpay-Role' => supervisor ? 'supervisor' : 'user',
44
+ 'Bootpay-User-JWT' => user_jwt
45
+ }.compact,
46
+ params: {
47
+ user_id: user_id,
48
+ login_id: login_id
49
+ }.compact
50
+ )
51
+ end
52
+
53
+ # 상품문의 작성 (POST /v1/product-qnas)
54
+ # @comment_by Claude
55
+ # @date: 26-09-29
56
+ # 회원(user_id·login_id·user_jwt) 또는 비회원(guest_name + guest_password, 몰이 비회원 작성을 허용할 때).
57
+ # 회원 문의면 guest_* 는 무시된다.
58
+ def product_qna_create(product_id:, content:, title: nil, product_option_id: nil, option_text: nil,
59
+ is_secret: nil, guest_name: nil, guest_password: nil,
60
+ user_id: nil, login_id: nil, user_jwt: nil, idempotency_key: nil)
61
+ request(
62
+ uri: 'product-qnas',
63
+ method: :post,
64
+ headers: {
65
+ 'Idempotency-Key' => idempotency_key.presence || SecureRandom.uuid,
66
+ 'Bootpay-Role' => 'user',
67
+ 'Bootpay-User-JWT' => user_jwt
68
+ }.compact,
69
+ payload: {
70
+ product_id: product_id,
71
+ title: title,
72
+ content: content,
73
+ product_option_id: product_option_id,
74
+ option_text: option_text,
75
+ is_secret: is_secret,
76
+ guest_name: guest_name,
77
+ guest_password: guest_password,
78
+ user_id: user_id,
79
+ login_id: login_id
80
+ }.compact
81
+ )
82
+ end
83
+
84
+ # 상품문의 수정 (PUT /v1/product-qnas/:id) — 작성 회원 본인 또는 비회원 비밀번호, 답변 전만
85
+ # @comment_by Claude
86
+ # @date: 26-09-29
87
+ # 답변이 달린 문의는 409 PRODUCT_QNA_ALREADY_ANSWERED, 비회원 비밀번호가 틀리면 403 PRODUCT_QNA_GUEST_PASSWORD_INVALID.
88
+ def product_qna_update(product_qna_id:, title: nil, content: nil, is_secret: nil, guest_password: nil,
89
+ user_id: nil, login_id: nil, user_jwt: nil, idempotency_key: nil)
90
+ request(
91
+ uri: "product-qnas/#{product_qna_id}",
92
+ method: :put,
93
+ headers: {
94
+ 'Idempotency-Key' => idempotency_key.presence || SecureRandom.uuid,
95
+ 'Bootpay-Role' => 'user',
96
+ 'Bootpay-User-JWT' => user_jwt
97
+ }.compact,
98
+ payload: {
99
+ title: title,
100
+ content: content,
101
+ is_secret: is_secret,
102
+ guest_password: guest_password,
103
+ user_id: user_id,
104
+ login_id: login_id
105
+ }.compact
106
+ )
107
+ end
108
+
109
+ # 상품문의 삭제 (DELETE /v1/product-qnas/:id) — 작성 회원 본인·비회원 비밀번호 또는 supervisor
110
+ # @comment_by Claude
111
+ # @date: 26-09-29
112
+ # guest_password 는 URL 쿼리가 아닌 본문으로 보낸다(서버는 둘 다 받는다).
113
+ def product_qna_delete(product_qna_id:, guest_password: nil, user_id: nil, login_id: nil, user_jwt: nil,
114
+ supervisor: false, idempotency_key: nil)
115
+ request(
116
+ uri: "product-qnas/#{product_qna_id}",
117
+ method: :delete,
118
+ headers: {
119
+ 'Idempotency-Key' => idempotency_key.presence || SecureRandom.uuid,
120
+ 'Bootpay-Role' => supervisor ? 'supervisor' : 'user',
121
+ 'Bootpay-User-JWT' => user_jwt
122
+ }.compact,
123
+ payload: {
124
+ guest_password: guest_password,
125
+ user_id: user_id,
126
+ login_id: login_id
127
+ }.compact
128
+ )
129
+ end
130
+
131
+ # 상품문의 답변 등록·수정 (PUT /v1/product-qnas/:id/answer) — supervisor 전용
132
+ # @comment_by Claude
133
+ # @date: 26-09-29
134
+ # 답변이 없으면 만들고, 있으면 내용을 바꾼다. 응답은 답변이 반영된 상품문의 객체다.
135
+ def product_qna_answer(product_qna_id:, content:, idempotency_key: nil)
136
+ request(
137
+ uri: "product-qnas/#{product_qna_id}/answer",
138
+ method: :put,
139
+ headers: {
140
+ 'Idempotency-Key' => idempotency_key.presence || SecureRandom.uuid,
141
+ 'Bootpay-Role' => 'supervisor'
142
+ },
143
+ payload: {
144
+ content: content
145
+ }
146
+ )
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,145 @@
1
+ module BootpayStore::Concern::ProductReview
2
+ extend ActiveSupport::Concern
3
+
4
+ included do
5
+ # 상품평 목록 (GET /v1/reviews)
6
+ # @comment_by Claude
7
+ # @date: 26-09-29
8
+ # 회원 모드: user_id·login_id·user_jwt 로 정한 회원이 쓴 상품평만 본다.
9
+ # supervisor: true 면 몰 전체 상품평 — 이때 user_id 는 작성 회원 필터(선택)다.
10
+ # 상품 상세의 공개 상품평 목록은 기존 GET /v1/products/:id/reviews 를 쓴다.
11
+ def reviews(page: 1, limit: 20, product_id: nil, view: nil, user_id: nil, login_id: nil, user_jwt: nil,
12
+ supervisor: false, idempotency_key: nil)
13
+ request(
14
+ uri: 'reviews',
15
+ method: :get,
16
+ headers: {
17
+ 'Idempotency-Key' => idempotency_key.presence || SecureRandom.uuid,
18
+ 'Bootpay-Role' => supervisor ? 'supervisor' : 'user',
19
+ 'Bootpay-User-JWT' => user_jwt
20
+ }.compact,
21
+ params: {
22
+ page: page,
23
+ limit: limit,
24
+ product_id: product_id,
25
+ view: view,
26
+ user_id: user_id,
27
+ login_id: login_id
28
+ }.compact
29
+ )
30
+ end
31
+
32
+ # 상품평 단건 (GET /v1/reviews/:id)
33
+ # @comment_by Claude
34
+ # @date: 26-09-29
35
+ # 공개 상품평은 누구나, 숨김 상품평은 작성 회원 본인 또는 supervisor 만 본다.
36
+ def review_detail(product_review_id:, user_id: nil, login_id: nil, user_jwt: nil, supervisor: false,
37
+ idempotency_key: nil)
38
+ request(
39
+ uri: "reviews/#{product_review_id}",
40
+ method: :get,
41
+ headers: {
42
+ 'Idempotency-Key' => idempotency_key.presence || SecureRandom.uuid,
43
+ 'Bootpay-Role' => supervisor ? 'supervisor' : 'user',
44
+ 'Bootpay-User-JWT' => user_jwt
45
+ }.compact,
46
+ params: {
47
+ user_id: user_id,
48
+ login_id: login_id
49
+ }.compact
50
+ )
51
+ end
52
+
53
+ # 상품평 작성 (POST /v1/reviews) — 회원 전용
54
+ # @comment_by Claude
55
+ # @date: 26-09-29
56
+ # order_id 는 회원의 구매확정 주문, product_id(·product_option_id)는 그 주문에 담긴 상품이어야 한다.
57
+ # images 는 사진 URL 최대 5개(문자열 또는 { url: } 해시). 파일 업로드는 지원하지 않는다.
58
+ def review_create(order_id:, product_id:, rating:, content:, product_option_id: nil, images: nil,
59
+ user_id: nil, login_id: nil, user_jwt: nil, idempotency_key: nil)
60
+ request(
61
+ uri: 'reviews',
62
+ method: :post,
63
+ headers: {
64
+ 'Idempotency-Key' => idempotency_key.presence || SecureRandom.uuid,
65
+ 'Bootpay-Role' => 'user',
66
+ 'Bootpay-User-JWT' => user_jwt
67
+ }.compact,
68
+ payload: {
69
+ user_id: user_id,
70
+ login_id: login_id,
71
+ order_id: order_id,
72
+ product_id: product_id,
73
+ product_option_id: product_option_id,
74
+ rating: rating,
75
+ content: content,
76
+ images: images
77
+ }.compact
78
+ )
79
+ end
80
+
81
+ # 상품평 수정 (PUT /v1/reviews/:id) — 작성 회원 본인, 작성 후 7일 이내
82
+ # @comment_by Claude
83
+ # @date: 26-09-29
84
+ # images 는 보내면 통째로 교체한다([] 면 모두 삭제). 7일이 지나면 REVIEW_EDIT_TIME_EXPIRED.
85
+ def review_update(product_review_id:, rating: nil, content: nil, images: nil,
86
+ user_id: nil, login_id: nil, user_jwt: nil, idempotency_key: nil)
87
+ request(
88
+ uri: "reviews/#{product_review_id}",
89
+ method: :put,
90
+ headers: {
91
+ 'Idempotency-Key' => idempotency_key.presence || SecureRandom.uuid,
92
+ 'Bootpay-Role' => 'user',
93
+ 'Bootpay-User-JWT' => user_jwt
94
+ }.compact,
95
+ payload: {
96
+ user_id: user_id,
97
+ login_id: login_id,
98
+ rating: rating,
99
+ content: content,
100
+ images: images
101
+ }.compact
102
+ )
103
+ end
104
+
105
+ # 상품평 삭제 (DELETE /v1/reviews/:id) — 작성 회원 본인 또는 supervisor
106
+ # @comment_by Claude
107
+ # @date: 26-09-29
108
+ # 지급된 적립금 회수·상품 통계 차감이 함께 일어난다. reason 은 운영자 모드의 삭제 사유.
109
+ def review_delete(product_review_id:, reason: nil, user_id: nil, login_id: nil, user_jwt: nil,
110
+ supervisor: false, idempotency_key: nil)
111
+ request(
112
+ uri: "reviews/#{product_review_id}",
113
+ method: :delete,
114
+ headers: {
115
+ 'Idempotency-Key' => idempotency_key.presence || SecureRandom.uuid,
116
+ 'Bootpay-Role' => supervisor ? 'supervisor' : 'user',
117
+ 'Bootpay-User-JWT' => user_jwt
118
+ }.compact,
119
+ params: {
120
+ user_id: user_id,
121
+ login_id: login_id,
122
+ reason: reason
123
+ }.compact
124
+ )
125
+ end
126
+
127
+ # 상품평 판매자 답글 등록·수정 (PUT /v1/reviews/:id/reply) — supervisor 전용
128
+ # @comment_by Claude
129
+ # @date: 26-09-29
130
+ # 답글이 없으면 만들고, 있으면 내용을 바꾼다(10자 이상). 응답은 답글이 반영된 상품평 객체다.
131
+ def review_reply(product_review_id:, content:, idempotency_key: nil)
132
+ request(
133
+ uri: "reviews/#{product_review_id}/reply",
134
+ method: :put,
135
+ headers: {
136
+ 'Idempotency-Key' => idempotency_key.presence || SecureRandom.uuid,
137
+ 'Bootpay-Role' => 'supervisor'
138
+ },
139
+ payload: {
140
+ content: content
141
+ }
142
+ )
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,139 @@
1
+ module BootpayStore::Concern::Rest
2
+ extend ActiveSupport::Concern
3
+
4
+ included do
5
+ private
6
+
7
+ # 요청 경로에 맞는 API 기본 주소.
8
+ # @comment_by Claude (alfred)
9
+ # @date: 26-09-18
10
+ # 알림톡 API 는 26-09-18 부터 커머스 API(api.bootapi.com/v1)가 아니라 메시지 API(message.bootapi.com, /v1 없음)가 받는다.
11
+ # 경로·파라미터·응답은 그대로이고 호스트만 다르다 — 그래서 알림톡 메서드는 고치지 않고 여기서 주소만 가른다.
12
+ # 옛 주소(/alimtalk/*)는 410 으로 응답한다.
13
+ def api_base_url(uri)
14
+ table = uri.to_s.sub(%r{\A/}, '').start_with?('alimtalk') ? BootpayStore::RestClient::MESSAGE_API : BootpayStore::RestClient::API
15
+ table[@mode.to_sym]
16
+ end
17
+
18
+ # HTTP Request 기본 Method
19
+ # Comment by Gosomi
20
+ # Date: 2021-05-21
21
+ def request(method: :post, uri:, payload: {}, headers: {}, params: nil)
22
+ response = HTTP.headers(
23
+ {
24
+ Authorization: @token.present? ? "Bearer #{@token}" : "Basic #{basic_authentification}",
25
+ content_type: 'application/json',
26
+ accept: 'application/json',
27
+ bootpay_api_version: @api_version,
28
+ bootpay_sdk_version: Bootpay::V2_VERSION,
29
+ bootpay_sdk_type: '300'
30
+ }.merge!(headers).compact
31
+ ).send(
32
+ method.to_sym,
33
+ [api_base_url(uri), uri].join('/'),
34
+ json: payload,
35
+ params: params
36
+ )
37
+ BootpayStore::Response.new(
38
+ response.status.to_i == 200,
39
+ JSON.parse(response.body.to_s, symbolize_names: true)
40
+ )
41
+ rescue Exception => e
42
+ BootpayStore::Response.new(
43
+ false,
44
+ message: "부트페이 API 서버와의 통신이 실패하였습니다. 오류 메세지: #{e.message}",
45
+ backtrace: e.backtrace.join("\n")
46
+ )
47
+ end
48
+
49
+ # JSON 이 아닌 본문(CSV 등)을 파싱하지 않고 그대로 받는다.
50
+ # @comment_by Claude (alfred)
51
+ # @date: 26-08-27
52
+ # ⚠️ 공용 request 는 응답을 무조건 JSON.parse 하므로, CSV 를 돌려주는 엔드포인트
53
+ # (알림톡 템플릿 내보내기 format=csv)에서는 파싱 실패가 rescue 로 흘러
54
+ # **"부트페이 API 서버와의 통신이 실패하였습니다"** 라는 틀린 메시지로 보고된다.
55
+ # 실제로는 성공한 요청이라 그대로 두면 원인을 못 찾는다. 그래서 원문 경로를 따로 둔다.
56
+ # 성공 시 data 는 { body: '<원문 문자열>', content_type: '...' } 다.
57
+ def request_raw(uri:, method: :get, params: nil, headers: {})
58
+ response = HTTP.headers(
59
+ {
60
+ Authorization: @token.present? ? "Bearer #{@token}" : "Basic #{basic_authentification}",
61
+ accept: '*/*',
62
+ bootpay_api_version: @api_version,
63
+ bootpay_sdk_version: Bootpay::V2_VERSION,
64
+ bootpay_sdk_type: '300'
65
+ }.merge!(headers).compact
66
+ ).send(
67
+ method.to_sym,
68
+ [api_base_url(uri), uri].join('/'),
69
+ params: params
70
+ )
71
+ BootpayStore::Response.new(
72
+ response.status.to_i == 200,
73
+ body: response.body.to_s,
74
+ content_type: response.headers['Content-Type'].to_s
75
+ )
76
+ rescue Exception => e
77
+ BootpayStore::Response.new(
78
+ false,
79
+ message: "부트페이 API 서버와의 통신이 실패하였습니다. 오류 메세지: #{e.message}",
80
+ backtrace: e.backtrace.join("\n")
81
+ )
82
+ end
83
+
84
+ # multipart/form-data 전송 (파일 업로드용)
85
+ # @comment_by Claude (alfred)
86
+ # @date: 26-08-14
87
+ # ⚠️ content_type 을 명시하지 않는다. 명시하면 HTTP.rb 가 붙이는 boundary 가 사라져 본문이 깨진다.
88
+ # (Node SDK 는 인터셉터가 Content-Type 을 덮어써 본문이 null 로 전송되는 버그가 있다 — 같은 함정)
89
+ # 기존 request 는 json: 고정이라 손대지 않고 별도 메서드로 둔다.
90
+ def post_multipart(uri:, form: {}, headers: {})
91
+ response = HTTP.headers(
92
+ {
93
+ Authorization: @token.present? ? "Bearer #{@token}" : "Basic #{basic_authentification}",
94
+ accept: 'application/json',
95
+ bootpay_api_version: @api_version,
96
+ bootpay_sdk_version: Bootpay::V2_VERSION,
97
+ bootpay_sdk_type: '300'
98
+ }.merge!(headers).compact
99
+ ).post(
100
+ [api_base_url(uri), uri].join('/'),
101
+ form: form
102
+ )
103
+ BootpayStore::Response.new(
104
+ response.status.to_i == 200,
105
+ JSON.parse(response.body.to_s, symbolize_names: true)
106
+ )
107
+ rescue Exception => e
108
+ BootpayStore::Response.new(
109
+ false,
110
+ message: "부트페이 API 서버와의 통신이 실패하였습니다. 오류 메세지: #{e.message}",
111
+ backtrace: e.backtrace.join("\n")
112
+ )
113
+ end
114
+
115
+ # multipart form 값 정규화 — 파일은 FormData::File 로, 나머지는 문자열로.
116
+ # 파일은 경로(String)·IO·HTTP::FormData::File 셋 다 받는다.
117
+ # @comment_by Claude (alfred)
118
+ # @date: 26-08-14
119
+ def multipart_file(value)
120
+ return value if value.is_a?(HTTP::FormData::File)
121
+ HTTP::FormData::File.new(value)
122
+ end
123
+
124
+ def multipart_value(value)
125
+ case value
126
+ when Array, Hash then value.to_json
127
+ when TrueClass, FalseClass then value.to_s
128
+ else value.to_s
129
+ end
130
+ end
131
+
132
+ # basic authenticate 추가
133
+ # Comment by GOSOMI
134
+ # @date: 2026-02-20
135
+ def basic_authentification
136
+ Base64.strict_encode64("#{@client_key}:#{@secret_key}")
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,31 @@
1
+ module BootpayStore::Concern::Store
2
+ extend ActiveSupport::Concern
3
+
4
+ included do
5
+ # 가맹점 기본 정보를 조회한다 (/v1/store)
6
+ # Comment by Codex
7
+ # @date: 2026-02-23
8
+ def get_store(idempotency_key: nil)
9
+ request(
10
+ uri: 'store',
11
+ method: :get,
12
+ headers: {
13
+ 'Idempotency-Key' => idempotency_key.presence || SecureRandom.uuid
14
+ }
15
+ )
16
+ end
17
+
18
+ # 가맹점 상세 정보를 조회한다 (/v1/store/detail)
19
+ # Comment by Codex
20
+ # @date: 2026-02-23
21
+ def get_store_detail(idempotency_key: nil)
22
+ request(
23
+ uri: 'store/detail',
24
+ method: :get,
25
+ headers: {
26
+ 'Idempotency-Key' => idempotency_key.presence || SecureRandom.uuid
27
+ }
28
+ )
29
+ end
30
+ end
31
+ end