Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file added [frontend-builder
Empty file.
5 changes: 5 additions & 0 deletions backend/src/db/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
94 changes: 83 additions & 11 deletions backend/src/handlers/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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")
),
Expand All @@ -168,17 +169,16 @@ pub async fn logout(jar: CookieJar) -> impl IntoResponse {
pub async fn me(
State(pool): State<SqlitePool>,
axum::Extension(claims): axum::Extension<Claims>,
) -> Result<Json<AuthResponse>, 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<Json<crate::models::User>, 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(
Expand Down Expand Up @@ -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<SqlitePool>,
axum::Extension(claims): axum::Extension<Claims>,
Json(payload): Json<CurrencyRequest>,
) -> Result<Json<crate::models::User>, 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(&currency.as_str()) {
return Err(PaymeError::BadRequest(format!("Unsupported currency: {}", currency)));
}

sqlx::query("UPDATE users SET currency = ? WHERE id = ?")
.bind(&currency)
.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<SqlitePool>,
axum::Extension(claims): axum::Extension<Claims>,
) -> Result<Json<crate::models::User>, 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))
}
2 changes: 2 additions & 0 deletions backend/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
10 changes: 10 additions & 0 deletions backend/src/models/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions backend/tests/common/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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'))
)
"#,
Expand Down
19 changes: 17 additions & 2 deletions frontend/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,9 @@ export const api = {
body: JSON.stringify({ username, password }),
}),
logout: () => request<void>("/auth/logout", { method: "POST" }),
me: () => request<{ id: number; username: string }>("/auth/me"),
me: () => request<User>("/auth/me"),
changeUsername: (newUsername: string) =>
request<{ id: number; username: string }>("/auth/change-username", {
request<User>("/auth/change-username", {
method: "PUT",
body: JSON.stringify({ new_username: newUsername }),
}),
Expand All @@ -48,6 +48,12 @@ export const api = {
method: "PUT",
body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }),
}),
getCurrency: () => request<User>("/auth/currency"),
updateCurrency: (currency: string) =>
request<User>("/auth/currency", {
method: "PUT",
body: JSON.stringify({ currency }),
}),
clearAllData: (password: string) =>
request<{ message: string }>("/auth/clear-data", {
method: "DELETE",
Expand Down Expand Up @@ -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;
Expand Down
8 changes: 6 additions & 2 deletions frontend/src/components/BudgetSection.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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<number | null>(null);
Expand Down Expand Up @@ -131,7 +135,7 @@ export function BudgetSection({
</span>
<div className="flex items-center gap-2">
<span className="text-xs text-charcoal-500 dark:text-charcoal-400">
${budget.spent_amount.toFixed(2)} / ${budget.allocated_amount.toFixed(2)}
{formatCurrency(budget.spent_amount, currency)} / {formatCurrency(budget.allocated_amount, currency)}
</span>
{!isReadOnly && (
<button
Expand Down Expand Up @@ -198,7 +202,7 @@ export function BudgetSection({
<span className="text-sm">{cat.label}</span>
<div className="flex items-center gap-2">
<span className="text-xs text-charcoal-500">
${cat.default_amount.toFixed(2)}
{formatCurrency(cat.default_amount, currency)}
</span>
<button
onClick={() => startEditCategory(cat)}
Expand Down
10 changes: 7 additions & 3 deletions frontend/src/components/FixedExpenses.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { useState } from "react";
import { Plus, Trash2, Edit2, Check, X, Settings } from "lucide-react";
import { FixedExpense, 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";
Expand All @@ -12,6 +14,8 @@ interface FixedExpensesProps {
}

export function FixedExpenses({ expenses, onUpdate }: FixedExpensesProps) {
const { user } = useAuth();
const currency = user?.currency || "USD";
const [isManaging, setIsManaging] = useState(false);
const [isAdding, setIsAdding] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
Expand Down Expand Up @@ -81,7 +85,7 @@ export function FixedExpenses({ expenses, onUpdate }: FixedExpensesProps) {
{expense.label}
</span>
<span className="text-sm text-charcoal-600 dark:text-charcoal-400">
${expense.amount.toFixed(2)}
{formatCurrency(expense.amount, currency)}
</span>
</div>
))}
Expand All @@ -98,7 +102,7 @@ export function FixedExpenses({ expenses, onUpdate }: FixedExpensesProps) {
Total
</span>
<span className="text-sm font-semibold text-charcoal-800 dark:text-sand-100">
${total.toFixed(2)}
{formatCurrency(total, currency)}
</span>
</div>
)}
Expand Down Expand Up @@ -142,7 +146,7 @@ export function FixedExpenses({ expenses, onUpdate }: FixedExpensesProps) {
<div className="flex items-center justify-between py-2 border-b border-sand-200 dark:border-charcoal-800">
<span className="text-sm">{expense.label}</span>
<div className="flex items-center gap-2">
<span className="text-sm">${expense.amount.toFixed(2)}</span>
<span className="text-sm">{formatCurrency(expense.amount, currency)}</span>
<button
onClick={() => startEdit(expense)}
className="p-1 hover:bg-sand-200 dark:hover:bg-charcoal-800"
Expand Down
6 changes: 5 additions & 1 deletion frontend/src/components/IncomeSection.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { useState } from "react";
import { Plus, Trash2, Edit2, Check, X } from "lucide-react";
import { IncomeEntry, 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";
Expand All @@ -13,6 +15,8 @@ interface IncomeSectionProps {
}

export function IncomeSection({ monthId, entries, isReadOnly, onUpdate }: IncomeSectionProps) {
const { user } = useAuth();
const currency = user?.currency || "USD";
const [isAdding, setIsAdding] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [label, setLabel] = useState("");
Expand Down Expand Up @@ -110,7 +114,7 @@ export function IncomeSection({ monthId, entries, isReadOnly, onUpdate }: Income
</span>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-sage-600 dark:text-sage-400">
${entry.amount.toFixed(2)}
{formatCurrency(entry.amount, currency)}
</span>
{!isReadOnly && (
<>
Expand Down
6 changes: 5 additions & 1 deletion frontend/src/components/ItemsSection.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { useState } from "react";
import { Plus, Trash2, Edit2, Check, X } from "lucide-react";
import { ItemWithCategory, 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 { Select } from "./ui/Select";
Expand All @@ -21,6 +23,8 @@ export function ItemsSection({
isReadOnly,
onUpdate,
}: ItemsSectionProps) {
const { user } = useAuth();
const currency = user?.currency || "USD";
const [isAdding, setIsAdding] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [description, setDescription] = useState("");
Expand Down Expand Up @@ -243,7 +247,7 @@ export function ItemsSection({
</span>
</td>
<td className="py-2 text-right font-medium text-terracotta-600 dark:text-terracotta-400">
${item.amount.toFixed(2)}
{formatCurrency(item.amount, currency)}
</td>
{!isReadOnly && (
<td className="py-2">
Expand Down
6 changes: 5 additions & 1 deletion frontend/src/components/ProjectedSavingsCard.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { TrendingUp, HelpCircle } from "lucide-react";
import { Card } from "./ui/Card";
import { useAuth } from "../context/AuthContext";
import { formatCurrency } from "../utils/currencyFormatter";

interface ProjectedSavingsCardProps {
savings: number;
Expand All @@ -8,6 +10,8 @@ interface ProjectedSavingsCardProps {
}

export function ProjectedSavingsCard({ savings, remaining, onAnalyzeClick }: ProjectedSavingsCardProps) {
const { user } = useAuth();
const currency = user?.currency || "USD";
const projected = savings + remaining;

return (
Expand All @@ -20,7 +24,7 @@ export function ProjectedSavingsCard({ savings, remaining, onAnalyzeClick }: Pro
</div>
<div className="flex items-center justify-between">
<span className="text-sm font-semibold text-sage-700 dark:text-sage-400">
${projected.toFixed(2)}
{formatCurrency(projected, currency)}
</span>
{onAnalyzeClick && (
<button
Expand Down
6 changes: 5 additions & 1 deletion frontend/src/components/RetirementSavingsCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,12 @@ import { TrendingUp, Pencil, Check, X } from "lucide-react";
import { api } from "../api/client";
import { Card } from "./ui/Card";
import { Input } from "./ui/Input";
import { useAuth } from "../context/AuthContext";
import { formatCurrency } from "../utils/currencyFormatter";

export function RetirementSavingsCard() {
const { user } = useAuth();
const currency = user?.currency || "USD";
const [amount, setAmount] = useState<number>(0);
const [isEditing, setIsEditing] = useState(false);
const [editValue, setEditValue] = useState("");
Expand Down Expand Up @@ -63,7 +67,7 @@ export function RetirementSavingsCard() {
) : (
<div className="flex items-center gap-2">
<span className="text-xl font-semibold text-sage-600 dark:text-sage-400">
${amount.toFixed(2)}
{formatCurrency(amount, currency)}
</span>
<button
onClick={startEdit}
Expand Down
Loading