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,504 @@
1
+ """
2
+ Purchase Invoice.
3
+
4
+ Mirror image of Sales Invoice for the buying side:
5
+ Purchase Order -> Purchase Receipt -> **Purchase Invoice**
6
+
7
+ GL entries on submit:
8
+ Debit: Expense/Stock Account = net_amount per item
9
+ Debit: Tax Account = tax_amount per row (input tax)
10
+ Credit: Accounts Payable = grand_total
11
+ """
12
+
13
+ from lambda_erp.model import Document
14
+ from lambda_erp.utils import _dict, flt, nowdate, add_days
15
+ from lambda_erp.database import get_db
16
+ from lambda_erp.controllers.taxes_and_totals import calculate_taxes_and_totals
17
+ from lambda_erp.controllers.defaults import set_default_currency
18
+ from lambda_erp.accounting.general_ledger import make_gl_entries, make_reverse_gl_entries, to_base_currency
19
+ from lambda_erp.stock.stock_ledger import (
20
+ make_sl_entries,
21
+ build_buy_side_sles,
22
+ reverse_stock_sles,
23
+ )
24
+ from lambda_erp.exceptions import ValidationError
25
+
26
+ class PurchaseInvoice(Document):
27
+ DOCTYPE = "Purchase Invoice"
28
+ CHILD_TABLES = {
29
+ "items": ("Purchase Invoice Item", None),
30
+ "taxes": ("Sales Taxes and Charges", None),
31
+ }
32
+ PREFIX = "PINV"
33
+
34
+ LINK_FIELDS = {
35
+ "supplier": "Supplier",
36
+ "company": "Company",
37
+ "credit_to": "Account",
38
+ }
39
+ CHILD_LINK_FIELDS = {
40
+ "items": {
41
+ "item_code": "Item",
42
+ "warehouse": "Warehouse",
43
+ "expense_account": "Account",
44
+ "cost_center": "Cost Center",
45
+ },
46
+ "taxes": {
47
+ "account_head": "Account",
48
+ "cost_center": "Cost Center",
49
+ },
50
+ }
51
+ ACCOUNT_TYPE_CONSTRAINTS = {
52
+ "credit_to": {"account_type": "Payable"},
53
+ }
54
+ # items.expense_account intentionally omitted — it's legitimately routed
55
+ # to Expense (services), Asset/SIH (direct-receive stock), or Asset/SRBNB
56
+ # (PR→PI flow) depending on item type + update_stock.
57
+
58
+ def validate(self):
59
+ if not self.supplier:
60
+ raise ValidationError("Supplier is required")
61
+ if not self.get("items"):
62
+ raise ValidationError("At least one item is required")
63
+ if not self.posting_date:
64
+ self.posting_date = nowdate()
65
+
66
+ self._set_supplier_name()
67
+ self._set_missing_accounts()
68
+ self._set_item_defaults()
69
+ self._validate_stock_warehouses()
70
+ self._validate_no_double_receipt()
71
+
72
+ if self.is_return:
73
+ self._validate_return()
74
+
75
+ from lambda_erp.controllers.pricing_rule import apply_pricing_rules
76
+ apply_pricing_rules(self)
77
+
78
+ set_default_currency(self, "Supplier", "supplier")
79
+
80
+ calculate_taxes_and_totals(self)
81
+
82
+ if self.is_return:
83
+ self._validate_return_value()
84
+
85
+ self._data["outstanding_amount"] = flt(self.grand_total, 2)
86
+
87
+ if not self.due_date:
88
+ self._data["due_date"] = add_days(self.posting_date, 30)
89
+
90
+ def _set_supplier_name(self):
91
+ if not self.supplier_name and self.supplier:
92
+ db = get_db()
93
+ self.supplier_name = db.get_value("Supplier", self.supplier, "supplier_name")
94
+
95
+ def _set_missing_accounts(self):
96
+ """Route each line's expense_account based on item + update_stock.
97
+
98
+ Three valid workflows:
99
+ 1. PR -> PI (update_stock=0, stock item): line debits SRBNB to clear
100
+ the interim account opened by the Purchase Receipt.
101
+ 2. PI with update_stock=1 (direct receive-and-bill, stock item): line
102
+ debits Stock In Hand directly (and SLE entries post below in
103
+ on_submit). There is no prior PR to clear.
104
+ 3. Services / non-stock: line debits the default expense account.
105
+ """
106
+ db = get_db()
107
+ if not self.company:
108
+ return
109
+
110
+ if not self.credit_to:
111
+ self._data["credit_to"] = db.get_value(
112
+ "Company", self.company, "default_payable_account"
113
+ )
114
+ default_expense = db.get_value("Company", self.company, "default_expense_account")
115
+ stock_received_account = db.get_value(
116
+ "Company", self.company, "stock_received_but_not_billed"
117
+ )
118
+ stock_in_hand_account = db.get_value(
119
+ "Company", self.company, "stock_in_hand_account"
120
+ )
121
+ default_cc = db.get_value("Company", self.company, "default_cost_center")
122
+ directly_receiving = flt(self.get("update_stock")) == 1
123
+
124
+ for item in self.get("items"):
125
+ if not item.get("expense_account"):
126
+ is_stock = 0
127
+ if item.get("item_code"):
128
+ is_stock = db.get_value("Item", item["item_code"], "is_stock_item") or 0
129
+ if is_stock and directly_receiving and stock_in_hand_account:
130
+ item["expense_account"] = stock_in_hand_account
131
+ elif is_stock and stock_received_account:
132
+ item["expense_account"] = stock_received_account
133
+ else:
134
+ item["expense_account"] = default_expense
135
+ if not item.get("cost_center"):
136
+ item["cost_center"] = default_cc
137
+
138
+ def _validate_stock_warehouses(self):
139
+ """When update_stock=1, every stock-item line needs a warehouse so
140
+ the SLE has somewhere to put the received goods."""
141
+ if not flt(self.get("update_stock")):
142
+ return
143
+ db = get_db()
144
+ for item in self.get("items"):
145
+ if not item.get("item_code"):
146
+ continue
147
+ is_stock = db.get_value("Item", item["item_code"], "is_stock_item") or 0
148
+ if is_stock and not item.get("warehouse"):
149
+ raise ValidationError(
150
+ f"Warehouse is required for stock item {item['item_code']} "
151
+ f"when update_stock is checked"
152
+ )
153
+
154
+ def _check_no_linked_payment_entry(self):
155
+ """Mirror of SalesInvoice._check_no_linked_payment_entry — cancelling a
156
+ PI whose AP has been paid by a Payment Entry orphans the PE's
157
+ allocation and leaves AP/bank in an inconsistent state."""
158
+ db = get_db()
159
+ rows = db.sql(
160
+ 'SELECT DISTINCT pe.name AS pe_name '
161
+ 'FROM "Payment Entry Reference" per '
162
+ 'JOIN "Payment Entry" pe ON pe.name = per.parent '
163
+ 'WHERE pe.docstatus = 1 '
164
+ ' AND per.reference_doctype = ? AND per.reference_name = ? LIMIT 1',
165
+ [self.DOCTYPE, self.name],
166
+ )
167
+ if rows:
168
+ raise ValidationError(
169
+ f"Cannot cancel {self.name}: Payment Entry {rows[0]['pe_name']} "
170
+ f"is already allocated against it. Cancel the Payment Entry first."
171
+ )
172
+
173
+ def _validate_no_double_receipt(self):
174
+ """Block update_stock=1 when the referenced Purchase Order already has
175
+ a Purchase Receipt for the line. Otherwise stock arrives twice: once
176
+ via the PR and again when this invoice submits. Returns are exempt."""
177
+ if not flt(self.get("update_stock")) or self.is_return:
178
+ return
179
+ db = get_db()
180
+ for item in self.get("items"):
181
+ po = item.get("purchase_order")
182
+ po_item = item.get("purchase_order_item")
183
+ if not (po or po_item):
184
+ continue
185
+ pr_rows = db.sql(
186
+ 'SELECT pr.name AS pr_name '
187
+ 'FROM "Purchase Receipt Item" pri '
188
+ 'JOIN "Purchase Receipt" pr ON pr.name = pri.parent '
189
+ 'WHERE pr.docstatus = 1 '
190
+ 'AND (pri.against_purchase_order = ? OR pri.po_detail = ?) '
191
+ 'AND pri.item_code = ? LIMIT 1',
192
+ [po or "", po_item or "", item.get("item_code")],
193
+ )
194
+ if pr_rows:
195
+ raise ValidationError(
196
+ f"Cannot submit with update_stock=1: item {item.get('item_code')} "
197
+ f"was already received via Purchase Receipt {pr_rows[0]['pr_name']}. "
198
+ f"Either uncheck update_stock (use this as a bill only, clearing "
199
+ f"SRBNB) or cancel the Purchase Receipt first."
200
+ )
201
+
202
+ def _set_item_defaults(self):
203
+ db = get_db()
204
+ for item in self.get("items"):
205
+ if item.get("item_code") and not item.get("item_name"):
206
+ item_data = db.get_value(
207
+ "Item", item["item_code"],
208
+ ["item_name", "description", "stock_uom", "standard_rate"]
209
+ )
210
+ if item_data:
211
+ item["item_name"] = item_data.item_name
212
+ item["description"] = item.get("description") or item_data.description
213
+ item["uom"] = item.get("uom") or item_data.stock_uom
214
+ if not item.get("rate"):
215
+ item["rate"] = flt(item_data.standard_rate)
216
+
217
+ def _validate_return(self):
218
+ """Validate return-specific rules."""
219
+ if not self.return_against:
220
+ raise ValidationError("Return Against is required for a return invoice")
221
+
222
+ db = get_db()
223
+ original = db.get_value(self.DOCTYPE, self.return_against, ["name", "docstatus", "grand_total"])
224
+ if not original:
225
+ raise ValidationError(f"Original invoice {self.return_against} not found")
226
+ if original.docstatus != 1:
227
+ raise ValidationError(f"Original invoice {self.return_against} must be submitted")
228
+
229
+ # Aggregate already-returned qty per item across other submitted return
230
+ # invoices against the same original. Without this, the same item can be
231
+ # returned twice in separate debit notes, driving the original's
232
+ # outstanding negative and producing phantom AP balances.
233
+ already_returned: dict[str, float] = {}
234
+ prev_rows = db.sql(
235
+ """SELECT pii.item_code, COALESCE(SUM(ABS(pii.qty)), 0) AS qty
236
+ FROM "Purchase Invoice Item" pii
237
+ JOIN "Purchase Invoice" pi ON pi.name = pii.parent
238
+ WHERE pi.return_against = ?
239
+ AND pi.docstatus = 1
240
+ AND pi.name != ?
241
+ GROUP BY pii.item_code""",
242
+ [self.return_against, self.name or ""],
243
+ )
244
+ for row in prev_rows:
245
+ already_returned[row["item_code"]] = flt(row["qty"])
246
+
247
+ original_doc = PurchaseInvoice.load(self.return_against)
248
+ original_items = {item["item_code"]: flt(item["qty"]) for item in original_doc.get("items")}
249
+ for item in self.get("items"):
250
+ orig_qty = original_items.get(item.get("item_code"), 0)
251
+ prev = already_returned.get(item.get("item_code"), 0)
252
+ return_qty = abs(flt(item.get("qty")))
253
+ remaining = max(0, orig_qty - prev)
254
+ if return_qty > remaining + 0.01:
255
+ hint = (
256
+ f"original qty {orig_qty}, already returned {prev}, "
257
+ f"remaining {remaining}"
258
+ if prev
259
+ else f"original qty {orig_qty}"
260
+ )
261
+ raise ValidationError(
262
+ f"Return qty ({return_qty}) for {item.get('item_code')} exceeds "
263
+ f"remaining returnable qty ({hint})"
264
+ )
265
+
266
+ def _update_original_outstanding(self):
267
+ """Reduce original invoice outstanding when a return is submitted."""
268
+ db = get_db()
269
+ current = flt(db.get_value(self.DOCTYPE, self.return_against, "outstanding_amount"))
270
+ reduction = abs(flt(self.grand_total, 2))
271
+ new_outstanding = max(flt(current - reduction, 2), 0)
272
+ db.set_value(self.DOCTYPE, self.return_against, "outstanding_amount", new_outstanding)
273
+
274
+ def _validate_return_value(self):
275
+ """Mirror SalesInvoice._validate_return_value on the purchase side."""
276
+ db = get_db()
277
+ original_total = abs(
278
+ flt(db.get_value(self.DOCTYPE, self.return_against, "grand_total"), 2)
279
+ )
280
+ prev_rows = db.sql(
281
+ """SELECT COALESCE(SUM(ABS(grand_total)), 0) AS total
282
+ FROM "Purchase Invoice"
283
+ WHERE return_against = ?
284
+ AND docstatus = 1
285
+ AND name != ?""",
286
+ [self.return_against, self.name or ""],
287
+ )
288
+ already_returned = flt(prev_rows[0]["total"]) if prev_rows else 0
289
+ remaining_value = max(0, flt(original_total - already_returned, 2))
290
+ this_value = abs(flt(self.grand_total, 2))
291
+ if this_value > remaining_value + 0.01:
292
+ raise ValidationError(
293
+ f"Return total ({this_value}) exceeds remaining returnable value "
294
+ f"(original {original_total}, already returned {already_returned}, "
295
+ f"remaining {remaining_value})"
296
+ )
297
+
298
+ def _reverse_original_outstanding(self):
299
+ """Restore original invoice outstanding when a return is cancelled."""
300
+ db = get_db()
301
+ current = flt(db.get_value(self.DOCTYPE, self.return_against, "outstanding_amount"))
302
+ restoration = abs(flt(self.grand_total, 2))
303
+ db.set_value(self.DOCTYPE, self.return_against, "outstanding_amount", flt(current + restoration, 2))
304
+
305
+ def on_submit(self):
306
+ """Post GL entries.
307
+
308
+ Accounting entry for a Purchase Invoice:
309
+ Debit: Expense Account (per item) = net_amount
310
+ Debit: Tax Account (input tax) = tax_amount
311
+ Credit: Accounts Payable (supplier) = grand_total
312
+
313
+ When update_stock=1, this invoice also receives the goods — stock
314
+ items get SLE rows so Bin updates, and their expense_account was
315
+ routed to Stock In Hand in _set_missing_accounts so the existing
316
+ GL loop naturally books Dr Stock In Hand for those lines.
317
+ """
318
+ if flt(self.get("update_stock")):
319
+ sl_entries = self._get_stock_sl_entries()
320
+ if sl_entries:
321
+ make_sl_entries(sl_entries)
322
+
323
+ gl_entries = self._get_gl_entries()
324
+ # AP/expense/tax (and the SIH leg for direct-receive stock items) are
325
+ # built in document currency; convert to base. The buy-side SLEs above
326
+ # are valued in base too (build_buy_side_sles applies conversion_rate),
327
+ # so the Stock-In-Hand GL matches the stock-ledger value.
328
+ to_base_currency(gl_entries, self.get("conversion_rate"))
329
+ make_gl_entries(gl_entries)
330
+ self._update_purchase_order_billing()
331
+
332
+ if self.is_return and self.return_against:
333
+ self._update_original_outstanding()
334
+
335
+ def on_cancel(self):
336
+ self._check_no_linked_payment_entry()
337
+
338
+ if flt(self.get("update_stock")):
339
+ reversed_sles = reverse_stock_sles(self._get_stock_sl_entries())
340
+ if reversed_sles:
341
+ make_sl_entries(reversed_sles, allow_negative_stock=True)
342
+
343
+ make_reverse_gl_entries(
344
+ voucher_type=self.DOCTYPE,
345
+ voucher_no=self.name,
346
+ )
347
+ db = get_db()
348
+ db.set_value(self.DOCTYPE, self.name, "outstanding_amount", 0)
349
+
350
+ if self.is_return and self.return_against:
351
+ self._reverse_original_outstanding()
352
+
353
+ self._update_purchase_order_billing(cancel=True)
354
+
355
+ def _get_stock_sl_entries(self):
356
+ """SLEs for direct-receive PIs. Only stock items contribute; services
357
+ stay off the stock ledger. See stock_ledger.build_buy_side_sles for
358
+ how incoming cost is set from the supplier rate (not moving-average).
359
+ """
360
+ db = get_db()
361
+ stock_items = [
362
+ item
363
+ for item in self.get("items")
364
+ if item.get("item_code")
365
+ and item.get("warehouse")
366
+ and (db.get_value("Item", item["item_code"], "is_stock_item") or 0)
367
+ ]
368
+ return build_buy_side_sles(self, stock_items)
369
+
370
+ def _get_gl_entries(self):
371
+ """Build GL entry map - mirror image of Sales Invoice."""
372
+ gl_entries = []
373
+
374
+ # 1. Credit: Accounts Payable (supplier)
375
+ gl_entries.append(
376
+ _dict(
377
+ account=self.credit_to,
378
+ party_type="Supplier",
379
+ party=self.supplier,
380
+ credit=flt(self.grand_total, 2),
381
+ credit_in_account_currency=flt(self.grand_total, 2),
382
+ debit=0,
383
+ debit_in_account_currency=0,
384
+ against_voucher_type=self.DOCTYPE,
385
+ against_voucher=self.name,
386
+ voucher_type=self.DOCTYPE,
387
+ voucher_no=self.name,
388
+ posting_date=self.posting_date,
389
+ company=self.company,
390
+ remarks=self.remarks or f"Purchase Invoice {self.name}",
391
+ )
392
+ )
393
+
394
+ # 2. Debit: Expense/Stock accounts (per item)
395
+ expense_accounts = {}
396
+ for item in self.get("items"):
397
+ account = item.get("expense_account")
398
+ if account not in expense_accounts:
399
+ expense_accounts[account] = 0
400
+ expense_accounts[account] += flt(item.get("net_amount", 0))
401
+
402
+ for account, amount in expense_accounts.items():
403
+ gl_entries.append(
404
+ _dict(
405
+ account=account,
406
+ debit=flt(amount, 2),
407
+ debit_in_account_currency=flt(amount, 2),
408
+ credit=0,
409
+ credit_in_account_currency=0,
410
+ cost_center=self.get("items")[0].get("cost_center") if self.get("items") else None,
411
+ voucher_type=self.DOCTYPE,
412
+ voucher_no=self.name,
413
+ posting_date=self.posting_date,
414
+ company=self.company,
415
+ remarks=f"Expense from {self.name}",
416
+ )
417
+ )
418
+
419
+ # 3. Debit: Tax accounts (input tax / tax credit)
420
+ for tax in self.get("taxes") or []:
421
+ if flt(tax.get("tax_amount")):
422
+ gl_entries.append(
423
+ _dict(
424
+ account=tax.get("account_head"),
425
+ debit=flt(tax["tax_amount"], 2),
426
+ debit_in_account_currency=flt(tax["tax_amount"], 2),
427
+ credit=0,
428
+ credit_in_account_currency=0,
429
+ voucher_type=self.DOCTYPE,
430
+ voucher_no=self.name,
431
+ posting_date=self.posting_date,
432
+ company=self.company,
433
+ remarks=tax.get("description") or f"Tax: {tax.get('account_head')}",
434
+ )
435
+ )
436
+
437
+ return gl_entries
438
+
439
+ def _update_purchase_order_billing(self, cancel=False):
440
+ db = get_db()
441
+ for item in self.get("items"):
442
+ if item.get("purchase_order") and item.get("purchase_order_item"):
443
+ billed_qty = flt(item.get("qty"))
444
+ if cancel:
445
+ billed_qty = -billed_qty
446
+ current = db.get_value(
447
+ "Purchase Order Item", item["purchase_order_item"], "billed_qty"
448
+ ) or 0
449
+ db.set_value(
450
+ "Purchase Order Item", item["purchase_order_item"],
451
+ "billed_qty", flt(current) + billed_qty
452
+ )
453
+ db.commit()
454
+
455
+ def make_purchase_return(pinv_name):
456
+ """Create a Debit Note (return Purchase Invoice) from an existing Purchase Invoice."""
457
+ db = get_db()
458
+ original = PurchaseInvoice.load(pinv_name)
459
+
460
+ if original.docstatus != 1:
461
+ raise ValidationError("Purchase Invoice must be submitted before creating a return")
462
+ if original.is_return:
463
+ raise ValidationError("Cannot create a return against a return")
464
+
465
+ return_inv = PurchaseInvoice(
466
+ supplier=original.supplier,
467
+ company=original.company,
468
+ currency=original.get("currency") or "USD",
469
+ conversion_rate=original.get("conversion_rate") or 1.0,
470
+ posting_date=nowdate(),
471
+ credit_to=original.credit_to,
472
+ is_return=1,
473
+ return_against=original.name,
474
+ # Direct-receive PI (update_stock=1) put goods into stock on submit;
475
+ # the return must move them back out. Without this, AP reverses but
476
+ # the inventory just sits there.
477
+ update_stock=flt(original.get("update_stock")) or 0,
478
+ )
479
+
480
+ for item in original.get("items"):
481
+ return_inv.append("items", _dict(
482
+ item_code=item.get("item_code"),
483
+ item_name=item.get("item_name"),
484
+ description=item.get("description"),
485
+ qty=-flt(item.get("qty")),
486
+ uom=item.get("uom"),
487
+ rate=flt(item.get("rate")),
488
+ expense_account=item.get("expense_account"),
489
+ cost_center=item.get("cost_center"),
490
+ warehouse=item.get("warehouse"),
491
+ purchase_order=item.get("purchase_order"),
492
+ purchase_order_item=item.get("purchase_order_item"),
493
+ ))
494
+
495
+ for tax in original.get("taxes") or []:
496
+ return_inv.append("taxes", _dict(
497
+ charge_type=tax.get("charge_type"),
498
+ account_head=tax.get("account_head"),
499
+ description=tax.get("description"),
500
+ rate=flt(tax.get("rate")),
501
+ tax_amount=0,
502
+ ))
503
+
504
+ return return_inv
@@ -0,0 +1,172 @@
1
+ """Period-end revaluation of open foreign-currency monetary balances.
2
+
3
+ Open foreign receivables, payables, and bank/cash balances are carried at the
4
+ historical rate(s) they were booked at. At period end their base value has
5
+ drifted from today's rate; `run_period_revaluation` restates them to the
6
+ closing rate and books the difference as **unrealized** FX gain/loss.
7
+
8
+ Principles (see docs/multicurrency-phase-4c.md):
9
+ - Never edit posted entries — revaluation posts new GL, and an auto-reversal
10
+ dated the next day backs it out so it doesn't double-count once the balance
11
+ settles (and realized FX is recognized then).
12
+ - Only the base value moves; the account-currency balance is unchanged, so the
13
+ *_in_account_currency amounts on revaluation entries are 0.
14
+ """
15
+
16
+ from lambda_erp.utils import _dict, flt, nowdate, add_days, new_name
17
+ from lambda_erp.database import get_db
18
+ from lambda_erp.controllers.currency import get_exchange_rate
19
+ from lambda_erp.accounting.general_ledger import make_gl_entries, get_account_balances
20
+ from lambda_erp.exceptions import ValidationError
21
+
22
+ # A delta smaller than this (in base currency) isn't worth a GL line.
23
+ _EPSILON = 0.005
24
+
25
+
26
+ def collect_revaluation_lines(company, date):
27
+ """Return the per-balance revaluation breakdown (no posting).
28
+
29
+ Each line: account, currency, kind, is_asset, foreign, current_base,
30
+ closing_base, unrealized (= closing_base - current_base).
31
+ """
32
+ db = get_db()
33
+ base_ccy = db.get_value("Company", company, "default_currency") or "USD"
34
+ ar_account = db.get_value("Company", company, "default_receivable_account")
35
+ ap_account = db.get_value("Company", company, "default_payable_account")
36
+
37
+ lines = []
38
+
39
+ def _aggregate_invoices(doctypes):
40
+ by_ccy = {}
41
+ for doctype in doctypes:
42
+ rows = db.sql(
43
+ f'SELECT currency, conversion_rate, outstanding_amount FROM "{doctype}" '
44
+ f'WHERE company = ? AND docstatus = 1 AND outstanding_amount != 0 '
45
+ f'AND currency IS NOT NULL AND currency != ?',
46
+ [company, base_ccy],
47
+ )
48
+ for r in rows:
49
+ agg = by_ccy.setdefault(r["currency"], [0.0, 0.0])
50
+ out = flt(r["outstanding_amount"])
51
+ agg[0] += out
52
+ agg[1] += out * (flt(r["conversion_rate"]) or 1.0)
53
+ return by_ccy
54
+
55
+ def _add_party_lines(by_ccy, account, kind, is_asset):
56
+ for ccy, (out_sum, current_base) in by_ccy.items():
57
+ closing = get_exchange_rate(ccy, base_ccy, date)
58
+ closing_base = flt(out_sum * closing, 2)
59
+ lines.append(_dict(
60
+ account=account, currency=ccy, kind=kind, is_asset=is_asset,
61
+ foreign=flt(out_sum, 2), current_base=flt(current_base, 2),
62
+ closing_base=closing_base, unrealized=flt(closing_base - flt(current_base, 2), 2),
63
+ ))
64
+
65
+ _add_party_lines(_aggregate_invoices(["Sales Invoice", "POS Invoice"]),
66
+ ar_account, "receivable", True)
67
+ _add_party_lines(_aggregate_invoices(["Purchase Invoice"]),
68
+ ap_account, "payable", False)
69
+
70
+ # Foreign bank / cash accounts: the whole balance is open.
71
+ accts = db.sql(
72
+ 'SELECT name, account_currency FROM "Account" '
73
+ 'WHERE company = ? AND is_group = 0 AND account_currency IS NOT NULL '
74
+ "AND account_currency != ? AND account_type IN ('Bank', 'Cash')",
75
+ [company, base_ccy],
76
+ )
77
+ for a in accts:
78
+ base_bal, ccy_bal = get_account_balances(a["name"], company)
79
+ if abs(ccy_bal) < _EPSILON:
80
+ continue
81
+ closing = get_exchange_rate(a["account_currency"], base_ccy, date)
82
+ closing_base = flt(ccy_bal * closing, 2)
83
+ lines.append(_dict(
84
+ account=a["name"], currency=a["account_currency"], kind="cash", is_asset=True,
85
+ foreign=flt(ccy_bal, 2), current_base=flt(base_bal, 2),
86
+ closing_base=closing_base, unrealized=flt(closing_base - flt(base_bal, 2), 2),
87
+ ))
88
+
89
+ return lines
90
+
91
+
92
+ def _build_entries(lines, fx_account, company, date, voucher_no, swap):
93
+ """Control leg moves the base value in the account's natural direction
94
+ (asset -> debit on a gain); the Unrealized FX account is the contra.
95
+ Account-currency amounts stay 0 — only the base translation changes.
96
+ Negative deltas are left negative; make_gl_entries toggles them."""
97
+ entries = []
98
+ for ln in lines:
99
+ d = flt(ln["unrealized"], 2)
100
+ if abs(d) < _EPSILON:
101
+ continue
102
+ ctrl_debit, ctrl_credit = (d, 0) if ln["is_asset"] else (0, d)
103
+ fx_debit, fx_credit = (0, d) if ln["is_asset"] else (d, 0)
104
+ if swap:
105
+ ctrl_debit, ctrl_credit = ctrl_credit, ctrl_debit
106
+ fx_debit, fx_credit = fx_credit, fx_debit
107
+ remark = (f"{'Reversal of ' if swap else ''}unrealized FX revaluation "
108
+ f"{ln['currency']} ({ln['kind']})")
109
+ entries.append(_dict(
110
+ account=ln["account"], debit=ctrl_debit, credit=ctrl_credit,
111
+ debit_in_account_currency=0, credit_in_account_currency=0,
112
+ voucher_type="Period Revaluation", voucher_no=voucher_no,
113
+ posting_date=date, company=company, remarks=remark,
114
+ ))
115
+ entries.append(_dict(
116
+ account=fx_account, debit=fx_debit, credit=fx_credit,
117
+ debit_in_account_currency=0, credit_in_account_currency=0,
118
+ voucher_type="Period Revaluation", voucher_no=voucher_no,
119
+ posting_date=date, company=company, remarks=remark,
120
+ ))
121
+ return entries
122
+
123
+
124
+ def run_period_revaluation(company, date=None, *, post=True):
125
+ """Restate open foreign balances to the closing rate at `date`.
126
+
127
+ Posts a balanced revaluation voucher dated `date` plus an auto-reversal
128
+ dated the next day, then returns a result dict (breakdown + what was
129
+ posted + the net P&L impact). With post=False it's a dry run — the
130
+ breakdown only, nothing posted. Raises if a foreign balance exists but no
131
+ Unrealized Exchange Gain/Loss account is configured.
132
+ """
133
+ db = get_db()
134
+ on_date = date or nowdate()
135
+ base_ccy = db.get_value("Company", company, "default_currency") or "USD"
136
+ lines = collect_revaluation_lines(company, on_date)
137
+ postable = [ln for ln in lines if abs(flt(ln["unrealized"], 2)) >= _EPSILON]
138
+
139
+ # Net P&L impact in base currency: a gain on an asset and a (sign-flipped)
140
+ # loss on a liability. Positive = net unrealized gain.
141
+ net_pl = flt(sum(
142
+ (ln["unrealized"] if ln["is_asset"] else -ln["unrealized"]) for ln in lines
143
+ ), 2)
144
+
145
+ result = {
146
+ "company": company,
147
+ "date": on_date,
148
+ "base_currency": base_ccy,
149
+ "lines": lines,
150
+ "net_unrealized_pl": net_pl,
151
+ "posted": False,
152
+ "voucher_no": None,
153
+ "reversal_voucher_no": None,
154
+ "reversal_date": None,
155
+ }
156
+
157
+ if post and postable:
158
+ fx_account = db.get_value("Company", company, "default_unrealized_exchange_account")
159
+ if not fx_account:
160
+ raise ValidationError(
161
+ "No Unrealized Exchange Gain/Loss account is configured on the company; "
162
+ "cannot post the period revaluation."
163
+ )
164
+ reval_no = new_name("REVAL")
165
+ reversal_no = new_name("REVAL")
166
+ next_date = add_days(on_date, 1).isoformat()
167
+ make_gl_entries(_build_entries(postable, fx_account, company, on_date, reval_no, swap=False))
168
+ make_gl_entries(_build_entries(postable, fx_account, company, next_date, reversal_no, swap=True))
169
+ result.update(posted=True, voucher_no=reval_no,
170
+ reversal_voucher_no=reversal_no, reversal_date=next_date)
171
+
172
+ return result