itaja 0.1.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.
itaja-0.1.0/.gitignore ADDED
@@ -0,0 +1,18 @@
1
+ node_modules
2
+ dist
3
+ .env
4
+ .env.*
5
+ !.env.example
6
+ *.log
7
+ .DS_Store
8
+ /vendor
9
+ /composer.lock
10
+ .phpunit.cache
11
+
12
+ # Python
13
+ __pycache__/
14
+ .venv/
15
+ .mypy_cache/
16
+ .pytest_cache/
17
+ .ruff_cache/
18
+ packages/python/dist/
itaja-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 LIXALI STUDIO
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
itaja-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,377 @@
1
+ Metadata-Version: 2.5
2
+ Name: itaja
3
+ Version: 0.1.0
4
+ Summary: Itaja for Python servers: declare what you sell in code, push it, mount one webhook route, react to typed events. Django, FastAPI, Flask.
5
+ Project-URL: Homepage, https://github.com/itaja/itaja-sdks/tree/main/packages/python
6
+ Project-URL: Issues, https://github.com/itaja/itaja-sdks/issues
7
+ Project-URL: Source, https://github.com/itaja/itaja-sdks/tree/main/packages/python
8
+ Author: LIXALI STUDIO
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: credits,django,fastapi,flask,itaja,licenses,payments,subscriptions,webhooks
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Framework :: Django
14
+ Classifier: Framework :: FastAPI
15
+ Classifier: Framework :: Flask
16
+ Classifier: Intended Audience :: Developers
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3 :: Only
19
+ Classifier: Typing :: Typed
20
+ Requires-Python: >=3.10
21
+ Requires-Dist: httpx<1,>=0.27
22
+ Provides-Extra: django
23
+ Requires-Dist: django>=4.2; extra == 'django'
24
+ Provides-Extra: fastapi
25
+ Requires-Dist: fastapi>=0.110; extra == 'fastapi'
26
+ Provides-Extra: flask
27
+ Requires-Dist: flask>=3.0; extra == 'flask'
28
+ Provides-Extra: starlette
29
+ Requires-Dist: starlette>=0.37; extra == 'starlette'
30
+ Description-Content-Type: text/markdown
31
+
32
+ # itaja
33
+
34
+ Sell subscriptions, credits and licence keys from your Python server. Declare what
35
+ you sell in code, push it to Itaja, mount one webhook route (Django, FastAPI,
36
+ Starlette, Flask), react to typed events.
37
+
38
+ ```bash
39
+ pip install itaja # uv add itaja · poetry add itaja
40
+ ```
41
+
42
+ Requires Python 3.10+.
43
+
44
+ > **Server only.** This package holds your secret API key. Never ship it to a
45
+ > browser or a mobile app.
46
+
47
+ ## Quick start
48
+
49
+ ### 0. Configure
50
+
51
+ Put your keys in `.env` (never commit it) and pass them to `create_itaja`:
52
+
53
+ ```bash
54
+ # .env
55
+ ITAJA_API_KEY=itjs_… # sandbox key; an itjp_… key works on the live catalogue
56
+ ITAJA_WEBHOOK_SECRET=whsec_… # signing secret of your webhook endpoint (dashboard)
57
+ ITAJA_API_URL=https://api.getitaja.com/api/v1 # optional
58
+ ```
59
+
60
+ ```python
61
+ import os
62
+ from itaja import create_itaja
63
+
64
+ itaja = create_itaja(
65
+ api_key=os.environ["ITAJA_API_KEY"],
66
+ webhook_secret=os.environ["ITAJA_WEBHOOK_SECRET"],
67
+ base_url=os.environ.get("ITAJA_API_URL"),
68
+ catalog=[...],
69
+ )
70
+ ```
71
+
72
+ Each argument falls back to its variable when you leave it out. Pass other values
73
+ to use another account, a tenant's key or a staging API. The `itaja` command loads
74
+ `.env` and `.env.local` itself: nothing to export in your shell. In your app, load
75
+ `.env` the way you already do (django-environ, pydantic-settings, python-dotenv…).
76
+
77
+ ### 1. Declare what you sell
78
+
79
+ A free plan and a paid plan, with a quota that depends on the plan.
80
+
81
+ ```python
82
+ # itaja_config.py
83
+ import os
84
+ from itaja import create_itaja, feature, group, plan
85
+
86
+ # What the customer can do
87
+ messages = feature("messages", "metered")
88
+ export = feature("export", "boolean")
89
+
90
+ # One customer holds one plan of a group
91
+ app = group("app", "My App")
92
+
93
+ free = plan(
94
+ "free", app,
95
+ default=True, # everyone starts here, no payment
96
+ includes=[messages(limit=100, reset="month")],
97
+ )
98
+
99
+ pro = plan(
100
+ "pro", app,
101
+ price={"amount": 5000, "currency": "XOF", "interval": "month"},
102
+ trial_days=7,
103
+ includes=[messages(limit=2000, reset="month"), export()],
104
+ )
105
+
106
+ itaja = create_itaja(
107
+ api_key=os.environ.get("ITAJA_API_KEY"),
108
+ webhook_secret=os.environ.get("ITAJA_WEBHOOK_SECRET"),
109
+ catalog=[messages, export, free, pro],
110
+ )
111
+ ```
112
+
113
+ Your ids (`"pro"`, `"messages"`) are yours. Amounts are in **major units**
114
+ (`5000` XOF, `9.99` EUR), as in the API. Mistakes the API would refuse — an
115
+ undeclared feature, two default plans, a paid plan without a price, an invalid
116
+ id — raise `ItajaConfigError` as soon as the module is imported.
117
+
118
+ ### 2. Push it
119
+
120
+ ```bash
121
+ itaja push # shows the diff, asks, applies
122
+ ```
123
+
124
+ `push` uses the `api_key` of the client in `itaja_config.py`. Run it again: nothing
125
+ changes. Change a price and a **new version** of the plan is created; running
126
+ subscriptions keep theirs. Whatever you remove is archived, never deleted, and
127
+ products made in the dashboard are never touched.
128
+
129
+ | Command | Does |
130
+ |---|---|
131
+ | `itaja status` | lists what differs, exits 1 if anything does |
132
+ | `itaja push --yes` | applies without asking (CI, deploy pipeline) |
133
+ | `itaja catalog` | prints the JSON your code declares |
134
+ | `itaja --config myapp.billing:itaja push` | your client lives elsewhere |
135
+
136
+ The key's prefix picks the mode: `itjs_` is sandbox, `itjp_` is live.
137
+
138
+ ### 3. Mount the webhook
139
+
140
+ One route receives Itaja's events. It checks the signature on the raw body,
141
+ ignores a delivery it already handled, ignores events of the other mode, and
142
+ answers `500` when your handler raises so Itaja delivers again. It checks
143
+ deliveries with `webhook_secret`.
144
+
145
+ **Django** (`pip install "itaja[django]"`)
146
+
147
+ ```python
148
+ # urls.py
149
+ from django.urls import path
150
+ from itaja.integrations.django import webhook_view
151
+ from itaja_config import itaja
152
+
153
+ urlpatterns = [path("itaja/webhook", webhook_view(itaja))] # CSRF-exempt, POST only
154
+ ```
155
+
156
+ **FastAPI** (`pip install "itaja[fastapi]"`)
157
+
158
+ ```python
159
+ from fastapi import FastAPI
160
+ from itaja.integrations.starlette import webhook_router
161
+ from itaja_config import itaja
162
+
163
+ app = FastAPI()
164
+ app.include_router(webhook_router(itaja)) # POST /itaja/webhook
165
+ ```
166
+
167
+ **Starlette**: `Route("/itaja/webhook", webhook_endpoint(itaja), methods=["POST"])`
168
+ from `itaja.integrations.starlette`.
169
+
170
+ **Flask** (`pip install "itaja[flask]"`)
171
+
172
+ ```python
173
+ from flask import Flask
174
+ from itaja.integrations.flask import webhook_blueprint
175
+ from itaja_config import itaja
176
+
177
+ app = Flask(__name__)
178
+ app.register_blueprint(webhook_blueprint(itaja)) # POST /itaja/webhook
179
+ ```
180
+
181
+ Anything else: `itaja.webhooks.process(raw_body, headers)` (or `await
182
+ itaja.webhooks.process_async(...)`) returns the status and body to answer.
183
+
184
+ ### 4. React to events
185
+
186
+ ```python
187
+ @itaja.on("checkout.paid")
188
+ def fulfil(event):
189
+ Order.fulfil(event.data["checkoutId"])
190
+
191
+ @itaja.on("subscription.canceled")
192
+ async def downgrade(event): # async handlers work too
193
+ await accounts.downgrade(event.data["customerId"])
194
+ ```
195
+
196
+ Or pass them at creation: `create_itaja(..., on={"checkout.paid": fulfil})`.
197
+ `event.data` holds the fields Itaja documents for that type. The `"*"` handler
198
+ receives every delivery, including types this version does not know yet.
199
+
200
+ Test a handler locally with a signed sample:
201
+
202
+ ```bash
203
+ itaja trigger checkout.paid --url http://localhost:8000/itaja/webhook # signs with ITAJA_WEBHOOK_SECRET from .env
204
+ ```
205
+
206
+ ## What you can sell
207
+
208
+ | Mode | Declare with | Good for |
209
+ |---|---|---|
210
+ | [Subscriptions](#subscriptions) | `feature` · `group` · `plan` | SaaS, memberships |
211
+ | [Credits](#credits) | `credits` · `credit_pack` | usage, AI tokens, coins |
212
+ | [Licence keys](#licence-keys) | `license` | desktop or mobile software |
213
+ | [One-off payments](#one-off-payments) | `itaja.checkout.create` | a physical or digital order |
214
+
215
+ Each one can live in the same `catalog` list.
216
+
217
+ ### Subscriptions
218
+
219
+ The example above is the whole declaration. Around it:
220
+
221
+ ```python
222
+ team = plan(
223
+ "team", app,
224
+ price={"amount": 15000, "currency": "XOF", "interval": "month"},
225
+ includes=[messages(limit=20000, reset="month"), export()],
226
+ )
227
+ ```
228
+
229
+ - `interval`: `week` · `month` · `quarter` · `year`. `reset` on a quota: `day` · `week` · `month` · `year`.
230
+ - A plan without a `price` must be `default=True`; a group has at most one default.
231
+ - `trial_days` gives a free period before the first charge.
232
+
233
+ ```python
234
+ itaja.subscriptions.list(customerId=customer_id)
235
+ itaja.subscriptions.cancel(subscription_id) # at period end
236
+ ```
237
+
238
+ Events: `invoice.paid` · `invoice.payment_failed` · `subscription.past_due` · `subscription.canceled`.
239
+
240
+ > **Coming with the API:** `itaja.subscribe()` (new, upgrade, downgrade,
241
+ > cancel-to-free in one call), `check()` and `report()` for entitlements exist and
242
+ > raise `NOT_YET_AVAILABLE` until the API serves them
243
+ > ([itaja-services #465, #466](https://github.com/itaja/itaja-services/issues/465)).
244
+
245
+ ### Credits
246
+
247
+ A currency your app spends as the customer uses it, sold in packs.
248
+
249
+ ```python
250
+ from itaja import credit_pack, credits
251
+
252
+ tokens = credits("tokens", "AI tokens", coin_name="Tokens")
253
+
254
+ small = credit_pack("small", tokens, 100, {"amount": 1000, "currency": "XOF"})
255
+ large = credit_pack("large", tokens, 1000, {"amount": 8000, "currency": "XOF"})
256
+ ```
257
+
258
+ ```python
259
+ checkout = itaja.credits.buy(pack="small", customer_id=customer_id, home_url="https://shop.example/thanks", product="tokens")
260
+ # redirect the buyer to checkout["checkoutUrl"]
261
+
262
+ itaja.credits.consume(customer_id=customer_id, amount=3, product="tokens")
263
+ itaja.credits.balance(customer_id=customer_id, product="tokens")
264
+ ```
265
+
266
+ Events: `credits.granted` · `credits.consumed` · `credits.expired` · `credits.low` · `credits.clawed_back`.
267
+
268
+ ### Licence keys
269
+
270
+ Software that unlocks on a number of devices.
271
+
272
+ ```python
273
+ from itaja import license
274
+
275
+ desktop = license(
276
+ "desktop", "Desktop app",
277
+ price={"amount": 25000, "currency": "XOF"},
278
+ devices=3, # None = unlimited
279
+ duration="days-365", # "lifetime" · "days-N" · "from-first-activation-days-N"
280
+ )
281
+ ```
282
+
283
+ ```python
284
+ itaja.licenses.issue(product="desktop", customer_id=customer_id)
285
+ itaja.licenses.revoke(key_id, product="desktop", reason="chargeback")
286
+ ```
287
+
288
+ Events: `license.issued` · `activated` · `deactivated` · `revoked` · `expired` · `rotated` · `suspended` · `resumed` · `updated`.
289
+
290
+ ### One-off payments
291
+
292
+ Nothing to declare: create a checkout for an amount, send the customer to
293
+ `checkoutUrl`, and fulfil on `checkout.paid`.
294
+
295
+ ```python
296
+ checkout = itaja.checkout.create(
297
+ homeUrl="https://shop.example/thanks",
298
+ currency="XOF",
299
+ lineItems=[{"name": "Sandals", "quantity": 1, "unitPrice": 5000}],
300
+ )
301
+ ```
302
+
303
+ Payment links are a dashboard feature: no builder or endpoint here creates one.
304
+
305
+ ## Also in the SDK
306
+
307
+ ```python
308
+ itaja.customers.get("user_42") # your own customer id works too
309
+ itaja.request("GET", "/me") # any endpoint the helpers do not cover
310
+ ```
311
+
312
+ A product made in the dashboard is named by its Itaja id: `product_id="prd_…"`.
313
+
314
+ Verify a delivery yourself, without the route:
315
+
316
+ ```python
317
+ from itaja import construct_event
318
+
319
+ event = construct_event(raw_body, headers, secret=os.environ["ITAJA_WEBHOOK_SECRET"])
320
+ ```
321
+
322
+ Behind several processes or servers, share the processed-delivery store (it is in
323
+ memory by default) with any object that has `has(event_id)` and `add(event_id)`:
324
+
325
+ ```python
326
+ class RedisDedupe:
327
+ def has(self, event_id): return bool(redis.exists(f"itaja:{event_id}"))
328
+ def add(self, event_id): redis.set(f"itaja:{event_id}", 1, ex=86400)
329
+
330
+ itaja = create_itaja(..., dedupe=RedisDedupe())
331
+ ```
332
+
333
+ ## Errors and retries
334
+
335
+ Every failure is an `ItajaError` with a stable `code`, the HTTP `status` and the
336
+ API's `details`: `ItajaValidationError` (400/422), `ItajaAuthenticationError`
337
+ (401), `ItajaPermissionError` (403), `ItajaNotFoundError` (404),
338
+ `ItajaConflictError` (409), `ItajaRateLimitError` (429), `ItajaServerError`
339
+ (5xx), `ItajaConnectionError`, `ItajaSignatureError`, `ItajaConfigError`. No error
340
+ ever contains your key.
341
+
342
+ Every write carries an `Idempotency-Key`, one per call and reused on its retries,
343
+ so a retried write never runs twice. Network errors, `5xx`, `429` and
344
+ `409 IDEMPOTENCY_KEY_IN_PROGRESS` are retried twice, honouring `Retry-After`. Pass
345
+ `idempotency_key=` (an order id, say) to make **your own** retries safe too.
346
+
347
+ ## Configuration
348
+
349
+ Every argument of `create_itaja`, and the variable it falls back to:
350
+
351
+ | Argument | Environment | Default |
352
+ |---|---|---|
353
+ | `api_key` | `ITAJA_API_KEY` | — |
354
+ | `webhook_secret` | `ITAJA_WEBHOOK_SECRET` | — (only the webhook route needs it) |
355
+ | `base_url` | `ITAJA_API_URL` | `https://api.getitaja.com/api/v1` |
356
+ | `timeout` / `max_retries` | — | `30` / `2` |
357
+ | `dedupe` | — | in memory, 24 h |
358
+
359
+ There is no `environment` argument: the key's prefix decides sandbox or live.
360
+
361
+ ## Development
362
+
363
+ ```bash
364
+ uv sync
365
+ uv run pytest
366
+ uv run mypy
367
+ uv run ruff check
368
+ ```
369
+
370
+ `spec/catalog.schema.json` and `spec/signature-vectors.json` (repository root) are
371
+ shared with every Itaja SDK: this package builds, field for field, the catalogue
372
+ the TypeScript SDK builds (`spec/conformance/reference-catalog.json`), and accepts
373
+ every webhook the backend's own signer produced.
374
+
375
+ ## License
376
+
377
+ MIT