rairo Claude Opus 4.8 commited on
Commit
d2d004c
·
1 Parent(s): 1f5b5a3

Realign dashboard API to the v2 bot schema (transactions)

Browse files

The endpoints read the legacy sales/expenses subcollections that the upgraded
bot no longer writes, so dashboards returned zeros. Read the v2 `transactions`
schema instead and mirror the bot's accounting.

- analytics.py: pure aggregation (per-currency sales/COGS/gross/expenses/net,
cash-on-hand, receivables/payables, stock value, top items/customers/expenses,
daily series) + model_health over distillation_examples.
- /api/user/dashboard rewritten (v2 payload + legacy aliases); add
/api/user/transactions.
- /api/admin/dashboard/stats rewritten (per-user v2 aggregation, per-currency
leaderboards, global standing); add /api/admin/model-health.
- Auth, user CRUD, org and phone-migration endpoints unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Files changed (2) hide show
  1. analytics.py +306 -0
  2. main.py +190 -204
analytics.py ADDED
@@ -0,0 +1,306 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ analytics.py — v2 ledger aggregation for the Smart Qx dashboards.
3
+
4
+ The WhatsApp bot (smart-w) records ONE `transactions` collection per user
5
+ (`users/{mobile}/transactions`), each doc carrying a `transaction_type`
6
+ (sale | stock_in | expense | asset | loan | repayment | other) and a `details`
7
+ map with an `items[]` array. These helpers reproduce the bot's own accounting
8
+ (see smart-w/utility.py: _compute_cogs, _compute_cash_position, build_period_report)
9
+ so the dashboards and the WhatsApp reports agree to the cent.
10
+
11
+ Pure functions — they take plain dicts (Firestore `doc.to_dict()`), no Firebase here.
12
+ """
13
+
14
+ from collections import defaultdict
15
+ from datetime import datetime, timezone
16
+ from typing import Any, Dict, List, Optional
17
+
18
+
19
+ # ── Parsing helpers ──────────────────────────────────────────────────────────
20
+ _CURRENCY_MAP = {
21
+ "$": "USD", "dollar": "USD", "dollars": "USD", "usd": "USD",
22
+ "r": "ZAR", "rand": "ZAR", "rands": "ZAR", "zar": "ZAR",
23
+ "zwg": "ZWG", "zwl": "ZWG", "kes": "KES", "ngn": "NGN", "ghs": "GHS",
24
+ "eur": "EUR", "€": "EUR", "gbp": "GBP", "£": "GBP",
25
+ }
26
+
27
+
28
+ def normalize_currency_code(raw_code: Any, default_code: str = "USD") -> str:
29
+ """Messy currency string ('$', 'rand', 'R') → ISO code ('USD', 'ZAR')."""
30
+ if not raw_code or not isinstance(raw_code, str):
31
+ return default_code
32
+ return _CURRENCY_MAP.get(raw_code.lower().strip(), (raw_code.upper().strip()[:3] or default_code))
33
+
34
+
35
+ def money(value: Any) -> float:
36
+ """Best-effort money → float. Accepts numbers and strings like 'USD4', '$3.50', '50c'."""
37
+ if value is None:
38
+ return 0.0
39
+ if isinstance(value, bool):
40
+ return 0.0
41
+ if isinstance(value, (int, float)):
42
+ return float(value)
43
+ s = str(value).strip().replace(",", "")
44
+ if not s or s.lower() in ("none", "null", "nan", "?"):
45
+ return 0.0
46
+ import re
47
+ m = re.search(r"-?\d+(?:\.\d+)?", s)
48
+ if not m:
49
+ return 0.0
50
+ try:
51
+ return float(m.group(0))
52
+ except ValueError:
53
+ return 0.0
54
+
55
+
56
+ def money_opt(value: Any) -> Optional[float]:
57
+ """Like money() but returns None when there is no numeric value (distinguishes 0 from absent)."""
58
+ if value is None:
59
+ return None
60
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
61
+ return float(value)
62
+ import re
63
+ m = re.search(r"-?\d+(?:\.\d+)?", str(value))
64
+ return float(m.group(0)) if m else None
65
+
66
+
67
+ def _to_dt(ts: Any) -> Optional[datetime]:
68
+ """Firestore Timestamp / datetime / ISO string → tz-aware datetime (UTC)."""
69
+ if ts is None:
70
+ return None
71
+ if isinstance(ts, datetime):
72
+ return ts if ts.tzinfo else ts.replace(tzinfo=timezone.utc)
73
+ if hasattr(ts, "timestamp"): # Firestore Timestamp / DatetimeWithNanoseconds
74
+ try:
75
+ return datetime.fromtimestamp(ts.timestamp(), tz=timezone.utc)
76
+ except Exception:
77
+ return None
78
+ if isinstance(ts, str):
79
+ try:
80
+ return datetime.fromisoformat(ts.replace("Z", "+00:00"))
81
+ except Exception:
82
+ return None
83
+ return None
84
+
85
+
86
+ def _in_range(ts: Any, start: Optional[datetime], end: Optional[datetime]) -> bool:
87
+ if start is None and end is None:
88
+ return True
89
+ dt = _to_dt(ts)
90
+ if dt is None:
91
+ return False
92
+ if start and dt < start:
93
+ return False
94
+ if end and dt > end:
95
+ return False
96
+ return True
97
+
98
+
99
+ def _txn_type(d: Dict) -> str:
100
+ return (d.get("transaction_type") or d.get("type") or "other").lower()
101
+
102
+
103
+ def _items(details: Dict) -> List[Dict]:
104
+ it = details.get("items")
105
+ return it if isinstance(it, list) else []
106
+
107
+
108
+ def _truthy(v: Any) -> bool:
109
+ return str(v).strip().lower() in ("true", "1", "yes", "y", "service")
110
+
111
+
112
+ # ── Cost basis (COGS) ────────────────────────────────────────────────────────
113
+ def build_cost_map(stock_batches: List[Dict]) -> Dict[str, float]:
114
+ """item name → mean known unit cost (cost_each) from stock batches."""
115
+ sums: Dict[str, float] = defaultdict(float)
116
+ counts: Dict[str, int] = defaultdict(int)
117
+ for b in stock_batches or []:
118
+ name = str(b.get("name") or "").strip().lower()
119
+ c = money_opt(b.get("cost_each"))
120
+ if name and c is not None and c > 0:
121
+ sums[name] += c
122
+ counts[name] += 1
123
+ return {k: sums[k] / counts[k] for k in sums if counts[k]}
124
+
125
+
126
+ # ── Main ledger aggregation ──────────────────────────────────────────────────
127
+ def aggregate_ledger(txns: List[Dict], stock_batches: List[Dict], customers: List[Dict],
128
+ start: Optional[datetime] = None, end: Optional[datetime] = None,
129
+ default_currency: str = "USD") -> Dict[str, Any]:
130
+ """Return per-currency financials + insight lists. Mirrors the bot's report math.
131
+
132
+ Standing figures (cash on hand, receivables, payables, stock value) are CURRENT — the
133
+ date range only filters period flows (sales/expenses/cogs/net and the series/lists).
134
+ """
135
+ cur_metrics: Dict[str, Dict[str, float]] = defaultdict(
136
+ lambda: {"sales": 0.0, "cogs": 0.0, "expenses": 0.0, "stock_purchases": 0.0, "sales_count": 0})
137
+ item_rev: Dict[str, Dict[str, float]] = defaultdict(lambda: defaultdict(float)) # item → cur → revenue
138
+ item_qty: Dict[str, float] = defaultdict(float)
139
+ expense_cat: Dict[str, Dict[str, float]] = defaultdict(lambda: defaultdict(float)) # cat → cur → amount
140
+ daily: Dict[str, Dict[str, float]] = defaultdict(lambda: {"revenue": 0.0, "cogs": 0.0, "expenses": 0.0})
141
+
142
+ cost_map = build_cost_map(stock_batches)
143
+ last_cur = normalize_currency_code(default_currency, "USD")
144
+ cash = 0.0 # cash on hand is CURRENT (whole trail), not date-filtered
145
+
146
+ for d in txns or []:
147
+ ttype = _txn_type(d)
148
+ det = d.get("details", {}) or {}
149
+ cur = normalize_currency_code(det.get("currency"), last_cur)
150
+ last_cur = cur
151
+ amount = money(det.get("amount") or det.get("total"))
152
+ paid = money_opt(det.get("amount_paid"))
153
+ in_period = _in_range(d.get("created_at"), start, end)
154
+ day_key = (_to_dt(d.get("created_at")) or datetime.now(timezone.utc)).strftime("%Y-%m-%d")
155
+
156
+ # --- cash on hand (current, all-time) ---
157
+ if ttype == "sale":
158
+ credit = money(det.get("customer_credit"))
159
+ cash += max(amount - credit, 0.0)
160
+ elif ttype in ("stock_in", "expense", "asset"):
161
+ cash -= (paid if paid is not None else amount)
162
+ elif ttype == "loan":
163
+ direction = (det.get("loan_direction") or "").lower()
164
+ if direction == "lent":
165
+ cash -= amount
166
+ elif direction == "borrowed":
167
+ cash += amount
168
+ elif ttype == "repayment":
169
+ cash += money(det.get("cash_in"))
170
+ cash -= money(det.get("cash_out"))
171
+
172
+ if not in_period:
173
+ continue
174
+
175
+ # --- period flows ---
176
+ if ttype == "sale":
177
+ cur_metrics[cur]["sales"] += amount
178
+ cur_metrics[cur]["sales_count"] += 1
179
+ daily[day_key]["revenue"] += amount
180
+ line_cogs = 0.0
181
+ for it in _items(det):
182
+ if _truthy(it.get("is_service")):
183
+ continue
184
+ nm = str(it.get("item") or it.get("name") or "").strip().lower()
185
+ qty = money(it.get("quantity"))
186
+ if nm and qty > 0:
187
+ unit_cost = cost_map.get(nm, 0.0)
188
+ line_cogs += qty * unit_cost
189
+ item_qty[nm] += qty
190
+ # per-item revenue share for leaderboards
191
+ ippu = money_opt(it.get("price_per_unit") or it.get("price_each"))
192
+ iamt = money_opt(it.get("amount"))
193
+ rev = iamt if iamt is not None else (ippu * qty if (ippu is not None and qty) else None)
194
+ if nm and rev:
195
+ item_rev[nm][cur] += rev
196
+ cur_metrics[cur]["cogs"] += line_cogs
197
+ daily[day_key]["cogs"] += line_cogs
198
+ elif ttype == "expense":
199
+ cur_metrics[cur]["expenses"] += amount
200
+ cat = (det.get("category") or det.get("description") or "other") or "other"
201
+ expense_cat[str(cat)][cur] += amount
202
+ daily[day_key]["expenses"] += amount
203
+ elif ttype == "stock_in":
204
+ cur_metrics[cur]["stock_purchases"] += amount
205
+
206
+ # finalize per-currency
207
+ by_cur: Dict[str, Dict[str, float]] = {}
208
+ for cur, m in cur_metrics.items():
209
+ gross = round(m["sales"] - m["cogs"], 2)
210
+ net = round(gross - m["expenses"], 2)
211
+ by_cur[cur] = {
212
+ "sales": round(m["sales"], 2),
213
+ "cogs": round(m["cogs"], 2),
214
+ "grossProfit": gross,
215
+ "grossMarginPct": round((gross / m["sales"] * 100.0), 1) if m["sales"] else 0.0,
216
+ "expenses": round(m["expenses"], 2),
217
+ "netProfit": net,
218
+ "stockPurchases": round(m["stock_purchases"], 2),
219
+ "salesCount": int(m["sales_count"]),
220
+ }
221
+
222
+ # standing (current)
223
+ receivables = round(sum(money(c.get("receivable", c.get("outstanding_credit"))) for c in (customers or [])), 2)
224
+ payables = round(sum(money(c.get("payable", c.get("outstanding_change"))) for c in (customers or [])), 2)
225
+ stock_value = round(sum(money(b.get("quantity_remaining")) * money(b.get("cost_each")) for b in (stock_batches or [])), 2)
226
+
227
+ # leaderboards / lists
228
+ top_items = sorted(
229
+ ({"item": nm, "quantity": round(item_qty.get(nm, 0.0), 2),
230
+ "revenue": round(sum(cm.values()), 2), "byCurrency": {k: round(v, 2) for k, v in cm.items()}}
231
+ for nm, cm in item_rev.items()),
232
+ key=lambda x: -x["revenue"])[:10]
233
+ top_customers = sorted(
234
+ ({"name": c.get("name", "Customer"),
235
+ "receivable": round(money(c.get("receivable", c.get("outstanding_credit"))), 2),
236
+ "totalPurchases": round(money(c.get("total_purchases")), 2),
237
+ "visits": int(money(c.get("visit_count")))}
238
+ for c in (customers or [])),
239
+ key=lambda x: -x["totalPurchases"])[:10]
240
+ top_expenses = sorted(
241
+ ({"category": cat, "amount": round(sum(cm.values()), 2),
242
+ "byCurrency": {k: round(v, 2) for k, v in cm.items()}}
243
+ for cat, cm in expense_cat.items()),
244
+ key=lambda x: -x["amount"])[:10]
245
+ daily_series = [
246
+ {"date": day, "revenue": round(v["revenue"], 2), "cogs": round(v["cogs"], 2),
247
+ "expenses": round(v["expenses"], 2),
248
+ "profit": round(v["revenue"] - v["cogs"] - v["expenses"], 2)}
249
+ for day, v in sorted(daily.items())]
250
+
251
+ return {
252
+ "byCurrency": by_cur,
253
+ "cashOnHand": round(cash, 2),
254
+ "receivables": receivables,
255
+ "payables": payables,
256
+ "stockValue": stock_value,
257
+ "topItems": top_items,
258
+ "topCustomers": top_customers,
259
+ "topExpenses": top_expenses,
260
+ "dailySeries": daily_series,
261
+ }
262
+
263
+
264
+ # ── Model-Health (distillation session capture) ──────────────────────────────
265
+ def model_health(examples: List[Dict], start: Optional[datetime] = None,
266
+ end: Optional[datetime] = None, opt_out_count: int = 0) -> Dict[str, Any]:
267
+ """Aggregate distillation_examples: how we record & close sessions.
268
+
269
+ Confirm/Cancel verdicts are a live extraction-accuracy proxy. `archived` are the
270
+ anonymised reset snapshots (reason == account_reset)."""
271
+ by_task: Dict[str, int] = defaultdict(int)
272
+ by_modality: Dict[str, int] = defaultdict(int)
273
+ by_verdict: Dict[str, int] = defaultdict(int)
274
+ growth: Dict[str, int] = defaultdict(int)
275
+ reset_snapshots = 0
276
+ total = 0
277
+
278
+ for e in examples or []:
279
+ if not _in_range(e.get("created_at"), start, end):
280
+ continue
281
+ total += 1
282
+ by_task[str(e.get("task") or "unknown")] += 1
283
+ by_modality[str(e.get("modality") or "unknown")] += 1
284
+ verdict = str(e.get("verdict") or "pending")
285
+ by_verdict[verdict] += 1
286
+ if str(e.get("reason") or "") == "account_reset" or e.get("task") == "ledger_snapshot":
287
+ reset_snapshots += 1
288
+ day = (_to_dt(e.get("created_at")) or datetime.now(timezone.utc)).strftime("%Y-%m-%d")
289
+ growth[day] += 1
290
+
291
+ confirmed = by_verdict.get("confirmed", 0)
292
+ rejected = by_verdict.get("rejected", 0)
293
+ labelled = confirmed + rejected
294
+ confirm_rate = round(confirmed / labelled * 100.0, 1) if labelled else None
295
+
296
+ return {
297
+ "totalCaptured": total,
298
+ "byTask": dict(by_task),
299
+ "byModality": dict(by_modality),
300
+ "byVerdict": dict(by_verdict),
301
+ "confirmRate": confirm_rate, # % of labelled sessions the user confirmed
302
+ "labelledCount": labelled,
303
+ "growthSeries": [{"date": d, "count": growth[d]} for d in sorted(growth)],
304
+ "optOutCount": int(opt_out_count),
305
+ "resetSnapshots": reset_snapshots,
306
+ }
main.py CHANGED
@@ -7,6 +7,8 @@ from flask_cors import CORS
7
  import firebase_admin
8
  from firebase_admin import credentials, auth, firestore
9
 
 
 
10
  # --- Basic Configuration ---
11
  logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
12
 
@@ -92,6 +94,28 @@ def normalize_currency_code(raw_code, default_code='USD'):
92
  clean_code = raw_code.lower().strip()
93
  return currency_map.get(clean_code, default_code)
94
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95
  # -----------------------------------------------------------------------------
96
  # 3. AUTHENTICATION & USER MANAGEMENT ENDPOINTS
97
  # -----------------------------------------------------------------------------
@@ -244,82 +268,80 @@ def get_user_dashboard():
244
  if not phone_number: return jsonify({'error': 'No phone number is associated with your account.'}), 404
245
 
246
  try:
247
- start_date_str = request.args.get('start_date')
248
- end_date_str = request.args.get('end_date')
249
-
250
- start_date, end_date = None, None
251
- if start_date_str:
252
- start_date = datetime.fromisoformat(start_date_str.replace('Z', '+00:00'))
253
- if end_date_str:
254
- end_date = datetime.fromisoformat(end_date_str.replace('Z', '+00:00'))
255
-
256
  bot_data_id = phone_number.lstrip('+')
257
- bot_user_ref = db.collection('users').document(bot_data_id)
258
-
259
- sales_revenue_by_currency, cogs_by_currency, expenses_by_currency = {}, {}, {}
260
- sales_count = 0
261
-
262
- default_currency_code = normalize_currency_code(user_data.get('defaultCurrency'), 'USD')
263
- last_seen_currency_code = default_currency_code
264
-
265
- # Process Sales
266
- sales_query = bot_user_ref.collection('sales').stream()
267
- for doc in sales_query:
268
- doc_data = doc.to_dict()
269
- created_at = doc_data.get('createdAt')
270
 
271
- if start_date and (not created_at or created_at < start_date):
272
- continue
273
- if end_date and (not created_at or created_at > end_date):
274
- continue
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
275
 
276
- details = doc_data.get('details', {})
277
- currency_code = normalize_currency_code(details.get('currency'), last_seen_currency_code)
278
- last_seen_currency_code = currency_code
279
- quantity, price, cost = int(details.get('quantity', 1)), float(details.get('price', 0)), float(details.get('cost', 0))
280
- sales_revenue_by_currency[currency_code] = sales_revenue_by_currency.get(currency_code, 0) + (price * quantity)
281
- cogs_by_currency[currency_code] = cogs_by_currency.get(currency_code, 0) + (cost * quantity)
282
- sales_count += 1
283
-
284
- # Process Expenses
285
- expenses_query = bot_user_ref.collection('expenses').stream()
286
- for doc in expenses_query:
287
- doc_data = doc.to_dict()
288
- created_at = doc_data.get('createdAt')
289
 
290
- if start_date and (not created_at or created_at < start_date):
291
- continue
292
- if end_date and (not created_at or created_at > end_date):
293
- continue
294
 
295
- details = doc.to_dict().get('details', {})
296
- currency_code = normalize_currency_code(details.get('currency'), last_seen_currency_code)
297
- last_seen_currency_code = currency_code
298
- amount = float(details.get('amount', 0))
299
- expenses_by_currency[currency_code] = expenses_by_currency.get(currency_code, 0) + amount
300
-
301
- # Calculate final totals using the clean, normalized currency codes
302
- all_currencies = set(sales_revenue_by_currency.keys()) | set(cogs_by_currency.keys()) | set(expenses_by_currency.keys())
303
- gross_profit_by_currency, net_profit_by_currency = {}, {}
304
-
305
- for curr in all_currencies:
306
- revenue, cogs, expenses = sales_revenue_by_currency.get(curr, 0), cogs_by_currency.get(curr, 0), expenses_by_currency.get(curr, 0)
307
- gross_profit_by_currency[curr] = round(revenue - cogs, 2)
308
- net_profit_by_currency[curr] = round(revenue - cogs - expenses, 2)
309
-
310
- dashboard_data = {
311
- 'totalSalesRevenueByCurrency': sales_revenue_by_currency,
312
- 'totalCostOfGoodsSoldByCurrency': cogs_by_currency,
313
- 'grossProfitByCurrency': gross_profit_by_currency,
314
- 'totalExpensesByCurrency': expenses_by_currency,
315
- 'netProfitByCurrency': net_profit_by_currency,
316
- 'salesCount': sales_count,
317
- }
318
- return jsonify(dashboard_data), 200
319
-
 
 
 
 
 
 
 
 
 
320
  except Exception as e:
321
- logging.error(f"Error fetching dashboard data for user {uid} (phone: {phone_number}): {e}")
322
- return jsonify({'error': 'An error occurred while fetching your dashboard data.'}), 500
323
 
324
  # Replace your existing PUT /api/user/profile with this one.
325
  @app.route('/api/user/profile', methods=['PUT'])
@@ -812,151 +834,96 @@ def get_admin_dashboard_stats():
812
  """
813
  try:
814
  verify_admin_and_get_uid(request.headers.get('Authorization'))
 
815
 
816
- start_date_str = request.args.get('start_date')
817
- end_date_str = request.args.get('end_date')
818
-
819
- start_date, end_date = None, None
820
- if start_date_str:
821
- start_date = datetime.fromisoformat(start_date_str.replace('Z', '+00:00'))
822
- if end_date_str:
823
- end_date = datetime.fromisoformat(end_date_str.replace('Z', '+00:00'))
824
-
825
- # --- Initialization ---
826
  all_users_docs = list(db.collection('users').stream())
827
  all_orgs_docs = list(db.collection('organizations').stream())
828
-
829
- user_sales_data, global_item_revenue, global_expense_totals, phone_to_user_map = {}, {}, {}, {}
830
- global_sales_rev_by_curr, global_cogs_by_curr, global_expenses_by_curr = {}, {}, {}
831
-
832
- # --- First Pass: Process all documents to gather stats and approved phones ---
833
- pending_approvals, approved_users, admin_count = 0, 0, 0
834
- approved_phone_numbers = []
835
 
 
 
 
836
  for doc in all_users_docs:
837
- user_data = doc.to_dict()
838
- # This check ensures we only process documents that are actual user profiles
839
- if user_data.get('email'):
840
- phone = user_data.get('phone')
841
- if user_data.get('phoneStatus') == 'pending':
842
- pending_approvals += 1
843
- elif user_data.get('phoneStatus') == 'approved' and phone:
844
- approved_users += 1
845
- approved_phone_numbers.append(phone)
846
- phone_to_user_map[phone] = {
847
- 'displayName': user_data.get('displayName', 'N/A'), 'uid': user_data.get('uid'),
848
- 'defaultCurrency': user_data.get('defaultCurrency', 'USD')
849
- }
850
- if user_data.get('isAdmin', False):
851
- admin_count += 1
852
-
853
- user_profile_docs = [doc for doc in all_users_docs if doc.to_dict().get('email')]
854
-
855
- user_stats = {
856
- 'total': len(user_profile_docs),
857
- 'admins': admin_count,
858
- 'approvedForBot': approved_users,
859
- 'pendingApproval': pending_approvals
860
- }
861
-
862
- org_stats = {'total': len(all_orgs_docs)}
863
-
864
- # --- Second Pass: Aggregate financial data from bot documents ---
865
- sales_count = 0
866
- for phone in approved_phone_numbers:
867
  try:
868
- bot_data_id = phone.lstrip('+')
869
- bot_user_ref = db.collection('users').document(bot_data_id)
870
-
871
- user_sales_data[phone] = {'total_revenue_by_currency': {}, 'item_sales': {}}
872
- last_seen_currency_code = normalize_currency_code(phone_to_user_map.get(phone, {}).get('defaultCurrency'), 'USD')
873
-
874
- sales_query = bot_user_ref.collection('sales').stream()
875
- for sale_doc in sales_query:
876
- sale_data = sale_doc.to_dict()
877
- created_at = sale_data.get('createdAt')
878
-
879
- if start_date and (not created_at or created_at < start_date):
880
- continue
881
- if end_date and (not created_at or created_at > end_date):
882
- continue
883
-
884
- details = sale_data.get('details', {})
885
- currency_code = normalize_currency_code(details.get('currency'), last_seen_currency_code)
886
- last_seen_currency_code = currency_code
887
- quantity, price, cost = int(details.get('quantity', 1)), float(details.get('price', 0)), float(details.get('cost', 0))
888
- sale_revenue = price * quantity
889
- item_name = details.get('item', 'Unknown Item')
890
-
891
- global_sales_rev_by_curr[currency_code] = global_sales_rev_by_curr.get(currency_code, 0) + sale_revenue
892
- global_cogs_by_curr[currency_code] = global_cogs_by_curr.get(currency_code, 0) + (cost * quantity)
893
- sales_count += 1
894
-
895
- user_sales_data[phone]['total_revenue_by_currency'][currency_code] = user_sales_data[phone]['total_revenue_by_currency'].get(currency_code, 0) + sale_revenue
896
-
897
- if item_name not in global_item_revenue: global_item_revenue[item_name] = {}
898
- global_item_revenue[item_name][currency_code] = global_item_revenue[item_name].get(currency_code, 0) + sale_revenue
899
-
900
- expenses_query = bot_user_ref.collection('expenses').stream()
901
- for expense_doc in expenses_query:
902
- expense_data = expense_doc.to_dict()
903
- created_at = expense_data.get('createdAt')
904
-
905
- if start_date and (not created_at or created_at < start_date):
906
- continue
907
- if end_date and (not created_at or created_at > end_date):
908
- continue
909
-
910
- details = expense_data.get('details', {})
911
- currency_code = normalize_currency_code(details.get('currency'), last_seen_currency_code)
912
- last_seen_currency_code = currency_code
913
- amount = float(details.get('amount', 0))
914
- category = details.get('description', 'Uncategorized')
915
-
916
- global_expenses_by_curr[currency_code] = global_expenses_by_curr.get(currency_code, 0) + amount
917
- if category not in global_expense_totals: global_expense_totals[category] = {}
918
- global_expense_totals[category][currency_code] = global_expense_totals[category].get(currency_code, 0) + amount
919
  except Exception as e:
920
- logging.error(f"Admin stats: Could not process data for phone {phone}. Error: {e}")
921
  continue
922
-
923
- # --- Post-Processing: Generate Leaderboards For Each Currency ---
924
- all_currencies = set(global_sales_rev_by_curr.keys()) | set(global_expenses_by_curr.keys())
925
-
926
- leaderboards = {'topUsersByRevenue': {}, 'topSellingItems': {}, 'topExpenses': {}}
927
-
928
- for currency in all_currencies:
929
- users_in_curr = [(phone, data['total_revenue_by_currency'].get(currency, 0)) for phone, data in user_sales_data.items() if data['total_revenue_by_currency'].get(currency, 0) > 0]
930
- sorted_users = sorted(users_in_curr, key=lambda item: item[1], reverse=True)
931
- leaderboards['topUsersByRevenue'][currency] = [{'displayName': phone_to_user_map.get(phone, {}).get('displayName'), 'uid': phone_to_user_map.get(phone, {}).get('uid'), 'totalRevenue': round(revenue, 2)} for phone, revenue in sorted_users[:5]]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
932
 
933
- items_in_curr = [(name, totals.get(currency, 0)) for name, totals in global_item_revenue.items() if totals.get(currency, 0) > 0]
934
- sorted_items = sorted(items_in_curr, key=lambda item: item[1], reverse=True)
935
- leaderboards['topSellingItems'][currency] = [{'item': name, 'totalRevenue': round(revenue, 2)} for name, revenue in sorted_items[:5]]
936
-
937
- expenses_in_curr = [(name, totals.get(currency, 0)) for name, totals in global_expense_totals.items() if totals.get(currency, 0) > 0]
938
- sorted_expenses = sorted(expenses_in_curr, key=lambda item: item[1], reverse=True)
939
- leaderboards['topExpenses'][currency] = [{'category': name, 'totalAmount': round(amount, 2)} for name, amount in sorted_expenses[:5]]
940
-
941
- # --- Final Assembly ---
942
- global_net_profit_by_curr = {}
943
- for curr in all_currencies:
944
- revenue = global_sales_rev_by_curr.get(curr, 0)
945
- cogs = global_cogs_by_curr.get(curr, 0)
946
- expenses = global_expenses_by_curr.get(curr, 0)
947
- global_net_profit_by_curr[curr] = round(revenue - cogs - expenses, 2)
948
-
949
- system_stats = {
950
- 'totalSalesRevenueByCurrency': {k: round(v, 2) for k, v in global_sales_rev_by_curr.items()},
951
- 'totalCostOfGoodsSoldByCurrency': {k: round(v, 2) for k, v in global_cogs_by_curr.items()},
952
- 'totalExpensesByCurrency': {k: round(v, 2) for k, v in global_expenses_by_curr.items()},
953
- 'totalNetProfitByCurrency': global_net_profit_by_curr,
954
- 'totalSalesCount': sales_count,
955
- }
956
 
957
  return jsonify({
958
- 'userStats': user_stats, 'organizationStats': org_stats,
959
- 'systemStats': system_stats, 'leaderboards': leaderboards
 
 
 
 
 
 
 
 
 
 
 
960
  }), 200
961
 
962
  except PermissionError as e:
@@ -964,6 +931,25 @@ def get_admin_dashboard_stats():
964
  except Exception as e:
965
  logging.error(f"Admin failed to fetch dashboard stats: {e}", exc_info=True)
966
  return jsonify({'error': 'An internal error occurred while fetching stats'}), 500
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
967
  # -----------------------------------------------------------------------------
968
  # 8. SERVER EXECUTION
969
  # -----------------------------------------------------------------------------
 
7
  import firebase_admin
8
  from firebase_admin import credentials, auth, firestore
9
 
10
+ import analytics # v2 ledger aggregation (reads the transactions schema)
11
+
12
  # --- Basic Configuration ---
13
  logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
14
 
 
94
  clean_code = raw_code.lower().strip()
95
  return currency_map.get(clean_code, default_code)
96
 
97
+
98
+ # ── v2 ledger helpers ────────────────────────────────────────────────────────
99
+ def parse_date_range(req):
100
+ """Parse ?start_date&end_date (ISO) into tz-aware datetimes (or None)."""
101
+ def _p(s):
102
+ if not s:
103
+ return None
104
+ try:
105
+ return datetime.fromisoformat(s.replace('Z', '+00:00'))
106
+ except Exception:
107
+ return None
108
+ return _p(req.args.get('start_date')), _p(req.args.get('end_date'))
109
+
110
+
111
+ def fetch_user_ledger(bot_data_id):
112
+ """Read a bot user's v2 ledger: (transactions, stock_batches, customers) as dicts."""
113
+ ref = db.collection('users').document(bot_data_id)
114
+ txns = [d.to_dict() for d in ref.collection('transactions').stream()]
115
+ batches = [d.to_dict() for d in ref.collection('stock_batches').stream()]
116
+ customers = [d.to_dict() for d in ref.collection('customers').stream()]
117
+ return txns, batches, customers
118
+
119
  # -----------------------------------------------------------------------------
120
  # 3. AUTHENTICATION & USER MANAGEMENT ENDPOINTS
121
  # -----------------------------------------------------------------------------
 
268
  if not phone_number: return jsonify({'error': 'No phone number is associated with your account.'}), 404
269
 
270
  try:
271
+ start_date, end_date = parse_date_range(request)
272
+ default_currency = user_data.get('defaultCurrency', 'USD')
 
 
 
 
 
 
 
273
  bot_data_id = phone_number.lstrip('+')
 
 
 
 
 
 
 
 
 
 
 
 
 
274
 
275
+ txns, batches, customers = fetch_user_ledger(bot_data_id)
276
+ agg = analytics.aggregate_ledger(txns, batches, customers, start_date, end_date,
277
+ default_currency=default_currency)
278
+
279
+ # Legacy-compatible per-currency maps (so existing UI keeps working during the switch)
280
+ by_cur = agg['byCurrency']
281
+ legacy = {
282
+ 'totalSalesRevenueByCurrency': {c: v['sales'] for c, v in by_cur.items()},
283
+ 'totalCostOfGoodsSoldByCurrency': {c: v['cogs'] for c, v in by_cur.items()},
284
+ 'grossProfitByCurrency': {c: v['grossProfit'] for c, v in by_cur.items()},
285
+ 'totalExpensesByCurrency': {c: v['expenses'] for c, v in by_cur.items()},
286
+ 'netProfitByCurrency': {c: v['netProfit'] for c, v in by_cur.items()},
287
+ 'salesCount': sum(v['salesCount'] for v in by_cur.values()),
288
+ }
289
+ # New v2 insight payload
290
+ payload = {
291
+ **legacy,
292
+ 'byCurrency': by_cur,
293
+ 'cashOnHand': agg['cashOnHand'],
294
+ 'receivables': agg['receivables'],
295
+ 'payables': agg['payables'],
296
+ 'stockValue': agg['stockValue'],
297
+ 'topItems': agg['topItems'],
298
+ 'topCustomers': agg['topCustomers'],
299
+ 'dailySeries': agg['dailySeries'],
300
+ }
301
+ return jsonify(payload), 200
302
 
303
+ except Exception as e:
304
+ logging.error(f"Error fetching dashboard data for user {uid} (phone: {phone_number}): {e}", exc_info=True)
305
+ return jsonify({'error': 'An error occurred while fetching your dashboard data.'}), 500
 
 
 
 
 
 
 
 
 
 
306
 
 
 
 
 
307
 
308
+ @app.route('/api/user/transactions', methods=['GET'])
309
+ def get_user_transactions():
310
+ """Recent ledger rows for the user's dashboard activity feed."""
311
+ uid = verify_token(request.headers.get('Authorization'))
312
+ if not uid:
313
+ return jsonify({'error': 'Invalid or expired token'}), 401
314
+ user_doc = db.collection('users').document(uid).get()
315
+ if not user_doc.exists:
316
+ return jsonify({'error': 'User not found'}), 404
317
+ user_data = user_doc.to_dict()
318
+ if user_data.get('phoneStatus') != 'approved' or not user_data.get('phone'):
319
+ return jsonify({'error': 'Your phone number is not yet approved.'}), 403
320
+ try:
321
+ limit = min(int(request.args.get('limit', 25)), 100)
322
+ bot_data_id = user_data['phone'].lstrip('+')
323
+ q = (db.collection('users').document(bot_data_id).collection('transactions')
324
+ .order_by('created_at', direction=firestore.Query.DESCENDING).limit(limit))
325
+ rows = []
326
+ for d in q.stream():
327
+ v = d.to_dict()
328
+ det = v.get('details', {}) or {}
329
+ ts = v.get('created_at')
330
+ rows.append({
331
+ 'id': d.id,
332
+ 'type': (v.get('transaction_type') or v.get('type') or 'other'),
333
+ 'description': det.get('description') or ', '.join(
334
+ f"{it.get('quantity','')} {it.get('item') or it.get('name','')}".strip()
335
+ for it in (det.get('items') or []) if isinstance(it, dict)),
336
+ 'amount': analytics.money(det.get('amount') or det.get('total')),
337
+ 'currency': normalize_currency_code(det.get('currency'), 'USD'),
338
+ 'party': det.get('customer') or det.get('party') or '',
339
+ 'createdAt': ts.isoformat() if hasattr(ts, 'isoformat') else (str(ts) if ts else None),
340
+ })
341
+ return jsonify({'transactions': rows}), 200
342
  except Exception as e:
343
+ logging.error(f"Error fetching transactions for {uid}: {e}", exc_info=True)
344
+ return jsonify({'error': 'An error occurred while fetching your transactions.'}), 500
345
 
346
  # Replace your existing PUT /api/user/profile with this one.
347
  @app.route('/api/user/profile', methods=['PUT'])
 
834
  """
835
  try:
836
  verify_admin_and_get_uid(request.headers.get('Authorization'))
837
+ start_date, end_date = parse_date_range(request)
838
 
 
 
 
 
 
 
 
 
 
 
839
  all_users_docs = list(db.collection('users').stream())
840
  all_orgs_docs = list(db.collection('organizations').stream())
 
 
 
 
 
 
 
841
 
842
+ # User/profile stats + approved phone map (profile docs have an 'email')
843
+ pending_approvals = approved_users = admin_count = 0
844
+ phone_to_user = {}
845
  for doc in all_users_docs:
846
+ u = doc.to_dict()
847
+ if not u.get('email'):
848
+ continue
849
+ if u.get('isAdmin'):
850
+ admin_count += 1
851
+ status, phone = u.get('phoneStatus'), u.get('phone')
852
+ if status == 'pending':
853
+ pending_approvals += 1
854
+ elif status == 'approved' and phone:
855
+ approved_users += 1
856
+ phone_to_user[phone] = {'displayName': u.get('displayName', 'N/A'), 'uid': u.get('uid'),
857
+ 'defaultCurrency': u.get('defaultCurrency', 'USD')}
858
+ user_profile_count = sum(1 for d in all_users_docs if d.to_dict().get('email'))
859
+
860
+ # Aggregate every approved user's v2 ledger and merge globally
861
+ from collections import defaultdict as _dd
862
+ gcur = _dd(lambda: {'sales': 0.0, 'cogs': 0.0, 'expenses': 0.0, 'salesCount': 0})
863
+ item_rev = _dd(lambda: _dd(float)) # item -> cur -> revenue
864
+ exp_cat = _dd(lambda: _dd(float)) # category -> cur -> amount
865
+ user_rev = _dd(lambda: _dd(float)) # phone -> cur -> revenue
866
+ g_cash = g_recv = g_pay = g_stock = 0.0
867
+
868
+ for phone in phone_to_user:
 
 
 
 
 
 
 
869
  try:
870
+ txns, batches, customers = fetch_user_ledger(phone.lstrip('+'))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
871
  except Exception as e:
872
+ logging.error(f"Admin stats: ledger read failed for {phone}: {e}")
873
  continue
874
+ agg = analytics.aggregate_ledger(txns, batches, customers, start_date, end_date,
875
+ default_currency=phone_to_user[phone]['defaultCurrency'])
876
+ for cur, m in agg['byCurrency'].items():
877
+ gcur[cur]['sales'] += m['sales']; gcur[cur]['cogs'] += m['cogs']
878
+ gcur[cur]['expenses'] += m['expenses']; gcur[cur]['salesCount'] += m['salesCount']
879
+ user_rev[phone][cur] += m['sales']
880
+ for it in agg['topItems']:
881
+ for cur, v in it['byCurrency'].items():
882
+ item_rev[it['item']][cur] += v
883
+ for ex in agg['topExpenses']:
884
+ for cur, v in ex['byCurrency'].items():
885
+ exp_cat[ex['category']][cur] += v
886
+ g_cash += agg['cashOnHand']; g_recv += agg['receivables']
887
+ g_pay += agg['payables']; g_stock += agg['stockValue']
888
+
889
+ all_currencies = set(gcur)
890
+ system_by_cur, total_count = {}, 0
891
+ for cur, m in gcur.items():
892
+ gross = round(m['sales'] - m['cogs'], 2)
893
+ system_by_cur[cur] = {
894
+ 'sales': round(m['sales'], 2), 'cogs': round(m['cogs'], 2), 'grossProfit': gross,
895
+ 'expenses': round(m['expenses'], 2), 'netProfit': round(gross - m['expenses'], 2),
896
+ 'salesCount': int(m['salesCount'])}
897
+ total_count += int(m['salesCount'])
898
 
899
+ leaderboards = {'topUsersByRevenue': {}, 'topSellingItems': {}, 'topExpenses': {}}
900
+ for cur in all_currencies:
901
+ users_c = sorted(((p, r.get(cur, 0.0)) for p, r in user_rev.items() if r.get(cur, 0) > 0),
902
+ key=lambda x: -x[1])[:5]
903
+ leaderboards['topUsersByRevenue'][cur] = [
904
+ {'displayName': phone_to_user.get(p, {}).get('displayName'),
905
+ 'uid': phone_to_user.get(p, {}).get('uid'), 'totalRevenue': round(rev, 2)} for p, rev in users_c]
906
+ items_c = sorted(((n, t.get(cur, 0.0)) for n, t in item_rev.items() if t.get(cur, 0) > 0),
907
+ key=lambda x: -x[1])[:5]
908
+ leaderboards['topSellingItems'][cur] = [{'item': n, 'totalRevenue': round(v, 2)} for n, v in items_c]
909
+ exp_c = sorted(((n, t.get(cur, 0.0)) for n, t in exp_cat.items() if t.get(cur, 0) > 0),
910
+ key=lambda x: -x[1])[:5]
911
+ leaderboards['topExpenses'][cur] = [{'category': n, 'totalAmount': round(v, 2)} for n, v in exp_c]
 
 
 
 
 
 
 
 
 
 
912
 
913
  return jsonify({
914
+ 'userStats': {'total': user_profile_count, 'admins': admin_count,
915
+ 'approvedForBot': approved_users, 'pendingApproval': pending_approvals},
916
+ 'organizationStats': {'total': len(all_orgs_docs)},
917
+ 'systemStats': {
918
+ 'byCurrency': system_by_cur,
919
+ 'totalSalesRevenueByCurrency': {c: v['sales'] for c, v in system_by_cur.items()},
920
+ 'totalCostOfGoodsSoldByCurrency': {c: v['cogs'] for c, v in system_by_cur.items()},
921
+ 'totalExpensesByCurrency': {c: v['expenses'] for c, v in system_by_cur.items()},
922
+ 'totalNetProfitByCurrency': {c: v['netProfit'] for c, v in system_by_cur.items()},
923
+ 'totalSalesCount': total_count,
924
+ 'cashOnHand': round(g_cash, 2), 'receivables': round(g_recv, 2),
925
+ 'payables': round(g_pay, 2), 'stockValue': round(g_stock, 2)},
926
+ 'leaderboards': leaderboards,
927
  }), 200
928
 
929
  except PermissionError as e:
 
931
  except Exception as e:
932
  logging.error(f"Admin failed to fetch dashboard stats: {e}", exc_info=True)
933
  return jsonify({'error': 'An internal error occurred while fetching stats'}), 500
934
+
935
+
936
+ @app.route('/api/admin/model-health', methods=['GET'])
937
+ def get_model_health():
938
+ """Scientific view of how we record & close sessions: the distillation dataset."""
939
+ try:
940
+ verify_admin_and_get_uid(request.headers.get('Authorization'))
941
+ start_date, end_date = parse_date_range(request)
942
+ examples = [d.to_dict() for d in db.collection('distillation_examples').limit(20000).stream()]
943
+ opt_out = sum(1 for d in db.collection('users').stream()
944
+ if d.to_dict().get('distill_consent') is False)
945
+ return jsonify(analytics.model_health(examples, start_date, end_date, opt_out)), 200
946
+ except PermissionError as e:
947
+ return jsonify({'error': str(e)}), 403
948
+ except Exception as e:
949
+ logging.error(f"Admin failed to fetch model health: {e}", exc_info=True)
950
+ return jsonify({'error': 'An internal error occurred while fetching model health'}), 500
951
+
952
+
953
  # -----------------------------------------------------------------------------
954
  # 8. SERVER EXECUTION
955
  # -----------------------------------------------------------------------------