Compare commits
2 Commits
df2cf418ea
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 2cff25c55b | |||
| 51074d02a9 |
@@ -253,7 +253,7 @@ All API routes are prefixed with `/api` to avoid conflicts with frontend routes.
|
||||
### Authentication
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| ------ | -------------------- | ---------------------------------- |
|
||||
| POST | `/api/auth/register` | Register new user |
|
||||
| POST | `/api/auth/login` | Login, returns JWT + refresh token |
|
||||
| POST | `/api/auth/refresh` | Refresh access token |
|
||||
@@ -263,7 +263,7 @@ All API routes are prefixed with `/api` to avoid conflicts with frontend routes.
|
||||
### Assets
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| ------ | ----------------- | ------------------------ |
|
||||
| GET | `/api/assets` | List all assets for user |
|
||||
| POST | `/api/assets` | Create new asset |
|
||||
| GET | `/api/assets/:id` | Get asset by ID |
|
||||
@@ -273,7 +273,7 @@ All API routes are prefixed with `/api` to avoid conflicts with frontend routes.
|
||||
### Liabilities
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| ------ | ---------------------- | ----------------------------- |
|
||||
| GET | `/api/liabilities` | List all liabilities for user |
|
||||
| POST | `/api/liabilities` | Create new liability |
|
||||
| GET | `/api/liabilities/:id` | Get liability by ID |
|
||||
@@ -283,7 +283,7 @@ All API routes are prefixed with `/api` to avoid conflicts with frontend routes.
|
||||
### Net Worth Snapshots
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| ------ | -------------------------- | ---------------------------------------- |
|
||||
| GET | `/api/net-worth/snapshots` | List snapshots (with date range filter) |
|
||||
| POST | `/api/net-worth/snapshots` | Create snapshot (auto-calculates totals) |
|
||||
| GET | `/api/net-worth/current` | Get current net worth calculation |
|
||||
@@ -291,7 +291,7 @@ All API routes are prefixed with `/api` to avoid conflicts with frontend routes.
|
||||
### Debt Categories
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| ------ | --------------------------- | ------------------------------------------- |
|
||||
| GET | `/api/debts/categories` | List all categories |
|
||||
| POST | `/api/debts/categories` | Create category |
|
||||
| PUT | `/api/debts/categories/:id` | Update category |
|
||||
@@ -300,7 +300,7 @@ All API routes are prefixed with `/api` to avoid conflicts with frontend routes.
|
||||
### Debt Accounts
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| ------ | ------------------------- | -------------------------------- |
|
||||
| GET | `/api/debts/accounts` | List all debt accounts |
|
||||
| POST | `/api/debts/accounts` | Create debt account |
|
||||
| GET | `/api/debts/accounts/:id` | Get account with payment history |
|
||||
@@ -310,7 +310,7 @@ All API routes are prefixed with `/api` to avoid conflicts with frontend routes.
|
||||
### Debt Payments
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| ------ | ---------------------------------- | --------------------------------- |
|
||||
| GET | `/api/debts/accounts/:id/payments` | List payments for account |
|
||||
| POST | `/api/debts/accounts/:id/payments` | Record payment (updates balance) |
|
||||
| DELETE | `/api/debts/payments/:id` | Delete payment (restores balance) |
|
||||
@@ -318,7 +318,7 @@ All API routes are prefixed with `/api` to avoid conflicts with frontend routes.
|
||||
### Clients
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| ------ | ------------------ | ----------------------------- |
|
||||
| GET | `/api/clients` | List all clients |
|
||||
| POST | `/api/clients` | Create client |
|
||||
| GET | `/api/clients/:id` | Get client with invoice stats |
|
||||
@@ -328,7 +328,7 @@ All API routes are prefixed with `/api` to avoid conflicts with frontend routes.
|
||||
### Invoices
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| ------ | -------------------------- | -------------------------------------------- |
|
||||
| GET | `/api/invoices` | List invoices (filterable by status, client) |
|
||||
| POST | `/api/invoices` | Create invoice with line items |
|
||||
| GET | `/api/invoices/:id` | Get invoice with line items |
|
||||
@@ -339,7 +339,7 @@ All API routes are prefixed with `/api` to avoid conflicts with frontend routes.
|
||||
### Income Sources
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| ------ | -------------------------- | -------------------- |
|
||||
| GET | `/api/cashflow/income` | List income sources |
|
||||
| POST | `/api/cashflow/income` | Create income source |
|
||||
| PUT | `/api/cashflow/income/:id` | Update income source |
|
||||
@@ -348,7 +348,7 @@ All API routes are prefixed with `/api` to avoid conflicts with frontend routes.
|
||||
### Expenses
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| ------ | ---------------------------- | -------------- |
|
||||
| GET | `/api/cashflow/expenses` | List expenses |
|
||||
| POST | `/api/cashflow/expenses` | Create expense |
|
||||
| PUT | `/api/cashflow/expenses/:id` | Update expense |
|
||||
@@ -357,7 +357,7 @@ All API routes are prefixed with `/api` to avoid conflicts with frontend routes.
|
||||
### Transactions
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| ------ | -------------------------------- | ----------------------------------------------- |
|
||||
| GET | `/api/cashflow/transactions` | List transactions (with date range, pagination) |
|
||||
| POST | `/api/cashflow/transactions` | Create transaction |
|
||||
| DELETE | `/api/cashflow/transactions/:id` | Delete transaction |
|
||||
@@ -365,13 +365,13 @@ All API routes are prefixed with `/api` to avoid conflicts with frontend routes.
|
||||
### Dashboard / Summary
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| ------ | ------------------------ | ---------------------------- |
|
||||
| GET | `/api/dashboard/summary` | Get aggregated summary stats |
|
||||
|
||||
### Health Check
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| ------ | ------------- | ------------------------------- |
|
||||
| GET | `/api/health` | Health check (no auth required) |
|
||||
|
||||
---
|
||||
@@ -390,6 +390,7 @@ All API routes are prefixed with `/api` to avoid conflicts with frontend routes.
|
||||
- Store refresh token hash in DB or Redis
|
||||
|
||||
3. **JWT Payload:**
|
||||
|
||||
```typescript
|
||||
interface JWTPayload {
|
||||
sub: string; // user ID
|
||||
@@ -521,7 +522,7 @@ import path from 'path';
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
app.register(fastifyStatic, {
|
||||
root: path.join(__dirname, '../public'),
|
||||
prefix: '/',
|
||||
prefix: '/'
|
||||
});
|
||||
|
||||
// SPA fallback - serve index.html for all non-API routes
|
||||
@@ -641,14 +642,14 @@ services:
|
||||
POSTGRES_PASSWORD: wealth_dev
|
||||
POSTGRES_DB: wealth
|
||||
ports:
|
||||
- "5432:5432"
|
||||
- '5432:5432'
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
|
||||
api:
|
||||
build: .
|
||||
ports:
|
||||
- "3000:3000"
|
||||
- '3000:3000'
|
||||
environment:
|
||||
DATABASE_URL: postgresql://wealth:wealth_dev@db:5432/wealth
|
||||
JWT_SECRET: dev-secret-change-in-production
|
||||
@@ -807,4 +808,3 @@ interface Transaction {
|
||||
note?: string;
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ class DatabaseConnection {
|
||||
public static getInstance(): PrismaClient {
|
||||
if (!DatabaseConnection.instance) {
|
||||
DatabaseConnection.instance = new PrismaClient({
|
||||
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
|
||||
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error']
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -6,29 +6,13 @@ import {z} from 'zod';
|
||||
const createLiabilitySchema = z.object({
|
||||
name: z.string().min(1).max(255),
|
||||
type: z.string().min(1),
|
||||
currentBalance: z.number().min(0),
|
||||
interestRate: z.number().min(0).max(100).optional(),
|
||||
minimumPayment: z.number().min(0).optional(),
|
||||
dueDate: z
|
||||
.string()
|
||||
.transform(str => new Date(str))
|
||||
.optional(),
|
||||
creditor: z.string().max(255).optional(),
|
||||
notes: z.string().optional()
|
||||
balance: z.number().min(0)
|
||||
});
|
||||
|
||||
const updateLiabilitySchema = z.object({
|
||||
name: z.string().min(1).max(255).optional(),
|
||||
type: z.string().min(1).optional(),
|
||||
currentBalance: z.number().min(0).optional(),
|
||||
interestRate: z.number().min(0).max(100).optional(),
|
||||
minimumPayment: z.number().min(0).optional(),
|
||||
dueDate: z
|
||||
.string()
|
||||
.transform(str => new Date(str))
|
||||
.optional(),
|
||||
creditor: z.string().max(255).optional(),
|
||||
notes: z.string().optional()
|
||||
balance: z.number().min(0).optional()
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -46,4 +46,24 @@ export class AssetRepository {
|
||||
|
||||
return result._sum.value || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get assets grouped by type
|
||||
*/
|
||||
async getByType(userId: string): Promise<Record<string, Asset[]>> {
|
||||
const assets = await this.findAllByUser(userId);
|
||||
|
||||
return assets.reduce(
|
||||
(acc, asset) => {
|
||||
const type = asset.type;
|
||||
if (!acc[type]) {
|
||||
acc[type] = [];
|
||||
}
|
||||
acc[type].push(asset);
|
||||
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, Asset[]>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,4 +76,27 @@ export class InvoiceRepository implements IUserScopedRepository<Invoice> {
|
||||
|
||||
return `INV-${year}-${String(count + 1).padStart(3, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get invoice statistics for a user
|
||||
*/
|
||||
async getStats(userId: string): Promise<{
|
||||
totalInvoices: number;
|
||||
paidInvoices: number;
|
||||
outstandingAmount: number;
|
||||
overdueInvoices: number;
|
||||
}> {
|
||||
const invoices = await prisma.invoice.findMany({
|
||||
where: {userId},
|
||||
select: {status: true, total: true, dueDate: true}
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
const totalInvoices = invoices.length;
|
||||
const paidInvoices = invoices.filter(inv => inv.status === 'paid').length;
|
||||
const outstandingAmount = invoices.filter(inv => inv.status !== 'paid' && inv.status !== 'cancelled').reduce((sum, inv) => sum + inv.total, 0);
|
||||
const overdueInvoices = invoices.filter(inv => inv.status !== 'paid' && inv.status !== 'cancelled' && inv.dueDate < now).length;
|
||||
|
||||
return {totalInvoices, paidInvoices, outstandingAmount, overdueInvoices};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ export class DashboardService {
|
||||
const assetAllocation = Object.entries(assetsByType).map(([type, assets]) => ({
|
||||
type,
|
||||
count: assets.length,
|
||||
totalValue: assets.reduce((sum, asset) => sum + asset.currentValue, 0)
|
||||
totalValue: assets.reduce((sum, asset) => sum + asset.value, 0)
|
||||
}));
|
||||
|
||||
return {
|
||||
|
||||
@@ -5,23 +5,13 @@ import {NotFoundError, ValidationError, ForbiddenError} from '../utils/errors';
|
||||
export interface CreateLiabilityDTO {
|
||||
name: string;
|
||||
type: string;
|
||||
currentBalance: number;
|
||||
interestRate?: number;
|
||||
minimumPayment?: number;
|
||||
dueDate?: Date;
|
||||
creditor?: string;
|
||||
notes?: string;
|
||||
balance: number;
|
||||
}
|
||||
|
||||
export interface UpdateLiabilityDTO {
|
||||
name?: string;
|
||||
type?: string;
|
||||
currentBalance?: number;
|
||||
interestRate?: number;
|
||||
minimumPayment?: number;
|
||||
dueDate?: Date;
|
||||
creditor?: string;
|
||||
notes?: string;
|
||||
balance?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -41,12 +31,7 @@ export class LiabilityService {
|
||||
return this.liabilityRepository.create({
|
||||
name: data.name,
|
||||
type: data.type,
|
||||
currentBalance: data.currentBalance,
|
||||
interestRate: data.interestRate,
|
||||
minimumPayment: data.minimumPayment,
|
||||
dueDate: data.dueDate,
|
||||
creditor: data.creditor,
|
||||
notes: data.notes,
|
||||
balance: data.balance,
|
||||
user: {
|
||||
connect: {id: userId}
|
||||
}
|
||||
@@ -85,7 +70,7 @@ export class LiabilityService {
|
||||
// Verify ownership
|
||||
await this.getById(id, userId);
|
||||
|
||||
if (data.currentBalance !== undefined || data.interestRate !== undefined || data.minimumPayment !== undefined) {
|
||||
if (data.balance !== undefined) {
|
||||
this.validateLiabilityData(data as CreateLiabilityDTO);
|
||||
}
|
||||
|
||||
@@ -120,16 +105,8 @@ export class LiabilityService {
|
||||
* Validate liability data
|
||||
*/
|
||||
private validateLiabilityData(data: CreateLiabilityDTO | UpdateLiabilityDTO): void {
|
||||
if (data.currentBalance !== undefined && data.currentBalance < 0) {
|
||||
throw new ValidationError('Current balance cannot be negative');
|
||||
}
|
||||
|
||||
if (data.interestRate !== undefined && (data.interestRate < 0 || data.interestRate > 100)) {
|
||||
throw new ValidationError('Interest rate must be between 0 and 100');
|
||||
}
|
||||
|
||||
if (data.minimumPayment !== undefined && data.minimumPayment < 0) {
|
||||
throw new ValidationError('Minimum payment cannot be negative');
|
||||
if (data.balance !== undefined && data.balance < 0) {
|
||||
throw new ValidationError('Balance cannot be negative');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,11 +10,11 @@ services:
|
||||
POSTGRES_PASSWORD: wealth_dev
|
||||
POSTGRES_DB: wealth_dev
|
||||
ports:
|
||||
- "5432:5432"
|
||||
- '5432:5432'
|
||||
volumes:
|
||||
- postgres_dev_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U wealth -d wealth_dev"]
|
||||
test: ['CMD-SHELL', 'pg_isready -U wealth -d wealth_dev']
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
@@ -28,11 +28,11 @@ services:
|
||||
POSTGRES_PASSWORD: ${STAGING_DB_PASSWORD:-wealth_staging}
|
||||
POSTGRES_DB: wealth_staging
|
||||
ports:
|
||||
- "5433:5432"
|
||||
- '5433:5432'
|
||||
volumes:
|
||||
- postgres_staging_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U wealth -d wealth_staging"]
|
||||
test: ['CMD-SHELL', 'pg_isready -U wealth -d wealth_staging']
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
@@ -46,11 +46,11 @@ services:
|
||||
POSTGRES_PASSWORD: ${PROD_DB_PASSWORD:?PROD_DB_PASSWORD is required}
|
||||
POSTGRES_DB: wealth_prod
|
||||
ports:
|
||||
- "5434:5432"
|
||||
- '5434:5432'
|
||||
volumes:
|
||||
- postgres_prod_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U wealth -d wealth_prod"]
|
||||
test: ['CMD-SHELL', 'pg_isready -U wealth -d wealth_prod']
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
@@ -63,4 +63,3 @@ volumes:
|
||||
postgres_dev_data:
|
||||
postgres_staging_data:
|
||||
postgres_prod_data:
|
||||
|
||||
|
||||
@@ -28,12 +28,7 @@ export default defineConfig([
|
||||
// Frontend Web - TypeScript/React files
|
||||
{
|
||||
files: ['frontend-web/**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite
|
||||
],
|
||||
extends: [js.configs.recommended, tseslint.configs.recommended, reactHooks.configs.flat.recommended, reactRefresh.configs.vite],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser
|
||||
|
||||
@@ -57,8 +57,7 @@ export default function Layout() {
|
||||
</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"
|
||||
>
|
||||
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>
|
||||
|
||||
@@ -10,4 +10,3 @@ export default function ProtectedRoute() {
|
||||
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ export default function AddAssetDialog({open, onOpenChange}: Props) {
|
||||
createAsset({
|
||||
name: sanitizeString(form.name),
|
||||
type: form.type.toUpperCase() as 'CASH' | 'INVESTMENT' | 'PROPERTY' | 'VEHICLE' | 'OTHER',
|
||||
value: valueNum,
|
||||
value: valueNum
|
||||
})
|
||||
);
|
||||
onOpenChange(false);
|
||||
|
||||
@@ -23,7 +23,7 @@ export default function AddLiabilityDialog({open, onOpenChange}: Props) {
|
||||
createLiability({
|
||||
name: form.name,
|
||||
type: form.type.toUpperCase() as 'CREDIT_CARD' | 'LOAN' | 'MORTGAGE' | 'OTHER',
|
||||
currentBalance: parseFloat(form.balance) || 0,
|
||||
currentBalance: parseFloat(form.balance) || 0
|
||||
})
|
||||
);
|
||||
onOpenChange(false);
|
||||
|
||||
@@ -66,7 +66,7 @@ export default function EditAssetDialog({open, onOpenChange, asset}: Props) {
|
||||
data: {
|
||||
name: form.name.trim(),
|
||||
type: form.type.toUpperCase() as 'CASH' | 'INVESTMENT' | 'PROPERTY' | 'VEHICLE' | 'OTHER',
|
||||
value: valueNum,
|
||||
value: valueNum
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
@@ -66,7 +66,7 @@ export default function EditLiabilityDialog({open, onOpenChange, liability}: Pro
|
||||
data: {
|
||||
name: form.name.trim(),
|
||||
type: form.type.toUpperCase() as 'CREDIT_CARD' | 'LOAN' | 'MORTGAGE' | 'OTHER',
|
||||
currentBalance: balanceNum,
|
||||
currentBalance: balanceNum
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
@@ -16,7 +16,7 @@ export default function LoginDialog({open, onOpenChange, onSwitchToSignUp}: Prop
|
||||
const dispatch = useAppDispatch();
|
||||
const [form, setForm] = useState({
|
||||
email: '',
|
||||
password: '',
|
||||
password: ''
|
||||
});
|
||||
const [error, setError] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -36,7 +36,7 @@ export default function LoginDialog({open, onOpenChange, onSwitchToSignUp}: Prop
|
||||
await dispatch(
|
||||
loginUser({
|
||||
email: form.email,
|
||||
password: form.password,
|
||||
password: form.password
|
||||
})
|
||||
).unwrap();
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ export default function SignUpDialog({open, onOpenChange, onSwitchToLogin}: Prop
|
||||
name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
confirmPassword: ''
|
||||
});
|
||||
const [error, setError] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -45,7 +45,7 @@ export default function SignUpDialog({open, onOpenChange, onSwitchToLogin}: Prop
|
||||
registerUser({
|
||||
email: form.email,
|
||||
password: form.password,
|
||||
name: form.name,
|
||||
name: form.name
|
||||
})
|
||||
).unwrap();
|
||||
|
||||
@@ -117,9 +117,7 @@ export default function SignUpDialog({open, onOpenChange, onSwitchToLogin}: Prop
|
||||
/>
|
||||
</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>
|
||||
<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" disabled={isLoading}>
|
||||
@@ -134,4 +132,3 @@ export default function SignUpDialog({open, onOpenChange, onSwitchToLogin}: Prop
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -69,5 +69,5 @@ export const authService = {
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
@@ -52,7 +52,7 @@ export const incomeService = {
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
return apiClient.delete<void>(`/cashflow/income/${id}`);
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
export const expenseService = {
|
||||
@@ -70,7 +70,7 @@ export const expenseService = {
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
return apiClient.delete<void>(`/cashflow/expenses/${id}`);
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
export const transactionService = {
|
||||
@@ -84,5 +84,5 @@ export const transactionService = {
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
return apiClient.delete<void>(`/cashflow/transactions/${id}`);
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
@@ -28,7 +28,7 @@ class ApiClient {
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers as Record<string, string>),
|
||||
...(options.headers as Record<string, string>)
|
||||
};
|
||||
|
||||
if (token) {
|
||||
@@ -38,7 +38,7 @@ class ApiClient {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers,
|
||||
headers
|
||||
});
|
||||
|
||||
// Handle non-JSON responses
|
||||
@@ -48,7 +48,7 @@ class ApiClient {
|
||||
throw {
|
||||
message: 'Request failed',
|
||||
statusCode: response.status,
|
||||
error: response.statusText,
|
||||
error: response.statusText
|
||||
} as ApiError;
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ class ApiClient {
|
||||
throw {
|
||||
message: data.message || 'Request failed',
|
||||
statusCode: response.status,
|
||||
error: data.error,
|
||||
error: data.error
|
||||
} as ApiError;
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ class ApiClient {
|
||||
throw {
|
||||
message: 'Network error',
|
||||
statusCode: 0,
|
||||
error: String(error),
|
||||
error: String(error)
|
||||
} as ApiError;
|
||||
}
|
||||
}
|
||||
@@ -85,21 +85,21 @@ class ApiClient {
|
||||
async post<T>(endpoint: string, data?: unknown): Promise<T> {
|
||||
return this.request<T>(endpoint, {
|
||||
method: 'POST',
|
||||
body: data ? JSON.stringify(data) : undefined,
|
||||
body: data ? JSON.stringify(data) : undefined
|
||||
});
|
||||
}
|
||||
|
||||
async put<T>(endpoint: string, data?: unknown): Promise<T> {
|
||||
return this.request<T>(endpoint, {
|
||||
method: 'PUT',
|
||||
body: data ? JSON.stringify(data) : undefined,
|
||||
body: data ? JSON.stringify(data) : undefined
|
||||
});
|
||||
}
|
||||
|
||||
async patch<T>(endpoint: string, data?: unknown): Promise<T> {
|
||||
return this.request<T>(endpoint, {
|
||||
method: 'PATCH',
|
||||
body: data ? JSON.stringify(data) : undefined,
|
||||
body: data ? JSON.stringify(data) : undefined
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -90,7 +90,7 @@ export const assetService = {
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
return apiClient.delete<void>(`/assets/${id}`);
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
export const liabilityService = {
|
||||
@@ -112,7 +112,7 @@ export const liabilityService = {
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
return apiClient.delete<void>(`/liabilities/${id}`);
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
export const snapshotService = {
|
||||
@@ -124,13 +124,7 @@ export const snapshotService = {
|
||||
return apiClient.get<{snapshot: NetWorthSnapshot}>(`/networth/snapshots/${id}`);
|
||||
},
|
||||
|
||||
async create(data: {
|
||||
date: string;
|
||||
totalAssets: number;
|
||||
totalLiabilities: number;
|
||||
netWorth: number;
|
||||
notes?: string;
|
||||
}): Promise<{snapshot: NetWorthSnapshot}> {
|
||||
async create(data: {date: string; totalAssets: number; totalLiabilities: number; netWorth: number; notes?: string}): Promise<{snapshot: NetWorthSnapshot}> {
|
||||
return apiClient.post<{snapshot: NetWorthSnapshot}>('/networth/snapshots', data);
|
||||
},
|
||||
|
||||
@@ -140,5 +134,5 @@ export const snapshotService = {
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
return apiClient.delete<void>(`/networth/snapshots/${id}`);
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
@@ -33,5 +33,5 @@ export const tokenStorage = {
|
||||
clear(): void {
|
||||
this.removeToken();
|
||||
this.removeUser();
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
@@ -9,33 +9,33 @@ const features = [
|
||||
{
|
||||
icon: TrendingUp,
|
||||
title: 'Net Worth Tracking',
|
||||
description: 'Monitor your assets and liabilities over time with beautiful charts and insights.',
|
||||
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.',
|
||||
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.',
|
||||
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.',
|
||||
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.',
|
||||
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.',
|
||||
},
|
||||
description: 'Your financial data stays on your device. No cloud sync required.'
|
||||
}
|
||||
];
|
||||
|
||||
export default function LandingPage() {
|
||||
@@ -64,9 +64,7 @@ export default function LandingPage() {
|
||||
{/* 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>
|
||||
<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 clients—all in one place.
|
||||
</p>
|
||||
@@ -86,7 +84,7 @@ export default function LandingPage() {
|
||||
<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) => (
|
||||
{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" />
|
||||
@@ -103,9 +101,7 @@ export default function LandingPage() {
|
||||
<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>
|
||||
<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>
|
||||
@@ -116,11 +112,10 @@ export default function LandingPage() {
|
||||
<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.
|
||||
<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>
|
||||
@@ -136,9 +131,22 @@ export default function LandingPage() {
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<LoginDialog open={loginOpen} onOpenChange={setLoginOpen} onSwitchToSignUp={() => { setLoginOpen(false); setSignUpOpen(true); }} />
|
||||
<SignUpDialog open={signUpOpen} onOpenChange={setSignUpOpen} onSwitchToLogin={() => { setSignUpOpen(false); setLoginOpen(true); }} />
|
||||
<LoginDialog
|
||||
open={loginOpen}
|
||||
onOpenChange={setLoginOpen}
|
||||
onSwitchToSignUp={() => {
|
||||
setLoginOpen(false);
|
||||
setSignUpOpen(true);
|
||||
}}
|
||||
/>
|
||||
<SignUpDialog
|
||||
open={signUpOpen}
|
||||
onOpenChange={setSignUpOpen}
|
||||
onSwitchToLogin={() => {
|
||||
setSignUpOpen(false);
|
||||
setLoginOpen(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import {createSlice, createAsyncThunk, type PayloadAction} from '@reduxjs/toolkit';
|
||||
import {incomeService, expenseService, transactionService, type IncomeSource as ApiIncome, type Expense as ApiExpense, type Transaction as ApiTransaction} from '@/lib/api/cashflow.service';
|
||||
import {
|
||||
incomeService,
|
||||
expenseService,
|
||||
transactionService,
|
||||
type IncomeSource as ApiIncome,
|
||||
type Expense as ApiExpense,
|
||||
type Transaction as ApiTransaction
|
||||
} from '@/lib/api/cashflow.service';
|
||||
|
||||
export interface IncomeSource {
|
||||
id: string;
|
||||
@@ -66,7 +73,7 @@ const mapApiIncomeToIncome = (apiIncome: ApiIncome): IncomeSource => ({
|
||||
category: 'Income',
|
||||
nextDate: new Date().toISOString(),
|
||||
isActive: true,
|
||||
createdAt: apiIncome.createdAt || new Date().toISOString(),
|
||||
createdAt: apiIncome.createdAt || new Date().toISOString()
|
||||
});
|
||||
|
||||
const mapApiExpenseToExpense = (apiExpense: ApiExpense): Expense => ({
|
||||
@@ -78,7 +85,7 @@ const mapApiExpenseToExpense = (apiExpense: ApiExpense): Expense => ({
|
||||
nextDate: new Date().toISOString(),
|
||||
isActive: true,
|
||||
isEssential: apiExpense.isEssential || false,
|
||||
createdAt: apiExpense.createdAt || new Date().toISOString(),
|
||||
createdAt: apiExpense.createdAt || new Date().toISOString()
|
||||
});
|
||||
|
||||
const mapApiTransactionToTransaction = (apiTransaction: ApiTransaction): Transaction => ({
|
||||
@@ -88,7 +95,7 @@ const mapApiTransactionToTransaction = (apiTransaction: ApiTransaction): Transac
|
||||
amount: apiTransaction.amount,
|
||||
category: apiTransaction.category || 'Other',
|
||||
date: apiTransaction.date,
|
||||
note: apiTransaction.notes,
|
||||
note: apiTransaction.notes
|
||||
});
|
||||
|
||||
// Async thunks
|
||||
@@ -210,7 +217,7 @@ const cashflowSlice = createSlice({
|
||||
state.isLoading = false;
|
||||
state.error = action.payload as string;
|
||||
});
|
||||
},
|
||||
}
|
||||
});
|
||||
|
||||
export const {
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
type CreateAssetRequest,
|
||||
type UpdateAssetRequest,
|
||||
type CreateLiabilityRequest,
|
||||
type UpdateLiabilityRequest,
|
||||
type UpdateLiabilityRequest
|
||||
} from '@/lib/api/networth.service';
|
||||
|
||||
export interface Asset {
|
||||
@@ -57,7 +57,7 @@ const mapApiAssetToAsset = (apiAsset: ApiAsset): Asset => ({
|
||||
name: apiAsset.name,
|
||||
type: apiAsset.type.toLowerCase() as Asset['type'],
|
||||
value: apiAsset.value,
|
||||
updatedAt: apiAsset.updatedAt || new Date().toISOString(),
|
||||
updatedAt: apiAsset.updatedAt || new Date().toISOString()
|
||||
});
|
||||
|
||||
const mapApiLiabilityToLiability = (apiLiability: ApiLiability): Liability => ({
|
||||
@@ -65,7 +65,7 @@ const mapApiLiabilityToLiability = (apiLiability: ApiLiability): Liability => ({
|
||||
name: apiLiability.name,
|
||||
type: apiLiability.type.toLowerCase() as Liability['type'],
|
||||
balance: apiLiability.currentBalance,
|
||||
updatedAt: apiLiability.updatedAt || new Date().toISOString(),
|
||||
updatedAt: apiLiability.updatedAt || new Date().toISOString()
|
||||
});
|
||||
|
||||
// Async thunks for assets
|
||||
@@ -191,7 +191,7 @@ const netWorthSlice = createSlice({
|
||||
},
|
||||
setError: (state, action: PayloadAction<string | null>) => {
|
||||
state.error = action.payload;
|
||||
},
|
||||
}
|
||||
},
|
||||
extraReducers: builder => {
|
||||
// Fetch assets
|
||||
@@ -267,7 +267,7 @@ const netWorthSlice = createSlice({
|
||||
state.isLoading = false;
|
||||
state.error = action.payload as string;
|
||||
});
|
||||
},
|
||||
}
|
||||
});
|
||||
|
||||
export const {setLoading, setError} = netWorthSlice.actions;
|
||||
|
||||
Reference in New Issue
Block a user