lambda-erp 0.1.0__py3-none-any.whl

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 (60) hide show
  1. api/__init__.py +0 -0
  2. api/attachments.py +229 -0
  3. api/auth.py +511 -0
  4. api/bootstrap.py +498 -0
  5. api/chat.py +2764 -0
  6. api/demo_limits.py +400 -0
  7. api/deps.py +7 -0
  8. api/errors.py +56 -0
  9. api/main.py +182 -0
  10. api/pdf.py +151 -0
  11. api/providers.py +116 -0
  12. api/routers/__init__.py +0 -0
  13. api/routers/accounting.py +63 -0
  14. api/routers/admin.py +122 -0
  15. api/routers/analytics.py +1009 -0
  16. api/routers/bank_reconciliation.py +31 -0
  17. api/routers/documents.py +100 -0
  18. api/routers/masters.py +396 -0
  19. api/routers/reports.py +735 -0
  20. api/routers/setup.py +387 -0
  21. api/services.py +372 -0
  22. api/templates/document.html +197 -0
  23. lambda_erp/__init__.py +3 -0
  24. lambda_erp/accounting/__init__.py +0 -0
  25. lambda_erp/accounting/bank_transaction.py +76 -0
  26. lambda_erp/accounting/budget.py +117 -0
  27. lambda_erp/accounting/chart_of_accounts.py +183 -0
  28. lambda_erp/accounting/general_ledger.py +362 -0
  29. lambda_erp/accounting/journal_entry.py +235 -0
  30. lambda_erp/accounting/payment_entry.py +515 -0
  31. lambda_erp/accounting/pos_invoice.py +342 -0
  32. lambda_erp/accounting/purchase_invoice.py +504 -0
  33. lambda_erp/accounting/revaluation.py +172 -0
  34. lambda_erp/accounting/sales_invoice.py +523 -0
  35. lambda_erp/accounting/subscription.py +132 -0
  36. lambda_erp/buying/__init__.py +0 -0
  37. lambda_erp/buying/purchase_order.py +165 -0
  38. lambda_erp/controllers/__init__.py +0 -0
  39. lambda_erp/controllers/currency.py +52 -0
  40. lambda_erp/controllers/defaults.py +51 -0
  41. lambda_erp/controllers/pricing_rule.py +103 -0
  42. lambda_erp/controllers/taxes_and_totals.py +369 -0
  43. lambda_erp/database.py +1543 -0
  44. lambda_erp/exceptions.py +37 -0
  45. lambda_erp/hooks.py +37 -0
  46. lambda_erp/model.py +462 -0
  47. lambda_erp/selling/__init__.py +0 -0
  48. lambda_erp/selling/quotation.py +263 -0
  49. lambda_erp/selling/sales_order.py +214 -0
  50. lambda_erp/simulation.py +704 -0
  51. lambda_erp/stock/__init__.py +0 -0
  52. lambda_erp/stock/delivery_note.py +254 -0
  53. lambda_erp/stock/purchase_receipt.py +356 -0
  54. lambda_erp/stock/stock_entry.py +330 -0
  55. lambda_erp/stock/stock_ledger.py +337 -0
  56. lambda_erp/utils.py +167 -0
  57. lambda_erp-0.1.0.dist-info/METADATA +454 -0
  58. lambda_erp-0.1.0.dist-info/RECORD +60 -0
  59. lambda_erp-0.1.0.dist-info/WHEEL +4 -0
  60. lambda_erp-0.1.0.dist-info/licenses/LICENSE +21 -0
api/demo_limits.py ADDED
@@ -0,0 +1,400 @@
1
+ """Token-spend rate limiting for the public demo.
2
+
3
+ Two sliding 1-hour windows guard the demo budget:
4
+ * global — caps total USD spend across all visitors per hour
5
+ * per-IP — caps a single client IP per hour
6
+
7
+ Default thresholds: $10/hr global ($240/day). The per-IP cap defaults
8
+ to ~$0.52/hr (the previous absolute cap, i.e. 25% of the old $50/day
9
+ budget) and is also clamped to at most 25% of the global cap so one
10
+ actor cannot monopolize it.
11
+
12
+ Only `public_manager` traffic counts against the global cap (that's
13
+ demo traffic); admin/manager sessions are still logged but exempt.
14
+
15
+ Every LLM call is persisted to the `Demo Spend Log` table so admins can
16
+ see per-window breakdowns (1h/2h/4h/12h/24h/7d) via
17
+ /api/admin/demo-spend. Single-replica deploy is the hard prerequisite
18
+ for this design — the in-process SQLite file is the single source of
19
+ truth. If the container ever scales horizontally, swap the store.
20
+
21
+ Pricing lives in `api.providers`; this module only owns persistence and
22
+ limit checks.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import os
28
+ import threading
29
+ import time
30
+ from dataclasses import dataclass
31
+ from typing import Optional
32
+
33
+ from lambda_erp.database import get_db
34
+
35
+
36
+ _WINDOW_SECONDS = 3600 # 1 hour
37
+
38
+ # Upper bound on how long a reservation may hold budget before it's
39
+ # considered stale and ignored. Must exceed the longest provider call
40
+ # timeout (OpenAI/Anthropic clients use 120s) plus a fudge for the
41
+ # event-loop latency between the SDK call returning and settle() firing.
42
+ # Defence-in-depth against cancellation leaks that slip past try/finally.
43
+ _RESERVATION_TTL_SECONDS = 180
44
+
45
+
46
+ _SCHEMA_SQL = """
47
+ CREATE TABLE IF NOT EXISTS "Demo Spend Log" (
48
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
49
+ ts REAL NOT NULL,
50
+ ip TEXT,
51
+ role TEXT,
52
+ provider TEXT,
53
+ model TEXT,
54
+ prompt_tokens INTEGER DEFAULT 0,
55
+ completion_tokens INTEGER DEFAULT 0,
56
+ cost_usd REAL NOT NULL,
57
+ session_id TEXT
58
+ );
59
+ """
60
+ _INDEX_TS_SQL = 'CREATE INDEX IF NOT EXISTS "idx_demo_spend_ts" ON "Demo Spend Log" (ts)'
61
+ _INDEX_IP_TS_SQL = 'CREATE INDEX IF NOT EXISTS "idx_demo_spend_ip_ts" ON "Demo Spend Log" (ip, ts)'
62
+
63
+ _schema_lock = threading.Lock()
64
+ _schema_ready = False
65
+
66
+
67
+ @dataclass
68
+ class _Reservation:
69
+ ip: str
70
+ role: str
71
+ estimated_usd: float
72
+ created_at: float
73
+
74
+
75
+ def init_schema() -> None:
76
+ """Create the spend-log table. Safe to call repeatedly; guarded by a
77
+ module-level flag so we don't re-issue DDL on every request."""
78
+ global _schema_ready
79
+ with _schema_lock:
80
+ if _schema_ready:
81
+ return
82
+ db = get_db()
83
+ db.conn.execute(_SCHEMA_SQL)
84
+ db.conn.execute(_INDEX_TS_SQL)
85
+ db.conn.execute(_INDEX_IP_TS_SQL)
86
+ db.conn.commit()
87
+ _schema_ready = True
88
+
89
+
90
+ class DemoSpendLimiter:
91
+ """SQLite-backed spend tracker with sliding-window caps.
92
+
93
+ `check()` reads SUM(cost_usd) over the last hour for both the global
94
+ demo bucket (role=public_manager) and the caller's IP; `record()`
95
+ appends one row per LLM call for both limiting and analytics.
96
+ """
97
+
98
+ def __init__(self, global_hourly_usd: float, per_ip_hourly_usd: float):
99
+ self.global_hourly_usd = global_hourly_usd
100
+ self.per_ip_hourly_usd = per_ip_hourly_usd
101
+ self._reservation_lock = threading.Lock()
102
+ self._next_reservation_id = 1
103
+ self._reservations: dict[int, _Reservation] = {}
104
+
105
+ def _reserved_totals_unlocked(self, ip: str) -> tuple[float, float]:
106
+ # Opportunistically evict stale reservations. This is the only
107
+ # path that totals reservations, so piggy-backing the sweep keeps
108
+ # the lock count down without needing a separate background task.
109
+ now = time.time()
110
+ cutoff = now - _RESERVATION_TTL_SECONDS
111
+ stale = [rid for rid, row in self._reservations.items() if row.created_at < cutoff]
112
+ for rid in stale:
113
+ self._reservations.pop(rid, None)
114
+
115
+ global_reserved = 0.0
116
+ ip_reserved = 0.0
117
+ for row in self._reservations.values():
118
+ if row.role != "public_manager":
119
+ continue
120
+ global_reserved += row.estimated_usd
121
+ if row.ip == ip:
122
+ ip_reserved += row.estimated_usd
123
+ return global_reserved, ip_reserved
124
+
125
+ # ---- checks --------------------------------------------------------
126
+
127
+ def check(self, ip: str) -> Optional[str]:
128
+ """Return a human-readable reason if `ip` (or the global demo
129
+ bucket) has crossed its hourly cap, else None. Admin/manager
130
+ traffic is never blocked here — that decision is at the call
131
+ site via `is_demo_role(role)`."""
132
+ init_schema()
133
+ db = get_db()
134
+ cutoff = time.time() - _WINDOW_SECONDS
135
+
136
+ global_spend = db.conn.execute(
137
+ 'SELECT COALESCE(SUM(cost_usd), 0) AS s FROM "Demo Spend Log" '
138
+ 'WHERE ts > ? AND role = ?',
139
+ (cutoff, "public_manager"),
140
+ ).fetchone()["s"]
141
+ if global_spend >= self.global_hourly_usd:
142
+ return (
143
+ f"Demo budget exhausted for this hour "
144
+ f"(~${global_spend:.2f} / ${self.global_hourly_usd:.2f}). "
145
+ "Please try again later."
146
+ )
147
+
148
+ ip_spend = db.conn.execute(
149
+ 'SELECT COALESCE(SUM(cost_usd), 0) AS s FROM "Demo Spend Log" '
150
+ 'WHERE ts > ? AND ip = ? AND role = ?',
151
+ (cutoff, ip, "public_manager"),
152
+ ).fetchone()["s"]
153
+ if ip_spend >= self.per_ip_hourly_usd:
154
+ return (
155
+ f"You've reached the hourly demo limit for your IP "
156
+ f"(~${ip_spend:.2f} / ${self.per_ip_hourly_usd:.2f}). "
157
+ "Please try again later."
158
+ )
159
+ return None
160
+
161
+ def reserve(
162
+ self,
163
+ ip: str,
164
+ *,
165
+ estimated_usd: float,
166
+ role: str | None = None,
167
+ ) -> tuple[Optional[str], Optional[int]]:
168
+ """Atomically reserve budget before an outbound LLM call."""
169
+ amount = max(float(estimated_usd or 0.0), 0.0)
170
+ if amount <= 0:
171
+ return None, None
172
+ if role != "public_manager":
173
+ return None, None
174
+
175
+ init_schema()
176
+ db = get_db()
177
+ cutoff = time.time() - _WINDOW_SECONDS
178
+ with self._reservation_lock:
179
+ global_spend = db.conn.execute(
180
+ 'SELECT COALESCE(SUM(cost_usd), 0) AS s FROM "Demo Spend Log" '
181
+ 'WHERE ts > ? AND role = ?',
182
+ (cutoff, "public_manager"),
183
+ ).fetchone()["s"]
184
+ ip_spend = db.conn.execute(
185
+ 'SELECT COALESCE(SUM(cost_usd), 0) AS s FROM "Demo Spend Log" '
186
+ 'WHERE ts > ? AND ip = ? AND role = ?',
187
+ (cutoff, ip, "public_manager"),
188
+ ).fetchone()["s"]
189
+ reserved_global, reserved_ip = self._reserved_totals_unlocked(ip)
190
+
191
+ projected_global = float(global_spend) + reserved_global + amount
192
+ if projected_global > self.global_hourly_usd:
193
+ return (
194
+ f"Demo budget exhausted for this hour "
195
+ f"(~${global_spend + reserved_global:.2f} / ${self.global_hourly_usd:.2f}). "
196
+ "Please try again later.",
197
+ None,
198
+ )
199
+
200
+ projected_ip = float(ip_spend) + reserved_ip + amount
201
+ if projected_ip > self.per_ip_hourly_usd:
202
+ return (
203
+ f"You've reached the hourly demo limit for your IP "
204
+ f"(~${ip_spend + reserved_ip:.2f} / ${self.per_ip_hourly_usd:.2f}). "
205
+ "Please try again later.",
206
+ None,
207
+ )
208
+
209
+ reservation_id = self._next_reservation_id
210
+ self._next_reservation_id += 1
211
+ self._reservations[reservation_id] = _Reservation(
212
+ ip=ip,
213
+ role=role or "",
214
+ estimated_usd=amount,
215
+ created_at=time.time(),
216
+ )
217
+ return None, reservation_id
218
+
219
+ def release(self, reservation_id: int | None) -> None:
220
+ """Drop a reservation without recording spend (failed call path)."""
221
+ if reservation_id is None:
222
+ return
223
+ with self._reservation_lock:
224
+ self._reservations.pop(reservation_id, None)
225
+
226
+ def settle(
227
+ self,
228
+ reservation_id: int | None,
229
+ *,
230
+ actual_cost_usd: float,
231
+ ip: str,
232
+ role: str | None = None,
233
+ provider: str | None = None,
234
+ model: str | None = None,
235
+ prompt_tokens: int = 0,
236
+ completion_tokens: int = 0,
237
+ session_id: str | None = None,
238
+ ) -> None:
239
+ """Finalize a reservation and persist the actual spend row."""
240
+ if reservation_id is not None:
241
+ with self._reservation_lock:
242
+ self._reservations.pop(reservation_id, None)
243
+ self.record(
244
+ ip,
245
+ actual_cost_usd,
246
+ role=role,
247
+ provider=provider,
248
+ model=model,
249
+ prompt_tokens=prompt_tokens,
250
+ completion_tokens=completion_tokens,
251
+ session_id=session_id,
252
+ )
253
+
254
+ # ---- recording -----------------------------------------------------
255
+
256
+ def record(
257
+ self,
258
+ ip: str,
259
+ cost_usd: float,
260
+ *,
261
+ role: str | None = None,
262
+ provider: str | None = None,
263
+ model: str | None = None,
264
+ prompt_tokens: int = 0,
265
+ completion_tokens: int = 0,
266
+ session_id: str | None = None,
267
+ ) -> None:
268
+ if cost_usd <= 0:
269
+ return
270
+ init_schema()
271
+ db = get_db()
272
+ db.conn.execute(
273
+ 'INSERT INTO "Demo Spend Log" '
274
+ '(ts, ip, role, provider, model, prompt_tokens, completion_tokens, cost_usd, session_id) '
275
+ 'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
276
+ (
277
+ time.time(),
278
+ ip,
279
+ role,
280
+ provider,
281
+ model,
282
+ int(prompt_tokens or 0),
283
+ int(completion_tokens or 0),
284
+ float(cost_usd),
285
+ session_id,
286
+ ),
287
+ )
288
+ db.conn.commit()
289
+
290
+ # ---- reporting -----------------------------------------------------
291
+
292
+ def snapshot(self) -> dict:
293
+ """Lightweight health snapshot — for logs / live dashboards."""
294
+ init_schema()
295
+ db = get_db()
296
+ cutoff = time.time() - _WINDOW_SECONDS
297
+ row = db.conn.execute(
298
+ 'SELECT COALESCE(SUM(cost_usd), 0) AS s, '
299
+ 'COUNT(DISTINCT ip) AS ips '
300
+ 'FROM "Demo Spend Log" WHERE ts > ? AND role = ?',
301
+ (cutoff, "public_manager"),
302
+ ).fetchone()
303
+ return {
304
+ "global_hourly_usd": round(row["s"], 4),
305
+ "global_hourly_cap": self.global_hourly_usd,
306
+ "per_ip_hourly_cap": self.per_ip_hourly_usd,
307
+ "active_ips": int(row["ips"] or 0),
308
+ }
309
+
310
+
311
+ # ---------------------------------------------------------------------------
312
+ # Process-wide singleton. Thresholds are read once at import time from env.
313
+ # ---------------------------------------------------------------------------
314
+
315
+ def _env_float(name: str, default: float) -> float:
316
+ raw = os.environ.get(name)
317
+ if raw is None or raw == "":
318
+ return default
319
+ try:
320
+ return float(raw)
321
+ except ValueError:
322
+ return default
323
+
324
+
325
+ # $10/hr → $240/day cap.
326
+ _GLOBAL_HOURLY_USD = _env_float("LAMBDA_ERP_DEMO_GLOBAL_HOURLY_USD", 10.0)
327
+ # Pinned to the previous absolute default (25% of the old $50/day
328
+ # global ≈ $0.521/hr) so raising the global cap does not also raise
329
+ # what a single IP can spend.
330
+ _PER_IP_HOURLY_USD = _env_float(
331
+ "LAMBDA_ERP_DEMO_PER_IP_HOURLY_USD",
332
+ (50.0 / 24.0) * 0.25,
333
+ )
334
+ _PER_IP_HOURLY_USD = min(_PER_IP_HOURLY_USD, _GLOBAL_HOURLY_USD * 0.25)
335
+
336
+ limiter = DemoSpendLimiter(
337
+ global_hourly_usd=_GLOBAL_HOURLY_USD,
338
+ per_ip_hourly_usd=_PER_IP_HOURLY_USD,
339
+ )
340
+
341
+
342
+ def is_demo_role(role: Optional[str]) -> bool:
343
+ """Limits only apply to the shared demo account, not admins/managers."""
344
+ return role == "public_manager"
345
+
346
+
347
+ def demo_max_completion_tokens(default: int = 4096) -> int:
348
+ """Env-overridable cap for `max_completion_tokens` on demo calls.
349
+
350
+ Defaults to 1024 — tight enough to bound worst-case turn cost but
351
+ generous enough for typical replies. Callers apply this only for
352
+ `public_manager`; logged-in managers/admins keep the original cap."""
353
+ raw = os.environ.get("LAMBDA_ERP_DEMO_MAX_COMPLETION_TOKENS")
354
+ if raw is None or raw == "":
355
+ return 1024
356
+ try:
357
+ return max(256, int(raw))
358
+ except ValueError:
359
+ return 1024
360
+
361
+
362
+ def demo_call_reserve_usd() -> float:
363
+ """Conservative pre-call reservation for demo LLM requests."""
364
+ raw = os.environ.get("LAMBDA_ERP_DEMO_CALL_RESERVE_USD")
365
+ if raw is None or raw == "":
366
+ return 0.35
367
+ try:
368
+ return max(0.05, float(raw))
369
+ except ValueError:
370
+ return 0.35
371
+
372
+
373
+ def demo_max_message_chars() -> int:
374
+ """Env-overridable cap on the length of a single chat message from a
375
+ public_manager session. Without this, a visitor could paste ~100k
376
+ characters and burn the global hourly budget in a single call even
377
+ though the per-call max_completion_tokens is tight — input tokens
378
+ aren't capped server-side otherwise."""
379
+ raw = os.environ.get("LAMBDA_ERP_DEMO_MAX_MESSAGE_CHARS")
380
+ if raw is None or raw == "":
381
+ return 300
382
+ try:
383
+ return max(50, int(raw))
384
+ except ValueError:
385
+ return 300
386
+
387
+
388
+ def demo_max_attachment_bytes() -> int:
389
+ """Env-overridable cap on the size of a single chat attachment uploaded
390
+ from a public_manager session. Default 100 KiB. Images/PDFs are sent
391
+ to the LLM as base64 multimodal parts, so each uploaded byte becomes
392
+ ~1.33 bytes of prompt — a 10 MB file would otherwise blow the hourly
393
+ budget in one call."""
394
+ raw = os.environ.get("LAMBDA_ERP_DEMO_MAX_ATTACHMENT_BYTES")
395
+ if raw is None or raw == "":
396
+ return 100 * 1024
397
+ try:
398
+ return max(1024, int(raw))
399
+ except ValueError:
400
+ return 100 * 1024
api/deps.py ADDED
@@ -0,0 +1,7 @@
1
+ """FastAPI dependency injection."""
2
+
3
+ from lambda_erp.database import get_db, Database
4
+
5
+
6
+ def get_database() -> Database:
7
+ return get_db()
api/errors.py ADDED
@@ -0,0 +1,56 @@
1
+ """Map lambda_erp exceptions to HTTP responses."""
2
+
3
+ from fastapi import FastAPI, Request
4
+ from fastapi.responses import JSONResponse
5
+ from lambda_erp.exceptions import (
6
+ ValidationError,
7
+ MandatoryError,
8
+ DocumentStatusError,
9
+ DebitCreditNotEqual,
10
+ NegativeStockError,
11
+ InvalidAccountError,
12
+ InvalidCurrency,
13
+ InsufficientFunds,
14
+ )
15
+
16
+
17
+ def register_exception_handlers(app: FastAPI):
18
+
19
+ @app.exception_handler(ValueError)
20
+ async def value_error(request: Request, exc: ValueError):
21
+ return JSONResponse(status_code=422, content={"detail": str(exc)})
22
+
23
+ @app.exception_handler(DocumentStatusError)
24
+ async def document_status_error(request: Request, exc: DocumentStatusError):
25
+ return JSONResponse(status_code=409, content={"detail": str(exc)})
26
+
27
+ @app.exception_handler(ValidationError)
28
+ async def validation_error(request: Request, exc: ValidationError):
29
+ msg = str(exc)
30
+ if msg.endswith("not found"):
31
+ return JSONResponse(status_code=404, content={"detail": msg})
32
+ return JSONResponse(status_code=422, content={"detail": msg})
33
+
34
+ @app.exception_handler(MandatoryError)
35
+ async def mandatory_error(request: Request, exc: MandatoryError):
36
+ return JSONResponse(status_code=422, content={"detail": str(exc)})
37
+
38
+ @app.exception_handler(DebitCreditNotEqual)
39
+ async def debit_credit_error(request: Request, exc: DebitCreditNotEqual):
40
+ return JSONResponse(status_code=422, content={"detail": str(exc)})
41
+
42
+ @app.exception_handler(NegativeStockError)
43
+ async def negative_stock_error(request: Request, exc: NegativeStockError):
44
+ return JSONResponse(status_code=422, content={"detail": str(exc)})
45
+
46
+ @app.exception_handler(InvalidAccountError)
47
+ async def invalid_account_error(request: Request, exc: InvalidAccountError):
48
+ return JSONResponse(status_code=422, content={"detail": str(exc)})
49
+
50
+ @app.exception_handler(InvalidCurrency)
51
+ async def invalid_currency_error(request: Request, exc: InvalidCurrency):
52
+ return JSONResponse(status_code=422, content={"detail": str(exc)})
53
+
54
+ @app.exception_handler(InsufficientFunds)
55
+ async def insufficient_funds_error(request: Request, exc: InsufficientFunds):
56
+ return JSONResponse(status_code=422, content={"detail": str(exc)})
api/main.py ADDED
@@ -0,0 +1,182 @@
1
+ """
2
+ FastAPI application entry point.
3
+
4
+ Run with: uvicorn api.main:app --reload --port 8000
5
+ """
6
+
7
+ import os
8
+ from contextlib import asynccontextmanager
9
+ from fastapi import FastAPI, HTTPException, WebSocket
10
+ from fastapi.middleware.cors import CORSMiddleware
11
+ from fastapi.responses import FileResponse
12
+ from fastapi.staticfiles import StaticFiles
13
+
14
+ from lambda_erp.database import setup
15
+
16
+ from api.errors import register_exception_handlers
17
+ from api.auth import router as auth_router, COOKIE_NAME, decode_token
18
+ from api.attachments import router as attachments_router
19
+ from api.chat import chat_websocket, router as chat_router
20
+ from api.routers import admin, documents, masters, reports, setup as setup_router, bank_reconciliation, analytics, accounting
21
+
22
+
23
+ def load_plugins() -> None:
24
+ """Import customer extension modules named in LAMBDA_ERP_PLUGINS and call
25
+ each module's register().
26
+
27
+ A customer deployment is a separate repo that depends on this core and
28
+ registers doctype overrides / lifecycle hooks (see
29
+ docs/core-extension-architecture.md). Set e.g. LAMBDA_ERP_PLUGINS=acme
30
+ (comma-separated for several). Runs after setup() and BEFORE any documents
31
+ are created, so overrides/hooks apply to seeded data too. Unset = no-op, so
32
+ the core runs unchanged. Fails fast on a broken plugin rather than booting
33
+ silently without the customer's customizations.
34
+ """
35
+ import importlib
36
+
37
+ names = [m.strip() for m in os.environ.get("LAMBDA_ERP_PLUGINS", "").split(",") if m.strip()]
38
+ for name in names:
39
+ print(f"[plugins] loading {name}", flush=True)
40
+ module = importlib.import_module(name)
41
+ register = getattr(module, "register", None)
42
+ if not callable(register):
43
+ raise RuntimeError(f"Plugin '{name}' has no callable register()")
44
+ register()
45
+
46
+
47
+ @asynccontextmanager
48
+ async def lifespan(app: FastAPI):
49
+ # Startup: initialize database
50
+ db_path = os.environ.get("LAMBDA_ERP_DB", "lambda_erp.db")
51
+ setup(db_path)
52
+
53
+ # Load customer extension plugins before anything creates documents.
54
+ load_plugins()
55
+
56
+ # Ensure the demo spend log table exists before any LLM call happens.
57
+ from api.demo_limits import init_schema as init_demo_spend_schema
58
+ init_demo_spend_schema()
59
+
60
+ # When packaged as a demo container, land visitors straight in demo mode.
61
+ if os.environ.get("LAMBDA_ERP_AUTO_DEMO") == "1":
62
+ from api.bootstrap import bootstrap_demo
63
+
64
+ bootstrap_demo()
65
+
66
+ yield
67
+ # Shutdown: nothing to clean up (SQLite handles it)
68
+
69
+
70
+ app = FastAPI(
71
+ title="Lambda ERP",
72
+ version="0.1.0",
73
+ lifespan=lifespan,
74
+ )
75
+
76
+ # CORS for Vite dev server
77
+ app.add_middleware(
78
+ CORSMiddleware,
79
+ allow_origins=["http://localhost:5173"],
80
+ allow_credentials=True,
81
+ allow_methods=["*"],
82
+ allow_headers=["*"],
83
+ )
84
+
85
+ # Exception handlers
86
+ register_exception_handlers(app)
87
+
88
+ # Routers
89
+ app.include_router(auth_router, prefix="/api")
90
+ app.include_router(attachments_router, prefix="/api")
91
+ app.include_router(documents.router, prefix="/api")
92
+ app.include_router(masters.router, prefix="/api")
93
+ app.include_router(reports.router, prefix="/api")
94
+ app.include_router(analytics.router, prefix="/api")
95
+ app.include_router(setup_router.router, prefix="/api")
96
+ app.include_router(bank_reconciliation.router, prefix="/api")
97
+ app.include_router(accounting.router, prefix="/api")
98
+ app.include_router(admin.router, prefix="/api")
99
+ app.include_router(chat_router, prefix="/api")
100
+
101
+
102
+ @app.get("/api/health")
103
+ def health():
104
+ return {"status": "ok"}
105
+
106
+
107
+ @app.websocket("/ws/chat")
108
+ async def ws_chat(websocket: WebSocket):
109
+ # Authenticate via cookie, fall back to public_manager for demo mode
110
+ from lambda_erp.database import get_db
111
+
112
+ token = websocket.cookies.get(COOKIE_NAME)
113
+ user_name = decode_token(token) if token else None
114
+ user = None
115
+
116
+ db = get_db()
117
+ if user_name:
118
+ user = db.get_value("User", user_name, ["name", "full_name", "role", "enabled"])
119
+ if user and not user.get("enabled"):
120
+ user = None
121
+
122
+ # Fall back to public manager
123
+ if not user:
124
+ pub = db.sql('SELECT name, full_name, role, enabled FROM "User" WHERE role = "public_manager" AND enabled = 1')
125
+ user = pub[0] if pub else None
126
+
127
+ if not user:
128
+ await websocket.accept()
129
+ await websocket.close(code=4001, reason="Not authenticated")
130
+ return
131
+
132
+ # Client IP for demo-spend rate limiting. Azure Container Apps ingress
133
+ # (and any sane reverse proxy) sets X-Forwarded-For with the original
134
+ # client as the leftmost entry; fall back to the socket peer for local
135
+ # dev where no proxy is in front.
136
+ xff = websocket.headers.get("x-forwarded-for", "")
137
+ if xff:
138
+ client_ip = xff.split(",")[0].strip()
139
+ else:
140
+ client_ip = websocket.client.host if websocket.client else "unknown"
141
+
142
+ await chat_websocket(websocket, user_info=dict(user), client_ip=client_ip)
143
+
144
+
145
+ # Serve frontend build in production (if dist/ exists).
146
+ #
147
+ # In local dev the frontend runs on Vite's dev server (port 5173), which
148
+ # has SPA fallback built in — any unknown route serves index.html so
149
+ # React Router can take over. In the container, FastAPI serves the built
150
+ # frontend itself, and StaticFiles(html=True) only serves index.html for
151
+ # directory requests, not arbitrary paths. That made direct-URL visits
152
+ # and reloads on routes like /chat/:id return `{"detail": "Not Found"}`
153
+ # instead of the React app. The route below replicates Vite's fallback
154
+ # semantics: real files (assets, favicon, logos) resolve as-is; anything
155
+ # else falls through to index.html and React Router handles it
156
+ # client-side. API and WebSocket paths keep returning their normal 404s.
157
+ frontend_dist = os.path.join(os.path.dirname(__file__), "..", "frontend", "dist")
158
+ if os.path.isdir(frontend_dist):
159
+ assets_dir = os.path.join(frontend_dist, "assets")
160
+ if os.path.isdir(assets_dir):
161
+ app.mount("/assets", StaticFiles(directory=assets_dir), name="assets")
162
+
163
+ frontend_root = os.path.realpath(frontend_dist)
164
+ index_html = os.path.join(frontend_root, "index.html")
165
+
166
+ @app.get("/{full_path:path}", include_in_schema=False)
167
+ async def spa_fallback(full_path: str):
168
+ # Let API/WS paths that didn't match a real route 404 as JSON,
169
+ # rather than accidentally returning the SPA shell HTML.
170
+ if full_path.startswith(("api/", "ws/")):
171
+ raise HTTPException(status_code=404, detail="Not Found")
172
+
173
+ # Real public files (favicon.ico, favicon.png, logo_*.png, etc.)
174
+ # should resolve to themselves. Guard against path traversal by
175
+ # re-anchoring to the resolved frontend_dist root.
176
+ if full_path:
177
+ candidate = os.path.realpath(os.path.join(frontend_root, full_path))
178
+ if candidate.startswith(frontend_root + os.sep) and os.path.isfile(candidate):
179
+ return FileResponse(candidate)
180
+
181
+ # SPA fallback — React Router takes over from here.
182
+ return FileResponse(index_html)