lambda-erp 0.8.28__tar.gz → 0.8.30__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 (96) hide show
  1. {lambda_erp-0.8.28 → lambda_erp-0.8.30}/PKG-INFO +10 -10
  2. {lambda_erp-0.8.28 → lambda_erp-0.8.30}/README.md +9 -9
  3. {lambda_erp-0.8.28 → lambda_erp-0.8.30}/api/attachments.py +14 -4
  4. {lambda_erp-0.8.28 → lambda_erp-0.8.30}/api/auth.py +143 -33
  5. {lambda_erp-0.8.28 → lambda_erp-0.8.30}/api/bootstrap.py +46 -42
  6. {lambda_erp-0.8.28 → lambda_erp-0.8.30}/api/chat.py +128 -210
  7. {lambda_erp-0.8.28 → lambda_erp-0.8.30}/api/main.py +4 -1
  8. {lambda_erp-0.8.28 → lambda_erp-0.8.30}/api/oauth.py +121 -23
  9. {lambda_erp-0.8.28 → lambda_erp-0.8.30}/api/routers/analytics.py +220 -44
  10. {lambda_erp-0.8.28 → lambda_erp-0.8.30}/api/routers/mcp.py +5 -28
  11. {lambda_erp-0.8.28 → lambda_erp-0.8.30}/api/routers/setup.py +16 -8
  12. lambda_erp-0.8.30/api/tool_permissions.py +52 -0
  13. {lambda_erp-0.8.28 → lambda_erp-0.8.30}/frontend/src/api/client.ts +2 -2
  14. {lambda_erp-0.8.28 → lambda_erp-0.8.30}/lambda_erp/database.py +16 -0
  15. {lambda_erp-0.8.28 → lambda_erp-0.8.30}/pyproject.toml +1 -1
  16. {lambda_erp-0.8.28 → lambda_erp-0.8.30}/.gitignore +0 -0
  17. {lambda_erp-0.8.28 → lambda_erp-0.8.30}/LICENSE +0 -0
  18. {lambda_erp-0.8.28 → lambda_erp-0.8.30}/api/__init__.py +0 -0
  19. {lambda_erp-0.8.28 → lambda_erp-0.8.30}/api/demo_limits.py +0 -0
  20. {lambda_erp-0.8.28 → lambda_erp-0.8.30}/api/deps.py +0 -0
  21. {lambda_erp-0.8.28 → lambda_erp-0.8.30}/api/errors.py +0 -0
  22. {lambda_erp-0.8.28 → lambda_erp-0.8.30}/api/list_values.py +0 -0
  23. {lambda_erp-0.8.28 → lambda_erp-0.8.30}/api/pdf.py +0 -0
  24. {lambda_erp-0.8.28 → lambda_erp-0.8.30}/api/providers.py +0 -0
  25. {lambda_erp-0.8.28 → lambda_erp-0.8.30}/api/remarks_md.py +0 -0
  26. {lambda_erp-0.8.28 → lambda_erp-0.8.30}/api/routers/__init__.py +0 -0
  27. {lambda_erp-0.8.28 → lambda_erp-0.8.30}/api/routers/accounting.py +0 -0
  28. {lambda_erp-0.8.28 → lambda_erp-0.8.30}/api/routers/actions.py +0 -0
  29. {lambda_erp-0.8.28 → lambda_erp-0.8.30}/api/routers/admin.py +0 -0
  30. {lambda_erp-0.8.28 → lambda_erp-0.8.30}/api/routers/availability.py +0 -0
  31. {lambda_erp-0.8.28 → lambda_erp-0.8.30}/api/routers/bank_reconciliation.py +0 -0
  32. {lambda_erp-0.8.28 → lambda_erp-0.8.30}/api/routers/bank_statements.py +0 -0
  33. {lambda_erp-0.8.28 → lambda_erp-0.8.30}/api/routers/chat_api.py +0 -0
  34. {lambda_erp-0.8.28 → lambda_erp-0.8.30}/api/routers/documents.py +0 -0
  35. {lambda_erp-0.8.28 → lambda_erp-0.8.30}/api/routers/masters.py +0 -0
  36. {lambda_erp-0.8.28 → lambda_erp-0.8.30}/api/routers/proposals.py +0 -0
  37. {lambda_erp-0.8.28 → lambda_erp-0.8.30}/api/routers/reports.py +0 -0
  38. {lambda_erp-0.8.28 → lambda_erp-0.8.30}/api/services.py +0 -0
  39. {lambda_erp-0.8.28 → lambda_erp-0.8.30}/api/templates/document.html +0 -0
  40. {lambda_erp-0.8.28 → lambda_erp-0.8.30}/api/templates/proposal.html +0 -0
  41. {lambda_erp-0.8.28 → lambda_erp-0.8.30}/docs/agents/README.md +0 -0
  42. {lambda_erp-0.8.28 → lambda_erp-0.8.30}/frontend/LICENSE +0 -0
  43. {lambda_erp-0.8.28 → lambda_erp-0.8.30}/frontend/README.md +0 -0
  44. {lambda_erp-0.8.28 → lambda_erp-0.8.30}/lambda_erp/__init__.py +0 -0
  45. {lambda_erp-0.8.28 → lambda_erp-0.8.30}/lambda_erp/accounting/__init__.py +0 -0
  46. {lambda_erp-0.8.28 → lambda_erp-0.8.30}/lambda_erp/accounting/bank_account.py +0 -0
  47. {lambda_erp-0.8.28 → lambda_erp-0.8.30}/lambda_erp/accounting/bank_reconciliation.py +0 -0
  48. {lambda_erp-0.8.28 → lambda_erp-0.8.30}/lambda_erp/accounting/bank_statement_import.py +0 -0
  49. {lambda_erp-0.8.28 → lambda_erp-0.8.30}/lambda_erp/accounting/bank_transaction.py +0 -0
  50. {lambda_erp-0.8.28 → lambda_erp-0.8.30}/lambda_erp/accounting/budget.py +0 -0
  51. {lambda_erp-0.8.28 → lambda_erp-0.8.30}/lambda_erp/accounting/camt.py +0 -0
  52. {lambda_erp-0.8.28 → lambda_erp-0.8.30}/lambda_erp/accounting/chart_of_accounts.py +0 -0
  53. {lambda_erp-0.8.28 → lambda_erp-0.8.30}/lambda_erp/accounting/general_ledger.py +0 -0
  54. {lambda_erp-0.8.28 → lambda_erp-0.8.30}/lambda_erp/accounting/journal_entry.py +0 -0
  55. {lambda_erp-0.8.28 → lambda_erp-0.8.30}/lambda_erp/accounting/payment_entry.py +0 -0
  56. {lambda_erp-0.8.28 → lambda_erp-0.8.30}/lambda_erp/accounting/pos_invoice.py +0 -0
  57. {lambda_erp-0.8.28 → lambda_erp-0.8.30}/lambda_erp/accounting/purchase_invoice.py +0 -0
  58. {lambda_erp-0.8.28 → lambda_erp-0.8.30}/lambda_erp/accounting/revaluation.py +0 -0
  59. {lambda_erp-0.8.28 → lambda_erp-0.8.30}/lambda_erp/accounting/sales_invoice.py +0 -0
  60. {lambda_erp-0.8.28 → lambda_erp-0.8.30}/lambda_erp/accounting/setup/__init__.py +0 -0
  61. {lambda_erp-0.8.28 → lambda_erp-0.8.30}/lambda_erp/accounting/setup/engine.py +0 -0
  62. {lambda_erp-0.8.28 → lambda_erp-0.8.30}/lambda_erp/accounting/setup/pack.py +0 -0
  63. {lambda_erp-0.8.28 → lambda_erp-0.8.30}/lambda_erp/accounting/setup/packs/__init__.py +0 -0
  64. {lambda_erp-0.8.28 → lambda_erp-0.8.30}/lambda_erp/accounting/setup/packs/ch.py +0 -0
  65. {lambda_erp-0.8.28 → lambda_erp-0.8.30}/lambda_erp/accounting/setup/packs/de_common.py +0 -0
  66. {lambda_erp-0.8.28 → lambda_erp-0.8.30}/lambda_erp/accounting/setup/packs/de_skr03.py +0 -0
  67. {lambda_erp-0.8.28 → lambda_erp-0.8.30}/lambda_erp/accounting/setup/packs/de_skr04.py +0 -0
  68. {lambda_erp-0.8.28 → lambda_erp-0.8.30}/lambda_erp/accounting/setup/packs/generic.py +0 -0
  69. {lambda_erp-0.8.28 → lambda_erp-0.8.30}/lambda_erp/accounting/setup/profiles.py +0 -0
  70. {lambda_erp-0.8.28 → lambda_erp-0.8.30}/lambda_erp/accounting/setup/spine.py +0 -0
  71. {lambda_erp-0.8.28 → lambda_erp-0.8.30}/lambda_erp/accounting/subscription.py +0 -0
  72. {lambda_erp-0.8.28 → lambda_erp-0.8.30}/lambda_erp/assets/__init__.py +0 -0
  73. {lambda_erp-0.8.28 → lambda_erp-0.8.30}/lambda_erp/assets/asset.py +0 -0
  74. {lambda_erp-0.8.28 → lambda_erp-0.8.30}/lambda_erp/assets/reservation.py +0 -0
  75. {lambda_erp-0.8.28 → lambda_erp-0.8.30}/lambda_erp/buying/__init__.py +0 -0
  76. {lambda_erp-0.8.28 → lambda_erp-0.8.30}/lambda_erp/buying/purchase_order.py +0 -0
  77. {lambda_erp-0.8.28 → lambda_erp-0.8.30}/lambda_erp/controllers/__init__.py +0 -0
  78. {lambda_erp-0.8.28 → lambda_erp-0.8.30}/lambda_erp/controllers/currency.py +0 -0
  79. {lambda_erp-0.8.28 → lambda_erp-0.8.30}/lambda_erp/controllers/defaults.py +0 -0
  80. {lambda_erp-0.8.28 → lambda_erp-0.8.30}/lambda_erp/controllers/pricing_rule.py +0 -0
  81. {lambda_erp-0.8.28 → lambda_erp-0.8.30}/lambda_erp/controllers/taxes_and_totals.py +0 -0
  82. {lambda_erp-0.8.28 → lambda_erp-0.8.30}/lambda_erp/exceptions.py +0 -0
  83. {lambda_erp-0.8.28 → lambda_erp-0.8.30}/lambda_erp/hooks.py +0 -0
  84. {lambda_erp-0.8.28 → lambda_erp-0.8.30}/lambda_erp/model.py +0 -0
  85. {lambda_erp-0.8.28 → lambda_erp-0.8.30}/lambda_erp/selling/__init__.py +0 -0
  86. {lambda_erp-0.8.28 → lambda_erp-0.8.30}/lambda_erp/selling/proposal.py +0 -0
  87. {lambda_erp-0.8.28 → lambda_erp-0.8.30}/lambda_erp/selling/quotation.py +0 -0
  88. {lambda_erp-0.8.28 → lambda_erp-0.8.30}/lambda_erp/selling/sales_order.py +0 -0
  89. {lambda_erp-0.8.28 → lambda_erp-0.8.30}/lambda_erp/simulation.py +0 -0
  90. {lambda_erp-0.8.28 → lambda_erp-0.8.30}/lambda_erp/stock/__init__.py +0 -0
  91. {lambda_erp-0.8.28 → lambda_erp-0.8.30}/lambda_erp/stock/delivery_note.py +0 -0
  92. {lambda_erp-0.8.28 → lambda_erp-0.8.30}/lambda_erp/stock/purchase_receipt.py +0 -0
  93. {lambda_erp-0.8.28 → lambda_erp-0.8.30}/lambda_erp/stock/stock_entry.py +0 -0
  94. {lambda_erp-0.8.28 → lambda_erp-0.8.30}/lambda_erp/stock/stock_ledger.py +0 -0
  95. {lambda_erp-0.8.28 → lambda_erp-0.8.30}/lambda_erp/utils.py +0 -0
  96. {lambda_erp-0.8.28 → lambda_erp-0.8.30}/terraform/README.md +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: lambda-erp
3
- Version: 0.8.28
3
+ Version: 0.8.30
4
4
  Summary: Core ERP logic - accounting, sales, purchasing, inventory
5
5
  Author: TORUS INVESTMENTS AG
6
6
  License-Expression: Apache-2.0
@@ -87,8 +87,8 @@ Each of those is hours of skilled work today. With a capable LLM in the loop, th
87
87
  │ - Document forms │ │ - Document CRUD API │
88
88
  │ - Reports / Analytics │ │ - Report endpoints │
89
89
  │ - Chat (WebSocket) │ │ - Auth (JWT cookie) │
90
- │ - Client-side JS runtime │ │ - WebSocket chat gateway │
91
- │ (Web Worker) for charts │ └────────────┬───────────────┘
90
+ │ - Declarative report │ │ - WebSocket chat gateway │
91
+ │ interpreter for charts │ └────────────┬───────────────┘
92
92
  └────────────────────────────┘ │
93
93
  ▼
94
94
  ┌──────────────────────────────────────────┐
@@ -96,8 +96,8 @@ Each of those is hours of skilled work today. With a capable LLM in the loop, th
96
96
  │ - GPT-5.6 drives the reasoning loop │
97
97
  │ - Tool-use: document CRUD, search, │
98
98
  │ reports, aggregations, analytics │
99
- │ - Delegates JS generation to Anthropic │
100
- │ code-specialist sub-agent │
99
+ │ - Delegates report specs to Anthropic │
100
+ │ report-specialist model │
101
101
  └──────────────────┬───────────────────────┘
102
102
  │
103
103
  ▼
@@ -117,9 +117,9 @@ Each of those is hours of skilled work today. With a capable LLM in the loop, th
117
117
  - **Extensions are first-class to the AI.** A customer deployment registers its own doctypes and master types through plugin seams (`register_doctype`, `register_master`) and the chat discovers them automatically - tool schemas and the system prompt are built per request from the live registries, and fields are introspected from the live table, so there is nothing to teach per type. A CRM lead added by a plugin is searchable in chat from day one, with zero prompt or tool edits.
118
118
  - **One shape for every document.** Invoices, sales orders, stock entries, payments - all share a single `Document` base class and the same three-state lifecycle (Draft → Submitted → Cancelled) with `on_submit`/`on_cancel` hooks. The LLM learns the pattern once and drives every doctype the same way. Leading open-source and commercial ERPs have per-model action verbs spread across 150+ core models; each one is a separate tool the model has to get right.
119
119
  - **Metadata-driven UI, shared with the LLM.** A single React form component renders every doctype from `frontend/src/lib/doctypes.ts`. The schema the model reasons over and the schema the user sees are literally the same file. Adding a field is two lines - one in the Python class, one in the config - not a new module with hand-written views and inheritance overlays.
120
- - **Two-model orchestration.** A planner model handles reasoning and tool-use. When it needs to generate code for a custom report, it delegates to a code-specialist sub-agent. This keeps each model doing what it's best at and keeps latency down on simple turns.
120
+ - **Two-model orchestration.** A planner model handles reasoning and tool-use. When it needs a custom report, it delegates a bounded declarative report specification to a report-specialist model. No generated report JavaScript is executed in the browser.
121
121
  - **Semantic datasets, not free SQL.** The LLM can't write raw SQL; it calls whitelisted semantic datasets (`sales_invoices`, `purchase_invoices`, `ar_open_items`, `stock_balances`, etc.) with whitelisted filters and group-bys. This makes the system auditable without sacrificing flexibility.
122
- - **Client-side analytics runtime.** Custom report JS executes in a sandboxed Web Worker in the user's browser. The server never runs untrusted JS. Charts are persisted as portable specs, not screenshots.
122
+ - **Client-side analytics runtime.** A bounded declarative interpreter groups, aggregates, sorts, and charts whitelisted semantic datasets. Report drafts contain portable JSON specifications, never executable code.
123
123
  - **Double-entry invariant enforced.** Every submitted document that touches the GL must balance to zero. The engine adds round-off entries for rounding gaps and refuses to post imbalanced vouchers.
124
124
  - **One deployment per customer, simple to operate.** Lambda ERP is built to be self-hosted by a single company for its own books - not as a multi-tenant SaaS. One FastAPI process, one database, one VPS is enough. If you want a hosted offering, we'll ship a dedicated instance per customer.
125
125
 
@@ -186,7 +186,7 @@ The result is booked in one step — chart of accounts, sensible default account
186
186
  Four things had to be true for this to work, and they all became true in the last ~18 months:
187
187
 
188
188
  1. **LLMs can reliably call tools.** A year ago, models would hallucinate tool calls, mangle JSON, or drift after 2–3 steps. Today's frontier models can run an 8-step reasoning loop over a real tool inventory without falling off.
189
- 2. **Costs collapsed.** Generating a custom report via a code-specialist sub-agent is cents of compute. Even keeping a human reviewer fully in the loop, the marginal cost of "one more report" or "one more dashboard" drops by orders of magnitude — which means companies actually ask for them, instead of living with the defaults.
189
+ 2. **Costs collapsed.** Generating a custom report via a report-specialist model is cents of compute. Even keeping a human reviewer fully in the loop, the marginal cost of "one more report" or "one more dashboard" drops by orders of magnitude — which means companies actually ask for them, instead of living with the defaults.
190
190
  3. **Structured output + function calling are first-class.** We can constrain the LLM's outputs to valid tool-call schemas, safe SQL parameters, and typed JSON - which is what makes an AI-native ERP even conceivable as a safe thing to run.
191
191
  4. **Greenfield is finally cheaper than retrofit.** Twenty-year ERP codebases have hundreds of bespoke models and thousands of hand-written forms - teaching an LLM to drive that reliably means curating a custom tool layer over every quirk. Starting from scratch around one Document lifecycle and a metadata-driven UI is now cheaper than retrofitting an existing platform.
192
192
 
@@ -262,14 +262,14 @@ Open `http://localhost:5173`. Vite proxies `/api/*` to the backend.
262
262
 
263
263
  ```
264
264
  OPENAI_API_KEY=sk-...
265
- ANTHROPIC_API_KEY=sk-ant-... # optional, used for the code-specialist sub-agent
265
+ ANTHROPIC_API_KEY=sk-ant-... # optional, used for the report-specialist model
266
266
  ANTHROPIC_CODE_MODEL=claude-opus-4-7 # optional, default shown
267
267
  LAMBDA_ERP_ADMIN_EMAIL=admin@example.com # optional, seeds the admin at boot
268
268
  LAMBDA_ERP_ADMIN_PASSWORD=... # optional, required with the line above
269
269
  LAMBDA_ERP_ADMIN_NAME=Administrator # optional, display name for the seeded admin
270
270
  ```
271
271
 
272
- Chat needs `OPENAI_API_KEY`. Custom-report code generation uses `ANTHROPIC_API_KEY` when set; otherwise it falls back and the chat will tell you it can't generate reports.
272
+ Chat needs `OPENAI_API_KEY`. Custom-report specification generation uses `ANTHROPIC_API_KEY` when set; otherwise the chat will tell you it can't generate reports.
273
273
 
274
274
  **Seeded admin.** By default the first person to register becomes the admin. Set `LAMBDA_ERP_ADMIN_EMAIL` + `LAMBDA_ERP_ADMIN_PASSWORD` to instead provision that admin automatically at startup — useful when the database is recreated on every deploy, so a redeployed instance can't be claimed by whoever visits first. It's create-if-missing and idempotent (an existing account with that email is promoted to an enabled admin; its password is left untouched), and the password is only ever read from the environment.
275
275
 
@@ -58,8 +58,8 @@ Each of those is hours of skilled work today. With a capable LLM in the loop, th
58
58
  │ - Document forms │ │ - Document CRUD API │
59
59
  │ - Reports / Analytics │ │ - Report endpoints │
60
60
  │ - Chat (WebSocket) │ │ - Auth (JWT cookie) │
61
- │ - Client-side JS runtime │ │ - WebSocket chat gateway │
62
- │ (Web Worker) for charts │ └────────────┬───────────────┘
61
+ │ - Declarative report │ │ - WebSocket chat gateway │
62
+ │ interpreter for charts │ └────────────┬───────────────┘
63
63
  └────────────────────────────┘ │
64
64
  ▼
65
65
  ┌──────────────────────────────────────────┐
@@ -67,8 +67,8 @@ Each of those is hours of skilled work today. With a capable LLM in the loop, th
67
67
  │ - GPT-5.6 drives the reasoning loop │
68
68
  │ - Tool-use: document CRUD, search, │
69
69
  │ reports, aggregations, analytics │
70
- │ - Delegates JS generation to Anthropic │
71
- │ code-specialist sub-agent │
70
+ │ - Delegates report specs to Anthropic │
71
+ │ report-specialist model │
72
72
  └──────────────────┬───────────────────────┘
73
73
  │
74
74
  ▼
@@ -88,9 +88,9 @@ Each of those is hours of skilled work today. With a capable LLM in the loop, th
88
88
  - **Extensions are first-class to the AI.** A customer deployment registers its own doctypes and master types through plugin seams (`register_doctype`, `register_master`) and the chat discovers them automatically - tool schemas and the system prompt are built per request from the live registries, and fields are introspected from the live table, so there is nothing to teach per type. A CRM lead added by a plugin is searchable in chat from day one, with zero prompt or tool edits.
89
89
  - **One shape for every document.** Invoices, sales orders, stock entries, payments - all share a single `Document` base class and the same three-state lifecycle (Draft → Submitted → Cancelled) with `on_submit`/`on_cancel` hooks. The LLM learns the pattern once and drives every doctype the same way. Leading open-source and commercial ERPs have per-model action verbs spread across 150+ core models; each one is a separate tool the model has to get right.
90
90
  - **Metadata-driven UI, shared with the LLM.** A single React form component renders every doctype from `frontend/src/lib/doctypes.ts`. The schema the model reasons over and the schema the user sees are literally the same file. Adding a field is two lines - one in the Python class, one in the config - not a new module with hand-written views and inheritance overlays.
91
- - **Two-model orchestration.** A planner model handles reasoning and tool-use. When it needs to generate code for a custom report, it delegates to a code-specialist sub-agent. This keeps each model doing what it's best at and keeps latency down on simple turns.
91
+ - **Two-model orchestration.** A planner model handles reasoning and tool-use. When it needs a custom report, it delegates a bounded declarative report specification to a report-specialist model. No generated report JavaScript is executed in the browser.
92
92
  - **Semantic datasets, not free SQL.** The LLM can't write raw SQL; it calls whitelisted semantic datasets (`sales_invoices`, `purchase_invoices`, `ar_open_items`, `stock_balances`, etc.) with whitelisted filters and group-bys. This makes the system auditable without sacrificing flexibility.
93
- - **Client-side analytics runtime.** Custom report JS executes in a sandboxed Web Worker in the user's browser. The server never runs untrusted JS. Charts are persisted as portable specs, not screenshots.
93
+ - **Client-side analytics runtime.** A bounded declarative interpreter groups, aggregates, sorts, and charts whitelisted semantic datasets. Report drafts contain portable JSON specifications, never executable code.
94
94
  - **Double-entry invariant enforced.** Every submitted document that touches the GL must balance to zero. The engine adds round-off entries for rounding gaps and refuses to post imbalanced vouchers.
95
95
  - **One deployment per customer, simple to operate.** Lambda ERP is built to be self-hosted by a single company for its own books - not as a multi-tenant SaaS. One FastAPI process, one database, one VPS is enough. If you want a hosted offering, we'll ship a dedicated instance per customer.
96
96
 
@@ -157,7 +157,7 @@ The result is booked in one step — chart of accounts, sensible default account
157
157
  Four things had to be true for this to work, and they all became true in the last ~18 months:
158
158
 
159
159
  1. **LLMs can reliably call tools.** A year ago, models would hallucinate tool calls, mangle JSON, or drift after 2–3 steps. Today's frontier models can run an 8-step reasoning loop over a real tool inventory without falling off.
160
- 2. **Costs collapsed.** Generating a custom report via a code-specialist sub-agent is cents of compute. Even keeping a human reviewer fully in the loop, the marginal cost of "one more report" or "one more dashboard" drops by orders of magnitude — which means companies actually ask for them, instead of living with the defaults.
160
+ 2. **Costs collapsed.** Generating a custom report via a report-specialist model is cents of compute. Even keeping a human reviewer fully in the loop, the marginal cost of "one more report" or "one more dashboard" drops by orders of magnitude — which means companies actually ask for them, instead of living with the defaults.
161
161
  3. **Structured output + function calling are first-class.** We can constrain the LLM's outputs to valid tool-call schemas, safe SQL parameters, and typed JSON - which is what makes an AI-native ERP even conceivable as a safe thing to run.
162
162
  4. **Greenfield is finally cheaper than retrofit.** Twenty-year ERP codebases have hundreds of bespoke models and thousands of hand-written forms - teaching an LLM to drive that reliably means curating a custom tool layer over every quirk. Starting from scratch around one Document lifecycle and a metadata-driven UI is now cheaper than retrofitting an existing platform.
163
163
 
@@ -233,14 +233,14 @@ Open `http://localhost:5173`. Vite proxies `/api/*` to the backend.
233
233
 
234
234
  ```
235
235
  OPENAI_API_KEY=sk-...
236
- ANTHROPIC_API_KEY=sk-ant-... # optional, used for the code-specialist sub-agent
236
+ ANTHROPIC_API_KEY=sk-ant-... # optional, used for the report-specialist model
237
237
  ANTHROPIC_CODE_MODEL=claude-opus-4-7 # optional, default shown
238
238
  LAMBDA_ERP_ADMIN_EMAIL=admin@example.com # optional, seeds the admin at boot
239
239
  LAMBDA_ERP_ADMIN_PASSWORD=... # optional, required with the line above
240
240
  LAMBDA_ERP_ADMIN_NAME=Administrator # optional, display name for the seeded admin
241
241
  ```
242
242
 
243
- Chat needs `OPENAI_API_KEY`. Custom-report code generation uses `ANTHROPIC_API_KEY` when set; otherwise it falls back and the chat will tell you it can't generate reports.
243
+ Chat needs `OPENAI_API_KEY`. Custom-report specification generation uses `ANTHROPIC_API_KEY` when set; otherwise the chat will tell you it can't generate reports.
244
244
 
245
245
  **Seeded admin.** By default the first person to register becomes the admin. Set `LAMBDA_ERP_ADMIN_EMAIL` + `LAMBDA_ERP_ADMIN_PASSWORD` to instead provision that admin automatically at startup — useful when the database is recreated on every deploy, so a redeployed instance can't be claimed by whoever visits first. It's create-if-missing and idempotent (an existing account with that email is promoted to an enabled admin; its password is left untouched), and the password is only ever read from the environment.
246
246
 
@@ -238,6 +238,12 @@ async def upload_attachment(
238
238
  user: dict = Depends(require_role("viewer")),
239
239
  ):
240
240
  """Upload a chat attachment. Returns metadata the client uses to attach it to a message."""
241
+ db = get_db()
242
+ sessions = db.sql('SELECT user_id FROM "Chat Session" WHERE id = ?', [session_id])
243
+ if not sessions or sessions[0].get("user_id") != user.get("name"):
244
+ # Keep foreign session IDs indistinguishable from nonexistent ones.
245
+ raise HTTPException(status_code=404, detail="Chat session not found")
246
+
241
247
  mime = (file.content_type or "application/octet-stream").lower()
242
248
  if mime not in ALLOWED_MIME_TYPES:
243
249
  # Browsers/OSes often mislabel Office files (e.g. octet-stream) — fall
@@ -253,9 +259,14 @@ async def upload_attachment(
253
259
  "and structured bank files (XML/ZIP)."),
254
260
  )
255
261
 
256
- data = await file.read()
257
- if len(data) > MAX_ATTACHMENT_SIZE:
258
- raise HTTPException(status_code=413, detail=f"File too large. Maximum {MAX_ATTACHMENT_SIZE // (1024 * 1024)} MB.")
262
+ chunks: list[bytes] = []
263
+ size = 0
264
+ while chunk := await file.read(1024 * 1024):
265
+ size += len(chunk)
266
+ if size > MAX_ATTACHMENT_SIZE:
267
+ raise HTTPException(status_code=413, detail=f"File too large. Maximum {MAX_ATTACHMENT_SIZE // (1024 * 1024)} MB.")
268
+ chunks.append(chunk)
269
+ data = b"".join(chunks)
259
270
  if not data:
260
271
  raise HTTPException(status_code=400, detail="Empty file.")
261
272
 
@@ -275,7 +286,6 @@ async def upload_attachment(
275
286
  ),
276
287
  )
277
288
 
278
- db = get_db()
279
289
  # Sanity-cap the number of attachments per session
280
290
  cnt = db.sql('SELECT COUNT(*) as c FROM "Chat Attachment" WHERE session_id = ?', [session_id])
281
291
  if cnt and cnt[0]["c"] >= MAX_ATTACHMENTS_PER_SESSION:
@@ -196,6 +196,87 @@ class SetPasswordRequest(BaseModel):
196
196
  ROLE_HIERARCHY = {"admin": 3, "manager": 2, "public_manager": 2, "viewer": 1}
197
197
  ASSIGNABLE_ROLES = {"admin", "manager", "viewer"}
198
198
 
199
+ SESSION_CREDENTIAL = "session"
200
+ API_KEY_CREDENTIAL = "api_key"
201
+ PUBLIC_DEMO_CREDENTIAL = "public_demo"
202
+
203
+
204
+ def make_auth_principal(
205
+ user: dict,
206
+ credential_type: str,
207
+ *,
208
+ name: str | None = None,
209
+ api_key_id: str | None = None,
210
+ ) -> dict:
211
+ """Return the request-scoped identity used by authorization checks.
212
+
213
+ ``User`` and ``Api Key`` rows remain the persistence model. A principal is
214
+ deliberately ephemeral: it records both *who* the owner is and *how* this
215
+ request authenticated, so a role-capped API key cannot be mistaken for an
216
+ interactive browser session at credential-management endpoints.
217
+ """
218
+ principal = dict(user)
219
+ principal["user_id"] = user["name"]
220
+ principal["name"] = name or user["name"]
221
+ principal["credential_type"] = credential_type
222
+ if api_key_id:
223
+ principal["api_key_id"] = api_key_id
224
+ return principal
225
+
226
+
227
+ def refresh_auth_principal(principal: dict | None) -> dict | None:
228
+ """Re-resolve an authenticated principal from live database state.
229
+
230
+ Used at long-lived boundaries (notably chat WebSockets and tool dispatch)
231
+ so disabling/demoting a user or revoking a key takes effect without waiting
232
+ for the connection to close. Legacy in-process callers without an explicit
233
+ credential type are returned unchanged; every real HTTP/WS auth path sets it.
234
+ """
235
+ if not principal:
236
+ return None
237
+ credential_type = principal.get("credential_type")
238
+ if not credential_type:
239
+ return principal
240
+
241
+ db = get_db()
242
+ if credential_type == API_KEY_CREDENTIAL:
243
+ key_id = principal.get("api_key_id")
244
+ rows = db.sql(
245
+ 'SELECT id, owner, role, session_owner, revoked FROM "Api Key" WHERE id = ?',
246
+ [key_id],
247
+ )
248
+ if not rows or rows[0].get("revoked"):
249
+ return None
250
+ key = dict(rows[0])
251
+ owner = db.get_value(
252
+ "User", key["owner"], ["name", "email", "full_name", "role", "enabled"]
253
+ )
254
+ if not owner or not owner.get("enabled"):
255
+ return None
256
+ owner = dict(owner)
257
+ effective_role = (
258
+ key["role"] if _role_rank(key["role"]) <= _role_rank(owner["role"])
259
+ else owner["role"]
260
+ )
261
+ owner["role"] = effective_role
262
+ return make_auth_principal(
263
+ owner,
264
+ API_KEY_CREDENTIAL,
265
+ name=principal.get("name") or key.get("session_owner") or owner["name"],
266
+ api_key_id=key["id"],
267
+ )
268
+
269
+ user_id = principal.get("user_id") or principal.get("name")
270
+ user = db.get_value(
271
+ "User", user_id, ["name", "email", "full_name", "role", "enabled"]
272
+ )
273
+ if not user or not user.get("enabled"):
274
+ return None
275
+ user = dict(user)
276
+ if credential_type == PUBLIC_DEMO_CREDENTIAL and user.get("role") != "public_manager":
277
+ return None
278
+ return make_auth_principal(user, credential_type)
279
+
199
280
 
200
281
  def get_current_user(request: Request) -> dict:
201
282
  """FastAPI dependency: resolve the caller for every cookie-gated REST route.
@@ -223,26 +304,23 @@ def get_current_user(request: Request) -> dict:
223
304
  if user_name:
224
305
  user = db.get_value("User", user_name, ["name", "email", "full_name", "role", "enabled"])
225
306
  if user and user.get("enabled"):
226
- return dict(user)
307
+ return make_auth_principal(dict(user), SESSION_CREDENTIAL)
227
308
 
228
309
  bearer = _bearer_token(request)
229
310
  if bearer is not None:
230
311
  if not _setting_enabled(db, "rest_api_enabled"):
231
312
  raise HTTPException(status_code=401, detail="REST API key access is disabled")
232
313
  key, owner, effective_role = _lookup_api_key(db, bearer)
233
- return {
234
- "name": owner["name"],
235
- "email": owner.get("email"),
236
- "full_name": owner.get("full_name"),
237
- "role": effective_role,
238
- "enabled": owner.get("enabled"),
239
- "api_key_id": key["id"],
240
- }
314
+ owner = dict(owner)
315
+ owner["role"] = effective_role
316
+ return make_auth_principal(
317
+ owner, API_KEY_CREDENTIAL, api_key_id=key["id"]
318
+ )
241
319
 
242
320
  # Fall back to public manager (demo mode)
243
321
  pub = db.sql('SELECT name, email, full_name, role, enabled FROM "User" WHERE role = \'public_manager\' AND enabled = 1')
244
322
  if pub:
245
- return dict(pub[0])
323
+ return make_auth_principal(dict(pub[0]), PUBLIC_DEMO_CREDENTIAL)
246
324
 
247
325
  raise HTTPException(status_code=401, detail="Not authenticated")
248
326
 
@@ -272,6 +350,34 @@ require_manager = require_role("manager")
272
350
  require_viewer = require_role("viewer")
273
351
 
274
352
 
353
+ def require_interactive_user(user: dict = Depends(get_current_user)) -> dict:
354
+ """Require a real browser session for account/credential management."""
355
+ if user.get("credential_type") != SESSION_CREDENTIAL:
356
+ raise HTTPException(
357
+ status_code=403,
358
+ detail="This account-security operation requires an interactive sign-in.",
359
+ )
360
+ return user
361
+
362
+
363
+ def require_interactive_role(minimum_role: str):
364
+ """Require both a browser session and the requested live account role."""
365
+ min_level = ROLE_HIERARCHY[minimum_role]
366
+
367
+ def checker(user: dict = Depends(require_interactive_user)) -> dict:
368
+ if ROLE_HIERARCHY.get(user.get("role"), 0) < min_level:
369
+ raise HTTPException(
370
+ status_code=403,
371
+ detail=f"Requires {minimum_role} role or higher",
372
+ )
373
+ return user
374
+
375
+ return checker
376
+
377
+
378
+ require_interactive_admin = require_interactive_role("admin")
379
+
380
+
275
381
  def require_non_public_manager(user: dict = Depends(get_current_user)) -> dict:
276
382
  user_level = ROLE_HIERARCHY.get(user["role"], 0)
277
383
  if user_level < ROLE_HIERARCHY["manager"]:
@@ -404,7 +510,7 @@ def me(user: dict = Depends(get_current_user)):
404
510
 
405
511
 
406
512
  @router.post("/change-password")
407
- def change_password(data: ChangePasswordRequest, user: dict = Depends(get_current_user)):
513
+ def change_password(data: ChangePasswordRequest, user: dict = Depends(require_interactive_user)):
408
514
  """Change the signed-in user's own password (verifies the current one)."""
409
515
  # The public_manager is a shared demo account — no real password to change.
410
516
  if user["role"] == "public_manager":
@@ -426,7 +532,7 @@ def change_password(data: ChangePasswordRequest, user: dict = Depends(get_curren
426
532
 
427
533
 
428
534
  @router.post("/set-password")
429
- def set_password(data: SetPasswordRequest, user: dict = Depends(get_current_user)):
535
+ def set_password(data: SetPasswordRequest, user: dict = Depends(require_interactive_user)):
430
536
  """Set a first password for an account that has none — e.g. a user created
431
537
  via social login who wants an email+password fallback. Requires an
432
538
  authenticated session; refuses if a usable password already exists (that
@@ -451,7 +557,7 @@ def set_password(data: SetPasswordRequest, user: dict = Depends(get_current_user
451
557
 
452
558
 
453
559
  @router.post("/invite")
454
- def create_invite(data: InviteRequest, user: dict = Depends(require_admin)):
560
+ def create_invite(data: InviteRequest, user: dict = Depends(require_interactive_admin)):
455
561
  db = get_db()
456
562
 
457
563
  validate_assignable_role(data.role)
@@ -474,14 +580,14 @@ def create_invite(data: InviteRequest, user: dict = Depends(require_admin)):
474
580
 
475
581
 
476
582
  @router.get("/users")
477
- def list_users(user: dict = Depends(require_admin)):
583
+ def list_users(user: dict = Depends(require_interactive_admin)):
478
584
  db = get_db()
479
585
  rows = db.sql('SELECT name, email, full_name, role, enabled, creation FROM "User" ORDER BY creation')
480
586
  return [dict(r) for r in rows]
481
587
 
482
588
 
483
589
  @router.put("/users/{user_name}/role")
484
- def change_role(user_name: str, data: ChangeRoleRequest, user: dict = Depends(require_admin)):
590
+ def change_role(user_name: str, data: ChangeRoleRequest, user: dict = Depends(require_interactive_admin)):
485
591
  validate_assignable_role(data.role)
486
592
 
487
593
  db = get_db()
@@ -498,7 +604,7 @@ def change_role(user_name: str, data: ChangeRoleRequest, user: dict = Depends(re
498
604
 
499
605
 
500
606
  @router.delete("/users/{user_name}")
501
- def disable_user(user_name: str, user: dict = Depends(require_admin)):
607
+ def disable_user(user_name: str, user: dict = Depends(require_interactive_admin)):
502
608
  if user_name == user["name"]:
503
609
  raise HTTPException(status_code=409, detail="Cannot disable yourself")
504
610
 
@@ -511,14 +617,14 @@ def disable_user(user_name: str, user: dict = Depends(require_admin)):
511
617
 
512
618
 
513
619
  @router.get("/invites")
514
- def list_invites(user: dict = Depends(require_admin)):
620
+ def list_invites(user: dict = Depends(require_interactive_admin)):
515
621
  db = get_db()
516
622
  rows = db.sql('SELECT token, email, role, used, creation FROM "Invite" ORDER BY creation DESC')
517
623
  return [dict(r) for r in rows]
518
624
 
519
625
 
520
626
  @router.delete("/invites/{token}")
521
- def revoke_invite(token: str, user: dict = Depends(require_admin)):
627
+ def revoke_invite(token: str, user: dict = Depends(require_interactive_admin)):
522
628
  """Revoke a pending (unused) invite so its link no longer works."""
523
629
  db = get_db()
524
630
  rows = db.sql('SELECT token, used FROM "Invite" WHERE token = ?', [token])
@@ -532,7 +638,7 @@ def revoke_invite(token: str, user: dict = Depends(require_admin)):
532
638
 
533
639
 
534
640
  @router.post("/public-manager")
535
- def create_public_manager(user: dict = Depends(require_admin)):
641
+ def create_public_manager(user: dict = Depends(require_interactive_admin)):
536
642
  """Create (or re-enable) the public manager account for demo mode.
537
643
 
538
644
  Also seeds the chat-replay artefacts the "Enter Live Demo" script
@@ -580,7 +686,7 @@ def create_public_manager(user: dict = Depends(require_admin)):
580
686
 
581
687
 
582
688
  @router.delete("/public-manager")
583
- def remove_public_manager(user: dict = Depends(require_admin)):
689
+ def remove_public_manager(user: dict = Depends(require_interactive_admin)):
584
690
  """Disable the public manager account."""
585
691
  db = get_db()
586
692
  existing = db.sql('SELECT name FROM "User" WHERE role = \'public_manager\'')
@@ -690,7 +796,7 @@ def ensure_seed_admin() -> dict | None:
690
796
 
691
797
 
692
798
  @router.get("/settings")
693
- def get_settings(user: dict = Depends(get_current_user)):
799
+ def get_settings(user: dict = Depends(require_interactive_user)):
694
800
  db = get_db()
695
801
  rows = db.sql('SELECT key, value FROM "Settings"')
696
802
  settings = {r["key"]: r["value"] for r in rows}
@@ -702,7 +808,7 @@ def get_settings(user: dict = Depends(get_current_user)):
702
808
 
703
809
 
704
810
  @router.put("/settings")
705
- def update_settings(data: dict, user: dict = Depends(require_admin)):
811
+ def update_settings(data: dict, user: dict = Depends(require_interactive_admin)):
706
812
  db = get_db()
707
813
  for key, value in data.items():
708
814
  existing = db.sql('SELECT key FROM "Settings" WHERE key = ?', [key])
@@ -857,13 +963,17 @@ def get_api_caller(request: Request) -> dict:
857
963
  raise HTTPException(status_code=401, detail="Missing or malformed Authorization header")
858
964
  key, owner, effective_role = _lookup_api_key(db, token)
859
965
 
860
- return {
861
- "name": key["session_owner"],
862
- "full_name": f"{owner.get('full_name') or key['owner']} (API)",
863
- "role": effective_role,
864
- "api_key_id": key["id"],
865
- "api_key_user": key["owner"],
866
- }
966
+ owner = dict(owner)
967
+ owner["role"] = effective_role
968
+ principal = make_auth_principal(
969
+ owner,
970
+ API_KEY_CREDENTIAL,
971
+ name=key["session_owner"],
972
+ api_key_id=key["id"],
973
+ )
974
+ principal["full_name"] = f"{owner.get('full_name') or key['owner']} (API)"
975
+ principal["api_key_user"] = key["owner"]
976
+ return principal
867
977
 
868
978
 
869
979
  class ApiKeyCreate(BaseModel):
@@ -904,7 +1014,7 @@ def _require_key_access(key: dict, user: dict) -> None:
904
1014
 
905
1015
 
906
1016
  @router.get("/api-keys")
907
- def list_api_keys(user: dict = Depends(require_viewer)):
1017
+ def list_api_keys(user: dict = Depends(require_interactive_user)):
908
1018
  """List API keys (metadata only — the token is never returned).
909
1019
 
910
1020
  Users see their own keys; admins see everyone's. Each key carries its
@@ -930,7 +1040,7 @@ def list_api_keys(user: dict = Depends(require_viewer)):
930
1040
 
931
1041
 
932
1042
  @router.post("/api-keys")
933
- def create_api_key(data: ApiKeyCreate, user: dict = Depends(require_viewer)):
1043
+ def create_api_key(data: ApiKeyCreate, user: dict = Depends(require_interactive_user)):
934
1044
  """Create an API key for YOURSELF. Returns the full token exactly once.
935
1045
 
936
1046
  Self-service: any logged-in (non-demo) user can mint keys, because a key
@@ -975,7 +1085,7 @@ def create_api_key(data: ApiKeyCreate, user: dict = Depends(require_viewer)):
975
1085
 
976
1086
 
977
1087
  @router.post("/api-keys/{key_id}/revoke")
978
- def revoke_api_key(key_id: str, user: dict = Depends(require_viewer)):
1088
+ def revoke_api_key(key_id: str, user: dict = Depends(require_interactive_user)):
979
1089
  """Revoke (soft-disable) an API key. Owner or admin."""
980
1090
  db = get_db()
981
1091
  key = _get_key_or_404(db, key_id)
@@ -986,7 +1096,7 @@ def revoke_api_key(key_id: str, user: dict = Depends(require_viewer)):
986
1096
 
987
1097
 
988
1098
  @router.delete("/api-keys/{key_id}")
989
- def delete_api_key(key_id: str, user: dict = Depends(require_viewer)):
1099
+ def delete_api_key(key_id: str, user: dict = Depends(require_interactive_user)):
990
1100
  """Hard-delete a REVOKED API key. Owner or admin.
991
1101
 
992
1102
  Two-step by design: a live key must be revoked first (409 otherwise), so a
@@ -373,50 +373,22 @@ def _ensure_top_customer_snapshots(db) -> None:
373
373
  def _ensure_top7_report_draft(db) -> None:
374
374
  """Create the 'Top 7 Customers by Revenue' analytics draft the chat
375
375
  replay links to, so clicking the bar-chart link opens a real report."""
376
- if _get_setting(db, SETTING_DEMO_TOP7_REPORT_ID):
377
- return
376
+ existing_id = _get_setting(db, SETTING_DEMO_TOP7_REPORT_ID)
377
+ if existing_id:
378
+ rows = db.sql('SELECT definition_json FROM "Report Draft" WHERE id = ?', [existing_id])
379
+ if rows:
380
+ try:
381
+ if json.loads(rows[0]["definition_json"]).get("runtime_version") == 1:
382
+ return
383
+ except (TypeError, ValueError):
384
+ pass
385
+ # Legacy executable drafts are inert in the new runtime. Recreate only
386
+ # this known demo asset; user-authored drafts are never auto-migrated.
387
+ db.sql('DELETE FROM "Report Draft" WHERE id = ?', [existing_id])
388
+ db.conn.commit()
378
389
 
379
390
  from api.routers.analytics import create_report_draft_record
380
391
 
381
- transform_js = (
382
- "const grouped = helpers.group(sales, ['customer', 'customer_name'], {\n"
383
- " revenue: ['sum', 'net_total'],\n"
384
- "});\n"
385
- "const sorted = helpers.sortBy(grouped, 'revenue', 'desc');\n"
386
- "const top = helpers.topN(sorted, 7);\n"
387
- "const rows = top.map(function(r) { return {\n"
388
- " customer: r.customer,\n"
389
- " customer_name: r.customer_name || r.customer,\n"
390
- " revenue: r.revenue,\n"
391
- "}; });\n"
392
- "return {\n"
393
- " title: 'Top 7 Customers by Revenue',\n"
394
- " kpis: [\n"
395
- " { label: 'Total Revenue (Top 7)', value: helpers.sum(rows, 'revenue'), format: 'currency' },\n"
396
- " { label: 'Customers Shown', value: rows.length, format: 'number' },\n"
397
- " ],\n"
398
- " tables: [\n"
399
- " {\n"
400
- " title: 'Top 7 Customers by Revenue',\n"
401
- " columns: [\n"
402
- " { key: 'customer_name', label: 'Customer', type: 'string' },\n"
403
- " { key: 'revenue', label: 'Revenue', type: 'currency' },\n"
404
- " ],\n"
405
- " rows: rows,\n"
406
- " },\n"
407
- " ],\n"
408
- " charts: [\n"
409
- " {\n"
410
- " title: 'Top 7 Customers by Revenue',\n"
411
- " type: 'bar',\n"
412
- " x: 'customer_name',\n"
413
- " y: 'revenue',\n"
414
- " dataTable: 'Top 7 Customers by Revenue',\n"
415
- " },\n"
416
- " ],\n"
417
- "};"
418
- )
419
-
420
392
  payload = {
421
393
  "title": "Top 7 Customers by Revenue",
422
394
  "description": "Top 7 customers ranked by total submitted sales invoice revenue (returns excluded).",
@@ -428,7 +400,39 @@ def _ensure_top7_report_draft(db) -> None:
428
400
  "filters": {"is_return": 0},
429
401
  }
430
402
  ],
431
- "transform_js": transform_js,
403
+ "report": {
404
+ "version": 1,
405
+ "kpis": [
406
+ {"label": "Total Revenue (Top 7)", "source": "top_customers", "op": "sum", "field": "revenue", "format": "currency"},
407
+ {"label": "Customers Shown", "source": "top_customers", "op": "count", "format": "number"},
408
+ ],
409
+ "tables": [{
410
+ "id": "top_customers",
411
+ "title": "Top 7 Customers by Revenue",
412
+ "source": "sales",
413
+ "dimensions": [
414
+ {"field": "customer"},
415
+ {"field": "customer_name", "fallback": "—"},
416
+ ],
417
+ "measures": [
418
+ {"key": "revenue", "op": "sum", "field": "net_total", "type": "currency"},
419
+ ],
420
+ "columns": [
421
+ {"key": "customer_name", "label": "Customer", "type": "string"},
422
+ {"key": "revenue", "label": "Revenue", "type": "currency"},
423
+ ],
424
+ "sort": [{"field": "revenue", "direction": "desc"}],
425
+ "limit": 7,
426
+ }],
427
+ "charts": [{
428
+ "id": "top_customers_chart",
429
+ "title": "Top 7 Customers by Revenue",
430
+ "type": "bar",
431
+ "data_table": "top_customers",
432
+ "x": "customer_name",
433
+ "y": "revenue",
434
+ }],
435
+ },
432
436
  }
433
437
  # Own the draft as the public_manager so the demo user can see it in
434
438
  # the Custom Analytics sidebar and open it without hitting the 403