MJ-Prod commited on
Commit
5ed1450
·
1 Parent(s): 7926b81
Files changed (1) hide show
  1. app.py +265 -154
app.py CHANGED
@@ -730,124 +730,6 @@ async def trial_prescription_activity(request: Request):
730
  'date_range': {'start': str(start_date), 'end': str(end_date)},
731
  }
732
 
733
-
734
- @app.post("/trial/cashflow/data")
735
- async def trial_cashflow_data(request: Request):
736
- """
737
- Returns income + expense breakdown grouped by category and merchant
738
- for the cash flow bubble visualization.
739
- """
740
- from plaid_client import _load_raw_transactions
741
- from datetime import date, timedelta
742
- from collections import defaultdict
743
- import re
744
-
745
- try:
746
- fixture = _load_raw_transactions()
747
- all_transactions = fixture['transactions']
748
- except Exception as e:
749
- print(f"Cashflow fixture error: {e}", flush=True)
750
- return {"income_total": 0, "categories": []}
751
-
752
- # Filter to last 30 days
753
- end_date = date.today()
754
- start_date = end_date - timedelta(days=30)
755
-
756
- income_total = 0.0
757
- category_data = defaultdict(lambda: {
758
- "total": 0.0,
759
- "merchants": defaultdict(lambda: {"total": 0.0, "count": 0, "transactions": []}),
760
- "count": 0,
761
- })
762
-
763
- for txn in all_transactions:
764
- txn_date_val = txn.get('date')
765
- if isinstance(txn_date_val, str):
766
- txn_date = date.fromisoformat(txn_date_val)
767
- else:
768
- txn_date = txn_date_val
769
-
770
- if txn_date < start_date or txn_date > end_date:
771
- continue
772
-
773
- amount = txn['amount']
774
- name = txn.get('name', 'Unknown')
775
- category = txn.get('personal_finance_category', {}).get('primary', 'OTHER')
776
-
777
- # Skip transfers
778
- if category in ['TRANSFER_IN', 'TRANSFER_OUT']:
779
- continue
780
-
781
- # Identify income (negative amounts or payroll patterns)
782
- name_lower = name.lower()
783
- is_income = amount < 0 or (
784
- category == 'LOAN_PAYMENTS' and
785
- any(w in name_lower for w in ['payroll', 'salary', 'direct dep', 'employer', 'wages'])
786
- )
787
-
788
- if is_income:
789
- income_total += abs(amount)
790
- continue
791
-
792
- # Skip other loan payments (mortgage will get its own treatment via recurring)
793
- if category == 'LOAN_PAYMENTS':
794
- continue
795
-
796
- # Group into category → merchant
797
- normalized_merchant = re.sub(r'[0-9#]+', '', name).strip()
798
- normalized_merchant = re.sub(r'\s+', ' ', normalized_merchant).upper() or name
799
-
800
- category_data[category]["total"] += amount
801
- category_data[category]["count"] += 1
802
- category_data[category]["merchants"][normalized_merchant]["total"] += amount
803
- category_data[category]["merchants"][normalized_merchant]["count"] += 1
804
- category_data[category]["merchants"][normalized_merchant]["transactions"].append({
805
- "date": str(txn_date),
806
- "amount": round(amount, 2),
807
- "name": name,
808
- })
809
-
810
- # Convert to sorted list
811
- categories = []
812
- for cat_name, cat_info in category_data.items():
813
- # Build merchant list
814
- merchants = []
815
- for merchant_name, merchant_info in cat_info["merchants"].items():
816
- merchants.append({
817
- "name": merchant_name,
818
- "display_name": merchant_info["transactions"][0]["name"] if merchant_info["transactions"] else merchant_name,
819
- "total": round(merchant_info["total"], 2),
820
- "count": merchant_info["count"],
821
- "transactions": sorted(
822
- merchant_info["transactions"],
823
- key=lambda x: x["date"],
824
- reverse=True
825
- )[:10], # Cap at 10 most recent
826
- })
827
- merchants.sort(key=lambda x: -x["total"])
828
-
829
- # Human-readable category name
830
- readable_name = cat_name.replace("_", " ").title()
831
-
832
- categories.append({
833
- "name": readable_name,
834
- "raw_name": cat_name,
835
- "total": round(cat_info["total"], 2),
836
- "count": cat_info["count"],
837
- "merchants": merchants,
838
- })
839
-
840
- categories.sort(key=lambda x: -x["total"])
841
-
842
- return {
843
- "income_total": round(income_total, 2),
844
- "categories": categories,
845
- "date_range": {
846
- "start": str(start_date),
847
- "end": str(end_date),
848
- }
849
- }
850
-
851
  @app.post("/trial/cashflow/data")
852
  async def trial_cashflow_data(request: Request):
853
  """
@@ -994,20 +876,20 @@ async def trial_cashflow_opinion(request: Request):
994
 
995
  prompt = f"""The user is looking at their cash flow for the last 30 days.
996
 
997
- Income this month: ${income_total:.2f}
998
- Top spending categories:
999
- {cats_str}
1000
- Total spent: ${total_spent:.2f}
1001
- Remaining: ${remaining:.2f}
1002
 
1003
- Give a warm, doctor-patient style overview of the whole month. Cover:
1004
- 1. What the balance looks like (income vs spending)
1005
- 2. What's healthy or notable in the split
1006
- 3. Which category stands out (biggest, or most movable) — just observation, not judgment
1007
 
1008
- Keep it under 90 words. Warm, calm, non-judgmental. No advice yet — just the "here's what I see" moment. Speak like a doctor reviewing a chart, not a financial advisor giving tips.
1009
 
1010
- CRITICAL: Respond with prose only. No headers, no bullets, no chart syntax, no lists."""
1011
 
1012
  elif level == "category":
1013
  cat_name = body.get("category_name", "")
@@ -1021,20 +903,20 @@ CRITICAL: Respond with prose only. No headers, no bullets, no chart syntax, no l
1021
 
1022
  prompt = f"""The user just zoomed into their "{cat_name}" spending.
1023
 
1024
- Category: {cat_name}
1025
- Total spent: ${cat_total:.2f} ({cat_count} transactions)
1026
- Percentage of monthly income: {pct_of_income:.0f}%
1027
- Top merchants in this category:
1028
- {merchants_str}
1029
 
1030
- Give a warm, doctor-patient style diagnosis of this category. Cover:
1031
- 1. Whether the amount is normal, high, or low for this category
1032
- 2. What the merchant breakdown shows (concentration vs spread)
1033
- 3. One small observation about the pattern — only if genuinely useful
1034
 
1035
- Keep under 90 words. Warm, calm, non-judgmental. Speak like a doctor examining a specific symptom.
1036
 
1037
- CRITICAL: Respond with prose only. No headers, no bullets, no chart syntax, no lists."""
1038
 
1039
  elif level == "merchant":
1040
  merchant_name = body.get("merchant_name", "")
@@ -1048,22 +930,22 @@ CRITICAL: Respond with prose only. No headers, no bullets, no chart syntax, no l
1048
 
1049
  prompt = f"""The user just zoomed into a specific merchant: {merchant_name}
1050
 
1051
- Merchant: {merchant_name}
1052
- Category: {category_name}
1053
- Total spent here: ${merchant_total:.2f}
1054
- Number of transactions: {merchant_count}
1055
- Average per transaction: ${avg:.2f}
1056
- Recent transactions:
1057
- {recent_str}
1058
 
1059
- Give a warm, doctor-patient style opinion on this specific merchant. Cover:
1060
- 1. What the pattern looks like (frequency, size)
1061
- 2. Whether it's a habit worth noticing (recurring visits, high per-transaction cost)
1062
- 3. Reassurance if it's fine, or a gentle prompt if there's something worth looking at
1063
 
1064
- Keep under 80 words. Warm, calm, non-judgmental, specific to this merchant.
1065
 
1066
- CRITICAL: Respond with prose only. No headers, no bullets, no chart syntax, no lists."""
1067
  else:
1068
  return {"error": "Invalid level"}
1069
 
@@ -1072,4 +954,233 @@ CRITICAL: Respond with prose only. No headers, no bullets, no chart syntax, no l
1072
  yield f"data: {json.dumps({'chunk': token})}\n\n"
1073
  yield "data: [DONE]\n\n"
1074
 
1075
- return StreamingResponse(generate(), media_type="text/event-stream")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
730
  'date_range': {'start': str(start_date), 'end': str(end_date)},
731
  }
732
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
733
  @app.post("/trial/cashflow/data")
734
  async def trial_cashflow_data(request: Request):
735
  """
 
876
 
877
  prompt = f"""The user is looking at their cash flow for the last 30 days.
878
 
879
+ Income this month: ${income_total:.2f}
880
+ Top spending categories:
881
+ {cats_str}
882
+ Total spent: ${total_spent:.2f}
883
+ Remaining: ${remaining:.2f}
884
 
885
+ Give a warm, doctor-patient style overview of the whole month. Cover:
886
+ 1. What the balance looks like (income vs spending)
887
+ 2. What's healthy or notable in the split
888
+ 3. Which category stands out (biggest, or most movable) — just observation, not judgment
889
 
890
+ Keep it under 90 words. Warm, calm, non-judgmental. No advice yet — just the "here's what I see" moment. Speak like a doctor reviewing a chart, not a financial advisor giving tips.
891
 
892
+ CRITICAL: Respond with prose only. No headers, no bullets, no chart syntax, no lists."""
893
 
894
  elif level == "category":
895
  cat_name = body.get("category_name", "")
 
903
 
904
  prompt = f"""The user just zoomed into their "{cat_name}" spending.
905
 
906
+ Category: {cat_name}
907
+ Total spent: ${cat_total:.2f} ({cat_count} transactions)
908
+ Percentage of monthly income: {pct_of_income:.0f}%
909
+ Top merchants in this category:
910
+ {merchants_str}
911
 
912
+ Give a warm, doctor-patient style diagnosis of this category. Cover:
913
+ 1. Whether the amount is normal, high, or low for this category
914
+ 2. What the merchant breakdown shows (concentration vs spread)
915
+ 3. One small observation about the pattern — only if genuinely useful
916
 
917
+ Keep under 90 words. Warm, calm, non-judgmental. Speak like a doctor examining a specific symptom.
918
 
919
+ CRITICAL: Respond with prose only. No headers, no bullets, no chart syntax, no lists."""
920
 
921
  elif level == "merchant":
922
  merchant_name = body.get("merchant_name", "")
 
930
 
931
  prompt = f"""The user just zoomed into a specific merchant: {merchant_name}
932
 
933
+ Merchant: {merchant_name}
934
+ Category: {category_name}
935
+ Total spent here: ${merchant_total:.2f}
936
+ Number of transactions: {merchant_count}
937
+ Average per transaction: ${avg:.2f}
938
+ Recent transactions:
939
+ {recent_str}
940
 
941
+ Give a warm, doctor-patient style opinion on this specific merchant. Cover:
942
+ 1. What the pattern looks like (frequency, size)
943
+ 2. Whether it's a habit worth noticing (recurring visits, high per-transaction cost)
944
+ 3. Reassurance if it's fine, or a gentle prompt if there's something worth looking at
945
 
946
+ Keep under 80 words. Warm, calm, non-judgmental, specific to this merchant.
947
 
948
+ CRITICAL: Respond with prose only. No headers, no bullets, no chart syntax, no lists."""
949
  else:
950
  return {"error": "Invalid level"}
951
 
 
954
  yield f"data: {json.dumps({'chunk': token})}\n\n"
955
  yield "data: [DONE]\n\n"
956
 
957
+ return StreamingResponse(generate(), media_type="text/event-stream")
958
+
959
+
960
+
961
+ @app.post("/trial/yearsphere/data")
962
+ async def trial_yearsphere_data(request: Request):
963
+ """
964
+ Returns 12 months of data for the Year Sphere.
965
+ Consumes get_recurring_from_fixtures() — same source as the working calendar.
966
+ Recurring items are placed on the days they historically hit (past) or are projected to hit (future).
967
+ """
968
+ from plaid_client import _load_raw_transactions, get_recurring_from_fixtures
969
+ from datetime import date
970
+ from collections import defaultdict
971
+ from calendar import monthrange
972
+
973
+ try:
974
+ fixture = _load_raw_transactions()
975
+ all_transactions = fixture['transactions']
976
+ except Exception as e:
977
+ print(f"YearSphere fixture error: {e}", flush=True)
978
+ return {"months": [], "current_month_index": 0}
979
+
980
+ # Pull the same recurring data the working calendar uses
981
+ try:
982
+ recurring_data = get_recurring_from_fixtures()
983
+ except Exception as e:
984
+ print(f"YearSphere recurring fetch error: {e}", flush=True)
985
+ recurring_data = {"recurring_expenses": [], "recurring_income": [], "projected_events": []}
986
+
987
+ recurring_expenses = recurring_data.get("recurring_expenses", [])
988
+ recurring_income = recurring_data.get("recurring_income", [])
989
+ projected_events = recurring_data.get("projected_events", [])
990
+
991
+ today = date.today()
992
+
993
+ # Build the month window: 8 months back through 3 months forward
994
+ window = []
995
+ for offset in range(-8, 4):
996
+ target_month = today.month + offset
997
+ target_year = today.year
998
+ while target_month < 1:
999
+ target_month += 12
1000
+ target_year -= 1
1001
+ while target_month > 12:
1002
+ target_month -= 12
1003
+ target_year += 1
1004
+ window.append((target_year, target_month))
1005
+
1006
+ # --- Build a per-month index of recurring occurrences (both historical + projected) ---
1007
+ # Key: (year, month) -> list of {name, amount, date, is_income}
1008
+ recurring_by_month: dict = defaultdict(list)
1009
+ # Track which (year, month, normalized_name) we've seen so we don't double-list
1010
+ seen_month_names: set = set()
1011
+
1012
+ def add_recurring_occurrence(year: int, month: int, day: int, name: str, amount: float, is_income: bool):
1013
+ # Guard against duplicate entries for the same recurring item in the same month
1014
+ # (e.g. biweekly items hit twice — those are legit, don't dedupe those; dedupe monthly ones)
1015
+ dedupe_key = (year, month, name.upper().strip(), day)
1016
+ if dedupe_key in seen_month_names:
1017
+ return
1018
+ seen_month_names.add(dedupe_key)
1019
+ recurring_by_month[(year, month)].append({
1020
+ "name": name[:30],
1021
+ "amount": round(abs(amount), 2),
1022
+ "date": f"{year:04d}-{month:02d}-{day:02d}",
1023
+ "is_income": is_income,
1024
+ })
1025
+
1026
+ # Add historical occurrences from the recurring_expenses / recurring_income "history" arrays
1027
+ for item in recurring_expenses:
1028
+ for hist in item.get("history", []):
1029
+ hist_date = date.fromisoformat(hist["date"])
1030
+ add_recurring_occurrence(
1031
+ hist_date.year, hist_date.month, hist_date.day,
1032
+ item["merchant"], hist["amount"], is_income=False,
1033
+ )
1034
+ for item in recurring_income:
1035
+ for hist in item.get("history", []):
1036
+ hist_date = date.fromisoformat(hist["date"])
1037
+ add_recurring_occurrence(
1038
+ hist_date.year, hist_date.month, hist_date.day,
1039
+ item["merchant"], hist["amount"], is_income=True,
1040
+ )
1041
+
1042
+ # Add projected future events
1043
+ for ev in projected_events:
1044
+ ev_date = date.fromisoformat(ev["date"])
1045
+ if ev_date <= today:
1046
+ continue # Only add projections for future dates
1047
+ add_recurring_occurrence(
1048
+ ev_date.year, ev_date.month, ev_date.day,
1049
+ ev.get("merchant", "Unknown"), ev.get("amount", 0.0),
1050
+ is_income=ev.get("is_income", False),
1051
+ )
1052
+
1053
+ # --- Build per-month output ---
1054
+ months_out = []
1055
+ for (year, month) in window:
1056
+ month_start = date(year, month, 1)
1057
+ _, last_day = monthrange(year, month)
1058
+ month_end = date(year, month, last_day)
1059
+
1060
+ is_past = month_end < today
1061
+ is_current = month_start <= today <= month_end
1062
+ is_future = month_start > today
1063
+
1064
+ total_spent = 0.0
1065
+ total_income = 0.0
1066
+ daily_spending = defaultdict(float)
1067
+ daily_transactions = defaultdict(list)
1068
+
1069
+ # Real transactions from fixture (only for past + current)
1070
+ if not is_future:
1071
+ for txn in all_transactions:
1072
+ txn_date_val = txn.get('date')
1073
+ if isinstance(txn_date_val, str):
1074
+ txn_date = date.fromisoformat(txn_date_val)
1075
+ else:
1076
+ txn_date = txn_date_val
1077
+
1078
+ if txn_date < month_start or txn_date > month_end:
1079
+ continue
1080
+
1081
+ amount = txn['amount']
1082
+ name = txn.get('name', 'Unknown')
1083
+ category = txn.get('personal_finance_category', {}).get('primary', 'OTHER')
1084
+ name_lower = name.lower()
1085
+
1086
+ if category in ['TRANSFER_IN', 'TRANSFER_OUT']:
1087
+ continue
1088
+
1089
+ is_income = amount < 0 or (
1090
+ category == 'LOAN_PAYMENTS' and
1091
+ any(w in name_lower for w in ['payroll', 'salary', 'direct dep', 'employer', 'wages'])
1092
+ )
1093
+
1094
+ daily_transactions[str(txn_date)].append({
1095
+ "name": name[:30],
1096
+ "amount": round(abs(amount), 2),
1097
+ "is_income": is_income,
1098
+ "category": category,
1099
+ })
1100
+
1101
+ if is_income:
1102
+ total_income += abs(amount)
1103
+ else:
1104
+ total_spent += amount
1105
+ daily_spending[str(txn_date)] += amount
1106
+
1107
+ # Overlay recurring occurrences (historical for past/current, projected for future)
1108
+ # These are added on top of raw transactions so future months get populated,
1109
+ # and past months get any recurring occurrences that the raw fixture might miss.
1110
+ for rec in recurring_by_month.get((year, month), []):
1111
+ rec_date_str = rec["date"]
1112
+ already_in_day = any(
1113
+ t["name"].upper().strip() == rec["name"].upper().strip()
1114
+ for t in daily_transactions.get(rec_date_str, [])
1115
+ )
1116
+ if not already_in_day:
1117
+ daily_transactions[rec_date_str].append({
1118
+ "name": rec["name"],
1119
+ "amount": rec["amount"],
1120
+ "is_income": rec["is_income"],
1121
+ "category": "RECURRING",
1122
+ })
1123
+ if is_future:
1124
+ if rec["is_income"]:
1125
+ total_income += rec["amount"]
1126
+ else:
1127
+ total_spent += rec["amount"]
1128
+ daily_spending[rec_date_str] += rec["amount"]
1129
+
1130
+ # For the recurring lists at the bottom of the zoomed view, use the recurring_by_month entries
1131
+ month_recurring = recurring_by_month.get((year, month), [])
1132
+ recurring_income_items = [
1133
+ {"name": r["name"], "amount": r["amount"], "date": r["date"]}
1134
+ for r in month_recurring if r["is_income"]
1135
+ ]
1136
+ recurring_expense_items = [
1137
+ {"name": r["name"], "amount": r["amount"], "date": r["date"]}
1138
+ for r in month_recurring if not r["is_income"]
1139
+ ]
1140
+
1141
+ # Future months: total_spent stays 0 so the sphere stays flat
1142
+ # (recurring projections were added above but we reset total_spent to 0 for the elevation math)
1143
+ if is_future:
1144
+ total_spent = 0.0
1145
+
1146
+ # Build daily_data
1147
+ days_in_month = last_day
1148
+ max_day_spending = max(daily_spending.values()) if daily_spending else 1
1149
+ daily_data = []
1150
+ for day_num in range(1, days_in_month + 1):
1151
+ day_str = str(date(year, month, day_num))
1152
+ spent = daily_spending.get(day_str, 0.0)
1153
+ intensity = spent / max_day_spending if max_day_spending > 0 else 0
1154
+ daily_data.append({
1155
+ "day": day_num,
1156
+ "amount": round(spent, 2),
1157
+ "intensity": round(intensity, 2),
1158
+ "transactions": daily_transactions.get(day_str, []),
1159
+ })
1160
+
1161
+ first_weekday = month_start.weekday()
1162
+ first_weekday = (first_weekday + 1) % 7 # Sun=0
1163
+
1164
+ months_out.append({
1165
+ "year": year,
1166
+ "month": month,
1167
+ "month_name": month_start.strftime("%b").upper(),
1168
+ "month_name_full": month_start.strftime("%B"),
1169
+ "total_spent": round(total_spent, 2),
1170
+ "total_income": round(total_income, 2),
1171
+ "is_past": is_past,
1172
+ "is_current": is_current,
1173
+ "is_future": is_future,
1174
+ "daily_data": daily_data,
1175
+ "first_weekday": first_weekday,
1176
+ "days_in_month": days_in_month,
1177
+ "recurring_income": sorted(recurring_income_items, key=lambda x: -x["amount"])[:5],
1178
+ "recurring_expenses": sorted(recurring_expense_items, key=lambda x: -x["amount"])[:10],
1179
+ })
1180
+
1181
+ current_idx = next((i for i, m in enumerate(months_out) if m["is_current"]), 8)
1182
+
1183
+ return {
1184
+ "months": months_out,
1185
+ "current_month_index": current_idx,
1186
+ }