Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 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
6 changes: 6 additions & 0 deletions backend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions backend/src/db/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,12 @@ 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
59 changes: 56 additions & 3 deletions backend/src/handlers/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ pub struct AuthRequest {
pub struct AuthResponse {
pub id: i64,
pub username: String,
pub currency: String,
}

#[utoipa::path(
Expand Down Expand Up @@ -65,6 +66,7 @@ pub async fn register(
Ok(Json(AuthResponse {
id: result,
username: payload.username,
currency: "USD".to_string(),
}))
}

Expand All @@ -87,8 +89,8 @@ pub async fn login(
Json(payload): Json<AuthRequest>,
) -> Result<impl IntoResponse, PaymeError> {
payload.validate()?;
let user: (i64, String, String) =
sqlx::query_as("SELECT id, username, password_hash FROM users WHERE username = ?")
let user: (i64, String, String, String) =
sqlx::query_as("SELECT id, username, password_hash, currency FROM users WHERE username = ?")
.bind(&payload.username)
.fetch_optional(&pool)
.await?
Expand Down Expand Up @@ -128,6 +130,7 @@ pub async fn login(
Json(AuthResponse {
id: user.0,
username: user.1,
currency: user.3,
}),
))
}
Expand Down Expand Up @@ -169,7 +172,7 @@ 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 = ?")
let user: (i64, String, String) = sqlx::query_as("SELECT id, username, currency FROM users WHERE id = ?")
.bind(claims.sub)
.fetch_optional(&pool)
.await?
Expand All @@ -178,6 +181,7 @@ pub async fn me(
Ok(Json(AuthResponse {
id: user.0,
username: user.1,
currency: user.2, // ADDED
}))
}

Expand Down Expand Up @@ -240,9 +244,16 @@ pub async fn change_username(
.execute(&pool)
.await?;

// ADDED: Fetch currency
let currency: (String,) = sqlx::query_as("SELECT currency FROM users WHERE id = ?")
.bind(claims.sub)
.fetch_one(&pool)
.await?;

Ok(Json(AuthResponse {
id: claims.sub,
username: payload.new_username,
currency: currency.0, // ADDED
}))
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The // ADDED comments don't seem to be needed? Same with line 184 above.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed on latest PR

}

Expand Down Expand Up @@ -359,3 +370,45 @@ pub async fn clear_all_data(
Json(serde_json::json!({"message": "All data cleared"})),
))
}
#[derive(Deserialize, ToSchema, Validate)]
pub struct ChangeCurrencyRequest {
#[validate(length(min = 3, max = 3))]
pub currency: String,
}

Comment on lines 379 to 384
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This allows any three-character string (e.g. "FOO", "BAR", "$$$"). The backend should not accept values outside the supported currency set enforced by your frontend changes

#[utoipa::path(
put,
path = "/api/auth/change-currency",
request_body = ChangeCurrencyRequest,
responses(
(status = 200, description = "Currency changed successfully", body = AuthResponse),
(status = 500, description = "Internal server error")
),
tag = "Auth",
summary = "Change currency preference",
description = "Updates the authenticated user's preferred display currency."
)]
pub async fn change_currency(
State(pool): State<SqlitePool>,
axum::Extension(claims): axum::Extension<Claims>,
Json(payload): Json<ChangeCurrencyRequest>,
) -> Result<Json<AuthResponse>, PaymeError> {
payload.validate()?;

sqlx::query("UPDATE users SET currency = ? WHERE id = ?")
.bind(&payload.currency)
.bind(claims.sub)
.execute(&pool)
.await?;

let user: (String,) = sqlx::query_as("SELECT username FROM users WHERE id = ?")
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know that this is necessary. We already do this?

const response = await api.auth.changeCurrency(...)
updateCurrency(response.currency)

username is ignored. I'm pretty sure we can just omit it.

.bind(claims.sub)
.fetch_one(&pool)
.await?;

Ok(Json(AuthResponse {
id: claims.sub,
username: user.0,
currency: payload.currency,
}))
}
1 change: 1 addition & 0 deletions backend/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ 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/change-currency", put(auth::change_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
4 changes: 3 additions & 1 deletion backend/src/openapi/mod.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use utoipa::OpenApi;

use crate::handlers::{
auth::{AuthRequest, AuthResponse},
auth::{AuthRequest, AuthResponse, ChangeCurrencyRequest},
budget::{CreateCategory, UpdateCategory, UpdateMonthlyBudget},
export::{
BudgetExport, CategoryExport, FixedExpenseExport, IncomeExport, ItemExport, MonthExport,
Expand All @@ -24,6 +24,7 @@ use crate::models::{
crate::handlers::auth::login,
crate::handlers::auth::logout,
crate::handlers::auth::me,
crate::handlers::auth::change_currency,
crate::handlers::export::export_json,
crate::handlers::export::import_json,
crate::handlers::budget::list_monthly_budgets,
Expand Down Expand Up @@ -58,6 +59,7 @@ use crate::models::{
components(schemas(
AuthRequest,
AuthResponse,
ChangeCurrencyRequest,
MonthlyBudget,
UpdateMonthlyBudget,
IncomeEntry,
Expand Down
16 changes: 15 additions & 1 deletion frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 9 additions & 4 deletions frontend/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,19 +27,19 @@ async function request<T>(
export const api = {
auth: {
register: (username: string, password: string) =>
request<{ id: number; username: string }>("/auth/register", {
request<{ id: number; username: string; currency: string }>("/auth/register", {
method: "POST",
body: JSON.stringify({ username, password }),
}),
login: (username: string, password: string) =>
request<{ id: number; username: string }>("/auth/login", {
request<{ id: number; username: string; currency: string }>("/auth/login", {
method: "POST",
body: JSON.stringify({ username, password }),
}),
logout: () => request<void>("/auth/logout", { method: "POST" }),
me: () => request<{ id: number; username: string }>("/auth/me"),
me: () => request<{ id: number; username: string; currency: string }>("/auth/me"),
changeUsername: (newUsername: string) =>
request<{ id: number; username: string }>("/auth/change-username", {
request<{ id: number; username: string; currency: string }>("/auth/change-username", {
method: "PUT",
body: JSON.stringify({ new_username: newUsername }),
}),
Expand All @@ -48,6 +48,11 @@ export const api = {
method: "PUT",
body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }),
}),
changeCurrency: (currency: string) =>
request<{ id: number; username: string; currency: string }>("/auth/change-currency", {
method: "PUT",
body: JSON.stringify({ currency }),
}),
clearAllData: (password: string) =>
request<{ message: string }>("/auth/clear-data", {
method: "DELETE",
Expand Down
6 changes: 4 additions & 2 deletions frontend/src/components/BudgetSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { Input } from "./ui/Input";
import { Button } from "./ui/Button";
import { ProgressBar } from "./ui/ProgressBar";
import { Modal } from "./ui/Modal";
import { useCurrency } from "../hooks/useCurrency";

interface BudgetSectionProps {
monthId: number;
Expand All @@ -23,6 +24,7 @@ export function BudgetSection({
onUpdate,
}: BudgetSectionProps) {
const [isManaging, setIsManaging] = useState(false);
const { format } = useCurrency();
const [isAddingCategory, setIsAddingCategory] = useState(false);
const [editingCategoryId, setEditingCategoryId] = useState<number | null>(null);
const [editingBudgetId, setEditingBudgetId] = useState<number | null>(null);
Expand Down Expand Up @@ -131,7 +133,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)}
{format(budget.spent_amount)} / {format(budget.allocated_amount)}
</span>
{!isReadOnly && (
<button
Expand Down Expand Up @@ -198,7 +200,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)}
{format(cat.default_amount)}
</span>
<button
onClick={() => startEditCategory(cat)}
Expand Down
8 changes: 5 additions & 3 deletions frontend/src/components/FixedExpenses.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Card } from "./ui/Card";
import { Input } from "./ui/Input";
import { Button } from "./ui/Button";
import { Modal } from "./ui/Modal";
import { useCurrency } from "../hooks/useCurrency";

interface FixedExpensesProps {
expenses: FixedExpense[];
Expand All @@ -17,6 +18,7 @@ export function FixedExpenses({ expenses, onUpdate }: FixedExpensesProps) {
const [editingId, setEditingId] = useState<number | null>(null);
const [label, setLabel] = useState("");
const [amount, setAmount] = useState("");
const { format } = useCurrency();

const handleAdd = async () => {
if (!label || !amount) return;
Expand Down Expand Up @@ -81,7 +83,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)}
{format(expense.amount)}
</span>
</div>
))}
Expand All @@ -98,7 +100,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)}
{format(total)}
</span>
</div>
)}
Expand Down Expand Up @@ -142,7 +144,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">{format(expense.amount)}</span>
<button
onClick={() => startEdit(expense)}
className="p-1 hover:bg-sand-200 dark:hover:bg-charcoal-800"
Expand Down
Loading
Loading