diff --git a/[frontend-builder b/[frontend-builder new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/db/mod.rs b/backend/src/db/mod.rs index f892d71..e7219a4 100644 --- a/backend/src/db/mod.rs +++ b/backend/src/db/mod.rs @@ -39,6 +39,11 @@ pub async fn run_migrations(pool: &SqlitePool) -> Result<(), sqlx::Error> { .await .ok(); + sqlx::query("ALTER TABLE users ADD COLUMN currency TEXT NOT NULL DEFAULT 'USD'") + .execute(pool) + .await + .ok(); + sqlx::query("UPDATE users SET retirement_savings = roth_ira WHERE retirement_savings = 0 AND roth_ira IS NOT NULL AND roth_ira > 0") .execute(pool) .await diff --git a/backend/src/handlers/auth.rs b/backend/src/handlers/auth.rs index a624013..ed1767e 100644 --- a/backend/src/handlers/auth.rs +++ b/backend/src/handlers/auth.rs @@ -14,6 +14,7 @@ use validator::Validate; use crate::error::PaymeError; use crate::middleware::auth::Claims; +use crate::models::User; #[derive(Deserialize, ToSchema, Validate)] pub struct AuthRequest { @@ -157,7 +158,7 @@ pub async fn logout(jar: CookieJar) -> impl IntoResponse { get, path = "/api/auth/me", responses( - (status = 200, description = "Current user retrieved", body = AuthResponse), + (status = 200, description = "Current user retrieved", body = crate::models::User), (status = 404, description = "User not found"), (status = 500, description = "Internal server error") ), @@ -168,17 +169,16 @@ pub async fn logout(jar: CookieJar) -> impl IntoResponse { pub async fn me( State(pool): State, axum::Extension(claims): axum::Extension, -) -> Result, PaymeError> { - let user: (i64, String) = sqlx::query_as("SELECT id, username FROM users WHERE id = ?") - .bind(claims.sub) - .fetch_optional(&pool) - .await? - .ok_or(PaymeError::NotFound)?; +) -> Result, PaymeError> { + let user = sqlx::query_as::<_, crate::models::User>( + "SELECT id, username, savings, savings_goal, retirement_savings, currency FROM users WHERE id = ?" + ) + .bind(claims.sub) + .fetch_optional(&pool) + .await? + .ok_or(PaymeError::NotFound)?; - Ok(Json(AuthResponse { - id: user.0, - username: user.1, - })) + Ok(Json(user)) } pub async fn export_db( @@ -359,3 +359,75 @@ pub async fn clear_all_data( Json(serde_json::json!({"message": "All data cleared"})), )) } + +#[derive(Deserialize, ToSchema)] +pub struct CurrencyRequest { + pub currency: String, +} + +#[utoipa::path( + put, + path = "/api/auth/currency", + request_body = CurrencyRequest, + responses( + (status = 200, description = "Currency updated successfully", body = crate::models::User), + (status = 400, description = "Invalid currency"), + (status = 500, description = "Internal server error") + ), + tag = "Auth", + summary = "Update user currency", + description = "Updates the authenticated user's preferred currency." +)] +pub async fn update_currency( + State(pool): State, + axum::Extension(claims): axum::Extension, + Json(payload): Json, +) -> Result, PaymeError> { + let currency = payload.currency.to_uppercase(); + + // Validate currency code (basic validation for common currencies) + let valid_currencies = vec!["USD", "EUR", "GBP", "JPY", "CAD", "AUD", "CHF", "CNY", "INR", "MXN", "BRL", "ZAR"]; + if !valid_currencies.contains(¤cy.as_str()) { + return Err(PaymeError::BadRequest(format!("Unsupported currency: {}", currency))); + } + + sqlx::query("UPDATE users SET currency = ? WHERE id = ?") + .bind(¤cy) + .bind(claims.sub) + .execute(&pool) + .await?; + + let user = sqlx::query_as::<_, crate::models::User>( + "SELECT id, username, savings, savings_goal, retirement_savings, currency FROM users WHERE id = ?" + ) + .bind(claims.sub) + .fetch_one(&pool) + .await?; + + Ok(Json(user)) +} + +#[utoipa::path( + get, + path = "/api/auth/currency", + responses( + (status = 200, description = "User currency retrieved", body = crate::models::User), + (status = 500, description = "Internal server error") + ), + tag = "Auth", + summary = "Get user currency", + description = "Retrieves the authenticated user's preferred currency." +)] +pub async fn get_currency( + State(pool): State, + axum::Extension(claims): axum::Extension, +) -> Result, PaymeError> { + let user = sqlx::query_as::<_, crate::models::User>( + "SELECT id, username, savings, savings_goal, retirement_savings, currency FROM users WHERE id = ?" + ) + .bind(claims.sub) + .fetch_one(&pool) + .await?; + + Ok(Json(user)) +} diff --git a/backend/src/lib.rs b/backend/src/lib.rs index f08cab3..44feaa9 100644 --- a/backend/src/lib.rs +++ b/backend/src/lib.rs @@ -32,6 +32,8 @@ pub fn create_app(pool: SqlitePool) -> Router { .route("/api/auth/me", get(auth::me)) .route("/api/auth/change-username", put(auth::change_username)) .route("/api/auth/change-password", put(auth::change_password)) + .route("/api/auth/currency", get(auth::get_currency)) + .route("/api/auth/currency", put(auth::update_currency)) .route("/api/auth/clear-data", delete(auth::clear_all_data)) .route("/api/export", get(auth::export_db)) .route("/api/months", get(months::list_months)) diff --git a/backend/src/models/mod.rs b/backend/src/models/mod.rs index 92cae55..a69f944 100644 --- a/backend/src/models/mod.rs +++ b/backend/src/models/mod.rs @@ -2,6 +2,16 @@ use chrono::{DateTime, NaiveDate, Utc}; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow, ToSchema)] +pub struct User { + pub id: i64, + pub username: String, + pub savings: f64, + pub savings_goal: f64, + pub retirement_savings: f64, + pub currency: String, +} + #[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow, ToSchema)] pub struct FixedExpense { pub id: i64, diff --git a/backend/tests/common/mod.rs b/backend/tests/common/mod.rs index 1cc9ee1..0b57321 100644 --- a/backend/tests/common/mod.rs +++ b/backend/tests/common/mod.rs @@ -41,6 +41,7 @@ async fn run_migrations(pool: &SqlitePool) { savings REAL NOT NULL DEFAULT 0, savings_goal REAL NOT NULL DEFAULT 0, retirement_savings REAL NOT NULL DEFAULT 0, + currency TEXT NOT NULL DEFAULT 'USD', created_at TEXT NOT NULL DEFAULT (datetime('now')) ) "#, diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 01cc1cb..80a2003 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -37,9 +37,9 @@ export const api = { body: JSON.stringify({ username, password }), }), logout: () => request("/auth/logout", { method: "POST" }), - me: () => request<{ id: number; username: string }>("/auth/me"), + me: () => request("/auth/me"), changeUsername: (newUsername: string) => - request<{ id: number; username: string }>("/auth/change-username", { + request("/auth/change-username", { method: "PUT", body: JSON.stringify({ new_username: newUsername }), }), @@ -48,6 +48,12 @@ export const api = { method: "PUT", body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }), }), + getCurrency: () => request("/auth/currency"), + updateCurrency: (currency: string) => + request("/auth/currency", { + method: "PUT", + body: JSON.stringify({ currency }), + }), clearAllData: (password: string) => request<{ message: string }>("/auth/clear-data", { method: "DELETE", @@ -219,6 +225,15 @@ export interface UserExport { }[]; } +export interface User { + id: number; + username: string; + savings: number; + savings_goal: number; + retirement_savings: number; + currency: string; +} + export interface Month { id: number; user_id: number; diff --git a/frontend/src/components/BudgetSection.tsx b/frontend/src/components/BudgetSection.tsx index 7540659..5aeee54 100644 --- a/frontend/src/components/BudgetSection.tsx +++ b/frontend/src/components/BudgetSection.tsx @@ -1,6 +1,8 @@ import { useState } from "react"; import { Plus, Trash2, Edit2, Check, X, Settings } from "lucide-react"; import { MonthlyBudgetWithCategory, BudgetCategory, api } from "../api/client"; +import { useAuth } from "../context/AuthContext"; +import { formatCurrency } from "../utils/currencyFormatter"; import { Card } from "./ui/Card"; import { Input } from "./ui/Input"; import { Button } from "./ui/Button"; @@ -22,6 +24,8 @@ export function BudgetSection({ isReadOnly, onUpdate, }: BudgetSectionProps) { + const { user } = useAuth(); + const currency = user?.currency || "USD"; const [isManaging, setIsManaging] = useState(false); const [isAddingCategory, setIsAddingCategory] = useState(false); const [editingCategoryId, setEditingCategoryId] = useState(null); @@ -131,7 +135,7 @@ export function BudgetSection({
- ${budget.spent_amount.toFixed(2)} / ${budget.allocated_amount.toFixed(2)} + {formatCurrency(budget.spent_amount, currency)} / {formatCurrency(budget.allocated_amount, currency)} {!isReadOnly && (
))} @@ -98,7 +102,7 @@ export function FixedExpenses({ expenses, onUpdate }: FixedExpensesProps) { Total - ${total.toFixed(2)} + {formatCurrency(total, currency)} )} @@ -142,7 +146,7 @@ export function FixedExpenses({ expenses, onUpdate }: FixedExpensesProps) {
{expense.label}
- ${expense.amount.toFixed(2)} + {formatCurrency(expense.amount, currency)}
diff --git a/frontend/src/components/Stats.tsx b/frontend/src/components/Stats.tsx index c0f75e8..228403a 100644 --- a/frontend/src/components/Stats.tsx +++ b/frontend/src/components/Stats.tsx @@ -9,6 +9,8 @@ import { ResponsiveContainer, } from "recharts"; import { api, StatsResponse } from "../api/client"; +import { useAuth } from "../context/AuthContext"; +import { formatCurrency } from "../utils/currencyFormatter"; import { Modal } from "./ui/Modal"; import { Button } from "./ui/Button"; @@ -18,6 +20,8 @@ const MONTH_NAMES = [ ]; export function Stats() { + const { user } = useAuth(); + const currency = user?.currency || "USD"; const [isOpen, setIsOpen] = useState(false); const [stats, setStats] = useState(null); const [loading, setLoading] = useState(false); @@ -67,7 +71,7 @@ export function Stats() { Avg Monthly Spending
- ${stats.average_monthly_spending.toFixed(2)} + {formatCurrency(stats.average_monthly_spending, currency)}
@@ -75,7 +79,7 @@ export function Stats() { Avg Monthly Income
- ${stats.average_monthly_income.toFixed(2)} + {formatCurrency(stats.average_monthly_income, currency)}
@@ -143,7 +147,7 @@ export function Stats() {
- ${cat.current_month_spent.toFixed(2)} + {formatCurrency(cat.current_month_spent, currency)} {cat.change_amount !== 0 && (
)}
diff --git a/frontend/src/components/Summary.tsx b/frontend/src/components/Summary.tsx index 62b16ae..7b539ee 100644 --- a/frontend/src/components/Summary.tsx +++ b/frontend/src/components/Summary.tsx @@ -1,6 +1,8 @@ import { TrendingDown, Wallet, CreditCard, PiggyBank } from "lucide-react"; import { Card } from "./ui/Card"; import { ReactNode } from "react"; +import { useAuth } from "../context/AuthContext"; +import { formatCurrency } from "../utils/currencyFormatter"; interface SummaryProps { totalIncome: number; @@ -11,6 +13,8 @@ interface SummaryProps { } export function Summary({ totalIncome, totalFixed, totalSpent, remaining, extraCard }: SummaryProps) { + const { user } = useAuth(); + const currency = user?.currency || "USD"; const isPositive = remaining >= 0; const items = [ @@ -53,7 +57,7 @@ export function Summary({ totalIncome, totalFixed, totalSpent, remaining, extraC {item.label}
- ${Math.abs(item.value).toFixed(2)} + {formatCurrency(Math.abs(item.value), currency)} {item.label === "Remaining" && item.value < 0 && ( deficit )} diff --git a/frontend/src/context/AuthContext.tsx b/frontend/src/context/AuthContext.tsx index 9ca71a1..46b29c6 100644 --- a/frontend/src/context/AuthContext.tsx +++ b/frontend/src/context/AuthContext.tsx @@ -1,10 +1,5 @@ import { createContext, useContext, useEffect, useState, ReactNode } from "react"; -import { api } from "../api/client"; - -interface User { - id: number; - username: string; -} +import { api, User } from "../api/client"; interface AuthContextType { user: User | null; @@ -13,6 +8,7 @@ interface AuthContextType { register: (username: string, password: string) => Promise; logout: () => Promise; updateUsername: (username: string) => void; + updateCurrency: (currency: string) => Promise; } const AuthContext = createContext(undefined); @@ -30,8 +26,10 @@ export function AuthProvider({ children }: { children: ReactNode }) { }, []); const login = async (username: string, password: string) => { - const user = await api.auth.login(username, password); - setUser(user); + await api.auth.login(username, password); + // After login, fetch full user data with currency + const fullUser = await api.auth.me(); + setUser(fullUser); }; const register = async (username: string, password: string) => { @@ -53,8 +51,13 @@ export function AuthProvider({ children }: { children: ReactNode }) { } }; + const updateCurrency = async (currency: string) => { + const updatedUser = await api.auth.updateCurrency(currency); + setUser(updatedUser); + }; + return ( - + {children} ); diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 2919f6a..9d6da11 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { Layout } from "../components/Layout"; import { Button } from "../components/ui/Button"; import { Input } from "../components/ui/Input"; +import { Select } from "../components/ui/Select"; import { Modal } from "../components/ui/Modal"; import { useAuth } from "../context/AuthContext"; import { api } from "../api/client"; @@ -11,21 +12,40 @@ interface SettingsProps { onBack: () => void; } +const AVAILABLE_CURRENCIES = [ + { value: "USD", label: "US Dollar ($)" }, + { value: "EUR", label: "Euro (€)" }, + { value: "GBP", label: "British Pound (£)" }, + { value: "JPY", label: "Japanese Yen (¥)" }, + { value: "CAD", label: "Canadian Dollar (C$)" }, + { value: "AUD", label: "Australian Dollar (A$)" }, + { value: "CHF", label: "Swiss Franc (CHF)" }, + { value: "CNY", label: "Chinese Yuan (¥)" }, + { value: "INR", label: "Indian Rupee (₹)" }, + { value: "MXN", label: "Mexican Peso ($)" }, + { value: "BRL", label: "Brazilian Real (R$)" }, + { value: "ZAR", label: "South African Rand (R)" }, +]; + export function Settings({ onBack }: SettingsProps) { - const { user, logout, updateUsername } = useAuth(); + const { user, logout, updateUsername, updateCurrency } = useAuth(); const [newUsername, setNewUsername] = useState(user?.username || ""); + const [selectedCurrency, setSelectedCurrency] = useState(user?.currency || "USD"); const [currentPassword, setCurrentPassword] = useState(""); const [newPassword, setNewPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState(""); const [deletePassword, setDeletePassword] = useState(""); const [showDeleteModal, setShowDeleteModal] = useState(false); const [usernameLoading, setUsernameLoading] = useState(false); + const [currencyLoading, setCurrencyLoading] = useState(false); const [passwordLoading, setPasswordLoading] = useState(false); const [deleteLoading, setDeleteLoading] = useState(false); const [usernameError, setUsernameError] = useState(""); + const [currencyError, setCurrencyError] = useState(""); const [passwordError, setPasswordError] = useState(""); const [deleteError, setDeleteError] = useState(""); const [usernameSuccess, setUsernameSuccess] = useState(false); + const [currencySuccess, setCurrencySuccess] = useState(false); const [passwordSuccess, setPasswordSuccess] = useState(false); const handleChangeUsername = async (e: React.FormEvent) => { @@ -51,6 +71,23 @@ export function Settings({ onBack }: SettingsProps) { } }; + const handleChangeCurrency = async (e: React.FormEvent) => { + e.preventDefault(); + setCurrencyError(""); + setCurrencySuccess(false); + + setCurrencyLoading(true); + try { + await updateCurrency(selectedCurrency); + setCurrencySuccess(true); + setTimeout(() => setCurrencySuccess(false), 3000); + } catch { + setCurrencyError("Failed to update currency."); + } finally { + setCurrencyLoading(false); + } + }; + const handleChangePassword = async (e: React.FormEvent) => { e.preventDefault(); setPasswordError(""); @@ -140,6 +177,30 @@ export function Settings({ onBack }: SettingsProps) {
+
+

+ Currency +

+
+