bootpay-backend 2.7.0__tar.gz → 2.9.0__tar.gz

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 (74) hide show
  1. {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/PKG-INFO +4 -2
  2. {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/README.md +3 -1
  3. {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/bootpay_backend/commerce/__init__.py +66 -1
  4. {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/bootpay_backend/commerce/commerce_resource.py +122 -26
  5. {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/bootpay_backend/commerce/modules/__init__.py +25 -1
  6. bootpay_backend-2.9.0/bootpay_backend/commerce/modules/alimtalk_message.py +90 -0
  7. bootpay_backend-2.9.0/bootpay_backend/commerce/modules/alimtalk_official.py +90 -0
  8. bootpay_backend-2.9.0/bootpay_backend/commerce/modules/alimtalk_optout.py +97 -0
  9. bootpay_backend-2.9.0/bootpay_backend/commerce/modules/alimtalk_send.py +103 -0
  10. bootpay_backend-2.9.0/bootpay_backend/commerce/modules/alimtalk_sender.py +144 -0
  11. bootpay_backend-2.9.0/bootpay_backend/commerce/modules/alimtalk_template.py +238 -0
  12. bootpay_backend-2.9.0/bootpay_backend/commerce/modules/alimtalk_webhook.py +110 -0
  13. bootpay_backend-2.9.0/bootpay_backend/commerce/modules/faq.py +141 -0
  14. bootpay_backend-2.9.0/bootpay_backend/commerce/modules/inquiry.py +192 -0
  15. bootpay_backend-2.9.0/bootpay_backend/commerce/modules/notice.py +146 -0
  16. bootpay_backend-2.9.0/bootpay_backend/commerce/modules/product_qna.py +216 -0
  17. bootpay_backend-2.9.0/bootpay_backend/commerce/modules/product_review.py +204 -0
  18. {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/bootpay_backend/commerce/types/__init__.py +218 -0
  19. {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/bootpay_backend/rest_client.py +3 -2
  20. {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/bootpay_backend.egg-info/PKG-INFO +4 -2
  21. {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/bootpay_backend.egg-info/SOURCES.txt +15 -0
  22. {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/setup.py +1 -1
  23. bootpay_backend-2.9.0/tests/commerce/test_alimtalk.py +537 -0
  24. bootpay_backend-2.9.0/tests/commerce/test_board.py +464 -0
  25. bootpay_backend-2.9.0/tests/pg/test_cash_receipt_wire_format.py +54 -0
  26. {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/LICENSE +0 -0
  27. {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/bootpay_backend/__init__.py +0 -0
  28. {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/bootpay_backend/bankcode.py +0 -0
  29. {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/bootpay_backend/commerce/modules/cart.py +0 -0
  30. {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/bootpay_backend/commerce/modules/category.py +0 -0
  31. {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/bootpay_backend/commerce/modules/coupon.py +0 -0
  32. {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/bootpay_backend/commerce/modules/invoice.py +0 -0
  33. {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/bootpay_backend/commerce/modules/mall_setting.py +0 -0
  34. {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/bootpay_backend/commerce/modules/order.py +0 -0
  35. {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/bootpay_backend/commerce/modules/order_cancel.py +0 -0
  36. {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/bootpay_backend/commerce/modules/order_subscription.py +0 -0
  37. {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/bootpay_backend/commerce/modules/order_subscription_adjustment.py +0 -0
  38. {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/bootpay_backend/commerce/modules/order_subscription_bill.py +0 -0
  39. {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/bootpay_backend/commerce/modules/order_subscription_request.py +0 -0
  40. {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/bootpay_backend/commerce/modules/point.py +0 -0
  41. {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/bootpay_backend/commerce/modules/product.py +0 -0
  42. {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/bootpay_backend/commerce/modules/store.py +0 -0
  43. {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/bootpay_backend/commerce/modules/user.py +0 -0
  44. {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/bootpay_backend/commerce/modules/user_group.py +0 -0
  45. {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/bootpay_backend/commerce/modules/webhook.py +0 -0
  46. {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/bootpay_backend.egg-info/dependency_links.txt +0 -0
  47. {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/bootpay_backend.egg-info/not-zip-safe +0 -0
  48. {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/bootpay_backend.egg-info/requires.txt +0 -0
  49. {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/bootpay_backend.egg-info/top_level.txt +0 -0
  50. {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/setup.cfg +0 -0
  51. {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/tests/__init__.py +0 -0
  52. {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/tests/commerce/__init__.py +0 -0
  53. {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/tests/commerce/test_auth_contract.py +0 -0
  54. {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/tests/commerce/test_mall_setting.py +0 -0
  55. {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/tests/commerce/test_order.py +0 -0
  56. {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/tests/commerce/test_order_subscription_charge.py +0 -0
  57. {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/tests/commerce/test_product.py +0 -0
  58. {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/tests/commerce/test_product_mall.py +0 -0
  59. {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/tests/commerce/test_store.py +0 -0
  60. {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/tests/commerce/test_token.py +0 -0
  61. {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/tests/commerce/test_user.py +0 -0
  62. {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/tests/commerce/test_user_mall.py +0 -0
  63. {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/tests/commerce/test_webhook.py +0 -0
  64. {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/tests/commerce/test_wire_format.py +0 -0
  65. {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/tests/conftest.py +0 -0
  66. {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/tests/pg/__init__.py +0 -0
  67. {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/tests/pg/test_auth.py +0 -0
  68. {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/tests/pg/test_billing.py +0 -0
  69. {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/tests/pg/test_cash_receipt.py +0 -0
  70. {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/tests/pg/test_legacy_compatibility.py +0 -0
  71. {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/tests/pg/test_payment.py +0 -0
  72. {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/tests/pg/test_sequential_billing_key.py +0 -0
  73. {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/tests/pg/test_token.py +0 -0
  74. {bootpay_backend-2.7.0 → bootpay_backend-2.9.0}/tests/pg/test_wallet.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bootpay_backend
3
- Version: 2.7.0
3
+ Version: 2.9.0
4
4
  Summary: bootpay server side plugin for python
5
5
  Home-page: https://github.com/bootpay/backend-python/tree/2-x-development
6
6
  Author: bootpay <bootpay.co.kr@gmail.com>
@@ -404,9 +404,11 @@ if 'error_code' not in token:
404
404
 
405
405
  ## 9-3. (별건) 현금영수증 발행
406
406
  부트페이 결제와 상관없이 금액, 상품명, 현금영수증 발행정보 등을 보내 현금영수증을 발행하는 API 입니다
407
+
408
+ `pg` 는 선택 파라미터입니다. 생략하면 프로젝트에 설정된 기본 PG 로 발행됩니다.
407
409
  ```python
408
410
  response = bootpay.request_cash_receipt(
409
- pg='토스',
411
+ pg='토스', # 생략 가능 (미지정 시 기본 PG 사용)
410
412
  price=1000,
411
413
  order_name='테스트',
412
414
  cash_receipt_type='소득공제',
@@ -364,9 +364,11 @@ if 'error_code' not in token:
364
364
 
365
365
  ## 9-3. (별건) 현금영수증 발행
366
366
  부트페이 결제와 상관없이 금액, 상품명, 현금영수증 발행정보 등을 보내 현금영수증을 발행하는 API 입니다
367
+
368
+ `pg` 는 선택 파라미터입니다. 생략하면 프로젝트에 설정된 기본 PG 로 발행됩니다.
367
369
  ```python
368
370
  response = bootpay.request_cash_receipt(
369
- pg='토스',
371
+ pg='토스', # 생략 가능 (미지정 시 기본 PG 사용)
370
372
  price=1000,
371
373
  order_name='테스트',
372
374
  cash_receipt_type='소득공제',
@@ -17,7 +17,19 @@ from .modules import (
17
17
  CartModule,
18
18
  OrderSubscriptionRequestModule,
19
19
  MallSettingModule,
20
- WebhookModule
20
+ WebhookModule,
21
+ FaqModule,
22
+ NoticeModule,
23
+ InquiryModule,
24
+ ProductQnaModule,
25
+ ProductReviewModule,
26
+ AlimtalkMessageModule,
27
+ AlimtalkOfficialModule,
28
+ AlimtalkOptoutModule,
29
+ AlimtalkSendModule,
30
+ AlimtalkSenderModule,
31
+ AlimtalkTemplateModule,
32
+ AlimtalkWebhookModule
21
33
  )
22
34
  from .types import *
23
35
 
@@ -80,6 +92,20 @@ class BootpayCommerce(BootpayCommerceResource):
80
92
  self.order_subscription_request = OrderSubscriptionRequestModule(self)
81
93
  self.mall_setting = MallSettingModule(self)
82
94
  self.webhook = WebhookModule(self)
95
+ # 게시판 계열 (FAQ·공지사항·1:1문의·상품문의·상품평)
96
+ self.faq = FaqModule(self)
97
+ self.notice = NoticeModule(self)
98
+ self.inquiry = InquiryModule(self)
99
+ self.product_qna = ProductQnaModule(self)
100
+ self.product_review = ProductReviewModule(self)
101
+ # 알림톡 (/alimtalk 계열 — 메시지 API 호스트) — 주문·구독 웹훅과 별개다
102
+ self.alimtalk_message = AlimtalkMessageModule(self)
103
+ self.alimtalk_official = AlimtalkOfficialModule(self)
104
+ self.alimtalk_optout = AlimtalkOptoutModule(self)
105
+ self.alimtalk_send = AlimtalkSendModule(self)
106
+ self.alimtalk_sender = AlimtalkSenderModule(self)
107
+ self.alimtalk_template = AlimtalkTemplateModule(self)
108
+ self.alimtalk_webhook = AlimtalkWebhookModule(self)
83
109
 
84
110
  def get_access_token(self):
85
111
  """
@@ -212,6 +238,18 @@ __all__ = [
212
238
  'OrderSubscriptionRequestModule',
213
239
  'MallSettingModule',
214
240
  'WebhookModule',
241
+ 'FaqModule',
242
+ 'NoticeModule',
243
+ 'InquiryModule',
244
+ 'ProductQnaModule',
245
+ 'ProductReviewModule',
246
+ 'AlimtalkMessageModule',
247
+ 'AlimtalkOfficialModule',
248
+ 'AlimtalkOptoutModule',
249
+ 'AlimtalkSendModule',
250
+ 'AlimtalkSenderModule',
251
+ 'AlimtalkTemplateModule',
252
+ 'AlimtalkWebhookModule',
215
253
  # Types
216
254
  'ListParams',
217
255
  'CommerceAddress',
@@ -309,4 +347,31 @@ __all__ = [
309
347
  'MallSettingUpdateParams',
310
348
  'CommerceMallSetting',
311
349
  'SendTestWebhookParams',
350
+ # Alimtalk Types
351
+ 'ALIMTALK_WEBHOOK_EVENT_SEND_REQUESTED',
352
+ 'ALIMTALK_WEBHOOK_EVENT_SEND_SUCCESS',
353
+ 'ALIMTALK_WEBHOOK_EVENT_SEND_FAILED',
354
+ 'ALIMTALK_WEBHOOK_EVENT_SEND_CANCELED',
355
+ 'ALIMTALK_WEBHOOK_EVENT_FALLBACK_SENT',
356
+ 'ALIMTALK_WEBHOOK_EVENT_TEMPLATE_APPROVED',
357
+ 'ALIMTALK_WEBHOOK_EVENT_TEMPLATE_REJECTED',
358
+ 'ALIMTALK_WEBHOOK_EVENT_OPTOUT_CREATED',
359
+ 'AlimtalkMessageListParams',
360
+ 'AlimtalkMessageStatsParams',
361
+ 'AlimtalkOfficialListParams',
362
+ 'AlimtalkOfficialRecommendParams',
363
+ 'AlimtalkOptoutListParams',
364
+ 'AlimtalkOptoutCreateParams',
365
+ 'AlimtalkOptoutCheckParams',
366
+ 'AlimtalkSendParams',
367
+ 'AlimtalkSendRecipient',
368
+ 'AlimtalkSendBulkParams',
369
+ 'AlimtalkSenderOtpParams',
370
+ 'AlimtalkSenderCreateParams',
371
+ 'AlimtalkTemplateListParams',
372
+ 'AlimtalkTemplateCreateParams',
373
+ 'AlimtalkTemplateUpdateParams',
374
+ 'AlimtalkTemplateExportParams',
375
+ 'AlimtalkWebhookUpdateParams',
376
+ 'AlimtalkWebhookDeliveryListParams',
312
377
  ]
@@ -13,6 +13,13 @@ class BootpayCommerceResource:
13
13
  'stage': 'https://stage-api.bootapi.com/v1',
14
14
  'production': 'https://api.bootapi.com/v1'
15
15
  }
16
+ # 알림톡 API 전용 — 메시지 API 가 직접 받는다(경로에 /v1 없음). _api_base_url 이 고른다.
17
+ # @date: 26-09-18
18
+ MESSAGE_API_ENTRYPOINTS = {
19
+ 'development': 'https://dev-m.bootapi.com',
20
+ 'stage': 'https://stage-m.bootapi.com',
21
+ 'production': 'https://message.bootapi.com'
22
+ }
16
23
  API_VERSION = '1.0.0'
17
24
  SDK_VERSION = '1.0.0'
18
25
 
@@ -23,6 +30,7 @@ class BootpayCommerceResource:
23
30
  self.client_key: Optional[str] = None
24
31
  self.secret_key: Optional[str] = None
25
32
  self.timeout = 60
33
+ self._message_api_url: Optional[str] = None
26
34
 
27
35
  def set_configuration(self, client_key: str, secret_key: str, mode: str = 'production'):
28
36
  """
@@ -64,9 +72,27 @@ class BootpayCommerceResource:
64
72
  encoded = base64.b64encode(credentials.encode()).decode()
65
73
  return f'Basic {encoded}'
66
74
 
75
+ def set_message_api_url(self, url: str):
76
+ """
77
+ 알림톡(메시지 API) URL 을 변경한다.
78
+ @date: 26-09-18
79
+ """
80
+ self._message_api_url = url
81
+
82
+ def _api_base_url(self, url: str) -> str:
83
+ """
84
+ 요청 경로에 맞는 API 기본 주소.
85
+ 알림톡 API 는 26-09-18 부터 커머스 API(api.bootapi.com/v1)가 아니라 메시지 API(message.bootapi.com, /v1 없음)가 받는다.
86
+ 경로·파라미터·응답은 그대로이고 호스트만 다르다 — 그래서 알림톡 모듈은 고치지 않고 여기서 주소만 가른다.
87
+ 옛 주소(/v1/alimtalk/*)는 410 으로 응답한다.
88
+ """
89
+ if url.lstrip('/').startswith('alimtalk'):
90
+ return self._message_api_url or self.MESSAGE_API_ENTRYPOINTS[self.mode]
91
+ return self.API_ENTRYPOINTS[self.mode]
92
+
67
93
  def _entrypoints(self, url: str) -> str:
68
94
  """엔트리포인트 URL 생성"""
69
- return '/'.join([self.API_ENTRYPOINTS[self.mode], url])
95
+ return '/'.join([self._api_base_url(url), url])
70
96
 
71
97
  def _get_headers(self, include_auth: bool = True, headers: Optional[Dict[str, str]] = None) -> Dict[str, str]:
72
98
  """
@@ -134,6 +160,93 @@ class BootpayCommerceResource:
134
160
  :param image_paths: 이미지 파일 경로 배열
135
161
  :param headers: 요청별 추가 헤더 (공통값을 덮어쓴다)
136
162
  """
163
+ request_headers = self._multipart_headers(headers)
164
+ form_data = self._serialize_form_data(data)
165
+
166
+ # 파일 준비
167
+ # ⚠️ Rails 는 반복된 `images` 를 배열로 받지 않는다. images[0], images[1] ... 로 인덱싱해야 한다.
168
+ files = []
169
+ if image_paths:
170
+ for index, image_path in enumerate(image_paths):
171
+ filename = os.path.basename(image_path)
172
+ files.append((f'images[{index}]', (filename, open(image_path, 'rb'))))
173
+
174
+ try:
175
+ response = requests.post(
176
+ self._entrypoints(url),
177
+ headers=request_headers,
178
+ data=form_data,
179
+ files=files if files else None,
180
+ timeout=self.timeout
181
+ )
182
+ return response.json()
183
+ finally:
184
+ # 파일 핸들 닫기
185
+ for _, file_tuple in files:
186
+ file_tuple[1].close()
187
+
188
+ def post_multipart_file(self, url: str, field: str, file: Any, data: Optional[Dict] = None,
189
+ headers: Optional[Dict[str, str]] = None):
190
+ """
191
+ 단일 파일 필드 multipart/form-data POST 요청
192
+ ⚠️ post_multipart 는 Rails 배열 규약(images[0], images[1] ...) 전용이다.
193
+ 알림톡 템플릿 이미지처럼 서버가 필드명을 정해 둔 단일 파일 업로드는 이 메서드를 쓴다.
194
+ images[0] 으로 올리면 서버가 파일을 찾지 못한다.
195
+ :param url: 요청 URL
196
+ :param field: 파일 폼 필드명 (예: 'image')
197
+ :param file: 파일 경로(str) 또는 이미 열린 파일 객체
198
+ :param data: 함께 보낼 폼 데이터
199
+ :param headers: 요청별 추가 헤더 (공통값을 덮어쓴다)
200
+ """
201
+ request_headers = self._multipart_headers(headers)
202
+ form_data = self._serialize_form_data(data or {})
203
+
204
+ # 경로를 받으면 여기서 열고 반드시 닫는다. 파일 객체를 받으면 호출자 소유이므로 닫지 않는다.
205
+ opened = open(file, 'rb') if isinstance(file, str) else None
206
+ handle = opened if opened is not None else file
207
+ filename = os.path.basename(file if isinstance(file, str) else getattr(file, 'name', field))
208
+
209
+ try:
210
+ response = requests.post(
211
+ self._entrypoints(url),
212
+ headers=request_headers,
213
+ data=form_data,
214
+ files={field: (filename, handle)},
215
+ timeout=self.timeout
216
+ )
217
+ return response.json()
218
+ finally:
219
+ if opened is not None:
220
+ opened.close()
221
+
222
+ def get_raw(self, url: str, params: Optional[Dict] = None, headers: Optional[Dict[str, str]] = None):
223
+ """
224
+ JSON 이 아닌 본문(CSV 등)을 파싱하지 않고 그대로 받는다.
225
+ ⚠️ 공용 get 은 응답을 무조건 response.json() 으로 파싱하므로, CSV 를 돌려주는 endpoint
226
+ (알림톡 템플릿 내보내기 format=csv)에서는 성공한 요청이 파싱 예외로 터진다.
227
+ 실제로는 200 이라 원인을 찾기 어려워서 원문 경로를 따로 둔다.
228
+ :return: {'body': str, 'content_type': str, 'status': int}
229
+ """
230
+ # 원문 경로라 Accept 를 */* 로 낮춘다 (요청별로 지정했다면 그 값을 존중한다).
231
+ request_headers = self._get_headers(headers={'Accept': '*/*', **(headers or {})})
232
+ response = requests.get(
233
+ self._entrypoints(url),
234
+ headers=request_headers,
235
+ params=params,
236
+ timeout=self.timeout
237
+ )
238
+ return {
239
+ 'body': response.text,
240
+ 'content_type': response.headers.get('Content-Type', ''),
241
+ 'status': response.status_code
242
+ }
243
+
244
+ def _multipart_headers(self, headers: Optional[Dict[str, str]] = None) -> Dict[str, str]:
245
+ """
246
+ multipart 요청 공통 헤더
247
+ ⚠️ Content-Type 은 넣지 않는다 — requests 가 생성한 값(boundary 포함)을 그대로 써야 한다.
248
+ 직접 지정하면 boundary 가 사라져 본문이 서버에서 null 로 파싱된다.
249
+ """
137
250
  request_headers = {
138
251
  'Accept': 'application/json',
139
252
  'Accept-Charset': 'utf-8',
@@ -147,10 +260,14 @@ class BootpayCommerceResource:
147
260
  request_headers['Authorization'] = basic_auth
148
261
  if headers:
149
262
  request_headers.update(headers)
263
+ return request_headers
150
264
 
151
- # 폼 데이터 준비
152
- # ⚠️ bool 은 소문자 'true'/'false' 로 보낸다 — str(True) 의 'True'/'False' 는
153
- # Rails 캐스팅 대상이 아니라서 "False" 가 true 로 해석되는 실위험이 있다.
265
+ def _serialize_form_data(self, data: Dict) -> Dict[str, str]:
266
+ """
267
+ 폼 데이터 직렬화
268
+ ⚠️ bool 은 소문자 'true'/'false' 로 보낸다 — str(True) 의 'True'/'False' 는
269
+ Rails 캐스팅 대상이 아니라서 "False" 가 true 로 해석되는 실위험이 있다.
270
+ """
154
271
  form_data = {}
155
272
  for key, value in data.items():
156
273
  if value is not None:
@@ -160,28 +277,7 @@ class BootpayCommerceResource:
160
277
  form_data[key] = json.dumps(value)
161
278
  else:
162
279
  form_data[key] = str(value)
163
-
164
- # 파일 준비
165
- # ⚠️ Rails 는 반복된 `images` 를 배열로 받지 않는다. images[0], images[1] ... 로 인덱싱해야 한다.
166
- files = []
167
- if image_paths:
168
- for index, image_path in enumerate(image_paths):
169
- filename = os.path.basename(image_path)
170
- files.append((f'images[{index}]', (filename, open(image_path, 'rb'))))
171
-
172
- try:
173
- response = requests.post(
174
- self._entrypoints(url),
175
- headers=request_headers,
176
- data=form_data,
177
- files=files if files else None,
178
- timeout=self.timeout
179
- )
180
- return response.json()
181
- finally:
182
- # 파일 핸들 닫기
183
- for _, file_tuple in files:
184
- file_tuple[1].close()
280
+ return form_data
185
281
 
186
282
  def put(self, url: str, data: Optional[Dict] = None, headers: Optional[Dict[str, str]] = None):
187
283
  """PUT 요청"""
@@ -16,6 +16,18 @@ from .cart import CartModule
16
16
  from .order_subscription_request import OrderSubscriptionRequestModule
17
17
  from .mall_setting import MallSettingModule
18
18
  from .webhook import WebhookModule
19
+ from .faq import FaqModule
20
+ from .notice import NoticeModule
21
+ from .inquiry import InquiryModule
22
+ from .product_qna import ProductQnaModule
23
+ from .product_review import ProductReviewModule
24
+ from .alimtalk_message import AlimtalkMessageModule
25
+ from .alimtalk_official import AlimtalkOfficialModule
26
+ from .alimtalk_optout import AlimtalkOptoutModule
27
+ from .alimtalk_send import AlimtalkSendModule
28
+ from .alimtalk_sender import AlimtalkSenderModule
29
+ from .alimtalk_template import AlimtalkTemplateModule
30
+ from .alimtalk_webhook import AlimtalkWebhookModule
19
31
 
20
32
  __all__ = [
21
33
  'UserModule',
@@ -34,5 +46,17 @@ __all__ = [
34
46
  'CartModule',
35
47
  'OrderSubscriptionRequestModule',
36
48
  'MallSettingModule',
37
- 'WebhookModule'
49
+ 'WebhookModule',
50
+ 'FaqModule',
51
+ 'NoticeModule',
52
+ 'InquiryModule',
53
+ 'ProductQnaModule',
54
+ 'ProductReviewModule',
55
+ 'AlimtalkMessageModule',
56
+ 'AlimtalkOfficialModule',
57
+ 'AlimtalkOptoutModule',
58
+ 'AlimtalkSendModule',
59
+ 'AlimtalkSenderModule',
60
+ 'AlimtalkTemplateModule',
61
+ 'AlimtalkWebhookModule'
38
62
  ]
@@ -0,0 +1,90 @@
1
+ from typing import TYPE_CHECKING, Optional, Dict, Any
2
+ from urllib.parse import urlencode
3
+
4
+ if TYPE_CHECKING:
5
+ from ..commerce_resource import BootpayCommerceResource
6
+
7
+ from ..types import (
8
+ AlimtalkMessageListParams,
9
+ AlimtalkMessageStatsParams
10
+ )
11
+
12
+
13
+ class AlimtalkMessageModule:
14
+ """
15
+ 알림톡 발송내역·집계 모듈 (GET /alimtalk/messages 계열)
16
+
17
+ **유료** 알림톡만 조회된다 (무료 커머스 알림톡은 포함되지 않는다).
18
+ 상태는 벤더 결과 동기화로 확정되므로 접수 직후에는 requested 로 보인다.
19
+ """
20
+
21
+ def __init__(self, bootpay: 'BootpayCommerceResource'):
22
+ self._bootpay = bootpay
23
+
24
+ def list(self, params: Optional[AlimtalkMessageListParams] = None):
25
+ """
26
+ 발송내역 목록 조회
27
+ GET /alimtalk/messages
28
+ ⚠️ 기간 기본값은 최근 30일이고 최대 조회 폭은 92일이다 — 초과분은 거부하지 않고 시작일을 당겨 잘라낸다.
29
+ 실제 적용된 구간은 응답의 period 로 확인한다.
30
+ :param params: 조회 파라미터 (status: requested·success·failed·canceled)
31
+ :return: {'list': [...], 'count': int, 'page': int, 'per': int, 'period': {'from':, 'to':}}
32
+ """
33
+ query = urlencode(self._query(params))
34
+ return self._bootpay.get(
35
+ f'alimtalk/messages{"?" + query if query else ""}',
36
+ headers=self._alimtalk_headers()
37
+ )
38
+
39
+ def stats(self, params: Optional[AlimtalkMessageStatsParams] = None):
40
+ """
41
+ 기간 집계 조회
42
+ GET /alimtalk/messages/stats
43
+ 일자별 집계 원장에서 읽으므로 응답이 빠르다.
44
+ ⚠️ billing.unit_price_source 가 'default' 면 **잠정 단가**다 (확정 청구액이 아니다).
45
+ ⚠️ billable_count 는 성공 − 폴백이다 — 폴백분은 LMS 단가로 따로 계산된다.
46
+ :param params: 조회 기간
47
+ :return: {'period':, 'totals': {...}, 'daily': [...], 'billing': {...}}
48
+ """
49
+ query = urlencode(self._query(params))
50
+ return self._bootpay.get(
51
+ f'alimtalk/messages/stats{"?" + query if query else ""}',
52
+ headers=self._alimtalk_headers()
53
+ )
54
+
55
+ def detail(self, receipt_id: str):
56
+ """
57
+ 단건 발송 결과 조회
58
+ GET /alimtalk/messages/{receipt_id}
59
+ 실패 사유는 error_code·error_message 에 담긴다.
60
+ fallback_type 은 폴백이 꺼진 건이면 None, 켜진 건이면 LMS 다.
61
+ 다른 프로젝트의 건이거나 없으면 404(3025).
62
+ :param receipt_id: 발송 접수 ID
63
+ :return: 발송 상세
64
+ """
65
+ return self._bootpay.get(
66
+ f'alimtalk/messages/{receipt_id}',
67
+ headers=self._alimtalk_headers()
68
+ )
69
+
70
+ def _query(self, params: Optional[Dict[str, Any]]) -> Dict[str, Any]:
71
+ """
72
+ None 을 제거하고 bool 을 소문자 'true'/'false' 로 직렬화한다.
73
+ ⚠️ urlencode 는 True 를 'True' 로 쓴다 — Rails 의 boolean 캐스팅은 'False' 를 참으로 읽으므로
74
+ 그대로 보내면 false 가 true 로 뒤집힌다.
75
+ """
76
+ query = {}
77
+ for key, value in (params or {}).items():
78
+ if value is None:
79
+ continue
80
+ query[key] = ('true' if value else 'false') if isinstance(value, bool) else value
81
+ return query
82
+
83
+ def _alimtalk_headers(self) -> Dict[str, str]:
84
+ """
85
+ 알림톡 API 요청 헤더
86
+ ★Idempotency-Key 를 싣지 않는다★ 알림톡 API 는 이 헤더를 읽지 않는다
87
+ (멱등은 발송의 ref_id 로만 성립한다).
88
+ ★BOOTPAY-ROLE 은 항상 user★ 알림톡 스코프 키가 전부 user:alimtalk_* 다.
89
+ """
90
+ return {'BOOTPAY-ROLE': 'user'}
@@ -0,0 +1,90 @@
1
+ from typing import TYPE_CHECKING, Optional, Dict, Any
2
+ from urllib.parse import urlencode
3
+
4
+ if TYPE_CHECKING:
5
+ from ..commerce_resource import BootpayCommerceResource
6
+
7
+ from ..types import (
8
+ AlimtalkOfficialListParams,
9
+ AlimtalkOfficialRecommendParams
10
+ )
11
+
12
+
13
+ class AlimtalkOfficialModule:
14
+ """
15
+ 부트페이 공식 알림톡 템플릿 카탈로그 모듈 (/alimtalk/official 계열)
16
+
17
+ 부트페이가 미리 카카오 승인을 받아 둔 템플릿이라, 그룹키가 등록된 채널이면 **검수 없이 즉시 발송**된다.
18
+ `alimtalk_sender.create` 로 채널을 등록하면 그룹 등록이 함께 끝나므로 따로 채택할 것이 없다.
19
+ (채택 endpoint 는 서버에서도 비활성화되어 SDK 에 두지 않는다)
20
+
21
+ 전부 조회 계열이라 부작용이 없다 (자체 DB 만 본다).
22
+ """
23
+
24
+ def __init__(self, bootpay: 'BootpayCommerceResource'):
25
+ self._bootpay = bootpay
26
+
27
+ def list(self, params: Optional[AlimtalkOfficialListParams] = None):
28
+ """
29
+ 공식 템플릿 검색
30
+ GET /alimtalk/official
31
+ keyword 는 본문·이름·분류를 부분일치(대소문자 무시)로 훑는다.
32
+ ⚠️ 서버는 q 를 먼저 보고 없으면 keyword 를 본다 — 정본 키인 q 로 보낸다.
33
+ :param params: 조회 파라미터 (msg_type 은 BA·EX 만 존재한다)
34
+ :return: {'list': [...], 'count': int, 'page': int, 'per': int, 'categories': [...]}
35
+ """
36
+ params = dict(params or {})
37
+ query_params = {
38
+ 'q': params.pop('q', None) or params.pop('keyword', None),
39
+ 'category': params.get('category'),
40
+ 'msg_type': params.get('msg_type'),
41
+ 'page': params.get('page'),
42
+ 'per': params.get('per'),
43
+ 'ksp_id': params.get('ksp_id')
44
+ }
45
+
46
+ query = urlencode(self._compact(query_params))
47
+ return self._bootpay.get(
48
+ f'alimtalk/official{"?" + query if query else ""}',
49
+ headers=self._alimtalk_headers()
50
+ )
51
+
52
+ def recommend(self, params: AlimtalkOfficialRecommendParams):
53
+ """
54
+ 보내려는 문구로 공식 템플릿을 추천받는다
55
+ POST /alimtalk/official/recommend
56
+ 유사도 score(0~1) 내림차순으로 돌려준다.
57
+ :param params: 추천 파라미터 (text 필수)
58
+ :return: 추천 템플릿 목록
59
+ """
60
+ return self._bootpay.post(
61
+ 'alimtalk/official/recommend',
62
+ self._compact(params),
63
+ headers=self._alimtalk_headers()
64
+ )
65
+
66
+ def detail(self, code: str, ksp_id: Optional[str] = None):
67
+ """
68
+ 공식 템플릿 상세 조회
69
+ GET /alimtalk/official/{code}
70
+ :param code: 서버 채번 코드 (슬래시를 포함하지 않는다). 없거나 미노출이면 404(3015).
71
+ :param ksp_id: 주면 그 채널의 변수 예문 사전으로 variable_examples 를 채워 준다 (표시용)
72
+ :return: 공식 템플릿 상세
73
+ """
74
+ query = urlencode(self._compact({'ksp_id': ksp_id}))
75
+ return self._bootpay.get(
76
+ f'alimtalk/official/{code}{"?" + query if query else ""}',
77
+ headers=self._alimtalk_headers()
78
+ )
79
+
80
+ def _compact(self, params: Optional[Dict[str, Any]]) -> Dict[str, Any]:
81
+ """None 값을 제거한다."""
82
+ return {k: v for k, v in (params or {}).items() if v is not None}
83
+
84
+ def _alimtalk_headers(self) -> Dict[str, str]:
85
+ """
86
+ 알림톡 API 요청 헤더
87
+ ★Idempotency-Key 를 싣지 않는다★ 알림톡 API 는 이 헤더를 읽지 않는다.
88
+ ★BOOTPAY-ROLE 은 항상 user★ 알림톡 스코프 키가 전부 user:alimtalk_* 다.
89
+ """
90
+ return {'BOOTPAY-ROLE': 'user'}
@@ -0,0 +1,97 @@
1
+ from typing import TYPE_CHECKING, Optional, Dict, Any
2
+ from urllib.parse import urlencode
3
+
4
+ if TYPE_CHECKING:
5
+ from ..commerce_resource import BootpayCommerceResource
6
+
7
+ from ..types import (
8
+ AlimtalkOptoutListParams,
9
+ AlimtalkOptoutCreateParams,
10
+ AlimtalkOptoutCheckParams
11
+ )
12
+
13
+
14
+ class AlimtalkOptoutModule:
15
+ """
16
+ 알림톡 수신거부 모듈 (/alimtalk/optouts 계열, 가맹점 CRM 수신거부 동기화용)
17
+
18
+ 발송 판정과 **같은 기준**으로 다룬다 — 부트페이 전역(global) + 내 프로젝트.
19
+ ⚠️ 전역 건은 **조회는 되지만 해제할 수 없다** (releasable: False).
20
+ 이걸 노출하지 않으면 "화면엔 수신거부가 아닌데 발송은 3021 로 막히는" 상태가 된다.
21
+ """
22
+
23
+ def __init__(self, bootpay: 'BootpayCommerceResource'):
24
+ self._bootpay = bootpay
25
+
26
+ def list(self, params: Optional[AlimtalkOptoutListParams] = None):
27
+ """
28
+ 수신거부 목록 조회
29
+ GET /alimtalk/optouts
30
+ phone 은 숫자만 남겨 **부분일치**로 찾는다 (정확 매칭이 아니다). 50건 단위로 페이징된다.
31
+ :param params: 조회 파라미터
32
+ :return: {'list': [{'id':, 'phone':, 'scope':, 'global':, 'releasable':, 'source':,
33
+ 'reason':, 'opted_out_at':, 'created_at':}], 'count': int, 'page': int}
34
+ """
35
+ query = urlencode(self._compact(params))
36
+ return self._bootpay.get(
37
+ f'alimtalk/optouts{"?" + query if query else ""}',
38
+ headers=self._alimtalk_headers()
39
+ )
40
+
41
+ def create(self, params: AlimtalkOptoutCreateParams):
42
+ """
43
+ 수신거부 등록
44
+ POST /alimtalk/optouts
45
+ 내 프로젝트 스코프로 등록된다 (source: api). 같은 번호를 다시 등록해도 멱등이다.
46
+ :param params: 등록 파라미터 (phone 필수)
47
+ :return: 등록된 수신거부
48
+ """
49
+ return self._bootpay.post(
50
+ 'alimtalk/optouts',
51
+ self._compact(params),
52
+ headers=self._alimtalk_headers()
53
+ )
54
+
55
+ def check(self, params: AlimtalkOptoutCheckParams):
56
+ """
57
+ 발송 전 수신거부 사전 확인
58
+ POST /alimtalk/optouts/check
59
+ 발송 판정과 **같은 축**으로 대조하므로, 벌크에서 skipped 로 낭비될 건을 미리 뺄 수 있다.
60
+ 단건(phone)·다건(phones) 모두 받는다.
61
+ ⚠️ 1회 최대 1,000건이고 넘으면 -48 이다 (중복은 서버가 제거).
62
+ :param params: 확인 파라미터
63
+ :return: {'list': [{'phone':, 'opted_out':, 'global':, 'releasable':, 'opted_out_at':}],
64
+ 'count': int, 'opted_out_count': int}
65
+ """
66
+ return self._bootpay.post(
67
+ 'alimtalk/optouts/check',
68
+ self._compact(params),
69
+ headers=self._alimtalk_headers()
70
+ )
71
+
72
+ def release(self, phone: str):
73
+ """
74
+ 수신거부 해제
75
+ DELETE /alimtalk/optouts/{phone}
76
+ 내 프로젝트 스코프 건만 해제되며 멱등이다 (없어도 성공).
77
+ ⚠️ 전역 차단은 해제되지 않고 global_blocked: True 로 알려 준다 —
78
+ "지웠는데 여전히 막히는" 상태를 응답으로 드러내기 위함이다.
79
+ :param phone: 해제할 수신번호
80
+ :return: {'phone':, 'released':, 'global_blocked':}
81
+ """
82
+ return self._bootpay.delete(
83
+ f'alimtalk/optouts/{phone}',
84
+ headers=self._alimtalk_headers()
85
+ )
86
+
87
+ def _compact(self, params: Optional[Dict[str, Any]]) -> Dict[str, Any]:
88
+ """None 값을 제거한다."""
89
+ return {k: v for k, v in (params or {}).items() if v is not None}
90
+
91
+ def _alimtalk_headers(self) -> Dict[str, str]:
92
+ """
93
+ 알림톡 API 요청 헤더
94
+ ★Idempotency-Key 를 싣지 않는다★ 알림톡 API 는 이 헤더를 읽지 않는다.
95
+ ★BOOTPAY-ROLE 은 항상 user★ 알림톡 스코프 키가 전부 user:alimtalk_* 다.
96
+ """
97
+ return {'BOOTPAY-ROLE': 'user'}