lambda-erp 0.8.21__tar.gz → 0.8.22__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 (94) hide show
  1. {lambda_erp-0.8.21 → lambda_erp-0.8.22}/PKG-INFO +2 -1
  2. {lambda_erp-0.8.21 → lambda_erp-0.8.22}/api/attachments.py +20 -2
  3. {lambda_erp-0.8.21 → lambda_erp-0.8.22}/api/chat.py +175 -4
  4. {lambda_erp-0.8.21 → lambda_erp-0.8.22}/api/main.py +2 -1
  5. lambda_erp-0.8.22/api/routers/bank_statements.py +118 -0
  6. {lambda_erp-0.8.21 → lambda_erp-0.8.22}/api/services.py +2 -0
  7. {lambda_erp-0.8.21 → lambda_erp-0.8.22}/frontend/src/api/client.ts +84 -0
  8. lambda_erp-0.8.22/lambda_erp/accounting/bank_account.py +77 -0
  9. lambda_erp-0.8.22/lambda_erp/accounting/bank_statement_import.py +417 -0
  10. {lambda_erp-0.8.21 → lambda_erp-0.8.22}/lambda_erp/accounting/bank_transaction.py +22 -3
  11. lambda_erp-0.8.22/lambda_erp/accounting/camt.py +457 -0
  12. {lambda_erp-0.8.21 → lambda_erp-0.8.22}/lambda_erp/database.py +176 -2
  13. {lambda_erp-0.8.21 → lambda_erp-0.8.22}/pyproject.toml +2 -1
  14. {lambda_erp-0.8.21 → lambda_erp-0.8.22}/.gitignore +0 -0
  15. {lambda_erp-0.8.21 → lambda_erp-0.8.22}/LICENSE +0 -0
  16. {lambda_erp-0.8.21 → lambda_erp-0.8.22}/README.md +0 -0
  17. {lambda_erp-0.8.21 → lambda_erp-0.8.22}/api/__init__.py +0 -0
  18. {lambda_erp-0.8.21 → lambda_erp-0.8.22}/api/auth.py +0 -0
  19. {lambda_erp-0.8.21 → lambda_erp-0.8.22}/api/bootstrap.py +0 -0
  20. {lambda_erp-0.8.21 → lambda_erp-0.8.22}/api/demo_limits.py +0 -0
  21. {lambda_erp-0.8.21 → lambda_erp-0.8.22}/api/deps.py +0 -0
  22. {lambda_erp-0.8.21 → lambda_erp-0.8.22}/api/errors.py +0 -0
  23. {lambda_erp-0.8.21 → lambda_erp-0.8.22}/api/list_values.py +0 -0
  24. {lambda_erp-0.8.21 → lambda_erp-0.8.22}/api/oauth.py +0 -0
  25. {lambda_erp-0.8.21 → lambda_erp-0.8.22}/api/pdf.py +0 -0
  26. {lambda_erp-0.8.21 → lambda_erp-0.8.22}/api/providers.py +0 -0
  27. {lambda_erp-0.8.21 → lambda_erp-0.8.22}/api/remarks_md.py +0 -0
  28. {lambda_erp-0.8.21 → lambda_erp-0.8.22}/api/routers/__init__.py +0 -0
  29. {lambda_erp-0.8.21 → lambda_erp-0.8.22}/api/routers/accounting.py +0 -0
  30. {lambda_erp-0.8.21 → lambda_erp-0.8.22}/api/routers/actions.py +0 -0
  31. {lambda_erp-0.8.21 → lambda_erp-0.8.22}/api/routers/admin.py +0 -0
  32. {lambda_erp-0.8.21 → lambda_erp-0.8.22}/api/routers/analytics.py +0 -0
  33. {lambda_erp-0.8.21 → lambda_erp-0.8.22}/api/routers/availability.py +0 -0
  34. {lambda_erp-0.8.21 → lambda_erp-0.8.22}/api/routers/bank_reconciliation.py +0 -0
  35. {lambda_erp-0.8.21 → lambda_erp-0.8.22}/api/routers/chat_api.py +0 -0
  36. {lambda_erp-0.8.21 → lambda_erp-0.8.22}/api/routers/documents.py +0 -0
  37. {lambda_erp-0.8.21 → lambda_erp-0.8.22}/api/routers/masters.py +0 -0
  38. {lambda_erp-0.8.21 → lambda_erp-0.8.22}/api/routers/mcp.py +0 -0
  39. {lambda_erp-0.8.21 → lambda_erp-0.8.22}/api/routers/proposals.py +0 -0
  40. {lambda_erp-0.8.21 → lambda_erp-0.8.22}/api/routers/reports.py +0 -0
  41. {lambda_erp-0.8.21 → lambda_erp-0.8.22}/api/routers/setup.py +0 -0
  42. {lambda_erp-0.8.21 → lambda_erp-0.8.22}/api/templates/document.html +0 -0
  43. {lambda_erp-0.8.21 → lambda_erp-0.8.22}/api/templates/proposal.html +0 -0
  44. {lambda_erp-0.8.21 → lambda_erp-0.8.22}/docs/agents/README.md +0 -0
  45. {lambda_erp-0.8.21 → lambda_erp-0.8.22}/frontend/LICENSE +0 -0
  46. {lambda_erp-0.8.21 → lambda_erp-0.8.22}/frontend/README.md +0 -0
  47. {lambda_erp-0.8.21 → lambda_erp-0.8.22}/lambda_erp/__init__.py +0 -0
  48. {lambda_erp-0.8.21 → lambda_erp-0.8.22}/lambda_erp/accounting/__init__.py +0 -0
  49. {lambda_erp-0.8.21 → lambda_erp-0.8.22}/lambda_erp/accounting/budget.py +0 -0
  50. {lambda_erp-0.8.21 → lambda_erp-0.8.22}/lambda_erp/accounting/chart_of_accounts.py +0 -0
  51. {lambda_erp-0.8.21 → lambda_erp-0.8.22}/lambda_erp/accounting/general_ledger.py +0 -0
  52. {lambda_erp-0.8.21 → lambda_erp-0.8.22}/lambda_erp/accounting/journal_entry.py +0 -0
  53. {lambda_erp-0.8.21 → lambda_erp-0.8.22}/lambda_erp/accounting/payment_entry.py +0 -0
  54. {lambda_erp-0.8.21 → lambda_erp-0.8.22}/lambda_erp/accounting/pos_invoice.py +0 -0
  55. {lambda_erp-0.8.21 → lambda_erp-0.8.22}/lambda_erp/accounting/purchase_invoice.py +0 -0
  56. {lambda_erp-0.8.21 → lambda_erp-0.8.22}/lambda_erp/accounting/revaluation.py +0 -0
  57. {lambda_erp-0.8.21 → lambda_erp-0.8.22}/lambda_erp/accounting/sales_invoice.py +0 -0
  58. {lambda_erp-0.8.21 → lambda_erp-0.8.22}/lambda_erp/accounting/setup/__init__.py +0 -0
  59. {lambda_erp-0.8.21 → lambda_erp-0.8.22}/lambda_erp/accounting/setup/engine.py +0 -0
  60. {lambda_erp-0.8.21 → lambda_erp-0.8.22}/lambda_erp/accounting/setup/pack.py +0 -0
  61. {lambda_erp-0.8.21 → lambda_erp-0.8.22}/lambda_erp/accounting/setup/packs/__init__.py +0 -0
  62. {lambda_erp-0.8.21 → lambda_erp-0.8.22}/lambda_erp/accounting/setup/packs/ch.py +0 -0
  63. {lambda_erp-0.8.21 → lambda_erp-0.8.22}/lambda_erp/accounting/setup/packs/de_common.py +0 -0
  64. {lambda_erp-0.8.21 → lambda_erp-0.8.22}/lambda_erp/accounting/setup/packs/de_skr03.py +0 -0
  65. {lambda_erp-0.8.21 → lambda_erp-0.8.22}/lambda_erp/accounting/setup/packs/de_skr04.py +0 -0
  66. {lambda_erp-0.8.21 → lambda_erp-0.8.22}/lambda_erp/accounting/setup/packs/generic.py +0 -0
  67. {lambda_erp-0.8.21 → lambda_erp-0.8.22}/lambda_erp/accounting/setup/profiles.py +0 -0
  68. {lambda_erp-0.8.21 → lambda_erp-0.8.22}/lambda_erp/accounting/setup/spine.py +0 -0
  69. {lambda_erp-0.8.21 → lambda_erp-0.8.22}/lambda_erp/accounting/subscription.py +0 -0
  70. {lambda_erp-0.8.21 → lambda_erp-0.8.22}/lambda_erp/assets/__init__.py +0 -0
  71. {lambda_erp-0.8.21 → lambda_erp-0.8.22}/lambda_erp/assets/asset.py +0 -0
  72. {lambda_erp-0.8.21 → lambda_erp-0.8.22}/lambda_erp/assets/reservation.py +0 -0
  73. {lambda_erp-0.8.21 → lambda_erp-0.8.22}/lambda_erp/buying/__init__.py +0 -0
  74. {lambda_erp-0.8.21 → lambda_erp-0.8.22}/lambda_erp/buying/purchase_order.py +0 -0
  75. {lambda_erp-0.8.21 → lambda_erp-0.8.22}/lambda_erp/controllers/__init__.py +0 -0
  76. {lambda_erp-0.8.21 → lambda_erp-0.8.22}/lambda_erp/controllers/currency.py +0 -0
  77. {lambda_erp-0.8.21 → lambda_erp-0.8.22}/lambda_erp/controllers/defaults.py +0 -0
  78. {lambda_erp-0.8.21 → lambda_erp-0.8.22}/lambda_erp/controllers/pricing_rule.py +0 -0
  79. {lambda_erp-0.8.21 → lambda_erp-0.8.22}/lambda_erp/controllers/taxes_and_totals.py +0 -0
  80. {lambda_erp-0.8.21 → lambda_erp-0.8.22}/lambda_erp/exceptions.py +0 -0
  81. {lambda_erp-0.8.21 → lambda_erp-0.8.22}/lambda_erp/hooks.py +0 -0
  82. {lambda_erp-0.8.21 → lambda_erp-0.8.22}/lambda_erp/model.py +0 -0
  83. {lambda_erp-0.8.21 → lambda_erp-0.8.22}/lambda_erp/selling/__init__.py +0 -0
  84. {lambda_erp-0.8.21 → lambda_erp-0.8.22}/lambda_erp/selling/proposal.py +0 -0
  85. {lambda_erp-0.8.21 → lambda_erp-0.8.22}/lambda_erp/selling/quotation.py +0 -0
  86. {lambda_erp-0.8.21 → lambda_erp-0.8.22}/lambda_erp/selling/sales_order.py +0 -0
  87. {lambda_erp-0.8.21 → lambda_erp-0.8.22}/lambda_erp/simulation.py +0 -0
  88. {lambda_erp-0.8.21 → lambda_erp-0.8.22}/lambda_erp/stock/__init__.py +0 -0
  89. {lambda_erp-0.8.21 → lambda_erp-0.8.22}/lambda_erp/stock/delivery_note.py +0 -0
  90. {lambda_erp-0.8.21 → lambda_erp-0.8.22}/lambda_erp/stock/purchase_receipt.py +0 -0
  91. {lambda_erp-0.8.21 → lambda_erp-0.8.22}/lambda_erp/stock/stock_entry.py +0 -0
  92. {lambda_erp-0.8.21 → lambda_erp-0.8.22}/lambda_erp/stock/stock_ledger.py +0 -0
  93. {lambda_erp-0.8.21 → lambda_erp-0.8.22}/lambda_erp/utils.py +0 -0
  94. {lambda_erp-0.8.21 → lambda_erp-0.8.22}/terraform/README.md +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: lambda-erp
3
- Version: 0.8.21
3
+ Version: 0.8.22
4
4
  Summary: Core ERP logic - accounting, sales, purchasing, inventory
5
5
  Author: TORUS INVESTMENTS AG
6
6
  License-Expression: Apache-2.0
@@ -8,6 +8,7 @@ License-File: LICENSE
8
8
  Requires-Python: >=3.10
9
9
  Requires-Dist: anthropic>=0.40
10
10
  Requires-Dist: bcrypt>=5
11
+ Requires-Dist: defusedxml>=0.7
11
12
  Requires-Dist: fastapi>=0.115
12
13
  Requires-Dist: holidays>=0.40
13
14
  Requires-Dist: httpx>=0.27
@@ -49,10 +49,17 @@ OFFICE_MIME_TYPES = {
49
49
  # Plain-text formats — inlined into the prompt as text (no file part needed).
50
50
  TEXT_MIME_TYPES = {"text/csv", "text/plain"}
51
51
 
52
+ # Structured bank uploads are retained as ordinary chat attachments, but are
53
+ # never dumped wholesale into the LLM context. Dedicated deterministic tools
54
+ # parse them and return a compact preview instead.
55
+ STRUCTURED_TOOL_MIME_TYPES = {
56
+ "application/xml", "text/xml", "application/zip", "application/x-zip-compressed",
57
+ }
58
+
52
59
  ALLOWED_MIME_TYPES = {
53
60
  "image/png", "image/jpeg", "image/gif", "image/webp",
54
61
  "application/pdf",
55
- } | OFFICE_MIME_TYPES | TEXT_MIME_TYPES
62
+ } | OFFICE_MIME_TYPES | TEXT_MIME_TYPES | STRUCTURED_TOOL_MIME_TYPES
56
63
 
57
64
  # Extension -> canonical mime, so a correctly-named file still uploads when the
58
65
  # browser/OS mislabels its type (Office files are frequently sent as
@@ -69,6 +76,8 @@ _EXT_TO_MIME = {
69
76
  "odp": "application/vnd.oasis.opendocument.presentation",
70
77
  "csv": "text/csv",
71
78
  "txt": "text/plain",
79
+ "xml": "application/xml",
80
+ "zip": "application/zip",
72
81
  "pdf": "application/pdf",
73
82
  "png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg",
74
83
  "gif": "image/gif", "webp": "image/webp",
@@ -240,7 +249,8 @@ async def upload_attachment(
240
249
  raise HTTPException(
241
250
  status_code=400,
242
251
  detail=(f"Unsupported file type: {mime or ext or 'unknown'}. Allowed: images, PDF, "
243
- "spreadsheets (Excel/CSV/ODS), and documents (Word/OpenDocument)."),
252
+ "spreadsheets (Excel/CSV/ODS), documents (Word/OpenDocument), "
253
+ "and structured bank files (XML/ZIP)."),
244
254
  )
245
255
 
246
256
  data = await file.read()
@@ -389,6 +399,14 @@ def build_multimodal_content(attachment: dict) -> dict:
389
399
  "type": "image_url",
390
400
  "image_url": {"url": f"data:{mime};base64,{data_b64}"},
391
401
  }
402
+ if mime in STRUCTURED_TOOL_MIME_TYPES:
403
+ return {
404
+ "type": "text",
405
+ "text": (
406
+ f"[Structured attachment: {filename}. Do not retrieve or parse its raw contents in "
407
+ "the conversation; use the dedicated bank-statement preview tool.]"
408
+ ),
409
+ }
392
410
  if mime in TEXT_MIME_TYPES or mime.startswith("text/"):
393
411
  text = attachment["data"].decode("utf-8", errors="replace")
394
412
  return {"type": "text", "text": f"[File: {filename}]\n{text}"}
@@ -448,7 +448,7 @@ DOCUMENT_SLUGS = [
448
448
  "purchase-order", "purchase-invoice",
449
449
  "payment-entry", "journal-entry", "stock-entry",
450
450
  "delivery-note", "purchase-receipt", "pos-invoice",
451
- "pricing-rule", "budget", "subscription", "bank-transaction",
451
+ "pricing-rule", "budget", "subscription", "bank-account", "bank-transaction",
452
452
  # Owned equipment and its hire calendar. Core doctypes, so they belong in
453
453
  # this list rather than surfacing as "deployment-specific extensions"
454
454
  # (_extra_document_slugs is defined as everything NOT named here).
@@ -814,10 +814,58 @@ TOOLS = [
814
814
  "type": "function",
815
815
  "function": {
816
816
  "name": "list_chat_attachments",
817
- "description": "List all files the user has uploaded in this chat session — PDFs, images, spreadsheets (Excel/CSV/ODS), and documents (Word/OpenDocument). Returns metadata including id, filename, mime type, size, and upload date. Use this when the user references a previously uploaded file or you need to find an attachment to retrieve.",
817
+ "description": "List all files the user has uploaded in this chat session — PDFs, images, spreadsheets (Excel/CSV/ODS), documents (Word/OpenDocument), and structured bank files (CAMT XML/ZIP). Returns metadata including id, filename, mime type, size, and upload date. Use this when the user references a previously uploaded file or you need its attachment id.",
818
818
  "parameters": {"type": "object", "properties": {}},
819
819
  },
820
820
  },
821
+ {
822
+ "type": "function",
823
+ "function": {
824
+ "name": "preview_bank_statement_attachments",
825
+ "description": "Safely parse CAMT.053 XML or ZIP chat attachments and return a compact, read-only import preview: masked account, currency, period, balances, entry/detail/batch/QR counts, duplicates, warnings, and any exact Bank Account mapping. The raw XML is never added to the model context. Always call this before importing bank statements.",
826
+ "parameters": {
827
+ "type": "object",
828
+ "properties": {
829
+ "attachment_ids": {
830
+ "type": "array",
831
+ "items": {"type": "string"},
832
+ "minItems": 1,
833
+ "maxItems": 20,
834
+ "description": "CAMT XML/ZIP attachment ids from this chat.",
835
+ },
836
+ },
837
+ "required": ["attachment_ids"],
838
+ },
839
+ },
840
+ },
841
+ {
842
+ "type": "function",
843
+ "function": {
844
+ "name": "import_bank_statement_attachments",
845
+ "description": "Import previously previewed CAMT attachments as Bank Transactions without creating or posting any Payment Entry, Journal Entry, or GL entry. Exact file re-uploads and stable duplicate bank entries are skipped. Use only after showing the preview and receiving explicit user confirmation. Requires manager access and is unavailable in public demo mode.",
846
+ "parameters": {
847
+ "type": "object",
848
+ "properties": {
849
+ "attachment_ids": {
850
+ "type": "array",
851
+ "items": {"type": "string"},
852
+ "minItems": 1,
853
+ "maxItems": 20,
854
+ },
855
+ "mappings": {
856
+ "type": "object",
857
+ "additionalProperties": {"type": "string"},
858
+ "description": "Optional mapping of statement_key from the preview to Bank Account document name. Exact IBAN matches are used automatically.",
859
+ },
860
+ "confirmed": {
861
+ "type": "boolean",
862
+ "description": "Must be true, and only after the user explicitly confirmed the displayed preview.",
863
+ },
864
+ },
865
+ "required": ["attachment_ids", "confirmed"],
866
+ },
867
+ },
868
+ },
821
869
  {
822
870
  "type": "function",
823
871
  "function": {
@@ -1302,15 +1350,20 @@ def build_tools(user_info: dict | None = None):
1302
1350
  extra_docs = _extra_document_slugs()
1303
1351
  extra_masters = _extra_master_types()
1304
1352
  action_tools = services.registered_action_tools()
1353
+ hidden_tools: set[str] = set()
1305
1354
  if user_info is not None:
1306
1355
  role = user_info.get("role")
1307
1356
  action_tools = [
1308
1357
  tool for tool in action_tools
1309
1358
  if services.registered_action_allowed(tool["function"]["name"], role)
1310
1359
  ]
1311
- if not extra_docs and not extra_masters and not action_tools:
1360
+ if role not in {"manager", "admin"}:
1361
+ hidden_tools.add("import_bank_statement_attachments")
1362
+ if not extra_docs and not extra_masters and not action_tools and not hidden_tools:
1312
1363
  return TOOLS
1313
1364
  tools = copy.deepcopy(TOOLS)
1365
+ if hidden_tools:
1366
+ tools = [tool for tool in tools if tool["function"]["name"] not in hidden_tools]
1314
1367
  for tool in tools:
1315
1368
  props = tool["function"].get("parameters", {}).get("properties", {})
1316
1369
  for schema in props.values():
@@ -1813,6 +1866,98 @@ def _handle_retrieve_chat_attachment(args, session_id: str | None = None, user_i
1813
1866
  }
1814
1867
 
1815
1868
 
1869
+ def _load_camt_chat_attachments(args, user_id: str | None):
1870
+ attachment_ids = args.get("attachment_ids") or []
1871
+ if not user_id:
1872
+ return None, {"error": "No authenticated user for bank statement attachments."}
1873
+ if not isinstance(attachment_ids, list) or not 1 <= len(attachment_ids) <= 20:
1874
+ return None, {"error": "attachment_ids must contain between 1 and 20 attachment ids."}
1875
+ from api.attachments import get_attachments_by_ids
1876
+ from lambda_erp.accounting.camt import CamtError, parse_camt_upload
1877
+
1878
+ attachments = get_attachments_by_ids(attachment_ids, user_id)
1879
+ found = {item["id"] for item in attachments}
1880
+ missing = [item for item in attachment_ids if item not in found]
1881
+ if missing:
1882
+ return None, {"error": "One or more attachments were not found or access was denied."}
1883
+ documents = []
1884
+ try:
1885
+ for attachment in attachments:
1886
+ documents.extend(parse_camt_upload(attachment["data"], attachment["filename"]))
1887
+ except CamtError as exc:
1888
+ return None, {"error": str(exc)}
1889
+ return documents, None
1890
+
1891
+
1892
+ def _handle_preview_bank_statement_attachments(args, user_id: str | None = None):
1893
+ documents, error = _load_camt_chat_attachments(args, user_id)
1894
+ if error:
1895
+ return error
1896
+ from lambda_erp.accounting.bank_statement_import import preview_documents
1897
+ return preview_documents(documents)
1898
+
1899
+
1900
+ def _handle_import_bank_statement_attachments(args, user_info: dict | None = None):
1901
+ role = (user_info or {}).get("role")
1902
+ if role not in {"manager", "admin"}:
1903
+ return {"error": "Importing bank statements requires manager access and is unavailable in demo mode."}
1904
+ if args.get("confirmed") is not True:
1905
+ return {
1906
+ "error": (
1907
+ "Explicit confirmation is required. Show the bank statement preview, ask the user "
1908
+ "to confirm it, and call the import tool in a later turn with confirmed=true."
1909
+ )
1910
+ }
1911
+ user_id = (user_info or {}).get("name")
1912
+ documents, error = _load_camt_chat_attachments(args, user_id)
1913
+ if error:
1914
+ return error
1915
+ from lambda_erp.accounting.bank_statement_import import import_documents, preview_documents
1916
+ from lambda_erp.exceptions import ValidationError
1917
+
1918
+ mappings = args.get("mappings") or {}
1919
+ if not isinstance(mappings, dict):
1920
+ return {"error": "mappings must be an object of statement_key to Bank Account name."}
1921
+ # Exact IBAN matches are safe to fill automatically; anything ambiguous or
1922
+ # unmapped is returned to the model as a focused question for the user.
1923
+ preview = preview_documents(documents)
1924
+ effective_mappings = dict(mappings)
1925
+ missing = []
1926
+ for statement in preview["statements"]:
1927
+ if statement["already_imported"]:
1928
+ continue
1929
+ key = statement["statement_key"]
1930
+ matched = statement.get("matched_bank_account")
1931
+ if key not in effective_mappings and matched:
1932
+ effective_mappings[key] = matched["name"]
1933
+ if key not in effective_mappings:
1934
+ missing.append({
1935
+ "statement_key": key,
1936
+ "file_name": statement["file_name"],
1937
+ "account_iban_masked": statement["account_iban_masked"],
1938
+ "currency": statement["account_currency"],
1939
+ })
1940
+ if missing:
1941
+ return {
1942
+ "error": "Bank Account mapping is required before import.",
1943
+ "unmapped_statements": missing,
1944
+ }
1945
+ try:
1946
+ result = import_documents(
1947
+ documents, effective_mappings, imported_by=user_id
1948
+ )
1949
+ except ValidationError as exc:
1950
+ return {"error": str(exc)}
1951
+ # Keep the model context bounded even for multi-year statements.
1952
+ return {
1953
+ "imports": [
1954
+ {key: value for key, value in item.items() if key != "transactions"}
1955
+ for item in result["imports"]
1956
+ ],
1957
+ "exact_duplicate_imports": result["exact_duplicate_imports"],
1958
+ }
1959
+
1960
+
1816
1961
  def _handle_query_dataset(args):
1817
1962
  from api.routers.analytics import aggregate_semantic_dataset
1818
1963
 
@@ -1970,6 +2115,8 @@ TOOL_HANDLERS = {
1970
2115
  "retrieve_chat_history": lambda args: _handle_retrieve_chat_history(args),
1971
2116
  "list_chat_attachments": _handle_list_chat_attachments,
1972
2117
  "retrieve_chat_attachment": _handle_retrieve_chat_attachment,
2118
+ "preview_bank_statement_attachments": _handle_preview_bank_statement_attachments,
2119
+ "import_bank_statement_attachments": _handle_import_bank_statement_attachments,
1973
2120
  "query_dataset": _handle_query_dataset,
1974
2121
  "create_custom_analytics_report": lambda args: _handle_create_custom_analytics_report(args),
1975
2122
  "get_custom_analytics_report": lambda args: _handle_get_custom_analytics_report(args),
@@ -2203,13 +2350,31 @@ When a user asks you to do something they don't have permission for, explain wha
2203
2350
  ## Available Document Types (use the slug when calling tools)
2204
2351
  - **Selling:** quotation, proposal, sales-order, sales-invoice, pos-invoice
2205
2352
  - **Buying:** purchase-order, purchase-invoice
2206
- - **Accounting:** payment-entry, journal-entry, budget, subscription, bank-transaction
2353
+ - **Accounting:** payment-entry, journal-entry, budget, subscription, bank-account, bank-transaction
2207
2354
  - **Stock:** stock-entry, delivery-note, purchase-receipt
2208
2355
  - **Assets & hire:** asset, reservation
2209
2356
  - **Settings:** pricing-rule{extension_doctypes_line}{chat_doctype_section}
2210
2357
 
2211
2358
  ## Document Workflow & What Each Document Does
2212
2359
 
2360
+ ### CAMT bank statement imports
2361
+ CAMT XML/ZIP attachments are structured financial inputs. Never retrieve their raw contents into
2362
+ the conversation and never try to interpret the XML manually. Use this workflow:
2363
+ 1. Find the attachment ids with `list_chat_attachments` when necessary.
2364
+ 2. Call `preview_bank_statement_attachments` and present its period, masked account, currency,
2365
+ opening/closing balances, credits/debits, entry/detail/batch counts, QR-reference count,
2366
+ duplicate status, warnings, and proposed Bank Account mapping.
2367
+ 3. If there is no exact mapping, list or create a `bank-account` document. It must link the external
2368
+ IBAN to the correct Company and GL Account; never guess that accounting mapping.
2369
+ 4. Ask the user for explicit confirmation after showing the preview. Do not call the import tool in
2370
+ the same turn in which you first show the preview.
2371
+ 5. Only after that confirmation, call `import_bank_statement_attachments` with `confirmed=true`.
2372
+
2373
+ The CAMT import creates unposted Bank Transactions and their batch details only. It never creates,
2374
+ submits, or posts a Payment Entry, Journal Entry, or GL Entry. Do not imply that invoices have been
2375
+ settled or the ledger changed merely because a statement was imported. Matching/reconciliation is a
2376
+ separate later step. Keep Bank Account IBANs masked in ordinary chat responses.
2377
+
2213
2378
  ### Sales Cycle
2214
2379
  Quotation → Sales Order → Delivery Note (shipping) / Sales Invoice (billing) → Payment Entry
2215
2380
  Shortcuts: Quotation can also convert directly to Sales Invoice or Delivery Note (skipping Sales Order) for quick deals.
@@ -3042,6 +3207,12 @@ async def run_thinking_loop(
3042
3207
  tool_handlers["retrieve_chat_history"] = lambda args: _handle_retrieve_chat_history(args, session_id)
3043
3208
  user_id_for_tools = user_info.get("name") if user_info else None
3044
3209
  tool_handlers["list_chat_attachments"] = lambda args: _handle_list_chat_attachments(args, session_id, user_id_for_tools)
3210
+ tool_handlers["preview_bank_statement_attachments"] = (
3211
+ lambda args: _handle_preview_bank_statement_attachments(args, user_id_for_tools)
3212
+ )
3213
+ tool_handlers["import_bank_statement_attachments"] = (
3214
+ lambda args: _handle_import_bank_statement_attachments(args, user_info)
3215
+ )
3045
3216
  _scoped_retrieve_attachment = (
3046
3217
  lambda args: _handle_retrieve_chat_attachment(args, session_id, user_id_for_tools)
3047
3218
  )
@@ -19,7 +19,7 @@ from api.auth import router as auth_router, COOKIE_NAME, decode_token
19
19
  from api.oauth import router as oauth_router
20
20
  from api.attachments import router as attachments_router
21
21
  from api.chat import chat_websocket, router as chat_router
22
- from api.routers import admin, documents, masters, reports, setup as setup_router, bank_reconciliation, analytics, accounting, proposals, chat_api, mcp, availability, actions
22
+ from api.routers import admin, documents, masters, reports, setup as setup_router, bank_reconciliation, bank_statements, analytics, accounting, proposals, chat_api, mcp, availability, actions
23
23
 
24
24
 
25
25
  def load_plugins() -> None:
@@ -112,6 +112,7 @@ app.include_router(reports.router, prefix="/api")
112
112
  app.include_router(analytics.router, prefix="/api")
113
113
  app.include_router(setup_router.router, prefix="/api")
114
114
  app.include_router(bank_reconciliation.router, prefix="/api")
115
+ app.include_router(bank_statements.router, prefix="/api")
115
116
  app.include_router(accounting.router, prefix="/api")
116
117
  app.include_router(admin.router, prefix="/api")
117
118
  app.include_router(chat_router, prefix="/api")
@@ -0,0 +1,118 @@
1
+ """Manual CAMT statement preview, import history, and audit-file access."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from urllib.parse import quote
7
+
8
+ from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
9
+ from fastapi.responses import Response
10
+
11
+ from api.auth import require_non_public_manager, require_role
12
+ from lambda_erp.accounting.bank_statement_import import import_documents, preview_documents
13
+ from lambda_erp.accounting.camt import CamtError, MAX_ARCHIVE_BYTES, mask_iban, parse_camt_upload
14
+ from lambda_erp.database import get_db
15
+ from lambda_erp.exceptions import ValidationError
16
+
17
+
18
+ router = APIRouter(prefix="/bank-statements", tags=["bank-statements"])
19
+ MAX_TOTAL_UPLOAD_BYTES = 50 * 1024 * 1024
20
+
21
+
22
+ async def _parse_uploads(files: list[UploadFile]):
23
+ if not files:
24
+ raise HTTPException(status_code=422, detail="Select at least one CAMT XML or ZIP file")
25
+ documents = []
26
+ total = 0
27
+ for upload in files:
28
+ data = await upload.read(MAX_ARCHIVE_BYTES + 1)
29
+ total += len(data)
30
+ if len(data) > MAX_ARCHIVE_BYTES:
31
+ raise HTTPException(status_code=413, detail=f"File {upload.filename!r} is too large")
32
+ if total > MAX_TOTAL_UPLOAD_BYTES:
33
+ raise HTTPException(status_code=413, detail="Combined uploads are too large")
34
+ try:
35
+ documents.extend(parse_camt_upload(data, upload.filename or "statement.xml"))
36
+ except CamtError as exc:
37
+ raise HTTPException(status_code=422, detail=f"{upload.filename}: {exc}") from exc
38
+ return documents
39
+
40
+
41
+ @router.post("/preview")
42
+ async def preview_bank_statements(
43
+ files: list[UploadFile] = File(...),
44
+ _user: dict = Depends(require_non_public_manager),
45
+ ):
46
+ """Parse uploads without writing any statement or transaction data."""
47
+ return preview_documents(await _parse_uploads(files))
48
+
49
+
50
+ @router.post("/import")
51
+ async def import_bank_statements(
52
+ files: list[UploadFile] = File(...),
53
+ mappings: str = Form(...),
54
+ user: dict = Depends(require_non_public_manager),
55
+ ):
56
+ """Re-parse and atomically import the previewed uploads."""
57
+ try:
58
+ parsed_mappings = json.loads(mappings)
59
+ except (TypeError, json.JSONDecodeError) as exc:
60
+ raise HTTPException(status_code=422, detail="mappings must be a JSON object") from exc
61
+ if not isinstance(parsed_mappings, dict) or not all(
62
+ isinstance(key, str) and isinstance(value, str)
63
+ for key, value in parsed_mappings.items()
64
+ ):
65
+ raise HTTPException(status_code=422, detail="mappings must map statement keys to Bank Accounts")
66
+ try:
67
+ return import_documents(
68
+ await _parse_uploads(files), parsed_mappings, imported_by=user.get("name")
69
+ )
70
+ except ValidationError as exc:
71
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
72
+
73
+
74
+ @router.get("")
75
+ def list_bank_statement_imports(
76
+ limit: int = 50,
77
+ _user: dict = Depends(require_role("viewer")),
78
+ ):
79
+ limit = max(1, min(limit, 200))
80
+ rows = get_db().sql(
81
+ 'SELECT i.name, i.bank_account, b.account_name AS bank_account_name, '
82
+ 'i.source_filename, i.schema_version, i.account_iban, i.currency, '
83
+ 'i.from_date, i.to_date, i.opening_balance, i.closing_balance, '
84
+ 'i.imported_entry_count, i.duplicate_entry_count, i.warning_count, '
85
+ 'i.status, i.imported_by, i.creation '
86
+ 'FROM "Bank Statement Import" i '
87
+ 'LEFT JOIN "Bank Account" b ON b.name = i.bank_account '
88
+ 'ORDER BY i.creation DESC LIMIT ?',
89
+ [limit],
90
+ )
91
+ result = []
92
+ for row in rows:
93
+ item = dict(row)
94
+ item["account_iban_masked"] = mask_iban(item.pop("account_iban") or "")
95
+ result.append(item)
96
+ return {"rows": result}
97
+
98
+
99
+ @router.get("/{import_name}/source")
100
+ def download_bank_statement_source(
101
+ import_name: str,
102
+ _user: dict = Depends(require_non_public_manager),
103
+ ):
104
+ rows = get_db().sql(
105
+ 'SELECT i.source_filename, s.source_data '
106
+ 'FROM "Bank Statement Import" i '
107
+ 'JOIN "Bank Statement Source" s ON s.import_name = i.name '
108
+ 'WHERE i.name = ?',
109
+ [import_name],
110
+ )
111
+ if not rows:
112
+ raise HTTPException(status_code=404, detail="Bank statement source not found")
113
+ filename = rows[0]["source_filename"] or f"{import_name}.xml"
114
+ return Response(
115
+ content=bytes(rows[0]["source_data"]),
116
+ media_type="application/xml",
117
+ headers={"Content-Disposition": f"attachment; filename*=UTF-8''{quote(filename)}"},
118
+ )
@@ -25,6 +25,7 @@ from lambda_erp.controllers.pricing_rule import PricingRule
25
25
  from lambda_erp.accounting.budget import Budget
26
26
  from lambda_erp.accounting.subscription import Subscription
27
27
  from lambda_erp.accounting.bank_transaction import BankTransaction
28
+ from lambda_erp.accounting.bank_account import BankAccount
28
29
  from lambda_erp.assets.asset import Asset
29
30
  from lambda_erp.assets.reservation import Reservation
30
31
 
@@ -48,6 +49,7 @@ DOCUMENT_CLASSES = {
48
49
  "Budget": Budget,
49
50
  "Subscription": Subscription,
50
51
  "Bank Transaction": BankTransaction,
52
+ "Bank Account": BankAccount,
51
53
  # Neither posts to the GL or the Stock Ledger; both stay at docstatus 0 and
52
54
  # carry their meaning in `status`. Registered here so the generic document
53
55
  # CRUD, the chat tools and MCP drive them like every other doctype.
@@ -61,6 +61,71 @@ export async function request<T>(path: string, options?: RequestInit): Promise<T
61
61
  return res.json();
62
62
  }
63
63
 
64
+ async function multipartRequest<T>(path: string, form: FormData): Promise<T> {
65
+ const res = await fetch(`${BASE}${path}`, {
66
+ method: "POST",
67
+ credentials: "include",
68
+ body: form,
69
+ });
70
+ if (!res.ok) {
71
+ if (res.status === 401) {
72
+ window.location.href = "/login";
73
+ throw new ApiError(401, "Session expired");
74
+ }
75
+ const body = await res.json().catch(() => ({ detail: res.statusText }));
76
+ throw new ApiError(res.status, formatErrorDetail(body.detail));
77
+ }
78
+ return res.json() as Promise<T>;
79
+ }
80
+
81
+ export interface BankStatementPreview {
82
+ statement_key: string;
83
+ file_name: string;
84
+ schema_version: string;
85
+ statement_id: string;
86
+ account_iban_masked: string;
87
+ account_name: string | null;
88
+ account_currency: string;
89
+ bank_name: string | null;
90
+ bank_bic: string | null;
91
+ from_date: string | null;
92
+ to_date: string | null;
93
+ opening_balance: string | null;
94
+ closing_balance: string | null;
95
+ credit_total: string;
96
+ debit_total: string;
97
+ entry_count: number;
98
+ booked_entry_count: number;
99
+ detail_count: number;
100
+ batch_entry_count: number;
101
+ qr_reference_count: number;
102
+ duplicate_entry_count: number;
103
+ already_imported: boolean;
104
+ existing_import: string | null;
105
+ matched_bank_account: {
106
+ name: string;
107
+ account_name: string;
108
+ company: string;
109
+ account: string;
110
+ currency: string;
111
+ bank_name: string | null;
112
+ iban_masked: string;
113
+ } | null;
114
+ warnings: string[];
115
+ }
116
+
117
+ export interface BankStatementImportResult {
118
+ imports: Array<{
119
+ name: string;
120
+ bank_account: string;
121
+ account_iban_masked: string;
122
+ imported_entry_count: number;
123
+ duplicate_entry_count: number;
124
+ skipped_non_booked_count: number;
125
+ }>;
126
+ exact_duplicate_imports: string[];
127
+ }
128
+
64
129
  function qs(params?: Record<string, string | number | undefined>) {
65
130
  if (!params) return "";
66
131
  const clean = Object.fromEntries(
@@ -153,6 +218,25 @@ export const api = {
153
218
  },
154
219
  getChatAttachmentUrl: (id: string) => `${BASE}/chat/attachments/${encodeURIComponent(id)}`,
155
220
 
221
+ previewBankStatements: (files: File[]) => {
222
+ const form = new FormData();
223
+ files.forEach((file) => form.append("files", file));
224
+ return multipartRequest<{ statements: BankStatementPreview[] }>("/bank-statements/preview", form);
225
+ },
226
+
227
+ importBankStatements: (files: File[], mappings: Record<string, string>) => {
228
+ const form = new FormData();
229
+ files.forEach((file) => form.append("files", file));
230
+ form.append("mappings", JSON.stringify(mappings));
231
+ return multipartRequest<BankStatementImportResult>("/bank-statements/import", form);
232
+ },
233
+
234
+ listBankStatementImports: (limit = 50) =>
235
+ request<{ rows: any[] }>(`/bank-statements${qs({ limit })}`),
236
+
237
+ bankStatementSourceUrl: (name: string) =>
238
+ `${BASE}/bank-statements/${encodeURIComponent(name)}/source`,
239
+
156
240
  // Chat
157
241
  createChatSession: () =>
158
242
  request<{ id: string; title: string; created_at: string; updated_at: string; last_message_at?: string | null }>(
@@ -0,0 +1,77 @@
1
+ """Bank-account master used to map an external IBAN to a GL account."""
2
+
3
+ import re
4
+
5
+ from lambda_erp.database import get_db
6
+ from lambda_erp.exceptions import ValidationError
7
+ from lambda_erp.model import Document
8
+
9
+
10
+ _IBAN_RE = re.compile(r"^[A-Z]{2}[0-9A-Z]{13,32}$")
11
+
12
+
13
+ def normalize_iban(value: str | None) -> str:
14
+ """Return a compact uppercase IBAN and validate its checksum.
15
+
16
+ IBANs are identifiers, not display strings. Persisting one canonical form
17
+ makes account lookup and CAMT imports deterministic even when users paste
18
+ spaces or lowercase characters from a bank document.
19
+ """
20
+ iban = re.sub(r"\s+", "", value or "").upper()
21
+ if not _IBAN_RE.fullmatch(iban):
22
+ raise ValidationError("IBAN has an invalid format")
23
+ rearranged = iban[4:] + iban[:4]
24
+ numeric = "".join(str(ord(ch) - 55) if ch.isalpha() else ch for ch in rearranged)
25
+ remainder = 0
26
+ for offset in range(0, len(numeric), 9):
27
+ remainder = int(str(remainder) + numeric[offset:offset + 9]) % 97
28
+ if remainder != 1:
29
+ raise ValidationError("IBAN checksum is invalid")
30
+ return iban
31
+
32
+
33
+ class BankAccount(Document):
34
+ """A real-world bank account mapped to one ledger Bank account."""
35
+
36
+ DOCTYPE = "Bank Account"
37
+ CHILD_TABLES = {}
38
+ PREFIX = "BANK"
39
+
40
+ LINK_FIELDS = {
41
+ "company": "Company",
42
+ "account": "Account",
43
+ }
44
+ ACCOUNT_TYPE_CONSTRAINTS = {
45
+ "account": {"account_type": "Bank"},
46
+ }
47
+
48
+ def validate(self):
49
+ if not self.account_name:
50
+ raise ValidationError("Bank Account Name is required")
51
+ if not self.company:
52
+ raise ValidationError("Company is required")
53
+ if not self.account:
54
+ raise ValidationError("GL Account is required")
55
+
56
+ self._data["iban"] = normalize_iban(self.iban)
57
+ account = get_db().get_value(
58
+ "Account", self.account, ["company", "account_currency", "account_type"]
59
+ )
60
+ if account:
61
+ if account.get("company") != self.company:
62
+ raise ValidationError("GL Account belongs to a different company")
63
+ account_currency = (account.get("account_currency") or "").upper()
64
+ requested_currency = (self.currency or account_currency).upper()
65
+ if account_currency and requested_currency != account_currency:
66
+ raise ValidationError(
67
+ f"Bank Account currency {requested_currency} does not match "
68
+ f"GL Account currency {account_currency}"
69
+ )
70
+ self._data["currency"] = requested_currency
71
+
72
+ existing = get_db().sql(
73
+ 'SELECT name FROM "Bank Account" WHERE iban = ? AND name <> ? LIMIT 1',
74
+ [self.iban, self.name],
75
+ )
76
+ if existing:
77
+ raise ValidationError("This IBAN is already mapped to another Bank Account")