usethatapp 2.2.0__tar.gz → 2.3.1__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.
- {usethatapp-2.2.0 → usethatapp-2.3.1}/CHANGELOG.md +33 -1
- usethatapp-2.3.1/PKG-INFO +194 -0
- usethatapp-2.3.1/README.md +161 -0
- {usethatapp-2.2.0 → usethatapp-2.3.1}/pyproject.toml +1 -1
- {usethatapp-2.2.0 → usethatapp-2.3.1}/tests/test_client.py +14 -0
- {usethatapp-2.2.0 → usethatapp-2.3.1}/tests/test_license_keys.py +1 -0
- {usethatapp-2.2.0 → usethatapp-2.3.1}/usethatapp/__init__.py +1 -1
- {usethatapp-2.2.0 → usethatapp-2.3.1}/usethatapp/client.py +2 -0
- {usethatapp-2.2.0 → usethatapp-2.3.1}/usethatapp/types.py +13 -0
- usethatapp-2.3.1/usethatapp.egg-info/PKG-INFO +194 -0
- usethatapp-2.2.0/PKG-INFO +0 -278
- usethatapp-2.2.0/README.md +0 -245
- usethatapp-2.2.0/usethatapp.egg-info/PKG-INFO +0 -278
- {usethatapp-2.2.0 → usethatapp-2.3.1}/LICENSE +0 -0
- {usethatapp-2.2.0 → usethatapp-2.3.1}/MANIFEST.in +0 -0
- {usethatapp-2.2.0 → usethatapp-2.3.1}/setup.cfg +0 -0
- {usethatapp-2.2.0 → usethatapp-2.3.1}/tests/__init__.py +0 -0
- {usethatapp-2.2.0 → usethatapp-2.3.1}/tests/conftest.py +0 -0
- {usethatapp-2.2.0 → usethatapp-2.3.1}/tests/test_config.py +0 -0
- {usethatapp-2.2.0 → usethatapp-2.3.1}/tests/test_purchase.py +0 -0
- {usethatapp-2.2.0 → usethatapp-2.3.1}/usethatapp/config.py +0 -0
- {usethatapp-2.2.0 → usethatapp-2.3.1}/usethatapp/discovery.py +0 -0
- {usethatapp-2.2.0 → usethatapp-2.3.1}/usethatapp/errors.py +0 -0
- {usethatapp-2.2.0 → usethatapp-2.3.1}/usethatapp/py.typed +0 -0
- {usethatapp-2.2.0 → usethatapp-2.3.1}/usethatapp.egg-info/SOURCES.txt +0 -0
- {usethatapp-2.2.0 → usethatapp-2.3.1}/usethatapp.egg-info/dependency_links.txt +0 -0
- {usethatapp-2.2.0 → usethatapp-2.3.1}/usethatapp.egg-info/requires.txt +0 -0
- {usethatapp-2.2.0 → usethatapp-2.3.1}/usethatapp.egg-info/top_level.txt +0 -0
|
@@ -4,7 +4,28 @@ All notable changes to this project are documented in this file. This project ad
|
|
|
4
4
|
[Semantic Versioning](https://semver.org/) and follows a clear, machine- and human-readable
|
|
5
5
|
format inspired by "Keep a Changelog".
|
|
6
6
|
|
|
7
|
-
## [
|
|
7
|
+
## [2.3.1] - 2026-11-02
|
|
8
|
+
|
|
9
|
+
### Documentation
|
|
10
|
+
|
|
11
|
+
- README rewritten around the default integration: license keys first,
|
|
12
|
+
then prices and buy links, with Hosted sign-in as the optional add-on.
|
|
13
|
+
Corrected stale claims (purchases are open to international buyers;
|
|
14
|
+
external sales is on by default; a cancellation keeps access until
|
|
15
|
+
`period_end`; seller apps always use a confidential client, so the
|
|
16
|
+
secret is never optional). The v1 migration table moved here, under
|
|
17
|
+
2.0.0.
|
|
18
|
+
- New `examples/license_keys` Flask app for the license-key flow.
|
|
19
|
+
|
|
20
|
+
## [2.3.0] - 2026-10-29
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
|
|
24
|
+
- `Entitlement.is_trial` and `LicenseState.is_trial`: true while the user is in a free
|
|
25
|
+
trial of a paid plan (entitled, nothing charged yet; `period_end` is the conversion
|
|
26
|
+
date). Explicit so integrations never string-match `status == "trialing"`. Both are
|
|
27
|
+
trailing fields with a `False` default, so positional construction and servers that
|
|
28
|
+
predate the field keep working.
|
|
8
29
|
|
|
9
30
|
## [2.2.0] - 2026-08-25
|
|
10
31
|
|
|
@@ -189,6 +210,17 @@ Breaking rewrite onto standard OAuth2 / OpenID Connect. usethatapp.com is
|
|
|
189
210
|
now an OpenID Provider; the SDK is a framework-agnostic OIDC client. The
|
|
190
211
|
v1 launch-envelope push + signed `get_version` pull are gone.
|
|
191
212
|
|
|
213
|
+
### Migrating from 1.x
|
|
214
|
+
|
|
215
|
+
| 1.x | 2.x |
|
|
216
|
+
|---|---|
|
|
217
|
+
| `get_user(payload)` (decrypt envelope) | `begin_login()` + `complete_login()` (OIDC) |
|
|
218
|
+
| `UtaUser.user_key` | `UtaSession.sub` (pairwise, stable per app) |
|
|
219
|
+
| `get_version(user_key) -> str | None` | `get_entitlement(access_token) -> Entitlement` |
|
|
220
|
+
| RSA keys (`UTA_PRIVATE_KEY`, market key) | OAuth client (`UTA_CLIENT_ID` / `UTA_CLIENT_SECRET`) |
|
|
221
|
+
| `UTA_APP_ID` | Gone: the client id identifies your app |
|
|
222
|
+
| `uta_launch_view` Django decorator | Gone: wire your own callback view |
|
|
223
|
+
|
|
192
224
|
### Removed
|
|
193
225
|
|
|
194
226
|
- `get_user` / `get_user_from_request` / `get_user_from_request_async` and
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: usethatapp
|
|
3
|
+
Version: 2.3.1
|
|
4
|
+
Summary: Python SDK for usethatapp.com — OpenID Connect login and live license entitlement queries.
|
|
5
|
+
Author-email: UseThatApp <support@usethatapp.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/UseThatApp/python
|
|
8
|
+
Project-URL: Changelog, https://github.com/UseThatApp/python/blob/main/CHANGELOG.md
|
|
9
|
+
Project-URL: Documentation, https://docs.usethatapp.com
|
|
10
|
+
Project-URL: Repository, https://github.com/UseThatApp/python
|
|
11
|
+
Keywords: UseThatApp,Python,Web Apps,licensing,SDK
|
|
12
|
+
Classifier: Programming Language :: Python
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Requires-Python: >=3.9
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Requires-Dist: httpx>=0.24
|
|
24
|
+
Requires-Dist: joserfc>=1.0
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
27
|
+
Requires-Dist: pytest-asyncio>=0.21; extra == "dev"
|
|
28
|
+
Requires-Dist: pytest-cov>=4; extra == "dev"
|
|
29
|
+
Requires-Dist: respx>=0.20; extra == "dev"
|
|
30
|
+
Requires-Dist: mypy>=1.5; extra == "dev"
|
|
31
|
+
Requires-Dist: Django>=3.2; extra == "dev"
|
|
32
|
+
Dynamic: license-file
|
|
33
|
+
|
|
34
|
+
# usethatapp
|
|
35
|
+
|
|
36
|
+
Python SDK for [UseThatApp](https://www.usethatapp.com), the merchant of
|
|
37
|
+
record for web apps. You put Buy buttons on your own website; UseThatApp
|
|
38
|
+
runs checkout, charges the card, collects and remits tax, handles refunds,
|
|
39
|
+
and pays you through Stripe. This SDK lets your server show live prices,
|
|
40
|
+
build checkout links, and check what each customer bought.
|
|
41
|
+
|
|
42
|
+
- Docs: <https://docs.usethatapp.com>
|
|
43
|
+
- AI agents: read <https://docs.usethatapp.com/llms-full.txt> (the whole
|
|
44
|
+
site as one Markdown file) and follow its "Rules for AI agents".
|
|
45
|
+
|
|
46
|
+
## Install
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pip install usethatapp
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Python 3.9+. Runtime dependencies: `httpx`, `joserfc`. The SDK is
|
|
53
|
+
**server-side only**: it holds your client secret, so never run it in a
|
|
54
|
+
browser or ship it inside a desktop app. It never touches your web
|
|
55
|
+
framework — it takes and returns plain values, and you wire it into your
|
|
56
|
+
routes.
|
|
57
|
+
|
|
58
|
+
## Configure
|
|
59
|
+
|
|
60
|
+
Settings come from `django.conf.settings` when Django is configured,
|
|
61
|
+
otherwise from environment variables. `configure(...)` overrides either in
|
|
62
|
+
code.
|
|
63
|
+
|
|
64
|
+
| Variable | Needed for |
|
|
65
|
+
|---|---|
|
|
66
|
+
| `UTA_CLIENT_ID` | Everything except `purchase_url` |
|
|
67
|
+
| `UTA_CLIENT_SECRET` (or `UTA_CLIENT_SECRET_PATH` for a mounted file) | License keys, Hosted sign-in |
|
|
68
|
+
| `UTA_REDIRECT_URI` | Hosted sign-in only |
|
|
69
|
+
| `UTA_SCOPES`, `UTA_API_URL`, `UTA_ISSUER`, `UTA_REQUEST_TIMEOUT_SECONDS`, `UTA_CLOCK_SKEW_SECONDS` | Optional; defaults point at production |
|
|
70
|
+
|
|
71
|
+
The client id and secret are on your app's *Manage → Integration* page.
|
|
72
|
+
The secret is shown once; keep it in your host's secret store.
|
|
73
|
+
|
|
74
|
+
## Verify purchases with license keys (the default)
|
|
75
|
+
|
|
76
|
+
Each purchase creates one license key. When checkout finishes, the buyer
|
|
77
|
+
returns to your buy link's `next` URL with `?uta_order=…` appended. Your
|
|
78
|
+
server exchanges that once for the key, stores it on the user signed in to
|
|
79
|
+
*your* app, and validates it wherever you gate a paid feature. Your login
|
|
80
|
+
stays yours.
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
from usethatapp import UtaOrderProcessingError, get_order, validate_license_key
|
|
84
|
+
|
|
85
|
+
# Return route, e.g. /thanks?uta_order=...
|
|
86
|
+
try:
|
|
87
|
+
order = get_order(request.args["uta_order"])
|
|
88
|
+
except UtaOrderProcessingError:
|
|
89
|
+
... # paid, key not stored yet: show "finishing up" and retry in a second or two
|
|
90
|
+
current_user.license_key = order.license_key
|
|
91
|
+
current_user.license_id = order.license_id
|
|
92
|
+
|
|
93
|
+
# Any gate:
|
|
94
|
+
state = validate_license_key(current_user.license_key)
|
|
95
|
+
if state.entitled and state.product_public_id == "prod_<your-pro-id>":
|
|
96
|
+
... # paid feature
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
- Gate on `entitled` and `product_public_id` (the plan's `prod_…` id;
|
|
100
|
+
`product_id` holds the same value). Never on the plan's display name.
|
|
101
|
+
- A cancellation keeps `entitled=True` until `period_end`; the buyer paid
|
|
102
|
+
for that period. Cache the answer for a session or up to a day, never past
|
|
103
|
+
`period_end`.
|
|
104
|
+
- A buyer who already holds a plan is sent back to `next` with no
|
|
105
|
+
`uta_order`; treat its absence as nothing to link.
|
|
106
|
+
- A canceled key returns `entitled=False`; it does not raise.
|
|
107
|
+
`UtaNotFoundError` means the string was never a key for your app.
|
|
108
|
+
- A lost key is always visible on the buyer's manage page. Rotate only a
|
|
109
|
+
leaked one: `regenerate_license_key(license_id)` returns the new key once
|
|
110
|
+
and kills the old one.
|
|
111
|
+
|
|
112
|
+
Walkthrough, statuses and edge cases: <https://docs.usethatapp.com/license-keys>.
|
|
113
|
+
|
|
114
|
+
## Show prices and link to checkout
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
from usethatapp import get_prices, manage_url, purchase_url
|
|
118
|
+
|
|
119
|
+
for price in get_prices().prices: # anonymous; needs only UTA_CLIENT_ID
|
|
120
|
+
show_plan(price.product_name, price.amount, price.currency,
|
|
121
|
+
price.frequency, price.buy_url)
|
|
122
|
+
|
|
123
|
+
purchase_url("prc_...", next="https://yourapp.example/thanks") # no network call
|
|
124
|
+
manage_url(next="https://yourapp.example/account") # buyer's subscription page
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Never hardcode prices: render them from `get_prices()` and use each
|
|
128
|
+
price's `buy_url`. `amount` is a decimal string (`"10.00"`). `next` must be
|
|
129
|
+
HTTPS on your app's registered domain (loopback hosts such as `localhost`
|
|
130
|
+
are accepted for local testing). Until the seller is verified, the app is
|
|
131
|
+
published and the product is active, a buy link shows the buyer a "not
|
|
132
|
+
available" page instead of checkout; it answers 404 only for an unknown
|
|
133
|
+
price id or an app whose sales UseThatApp has suspended. Details:
|
|
134
|
+
<https://docs.usethatapp.com/sell>.
|
|
135
|
+
|
|
136
|
+
## Hosted sign-in (optional add-on)
|
|
137
|
+
|
|
138
|
+
Only if your app has no accounts of its own: UseThatApp runs login over
|
|
139
|
+
OpenID Connect, and `get_entitlement` reports what the signed-in user
|
|
140
|
+
bought. The entitlement call needs the Hosted sign-in add-on, switched on
|
|
141
|
+
in the dashboard.
|
|
142
|
+
|
|
143
|
+
```python
|
|
144
|
+
from usethatapp import begin_login, complete_login, get_entitlement
|
|
145
|
+
|
|
146
|
+
auth_url, flow_state = begin_login() # /login: keep flow_state in the session
|
|
147
|
+
s = complete_login(code=..., state=..., flow_state=flow_state) # /callback
|
|
148
|
+
ent = get_entitlement(s.access_token) # gate on ent.entitled + ent.product_public_id
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Users are identified by `s.sub`, a pairwise pseudonymous id with no email or
|
|
152
|
+
name. Setup, sign-out and stack notes: <https://docs.usethatapp.com/hosted-sign-in>.
|
|
153
|
+
|
|
154
|
+
## Errors
|
|
155
|
+
|
|
156
|
+
Every exception subclasses `UtaError`.
|
|
157
|
+
|
|
158
|
+
| Exception | When |
|
|
159
|
+
|---|---|
|
|
160
|
+
| `UtaConfigError` | Missing settings, or the client id/secret was rejected |
|
|
161
|
+
| `UtaNotFoundError` (`.code`) | A key, order or license id never issued for your app |
|
|
162
|
+
| `UtaOrderProcessingError` | Order paid, key not stored yet: retry shortly |
|
|
163
|
+
| `UtaLicenseCanceledError` | Rotating a key whose license has ended |
|
|
164
|
+
| `UtaServerError` (`.retry_after`) | Rate limited (429) or a server error: back off and retry |
|
|
165
|
+
| `UtaTokenError`, `UtaPermissionError`, `UtaServiceNotEnabledError`, `UtaAuthError`, `UtaDiscoveryError` | Hosted sign-in only |
|
|
166
|
+
|
|
167
|
+
Full table: <https://docs.usethatapp.com/rest-api#errors>.
|
|
168
|
+
|
|
169
|
+
## API
|
|
170
|
+
|
|
171
|
+
| Function | Returns |
|
|
172
|
+
|---|---|
|
|
173
|
+
| `get_order(ref)` / `validate_license_key(key)` / `regenerate_license_key(license_id)` | `LicenseState` |
|
|
174
|
+
| `get_prices()` / `get_prices_async()` | `AppPrices` (with `Price` entries) |
|
|
175
|
+
| `get_app_info()` / `get_app_info_async()` | `AppInfo` |
|
|
176
|
+
| `purchase_url(price_id, *, next=, ref=, email=)` / `manage_url(*, next=)` | `str` (no network) |
|
|
177
|
+
| `begin_login()` / `complete_login(code=, state=, flow_state=)` / `refresh(refresh_token)` | `(url, flow_state)` / `UtaSession` |
|
|
178
|
+
| `get_entitlement(access_token)` / `get_entitlement_async(...)` | `Entitlement` |
|
|
179
|
+
| `userinfo(access_token)` / `logout_url(id_token=, post_logout_redirect_uri=)` | `{"sub": …}` / `str` |
|
|
180
|
+
| `configure(**settings)` / `load_config()` / `reset_config()` | Configuration |
|
|
181
|
+
|
|
182
|
+
Every call is plain HTTPS underneath, documented at
|
|
183
|
+
<https://docs.usethatapp.com/rest-api>.
|
|
184
|
+
|
|
185
|
+
## Examples
|
|
186
|
+
|
|
187
|
+
[`examples/`](./examples/) holds runnable apps: `license_keys` (Flask,
|
|
188
|
+
the default integration) and Hosted sign-in apps for Flask, Django and
|
|
189
|
+
FastAPI.
|
|
190
|
+
|
|
191
|
+
## Changelog and license
|
|
192
|
+
|
|
193
|
+
[CHANGELOG.md](./CHANGELOG.md), including how to migrate from 1.x. MIT —
|
|
194
|
+
see [LICENSE](./LICENSE).
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# usethatapp
|
|
2
|
+
|
|
3
|
+
Python SDK for [UseThatApp](https://www.usethatapp.com), the merchant of
|
|
4
|
+
record for web apps. You put Buy buttons on your own website; UseThatApp
|
|
5
|
+
runs checkout, charges the card, collects and remits tax, handles refunds,
|
|
6
|
+
and pays you through Stripe. This SDK lets your server show live prices,
|
|
7
|
+
build checkout links, and check what each customer bought.
|
|
8
|
+
|
|
9
|
+
- Docs: <https://docs.usethatapp.com>
|
|
10
|
+
- AI agents: read <https://docs.usethatapp.com/llms-full.txt> (the whole
|
|
11
|
+
site as one Markdown file) and follow its "Rules for AI agents".
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pip install usethatapp
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Python 3.9+. Runtime dependencies: `httpx`, `joserfc`. The SDK is
|
|
20
|
+
**server-side only**: it holds your client secret, so never run it in a
|
|
21
|
+
browser or ship it inside a desktop app. It never touches your web
|
|
22
|
+
framework — it takes and returns plain values, and you wire it into your
|
|
23
|
+
routes.
|
|
24
|
+
|
|
25
|
+
## Configure
|
|
26
|
+
|
|
27
|
+
Settings come from `django.conf.settings` when Django is configured,
|
|
28
|
+
otherwise from environment variables. `configure(...)` overrides either in
|
|
29
|
+
code.
|
|
30
|
+
|
|
31
|
+
| Variable | Needed for |
|
|
32
|
+
|---|---|
|
|
33
|
+
| `UTA_CLIENT_ID` | Everything except `purchase_url` |
|
|
34
|
+
| `UTA_CLIENT_SECRET` (or `UTA_CLIENT_SECRET_PATH` for a mounted file) | License keys, Hosted sign-in |
|
|
35
|
+
| `UTA_REDIRECT_URI` | Hosted sign-in only |
|
|
36
|
+
| `UTA_SCOPES`, `UTA_API_URL`, `UTA_ISSUER`, `UTA_REQUEST_TIMEOUT_SECONDS`, `UTA_CLOCK_SKEW_SECONDS` | Optional; defaults point at production |
|
|
37
|
+
|
|
38
|
+
The client id and secret are on your app's *Manage → Integration* page.
|
|
39
|
+
The secret is shown once; keep it in your host's secret store.
|
|
40
|
+
|
|
41
|
+
## Verify purchases with license keys (the default)
|
|
42
|
+
|
|
43
|
+
Each purchase creates one license key. When checkout finishes, the buyer
|
|
44
|
+
returns to your buy link's `next` URL with `?uta_order=…` appended. Your
|
|
45
|
+
server exchanges that once for the key, stores it on the user signed in to
|
|
46
|
+
*your* app, and validates it wherever you gate a paid feature. Your login
|
|
47
|
+
stays yours.
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from usethatapp import UtaOrderProcessingError, get_order, validate_license_key
|
|
51
|
+
|
|
52
|
+
# Return route, e.g. /thanks?uta_order=...
|
|
53
|
+
try:
|
|
54
|
+
order = get_order(request.args["uta_order"])
|
|
55
|
+
except UtaOrderProcessingError:
|
|
56
|
+
... # paid, key not stored yet: show "finishing up" and retry in a second or two
|
|
57
|
+
current_user.license_key = order.license_key
|
|
58
|
+
current_user.license_id = order.license_id
|
|
59
|
+
|
|
60
|
+
# Any gate:
|
|
61
|
+
state = validate_license_key(current_user.license_key)
|
|
62
|
+
if state.entitled and state.product_public_id == "prod_<your-pro-id>":
|
|
63
|
+
... # paid feature
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
- Gate on `entitled` and `product_public_id` (the plan's `prod_…` id;
|
|
67
|
+
`product_id` holds the same value). Never on the plan's display name.
|
|
68
|
+
- A cancellation keeps `entitled=True` until `period_end`; the buyer paid
|
|
69
|
+
for that period. Cache the answer for a session or up to a day, never past
|
|
70
|
+
`period_end`.
|
|
71
|
+
- A buyer who already holds a plan is sent back to `next` with no
|
|
72
|
+
`uta_order`; treat its absence as nothing to link.
|
|
73
|
+
- A canceled key returns `entitled=False`; it does not raise.
|
|
74
|
+
`UtaNotFoundError` means the string was never a key for your app.
|
|
75
|
+
- A lost key is always visible on the buyer's manage page. Rotate only a
|
|
76
|
+
leaked one: `regenerate_license_key(license_id)` returns the new key once
|
|
77
|
+
and kills the old one.
|
|
78
|
+
|
|
79
|
+
Walkthrough, statuses and edge cases: <https://docs.usethatapp.com/license-keys>.
|
|
80
|
+
|
|
81
|
+
## Show prices and link to checkout
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
from usethatapp import get_prices, manage_url, purchase_url
|
|
85
|
+
|
|
86
|
+
for price in get_prices().prices: # anonymous; needs only UTA_CLIENT_ID
|
|
87
|
+
show_plan(price.product_name, price.amount, price.currency,
|
|
88
|
+
price.frequency, price.buy_url)
|
|
89
|
+
|
|
90
|
+
purchase_url("prc_...", next="https://yourapp.example/thanks") # no network call
|
|
91
|
+
manage_url(next="https://yourapp.example/account") # buyer's subscription page
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Never hardcode prices: render them from `get_prices()` and use each
|
|
95
|
+
price's `buy_url`. `amount` is a decimal string (`"10.00"`). `next` must be
|
|
96
|
+
HTTPS on your app's registered domain (loopback hosts such as `localhost`
|
|
97
|
+
are accepted for local testing). Until the seller is verified, the app is
|
|
98
|
+
published and the product is active, a buy link shows the buyer a "not
|
|
99
|
+
available" page instead of checkout; it answers 404 only for an unknown
|
|
100
|
+
price id or an app whose sales UseThatApp has suspended. Details:
|
|
101
|
+
<https://docs.usethatapp.com/sell>.
|
|
102
|
+
|
|
103
|
+
## Hosted sign-in (optional add-on)
|
|
104
|
+
|
|
105
|
+
Only if your app has no accounts of its own: UseThatApp runs login over
|
|
106
|
+
OpenID Connect, and `get_entitlement` reports what the signed-in user
|
|
107
|
+
bought. The entitlement call needs the Hosted sign-in add-on, switched on
|
|
108
|
+
in the dashboard.
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
from usethatapp import begin_login, complete_login, get_entitlement
|
|
112
|
+
|
|
113
|
+
auth_url, flow_state = begin_login() # /login: keep flow_state in the session
|
|
114
|
+
s = complete_login(code=..., state=..., flow_state=flow_state) # /callback
|
|
115
|
+
ent = get_entitlement(s.access_token) # gate on ent.entitled + ent.product_public_id
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Users are identified by `s.sub`, a pairwise pseudonymous id with no email or
|
|
119
|
+
name. Setup, sign-out and stack notes: <https://docs.usethatapp.com/hosted-sign-in>.
|
|
120
|
+
|
|
121
|
+
## Errors
|
|
122
|
+
|
|
123
|
+
Every exception subclasses `UtaError`.
|
|
124
|
+
|
|
125
|
+
| Exception | When |
|
|
126
|
+
|---|---|
|
|
127
|
+
| `UtaConfigError` | Missing settings, or the client id/secret was rejected |
|
|
128
|
+
| `UtaNotFoundError` (`.code`) | A key, order or license id never issued for your app |
|
|
129
|
+
| `UtaOrderProcessingError` | Order paid, key not stored yet: retry shortly |
|
|
130
|
+
| `UtaLicenseCanceledError` | Rotating a key whose license has ended |
|
|
131
|
+
| `UtaServerError` (`.retry_after`) | Rate limited (429) or a server error: back off and retry |
|
|
132
|
+
| `UtaTokenError`, `UtaPermissionError`, `UtaServiceNotEnabledError`, `UtaAuthError`, `UtaDiscoveryError` | Hosted sign-in only |
|
|
133
|
+
|
|
134
|
+
Full table: <https://docs.usethatapp.com/rest-api#errors>.
|
|
135
|
+
|
|
136
|
+
## API
|
|
137
|
+
|
|
138
|
+
| Function | Returns |
|
|
139
|
+
|---|---|
|
|
140
|
+
| `get_order(ref)` / `validate_license_key(key)` / `regenerate_license_key(license_id)` | `LicenseState` |
|
|
141
|
+
| `get_prices()` / `get_prices_async()` | `AppPrices` (with `Price` entries) |
|
|
142
|
+
| `get_app_info()` / `get_app_info_async()` | `AppInfo` |
|
|
143
|
+
| `purchase_url(price_id, *, next=, ref=, email=)` / `manage_url(*, next=)` | `str` (no network) |
|
|
144
|
+
| `begin_login()` / `complete_login(code=, state=, flow_state=)` / `refresh(refresh_token)` | `(url, flow_state)` / `UtaSession` |
|
|
145
|
+
| `get_entitlement(access_token)` / `get_entitlement_async(...)` | `Entitlement` |
|
|
146
|
+
| `userinfo(access_token)` / `logout_url(id_token=, post_logout_redirect_uri=)` | `{"sub": …}` / `str` |
|
|
147
|
+
| `configure(**settings)` / `load_config()` / `reset_config()` | Configuration |
|
|
148
|
+
|
|
149
|
+
Every call is plain HTTPS underneath, documented at
|
|
150
|
+
<https://docs.usethatapp.com/rest-api>.
|
|
151
|
+
|
|
152
|
+
## Examples
|
|
153
|
+
|
|
154
|
+
[`examples/`](./examples/) holds runnable apps: `license_keys` (Flask,
|
|
155
|
+
the default integration) and Hosted sign-in apps for Flask, Django and
|
|
156
|
+
FastAPI.
|
|
157
|
+
|
|
158
|
+
## Changelog and license
|
|
159
|
+
|
|
160
|
+
[CHANGELOG.md](./CHANGELOG.md), including how to migrate from 1.x. MIT —
|
|
161
|
+
see [LICENSE](./LICENSE).
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "usethatapp"
|
|
7
|
-
version = "2.
|
|
7
|
+
version = "2.3.1"
|
|
8
8
|
description = "Python SDK for usethatapp.com — OpenID Connect login and live license entitlement queries."
|
|
9
9
|
readme = { file = "README.md", content-type = "text/markdown" }
|
|
10
10
|
authors = [{ name = "UseThatApp", email = "support@usethatapp.com"}]
|
|
@@ -148,9 +148,23 @@ def test_get_entitlement_licensed(oidc_routes):
|
|
|
148
148
|
assert ent.entitled and ent.version == "Pro" and ent.product_id == "p-1"
|
|
149
149
|
assert ent.product_public_id == "prod_abc123"
|
|
150
150
|
assert ent.status == "active" and ent.is_free is False
|
|
151
|
+
assert ent.is_trial is False
|
|
151
152
|
assert ent.period_end == "2026-07-01"
|
|
152
153
|
|
|
153
154
|
|
|
155
|
+
def test_get_entitlement_trialing(oidc_routes):
|
|
156
|
+
oidc_routes.get(API_URL + "/licensing/entitlement/").mock(
|
|
157
|
+
return_value=httpx.Response(200, json={
|
|
158
|
+
"entitled": True, "version": "Pro", "product_id": "prod_abc",
|
|
159
|
+
"product_public_id": "prod_abc", "status": "trialing",
|
|
160
|
+
"is_free": False, "is_trial": True, "period_end": "2026-11-11",
|
|
161
|
+
})
|
|
162
|
+
)
|
|
163
|
+
ent = get_entitlement("at-123")
|
|
164
|
+
assert ent.entitled and ent.is_trial and ent.status == "trialing"
|
|
165
|
+
assert ent.period_end == "2026-11-11"
|
|
166
|
+
|
|
167
|
+
|
|
154
168
|
def test_get_entitlement_free(oidc_routes):
|
|
155
169
|
oidc_routes.get(API_URL + "/licensing/entitlement/").mock(
|
|
156
170
|
return_value=httpx.Response(200, json={
|
|
@@ -44,6 +44,7 @@ def test_validate_sends_client_credentials(respx_mock):
|
|
|
44
44
|
)
|
|
45
45
|
state = validate_license_key("key_abc")
|
|
46
46
|
assert state.entitled and state.status == "active"
|
|
47
|
+
assert state.is_trial is False
|
|
47
48
|
assert state.license_id == STATE["license_id"]
|
|
48
49
|
assert state.license_key is None
|
|
49
50
|
request = route.calls.last.request
|
|
@@ -694,6 +694,7 @@ def _parse_entitlement(data: Mapping[str, Any]) -> Entitlement:
|
|
|
694
694
|
period_end=data.get("period_end"),
|
|
695
695
|
product_public_id=data.get("product_public_id"),
|
|
696
696
|
raw=dict(data),
|
|
697
|
+
is_trial=bool(data.get("is_trial", False)),
|
|
697
698
|
)
|
|
698
699
|
|
|
699
700
|
|
|
@@ -870,6 +871,7 @@ def _parse_license_state(data: Mapping[str, Any]) -> LicenseState:
|
|
|
870
871
|
canceled_at=data.get("canceled_at"),
|
|
871
872
|
license_key=data.get("license_key"),
|
|
872
873
|
rotated_at=data.get("rotated_at"),
|
|
874
|
+
is_trial=bool(data.get("is_trial", False)),
|
|
873
875
|
# Alias pair: fall back to product_public_id against a server
|
|
874
876
|
# that predates the identifier cutover.
|
|
875
877
|
product_id=str(
|
|
@@ -87,6 +87,15 @@ class Entitlement:
|
|
|
87
87
|
:attr:`Price.product_id` from the pricing API. New integrations should
|
|
88
88
|
gate on this field."""
|
|
89
89
|
|
|
90
|
+
is_trial: bool = False
|
|
91
|
+
"""True while the user is in a free trial of a paid plan: entitled to
|
|
92
|
+
the plan's real functionality, nothing charged yet. While true,
|
|
93
|
+
:attr:`period_end` is the date the trial converts (the first charge).
|
|
94
|
+
A canceled trial keeps ``entitled`` until that date and then drops it
|
|
95
|
+
— never cache past ``period_end``. Explicit so you never string-match
|
|
96
|
+
:attr:`status`. Trailing with a default (see the note above) so
|
|
97
|
+
positional construction predating it still works."""
|
|
98
|
+
|
|
90
99
|
|
|
91
100
|
@dataclass(frozen=True)
|
|
92
101
|
class AppInfo:
|
|
@@ -211,3 +220,7 @@ class LicenseState:
|
|
|
211
220
|
gating code works identically in both verification modes. Trailing
|
|
212
221
|
with a default so positional construction predating it still
|
|
213
222
|
works."""
|
|
223
|
+
|
|
224
|
+
is_trial: bool = False
|
|
225
|
+
"""True while the license is in its free trial — see
|
|
226
|
+
:attr:`Entitlement.is_trial`."""
|