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,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