lambda-erp 0.1.48__tar.gz → 0.2.0__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 (79) hide show
  1. {lambda_erp-0.1.48 → lambda_erp-0.2.0}/PKG-INFO +16 -2
  2. {lambda_erp-0.1.48 → lambda_erp-0.2.0}/README.md +15 -1
  3. {lambda_erp-0.1.48 → lambda_erp-0.2.0}/api/auth.py +31 -20
  4. {lambda_erp-0.1.48 → lambda_erp-0.2.0}/api/chat.py +126 -1
  5. {lambda_erp-0.1.48 → lambda_erp-0.2.0}/api/routers/setup.py +70 -4
  6. {lambda_erp-0.1.48 → lambda_erp-0.2.0}/frontend/src/api/client.ts +5 -2
  7. {lambda_erp-0.1.48 → lambda_erp-0.2.0}/lambda_erp/accounting/chart_of_accounts.py +64 -28
  8. lambda_erp-0.2.0/lambda_erp/accounting/setup/__init__.py +67 -0
  9. lambda_erp-0.2.0/lambda_erp/accounting/setup/engine.py +240 -0
  10. lambda_erp-0.2.0/lambda_erp/accounting/setup/pack.py +135 -0
  11. lambda_erp-0.2.0/lambda_erp/accounting/setup/packs/__init__.py +16 -0
  12. lambda_erp-0.2.0/lambda_erp/accounting/setup/packs/ch.py +256 -0
  13. lambda_erp-0.2.0/lambda_erp/accounting/setup/packs/generic.py +55 -0
  14. lambda_erp-0.2.0/lambda_erp/accounting/setup/profiles.py +290 -0
  15. lambda_erp-0.2.0/lambda_erp/accounting/setup/spine.py +89 -0
  16. {lambda_erp-0.1.48 → lambda_erp-0.2.0}/lambda_erp/database.py +3 -3
  17. {lambda_erp-0.1.48 → lambda_erp-0.2.0}/pyproject.toml +1 -1
  18. {lambda_erp-0.1.48 → lambda_erp-0.2.0}/.gitignore +0 -0
  19. {lambda_erp-0.1.48 → lambda_erp-0.2.0}/LICENSE +0 -0
  20. {lambda_erp-0.1.48 → lambda_erp-0.2.0}/api/__init__.py +0 -0
  21. {lambda_erp-0.1.48 → lambda_erp-0.2.0}/api/attachments.py +0 -0
  22. {lambda_erp-0.1.48 → lambda_erp-0.2.0}/api/bootstrap.py +0 -0
  23. {lambda_erp-0.1.48 → lambda_erp-0.2.0}/api/demo_limits.py +0 -0
  24. {lambda_erp-0.1.48 → lambda_erp-0.2.0}/api/deps.py +0 -0
  25. {lambda_erp-0.1.48 → lambda_erp-0.2.0}/api/errors.py +0 -0
  26. {lambda_erp-0.1.48 → lambda_erp-0.2.0}/api/main.py +0 -0
  27. {lambda_erp-0.1.48 → lambda_erp-0.2.0}/api/oauth.py +0 -0
  28. {lambda_erp-0.1.48 → lambda_erp-0.2.0}/api/pdf.py +0 -0
  29. {lambda_erp-0.1.48 → lambda_erp-0.2.0}/api/providers.py +0 -0
  30. {lambda_erp-0.1.48 → lambda_erp-0.2.0}/api/remarks_md.py +0 -0
  31. {lambda_erp-0.1.48 → lambda_erp-0.2.0}/api/routers/__init__.py +0 -0
  32. {lambda_erp-0.1.48 → lambda_erp-0.2.0}/api/routers/accounting.py +0 -0
  33. {lambda_erp-0.1.48 → lambda_erp-0.2.0}/api/routers/admin.py +0 -0
  34. {lambda_erp-0.1.48 → lambda_erp-0.2.0}/api/routers/analytics.py +0 -0
  35. {lambda_erp-0.1.48 → lambda_erp-0.2.0}/api/routers/bank_reconciliation.py +0 -0
  36. {lambda_erp-0.1.48 → lambda_erp-0.2.0}/api/routers/chat_api.py +0 -0
  37. {lambda_erp-0.1.48 → lambda_erp-0.2.0}/api/routers/documents.py +0 -0
  38. {lambda_erp-0.1.48 → lambda_erp-0.2.0}/api/routers/masters.py +0 -0
  39. {lambda_erp-0.1.48 → lambda_erp-0.2.0}/api/routers/proposals.py +0 -0
  40. {lambda_erp-0.1.48 → lambda_erp-0.2.0}/api/routers/reports.py +0 -0
  41. {lambda_erp-0.1.48 → lambda_erp-0.2.0}/api/services.py +0 -0
  42. {lambda_erp-0.1.48 → lambda_erp-0.2.0}/api/templates/document.html +0 -0
  43. {lambda_erp-0.1.48 → lambda_erp-0.2.0}/api/templates/proposal.html +0 -0
  44. {lambda_erp-0.1.48 → lambda_erp-0.2.0}/docs/agents/README.md +0 -0
  45. {lambda_erp-0.1.48 → lambda_erp-0.2.0}/frontend/README.md +0 -0
  46. {lambda_erp-0.1.48 → lambda_erp-0.2.0}/lambda_erp/__init__.py +0 -0
  47. {lambda_erp-0.1.48 → lambda_erp-0.2.0}/lambda_erp/accounting/__init__.py +0 -0
  48. {lambda_erp-0.1.48 → lambda_erp-0.2.0}/lambda_erp/accounting/bank_transaction.py +0 -0
  49. {lambda_erp-0.1.48 → lambda_erp-0.2.0}/lambda_erp/accounting/budget.py +0 -0
  50. {lambda_erp-0.1.48 → lambda_erp-0.2.0}/lambda_erp/accounting/general_ledger.py +0 -0
  51. {lambda_erp-0.1.48 → lambda_erp-0.2.0}/lambda_erp/accounting/journal_entry.py +0 -0
  52. {lambda_erp-0.1.48 → lambda_erp-0.2.0}/lambda_erp/accounting/payment_entry.py +0 -0
  53. {lambda_erp-0.1.48 → lambda_erp-0.2.0}/lambda_erp/accounting/pos_invoice.py +0 -0
  54. {lambda_erp-0.1.48 → lambda_erp-0.2.0}/lambda_erp/accounting/purchase_invoice.py +0 -0
  55. {lambda_erp-0.1.48 → lambda_erp-0.2.0}/lambda_erp/accounting/revaluation.py +0 -0
  56. {lambda_erp-0.1.48 → lambda_erp-0.2.0}/lambda_erp/accounting/sales_invoice.py +0 -0
  57. {lambda_erp-0.1.48 → lambda_erp-0.2.0}/lambda_erp/accounting/subscription.py +0 -0
  58. {lambda_erp-0.1.48 → lambda_erp-0.2.0}/lambda_erp/buying/__init__.py +0 -0
  59. {lambda_erp-0.1.48 → lambda_erp-0.2.0}/lambda_erp/buying/purchase_order.py +0 -0
  60. {lambda_erp-0.1.48 → lambda_erp-0.2.0}/lambda_erp/controllers/__init__.py +0 -0
  61. {lambda_erp-0.1.48 → lambda_erp-0.2.0}/lambda_erp/controllers/currency.py +0 -0
  62. {lambda_erp-0.1.48 → lambda_erp-0.2.0}/lambda_erp/controllers/defaults.py +0 -0
  63. {lambda_erp-0.1.48 → lambda_erp-0.2.0}/lambda_erp/controllers/pricing_rule.py +0 -0
  64. {lambda_erp-0.1.48 → lambda_erp-0.2.0}/lambda_erp/controllers/taxes_and_totals.py +0 -0
  65. {lambda_erp-0.1.48 → lambda_erp-0.2.0}/lambda_erp/exceptions.py +0 -0
  66. {lambda_erp-0.1.48 → lambda_erp-0.2.0}/lambda_erp/hooks.py +0 -0
  67. {lambda_erp-0.1.48 → lambda_erp-0.2.0}/lambda_erp/model.py +0 -0
  68. {lambda_erp-0.1.48 → lambda_erp-0.2.0}/lambda_erp/selling/__init__.py +0 -0
  69. {lambda_erp-0.1.48 → lambda_erp-0.2.0}/lambda_erp/selling/proposal.py +0 -0
  70. {lambda_erp-0.1.48 → lambda_erp-0.2.0}/lambda_erp/selling/quotation.py +0 -0
  71. {lambda_erp-0.1.48 → lambda_erp-0.2.0}/lambda_erp/selling/sales_order.py +0 -0
  72. {lambda_erp-0.1.48 → lambda_erp-0.2.0}/lambda_erp/simulation.py +0 -0
  73. {lambda_erp-0.1.48 → lambda_erp-0.2.0}/lambda_erp/stock/__init__.py +0 -0
  74. {lambda_erp-0.1.48 → lambda_erp-0.2.0}/lambda_erp/stock/delivery_note.py +0 -0
  75. {lambda_erp-0.1.48 → lambda_erp-0.2.0}/lambda_erp/stock/purchase_receipt.py +0 -0
  76. {lambda_erp-0.1.48 → lambda_erp-0.2.0}/lambda_erp/stock/stock_entry.py +0 -0
  77. {lambda_erp-0.1.48 → lambda_erp-0.2.0}/lambda_erp/stock/stock_ledger.py +0 -0
  78. {lambda_erp-0.1.48 → lambda_erp-0.2.0}/lambda_erp/utils.py +0 -0
  79. {lambda_erp-0.1.48 → lambda_erp-0.2.0}/terraform/README.md +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lambda-erp
3
- Version: 0.1.48
3
+ Version: 0.2.0
4
4
  Summary: Core ERP logic - accounting, sales, purchasing, inventory
5
5
  Author: TORUS INVESTMENTS AG
6
6
  License-Expression: MIT
@@ -135,8 +135,22 @@ Each of those is hours of skilled work today. With a capable LLM in the loop, th
135
135
 
136
136
  ---
137
137
 
138
+ ## Set up your books by describing your business
139
+
140
+ Standing up a new company usually means an accountant hand-building a chart of accounts. In Lambda ERP you do it in the chat: tell the assistant what kind of business you run and which country you're in, and it proposes the exact accounts — explaining the structure and the judgment calls, and creating nothing until you approve. It's the "chart-of-accounts design and mapping" work from above, done in seconds and reviewed by you.
141
+
142
+ Two dimensions shape the chart, and they compose:
143
+
144
+ - **Localization packs** bring a country's real chart and tax setup. Today: a **generic / international** chart (also the permanent fallback for anywhere not yet localized) and **Switzerland** — the *Kontenrahmen KMU*, in German, in CHF, with MWST tax templates at the current rates. Each country is a self-contained pack, so more can land without disturbing the ones already there — that's the roadmap, one jurisdiction at a time.
145
+ - **Sector profiles** tailor the chart to how you operate — **services, retail / POS, hospitality, wholesale / distribution, import / export, manufacturing, construction** — adding the accounts that model actually needs (work-in-progress and retention for construction, food-vs-beverage cost lines for hospitality, landed-cost accounts for import/export, and so on). Profiles are country-independent: the same seven apply on top of every localization.
146
+
147
+ The result is booked in one step — chart of accounts, sensible default accounts, tax templates, and a cost center — ready for opening balances. New here? The in-app **Tutorial** has a one-click **Get started** that opens the wizard in chat.
148
+
149
+ ---
150
+
138
151
  ## What works today
139
152
 
153
+ - **Guided company setup through chat** — pick a country (generic or Switzerland, more coming) and business type, preview the exact chart of accounts, confirm, and it's booked
140
154
  - Full sales and purchase cycles (Quotation → Sales Order → Delivery Note → Sales Invoice → Payment Entry, and the buying equivalents)
141
155
  - Returns / credit notes / debit notes with proper GL and stock reversal
142
156
  - Moving-average stock ledger with negative-stock protection
@@ -176,7 +190,7 @@ Four things had to be true for this to work, and they all became true in the las
176
190
 
177
191
  Every company configuring an ERP runs into the same problems: local tax rules, common workflow patterns, industry-specific accounting quirks. Most of that knowledge isn't a competitive advantage — it's the same ground being re-covered separately at every implementation, by every team, in slightly different ways.
178
192
 
179
- We want Lambda ERP to be where that knowledge lives in public. The base system is MIT-licensed, and we're organizing the repo so that community contributions - a German VAT pack, a U.S. sales-tax-by-state module, an industry template for professional services - can slot in under `docs/` and be picked up by the LLM as reference material. The goal is a true community where running an ERP gets cheaper, faster, and more flexible for everyone, not just the implementer.
193
+ We want Lambda ERP to be where that knowledge lives in public. The base system is MIT-licensed, and the repo has real seams for that knowledge to slot into: a new country's chart of accounts and tax rules is a self-contained **localization pack**, and an industry's way of operating is a **sector profile** — both live in `lambda_erp/accounting/setup/` and are documented for contributors in [`docs/agents/company_setup.md`](docs/agents/company_setup.md). A German VAT pack, a U.S. sales-tax-by-state module, a template for professional services each is an additive contribution that every deployment picks up. The goal is a true community where running an ERP gets cheaper, faster, and more flexible for everyone, not just the implementer.
180
194
 
181
195
  ---
182
196
 
@@ -108,8 +108,22 @@ Each of those is hours of skilled work today. With a capable LLM in the loop, th
108
108
 
109
109
  ---
110
110
 
111
+ ## Set up your books by describing your business
112
+
113
+ Standing up a new company usually means an accountant hand-building a chart of accounts. In Lambda ERP you do it in the chat: tell the assistant what kind of business you run and which country you're in, and it proposes the exact accounts — explaining the structure and the judgment calls, and creating nothing until you approve. It's the "chart-of-accounts design and mapping" work from above, done in seconds and reviewed by you.
114
+
115
+ Two dimensions shape the chart, and they compose:
116
+
117
+ - **Localization packs** bring a country's real chart and tax setup. Today: a **generic / international** chart (also the permanent fallback for anywhere not yet localized) and **Switzerland** — the *Kontenrahmen KMU*, in German, in CHF, with MWST tax templates at the current rates. Each country is a self-contained pack, so more can land without disturbing the ones already there — that's the roadmap, one jurisdiction at a time.
118
+ - **Sector profiles** tailor the chart to how you operate — **services, retail / POS, hospitality, wholesale / distribution, import / export, manufacturing, construction** — adding the accounts that model actually needs (work-in-progress and retention for construction, food-vs-beverage cost lines for hospitality, landed-cost accounts for import/export, and so on). Profiles are country-independent: the same seven apply on top of every localization.
119
+
120
+ The result is booked in one step — chart of accounts, sensible default accounts, tax templates, and a cost center — ready for opening balances. New here? The in-app **Tutorial** has a one-click **Get started** that opens the wizard in chat.
121
+
122
+ ---
123
+
111
124
  ## What works today
112
125
 
126
+ - **Guided company setup through chat** — pick a country (generic or Switzerland, more coming) and business type, preview the exact chart of accounts, confirm, and it's booked
113
127
  - Full sales and purchase cycles (Quotation → Sales Order → Delivery Note → Sales Invoice → Payment Entry, and the buying equivalents)
114
128
  - Returns / credit notes / debit notes with proper GL and stock reversal
115
129
  - Moving-average stock ledger with negative-stock protection
@@ -149,7 +163,7 @@ Four things had to be true for this to work, and they all became true in the las
149
163
 
150
164
  Every company configuring an ERP runs into the same problems: local tax rules, common workflow patterns, industry-specific accounting quirks. Most of that knowledge isn't a competitive advantage — it's the same ground being re-covered separately at every implementation, by every team, in slightly different ways.
151
165
 
152
- We want Lambda ERP to be where that knowledge lives in public. The base system is MIT-licensed, and we're organizing the repo so that community contributions - a German VAT pack, a U.S. sales-tax-by-state module, an industry template for professional services - can slot in under `docs/` and be picked up by the LLM as reference material. The goal is a true community where running an ERP gets cheaper, faster, and more flexible for everyone, not just the implementer.
166
+ We want Lambda ERP to be where that knowledge lives in public. The base system is MIT-licensed, and the repo has real seams for that knowledge to slot into: a new country's chart of accounts and tax rules is a self-contained **localization pack**, and an industry's way of operating is a **sector profile** — both live in `lambda_erp/accounting/setup/` and are documented for contributors in [`docs/agents/company_setup.md`](docs/agents/company_setup.md). A German VAT pack, a U.S. sales-tax-by-state module, a template for professional services each is an additive contribution that every deployment picks up. The goal is a true community where running an ERP gets cheaper, faster, and more flexible for everyone, not just the implementer.
153
167
 
154
168
  ---
155
169
 
@@ -680,14 +680,14 @@ def get_api_caller(request: Request) -> dict:
680
680
  raise HTTPException(status_code=401, detail="Empty bearer token")
681
681
 
682
682
  rows = db.sql(
683
- 'SELECT id, name, user, role, session_owner FROM "Api Key" WHERE key_hash = ? AND revoked = 0',
683
+ 'SELECT id, name, owner, role, session_owner FROM "Api Key" WHERE key_hash = ? AND revoked = 0',
684
684
  [hash_api_key(token)],
685
685
  )
686
686
  if not rows:
687
687
  raise HTTPException(status_code=401, detail="Invalid API key")
688
688
  key = dict(rows[0])
689
689
 
690
- owner = db.get_value("User", key["user"], ["name", "full_name", "role", "enabled"])
690
+ owner = db.get_value("User", key["owner"], ["name", "full_name", "role", "enabled"])
691
691
  if not owner or not owner.get("enabled"):
692
692
  raise HTTPException(status_code=401, detail="Invalid API key")
693
693
  effective_role = (
@@ -699,10 +699,10 @@ def get_api_caller(request: Request) -> dict:
699
699
 
700
700
  return {
701
701
  "name": key["session_owner"],
702
- "full_name": f"{owner.get('full_name') or key['user']} (API)",
702
+ "full_name": f"{owner.get('full_name') or key['owner']} (API)",
703
703
  "role": effective_role,
704
704
  "api_key_id": key["id"],
705
- "api_key_user": key["user"],
705
+ "api_key_user": key["owner"],
706
706
  }
707
707
 
708
708
 
@@ -717,7 +717,11 @@ def _serialize_api_key(row: dict) -> dict:
717
717
  return {
718
718
  "id": row["id"],
719
719
  "name": row["name"],
720
- "user": row.get("user"),
720
+ "user": row.get("owner"),
721
+ # Owner's display identity so the UI can group keys per user with a
722
+ # human name instead of the opaque USR-id (admins see many owners).
723
+ "owner_full_name": row.get("owner_full_name"),
724
+ "owner_email": row.get("owner_email"),
721
725
  "role": row["role"],
722
726
  "key_prefix": row["key_prefix"],
723
727
  "created_at": row.get("created_at"),
@@ -727,7 +731,7 @@ def _serialize_api_key(row: dict) -> dict:
727
731
 
728
732
 
729
733
  def _get_key_or_404(db, key_id: str) -> dict:
730
- rows = db.sql('SELECT id, user, revoked FROM "Api Key" WHERE id = ?', [key_id])
734
+ rows = db.sql('SELECT id, owner, revoked FROM "Api Key" WHERE id = ?', [key_id])
731
735
  if not rows:
732
736
  raise HTTPException(status_code=404, detail="API key not found")
733
737
  return dict(rows[0])
@@ -735,7 +739,7 @@ def _get_key_or_404(db, key_id: str) -> dict:
735
739
 
736
740
  def _require_key_access(key: dict, user: dict) -> None:
737
741
  """A key is managed by its owner; admins can manage every key."""
738
- if key["user"] != user["name"] and user["role"] != "admin":
742
+ if key["owner"] != user["name"] and user["role"] != "admin":
739
743
  raise HTTPException(status_code=403, detail="Not your API key")
740
744
 
741
745
 
@@ -743,21 +747,26 @@ def _require_key_access(key: dict, user: dict) -> None:
743
747
  def list_api_keys(user: dict = Depends(require_viewer)):
744
748
  """List API keys (metadata only — the token is never returned).
745
749
 
746
- Users see their own keys; admins see everyone's.
750
+ Users see their own keys; admins see everyone's. Each key carries its
751
+ owner's display name/email and an `is_mine` flag so the UI can group keys
752
+ per user (your own first) and warn before touching someone else's.
747
753
  """
748
754
  db = get_db()
755
+ base = (
756
+ 'SELECT k.id, k.name, k.owner, k.role, k.key_prefix, k.created_at, '
757
+ 'k.last_used_at, k.revoked, u.full_name AS owner_full_name, u.email AS owner_email '
758
+ 'FROM "Api Key" k LEFT JOIN "User" u ON u.name = k.owner '
759
+ )
749
760
  if user["role"] == "admin":
750
- rows = db.sql(
751
- 'SELECT id, name, user, role, key_prefix, created_at, last_used_at, revoked '
752
- 'FROM "Api Key" ORDER BY created_at DESC'
753
- )
761
+ rows = db.sql(base + 'ORDER BY k.created_at DESC')
754
762
  else:
755
- rows = db.sql(
756
- 'SELECT id, name, user, role, key_prefix, created_at, last_used_at, revoked '
757
- 'FROM "Api Key" WHERE user = ? ORDER BY created_at DESC',
758
- [user["name"]],
759
- )
760
- return [_serialize_api_key(dict(r)) for r in rows]
763
+ rows = db.sql(base + 'WHERE k.owner = ? ORDER BY k.created_at DESC', [user["name"]])
764
+ out = []
765
+ for r in rows:
766
+ d = _serialize_api_key(dict(r))
767
+ d["is_mine"] = d["user"] == user["name"]
768
+ out.append(d)
769
+ return out
761
770
 
762
771
 
763
772
  @router.post("/api-keys")
@@ -789,16 +798,18 @@ def create_api_key(data: ApiKeyCreate, user: dict = Depends(require_viewer)):
789
798
  created_at = now()
790
799
  db.sql(
791
800
  'INSERT INTO "Api Key" '
792
- '(id, name, user, key_hash, key_prefix, role, session_owner, created_at, last_used_at, revoked) '
801
+ '(id, name, owner, key_hash, key_prefix, role, session_owner, created_at, last_used_at, revoked) '
793
802
  'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0)',
794
803
  [key_id, name, user["name"], key_hash, key_prefix, role, f"api:{user['name']}", created_at, None],
795
804
  )
796
805
  db.conn.commit()
797
806
  row = {
798
- "id": key_id, "name": name, "user": user["name"], "role": role, "key_prefix": key_prefix,
807
+ "id": key_id, "name": name, "owner": user["name"], "role": role, "key_prefix": key_prefix,
799
808
  "created_at": created_at, "last_used_at": None, "revoked": 0,
809
+ "owner_full_name": user.get("full_name"), "owner_email": user.get("email"),
800
810
  }
801
811
  result = _serialize_api_key(row)
812
+ result["is_mine"] = True
802
813
  result["token"] = token # shown once
803
814
  return result
804
815
 
@@ -1067,6 +1067,75 @@ TOOLS = [
1067
1067
  },
1068
1068
  },
1069
1069
  },
1070
+ {
1071
+ "type": "function",
1072
+ "function": {
1073
+ "name": "plan_company_setup",
1074
+ "description": (
1075
+ "PREVIEW a new company's chart of accounts — creates NOTHING. Use this "
1076
+ "when the user asks to set up a company / get started / create their books. "
1077
+ "Returns the proposed jurisdiction, currency, the full account tree, the "
1078
+ "sector-specific accounts that would be added, the company default-account "
1079
+ "wiring, and — importantly — `sector.guidance` (why the chart looks this way) "
1080
+ "and `sector.big_decisions` (points you MUST confirm with the user before "
1081
+ "applying). Call this first, walk the user through the plan and the big "
1082
+ "decisions in plain language, and only call `apply_company_setup` once they "
1083
+ "confirm.\n\n"
1084
+ "`sector` is one of: services, retail_pos, hospitality, distribution, "
1085
+ "import_export, manufacturing, construction. If you're unsure which fits, "
1086
+ "ASK the user about their business rather than guessing. `country` selects "
1087
+ "the jurisdiction chart. Available: 'generic' (international, USD) and 'CH' "
1088
+ "(Switzerland — Kontenrahmen KMU, German account names, CHF, with MWST tax "
1089
+ "templates). Any other country falls back to the generic chart — tell the "
1090
+ "user when `jurisdiction.is_fallback` is true."
1091
+ ),
1092
+ "parameters": {
1093
+ "type": "object",
1094
+ "properties": {
1095
+ "name": {"type": "string", "description": "The company name"},
1096
+ "sector": {
1097
+ "type": "string",
1098
+ "enum": ["services", "retail_pos", "hospitality", "distribution",
1099
+ "import_export", "manufacturing", "construction"],
1100
+ "description": "Operating-mode profile. Omit if the business is generic/unknown.",
1101
+ },
1102
+ "country": {"type": "string", "description": "Jurisdiction code, e.g. 'CH'. Defaults to generic."},
1103
+ "variant": {"type": "string", "description": "Sub-chart variant, e.g. 'skr03' (rarely needed)."},
1104
+ "currency": {"type": "string", "description": "Base currency, e.g. 'USD', 'CHF'. Defaults to the pack currency."},
1105
+ },
1106
+ "required": ["name"],
1107
+ },
1108
+ },
1109
+ },
1110
+ {
1111
+ "type": "function",
1112
+ "function": {
1113
+ "name": "apply_company_setup",
1114
+ "description": (
1115
+ "CREATE the company, its chart of accounts (base + sector overlay), the "
1116
+ "company default accounts, any jurisdiction tax accounts, and the default "
1117
+ "cost center — in one step. Only call this AFTER `plan_company_setup` and "
1118
+ "AFTER the user has explicitly confirmed the plan and any big decisions. "
1119
+ "Refuses if the company already has accounts (admin-only). Pass the SAME "
1120
+ "arguments you used for the plan the user approved."
1121
+ ),
1122
+ "parameters": {
1123
+ "type": "object",
1124
+ "properties": {
1125
+ "name": {"type": "string", "description": "The company name (must match the approved plan)"},
1126
+ "sector": {
1127
+ "type": "string",
1128
+ "enum": ["services", "retail_pos", "hospitality", "distribution",
1129
+ "import_export", "manufacturing", "construction"],
1130
+ },
1131
+ "country": {"type": "string"},
1132
+ "variant": {"type": "string"},
1133
+ "currency": {"type": "string"},
1134
+ },
1135
+ "required": ["name"],
1136
+ },
1137
+ },
1138
+ },
1070
1139
  ]
1071
1140
 
1072
1141
  # ---------------------------------------------------------------------------
@@ -1583,6 +1652,34 @@ def _handle_update_custom_analytics_report(args, user_info: dict | None = None,
1583
1652
  return row
1584
1653
 
1585
1654
 
1655
+ def _handle_plan_company_setup(args):
1656
+ from lambda_erp.accounting.setup import plan_company_setup
1657
+ name = (args.get("name") or "").strip()
1658
+ if not name:
1659
+ return {"error": "A company name is required to plan the setup."}
1660
+ return plan_company_setup(
1661
+ name,
1662
+ country=(args.get("country") or None),
1663
+ variant=(args.get("variant") or None),
1664
+ sector=(args.get("sector") or None),
1665
+ currency=(args.get("currency") or None),
1666
+ )
1667
+
1668
+
1669
+ def _handle_apply_company_setup(args):
1670
+ from lambda_erp.accounting.setup import apply_company_setup
1671
+ name = (args.get("name") or "").strip()
1672
+ if not name:
1673
+ return {"error": "A company name is required to apply the setup."}
1674
+ return apply_company_setup(
1675
+ name,
1676
+ country=(args.get("country") or None),
1677
+ variant=(args.get("variant") or None),
1678
+ sector=(args.get("sector") or None),
1679
+ currency=(args.get("currency") or None),
1680
+ )
1681
+
1682
+
1586
1683
  TOOL_HANDLERS = {
1587
1684
  "list_documents": _handle_list_documents,
1588
1685
  "get_document": _handle_get_document,
@@ -1606,6 +1703,8 @@ TOOL_HANDLERS = {
1606
1703
  "create_custom_analytics_report": lambda args: _handle_create_custom_analytics_report(args),
1607
1704
  "get_custom_analytics_report": lambda args: _handle_get_custom_analytics_report(args),
1608
1705
  "update_custom_analytics_report": lambda args: _handle_update_custom_analytics_report(args),
1706
+ "plan_company_setup": _handle_plan_company_setup,
1707
+ "apply_company_setup": _handle_apply_company_setup,
1609
1708
  }
1610
1709
 
1611
1710
  # ---------------------------------------------------------------------------
@@ -1747,6 +1846,21 @@ Always include the view link after creating, submitting, converting, or updating
1747
1846
  else:
1748
1847
  role_desc = "You have **viewer** access — you can view documents, master data, and reports, but you cannot create or modify data. If the user asks you to create or change something, let them know they need a manager or admin to do that."
1749
1848
 
1849
+ # Only admins can run setup, so only they get the wizard playbook (keeps the
1850
+ # prompt lean for everyone else). Triggered from chat OR the Tutorial page's
1851
+ # "Get started" button, which sends a set-up-my-company message.
1852
+ if user_role == "admin":
1853
+ company_setup_section = """## Guided company setup (chart of accounts)
1854
+ When the user wants to **set up a new company**, get started, or create their books/chart of accounts, run the guided setup — do NOT hand-create accounts one by one:
1855
+ 1. **Identify the business type.** Which of these fits: services/consulting, retail/POS, hospitality/F&B, distribution/wholesale, import/export, manufacturing, or construction? If the conversation doesn't make it obvious, ASK — never guess the sector.
1856
+ 2. **Preview with `plan_company_setup`** (company name + sector + country/currency if known). This creates NOTHING; it returns the proposed chart.
1857
+ 3. **Walk them through the plan** in plain language: the sector-specific accounts (`sector_added_accounts`) and the `sector.guidance` (why the chart is shaped this way). Present every item in `sector.big_decisions` and get an explicit yes/no on each — these are the decisions you must not make for them.
1858
+ 4. **Only after they confirm, call `apply_company_setup`** with the SAME arguments to create the company, accounts, defaults, and cost center.
1859
+ If `jurisdiction.is_fallback` is true, tell the user their country isn't localized yet and you're using the generic international chart for now.
1860
+ """
1861
+ else:
1862
+ company_setup_section = ""
1863
+
1750
1864
  return f"""You are an ERP assistant for Lambda ERP. Today's date is {date.today().isoformat()}.
1751
1865
 
1752
1866
  You help users manage their business by creating documents, looking up data, and running reports — all through natural conversation.
@@ -1768,7 +1882,7 @@ When you do build a custom report, pass a plain-language `intent` to `create_cus
1768
1882
  ## Current User
1769
1883
  You are speaking with **{user_name}** (role: **{user_role}**).
1770
1884
  {role_desc}
1771
-
1885
+ {company_setup_section}
1772
1886
  {company_context}
1773
1887
  {analytics_context}
1774
1888
  {uom_context}
@@ -2392,6 +2506,17 @@ async def run_thinking_loop(
2392
2506
  tool_handlers["update_master"] = denied
2393
2507
  tool_handlers["delete_master"] = denied
2394
2508
 
2509
+ # Company setup (chart of accounts, defaults, cost center) is admin-only,
2510
+ # mirroring POST /api/setup/company. Non-admins can still be *shown* a plan?
2511
+ # No — even the preview reveals structure they can't act on, so deny both.
2512
+ if user_role != "admin":
2513
+ setup_denied = lambda _args: {
2514
+ "error": "Setting up a company is an admin task. Ask an administrator "
2515
+ "to run the guided setup, or open it yourself from the Tutorial."
2516
+ }
2517
+ tool_handlers["plan_company_setup"] = setup_denied
2518
+ tool_handlers["apply_company_setup"] = setup_denied
2519
+
2395
2520
  # Scope session-aware tools without mutating globals.
2396
2521
  if session_id:
2397
2522
  tool_handlers["retrieve_chat_history"] = lambda args: _handle_retrieve_chat_history(args, session_id)
@@ -7,6 +7,12 @@ from fastapi import APIRouter, Depends
7
7
  from lambda_erp.database import get_db
8
8
  from lambda_erp.utils import _dict, flt, nowdate
9
9
  from lambda_erp.accounting.chart_of_accounts import setup_chart_of_accounts, setup_cost_center
10
+ from lambda_erp.accounting.setup import (
11
+ plan_company_setup,
12
+ apply_company_setup,
13
+ list_profiles,
14
+ list_packs,
15
+ )
10
16
  from api.auth import require_role
11
17
 
12
18
  # Sample US corporate addresses (streets, cities, states) used to auto-fill
@@ -94,17 +100,71 @@ def create_company(data: dict, _user: dict = Depends(require_role("admin"))):
94
100
  **contact,
95
101
  ))
96
102
 
97
- setup_chart_of_accounts(name, currency)
98
- cost_center = setup_cost_center(name)
103
+ # Route the chart through the localization-pack engine. The jurisdiction is
104
+ # taken from the company `country` (falls back to the generic pack for any
105
+ # unlocalized country), and an optional `sector` applies an operating-mode
106
+ # overlay. With neither, this is byte-identical to the legacy
107
+ # setup_chart_of_accounts + setup_cost_center path.
108
+ result = apply_company_setup(
109
+ name,
110
+ country=(contact.get("country") or None),
111
+ variant=(data.get("variant") or None),
112
+ sector=(data.get("sector") or None),
113
+ currency=currency,
114
+ )
115
+ if not result.get("ok"):
116
+ return {"detail": result.get("error", "setup failed")}
99
117
 
100
118
  return {
101
119
  "ok": True,
102
120
  "company": name,
103
- "cost_center": cost_center,
121
+ "cost_center": result["cost_center"],
104
122
  "currency": currency,
123
+ "jurisdiction": result["jurisdiction"],
124
+ "sector": result["sector"],
125
+ "accounts_created": result["accounts_created"],
126
+ "sector_added_accounts": result["sector_added_accounts"],
127
+ }
128
+
129
+
130
+ @router.get("/profiles")
131
+ def setup_profiles(_user: dict = Depends(require_role("viewer"))):
132
+ """List the available sector profiles and jurisdiction packs for setup."""
133
+ return {
134
+ "sectors": [
135
+ {
136
+ "key": p.key,
137
+ "label": p.label,
138
+ "summary": p.summary,
139
+ "big_decisions": p.big_decisions,
140
+ }
141
+ for p in list_profiles()
142
+ ],
143
+ "jurisdictions": [
144
+ {"key": p.key, "label": p.label, "currency": p.currency,
145
+ "has_standard_tax": p.setup_tax is not None}
146
+ for p in list_packs()
147
+ ],
105
148
  }
106
149
 
107
150
 
151
+ @router.post("/plan")
152
+ def setup_plan(data: dict, _user: dict = Depends(require_role("admin"))):
153
+ """Preview a company's chart of accounts without creating anything.
154
+
155
+ Expects: {"name": "...", "country": "CH"?, "variant": "skr03"?,
156
+ "sector": "manufacturing"?, "currency": "USD"?}
157
+ """
158
+ name = data.get("name") or "New Company"
159
+ return plan_company_setup(
160
+ name,
161
+ country=(data.get("country") or None),
162
+ variant=(data.get("variant") or None),
163
+ sector=(data.get("sector") or None),
164
+ currency=(data.get("currency") or None),
165
+ )
166
+
167
+
108
168
  @router.post("/seed-demo")
109
169
  def seed_demo(_user: dict = Depends(require_role("admin"))):
110
170
  """Seed demo master data (customers, suppliers, items, warehouse).
@@ -229,7 +289,13 @@ def import_account_balances(data: dict, _user: dict = Depends(require_role("admi
229
289
 
230
290
  db = get_db()
231
291
  abbr = company[:4].upper()
232
- equity_account = f"Opening Balance Equity - {abbr}"
292
+ # Read the balancing account from the company default rather than hardcoding
293
+ # the English name — localization packs use their own (e.g. the Swiss pack's
294
+ # "2990 Eröffnungsbilanz"). Mirrors the Opening Stock path in stock_entry.py.
295
+ equity_account = (
296
+ db.get_value("Company", company, "default_opening_balance_equity")
297
+ or f"Opening Balance Equity - {abbr}"
298
+ )
233
299
 
234
300
  accounts = []
235
301
  total_debit = 0
@@ -454,10 +454,13 @@ export const api = {
454
454
  request<Array<{
455
455
  id: string; name: string; user?: string; role: string; key_prefix: string;
456
456
  created_at?: string; last_used_at?: string | null; revoked: boolean;
457
+ owner_full_name?: string | null; owner_email?: string | null; is_mine?: boolean;
457
458
  }>>("/auth/api-keys"),
458
459
  createApiKey: (name: string, role: string) =>
459
- request<{ id: string; name: string; user?: string; role: string; key_prefix: string; token: string }>(
460
- "/auth/api-keys", { method: "POST", body: JSON.stringify({ name, role }) }),
460
+ request<{
461
+ id: string; name: string; user?: string; role: string; key_prefix: string; token: string;
462
+ owner_full_name?: string | null; owner_email?: string | null; is_mine?: boolean;
463
+ }>("/auth/api-keys", { method: "POST", body: JSON.stringify({ name, role }) }),
461
464
  revokeApiKey: (id: string) =>
462
465
  request<{ id: string; revoked: boolean }>(
463
466
  `/auth/api-keys/${encodeURIComponent(id)}/revoke`, { method: "POST" }),
@@ -111,21 +111,50 @@ STANDARD_CHART = {
111
111
  }
112
112
 
113
113
 
114
- def setup_chart_of_accounts(company_name, currency="USD"):
115
- """Create all accounts for a company from the standard chart.
114
+ # Company default-account wiring for the standard/generic chart, keyed
115
+ # {company_field: leaf_account_name} (un-suffixed). The company abbreviation
116
+ # suffix (" - ABBR") is appended at write time by apply_company_defaults. The
117
+ # generic localization pack reuses this so the new setup engine and this legacy
118
+ # path stay in lockstep.
119
+ STANDARD_DEFAULTS = {
120
+ "default_receivable_account": "Accounts Receivable",
121
+ "default_payable_account": "Accounts Payable",
122
+ "default_income_account": "Sales Revenue",
123
+ "default_expense_account": "Cost of Goods Sold",
124
+ "round_off_account": "Round Off",
125
+ "stock_in_hand_account": "Stock In Hand",
126
+ "stock_received_but_not_billed": "Stock Received But Not Billed",
127
+ "stock_adjustment_account": "Stock Adjustment",
128
+ "default_opening_balance_equity": "Opening Balance Equity",
129
+ "default_freight_in_account": "Freight In",
130
+ "default_customs_account": "Customs & Duties",
131
+ "default_exchange_gain_loss_account": "Exchange Gain/Loss",
132
+ "default_unrealized_exchange_account": "Unrealized Exchange Gain/Loss",
133
+ }
116
134
 
117
- This mirrors the reference implementation's setup wizard step that creates the Chart of Accounts
118
- from a template when a new company is created.
135
+
136
+ def account_abbr(company_name):
137
+ """The 4-char company suffix used across the chart (e.g. 'Lambda' -> 'LAMB')."""
138
+ return company_name[:4].upper()
139
+
140
+
141
+ def create_accounts_from_tree(company_name, tree, currency="USD"):
142
+ """Write an account tree for a company. Shared by the legacy setup and the
143
+ localization-pack engine so there is exactly one account writer.
144
+
145
+ ``tree`` is ``{account_name: {root_type?, report_type?, account_type?,
146
+ children?}}``. root_type / report_type inherit from the parent when omitted.
147
+ Does NOT commit — the caller owns the transaction boundary.
119
148
  """
120
149
  db = get_db()
121
150
 
122
- def _create_accounts(tree, parent=None, root_type=None, report_type=None):
123
- for account_name, details in tree.items():
151
+ def _create(subtree, parent=None, root_type=None, report_type=None):
152
+ for account_name, details in subtree.items():
124
153
  rt = details.get("root_type", root_type)
125
154
  rpt = details.get("report_type", report_type)
126
155
  has_children = "children" in details
127
156
 
128
- name = f"{account_name} - {company_name[:4].upper()}"
157
+ name = f"{account_name} - {account_abbr(company_name)}"
129
158
 
130
159
  account = _dict(
131
160
  name=name,
@@ -141,28 +170,35 @@ def setup_chart_of_accounts(company_name, currency="USD"):
141
170
  db.insert("Account", account)
142
171
 
143
172
  if has_children:
144
- _create_accounts(details["children"], parent=name, root_type=rt, report_type=rpt)
145
-
146
- _create_accounts(STANDARD_CHART)
147
-
148
- # Set up company defaults
149
- abbr = company_name[:4].upper()
150
- db.set_value("Company", company_name, {
151
- "default_receivable_account": f"Accounts Receivable - {abbr}",
152
- "default_payable_account": f"Accounts Payable - {abbr}",
153
- "default_income_account": f"Sales Revenue - {abbr}",
154
- "default_expense_account": f"Cost of Goods Sold - {abbr}",
155
- "round_off_account": f"Round Off - {abbr}",
156
- "stock_in_hand_account": f"Stock In Hand - {abbr}",
157
- "stock_received_but_not_billed": f"Stock Received But Not Billed - {abbr}",
158
- "stock_adjustment_account": f"Stock Adjustment - {abbr}",
159
- "default_opening_balance_equity": f"Opening Balance Equity - {abbr}",
160
- "default_freight_in_account": f"Freight In - {abbr}",
161
- "default_customs_account": f"Customs & Duties - {abbr}",
162
- "default_exchange_gain_loss_account": f"Exchange Gain/Loss - {abbr}",
163
- "default_unrealized_exchange_account": f"Unrealized Exchange Gain/Loss - {abbr}",
164
- })
173
+ _create(details["children"], parent=name, root_type=rt, report_type=rpt)
174
+
175
+ _create(tree)
176
+
165
177
 
178
+ def apply_company_defaults(company_name, defaults):
179
+ """Point Company default-account fields at the created leaf accounts.
180
+
181
+ ``defaults`` is ``{company_field: leaf_account_name}`` (un-suffixed); the
182
+ company suffix is appended here. Does NOT commit.
183
+ """
184
+ abbr = account_abbr(company_name)
185
+ resolved = {field: f"{leaf} - {abbr}" for field, leaf in defaults.items()}
186
+ if resolved:
187
+ db = get_db()
188
+ db.set_value("Company", company_name, resolved)
189
+
190
+
191
+ def setup_chart_of_accounts(company_name, currency="USD"):
192
+ """Create all accounts for a company from the standard chart.
193
+
194
+ This mirrors the reference implementation's setup wizard step that creates the Chart of Accounts
195
+ from a template when a new company is created. It is the jurisdiction-neutral
196
+ legacy path; the localization-pack engine (``lambda_erp.accounting.setup``)
197
+ builds on the same writer to support sector overlays and other jurisdictions.
198
+ """
199
+ db = get_db()
200
+ create_accounts_from_tree(company_name, STANDARD_CHART, currency)
201
+ apply_company_defaults(company_name, STANDARD_DEFAULTS)
166
202
  db.commit()
167
203
  return True
168
204