Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
189 changes: 189 additions & 0 deletions docs/WALLET_FEATURE.md
Original file line number Diff line number Diff line change
@@ -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
22 changes: 22 additions & 0 deletions migrations/007_wallets.sql
Original file line number Diff line number Diff line change
@@ -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);
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
5 changes: 5 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -56,6 +57,10 @@ const router = createBrowserRouter([
path: "/expenses",
element: <ExpensesPage />,
},
{
path: "/wallets",
element: <WalletsPage />,
},
{
path: "/settings",
element: <SettingsPage />,
Expand Down
49 changes: 47 additions & 2 deletions src/components/ExpenseForm.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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;
Expand All @@ -19,6 +20,25 @@ export const ExpenseForm: React.FC<ExpenseFormProps> = ({
value,
onChange,
}) => {
const [wallets, setWallets] = useState<Wallet[]>([]);
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);
Expand Down Expand Up @@ -142,6 +162,31 @@ export const ExpenseForm: React.FC<ExpenseFormProps> = ({
/>
</div>
</div>
<div>
<Label htmlFor="wallet" className="text-sm flex items-center gap-1">
<WalletIcon className="h-3 w-3" />
Wallet (Optional)
</Label>
<Select
value={value.walletId || "none"}
onValueChange={(newValue) =>
handleFieldChange("walletId", newValue === "none" ? null : newValue)
}
disabled={loadingWallets}
>
<SelectTrigger className="text-sm sm:text-base">
<SelectValue placeholder={loadingWallets ? "Loading wallets..." : "Select wallet"} />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">No wallet</SelectItem>
{wallets.map((wallet) => (
<SelectItem key={wallet.id} value={wallet.id}>
{wallet.name} ({wallet.currency} {wallet.current_balance.toFixed(2)})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<h3 className="font-semibold pt-3 sm:pt-4 border-t text-sm sm:text-base">
Line Items
</h3>
Expand Down
17 changes: 16 additions & 1 deletion src/components/Layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,17 @@ const Header: React.FC = () => {
>
Expenses
</NavLink>
<NavLink
to="/wallets"
className={({ isActive }) =>
cn(
"font-semibold transition-colors duration-200",
isActive ? activeLinkClass : inactiveLinkClass
)
}
>
Wallets
</NavLink>
</nav>
<div className="flex items-center gap-2">
<Button
Expand Down Expand Up @@ -102,7 +113,7 @@ const BottomNav: React.FC = () => {
const inactiveLinkClass = "text-gray-500 dark:text-gray-400";
const getLinkClass = (path: string) =>
cn(
"flex flex-col items-center gap-1 transition-colors duration-200 w-1/3",
"flex flex-col items-center gap-1 transition-colors duration-200 w-1/4",
location.pathname === path ? activeLinkClass : inactiveLinkClass
);
return (
Expand All @@ -115,6 +126,10 @@ const BottomNav: React.FC = () => {
<Wallet className="h-6 w-6" />
<span className="text-xs font-medium">Expenses</span>
</NavLink>
<NavLink to="/wallets" className={getLinkClass("/wallets")}>
<Wallet className="h-6 w-6" />
<span className="text-xs font-medium">Wallets</span>
</NavLink>
<NavLink to="/settings" className={getLinkClass("/settings")}>
<Settings className="h-6 w-6" />
<span className="text-xs font-medium">Settings</span>
Expand Down
Loading