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
api/routers/reports.py ADDED
@@ -0,0 +1,735 @@
1
+ """Reporting endpoints: Trial Balance, General Ledger, Stock Balance, P&L, Balance Sheet, Aging."""
2
+
3
+ from datetime import date
4
+
5
+ from fastapi import APIRouter, Depends, Query
6
+ from lambda_erp.database import get_db
7
+ from lambda_erp.utils import flt, nowdate
8
+ from lambda_erp.controllers.currency import get_exchange_rate
9
+ from api.auth import require_role
10
+
11
+ router = APIRouter(prefix="/reports", tags=["reports"], dependencies=[Depends(require_role("viewer"))])
12
+
13
+
14
+ def _scale_amounts(obj, rate):
15
+ """Recursively multiply every numeric amount by `rate` (booleans/strings
16
+ left alone). Safe for the financial statements, where every number is money."""
17
+ if isinstance(obj, bool):
18
+ return obj
19
+ if isinstance(obj, (int, float)):
20
+ return flt(obj * rate, 2)
21
+ if isinstance(obj, dict):
22
+ return {k: _scale_amounts(v, rate) for k, v in obj.items()}
23
+ if isinstance(obj, list):
24
+ return [_scale_amounts(v, rate) for v in obj]
25
+ return obj
26
+
27
+
28
+ def _present(db, report, company, presentation_currency, rate_date):
29
+ """Re-express a base-currency report in a presentation currency for display
30
+ only (a convenience translation at a single closing rate). The stored ledger
31
+ is never changed. A trial balance still balances since every line scales by
32
+ the same rate."""
33
+ base_ccy = (db.get_value("Company", company, "default_currency") if company else None) or "USD"
34
+ if not presentation_currency or presentation_currency == base_ccy:
35
+ report["base_currency"] = base_ccy
36
+ report["presentation_currency"] = base_ccy
37
+ report["presentation_rate"] = 1.0
38
+ return report
39
+ rate = get_exchange_rate(base_ccy, presentation_currency, rate_date or nowdate())
40
+ presented = _scale_amounts(report, rate)
41
+ presented["base_currency"] = base_ccy
42
+ presented["presentation_currency"] = presentation_currency
43
+ presented["presentation_rate"] = flt(rate, 6)
44
+ return presented
45
+
46
+
47
+ def _trial_balance(db, company=None, from_date=None, to_date=None):
48
+ filters = {"is_group": 0}
49
+ if company:
50
+ filters["company"] = company
51
+ accounts = db.get_all(
52
+ "Account", filters=filters,
53
+ fields=["name", "account_name", "root_type", "report_type"],
54
+ order_by="root_type, name",
55
+ )
56
+
57
+ date_clause = ""
58
+ params = []
59
+ if from_date:
60
+ date_clause += " AND posting_date >= ?"
61
+ params.append(from_date)
62
+ if to_date:
63
+ date_clause += " AND posting_date <= ?"
64
+ params.append(to_date)
65
+ if company:
66
+ date_clause += " AND company = ?"
67
+ params.append(company)
68
+
69
+ gl_data = db.sql(
70
+ f"""SELECT account,
71
+ COALESCE(SUM(debit), 0) as total_debit,
72
+ COALESCE(SUM(credit), 0) as total_credit
73
+ FROM "GL Entry"
74
+ WHERE is_cancelled = 0 {date_clause}
75
+ GROUP BY account""",
76
+ params,
77
+ )
78
+ gl_map = {row["account"]: row for row in gl_data}
79
+
80
+ rows = []
81
+ total_debit = 0
82
+ total_credit = 0
83
+ for acc in accounts:
84
+ gl = gl_map.get(acc["name"])
85
+ if not gl:
86
+ continue
87
+ debit = flt(gl["total_debit"], 2)
88
+ credit = flt(gl["total_credit"], 2)
89
+ if not debit and not credit:
90
+ continue
91
+ balance = flt(debit - credit, 2)
92
+ rows.append({
93
+ "account": acc["name"],
94
+ "account_name": acc["account_name"],
95
+ "root_type": acc["root_type"],
96
+ "report_type": acc["report_type"],
97
+ "debit": debit,
98
+ "credit": credit,
99
+ "balance": balance,
100
+ })
101
+ total_debit += debit
102
+ total_credit += credit
103
+
104
+ return {
105
+ "rows": rows,
106
+ "total_debit": flt(total_debit, 2),
107
+ "total_credit": flt(total_credit, 2),
108
+ "difference": flt(total_debit - total_credit, 2),
109
+ }
110
+
111
+
112
+ @router.get("/trial-balance")
113
+ def trial_balance(
114
+ company: str | None = None,
115
+ from_date: str | None = None,
116
+ to_date: str | None = None,
117
+ presentation_currency: str | None = None,
118
+ ):
119
+ db = get_db()
120
+ return _present(db, _trial_balance(db, company, from_date, to_date),
121
+ company, presentation_currency, to_date)
122
+
123
+
124
+ def _general_ledger(db, filters=None):
125
+ filters = filters or {}
126
+ where = ["is_cancelled = 0"]
127
+ params = []
128
+
129
+ if filters.get("account"):
130
+ where.append("account = ?")
131
+ params.append(filters["account"])
132
+ if filters.get("party"):
133
+ where.append("party = ?")
134
+ params.append(filters["party"])
135
+ if filters.get("voucher_type"):
136
+ where.append("voucher_type = ?")
137
+ params.append(filters["voucher_type"])
138
+ if filters.get("from_date"):
139
+ where.append("posting_date >= ?")
140
+ params.append(filters["from_date"])
141
+ if filters.get("to_date"):
142
+ where.append("posting_date <= ?")
143
+ params.append(filters["to_date"])
144
+ if filters.get("company"):
145
+ where.append("company = ?")
146
+ params.append(filters["company"])
147
+
148
+ limit = int(filters.get("limit", 200))
149
+ offset = int(filters.get("offset", 0))
150
+ where_str = " AND ".join(where)
151
+
152
+ # Total count for pagination controls.
153
+ total = db.sql(
154
+ f'SELECT COUNT(*) AS n FROM "GL Entry" WHERE {where_str}',
155
+ params,
156
+ )[0]["n"]
157
+
158
+ # Running balance must still be correct on page N: sum the net of every
159
+ # row that precedes this page's first row, then accumulate within the
160
+ # page. Sub-select avoids pulling the whole ledger into memory.
161
+ opening_balance = 0
162
+ if offset > 0:
163
+ opening_rows = db.sql(
164
+ f"""SELECT COALESCE(SUM(debit - credit), 0) AS opening
165
+ FROM (
166
+ SELECT debit, credit FROM "GL Entry"
167
+ WHERE {where_str}
168
+ ORDER BY posting_date, name
169
+ LIMIT ?
170
+ )""",
171
+ params + [offset],
172
+ )
173
+ opening_balance = flt(opening_rows[0]["opening"]) if opening_rows else 0
174
+
175
+ rows = db.sql(
176
+ f"""SELECT posting_date, account, party_type, party,
177
+ debit, credit, voucher_type, voucher_no, remarks
178
+ FROM "GL Entry"
179
+ WHERE {where_str}
180
+ ORDER BY posting_date, name
181
+ LIMIT ? OFFSET ?""",
182
+ params + [limit, offset],
183
+ )
184
+
185
+ balance = opening_balance
186
+ result = []
187
+ for row in rows:
188
+ balance += flt(row["debit"]) - flt(row["credit"])
189
+ entry = dict(row)
190
+ entry["balance"] = flt(balance, 2)
191
+ result.append(entry)
192
+
193
+ return {
194
+ "rows": result,
195
+ "total": int(total or 0),
196
+ "limit": limit,
197
+ "offset": offset,
198
+ "opening_balance": flt(opening_balance, 2),
199
+ }
200
+
201
+
202
+ @router.get("/general-ledger")
203
+ def general_ledger(
204
+ account: str | None = None,
205
+ party: str | None = None,
206
+ voucher_type: str | None = None,
207
+ from_date: str | None = None,
208
+ to_date: str | None = None,
209
+ company: str | None = None,
210
+ limit: int = Query(default=50, le=500),
211
+ offset: int = Query(default=0, ge=0),
212
+ ):
213
+ filters = {}
214
+ if account: filters["account"] = account
215
+ if party: filters["party"] = party
216
+ if voucher_type: filters["voucher_type"] = voucher_type
217
+ if from_date: filters["from_date"] = from_date
218
+ if to_date: filters["to_date"] = to_date
219
+ if company: filters["company"] = company
220
+ filters["limit"] = limit
221
+ filters["offset"] = offset
222
+ return _general_ledger(get_db(), filters)
223
+
224
+
225
+ def _stock_balance(db, item_code=None, warehouse=None):
226
+ filters = {}
227
+ if item_code:
228
+ filters["item_code"] = item_code
229
+ if warehouse:
230
+ filters["warehouse"] = warehouse
231
+
232
+ bins = db.get_all(
233
+ "Bin",
234
+ filters=filters if filters else None,
235
+ fields=["item_code", "warehouse", "actual_qty", "ordered_qty",
236
+ "reserved_qty", "valuation_rate", "stock_value"],
237
+ )
238
+
239
+ result = []
240
+ for b in bins:
241
+ item_name = db.get_value("Item", b["item_code"], "item_name") or b["item_code"]
242
+ entry = dict(b)
243
+ entry["item_name"] = item_name
244
+ result.append(entry)
245
+
246
+ return {"rows": result}
247
+
248
+
249
+ @router.get("/stock-balance")
250
+ def stock_balance(
251
+ item_code: str | None = None,
252
+ warehouse: str | None = None,
253
+ ):
254
+ return _stock_balance(get_db(), item_code, warehouse)
255
+
256
+
257
+ def _dashboard_summary(db, company=None):
258
+ """Compute dashboard summary metrics."""
259
+
260
+ company_filter = ""
261
+ params = []
262
+ if company:
263
+ company_filter = " AND company = ?"
264
+ params.append(company)
265
+
266
+ # Amounts are summed in base currency: each invoice's document-currency
267
+ # figure is scaled by its own (historical) conversion_rate, so foreign
268
+ # invoices aren't added in as if they were base-currency amounts.
269
+
270
+ # Total revenue (submitted sales invoices)
271
+ revenue = db.sql(
272
+ f'SELECT COALESCE(SUM(grand_total * COALESCE(conversion_rate, 1)), 0) as total '
273
+ f'FROM "Sales Invoice" WHERE docstatus = 1{company_filter}',
274
+ params,
275
+ )
276
+
277
+ # Outstanding receivable
278
+ receivable = db.sql(
279
+ f'SELECT COALESCE(SUM(outstanding_amount * COALESCE(conversion_rate, 1)), 0) as total '
280
+ f'FROM "Sales Invoice" WHERE docstatus = 1 AND outstanding_amount > 0{company_filter}',
281
+ params,
282
+ )
283
+
284
+ # Outstanding payable
285
+ payable = db.sql(
286
+ f'SELECT COALESCE(SUM(outstanding_amount * COALESCE(conversion_rate, 1)), 0) as total '
287
+ f'FROM "Purchase Invoice" WHERE docstatus = 1 AND outstanding_amount > 0{company_filter}',
288
+ params,
289
+ )
290
+
291
+ # Total stock value
292
+ stock_value = db.sql(
293
+ 'SELECT COALESCE(SUM(stock_value), 0) as total FROM "Bin"', [],
294
+ )
295
+
296
+ # Recent documents (last 10 across key types)
297
+ recent = []
298
+ for doctype in ["Sales Invoice", "Purchase Invoice", "Payment Entry", "Sales Order", "Quotation"]:
299
+ docs = db.get_all(
300
+ doctype,
301
+ fields=["name", "status", "docstatus", "creation"],
302
+ order_by="creation DESC",
303
+ limit=3,
304
+ )
305
+ for d in docs:
306
+ entry = dict(d)
307
+ entry["doctype"] = doctype
308
+ recent.append(entry)
309
+
310
+ recent.sort(key=lambda x: x.get("creation", ""), reverse=True)
311
+
312
+ return {
313
+ "total_revenue": flt(revenue[0]["total"], 2) if revenue else 0,
314
+ "outstanding_receivable": flt(receivable[0]["total"], 2) if receivable else 0,
315
+ "outstanding_payable": flt(payable[0]["total"], 2) if payable else 0,
316
+ "total_stock_value": flt(stock_value[0]["total"], 2) if stock_value else 0,
317
+ "recent_documents": recent[:10],
318
+ }
319
+
320
+
321
+ @router.get("/dashboard-summary")
322
+ def dashboard_summary(company: str | None = None):
323
+ return _dashboard_summary(get_db(), company)
324
+
325
+
326
+ # --- Profit & Loss ---
327
+
328
+ def _profit_and_loss(db, company=None, from_date=None, to_date=None):
329
+ """Income and Expense accounts grouped by root_type."""
330
+ filters = {"is_group": 0}
331
+ if company:
332
+ filters["company"] = company
333
+
334
+ accounts = db.get_all(
335
+ "Account", filters=filters,
336
+ fields=["name", "account_name", "root_type", "report_type"],
337
+ order_by="root_type, name",
338
+ )
339
+
340
+ date_clause = ""
341
+ params = []
342
+ if from_date:
343
+ date_clause += " AND posting_date >= ?"
344
+ params.append(from_date)
345
+ if to_date:
346
+ date_clause += " AND posting_date <= ?"
347
+ params.append(to_date)
348
+ if company:
349
+ date_clause += " AND company = ?"
350
+ params.append(company)
351
+
352
+ gl_data = db.sql(
353
+ f"""SELECT account,
354
+ COALESCE(SUM(debit), 0) as total_debit,
355
+ COALESCE(SUM(credit), 0) as total_credit
356
+ FROM "GL Entry"
357
+ WHERE is_cancelled = 0 {date_clause}
358
+ GROUP BY account""",
359
+ params,
360
+ )
361
+ gl_map = {row["account"]: row for row in gl_data}
362
+
363
+ income_rows = []
364
+ expense_rows = []
365
+ total_income = 0
366
+ total_expense = 0
367
+
368
+ for acc in accounts:
369
+ if acc["root_type"] not in ("Income", "Expense"):
370
+ continue
371
+ gl = gl_map.get(acc["name"])
372
+ if not gl:
373
+ continue
374
+ debit = flt(gl["total_debit"], 2)
375
+ credit = flt(gl["total_credit"], 2)
376
+ if not debit and not credit:
377
+ continue
378
+ # Income: credit balance (positive = earned). Expense: debit balance (positive = spent).
379
+ balance = flt(credit - debit, 2) if acc["root_type"] == "Income" else flt(debit - credit, 2)
380
+ row = {
381
+ "account": acc["name"],
382
+ "account_name": acc["account_name"],
383
+ "root_type": acc["root_type"],
384
+ "amount": abs(balance),
385
+ }
386
+ if acc["root_type"] == "Income":
387
+ income_rows.append(row)
388
+ total_income += abs(balance)
389
+ else:
390
+ expense_rows.append(row)
391
+ total_expense += abs(balance)
392
+
393
+ return {
394
+ "income": income_rows,
395
+ "expense": expense_rows,
396
+ "total_income": flt(total_income, 2),
397
+ "total_expense": flt(total_expense, 2),
398
+ "net_profit": flt(total_income - total_expense, 2),
399
+ }
400
+
401
+
402
+ @router.get("/profit-and-loss")
403
+ def profit_and_loss(
404
+ company: str | None = None,
405
+ from_date: str | None = None,
406
+ to_date: str | None = None,
407
+ presentation_currency: str | None = None,
408
+ ):
409
+ db = get_db()
410
+ return _present(db, _profit_and_loss(db, company, from_date, to_date),
411
+ company, presentation_currency, to_date)
412
+
413
+
414
+ # --- Balance Sheet ---
415
+
416
+ def _balance_sheet(db, company=None, as_of_date=None):
417
+ """Asset, Liability, and Equity accounts as of a date."""
418
+ filters = {"is_group": 0}
419
+ if company:
420
+ filters["company"] = company
421
+
422
+ accounts = db.get_all(
423
+ "Account", filters=filters,
424
+ fields=["name", "account_name", "root_type", "report_type"],
425
+ order_by="root_type, name",
426
+ )
427
+
428
+ date_clause = ""
429
+ params = []
430
+ if as_of_date:
431
+ date_clause += " AND posting_date <= ?"
432
+ params.append(as_of_date)
433
+ if company:
434
+ date_clause += " AND company = ?"
435
+ params.append(company)
436
+
437
+ gl_data = db.sql(
438
+ f"""SELECT account,
439
+ COALESCE(SUM(debit), 0) as total_debit,
440
+ COALESCE(SUM(credit), 0) as total_credit
441
+ FROM "GL Entry"
442
+ WHERE is_cancelled = 0 {date_clause}
443
+ GROUP BY account""",
444
+ params,
445
+ )
446
+ gl_map = {row["account"]: row for row in gl_data}
447
+
448
+ asset_rows = []
449
+ liability_rows = []
450
+ equity_rows = []
451
+ total_asset = 0
452
+ total_liability = 0
453
+ total_equity = 0
454
+
455
+ for acc in accounts:
456
+ if acc["root_type"] not in ("Asset", "Liability", "Equity"):
457
+ continue
458
+ gl = gl_map.get(acc["name"])
459
+ if not gl:
460
+ continue
461
+ debit = flt(gl["total_debit"], 2)
462
+ credit = flt(gl["total_credit"], 2)
463
+ if not debit and not credit:
464
+ continue
465
+ # Asset: debit balance. Liability/Equity: credit balance.
466
+ if acc["root_type"] == "Asset":
467
+ balance = flt(debit - credit, 2)
468
+ else:
469
+ balance = flt(credit - debit, 2)
470
+ row = {
471
+ "account": acc["name"],
472
+ "account_name": acc["account_name"],
473
+ "root_type": acc["root_type"],
474
+ "balance": balance,
475
+ }
476
+ if acc["root_type"] == "Asset":
477
+ asset_rows.append(row)
478
+ total_asset += balance
479
+ elif acc["root_type"] == "Liability":
480
+ liability_rows.append(row)
481
+ total_liability += balance
482
+ else:
483
+ equity_rows.append(row)
484
+ total_equity += balance
485
+
486
+ # Add retained earnings (net P&L) to equity
487
+ pl = _profit_and_loss(db, company, to_date=as_of_date)
488
+ net_profit = flt(pl["net_profit"], 2)
489
+ if net_profit:
490
+ equity_rows.append({
491
+ "account": "Retained Earnings (Current Period)",
492
+ "account_name": "Retained Earnings (Current Period)",
493
+ "root_type": "Equity",
494
+ "balance": net_profit,
495
+ })
496
+ total_equity += net_profit
497
+
498
+ return {
499
+ "assets": asset_rows,
500
+ "liabilities": liability_rows,
501
+ "equity": equity_rows,
502
+ "total_assets": flt(total_asset, 2),
503
+ "total_liabilities": flt(total_liability, 2),
504
+ "total_equity": flt(total_equity, 2),
505
+ "total_liabilities_and_equity": flt(total_liability + total_equity, 2),
506
+ }
507
+
508
+
509
+ @router.get("/balance-sheet")
510
+ def balance_sheet(
511
+ company: str | None = None,
512
+ as_of_date: str | None = None,
513
+ presentation_currency: str | None = None,
514
+ ):
515
+ db = get_db()
516
+ return _present(db, _balance_sheet(db, company, as_of_date),
517
+ company, presentation_currency, as_of_date)
518
+
519
+
520
+ # --- Accounts Receivable Aging ---
521
+
522
+ def _ar_aging(db, company=None, as_of_date=None):
523
+ """Outstanding Sales Invoices bucketed by age, as of a given date.
524
+
525
+ Rebuilds the outstanding balance historically rather than using the
526
+ current `outstanding_amount` column, so setting as_of_date to a past
527
+ date actually "rewinds" the ledger:
528
+
529
+ outstanding_at_date = grand_total
530
+ - Payment Entries allocated ≤ as_of_date
531
+ - Credit Notes (return SIs) posted ≤ as_of_date
532
+ """
533
+ if not as_of_date:
534
+ as_of_date = date.today().isoformat()
535
+
536
+ where = [
537
+ "si.docstatus = 1",
538
+ "COALESCE(si.is_return, 0) = 0",
539
+ "si.posting_date <= ?",
540
+ ]
541
+ params: list = [as_of_date]
542
+ if company:
543
+ where.append("si.company = ?")
544
+ params.append(company)
545
+
546
+ # Per-invoice outstanding_at_date is computed with two correlated
547
+ # sub-queries: sum allocations and sum credit-note reductions, both
548
+ # filtered to documents that existed on as_of_date.
549
+ invoices = db.sql(
550
+ f"""SELECT si.name, si.customer, si.posting_date, si.due_date,
551
+ si.grand_total, COALESCE(si.conversion_rate, 1) AS conversion_rate,
552
+ si.grand_total
553
+ - COALESCE((
554
+ SELECT SUM(per.allocated_amount)
555
+ FROM "Payment Entry Reference" per
556
+ JOIN "Payment Entry" pe ON pe.name = per.parent
557
+ WHERE pe.docstatus = 1
558
+ AND pe.posting_date <= ?
559
+ AND per.reference_doctype = 'Sales Invoice'
560
+ AND per.reference_name = si.name
561
+ ), 0)
562
+ - COALESCE((
563
+ SELECT SUM(ABS(sir.grand_total))
564
+ FROM "Sales Invoice" sir
565
+ WHERE sir.docstatus = 1
566
+ AND sir.is_return = 1
567
+ AND sir.return_against = si.name
568
+ AND sir.posting_date <= ?
569
+ ), 0) AS outstanding_at_date
570
+ FROM "Sales Invoice" si
571
+ WHERE {' AND '.join(where)}
572
+ ORDER BY si.due_date""",
573
+ [as_of_date, as_of_date] + params,
574
+ )
575
+
576
+ rows = []
577
+ totals = {"outstanding": 0, "current": 0, "b1_30": 0, "b31_60": 0, "b61_90": 0, "b90_plus": 0}
578
+
579
+ for inv in invoices:
580
+ # outstanding_at_date is in the invoice's document currency; scale by its
581
+ # own historical rate so the report aggregates in base currency.
582
+ outstanding = flt(flt(inv["outstanding_at_date"], 2) * flt(inv["conversion_rate"] or 1), 2)
583
+ if outstanding <= 0:
584
+ continue
585
+
586
+ due = inv["due_date"] or inv["posting_date"]
587
+ days_overdue = (date.fromisoformat(as_of_date) - date.fromisoformat(due)).days
588
+ if days_overdue < 0:
589
+ days_overdue = 0
590
+
591
+ bucket = {"current": 0, "b1_30": 0, "b31_60": 0, "b61_90": 0, "b90_plus": 0}
592
+ if days_overdue == 0:
593
+ bucket["current"] = outstanding
594
+ elif days_overdue <= 30:
595
+ bucket["b1_30"] = outstanding
596
+ elif days_overdue <= 60:
597
+ bucket["b31_60"] = outstanding
598
+ elif days_overdue <= 90:
599
+ bucket["b61_90"] = outstanding
600
+ else:
601
+ bucket["b90_plus"] = outstanding
602
+
603
+ customer_name = db.get_value("Customer", inv["customer"], "customer_name") or inv["customer"]
604
+ rows.append({
605
+ "invoice": inv["name"],
606
+ "customer": inv["customer"],
607
+ "customer_name": customer_name,
608
+ "posting_date": inv["posting_date"],
609
+ "due_date": due,
610
+ "days_overdue": max(days_overdue, 0),
611
+ "outstanding": outstanding,
612
+ **bucket,
613
+ })
614
+
615
+ totals["outstanding"] += outstanding
616
+ for k in bucket:
617
+ totals[k] += bucket[k]
618
+
619
+ for k in totals:
620
+ totals[k] = flt(totals[k], 2)
621
+
622
+ return {"rows": rows, "totals": totals, "as_of_date": as_of_date}
623
+
624
+
625
+ @router.get("/ar-aging")
626
+ def ar_aging(
627
+ company: str | None = None,
628
+ as_of_date: str | None = None,
629
+ ):
630
+ return _ar_aging(get_db(), company, as_of_date)
631
+
632
+
633
+ # --- Accounts Payable Aging ---
634
+
635
+ def _ap_aging(db, company=None, as_of_date=None):
636
+ """Outstanding Purchase Invoices bucketed by age, as of a given date.
637
+
638
+ Mirror image of _ar_aging — rebuilds outstanding historically so
639
+ past-date queries reflect what was open on that date.
640
+ """
641
+ if not as_of_date:
642
+ as_of_date = date.today().isoformat()
643
+
644
+ where = [
645
+ "pi.docstatus = 1",
646
+ "COALESCE(pi.is_return, 0) = 0",
647
+ "pi.posting_date <= ?",
648
+ ]
649
+ params: list = [as_of_date]
650
+ if company:
651
+ where.append("pi.company = ?")
652
+ params.append(company)
653
+
654
+ invoices = db.sql(
655
+ f"""SELECT pi.name, pi.supplier, pi.posting_date, pi.due_date,
656
+ pi.grand_total, COALESCE(pi.conversion_rate, 1) AS conversion_rate,
657
+ pi.grand_total
658
+ - COALESCE((
659
+ SELECT SUM(per.allocated_amount)
660
+ FROM "Payment Entry Reference" per
661
+ JOIN "Payment Entry" pe ON pe.name = per.parent
662
+ WHERE pe.docstatus = 1
663
+ AND pe.posting_date <= ?
664
+ AND per.reference_doctype = 'Purchase Invoice'
665
+ AND per.reference_name = pi.name
666
+ ), 0)
667
+ - COALESCE((
668
+ SELECT SUM(ABS(pir.grand_total))
669
+ FROM "Purchase Invoice" pir
670
+ WHERE pir.docstatus = 1
671
+ AND pir.is_return = 1
672
+ AND pir.return_against = pi.name
673
+ AND pir.posting_date <= ?
674
+ ), 0) AS outstanding_at_date
675
+ FROM "Purchase Invoice" pi
676
+ WHERE {' AND '.join(where)}
677
+ ORDER BY pi.due_date""",
678
+ [as_of_date, as_of_date] + params,
679
+ )
680
+
681
+ rows = []
682
+ totals = {"outstanding": 0, "current": 0, "b1_30": 0, "b31_60": 0, "b61_90": 0, "b90_plus": 0}
683
+
684
+ for inv in invoices:
685
+ # outstanding_at_date is in the invoice's document currency; scale by its
686
+ # own historical rate so the report aggregates in base currency.
687
+ outstanding = flt(flt(inv["outstanding_at_date"], 2) * flt(inv["conversion_rate"] or 1), 2)
688
+ if outstanding <= 0:
689
+ continue
690
+
691
+ due = inv["due_date"] or inv["posting_date"]
692
+ days_overdue = (date.fromisoformat(as_of_date) - date.fromisoformat(due)).days
693
+ if days_overdue < 0:
694
+ days_overdue = 0
695
+
696
+ bucket = {"current": 0, "b1_30": 0, "b31_60": 0, "b61_90": 0, "b90_plus": 0}
697
+ if days_overdue == 0:
698
+ bucket["current"] = outstanding
699
+ elif days_overdue <= 30:
700
+ bucket["b1_30"] = outstanding
701
+ elif days_overdue <= 60:
702
+ bucket["b31_60"] = outstanding
703
+ elif days_overdue <= 90:
704
+ bucket["b61_90"] = outstanding
705
+ else:
706
+ bucket["b90_plus"] = outstanding
707
+
708
+ supplier_name = db.get_value("Supplier", inv["supplier"], "supplier_name") or inv["supplier"]
709
+ rows.append({
710
+ "invoice": inv["name"],
711
+ "supplier": inv["supplier"],
712
+ "supplier_name": supplier_name,
713
+ "posting_date": inv["posting_date"],
714
+ "due_date": due,
715
+ "days_overdue": max(days_overdue, 0),
716
+ "outstanding": outstanding,
717
+ **bucket,
718
+ })
719
+
720
+ totals["outstanding"] += outstanding
721
+ for k in bucket:
722
+ totals[k] += bucket[k]
723
+
724
+ for k in totals:
725
+ totals[k] = flt(totals[k], 2)
726
+
727
+ return {"rows": rows, "totals": totals, "as_of_date": as_of_date}
728
+
729
+
730
+ @router.get("/ap-aging")
731
+ def ap_aging(
732
+ company: str | None = None,
733
+ as_of_date: str | None = None,
734
+ ):
735
+ return _ap_aging(get_db(), company, as_of_date)