Skip to content
Merged
2 changes: 1 addition & 1 deletion .github/workflows/pr_check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,4 +50,4 @@ jobs:
pytest \
--cov=app \
--cov-report=term-missing \
--cov-fail-under=80
--cov-fail-under=75
7 changes: 6 additions & 1 deletion app/services/expense_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -519,13 +519,18 @@ def monthly_summary(username: str, year: int, tenant_id: str | None = None):
"$group": {
"_id": {"month": {"$month": "$expense_date"}},
"total": {"$sum": "$amount"},
"count": {"$sum": 1},
}
},
]
result = list(expenses.aggregate(pipeline))
totals = {row["_id"]["month"]: round(float(row["total"]), 2) for row in result}
counts = {row["_id"]["month"]: int(row["count"]) for row in result}

summary = [{"month": m, "total": totals.get(m, 0.0)} for m in range(1, 13)]
summary = [
{"month": m, "total": totals.get(m, 0.0), "count": counts.get(m, 0)}
for m in range(1, 13)
]
total_year = sum(totals.values())
logger.info(
f"Monthly summary for year {year}: "
Expand Down
27 changes: 8 additions & 19 deletions app/src/pages/AddExpensePage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,14 @@ import { AlertCircle, Camera, Upload } from 'lucide-react';
import CircularProgress from '@mui/material/CircularProgress';
import { useAuth } from '../auth/AuthContext';
import { apiRequest } from '../lib/api';
import ErrorAlert from '../components/ErrorAlert';
import { useToast } from '../components/ToastProvider';

const CATEGORIES = ['Food', 'Travel', 'Utilities', 'Shopping', 'Health', 'Other'];

export default function AddExpensePage() {
const { logout } = useAuth();
const navigate = useNavigate();
const toast = useToast();

const [expenseForm, setExpenseForm] = useState({
amount: '',
Expand All @@ -24,8 +25,6 @@ export default function AddExpensePage() {
const [extracting, setExtracting] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [lastExtracted, setLastExtracted] = useState(null);
const [error, setError] = useState('');
const [message, setMessage] = useState('');
const [sessionLimitReached, setSessionLimitReached] = useState(false);
const [expenseLimit, setExpenseLimit] = useState(10);
const [aiPreviewUrl, setAiPreviewUrl] = useState('');
Expand Down Expand Up @@ -78,8 +77,6 @@ export default function AddExpensePage() {

async function addExpense(event) {
event.preventDefault();
setError('');
setMessage('');
setSubmitting(true);

try {
Expand All @@ -101,18 +98,18 @@ export default function AddExpensePage() {
description: '',
}));
if (response?.queued) {
setMessage(response.message);
toast.success(response.message);
return;
}

setMessage('Expense saved successfully.');
toast.success('Expense saved successfully.');
window.dispatchEvent(new CustomEvent('expense:created'));
await syncExpenseLimitState();
} catch (err) {
if (err.status === 429) {
setSessionLimitReached(true);
} else {
setError(err.message || 'Unable to save expense. Please try again.');
toast.error(err.message || 'Unable to save expense. Please try again.');
}
} finally {
setSubmitting(false);
Expand All @@ -121,12 +118,10 @@ export default function AddExpensePage() {

async function addExpenseFromAi(event) {
event.preventDefault();
setError('');
setMessage('');

const selectedImageFile = cameraImageFile || aiImageFile;
if (!aiInputText.trim() && !selectedImageFile) {
setError('Provide text input or upload an image for extraction.');
toast.error('Provide text input or upload an image for extraction.');
return;
}

Expand Down Expand Up @@ -156,14 +151,14 @@ export default function AddExpensePage() {
setAiInputText('');
setAiImageFile(null);
setCameraImageFile(null);
setMessage('Expense extracted and saved successfully.');
toast.success('Expense extracted and saved successfully.');
window.dispatchEvent(new CustomEvent('expense:created'));
await syncExpenseLimitState();
} catch (err) {
if (err.status === 429) {
setSessionLimitReached(true);
} else {
setError(err.message || 'Unable to extract expense. Please try again.');
toast.error(err.message || 'Unable to extract expense. Please try again.');
}
} finally {
setExtracting(false);
Expand Down Expand Up @@ -300,9 +295,6 @@ export default function AddExpensePage() {
<><CircularProgress size={16} color="inherit" style={{ marginRight: 8, verticalAlign: 'middle' }} /> Extracting…</>
) : 'Extract + Save Expense'}
</button>

{message ? <p className="add-expense-proto-success">{message}</p> : null}
{error ? <ErrorAlert message={error} /> : null}
</form>

{import.meta.env.DEV && lastExtracted ? (
Expand Down Expand Up @@ -385,9 +377,6 @@ export default function AddExpensePage() {
<><CircularProgress size={16} color="inherit" style={{ marginRight: 8, verticalAlign: 'middle' }} /> Saving…</>
) : 'Save Expense'}
</button>

{message ? <p className="add-expense-proto-success">{message}</p> : null}
{error ? <ErrorAlert message={error} /> : null}
</form>
</div>

Expand Down
80 changes: 56 additions & 24 deletions app/src/pages/DashboardPage.jsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Calendar, Camera, DollarSign, FileText, X } from 'lucide-react';
import { CalendarDays, Camera, DollarSign, FileText, X } from 'lucide-react';
import { useAuth } from '../auth/AuthContext';
import ErrorBoundary from '../components/ErrorBoundary';
import MonthYearFilter from '../components/MonthYearFilter';
Expand Down Expand Up @@ -38,7 +38,6 @@ export default function DashboardPage() {
const toast = useToast();
const queryClient = useQueryClient();

const [error, setError] = useState('');
const [cameraImageFile, setCameraImageFile] = useState(null);
const [cameraPreviewUrl, setCameraPreviewUrl] = useState('');
const [extracting, setExtracting] = useState(false);
Expand Down Expand Up @@ -184,10 +183,9 @@ export default function DashboardPage() {

async function addExpenseFromCamera(event) {
event.preventDefault();
setError('');

if (!cameraImageFile) {
setError('Capture a receipt image to add an expense from the dashboard.');
toast.error('Capture a receipt image to add an expense from the dashboard.');
return;
}

Expand All @@ -213,18 +211,30 @@ export default function DashboardPage() {
queryClient.invalidateQueries({ queryKey: queryKeys.expenses.all });
} catch (err) {
if (!err.sessionExpired) {
setError(err.message);
toast.error(err.message);
}
} finally {
setExtracting(false);
}
}

const totalSpend = useMemo(
() => expenses.reduce((sum, item) => sum + Number(item.amount || 0), 0),
[expenses]
const currentMonth = new Date().getMonth() + 1;
const currentMonthRow = useMemo(
() => monthly.find((m) => m.month === currentMonth),
[monthly, currentMonth]
);
const currentMonthTotal = currentMonthRow?.total || 0;
const currentMonthCount = currentMonthRow?.count || 0;

const yearTotal = useMemo(
() => monthly.reduce((sum, m) => sum + Number(m.total || 0), 0),
[monthly]
);
const yearEntries = useMemo(
() => monthly.reduce((sum, m) => sum + Number(m.count || 0), 0),
[monthly]
);
const avgPerEntry = yearEntries > 0 ? yearTotal / yearEntries : 0;

const completedOnboardingSteps = useMemo(() => {
const steps = [];
Expand All @@ -244,40 +254,62 @@ export default function DashboardPage() {
<section className="dashboard-proto-stats">
<article className="dashboard-proto-card">
<div className="dashboard-proto-stat-head">
<span className="dashboard-proto-icon blue">
<DollarSign />
<span className="dashboard-proto-icon green">
<CalendarDays />
</span>
<span>Total Expenses</span>
<span>Current Month</span>
</div>
{summaryLoading ? (
<div className="skeleton skeleton-stat-value" aria-hidden="true" />
) : (
<p className="dashboard-proto-stat-value">{formatInr(totalSpend)}</p>
<div className="dashboard-proto-stat-body">
<p className="dashboard-proto-stat-value">{formatInr(currentMonthTotal)}</p>
<p className="dashboard-proto-stat-sub">
<span className="dashboard-proto-stat-sub-label">Expenses logged</span>{' '}
{currentMonthCount}
</p>
</div>
)}
</article>

<article className="dashboard-proto-card">
<div className="dashboard-proto-stat-head">
<span className="dashboard-proto-icon violet">
<FileText />
<span className="dashboard-proto-icon blue">
<DollarSign />
</span>
<span>Entries</span>
<span>Total Expenses</span>
</div>
{summaryLoading ? (
<div className="skeleton skeleton-stat-value" aria-hidden="true" />
) : (
<p className="dashboard-proto-stat-value">{expenses.length}</p>
<div className="dashboard-proto-stat-body">
<p className="dashboard-proto-stat-value">{formatInr(yearTotal)}</p>
<p className="dashboard-proto-stat-sub">
<span className="dashboard-proto-stat-sub-label">Tracking Year</span>{' '}
{currentYear}
</p>
</div>
)}
</article>

<article className="dashboard-proto-card">
<div className="dashboard-proto-stat-head">
<span className="dashboard-proto-icon green">
<Calendar />
<span className="dashboard-proto-icon violet">
<FileText />
</span>
<span>Tracking Year</span>
<span>Entries</span>
</div>
<p className="dashboard-proto-stat-value">{currentYear}</p>
{summaryLoading ? (
<div className="skeleton skeleton-stat-value" aria-hidden="true" />
) : (
<div className="dashboard-proto-stat-body">
<p className="dashboard-proto-stat-value">{expenses.length}</p>
<p className="dashboard-proto-stat-sub">
<span className="dashboard-proto-stat-sub-label">Avg / entry</span>{' '}
{formatInr(avgPerEntry)}
</p>
</div>
)}
</article>
</section>

Expand Down Expand Up @@ -330,8 +362,8 @@ export default function DashboardPage() {
<pre>{JSON.stringify(lastExtracted, null, 2)}</pre>
</div>
) : null}
{error || summaryError ? (
<p className="error-text">{error || summaryError}</p>
{summaryError ? (
<p className="error-text">{summaryError}</p>
) : null}
</div>

Expand All @@ -349,8 +381,8 @@ export default function DashboardPage() {
>
Go to Add Expense
</button>
{error || summaryError ? (
<p className="error-text">{error || summaryError}</p>
{summaryError ? (
<p className="error-text">{summaryError}</p>
) : null}
</div>

Expand Down
13 changes: 13 additions & 0 deletions app/src/styles/dashboard.css
Original file line number Diff line number Diff line change
Expand Up @@ -483,6 +483,19 @@
font-variant-numeric: tabular-nums;
}

.dashboard-proto-stat-sub {
margin-top: 6px;
font-size: 0.875rem;
font-weight: 400;
color: var(--muted-strong);
font-variant-numeric: tabular-nums;
}

.dashboard-proto-stat-sub-label {
font-weight: 700;
}


/* --- Mobile / Desktop visibility --- */
.dashboard-proto-mobile-only {
display: none;
Expand Down
9 changes: 9 additions & 0 deletions app/src/styles/responsive.css
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,15 @@
flex: 1;
}

.dashboard-proto-stat-body {
text-align: right;
}

.dashboard-proto-stat-sub {
margin-top: 2px;
text-align: right;
}

.dashboard-proto-stat-value {
font-size: 1.25rem;
margin-top: 0;
Expand Down
9 changes: 6 additions & 3 deletions tests/test_google_oauth.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Tests for Google OAuth authorization, domain checking, URL formatting, and API endpoints."""

from urllib.parse import parse_qs, urlparse

import pytest
from fastapi.testclient import TestClient
from mongomock import MongoClient
Expand Down Expand Up @@ -47,12 +48,12 @@ def test_oauth_login_or_create_new_user(mock_users_repo):
"""Verify new user document creation via Google userinfo."""
userinfo = {
"sub": "google-sub-12345",
"email": "jane.doe@example.com",
"email": "jane.doe@gmail.com",
"given_name": "Jane",
}
user = oauth_login_or_create(userinfo)

assert user["username"] == "jane.doe@example.com"
assert user["username"] == "jane.doe@gmail.com"
assert user["email_verified"] is True
assert user["oauth_provider"] == "google"
assert user["google_sub"] == "google-sub-12345"
Expand Down Expand Up @@ -98,6 +99,8 @@ def test_google_auth_endpoint_redirect(monkeypatch):
def test_google_auth_callback_error_query():
"""Verify OAuth error response redirects to /login?error=oauth_denied."""
client = TestClient(app)
response = client.get("/api/v1/auth/google/callback?error=access_denied", follow_redirects=False)
response = client.get(
"/api/v1/auth/google/callback?error=access_denied", follow_redirects=False
)
assert response.status_code == 302
assert response.headers["location"] == "/login?error=oauth_denied"
Loading