bootpay 1.2.0 → 3.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 (101) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +8 -9
  3. data/.rspec +2 -0
  4. data/CHANGELOG.md +63 -21
  5. data/CODE_OF_CONDUCT.md +1 -1
  6. data/Gemfile +2 -4
  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 +23 -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 +47 -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 +83 -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/invoice.rb +102 -0
  47. data/lib/bootpay_store/concern/mall_setting.rb +385 -0
  48. data/lib/bootpay_store/concern/order.rb +74 -0
  49. data/lib/bootpay_store/concern/order_subscription.rb +391 -0
  50. data/lib/bootpay_store/concern/payment.rb +119 -0
  51. data/lib/bootpay_store/concern/product.rb +175 -0
  52. data/lib/bootpay_store/concern/rest.rb +128 -0
  53. data/lib/bootpay_store/concern/store.rb +31 -0
  54. data/lib/bootpay_store/concern/supervisor.rb +211 -0
  55. data/lib/bootpay_store/concern/token.rb +24 -0
  56. data/lib/bootpay_store/concern/user.rb +325 -0
  57. data/lib/bootpay_store/concern/user_group.rb +221 -0
  58. data/lib/bootpay_store/concern/webhook.rb +17 -0
  59. data/lib/bootpay_store/concern.rb +45 -0
  60. data/lib/bootpay_store/response.rb +14 -0
  61. data/lib/version.rb +9 -0
  62. metadata +77 -49
  63. data/.DS_Store +0 -0
  64. data/.env.example +0 -23
  65. data/lib/.DS_Store +0 -0
  66. data/lib/bootpay/.DS_Store +0 -0
  67. data/lib/bootpay/authentication.rb +0 -61
  68. data/lib/bootpay/automatic_transfer.rb +0 -56
  69. data/lib/bootpay/billing.rb +0 -157
  70. data/lib/bootpay/cancel.rb +0 -29
  71. data/lib/bootpay/cash_receipt.rb +0 -77
  72. data/lib/bootpay/commerce/cart.rb +0 -20
  73. data/lib/bootpay/commerce/category.rb +0 -39
  74. data/lib/bootpay/commerce/commerce_resource.rb +0 -196
  75. data/lib/bootpay/commerce/coupon.rb +0 -41
  76. data/lib/bootpay/commerce/invoice.rb +0 -46
  77. data/lib/bootpay/commerce/order.rb +0 -61
  78. data/lib/bootpay/commerce/order_cancel.rb +0 -52
  79. data/lib/bootpay/commerce/order_subscription.rb +0 -117
  80. data/lib/bootpay/commerce/order_subscription_adjustment.rb +0 -29
  81. data/lib/bootpay/commerce/order_subscription_bill.rb +0 -50
  82. data/lib/bootpay/commerce/order_subscription_request.rb +0 -60
  83. data/lib/bootpay/commerce/point.rb +0 -36
  84. data/lib/bootpay/commerce/product.rb +0 -63
  85. data/lib/bootpay/commerce/store.rb +0 -21
  86. data/lib/bootpay/commerce/user.rb +0 -75
  87. data/lib/bootpay/commerce/user_group.rb +0 -70
  88. data/lib/bootpay/easy.rb +0 -25
  89. data/lib/bootpay/escrow.rb +0 -24
  90. data/lib/bootpay/link.rb +0 -60
  91. data/lib/bootpay/naverpay.rb +0 -17
  92. data/lib/bootpay/payment_resource.rb +0 -31
  93. data/lib/bootpay/reseller.rb +0 -12
  94. data/lib/bootpay/rest.rb +0 -46
  95. data/lib/bootpay/submit.rb +0 -20
  96. data/lib/bootpay/token.rb +0 -33
  97. data/lib/bootpay/verification.rb +0 -55
  98. data/lib/bootpay/version.rb +0 -25
  99. data/lib/bootpay/wallet.rb +0 -56
  100. data/lib/bootpay_commerce.rb +0 -152
  101. /data/lib/{response.rb → bootpay/response.rb} +0 -0
@@ -0,0 +1,176 @@
1
+ module BootpayStorage::Concern::Rest
2
+ extend ActiveSupport::Concern
3
+
4
+ included do
5
+ private
6
+
7
+ # HTTP Request 기본 Method
8
+ # Comment by Gosomi
9
+ # Date: 2021-05-21
10
+ def request(method: :post, uri:, payload: {}, headers: {}, params: nil)
11
+ response = HTTP.headers(
12
+ {
13
+ Authorization: "Bearer #{@token}",
14
+ content_type: 'application/json',
15
+ accept: 'application/json',
16
+ bootpay_api_version: @api_version,
17
+ bootpay_sdk_version: Bootpay::V2_VERSION,
18
+ bootpay_sdk_type: '300'
19
+ }.merge!(headers).compact
20
+ ).send(
21
+ method.to_sym,
22
+ [BootpayStorage::RestClient::API[@mode.to_sym], uri].join('/'),
23
+ json: payload,
24
+ params: params
25
+ )
26
+ BootpayStorage::Response.new(
27
+ response.status.to_i == 200,
28
+ JSON.parse(response.body.to_s, symbolize_names: true)
29
+ )
30
+ rescue Exception => e
31
+ BootpayStorage::Response.new(
32
+ false,
33
+ message: "부트페이 API 서버와의 통신이 실패하였습니다. 오류 메세지: #{e.message}",
34
+ backtrace: e.backtrace.join("\n")
35
+ )
36
+ end
37
+
38
+ # Multipart 파일 전송 Method
39
+ # Comment by Gosomi
40
+ # Date: 2025-03-25
41
+ def upload(uri:, images:, headers: {}, params: nil)
42
+ # 이미지 데이터를 배열로 받음
43
+ files = images.each_with_index.map do |data, index|
44
+ filename = "image_#{Time.now.to_i}_#{index}.jpg"
45
+ HTTP::FormData::File.new(data, filename: filename)
46
+ end
47
+
48
+ # HTTP 요청
49
+ response = HTTP.headers(
50
+ {
51
+ Authorization: "Bearer #{@token}",
52
+ accept: 'application/json',
53
+ bootpay_api_version: @api_version,
54
+ bootpay_sdk_version: Bootpay::V2_VERSION,
55
+ bootpay_sdk_type: '300'
56
+ }.merge!(headers).compact
57
+ ).post(
58
+ [BootpayStorage::RestClient::API[@mode.to_sym], uri].join('/'),
59
+ form: { images: files },
60
+ params: params
61
+ )
62
+
63
+ # JSON 파싱 시도
64
+ parsed_response = begin
65
+ JSON.parse(response.body.to_s, symbolize_names: true)
66
+ rescue JSON::ParserError => e
67
+ { error: "응답 파싱 실패: #{e.message}", body: response.body.to_s }
68
+ end
69
+
70
+ # 응답 처리
71
+ BootpayStorage::Response.new(
72
+ response.status.to_i == 200,
73
+ parsed_response
74
+ )
75
+ rescue Exception => e
76
+ BootpayStorage::Response.new(
77
+ false,
78
+ message: "파일 업로드 실패: #{e.message}",
79
+ backtrace: e.backtrace.join("\n")
80
+ )
81
+ end
82
+
83
+ # csv Multipart 파일 전송 Method
84
+ # Comment by ehowlsla
85
+ # Date: 2025-07-17
86
+ def csv_upload(uri:, files:, headers: {}, params: nil)
87
+ # 이미지 데이터를 배열로 받음
88
+ files = files.each_with_index.map do |data, index|
89
+ filename = "csv_#{Time.now.to_i}_#{index}.csv"
90
+ HTTP::FormData::File.new(data, filename: filename)
91
+ end
92
+
93
+ # HTTP 요청
94
+ response = HTTP.headers(
95
+ {
96
+ Authorization: "Bearer #{@token}",
97
+ accept: 'application/json',
98
+ bootpay_api_version: @api_version,
99
+ bootpay_sdk_version: Bootpay::V2_VERSION,
100
+ bootpay_sdk_type: '300'
101
+ }.merge!(headers).compact
102
+ ).post(
103
+ [BootpayStorage::RestClient::API[@mode.to_sym], uri].join('/'),
104
+ form: { images: files },
105
+ params: params
106
+ )
107
+
108
+ # JSON 파싱 시도
109
+ parsed_response = begin
110
+ JSON.parse(response.body.to_s, symbolize_names: true)
111
+ rescue JSON::ParserError => e
112
+ { error: "응답 파싱 실패: #{e.message}", body: response.body.to_s }
113
+ end
114
+
115
+ # 응답 처리
116
+ BootpayStorage::Response.new(
117
+ response.status.to_i == 200,
118
+ parsed_response
119
+ )
120
+ rescue Exception => e
121
+ BootpayStorage::Response.new(
122
+ false,
123
+ message: "파일 업로드 실패: #{e.message}",
124
+ backtrace: e.backtrace.join("\n")
125
+ )
126
+ end
127
+
128
+
129
+ # def upload(uri:, image_data:, image_name:, headers: {}, params: nil)
130
+ #
131
+ # # puts [BootpayStorage::RestClient::API[@mode.to_sym], uri].join('/')
132
+ #
133
+ # # 파일 객체 생성
134
+ # file = HTTP::FormData::File.new(image_data, filename: image_name)
135
+ #
136
+ # # HTTP 요청
137
+ # response = HTTP.headers(
138
+ # {
139
+ # Authorization: "Bearer #{@token}",
140
+ # accept: 'application/json',
141
+ # bootpay_api_version: @api_version,
142
+ # bootpay_sdk_version: Bootpay::V2_VERSION,
143
+ # bootpay_sdk_type: '300'
144
+ # }.merge!(headers).compact
145
+ # ).post(
146
+ # [BootpayStorage::RestClient::API[@mode.to_sym], uri].join('/'),
147
+ # form: { images: [file] },
148
+ # params: params
149
+ # )
150
+ #
151
+ # # 응답 상태와 바디 출력
152
+ # puts "Response Status: #{response.status}"
153
+ # puts "Response Body: #{response.body.to_s}"
154
+ #
155
+ # # JSON 파싱 시도
156
+ # parsed_response = begin
157
+ # JSON.parse(response.body.to_s, symbolize_names: true)
158
+ # rescue JSON::ParserError => e
159
+ # { error: "응답 파싱 실패: #{e.message}", body: response.body.to_s }
160
+ # end
161
+ #
162
+ # # 응답 처리
163
+ # BootpayStorage::Response.new(
164
+ # response.status.to_i == 200,
165
+ # parsed_response
166
+ # )
167
+ # rescue Exception => e
168
+ # BootpayStorage::Response.new(
169
+ # false,
170
+ # message: "파일 업로드 실패: #{e.message}",
171
+ # backtrace: e.backtrace.join("\n")
172
+ # )
173
+ # end
174
+
175
+ end
176
+ end
@@ -0,0 +1,9 @@
1
+ module BootpayStorage::Concern::Token
2
+ extend ActiveSupport::Concern
3
+
4
+ included do
5
+ def set_token(token)
6
+ @token = token
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,12 @@
1
+ module BootpayStorage
2
+ module Concern
3
+ require_relative 'concern/rest'
4
+ require_relative 'concern/token'
5
+ require_relative 'concern/image'
6
+ require_relative 'concern/csv'
7
+ include Rest
8
+ include Image
9
+ include Token
10
+ include Csv
11
+ end
12
+ end
@@ -0,0 +1,14 @@
1
+ module BootpayStorage
2
+ class Response
3
+ attr_reader :data
4
+
5
+ def initialize(success = true, data = {})
6
+ @success = success
7
+ @data = data
8
+ end
9
+
10
+ def success?
11
+ @success
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/all'
4
+ require 'http'
5
+ require 'base64'
6
+ require_relative 'response'
7
+ require_relative '../version'
8
+ require_relative 'concern'
9
+
10
+ module BootpayStore
11
+ class RestClient
12
+ include Concern
13
+
14
+ API =
15
+ {
16
+ development: 'https://dev-api.bootapi.com/v1',
17
+ stage: 'https://stage-api.bootapi.com/v1',
18
+ production: 'https://api.bootapi.com/v1'
19
+ }
20
+
21
+ SDK_VERSION = '5.0.0'
22
+
23
+ def initialize(client_key: nil, private_key: nil, server_key: nil, secret_key: nil, mode: 'production')
24
+ @client_key = server_key.presence || client_key
25
+ @secret_key = private_key.presence || secret_key
26
+ @mode = mode.presence || 'production'
27
+ @token = nil
28
+ @api_version = SDK_VERSION
29
+ raise ArgumentError, "개발환경 mode는 development, stage, production 중에서 선택이 가능합니다." if API[@mode.to_sym].blank?
30
+ end
31
+
32
+ # API URL을 변경
33
+ # Comment by GOSOMI
34
+ # @date: 2023-05-26
35
+ def set_api_url(url)
36
+ API[@mode.to_sym] = url
37
+ end
38
+
39
+ # API 버전을 설정한다
40
+ # Comment by Gosomi
41
+ # Date: 2022-07-29
42
+ def set_api_version(version)
43
+ raise ArgumentError, 'API Version은 4.0.0 이상만 설정이 가능합니다.' if version < '4.0.0'
44
+ @api_version = version
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,67 @@
1
+ module BootpayStore::Concern::AlimtalkMessage
2
+ extend ActiveSupport::Concern
3
+
4
+ # 알림톡 발송내역·집계 — GET /v1/alimtalk/messages 계열
5
+ #
6
+ # **유료** 알림톡만 조회된다(무료 커머스 알림톡은 포함되지 않는다).
7
+ # 상태는 벤더 결과 동기화로 확정되므로 접수 직후에는 requested 로 보인다.
8
+ #
9
+ # @comment_by Claude (alfred)
10
+ # @date: 26-08-27
11
+ included do
12
+ # 발송내역 목록을 조회한다 (GET /v1/alimtalk/messages)
13
+ # status: requested·success·failed·canceled
14
+ # to: 수신번호(하이픈 무관, 정확 매칭) / ref_id: 발송 시 넘긴 멱등키
15
+ # ⚠️ 기간 기본값은 최근 30일이고 최대 조회 폭은 92일이다 — 초과분은 거부하지 않고 시작일을 당겨 잘라낸다.
16
+ # 실제 적용된 구간은 응답의 period 로 확인한다.
17
+ # 응답: { list: [...], count:, page:, per:, period: { from:, to: } }
18
+ def alimtalk_message_list(template_code: nil, status: nil, ref_id: nil, to: nil,
19
+ s_at: nil, e_at: nil, page: nil, limit: nil)
20
+ request(
21
+ uri: 'alimtalk/messages',
22
+ method: :get,
23
+ headers: { 'Bootpay-Role' => 'user' },
24
+ params: {
25
+ template_code: template_code,
26
+ status: status,
27
+ ref_id: ref_id,
28
+ to: to,
29
+ s_at: s_at,
30
+ e_at: e_at,
31
+ page: page,
32
+ limit: limit # 서버 기본 20, 최대 100
33
+ }.compact
34
+ )
35
+ end
36
+
37
+ # 기간 집계를 조회한다 (GET /v1/alimtalk/messages/stats)
38
+ # 일자별 집계 원장에서 읽으므로 응답이 빠르다.
39
+ # 응답: { period:, totals: { sent, success, failed, fallback, opted_out_hit, rejected, canceled, success_rate },
40
+ # daily: [...], billing: { billable_count, unit_price, fallback_count, ..., amount } }
41
+ # ⚠️ billing.unit_price_source 가 'default' 면 **잠정 단가**다(확정 청구액이 아니다).
42
+ # ⚠️ billable_count 는 성공 − 폴백이다 — 폴백분은 LMS 단가로 따로 계산된다.
43
+ def alimtalk_message_stats(s_at: nil, e_at: nil)
44
+ request(
45
+ uri: 'alimtalk/messages/stats',
46
+ method: :get,
47
+ headers: { 'Bootpay-Role' => 'user' },
48
+ params: {
49
+ s_at: s_at,
50
+ e_at: e_at
51
+ }.compact
52
+ )
53
+ end
54
+
55
+ # 단건 발송 결과를 조회한다 (GET /v1/alimtalk/messages/:receipt_id)
56
+ # 실패 사유는 error_code·error_message 에 담긴다.
57
+ # fallback_type 은 폴백이 꺼진 건이면 null, 켜진 건이면 LMS 다.
58
+ # 다른 프로젝트의 건이거나 없으면 404(3025).
59
+ def alimtalk_message_detail(receipt_id:)
60
+ request(
61
+ uri: "alimtalk/messages/#{receipt_id}",
62
+ method: :get,
63
+ headers: { 'Bootpay-Role' => 'user' }
64
+ )
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,63 @@
1
+ module BootpayStore::Concern::AlimtalkOfficial
2
+ extend ActiveSupport::Concern
3
+
4
+ # 부트페이 공식 알림톡 템플릿 카탈로그 — GET/POST /v1/alimtalk/official 계열
5
+ #
6
+ # 부트페이가 미리 카카오 승인을 받아 둔 템플릿이라, 그룹키가 등록된 채널이면 **검수 없이 즉시 발송**된다.
7
+ # `alimtalk_sender_create` 로 채널을 등록하면 그룹 등록이 함께 끝나므로 따로 채택할 것이 없다.
8
+ #
9
+ # 전부 조회 계열이라 부작용이 없다(자체 DB 만 본다).
10
+ # @date: 26-08-27 채택(alimtalk_official_adopt) 제거 — 서버에서도 엔드포인트를 비활성화했다.
11
+ #
12
+ # @comment_by Claude (alfred)
13
+ # @date: 26-08-27
14
+ included do
15
+ # 공식 템플릿을 검색한다 (GET /v1/alimtalk/official)
16
+ # keyword 는 본문·이름·분류를 부분일치(대소문자 무시)로 훑는다.
17
+ # msg_type 은 BA(기본형)·EX(부가정보형)만 존재한다 — 그룹 템플릿이라 AD/MI 는 쓸 수 없다.
18
+ # ksp_id 를 주면 그 채널의 변수 예문 사전으로 variable_examples 를 채워 준다(표시용).
19
+ # 응답: { list: [...], count:, page:, per:, categories: [...] }
20
+ def alimtalk_official_list(keyword: nil, category: nil, msg_type: nil, page: nil, per: nil, ksp_id: nil)
21
+ request(
22
+ uri: 'alimtalk/official',
23
+ method: :get,
24
+ headers: { 'Bootpay-Role' => 'user' },
25
+ params: {
26
+ q: keyword, # 서버는 q 를 먼저 보고 없으면 keyword 를 본다 — 정본 키인 q 로 보낸다
27
+ category: category,
28
+ msg_type: msg_type,
29
+ page: page,
30
+ per: per, # 서버 기본 20, 최대 100 으로 clamp
31
+ ksp_id: ksp_id
32
+ }.compact
33
+ )
34
+ end
35
+
36
+ # 보내려는 문구로 공식 템플릿을 추천받는다 (POST /v1/alimtalk/official/recommend)
37
+ # 유사도 score(0~1) 내림차순으로 돌려준다.
38
+ def alimtalk_official_recommend(text:, category: nil, limit: nil, ksp_id: nil)
39
+ request(
40
+ uri: 'alimtalk/official/recommend',
41
+ method: :post,
42
+ headers: { 'Bootpay-Role' => 'user' },
43
+ payload: {
44
+ text: text,
45
+ category: category,
46
+ limit: limit, # 서버 기본 5
47
+ ksp_id: ksp_id
48
+ }.compact
49
+ )
50
+ end
51
+
52
+ # 공식 템플릿 상세를 조회한다 (GET /v1/alimtalk/official/:code)
53
+ # code 는 서버 채번 코드(슬래시를 포함하지 않는다). 없거나 미노출이면 404(3015).
54
+ def alimtalk_official_detail(code:, ksp_id: nil)
55
+ request(
56
+ uri: "alimtalk/official/#{code}",
57
+ method: :get,
58
+ headers: { 'Bootpay-Role' => 'user' },
59
+ params: { ksp_id: ksp_id }.compact
60
+ )
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,72 @@
1
+ module BootpayStore::Concern::AlimtalkOptout
2
+ extend ActiveSupport::Concern
3
+
4
+ # 알림톡 수신거부 — /v1/alimtalk/optouts 계열 (가맹점 CRM 수신거부 동기화용)
5
+ #
6
+ # 발송 판정과 **같은 기준**으로 다룬다 — 부트페이 전역(global) + 내 프로젝트.
7
+ # ⚠️ 전역 건은 **조회는 되지만 해제할 수 없다**(releasable: false).
8
+ # 이걸 노출하지 않으면 "화면엔 수신거부가 아닌데 발송은 3021 로 막히는" 상태가 된다.
9
+ #
10
+ # @comment_by Claude (alfred)
11
+ # @date: 26-08-27
12
+ included do
13
+ # 수신거부 목록을 조회한다 (GET /v1/alimtalk/optouts)
14
+ # phone 은 숫자만 남겨 **부분일치**로 찾는다(정확 매칭이 아니다). 50건 단위로 페이징된다.
15
+ # 응답: { list: [{ id:, phone:, scope:, global:, releasable:, source:, reason:, opted_out_at:, created_at: }],
16
+ # count:, page: }
17
+ def alimtalk_optout_list(phone: nil, page: nil)
18
+ request(
19
+ uri: 'alimtalk/optouts',
20
+ method: :get,
21
+ headers: { 'Bootpay-Role' => 'user' },
22
+ params: {
23
+ phone: phone,
24
+ page: page
25
+ }.compact
26
+ )
27
+ end
28
+
29
+ # 수신거부를 등록한다 (POST /v1/alimtalk/optouts)
30
+ # 내 프로젝트 스코프로 등록된다(source: api). 같은 번호를 다시 등록해도 멱등이다.
31
+ def alimtalk_optout_create(phone:, reason: nil)
32
+ request(
33
+ uri: 'alimtalk/optouts',
34
+ method: :post,
35
+ headers: { 'Bootpay-Role' => 'user' },
36
+ payload: {
37
+ phone: phone,
38
+ reason: reason
39
+ }.compact
40
+ )
41
+ end
42
+
43
+ # 발송 전에 수신거부를 사전 확인한다 (POST /v1/alimtalk/optouts/check)
44
+ # 발송 판정과 **같은 축**으로 대조하므로, 벌크에서 skipped 로 낭비될 건을 미리 뺄 수 있다.
45
+ # 단건(phone)·다건(phones) 모두 받는다. ⚠️ 1회 최대 1,000건이고 넘으면 -48 이다(중복은 서버가 제거).
46
+ # 응답: { list: [{ phone:, opted_out:, global:, releasable:, opted_out_at: }], count:, opted_out_count: }
47
+ def alimtalk_optout_check(phones: nil, phone: nil)
48
+ request(
49
+ uri: 'alimtalk/optouts/check',
50
+ method: :post,
51
+ headers: { 'Bootpay-Role' => 'user' },
52
+ payload: {
53
+ phones: phones,
54
+ phone: phone
55
+ }.compact
56
+ )
57
+ end
58
+
59
+ # 수신거부를 해제한다 (DELETE /v1/alimtalk/optouts/:phone)
60
+ # 내 프로젝트 스코프 건만 해제되며 멱등이다(없어도 성공).
61
+ # ⚠️ 전역 차단은 해제되지 않고 global_blocked: true 로 알려 준다 —
62
+ # "지웠는데 여전히 막히는" 상태를 응답으로 드러내기 위함이다.
63
+ # 응답: { phone:, released:, global_blocked: }
64
+ def alimtalk_optout_release(phone:)
65
+ request(
66
+ uri: "alimtalk/optouts/#{phone}",
67
+ method: :delete,
68
+ headers: { 'Bootpay-Role' => 'user' }
69
+ )
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,83 @@
1
+ module BootpayStore::Concern::AlimtalkSend
2
+ extend ActiveSupport::Concern
3
+
4
+ # 알림톡 발송 — POST /v1/alimtalk/send · /send/bulk · DELETE /send/:receipt_id
5
+ #
6
+ # ⚠️ **실제로 카카오톡이 발송되고 과금된다. 샌드박스가 없다.**
7
+ #
8
+ # 처리 순서: 멱등 확인 → 템플릿·채널 해석 → 발송권한 → 지갑 자격 → 발송제어 → 폴백 확정(발신번호 확보)
9
+ # → 수신거부 대조 → 변수 치환·규격검증 → 접수(READY) → 워커 전송
10
+ #
11
+ # - **멱등**: 같은 (프로젝트, ref_id) 로 재요청하면 기존 receipt 를 그대로 돌려준다. 실패한 건만 재발송된다.
12
+ # - **필수 변수**: 템플릿 응답의 required_variables 를 모두 채워야 한다. 하나라도 비면 3017 로 거부된다.
13
+ # ⚠️ 다만 실제로 치환되어 나가는 건 본문·강조 타이틀·버튼 링크뿐이다 — 보조문구와 아이템리스트형
14
+ # 요소는 발송 페이로드에 자리가 없어 카카오가 등록된 템플릿 문구 그대로 렌더한다.
15
+ # - **채널**: sender_key(공개키)로 지정한다. 생략하면 프로젝트 연동 채널로 해석하며,
16
+ # 연동 채널이 둘 이상일 때만 필수다(ksp_id 는 내부 문서 id 라 발송 API 에 쓰지 않는다).
17
+ #
18
+ # @comment_by Claude (alfred)
19
+ # @date: 26-08-27
20
+ included do
21
+ # 단건 발송 (POST /v1/alimtalk/send)
22
+ # variables: { company_name: '부트페이몰', user_name: '홍길동' } 형태의 치환값
23
+ # ref_id: 가맹점 발송 식별자 — **멱등 키**로 쓰인다
24
+ # reserved_at: 예약 발송 시각(ISO8601). 생략하면 즉시 발송
25
+ # fallback: 알림톡 실패 시 문자(LMS) 대체발송 여부.
26
+ # ⚠️ **미지정(nil)과 false 는 다르다** — nil 이면 프로젝트 기본값을 따르고, false 는 명시적으로 끈다.
27
+ # 켜면 발신번호가 등록돼 있어야 하며 없으면 3030 으로 거부된다. 대체 문자에는 수신거부 링크가 자동 포함된다.
28
+ # 응답: { receipt_id:, ref_id:, to:, status: } — 접수 직후 status 는 requested
29
+ def alimtalk_send(template_code:, to:, variables: nil, ref_id: nil, fallback: nil,
30
+ reserved_at: nil, sender_key: nil, user_id: nil)
31
+ request(
32
+ uri: 'alimtalk/send',
33
+ method: :post,
34
+ headers: { 'Bootpay-Role' => 'user' },
35
+ payload: {
36
+ template_code: template_code,
37
+ to: to,
38
+ variables: variables,
39
+ ref_id: ref_id,
40
+ fallback: fallback, # compact 은 nil 만 걷어내므로 false 는 그대로 전달된다
41
+ reserved_at: reserved_at,
42
+ sender_key: sender_key,
43
+ user_id: user_id
44
+ }.compact
45
+ )
46
+ end
47
+
48
+ # 벌크 발송 (POST /v1/alimtalk/send/bulk) — 1요청 = N수신자
49
+ # recipients: [{ to: '01012345678', ref_id: 'bulk-0001', variables: { ... } }, ...]
50
+ # ⚠️ 수신자 수만큼 실제 발송되고 과금된다.
51
+ # - 쿼터를 넘으면 요청 시점에 **전체 거부**된다(3022) — 일부만 나가지 않는다.
52
+ # - 개별 수신자의 실패는 건별 rejected 로 표시되고 나머지는 정상 발송된다.
53
+ # - 수신거부 번호는 skipped 이며 **과금되지 않고 발송 기록도 만들지 않는다**.
54
+ # - fallback 은 요청 단위로 한 번만 판정한다 — 발신번호가 없으면 요청 전체가 3030 으로 거부된다.
55
+ # 응답: { count:, requested:, skipped:, rejected:, receipts: [...] }
56
+ def alimtalk_send_bulk(template_code:, recipients:, fallback: nil, reserved_at: nil,
57
+ sender_key: nil, user_id: nil)
58
+ request(
59
+ uri: 'alimtalk/send/bulk',
60
+ method: :post,
61
+ headers: { 'Bootpay-Role' => 'user' },
62
+ payload: {
63
+ template_code: template_code,
64
+ recipients: recipients,
65
+ fallback: fallback,
66
+ reserved_at: reserved_at,
67
+ sender_key: sender_key,
68
+ user_id: user_id
69
+ }.compact
70
+ )
71
+ end
72
+
73
+ # 예약 발송을 취소한다 (DELETE /v1/alimtalk/send/:receipt_id)
74
+ # 접수(READY) 상태의 예약 건만 취소할 수 있다 — 이미 전송에 들어갔으면 3023 이다.
75
+ def alimtalk_send_cancel(receipt_id:)
76
+ request(
77
+ uri: "alimtalk/send/#{receipt_id}",
78
+ method: :delete,
79
+ headers: { 'Bootpay-Role' => 'user' }
80
+ )
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,105 @@
1
+ module BootpayStore::Concern::AlimtalkSender
2
+ extend ActiveSupport::Concern
3
+
4
+ # 알림톡 발신프로필(카카오채널) 생명주기 — GET /v1/alimtalk/categories · /senders 계열
5
+ #
6
+ # 카테고리 조회 → OTP 발송 → 발신프로필 등록 → 목록/상세 → 연동 해지 순으로 쓴다.
7
+ # 등록이 끝나면 서버가 그룹키 등록까지 자동으로 하므로, 공식 템플릿은 별도 채택 없이 바로 발송된다.
8
+ #
9
+ # ⚠️ 실제 부작용: `alimtalk_sender_otp` 는 채널 관리자 휴대폰으로 **문자를 실제 발송**하고,
10
+ # `alimtalk_sender_create` 는 카카오에 발신프로필을 **실제 등록**한다. 샌드박스가 없다.
11
+ #
12
+ # ★Idempotency-Key 를 싣지 않는다★ 알림톡 API 는 이 헤더를 읽지 않는다(멱등은 발송의 ref_id 로만 성립).
13
+ # invoice/product 처럼 무조건 붙이면 서버가 주지 않는 보장을 주는 것처럼 보인다.
14
+ # ★Bootpay-Role 은 항상 user★ 알림톡 스코프 키가 전부 `user:alimtalk_*` 다.
15
+ #
16
+ # @comment_by Claude (alfred)
17
+ # @date: 26-08-27
18
+ included do
19
+ # 카카오 카테고리 목록을 조회한다 (GET /v1/alimtalk/categories)
20
+ # 발신프로필 등록 시 필요한 category_code 후보다. 벤더 응답을 그대로 프록시한다.
21
+ def alimtalk_categories
22
+ request(
23
+ uri: 'alimtalk/categories',
24
+ method: :get,
25
+ headers: { 'Bootpay-Role' => 'user' }
26
+ )
27
+ end
28
+
29
+ # 채널 관리자폰으로 OTP 를 발송한다 (POST /v1/alimtalk/senders/otp)
30
+ # ⚠️ 실제로 문자가 나간다. 여기서 받은 인증번호를 alimtalk_sender_create 의 otp 로 넘긴다.
31
+ def alimtalk_sender_otp(yellow_id:, phone:)
32
+ request(
33
+ uri: 'alimtalk/senders/otp',
34
+ method: :post,
35
+ headers: { 'Bootpay-Role' => 'user' },
36
+ payload: {
37
+ yellow_id: yellow_id,
38
+ phone: phone
39
+ }.compact
40
+ )
41
+ end
42
+
43
+ # 발신프로필을 등록한다 (POST /v1/alimtalk/senders)
44
+ # ⚠️ 카카오에 발신프로필이 실제 등록된다. 같은 yellow_id 를 다시 등록하면 기존 프로필을 재사용한다(dedup).
45
+ # 등록 성공 시 그룹키 등록까지 서버가 수행하므로 공식 카탈로그 전체를 바로 발송할 수 있다.
46
+ def alimtalk_sender_create(otp:, yellow_id:, phone:, category_code:)
47
+ request(
48
+ uri: 'alimtalk/senders',
49
+ method: :post,
50
+ headers: { 'Bootpay-Role' => 'user' },
51
+ payload: {
52
+ otp: otp,
53
+ yellow_id: yellow_id,
54
+ phone: phone,
55
+ category_code: category_code
56
+ }.compact
57
+ )
58
+ end
59
+
60
+ # 연동한 채널 목록을 조회한다 (GET /v1/alimtalk/senders)
61
+ # 자체 DB 만 조회하며 벤더를 호출하지 않는다. 응답은 { list: [...], count: N }.
62
+ def alimtalk_sender_list
63
+ request(
64
+ uri: 'alimtalk/senders',
65
+ method: :get,
66
+ headers: { 'Bootpay-Role' => 'user' }
67
+ )
68
+ end
69
+
70
+ # 채널 상세를 조회한다 (GET /v1/alimtalk/senders/:id)
71
+ # sync: true 면 벤더에서 채널 상태를 다시 읽어 반영한다(느리다). 미지정이면 자체 DB 만 본다.
72
+ # ⚠️ 미연동/미존재 채널은 404, 다른 프로젝트의 채널은 403 으로 오며 둘 다 error_code 는 3024 다.
73
+ def alimtalk_sender_detail(ksp_id:, sync: nil)
74
+ request(
75
+ uri: "alimtalk/senders/#{ksp_id}",
76
+ method: :get,
77
+ headers: { 'Bootpay-Role' => 'user' },
78
+ params: { sync: sync }.compact
79
+ )
80
+ end
81
+
82
+ # 채널 연동을 해지한다 (DELETE /v1/alimtalk/senders/:id)
83
+ # 이 프로젝트와의 연동만 끊는다 — 채널 모델과 템플릿은 보존된다. 성공 시 본문은 null 이다.
84
+ def alimtalk_sender_release(ksp_id:)
85
+ request(
86
+ uri: "alimtalk/senders/#{ksp_id}",
87
+ method: :delete,
88
+ headers: { 'Bootpay-Role' => 'user' }
89
+ )
90
+ end
91
+
92
+ # 채널 변수 예문 사전을 갱신한다 (PUT /v1/alimtalk/senders/:id/variable_examples)
93
+ # 템플릿 미리보기에서 #{user_name} 대신 '홍길동' 처럼 읽히게 하는 **표시용** 값이다.
94
+ # ⚠️ 발송값이 아니다 — 벤더로 전송되지 않으므로 검수 상태와 무관하다. 보낸 키만 덮어쓴다(부분 갱신).
95
+ # examples: { user_name: '홍길동', company_name: '부트페이몰' } — 키에 '.' 이나 선행 '$' 는 쓸 수 없다.
96
+ def alimtalk_sender_variable_examples(ksp_id:, examples:)
97
+ request(
98
+ uri: "alimtalk/senders/#{ksp_id}/variable_examples",
99
+ method: :put,
100
+ headers: { 'Bootpay-Role' => 'user' },
101
+ payload: { examples: examples }.compact
102
+ )
103
+ end
104
+ end
105
+ end