bootpay-backend-ruby 3.0.0.pre.alpha.3 → 3.0.0.pre.alpha.4

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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +5 -1
  3. data/CHANGELOG.md +10 -0
  4. data/Gemfile +1 -1
  5. data/bootpay-backend-ruby.gemspec +9 -1
  6. data/lib/bootpay/bootpay-rest-client.rb +7 -2
  7. data/lib/bootpay/concern/authenticate.rb +19 -17
  8. data/lib/bootpay/concern/cash_receipt.rb +1 -1
  9. data/lib/bootpay/concern/payment.rb +2 -1
  10. data/lib/bootpay/concern/rest.rb +19 -1
  11. data/lib/bootpay/concern/sdk.rb +16 -0
  12. data/lib/bootpay/concern/subscription.rb +15 -28
  13. data/lib/bootpay/concern/token.rb +13 -0
  14. data/lib/bootpay/concern/wallet.rb +4 -25
  15. data/lib/bootpay_storage/concern/csv.rb +18 -0
  16. data/lib/bootpay_storage/concern/image.rb +12 -0
  17. data/lib/bootpay_storage/concern/rest.rb +45 -0
  18. data/lib/bootpay_storage/concern.rb +2 -0
  19. data/lib/bootpay_store/bootpay-store-rest-client.rb +7 -6
  20. data/lib/bootpay_store/concern/alimtalk_message.rb +67 -0
  21. data/lib/bootpay_store/concern/alimtalk_official.rb +63 -0
  22. data/lib/bootpay_store/concern/alimtalk_optout.rb +72 -0
  23. data/lib/bootpay_store/concern/alimtalk_send.rb +83 -0
  24. data/lib/bootpay_store/concern/alimtalk_sender.rb +105 -0
  25. data/lib/bootpay_store/concern/alimtalk_template.rb +215 -0
  26. data/lib/bootpay_store/concern/alimtalk_webhook.rb +84 -0
  27. data/lib/bootpay_store/concern/invoice.rb +102 -0
  28. data/lib/bootpay_store/concern/mall_setting.rb +385 -0
  29. data/lib/bootpay_store/concern/order.rb +74 -0
  30. data/lib/bootpay_store/concern/order_subscription.rb +391 -0
  31. data/lib/bootpay_store/concern/payment.rb +119 -0
  32. data/lib/bootpay_store/concern/product.rb +175 -0
  33. data/lib/bootpay_store/concern/rest.rb +91 -1
  34. data/lib/bootpay_store/concern/store.rb +31 -0
  35. data/lib/bootpay_store/concern/supervisor.rb +211 -0
  36. data/lib/bootpay_store/concern/token.rb +4 -4
  37. data/lib/bootpay_store/concern/user.rb +325 -0
  38. data/lib/bootpay_store/concern/user_group.rb +221 -0
  39. data/lib/bootpay_store/concern/webhook.rb +17 -0
  40. data/lib/bootpay_store/concern.rb +38 -28
  41. data/lib/version.rb +1 -1
  42. metadata +38 -5
@@ -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
@@ -0,0 +1,215 @@
1
+ module BootpayStore::Concern::AlimtalkTemplate
2
+ extend ActiveSupport::Concern
3
+
4
+ # 가맹점 자체 알림톡 템플릿 CRUD·등록·검수 — /v1/alimtalk/templates 계열
5
+ #
6
+ # 흐름: (초안 생성 → 확인 → 대행사 등록) → 검수 요청 → 승인(APR) → 발송 가능
7
+ # `alimtalk_template_create(register: false)` 로 초안만 만들고, 내용을 확인한 뒤
8
+ # `alimtalk_template_register` 로 올리는 것을 권장한다.
9
+ #
10
+ # ⚠️ `register` 를 명시적으로 false 로 주지 않으면 **생성 즉시 대행사·카카오에 실제 등록**된다.
11
+ # ⚠️ 본문 변수는 `#{변수명}` 형식이고 템플릿 전체에서 최대 40개다.
12
+ #
13
+ # @comment_by Claude (alfred)
14
+ # @date: 26-08-27
15
+ included do
16
+ # 내 자체 템플릿 목록을 조회한다 (GET /v1/alimtalk/templates)
17
+ # ins: 검수상태 필터 — 1 REG(등록) / 2 REQ(검수요청) / 3 APR(승인) / 4 KRR(등록거절) / 5 REJ(승인반려).
18
+ # 숫자·숫자문자열·벤더 문자열('APR' 등)을 모두 받는다. 해석 못 하는 값은 필터 없음으로 떨어진다.
19
+ # keyword: 코드·이름·본문·분류 부분일치. sort: latest(기본)·oldest·code.
20
+ # ⚠️ 페이지네이션이 없다 — 필터에 걸린 템플릿을 한 번에 모두 돌려준다.
21
+ def alimtalk_template_list(ins: nil, sort: nil, keyword: nil)
22
+ request(
23
+ uri: 'alimtalk/templates',
24
+ method: :get,
25
+ headers: { 'Bootpay-Role' => 'user' },
26
+ params: {
27
+ ins: ins,
28
+ sort: sort,
29
+ keyword: keyword
30
+ }.compact
31
+ )
32
+ end
33
+
34
+ # 자체 템플릿을 생성한다 (POST /v1/alimtalk/templates)
35
+ # ⚠️ register 를 false 로 주지 않으면 대행사·카카오에 **실제 등록**된다(되돌리려면 삭제해야 한다).
36
+ #
37
+ # emphasize_type: NONE·TEXT(강조표기형)·IMAGE(이미지형)·ITEM_LIST(아이템리스트형)
38
+ # - TEXT 는 emphasize_title·emphasize_subtitle 둘 다 필수(각 50자·40자)
39
+ # - IMAGE 는 이미지 필수 — alimtalk_template_image 로 올린 URL 을 storage_image_url 로 넘긴다
40
+ # - ITEM_LIST 는 template_item.list(2~10개) 필수 + template_header·item_highlight·이미지 중 하나 이상
41
+ # msg_type: BA(기본형)·EX(부가정보형, template_extra 필수)·AD(채널추가형)·MI(복합형)
42
+ # - AD·MI 는 채널추가(AC) 버튼이 필수다
43
+ # examples: 변수 예문(표시용). 주면 **모든 변수에 예문이 있어야** 한다(없으면 3017).
44
+ def alimtalk_template_create(ksp_id:, name: nil, content: nil, register: nil, buttons: nil,
45
+ msg_type: nil, emphasize_type: nil, emphasize_title: nil,
46
+ emphasize_subtitle: nil, template_extra: nil, template_header: nil,
47
+ item_highlight: nil, template_item: nil, image_url: nil,
48
+ storage_image_url: nil, security_flag: nil, category: nil, tags: nil,
49
+ examples: nil, template_code: nil, **attrs)
50
+ request(
51
+ uri: 'alimtalk/templates',
52
+ method: :post,
53
+ headers: { 'Bootpay-Role' => 'user' },
54
+ payload: {
55
+ ksp_id: ksp_id,
56
+ register: register,
57
+ name: name,
58
+ content: content,
59
+ buttons: buttons,
60
+ msg_type: msg_type,
61
+ emphasize_type: emphasize_type,
62
+ emphasize_title: emphasize_title,
63
+ emphasize_subtitle: emphasize_subtitle,
64
+ template_extra: template_extra,
65
+ template_header: template_header,
66
+ item_highlight: item_highlight,
67
+ template_item: template_item,
68
+ image_url: image_url,
69
+ storage_image_url: storage_image_url,
70
+ security_flag: security_flag,
71
+ category: category,
72
+ tags: tags,
73
+ examples: examples,
74
+ template_code: template_code
75
+ }.merge(attrs).compact
76
+ )
77
+ end
78
+
79
+ # 자체 템플릿 상세를 조회한다 (GET /v1/alimtalk/templates/:id)
80
+ # template_id 는 문서 id 이고, ObjectId 형식이 아니면 **템플릿 코드**로 해석한다.
81
+ # ⚠️ sync 는 서버 기본값이 **true** 라 조회만 해도 벤더 상태 동기화가 일어난다.
82
+ # 초안(등록 전)을 조회할 때는 sync: false 를 권장한다.
83
+ def alimtalk_template_detail(template_id:, sync: nil)
84
+ request(
85
+ uri: "alimtalk/templates/#{template_id}",
86
+ method: :get,
87
+ headers: { 'Bootpay-Role' => 'user' },
88
+ params: { sync: sync }.compact
89
+ )
90
+ end
91
+
92
+ # 자체 템플릿을 수정한다 (PUT /v1/alimtalk/templates/:id)
93
+ # ⚠️ **부분 수정이 아니다.** 보내지 않은 필드는 nil 로 덮어써지므로 항상 전체 필드를 보낸다.
94
+ # ⚠️ 등록된 템플릿을 수정하면 벤더에도 수정 요청이 나간다.
95
+ # 수정 가능 상태는 초안 / REG(등록) / REJ(승인반려) / KRR(등록거절) 뿐이다 — APR·REQ 는 거부된다.
96
+ # storage_image_url 을 빈 값으로 보내면 **이미지 삭제**로 처리되어 벤더에도 전달된다.
97
+ def alimtalk_template_update(template_id:, name: nil, content: nil, buttons: nil, msg_type: nil,
98
+ emphasize_type: nil, emphasize_title: nil, emphasize_subtitle: nil,
99
+ template_extra: nil, template_header: nil, item_highlight: nil,
100
+ template_item: nil, image_url: nil, storage_image_url: nil,
101
+ security_flag: nil, category: nil, tags: nil, examples: nil,
102
+ template_code: nil, **attrs)
103
+ request(
104
+ uri: "alimtalk/templates/#{template_id}",
105
+ method: :put,
106
+ headers: { 'Bootpay-Role' => 'user' },
107
+ payload: {
108
+ name: name,
109
+ content: content,
110
+ buttons: buttons,
111
+ msg_type: msg_type,
112
+ emphasize_type: emphasize_type,
113
+ emphasize_title: emphasize_title,
114
+ emphasize_subtitle: emphasize_subtitle,
115
+ template_extra: template_extra,
116
+ template_header: template_header,
117
+ item_highlight: item_highlight,
118
+ template_item: template_item,
119
+ image_url: image_url,
120
+ storage_image_url: storage_image_url,
121
+ security_flag: security_flag,
122
+ category: category,
123
+ tags: tags,
124
+ examples: examples,
125
+ template_code: template_code
126
+ }.merge(attrs).compact
127
+ )
128
+ end
129
+
130
+ # 자체 템플릿을 삭제한다 (DELETE /v1/alimtalk/templates/:id)
131
+ # 초안(등록 전)은 대행사 거부와 무관하게 로컬에서 삭제된다.
132
+ # ⚠️ 등록분은 **대행사 삭제가 성공해야** 삭제된다 — 승인(APR) 템플릿은 카카오가 거부하므로
133
+ # 500(3013)이 오고 템플릿은 남는다. 같은 코드가 대행사에 선점된 채 로컬만 사라지는 것을 막기 위함이다.
134
+ def alimtalk_template_delete(template_id:)
135
+ request(
136
+ uri: "alimtalk/templates/#{template_id}",
137
+ method: :delete,
138
+ headers: { 'Bootpay-Role' => 'user' }
139
+ )
140
+ end
141
+
142
+ # 초안을 대행사에 등록한다 (POST /v1/alimtalk/templates/:id/register)
143
+ # ⚠️ 대행사·카카오에 실제 등록된다. 등록 전(초안) 상태에서만 호출할 수 있다.
144
+ def alimtalk_template_register(template_id:)
145
+ request(
146
+ uri: "alimtalk/templates/#{template_id}/register",
147
+ method: :post,
148
+ headers: { 'Bootpay-Role' => 'user' }
149
+ )
150
+ end
151
+
152
+ # 검수를 요청한다 (POST /v1/alimtalk/templates/:id/inspect)
153
+ # ⚠️ **카카오에 검수를 요청하며 취소할 수 없다.**
154
+ # 대행사 등록이 끝난 대기(R) + REG(등록) 상태에서만 호출할 수 있다 — 초안은 먼저 register 를 부른다.
155
+ # 반려(REJ/KRR)된 건은 재요청이 아니라 **수정 후 재요청**이다. 반려 사유는 응답의 comments 에 담긴다.
156
+ def alimtalk_template_inspect(template_id:)
157
+ request(
158
+ uri: "alimtalk/templates/#{template_id}/inspect",
159
+ method: :post,
160
+ headers: { 'Bootpay-Role' => 'user' }
161
+ )
162
+ end
163
+
164
+ # 템플릿 목록을 내보낸다 (GET /v1/alimtalk/templates/export)
165
+ # scope: private(기본, 내 채널 자체 템플릿)·official(공식 카탈로그)·all
166
+ # ⚠️ 기본 format 을 **json 으로 둔다** — 서버 기본은 csv 지만, csv 본문은 JSON 이 아니라서
167
+ # 공용 request 의 파싱을 통과하지 못한다. csv 를 주면 파싱 없이 원문 문자열을 담아 돌려준다.
168
+ # 1회 5,000건을 넘으면 3031 로 거부되므로 채널·상태 필터로 좁힌다.
169
+ def alimtalk_template_export(format: 'json', scope: nil, ksp_id: nil, status: nil, include_content: nil)
170
+ params = {
171
+ format: format,
172
+ scope: scope,
173
+ ksp_id: ksp_id,
174
+ status: status,
175
+ include_content: include_content
176
+ }.compact
177
+
178
+ return request_raw(uri: 'alimtalk/templates/export', params: params,
179
+ headers: { 'Bootpay-Role' => 'user' }) if format.to_s == 'csv'
180
+
181
+ request(
182
+ uri: 'alimtalk/templates/export',
183
+ method: :get,
184
+ headers: { 'Bootpay-Role' => 'user' },
185
+ params: params
186
+ )
187
+ end
188
+
189
+ # 이미지형 템플릿의 원본 이미지를 올린다 (POST /v1/alimtalk/templates/image)
190
+ # 돌려받은 image_url 을 템플릿 생성/수정의 storage_image_url 로 넘긴다.
191
+ # 규격을 업로드 **전에** 서버가 검사한다 — jpg/png · 500KB 이하 · 가로 500px 이상 · 2:1.
192
+ # image 는 파일 경로(String)·IO·HTTP::FormData::File 을 모두 받는다.
193
+ # replace_url 을 주면 업로드 성공 후에 기존 파일을 지운다.
194
+ def alimtalk_template_image(image:, replace_url: nil)
195
+ form = { 'image' => multipart_file(image) }
196
+ form['replace_url'] = multipart_value(replace_url) if replace_url.present?
197
+
198
+ post_multipart(uri: 'alimtalk/templates/image', form: form,
199
+ headers: { 'Bootpay-Role' => 'user' })
200
+ end
201
+
202
+ # 아이템리스트형의 하이라이트 썸네일을 올린다 (POST /v1/alimtalk/templates/highlight_image)
203
+ # ⚠️ 본문 이미지와 **규격이 다르다** — jpg/png · 500KB 이하 · 가로 **108px** 이상 · **1:1**.
204
+ # 본문 이미지 엔드포인트로 올리면 거부된다.
205
+ # 돌려받은 image_url 은 item_highlight.storage_image_url 로 넘긴다.
206
+ # ⚠️ 썸네일을 붙이면 하이라이트 글자 한도가 줄어든다(타이틀 30→21, 설명 19→13).
207
+ def alimtalk_template_highlight_image(image:, replace_url: nil)
208
+ form = { 'image' => multipart_file(image) }
209
+ form['replace_url'] = multipart_value(replace_url) if replace_url.present?
210
+
211
+ post_multipart(uri: 'alimtalk/templates/highlight_image', form: form,
212
+ headers: { 'Bootpay-Role' => 'user' })
213
+ end
214
+ end
215
+ end
@@ -0,0 +1,84 @@
1
+ module BootpayStore::Concern::AlimtalkWebhook
2
+ extend ActiveSupport::Concern
3
+
4
+ # 알림톡 발송결과·검수결과 웹훅 설정 — /v1/alimtalk/webhook 계열
5
+ #
6
+ # ⚠️ **주문·구독 통합 웹훅과 완전히 별개다.** 알림톡 이벤트를 기존 주문 웹훅 URL 로 태우면
7
+ # 그 수신 서버가 모르는 payload 를 받아 기존 연동이 깨진다. 그래서 수신 URL 을 따로 둔다.
8
+ # (`send_test_webhook` 은 주문 웹훅용이다 — 이 파일의 `alimtalk_webhook_test` 와 혼동하지 말 것)
9
+ #
10
+ # ## 서명 검증
11
+ # 요청에 다음 헤더가 붙는다.
12
+ # X-Bootpay-Signature: sha256=HMAC_SHA256(secret, "{X-Bootpay-Timestamp}.{raw_body}")
13
+ # 타임스탬프가 5분 이상 지난 요청은 거부한다(replay 방지).
14
+ #
15
+ # @comment_by Claude (alfred)
16
+ # @date: 26-08-27
17
+ included do
18
+ # 웹훅 설정을 조회한다 (GET /v1/alimtalk/webhook)
19
+ # 시크릿은 앞 12자만 노출된다. 미설정이면 { configured: false } 로 온다.
20
+ def alimtalk_webhook_detail
21
+ request(
22
+ uri: 'alimtalk/webhook',
23
+ method: :get,
24
+ headers: { 'Bootpay-Role' => 'user' }
25
+ )
26
+ end
27
+
28
+ # 웹훅 설정을 저장한다 (PUT /v1/alimtalk/webhook)
29
+ # url 은 **https 만** 허용한다(아니면 3028). 최초 저장 시 서명 시크릿이 자동 발급된다.
30
+ # events: 구독할 이벤트 코드. 목록에 없는 값은 저장 시 조용히 버려진다(유령 구독 방지).
31
+ # 300 발송 접수(기본 미구독) / 301 전달 성공 / 302 전달 실패 / 303 예약 취소 /
32
+ # 304 문자(LMS) 대체발송 전환 / 310 검수 승인 / 311 검수 반려 / 320 수신거부 등록(기본 미구독)
33
+ # events 를 비우면 기본 구독셋(301·302·303·304·310·311)이 적용된다.
34
+ def alimtalk_webhook_update(url: nil, events: nil, enabled: nil)
35
+ request(
36
+ uri: 'alimtalk/webhook',
37
+ method: :put,
38
+ headers: { 'Bootpay-Role' => 'user' },
39
+ payload: {
40
+ url: url,
41
+ events: events,
42
+ enabled: enabled
43
+ }.compact
44
+ )
45
+ end
46
+
47
+ # 테스트 이벤트를 1건 발송한다 (POST /v1/alimtalk/webhook/test)
48
+ # ⚠️ **설정된 URL 로 실제 HTTP 요청이 나간다.** 구독 여부와 무관하게 보낸다.
49
+ # 웹훅이 설정돼 있지 않으면 3029. 응답: { delivery_id:, url:, queued: }
50
+ def alimtalk_webhook_test
51
+ request(
52
+ uri: 'alimtalk/webhook/test',
53
+ method: :post,
54
+ headers: { 'Bootpay-Role' => 'user' }
55
+ )
56
+ end
57
+
58
+ # 서명 시크릿을 재발급한다 (POST /v1/alimtalk/webhook/secret)
59
+ # ⚠️ **이 응답에서만 secret 원문을 돌려준다**(이후 조회는 마스킹된다).
60
+ # ⚠️ 이미 큐에 있는 전송 건은 발송 당시 시크릿으로 서명된다.
61
+ def alimtalk_webhook_rotate_secret
62
+ request(
63
+ uri: 'alimtalk/webhook/secret',
64
+ method: :post,
65
+ headers: { 'Bootpay-Role' => 'user' }
66
+ )
67
+ end
68
+
69
+ # 웹훅 전송 이력을 조회한다 (GET /v1/alimtalk/webhook/deliveries)
70
+ # 성공·실패를 모두 남긴다. 응답: { list: [{ delivery_id:, event:, event_code:, url:, status:,
71
+ # retry_count:, max_retry:, tags:, created_at: }], count:, page:, per: }
72
+ def alimtalk_webhook_deliveries(page: nil, limit: nil)
73
+ request(
74
+ uri: 'alimtalk/webhook/deliveries',
75
+ method: :get,
76
+ headers: { 'Bootpay-Role' => 'user' },
77
+ params: {
78
+ page: page,
79
+ limit: limit # 서버 기본 20, 최대 100
80
+ }.compact
81
+ )
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,102 @@
1
+ module BootpayStore::Concern::Invoice
2
+ extend ActiveSupport::Concern
3
+
4
+ included do
5
+ # 청구서를 생성한다
6
+ # Comment by GOSOMI
7
+ # @date: 2025-10-03
8
+ def request_checkout(sdk: false, idempotency_key: nil, name:, memo: nil, user: {}, products: [], price: 0, tax_free_price: 0, delivery_price: 0,
9
+ redirect_url: nil, request_id: nil, use_notification: false, use_auto_login: false, expired_at: nil, metadata: {},
10
+ webhook_url: nil, header_content_type: 'application/json', usage_api_url: nil, extra: {})
11
+ request(
12
+ uri: 'invoices',
13
+ method: :post,
14
+ headers: {
15
+ 'Idempotency-Key' => idempotency_key.presence || SecureRandom.uuid,
16
+ 'Bootpay-Role' => 'user'
17
+ },
18
+ payload:
19
+ {
20
+ sdk: sdk,
21
+ name: name,
22
+ memo: memo,
23
+ user: user,
24
+ products: products,
25
+ price: price,
26
+ tax_free_price: tax_free_price,
27
+ delivery_price: delivery_price,
28
+ redirect_url: redirect_url,
29
+ request_id: request_id,
30
+ use_notification: use_notification,
31
+ use_auto_login: use_auto_login,
32
+ expired_at: expired_at,
33
+ metadata: metadata,
34
+ webhook_url: webhook_url,
35
+ header_content_type: header_content_type,
36
+ usage_api_url: usage_api_url,
37
+ extra: extra
38
+ }.compact
39
+ )
40
+ end
41
+
42
+ alias :create_invoice :request_checkout
43
+
44
+ # 청구서 목록을 조회한다 (GET /v1/invoices)
45
+ # @comment_by Claude (alfred)
46
+ # @date: 26-08-14
47
+ # 응답은 { list: [...], count: N } 구조다 ({ items, total } 아님 — Node SDK 타입선언이 틀렸던 지점).
48
+ # 서버 기본 limit 은 24.
49
+ def invoice_list(page: 1, limit: 24, keyword: nil, cs_type: nil, user_id: nil,
50
+ product_type: nil, css_at: nil, cse_at: nil, idempotency_key: nil)
51
+ request(
52
+ uri: 'invoices',
53
+ method: :get,
54
+ headers: {
55
+ 'Idempotency-Key' => idempotency_key.presence || SecureRandom.uuid,
56
+ 'Bootpay-Role' => 'user'
57
+ },
58
+ params: {
59
+ page: page,
60
+ limit: limit,
61
+ keyword: keyword,
62
+ cs_type: cs_type,
63
+ user_id: user_id,
64
+ product_type: product_type,
65
+ css_at: css_at,
66
+ cse_at: cse_at
67
+ }.compact
68
+ )
69
+ end
70
+
71
+ # 청구서 상세를 조회한다 (GET /v1/invoices/:id)
72
+ # @comment_by Claude (alfred)
73
+ # @date: 26-08-14
74
+ def invoice_detail(invoice_id:, idempotency_key: nil)
75
+ request(
76
+ uri: "invoices/#{invoice_id}",
77
+ method: :get,
78
+ headers: {
79
+ 'Idempotency-Key' => idempotency_key.presence || SecureRandom.uuid,
80
+ 'Bootpay-Role' => 'user'
81
+ }
82
+ )
83
+ end
84
+
85
+ # 청구서를 재안내한다 (POST /v1/invoices/:id/notify)
86
+ # @comment_by Claude (alfred)
87
+ # @date: 26-08-14
88
+ # send_types 미전달 시 서버가 빈 배열로 처리한다.
89
+ # ⚠️ 실제 고객에게 알림이 발송되므로 테스트 호출 주의.
90
+ def invoice_notify(invoice_id:, send_types: nil, idempotency_key: nil)
91
+ request(
92
+ uri: "invoices/#{invoice_id}/notify",
93
+ method: :post,
94
+ headers: {
95
+ 'Idempotency-Key' => idempotency_key.presence || SecureRandom.uuid,
96
+ 'Bootpay-Role' => 'user'
97
+ },
98
+ payload: { send_types: send_types }.compact
99
+ )
100
+ end
101
+ end
102
+ end