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.
- {sub2api-0.1.1 → sub2api-0.2.0}/PKG-INFO +35 -2
- {sub2api-0.1.1 → sub2api-0.2.0}/README.md +34 -1
- {sub2api-0.1.1 → sub2api-0.2.0}/pyproject.toml +1 -1
- {sub2api-0.1.1 → sub2api-0.2.0}/src/sub2api/__init__.py +15 -1
- {sub2api-0.1.1 → sub2api-0.2.0}/src/sub2api/_client.py +26 -6
- {sub2api-0.1.1 → sub2api-0.2.0}/src/sub2api/_models.py +30 -0
- {sub2api-0.1.1 → sub2api-0.2.0}/src/sub2api/_resources.py +217 -0
- {sub2api-0.1.1 → sub2api-0.2.0}/tests/test_resources.py +127 -0
- {sub2api-0.1.1 → sub2api-0.2.0}/.gitignore +0 -0
- {sub2api-0.1.1 → sub2api-0.2.0}/LICENSE +0 -0
- {sub2api-0.1.1 → sub2api-0.2.0}/src/sub2api/_exceptions.py +0 -0
- {sub2api-0.1.1 → sub2api-0.2.0}/src/sub2api/py.typed +0 -0
- {sub2api-0.1.1 → sub2api-0.2.0}/tests/test_client.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: sub2api
|
|
3
|
-
Version: 0.
|
|
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.
|
|
@@ -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.
|
|
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.
|
|
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
|
|
File without changes
|