lambda-erp 0.1.12__tar.gz → 0.1.14__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 (65) hide show
  1. {lambda_erp-0.1.12 → lambda_erp-0.1.14}/PKG-INFO +1 -1
  2. {lambda_erp-0.1.12 → lambda_erp-0.1.14}/api/auth.py +47 -6
  3. {lambda_erp-0.1.12 → lambda_erp-0.1.14}/api/pdf.py +30 -2
  4. {lambda_erp-0.1.12 → lambda_erp-0.1.14}/api/routers/admin.py +1 -1
  5. {lambda_erp-0.1.12 → lambda_erp-0.1.14}/frontend/src/api/client.ts +9 -1
  6. {lambda_erp-0.1.12 → lambda_erp-0.1.14}/lambda_erp/database.py +7 -0
  7. {lambda_erp-0.1.12 → lambda_erp-0.1.14}/pyproject.toml +1 -1
  8. {lambda_erp-0.1.12 → lambda_erp-0.1.14}/.gitignore +0 -0
  9. {lambda_erp-0.1.12 → lambda_erp-0.1.14}/LICENSE +0 -0
  10. {lambda_erp-0.1.12 → lambda_erp-0.1.14}/README.md +0 -0
  11. {lambda_erp-0.1.12 → lambda_erp-0.1.14}/api/__init__.py +0 -0
  12. {lambda_erp-0.1.12 → lambda_erp-0.1.14}/api/attachments.py +0 -0
  13. {lambda_erp-0.1.12 → lambda_erp-0.1.14}/api/bootstrap.py +0 -0
  14. {lambda_erp-0.1.12 → lambda_erp-0.1.14}/api/chat.py +0 -0
  15. {lambda_erp-0.1.12 → lambda_erp-0.1.14}/api/demo_limits.py +0 -0
  16. {lambda_erp-0.1.12 → lambda_erp-0.1.14}/api/deps.py +0 -0
  17. {lambda_erp-0.1.12 → lambda_erp-0.1.14}/api/errors.py +0 -0
  18. {lambda_erp-0.1.12 → lambda_erp-0.1.14}/api/main.py +0 -0
  19. {lambda_erp-0.1.12 → lambda_erp-0.1.14}/api/providers.py +0 -0
  20. {lambda_erp-0.1.12 → lambda_erp-0.1.14}/api/routers/__init__.py +0 -0
  21. {lambda_erp-0.1.12 → lambda_erp-0.1.14}/api/routers/accounting.py +0 -0
  22. {lambda_erp-0.1.12 → lambda_erp-0.1.14}/api/routers/analytics.py +0 -0
  23. {lambda_erp-0.1.12 → lambda_erp-0.1.14}/api/routers/bank_reconciliation.py +0 -0
  24. {lambda_erp-0.1.12 → lambda_erp-0.1.14}/api/routers/documents.py +0 -0
  25. {lambda_erp-0.1.12 → lambda_erp-0.1.14}/api/routers/masters.py +0 -0
  26. {lambda_erp-0.1.12 → lambda_erp-0.1.14}/api/routers/reports.py +0 -0
  27. {lambda_erp-0.1.12 → lambda_erp-0.1.14}/api/routers/setup.py +0 -0
  28. {lambda_erp-0.1.12 → lambda_erp-0.1.14}/api/services.py +0 -0
  29. {lambda_erp-0.1.12 → lambda_erp-0.1.14}/api/templates/document.html +0 -0
  30. {lambda_erp-0.1.12 → lambda_erp-0.1.14}/docs/agents/README.md +0 -0
  31. {lambda_erp-0.1.12 → lambda_erp-0.1.14}/frontend/README.md +0 -0
  32. {lambda_erp-0.1.12 → lambda_erp-0.1.14}/lambda_erp/__init__.py +0 -0
  33. {lambda_erp-0.1.12 → lambda_erp-0.1.14}/lambda_erp/accounting/__init__.py +0 -0
  34. {lambda_erp-0.1.12 → lambda_erp-0.1.14}/lambda_erp/accounting/bank_transaction.py +0 -0
  35. {lambda_erp-0.1.12 → lambda_erp-0.1.14}/lambda_erp/accounting/budget.py +0 -0
  36. {lambda_erp-0.1.12 → lambda_erp-0.1.14}/lambda_erp/accounting/chart_of_accounts.py +0 -0
  37. {lambda_erp-0.1.12 → lambda_erp-0.1.14}/lambda_erp/accounting/general_ledger.py +0 -0
  38. {lambda_erp-0.1.12 → lambda_erp-0.1.14}/lambda_erp/accounting/journal_entry.py +0 -0
  39. {lambda_erp-0.1.12 → lambda_erp-0.1.14}/lambda_erp/accounting/payment_entry.py +0 -0
  40. {lambda_erp-0.1.12 → lambda_erp-0.1.14}/lambda_erp/accounting/pos_invoice.py +0 -0
  41. {lambda_erp-0.1.12 → lambda_erp-0.1.14}/lambda_erp/accounting/purchase_invoice.py +0 -0
  42. {lambda_erp-0.1.12 → lambda_erp-0.1.14}/lambda_erp/accounting/revaluation.py +0 -0
  43. {lambda_erp-0.1.12 → lambda_erp-0.1.14}/lambda_erp/accounting/sales_invoice.py +0 -0
  44. {lambda_erp-0.1.12 → lambda_erp-0.1.14}/lambda_erp/accounting/subscription.py +0 -0
  45. {lambda_erp-0.1.12 → lambda_erp-0.1.14}/lambda_erp/buying/__init__.py +0 -0
  46. {lambda_erp-0.1.12 → lambda_erp-0.1.14}/lambda_erp/buying/purchase_order.py +0 -0
  47. {lambda_erp-0.1.12 → lambda_erp-0.1.14}/lambda_erp/controllers/__init__.py +0 -0
  48. {lambda_erp-0.1.12 → lambda_erp-0.1.14}/lambda_erp/controllers/currency.py +0 -0
  49. {lambda_erp-0.1.12 → lambda_erp-0.1.14}/lambda_erp/controllers/defaults.py +0 -0
  50. {lambda_erp-0.1.12 → lambda_erp-0.1.14}/lambda_erp/controllers/pricing_rule.py +0 -0
  51. {lambda_erp-0.1.12 → lambda_erp-0.1.14}/lambda_erp/controllers/taxes_and_totals.py +0 -0
  52. {lambda_erp-0.1.12 → lambda_erp-0.1.14}/lambda_erp/exceptions.py +0 -0
  53. {lambda_erp-0.1.12 → lambda_erp-0.1.14}/lambda_erp/hooks.py +0 -0
  54. {lambda_erp-0.1.12 → lambda_erp-0.1.14}/lambda_erp/model.py +0 -0
  55. {lambda_erp-0.1.12 → lambda_erp-0.1.14}/lambda_erp/selling/__init__.py +0 -0
  56. {lambda_erp-0.1.12 → lambda_erp-0.1.14}/lambda_erp/selling/quotation.py +0 -0
  57. {lambda_erp-0.1.12 → lambda_erp-0.1.14}/lambda_erp/selling/sales_order.py +0 -0
  58. {lambda_erp-0.1.12 → lambda_erp-0.1.14}/lambda_erp/simulation.py +0 -0
  59. {lambda_erp-0.1.12 → lambda_erp-0.1.14}/lambda_erp/stock/__init__.py +0 -0
  60. {lambda_erp-0.1.12 → lambda_erp-0.1.14}/lambda_erp/stock/delivery_note.py +0 -0
  61. {lambda_erp-0.1.12 → lambda_erp-0.1.14}/lambda_erp/stock/purchase_receipt.py +0 -0
  62. {lambda_erp-0.1.12 → lambda_erp-0.1.14}/lambda_erp/stock/stock_entry.py +0 -0
  63. {lambda_erp-0.1.12 → lambda_erp-0.1.14}/lambda_erp/stock/stock_ledger.py +0 -0
  64. {lambda_erp-0.1.12 → lambda_erp-0.1.14}/lambda_erp/utils.py +0 -0
  65. {lambda_erp-0.1.12 → lambda_erp-0.1.14}/terraform/README.md +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lambda-erp
3
- Version: 0.1.12
3
+ Version: 0.1.14
4
4
  Summary: Core ERP logic - accounting, sales, purchasing, inventory
5
5
  Author: TORUS INVESTMENTS AG
6
6
  License-Expression: MIT
@@ -244,10 +244,23 @@ def require_non_public_manager(user: dict = Depends(get_current_user)) -> dict:
244
244
 
245
245
  @router.get("/setup-status")
246
246
  def auth_setup_status():
247
- """Public: check if any users exist (for first-run detection)."""
247
+ """Public: first-run detection + whether registration is open.
248
+
249
+ - first_run: no users yet — the next registrant becomes the admin.
250
+ - public_signup: the admin has enabled open self-registration (as viewer).
251
+ - registration_open: registration without an invite is possible at all
252
+ (first_run OR public_signup) — the signup form keys off this.
253
+ """
248
254
  db = get_db()
249
255
  count = db.sql('SELECT COUNT(*) as cnt FROM "User"')[0]["cnt"]
250
- return {"has_users": count > 0, "registration_open": count == 0}
256
+ first_run = count == 0
257
+ public_signup = _setting_enabled(db, "allow_public_signup")
258
+ return {
259
+ "has_users": count > 0,
260
+ "first_run": first_run,
261
+ "public_signup": public_signup,
262
+ "registration_open": first_run or public_signup,
263
+ }
251
264
 
252
265
 
253
266
  @router.post("/register")
@@ -263,11 +276,10 @@ def register(data: RegisterRequest, request: Request, response: Response):
263
276
  user_count = db.sql('SELECT COUNT(*) as cnt FROM "User"')[0]["cnt"]
264
277
 
265
278
  if user_count == 0:
279
+ # First user bootstraps the instance as admin.
266
280
  role = "admin"
267
- else:
268
- if not data.invite_token:
269
- raise HTTPException(status_code=403, detail="Registration requires an invite")
270
-
281
+ elif data.invite_token:
282
+ # Invite-only registration: the invite is bound to an email and a role.
271
283
  rows = db.sql('SELECT token, email, role, used FROM "Invite" WHERE token = ?', [data.invite_token])
272
284
  invite = rows[0] if rows else None
273
285
  if not invite:
@@ -280,6 +292,11 @@ def register(data: RegisterRequest, request: Request, response: Response):
280
292
  role = validate_assignable_role(invite["role"])
281
293
  db.sql('UPDATE "Invite" SET used = 1 WHERE token = ?', [data.invite_token])
282
294
  db.conn.commit()
295
+ elif _setting_enabled(db, "allow_public_signup"):
296
+ # Open signup is enabled: anyone may self-register, but only as a viewer.
297
+ role = "viewer"
298
+ else:
299
+ raise HTTPException(status_code=403, detail="Registration requires an invite")
283
300
 
284
301
  user_name = f"USR-{uuid.uuid4().hex[:8]}"
285
302
  db.insert("User", {
@@ -401,6 +418,20 @@ def list_invites(user: dict = Depends(require_admin)):
401
418
  return [dict(r) for r in rows]
402
419
 
403
420
 
421
+ @router.delete("/invites/{token}")
422
+ def revoke_invite(token: str, user: dict = Depends(require_admin)):
423
+ """Revoke a pending (unused) invite so its link no longer works."""
424
+ db = get_db()
425
+ rows = db.sql('SELECT token, used FROM "Invite" WHERE token = ?', [token])
426
+ if not rows:
427
+ raise HTTPException(status_code=404, detail="Invite not found")
428
+ if rows[0]["used"]:
429
+ raise HTTPException(status_code=409, detail="Invite already used")
430
+ db.sql('DELETE FROM "Invite" WHERE token = ?', [token])
431
+ db.conn.commit()
432
+ return {"ok": True}
433
+
434
+
404
435
  @router.post("/public-manager")
405
436
  def create_public_manager(user: dict = Depends(require_admin)):
406
437
  """Create (or re-enable) the public manager account for demo mode.
@@ -477,9 +508,19 @@ def get_public_manager_status():
477
508
  DEFAULTS = {
478
509
  "pdf_page_size": "A4",
479
510
  "opening_balances_enabled": "1",
511
+ # When "1", anyone may self-register (as viewer) without an invite. Default
512
+ # off: after the first user (admin), registration is invite-only.
513
+ "allow_public_signup": "0",
480
514
  }
481
515
 
482
516
 
517
+ def _setting_enabled(db, key: str) -> bool:
518
+ """Read a boolean Settings flag, honoring DEFAULTS for unset keys."""
519
+ rows = db.sql('SELECT value FROM "Settings" WHERE key = ?', [key])
520
+ value = rows[0]["value"] if rows else DEFAULTS.get(key)
521
+ return str(value) == "1"
522
+
523
+
483
524
  @router.get("/settings")
484
525
  def get_settings(user: dict = Depends(get_current_user)):
485
526
  db = get_db()
@@ -37,6 +37,22 @@ def register_pdf_template_dir(path: str) -> None:
37
37
  _plugin_template_dirs.insert(0, path)
38
38
  _jinja_env.loader = _build_loader()
39
39
 
40
+
41
+ _pdf_context_providers = []
42
+
43
+
44
+ def register_pdf_context(fn) -> None:
45
+ """Register a provider that augments the PDF render context.
46
+
47
+ `fn(doctype, name, context)` is called just before rendering with the
48
+ assembled context (doc, company_info, party_info, currency, items, …) and
49
+ may return a dict of EXTRA keys to merge in — e.g. a computed Swiss QR-bill
50
+ image for invoices. Lets a deployment add per-document content the template
51
+ can't compute itself. Exceptions are swallowed so a buggy provider can't
52
+ break PDF generation.
53
+ """
54
+ _pdf_context_providers.append(fn)
55
+
40
56
  # Document type → (title, party_field, party_name_field, party_doctype, party_label)
41
57
  DOC_CONFIG = {
42
58
  "Quotation": ("Quotation", "customer", "customer_name", "Customer", "Customer"),
@@ -114,7 +130,7 @@ def generate_pdf(doctype_slug: str, name: str) -> bytes:
114
130
  company_info = {}
115
131
  if company_id:
116
132
  row = db.get_value("Company", company_id,
117
- ["company_name", "email", "phone", "address", "city", "zip_code", "country", "tax_id"])
133
+ ["company_name", "email", "phone", "address", "city", "zip_code", "country", "tax_id", "iban"])
118
134
  if row:
119
135
  company_info = _get_dict(row)
120
136
  # Show the company's CURRENT display name from the master.
@@ -164,7 +180,7 @@ def generate_pdf(doctype_slug: str, name: str) -> bytes:
164
180
  # plugin's branded override) can reference sibling assets — logo.png, fonts,
165
181
  # CSS — by relative path. Falls back to the built-in templates dir.
166
182
  base_url = template.filename or os.path.join(TEMPLATE_DIR, "document.html")
167
- html_str = template.render(
183
+ context = dict(
168
184
  doc=doc,
169
185
  title=title,
170
186
  company_name=company_name,
@@ -180,4 +196,16 @@ def generate_pdf(doctype_slug: str, name: str) -> bytes:
180
196
  page_size=page_size,
181
197
  )
182
198
 
199
+ # Let deployment plugins augment the context (e.g. a Swiss QR-bill image for
200
+ # invoices). A provider that raises must not break PDF generation.
201
+ for provider in _pdf_context_providers:
202
+ try:
203
+ extra = provider(doctype, name, context)
204
+ if extra:
205
+ context.update(extra)
206
+ except Exception:
207
+ pass
208
+
209
+ html_str = template.render(**context)
210
+
183
211
  return HTML(string=html_str, base_url=base_url).write_pdf()
@@ -36,7 +36,7 @@ def _window_totals(now: float, seconds: int) -> dict:
36
36
  totals = db.conn.execute(
37
37
  'SELECT '
38
38
  ' COALESCE(SUM(cost_usd), 0) AS total_usd, '
39
- ' COALESCE(SUM(CASE WHEN role = "public_manager" THEN cost_usd ELSE 0 END), 0) AS demo_usd, '
39
+ ' COALESCE(SUM(CASE WHEN role = \'public_manager\' THEN cost_usd ELSE 0 END), 0) AS demo_usd, '
40
40
  ' COALESCE(SUM(prompt_tokens), 0) AS prompt_tokens, '
41
41
  ' COALESCE(SUM(completion_tokens), 0) AS completion_tokens, '
42
42
  ' COUNT(*) AS call_count, '
@@ -353,7 +353,13 @@ export const api = {
353
353
  request<any>("/setup/opening-balances/invoices", { method: "POST", body: JSON.stringify(data) }),
354
354
 
355
355
  // Auth
356
- authSetupStatus: () => request<{ has_users: boolean; registration_open: boolean }>("/auth/setup-status"),
356
+ authSetupStatus: () =>
357
+ request<{
358
+ has_users: boolean;
359
+ first_run: boolean;
360
+ public_signup: boolean;
361
+ registration_open: boolean;
362
+ }>("/auth/setup-status"),
357
363
  authRegister: (data: { email: string; full_name: string; password: string; invite_token?: string }) =>
358
364
  request<any>("/auth/register", { method: "POST", body: JSON.stringify(data) }),
359
365
  authLogin: (email: string, password: string) =>
@@ -368,6 +374,8 @@ export const api = {
368
374
  authDisableUser: (userName: string) =>
369
375
  request<any>(`/auth/users/${encodeURIComponent(userName)}`, { method: "DELETE" }),
370
376
  authListInvites: () => request<any[]>("/auth/invites"),
377
+ authRevokeInvite: (token: string) =>
378
+ request<any>(`/auth/invites/${encodeURIComponent(token)}`, { method: "DELETE" }),
371
379
  getPublicManagerStatus: () => request<{ active: boolean; user?: any }>("/auth/public-manager"),
372
380
  createPublicManager: () => request<any>("/auth/public-manager", { method: "POST" }),
373
381
  removePublicManager: () => request<any>("/auth/public-manager", { method: "DELETE" }),
@@ -270,6 +270,7 @@ class Database:
270
270
  zip_code TEXT,
271
271
  country TEXT,
272
272
  tax_id TEXT,
273
+ iban TEXT,
273
274
  default_cost_center TEXT,
274
275
  round_off_account TEXT,
275
276
  round_off_cost_center TEXT,
@@ -1707,6 +1708,11 @@ def _m013_unrealized_exchange_account(db: "Database") -> None:
1707
1708
  )
1708
1709
 
1709
1710
 
1711
+ def _m014_company_iban(db: "Database") -> None:
1712
+ """Add Company.iban (bank account for payment instructions / QR-bills)."""
1713
+ db._add_column_if_missing("Company", "iban", "TEXT")
1714
+
1715
+
1710
1716
  Database.MIGRATIONS = [
1711
1717
  (1, "chat_message_session_id", _m001_chat_message_session_id),
1712
1718
  (2, "chat_session_user_id", _m002_chat_session_user_id),
@@ -1721,6 +1727,7 @@ Database.MIGRATIONS = [
1721
1727
  (11, "payment_entry_currency", _m011_payment_entry_currency),
1722
1728
  (12, "exchange_gain_loss_account", _m012_exchange_gain_loss_account),
1723
1729
  (13, "unrealized_exchange_account", _m013_unrealized_exchange_account),
1730
+ (14, "company_iban", _m014_company_iban),
1724
1731
  ]
1725
1732
 
1726
1733
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "lambda-erp"
3
- version = "0.1.12"
3
+ version = "0.1.14"
4
4
  description = "Core ERP logic - accounting, sales, purchasing, inventory"
5
5
  readme = "README.md"
6
6
  license = "MIT"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes