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,523 @@
1
+ """
2
+ Sales Invoice.
3
+
4
+ The Sales Invoice is the most complex document in the reference implementation (~2,500 lines).
5
+ It's where the sales cycle culminates in actual financial impact:
6
+ Quotation -> Sales Order -> Delivery Note -> **Sales Invoice**
7
+
8
+ Key behaviors on submit:
9
+ 1. Creates GL entries (Debit: Receivable, Credit: Income + Tax accounts)
10
+ 2. Updates outstanding_amount
11
+ 3. Updates Sales Order billing status
12
+ 4. Optionally updates stock (if update_stock is checked)
13
+
14
+ This is a simplified port focusing on the core GL posting logic.
15
+ """
16
+
17
+ from lambda_erp.model import Document
18
+ from lambda_erp.utils import _dict, flt, getdate, nowdate, add_days
19
+ from lambda_erp.database import get_db
20
+ from lambda_erp.controllers.taxes_and_totals import calculate_taxes_and_totals
21
+ from lambda_erp.controllers.defaults import set_default_currency
22
+ from lambda_erp.accounting.general_ledger import make_gl_entries, make_reverse_gl_entries, to_base_currency
23
+ from lambda_erp.stock.stock_ledger import (
24
+ make_sl_entries,
25
+ build_sell_side_sles,
26
+ build_cost_basis_gl,
27
+ reverse_stock_sles,
28
+ )
29
+ from lambda_erp.exceptions import ValidationError
30
+
31
+ class SalesInvoice(Document):
32
+ DOCTYPE = "Sales Invoice"
33
+ CHILD_TABLES = {
34
+ "items": ("Sales Invoice Item", None),
35
+ "taxes": ("Sales Taxes and Charges", None),
36
+ }
37
+ PREFIX = "SINV"
38
+
39
+ LINK_FIELDS = {
40
+ "customer": "Customer",
41
+ "company": "Company",
42
+ "debit_to": "Account",
43
+ }
44
+ CHILD_LINK_FIELDS = {
45
+ "items": {
46
+ "item_code": "Item",
47
+ "warehouse": "Warehouse",
48
+ "income_account": "Account",
49
+ "cost_center": "Cost Center",
50
+ },
51
+ "taxes": {
52
+ "account_head": "Account",
53
+ "cost_center": "Cost Center",
54
+ },
55
+ }
56
+ ACCOUNT_TYPE_CONSTRAINTS = {
57
+ "debit_to": {"account_type": "Receivable"},
58
+ }
59
+ CHILD_ACCOUNT_TYPE_CONSTRAINTS = {
60
+ "items": {"income_account": {"root_type": "Income"}},
61
+ }
62
+
63
+ def validate(self):
64
+ """Validate the sales invoice.
65
+
66
+ Mirrors the reference implementation's SalesInvoice.validate() which runs through
67
+ the full controller chain:
68
+ SellingController.validate()
69
+ -> StockController.validate()
70
+ -> AccountsController.validate()
71
+ -> set_missing_values
72
+ -> calculate_taxes_and_totals
73
+ -> validate_party
74
+ """
75
+ if not self.customer:
76
+ raise ValidationError("Customer is required")
77
+ if not self.get("items"):
78
+ raise ValidationError("At least one item is required")
79
+ if not self.posting_date:
80
+ self.posting_date = nowdate()
81
+
82
+ self._set_customer_name()
83
+ self._set_missing_accounts()
84
+ self._set_item_defaults()
85
+ self._validate_no_double_shipment()
86
+
87
+ if self.is_return:
88
+ self._validate_return()
89
+
90
+ from lambda_erp.controllers.pricing_rule import apply_pricing_rules
91
+ apply_pricing_rules(self)
92
+
93
+ set_default_currency(self, "Customer", "customer")
94
+
95
+ calculate_taxes_and_totals(self)
96
+
97
+ if self.is_return:
98
+ self._validate_return_value()
99
+
100
+ # Set outstanding = grand total (before any payments)
101
+ self._data["outstanding_amount"] = flt(self.grand_total, 2)
102
+
103
+ if not self.due_date:
104
+ self._data["due_date"] = add_days(self.posting_date, 30)
105
+
106
+ def _set_customer_name(self):
107
+ if not self.customer_name and self.customer:
108
+ db = get_db()
109
+ self.customer_name = db.get_value("Customer", self.customer, "customer_name")
110
+
111
+ def _set_missing_accounts(self):
112
+ """Set default accounts from Company if not specified.
113
+
114
+ In the reference implementation, this is handled by AccountsController.set_missing_values()
115
+ which pulls defaults from Company settings.
116
+ """
117
+ db = get_db()
118
+ if self.company:
119
+ if not self.debit_to:
120
+ self._data["debit_to"] = db.get_value(
121
+ "Company", self.company, "default_receivable_account"
122
+ )
123
+
124
+ # Set income account on items
125
+ default_income = db.get_value("Company", self.company, "default_income_account")
126
+ default_cc = db.get_value("Company", self.company, "default_cost_center")
127
+ for item in self.get("items"):
128
+ if not item.get("income_account"):
129
+ item["income_account"] = default_income
130
+ if not item.get("cost_center"):
131
+ item["cost_center"] = default_cc
132
+
133
+ def _set_item_defaults(self):
134
+ db = get_db()
135
+ for item in self.get("items"):
136
+ if item.get("item_code") and not item.get("item_name"):
137
+ item_data = db.get_value(
138
+ "Item", item["item_code"],
139
+ ["item_name", "description", "stock_uom", "standard_rate"]
140
+ )
141
+ if item_data:
142
+ item["item_name"] = item_data.item_name
143
+ item["description"] = item.get("description") or item_data.description
144
+ item["uom"] = item.get("uom") or item_data.stock_uom
145
+ if not item.get("rate"):
146
+ item["rate"] = flt(item_data.standard_rate)
147
+
148
+ def _validate_no_double_shipment(self):
149
+ """Block update_stock=1 when the referenced Sales Order already has a
150
+ Delivery Note for the line. Otherwise stock ships twice: once via the
151
+ DN and again when this invoice submits. Returns are exempt (they
152
+ reverse the SI's own earlier shipment, not a separate DN)."""
153
+ if not flt(self.get("update_stock")) or self.is_return:
154
+ return
155
+ db = get_db()
156
+ for item in self.get("items"):
157
+ so_item = item.get("sales_order_item") or item.get("so_detail")
158
+ so = item.get("sales_order")
159
+ if not (so or so_item):
160
+ continue
161
+ dn_rows = db.sql(
162
+ 'SELECT dn.name AS dn_name '
163
+ 'FROM "Delivery Note Item" dni '
164
+ 'JOIN "Delivery Note" dn ON dn.name = dni.parent '
165
+ 'WHERE dn.docstatus = 1 '
166
+ 'AND (dni.against_sales_order = ? OR dni.so_detail = ?) '
167
+ 'AND dni.item_code = ? LIMIT 1',
168
+ [so or "", so_item or "", item.get("item_code")],
169
+ )
170
+ if dn_rows:
171
+ raise ValidationError(
172
+ f"Cannot submit with update_stock=1: item {item.get('item_code')} "
173
+ f"was already shipped via Delivery Note {dn_rows[0]['dn_name']}. "
174
+ f"Either uncheck update_stock (use this as a bill only) or "
175
+ f"cancel the Delivery Note first."
176
+ )
177
+
178
+ def _validate_return(self):
179
+ """Validate return-specific rules."""
180
+ if not self.return_against:
181
+ raise ValidationError("Return Against is required for a return invoice")
182
+
183
+ db = get_db()
184
+ original = db.get_value(self.DOCTYPE, self.return_against, ["name", "docstatus", "grand_total"])
185
+ if not original:
186
+ raise ValidationError(f"Original invoice {self.return_against} not found")
187
+ if original.docstatus != 1:
188
+ raise ValidationError(f"Original invoice {self.return_against} must be submitted")
189
+
190
+ # Aggregate already-returned qty per item across other submitted return
191
+ # invoices against the same original. Without this, the same item can be
192
+ # returned twice in separate credit notes, driving the original's
193
+ # outstanding negative and producing phantom AR credits.
194
+ already_returned: dict[str, float] = {}
195
+ prev_rows = db.sql(
196
+ """SELECT sii.item_code, COALESCE(SUM(ABS(sii.qty)), 0) AS qty
197
+ FROM "Sales Invoice Item" sii
198
+ JOIN "Sales Invoice" si ON si.name = sii.parent
199
+ WHERE si.return_against = ?
200
+ AND si.docstatus = 1
201
+ AND si.name != ?
202
+ GROUP BY sii.item_code""",
203
+ [self.return_against, self.name or ""],
204
+ )
205
+ for row in prev_rows:
206
+ already_returned[row["item_code"]] = flt(row["qty"])
207
+
208
+ # Verify return quantities don't exceed original minus what's been returned.
209
+ original_doc = SalesInvoice.load(self.return_against)
210
+ original_items = {item["item_code"]: flt(item["qty"]) for item in original_doc.get("items")}
211
+ for item in self.get("items"):
212
+ orig_qty = original_items.get(item.get("item_code"), 0)
213
+ prev = already_returned.get(item.get("item_code"), 0)
214
+ return_qty = abs(flt(item.get("qty")))
215
+ remaining = max(0, orig_qty - prev)
216
+ if return_qty > remaining + 0.01:
217
+ hint = (
218
+ f"original qty {orig_qty}, already returned {prev}, "
219
+ f"remaining {remaining}"
220
+ if prev
221
+ else f"original qty {orig_qty}"
222
+ )
223
+ raise ValidationError(
224
+ f"Return qty ({return_qty}) for {item.get('item_code')} exceeds "
225
+ f"remaining returnable qty ({hint})"
226
+ )
227
+
228
+ def _update_original_outstanding(self):
229
+ """Reduce original invoice outstanding when a return is submitted."""
230
+ db = get_db()
231
+ current = flt(db.get_value(self.DOCTYPE, self.return_against, "outstanding_amount"))
232
+ reduction = abs(flt(self.grand_total, 2))
233
+ new_outstanding = max(flt(current - reduction, 2), 0)
234
+ db.set_value(self.DOCTYPE, self.return_against, "outstanding_amount", new_outstanding)
235
+
236
+ def _validate_return_value(self):
237
+ """Cap the return's value at what is still economically returnable from
238
+ the original document. Quantity-only validation is insufficient because
239
+ a user could keep qty within bounds but edit rate/tax upward and create
240
+ an oversized credit note.
241
+ """
242
+ db = get_db()
243
+ original_total = abs(
244
+ flt(db.get_value(self.DOCTYPE, self.return_against, "grand_total"), 2)
245
+ )
246
+ prev_rows = db.sql(
247
+ """SELECT COALESCE(SUM(ABS(grand_total)), 0) AS total
248
+ FROM "Sales Invoice"
249
+ WHERE return_against = ?
250
+ AND docstatus = 1
251
+ AND name != ?""",
252
+ [self.return_against, self.name or ""],
253
+ )
254
+ already_returned = flt(prev_rows[0]["total"]) if prev_rows else 0
255
+ remaining_value = max(0, flt(original_total - already_returned, 2))
256
+ this_value = abs(flt(self.grand_total, 2))
257
+ if this_value > remaining_value + 0.01:
258
+ raise ValidationError(
259
+ f"Return total ({this_value}) exceeds remaining returnable value "
260
+ f"(original {original_total}, already returned {already_returned}, "
261
+ f"remaining {remaining_value})"
262
+ )
263
+
264
+ def _reverse_original_outstanding(self):
265
+ """Restore original invoice outstanding when a return is cancelled."""
266
+ db = get_db()
267
+ current = flt(db.get_value(self.DOCTYPE, self.return_against, "outstanding_amount"))
268
+ restoration = abs(flt(self.grand_total, 2))
269
+ db.set_value(self.DOCTYPE, self.return_against, "outstanding_amount", flt(current + restoration, 2))
270
+
271
+ def _check_no_linked_payment_entry(self):
272
+ """Block cancel if a submitted Payment Entry allocates against this
273
+ invoice. Otherwise the reversal orphans the PE's allocation — AR
274
+ swings into credit balance and the cash is in the bank with nothing
275
+ backing it. Recourse: cancel the PE first."""
276
+ db = get_db()
277
+ rows = db.sql(
278
+ 'SELECT DISTINCT pe.name AS pe_name '
279
+ 'FROM "Payment Entry Reference" per '
280
+ 'JOIN "Payment Entry" pe ON pe.name = per.parent '
281
+ 'WHERE pe.docstatus = 1 '
282
+ ' AND per.reference_doctype = ? AND per.reference_name = ? LIMIT 1',
283
+ [self.DOCTYPE, self.name],
284
+ )
285
+ if rows:
286
+ raise ValidationError(
287
+ f"Cannot cancel {self.name}: Payment Entry {rows[0]['pe_name']} "
288
+ f"is already allocated against it. Cancel the Payment Entry first."
289
+ )
290
+
291
+ def on_submit(self):
292
+ """Post GL entries on submission.
293
+
294
+ This is the core accounting logic. In the reference implementation, this is handled by
295
+ AccountsController.make_gl_entries() which builds a gl_map and
296
+ then calls general_ledger.make_gl_entries().
297
+
298
+ The accounting entry for a Sales Invoice:
299
+ Debit: Accounts Receivable (customer) = grand_total
300
+ Credit: Income Account (per item) = net_amount per item
301
+ Credit: Tax Account (per tax row) = tax_amount per row
302
+
303
+ If update_stock is checked, this invoice also ships goods directly:
304
+ - Creates SLE entries (stock leaves the warehouse)
305
+ - Posts Dr COGS / Cr Stock In Hand on top of the revenue entries
306
+ """
307
+ gl_entries = self._get_gl_entries()
308
+ # Revenue side (AR/income/tax) is built in document currency; convert to
309
+ # base before posting. Cost-of-goods entries below are already in base
310
+ # (moving-average cost), so they're appended after the conversion.
311
+ to_base_currency(gl_entries, self.get("conversion_rate"))
312
+
313
+ if flt(self.get("update_stock")):
314
+ sl_entries = self._get_stock_sl_entries()
315
+ if sl_entries:
316
+ make_sl_entries(sl_entries)
317
+ gl_entries.extend(
318
+ build_cost_basis_gl(self, remarks=f"Direct shipment via {self.name}")
319
+ )
320
+
321
+ make_gl_entries(gl_entries)
322
+
323
+ # Update Sales Order billing status if linked
324
+ self._update_sales_order_billing()
325
+
326
+ # Update original invoice outstanding for returns
327
+ if self.is_return and self.return_against:
328
+ self._update_original_outstanding()
329
+
330
+ def on_cancel(self):
331
+ """Reverse GL entries on cancellation."""
332
+ self._check_no_linked_payment_entry()
333
+
334
+ if flt(self.get("update_stock")):
335
+ reversed_sles = reverse_stock_sles(self._get_stock_sl_entries())
336
+ if reversed_sles:
337
+ make_sl_entries(reversed_sles, allow_negative_stock=True)
338
+
339
+ make_reverse_gl_entries(
340
+ voucher_type=self.DOCTYPE,
341
+ voucher_no=self.name,
342
+ )
343
+
344
+ # Reset outstanding
345
+ db = get_db()
346
+ db.set_value(self.DOCTYPE, self.name, "outstanding_amount", 0)
347
+
348
+ # Restore original invoice outstanding for returns
349
+ if self.is_return and self.return_against:
350
+ self._reverse_original_outstanding()
351
+
352
+ # Reverse Sales Order billing status
353
+ self._update_sales_order_billing(cancel=True)
354
+
355
+ def _get_gl_entries(self):
356
+ """Build the GL entry map for this invoice.
357
+
358
+ AccountsController.get_gl_entries().
359
+
360
+ This is the heart of the accounting: building the list of
361
+ debit/credit entries that maintain the double-entry invariant.
362
+ """
363
+ gl_entries = []
364
+
365
+ # 1. Debit: Accounts Receivable
366
+ # In the reference implementation: debit_to account with party_type=Customer
367
+ gl_entries.append(
368
+ _dict(
369
+ account=self.debit_to,
370
+ party_type="Customer",
371
+ party=self.customer,
372
+ debit=flt(self.grand_total, 2),
373
+ debit_in_account_currency=flt(self.grand_total, 2),
374
+ credit=0,
375
+ credit_in_account_currency=0,
376
+ against_voucher_type=self.DOCTYPE,
377
+ against_voucher=self.name,
378
+ voucher_type=self.DOCTYPE,
379
+ voucher_no=self.name,
380
+ posting_date=self.posting_date,
381
+ company=self.company,
382
+ remarks=self.remarks or f"Sales Invoice {self.name} against {self.customer_name}",
383
+ )
384
+ )
385
+
386
+ # 2. Credit: Income accounts (per item)
387
+ # In the reference implementation, items with the same income_account are grouped
388
+ income_accounts = {}
389
+ for item in self.get("items"):
390
+ account = item.get("income_account")
391
+ if account not in income_accounts:
392
+ income_accounts[account] = 0
393
+ income_accounts[account] += flt(item.get("net_amount", 0))
394
+
395
+ for account, amount in income_accounts.items():
396
+ gl_entries.append(
397
+ _dict(
398
+ account=account,
399
+ credit=flt(amount, 2),
400
+ credit_in_account_currency=flt(amount, 2),
401
+ debit=0,
402
+ debit_in_account_currency=0,
403
+ cost_center=self.get("items")[0].get("cost_center") if self.get("items") else None,
404
+ voucher_type=self.DOCTYPE,
405
+ voucher_no=self.name,
406
+ posting_date=self.posting_date,
407
+ company=self.company,
408
+ remarks=self.remarks or f"Income from {self.name}",
409
+ )
410
+ )
411
+
412
+ # 3. Credit: Tax accounts (per tax row)
413
+ for tax in self.get("taxes") or []:
414
+ if flt(tax.get("tax_amount")):
415
+ gl_entries.append(
416
+ _dict(
417
+ account=tax.get("account_head"),
418
+ credit=flt(tax["tax_amount"], 2),
419
+ credit_in_account_currency=flt(tax["tax_amount"], 2),
420
+ debit=0,
421
+ debit_in_account_currency=0,
422
+ cost_center=self.get("items")[0].get("cost_center") if self.get("items") else None,
423
+ voucher_type=self.DOCTYPE,
424
+ voucher_no=self.name,
425
+ posting_date=self.posting_date,
426
+ company=self.company,
427
+ remarks=tax.get("description") or f"Tax: {tax.get('account_head')}",
428
+ )
429
+ )
430
+
431
+ return gl_entries
432
+
433
+ def _get_stock_sl_entries(self):
434
+ """SLEs for direct-ship invoices. Shared helper handles both normal
435
+ ship-out and returns via the qty sign."""
436
+ return build_sell_side_sles(self, self.get("items"))
437
+
438
+ def _update_sales_order_billing(self, cancel=False):
439
+ """Update the billing status on linked Sales Orders.
440
+
441
+ Instead of incrementing/decrementing billed_qty, we recalculate it
442
+ from all submitted (non-cancelled) invoices. This prevents drift
443
+ from failed submits or repeated cancellations.
444
+ """
445
+ db = get_db()
446
+ so_names = set()
447
+ so_items = set()
448
+ for item in self.get("items"):
449
+ if item.get("sales_order"):
450
+ so_names.add(item["sales_order"])
451
+ if item.get("sales_order_item"):
452
+ so_items.add(item["sales_order_item"])
453
+
454
+ # Recalculate billed_qty for each SO item from submitted invoices
455
+ for so_item in so_items:
456
+ result = db.sql(
457
+ """SELECT COALESCE(SUM(qty), 0) as total_billed
458
+ FROM "Sales Invoice Item"
459
+ WHERE sales_order_item = ?
460
+ AND parent IN (
461
+ SELECT name FROM "Sales Invoice" WHERE docstatus = 1
462
+ )""",
463
+ [so_item],
464
+ )
465
+ billed = flt(result[0]["total_billed"]) if result else 0
466
+ db.set_value("Sales Order Item", so_item, "billed_qty", billed)
467
+
468
+ # Recalculate per_billed on each Sales Order
469
+ for so_name in so_names:
470
+ from lambda_erp.selling.sales_order import SalesOrder
471
+ so = SalesOrder.load(so_name)
472
+ so.update_billing_status()
473
+
474
+ def make_sales_return(sinv_name):
475
+ """Create a Credit Note (return Sales Invoice) from an existing Sales Invoice."""
476
+ db = get_db()
477
+ original = SalesInvoice.load(sinv_name)
478
+
479
+ if original.docstatus != 1:
480
+ raise ValidationError("Sales Invoice must be submitted before creating a return")
481
+ if original.is_return:
482
+ raise ValidationError("Cannot create a return against a return")
483
+
484
+ return_inv = SalesInvoice(
485
+ customer=original.customer,
486
+ company=original.company,
487
+ currency=original.get("currency") or "USD",
488
+ conversion_rate=original.get("conversion_rate") or 1.0,
489
+ posting_date=nowdate(),
490
+ debit_to=original.debit_to,
491
+ is_return=1,
492
+ return_against=original.name,
493
+ # If the original invoice shipped goods directly (update_stock=1),
494
+ # the return must put them back; otherwise AR reverses but stock
495
+ # stays stranded. Non-direct-ship invoices keep the default of 0.
496
+ update_stock=flt(original.get("update_stock")) or 0,
497
+ )
498
+
499
+ for item in original.get("items"):
500
+ return_inv.append("items", _dict(
501
+ item_code=item.get("item_code"),
502
+ item_name=item.get("item_name"),
503
+ description=item.get("description"),
504
+ qty=-flt(item.get("qty")),
505
+ uom=item.get("uom"),
506
+ rate=flt(item.get("rate")),
507
+ income_account=item.get("income_account"),
508
+ cost_center=item.get("cost_center"),
509
+ warehouse=item.get("warehouse"),
510
+ sales_order=item.get("sales_order"),
511
+ sales_order_item=item.get("sales_order_item"),
512
+ ))
513
+
514
+ for tax in original.get("taxes") or []:
515
+ return_inv.append("taxes", _dict(
516
+ charge_type=tax.get("charge_type"),
517
+ account_head=tax.get("account_head"),
518
+ description=tax.get("description"),
519
+ rate=flt(tax.get("rate")),
520
+ tax_amount=0,
521
+ ))
522
+
523
+ return return_inv
@@ -0,0 +1,132 @@
1
+ """
2
+ Subscription.
3
+
4
+ Subscription generates recurring Sales or Purchase Invoices based on a
5
+ billing interval. Call process() to check if a new invoice is due and
6
+ create it automatically.
7
+ """
8
+
9
+ from lambda_erp.model import Document
10
+ from lambda_erp.utils import _dict, flt, getdate, nowdate, add_days
11
+ from lambda_erp.database import get_db
12
+ from lambda_erp.exceptions import ValidationError
13
+ from datetime import timedelta
14
+ from dateutil.relativedelta import relativedelta
15
+
16
+ class Subscription(Document):
17
+ DOCTYPE = "Subscription"
18
+ CHILD_TABLES = {
19
+ "plans": ("Subscription Plan", None),
20
+ }
21
+ PREFIX = "SUB"
22
+
23
+ def validate(self):
24
+ if not self.party_type:
25
+ raise ValidationError("Party Type is required")
26
+ if not self.party:
27
+ raise ValidationError("Party is required")
28
+ if not self.start_date:
29
+ raise ValidationError("Start Date is required")
30
+ if not self.get("plans"):
31
+ raise ValidationError("At least one plan item is required")
32
+ if not self.billing_interval:
33
+ self._data["billing_interval"] = "Monthly"
34
+ if not self.company:
35
+ db = get_db()
36
+ companies = db.get_all("Company", fields=["name"], limit=1)
37
+ if companies:
38
+ self._data["company"] = companies[0]["name"]
39
+
40
+ # Initialize billing period
41
+ if not self.current_invoice_start:
42
+ self._data["current_invoice_start"] = self.start_date
43
+ if not self.current_invoice_end:
44
+ self._data["current_invoice_end"] = self._get_next_date(self.start_date)
45
+
46
+ self._set_status()
47
+
48
+ def _set_status(self):
49
+ if self._data.get("status") == "Cancelled":
50
+ return
51
+ today = getdate(nowdate())
52
+ if self.end_date and getdate(self.end_date) < today:
53
+ self._data["status"] = "Completed"
54
+ elif self.current_invoice_end and getdate(self.current_invoice_end) < today:
55
+ self._data["status"] = "Past Due Date"
56
+ else:
57
+ self._data["status"] = "Active"
58
+
59
+ def _get_next_date(self, from_date):
60
+ d = getdate(from_date)
61
+ interval = self.billing_interval or "Monthly"
62
+ if interval == "Monthly":
63
+ d = d + relativedelta(months=1)
64
+ elif interval == "Quarterly":
65
+ d = d + relativedelta(months=3)
66
+ elif interval == "Half-Yearly":
67
+ d = d + relativedelta(months=6)
68
+ elif interval == "Yearly":
69
+ d = d + relativedelta(years=1)
70
+ else:
71
+ d = d + relativedelta(months=1)
72
+ return str(d)
73
+
74
+ def process(self):
75
+ """Check if a new invoice should be generated and create it.
76
+
77
+ Returns the created invoice dict, or None if no invoice was due.
78
+ """
79
+ if self._data.get("status") in ("Cancelled", "Completed"):
80
+ return None
81
+
82
+ today = getdate(nowdate())
83
+ invoice_end = getdate(self.current_invoice_end) if self.current_invoice_end else today
84
+
85
+ if today < invoice_end:
86
+ return None # Not due yet
87
+
88
+ # Create invoice
89
+ invoice = self._create_invoice()
90
+
91
+ # Advance to next period
92
+ self._data["current_invoice_start"] = self.current_invoice_end
93
+ self._data["current_invoice_end"] = self._get_next_date(self.current_invoice_end)
94
+
95
+ # Check if subscription has ended
96
+ if self.end_date and getdate(self.end_date) <= today:
97
+ self._data["status"] = "Completed"
98
+ else:
99
+ self._data["status"] = "Active"
100
+
101
+ self._persist()
102
+
103
+ return invoice.as_dict()
104
+
105
+ def _create_invoice(self):
106
+ if self.party_type == "Customer":
107
+ from lambda_erp.accounting.sales_invoice import SalesInvoice
108
+ invoice = SalesInvoice(
109
+ customer=self.party,
110
+ company=self.company,
111
+ posting_date=nowdate(),
112
+ subscription=self.name,
113
+ )
114
+ else:
115
+ from lambda_erp.accounting.purchase_invoice import PurchaseInvoice
116
+ invoice = PurchaseInvoice(
117
+ supplier=self.party,
118
+ company=self.company,
119
+ posting_date=nowdate(),
120
+ subscription=self.name,
121
+ )
122
+
123
+ for plan in self.get("plans"):
124
+ invoice.append("items", _dict(
125
+ item_code=plan.get("item_code"),
126
+ item_name=plan.get("item_name"),
127
+ qty=flt(plan.get("qty", 1)),
128
+ rate=flt(plan.get("rate", 0)),
129
+ ))
130
+
131
+ invoice.save()
132
+ return invoice
File without changes