Add backend API for personal finance management application

- Introduced a comprehensive backend API using TypeScript, Fastify, and PostgreSQL.
- Added essential files including architecture documentation, environment configuration, and Docker setup.
- Implemented RESTful routes for managing assets, liabilities, clients, invoices, and cashflow.
- Established a robust database schema with Prisma for data management.
- Integrated middleware for authentication and error handling.
- Created service and repository layers to adhere to SOLID principles and clean architecture.
- Included example environment variables for development, staging, and production setups.
This commit is contained in:
2025-12-07 12:59:09 -05:00
parent 9d493ba82f
commit cd93dcbfd2
70 changed files with 8649 additions and 6 deletions

View File

@@ -1,8 +1,11 @@
import {lazy, Suspense} from 'react';
import {BrowserRouter, Routes, Route} from 'react-router-dom';
import {BrowserRouter, Routes, Route, Navigate} from 'react-router-dom';
import {useAppSelector} from '@/store';
import Layout from '@/components/Layout';
import ProtectedRoute from '@/components/ProtectedRoute';
// Code splitting: lazy load route components
const LandingPage = lazy(() => import('@/pages/LandingPage'));
const NetWorthPage = lazy(() => import('@/pages/NetWorthPage'));
const CashflowPage = lazy(() => import('@/pages/CashflowPage'));
const DebtsPage = lazy(() => import('@/pages/DebtsPage'));
@@ -16,10 +19,27 @@ const PageLoader = () => (
</div>
);
export default function App() {
function AppRoutes() {
const {isAuthenticated} = useAppSelector(state => state.user);
return (
<BrowserRouter>
<Routes>
<Routes>
{/* Public route - Landing page */}
<Route
path="/welcome"
element={
isAuthenticated ? (
<Navigate to="/" replace />
) : (
<Suspense fallback={<PageLoader />}>
<LandingPage />
</Suspense>
)
}
/>
{/* Protected routes */}
<Route element={<ProtectedRoute />}>
<Route path="/" element={<Layout />}>
<Route
index
@@ -62,7 +82,18 @@ export default function App() {
}
/>
</Route>
</Routes>
</Route>
{/* Catch-all redirect */}
<Route path="*" element={<Navigate to={isAuthenticated ? '/' : '/welcome'} replace />} />
</Routes>
);
}
export default function App() {
return (
<BrowserRouter>
<AppRoutes />
</BrowserRouter>
);
}

View File

@@ -1,5 +1,6 @@
import {NavLink, Outlet} from 'react-router-dom';
import {TrendingUp, CreditCard, FileText, Users, ArrowLeftRight} from 'lucide-react';
import {TrendingUp, CreditCard, FileText, Users, ArrowLeftRight, LogOut} from 'lucide-react';
import {useAppSelector, useAppDispatch, clearUser} from '@/store';
const navItems = [
{to: '/', label: 'Net Worth', icon: TrendingUp},
@@ -10,6 +11,13 @@ const navItems = [
];
export default function Layout() {
const dispatch = useAppDispatch();
const {currentUser} = useAppSelector(state => state.user);
const handleLogout = () => {
dispatch(clearUser());
};
return (
<div className="flex min-h-screen">
{/* Sidebar */}
@@ -35,6 +43,26 @@ export default function Layout() {
</NavLink>
))}
</nav>
{/* User section */}
<div className="border-t border-border p-2">
<div className="flex items-center gap-2 px-2.5 py-2 rounded-lg">
<div className="h-7 w-7 rounded-full bg-accent flex items-center justify-center text-xs font-medium shrink-0">
{currentUser?.name?.charAt(0).toUpperCase() || '?'}
</div>
<div className="opacity-0 group-hover:opacity-100 transition-opacity duration-200 overflow-hidden">
<p className="text-sm font-medium truncate max-w-24">{currentUser?.name}</p>
<p className="text-xs text-muted-foreground truncate max-w-24">{currentUser?.email}</p>
</div>
</div>
<button
onClick={handleLogout}
className="flex h-9 w-full items-center gap-3 rounded-lg px-2.5 text-muted-foreground hover:bg-accent/50 hover:text-foreground transition-colors"
>
<LogOut className="h-[18px] w-[18px] shrink-0" />
<span className="text-sm opacity-0 group-hover:opacity-100 transition-opacity duration-200 whitespace-nowrap">Log out</span>
</button>
</div>
</aside>
{/* Main content */}

View File

@@ -0,0 +1,13 @@
import {Navigate, Outlet} from 'react-router-dom';
import {useAppSelector} from '@/store';
export default function ProtectedRoute() {
const {isAuthenticated} = useAppSelector(state => state.user);
if (!isAuthenticated) {
return <Navigate to="/welcome" replace />;
}
return <Outlet />;
}

View File

@@ -0,0 +1,88 @@
import {useState} from 'react';
import {Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle} from '@/components/ui/dialog';
import {Button} from '@/components/ui/button';
import {Input} from '@/components/ui/input';
import {Label} from '@/components/ui/label';
import {useAppDispatch, setUser} from '@/store';
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
onSwitchToSignUp: () => void;
}
export default function LoginDialog({open, onOpenChange, onSwitchToSignUp}: Props) {
const dispatch = useAppDispatch();
const [form, setForm] = useState({
email: '',
password: '',
});
const [error, setError] = useState('');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setError('');
// Mock login - in production this would validate against an API
if (!form.email || !form.password) {
setError('Please enter your email and password');
return;
}
// Mock successful login
dispatch(setUser({
id: crypto.randomUUID(),
email: form.email,
name: form.email.split('@')[0],
}));
onOpenChange(false);
setForm({email: '', password: ''});
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="card-elevated sm:max-w-md">
<DialogHeader>
<DialogTitle>Welcome back</DialogTitle>
<DialogDescription>Log in to your account</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit}>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="login-email">Email</Label>
<Input
id="login-email"
type="email"
placeholder="john@example.com"
value={form.email}
onChange={e => setForm({...form, email: e.target.value})}
className="input-depth"
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="login-password">Password</Label>
<Input
id="login-password"
type="password"
placeholder="••••••••"
value={form.password}
onChange={e => setForm({...form, password: e.target.value})}
className="input-depth"
required
/>
</div>
{error && <p className="text-sm text-red-400">{error}</p>}
</div>
<DialogFooter className="flex-col gap-2 sm:flex-col">
<Button type="submit" className="w-full">Log in</Button>
<Button type="button" variant="ghost" className="w-full" onClick={onSwitchToSignUp}>
Don't have an account? Sign up
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,122 @@
import {useState} from 'react';
import {Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle} from '@/components/ui/dialog';
import {Button} from '@/components/ui/button';
import {Input} from '@/components/ui/input';
import {Label} from '@/components/ui/label';
import {useAppDispatch, setUser} from '@/store';
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
onSwitchToLogin: () => void;
}
export default function SignUpDialog({open, onOpenChange, onSwitchToLogin}: Props) {
const dispatch = useAppDispatch();
const [form, setForm] = useState({
name: '',
email: '',
password: '',
confirmPassword: '',
});
const [error, setError] = useState('');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (form.password !== form.confirmPassword) {
setError('Passwords do not match');
return;
}
if (form.password.length < 6) {
setError('Password must be at least 6 characters');
return;
}
// Mock sign up - in production this would call an API
dispatch(setUser({
id: crypto.randomUUID(),
email: form.email,
name: form.name,
}));
onOpenChange(false);
setForm({name: '', email: '', password: '', confirmPassword: ''});
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="card-elevated sm:max-w-md">
<DialogHeader>
<DialogTitle>Create an account</DialogTitle>
<DialogDescription>Enter your details to get started</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit}>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="signup-name">Name</Label>
<Input
id="signup-name"
type="text"
placeholder="John Doe"
value={form.name}
onChange={e => setForm({...form, name: e.target.value})}
className="input-depth"
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="signup-email">Email</Label>
<Input
id="signup-email"
type="email"
placeholder="john@example.com"
value={form.email}
onChange={e => setForm({...form, email: e.target.value})}
className="input-depth"
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="signup-password">Password</Label>
<Input
id="signup-password"
type="password"
placeholder="••••••••"
value={form.password}
onChange={e => setForm({...form, password: e.target.value})}
className="input-depth"
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="signup-confirm">Confirm Password</Label>
<Input
id="signup-confirm"
type="password"
placeholder="••••••••"
value={form.confirmPassword}
onChange={e => setForm({...form, confirmPassword: e.target.value})}
className="input-depth"
required
/>
</div>
{error && <p className="text-sm text-red-400">{error}</p>}
<p className="text-xs text-muted-foreground">
By signing up, you agree to our Terms of Service and Privacy Policy.
</p>
</div>
<DialogFooter className="flex-col gap-2 sm:flex-col">
<Button type="submit" className="w-full">Create account</Button>
<Button type="button" variant="ghost" className="w-full" onClick={onSwitchToLogin}>
Already have an account? Log in
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,144 @@
import {useState} from 'react';
import {Button} from '@/components/ui/button';
import {Card, CardContent} from '@/components/ui/card';
import {TrendingUp, CreditCard, FileText, ArrowLeftRight, Shield, BarChart3} from 'lucide-react';
import LoginDialog from '@/components/dialogs/LoginDialog';
import SignUpDialog from '@/components/dialogs/SignUpDialog';
const features = [
{
icon: TrendingUp,
title: 'Net Worth Tracking',
description: 'Monitor your assets and liabilities over time with beautiful charts and insights.',
},
{
icon: CreditCard,
title: 'Debt Management',
description: 'Organize and track debt paydown across multiple accounts and categories.',
},
{
icon: ArrowLeftRight,
title: 'Cashflow Analysis',
description: 'Understand your income and expenses to optimize your savings rate.',
},
{
icon: FileText,
title: 'Invoicing',
description: 'Create professional invoices and track payments from your clients.',
},
{
icon: BarChart3,
title: 'Visual Reports',
description: 'Clean, minimal dashboards that put your data front and center.',
},
{
icon: Shield,
title: 'Private & Secure',
description: 'Your financial data stays on your device. No cloud sync required.',
},
];
export default function LandingPage() {
const [loginOpen, setLoginOpen] = useState(false);
const [signUpOpen, setSignUpOpen] = useState(false);
return (
<div className="min-h-screen">
{/* Header */}
<header className="border-b border-border">
<div className="max-w-5xl mx-auto px-6 h-14 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-lg font-semibold">Wealth</span>
</div>
<div className="flex gap-2">
<Button variant="ghost" size="sm" onClick={() => setLoginOpen(true)}>
Log in
</Button>
<Button size="sm" onClick={() => setSignUpOpen(true)}>
Sign up
</Button>
</div>
</div>
</header>
{/* Hero */}
<section className="py-20 px-6">
<div className="max-w-3xl mx-auto text-center">
<h1 className="text-4xl font-semibold tracking-tight mb-4">
Take control of your finances
</h1>
<p className="text-lg text-muted-foreground mb-8 max-w-xl mx-auto">
A clean, minimal tool to track your net worth, manage debt, monitor cashflow, and invoice clientsall in one place.
</p>
<div className="flex gap-3 justify-center">
<Button size="lg" onClick={() => setSignUpOpen(true)}>
Get Started
</Button>
<Button variant="secondary" size="lg" onClick={() => setLoginOpen(true)}>
Log in
</Button>
</div>
</div>
</section>
{/* Features */}
<section className="py-16 px-6 border-t border-border">
<div className="max-w-5xl mx-auto">
<h2 className="text-xl font-semibold text-center mb-10">Everything you need</h2>
<div className="grid grid-cols-3 gap-4">
{features.map((feature) => (
<Card key={feature.title} className="card-elevated">
<CardContent className="p-5">
<feature.icon className="h-5 w-5 mb-3 text-muted-foreground" />
<h3 className="font-medium mb-1">{feature.title}</h3>
<p className="text-sm text-muted-foreground">{feature.description}</p>
</CardContent>
</Card>
))}
</div>
</div>
</section>
{/* CTA */}
<section className="py-16 px-6 border-t border-border">
<div className="max-w-xl mx-auto text-center">
<h2 className="text-xl font-semibold mb-3">Ready to build wealth?</h2>
<p className="text-muted-foreground mb-6">
Start tracking your finances today. It's free to get started.
</p>
<Button size="lg" onClick={() => setSignUpOpen(true)}>
Create your account
</Button>
</div>
</section>
{/* Disclaimer */}
<section className="py-8 px-6 border-t border-border bg-muted/30">
<div className="max-w-3xl mx-auto">
<p className="text-xs text-muted-foreground text-center leading-relaxed">
<strong>Disclaimer:</strong> This application is for informational and personal tracking purposes only.
It does not constitute financial, investment, tax, or legal advice. The information provided should not
be relied upon for making financial decisions. Always consult with qualified professionals before making
any financial decisions. We make no guarantees about the accuracy or completeness of the data you enter
or the calculations performed. Use at your own risk. Past performance is not indicative of future results.
</p>
</div>
</section>
{/* Footer */}
<footer className="py-6 px-6 border-t border-border">
<div className="max-w-5xl mx-auto flex justify-between items-center text-sm text-muted-foreground">
<span>© {new Date().getFullYear()} Wealth</span>
<div className="flex gap-4">
<span>Privacy</span>
<span>Terms</span>
</div>
</div>
</footer>
<LoginDialog open={loginOpen} onOpenChange={setLoginOpen} onSwitchToSignUp={() => { setLoginOpen(false); setSignUpOpen(true); }} />
<SignUpDialog open={signUpOpen} onOpenChange={setSignUpOpen} onSwitchToLogin={() => { setSignUpOpen(false); setLoginOpen(true); }} />
</div>
);
}