bursar 0.0.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.
Files changed (54) hide show
  1. bursar-0.0.1/PKG-INFO +432 -0
  2. bursar-0.0.1/README.md +396 -0
  3. bursar-0.0.1/pyproject.toml +128 -0
  4. bursar-0.0.1/setup.cfg +4 -0
  5. bursar-0.0.1/src/bursar/__init__.py +141 -0
  6. bursar-0.0.1/src/bursar/__main__.py +419 -0
  7. bursar-0.0.1/src/bursar/allowance.py +177 -0
  8. bursar-0.0.1/src/bursar/breakdown.py +35 -0
  9. bursar-0.0.1/src/bursar/config.py +161 -0
  10. bursar-0.0.1/src/bursar/engine.py +257 -0
  11. bursar-0.0.1/src/bursar/events.py +134 -0
  12. bursar-0.0.1/src/bursar/expr.py +494 -0
  13. bursar-0.0.1/src/bursar/interface/__init__.py +1 -0
  14. bursar-0.0.1/src/bursar/interface/base.py +772 -0
  15. bursar-0.0.1/src/bursar/interface/memory.py +1945 -0
  16. bursar-0.0.1/src/bursar/interface/models.py +596 -0
  17. bursar-0.0.1/src/bursar/interface/postgres.py +1120 -0
  18. bursar-0.0.1/src/bursar/interface/supabase.py +1014 -0
  19. bursar-0.0.1/src/bursar/manager.py +1566 -0
  20. bursar-0.0.1/src/bursar/metrics.py +50 -0
  21. bursar-0.0.1/src/bursar/py.typed +0 -0
  22. bursar-0.0.1/src/bursar/sql/001_core_schema.sql +163 -0
  23. bursar-0.0.1/src/bursar/sql/002_credit_rpcs.sql +217 -0
  24. bursar-0.0.1/src/bursar/sql/003_pricing_config.sql +231 -0
  25. bursar-0.0.1/src/bursar/sql/004_plans.sql +377 -0
  26. bursar-0.0.1/src/bursar/sql/005_spend_caps.sql +105 -0
  27. bursar-0.0.1/src/bursar/sql/006_refunds_and_expiry.sql +360 -0
  28. bursar-0.0.1/src/bursar/sql/007_analytics.sql +327 -0
  29. bursar-0.0.1/src/bursar/sql/008_teams.sql +324 -0
  30. bursar-0.0.1/src/bursar/sql/009_deduct_and_leases.sql +993 -0
  31. bursar-0.0.1/src/bursar/sql/010_credit_tiers.sql +212 -0
  32. bursar-0.0.1/src/bursar/sql/011_lazy_expiry.sql +383 -0
  33. bursar-0.0.1/src/bursar/sql/012_feature_limits.sql +74 -0
  34. bursar-0.0.1/src/bursar/sql/__init__.py +13 -0
  35. bursar-0.0.1/src/bursar.egg-info/PKG-INFO +432 -0
  36. bursar-0.0.1/src/bursar.egg-info/SOURCES.txt +52 -0
  37. bursar-0.0.1/src/bursar.egg-info/dependency_links.txt +1 -0
  38. bursar-0.0.1/src/bursar.egg-info/entry_points.txt +2 -0
  39. bursar-0.0.1/src/bursar.egg-info/requires.txt +19 -0
  40. bursar-0.0.1/src/bursar.egg-info/top_level.txt +1 -0
  41. bursar-0.0.1/tests/test_allowance.py +338 -0
  42. bursar-0.0.1/tests/test_cli.py +567 -0
  43. bursar-0.0.1/tests/test_config.py +721 -0
  44. bursar-0.0.1/tests/test_engine.py +612 -0
  45. bursar-0.0.1/tests/test_expr.py +677 -0
  46. bursar-0.0.1/tests/test_lazy_expiry.py +186 -0
  47. bursar-0.0.1/tests/test_lease.py +567 -0
  48. bursar-0.0.1/tests/test_lease_adversarial.py +531 -0
  49. bursar-0.0.1/tests/test_manager.py +2074 -0
  50. bursar-0.0.1/tests/test_store.py +1810 -0
  51. bursar-0.0.1/tests/test_store_integration.py +3161 -0
  52. bursar-0.0.1/tests/test_subscription_cycle.py +193 -0
  53. bursar-0.0.1/tests/test_tiers.py +715 -0
  54. bursar-0.0.1/tests/test_tiers_adversarial.py +375 -0
bursar-0.0.1/PKG-INFO ADDED
@@ -0,0 +1,432 @@
1
+ Metadata-Version: 2.4
2
+ Name: bursar
3
+ Version: 0.0.1
4
+ Summary: Declarative credit calculation engine for AI SaaS platforms
5
+ Project-URL: Homepage, https://zonastery.github.io/bursar/
6
+ Project-URL: Source, https://github.com/Zonastery/bursar
7
+ Project-URL: Documentation, https://zonastery.github.io/bursar/
8
+ Project-URL: Issues, https://github.com/Zonastery/bursar/issues
9
+ Keywords: credits,billing,llm,usage-metering,pricing
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
18
+ Requires-Python: <3.14,>=3.11
19
+ Description-Content-Type: text/markdown
20
+ Requires-Dist: pydantic>=2.0.0
21
+ Requires-Dist: python-dotenv>=1.0.0
22
+ Requires-Dist: pyyaml>=6.0.3
23
+ Provides-Extra: supabase
24
+ Requires-Dist: httpx>=0.28.1; extra == "supabase"
25
+ Provides-Extra: postgres
26
+ Requires-Dist: psycopg2-binary>=2.9.12; extra == "postgres"
27
+ Provides-Extra: test
28
+ Requires-Dist: pytest>=8.0; extra == "test"
29
+ Requires-Dist: ruff>=0.15.0; extra == "test"
30
+ Requires-Dist: pyright>=1.1.390; extra == "test"
31
+ Requires-Dist: pytest-testmon>=2.2.0; extra == "test"
32
+ Requires-Dist: pytest-postgresql>=7.0; extra == "test"
33
+ Requires-Dist: httpx>=0.28.1; extra == "test"
34
+ Requires-Dist: psycopg2-binary>=2.9.12; extra == "test"
35
+ Requires-Dist: pyyaml>=6.0.3; extra == "test"
36
+
37
+ # bursar
38
+
39
+ [![CI](https://github.com/Zonastery/bursar/actions/workflows/ci.yml/badge.svg)](https://github.com/Zonastery/bursar/actions/workflows/ci.yml)
40
+ [![Python 3.11+](https://img.shields.io/badge/python-3.11%20%7C%203.12%20%7C%203.13-blue)](https://www.python.org/)
41
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE)
42
+
43
+ Add usage-based credits to your AI SaaS in minutes — not weeks.
44
+
45
+ bursar is a drop-in credit calculation engine. Define pricing as math expressions
46
+ (per-model, per-tool, search/RAG, cache, fixed jobs), connect a database, and
47
+ start deducting credits. No billing infrastructure to build. Pricing lives in
48
+ your DB — update it live without redeploys.
49
+
50
+ ```python
51
+ from bursar import CreditManager, UsageMetrics
52
+ from bursar.interface.supabase import HttpxSupabaseStore
53
+
54
+ store = HttpxSupabaseStore(url=supabase_url, key=service_role_key)
55
+ manager = CreditManager(store=store)
56
+ manager.load_pricing_from_store()
57
+
58
+ manager.add_credits("user_abc", 1000)
59
+
60
+ result = manager.deduct(
61
+ user_id="user_abc",
62
+ metrics=UsageMetrics(model="gpt-4", input_tokens=500, output_tokens=200),
63
+ idempotency_key="chat_42",
64
+ )
65
+ print(f"Deducted {abs(result.amount)} credits. Balance: {result.balance_after}")
66
+ ```
67
+
68
+ ## Features
69
+
70
+ - **Safe expression engine** — Python `ast` module with strict allowlist. `min`, `max`, `if`, `tier`, `clamp`, `ceil`, `floor`, `round`, `percentile`. No eval/exec, no attribute access, no imports.
71
+ - **Plan-based pricing** — Subscription plans with free monthly allowances, rate overrides, and feature flags. Allowance consumed before balance.
72
+ - **Refunds** — Full and partial credit reversals with duplicate detection and idempotency.
73
+ - **Credit expiry / TTL** — Time-bound credits with `expires_at` on `add_credits`. Sweep with dry-run mode.
74
+ - **Team / shared balances** — Separate team credit pools with per-member spend caps and attribution.
75
+ - **Spend caps** — Per-user daily/monthly limits with `deny`, `warn`, `notify` actions. Per-model caps supported.
76
+ - **Usage analytics** — `spend_by_user`, `spend_by_model`, `top_users`, `daily_spend`, `aggregate_stats` across time windows.
77
+ - **Event hooks** — Typed pub/sub for `credits.deducted`, `credits.added`, `credits.refunded`, `credits.expired`, `credits.cap_reached`, `credits.cap_warning`, `credits.low_balance`.
78
+ - **Database-backed pricing** — Live updates without redeploys. Dict loading for testing.
79
+ - **Multi-dimensional** — Per-model (with `_default` fallback), per-tool overrides, search/RAG, cache discounts, fixed-cost jobs.
80
+ - **Pluggable storage** — `CreditStore` adapters: Supabase, PostgreSQL, in-memory.
81
+ - **Safe defaults** — `min_balance` floor, atomic idempotent deductions, fractional `Decimal` credits (no truncation).
82
+ - **Auditable** — Structured `CostBreakdown` with per-dimension costs.
83
+
84
+ ## Installation
85
+
86
+ ```bash
87
+ pip install bursar
88
+
89
+ # With Supabase store
90
+ pip install "bursar[supabase]"
91
+
92
+ # With PostgreSQL store
93
+ pip install "bursar[postgres]"
94
+
95
+ # Development & testing
96
+ pip install "bursar[test]"
97
+ ```
98
+
99
+ Requires Python 3.11+.
100
+
101
+ ## Full docs
102
+
103
+ **[zonastery.github.io/bursar](https://zonastery.github.io/bursar/)** — Python API reference, expressions, configuration, examples.
104
+
105
+ ## Quick Start
106
+
107
+ ### 0. Stateless calculation (no database)
108
+
109
+ ```python
110
+ from bursar import PricingEngine, UsageMetrics
111
+
112
+ engine = PricingEngine.from_dict({
113
+ "version": 1,
114
+ "models": {"_default": "input_tokens * 0.001 + output_tokens * 0.003"},
115
+ })
116
+
117
+ result = engine.calculate(UsageMetrics(model="gpt-4", input_tokens=500, output_tokens=200))
118
+ print(f"Total credits: {result.total}")
119
+ ```
120
+
121
+ ### 1. Install and migrate
122
+
123
+ ```bash
124
+ pip install "bursar[postgres]"
125
+
126
+ # The connection string is read from DATABASE_URL (recommended) — keeping the
127
+ # password out of your shell history, `ps` output and CI logs.
128
+ export DATABASE_URL="postgresql://user:pass@host:5432/db"
129
+ bursar migrate
130
+ ```
131
+
132
+ > A positional URL (`bursar migrate "postgresql://…"`) still works for convenience
133
+ > but is discouraged and prints a warning, since it leaks the password via the
134
+ > process list, shell history and CI logs.
135
+
136
+ Creates all tables (`user_credits`, `credit_transactions`, `credit_reservations`,
137
+ `credit_plans`, `credit_usage_window`, `credit_teams`, `credit_team_members`,
138
+ `credit_spend_caps`, `credit_pricing_config`) and 20+ RPCs — all idempotent.
139
+
140
+ ### 2. Pricing version management
141
+
142
+ ```bash
143
+ # Apply new pricing (creates v1)
144
+ bursar pricing set - <<'JSON'
145
+ {
146
+ "version": 1,
147
+ "models": { "_default": "input_tokens * 0.01 + output_tokens * 0.03" },
148
+ "plans": {
149
+ "free": { "id": "free", "name": "Free Tier", "free_allowance": 50000 },
150
+ "pro": { "id": "pro", "name": "Pro", "free_allowance": 500000 }
151
+ }
152
+ }
153
+ JSON
154
+
155
+ # Apply with a label
156
+ bursar pricing set pricing.yaml --label "deploy-42"
157
+
158
+ # List all versions (* = active)
159
+ bursar pricing list
160
+
161
+ # Switch active pricing
162
+ bursar pricing activate 1
163
+
164
+ # Diff two versions
165
+ bursar pricing diff 1 2
166
+
167
+ # Export a version as JSON
168
+ bursar pricing export 2
169
+
170
+ # Validate without applying
171
+ bursar pricing validate pricing.yaml
172
+ ```
173
+
174
+ Each `pricing set` creates a new immutable version. Roll back with `pricing activate <version>`.
175
+
176
+ | Command | Description |
177
+ |---------|-------------|
178
+ | `pricing set <file> [--label <msg>]` | Apply config (always creates new version) |
179
+ | `pricing get` | Show active config |
180
+ | `pricing list` | List all versions |
181
+ | `pricing activate <version>` | Switch to any version |
182
+ | `pricing validate <file>` | Dry-run validate |
183
+ | `pricing diff <v1> <v2>` | Unified diff between versions |
184
+ | `pricing export <version>` | Dump version as JSON |
185
+
186
+ ### 3. Deduct credits
187
+
188
+ ```python
189
+ from bursar import CreditManager, UsageMetrics
190
+ from bursar.interface.postgres import PostgresStore
191
+
192
+ store = PostgresStore("postgresql://user:pass@host:5432/db")
193
+ manager = CreditManager(store=store)
194
+ manager.load_pricing_from_store()
195
+
196
+ manager.add_credits("user_abc", 1000)
197
+ result = manager.deduct(
198
+ user_id="user_abc",
199
+ metrics=UsageMetrics(model="gpt-4", input_tokens=500, output_tokens=200),
200
+ idempotency_key="tx_001",
201
+ )
202
+ print(f"Deducted {abs(result.amount)} credits. Balance: {result.balance_after}")
203
+ ```
204
+
205
+ ## Pricing Configuration
206
+
207
+ ### Basic config
208
+
209
+ ```json
210
+ {
211
+ "version": 1,
212
+ "models": {
213
+ "gpt-4": "input_tokens * 0.01 + output_tokens * 0.03",
214
+ "_default": "input_tokens * 0.001 + output_tokens * 0.003"
215
+ },
216
+ "tools": { "_default": "tool_calls * 0" },
217
+ "search": { "costs": "search_queries * 0.5 + search_results * 0.05" },
218
+ "cache": { "discount": "-cache_read_tokens * 0.0045" },
219
+ "fixed": { "batch_job": 20 },
220
+ "min_balance": 5
221
+ }
222
+ ```
223
+
224
+ ### With plans
225
+
226
+ ```json
227
+ {
228
+ "version": 1,
229
+ "models": { "_default": "input_tokens * 0.01 + output_tokens * 0.03" },
230
+ "plans": {
231
+ "free": {
232
+ "id": "free",
233
+ "name": "Free Tier",
234
+ "free_allowance": 50000,
235
+ "rate_overrides": { "_default": "input_tokens * 0.02 + output_tokens * 0.06" },
236
+ "features": { "max_concurrency": 1, "background_removal": true },
237
+ "feature_limits": {
238
+ "background_removal": { "max_calls": 5, "period": "monthly", "action": "deny" }
239
+ }
240
+ },
241
+ "pro": {
242
+ "id": "pro",
243
+ "name": "Pro Plan",
244
+ "free_allowance": 500000
245
+ }
246
+ }
247
+ }
248
+ ```
249
+
250
+ ## Feature Examples
251
+
252
+ ### Refunds
253
+
254
+ ```python
255
+ tx = manager.deduct("user_abc", UsageMetrics(model="gpt-4", input_tokens=500))
256
+ refund = manager.refund_credits(tx.transaction_id) # full refund
257
+ partial = manager.refund_credits(tx.transaction_id, amount=5) # partial
258
+ ```
259
+
260
+ ### Credit expiry
261
+
262
+ ```python
263
+ manager.add_credits("user_abc", 100, "purchase", expires_at=datetime(2025, 1, 1))
264
+ result = manager.sweep_expired_credits() # sweep
265
+ report = manager.sweep_expired_credits(dry_run=True) # preview only
266
+ ```
267
+
268
+ ### Team / shared balances
269
+
270
+ ```python
271
+ team = store.create_team("Engineering", initial_balance=5000)
272
+ store.add_team_member(team.team_id, "user_abc", role="admin", spend_cap=1000)
273
+ result = manager.deduct_team(team.team_id, "user_abc", UsageMetrics(model="gpt-4", input_tokens=500))
274
+ ```
275
+
276
+ ### Spend caps
277
+
278
+ ```python
279
+ from bursar.interface.models import SpendCap
280
+ store.set_spend_cap(SpendCap(user_id="user_abc", cap_type="daily", limit=100, action="deny"))
281
+ ```
282
+
283
+ ### Financial safety (leases)
284
+
285
+ Because bursar charges *after* the AI call, the safe pattern is an atomic **lease** taken *before* the work: `reserve` a worst-case hold against `available = balance − Σ(active holds)`, do the work, then `settle` the **actual** cost (de-clamped) or `release` to cancel. `reserve` is the only admission gate. Two presets: `strict_prepaid` (default; floor ≥ 0, structurally zero debt) and `overdraft` (negative floor; bills full actual; for paid users with auto-reload).
286
+
287
+ ```python
288
+ from decimal import Decimal
289
+
290
+ manager = CreditManager(store=store, policy="strict_prepaid") # or policy="overdraft", overdraft_floor=Decimal("-50")
291
+ lease = manager.reserve("user_abc", Decimal("40")) # worst-case hold
292
+ deduction = manager.settle("user_abc", lease.lease_id, Decimal("11")) # actual cost; de-clamped
293
+ # on failure: manager.release("user_abc", lease.lease_id) # idempotent
294
+ ```
295
+
296
+ ### Usage analytics
297
+
298
+ ```python
299
+ from datetime import datetime, timedelta
300
+ now = datetime.now()
301
+ rows = manager.spend_by_user(now - timedelta(days=30), now) # per-user totals
302
+ rows = manager.spend_by_model(now - timedelta(days=30), now) # per-model spend
303
+ rows = manager.top_users(10, now - timedelta(days=30), now) # top 10 users
304
+ rows = manager.daily_spend(now - timedelta(days=30), now) # daily buckets
305
+ stats = manager.aggregate_stats(now - timedelta(days=30), now) # aggregate summary
306
+ ```
307
+
308
+ ### Events
309
+
310
+ ```python
311
+ from bursar.events import CreditEventEmitter
312
+ emitter = CreditEventEmitter()
313
+ manager = CreditManager(store=store, emitter=emitter)
314
+ emitter.on("credits.deducted", lambda e: print(f"User {e.user_id} spent credits"))
315
+ emitter.on("credits.low_balance", lambda e: send_alert(e.user_id, e.data["balance"]))
316
+ ```
317
+
318
+ ### Expression syntax
319
+
320
+ | Feature | Example |
321
+ |---------|---------|
322
+ | Arithmetic | `+`, `-`, `*`, `/`, `//`, `%` (exponentiation `**` is rejected at validate time) |
323
+ | Comparisons | `==`, `!=`, `<`, `<=`, `>`, `>=`, `in`, `not in` |
324
+ | Boolean | `and`, `or`, `not` |
325
+ | Ternary | `X if cond else Y` |
326
+ | Functions | `ceil`, `floor`, `round`, `min`, `max`, `if(cond,t,f)`, `tier(v,t1,r1,t2,r2,...)`, `clamp(x,lo,hi)`, `percentile(p,v1,v2,...)` |
327
+
328
+ ### Available metrics
329
+
330
+ | Variable | Source |
331
+ |----------|--------|
332
+ | `input_tokens` | `UsageMetrics.input_tokens` |
333
+ | `output_tokens` | `UsageMetrics.output_tokens` |
334
+ | `cache_read_tokens` | `UsageMetrics.cache_read_tokens` |
335
+ | `cache_write_tokens` | `UsageMetrics.cache_write_tokens` |
336
+ | `tool_calls` | `len(UsageMetrics.tool_calls)` |
337
+ | `search_queries` | `UsageMetrics.search_queries` |
338
+ | `search_results` | `UsageMetrics.search_results` |
339
+ | `web_search_calls` | `UsageMetrics.web_search_calls` |
340
+ | `code_exec_calls` | `UsageMetrics.code_exec_calls` |
341
+
342
+ ## Storage Backends
343
+
344
+ | Store | Import | Deps | Use case |
345
+ |-------|--------|------|----------|
346
+ | `MemoryStore` | `bursar.interface.memory.MemoryStore` | None | Testing, dev |
347
+ | `HttpxSupabaseStore` | `bursar.interface.supabase.HttpxSupabaseStore` | `httpx` | Supabase production |
348
+ | `PostgresStore` | `bursar.interface.postgres.PostgresStore` | `psycopg2` | Direct PostgreSQL |
349
+
350
+ ### Custom stores
351
+
352
+ Implement `bursar.interface.base.CreditStore` (ABC with 29 abstract methods).
353
+
354
+ ## Credit Lifecycle
355
+
356
+ `CreditManager.deduct()`:
357
+
358
+ 1. **Calculate** — `PricingEngine.calculate(metrics)` → `cost` (exact `Decimal`, no truncation)
359
+ 2. **Short-circuit** — if `cost <= 0`, return a zero-amount result without touching the store
360
+ 3. **Atomic charge** — one `store.deduct_with_allowance(...)` call applies plan allowance,
361
+ spend-cap enforcement, the `min_balance` floor and the balance debit inside a **single
362
+ transaction**, keyed by `idempotency_key` (a replay returns the original result)
363
+
364
+ The legacy two-phase `reserve_credits` + `deduct_credits` API is still available on the store
365
+ for callers that need a reservation step.
366
+
367
+ ### Additional operations
368
+
369
+ - **Refund:** `manager.refund_credits(tx_id, amount?)` — full or partial
370
+ - **Expire:** `manager.sweep_expired_credits(dry_run=True)` — preview or execute
371
+ - **Team deduct:** `manager.deduct_team(team_id, user_id, metrics)` — team pool
372
+ - **Analytics:** `spend_by_user`, `spend_by_model`, `top_users`, `daily_spend`, `aggregate_stats`
373
+ - **Events:** Subscribe via `CreditEventEmitter` for lifecycle hooks
374
+
375
+ ## SQL Migrations
376
+
377
+ 10 bundled migrations (`DATABASE_URL=… bursar migrate`):
378
+
379
+ | File | Contents |
380
+ |------|----------|
381
+ | `001_core_schema.sql` | Core tables (`user_credits`, `credit_transactions`, `credit_reservations`) + RLS + signup bonus trigger |
382
+ | `002_credit_rpcs.sql` | `credits_add`, `get_credits_balance` |
383
+ | `003_pricing_config.sql` | Pricing config table + get/set/list/activate RPCs |
384
+ | `004_plans.sql` | Subscription plans, usage windows, allowance RPCs |
385
+ | `005_spend_caps.sql` | Spend cap table + `check_spend_cap` RPC |
386
+ | `006_refunds_and_expiry.sql` | `refund_credits`, `expire_credits` |
387
+ | `007_analytics.sql` | Analytics + transaction-listing RPCs |
388
+ | `008_teams.sql` | Team balance pools + RPCs |
389
+ | `009_deduct_and_leases.sql` | Atomic `deduct_with_allowance` + full lease lifecycle |
390
+ | `010_credit_tiers.sql` | Configurable credit tiers (priority-ordered balance buckets) |
391
+
392
+ ## Architecture
393
+
394
+ ```
395
+ bursar/
396
+ expr.py # Safe AST expression evaluator
397
+ config.py # PricingConfig loading + validation
398
+ engine.py # PricingEngine — calculate, calculateBatch
399
+ metrics.py # UsageMetrics, ToolCall
400
+ breakdown.py # CostBreakdown
401
+ events.py # CreditEventEmitter pub/sub
402
+ manager.py # CreditManager orchestration
403
+ interface/
404
+ base.py # CreditStore ABC (29 abstract methods)
405
+ models.py # Pydantic schemas
406
+ memory.py # MemoryStore
407
+ supabase.py # HttpxSupabaseStore + run_migrations()
408
+ postgres.py # PostgresStore
409
+ sql/ # 001_*.sql … 015_*.sql (15 migrations)
410
+ ```
411
+
412
+ ## Expression Safety
413
+
414
+ 1. Parse `ast.parse(expr, mode="eval")`
415
+ 2. Walk AST — each node type in an allowlist
416
+ 3. Allowed functions: `ceil`, `floor`, `round`, `min`, `max`, `if`, `tier`, `clamp`, `percentile`
417
+ 4. Rejects: attributes, subscripts, lambdas, comprehensions, imports, exponentiation (`**`)
418
+ 5. Division / modulo by zero and non-finite results raise `ExpressionError` (never `inf`/`NaN`)
419
+ 6. `__builtins__` emptied at evaluation time
420
+ 7. All expressions — and their variable names — validated at config load time
421
+
422
+ ## Development
423
+
424
+ ```bash
425
+ pip install "bursar[test]"
426
+ pytest
427
+ ruff check .
428
+ ruff format .
429
+ pyright
430
+ ```
431
+
432
+ See [CONTRIBUTING.md](CONTRIBUTING.md).