bootpay-backend 2.5.0__tar.gz → 2.7.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 (59) hide show
  1. {bootpay_backend-2.5.0 → bootpay_backend-2.7.0}/PKG-INFO +1 -1
  2. {bootpay_backend-2.5.0 → bootpay_backend-2.7.0}/bootpay_backend/commerce/modules/order.py +1 -1
  3. {bootpay_backend-2.5.0 → bootpay_backend-2.7.0}/bootpay_backend/commerce/modules/order_subscription.py +10 -0
  4. {bootpay_backend-2.5.0 → bootpay_backend-2.7.0}/bootpay_backend/commerce/modules/order_subscription_adjustment.py +7 -0
  5. {bootpay_backend-2.5.0 → bootpay_backend-2.7.0}/bootpay_backend/commerce/modules/product.py +20 -2
  6. {bootpay_backend-2.5.0 → bootpay_backend-2.7.0}/bootpay_backend/commerce/modules/user.py +8 -2
  7. {bootpay_backend-2.5.0 → bootpay_backend-2.7.0}/bootpay_backend/commerce/types/__init__.py +37 -3
  8. {bootpay_backend-2.5.0 → bootpay_backend-2.7.0}/bootpay_backend/rest_client.py +1 -1
  9. {bootpay_backend-2.5.0 → bootpay_backend-2.7.0}/bootpay_backend.egg-info/PKG-INFO +1 -1
  10. {bootpay_backend-2.5.0 → bootpay_backend-2.7.0}/setup.py +1 -1
  11. {bootpay_backend-2.5.0 → bootpay_backend-2.7.0}/tests/commerce/test_wire_format.py +165 -0
  12. {bootpay_backend-2.5.0 → bootpay_backend-2.7.0}/LICENSE +0 -0
  13. {bootpay_backend-2.5.0 → bootpay_backend-2.7.0}/README.md +0 -0
  14. {bootpay_backend-2.5.0 → bootpay_backend-2.7.0}/bootpay_backend/__init__.py +0 -0
  15. {bootpay_backend-2.5.0 → bootpay_backend-2.7.0}/bootpay_backend/bankcode.py +0 -0
  16. {bootpay_backend-2.5.0 → bootpay_backend-2.7.0}/bootpay_backend/commerce/__init__.py +0 -0
  17. {bootpay_backend-2.5.0 → bootpay_backend-2.7.0}/bootpay_backend/commerce/commerce_resource.py +0 -0
  18. {bootpay_backend-2.5.0 → bootpay_backend-2.7.0}/bootpay_backend/commerce/modules/__init__.py +0 -0
  19. {bootpay_backend-2.5.0 → bootpay_backend-2.7.0}/bootpay_backend/commerce/modules/cart.py +0 -0
  20. {bootpay_backend-2.5.0 → bootpay_backend-2.7.0}/bootpay_backend/commerce/modules/category.py +0 -0
  21. {bootpay_backend-2.5.0 → bootpay_backend-2.7.0}/bootpay_backend/commerce/modules/coupon.py +0 -0
  22. {bootpay_backend-2.5.0 → bootpay_backend-2.7.0}/bootpay_backend/commerce/modules/invoice.py +0 -0
  23. {bootpay_backend-2.5.0 → bootpay_backend-2.7.0}/bootpay_backend/commerce/modules/mall_setting.py +0 -0
  24. {bootpay_backend-2.5.0 → bootpay_backend-2.7.0}/bootpay_backend/commerce/modules/order_cancel.py +0 -0
  25. {bootpay_backend-2.5.0 → bootpay_backend-2.7.0}/bootpay_backend/commerce/modules/order_subscription_bill.py +0 -0
  26. {bootpay_backend-2.5.0 → bootpay_backend-2.7.0}/bootpay_backend/commerce/modules/order_subscription_request.py +0 -0
  27. {bootpay_backend-2.5.0 → bootpay_backend-2.7.0}/bootpay_backend/commerce/modules/point.py +0 -0
  28. {bootpay_backend-2.5.0 → bootpay_backend-2.7.0}/bootpay_backend/commerce/modules/store.py +0 -0
  29. {bootpay_backend-2.5.0 → bootpay_backend-2.7.0}/bootpay_backend/commerce/modules/user_group.py +0 -0
  30. {bootpay_backend-2.5.0 → bootpay_backend-2.7.0}/bootpay_backend/commerce/modules/webhook.py +0 -0
  31. {bootpay_backend-2.5.0 → bootpay_backend-2.7.0}/bootpay_backend.egg-info/SOURCES.txt +0 -0
  32. {bootpay_backend-2.5.0 → bootpay_backend-2.7.0}/bootpay_backend.egg-info/dependency_links.txt +0 -0
  33. {bootpay_backend-2.5.0 → bootpay_backend-2.7.0}/bootpay_backend.egg-info/not-zip-safe +0 -0
  34. {bootpay_backend-2.5.0 → bootpay_backend-2.7.0}/bootpay_backend.egg-info/requires.txt +0 -0
  35. {bootpay_backend-2.5.0 → bootpay_backend-2.7.0}/bootpay_backend.egg-info/top_level.txt +0 -0
  36. {bootpay_backend-2.5.0 → bootpay_backend-2.7.0}/setup.cfg +0 -0
  37. {bootpay_backend-2.5.0 → bootpay_backend-2.7.0}/tests/__init__.py +0 -0
  38. {bootpay_backend-2.5.0 → bootpay_backend-2.7.0}/tests/commerce/__init__.py +0 -0
  39. {bootpay_backend-2.5.0 → bootpay_backend-2.7.0}/tests/commerce/test_auth_contract.py +0 -0
  40. {bootpay_backend-2.5.0 → bootpay_backend-2.7.0}/tests/commerce/test_mall_setting.py +0 -0
  41. {bootpay_backend-2.5.0 → bootpay_backend-2.7.0}/tests/commerce/test_order.py +0 -0
  42. {bootpay_backend-2.5.0 → bootpay_backend-2.7.0}/tests/commerce/test_order_subscription_charge.py +0 -0
  43. {bootpay_backend-2.5.0 → bootpay_backend-2.7.0}/tests/commerce/test_product.py +0 -0
  44. {bootpay_backend-2.5.0 → bootpay_backend-2.7.0}/tests/commerce/test_product_mall.py +0 -0
  45. {bootpay_backend-2.5.0 → bootpay_backend-2.7.0}/tests/commerce/test_store.py +0 -0
  46. {bootpay_backend-2.5.0 → bootpay_backend-2.7.0}/tests/commerce/test_token.py +0 -0
  47. {bootpay_backend-2.5.0 → bootpay_backend-2.7.0}/tests/commerce/test_user.py +0 -0
  48. {bootpay_backend-2.5.0 → bootpay_backend-2.7.0}/tests/commerce/test_user_mall.py +0 -0
  49. {bootpay_backend-2.5.0 → bootpay_backend-2.7.0}/tests/commerce/test_webhook.py +0 -0
  50. {bootpay_backend-2.5.0 → bootpay_backend-2.7.0}/tests/conftest.py +0 -0
  51. {bootpay_backend-2.5.0 → bootpay_backend-2.7.0}/tests/pg/__init__.py +0 -0
  52. {bootpay_backend-2.5.0 → bootpay_backend-2.7.0}/tests/pg/test_auth.py +0 -0
  53. {bootpay_backend-2.5.0 → bootpay_backend-2.7.0}/tests/pg/test_billing.py +0 -0
  54. {bootpay_backend-2.5.0 → bootpay_backend-2.7.0}/tests/pg/test_cash_receipt.py +0 -0
  55. {bootpay_backend-2.5.0 → bootpay_backend-2.7.0}/tests/pg/test_legacy_compatibility.py +0 -0
  56. {bootpay_backend-2.5.0 → bootpay_backend-2.7.0}/tests/pg/test_payment.py +0 -0
  57. {bootpay_backend-2.5.0 → bootpay_backend-2.7.0}/tests/pg/test_sequential_billing_key.py +0 -0
  58. {bootpay_backend-2.5.0 → bootpay_backend-2.7.0}/tests/pg/test_token.py +0 -0
  59. {bootpay_backend-2.5.0 → bootpay_backend-2.7.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.5.0
3
+ Version: 2.7.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>
@@ -52,7 +52,7 @@ class OrderModule:
52
52
  if params.get('payment_status') and len(params['payment_status']) > 0:
53
53
  query_params['payment_status'] = ','.join(map(str, params['payment_status']))
54
54
  if params.get('order_subscription_ids') and len(params['order_subscription_ids']) > 0:
55
- query_params['order_subscription_ids'] = ','.join(params['order_subscription_ids'])
55
+ query_params['order_subscription_ids'] = ','.join(map(str, params['order_subscription_ids']))
56
56
 
57
57
  query = urlencode(query_params) if query_params else ''
58
58
  return self._bootpay.get(f'orders{"?" + query if query else ""}')
@@ -193,6 +193,9 @@ class OrderSubscriptionModule:
193
193
  query_params['status'] = params['status']
194
194
  if params.get('user_id'):
195
195
  query_params['user_id'] = params['user_id']
196
+ # 주문번호 역조회 — 서버(#index)가 params[:order_number] 로 구독을 찾아준다.
197
+ if params.get('order_number'):
198
+ query_params['order_number'] = params['order_number']
196
199
 
197
200
  query = urlencode(query_params) if query_params else ''
198
201
  return self._bootpay.get(f'order_subscriptions{"?" + query if query else ""}')
@@ -210,6 +213,13 @@ class OrderSubscriptionModule:
210
213
  구독 계약 내용 변경
211
214
  PUT /v1/order_subscriptions/{order_subscription_id}
212
215
  바뀐 값만 보내면 된다 (나머지는 서버가 그대로 유지한다). supervisor scope.
216
+
217
+ price 는 회차별 결제 금액의 기준금액이다. 바꾸면 결제예정(READY) 회차의 청구액이
218
+ 즉시 다시 계산되고, 이후 회차도 이 금액으로 만들어진다. 이미 결제된 회차는 그대로다.
219
+ 0 이하는 받지 않는다. 특정 회차만 가감하려면 order_subscription_adjustment.create 를 쓴다.
220
+ (관리자 화면의 금액 변경과 같은 구현을 탄다)
221
+
222
+ memo 는 변경이력(SUBSCRIPTION_ACTION_UPDATE)에 남길 사유다.
213
223
  :param params: 수정 파라미터
214
224
  :return: CommerceOrderSubscription
215
225
  """
@@ -27,6 +27,13 @@ class OrderSubscriptionAdjustmentModule:
27
27
  가감산 조정항목 추가
28
28
  POST /v1/order_subscriptions/{order_subscription_id}/adjustments
29
29
  type 미전달시 서버가 price > 0 이면 SETUP_PRICE, 아니면 PERIOD_DISCOUNT 로 자동 판정한다.
30
+
31
+ 회차 지정 방법 3가지 (아래로 갈수록 넓다):
32
+ - duration: 5 → 5회차 한 건만
33
+ - duration_from: 3, duration_to: 7 → 3~7회차 각각 한 건씩 (총 5건)
34
+ - duration_from: 3, is_unlimited: True → 3회차부터 계약 끝까지 (레코드는 1건, duration_to 는 무시)
35
+ 상한은 계약 총회차이며, 총회차가 무제한인 계약은 60회차까지다.
36
+ 이미 결제가 끝난 회차는 거절된다. 범위 중 한 회차라도 최종 금액이 음수면 전부 거절된다(부분 반영 없음).
30
37
  :param order_subscription_id: 정기구독 ID
31
38
  :param adjustment: 조정 정보 (price/duration/tax_free_price 미지정시 각각 0 / 1 / 0)
32
39
  :param idempotency_key: 미지정시 자동 생성
@@ -24,6 +24,12 @@ class ProductModule:
24
24
  def list(self, params: Optional[ProductListParams] = None):
25
25
  """
26
26
  상품 목록 조회
27
+ GET /v1/products
28
+
29
+ ⚠️ 서버(v1/products_controller#index)가 읽는 것은
30
+ page / limit / keyword / category_id / ex_uid / sort 뿐이다.
31
+ type / period_type / s_at / e_at / category_code 는 보내도 에러 없이 무시되고
32
+ 전체 목록이 돌아온다 (하위호환을 위해 전송 자체는 유지한다).
27
33
  :param params: 조회 파라미터
28
34
  :return: {'items': List[CommerceProduct], 'total': int}
29
35
  """
@@ -35,6 +41,13 @@ class ProductModule:
35
41
  query_params['limit'] = params['limit']
36
42
  if params.get('keyword'):
37
43
  query_params['keyword'] = params['keyword']
44
+ if params.get('category_id'):
45
+ query_params['category_id'] = params['category_id']
46
+ if params.get('ex_uid'):
47
+ query_params['ex_uid'] = params['ex_uid']
48
+ if params.get('sort'):
49
+ query_params['sort'] = params['sort']
50
+ # 아래 4개는 서버가 읽지 않는다 — 기존 호출을 깨지 않으려고 전송만 유지한다
38
51
  if params.get('type') is not None:
39
52
  query_params['type'] = params['type']
40
53
  if params.get('period_type'):
@@ -69,6 +82,8 @@ class ProductModule:
69
82
  }
70
83
  if params.get('category_id'):
71
84
  query_params['category_id'] = params['category_id']
85
+ if params.get('ex_uid'):
86
+ query_params['ex_uid'] = params['ex_uid']
72
87
  if params.get('sort'):
73
88
  query_params['sort'] = params['sort']
74
89
  if params.get('keyword'):
@@ -131,15 +146,18 @@ class ProductModule:
131
146
  headers=self._mall_headers(user_jwt=user_jwt, idempotency_key=idempotency_key)
132
147
  )
133
148
 
134
- def lookup_product(self, product_id: str, idempotency_key: Optional[str] = None):
149
+ def lookup_product(self, product_id: str, user_jwt: Optional[str] = None,
150
+ idempotency_key: Optional[str] = None):
135
151
  """
136
152
  상품 정보 조회 — `product_detail` 과 같은 endpoint 다.
137
153
  ruby 동기화 파이프라인이 쓰는 이름이라 호환용으로 둔다.
154
+ 매뉴얼이 GET /v1/products/{product_id} 에 user_jwt 를 안내하므로 회원 컨텍스트 조회도 지원한다.
138
155
  :param product_id: 상품 ID
156
+ :param user_jwt: 회원 JWT (선택)
139
157
  :param idempotency_key: 미지정시 자동 생성
140
158
  :return: CommerceProduct
141
159
  """
142
- return self.product_detail(product_id, idempotency_key=idempotency_key)
160
+ return self.product_detail(product_id, user_jwt=user_jwt, idempotency_key=idempotency_key)
143
161
 
144
162
  def update(self, product: CommerceProduct, idempotency_key: Optional[str] = None):
145
163
  """
@@ -170,6 +170,9 @@ class UserModule:
170
170
  def list(self, params: Optional[UserListParams] = None):
171
171
  """
172
172
  사용자 목록 조회
173
+ ⚠️ 회원등급 필터의 서버(v1/users_controller#index) 키는 membership_type 이다.
174
+ member_type 으로 보내면 에러 없이 조용히 무시되고 전체 목록이 돌아온다.
175
+ 기존 호출 호환을 위해 member_type 도 받아 membership_type 으로 매핑한다.
173
176
  :param params: 조회 파라미터
174
177
  :return: {'items': List[CommerceUser], 'total': int}
175
178
  """
@@ -181,8 +184,11 @@ class UserModule:
181
184
  query_params['limit'] = params['limit']
182
185
  if params.get('keyword'):
183
186
  query_params['keyword'] = params['keyword']
184
- if params.get('member_type') is not None:
185
- query_params['member_type'] = params['member_type']
187
+ membership_type = params.get('membership_type')
188
+ if membership_type is None:
189
+ membership_type = params.get('member_type')
190
+ if membership_type is not None:
191
+ query_params['membership_type'] = membership_type
186
192
  if params.get('type'):
187
193
  query_params['type'] = params['type']
188
194
 
@@ -91,6 +91,9 @@ class CommerceUser(TypedDict, total=False):
91
91
 
92
92
 
93
93
  class UserListParams(ListParams, total=False):
94
+ # 회원등급 필터. 서버가 읽는 정식 키는 membership_type 이다.
95
+ membership_type: int
96
+ # ⚠️ 하위호환 별칭 — 지정하면 membership_type 으로 매핑되어 전송된다.
94
97
  member_type: int
95
98
  type: str
96
99
 
@@ -358,6 +361,23 @@ class CommerceProduct(TypedDict, total=False):
358
361
 
359
362
 
360
363
  class ProductListParams(ListParams, total=False):
364
+ # 상품 목록 조회 파라미터 (GET /v1/products)
365
+ #
366
+ # ⚠️ 서버(v1/products_controller#index)가 실제로 읽는 것은
367
+ # page / limit / keyword / category_id / ex_uid / sort 뿐이다.
368
+ # 아래 type / period_type / s_at / e_at / category_code 는 전송되더라도 에러 없이
369
+ # 무시되고 전체 목록이 돌아온다 — 필터가 걸린 것으로 착각하지 말 것.
370
+ # (keyword 는 26-08-26 서버 변경부터 적용된다. 그 이전 배포본에서는 무시된다.)
371
+
372
+ # 카테고리 ID 로 필터한다 (하위 카테고리 포함)
373
+ category_id: str
374
+ # 외부 UID 로 상품을 찾는다
375
+ ex_uid: str
376
+ # 정렬 키 — position | created_at | -created_at | price | -price | -sold
377
+ sort: str
378
+
379
+ # ── 아래는 서버가 읽지 않는다 (하위호환 때문에 인자만 유지) ──
380
+ # 서버의 상품 타입 필터는 문자열(subscription / discount / normal)이라 값 체계가 다르다
361
381
  type: int
362
382
  period_type: str
363
383
  s_at: str
@@ -367,9 +387,8 @@ class ProductListParams(ListParams, total=False):
367
387
 
368
388
  class MallProductListParams(ProductListParams, total=False):
369
389
  # 상품 목록 조회 (V1 Mall API) 파라미터
370
- # ⚠️ keyword 는 서버가 읽지 않는다 (하위호환 때문에 인자는 유지)
371
- category_id: str
372
- sort: str
390
+ # category_id / ex_uid / sort 는 ProductListParams 에서 상속받는다.
391
+ # 회원 JWT — Bootpay-User-JWT 헤더로 전송된다 (query 에는 포함되지 않는다)
373
392
  user_jwt: str
374
393
  # 미지정시 자동 생성 (Idempotency-Key 헤더로 전송, query 에는 포함되지 않는다)
375
394
  idempotency_key: str
@@ -716,6 +735,8 @@ class OrderSubscriptionListParams(ListParams, total=False):
716
735
  user_group_id: str
717
736
  user_id: str
718
737
  status: int
738
+ # 주문번호로 구독을 역조회한다
739
+ order_number: str
719
740
 
720
741
 
721
742
  class OrderSubscriptionUpdateParams(TypedDict, total=False):
@@ -738,6 +759,12 @@ class OrderSubscriptionUpdateParams(TypedDict, total=False):
738
759
  status: int
739
760
  payment_next_at: str
740
761
  service_end_at: str
762
+ # 회차별 결제 금액의 기준금액. 바꾸면 결제예정(READY) 회차의 청구액이 즉시 재계산되고
763
+ # 이후 회차도 이 금액으로 만들어진다. 이미 결제된 회차는 그대로다. 0 이하는 받지 않는다.
764
+ # 특정 회차만 가감하려면 order_subscription_adjustment.create 를 쓴다.
765
+ price: int
766
+ # 변경이력(SUBSCRIPTION_ACTION_UPDATE)에 남길 사유
767
+ memo: str
741
768
  # 미지정시 자동 생성 (Idempotency-Key 헤더로 전송, body 에는 포함되지 않는다)
742
769
  idempotency_key: str
743
770
 
@@ -883,6 +910,13 @@ class CommerceOrderSubscriptionAdjustment(TypedDict, total=False):
883
910
  name: str
884
911
  type: int
885
912
  created_at: str
913
+ # 회차 지정 방법 3가지 (아래로 갈수록 넓다) — create 전용 파라미터
914
+ # - duration: 5 → 5회차 한 건만
915
+ # - duration_from: 3, duration_to: 7 → 3~7회차 각각 한 건씩 (총 5건)
916
+ # - duration_from: 3, is_unlimited: True → 3회차부터 계약 끝까지 (레코드 1건, duration_to 는 무시)
917
+ duration_from: int
918
+ duration_to: int
919
+ is_unlimited: bool
886
920
 
887
921
 
888
922
  class OrderSubscriptionAdjustmentUpdateParams(TypedDict, total=False):
@@ -11,7 +11,7 @@ class BootpayBackend:
11
11
  'production': 'https://api.bootpay.co.kr/v2'
12
12
  }
13
13
  API_VERSION = '5.1.0'
14
- SDK_VERSION = '2.5.0'
14
+ SDK_VERSION = '2.7.0'
15
15
 
16
16
  def __init__(self, application_id=None, private_key=None, mode='production', client_key=None, secret_key=None):
17
17
  # application_id/private_key는 legacy 사용자를 위해 그대로 지원한다.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bootpay_backend
3
- Version: 2.5.0
3
+ Version: 2.7.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>
@@ -9,7 +9,7 @@ with open(path.join(this_directory, 'README.md'), encoding='utf-8') as f:
9
9
 
10
10
  setuptools.setup(
11
11
  name = 'bootpay_backend',
12
- version = '2.5.0',
12
+ version = '2.7.0',
13
13
  description = 'bootpay server side plugin for python',
14
14
  long_description=long_description,
15
15
  long_description_content_type='text/markdown',
@@ -246,6 +246,19 @@ def test_products_mall_defaults_page_1_limit_20(commerce, captured):
246
246
  assert captured['headers']['Bootpay-User-JWT'] == 'member-jwt'
247
247
 
248
248
 
249
+ def test_product_list_sends_server_read_filters(commerce, captured):
250
+ """list() 는 서버가 읽는 category_id / ex_uid / sort 를 실어야 한다."""
251
+ commerce.product.list({
252
+ 'page': 1, 'limit': 10, 'keyword': '커피',
253
+ 'category_id': 'cat1', 'ex_uid': 'EX-1', 'sort': '-price'
254
+ })
255
+ assert captured['method'] == 'get'
256
+ url = captured['url']
257
+ for fragment in ('page=1', 'limit=10', 'category_id=cat1', 'ex_uid=EX-1', 'sort=-price'):
258
+ assert fragment in url, f'{fragment} not in {url}'
259
+ assert 'keyword=' in url
260
+
261
+
249
262
  def test_product_detail_mall_supports_jwt(commerce, captured):
250
263
  commerce.product.product_detail('prod1', user_jwt='member-jwt')
251
264
  assert captured['url'].endswith('/v1/products/prod1')
@@ -390,6 +403,54 @@ def test_adjustment_create_defaults(commerce, captured):
390
403
  assert captured['json'] == {'price': 5000, 'duration': 1, 'tax_free_price': 0, 'name': '설치비'}
391
404
 
392
405
 
406
+ def test_adjustment_create_supports_duration_range(commerce, captured):
407
+ """duration_from ~ duration_to 로 회차 범위를 지정한다 (3~7회차 각각 한 건씩)."""
408
+ commerce.order_subscription_adjustment.create('os1', {
409
+ 'name': '기간할인',
410
+ 'price': -1000,
411
+ 'duration_from': 3,
412
+ 'duration_to': 7,
413
+ })
414
+
415
+ assert captured['method'] == 'post'
416
+ assert captured['url'].endswith('/v1/order_subscriptions/os1/adjustments')
417
+ assert captured['json'] == {
418
+ 'name': '기간할인', 'price': -1000, 'duration': 1, 'tax_free_price': 0,
419
+ 'duration_from': 3, 'duration_to': 7,
420
+ }
421
+ assert captured['headers']['BOOTPAY-ROLE'] == 'supervisor'
422
+
423
+
424
+ def test_adjustment_create_supports_unlimited_range(commerce, captured):
425
+ """duration_from + is_unlimited 이면 해당 회차부터 계약 끝까지 (duration_to 는 무시된다)."""
426
+ commerce.order_subscription_adjustment.create('os1', {
427
+ 'name': '무기한할인',
428
+ 'price': -500,
429
+ 'duration_from': 3,
430
+ 'is_unlimited': True,
431
+ 'duration_to': None,
432
+ }, idempotency_key='adj-key')
433
+
434
+ assert captured['json'] == {
435
+ 'name': '무기한할인', 'price': -500, 'duration': 1, 'tax_free_price': 0,
436
+ 'duration_from': 3, 'is_unlimited': True,
437
+ }
438
+ assert captured['headers']['Idempotency-Key'] == 'adj-key'
439
+
440
+
441
+ def test_adjustment_create_keeps_false_is_unlimited(commerce, captured):
442
+ """None 만 제거한다 — is_unlimited=False 는 그대로 전송되어야 한다."""
443
+ commerce.order_subscription_adjustment.create('os1', {
444
+ 'name': '단일회차',
445
+ 'price': 1000,
446
+ 'duration_from': 2,
447
+ 'duration_to': 4,
448
+ 'is_unlimited': False,
449
+ })
450
+
451
+ assert captured['json']['is_unlimited'] is False
452
+
453
+
393
454
  # ---------------------------------------------------------------------------
394
455
  # 파라미터 확장 — userGroup.limit / order.list / orderSubscription.list / orderSubscriptionRequest.list
395
456
  # ---------------------------------------------------------------------------
@@ -501,6 +562,22 @@ def test_order_subscription_update_uses_supervisor_role(commerce, captured):
501
562
  assert captured['headers']['BOOTPAY-ROLE'] == 'supervisor'
502
563
 
503
564
 
565
+ def test_order_subscription_update_sends_price(commerce, captured):
566
+ """price 는 회차별 결제 기준금액 — 계약변경 body 로 그대로 전송된다."""
567
+ commerce.order_subscription.update({
568
+ 'order_subscription_id': 'os1',
569
+ 'price': 15000,
570
+ 'order_name': None,
571
+ 'idempotency_key': 'price-key',
572
+ })
573
+
574
+ assert captured['method'] == 'put'
575
+ assert captured['url'].endswith('/v1/order_subscriptions/os1')
576
+ assert captured['json'] == {'price': 15000}
577
+ assert captured['headers']['BOOTPAY-ROLE'] == 'supervisor'
578
+ assert captured['headers']['Idempotency-Key'] == 'price-key'
579
+
580
+
504
581
  def test_order_subscription_request_list_role_depends_on_project_id(commerce, captured):
505
582
  commerce.order_subscription_request.list()
506
583
  assert 'page=1' in captured['url'] and 'limit=20' in captured['url']
@@ -561,3 +638,91 @@ def test_explicit_idempotency_key_is_forwarded_and_kept_out_of_body(commerce, ca
561
638
 
562
639
  assert captured['headers']['Idempotency-Key'] == 'fixed-key'
563
640
  assert captured['json'] == {'name': '카테고리'}
641
+
642
+
643
+ # ---------------------------------------------------------------------------
644
+ # ruby SDK 누락 파라미터 반영 — orders / order_subscriptions / products / users
645
+ # ---------------------------------------------------------------------------
646
+ def test_order_list_supports_subscription_filters(commerce, captured):
647
+ """구독 계약별·결제유형별 필터 — order_subscription_ids 는 콤마로 join 해서 보낸다."""
648
+ commerce.order.list({
649
+ 'order_subscription_ids': ['os1', 'os2'],
650
+ 'subscription_billing_type': 1,
651
+ })
652
+
653
+ assert captured['method'] == 'get'
654
+ assert 'order_subscription_ids=os1%2Cos2' in captured['url']
655
+ assert 'subscription_billing_type=1' in captured['url']
656
+
657
+
658
+ def test_order_list_omits_empty_status_filters(commerce, captured):
659
+ """값이 비었으면 status=&payment_status= 를 실어 보내지 않는다 (서버는 무시하지만 노이즈)."""
660
+ commerce.order.list({'page': 1, 'status': [], 'payment_status': [], 'order_subscription_ids': []})
661
+
662
+ assert 'status=' not in captured['url']
663
+ assert 'payment_status=' not in captured['url']
664
+ assert 'order_subscription_ids=' not in captured['url']
665
+
666
+
667
+ def test_order_subscription_list_supports_order_number(commerce, captured):
668
+ """주문번호로 구독을 역조회한다."""
669
+ commerce.order_subscription.list({'order_number': 'ON-1'})
670
+
671
+ assert captured['method'] == 'get'
672
+ assert '/v1/order_subscriptions?' in captured['url']
673
+ assert 'order_number=ON-1' in captured['url']
674
+
675
+
676
+ def test_order_subscription_update_sends_memo(commerce, captured):
677
+ """memo 는 변경이력에 남길 사유로 body 에 그대로 전송된다."""
678
+ commerce.order_subscription.update({
679
+ 'order_subscription_id': 'os1',
680
+ 'price': 15000,
681
+ 'memo': '고객 요청으로 금액 변경',
682
+ })
683
+
684
+ assert captured['method'] == 'put'
685
+ assert captured['url'].endswith('/v1/order_subscriptions/os1')
686
+ assert captured['json'] == {'price': 15000, 'memo': '고객 요청으로 금액 변경'}
687
+ assert captured['headers']['BOOTPAY-ROLE'] == 'supervisor'
688
+
689
+
690
+ def test_products_mall_supports_ex_uid(commerce, captured):
691
+ """외부 UID 로 상품 조회 — 서버(v1/products_controller#index)가 params[:ex_uid] 를 읽는다."""
692
+ commerce.product.products({'ex_uid': 'EX-1'})
693
+
694
+ assert captured['method'] == 'get'
695
+ assert 'ex_uid=EX-1' in captured['url']
696
+
697
+
698
+ def test_lookup_product_supports_jwt(commerce, captured):
699
+ """lookup_product 도 product_detail 과 같이 회원 컨텍스트 조회를 지원한다."""
700
+ commerce.product.lookup_product('prod1')
701
+ assert captured['url'].endswith('/v1/products/prod1')
702
+ assert 'Bootpay-User-JWT' not in captured['headers']
703
+
704
+ commerce.product.lookup_product('prod1', user_jwt='member-jwt', idempotency_key='lp-key')
705
+ assert captured['url'].endswith('/v1/products/prod1')
706
+ assert captured['headers']['Bootpay-User-JWT'] == 'member-jwt'
707
+ assert captured['headers']['Idempotency-Key'] == 'lp-key'
708
+
709
+
710
+ def test_user_list_sends_membership_type_not_member_type(commerce, captured):
711
+ """서버가 읽는 회원등급 키는 membership_type 이다 — member_type 으로 보내면 조용히 무시된다."""
712
+ commerce.user.list({'membership_type': 1})
713
+
714
+ assert captured['method'] == 'get'
715
+ assert 'membership_type=1' in captured['url']
716
+ assert 'member_type=1' not in captured['url']
717
+
718
+
719
+ def test_user_list_maps_legacy_member_type_alias(commerce, captured):
720
+ """기존 호출 호환 — member_type 은 membership_type 으로 매핑해서 보낸다."""
721
+ commerce.user.list({'member_type': 2, 'keyword': '테스트'})
722
+
723
+ assert 'membership_type=2' in captured['url']
724
+ assert 'member_type=2' not in captured['url']
725
+
726
+ # 둘 다 주면 정식 키가 우선한다.
727
+ commerce.user.list({'membership_type': 1, 'member_type': 2})
728
+ assert 'membership_type=1' in captured['url']
File without changes