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/auth.py ADDED
@@ -0,0 +1,511 @@
1
+ """Authentication & team management for Lambda ERP.
2
+
3
+ JWT cookie-based auth with role hierarchy: admin > manager > viewer.
4
+ First user to register becomes admin. Subsequent users need an invite.
5
+ """
6
+
7
+ import os
8
+ import uuid
9
+ import secrets
10
+ import hashlib
11
+ from datetime import datetime, timedelta, timezone
12
+
13
+ import bcrypt
14
+ from jose import jwt, JWTError
15
+ from fastapi import APIRouter, Depends, HTTPException, Request, Response
16
+ from pydantic import BaseModel
17
+
18
+ from lambda_erp.database import get_db
19
+ from lambda_erp.utils import now
20
+
21
+ # ---------------------------------------------------------------------------
22
+ # Config
23
+ # ---------------------------------------------------------------------------
24
+
25
+ def _resolve_jwt_secret() -> str:
26
+ """Get a stable JWT signing secret.
27
+
28
+ Priority:
29
+ 1. JWT_SECRET_KEY env var (ops override, e.g. for multi-container
30
+ deployments where all instances must share the secret).
31
+ 2. A `.jwt_secret` file next to the SQLite DB (auto-generated on
32
+ first boot, persists via the same volume as the DB so restarts
33
+ don't invalidate existing cookies).
34
+ 3. Newly-generated random secret (only used if the filesystem is
35
+ read-only or inaccessible — cookies will be invalidated on
36
+ restart in that degenerate case).
37
+ """
38
+ env_secret = os.environ.get("JWT_SECRET_KEY")
39
+ if env_secret:
40
+ return env_secret
41
+
42
+ db_path = os.environ.get("LAMBDA_ERP_DB", "lambda_erp.db")
43
+ data_dir = os.path.dirname(os.path.abspath(db_path)) or "."
44
+ secret_file = os.path.join(data_dir, ".jwt_secret")
45
+
46
+ if os.path.isfile(secret_file):
47
+ try:
48
+ with open(secret_file, "r", encoding="utf-8") as f:
49
+ existing = f.read().strip()
50
+ if existing:
51
+ return existing
52
+ except OSError:
53
+ pass
54
+
55
+ new_secret = secrets.token_hex(32)
56
+ try:
57
+ os.makedirs(data_dir, exist_ok=True)
58
+ fd = os.open(secret_file, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
59
+ with os.fdopen(fd, "w") as f:
60
+ f.write(new_secret)
61
+ except OSError:
62
+ # Filesystem is read-only or permission denied. Fall through and
63
+ # use the in-memory secret; it just won't survive this restart.
64
+ pass
65
+
66
+ return new_secret
67
+
68
+
69
+ SECRET_KEY = _resolve_jwt_secret()
70
+ ALGORITHM = "HS256"
71
+ COOKIE_NAME = "lambda_erp_token"
72
+ TOKEN_EXPIRE_DAYS = 30
73
+ PASSWORD_HASH_PREFIX = "bcrypt_sha256$"
74
+ router = APIRouter(prefix="/auth", tags=["auth"])
75
+
76
+ # ---------------------------------------------------------------------------
77
+ # Password helpers
78
+ # ---------------------------------------------------------------------------
79
+
80
+
81
+ def hash_password(password: str) -> str:
82
+ digest = hashlib.sha256(password.encode("utf-8")).hexdigest().encode("ascii")
83
+ hashed = bcrypt.hashpw(digest, bcrypt.gensalt()).decode("utf-8")
84
+ return f"{PASSWORD_HASH_PREFIX}{hashed}"
85
+
86
+
87
+ def verify_password(plain: str, hashed: str) -> bool:
88
+ if not hashed.startswith(PASSWORD_HASH_PREFIX):
89
+ return False
90
+
91
+ digest = hashlib.sha256(plain.encode("utf-8")).hexdigest().encode("ascii")
92
+ encoded_hash = hashed.removeprefix(PASSWORD_HASH_PREFIX).encode("utf-8")
93
+ try:
94
+ return bcrypt.checkpw(digest, encoded_hash)
95
+ except ValueError:
96
+ return False
97
+
98
+
99
+ # ---------------------------------------------------------------------------
100
+ # JWT helpers
101
+ # ---------------------------------------------------------------------------
102
+
103
+
104
+ def create_access_token(user_name: str) -> str:
105
+ expire = datetime.now(timezone.utc) + timedelta(days=TOKEN_EXPIRE_DAYS)
106
+ return jwt.encode({"sub": user_name, "exp": expire}, SECRET_KEY, algorithm=ALGORITHM)
107
+
108
+
109
+ def decode_token(token: str) -> str | None:
110
+ """Returns user name (sub) or None if invalid/expired."""
111
+ try:
112
+ payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
113
+ return payload.get("sub")
114
+ except JWTError:
115
+ return None
116
+
117
+
118
+ # ---------------------------------------------------------------------------
119
+ # Cookie helpers
120
+ # ---------------------------------------------------------------------------
121
+
122
+
123
+ def _is_https_request(request: Request) -> bool:
124
+ """True if the request reached us over HTTPS, including via a TLS-
125
+ terminating proxy (Azure Container Apps, Cloudflare, etc.) that sets
126
+ X-Forwarded-Proto."""
127
+ if request.url.scheme == "https":
128
+ return True
129
+ return request.headers.get("x-forwarded-proto", "").lower() == "https"
130
+
131
+
132
+ def set_auth_cookie(request: Request, response: Response, token: str):
133
+ response.set_cookie(
134
+ key=COOKIE_NAME,
135
+ value=token,
136
+ httponly=True,
137
+ # Only mark Secure when the request actually came in over HTTPS,
138
+ # so local http://localhost dev still gets the cookie.
139
+ secure=_is_https_request(request),
140
+ samesite="lax",
141
+ max_age=TOKEN_EXPIRE_DAYS * 24 * 3600,
142
+ path="/",
143
+ )
144
+
145
+
146
+ def clear_auth_cookie(response: Response):
147
+ response.delete_cookie(key=COOKIE_NAME, path="/")
148
+
149
+
150
+ # ---------------------------------------------------------------------------
151
+ # Pydantic models
152
+ # ---------------------------------------------------------------------------
153
+
154
+
155
+ class RegisterRequest(BaseModel):
156
+ email: str
157
+ full_name: str
158
+ password: str
159
+ invite_token: str | None = None
160
+
161
+
162
+ class LoginRequest(BaseModel):
163
+ email: str
164
+ password: str
165
+
166
+
167
+ class InviteRequest(BaseModel):
168
+ email: str
169
+ role: str = "viewer"
170
+
171
+
172
+ class ChangeRoleRequest(BaseModel):
173
+ role: str
174
+
175
+
176
+ # ---------------------------------------------------------------------------
177
+ # Dependencies
178
+ # ---------------------------------------------------------------------------
179
+
180
+ ROLE_HIERARCHY = {"admin": 3, "manager": 2, "public_manager": 2, "viewer": 1}
181
+ ASSIGNABLE_ROLES = {"admin", "manager", "viewer"}
182
+
183
+
184
+ def get_current_user(request: Request) -> dict:
185
+ """FastAPI dependency: extract and validate JWT from cookie.
186
+ Falls back to public_manager if one exists and no cookie is set."""
187
+ token = request.cookies.get(COOKIE_NAME)
188
+
189
+ if token:
190
+ user_name = decode_token(token)
191
+ if user_name:
192
+ db = get_db()
193
+ user = db.get_value("User", user_name, ["name", "email", "full_name", "role", "enabled"])
194
+ if user and user.get("enabled"):
195
+ return dict(user)
196
+
197
+ # Fall back to public manager (demo mode)
198
+ db = get_db()
199
+ pub = db.sql('SELECT name, email, full_name, role, enabled FROM "User" WHERE role = "public_manager" AND enabled = 1')
200
+ if pub:
201
+ return dict(pub[0])
202
+
203
+ raise HTTPException(status_code=401, detail="Not authenticated")
204
+
205
+
206
+ def validate_assignable_role(role: str) -> str:
207
+ if role not in ASSIGNABLE_ROLES:
208
+ raise HTTPException(status_code=422, detail=f"Invalid role: {role}")
209
+ return role
210
+
211
+
212
+ def require_role(minimum_role: str):
213
+ """Returns a dependency that checks the user has at least the specified role."""
214
+ min_level = ROLE_HIERARCHY[minimum_role]
215
+
216
+ def checker(user: dict = Depends(get_current_user)) -> dict:
217
+ user_level = ROLE_HIERARCHY.get(user["role"], 0)
218
+ if user_level < min_level:
219
+ raise HTTPException(status_code=403, detail=f"Requires {minimum_role} role or higher")
220
+ return user
221
+
222
+ return checker
223
+
224
+
225
+ # Pre-built for convenience
226
+ require_admin = require_role("admin")
227
+ require_manager = require_role("manager")
228
+ require_viewer = require_role("viewer")
229
+
230
+
231
+ def require_non_public_manager(user: dict = Depends(get_current_user)) -> dict:
232
+ user_level = ROLE_HIERARCHY.get(user["role"], 0)
233
+ if user_level < ROLE_HIERARCHY["manager"]:
234
+ raise HTTPException(status_code=403, detail="Requires manager role or higher")
235
+ if user["role"] == "public_manager":
236
+ raise HTTPException(status_code=403, detail="Demo mode cannot modify master data")
237
+ return user
238
+
239
+
240
+ # ---------------------------------------------------------------------------
241
+ # Endpoints
242
+ # ---------------------------------------------------------------------------
243
+
244
+
245
+ @router.get("/setup-status")
246
+ def auth_setup_status():
247
+ """Public: check if any users exist (for first-run detection)."""
248
+ db = get_db()
249
+ count = db.sql('SELECT COUNT(*) as cnt FROM "User"')[0]["cnt"]
250
+ return {"has_users": count > 0, "registration_open": count == 0}
251
+
252
+
253
+ @router.post("/register")
254
+ def register(data: RegisterRequest, request: Request, response: Response):
255
+ db = get_db()
256
+
257
+ # Check if email already taken
258
+ existing = db.sql('SELECT name FROM "User" WHERE email = ?', [data.email.lower()])
259
+ if existing:
260
+ raise HTTPException(status_code=409, detail="Email already registered")
261
+
262
+ # Determine role
263
+ user_count = db.sql('SELECT COUNT(*) as cnt FROM "User"')[0]["cnt"]
264
+
265
+ if user_count == 0:
266
+ role = "admin"
267
+ else:
268
+ if not data.invite_token:
269
+ raise HTTPException(status_code=403, detail="Registration requires an invite")
270
+
271
+ rows = db.sql('SELECT token, email, role, used FROM "Invite" WHERE token = ?', [data.invite_token])
272
+ invite = rows[0] if rows else None
273
+ if not invite:
274
+ raise HTTPException(status_code=404, detail="Invalid invite token")
275
+ if invite["used"]:
276
+ raise HTTPException(status_code=410, detail="Invite already used")
277
+ if invite["email"].lower() != data.email.lower():
278
+ raise HTTPException(status_code=403, detail="This invite was issued for a different email")
279
+
280
+ role = validate_assignable_role(invite["role"])
281
+ db.sql('UPDATE "Invite" SET used = 1 WHERE token = ?', [data.invite_token])
282
+ db.conn.commit()
283
+
284
+ user_name = f"USR-{uuid.uuid4().hex[:8]}"
285
+ db.insert("User", {
286
+ "name": user_name,
287
+ "email": data.email.lower(),
288
+ "full_name": data.full_name,
289
+ "hashed_password": hash_password(data.password),
290
+ "role": role,
291
+ "enabled": 1,
292
+ "creation": now(),
293
+ "modified": now(),
294
+ })
295
+
296
+ token = create_access_token(user_name)
297
+ set_auth_cookie(request, response, token)
298
+
299
+ return {"name": user_name, "email": data.email.lower(), "full_name": data.full_name, "role": role}
300
+
301
+
302
+ @router.post("/login")
303
+ def login(data: LoginRequest, request: Request, response: Response):
304
+ db = get_db()
305
+ rows = db.sql(
306
+ 'SELECT name, email, full_name, hashed_password, role, enabled FROM "User" WHERE email = ?',
307
+ [data.email.lower()],
308
+ )
309
+ if not rows:
310
+ raise HTTPException(status_code=401, detail="Invalid email or password")
311
+
312
+ user = rows[0]
313
+ if not verify_password(data.password, user["hashed_password"]):
314
+ raise HTTPException(status_code=401, detail="Invalid email or password")
315
+ if not user.get("enabled", 1):
316
+ raise HTTPException(status_code=403, detail="Account disabled")
317
+ if user["role"] == "public_manager":
318
+ raise HTTPException(status_code=403, detail="Demo mode account cannot sign in directly")
319
+
320
+ token = create_access_token(user["name"])
321
+ set_auth_cookie(request, response, token)
322
+
323
+ return {"name": user["name"], "email": user["email"], "full_name": user["full_name"], "role": user["role"]}
324
+
325
+
326
+ @router.post("/logout")
327
+ def logout(response: Response):
328
+ clear_auth_cookie(response)
329
+ return {"ok": True}
330
+
331
+
332
+ @router.get("/me")
333
+ def me(user: dict = Depends(get_current_user)):
334
+ return {"name": user["name"], "email": user["email"], "full_name": user["full_name"], "role": user["role"]}
335
+
336
+
337
+ @router.post("/invite")
338
+ def create_invite(data: InviteRequest, user: dict = Depends(require_admin)):
339
+ db = get_db()
340
+
341
+ validate_assignable_role(data.role)
342
+
343
+ existing = db.sql('SELECT name FROM "User" WHERE email = ?', [data.email.lower()])
344
+ if existing:
345
+ raise HTTPException(status_code=409, detail="User with this email already exists")
346
+
347
+ token = secrets.token_hex(16)
348
+ db.insert("Invite", {
349
+ "token": token,
350
+ "email": data.email.lower(),
351
+ "role": data.role,
352
+ "created_by": user["name"],
353
+ "used": 0,
354
+ "creation": now(),
355
+ })
356
+
357
+ return {"token": token, "email": data.email.lower(), "role": data.role, "link": f"/login?invite={token}"}
358
+
359
+
360
+ @router.get("/users")
361
+ def list_users(user: dict = Depends(require_admin)):
362
+ db = get_db()
363
+ rows = db.sql('SELECT name, email, full_name, role, enabled, creation FROM "User" ORDER BY creation')
364
+ return [dict(r) for r in rows]
365
+
366
+
367
+ @router.put("/users/{user_name}/role")
368
+ def change_role(user_name: str, data: ChangeRoleRequest, user: dict = Depends(require_admin)):
369
+ validate_assignable_role(data.role)
370
+
371
+ db = get_db()
372
+ if not db.exists("User", user_name):
373
+ raise HTTPException(status_code=404, detail="User not found")
374
+
375
+ if user_name == user["name"] and data.role != "admin":
376
+ admin_count = db.sql('SELECT COUNT(*) as cnt FROM "User" WHERE role = "admin" AND enabled = 1')[0]["cnt"]
377
+ if admin_count <= 1:
378
+ raise HTTPException(status_code=409, detail="Cannot demote the only admin")
379
+
380
+ db.set_value("User", user_name, {"role": data.role, "modified": now()})
381
+ return {"ok": True}
382
+
383
+
384
+ @router.delete("/users/{user_name}")
385
+ def disable_user(user_name: str, user: dict = Depends(require_admin)):
386
+ if user_name == user["name"]:
387
+ raise HTTPException(status_code=409, detail="Cannot disable yourself")
388
+
389
+ db = get_db()
390
+ if not db.exists("User", user_name):
391
+ raise HTTPException(status_code=404, detail="User not found")
392
+
393
+ db.set_value("User", user_name, {"enabled": 0, "modified": now()})
394
+ return {"ok": True}
395
+
396
+
397
+ @router.get("/invites")
398
+ def list_invites(user: dict = Depends(require_admin)):
399
+ db = get_db()
400
+ rows = db.sql('SELECT token, email, role, used, creation FROM "Invite" ORDER BY creation DESC')
401
+ return [dict(r) for r in rows]
402
+
403
+
404
+ @router.post("/public-manager")
405
+ def create_public_manager(user: dict = Depends(require_admin)):
406
+ """Create (or re-enable) the public manager account for demo mode.
407
+
408
+ Also seeds the chat-replay artefacts the "Enter Live Demo" script
409
+ narrates (quotation, purchase order, custom analytics draft, the
410
+ Redstone sales invoice, top-customer snapshots). Without this the
411
+ admin-UI toggle would produce a public_manager account but a blank
412
+ replay session, while booting with LAMBDA_ERP_ENABLE_PUBLIC_DEMO=1
413
+ would produce both — same end state no matter how demo mode gets
414
+ turned on.
415
+ """
416
+ db = get_db()
417
+ existing = db.sql('SELECT name, enabled FROM "User" WHERE role = "public_manager"')
418
+ if existing:
419
+ if not existing[0]["enabled"]:
420
+ db.set_value("User", existing[0]["name"], {"enabled": 1, "modified": now()})
421
+ user_name = existing[0]["name"]
422
+ status = "enabled"
423
+ else:
424
+ user_name = f"USR-{uuid.uuid4().hex[:8]}"
425
+ db.insert("User", {
426
+ "name": user_name,
427
+ "email": "demo@lambda-erp.local",
428
+ "full_name": "Demo User",
429
+ "hashed_password": hash_password(secrets.token_hex(32)),
430
+ "role": "public_manager",
431
+ "enabled": 1,
432
+ "creation": now(),
433
+ "modified": now(),
434
+ })
435
+ status = "created"
436
+
437
+ # Seed replay records idempotently. The helper is safe to call on
438
+ # every invocation — it checks existing settings before inserting.
439
+ # Wrapped so a seed edge case (e.g. simulator hasn't run yet in an
440
+ # unusual deploy) never blocks the public_manager toggle.
441
+ try:
442
+ companies = db.get_all("Company", fields=["name"])
443
+ if companies:
444
+ from api.bootstrap import ensure_demo_chat_records
445
+ ensure_demo_chat_records(companies[0]["name"])
446
+ except Exception:
447
+ pass
448
+
449
+ return {"ok": True, "name": user_name, "status": status}
450
+
451
+
452
+ @router.delete("/public-manager")
453
+ def remove_public_manager(user: dict = Depends(require_admin)):
454
+ """Disable the public manager account."""
455
+ db = get_db()
456
+ existing = db.sql('SELECT name FROM "User" WHERE role = "public_manager"')
457
+ if not existing:
458
+ return {"ok": True, "status": "not_found"}
459
+ db.set_value("User", existing[0]["name"], {"enabled": 0, "modified": now()})
460
+ return {"ok": True, "status": "disabled"}
461
+
462
+
463
+ @router.get("/public-manager")
464
+ def get_public_manager_status():
465
+ """Public: check if a public manager exists and is enabled."""
466
+ db = get_db()
467
+ existing = db.sql('SELECT name, full_name, role, enabled FROM "User" WHERE role = "public_manager" AND enabled = 1')
468
+ if existing:
469
+ return {"active": True, "user": dict(existing[0])}
470
+ return {"active": False}
471
+
472
+
473
+ # ---------------------------------------------------------------------------
474
+ # Settings
475
+ # ---------------------------------------------------------------------------
476
+
477
+ DEFAULTS = {
478
+ "pdf_page_size": "A4",
479
+ "opening_balances_enabled": "1",
480
+ }
481
+
482
+
483
+ @router.get("/settings")
484
+ def get_settings(user: dict = Depends(get_current_user)):
485
+ db = get_db()
486
+ rows = db.sql('SELECT key, value FROM "Settings"')
487
+ settings = {r["key"]: r["value"] for r in rows}
488
+ # Merge defaults for missing keys
489
+ for k, v in DEFAULTS.items():
490
+ if k not in settings:
491
+ settings[k] = v
492
+ return settings
493
+
494
+
495
+ @router.put("/settings")
496
+ def update_settings(data: dict, user: dict = Depends(require_admin)):
497
+ db = get_db()
498
+ for key, value in data.items():
499
+ existing = db.sql('SELECT key FROM "Settings" WHERE key = ?', [key])
500
+ if existing:
501
+ db.sql('UPDATE "Settings" SET value = ? WHERE key = ?', [str(value), key])
502
+ else:
503
+ db.sql('INSERT INTO "Settings" (key, value) VALUES (?, ?)', [key, str(value)])
504
+ db.conn.commit()
505
+ # Return updated settings
506
+ rows = db.sql('SELECT key, value FROM "Settings"')
507
+ settings = {r["key"]: r["value"] for r in rows}
508
+ for k, v in DEFAULTS.items():
509
+ if k not in settings:
510
+ settings[k] = v
511
+ return settings