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.
- api/__init__.py +0 -0
- api/attachments.py +229 -0
- api/auth.py +511 -0
- api/bootstrap.py +498 -0
- api/chat.py +2764 -0
- api/demo_limits.py +400 -0
- api/deps.py +7 -0
- api/errors.py +56 -0
- api/main.py +182 -0
- api/pdf.py +151 -0
- api/providers.py +116 -0
- api/routers/__init__.py +0 -0
- api/routers/accounting.py +63 -0
- api/routers/admin.py +122 -0
- api/routers/analytics.py +1009 -0
- api/routers/bank_reconciliation.py +31 -0
- api/routers/documents.py +100 -0
- api/routers/masters.py +396 -0
- api/routers/reports.py +735 -0
- api/routers/setup.py +387 -0
- api/services.py +372 -0
- api/templates/document.html +197 -0
- lambda_erp/__init__.py +3 -0
- lambda_erp/accounting/__init__.py +0 -0
- lambda_erp/accounting/bank_transaction.py +76 -0
- lambda_erp/accounting/budget.py +117 -0
- lambda_erp/accounting/chart_of_accounts.py +183 -0
- lambda_erp/accounting/general_ledger.py +362 -0
- lambda_erp/accounting/journal_entry.py +235 -0
- lambda_erp/accounting/payment_entry.py +515 -0
- lambda_erp/accounting/pos_invoice.py +342 -0
- lambda_erp/accounting/purchase_invoice.py +504 -0
- lambda_erp/accounting/revaluation.py +172 -0
- lambda_erp/accounting/sales_invoice.py +523 -0
- lambda_erp/accounting/subscription.py +132 -0
- lambda_erp/buying/__init__.py +0 -0
- lambda_erp/buying/purchase_order.py +165 -0
- lambda_erp/controllers/__init__.py +0 -0
- lambda_erp/controllers/currency.py +52 -0
- lambda_erp/controllers/defaults.py +51 -0
- lambda_erp/controllers/pricing_rule.py +103 -0
- lambda_erp/controllers/taxes_and_totals.py +369 -0
- lambda_erp/database.py +1543 -0
- lambda_erp/exceptions.py +37 -0
- lambda_erp/hooks.py +37 -0
- lambda_erp/model.py +462 -0
- lambda_erp/selling/__init__.py +0 -0
- lambda_erp/selling/quotation.py +263 -0
- lambda_erp/selling/sales_order.py +214 -0
- lambda_erp/simulation.py +704 -0
- lambda_erp/stock/__init__.py +0 -0
- lambda_erp/stock/delivery_note.py +254 -0
- lambda_erp/stock/purchase_receipt.py +356 -0
- lambda_erp/stock/stock_entry.py +330 -0
- lambda_erp/stock/stock_ledger.py +337 -0
- lambda_erp/utils.py +167 -0
- lambda_erp-0.1.0.dist-info/METADATA +454 -0
- lambda_erp-0.1.0.dist-info/RECORD +60 -0
- lambda_erp-0.1.0.dist-info/WHEEL +4 -0
- 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
|