diff --git a/docs/WALLET_FEATURE.md b/docs/WALLET_FEATURE.md new file mode 100644 index 0000000..197ce85 --- /dev/null +++ b/docs/WALLET_FEATURE.md @@ -0,0 +1,189 @@ +# Wallet Feature Implementation + +## Overview + +The wallet feature allows users to create and manage multiple wallets (e.g., bank accounts, cash on hand, work funds) and track their balances separately. When users create expenses, they can optionally associate them with a specific wallet, which automatically deducts the expense amount from that wallet's balance. + +## Key Features + +- **Multiple Wallets**: Users can create unlimited wallets with custom names +- **Balance Tracking**: Each wallet maintains an initial balance and current balance +- **Automatic Updates**: Wallet balances update automatically when expenses are created, edited, or deleted +- **Optional Integration**: Wallets are optional - expenses can be created without selecting a wallet +- **Backward Compatible**: Existing expenses without wallet associations continue to work + +## Database Schema + +### Wallets Table +```sql +CREATE TABLE wallets ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + initial_balance REAL NOT NULL DEFAULT 0, + current_balance REAL NOT NULL DEFAULT 0, + currency TEXT NOT NULL DEFAULT 'EGP', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); +``` + +### Expenses Table Updates +```sql +ALTER TABLE expenses ADD COLUMN wallet_id TEXT; +``` + +## API Endpoints + +All wallet endpoints require authentication. + +### Get All Wallets +``` +GET /api/wallets +Response: { success: true, data: Wallet[] } +``` + +### Get Single Wallet +``` +GET /api/wallets/:id +Response: { success: true, data: Wallet } +``` + +### Create Wallet +``` +POST /api/wallets +Body: { + name: string, + initialBalance: number, + currency: string +} +Response: { success: true, data: Wallet } +``` + +### Update Wallet +``` +PUT /api/wallets/:id +Body: { + name?: string, + initialBalance?: number, + currency?: string +} +Response: { success: true, data: Wallet } +``` + +Note: Updating `initialBalance` adjusts the `current_balance` by the difference to maintain the correct spent amount. + +### Delete Wallet +``` +DELETE /api/wallets/:id +Response: { success: true, message: string } +``` + +## Wallet Balance Management + +### Creation +When an expense is created with a `wallet_id`: +1. The expense is inserted into the database +2. The wallet's current_balance is decreased by the expense total +3. All operations are done in the `DBService.createExpense` method + +### Update +When an expense is updated: +1. If the wallet changed, the old wallet's balance is restored and the new wallet's balance is decreased +2. If only the total changed, the balance is recalculated for the associated wallet +3. All operations are done in the `DBService.updateExpense` method + +### Deletion +When an expense is deleted: +1. If the expense had a wallet, the balance is restored to that wallet +2. The expense is then deleted (line items cascade) +3. All operations are done in the `DBService.deleteExpense` method + +## Frontend Components + +### Wallets Page (`/wallets`) +- Displays all user wallets in a card grid +- Shows current balance, initial balance, and spent amount +- Provides create, edit, and delete actions +- Empty state with call-to-action for first wallet + +### Wallet Form +- Create new wallet with name, initial balance, and currency +- Edit existing wallet (updates adjust current balance) +- Form validation ensures required fields + +### Expense Form Integration +- Wallet selector dropdown (optional) +- Shows wallet name, currency, and current balance +- Loads wallets asynchronously on form mount +- Positioned after currency field + +## Navigation + +### Desktop +- Added "Wallets" link to header navigation +- Positioned between "Expenses" and settings + +### Mobile +- Added to bottom navigation bar +- Uses wallet icon +- 4-item navigation (Scan, Expenses, Wallets, Settings) + +## Migration + +To apply the wallet migration: + +```bash +# Local development +npm run db:migrate:007 + +# Production +npm run db:migrate:007:prod +``` + +## Files Modified/Created + +### Backend +- `migrations/007_wallets.sql` - Database migration +- `worker/types.ts` - Added Wallet interface, updated Expense interface +- `worker/services/db.service.ts` - Added wallet CRUD methods, updated expense methods +- `worker/handlers/wallet.handler.ts` - New wallet API handlers +- `worker/handlers/expenses.handler.ts` - Updated to handle walletId +- `worker/router.ts` - Added wallet routes +- `worker/utils/validation.ts` - Updated expense schema to include walletId + +### Frontend +- `src/types.ts` - Updated Expense interface +- `src/lib/wallet-service.ts` - New wallet API service +- `src/pages/WalletsPage.tsx` - New wallets management page +- `src/components/ExpenseForm.tsx` - Added wallet selector +- `src/components/Layout.tsx` - Added wallet navigation +- `src/App.tsx` - Added wallet route +- `package.json` - Added migration scripts + +## Future Enhancements + +Potential features to add: +- Wallet transfers (move money between wallets) +- Wallet-specific expense filtering +- Wallet balance history/timeline +- Multi-currency wallet support with conversion +- Wallet categories or tags +- Spending limits per wallet +- Wallet sharing between users + +## Testing Checklist + +- [ ] Create a new wallet +- [ ] Edit wallet name +- [ ] Edit wallet initial balance (verify current balance adjusts) +- [ ] Delete a wallet +- [ ] Create expense without wallet (verify it works) +- [ ] Create expense with wallet (verify balance decreases) +- [ ] Edit expense to change wallet (verify both wallet balances update) +- [ ] Edit expense to change amount (verify wallet balance updates) +- [ ] Edit expense to remove wallet (verify balance is restored) +- [ ] Delete expense with wallet (verify balance is restored) +- [ ] Check mobile responsive design +- [ ] Verify navigation on desktop and mobile diff --git a/migrations/007_wallets.sql b/migrations/007_wallets.sql new file mode 100644 index 0000000..805ffdf --- /dev/null +++ b/migrations/007_wallets.sql @@ -0,0 +1,22 @@ +-- Migration 007: Wallets +-- Add support for user wallets to track separate funds + +-- Create wallets table +CREATE TABLE IF NOT EXISTS wallets ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + initial_balance REAL NOT NULL DEFAULT 0, + current_balance REAL NOT NULL DEFAULT 0, + currency TEXT NOT NULL DEFAULT 'EGP', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + +-- Add wallet_id to expenses table (nullable for backward compatibility) +ALTER TABLE expenses ADD COLUMN wallet_id TEXT; + +-- Create index for faster wallet lookups +CREATE INDEX IF NOT EXISTS idx_wallets_user_id ON wallets(user_id); +CREATE INDEX IF NOT EXISTS idx_expenses_wallet_id ON expenses(wallet_id); diff --git a/package.json b/package.json index 5aa36b8..7a13ffe 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,8 @@ "db:migrate:prod": "wrangler d1 execute focal_expensi_db --remote --file=./migrations/001_initial_schema.sql", "db:migrate:002": "wrangler d1 execute focal_expensi_db --local --file=./migrations/002_quantity_real.sql", "db:migrate:002:prod": "wrangler d1 execute focal_expensi_db --remote --file=./migrations/002_quantity_real.sql", + "db:migrate:007": "wrangler d1 execute focal_expensi_db --local --file=./migrations/007_wallets.sql", + "db:migrate:007:prod": "wrangler d1 execute focal_expensi_db --remote --file=./migrations/007_wallets.sql", "setup:prod": "bash scripts/setup-production.sh" }, "dependencies": { diff --git a/src/App.tsx b/src/App.tsx index 162b5de..4fd97c1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,6 +4,7 @@ import { HomePage } from "@/pages/HomePage"; import { ExpensesPage } from "@/pages/ExpensesPage"; import { SettingsPage } from "@/pages/SettingsPage"; import { AdminPage } from "@/pages/AdminPage"; +import WalletsPage from "@/pages/WalletsPage"; import { LoginPage } from "@/pages/LoginPage"; import { LandingPage } from "@/pages/LandingPage"; import VerifyEmailPage from "@/pages/VerifyEmailPage"; @@ -56,6 +57,10 @@ const router = createBrowserRouter([ path: "/expenses", element: , }, + { + path: "/wallets", + element: , + }, { path: "/settings", element: , diff --git a/src/components/ExpenseForm.tsx b/src/components/ExpenseForm.tsx index 4e04653..b5a70e9 100644 --- a/src/components/ExpenseForm.tsx +++ b/src/components/ExpenseForm.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useRef, useEffect } from "react"; +import React, { useCallback, useRef, useEffect, useState } from "react"; import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; @@ -9,8 +9,9 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { Trash2, Plus, ScanLine } from "lucide-react"; +import { Trash2, Plus, ScanLine, Wallet as WalletIcon } from "lucide-react"; import type { ExpenseData } from "@/lib/expense-service"; +import { getWallets, type Wallet } from "@/lib/wallet-service"; interface ExpenseFormProps { value: ExpenseData; onChange: (data: ExpenseData) => void; @@ -19,6 +20,25 @@ export const ExpenseForm: React.FC = ({ value, onChange, }) => { + const [wallets, setWallets] = useState([]); + const [loadingWallets, setLoadingWallets] = useState(true); + + // Load wallets on mount + useEffect(() => { + const fetchWallets = async () => { + try { + const fetchedWallets = await getWallets(); + setWallets(fetchedWallets); + } catch (error) { + console.error('Failed to load wallets:', error); + } finally { + setLoadingWallets(false); + } + }; + + fetchWallets(); + }, []); + // Use refs to keep stable references to latest value and onChange const valueRef = useRef(value); const onChangeRef = useRef(onChange); @@ -142,6 +162,31 @@ export const ExpenseForm: React.FC = ({ /> +
+ + +

Line Items

diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 5feaffe..8fcd469 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -51,6 +51,17 @@ const Header: React.FC = () => { > Expenses + + cn( + "font-semibold transition-colors duration-200", + isActive ? activeLinkClass : inactiveLinkClass + ) + } + > + Wallets +
+ + + + Create New Wallet + + Add a new wallet to track your funds separately + + +
+
+ + + setFormData({ ...formData, name: e.target.value }) + } + /> +
+
+ + + setFormData({ + ...formData, + initialBalance: parseFloat(e.target.value) || 0, + }) + } + /> +
+
+ + + setFormData({ + ...formData, + currency: e.target.value.toUpperCase(), + }) + } + /> +
+
+ + + + +
+ +
+ + {wallets.length === 0 ? ( + + + +

No wallets yet

+

+ Create your first wallet to start tracking your funds separately +

+ +
+
+ ) : ( +
+ {wallets.map((wallet) => ( + + + + + + {wallet.name} + +
+ + +
+
+ {wallet.currency} +
+ +
+
+

Current Balance

+

+ {wallet.currency} {wallet.current_balance.toFixed(2)} +

+
+
+

Initial Balance

+

+ {wallet.currency} {wallet.initial_balance.toFixed(2)} +

+
+
+

Spent

+

0 + ? 'text-red-600' + : 'text-green-600' + }`} + > + {wallet.currency}{' '} + {(wallet.initial_balance - wallet.current_balance).toFixed(2)} +

+
+
+
+
+ ))} +
+ )} + + {/* Edit Dialog */} + + + + Edit Wallet + + Update wallet name, initial balance, or currency + + +
+
+ + + setFormData({ ...formData, name: e.target.value }) + } + /> +
+
+ + + setFormData({ + ...formData, + initialBalance: parseFloat(e.target.value) || 0, + }) + } + /> +
+
+ + + setFormData({ + ...formData, + currency: e.target.value.toUpperCase(), + }) + } + /> +
+
+ + + + +
+
+ + {/* Delete Confirmation Dialog */} + + + + Are you sure? + + This will permanently delete the wallet "{selectedWallet?.name}". + This action cannot be undone. + + + + Cancel + + {submitting && } + Delete + + + + + + ); +} diff --git a/src/types.ts b/src/types.ts index e59bdb9..2f55e6c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -14,6 +14,7 @@ export interface Expense { lineItems: LineItem[]; currency: string; category: string; + walletId?: string | null; } export type ExpenseData = Omit; diff --git a/worker/handlers/expenses.handler.ts b/worker/handlers/expenses.handler.ts index 25fbb1f..dfb3429 100644 --- a/worker/handlers/expenses.handler.ts +++ b/worker/handlers/expenses.handler.ts @@ -75,7 +75,7 @@ export async function createExpense(c: Context<{ Bindings: Env; Variables: Varia return error(validation.error, 400); } - const { merchant, date, total, currency, category, lineItems } = validation.data; + const { merchant, date, total, currency, category, walletId, lineItems } = validation.data; // Create expense const expenseId = crypto.randomUUID(); @@ -88,6 +88,7 @@ export async function createExpense(c: Context<{ Bindings: Env; Variables: Varia total, currency, category, + wallet_id: walletId || null, }, lineItems ); @@ -126,7 +127,7 @@ export async function updateExpense(c: Context<{ Bindings: Env; Variables: Varia return error(validation.error, 400); } - const { merchant, date, total, currency, category, lineItems } = validation.data; + const { merchant, date, total, currency, category, walletId, lineItems } = validation.data; // Update expense await dbService.updateExpense( @@ -138,6 +139,7 @@ export async function updateExpense(c: Context<{ Bindings: Env; Variables: Varia total, currency, category, + wallet_id: walletId !== undefined ? walletId : undefined, }, lineItems ); diff --git a/worker/handlers/wallet.handler.ts b/worker/handlers/wallet.handler.ts new file mode 100644 index 0000000..4b97bfb --- /dev/null +++ b/worker/handlers/wallet.handler.ts @@ -0,0 +1,154 @@ +import { Context } from 'hono'; +import { Env } from '../types'; +import { DBService } from '../services/db.service'; +import { validateRequest } from '../utils/validation'; +import { success, error, json, notFound } from '../utils/response'; +import { z } from 'zod'; + +type Variables = { + userId: string; + userEmail: string; + token: string; +}; + +// Validation schema for wallet +const walletSchema = z.object({ + name: z.string().min(1, 'Wallet name is required').max(100, 'Wallet name too long'), + initialBalance: z.number().min(0, 'Initial balance must be non-negative'), + currency: z.string().min(3, 'Currency code required').max(3, 'Invalid currency code'), +}); + +/** + * GET /api/wallets + * Get all wallets for the current user + */ +export async function getWallets(c: Context<{ Bindings: Env; Variables: Variables }>) { + const env = c.env; + const userId = c.get('userId'); + const dbService = new DBService(env.DB); + + const wallets = await dbService.getWalletsByUserId(userId); + + return json(success(wallets)); +} + +/** + * GET /api/wallets/:id + * Get a single wallet by ID + */ +export async function getWalletById(c: Context<{ Bindings: Env; Variables: Variables }>) { + const env = c.env; + const userId = c.get('userId'); + const walletId = c.req.param('id'); + const dbService = new DBService(env.DB); + + const wallet = await dbService.getWalletById(walletId, userId); + if (!wallet) { + return notFound('Wallet not found'); + } + + return json(success(wallet)); +} + +/** + * POST /api/wallets + * Create a new wallet + */ +export async function createWallet(c: Context<{ Bindings: Env; Variables: Variables }>) { + const env = c.env; + const userId = c.get('userId'); + const dbService = new DBService(env.DB); + + // Validate request body + const validation = await validateRequest(c.req.raw, walletSchema); + if (!validation.success) { + return error(validation.error, 400); + } + + const { name, initialBalance, currency } = validation.data; + + // Create wallet + const walletId = crypto.randomUUID(); + const wallet = await dbService.createWallet({ + id: walletId, + user_id: userId, + name, + initial_balance: initialBalance, + current_balance: initialBalance, + currency, + }); + + return json(success(wallet), 201); +} + +/** + * PUT /api/wallets/:id + * Update an existing wallet + */ +export async function updateWallet(c: Context<{ Bindings: Env; Variables: Variables }>) { + const env = c.env; + const userId = c.get('userId'); + const walletId = c.req.param('id'); + const dbService = new DBService(env.DB); + + // Check if wallet exists and belongs to user + const existingWallet = await dbService.getWalletById(walletId, userId); + if (!existingWallet) { + return notFound('Wallet not found'); + } + + // Validate request body (all fields optional for update) + const updateSchema = z.object({ + name: z.string().min(1).max(100).optional(), + initialBalance: z.number().min(0).optional(), + currency: z.string().min(3).max(3).optional(), + }); + + const validation = await validateRequest(c.req.raw, updateSchema); + if (!validation.success) { + return error(validation.error, 400); + } + + const { name, initialBalance, currency } = validation.data; + + // Calculate new current_balance if initial_balance changed + const updates: any = {}; + if (name !== undefined) updates.name = name; + if (currency !== undefined) updates.currency = currency; + if (initialBalance !== undefined) { + updates.initial_balance = initialBalance; + // Adjust current balance by the difference + const difference = initialBalance - existingWallet.initial_balance; + updates.current_balance = existingWallet.current_balance + difference; + } + + // Update wallet + await dbService.updateWallet(walletId, userId, updates); + + // Fetch updated wallet + const updatedWallet = await dbService.getWalletById(walletId, userId); + + return json(success(updatedWallet)); +} + +/** + * DELETE /api/wallets/:id + * Delete a wallet + */ +export async function deleteWallet(c: Context<{ Bindings: Env; Variables: Variables }>) { + const env = c.env; + const userId = c.get('userId'); + const walletId = c.req.param('id'); + const dbService = new DBService(env.DB); + + // Check if wallet exists and belongs to user + const wallet = await dbService.getWalletById(walletId, userId); + if (!wallet) { + return notFound('Wallet not found'); + } + + // Delete wallet + await dbService.deleteWallet(walletId, userId); + + return json(success({ message: 'Wallet deleted successfully' })); +} diff --git a/worker/router.ts b/worker/router.ts index 2f97652..0f327e6 100644 --- a/worker/router.ts +++ b/worker/router.ts @@ -8,6 +8,7 @@ import * as apiKeysHandler from './handlers/apiKeys.handler'; import * as receiptsHandler from './handlers/receipts.handler'; import * as errorsHandler from './handlers/errors.handler'; import * as adminHandler from './handlers/admin.handler'; +import * as walletHandler from './handlers/wallet.handler'; type Variables = { userId: string; @@ -51,6 +52,13 @@ export function createRouter() { app.put('/expenses/:id', authMiddleware, expensesHandler.updateExpense); app.delete('/expenses/:id', authMiddleware, expensesHandler.deleteExpense); + // ============ WALLET ROUTES (Protected) ============ + app.get('/wallets', authMiddleware, walletHandler.getWallets); + app.get('/wallets/:id', authMiddleware, walletHandler.getWalletById); + app.post('/wallets', authMiddleware, walletHandler.createWallet); + app.put('/wallets/:id', authMiddleware, walletHandler.updateWallet); + app.delete('/wallets/:id', authMiddleware, walletHandler.deleteWallet); + // ============ RECEIPT PROCESSING ROUTES (Protected) ============ app.post('/receipts/process', authMiddleware, receiptsHandler.processReceipt); app.post('/receipts/process-audio', authMiddleware, receiptsHandler.processAudioReceipt); diff --git a/worker/services/db.service.ts b/worker/services/db.service.ts index ca0f382..494c2da 100644 --- a/worker/services/db.service.ts +++ b/worker/services/db.service.ts @@ -1,4 +1,4 @@ -import { Env, User, Expense, LineItem, ApiKey, Session } from '../types'; +import { Env, User, Expense, LineItem, ApiKey, Session, Wallet } from '../types'; // ============ ADMIN ANALYTICS TYPES ============ @@ -150,8 +150,8 @@ export class DBService { // Insert expense await this.db - .prepare('INSERT INTO expenses (id, user_id, merchant, date, total, currency, category, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)') - .bind(expense.id, expense.user_id, expense.merchant, expense.date, expense.total, expense.currency, expense.category, now, now) + .prepare('INSERT INTO expenses (id, user_id, merchant, date, total, currency, category, wallet_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)') + .bind(expense.id, expense.user_id, expense.merchant, expense.date, expense.total, expense.currency, expense.category, expense.wallet_id || null, now, now) .run(); // Insert line items @@ -163,6 +163,15 @@ export class DBService { .run(); } + // Update wallet balance if wallet_id is provided + if (expense.wallet_id) { + const wallet = await this.getWalletById(expense.wallet_id, expense.user_id); + if (wallet) { + const newBalance = wallet.current_balance - expense.total; + await this.updateWalletBalance(expense.wallet_id, expense.user_id, newBalance); + } + } + return { ...expense, created_at: now, @@ -200,9 +209,15 @@ export class DBService { async updateExpense(id: string, userId: string, updates: Partial>, lineItems?: Array<{ description: string; quantity: number; price: number }>): Promise { const now = Date.now(); + // Get the existing expense to handle wallet balance updates + const existingExpense = await this.getExpenseById(id, userId); + if (!existingExpense) { + throw new Error('Expense not found'); + } + // Build dynamic UPDATE query const fields: string[] = []; - const values: (string | number)[] = []; + const values: (string | number | null)[] = []; if (updates.merchant !== undefined) { fields.push('merchant = ?'); @@ -224,6 +239,10 @@ export class DBService { fields.push('category = ?'); values.push(updates.category); } + if (updates.wallet_id !== undefined) { + fields.push('wallet_id = ?'); + values.push(updates.wallet_id || null); + } fields.push('updated_at = ?'); values.push(now); @@ -235,6 +254,32 @@ export class DBService { .bind(...values) .run(); + // Handle wallet balance updates + const newTotal = updates.total !== undefined ? updates.total : existingExpense.total; + const oldWalletId = existingExpense.wallet_id; + const newWalletId = updates.wallet_id !== undefined ? updates.wallet_id : existingExpense.wallet_id; + + // If wallet changed or total changed, update balances + if (oldWalletId !== newWalletId || (newWalletId && updates.total !== undefined)) { + // Restore balance to old wallet + if (oldWalletId) { + const oldWallet = await this.getWalletById(oldWalletId, userId); + if (oldWallet) { + const restoredBalance = oldWallet.current_balance + existingExpense.total; + await this.updateWalletBalance(oldWalletId, userId, restoredBalance); + } + } + + // Deduct from new wallet + if (newWalletId) { + const newWallet = await this.getWalletById(newWalletId, userId); + if (newWallet) { + const newBalance = newWallet.current_balance - newTotal; + await this.updateWalletBalance(newWalletId, userId, newBalance); + } + } + } + // Update line items if provided if (lineItems) { // Delete old line items @@ -255,6 +300,18 @@ export class DBService { } async deleteExpense(id: string, userId: string): Promise { + // Get the expense to restore wallet balance + const expense = await this.getExpenseById(id, userId); + + if (expense && expense.wallet_id) { + // Restore balance to wallet before deleting + const wallet = await this.getWalletById(expense.wallet_id, userId); + if (wallet) { + const restoredBalance = wallet.current_balance + expense.total; + await this.updateWalletBalance(expense.wallet_id, userId, restoredBalance); + } + } + // Line items will be cascade deleted await this.db .prepare('DELETE FROM expenses WHERE id = ? AND user_id = ?') @@ -600,4 +657,89 @@ export class DBService { return expensesWithLineItems; } + + // ============ WALLET OPERATIONS ============ + + async createWallet(wallet: Omit): Promise { + const now = Date.now(); + + await this.db + .prepare('INSERT INTO wallets (id, user_id, name, initial_balance, current_balance, currency, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)') + .bind(wallet.id, wallet.user_id, wallet.name, wallet.initial_balance, wallet.current_balance, wallet.currency, now, now) + .run(); + + return { + ...wallet, + created_at: now, + updated_at: now, + }; + } + + async getWalletsByUserId(userId: string): Promise { + const result = await this.db + .prepare('SELECT * FROM wallets WHERE user_id = ? ORDER BY created_at DESC') + .bind(userId) + .all(); + + return result.results || []; + } + + async getWalletById(id: string, userId: string): Promise { + const result = await this.db + .prepare('SELECT * FROM wallets WHERE id = ? AND user_id = ?') + .bind(id, userId) + .first(); + + return result || null; + } + + async updateWallet(id: string, userId: string, updates: Partial>): Promise { + const now = Date.now(); + + const fields: string[] = []; + const values: (string | number)[] = []; + + if (updates.name !== undefined) { + fields.push('name = ?'); + values.push(updates.name); + } + if (updates.initial_balance !== undefined) { + fields.push('initial_balance = ?'); + values.push(updates.initial_balance); + } + if (updates.current_balance !== undefined) { + fields.push('current_balance = ?'); + values.push(updates.current_balance); + } + if (updates.currency !== undefined) { + fields.push('currency = ?'); + values.push(updates.currency); + } + + fields.push('updated_at = ?'); + values.push(now); + + values.push(id, userId); + + await this.db + .prepare(`UPDATE wallets SET ${fields.join(', ')} WHERE id = ? AND user_id = ?`) + .bind(...values) + .run(); + } + + async updateWalletBalance(id: string, userId: string, newBalance: number): Promise { + const now = Date.now(); + + await this.db + .prepare('UPDATE wallets SET current_balance = ?, updated_at = ? WHERE id = ? AND user_id = ?') + .bind(newBalance, now, id, userId) + .run(); + } + + async deleteWallet(id: string, userId: string): Promise { + await this.db + .prepare('DELETE FROM wallets WHERE id = ? AND user_id = ?') + .bind(id, userId) + .run(); + } } diff --git a/worker/types.ts b/worker/types.ts index 6a8242a..3905827 100644 --- a/worker/types.ts +++ b/worker/types.ts @@ -57,6 +57,7 @@ export interface Expense { total: number; currency: string; category: string; + wallet_id?: string | null; // Optional wallet association created_at: number; updated_at: number; } @@ -84,6 +85,17 @@ export interface JWTPayload { exp?: number; } +export interface Wallet { + id: string; + user_id: string; + name: string; + initial_balance: number; + current_balance: number; + currency: string; + created_at: number; + updated_at: number; +} + export interface APIResponse { success: boolean; data?: T; diff --git a/worker/utils/validation.ts b/worker/utils/validation.ts index bf9c25c..76cb2d4 100644 --- a/worker/utils/validation.ts +++ b/worker/utils/validation.ts @@ -20,6 +20,7 @@ export const expenseSchema = z.object({ total: z.number().min(0, 'Total must be a non-negative number'), currency: z.string().length(3, 'Currency must be a 3-letter ISO code'), category: z.enum(['Food & Drink', 'Groceries', 'Travel', 'Shopping', 'Utilities', 'Other']), + walletId: z.string().optional().nullable(), lineItems: z.array( z.object({ description: z.string().min(1, 'Description is required'),