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
@@ -0,0 +1,369 @@
1
+ """
2
+ Tax and totals calculation engine.
3
+
4
+ Runs on every transaction (quotation, order, invoice) and supports several
5
+ charge types:
6
+ - "On Net Total": percentage of net total
7
+ - "On Previous Row Amount": percentage of a previous tax row
8
+ - "On Previous Row Total": percentage of cumulative total at a previous row
9
+ - "Actual": fixed amount
10
+ - "On Item Quantity": fixed amount per unit
11
+
12
+ Taxes can be inclusive (included in the printed price) or exclusive (added on top).
13
+ """
14
+
15
+ import json
16
+ from lambda_erp.utils import flt, cint, _dict
17
+ from lambda_erp.exceptions import ValidationError
18
+
19
+ class TaxCalculator:
20
+ """Calculate taxes and totals for a transaction document.
21
+
22
+ This is a direct port of the reference implementation's `calculate_taxes_and_totals` class.
23
+ The document must have:
24
+ - items: list of line items with qty, rate, amount, etc.
25
+ - taxes: list of tax rows with charge_type, rate, account_head, etc.
26
+ - conversion_rate: currency conversion rate
27
+ - discount_amount, apply_discount_on: optional discount fields
28
+ """
29
+
30
+ def __init__(self, doc):
31
+ self.doc = doc
32
+
33
+ def calculate(self):
34
+ """Main calculation entry point."""
35
+ items = self.doc.get("items") or []
36
+ if not items:
37
+ return
38
+
39
+ self.discount_amount_applied = False
40
+ self._calculate()
41
+
42
+ if self.doc.get("discount_amount"):
43
+ self.apply_discount_amount()
44
+
45
+ def _calculate(self):
46
+ self.calculate_item_values()
47
+ self.initialize_taxes()
48
+ self.determine_exclusive_rate()
49
+ self.calculate_net_total()
50
+ self.calculate_taxes()
51
+ self.calculate_totals()
52
+
53
+ def calculate_item_values(self):
54
+ """Calculate rate, amount, net_rate, net_amount for each line item.
55
+
56
+ - Discount percentage and discount amount
57
+ - Price list rate -> discounted rate
58
+ - Base currency conversion
59
+ """
60
+ conversion_rate = flt(self.doc.get("conversion_rate") or 1.0)
61
+
62
+ for item in self.doc.get("items"):
63
+ # Apply discount to get rate from price_list_rate
64
+ if flt(item.get("discount_percentage")) == 100:
65
+ item["rate"] = 0.0
66
+ elif item.get("price_list_rate"):
67
+ if not item.get("rate") or flt(item.get("discount_percentage")) > 0:
68
+ item["rate"] = flt(
69
+ flt(item["price_list_rate"]) * (1.0 - flt(item.get("discount_percentage")) / 100.0),
70
+ 2,
71
+ )
72
+ item["discount_amount"] = flt(
73
+ flt(item["price_list_rate"]) * flt(item.get("discount_percentage")) / 100.0, 2
74
+ )
75
+ elif item.get("discount_amount"):
76
+ item["rate"] = flt(item["price_list_rate"]) - flt(item["discount_amount"])
77
+
78
+ # Set net_rate = rate (before inclusive tax adjustment)
79
+ item["net_rate"] = flt(item.get("rate"), 2)
80
+
81
+ # Calculate amount
82
+ qty = flt(item.get("qty", 0))
83
+ item["amount"] = flt(flt(item.get("rate")) * qty, 2)
84
+ item["net_amount"] = flt(item["amount"], 2)
85
+
86
+ # Set base currency values
87
+ item["base_rate"] = flt(flt(item.get("rate")) * conversion_rate, 2)
88
+ item["base_amount"] = flt(flt(item["amount"]) * conversion_rate, 2)
89
+ item["base_net_rate"] = flt(flt(item["net_rate"]) * conversion_rate, 2)
90
+ item["base_net_amount"] = flt(flt(item["net_amount"]) * conversion_rate, 2)
91
+
92
+ def initialize_taxes(self):
93
+ """Reset tax computation fields before recalculating.
94
+
95
+ For `charge_type = "Actual"` rows (freight, shipping, customs, any
96
+ fixed-amount charge), `tax_amount` IS the user-provided input, not
97
+ a derived value — zeroing it here would wipe the freight amount on
98
+ every save and leave the Actual-charge path with nothing to
99
+ distribute. Only reset derived fields for those rows.
100
+ """
101
+ derived_fields = [
102
+ "total",
103
+ "tax_amount_for_current_item",
104
+ "grand_total_for_current_item",
105
+ "tax_fraction_for_current_item",
106
+ "grand_total_fraction_for_current_item",
107
+ ]
108
+ for tax in self.doc.get("taxes") or []:
109
+ if tax.get("charge_type") != "Actual":
110
+ tax["tax_amount"] = 0.0
111
+ for field in derived_fields:
112
+ tax[field] = 0.0
113
+
114
+ def determine_exclusive_rate(self):
115
+ """Adjust net_rate/net_amount for taxes included in the printed price.
116
+
117
+ This is the "tax-inclusive pricing" logic from the reference implementation. When a tax is
118
+ marked as included_in_print_rate, the item's net_amount is reduced so
119
+ that net_amount + tax = original amount.
120
+ """
121
+ taxes = self.doc.get("taxes") or []
122
+ if not any(cint(tax.get("included_in_print_rate")) for tax in taxes):
123
+ return
124
+
125
+ for item in self.doc.get("items"):
126
+ item_tax_map = self._load_item_tax_rate(item.get("item_tax_rate"))
127
+ cumulated_tax_fraction = 0
128
+ total_inclusive_tax_amount_per_qty = 0
129
+
130
+ for i, tax in enumerate(taxes):
131
+ tax_fraction, inclusive_amount_per_qty = self._get_current_tax_fraction(
132
+ tax, item_tax_map, i
133
+ )
134
+ tax["tax_fraction_for_current_item"] = tax_fraction
135
+
136
+ if i == 0:
137
+ tax["grand_total_fraction_for_current_item"] = 1 + tax_fraction
138
+ else:
139
+ tax["grand_total_fraction_for_current_item"] = (
140
+ taxes[i - 1].get("grand_total_fraction_for_current_item", 0) + tax_fraction
141
+ )
142
+
143
+ cumulated_tax_fraction += tax_fraction
144
+ total_inclusive_tax_amount_per_qty += inclusive_amount_per_qty * flt(item.get("qty"))
145
+
146
+ if item.get("qty") and (cumulated_tax_fraction or total_inclusive_tax_amount_per_qty):
147
+ amount = flt(item["amount"]) - total_inclusive_tax_amount_per_qty
148
+ item["net_amount"] = flt(amount / (1 + cumulated_tax_fraction), 2)
149
+ item["net_rate"] = flt(item["net_amount"] / flt(item["qty"]), 2)
150
+
151
+ conversion_rate = flt(self.doc.get("conversion_rate") or 1.0)
152
+ item["base_net_rate"] = flt(item["net_rate"] * conversion_rate, 2)
153
+ item["base_net_amount"] = flt(item["net_amount"] * conversion_rate, 2)
154
+
155
+ def _get_current_tax_fraction(self, tax, item_tax_map, idx):
156
+ """Get the tax fraction for back-calculating exclusive rate from inclusive price."""
157
+ current_tax_fraction = 0
158
+ inclusive_tax_amount_per_qty = 0
159
+
160
+ if cint(tax.get("included_in_print_rate")):
161
+ tax_rate = self._get_tax_rate(tax, item_tax_map)
162
+ charge_type = tax.get("charge_type", "On Net Total")
163
+ taxes = self.doc.get("taxes") or []
164
+
165
+ if charge_type == "On Net Total":
166
+ current_tax_fraction = tax_rate / 100.0
167
+ elif charge_type == "On Previous Row Amount":
168
+ row_id = cint(tax.get("row_id", 0)) - 1
169
+ if 0 <= row_id < len(taxes):
170
+ current_tax_fraction = (tax_rate / 100.0) * taxes[row_id].get(
171
+ "tax_fraction_for_current_item", 0
172
+ )
173
+ elif charge_type == "On Previous Row Total":
174
+ row_id = cint(tax.get("row_id", 0)) - 1
175
+ if 0 <= row_id < len(taxes):
176
+ current_tax_fraction = (tax_rate / 100.0) * taxes[row_id].get(
177
+ "grand_total_fraction_for_current_item", 0
178
+ )
179
+ elif charge_type == "On Item Quantity":
180
+ inclusive_tax_amount_per_qty = flt(tax_rate)
181
+
182
+ return current_tax_fraction, inclusive_tax_amount_per_qty
183
+
184
+ def _get_tax_rate(self, tax, item_tax_map):
185
+ """Get tax rate, checking item-specific overrides first."""
186
+ account_head = tax.get("account_head", "")
187
+ if account_head in item_tax_map:
188
+ return flt(item_tax_map[account_head])
189
+ return flt(tax.get("rate", 0))
190
+
191
+ def _load_item_tax_rate(self, item_tax_rate):
192
+ """Parse item_tax_rate JSON string to dict."""
193
+ if not item_tax_rate:
194
+ return {}
195
+ if isinstance(item_tax_rate, str):
196
+ try:
197
+ return json.loads(item_tax_rate)
198
+ except (json.JSONDecodeError, ValueError):
199
+ return {}
200
+ return item_tax_rate if isinstance(item_tax_rate, dict) else {}
201
+
202
+ def calculate_net_total(self):
203
+ """Sum up item amounts to get document totals."""
204
+ doc = self.doc
205
+ doc["total_qty"] = 0
206
+ doc["total"] = 0
207
+ doc["base_total"] = 0
208
+ doc["net_total"] = 0
209
+ doc["base_net_total"] = 0
210
+
211
+ for item in doc.get("items"):
212
+ doc["total_qty"] = flt(doc["total_qty"]) + flt(item.get("qty", 0))
213
+ doc["total"] = flt(doc["total"]) + flt(item.get("amount", 0))
214
+ doc["base_total"] = flt(doc["base_total"]) + flt(item.get("base_amount", 0))
215
+ doc["net_total"] = flt(doc["net_total"]) + flt(item.get("net_amount", 0))
216
+ doc["base_net_total"] = flt(doc["base_net_total"]) + flt(item.get("base_net_amount", 0))
217
+
218
+ for field in ["total", "base_total", "net_total", "base_net_total"]:
219
+ doc[field] = flt(doc[field], 2)
220
+
221
+ def calculate_taxes(self):
222
+ """Calculate tax amounts row by row, item by item.
223
+
224
+ This is the core tax calculation loop from the reference implementation. For each item,
225
+ it walks through each tax row and computes the tax amount based on
226
+ the charge_type (On Net Total, On Previous Row Amount, Actual, etc.).
227
+
228
+ The running total accumulates so each subsequent tax row can reference
229
+ the cumulative total from previous rows.
230
+ """
231
+ taxes = self.doc.get("taxes") or []
232
+ items = self.doc.get("items") or []
233
+ if not taxes:
234
+ return
235
+
236
+ # For "Actual" charge type, distribute evenly across items
237
+ actual_tax_dict = {}
238
+ for tax in taxes:
239
+ if tax.get("charge_type") == "Actual":
240
+ actual_tax_dict[tax.get("idx", 0)] = flt(tax.get("tax_amount", 0))
241
+
242
+ for n, item in enumerate(items):
243
+ item_tax_map = self._load_item_tax_rate(item.get("item_tax_rate"))
244
+
245
+ for i, tax in enumerate(taxes):
246
+ current_tax_amount = self._get_current_tax_amount(item, tax, item_tax_map)
247
+
248
+ # Adjust divisional loss to the last item (the reference implementation pattern)
249
+ if tax.get("charge_type") == "Actual":
250
+ idx = tax.get("idx", 0)
251
+ actual_tax_dict[idx] = actual_tax_dict.get(idx, 0) - current_tax_amount
252
+ if n == len(items) - 1:
253
+ current_tax_amount += actual_tax_dict.get(idx, 0)
254
+
255
+ # Accumulate tax amount
256
+ if tax.get("charge_type") != "Actual":
257
+ tax["tax_amount"] = flt(tax.get("tax_amount", 0)) + current_tax_amount
258
+
259
+ tax["tax_amount_for_current_item"] = current_tax_amount
260
+
261
+ # Build running grand total for this item
262
+ if i == 0:
263
+ tax["grand_total_for_current_item"] = flt(item.get("net_amount", 0)) + current_tax_amount
264
+ else:
265
+ tax["grand_total_for_current_item"] = flt(
266
+ taxes[i - 1].get("grand_total_for_current_item", 0)
267
+ ) + current_tax_amount
268
+
269
+ # Set cumulative totals on each tax row
270
+ for i, tax in enumerate(taxes):
271
+ tax["tax_amount"] = flt(tax.get("tax_amount", 0), 2)
272
+ if i == 0:
273
+ tax["total"] = flt(self.doc.get("net_total", 0)) + tax["tax_amount"]
274
+ else:
275
+ tax["total"] = flt(taxes[i - 1]["total"]) + tax["tax_amount"]
276
+
277
+ tax["total"] = flt(tax["total"], 2)
278
+
279
+ # Base currency
280
+ conversion_rate = flt(self.doc.get("conversion_rate") or 1.0)
281
+ tax["base_tax_amount"] = flt(tax["tax_amount"] * conversion_rate, 2)
282
+ tax["base_total"] = flt(tax["total"] * conversion_rate, 2)
283
+
284
+ def _get_current_tax_amount(self, item, tax, item_tax_map):
285
+ """Compute tax for one item + one tax row."""
286
+ tax_rate = self._get_tax_rate(tax, item_tax_map)
287
+ charge_type = tax.get("charge_type", "On Net Total")
288
+ taxes = self.doc.get("taxes") or []
289
+
290
+ if charge_type == "Actual":
291
+ # Distribute actual amount proportionally across items
292
+ total = flt(self.doc.get("net_total")) or 1
293
+ proportion = flt(item.get("net_amount", 0)) / total
294
+ return flt(tax.get("tax_amount", 0)) * proportion
295
+
296
+ elif charge_type == "On Net Total":
297
+ return flt(item.get("net_amount", 0)) * tax_rate / 100.0
298
+
299
+ elif charge_type == "On Previous Row Amount":
300
+ row_id = cint(tax.get("row_id", 0)) - 1
301
+ if 0 <= row_id < len(taxes):
302
+ return flt(taxes[row_id].get("tax_amount_for_current_item", 0)) * tax_rate / 100.0
303
+ return 0
304
+
305
+ elif charge_type == "On Previous Row Total":
306
+ row_id = cint(tax.get("row_id", 0)) - 1
307
+ if 0 <= row_id < len(taxes):
308
+ return flt(taxes[row_id].get("grand_total_for_current_item", 0)) * tax_rate / 100.0
309
+ return 0
310
+
311
+ elif charge_type == "On Item Quantity":
312
+ return flt(item.get("qty", 0)) * tax_rate
313
+
314
+ return 0
315
+
316
+ def calculate_totals(self):
317
+ """Calculate grand_total, rounded_total, etc."""
318
+ doc = self.doc
319
+ taxes = doc.get("taxes") or []
320
+
321
+ if taxes:
322
+ doc["grand_total"] = flt(taxes[-1]["total"], 2)
323
+ doc["total_taxes_and_charges"] = flt(doc["grand_total"]) - flt(doc["net_total"])
324
+ else:
325
+ doc["grand_total"] = flt(doc["net_total"], 2)
326
+ doc["total_taxes_and_charges"] = 0
327
+
328
+ doc["total_taxes_and_charges"] = flt(doc["total_taxes_and_charges"], 2)
329
+
330
+ conversion_rate = flt(doc.get("conversion_rate") or 1.0)
331
+ doc["base_grand_total"] = flt(flt(doc["grand_total"]) * conversion_rate, 2)
332
+ doc["base_total_taxes_and_charges"] = flt(
333
+ flt(doc["total_taxes_and_charges"]) * conversion_rate, 2
334
+ )
335
+
336
+ # Rounded total
337
+ doc["rounded_total"] = round(flt(doc["grand_total"]))
338
+ doc["rounding_adjustment"] = flt(doc["rounded_total"]) - flt(doc["grand_total"])
339
+
340
+ def apply_discount_amount(self):
341
+ """Apply a flat discount amount to the document."""
342
+ discount = flt(self.doc.get("discount_amount"))
343
+ if not discount:
344
+ return
345
+
346
+ if self.doc.get("apply_discount_on") == "Net Total":
347
+ # Distribute discount across items proportionally
348
+ net_total = flt(self.doc.get("net_total")) or 1
349
+ for item in self.doc.get("items"):
350
+ proportion = flt(item.get("net_amount", 0)) / net_total
351
+ item_discount = flt(discount * proportion, 2)
352
+ item["net_amount"] = flt(item["net_amount"]) - item_discount
353
+ if flt(item.get("qty")):
354
+ item["net_rate"] = flt(item["net_amount"] / flt(item["qty"]), 2)
355
+
356
+ self.discount_amount_applied = True
357
+ self._calculate()
358
+ else:
359
+ # Discount on Grand Total - subtract from grand total
360
+ self.doc["grand_total"] = flt(self.doc["grand_total"]) - discount
361
+ self.doc["base_grand_total"] = flt(self.doc["grand_total"]) * flt(
362
+ self.doc.get("conversion_rate") or 1.0
363
+ )
364
+ self.doc["rounded_total"] = round(flt(self.doc["grand_total"]))
365
+ self.doc["rounding_adjustment"] = flt(self.doc["rounded_total"]) - flt(self.doc["grand_total"])
366
+
367
+ def calculate_taxes_and_totals(doc):
368
+ """Convenience function matching the reference implementation's pattern."""
369
+ TaxCalculator(doc).calculate()