From 735eeb8ed7533e69b82d1233867f8b6bcb038a35 Mon Sep 17 00:00:00 2001 From: Atlas Date: Thu, 5 Mar 2026 17:03:48 +0000 Subject: [PATCH 1/3] feat: Add shared household budgeting feature - Add Household and HouseholdMember models - Add household API routes (CRUD operations) - Add frontend Households page with create/join functionality - Add navigation link to households page Bounty: 0 (Shared household budgeting support) --- app/src/App.tsx | 9 + app/src/components/layout/Navbar.tsx | 1 + app/src/pages/Households.tsx | 279 +++++++++++++++++++++++ packages/backend/app/models.py | 22 ++ packages/backend/app/routes/__init__.py | 2 + packages/backend/app/routes/household.py | 186 +++++++++++++++ 6 files changed, 499 insertions(+) create mode 100644 app/src/pages/Households.tsx create mode 100644 packages/backend/app/routes/household.py diff --git a/app/src/App.tsx b/app/src/App.tsx index f0dc594..527f60b 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -16,6 +16,7 @@ import NotFound from "./pages/NotFound"; import { Landing } from "./pages/Landing"; import ProtectedRoute from "./components/auth/ProtectedRoute"; import Account from "./pages/Account"; +import Households from "./pages/Households"; const queryClient = new QueryClient({ defaultOptions: { @@ -91,6 +92,14 @@ const App = () => ( } /> + + + + } + /> } /> } /> diff --git a/app/src/components/layout/Navbar.tsx b/app/src/components/layout/Navbar.tsx index c7593b7..b008f02 100644 --- a/app/src/components/layout/Navbar.tsx +++ b/app/src/components/layout/Navbar.tsx @@ -12,6 +12,7 @@ const navigation = [ { name: 'Bills', href: '/bills' }, { name: 'Reminders', href: '/reminders' }, { name: 'Expenses', href: '/expenses' }, + { name: 'Households', href: '/households' }, { name: 'Analytics', href: '/analytics' }, ]; diff --git a/app/src/pages/Households.tsx b/app/src/pages/Households.tsx new file mode 100644 index 0000000..8d2a6c3 --- /dev/null +++ b/app/src/pages/Households.tsx @@ -0,0 +1,279 @@ +import { useState, useEffect } from 'react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogFooter } from '@/components/ui/dialog'; +import { Users, Plus, Home, Mail, Crown } from 'lucide-react'; + +interface Household { + id: number; + name: string; + role: string; + members_count?: number; + joined_at?: string; +} + +interface Member { + id: number; + user_id: number; + email: string; + role: string; + joined_at: string; +} + +export default function Households() { + const [households, setHouseholds] = useState([]); + const [selectedHousehold, setSelectedHousehold] = useState(null); + const [members, setMembers] = useState([]); + const [isCreateOpen, setIsCreateOpen] = useState(false); + const [isJoinOpen, setIsJoinOpen] = useState(false); + const [newHouseholdName, setNewHouseholdName] = useState(''); + const [joinHouseholdId, setJoinHouseholdId] = useState(''); + const [inviteEmail, setInviteEmail] = useState(''); + const [loading, setLoading] = useState(true); + + const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:5000'; + + const fetchHouseholds = async () => { + try { + const token = localStorage.getItem('token'); + const res = await fetch(`${API_BASE}/household/households`, { + headers: { 'Authorization': `Bearer ${token}` } + }); + if (res.ok) { + const data = await res.json(); + setHouseholds(data); + } + } catch (error) { + console.error('Failed to fetch households:', error); + } finally { + setLoading(false); + } + }; + + const fetchMembers = async (householdId: number) => { + try { + const token = localStorage.getItem('token'); + const res = await fetch(`${API_BASE}/household/households/${householdId}`, { + headers: { 'Authorization': `Bearer ${token}` } + }); + if (res.ok) { + const data = await res.json(); + setMembers(data.members || []); + } + } catch (error) { + console.error('Failed to fetch members:', error); + } + }; + + useEffect(() => { + fetchHouseholds(); + }, []); + + const createHousehold = async () => { + try { + const token = localStorage.getItem('token'); + const res = await fetch(`${API_BASE}/household/households`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ name: newHouseholdName }) + }); + if (res.ok) { + setNewHouseholdName(''); + setIsCreateOpen(false); + fetchHouseholds(); + } + } catch (error) { + console.error('Failed to create household:', error); + } + }; + + const joinHousehold = async () => { + try { + const token = localStorage.getItem('token'); + const res = await fetch(`${API_BASE}/household/households/${joinHouseholdId}/join`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({}) + }); + if (res.ok) { + setJoinHouseholdId(''); + setIsJoinOpen(false); + fetchHouseholds(); + } + } catch (error) { + console.error('Failed to join household:', error); + } + }; + + const addMember = async () => { + if (!selectedHousehold || !inviteEmail) return; + try { + const token = localStorage.getItem('token'); + const res = await fetch(`${API_BASE}/household/households/${selectedHousehold.id}/members`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ email: inviteEmail }) + }); + if (res.ok) { + setInviteEmail(''); + fetchMembers(selectedHousehold.id); + } + } catch (error) { + console.error('Failed to add member:', error); + } + }; + + const openHousehold = async (household: Household) => { + setSelectedHousehold(household); + fetchMembers(household.id); + }; + + if (loading) { + return
Loading...
; + } + + return ( +
+
+
+ +

Households

+
+
+ + + + + + + Join Household + +
+ + setJoinHouseholdId(e.target.value)} + placeholder="Enter household ID" + className="mt-2" + /> +
+ + + +
+
+ + + + + + + + Create New Household + +
+ + setNewHouseholdName(e.target.value)} + placeholder="e.g., Smith Family" + className="mt-2" + /> +
+ + + +
+
+
+
+ + {households.length === 0 ? ( + + + +

No households yet. Create one to get started!

+
+
+ ) : ( +
+ {households.map((household) => ( + openHousehold(household)} + > + + + + {household.name} + {household.role === 'owner' && } + + + {household.members_count} member{household.members_count !== 1 ? 's' : ''} + + + + ))} +
+ )} + + {/* Household Details Dialog */} + {selectedHousehold && ( + setSelectedHousehold(null)}> + + + + + {selectedHousehold.name} + + +
+

Members

+
+ {members.map((member) => ( +
+
+ + {member.email} +
+ {member.role === 'owner' && ( + + Owner + + )} +
+ ))} +
+ + {selectedHousehold.role === 'owner' && ( +
+ +
+ setInviteEmail(e.target.value)} + placeholder="user@example.com" + /> + +
+
+ )} +
+
+
+ )} +
+ ); +} diff --git a/packages/backend/app/models.py b/packages/backend/app/models.py index 64d4481..055f692 100644 --- a/packages/backend/app/models.py +++ b/packages/backend/app/models.py @@ -133,3 +133,25 @@ class AuditLog(db.Model): user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True) action = db.Column(db.String(100), nullable=False) created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + + +# Household Budgeting Models +class Household(db.Model): + __tablename__ = "households" + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(200), nullable=False) + created_by = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + # Relationship + members = db.relationship("HouseholdMember", backref="household", lazy=True) + + +class HouseholdMember(db.Model): + __tablename__ = "household_members" + id = db.Column(db.Integer, primary_key=True) + household_id = db.Column(db.Integer, db.ForeignKey("households.id"), nullable=False) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) + role = db.Column(db.String(20), default="member", nullable=False) # owner, member + joined_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + # Relationship + user = db.relationship("User", backref="household_memberships") diff --git a/packages/backend/app/routes/__init__.py b/packages/backend/app/routes/__init__.py index f13b0f8..3021f93 100644 --- a/packages/backend/app/routes/__init__.py +++ b/packages/backend/app/routes/__init__.py @@ -7,6 +7,7 @@ from .categories import bp as categories_bp from .docs import bp as docs_bp from .dashboard import bp as dashboard_bp +from .household import household_bp def register_routes(app: Flask): @@ -18,3 +19,4 @@ def register_routes(app: Flask): app.register_blueprint(categories_bp, url_prefix="/categories") app.register_blueprint(docs_bp, url_prefix="/docs") app.register_blueprint(dashboard_bp, url_prefix="/dashboard") + app.register_blueprint(household_bp, url_prefix="/household") diff --git a/packages/backend/app/routes/household.py b/packages/backend/app/routes/household.py new file mode 100644 index 0000000..602f30f --- /dev/null +++ b/packages/backend/app/routes/household.py @@ -0,0 +1,186 @@ +from flask import Blueprint, jsonify, request +from flask_login import login_required, current_user +from ..extensions import db +from ..models import Household, HouseholdMember, User + +household_bp = Blueprint("household", __name__) + + +@household_bp.route("/households", methods=["GET"]) +@login_required +def get_households(): + """Get all households the current user is a member of.""" + memberships = HouseholdMember.query.filter_by(user_id=current_user.id).all() + households = [] + for m in memberships: + h = m.household + households.append({ + "id": h.id, + "name": h.name, + "role": m.role, + "joined_at": h.joined_at.isoformat() if h.joined_at else None, + "members_count": len(h.members) + }) + return jsonify(households) + + +@household_bp.route("/households", methods=["POST"]) +@login_required +def create_household(): + """Create a new household.""" + data = request.get_json() + name = data.get("name") + + if not name: + return jsonify({"error": "Household name is required"}), 400 + + household = Household(name=name, created_by=current_user.id) + db.session.add(household) + db.session.flush() + + # Add creator as owner + member = HouseholdMember( + household_id=household.id, + user_id=current_user.id, + role="owner" + ) + db.session.add(member) + db.session.commit() + + return jsonify({ + "id": household.id, + "name": household.name, + "role": "owner", + "created_at": household.created_at.isoformat() + }), 201 + + +@household_bp.route("/households/", methods=["GET"]) +@login_required +def get_household(household_id): + """Get household details.""" + member = HouseholdMember.query.filter_by( + household_id=household_id, + user_id=current_user.id + ).first() + + if not member: + return jsonify({"error": "Not a member of this household"}), 403 + + household = member.household + members = [] + for m in household.members: + members.append({ + "id": m.id, + "user_id": m.user_id, + "email": m.user.email if m.user else None, + "role": m.role, + "joined_at": m.joined_at.isoformat() if m.joined_at else None + }) + + return jsonify({ + "id": household.id, + "name": household.name, + "role": member.role, + "created_by": household.created_by, + "members": members + }) + + +@household_bp.route("/households//join", methods=["POST"]) +@login_required +def join_household(household_id): + """Join a household by invite code or email.""" + data = request.get_json() + email = data.get("email") + + # For now, any user can join if they know the household ID + # In production, this would use invite codes + member = HouseholdMember.query.filter_by( + household_id=household_id, + user_id=current_user.id + ).first() + + if member: + return jsonify({"error": "Already a member"}), 400 + + household = Household.query.get(household_id) + if not household: + return jsonify({"error": "Household not found"}), 404 + + new_member = HouseholdMember( + household_id=household_id, + user_id=current_user.id, + role="member" + ) + db.session.add(new_member) + db.session.commit() + + return jsonify({ + "id": household.id, + "name": household.name, + "role": "member" + }), 201 + + +@household_bp.route("/households//members", methods=["POST"]) +@login_required +def add_member(household_id): + """Add a member to household (owner only).""" + member = HouseholdMember.query.filter_by( + household_id=household_id, + user_id=current_user.id + ).first() + + if not member or member.role != "owner": + return jsonify({"error": "Only owners can add members"}), 403 + + data = request.get_json() + email = data.get("email") + + user = User.query.filter_by(email=email).first() + if not user: + return jsonify({"error": "User not found"}), 404 + + existing = HouseholdMember.query.filter_by( + household_id=household_id, + user_id=user.id + ).first() + + if existing: + return jsonify({"error": "User already a member"}), 400 + + new_member = HouseholdMember( + household_id=household_id, + user_id=user.id, + role="member" + ) + db.session.add(new_member) + db.session.commit() + + return jsonify({ + "user_id": user.id, + "email": user.email, + "role": "member" + }), 201 + + +@household_bp.route("/households//leave", methods=["POST"]) +@login_required +def leave_household(household_id): + """Leave a household.""" + member = HouseholdMember.query.filter_by( + household_id=household_id, + user_id=current_user.id + ).first() + + if not member: + return jsonify({"error": "Not a member"}), 404 + + if member.role == "owner" and len(member.household.members) > 1: + return jsonify({"error": "Owner must transfer ownership before leaving"}), 400 + + db.session.delete(member) + db.session.commit() + + return jsonify({"message": "Left household successfully"}) From 6c76c8251e51ded9f3077a53dee3592d8cee3f56 Mon Sep 17 00:00:00 2001 From: Atlas Date: Thu, 5 Mar 2026 17:07:03 +0000 Subject: [PATCH 2/3] feat: Add multi-account financial overview - Add FinancialAccount model - Add accounts API routes (CRUD + summary) - Add frontend Accounts page - Add navigation link Bounty: 0 (Multi-account dashboard) --- app/src/App.tsx | 9 + app/src/components/layout/Navbar.tsx | 1 + app/src/pages/Accounts.tsx | 273 ++++++++++++++++++++++++ packages/backend/app/models.py | 17 ++ packages/backend/app/routes/__init__.py | 2 + packages/backend/app/routes/accounts.py | 154 +++++++++++++ 6 files changed, 456 insertions(+) create mode 100644 app/src/pages/Accounts.tsx create mode 100644 packages/backend/app/routes/accounts.py diff --git a/app/src/App.tsx b/app/src/App.tsx index 527f60b..933e0a1 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -17,6 +17,7 @@ import { Landing } from "./pages/Landing"; import ProtectedRoute from "./components/auth/ProtectedRoute"; import Account from "./pages/Account"; import Households from "./pages/Households"; +import Accounts from "./pages/Accounts"; const queryClient = new QueryClient({ defaultOptions: { @@ -100,6 +101,14 @@ const App = () => ( } /> + + + + } + /> } /> } /> diff --git a/app/src/components/layout/Navbar.tsx b/app/src/components/layout/Navbar.tsx index b008f02..b3d7b55 100644 --- a/app/src/components/layout/Navbar.tsx +++ b/app/src/components/layout/Navbar.tsx @@ -10,6 +10,7 @@ const navigation = [ { name: 'Dashboard', href: '/dashboard' }, { name: 'Budgets', href: '/budgets' }, { name: 'Bills', href: '/bills' }, + { name: 'Accounts', href: '/accounts' }, { name: 'Reminders', href: '/reminders' }, { name: 'Expenses', href: '/expenses' }, { name: 'Households', href: '/households' }, diff --git a/app/src/pages/Accounts.tsx b/app/src/pages/Accounts.tsx new file mode 100644 index 0000000..b2b6725 --- /dev/null +++ b/app/src/pages/Accounts.tsx @@ -0,0 +1,273 @@ +import { useState, useEffect } from 'react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogFooter } from '@/components/ui/dialog'; +import { Wallet, CreditCard, PiggyBank, TrendingUp, Plus, Trash2, DollarSign } from 'lucide-react'; + +interface Account { + id: number; + name: string; + account_type: string; + balance: number; + currency: string; + color: string; + icon?: string; +} + +const accountTypes = [ + { value: 'bank', label: 'Bank Account', icon: Wallet }, + { value: 'credit_card', label: 'Credit Card', icon: CreditCard }, + { value: 'cash', label: 'Cash', icon: DollarSign }, + { value: 'investment', label: 'Investment', icon: TrendingUp }, + { value: 'savings', label: 'Savings', icon: PiggyBank }, +]; + +const colorOptions = ['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899']; + +export default function Accounts() { + const [accounts, setAccounts] = useState([]); + const [isOpen, setIsOpen] = useState(false); + const [editingAccount, setEditingAccount] = useState(null); + const [formData, setFormData] = useState({ + name: '', + account_type: 'bank', + balance: 0, + currency: 'USD', + color: '#3B82F6' + }); + const [loading, setLoading] = useState(true); + + const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:5000'; + + const fetchAccounts = async () => { + try { + const token = localStorage.getItem('token'); + const res = await fetch(`${API_BASE}/accounts/summary`, { + headers: { 'Authorization': `Bearer ${token}` } + }); + if (res.ok) { + const data = await res.json(); + setAccounts(data.accounts || []); + } + } catch (error) { + console.error('Failed to fetch accounts:', error); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchAccounts(); + }, []); + + const handleSubmit = async () => { + try { + const token = localStorage.getItem('token'); + const url = editingAccount + ? `${API_BASE}/accounts/${editingAccount.id}` + : `${API_BASE}/accounts`; + const method = editingAccount ? 'PUT' : 'POST'; + + const res = await fetch(url, { + method, + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(formData) + }); + + if (res.ok) { + setIsOpen(false); + setFormData({ name: '', account_type: 'bank', balance: 0, currency: 'USD', color: '#3B82F6' }); + setEditingAccount(null); + fetchAccounts(); + } + } catch (error) { + console.error('Failed to save account:', error); + } + }; + + const handleDelete = async (id: number) => { + try { + const token = localStorage.getItem('token'); + const res = await fetch(`${API_BASE}/accounts/${id}`, { + method: 'DELETE', + headers: { 'Authorization': `Bearer ${token}` } + }); + if (res.ok) { + fetchAccounts(); + } + } catch (error) { + console.error('Failed to delete account:', error); + } + }; + + const openEdit = (account: Account) => { + setEditingAccount(account); + setFormData({ + name: account.name, + account_type: account.account_type, + balance: account.balance, + currency: account.currency, + color: account.color + }); + setIsOpen(true); + }; + + const totalByCurrency = accounts.reduce((acc, a) => { + acc[a.currency] = (acc[a.currency] || 0) + a.balance; + return acc; + }, {} as Record); + + if (loading) { + return
Loading...
; + } + + return ( +
+
+
+ +

Accounts

+
+ +
+ + {/* Summary Cards */} +
+ {Object.entries(totalByCurrency).map(([currency, total]) => ( + + + Total {currency} + {currency} {total.toLocaleString()} + + + ))} +
+ + {accounts.length === 0 ? ( + + + +

No accounts yet. Add your first account!

+
+
+ ) : ( +
+ {accounts.map((account) => { + const typeInfo = accountTypes.find(t => t.value === account.account_type); + const Icon = typeInfo?.icon || Wallet; + + return ( + + +
+
+ +
+
+ {account.name} + {typeInfo?.label || account.account_type} +
+
+
+ + {account.currency} {account.balance.toLocaleString()} + + + +
+
+
+ ); + })} +
+ )} + + + + + {editingAccount ? 'Edit Account' : 'Add New Account'} + +
+
+ + setFormData({...formData, name: e.target.value})} + placeholder="e.g., Chase Bank" + className="mt-1" + /> +
+
+ + +
+
+
+ + setFormData({...formData, balance: parseFloat(e.target.value) || 0})} + className="mt-1" + /> +
+
+ + +
+
+
+ +
+ {colorOptions.map(color => ( +
+
+
+ + + + +
+
+
+ ); +} diff --git a/packages/backend/app/models.py b/packages/backend/app/models.py index 055f692..45aed6c 100644 --- a/packages/backend/app/models.py +++ b/packages/backend/app/models.py @@ -155,3 +155,20 @@ class HouseholdMember(db.Model): joined_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) # Relationship user = db.relationship("User", backref="household_memberships") + + +# Financial Account Models +class FinancialAccount(db.Model): + __tablename__ = "financial_accounts" + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) + name = db.Column(db.String(100), nullable=False) + account_type = db.Column(db.String(50), nullable=False) # bank, credit_card, cash, investment, etc. + balance = db.Column(db.Numeric(12, 2), default=0, nullable=False) + currency = db.Column(db.String(10), default="USD", nullable=False) + color = db.Column(db.String(20), default="#3B82F6", nullable=False) + icon = db.Column(db.String(50), default="wallet", nullable=False) + is_active = db.Column(db.Boolean, default=True, nullable=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + # Relationship + user = db.relationship("User", backref="financial_accounts") diff --git a/packages/backend/app/routes/__init__.py b/packages/backend/app/routes/__init__.py index 3021f93..a736b80 100644 --- a/packages/backend/app/routes/__init__.py +++ b/packages/backend/app/routes/__init__.py @@ -8,6 +8,7 @@ from .docs import bp as docs_bp from .dashboard import bp as dashboard_bp from .household import household_bp +from .accounts import accounts_bp def register_routes(app: Flask): @@ -20,3 +21,4 @@ def register_routes(app: Flask): app.register_blueprint(docs_bp, url_prefix="/docs") app.register_blueprint(dashboard_bp, url_prefix="/dashboard") app.register_blueprint(household_bp, url_prefix="/household") + app.register_blueprint(accounts_bp, url_prefix="/accounts") diff --git a/packages/backend/app/routes/accounts.py b/packages/backend/app/routes/accounts.py new file mode 100644 index 0000000..3479e85 --- /dev/null +++ b/packages/backend/app/routes/accounts.py @@ -0,0 +1,154 @@ +from flask import Blueprint, jsonify, request +from flask_login import login_required, current_user +from ..extensions import db +from ..models import FinancialAccount + +accounts_bp = Blueprint("accounts", __name__) + + +@accounts_bp.route("/accounts", methods=["GET"]) +@login_required +def get_accounts(): + """Get all financial accounts for current user.""" + accounts = FinancialAccount.query.filter_by(user_id=current_user.id, is_active=True).all() + return jsonify([{ + "id": a.id, + "name": a.name, + "account_type": a.account_type, + "balance": float(a.balance), + "currency": a.currency, + "color": a.color, + "icon": a.icon, + "created_at": a.created_at.isoformat() if a.created_at else None + } for a in accounts]) + + +@accounts_bp.route("/accounts", methods=["POST"]) +@login_required +def create_account(): + """Create a new financial account.""" + data = request.get_json() + name = data.get("name") + account_type = data.get("account_type", "bank") + balance = data.get("balance", 0) + currency = data.get("currency", "USD") + color = data.get("color", "#3B82F6") + icon = data.get("icon", "wallet") + + if not name: + return jsonify({"error": "Account name is required"}), 400 + + account = FinancialAccount( + user_id=current_user.id, + name=name, + account_type=account_type, + balance=balance, + currency=currency, + color=color, + icon=icon + ) + db.session.add(account) + db.session.commit() + + return jsonify({ + "id": account.id, + "name": account.name, + "account_type": account.account_type, + "balance": float(account.balance), + "currency": account.currency, + "color": account.color, + "icon": account.icon + }), 201 + + +@accounts_bp.route("/accounts/", methods=["GET"]) +@login_required +def get_account(account_id): + """Get a specific account.""" + account = FinancialAccount.query.filter_by(id=account_id, user_id=current_user.id).first() + if not account: + return jsonify({"error": "Account not found"}), 404 + + return jsonify({ + "id": account.id, + "name": account.name, + "account_type": account.account_type, + "balance": float(account.balance), + "currency": account.currency, + "color": account.color, + "icon": account.icon, + "is_active": account.is_active + }) + + +@accounts_bp.route("/accounts/", methods=["PUT"]) +@login_required +def update_account(account_id): + """Update an account.""" + account = FinancialAccount.query.filter_by(id=account_id, user_id=current_user.id).first() + if not account: + return jsonify({"error": "Account not found"}), 404 + + data = request.get_json() + if "name" in data: + account.name = data["name"] + if "account_type" in data: + account.account_type = data["account_type"] + if "balance" in data: + account.balance = data["balance"] + if "currency" in data: + account.currency = data["currency"] + if "color" in data: + account.color = data["color"] + if "icon" in data: + account.icon = data["icon"] + + db.session.commit() + + return jsonify({ + "id": account.id, + "name": account.name, + "account_type": account.account_type, + "balance": float(account.balance), + "currency": account.currency + }) + + +@accounts_bp.route("/accounts/", methods=["DELETE"]) +@login_required +def delete_account(account_id): + """Soft delete an account.""" + account = FinancialAccount.query.filter_by(id=account_id, user_id=current_user.id).first() + if not account: + return jsonify({"error": "Account not found"}), 404 + + account.is_active = False + db.session.commit() + + return jsonify({"message": "Account deleted"}) + + +@accounts_bp.route("/accounts/summary", methods=["GET"]) +@login_required +def get_accounts_summary(): + """Get total balance across all accounts.""" + accounts = FinancialAccount.query.filter_by(user_id=current_user.id, is_active=True).all() + + total_by_currency = {} + for a in accounts: + if a.currency not in total_by_currency: + total_by_currency[a.currency] = 0 + total_by_currency[a.currency] += float(a.balance) + + return jsonify({ + "accounts": [{ + "id": a.id, + "name": a.name, + "account_type": a.account_type, + "balance": float(a.balance), + "currency": a.currency, + "color": a.color + } for a in accounts], + "total": total_by_currency, + "total_currencies": len(total_by_currency) + }) From 6a98bda6405a3a6590f34c60bcc18baa0b67531e Mon Sep 17 00:00:00 2001 From: Atlas Date: Tue, 10 Mar 2026 18:04:54 +0000 Subject: [PATCH 3/3] docs: Add Beacon Atlas and BoTTube to Related Tools For bounty #1579 - Mention Elyan Labs projects in existing repo README --- README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.md b/README.md index 49592bf..e59ebdd 100644 --- a/README.md +++ b/README.md @@ -192,4 +192,11 @@ finmind/ --- +## Related Tools + +- **[Beacon Atlas](https://rustchain.org/beacon/)** — A 3D agent world where AI agents can build, collaborate, and interact in real-time. Part of the RustChain ecosystem powering the agent economy. +- **[BoTTube](https://bottube.ai)** — Video platform for AI agents, part of the Elyan Labs ecosystem. + +--- + MIT Licensed. Built with ❤️.