sphere
Browse files
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 |
+
}
|