lambda-erp 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. api/__init__.py +0 -0
  2. api/attachments.py +229 -0
  3. api/auth.py +511 -0
  4. api/bootstrap.py +498 -0
  5. api/chat.py +2764 -0
  6. api/demo_limits.py +400 -0
  7. api/deps.py +7 -0
  8. api/errors.py +56 -0
  9. api/main.py +182 -0
  10. api/pdf.py +151 -0
  11. api/providers.py +116 -0
  12. api/routers/__init__.py +0 -0
  13. api/routers/accounting.py +63 -0
  14. api/routers/admin.py +122 -0
  15. api/routers/analytics.py +1009 -0
  16. api/routers/bank_reconciliation.py +31 -0
  17. api/routers/documents.py +100 -0
  18. api/routers/masters.py +396 -0
  19. api/routers/reports.py +735 -0
  20. api/routers/setup.py +387 -0
  21. api/services.py +372 -0
  22. api/templates/document.html +197 -0
  23. lambda_erp/__init__.py +3 -0
  24. lambda_erp/accounting/__init__.py +0 -0
  25. lambda_erp/accounting/bank_transaction.py +76 -0
  26. lambda_erp/accounting/budget.py +117 -0
  27. lambda_erp/accounting/chart_of_accounts.py +183 -0
  28. lambda_erp/accounting/general_ledger.py +362 -0
  29. lambda_erp/accounting/journal_entry.py +235 -0
  30. lambda_erp/accounting/payment_entry.py +515 -0
  31. lambda_erp/accounting/pos_invoice.py +342 -0
  32. lambda_erp/accounting/purchase_invoice.py +504 -0
  33. lambda_erp/accounting/revaluation.py +172 -0
  34. lambda_erp/accounting/sales_invoice.py +523 -0
  35. lambda_erp/accounting/subscription.py +132 -0
  36. lambda_erp/buying/__init__.py +0 -0
  37. lambda_erp/buying/purchase_order.py +165 -0
  38. lambda_erp/controllers/__init__.py +0 -0
  39. lambda_erp/controllers/currency.py +52 -0
  40. lambda_erp/controllers/defaults.py +51 -0
  41. lambda_erp/controllers/pricing_rule.py +103 -0
  42. lambda_erp/controllers/taxes_and_totals.py +369 -0
  43. lambda_erp/database.py +1543 -0
  44. lambda_erp/exceptions.py +37 -0
  45. lambda_erp/hooks.py +37 -0
  46. lambda_erp/model.py +462 -0
  47. lambda_erp/selling/__init__.py +0 -0
  48. lambda_erp/selling/quotation.py +263 -0
  49. lambda_erp/selling/sales_order.py +214 -0
  50. lambda_erp/simulation.py +704 -0
  51. lambda_erp/stock/__init__.py +0 -0
  52. lambda_erp/stock/delivery_note.py +254 -0
  53. lambda_erp/stock/purchase_receipt.py +356 -0
  54. lambda_erp/stock/stock_entry.py +330 -0
  55. lambda_erp/stock/stock_ledger.py +337 -0
  56. lambda_erp/utils.py +167 -0
  57. lambda_erp-0.1.0.dist-info/METADATA +454 -0
  58. lambda_erp-0.1.0.dist-info/RECORD +60 -0
  59. lambda_erp-0.1.0.dist-info/WHEEL +4 -0
  60. lambda_erp-0.1.0.dist-info/licenses/LICENSE +21 -0
api/pdf.py ADDED
@@ -0,0 +1,151 @@
1
+ """PDF generation for ERP documents."""
2
+
3
+ import os
4
+ from jinja2 import Environment, FileSystemLoader
5
+ from weasyprint import HTML
6
+ from lambda_erp.database import get_db
7
+ from api.services import load_document
8
+
9
+ TEMPLATE_DIR = os.path.join(os.path.dirname(__file__), "templates")
10
+ _jinja_env = Environment(loader=FileSystemLoader(TEMPLATE_DIR), autoescape=True)
11
+
12
+ # Document type → (title, party_field, party_name_field, party_doctype, party_label)
13
+ DOC_CONFIG = {
14
+ "Quotation": ("Quotation", "customer", "customer_name", "Customer", "Customer"),
15
+ "Sales Order": ("Sales Order", "customer", "customer_name", "Customer", "Customer"),
16
+ "Sales Invoice": ("Sales Invoice", "customer", "customer_name", "Customer", "Customer"),
17
+ "Purchase Order": ("Purchase Order", "supplier", "supplier_name", "Supplier", "Supplier"),
18
+ "Purchase Invoice": ("Purchase Invoice", "supplier", "supplier_name", "Supplier", "Supplier"),
19
+ "Delivery Note": ("Delivery Note", "customer", "customer_name", "Customer", "Customer"),
20
+ "Purchase Receipt": ("Purchase Receipt", "supplier", "supplier_name", "Supplier", "Supplier"),
21
+ }
22
+
23
+ SHOW_WAREHOUSE = {"Delivery Note", "Purchase Receipt"}
24
+
25
+
26
+ def _get_dict(row):
27
+ """Convert a database row to a plain dict."""
28
+ if row is None:
29
+ return {}
30
+ if isinstance(row, dict):
31
+ return row
32
+ return dict(row) if row else {}
33
+
34
+
35
+ def generate_pdf(doctype_slug: str, name: str) -> bytes:
36
+ """Generate a PDF for a document and return raw bytes."""
37
+ doc = load_document(doctype_slug, name)
38
+ db = get_db()
39
+
40
+ # Resolve doctype display name
41
+ doctype = doctype_slug.replace("-", " ").title()
42
+ # Fix multi-word: "Sales Invoice" not "Sales-Invoice"
43
+ for dt in DOC_CONFIG:
44
+ if dt.lower().replace(" ", "-") == doctype_slug:
45
+ doctype = dt
46
+ break
47
+
48
+ config = DOC_CONFIG.get(doctype)
49
+ if not config:
50
+ # Fallback for unknown types
51
+ config = (doctype, None, None, None, "Party")
52
+
53
+ title, party_field, party_name_field, party_doctype, party_label = config
54
+
55
+ # Credit note / debit note titles
56
+ if doc.get("is_return"):
57
+ if doctype == "Sales Invoice":
58
+ title = "Credit Note"
59
+ elif doctype == "Purchase Invoice":
60
+ title = "Debit Note"
61
+ else:
62
+ title = f"{title} (Return)"
63
+
64
+ # Party info
65
+ party_name = doc.get(party_name_field or "", "") or doc.get(party_field or "", "")
66
+ party_info = {}
67
+ if party_field and party_doctype and doc.get(party_field):
68
+ fields = ["email", "phone", "address", "city", "zip_code", "country", "tax_id"]
69
+ if party_name_field:
70
+ fields = [party_name_field, *fields]
71
+ row = db.get_value(party_doctype, doc[party_field], fields)
72
+ if row:
73
+ party_info = _get_dict(row)
74
+ # Show the party's CURRENT name from the master, so a later
75
+ # correction (e.g. fixing a typo) appears on the PDF — consistent
76
+ # with the address fields, which are already looked up live. The
77
+ # name stored on the document is only a fallback for when the
78
+ # master record no longer exists.
79
+ live_name = party_info.get(party_name_field) if party_name_field else None
80
+ if live_name:
81
+ party_name = live_name
82
+
83
+ # Company info
84
+ company_id = doc.get("company", "")
85
+ company_name = company_id # fallback to the id if the master is gone
86
+ company_info = {}
87
+ if company_id:
88
+ row = db.get_value("Company", company_id,
89
+ ["company_name", "email", "phone", "address", "city", "zip_code", "country", "tax_id"])
90
+ if row:
91
+ company_info = _get_dict(row)
92
+ # Show the company's CURRENT display name from the master.
93
+ company_name = company_info.get("company_name") or company_id
94
+
95
+ # Currency
96
+ currency = doc.get("currency", "USD") or "USD"
97
+
98
+ # Meta fields (varies by doc type)
99
+ meta_fields = []
100
+ if doc.get("posting_date"):
101
+ meta_fields.append({"label": "Date", "value": doc["posting_date"]})
102
+ elif doc.get("transaction_date"):
103
+ meta_fields.append({"label": "Date", "value": doc["transaction_date"]})
104
+ if doc.get("due_date"):
105
+ meta_fields.append({"label": "Due Date", "value": doc["due_date"]})
106
+ if doc.get("valid_till"):
107
+ meta_fields.append({"label": "Valid Till", "value": doc["valid_till"]})
108
+ if doc.get("delivery_date"):
109
+ meta_fields.append({"label": "Delivery Date", "value": doc["delivery_date"]})
110
+ if doc.get("return_against"):
111
+ meta_fields.append({"label": "Return Against", "value": doc["return_against"]})
112
+ if doc.get("status"):
113
+ meta_fields.append({"label": "Status", "value": doc["status"]})
114
+
115
+ # Items — refresh each line's item_name from the Item master so a later
116
+ # correction to an item's name appears on the PDF, consistent with the
117
+ # party/company names. Quantities, rates, and amounts stay as transacted.
118
+ # Falls back to the name stored on the line if the master is gone.
119
+ items = doc.get("items", [])
120
+ for item in items:
121
+ code = item.get("item_code")
122
+ if code:
123
+ live_item_name = db.get_value("Item", code, "item_name")
124
+ if live_item_name:
125
+ item["item_name"] = live_item_name
126
+
127
+ taxes = doc.get("taxes", [])
128
+
129
+ # Page size setting
130
+ page_size_row = db.sql('SELECT value FROM "Settings" WHERE key = "pdf_page_size"')
131
+ page_size = page_size_row[0]["value"] if page_size_row else "A4"
132
+
133
+ # Render
134
+ template = _jinja_env.get_template("document.html")
135
+ html_str = template.render(
136
+ doc=doc,
137
+ title=title,
138
+ company_name=company_name,
139
+ company_info=company_info,
140
+ party_label=party_label,
141
+ party_name=party_name,
142
+ party_info=party_info,
143
+ currency=currency,
144
+ meta_fields=meta_fields,
145
+ items=items,
146
+ taxes=taxes,
147
+ show_warehouse=doctype in SHOW_WAREHOUSE,
148
+ page_size=page_size,
149
+ )
150
+
151
+ return HTML(string=html_str).write_pdf()
api/providers.py ADDED
@@ -0,0 +1,116 @@
1
+ """Pricing data for external LLM APIs the chat orchestrator uses.
2
+
3
+ Mirrors the shape of `lambda-web/backend/providers.py`. Only the models
4
+ this app actually calls are listed — refresh this dict when model
5
+ choices change. Prices are USD per 1M tokens.
6
+
7
+ Consumers import `cost_of_openai_call` / `cost_of_anthropic_call` to
8
+ turn an SDK `response.usage` object into a USD amount.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Any, Optional
14
+
15
+
16
+ # ---------------------------------------------------------------------------
17
+ # Pricing dicts
18
+ # ---------------------------------------------------------------------------
19
+
20
+ OPENAI_PRICING: dict[str, dict[str, Any]] = {
21
+ "gpt-5.4": {
22
+ "tiered": True,
23
+ "tiers": [
24
+ {"max_input_tokens": 272_000, "input": 2.50, "cached_input": 0.25, "output": 15.00},
25
+ {"max_input_tokens": None, "input": 5.00, "cached_input": 0.50, "output": 22.50},
26
+ ],
27
+ },
28
+ "gpt-4.1-nano": {"input": 0.10, "cached_input": 0.025, "output": 0.40},
29
+ "gpt-4.1-mini": {"input": 0.40, "cached_input": 0.10, "output": 1.60},
30
+ "gpt-4.1": {"input": 2.00, "cached_input": 0.50, "output": 8.00},
31
+ "gpt-5": {"input": 1.25, "cached_input": 0.125, "output": 10.00},
32
+ "gpt-5-mini": {"input": 0.25, "cached_input": 0.025, "output": 2.00},
33
+ "gpt-5-nano": {"input": 0.05, "cached_input": 0.005, "output": 0.40},
34
+ "gpt-4o": {"input": 2.50, "cached_input": 1.25, "output": 10.00},
35
+ "gpt-4o-mini": {"input": 0.15, "cached_input": 0.075, "output": 0.60},
36
+ }
37
+
38
+ ANTHROPIC_PRICING: dict[str, dict[str, Any]] = {
39
+ "claude-opus-4-7": {"input": 5.00, "output": 25.00, "cache_write": 6.25, "cache_read": 0.50},
40
+ "claude-opus-4-6": {"input": 5.00, "output": 25.00, "cache_write": 6.25, "cache_read": 0.50},
41
+ "claude-sonnet-4-6": {"input": 3.00, "output": 15.00, "cache_write": 3.75, "cache_read": 0.30},
42
+ "claude-sonnet-4-5": {"input": 3.00, "output": 15.00, "cache_write": 3.75, "cache_read": 0.30},
43
+ "claude-haiku-4-5": {"input": 1.00, "output": 5.00, "cache_write": 1.25, "cache_read": 0.10},
44
+ }
45
+
46
+
47
+ # ---------------------------------------------------------------------------
48
+ # Rate lookup — unknown models fall back to the most expensive model in each
49
+ # family so we never under-count a call we forgot to register.
50
+ # ---------------------------------------------------------------------------
51
+
52
+ def get_openai_rates(model: str, input_tokens: int = 0) -> dict[str, float]:
53
+ entry = OPENAI_PRICING.get(model)
54
+ if entry is None:
55
+ entry = OPENAI_PRICING["gpt-5.4"]
56
+ if entry.get("tiered"):
57
+ for tier in entry["tiers"]:
58
+ cap = tier.get("max_input_tokens")
59
+ if cap is None or input_tokens <= cap:
60
+ return tier
61
+ return entry["tiers"][-1]
62
+ return entry
63
+
64
+
65
+ def get_anthropic_rates(model: str) -> dict[str, float]:
66
+ return ANTHROPIC_PRICING.get(model) or ANTHROPIC_PRICING["claude-opus-4-7"]
67
+
68
+
69
+ # ---------------------------------------------------------------------------
70
+ # Cost calculators. `usage` is the SDK's raw usage object; we accept None
71
+ # and degrade to 0 so callers don't need to pre-check.
72
+ # ---------------------------------------------------------------------------
73
+
74
+ def cost_of_openai_call(model: str, usage: Optional[Any]) -> float:
75
+ """USD cost of one OpenAI chat.completions call from `response.usage`.
76
+
77
+ Understands the `prompt_tokens_details.cached_tokens` breakdown so cache
78
+ hits are billed at the discounted rate."""
79
+ if usage is None:
80
+ return 0.0
81
+ prompt_tokens = int(getattr(usage, "prompt_tokens", 0) or 0)
82
+ completion_tokens = int(getattr(usage, "completion_tokens", 0) or 0)
83
+
84
+ cached = 0
85
+ details = getattr(usage, "prompt_tokens_details", None)
86
+ if details is not None:
87
+ cached = int(getattr(details, "cached_tokens", 0) or 0)
88
+
89
+ rates = get_openai_rates(model, input_tokens=prompt_tokens)
90
+ uncached = max(prompt_tokens - cached, 0)
91
+ cached_rate = rates.get("cached_input", rates["input"])
92
+ total = (
93
+ uncached * rates["input"]
94
+ + cached * cached_rate
95
+ + completion_tokens * rates["output"]
96
+ )
97
+ return total / 1_000_000
98
+
99
+
100
+ def cost_of_anthropic_call(model: str, usage: Optional[Any]) -> float:
101
+ """USD cost of one Anthropic messages.create call from `response.usage`."""
102
+ if usage is None:
103
+ return 0.0
104
+ input_tokens = int(getattr(usage, "input_tokens", 0) or 0)
105
+ output_tokens = int(getattr(usage, "output_tokens", 0) or 0)
106
+ cache_create = int(getattr(usage, "cache_creation_input_tokens", 0) or 0)
107
+ cache_read = int(getattr(usage, "cache_read_input_tokens", 0) or 0)
108
+
109
+ rates = get_anthropic_rates(model)
110
+ total = (
111
+ input_tokens * rates["input"]
112
+ + cache_create * rates.get("cache_write", rates["input"])
113
+ + cache_read * rates.get("cache_read", rates["input"])
114
+ + output_tokens * rates["output"]
115
+ )
116
+ return total / 1_000_000
File without changes
@@ -0,0 +1,63 @@
1
+ """Accounting period-end actions (currently: foreign-currency revaluation)."""
2
+
3
+ from fastapi import APIRouter, Depends
4
+ from lambda_erp.database import get_db
5
+ from lambda_erp.accounting.revaluation import run_period_revaluation
6
+ from lambda_erp.utils import nowdate
7
+ from api.auth import require_role
8
+
9
+ router = APIRouter(prefix="/accounting", tags=["accounting"])
10
+
11
+
12
+ def _resolve_company(db, company):
13
+ if company:
14
+ return company
15
+ companies = db.get_all("Company", fields=["name"], limit=1)
16
+ return companies[0]["name"] if companies else None
17
+
18
+
19
+ @router.get("/currencies")
20
+ def list_currencies(company: str | None = None, _user: dict = Depends(require_role("viewer"))):
21
+ """Currencies the books can transact in, for the document currency dropdown.
22
+
23
+ Returns the company base currency, every currency that has a Currency
24
+ Exchange rate on file, and any currency set as a default on a Company /
25
+ Customer / Supplier. Base currency first, then the rest alphabetically. We
26
+ only offer convertible currencies so a new document can't be denominated in
27
+ one the ledger has no rate path to base for.
28
+ """
29
+ db = get_db()
30
+ company = _resolve_company(db, company)
31
+ base = (db.get_value("Company", company, "default_currency") if company else None) or "USD"
32
+
33
+ found = {base}
34
+ for row in db.sql(
35
+ 'SELECT from_currency AS c FROM "Currency Exchange" '
36
+ 'UNION SELECT to_currency AS c FROM "Currency Exchange"'
37
+ ):
38
+ if row.get("c"):
39
+ found.add(row["c"])
40
+ for table in ("Company", "Customer", "Supplier"):
41
+ for row in db.sql(f'SELECT DISTINCT default_currency AS c FROM "{table}"'):
42
+ if row.get("c"):
43
+ found.add(row["c"])
44
+
45
+ others = sorted(found - {base})
46
+ return {"base_currency": base, "currencies": [base, *others]}
47
+
48
+
49
+ @router.post("/revaluation")
50
+ def period_revaluation(data: dict, _user: dict = Depends(require_role("manager"))):
51
+ """Preview or post period-end FX revaluation of open foreign balances.
52
+
53
+ Body: {company?, date?, post?}. With post=false (default) it's a dry run —
54
+ returns the per-balance breakdown without touching the ledger. With
55
+ post=true it posts the revaluation + a next-day auto-reversal.
56
+ """
57
+ db = get_db()
58
+ company = _resolve_company(db, data.get("company"))
59
+ if not company:
60
+ return {"detail": "No company found; create one first."}
61
+ return run_period_revaluation(
62
+ company, data.get("date") or nowdate(), post=bool(data.get("post", False))
63
+ )
api/routers/admin.py ADDED
@@ -0,0 +1,122 @@
1
+ """Admin-only observability endpoints.
2
+
3
+ Currently exposes the demo spend overview. Add future admin-only
4
+ reports here to keep `require_admin`-gated routes together.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import time
10
+
11
+ from fastapi import APIRouter, Depends
12
+
13
+ from api.auth import require_admin
14
+ from api.demo_limits import init_schema, limiter
15
+ from lambda_erp.database import get_db
16
+
17
+
18
+ router = APIRouter(prefix="/admin", tags=["admin"])
19
+
20
+
21
+ # Fixed windows the UI offers. Ordered for display.
22
+ _WINDOWS = [
23
+ ("1h", 3600),
24
+ ("2h", 2 * 3600),
25
+ ("4h", 4 * 3600),
26
+ ("12h", 12 * 3600),
27
+ ("24h", 24 * 3600),
28
+ ("7d", 7 * 24 * 3600),
29
+ ]
30
+
31
+
32
+ def _window_totals(now: float, seconds: int) -> dict:
33
+ db = get_db()
34
+ cutoff = now - seconds
35
+
36
+ totals = db.conn.execute(
37
+ 'SELECT '
38
+ ' COALESCE(SUM(cost_usd), 0) AS total_usd, '
39
+ ' COALESCE(SUM(CASE WHEN role = "public_manager" THEN cost_usd ELSE 0 END), 0) AS demo_usd, '
40
+ ' COALESCE(SUM(prompt_tokens), 0) AS prompt_tokens, '
41
+ ' COALESCE(SUM(completion_tokens), 0) AS completion_tokens, '
42
+ ' COUNT(*) AS call_count, '
43
+ ' COUNT(DISTINCT ip) AS unique_ips '
44
+ 'FROM "Demo Spend Log" WHERE ts > ?',
45
+ (cutoff,),
46
+ ).fetchone()
47
+
48
+ by_provider_rows = db.conn.execute(
49
+ 'SELECT provider, '
50
+ ' COALESCE(SUM(cost_usd), 0) AS cost_usd, '
51
+ ' COUNT(*) AS call_count, '
52
+ ' COALESCE(SUM(prompt_tokens), 0) AS prompt_tokens, '
53
+ ' COALESCE(SUM(completion_tokens), 0) AS completion_tokens '
54
+ 'FROM "Demo Spend Log" WHERE ts > ? GROUP BY provider',
55
+ (cutoff,),
56
+ ).fetchall()
57
+
58
+ by_provider = {
59
+ (row["provider"] or "unknown"): {
60
+ "cost_usd": round(row["cost_usd"], 6),
61
+ "call_count": int(row["call_count"]),
62
+ "prompt_tokens": int(row["prompt_tokens"]),
63
+ "completion_tokens": int(row["completion_tokens"]),
64
+ }
65
+ for row in by_provider_rows
66
+ }
67
+
68
+ return {
69
+ "total_usd": round(totals["total_usd"], 6),
70
+ "demo_usd": round(totals["demo_usd"], 6),
71
+ "prompt_tokens": int(totals["prompt_tokens"]),
72
+ "completion_tokens": int(totals["completion_tokens"]),
73
+ "call_count": int(totals["call_count"]),
74
+ "unique_ips": int(totals["unique_ips"] or 0),
75
+ "by_provider": by_provider,
76
+ }
77
+
78
+
79
+ @router.get("/demo-spend")
80
+ def demo_spend_overview(_user: dict = Depends(require_admin)):
81
+ """Per-window spend breakdown, caps, and top-10 IPs over 24h.
82
+
83
+ Only `public_manager` spend counts against the demo cap — `demo_usd`
84
+ in each window is the number the global cap compares against. Non-
85
+ demo calls (your own admin/manager sessions) are included in
86
+ `total_usd` so you can see the full OpenAI + Anthropic bill from this
87
+ deployment.
88
+ """
89
+ init_schema()
90
+ now = time.time()
91
+
92
+ windows = {name: _window_totals(now, seconds) for name, seconds in _WINDOWS}
93
+
94
+ db = get_db()
95
+ top_ip_rows = db.conn.execute(
96
+ 'SELECT ip, role, '
97
+ ' COALESCE(SUM(cost_usd), 0) AS cost_usd, '
98
+ ' COUNT(*) AS call_count '
99
+ 'FROM "Demo Spend Log" WHERE ts > ? '
100
+ 'GROUP BY ip, role '
101
+ 'ORDER BY cost_usd DESC '
102
+ 'LIMIT 10',
103
+ (now - 24 * 3600,),
104
+ ).fetchall()
105
+ top_ips_24h = [
106
+ {
107
+ "ip": row["ip"] or "unknown",
108
+ "role": row["role"],
109
+ "cost_usd": round(row["cost_usd"], 6),
110
+ "call_count": int(row["call_count"]),
111
+ }
112
+ for row in top_ip_rows
113
+ ]
114
+
115
+ return {
116
+ "caps": {
117
+ "global_hourly_usd": limiter.global_hourly_usd,
118
+ "per_ip_hourly_usd": limiter.per_ip_hourly_usd,
119
+ },
120
+ "windows": windows,
121
+ "top_ips_24h": top_ips_24h,
122
+ }