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,117 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Budget.
|
|
3
|
+
|
|
4
|
+
Budget defines an annual spending limit for an account + cost center.
|
|
5
|
+
When GL entries are posted, validate_expense_against_budget() checks
|
|
6
|
+
whether the new expense would exceed the budget and raises an error
|
|
7
|
+
(Stop) or warning (Warn) accordingly.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from lambda_erp.model import Document
|
|
11
|
+
from lambda_erp.utils import _dict, flt, getdate, nowdate
|
|
12
|
+
from lambda_erp.database import get_db
|
|
13
|
+
from lambda_erp.exceptions import ValidationError
|
|
14
|
+
|
|
15
|
+
import warnings
|
|
16
|
+
|
|
17
|
+
class Budget(Document):
|
|
18
|
+
DOCTYPE = "Budget"
|
|
19
|
+
CHILD_TABLES = {
|
|
20
|
+
"monthly_distribution": ("Monthly Distribution", None),
|
|
21
|
+
}
|
|
22
|
+
PREFIX = "BDG"
|
|
23
|
+
|
|
24
|
+
def validate(self):
|
|
25
|
+
if not self.account:
|
|
26
|
+
raise ValidationError("Account is required")
|
|
27
|
+
if not self.fiscal_year:
|
|
28
|
+
raise ValidationError("Fiscal Year is required")
|
|
29
|
+
if not self.company:
|
|
30
|
+
raise ValidationError("Company is required")
|
|
31
|
+
if flt(self.budget_amount) <= 0:
|
|
32
|
+
raise ValidationError("Budget Amount must be greater than 0")
|
|
33
|
+
|
|
34
|
+
if not self.cost_center:
|
|
35
|
+
db = get_db()
|
|
36
|
+
self._data["cost_center"] = db.get_value(
|
|
37
|
+
"Company", self.company, "default_cost_center"
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
if not self._data.get("action_if_exceeded"):
|
|
41
|
+
self._data["action_if_exceeded"] = "Warn"
|
|
42
|
+
|
|
43
|
+
def validate_expense_against_budget(gl_entry):
|
|
44
|
+
"""Check if a GL entry would exceed any active budget.
|
|
45
|
+
|
|
46
|
+
Called from make_gl_entries() in general_ledger.py before saving.
|
|
47
|
+
Only checks debit entries to expense accounts.
|
|
48
|
+
"""
|
|
49
|
+
debit = flt(gl_entry.get("debit", 0))
|
|
50
|
+
if debit <= 0:
|
|
51
|
+
return
|
|
52
|
+
|
|
53
|
+
account = gl_entry.get("account")
|
|
54
|
+
if not account:
|
|
55
|
+
return
|
|
56
|
+
|
|
57
|
+
db = get_db()
|
|
58
|
+
|
|
59
|
+
# Only check expense accounts (root_type = Expense)
|
|
60
|
+
account_data = db.get_value("Account", account, ["root_type", "is_group"])
|
|
61
|
+
if not account_data or account_data.root_type != "Expense":
|
|
62
|
+
return
|
|
63
|
+
|
|
64
|
+
cost_center = gl_entry.get("cost_center")
|
|
65
|
+
company = gl_entry.get("company")
|
|
66
|
+
posting_date = gl_entry.get("posting_date") or nowdate()
|
|
67
|
+
|
|
68
|
+
# Determine fiscal year from posting date
|
|
69
|
+
year = getdate(posting_date).year
|
|
70
|
+
|
|
71
|
+
# Find matching budgets
|
|
72
|
+
filters = {"account": account, "company": company}
|
|
73
|
+
if cost_center:
|
|
74
|
+
filters["cost_center"] = cost_center
|
|
75
|
+
|
|
76
|
+
budgets = db.get_all("Budget", filters=filters, fields=["*"])
|
|
77
|
+
|
|
78
|
+
for budget in budgets:
|
|
79
|
+
budget_amount = flt(budget.get("budget_amount", 0))
|
|
80
|
+
if budget_amount <= 0:
|
|
81
|
+
continue
|
|
82
|
+
|
|
83
|
+
fiscal_year = budget.get("fiscal_year", "")
|
|
84
|
+
if fiscal_year and str(year) not in fiscal_year:
|
|
85
|
+
continue
|
|
86
|
+
|
|
87
|
+
# Sum existing expenses for this account + cost_center in this year
|
|
88
|
+
start_date = f"{year}-01-01"
|
|
89
|
+
end_date = f"{year}-12-31"
|
|
90
|
+
|
|
91
|
+
result = db.sql(
|
|
92
|
+
"""
|
|
93
|
+
SELECT COALESCE(SUM(debit), 0) - COALESCE(SUM(credit), 0) as total
|
|
94
|
+
FROM "GL Entry"
|
|
95
|
+
WHERE account = ?
|
|
96
|
+
AND company = ?
|
|
97
|
+
AND posting_date >= ? AND posting_date <= ?
|
|
98
|
+
AND is_cancelled = 0
|
|
99
|
+
""",
|
|
100
|
+
[account, company, start_date, end_date],
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
actual_expense = flt(result[0].get("total", 0)) if result else 0
|
|
104
|
+
total_with_new = actual_expense + debit
|
|
105
|
+
|
|
106
|
+
action = budget.get("action_if_exceeded", "Warn")
|
|
107
|
+
|
|
108
|
+
if total_with_new > budget_amount:
|
|
109
|
+
msg = (
|
|
110
|
+
f"Budget exceeded for {account}: "
|
|
111
|
+
f"Budget {budget_amount:.2f}, "
|
|
112
|
+
f"Actual {actual_expense:.2f} + New {debit:.2f} = {total_with_new:.2f}"
|
|
113
|
+
)
|
|
114
|
+
if action == "Stop":
|
|
115
|
+
raise ValidationError(msg)
|
|
116
|
+
elif action == "Warn":
|
|
117
|
+
warnings.warn(msg)
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Standard Chart of Accounts setup.
|
|
3
|
+
|
|
4
|
+
In the reference implementation, the Chart of Accounts is a tree structure defined in JSON files
|
|
5
|
+
under reference_impl/accounts/doctype/account/chart_of_accounts/. Each country
|
|
6
|
+
has its own chart.
|
|
7
|
+
|
|
8
|
+
This module provides a simplified standard chart that covers the essential
|
|
9
|
+
account types needed for a working ERP system.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from lambda_erp.utils import _dict, new_name
|
|
13
|
+
from lambda_erp.database import get_db
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# Standard chart of accounts - matches the essential structure from the reference implementation
|
|
17
|
+
STANDARD_CHART = {
|
|
18
|
+
"Assets": {
|
|
19
|
+
"root_type": "Asset",
|
|
20
|
+
"report_type": "Balance Sheet",
|
|
21
|
+
"children": {
|
|
22
|
+
"Current Assets": {
|
|
23
|
+
"children": {
|
|
24
|
+
"Accounts Receivable": {
|
|
25
|
+
"account_type": "Receivable",
|
|
26
|
+
},
|
|
27
|
+
"Bank Accounts": {
|
|
28
|
+
"children": {
|
|
29
|
+
"Primary Bank": {"account_type": "Bank"},
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
"Cash": {"account_type": "Cash"},
|
|
33
|
+
"Stock In Hand": {"account_type": "Stock"},
|
|
34
|
+
"Stock Received But Not Billed": {
|
|
35
|
+
"account_type": "Stock Received But Not Billed",
|
|
36
|
+
},
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
"Fixed Assets": {
|
|
40
|
+
"children": {
|
|
41
|
+
"Fixed Asset Account": {"account_type": "Fixed Asset"},
|
|
42
|
+
"Accumulated Depreciation": {
|
|
43
|
+
"account_type": "Accumulated Depreciation",
|
|
44
|
+
},
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
"Liabilities": {
|
|
50
|
+
"root_type": "Liability",
|
|
51
|
+
"report_type": "Balance Sheet",
|
|
52
|
+
"children": {
|
|
53
|
+
"Current Liabilities": {
|
|
54
|
+
"children": {
|
|
55
|
+
"Accounts Payable": {"account_type": "Payable"},
|
|
56
|
+
"Tax Payable": {"account_type": "Tax"},
|
|
57
|
+
"Salary Payable": {"account_type": "Payable"},
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
"Equity": {
|
|
63
|
+
"root_type": "Equity",
|
|
64
|
+
"report_type": "Balance Sheet",
|
|
65
|
+
"children": {
|
|
66
|
+
"Opening Balance Equity": {},
|
|
67
|
+
"Retained Earnings": {},
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
"Income": {
|
|
71
|
+
"root_type": "Income",
|
|
72
|
+
"report_type": "Profit and Loss",
|
|
73
|
+
"children": {
|
|
74
|
+
"Sales Revenue": {"account_type": "Income Account"},
|
|
75
|
+
"Service Revenue": {"account_type": "Income Account"},
|
|
76
|
+
"Other Income": {},
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
"Expenses": {
|
|
80
|
+
"root_type": "Expense",
|
|
81
|
+
"report_type": "Profit and Loss",
|
|
82
|
+
"children": {
|
|
83
|
+
"Cost of Goods Sold": {"account_type": "Cost of Goods Sold"},
|
|
84
|
+
"Operating Expenses": {
|
|
85
|
+
"children": {
|
|
86
|
+
"Administrative Expenses": {},
|
|
87
|
+
"Marketing Expenses": {},
|
|
88
|
+
"Salary Expense": {},
|
|
89
|
+
# Standard charge accounts for supplier-invoice charges
|
|
90
|
+
# that aren't item lines — freight/shipping, customs,
|
|
91
|
+
# import duties. Referenced via Company.default_* so the
|
|
92
|
+
# chat (or a human) has a sensible target when parsing
|
|
93
|
+
# a supplier bill with those charges on it.
|
|
94
|
+
"Freight In": {"account_type": "Chargeable"},
|
|
95
|
+
"Customs & Duties": {"account_type": "Chargeable"},
|
|
96
|
+
# Realized FX gain/loss on settling foreign-currency
|
|
97
|
+
# invoices (Payment Entry). Net of debits (losses) and
|
|
98
|
+
# credits (gains); sits in P&L.
|
|
99
|
+
"Exchange Gain/Loss": {},
|
|
100
|
+
# Unrealized FX from period-end revaluation of open foreign
|
|
101
|
+
# monetary balances. Posted at period end and reversed the
|
|
102
|
+
# next period (kept separate from realized for reporting).
|
|
103
|
+
"Unrealized Exchange Gain/Loss": {},
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
"Depreciation Expense": {"account_type": "Depreciation"},
|
|
107
|
+
"Stock Adjustment": {"account_type": "Stock Adjustment"},
|
|
108
|
+
"Round Off": {"account_type": "Round Off"},
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def setup_chart_of_accounts(company_name, currency="USD"):
|
|
115
|
+
"""Create all accounts for a company from the standard chart.
|
|
116
|
+
|
|
117
|
+
This mirrors the reference implementation's setup wizard step that creates the Chart of Accounts
|
|
118
|
+
from a template when a new company is created.
|
|
119
|
+
"""
|
|
120
|
+
db = get_db()
|
|
121
|
+
|
|
122
|
+
def _create_accounts(tree, parent=None, root_type=None, report_type=None):
|
|
123
|
+
for account_name, details in tree.items():
|
|
124
|
+
rt = details.get("root_type", root_type)
|
|
125
|
+
rpt = details.get("report_type", report_type)
|
|
126
|
+
has_children = "children" in details
|
|
127
|
+
|
|
128
|
+
name = f"{account_name} - {company_name[:4].upper()}"
|
|
129
|
+
|
|
130
|
+
account = _dict(
|
|
131
|
+
name=name,
|
|
132
|
+
account_name=account_name,
|
|
133
|
+
parent_account=parent,
|
|
134
|
+
company=company_name,
|
|
135
|
+
root_type=rt,
|
|
136
|
+
report_type=rpt,
|
|
137
|
+
account_type=details.get("account_type", ""),
|
|
138
|
+
account_currency=currency,
|
|
139
|
+
is_group=1 if has_children else 0,
|
|
140
|
+
)
|
|
141
|
+
db.insert("Account", account)
|
|
142
|
+
|
|
143
|
+
if has_children:
|
|
144
|
+
_create_accounts(details["children"], parent=name, root_type=rt, report_type=rpt)
|
|
145
|
+
|
|
146
|
+
_create_accounts(STANDARD_CHART)
|
|
147
|
+
|
|
148
|
+
# Set up company defaults
|
|
149
|
+
abbr = company_name[:4].upper()
|
|
150
|
+
db.set_value("Company", company_name, {
|
|
151
|
+
"default_receivable_account": f"Accounts Receivable - {abbr}",
|
|
152
|
+
"default_payable_account": f"Accounts Payable - {abbr}",
|
|
153
|
+
"default_income_account": f"Sales Revenue - {abbr}",
|
|
154
|
+
"default_expense_account": f"Cost of Goods Sold - {abbr}",
|
|
155
|
+
"round_off_account": f"Round Off - {abbr}",
|
|
156
|
+
"stock_in_hand_account": f"Stock In Hand - {abbr}",
|
|
157
|
+
"stock_received_but_not_billed": f"Stock Received But Not Billed - {abbr}",
|
|
158
|
+
"stock_adjustment_account": f"Stock Adjustment - {abbr}",
|
|
159
|
+
"default_opening_balance_equity": f"Opening Balance Equity - {abbr}",
|
|
160
|
+
"default_freight_in_account": f"Freight In - {abbr}",
|
|
161
|
+
"default_customs_account": f"Customs & Duties - {abbr}",
|
|
162
|
+
"default_exchange_gain_loss_account": f"Exchange Gain/Loss - {abbr}",
|
|
163
|
+
"default_unrealized_exchange_account": f"Unrealized Exchange Gain/Loss - {abbr}",
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
db.commit()
|
|
167
|
+
return True
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def setup_cost_center(company_name):
|
|
171
|
+
"""Create default cost center for a company."""
|
|
172
|
+
db = get_db()
|
|
173
|
+
name = f"Main - {company_name[:4].upper()}"
|
|
174
|
+
db.insert("Cost Center", _dict(
|
|
175
|
+
name=name,
|
|
176
|
+
cost_center_name="Main",
|
|
177
|
+
company=company_name,
|
|
178
|
+
is_group=0,
|
|
179
|
+
))
|
|
180
|
+
db.set_value("Company", company_name, "default_cost_center", name)
|
|
181
|
+
db.set_value("Company", company_name, "round_off_cost_center", name)
|
|
182
|
+
db.commit()
|
|
183
|
+
return name
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
"""
|
|
2
|
+
General Ledger entry creation engine.
|
|
3
|
+
|
|
4
|
+
The heart of double-entry bookkeeping. Every financial transaction ultimately
|
|
5
|
+
calls make_gl_entries() to post debit/credit entries to the General Ledger.
|
|
6
|
+
|
|
7
|
+
Key invariant: total debits MUST equal total credits for every voucher.
|
|
8
|
+
|
|
9
|
+
The flow:
|
|
10
|
+
Document.on_submit() -> make_gl_entries(gl_map)
|
|
11
|
+
-> process_gl_map() (merge similar entries)
|
|
12
|
+
-> toggle_debit_credit_if_negative()
|
|
13
|
+
-> process_debit_credit_difference() (round-off adjustment)
|
|
14
|
+
-> save_entries() (persist to DB)
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import copy
|
|
18
|
+
from lambda_erp.utils import _dict, flt, new_name, now
|
|
19
|
+
from lambda_erp.database import get_db
|
|
20
|
+
from lambda_erp.exceptions import DebitCreditNotEqual, InvalidAccountError
|
|
21
|
+
|
|
22
|
+
def make_gl_entries(gl_map, cancel=False, merge_entries=True):
|
|
23
|
+
"""Create GL entries from a list of GL entry dicts.
|
|
24
|
+
|
|
25
|
+
This is the main entry point, called by every document that affects
|
|
26
|
+
the accounting ledger (invoices, payments, journal entries, stock entries
|
|
27
|
+
with perpetual inventory, etc.).
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
gl_map: list of _dict with keys like account, debit, credit,
|
|
31
|
+
voucher_type, voucher_no, posting_date, company, etc.
|
|
32
|
+
cancel: if True, create reversing entries instead
|
|
33
|
+
merge_entries: if True, merge entries with same account/party/etc.
|
|
34
|
+
"""
|
|
35
|
+
if not gl_map:
|
|
36
|
+
return
|
|
37
|
+
|
|
38
|
+
if cancel:
|
|
39
|
+
make_reverse_gl_entries(gl_map)
|
|
40
|
+
return
|
|
41
|
+
|
|
42
|
+
gl_map = process_gl_map(gl_map, merge_entries)
|
|
43
|
+
|
|
44
|
+
if gl_map and len(gl_map) > 1:
|
|
45
|
+
process_debit_credit_difference(gl_map)
|
|
46
|
+
validate_disabled_accounts(gl_map)
|
|
47
|
+
|
|
48
|
+
# Budget check — validate expense entries before saving
|
|
49
|
+
from lambda_erp.accounting.budget import validate_expense_against_budget
|
|
50
|
+
for entry in gl_map:
|
|
51
|
+
validate_expense_against_budget(entry)
|
|
52
|
+
|
|
53
|
+
save_entries(gl_map)
|
|
54
|
+
elif gl_map:
|
|
55
|
+
raise InvalidAccountError(
|
|
56
|
+
"Incorrect number of General Ledger Entries found. "
|
|
57
|
+
"You might have selected a wrong Account in the transaction."
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
def to_base_currency(gl_entries, conversion_rate):
|
|
61
|
+
"""Scale document-currency GL amounts to the company's base currency.
|
|
62
|
+
|
|
63
|
+
Financial GL entries are built in document currency (debit ==
|
|
64
|
+
debit_in_account_currency). The ledger's debit/credit columns must be in
|
|
65
|
+
the company's base/functional currency, so multiply them by conversion_rate
|
|
66
|
+
while leaving the *_in_account_currency amounts in document currency.
|
|
67
|
+
|
|
68
|
+
Only pass financial entries (AR/AP, income/expense, tax, payments) through
|
|
69
|
+
this. Cost-of-goods / stock entries are already valued in base currency and
|
|
70
|
+
must NOT be re-scaled. A rate of 1.0 (single-currency, the default) is a
|
|
71
|
+
no-op, so existing single-currency books are untouched.
|
|
72
|
+
"""
|
|
73
|
+
rate = flt(conversion_rate) or 1.0
|
|
74
|
+
if rate == 1.0:
|
|
75
|
+
return gl_entries
|
|
76
|
+
for entry in gl_entries:
|
|
77
|
+
entry["debit"] = flt(flt(entry.get("debit", 0)) * rate, 2)
|
|
78
|
+
entry["credit"] = flt(flt(entry.get("credit", 0)) * rate, 2)
|
|
79
|
+
return gl_entries
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def process_gl_map(gl_map, merge_entries=True):
|
|
83
|
+
"""Process the GL entry map: merge similar entries, fix negative values."""
|
|
84
|
+
if not gl_map:
|
|
85
|
+
return []
|
|
86
|
+
|
|
87
|
+
if merge_entries:
|
|
88
|
+
gl_map = merge_similar_entries(gl_map)
|
|
89
|
+
|
|
90
|
+
gl_map = toggle_debit_credit_if_negative(gl_map)
|
|
91
|
+
|
|
92
|
+
return gl_map
|
|
93
|
+
|
|
94
|
+
def merge_similar_entries(gl_map):
|
|
95
|
+
"""Merge GL entries that have the same account + party + voucher_detail.
|
|
96
|
+
|
|
97
|
+
This is important because a single invoice might have multiple items
|
|
98
|
+
hitting the same income account - we want one consolidated GL entry.
|
|
99
|
+
"""
|
|
100
|
+
merged = []
|
|
101
|
+
merge_keys = [
|
|
102
|
+
"account", "cost_center", "party", "party_type",
|
|
103
|
+
"against_voucher", "against_voucher_type", "voucher_no"
|
|
104
|
+
]
|
|
105
|
+
|
|
106
|
+
for entry in gl_map:
|
|
107
|
+
key = tuple(entry.get(k, "") for k in merge_keys)
|
|
108
|
+
entry["_merge_key"] = key
|
|
109
|
+
|
|
110
|
+
same_head = None
|
|
111
|
+
for existing in merged:
|
|
112
|
+
if existing.get("_merge_key") == key:
|
|
113
|
+
same_head = existing
|
|
114
|
+
break
|
|
115
|
+
|
|
116
|
+
if same_head:
|
|
117
|
+
same_head["debit"] = flt(same_head["debit"]) + flt(entry.get("debit", 0))
|
|
118
|
+
same_head["credit"] = flt(same_head["credit"]) + flt(entry.get("credit", 0))
|
|
119
|
+
same_head["debit_in_account_currency"] = (
|
|
120
|
+
flt(same_head.get("debit_in_account_currency", 0))
|
|
121
|
+
+ flt(entry.get("debit_in_account_currency", 0))
|
|
122
|
+
)
|
|
123
|
+
same_head["credit_in_account_currency"] = (
|
|
124
|
+
flt(same_head.get("credit_in_account_currency", 0))
|
|
125
|
+
+ flt(entry.get("credit_in_account_currency", 0))
|
|
126
|
+
)
|
|
127
|
+
else:
|
|
128
|
+
merged.append(entry)
|
|
129
|
+
|
|
130
|
+
# Filter zero entries
|
|
131
|
+
merged = [e for e in merged if flt(e.get("debit"), 2) != 0 or flt(e.get("credit"), 2) != 0]
|
|
132
|
+
|
|
133
|
+
return merged
|
|
134
|
+
|
|
135
|
+
def toggle_debit_credit_if_negative(gl_map):
|
|
136
|
+
"""If debit is negative, move it to credit and vice versa.
|
|
137
|
+
|
|
138
|
+
Ensures all GL entries have non-negative debit and credit values.
|
|
139
|
+
"""
|
|
140
|
+
for entry in gl_map:
|
|
141
|
+
debit = flt(entry.get("debit", 0))
|
|
142
|
+
credit = flt(entry.get("credit", 0))
|
|
143
|
+
|
|
144
|
+
if debit < 0:
|
|
145
|
+
credit = credit - debit
|
|
146
|
+
debit = 0.0
|
|
147
|
+
|
|
148
|
+
if credit < 0:
|
|
149
|
+
debit = debit - credit
|
|
150
|
+
credit = 0.0
|
|
151
|
+
|
|
152
|
+
entry["debit"] = flt(debit, 2)
|
|
153
|
+
entry["credit"] = flt(credit, 2)
|
|
154
|
+
|
|
155
|
+
# Same for account currency amounts
|
|
156
|
+
debit_acc = flt(entry.get("debit_in_account_currency", 0))
|
|
157
|
+
credit_acc = flt(entry.get("credit_in_account_currency", 0))
|
|
158
|
+
|
|
159
|
+
if debit_acc < 0:
|
|
160
|
+
credit_acc = credit_acc - debit_acc
|
|
161
|
+
debit_acc = 0.0
|
|
162
|
+
if credit_acc < 0:
|
|
163
|
+
debit_acc = debit_acc - credit_acc
|
|
164
|
+
credit_acc = 0.0
|
|
165
|
+
|
|
166
|
+
entry["debit_in_account_currency"] = flt(debit_acc, 2)
|
|
167
|
+
entry["credit_in_account_currency"] = flt(credit_acc, 2)
|
|
168
|
+
|
|
169
|
+
return gl_map
|
|
170
|
+
|
|
171
|
+
def process_debit_credit_difference(gl_map):
|
|
172
|
+
"""Ensure total debits == total credits, adding round-off entry if needed.
|
|
173
|
+
|
|
174
|
+
This is the double-entry bookkeeping integrity check. Due to rounding,
|
|
175
|
+
there can be small differences which are posted to a round-off account.
|
|
176
|
+
"""
|
|
177
|
+
precision = 2
|
|
178
|
+
debit_credit_diff = sum(
|
|
179
|
+
flt(e.get("debit", 0), precision) - flt(e.get("credit", 0), precision)
|
|
180
|
+
for e in gl_map
|
|
181
|
+
)
|
|
182
|
+
debit_credit_diff = flt(debit_credit_diff, precision)
|
|
183
|
+
|
|
184
|
+
allowance = 0.5 # the reference implementation allows 0.5 for non-JE/PE documents
|
|
185
|
+
|
|
186
|
+
if abs(debit_credit_diff) > allowance:
|
|
187
|
+
raise DebitCreditNotEqual(
|
|
188
|
+
f"Debit and Credit not equal for {gl_map[0].get('voucher_type')} "
|
|
189
|
+
f"#{gl_map[0].get('voucher_no')}. Difference is {debit_credit_diff}."
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
# Add round-off entry if there's a small difference
|
|
193
|
+
if abs(debit_credit_diff) >= 0.01:
|
|
194
|
+
db = get_db()
|
|
195
|
+
company = gl_map[0].get("company")
|
|
196
|
+
round_off_account = db.get_value("Company", company, "round_off_account")
|
|
197
|
+
round_off_cost_center = db.get_value("Company", company, "round_off_cost_center")
|
|
198
|
+
|
|
199
|
+
if not round_off_account:
|
|
200
|
+
round_off_account = db.get_value("Company", company, "default_expense_account")
|
|
201
|
+
|
|
202
|
+
if round_off_account:
|
|
203
|
+
gl_map.append(
|
|
204
|
+
_dict(
|
|
205
|
+
account=round_off_account,
|
|
206
|
+
cost_center=round_off_cost_center,
|
|
207
|
+
debit=abs(debit_credit_diff) if debit_credit_diff < 0 else 0,
|
|
208
|
+
credit=debit_credit_diff if debit_credit_diff > 0 else 0,
|
|
209
|
+
debit_in_account_currency=abs(debit_credit_diff) if debit_credit_diff < 0 else 0,
|
|
210
|
+
credit_in_account_currency=debit_credit_diff if debit_credit_diff > 0 else 0,
|
|
211
|
+
voucher_type=gl_map[0].get("voucher_type"),
|
|
212
|
+
voucher_no=gl_map[0].get("voucher_no"),
|
|
213
|
+
posting_date=gl_map[0].get("posting_date"),
|
|
214
|
+
company=company,
|
|
215
|
+
remarks="Round-off adjustment",
|
|
216
|
+
)
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
def validate_disabled_accounts(gl_map):
|
|
220
|
+
"""Check that no disabled accounts are used."""
|
|
221
|
+
db = get_db()
|
|
222
|
+
for entry in gl_map:
|
|
223
|
+
account = entry.get("account")
|
|
224
|
+
if account:
|
|
225
|
+
disabled = db.get_value("Account", account, "disabled")
|
|
226
|
+
if disabled:
|
|
227
|
+
raise InvalidAccountError(
|
|
228
|
+
f"Cannot create accounting entries against disabled account: {account}"
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
def save_entries(gl_map):
|
|
232
|
+
"""Persist GL entries to the database."""
|
|
233
|
+
db = get_db()
|
|
234
|
+
for entry in gl_map:
|
|
235
|
+
gle = _dict(
|
|
236
|
+
name=new_name("GLE"),
|
|
237
|
+
posting_date=entry.get("posting_date"),
|
|
238
|
+
account=entry.get("account"),
|
|
239
|
+
party_type=entry.get("party_type"),
|
|
240
|
+
party=entry.get("party"),
|
|
241
|
+
cost_center=entry.get("cost_center"),
|
|
242
|
+
debit=flt(entry.get("debit"), 2),
|
|
243
|
+
credit=flt(entry.get("credit"), 2),
|
|
244
|
+
debit_in_account_currency=flt(entry.get("debit_in_account_currency"), 2),
|
|
245
|
+
credit_in_account_currency=flt(entry.get("credit_in_account_currency"), 2),
|
|
246
|
+
account_currency=entry.get("account_currency", "USD"),
|
|
247
|
+
voucher_type=entry.get("voucher_type"),
|
|
248
|
+
voucher_no=entry.get("voucher_no"),
|
|
249
|
+
against_voucher_type=entry.get("against_voucher_type"),
|
|
250
|
+
against_voucher=entry.get("against_voucher"),
|
|
251
|
+
remarks=entry.get("remarks"),
|
|
252
|
+
is_opening=entry.get("is_opening", "No"),
|
|
253
|
+
is_cancelled=0,
|
|
254
|
+
company=entry.get("company"),
|
|
255
|
+
fiscal_year=entry.get("fiscal_year"),
|
|
256
|
+
creation=now(),
|
|
257
|
+
modified=now(),
|
|
258
|
+
)
|
|
259
|
+
db.insert("GL Entry", gle)
|
|
260
|
+
|
|
261
|
+
def make_reverse_gl_entries(gl_entries=None, voucher_type=None, voucher_no=None):
|
|
262
|
+
"""Create reversing GL entries (swap debit/credit).
|
|
263
|
+
|
|
264
|
+
Called on document cancellation.
|
|
265
|
+
"""
|
|
266
|
+
db = get_db()
|
|
267
|
+
|
|
268
|
+
if not gl_entries:
|
|
269
|
+
gl_entries = db.get_all(
|
|
270
|
+
"GL Entry",
|
|
271
|
+
filters={"voucher_type": voucher_type, "voucher_no": voucher_no, "is_cancelled": 0},
|
|
272
|
+
fields=["*"],
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
if not gl_entries:
|
|
276
|
+
return
|
|
277
|
+
|
|
278
|
+
# Mark original entries as cancelled
|
|
279
|
+
for entry in gl_entries:
|
|
280
|
+
db.set_value("GL Entry", entry["name"], "is_cancelled", 1)
|
|
281
|
+
|
|
282
|
+
# Create reverse entries
|
|
283
|
+
for entry in gl_entries:
|
|
284
|
+
reverse = _dict(
|
|
285
|
+
name=new_name("GLE"),
|
|
286
|
+
posting_date=entry.get("posting_date"),
|
|
287
|
+
account=entry.get("account"),
|
|
288
|
+
party_type=entry.get("party_type"),
|
|
289
|
+
party=entry.get("party"),
|
|
290
|
+
cost_center=entry.get("cost_center"),
|
|
291
|
+
debit=flt(entry.get("credit"), 2), # swapped
|
|
292
|
+
credit=flt(entry.get("debit"), 2), # swapped
|
|
293
|
+
debit_in_account_currency=flt(entry.get("credit_in_account_currency"), 2),
|
|
294
|
+
credit_in_account_currency=flt(entry.get("debit_in_account_currency"), 2),
|
|
295
|
+
account_currency=entry.get("account_currency", "USD"),
|
|
296
|
+
voucher_type=entry.get("voucher_type"),
|
|
297
|
+
voucher_no=entry.get("voucher_no"),
|
|
298
|
+
against_voucher_type=entry.get("against_voucher_type"),
|
|
299
|
+
against_voucher=entry.get("against_voucher"),
|
|
300
|
+
remarks=f"On cancellation of {entry.get('voucher_no')}",
|
|
301
|
+
is_opening=entry.get("is_opening", "No"),
|
|
302
|
+
is_cancelled=1,
|
|
303
|
+
company=entry.get("company"),
|
|
304
|
+
fiscal_year=entry.get("fiscal_year"),
|
|
305
|
+
creation=now(),
|
|
306
|
+
modified=now(),
|
|
307
|
+
)
|
|
308
|
+
db.insert("GL Entry", reverse)
|
|
309
|
+
|
|
310
|
+
db.commit()
|
|
311
|
+
|
|
312
|
+
def get_account_balances(account, company=None):
|
|
313
|
+
"""Return (base_balance, account_currency_balance) for an account.
|
|
314
|
+
|
|
315
|
+
base_balance sums debit−credit (company currency); account_currency_balance
|
|
316
|
+
sums the *_in_account_currency columns. For a foreign-currency bank account
|
|
317
|
+
their ratio is the average carrying rate, used when converting the balance.
|
|
318
|
+
"""
|
|
319
|
+
db = get_db()
|
|
320
|
+
query = (
|
|
321
|
+
'SELECT COALESCE(SUM(debit), 0) - COALESCE(SUM(credit), 0) AS base, '
|
|
322
|
+
'COALESCE(SUM(debit_in_account_currency), 0) - '
|
|
323
|
+
'COALESCE(SUM(credit_in_account_currency), 0) AS ccy '
|
|
324
|
+
'FROM "GL Entry" WHERE account = ? AND is_cancelled = 0'
|
|
325
|
+
)
|
|
326
|
+
params = [account]
|
|
327
|
+
if company:
|
|
328
|
+
query += " AND company = ?"
|
|
329
|
+
params.append(company)
|
|
330
|
+
rows = db.sql(query, params)
|
|
331
|
+
if not rows:
|
|
332
|
+
return 0.0, 0.0
|
|
333
|
+
return flt(rows[0]["base"]), flt(rows[0]["ccy"])
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def get_gl_balance(account, company=None, posting_date=None):
|
|
337
|
+
"""Get the balance of an account (debit - credit).
|
|
338
|
+
|
|
339
|
+
Convenience function for querying account balances.
|
|
340
|
+
"""
|
|
341
|
+
db = get_db()
|
|
342
|
+
filters = {"account": account, "is_cancelled": 0}
|
|
343
|
+
if company:
|
|
344
|
+
filters["company"] = company
|
|
345
|
+
|
|
346
|
+
query = """
|
|
347
|
+
SELECT
|
|
348
|
+
COALESCE(SUM(debit), 0) - COALESCE(SUM(credit), 0) as balance
|
|
349
|
+
FROM "GL Entry"
|
|
350
|
+
WHERE account = ? AND is_cancelled = 0
|
|
351
|
+
"""
|
|
352
|
+
params = [account]
|
|
353
|
+
|
|
354
|
+
if company:
|
|
355
|
+
query += " AND company = ?"
|
|
356
|
+
params.append(company)
|
|
357
|
+
if posting_date:
|
|
358
|
+
query += " AND posting_date <= ?"
|
|
359
|
+
params.append(posting_date)
|
|
360
|
+
|
|
361
|
+
result = db.sql(query, params)
|
|
362
|
+
return flt(result[0]["balance"]) if result else 0
|