sub2api 0.1.1__tar.gz → 0.2.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: sub2api
3
- Version: 0.1.1
3
+ Version: 0.2.0
4
4
  Summary: Python client for the user-facing API of Sub2API instances
5
5
  Author: Eight Labs
6
6
  License-Expression: MIT
@@ -31,7 +31,7 @@ Description-Content-Type: text/markdown
31
31
 
32
32
  `sub2api` is a Python client for the shared user-facing panel API exposed by Sub2API instances. One client object represents one user's in-memory dashboard session. Requests use `curl_cffi` with Chrome browser impersonation by default.
33
33
 
34
- The library targets operations present on standard Sub2API deployments: account balance, platform quotas, usage history and statistics, API keys, groups, subscriptions, announcements, and redemption.
34
+ The library targets operations present on standard Sub2API deployments: account balance, optional payment deposits, payment orders, platform quotas, usage history and statistics, API keys, groups, subscriptions, announcements, and redemption.
35
35
 
36
36
  ## Install
37
37
 
@@ -186,6 +186,39 @@ result = client.redeem("REDEMPTION-CODE")
186
186
  redemption_history = client.redeem.history()
187
187
  ```
188
188
 
189
+ ## Deposits and payment
190
+
191
+ Payment is optional and must be enabled and configured by the instance administrator. Inspect the checkout configuration before offering a deposit:
192
+
193
+ ```python
194
+ checkout = client.payment.checkout_info()
195
+
196
+ if checkout.methods:
197
+ order = client.deposit(
198
+ 10,
199
+ payment_type="stripe",
200
+ return_url="https://app.example.com/payment/result",
201
+ )
202
+ print(order.order_id, order.pay_url, order.qr_code, order.client_secret)
203
+ ```
204
+
205
+ Available payment methods depend on the instance and can include `alipay`, `wxpay`, `stripe`, and `airwallex`. The returned order contains the provider-specific checkout data: a hosted payment URL, QR code, Stripe client secret, or WeChat OAuth/JSAPI payload. Creating the order does not credit the balance; the configured provider must confirm payment before the instance completes the deposit.
206
+
207
+ Use the payment resource to inspect and manage the order:
208
+
209
+ ```python
210
+ pending = client.payment.get(order.order_id)
211
+ verified = client.payment.verify(order.out_trade_no)
212
+ orders = client.payment.list(status="COMPLETED", order_type="balance")
213
+ client.payment.cancel(order.order_id)
214
+ ```
215
+
216
+ An instance without payment configuration returns its normal typed API error, including `PAYMENT_DISABLED` or `NO_AVAILABLE_INSTANCE`, rather than silently treating a deposit as successful. Public order recovery is also available when a checkout flow has a signed resume token:
217
+
218
+ ```python
219
+ public_order = client.payment.resolve_public(order.resume_token)
220
+ ```
221
+
189
222
  ## Fork-specific endpoints
190
223
 
191
224
  `request()` provides the same authentication, envelope handling, timezone parameter, refresh behavior, and error mapping for relative endpoints that are not part of the stable resource API.
@@ -2,7 +2,7 @@
2
2
 
3
3
  `sub2api` is a Python client for the shared user-facing panel API exposed by Sub2API instances. One client object represents one user's in-memory dashboard session. Requests use `curl_cffi` with Chrome browser impersonation by default.
4
4
 
5
- The library targets operations present on standard Sub2API deployments: account balance, platform quotas, usage history and statistics, API keys, groups, subscriptions, announcements, and redemption.
5
+ The library targets operations present on standard Sub2API deployments: account balance, optional payment deposits, payment orders, platform quotas, usage history and statistics, API keys, groups, subscriptions, announcements, and redemption.
6
6
 
7
7
  ## Install
8
8
 
@@ -157,6 +157,39 @@ result = client.redeem("REDEMPTION-CODE")
157
157
  redemption_history = client.redeem.history()
158
158
  ```
159
159
 
160
+ ## Deposits and payment
161
+
162
+ Payment is optional and must be enabled and configured by the instance administrator. Inspect the checkout configuration before offering a deposit:
163
+
164
+ ```python
165
+ checkout = client.payment.checkout_info()
166
+
167
+ if checkout.methods:
168
+ order = client.deposit(
169
+ 10,
170
+ payment_type="stripe",
171
+ return_url="https://app.example.com/payment/result",
172
+ )
173
+ print(order.order_id, order.pay_url, order.qr_code, order.client_secret)
174
+ ```
175
+
176
+ Available payment methods depend on the instance and can include `alipay`, `wxpay`, `stripe`, and `airwallex`. The returned order contains the provider-specific checkout data: a hosted payment URL, QR code, Stripe client secret, or WeChat OAuth/JSAPI payload. Creating the order does not credit the balance; the configured provider must confirm payment before the instance completes the deposit.
177
+
178
+ Use the payment resource to inspect and manage the order:
179
+
180
+ ```python
181
+ pending = client.payment.get(order.order_id)
182
+ verified = client.payment.verify(order.out_trade_no)
183
+ orders = client.payment.list(status="COMPLETED", order_type="balance")
184
+ client.payment.cancel(order.order_id)
185
+ ```
186
+
187
+ An instance without payment configuration returns its normal typed API error, including `PAYMENT_DISABLED` or `NO_AVAILABLE_INSTANCE`, rather than silently treating a deposit as successful. Public order recovery is also available when a checkout flow has a signed resume token:
188
+
189
+ ```python
190
+ public_order = client.payment.resolve_public(order.resume_token)
191
+ ```
192
+
160
193
  ## Fork-specific endpoints
161
194
 
162
195
  `request()` provides the same authentication, envelope handling, timezone parameter, refresh behavior, and error mapping for relative endpoints that are not part of the stable resource API.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "sub2api"
7
- version = "0.1.1"
7
+ version = "0.2.0"
8
8
  description = "Python client for the user-facing API of Sub2API instances"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -17,14 +17,21 @@ from ._models import (
17
17
  Announcement,
18
18
  APIKey,
19
19
  Balance,
20
+ CheckoutInfo,
20
21
  Group,
21
22
  KeyGroupMultiplier,
22
23
  Page,
24
+ PaymentConfig,
25
+ PaymentMethodLimit,
26
+ PaymentOrder,
27
+ PaymentOrderCreation,
23
28
  PlatformQuota,
29
+ PublicPaymentOrder,
24
30
  Redemption,
25
31
  Resource,
26
32
  SessionTokens,
27
33
  Subscription,
34
+ SubscriptionPlan,
28
35
  UsageRecord,
29
36
  User,
30
37
  )
@@ -35,15 +42,21 @@ __all__ = [
35
42
  "Announcement",
36
43
  "AuthenticationError",
37
44
  "Balance",
45
+ "CheckoutInfo",
38
46
  "ConfigurationError",
39
47
  "ConflictError",
40
48
  "Group",
41
49
  "KeyGroupMultiplier",
42
50
  "NotFoundError",
43
51
  "Page",
52
+ "PaymentConfig",
53
+ "PaymentMethodLimit",
54
+ "PaymentOrder",
55
+ "PaymentOrderCreation",
44
56
  "PermissionDeniedError",
45
57
  "PlatformQuota",
46
58
  "ProtocolError",
59
+ "PublicPaymentOrder",
47
60
  "RateLimitError",
48
61
  "Redemption",
49
62
  "Resource",
@@ -51,6 +64,7 @@ __all__ = [
51
64
  "Sub2API",
52
65
  "Sub2APIError",
53
66
  "Subscription",
67
+ "SubscriptionPlan",
54
68
  "TransportError",
55
69
  "TwoFactorRequired",
56
70
  "UsageRecord",
@@ -58,4 +72,4 @@ __all__ = [
58
72
  "ValidationError",
59
73
  ]
60
74
 
61
- __version__ = "0.1.1"
75
+ __version__ = "0.2.0"
@@ -22,12 +22,13 @@ from ._exceptions import (
22
22
  TwoFactorRequired,
23
23
  ValidationError,
24
24
  )
25
- from ._models import Balance, Resource, SessionTokens, User
25
+ from ._models import Balance, PaymentOrderCreation, Resource, SessionTokens, User
26
26
  from ._resources import (
27
27
  AccountResource,
28
28
  AnnouncementsResource,
29
29
  GroupsResource,
30
30
  KeysResource,
31
+ PaymentResource,
31
32
  RedeemResource,
32
33
  SubscriptionsResource,
33
34
  UsageResource,
@@ -146,10 +147,7 @@ class Sub2API:
146
147
  self._owns_http_client = session is None
147
148
  if session is None:
148
149
  if impersonate:
149
- self._http: Any = cast(
150
- Any,
151
- curl_requests.Session(impersonate=impersonate),
152
- )
150
+ self._http: Any = cast(Any, curl_requests.Session)(impersonate=impersonate)
153
151
  else:
154
152
  self._http = cast(Any, curl_requests.Session())
155
153
  else:
@@ -161,6 +159,7 @@ class Sub2API:
161
159
  self.history = self.usage
162
160
  self.subscriptions = SubscriptionsResource(self)
163
161
  self.announcements = AnnouncementsResource(self)
162
+ self.payment = PaymentResource(self)
164
163
  self.redeem = RedeemResource(self)
165
164
 
166
165
  @property
@@ -281,6 +280,27 @@ class Sub2API:
281
280
  def balance(self) -> Balance:
282
281
  return self.account.balance()
283
282
 
283
+ def deposit(
284
+ self,
285
+ amount: int | float,
286
+ payment_type: str,
287
+ *,
288
+ return_url: str | None = None,
289
+ payment_source: str | None = None,
290
+ openid: str | None = None,
291
+ wechat_resume_token: str | None = None,
292
+ is_mobile: bool | None = None,
293
+ ) -> PaymentOrderCreation:
294
+ return self.payment.deposit(
295
+ amount,
296
+ payment_type,
297
+ return_url=return_url,
298
+ payment_source=payment_source,
299
+ openid=openid,
300
+ wechat_resume_token=wechat_resume_token,
301
+ is_mobile=is_mobile,
302
+ )
303
+
284
304
  def public_settings(self) -> Resource:
285
305
  data = self._request(
286
306
  "GET",
@@ -376,7 +396,7 @@ class Sub2API:
376
396
  request_headers = {
377
397
  "Accept": "application/json",
378
398
  "Accept-Language": self.language,
379
- "User-Agent": "sub2api-python/0.1.0",
399
+ "User-Agent": "sub2api-python/0.2.0",
380
400
  "X-User-UI-Request": "1",
381
401
  }
382
402
  if authenticated and self._tokens.access_token:
@@ -11,10 +11,12 @@ _SENSITIVE_FIELDS = frozenset(
11
11
  {
12
12
  "access_token",
13
13
  "authorization",
14
+ "client_secret",
14
15
  "code",
15
16
  "cookie",
16
17
  "custom_key",
17
18
  "key",
19
+ "openid",
18
20
  "password",
19
21
  "refresh_token",
20
22
  "temp_token",
@@ -103,6 +105,34 @@ class Redemption(Resource):
103
105
  """A redemption result or history item."""
104
106
 
105
107
 
108
+ class PaymentConfig(Resource):
109
+ """Payment availability and account recharge configuration."""
110
+
111
+
112
+ class PaymentMethodLimit(Resource):
113
+ """Limits and fee information for one payment method."""
114
+
115
+
116
+ class CheckoutInfo(Resource):
117
+ """Payment methods and configuration used to start checkout."""
118
+
119
+
120
+ class SubscriptionPlan(Resource):
121
+ """A subscription plan available for purchase."""
122
+
123
+
124
+ class PaymentOrder(Resource):
125
+ """A user payment or balance-recharge order."""
126
+
127
+
128
+ class PaymentOrderCreation(Resource):
129
+ """Checkout details returned after creating a payment order."""
130
+
131
+
132
+ class PublicPaymentOrder(Resource):
133
+ """Non-sensitive payment order data returned by public recovery endpoints."""
134
+
135
+
106
136
  @dataclass(frozen=True)
107
137
  class KeyGroupMultiplier:
108
138
  """The group multiplier resolved for one API key."""
@@ -11,13 +11,19 @@ from ._models import (
11
11
  Announcement,
12
12
  APIKey,
13
13
  Balance,
14
+ CheckoutInfo,
14
15
  Group,
15
16
  KeyGroupMultiplier,
16
17
  Page,
18
+ PaymentConfig,
19
+ PaymentOrder,
20
+ PaymentOrderCreation,
17
21
  PlatformQuota,
22
+ PublicPaymentOrder,
18
23
  Redemption,
19
24
  Resource,
20
25
  Subscription,
26
+ SubscriptionPlan,
21
27
  UsageRecord,
22
28
  User,
23
29
  )
@@ -62,6 +68,14 @@ def _nonnegative(value: int | float, name: str) -> int | float:
62
68
  return value
63
69
 
64
70
 
71
+ def _amount(value: int | float, name: str = "amount") -> int | float:
72
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
73
+ raise ValueError(f"{name} must be a positive finite number")
74
+ if not math.isfinite(value) or value <= 0:
75
+ raise ValueError(f"{name} must be a positive finite number")
76
+ return value
77
+
78
+
65
79
  def _date(value: date | str | None) -> str | None:
66
80
  if value is None:
67
81
  return None
@@ -105,6 +119,209 @@ class AccountResource:
105
119
  return tuple(PlatformQuota(item) for item in quotas if isinstance(item, Mapping))
106
120
 
107
121
 
122
+ class PaymentResource:
123
+ def __init__(self, client: Sub2API) -> None:
124
+ self._client = client
125
+
126
+ def config(self) -> PaymentConfig:
127
+ data = self._client.request("GET", "payment/config")
128
+ return PaymentConfig(_mapping(data, "payment config"))
129
+
130
+ def checkout_info(self) -> CheckoutInfo:
131
+ data = self._client.request("GET", "payment/checkout-info")
132
+ return CheckoutInfo(_mapping(data, "checkout info"))
133
+
134
+ def limits(self) -> Resource:
135
+ return Resource(_mapping(self._client.request("GET", "payment/limits"), "payment limits"))
136
+
137
+ def plans(self) -> tuple[SubscriptionPlan, ...]:
138
+ data = _list(self._client.request("GET", "payment/plans"), "subscription plans")
139
+ return tuple(SubscriptionPlan(item) for item in data if isinstance(item, Mapping))
140
+
141
+ def create_order(
142
+ self,
143
+ amount: int | float,
144
+ payment_type: str,
145
+ *,
146
+ order_type: Literal["balance", "subscription"] = "balance",
147
+ plan_id: int | None = None,
148
+ return_url: str | None = None,
149
+ payment_source: str | None = None,
150
+ openid: str | None = None,
151
+ wechat_resume_token: str | None = None,
152
+ is_mobile: bool | None = None,
153
+ ) -> PaymentOrderCreation:
154
+ amount = _amount(amount)
155
+ if not payment_type.strip():
156
+ raise ValueError("payment_type must not be empty")
157
+ if order_type not in {"balance", "subscription"}:
158
+ raise ValueError("order_type must be 'balance' or 'subscription'")
159
+ if order_type == "subscription" and plan_id is None:
160
+ raise ValueError("plan_id is required for subscription orders")
161
+ if plan_id is not None:
162
+ _positive(plan_id, "plan_id")
163
+ if is_mobile is not None and not isinstance(is_mobile, bool):
164
+ raise ValueError("is_mobile must be a boolean")
165
+ payload: dict[str, Any] = {
166
+ "amount": amount,
167
+ "payment_type": payment_type,
168
+ "order_type": order_type,
169
+ }
170
+ optional = {
171
+ "plan_id": plan_id,
172
+ "return_url": return_url,
173
+ "payment_source": payment_source,
174
+ "openid": openid,
175
+ "wechat_resume_token": wechat_resume_token,
176
+ "is_mobile": is_mobile,
177
+ }
178
+ payload.update({key: value for key, value in optional.items() if value is not None})
179
+ data = self._client.request("POST", "payment/orders", json=payload)
180
+ return PaymentOrderCreation(_mapping(data, "created payment order"))
181
+
182
+ def deposit(
183
+ self,
184
+ amount: int | float,
185
+ payment_type: str,
186
+ *,
187
+ return_url: str | None = None,
188
+ payment_source: str | None = None,
189
+ openid: str | None = None,
190
+ wechat_resume_token: str | None = None,
191
+ is_mobile: bool | None = None,
192
+ ) -> PaymentOrderCreation:
193
+ return self.create_order(
194
+ amount,
195
+ payment_type,
196
+ order_type="balance",
197
+ return_url=return_url,
198
+ payment_source=payment_source,
199
+ openid=openid,
200
+ wechat_resume_token=wechat_resume_token,
201
+ is_mobile=is_mobile,
202
+ )
203
+
204
+ def create_deposit(
205
+ self,
206
+ amount: int | float,
207
+ payment_type: str,
208
+ *,
209
+ return_url: str | None = None,
210
+ payment_source: str | None = None,
211
+ openid: str | None = None,
212
+ wechat_resume_token: str | None = None,
213
+ is_mobile: bool | None = None,
214
+ ) -> PaymentOrderCreation:
215
+ return self.deposit(
216
+ amount,
217
+ payment_type,
218
+ return_url=return_url,
219
+ payment_source=payment_source,
220
+ openid=openid,
221
+ wechat_resume_token=wechat_resume_token,
222
+ is_mobile=is_mobile,
223
+ )
224
+
225
+ def list(
226
+ self,
227
+ *,
228
+ page: int = 1,
229
+ page_size: int = 20,
230
+ status: str | None = None,
231
+ order_type: str | None = None,
232
+ payment_type: str | None = None,
233
+ ) -> Page[PaymentOrder]:
234
+ params = {
235
+ "page": _positive(page, "page"),
236
+ "page_size": _positive(page_size, "page_size"),
237
+ "status": status,
238
+ "order_type": order_type,
239
+ "payment_type": payment_type,
240
+ }
241
+ return _page(
242
+ self._client.request("GET", "payment/orders/my", params=params),
243
+ PaymentOrder,
244
+ "payment orders",
245
+ )
246
+
247
+ def list_orders(self, **kwargs: Any) -> Page[PaymentOrder]:
248
+ return self.list(**kwargs)
249
+
250
+ def get(self, order_id: int) -> PaymentOrder:
251
+ order_id = _positive(order_id, "order_id")
252
+ data = self._client.request("GET", f"payment/orders/{order_id}")
253
+ return PaymentOrder(_mapping(data, "payment order"))
254
+
255
+ def get_order(self, order_id: int) -> PaymentOrder:
256
+ return self.get(order_id)
257
+
258
+ def cancel(self, order_id: int) -> Resource | None:
259
+ order_id = _positive(order_id, "order_id")
260
+ data = self._client.request("POST", f"payment/orders/{order_id}/cancel")
261
+ return Resource(data) if isinstance(data, Mapping) else None
262
+
263
+ def cancel_order(self, order_id: int) -> Resource | None:
264
+ return self.cancel(order_id)
265
+
266
+ def verify(self, out_trade_no: str) -> PaymentOrder:
267
+ if not out_trade_no.strip():
268
+ raise ValueError("out_trade_no must not be empty")
269
+ data = self._client.request(
270
+ "POST",
271
+ "payment/orders/verify",
272
+ json={"out_trade_no": out_trade_no},
273
+ )
274
+ return PaymentOrder(_mapping(data, "verified payment order"))
275
+
276
+ def verify_order(self, out_trade_no: str) -> PaymentOrder:
277
+ return self.verify(out_trade_no)
278
+
279
+ def verify_public(self, out_trade_no: str) -> PublicPaymentOrder:
280
+ if not out_trade_no.strip():
281
+ raise ValueError("out_trade_no must not be empty")
282
+ data = self._client.request(
283
+ "POST",
284
+ "payment/public/orders/verify",
285
+ json={"out_trade_no": out_trade_no},
286
+ authenticated=False,
287
+ )
288
+ return PublicPaymentOrder(_mapping(data, "public payment order"))
289
+
290
+ def resolve_public(self, resume_token: str) -> PublicPaymentOrder:
291
+ if not resume_token.strip():
292
+ raise ValueError("resume_token must not be empty")
293
+ data = self._client.request(
294
+ "POST",
295
+ "payment/public/orders/resolve",
296
+ json={"resume_token": resume_token},
297
+ authenticated=False,
298
+ )
299
+ return PublicPaymentOrder(_mapping(data, "public payment order"))
300
+
301
+ def request_refund(self, order_id: int, reason: str) -> Resource | None:
302
+ order_id = _positive(order_id, "order_id")
303
+ if not reason.strip():
304
+ raise ValueError("reason must not be empty")
305
+ data = self._client.request(
306
+ "POST",
307
+ f"payment/orders/{order_id}/refund-request",
308
+ json={"reason": reason},
309
+ )
310
+ return Resource(data) if isinstance(data, Mapping) else None
311
+
312
+ def refund_eligible_providers(self) -> tuple[str, ...]:
313
+ data = _mapping(
314
+ self._client.request("GET", "payment/orders/refund-eligible-providers"),
315
+ "refund eligibility",
316
+ )
317
+ provider_ids = data.get("provider_instance_ids", [])
318
+ if not isinstance(provider_ids, list) or any(
319
+ not isinstance(item, str) for item in provider_ids
320
+ ):
321
+ raise ProtocolError("refund eligibility contained invalid provider IDs")
322
+ return tuple(provider_ids)
323
+
324
+
108
325
  class KeysResource:
109
326
  def __init__(self, client: Sub2API) -> None:
110
327
  self._client = client
@@ -224,3 +224,130 @@ def test_history_filters_and_boolean_query_encoding() -> None:
224
224
  page = client.history(start_date="2025-01-01", end_date="2025-01-31", stream=True)
225
225
 
226
226
  assert page.items[0].model == "claude"
227
+
228
+
229
+ def test_payment_config_deposit_and_order_lifecycle() -> None:
230
+ seen: list[tuple[str, str, Any]] = []
231
+
232
+ def handler(request: httpx.Request) -> httpx.Response:
233
+ body = json.loads(request.content) if request.content else None
234
+ seen.append((request.method, request.url.path, body))
235
+ if request.url.path.endswith("/payment/config"):
236
+ return success({"enabled": True, "min_amount": 1, "enabled_payment_types": ["stripe"]})
237
+ if request.url.path.endswith("/payment/checkout-info"):
238
+ return success({"methods": {"stripe": {"available": True}}, "global_min": 1})
239
+ if request.url.path.endswith("/payment/limits"):
240
+ return success({"methods": {"stripe": {"single_min": 1}}})
241
+ if request.url.path.endswith("/payment/orders") and request.method == "POST":
242
+ assert request.headers["Authorization"] == "Bearer token"
243
+ assert body == {
244
+ "amount": 10,
245
+ "payment_type": "stripe",
246
+ "order_type": "balance",
247
+ "return_url": "https://app.example.test/payment/result",
248
+ "payment_source": "hosted_redirect",
249
+ "is_mobile": False,
250
+ }
251
+ return success(
252
+ {
253
+ "order_id": 42,
254
+ "amount": 10,
255
+ "pay_amount": 10.25,
256
+ "fee_rate": 2.5,
257
+ "payment_type": "stripe",
258
+ "client_secret": "secret-value",
259
+ "out_trade_no": "SUB2API-42",
260
+ "expires_at": "2026-01-01T00:30:00Z",
261
+ }
262
+ )
263
+ if request.url.path.endswith("/payment/orders/42"):
264
+ return success(
265
+ {
266
+ "id": 42,
267
+ "amount": 10,
268
+ "status": "PENDING",
269
+ "order_type": "balance",
270
+ }
271
+ )
272
+ if request.url.path.endswith("/payment/orders/my"):
273
+ assert request.url.params["status"] == "COMPLETED"
274
+ assert request.url.params["order_type"] == "balance"
275
+ return success(
276
+ {
277
+ "items": [{"id": 42, "status": "COMPLETED"}],
278
+ "total": 1,
279
+ "page": 1,
280
+ "page_size": 20,
281
+ "pages": 1,
282
+ }
283
+ )
284
+ if request.url.path.endswith("/payment/orders/verify"):
285
+ assert body == {"out_trade_no": "SUB2API-42"}
286
+ return success({"id": 42, "status": "COMPLETED", "out_trade_no": "SUB2API-42"})
287
+ if request.url.path.endswith("/payment/orders/42/cancel"):
288
+ return success({"message": "order cancelled"})
289
+ if request.url.path.endswith("/payment/orders/refund-eligible-providers"):
290
+ return success({"provider_instance_ids": ["stripe-1"]})
291
+ raise AssertionError(f"unexpected request: {request.method} {request.url}")
292
+
293
+ client = Sub2API(
294
+ "https://example.test",
295
+ access_token="token",
296
+ session=TestSession(handler),
297
+ )
298
+
299
+ assert client.payment.config().enabled
300
+ assert client.payment.checkout_info().methods.stripe.available
301
+ assert client.payment.limits().methods.stripe.single_min == 1
302
+ created = client.deposit(
303
+ 10,
304
+ "stripe",
305
+ return_url="https://app.example.test/payment/result",
306
+ payment_source="hosted_redirect",
307
+ is_mobile=False,
308
+ )
309
+ assert created.order_id == 42
310
+ assert created.client_secret == "secret-value"
311
+ assert client.payment.get_order(42).status == "PENDING"
312
+ assert (
313
+ client.payment.list_orders(status="COMPLETED", order_type="balance").items[0].status
314
+ == "COMPLETED"
315
+ )
316
+ assert client.payment.verify_order("SUB2API-42").status == "COMPLETED"
317
+ assert client.payment.cancel_order(42).message == "order cancelled"
318
+ assert client.payment.refund_eligible_providers() == ("stripe-1",)
319
+
320
+ assert seen[0][1].endswith("/payment/config")
321
+
322
+
323
+ def test_payment_public_order_recovery_does_not_send_authentication() -> None:
324
+ def handler(request: httpx.Request) -> httpx.Response:
325
+ assert request.url.path.endswith("/payment/public/orders/verify")
326
+ assert "Authorization" not in request.headers
327
+ assert json.loads(request.content) == {"out_trade_no": "SUB2API-42"}
328
+ return success({"out_trade_no": "SUB2API-42", "status": "COMPLETED", "paid": True})
329
+
330
+ client = Sub2API(
331
+ "https://example.test",
332
+ access_token="token",
333
+ session=TestSession(handler),
334
+ )
335
+
336
+ result = client.payment.verify_public("SUB2API-42")
337
+
338
+ assert result.paid
339
+ assert result.status == "COMPLETED"
340
+
341
+
342
+ def test_payment_order_validation_happens_before_network_request() -> None:
343
+ client = Sub2API(
344
+ "https://example.test",
345
+ session=TestSession(lambda request: success({})),
346
+ )
347
+
348
+ with pytest.raises(ValueError, match="positive finite"):
349
+ client.payment.deposit(0, "stripe")
350
+ with pytest.raises(ValueError, match="payment_type"):
351
+ client.payment.deposit(10, " ")
352
+ with pytest.raises(ValueError, match="plan_id"):
353
+ client.payment.create_order(10, "stripe", order_type="subscription")
File without changes
File without changes
File without changes
File without changes