Merge pull request #32 from acedanger/feature/database-integration

Replace in-memory store with database persistence
This commit is contained in:
Peter Wood
2025-05-06 06:14:56 -04:00
committed by GitHub
37 changed files with 2228 additions and 4059 deletions

View File

@@ -9,7 +9,7 @@ This project is a web user interface (UI) for a CRUD (Create, Read, Update, Dele
* **Framework:** Astro (latest version) * **Framework:** Astro (latest version)
* **Language:** TypeScript, JavaScript (client-side scripts), HTML, CSS * **Language:** TypeScript, JavaScript (client-side scripts), HTML, CSS
* **Styling:** Plain CSS (`src/styles/global.css`) * **Styling:** Plain CSS (`src/styles/global.css`)
* **Data:** Using Astro's built-in API routes in `src/pages/api/` with a temporary in-memory store (`src/data/store.ts`). **The goal is to eventually replace the in-memory store with a persistent database.** * **Data:** Using Astro's built-in API routes in `src/pages/api/` with persistent database storage via Prisma ORM (`src/data/db.service.ts`).
* **Development Environment:** VS Code Dev Container using private Docker image (`ghcr.io/acedanger/finance-devcontainer:latest`) * **Development Environment:** VS Code Dev Container using private Docker image (`ghcr.io/acedanger/finance-devcontainer:latest`)
## Development Environment ## Development Environment
@@ -41,20 +41,22 @@ This project is a web user interface (UI) for a CRUD (Create, Read, Update, Dele
* **Components:** Separate Astro components exist for major UI sections (Sidebar, MainContent, TransactionTable, AddTransactionForm, AccountSummary). * **Components:** Separate Astro components exist for major UI sections (Sidebar, MainContent, TransactionTable, AddTransactionForm, AccountSummary).
* **API Integration:** * **API Integration:**
* API routes structure implemented in `src/pages/api/` * API routes structure implemented in `src/pages/api/`
* Temporary data store in `src/data/store.ts` * Database integration using Prisma ORM in `src/data/db.service.ts`
* All API endpoints implemented and ready to use: * All API endpoints implemented and fully functional:
* GET /api/accounts - List all accounts * GET /api/accounts - List all accounts
* GET /api/accounts/:id - Get single account details * GET /api/accounts/:id - Get single account details
* GET /api/accounts/:id/transactions - Get transactions for an account * GET /api/accounts/:id/transactions - Get transactions for an account
* POST /api/transactions - Create new transaction * POST /api/transactions - Create new transaction
* PUT /api/transactions/:id - Update existing transaction * PUT /api/transactions/:id - Update existing transaction
* DELETE /api/transactions/:id - Delete transaction * DELETE /api/transactions/:id - Delete transaction
* Error handling and validation included * Comprehensive error handling and validation
* Prepared for future database integration with modular store design * Database persistence with proper transaction support
* **Account Switching:** Selecting an account from the dropdown in the sidebar correctly updates the Main Content area (header, transaction table) and the Account Summary section using client-side JavaScript (`<script>` tag in `index.astro`). * **Account Switching:** Selecting an account from the dropdown in the sidebar correctly updates the Main Content area (header, transaction table) and the Account Summary section using client-side JavaScript (`<script>` tag in `index.astro`).
* **Collapsible Form:** The "Add Transaction" section in the sidebar (`src/components/AddTransactionForm.astro`) can be expanded and collapsed using client-side JavaScript (`<script>` tag in `AddTransactionForm.astro`). * **Collapsible Form:** The "Add Transaction" section in the sidebar (`src/components/AddTransactionForm.astro`) can be expanded and collapsed using client-side JavaScript (`<script>` tag in `AddTransactionForm.astro`).
* **Basic Formatting:** Utility functions (`src/utils.ts`) exist for formatting currency and dates, used both server-side and client-side (mirrored in `index.astro` script). * **Basic Formatting:** Utility functions (`src/utils.ts`) exist for formatting currency and dates, used both server-side and client-side (mirrored in `index.astro` script).
* **Types:** Basic TypeScript types for `Account` and `Transaction` are defined in `src/types.ts`. * **Types:** Basic TypeScript types for `Account` and `Transaction` are defined in `src/types.ts`.
* **State Management:** Client-side state management using NanoStores, providing a reactive state for transactions and account data (`src/stores/transactionStore.ts`).
* **Database Integration:** Complete integration with a relational database using Prisma ORM, including transaction support to maintain data integrity.
## File Structure Overview ## File Structure Overview
@@ -64,22 +66,32 @@ This project is a web user interface (UI) for a CRUD (Create, Read, Update, Dele
* `.env.example`: Template for container environment variables * `.env.example`: Template for container environment variables
* `src/components/`: Reusable UI components. * `src/components/`: Reusable UI components.
* `src/data/`: Data store and persistence layer. * `src/data/`: Data store and persistence layer.
* `db.service.ts`: Database service layer using Prisma ORM
* `prisma.ts`: Prisma client initialization
* `src/layouts/`: Base page layout(s). * `src/layouts/`: Base page layout(s).
* `src/pages/`: Astro pages and API routes. * `src/pages/`: Astro pages and API routes.
* `index.astro`: Main page * `index.astro`: Main page
* `api/`: Backend API endpoints * `api/`: Backend API endpoints
* `accounts/`: Account-related endpoints * `accounts/`: Account-related endpoints
* `transactions/`: Transaction-related endpoints * `transactions/`: Transaction-related endpoints
* `src/stores/`: Client-side state management.
* `transactionStore.ts`: NanoStore implementation for transactions
* `src/styles/`: Global CSS styles. * `src/styles/`: Global CSS styles.
* `src/types.ts`: TypeScript type definitions. * `src/types.ts`: TypeScript type definitions.
* `src/utils.ts`: Utility functions (formatting, etc.). * `src/utils.ts`: Utility functions (formatting, etc.).
* `public/`: Static assets. * `public/`: Static assets.
* `prisma/`: Database schema and migrations.
* `schema.prisma`: Prisma schema definition
* `migrations/`: Database migrations
* `seed.ts`: Seed data script
## Next Steps & TODOs ## Next Steps & TODOs
1. **Complete API Implementation:** 1. **Fix Update Button Issue:**
* Add error handling and validation * Fix the disabled state of the update button in transaction editing mode (see issue #33)
* Prepare for future database integration * Ensure proper form validation state management
* Test updates to transactions thoroughly
2. **Implement Create Functionality:** 2. **Implement Create Functionality:**
* Add client-side JavaScript to the `AddTransactionForm.astro` component (or enhance the script in `index.astro`) to handle form submission. * Add client-side JavaScript to the `AddTransactionForm.astro` component (or enhance the script in `index.astro`) to handle form submission.
* Prevent default form submission. * Prevent default form submission.
@@ -91,6 +103,7 @@ This project is a web user interface (UI) for a CRUD (Create, Read, Update, Dele
* Refresh the transaction list for the current account (either by re-fetching or adding the new transaction to the client-side state). * Refresh the transaction list for the current account (either by re-fetching or adding the new transaction to the client-side state).
* Update the account balance display. * Update the account balance display.
* Handle API errors (display messages to the user). * Handle API errors (display messages to the user).
3. **Implement Update Functionality:** 3. **Implement Update Functionality:**
* Add event listeners to the "Edit" buttons in `TransactionTable.astro`. * Add event listeners to the "Edit" buttons in `TransactionTable.astro`.
* When "Edit" is clicked: * When "Edit" is clicked:
@@ -102,6 +115,7 @@ This project is a web user interface (UI) for a CRUD (Create, Read, Update, Dele
* Refresh the transaction list. * Refresh the transaction list.
* Update the account balance. * Update the account balance.
* Handle API errors. * Handle API errors.
4. **Implement Delete Functionality:** 4. **Implement Delete Functionality:**
* Add event listeners to the "Delete" buttons in `TransactionTable.astro`. * Add event listeners to the "Delete" buttons in `TransactionTable.astro`.
* When "Delete" is clicked: * When "Delete" is clicked:
@@ -111,8 +125,11 @@ This project is a web user interface (UI) for a CRUD (Create, Read, Update, Dele
* Remove the transaction row from the UI. * Remove the transaction row from the UI.
* Update the account balance. * Update the account balance.
* Handle API errors. * Handle API errors.
5. **Refine State Management:** As complexity grows, consider a more robust client-side state management solution if passing data via `define:vars` and simple DOM manipulation becomes unwieldy (e.g., using Nano Stores or a more full-featured framework integration if needed later).
5. **Refine State Management:** Continue improving the NanoStores implementation for better reactivity and handling of complex application states.
6. **Error Handling:** Implement more robust error handling and user feedback for API calls. 6. **Error Handling:** Implement more robust error handling and user feedback for API calls.
7. **Styling/UI Improvements:** Refine CSS, potentially add loading indicators, improve responsiveness further. 7. **Styling/UI Improvements:** Refine CSS, potentially add loading indicators, improve responsiveness further.
## Code Style & Conventions ## Code Style & Conventions

1
.node-version Normal file
View File

@@ -0,0 +1 @@
20.19.0

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
20.19.0

17
.vscode/settings.json vendored
View File

@@ -3,5 +3,20 @@
"terminal.integrated.enablePersistentSessions": true, "terminal.integrated.enablePersistentSessions": true,
"terminal.integrated.persistentSessionReviveProcess": "onExitAndWindowClose", "terminal.integrated.persistentSessionReviveProcess": "onExitAndWindowClose",
"terminal.integrated.enableMultiLinePasteWarning": "auto", "terminal.integrated.enableMultiLinePasteWarning": "auto",
"terminal.integrated.splitCwd": "workspaceRoot" "terminal.integrated.splitCwd": "workspaceRoot",
"rest-client.environmentVariables": {
"$shared": {
"version": "v1"
},
"development": {
"host": "localhost",
"port": "4321",
"baseUrl": "http://localhost:4321/api"
},
"production": {
"host": "finance-app-production.example.com",
"port": "443",
"baseUrl": "https://finance-app-production.example.com/api"
}
}
} }

View File

@@ -13,8 +13,15 @@ export default defineConfig({
}), }),
integrations: [react()], integrations: [react()],
vite: { vite: {
resolve: {
alias: {
// Use the browser version of react-dom/server for client-side rendering
'react-dom/server.browser': 'react-dom/cjs/react-dom-server.browser.production.min.js',
},
},
// Prevent server-only modules from being bundled for client side
ssr: { ssr: {
noExternal: ['react-dom/server', 'react-dom/server.browser'], noExternal: ['react-dom/server'],
}, },
}, },
}); });

View File

@@ -1,9 +1,57 @@
POST /api/bank-account/update/2 HTTP/1.1 @baseUrl = {{baseUrl}}
@host = {{host}}
@port = {{port}}
# Bank Account API Testing
### Get all accounts
GET {{baseUrl}}/accounts
Accept: application/json
### Get a specific account
GET {{baseUrl}}/accounts/1
Accept: application/json
### Get transactions for an account
GET {{baseUrl}}/accounts/1/transactions
Accept: application/json
### Create a new account
POST {{baseUrl}}/accounts
Content-Type: application/json Content-Type: application/json
Host: europa:3050
Content-Length: 85
{"name": "BofA Joint Checking","bankName": "Bank of America","accountNumber": "4581"} {
"name": "BofA Joint Checking",
"bankName": "Bank of America",
"accountNumber": "4581",
"balance": 1500,
"type": "CHECKING",
"status": "ACTIVE"
}
### ### Create a new transaction
POST {{baseUrl}}/transactions
Content-Type: application/json
{
"accountId": "1",
"date": "2025-05-06",
"description": "Coffee Shop",
"amount": -12.50,
"category": "Food & Dining",
"type": "WITHDRAWAL"
}
### Update a transaction
PUT {{baseUrl}}/transactions/1
Content-Type: application/json
{
"description": "Updated Coffee Purchase",
"amount": -15.75,
"category": "Food & Dining"
}
### Delete a transaction
DELETE {{baseUrl}}/transactions/1

View File

@@ -0,0 +1,155 @@
# Database Integration Testing Plan
This document outlines the testing strategy for verifying the successful integration of our PostgreSQL database with the finance application.
## Prerequisites
Before running tests, ensure that:
1. PostgreSQL is running (via Docker or locally)
2. Database migrations have been applied (`npm run db:migrate -- --name initial`)
3. Seed data has been loaded (`npm run db:seed`)
4. The application server is running (`npm run dev`)
## 1. Database Connection Testing
- **Test Case 1.1:** Verify Prisma can connect to the database
- Run `npx prisma studio` to open the Prisma Studio interface
- Verify you can view the database tables and data
- **Test Case 1.2:** Check database tables creation
- Verify `accounts` and `transactions` tables exist
- Verify all columns match the schema definition
- Check that proper indexes are created
## 2. Account Service Testing
- **Test Case 2.1:** Get all accounts
- Access `/api/accounts` endpoint
- Verify it returns the seeded accounts with correct data
- Check response format and status code (200)
- **Test Case 2.2:** Get single account
- Get account ID from the list of accounts
- Access `/api/accounts/{id}` endpoint
- Verify it returns the correct account data
- Check response format and status code (200)
- **Test Case 2.3:** Handle non-existent account
- Access `/api/accounts/nonexistent-id` endpoint
- Verify it returns a 404 status code with appropriate error message
## 3. Transaction Service Testing
- **Test Case 3.1:** Get account transactions
- Get account ID from the list of accounts
- Access `/api/accounts/{id}/transactions` endpoint
- Verify it returns the correct transactions for that account
- Check response format and status code (200)
- **Test Case 3.2:** Create new transaction
- Send POST request to `/api/transactions` with valid transaction data
- Verify the response contains the created transaction with an ID
- Check that account balance is updated correctly
- Check response status code (201)
- **Test Case 3.3:** Update transaction
- Get transaction ID from a list of transactions
- Send PUT request to `/api/transactions/{id}` with updated data
- Verify the transaction is updated in the database
- Check that account balance is adjusted correctly
- Check response status code (200)
- **Test Case 3.4:** Delete transaction
- Get transaction ID from a list of transactions
- Send DELETE request to `/api/transactions/{id}`
- Verify the transaction is removed from the database
- Check that account balance is adjusted correctly
- Check response status code (204)
## 4. Error Handling Testing
- **Test Case 4.1:** Submit invalid transaction data
- Send POST request with missing required fields
- Verify appropriate error messages are returned
- Check response status code (400)
- **Test Case 4.2:** Update non-existent transaction
- Send PUT request to `/api/transactions/nonexistent-id`
- Verify appropriate error message is returned
- Check response status code (404)
- **Test Case 4.3:** Delete non-existent transaction
- Send DELETE request to `/api/transactions/nonexistent-id`
- Verify appropriate error message is returned
- Check response status code (404)
## 5. UI Integration Testing
- **Test Case 5.1:** Account selection updates
- Select different accounts from the dropdown
- Verify account summary (balance) updates correctly
- Verify transaction table updates to show correct transactions
- **Test Case 5.2:** Add transaction form submission
- Fill out the transaction form with valid data
- Submit the form
- Verify the new transaction appears in the transaction table
- Verify account balance updates correctly
- **Test Case 5.3:** Edit transaction functionality
- Click edit button on an existing transaction
- Modify transaction data and submit
- Verify the transaction is updated in the transaction table
- Verify account balance updates correctly
- **Test Case 5.4:** Delete transaction functionality
- Click delete button on an existing transaction
- Confirm deletion
- Verify the transaction is removed from the transaction table
- Verify account balance updates correctly
## 6. Data Consistency Testing
- **Test Case 6.1:** Account balance consistency
- Calculate the sum of transaction amounts for an account
- Compare with the account balance in the database
- Verify they match exactly
- **Test Case 6.2:** Transaction sequence
- Create multiple transactions rapidly
- Verify all transactions are saved correctly
- Check that account balance reflects all transactions
## 7. Performance Testing
- **Test Case 7.1:** Load testing with multiple transactions
- Create a test script that adds 100+ transactions
- Verify the application handles the load without errors
- Check response times remain reasonable
## Test Automation
Consider creating automated tests for these scenarios using the following approaches:
1. **API Tests:** Use Vitest with Supertest to automate API endpoint testing
2. **Integration Tests:** Use Vitest with JSDOM to test UI components with mocked API calls
3. **E2E Tests:** Consider adding Playwright or Cypress for end-to-end testing
## Reporting Issues
Document any issues found during testing with:
1. Steps to reproduce
2. Expected behavior
3. Actual behavior
4. Screenshots or console logs if applicable
## Success Criteria
The database integration will be considered successfully tested when:
1. All CRUD operations work correctly through both API and UI
2. Account balances remain consistent with transactions
3. Error handling works correctly for all error cases
4. The application maintains performance under expected load

View File

@@ -1,413 +0,0 @@
Okay, let's set up a skeleton Fastify API project with Prisma to interact with a PostgreSQL database. This structure will provide the requested endpoints.
**Assumptions:**
1. You have Node.js and npm (or yarn) installed.
2. You have PostgreSQL installed and running locally.
3. You have a PostgreSQL database created (e.g., `finance_db`).
4. You have a PostgreSQL user with privileges on that database.
**Project Setup Steps:**
1. **Create Project Directory & Initialize:**
```bash
mkdir finance-api
cd finance-api
npm init -y
```
2. **Install Dependencies:**
```bash
# Runtime dependencies
npm install fastify @prisma/client dotenv
# Development dependencies
npm install --save-dev prisma typescript @types/node ts-node nodemon
# Or if you prefer plain Javascript, skip typescript/ts-node and adjust scripts
```
* `fastify`: The web framework.
* `@prisma/client`: The Prisma database client.
* `dotenv`: To load environment variables from a `.env` file.
* `prisma`: The Prisma CLI (for migrations, generation).
* `typescript`, `@types/node`, `ts-node`: For TypeScript support (recommended).
* `nodemon`: To automatically restart the server during development.
3. **Initialize Prisma:**
```bash
npx prisma init --datasource-provider postgresql
```
* This creates a `prisma` directory with a `schema.prisma` file and a `.env` file.
4. **Configure `.env`:**
Edit the newly created `.env` file and set your PostgreSQL connection URL:
```dotenv
# .env
# Replace with your actual database user, password, host, port, and database name
DATABASE_URL="postgresql://YOUR_USER:YOUR_PASSWORD@localhost:5432/finance_db?schema=public"
# API Server Configuration (Optional, but good practice)
API_HOST=0.0.0.0
API_PORT=3050
API_BASE_URL=https://finance.ptrwd.com # Used for documentation/reference, not binding
```
5. **Define Prisma Schema (`prisma/schema.prisma`):**
Define the model for your bank accounts.
```prisma
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model BankAccount {
id Int @id @default(autoincrement())
name String // e.g., "Checking Account", "Savings XYZ"
bankName String // e.g., "Chase", "Wells Fargo"
accountNumber String @unique // Consider encryption in a real app
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("bank_accounts") // Optional: specify table name in snake_case
}
```
6. **Create Initial Database Migration:**
```bash
npx prisma migrate dev --name init_bank_account
```
* This command does two things:
* Creates an SQL migration file in `prisma/migrations`.
* Applies the migration to your database, creating the `bank_accounts` table.
7. **Generate Prisma Client:**
Ensure the client is generated based on your schema:
```bash
npx prisma generate
```
* This command reads your `schema.prisma` and generates the typed `@prisma/client`. You typically run this after any schema change.
8. **Create the Fastify Server (`src/server.ts` or `server.js`):**
```typescript
// src/server.ts (if using TypeScript)
// If using Javascript, remove type annotations and use require instead of import
import Fastify, { FastifyInstance, RouteShorthandOptions } from 'fastify';
import { Server, IncomingMessage, ServerResponse } from 'http';
import { PrismaClient, BankAccount } from '@prisma/client';
import dotenv from 'dotenv';
// Load environment variables
dotenv.config();
const prisma = new PrismaClient();
const server: FastifyInstance = Fastify({
logger: true // Enable logging
});
// --- Type Definitions for Payloads/Params (Good Practice) ---
interface BankAccountCreateParams {
name: string;
bankName: string;
accountNumber: string;
}
interface BankAccountUpdateParams {
id: string;
}
interface BankAccountUpdateBody {
name?: string;
bankName?: string;
accountNumber?: string;
}
interface BankAccountDeleteParams {
id: string;
}
// --- API Routes ---
const API_PREFIX = '/api/bank-account';
// 1. Create Bank Account
server.post<{ Body: BankAccountCreateParams }>(
`${API_PREFIX}/create`,
async (request, reply): Promise<BankAccount> => {
try {
const { name, bankName, accountNumber } = request.body;
const newAccount = await prisma.bankAccount.create({
data: {
name,
bankName,
accountNumber,
},
});
reply.code(201); // Resource Created
return newAccount;
} catch (error: any) {
server.log.error(error);
// Basic duplicate check example (Prisma throws P2002)
if (error.code === 'P2002' && error.meta?.target?.includes('accountNumber')) {
reply.code(409); // Conflict
throw new Error(`Bank account with number ${request.body.accountNumber} already exists.`);
}
reply.code(500);
throw new Error('Failed to create bank account.');
}
}
);
// 2. Update Bank Account
server.post<{ Params: BankAccountUpdateParams; Body: BankAccountUpdateBody }>(
`${API_PREFIX}/update/:id`,
async (request, reply): Promise<BankAccount> => {
try {
const { id } = request.params;
const updateData = request.body;
// Ensure ID is a valid number before querying
const accountId = parseInt(id, 10);
if (isNaN(accountId)) {
reply.code(400);
throw new Error('Invalid account ID format.');
}
const updatedAccount = await prisma.bankAccount.update({
where: { id: accountId },
data: updateData,
});
return updatedAccount;
} catch (error: any) {
server.log.error(error);
// Handle case where account doesn't exist (Prisma throws P2025)
if (error.code === 'P2025') {
reply.code(404); // Not Found
throw new Error(`Bank account with ID ${request.params.id} not found.`);
}
// Handle potential duplicate account number on update
if (error.code === 'P2002' && error.meta?.target?.includes('accountNumber')) {
reply.code(409); // Conflict
throw new Error(`Bank account with number ${request.body.accountNumber} already exists.`);
}
reply.code(500);
throw new Error('Failed to update bank account.');
}
}
);
// 3. Delete Bank Account
server.delete<{ Params: BankAccountDeleteParams }>(
`${API_PREFIX}/delete/:id`,
async (request, reply): Promise<{ message: string; deletedAccount: BankAccount }> => {
try {
const { id } = request.params;
// Ensure ID is a valid number
const accountId = parseInt(id, 10);
if (isNaN(accountId)) {
reply.code(400);
throw new Error('Invalid account ID format.');
}
const deletedAccount = await prisma.bankAccount.delete({
where: { id: accountId },
});
return { message: `Bank account with ID ${id} deleted successfully.`, deletedAccount };
} catch (error: any) {
server.log.error(error);
// Handle case where account doesn't exist (Prisma throws P2025)
if (error.code === 'P2025') {
reply.code(404); // Not Found
throw new Error(`Bank account with ID ${request.params.id} not found.`);
}
reply.code(500);
throw new Error('Failed to delete bank account.');
}
}
);
// 4. Get All Bank Accounts
server.get(
`${API_PREFIX}/`, // Using trailing slash for consistency, Fastify often handles both
async (request, reply): Promise<BankAccount[]> => {
try {
const accounts = await prisma.bankAccount.findMany({
orderBy: { // Optional: Order results
createdAt: 'desc'
}
});
return accounts;
} catch (error: any) {
server.log.error(error);
reply.code(500);
throw new Error('Failed to retrieve bank accounts.');
}
}
);
// Optional: Get Single Bank Account by ID (Often useful)
server.get<{ Params: { id: string } }>(
`${API_PREFIX}/:id`,
async (request, reply): Promise<BankAccount> => {
try {
const { id } = request.params;
const accountId = parseInt(id, 10);
if (isNaN(accountId)) {
reply.code(400);
throw new Error('Invalid account ID format.');
}
const account = await prisma.bankAccount.findUnique({
where: { id: accountId },
});
if (!account) {
reply.code(404);
throw new Error(`Bank account with ID ${id} not found.`);
}
return account;
} catch (error: any) {
// Catch errors that might have been thrown before prisma query
if (!reply.sent) { // Check if a reply hasn't already been sent (e.g., by 400 error)
server.log.error(error);
reply.code(500);
throw new Error('Failed to retrieve bank account.');
}
// If reply already sent, just rethrow the original error
throw error;
}
}
);
// --- Graceful Shutdown ---
const gracefulShutdown = async (signal: string) => {
console.log(`*^! Received signal ${signal}. Shutting down...`);
try {
await server.close();
console.log('Fastify server closed.');
await prisma.$disconnect();
console.log('Prisma client disconnected.');
process.exit(0);
} catch (err) {
console.error('Error during shutdown:', err);
process.exit(1);
}
};
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
// --- Start Server ---
const start = async () => {
try {
const host = process.env.API_HOST || '0.0.0.0';
const port = parseInt(process.env.API_PORT || '3050', 10);
await server.listen({ port, host });
// Log after listen resolves (Fastify v3+)
// server.log.info(`Server listening at http://${host}:${port}`);
// Note: Fastify logger automatically logs listening address
} catch (err) {
server.log.error(err);
await prisma.$disconnect(); // Ensure prisma disconnects on startup error too
process.exit(1);
}
};
start();
// Export prisma instance if needed elsewhere (e.g., tests, scripts)
// export { prisma };
```
9. **Configure `tsconfig.json` (if using TypeScript):**
Create a `tsconfig.json` file in the root directory:
```json
// tsconfig.json
{
"compilerOptions": {
"target": "ES2017", // Or newer
"module": "CommonJS",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"moduleResolution": "node",
"sourceMap": true, // Helpful for debugging
"resolveJsonModule": true // Allows importing JSON files
},
"include": ["src/**/*"], // Only compile files in src
"exclude": ["node_modules", "**/*.spec.ts"] // Exclude tests if you have them
}
```
10. **Add Scripts to `package.json`:**
```json
// package.json (add or modify the scripts section)
"scripts": {
"build": "tsc", // Compile TypeScript to JavaScript (if using TS)
"start": "node dist/server.js", // Run compiled JS (if using TS)
"dev": "nodemon --watch src --ext ts --exec ts-node src/server.ts", // Dev mode with TS
// --- OR if using plain JavaScript ---
// "start": "node server.js",
// "dev": "nodemon server.js",
// --- Prisma commands for convenience ---
"prisma:migrate": "prisma migrate dev",
"prisma:generate": "prisma generate",
"prisma:studio": "prisma studio" // GUI to view/edit data
},
```
11. **Run the Development Server:**
```bash
npm run dev
```
**How to Test (using `curl`):**
*(Replace `YOUR_ACCOUNT_ID` with an actual ID after creating an account)*
* **Create:**
```bash
curl -X POST -H "Content-Type: application/json" \
-d '{"name": "My Checking", "bankName": "Local Bank", "accountNumber": "123456789"}' \
http://localhost:3050/api/bank-account/create
```
* **Get All:**
```bash
curl http://localhost:3050/api/bank-account/
```
* **Update (using the ID returned from create):**
```bash
curl -X POST -H "Content-Type: application/json" \
-d '{"name": "My Primary Checking"}' \
http://localhost:3050/api/bank-account/update/YOUR_ACCOUNT_ID
```
* **Delete (using the ID):**
```bash
curl -X DELETE http://localhost:3050/api/bank-account/delete/YOUR_ACCOUNT_ID
```
* **Get Specific (using the ID):**
```bash
curl http://localhost:3050/api/bank-account/YOUR_ACCOUNT_ID
```
This skeleton provides the core structure. You can build upon this by adding more robust error handling, input validation (using Fastify's built-in schema validation), authentication/authorization, more complex queries, and organizing routes into separate files/plugins as the application grows.

2840
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,16 +9,24 @@
"astro": "astro", "astro": "astro",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage", "test:coverage": "vitest run --coverage",
"format": "biome format --write .", "format": "biome format --write .",
"lint": "biome lint .", "lint": "biome lint .",
"check": "biome check --apply ." "check": "biome check --apply .",
"db:generate": "prisma generate",
"db:migrate": "prisma migrate dev",
"db:push": "prisma db push",
"db:seed": "node prisma/seed.js",
"db:studio": "prisma studio"
}, },
"dependencies": { "dependencies": {
"@astrojs/cloudflare": "^12.5.1", "@astrojs/cloudflare": "^12.5.1",
"@astrojs/node": "^9.2.1", "@astrojs/node": "^9.2.1",
"@astrojs/react": "^4.2.5", "@astrojs/react": "^4.2.5",
"@nanostores/react": "^1.0.0", "@nanostores/react": "^1.0.0",
"@prisma/client": "^6.7.0",
"@types/react": "^19.1.2", "@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2", "@types/react-dom": "^19.1.2",
"astro": "^5.7.5", "astro": "^5.7.5",
@@ -31,12 +39,18 @@
"@testing-library/dom": "^10.4.0", "@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3", "@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0", "@testing-library/react": "^16.3.0",
"@types/node": "^20.11.0",
"@types/supertest": "^6.0.3", "@types/supertest": "^6.0.3",
"@vitejs/plugin-react": "^4.4.1", "@vitejs/plugin-react": "^4.4.1",
"@vitest/coverage-v8": "^3.1.3", "@vitest/coverage-v8": "^3.1.3",
"jsdom": "^26.1.0", "jsdom": "^26.1.0",
"prisma": "^6.7.0",
"supertest": "^7.1.0", "supertest": "^7.1.0",
"ts-node": "^10.9.2",
"vitest": "^3.1.2", "vitest": "^3.1.2",
"wrangler": "^4.13.1" "wrangler": "^4.13.1"
},
"prisma": {
"seed": "node prisma/seed.js"
} }
} }

View File

@@ -0,0 +1,61 @@
-- CreateEnum
CREATE TYPE "AccountType" AS ENUM ('CHECKING', 'SAVINGS', 'CREDIT_CARD', 'INVESTMENT', 'OTHER');
-- CreateEnum
CREATE TYPE "AccountStatus" AS ENUM ('ACTIVE', 'CLOSED');
-- CreateEnum
CREATE TYPE "TransactionStatus" AS ENUM ('PENDING', 'CLEARED');
-- CreateEnum
CREATE TYPE "TransactionType" AS ENUM ('DEPOSIT', 'WITHDRAWAL', 'TRANSFER', 'UNSPECIFIED');
-- CreateTable
CREATE TABLE "accounts" (
"id" TEXT NOT NULL,
"bankName" TEXT NOT NULL,
"accountNumber" VARCHAR(6) NOT NULL,
"name" TEXT NOT NULL,
"type" "AccountType" NOT NULL DEFAULT 'CHECKING',
"status" "AccountStatus" NOT NULL DEFAULT 'ACTIVE',
"currency" TEXT NOT NULL DEFAULT 'USD',
"balance" DECIMAL(10,2) NOT NULL DEFAULT 0,
"notes" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "accounts_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "transactions" (
"id" TEXT NOT NULL,
"accountId" TEXT NOT NULL,
"date" TIMESTAMP(3) NOT NULL,
"description" TEXT NOT NULL,
"amount" DECIMAL(10,2) NOT NULL,
"category" TEXT,
"status" "TransactionStatus" NOT NULL DEFAULT 'CLEARED',
"type" "TransactionType" NOT NULL DEFAULT 'UNSPECIFIED',
"notes" TEXT,
"tags" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "transactions_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "accounts_status_idx" ON "accounts"("status");
-- CreateIndex
CREATE INDEX "transactions_accountId_idx" ON "transactions"("accountId");
-- CreateIndex
CREATE INDEX "transactions_date_idx" ON "transactions"("date");
-- CreateIndex
CREATE INDEX "transactions_category_idx" ON "transactions"("category");
-- AddForeignKey
ALTER TABLE "transactions" ADD CONSTRAINT "transactions_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "accounts"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

View File

@@ -9,13 +9,66 @@ datasource db {
url = env("DATABASE_URL") url = env("DATABASE_URL")
} }
model BankAccount { model Account {
id Int @id @default(autoincrement()) id String @id @default(uuid())
name String // e.g., "Checking Account", "Savings XYZ" bankName String
bankName String // e.g., "Chase", "Wells Fargo" accountNumber String @db.VarChar(6) // Last 6 digits
accountNumber String @unique // Consider encryption in a real app name String // Friendly name
type AccountType @default(CHECKING)
status AccountStatus @default(ACTIVE)
currency String @default("USD")
balance Decimal @default(0) @db.Decimal(10, 2)
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
transactions Transaction[]
@@index([status])
@@map("accounts")
}
model Transaction {
id String @id @default(uuid())
accountId String
account Account @relation(fields: [accountId], references: [id])
date DateTime
description String
amount Decimal @db.Decimal(10, 2)
category String?
status TransactionStatus @default(CLEARED)
type TransactionType @default(UNSPECIFIED)
notes String?
tags String? // Comma-separated values for tags
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@map("bank_accounts") // Optional: specify table name in snake_case @@index([accountId])
@@index([date])
@@index([category])
@@map("transactions")
}
enum AccountType {
CHECKING
SAVINGS
CREDIT_CARD
INVESTMENT
OTHER
}
enum AccountStatus {
ACTIVE
CLOSED
}
enum TransactionStatus {
PENDING
CLEARED
}
enum TransactionType {
DEPOSIT
WITHDRAWAL
TRANSFER
UNSPECIFIED
} }

87
prisma/seed.js Normal file
View File

@@ -0,0 +1,87 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
console.log('Starting database seeding...');
// Clear existing data
await prisma.transaction.deleteMany({});
await prisma.account.deleteMany({});
console.log('Cleared existing data');
// Create accounts
const checkingAccount = await prisma.account.create({
data: {
bankName: 'First National Bank',
accountNumber: '432198',
name: 'Checking Account',
type: 'CHECKING',
balance: 2500.0,
notes: 'Primary checking account',
},
});
const savingsAccount = await prisma.account.create({
data: {
bankName: 'First National Bank',
accountNumber: '876543',
name: 'Savings Account',
type: 'SAVINGS',
balance: 10000.0,
notes: 'Emergency fund',
},
});
console.log('Created accounts:', {
checkingAccount: checkingAccount.id,
savingsAccount: savingsAccount.id,
});
// Create transactions
const transactions = await Promise.all([
prisma.transaction.create({
data: {
accountId: checkingAccount.id,
date: new Date('2025-04-20'),
description: 'Grocery Store',
amount: -75.5,
category: 'Groceries',
type: 'WITHDRAWAL',
},
}),
prisma.transaction.create({
data: {
accountId: checkingAccount.id,
date: new Date('2025-04-21'),
description: 'Salary Deposit',
amount: 3000.0,
category: 'Income',
type: 'DEPOSIT',
},
}),
prisma.transaction.create({
data: {
accountId: savingsAccount.id,
date: new Date('2025-04-22'),
description: 'Transfer to Savings',
amount: 500.0,
category: 'Transfer',
type: 'TRANSFER',
},
}),
]);
console.log(`Created ${transactions.length} transactions`);
console.log('Seeding completed successfully!');
}
main()
.catch((e) => {
console.error('Error during seeding:', e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

87
prisma/seed.ts Normal file
View File

@@ -0,0 +1,87 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
console.log('Starting database seeding...');
// Clear existing data
await prisma.transaction.deleteMany({});
await prisma.account.deleteMany({});
console.log('Cleared existing data');
// Create accounts
const checkingAccount = await prisma.account.create({
data: {
bankName: 'First National Bank',
accountNumber: '432198',
name: 'Checking Account',
type: 'CHECKING',
balance: 2500.0,
notes: 'Primary checking account',
},
});
const savingsAccount = await prisma.account.create({
data: {
bankName: 'First National Bank',
accountNumber: '876543',
name: 'Savings Account',
type: 'SAVINGS',
balance: 10000.0,
notes: 'Emergency fund',
},
});
console.log('Created accounts:', {
checkingAccount: checkingAccount.id,
savingsAccount: savingsAccount.id,
});
// Create transactions
const transactions = await Promise.all([
prisma.transaction.create({
data: {
accountId: checkingAccount.id,
date: new Date('2025-04-20'),
description: 'Grocery Store',
amount: -75.5,
category: 'Groceries',
type: 'WITHDRAWAL',
},
}),
prisma.transaction.create({
data: {
accountId: checkingAccount.id,
date: new Date('2025-04-21'),
description: 'Salary Deposit',
amount: 3000.0,
category: 'Income',
type: 'DEPOSIT',
},
}),
prisma.transaction.create({
data: {
accountId: savingsAccount.id,
date: new Date('2025-04-22'),
description: 'Transfer to Savings',
amount: 500.0,
category: 'Transfer',
type: 'TRANSFER',
},
}),
]);
console.log(`Created ${transactions.length} transactions`);
console.log('Seeding completed successfully!');
}
main()
.catch((e) => {
console.error('Error during seeding:', e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

View File

@@ -1,271 +1,269 @@
import { useStore } from '@nanostores/react'; import { useStore } from '@nanostores/react';
import type React from 'react'; import React, { useEffect, useState } from 'react';
import { useEffect, useState } from 'react';
// Import store atoms and actions
import { import {
cancelEditingTransaction, cancelEditingTransaction,
currentAccountId as currentAccountIdStore, currentAccountId,
loadTransactionsForAccount,
transactionSaved, transactionSaved,
transactionToEdit as transactionToEditStore, transactionToEdit,
triggerRefresh, triggerRefresh,
} from '../stores/transactionStore'; } from '../stores/transactionStore';
import type { Transaction } from '../types';
export default function AddTransactionForm() { export default function AddTransactionForm() {
// --- Read state from store --- const accountId = useStore(currentAccountId);
const currentAccountId = useStore(currentAccountIdStore); const editingTransaction = useStore(transactionToEdit);
const transactionToEdit = useStore(transactionToEditStore);
// --- State Variables --- // Form state - initialize with empty values to avoid hydration mismatch
const [date, setDate] = useState(''); const [date, setDate] = useState('');
const [description, setDescription] = useState(''); const [description, setDescription] = useState('');
const [amount, setAmount] = useState(''); const [amount, setAmount] = useState('');
const [editingId, setEditingId] = useState<string | null>(null); const [category, setCategory] = useState('');
const [isLoading, setIsLoading] = useState(false); const [type, setType] = useState('WITHDRAWAL');
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null); const [successMessage, setSuccessMessage] = useState<string | null>(null);
const isEditMode = !!editingId; // Set initial date only on client-side after component mounts
// --- Effects ---
// Effect to set default date on mount
useEffect(() => { useEffect(() => {
if (!transactionToEdit) { // Only run this effect on the client side to prevent hydration mismatch
setDate(new Date().toISOString().split('T')[0]); if (!date) {
const today = new Date();
setDate(today.toISOString().split('T')[0]);
} }
}, [transactionToEdit]); }, []);
// Effect to populate form when editing // Reset form when accountId changes or when switching from edit to add mode
useEffect(() => { useEffect(() => {
if (transactionToEdit) { if (!editingTransaction) {
setEditingId(transactionToEdit.id);
try {
const dateObj = new Date(transactionToEdit.date);
if (!Number.isNaN(dateObj.getTime())) {
setDate(dateObj.toISOString().split('T')[0]);
} else {
console.warn('Invalid date received for editing:', transactionToEdit.date);
setDate('');
}
} catch (e) {
console.error('Error parsing date for editing:', e);
setDate('');
}
setDescription(transactionToEdit.description);
setAmount(transactionToEdit.amount.toString());
setError(null);
setSuccessMessage(null);
} else if (!isLoading) {
resetForm(); resetForm();
} }
}, [transactionToEdit, isLoading]); }, [accountId, editingTransaction === null]);
// Clear success message after 5 seconds // Populate form when editing a transaction
useEffect(() => { useEffect(() => {
if (successMessage) { if (editingTransaction) {
const timer = setTimeout(() => { let dateStr: string;
setSuccessMessage(null); try {
}, 5000); if (editingTransaction.date instanceof Date) {
return () => clearTimeout(timer); dateStr = editingTransaction.date.toISOString().split('T')[0];
} else {
// Handle string dates safely
const parsedDate = new Date(String(editingTransaction.date));
dateStr = Number.isNaN(parsedDate.getTime())
? new Date().toISOString().split('T')[0] // Fallback to today if invalid
: parsedDate.toISOString().split('T')[0];
}
} catch (error) {
console.error('Error parsing date for edit:', error);
dateStr = new Date().toISOString().split('T')[0]; // Fallback to today
} }
}, [successMessage]);
// --- Helper Functions --- setDate(dateStr);
setDescription(editingTransaction.description);
setAmount(String(Math.abs(editingTransaction.amount)));
setCategory(editingTransaction.category || '');
setType(editingTransaction.amount < 0 ? 'WITHDRAWAL' : 'DEPOSIT');
}
}, [editingTransaction]);
const resetForm = () => { const resetForm = () => {
setEditingId(null); // Get today's date in YYYY-MM-DD format for the date input
setDate(new Date().toISOString().split('T')[0]); const today = new Date().toISOString().split('T')[0];
setDate(today);
setDescription(''); setDescription('');
setAmount(''); setAmount('');
setCategory('');
setType('WITHDRAWAL');
setError(null); setError(null);
setSuccessMessage(null); setSuccessMessage(null);
}; };
const validateForm = (): string[] => {
const errors: string[] = [];
if (!description || description.trim().length < 2) {
errors.push('Description must be at least 2 characters long');
}
if (!amount) {
errors.push('Amount is required');
} else {
const amountNum = Number.parseFloat(amount);
if (Number.isNaN(amountNum)) {
errors.push('Amount must be a valid number');
} else if (amountNum === 0) {
errors.push('Amount cannot be zero');
}
}
if (!date) {
errors.push('Date is required');
} else {
try {
const dateObj = new Date(`${date}T00:00:00`);
if (Number.isNaN(dateObj.getTime())) {
errors.push('Invalid date format');
}
} catch (e) {
errors.push('Invalid date format');
}
}
return errors;
};
// --- Event Handlers ---
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!accountId) {
setError('No account selected');
return;
}
if (!date || !description || !amount) {
setError('Date, description and amount are required');
return;
}
setIsSubmitting(true);
setError(null); setError(null);
setSuccessMessage(null); setSuccessMessage(null);
if (isLoading || !currentAccountId) { // Calculate final amount based on type
if (!currentAccountId) setError('No account selected.'); const finalAmount = type === 'DEPOSIT' ? Math.abs(Number(amount)) : -Math.abs(Number(amount));
return;
}
const validationErrors = validateForm();
if (validationErrors.length > 0) {
setError(validationErrors.join('. '));
return;
}
setIsLoading(true);
try { try {
const transactionData = { let response;
accountId: currentAccountId,
date: date,
description: description.trim(),
amount: Number.parseFloat(amount),
};
const method = editingId ? 'PUT' : 'POST'; if (editingTransaction) {
const url = editingId ? `/api/transactions/${editingId}` : '/api/transactions'; // Update existing transaction
response = await fetch(`/api/transactions/${editingTransaction.id}`, {
const response = await fetch(url, { method: 'PUT',
method, headers: {
headers: { 'Content-Type': 'application/json' }, 'Content-Type': 'application/json',
body: JSON.stringify(transactionData), },
body: JSON.stringify({
accountId,
date,
description,
amount: finalAmount,
category: category || undefined,
type,
}),
}); });
} else {
// Create new transaction
response = await fetch('/api/transactions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
accountId,
date,
description,
amount: finalAmount,
category: category || undefined,
type,
}),
});
}
if (!response.ok) { if (!response.ok) {
let errorMsg = `Failed to ${isEditMode ? 'update' : 'create'} transaction`;
try {
const errorData = await response.json(); const errorData = await response.json();
errorMsg = errorData.error || errorMsg; throw new Error(errorData.error || 'Transaction operation failed');
} catch (jsonError) {
errorMsg = `${response.status}: ${response.statusText}`;
}
throw new Error(errorMsg);
} }
const savedTransaction: Transaction = await response.json(); const savedTransaction = await response.json();
// First notify about the saved transaction // Handle success
transactionSaved(savedTransaction);
// Then explicitly trigger a refresh to ensure balance updates
triggerRefresh();
// Set success message before clearing form
setSuccessMessage( setSuccessMessage(
isEditMode ? 'Transaction updated successfully' : 'Transaction created successfully', editingTransaction
? 'Transaction updated successfully!'
: 'Transaction added successfully!',
); );
// Only reset the form after the success message is shown // Reset form
setTimeout(() => {
resetForm(); resetForm();
// Optionally collapse the form after success
const addTransactionSection = document.getElementById('add-transaction-section'); // Clear editing state
const toggleAddTxnBtn = document.getElementById('toggle-add-txn-btn'); if (editingTransaction) {
if (addTransactionSection?.classList.contains('expanded')) { cancelEditingTransaction();
addTransactionSection.classList.replace('expanded', 'collapsed');
toggleAddTxnBtn?.setAttribute('aria-expanded', 'false');
} }
}, 2000);
// Notify about saved transaction
transactionSaved(savedTransaction);
// Reload transactions to ensure the list is up to date
await loadTransactionsForAccount(accountId);
} catch (err) { } catch (err) {
const errorMessage = err instanceof Error ? err.message : 'An unexpected error occurred'; setError(err instanceof Error ? err.message : 'An unknown error occurred');
setError(errorMessage); console.error('Transaction error:', err);
setSuccessMessage(null);
} finally { } finally {
setIsLoading(false); setIsSubmitting(false);
} }
}; };
const handleCancel = () => { const handleCancel = () => {
resetForm(); if (editingTransaction) {
cancelEditingTransaction(); cancelEditingTransaction();
}
resetForm();
}; };
// --- JSX ---
return ( return (
<form id="add-transaction-form-react" role="form" onSubmit={handleSubmit} noValidate> <div className="transaction-form-container">
<h4>{isEditMode ? 'Edit Transaction' : 'New Transaction'}</h4> <h3>{editingTransaction ? 'Edit Transaction' : 'Add Transaction'}</h3>
{error && (
<div className="error-message" data-testid="error-message">
{error}
</div>
)}
{successMessage && (
<div className="success-message" data-testid="success-message">
{successMessage}
</div>
)}
{successMessage && <div className="success-message">{successMessage}</div>}
{error && <div className="error-message">{error}</div>}
<form onSubmit={handleSubmit}>
<div className="form-group"> <div className="form-group">
<label htmlFor="txn-date-react">Date</label> <label htmlFor="txn-date">Date:</label>
<input <input
type="date" type="date"
id="txn-date-react" id="txn-date"
name="date"
value={date} value={date}
onChange={(e) => setDate(e.target.value)} onChange={(e) => setDate(e.target.value)}
disabled={isSubmitting}
required required
disabled={isLoading}
/> />
</div> </div>
<div className="form-group"> <div className="form-group">
<label htmlFor="txn-description-react">Description</label> <label htmlFor="txn-description">Description:</label>
<input <input
type="text" type="text"
id="txn-description-react" id="txn-description"
name="description"
value={description} value={description}
onChange={(e) => setDescription(e.target.value)} onChange={(e) => setDescription(e.target.value)}
disabled={isSubmitting}
placeholder="e.g., Grocery store"
required required
minLength={2}
maxLength={100}
placeholder="e.g. Groceries"
disabled={isLoading}
/> />
</div> </div>
<div className="form-group">
<label htmlFor="txn-amount-react">Amount</label> <div className="form-group amount-group">
<label htmlFor="txn-amount">Amount:</label>
<div className="amount-input-group">
<select value={type} onChange={(e) => setType(e.target.value)} disabled={isSubmitting}>
<option value="WITHDRAWAL">-</option>
<option value="DEPOSIT">+</option>
</select>
<span className="currency-symbol">$</span>
<input <input
type="number" type="number"
id="txn-amount-react" id="txn-amount"
name="amount"
value={amount} value={amount}
onChange={(e) => setAmount(e.target.value)} onChange={(e) => setAmount(e.target.value)}
disabled={isSubmitting}
step="0.01" step="0.01"
min="0"
placeholder="0.00"
required required
placeholder="e.g. -25.50 or 1200.00"
disabled={isLoading}
/> />
<small className="help-text">Use negative numbers for expenses (e.g., -50.00)</small>
</div> </div>
<div className="button-group"> </div>
<div className="form-group">
<label htmlFor="txn-category">Category (optional):</label>
<input
type="text"
id="txn-category"
value={category}
onChange={(e) => setCategory(e.target.value)}
disabled={isSubmitting}
placeholder="e.g., Food, Bills, etc."
/>
</div>
<div className="form-actions">
<button <button
type="submit" type="button"
className={`form-submit-btn ${isLoading ? 'loading' : ''}`} onClick={handleCancel}
disabled={isLoading} disabled={isSubmitting}
className="cancel-btn"
> >
{isLoading ? 'Saving...' : isEditMode ? 'Update Transaction' : 'Save Transaction'}
</button>
{isEditMode && (
<button type="button" className="cancel-btn" onClick={handleCancel} disabled={isLoading}>
Cancel Cancel
</button> </button>
)} <button
type="submit"
// Allow submitting if we're editing a transaction, even if no account is currently selected
disabled={isSubmitting || (!editingTransaction && !accountId)}
className="submit-btn"
>
{isSubmitting ? 'Processing...' : editingTransaction ? 'Update' : 'Add'}
</button>
</div> </div>
</form> </form>
</div>
); );
} }

View File

@@ -10,7 +10,7 @@ const { account } = Astro.props;
--- ---
<main class="main-content"> <main class="main-content">
<header class="main-header"> <header class="main-header">
<h1>Transactions for <span id="current-account-name">{account.name} (***{account.last4})</span></h1> <h1>Transactions for <span id="current-account-name">{account.name} (***{account.accountNumber.slice(-3)})</span></h1>
</header> </header>
<TransactionTable client:load /> <TransactionTable client:load />
</main> </main>

View File

@@ -1,7 +1,7 @@
--- ---
import type { Account } from '../types'; import type { Account } from "../types";
import AccountSummary from './AccountSummary.tsx'; // Import the React component instead of the Astro one import AccountSummary from "./AccountSummary.tsx"; // Import the React component instead of the Astro one
import AddTransactionForm from './AddTransactionForm.tsx'; import AddTransactionForm from "./AddTransactionForm.tsx";
interface Props { interface Props {
accounts: Account[]; accounts: Account[];
@@ -10,22 +10,32 @@ interface Props {
const { accounts, initialAccount } = Astro.props; const { accounts, initialAccount } = Astro.props;
--- ---
<aside class="sidebar"> <aside class="sidebar">
<div class="sidebar-header"> <div class="sidebar-header">
<h2>My finances</h2> <h2>My finances</h2>
{/* Add button to toggle form visibility */} {/* Add button to toggle form visibility */}
<button id="toggle-add-txn-btn" aria-expanded="false" aria-controls="add-transaction-section"> <button
id="toggle-add-txn-btn"
aria-expanded="false"
aria-controls="add-transaction-section"
>
+ New Txn + New Txn
</button> </button>
</div> </div>
<nav class="account-nav"> <nav class="account-nav">
<h3>Accounts</h3> <h3>Accounts</h3>
<select id="account-select" name="account"> <select id="account-select" name="account">
{accounts.map(account => ( {
<option value={account.id} selected={account.id === initialAccount.id}> accounts.map((account) => (
{account.name} (***{account.last4}) <option
value={account.id}
selected={account.id === initialAccount.id}
>
{account.name} (***{account.accountNumber.slice(-3)})
</option> </option>
))} ))
}
</select> </select>
</nav> </nav>
@@ -34,30 +44,37 @@ const { accounts, initialAccount } = Astro.props;
{/* Section to contain the React form, initially hidden */} {/* Section to contain the React form, initially hidden */}
<section id="add-transaction-section" class="collapsible collapsed"> <section id="add-transaction-section" class="collapsible collapsed">
{/* {
/*
Use the React component here. Use the React component here.
It now gets its state (currentAccountId, transactionToEdit) It now gets its state (currentAccountId, transactionToEdit)
directly from the Nano Store. directly from the Nano Store.
*/} */
}
<AddTransactionForm client:load /> <AddTransactionForm client:load />
</section> </section>
</aside> </aside>
{/* Keep the script for toggling visibility for now */} {/* Keep the script for toggling visibility for now */}
<script> <script>
const toggleButton = document.getElementById('toggle-add-txn-btn'); const toggleButton = document.getElementById("toggle-add-txn-btn");
const formSection = document.getElementById('add-transaction-section'); const formSection = document.getElementById("add-transaction-section");
if (toggleButton && formSection) { if (toggleButton && formSection) {
toggleButton.addEventListener('click', () => { toggleButton.addEventListener("click", () => {
const isExpanded = toggleButton.getAttribute('aria-expanded') === 'true'; const isExpanded =
toggleButton.setAttribute('aria-expanded', String(!isExpanded)); toggleButton.getAttribute("aria-expanded") === "true";
formSection.classList.toggle('collapsed'); toggleButton.setAttribute("aria-expanded", String(!isExpanded));
formSection.classList.toggle('expanded'); formSection.classList.toggle("collapsed");
formSection.classList.toggle("expanded");
// Optional: Focus first field when expanding // Optional: Focus first field when expanding
if (!isExpanded) { if (!isExpanded) {
// Cast the result to HTMLElement before calling focus // Cast the result to HTMLElement before calling focus
(formSection.querySelector('input, select, textarea') as HTMLElement)?.focus(); (
formSection.querySelector(
"input, select, textarea",
) as HTMLElement
)?.focus();
} }
}); });
} }

View File

@@ -1,54 +1,82 @@
import { useStore } from '@nanostores/react'; import { useStore } from '@nanostores/react';
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { import {
currentAccountId as currentAccountIdStore, currentAccountId as currentAccountIdStore,
currentTransactions as currentTransactionsStore,
refreshKey, refreshKey,
startEditingTransaction, startEditingTransaction,
triggerRefresh, triggerRefresh,
loadTransactionsForAccount,
} from '../stores/transactionStore'; } from '../stores/transactionStore';
import type { Transaction } from '../types'; import type { Transaction } from '../types';
import { formatCurrency, formatDate } from '../utils'; import { formatCurrency, formatDate } from '../utils';
type TransactionTableProps = {}; export default function TransactionTable() {
export default function TransactionTable({}: TransactionTableProps) {
const currentAccountId = useStore(currentAccountIdStore); const currentAccountId = useStore(currentAccountIdStore);
const refreshCounter = useStore(refreshKey); const refreshCounter = useStore(refreshKey);
const transactions = useStore(currentTransactionsStore);
const [transactions, setTransactions] = useState<Transaction[]>([]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
useEffect(() => { // Fetch transactions when account ID changes or refresh is triggered
const fetchTransactions = useCallback(async () => {
if (!currentAccountId) { if (!currentAccountId) {
setTransactions([]); console.log('TransactionTable: No account selected, skipping transaction load');
return; return;
} }
const fetchTransactions = async () => {
setIsLoading(true); setIsLoading(true);
setError(null); setError(null);
try { try {
const response = await fetch(`/api/accounts/${currentAccountId}/transactions`); console.log(`TransactionTable: Loading transactions for account ${currentAccountId}`);
if (!response.ok) { await loadTransactionsForAccount(currentAccountId);
throw new Error('Failed to fetch transactions'); console.log('TransactionTable: Transactions loaded successfully');
}
const data: Transaction[] = await response.json();
setTransactions(data);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'An unknown error occurred'); const errorMessage = err instanceof Error ? err.message : 'An unknown error occurred';
setTransactions([]); console.error('TransactionTable: Error loading transactions:', errorMessage);
setError(errorMessage);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}, [currentAccountId]);
// Effect for loading transactions when account changes or refresh is triggered
useEffect(() => {
fetchTransactions();
}, [fetchTransactions, refreshCounter]);
// Safe sort function that handles invalid dates gracefully
const safeSort = (transactions: Transaction[]) => {
if (!Array.isArray(transactions)) {
console.warn('Expected transactions array but received:', transactions);
return [];
}
return [...transactions].sort((a, b) => {
try {
// Safely parse dates with validation
const dateA = a.date ? new Date(a.date).getTime() : 0;
const dateB = b.date ? new Date(b.date).getTime() : 0;
// If either date is invalid, use a fallback approach
if (Number.isNaN(dateA) || Number.isNaN(dateB)) {
console.warn('Found invalid date during sort:', { a: a.date, b: b.date });
// Sort by ID as fallback or keep original order
return (b.id || '').localeCompare(a.id || '');
}
return dateB - dateA; // Newest first
} catch (error) {
console.error('Error during transaction sort:', error);
return 0; // Keep original order on error
}
});
}; };
fetchTransactions(); // Format transactions to display in table - with better error handling
}, [currentAccountId, refreshCounter]); const sortedTransactions = Array.isArray(transactions) ? safeSort(transactions) : [];
const sortedTransactions = [...transactions].sort(
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime(),
);
const handleDelete = async (txnId: string) => { const handleDelete = async (txnId: string) => {
if (!confirm('Are you sure you want to delete this transaction?')) { if (!confirm('Are you sure you want to delete this transaction?')) {
@@ -74,14 +102,7 @@ export default function TransactionTable({}: TransactionTableProps) {
} }
console.log(`Transaction ${txnId} deleted successfully.`); console.log(`Transaction ${txnId} deleted successfully.`);
triggerRefresh(); // This will reload transactions
// Remove from local state
setTransactions((currentTransactions) =>
currentTransactions.filter((txn) => txn.id !== txnId),
);
// Trigger refresh to update balances and table
triggerRefresh();
} catch (error) { } catch (error) {
alert(error instanceof Error ? error.message : 'Failed to delete transaction'); alert(error instanceof Error ? error.message : 'Failed to delete transaction');
console.error('Delete error:', error); console.error('Delete error:', error);
@@ -107,7 +128,7 @@ export default function TransactionTable({}: TransactionTableProps) {
// Helper function to render loading state // Helper function to render loading state
const renderLoading = () => ( const renderLoading = () => (
<tr> <tr>
<td colSpan={4} style={{ textAlign: 'center', padding: '2rem' }}> <td colSpan={5} style={{ textAlign: 'center', padding: '2rem' }}>
Loading transactions... Loading transactions...
</td> </td>
</tr> </tr>
@@ -117,11 +138,12 @@ export default function TransactionTable({}: TransactionTableProps) {
const renderEmpty = () => ( const renderEmpty = () => (
<tr> <tr>
<td <td
colSpan={4} colSpan={5}
style={{ style={{
textAlign: 'center', textAlign: 'center',
fontStyle: 'italic', fontStyle: 'italic',
color: '#777', color: '#777',
padding: '2rem 1rem',
}} }}
> >
No transactions found for this account. No transactions found for this account.
@@ -129,15 +151,16 @@ export default function TransactionTable({}: TransactionTableProps) {
</tr> </tr>
); );
// Helper function to render transaction rows // Helper function to render transaction rows with better error handling
const renderRows = () => const renderRows = () =>
sortedTransactions.map((txn) => ( sortedTransactions.map((txn) => (
<tr key={txn.id} data-txn-id={txn.id}> <tr key={txn.id} data-txn-id={txn.id}>
<td>{formatDate(txn.date)}</td> <td>{formatDate(txn.date)}</td>
<td>{txn.description}</td> <td>{txn.description || 'No description'}</td>
<td className={`amount-col ${txn.amount >= 0 ? 'amount-positive' : 'amount-negative'}`}> <td className={`amount-col ${txn.amount >= 0 ? 'amount-positive' : 'amount-negative'}`}>
{formatCurrency(txn.amount)} {formatCurrency(txn.amount)}
</td> </td>
<td>{txn.category || 'Uncategorized'}</td>
<td> <td>
<button <button
type="button" type="button"
@@ -162,7 +185,7 @@ export default function TransactionTable({}: TransactionTableProps) {
return ( return (
<div id="transaction-section" className={isLoading ? 'loading' : ''}> <div id="transaction-section" className={isLoading ? 'loading' : ''}>
{error && ( {error && (
<div className="error-message" style={{ padding: '1rem' }}> <div className="error-message" style={{ padding: '1rem', marginBottom: '1rem' }}>
Error loading transactions: {error} Error loading transactions: {error}
</div> </div>
)} )}
@@ -172,14 +195,13 @@ export default function TransactionTable({}: TransactionTableProps) {
<th>Date</th> <th>Date</th>
<th>Description</th> <th>Description</th>
<th className="amount-col">Amount</th> <th className="amount-col">Amount</th>
<th>Category</th>
<th>Actions</th> <th>Actions</th>
</tr> </tr>
</thead> </thead>
<tbody id="transaction-table-body"> <tbody id="transaction-table-body">
{isLoading {isLoading
? renderLoading() ? renderLoading()
: error
? null // Error message is shown above the table
: sortedTransactions.length === 0 : sortedTransactions.length === 0
? renderEmpty() ? renderEmpty()
: renderRows()} : renderRows()}

275
src/data/db.service.ts Normal file
View File

@@ -0,0 +1,275 @@
import type { PrismaClient } from '@prisma/client';
import { Decimal } from '@prisma/client/runtime/library';
import type { Account, Transaction } from '../types';
import { prisma } from './prisma';
// Define the enums ourselves since Prisma isn't exporting them
export enum AccountType {
CHECKING = 'CHECKING',
SAVINGS = 'SAVINGS',
CREDIT_CARD = 'CREDIT_CARD',
INVESTMENT = 'INVESTMENT',
OTHER = 'OTHER',
}
export enum AccountStatus {
ACTIVE = 'ACTIVE',
CLOSED = 'CLOSED',
}
export enum TransactionStatus {
PENDING = 'PENDING',
CLEARED = 'CLEARED',
}
export enum TransactionType {
DEPOSIT = 'DEPOSIT',
WITHDRAWAL = 'WITHDRAWAL',
TRANSFER = 'TRANSFER',
UNSPECIFIED = 'UNSPECIFIED',
}
// Account services
export const accountService = {
/**
* Get all accounts
*/
async getAll(): Promise<Account[]> {
return prisma.account.findMany({
orderBy: { name: 'asc' },
});
},
/**
* Get account by ID
*/
async getById(id: string): Promise<Account | null> {
return prisma.account.findUnique({
where: { id },
});
},
/**
* Create a new account
*/
async create(data: {
bankName: string;
accountNumber: string;
name: string;
type?: AccountType;
status?: AccountStatus;
currency?: string;
balance?: number;
notes?: string;
}): Promise<Account> {
return prisma.account.create({
data,
});
},
/**
* Update an account
*/
async update(
id: string,
data: {
bankName?: string;
accountNumber?: string;
name?: string;
type?: AccountType;
status?: AccountStatus;
currency?: string;
balance?: number;
notes?: string;
},
): Promise<Account | null> {
return prisma.account.update({
where: { id },
data,
});
},
/**
* Delete an account
*/
async delete(id: string): Promise<Account | null> {
return prisma.account.delete({
where: { id },
});
},
/**
* Update account balance
*/
async updateBalance(id: string, amount: number): Promise<Account | null> {
const account = await prisma.account.findUnique({
where: { id },
});
if (!account) return null;
return prisma.account.update({
where: { id },
data: {
balance: {
increment: amount,
},
},
});
},
};
// Transaction services
export const transactionService = {
/**
* Get all transactions
*/
async getAll(): Promise<Transaction[]> {
return prisma.transaction.findMany({
orderBy: { date: 'desc' },
});
},
/**
* Get transactions by account ID
*/
async getByAccountId(accountId: string): Promise<Transaction[]> {
return prisma.transaction.findMany({
where: { accountId },
orderBy: { date: 'desc' },
});
},
/**
* Get transaction by ID
*/
async getById(id: string): Promise<Transaction | null> {
return prisma.transaction.findUnique({
where: { id },
});
},
/**
* Create a new transaction and update account balance
*/
async create(data: {
accountId: string;
date: Date;
description: string;
amount: number;
category?: string;
status?: TransactionStatus;
type?: TransactionType;
notes?: string;
tags?: string;
}): Promise<Transaction> {
// Use a transaction to ensure data consistency
return prisma.$transaction(async (prismaClient: PrismaClient) => {
// Create the transaction
const transaction = await prismaClient.transaction.create({
data,
});
// Update the account balance
await prismaClient.account.update({
where: { id: data.accountId },
data: {
balance: {
increment: data.amount,
},
},
});
return transaction;
});
},
/**
* Update a transaction and adjust account balance
*/
async update(
id: string,
data: {
accountId?: string;
date?: Date;
description?: string;
amount?: number;
category?: string;
status?: TransactionStatus;
type?: TransactionType;
notes?: string;
tags?: string;
},
): Promise<Transaction | null> {
// If amount is changing, we need to adjust the account balance
if (typeof data.amount !== 'undefined') {
return prisma.$transaction(async (prismaClient: PrismaClient) => {
// Get the current transaction to calculate difference
const currentTxn = await prismaClient.transaction.findUnique({
where: { id },
});
if (!currentTxn) return null;
// Calculate amount difference - amount is guaranteed to be defined at this point
const amount = data.amount; // Store in a constant to help TypeScript understand
const amountDifference = amount - Number(currentTxn.amount);
// Update transaction
const updatedTxn = await prismaClient.transaction.update({
where: { id },
data,
});
// Update account balance
await prismaClient.account.update({
where: { id: data.accountId || currentTxn.accountId },
data: {
balance: {
increment: amountDifference,
},
},
});
return updatedTxn;
});
}
// If amount isn't changing, just update the transaction
return prisma.transaction.update({
where: { id },
data,
});
},
/**
* Delete a transaction and adjust account balance
*/
async delete(id: string): Promise<Transaction | null> {
return prisma.$transaction(async (prismaClient: PrismaClient) => {
// Get transaction before deleting
const transaction = await prismaClient.transaction.findUnique({
where: { id },
});
if (!transaction) return null;
// Delete the transaction
const deletedTxn = await prismaClient.transaction.delete({
where: { id },
});
// Adjust the account balance (reverse the transaction amount)
await prismaClient.account.update({
where: { id: transaction.accountId },
data: {
balance: {
decrement: Number(transaction.amount),
},
},
});
return deletedTxn;
});
},
};

13
src/data/prisma.ts Normal file
View File

@@ -0,0 +1,13 @@
import { PrismaClient } from '@prisma/client';
// Prevent multiple instances of Prisma Client in development
declare global {
// eslint-disable-next-line no-var
var prismaClient: PrismaClient | undefined;
}
export const prisma = global.prismaClient || new PrismaClient();
if (process.env.NODE_ENV !== 'production') {
global.prismaClient = prisma;
}

View File

@@ -1,57 +0,0 @@
// TODO: Database Integration & Persistence
// - Implement database schema design
// - Add database connection pooling
// - Implement data migration strategy
// - Add database backup and recovery plans
// - Implement data validation layer
// - Add transaction logging
// - Implement audit trail
// - Add data archival strategy
import type { Account, Transaction } from '../types';
// TODO: Replace in-memory store with persistent database
// - Implement database connection and configuration
// - Create database schema for accounts and transactions
// - Add migration system for schema changes
// - Update all CRUD operations to use database instead of arrays
// Temporary in-memory store for development
export const accounts: Account[] = [
{
id: '1',
name: 'Checking Account',
last4: '4321',
balance: 2500.0,
},
{
id: '2',
name: 'Savings Account',
last4: '8765',
balance: 10000.0,
},
];
export const transactions: Transaction[] = [
{
id: '1',
accountId: '1',
date: '2025-04-20',
description: 'Grocery Store',
amount: -75.5,
},
{
id: '2',
accountId: '1',
date: '2025-04-21',
description: 'Salary Deposit',
amount: 3000.0,
},
{
id: '3',
accountId: '2',
date: '2025-04-22',
description: 'Transfer to Savings',
amount: 500.0,
},
];

View File

@@ -1,8 +1,9 @@
import type { APIRoute } from 'astro'; import type { APIRoute } from 'astro';
import { accounts } from '../../../../data/store'; import { accountService } from '../../../../data/db.service';
export const GET: APIRoute = async ({ params }) => { export const GET: APIRoute = async ({ params }) => {
const account = accounts.find((a) => a.id === params.id); try {
const account = await accountService.getById(params.id as string);
if (!account) { if (!account) {
return new Response(JSON.stringify({ error: 'Account not found' }), { return new Response(JSON.stringify({ error: 'Account not found' }), {
@@ -19,4 +20,13 @@ export const GET: APIRoute = async ({ params }) => {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
}); });
} catch (error) {
console.error('Error fetching account details:', error);
return new Response(JSON.stringify({ error: 'Failed to fetch account details' }), {
status: 500,
headers: {
'Content-Type': 'application/json',
},
});
}
}; };

View File

@@ -1,13 +1,29 @@
import type { APIRoute } from 'astro'; import type { APIRoute } from 'astro';
import { transactions } from '../../../../../data/store'; import { transactionService } from '../../../../../data/db.service';
export const GET: APIRoute = async ({ params }) => { export const GET: APIRoute = async ({ params }) => {
const accountTransactions = transactions.filter((t) => t.accountId === params.id); try {
const accountTransactions = await transactionService.getByAccountId(params.id as string);
return new Response(JSON.stringify(accountTransactions), { // Convert Decimal to number for each transaction in response
const response = accountTransactions.map((transaction) => ({
...transaction,
amount: Number(transaction.amount),
}));
return new Response(JSON.stringify(response), {
status: 200, status: 200,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
}); });
} catch (error) {
console.error('Error fetching account transactions:', error);
return new Response(JSON.stringify({ error: 'Failed to fetch account transactions' }), {
status: 500,
headers: {
'Content-Type': 'application/json',
},
});
}
}; };

View File

@@ -1,11 +1,65 @@
import type { APIRoute } from 'astro'; import type { APIRoute } from 'astro';
import { accounts } from '../../../data/store'; import { AccountStatus, AccountType, accountService } from '../../../data/db.service';
import type { Account } from '../../../types';
export const GET: APIRoute = async () => { export const GET: APIRoute = async () => {
try {
const accounts = await accountService.getAll();
return new Response(JSON.stringify(accounts), { return new Response(JSON.stringify(accounts), {
status: 200, status: 200,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
}); });
} catch (error) {
console.error('Error fetching accounts:', error);
return new Response(JSON.stringify({ error: 'Failed to fetch accounts' }), {
status: 500,
headers: {
'Content-Type': 'application/json',
},
});
}
};
export const POST: APIRoute = async ({ request }) => {
try {
const accountData = (await request.json()) as Omit<Account, 'id' | 'createdAt' | 'updatedAt'>;
// Validate required fields
if (!accountData.name || !accountData.bankName || !accountData.accountNumber) {
return new Response(
JSON.stringify({
error: 'Missing required fields: name, bankName, and accountNumber are required',
}),
{
status: 400,
headers: { 'Content-Type': 'application/json' },
},
);
}
// Set default values if not provided
const data = {
...accountData,
balance: accountData.balance || 0,
type: accountData.type || AccountType.CHECKING,
status: accountData.status || AccountStatus.ACTIVE,
};
// Create the account
const newAccount = await accountService.create(data);
return new Response(JSON.stringify(newAccount), {
status: 201,
headers: { 'Content-Type': 'application/json' },
});
} catch (error) {
console.error('Error creating account:', error);
return new Response(JSON.stringify({ error: 'Failed to create account' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
}; };

View File

@@ -1,7 +1,37 @@
import type { APIRoute } from 'astro'; import type { APIRoute } from 'astro';
import { accounts, transactions } from '../../../../data/store'; import { transactionService } from '../../../../data/db.service';
import type { Transaction } from '../../../../types'; import type { Transaction } from '../../../../types';
export const GET: APIRoute = async ({ params }) => {
try {
const transaction = await transactionService.getById(params.id as string);
if (!transaction) {
return new Response(JSON.stringify({ error: 'Transaction not found' }), {
status: 404,
headers: { 'Content-Type': 'application/json' },
});
}
// Convert Decimal to number for response
const response = {
...transaction,
amount: Number(transaction.amount),
};
return new Response(JSON.stringify(response), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
} catch (error) {
console.error('Error fetching transaction:', error);
return new Response(JSON.stringify({ error: 'Failed to fetch transaction' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
};
export const PUT: APIRoute = async ({ request, params }) => { export const PUT: APIRoute = async ({ request, params }) => {
const { id } = params; const { id } = params;
@@ -14,68 +44,48 @@ export const PUT: APIRoute = async ({ request, params }) => {
try { try {
const updates = (await request.json()) as Partial<Transaction>; const updates = (await request.json()) as Partial<Transaction>;
const transactionIndex = transactions.findIndex((t) => t.id === id);
if (transactionIndex === -1) { // Check if transaction exists
const existingTransaction = await transactionService.getById(id);
if (!existingTransaction) {
return new Response(JSON.stringify({ error: 'Transaction not found' }), { return new Response(JSON.stringify({ error: 'Transaction not found' }), {
status: 404, status: 404,
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
}); });
} }
const oldTransaction = transactions[transactionIndex]; // Convert date to Date object if it's a string
const updatedData: any = { ...updates };
if (typeof updates.date === 'string') {
updatedData.date = new Date(updates.date);
}
// Get the old account first // Update the transaction using the service
const oldAccount = accounts.find((a) => a.id === oldTransaction.accountId); // The service will automatically handle account balance adjustments
if (!oldAccount) { const updatedTransaction = await transactionService.update(id, updatedData);
return new Response(JSON.stringify({ error: 'Account not found' }), {
status: 404, if (!updatedTransaction) {
return new Response(JSON.stringify({ error: 'Failed to update transaction' }), {
status: 500,
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
}); });
} }
// If account is changing, validate new account exists // Convert Decimal to number for response
let newAccount = oldAccount; const response = {
if (updates.accountId && updates.accountId !== oldTransaction.accountId) { ...updatedTransaction,
const foundAccount = accounts.find((a) => a.id === updates.accountId); amount: Number(updatedTransaction.amount),
if (!foundAccount) {
return new Response(JSON.stringify({ error: 'Account not found' }), {
status: 404,
headers: { 'Content-Type': 'application/json' },
});
}
newAccount = foundAccount;
}
// First, remove the old transaction's effect on the old account
oldAccount.balance -= oldTransaction.amount;
// Create updated transaction
const updatedTransaction: Transaction = {
...oldTransaction,
...updates,
id: id, // Ensure ID doesn't change
}; };
// Then add the new transaction's effect to the appropriate account return new Response(JSON.stringify(response), {
if (newAccount === oldAccount) {
// If same account, just add the new amount
oldAccount.balance += updatedTransaction.amount;
} else {
// If different account, add to the new account
newAccount.balance += updatedTransaction.amount;
}
// Update transaction in array
transactions[transactionIndex] = updatedTransaction;
return new Response(JSON.stringify(updatedTransaction), {
status: 200, status: 200,
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
}); });
} catch (error) { } catch (error) {
return new Response(JSON.stringify({ error: 'Invalid request body' }), { console.error('Error updating transaction:', error);
status: 400, return new Response(JSON.stringify({ error: 'Failed to update transaction' }), {
status: 500,
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
}); });
} }
@@ -91,30 +101,24 @@ export const DELETE: APIRoute = async ({ params }) => {
}); });
} }
const transactionIndex = transactions.findIndex((t) => t.id === id); try {
// Delete the transaction using the service
// The service will automatically handle account balance adjustments
const deletedTransaction = await transactionService.delete(id);
if (transactionIndex === -1) { if (!deletedTransaction) {
return new Response(JSON.stringify({ error: 'Transaction not found' }), { return new Response(JSON.stringify({ error: 'Transaction not found' }), {
status: 404, status: 404,
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
}); });
} }
const transaction = transactions[transactionIndex]; return new Response(null, { status: 204 });
const account = accounts.find((a) => a.id === transaction.accountId); } catch (error) {
console.error('Error deleting transaction:', error);
if (!account) { return new Response(JSON.stringify({ error: 'Failed to delete transaction' }), {
return new Response(JSON.stringify({ error: 'Account not found' }), { status: 500,
status: 404,
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
}); });
} }
// Update account balance
account.balance -= transaction.amount;
// Remove transaction from array
transactions.splice(transactionIndex, 1);
return new Response(null, { status: 204 });
}; };

View File

@@ -11,7 +11,7 @@
*/ */
import type { APIRoute } from 'astro'; import type { APIRoute } from 'astro';
import { accounts, transactions } from '../../../data/store'; import { accountService, transactionService } from '../../../data/db.service';
import type { Transaction } from '../../../types'; import type { Transaction } from '../../../types';
/** /**
@@ -43,7 +43,7 @@ export const POST: APIRoute = async ({ request }) => {
} }
// Validate account exists // Validate account exists
const account = accounts.find((a) => a.id === transaction.accountId); const account = await accountService.getById(transaction.accountId);
if (!account) { if (!account) {
return new Response(JSON.stringify({ error: 'Account not found' }), { return new Response(JSON.stringify({ error: 'Account not found' }), {
status: 404, status: 404,
@@ -51,25 +51,38 @@ export const POST: APIRoute = async ({ request }) => {
}); });
} }
// Create new transaction with generated ID // Convert string date to Date object if needed
const newTransaction: Transaction = { const transactionDate =
...transaction, typeof transaction.date === 'string' ? new Date(transaction.date) : transaction.date;
id: (transactions.length + 1).toString(), // Simple ID generation for demo
// Create new transaction with database service
// The database service will also update the account balance
const newTransaction = await transactionService.create({
accountId: transaction.accountId,
date: transactionDate,
description: transaction.description,
amount: transaction.amount,
category: transaction.category,
status: transaction.status as any,
type: transaction.type as any,
notes: transaction.notes,
tags: transaction.tags,
});
// Convert Decimal to number for response
const response = {
...newTransaction,
amount: Number(newTransaction.amount),
}; };
// Update account balance return new Response(JSON.stringify(response), {
account.balance += transaction.amount;
// Add to transactions array
transactions.push(newTransaction);
return new Response(JSON.stringify(newTransaction), {
status: 201, status: 201,
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
}); });
} catch (error) { } catch (error) {
return new Response(JSON.stringify({ error: 'Invalid request body' }), { console.error('Error creating transaction:', error);
status: 400, return new Response(JSON.stringify({ error: 'Failed to create transaction' }), {
status: 500,
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
}); });
} }

View File

@@ -1,13 +1,8 @@
--- ---
import MainContent from '../components/MainContent.astro'; import MainContent from "../components/MainContent.astro";
import Sidebar from '../components/Sidebar.astro'; import Sidebar from "../components/Sidebar.astro";
import BaseLayout from '../layouts/BaseLayout.astro'; import BaseLayout from "../layouts/BaseLayout.astro";
import type { Account, Transaction } from '../types'; import type { Account, Transaction } from "../types";
export interface Props {
account: Account;
transactions: Transaction[];
}
// Get the base URL from the incoming request // Get the base URL from the incoming request
const baseUrl = new URL(Astro.request.url).origin; const baseUrl = new URL(Astro.request.url).origin;
@@ -18,10 +13,11 @@ const accounts: Account[] = await accountsResponse.json();
// Initialize with first account or empty account if none exist // Initialize with first account or empty account if none exist
const initialAccount: Account = accounts[0] || { const initialAccount: Account = accounts[0] || {
id: '', id: "",
name: 'No accounts available', name: "No accounts available",
last4: '0000', accountNumber: "000000",
balance: 0, balance: 0,
bankName: "",
}; };
// Fetch initial transactions if we have an account, using absolute URL // Fetch initial transactions if we have an account, using absolute URL
@@ -34,27 +30,6 @@ if (initialAccount.id) {
} }
--- ---
<!--
TODO: State Management Improvements
- Consider implementing Nano Stores for better state management
- Add more robust error handling and user feedback
- Implement loading states for all async operations
- Add offline support with data synchronization
- Consider implementing optimistic updates for better UX
-->
<!--
TODO: Performance & Monitoring
- Implement client-side error tracking
- Add performance metrics collection
- Set up monitoring for API response times
- Implement request caching strategy
- Add lazy loading for transaction history
- Optimize bundle size
- Add performance budgets
- Implement progressive loading
-->
<BaseLayout title="Bank Transactions Dashboard"> <BaseLayout title="Bank Transactions Dashboard">
<div class="dashboard-layout"> <div class="dashboard-layout">
<Sidebar accounts={accounts} initialAccount={initialAccount} /> <Sidebar accounts={accounts} initialAccount={initialAccount} />
@@ -63,103 +38,228 @@ TODO: Performance & Monitoring
</BaseLayout> </BaseLayout>
<script> <script>
// Import types for client-side script // Import store actions - done directly to avoid TypeScript import issues
type Transaction = import('../types').Transaction; import {
type Account = import('../types').Account; currentAccountId,
setTransactions,
loadTransactionsForAccount,
startEditingTransaction,
} from "../stores/transactionStore";
// Import store atoms and actions // Access server-rendered data which is available as globals
import { currentAccountId, startEditingTransaction } from '../stores/transactionStore'; const initialAccountData = JSON.parse(
document
.getElementById("initial-account-data")
?.getAttribute("data-account") || "{}",
);
const initialTransactionsData = JSON.parse(
document
.getElementById("initial-transactions-data")
?.getAttribute("data-transactions") || "[]",
);
// --- DOM Elements --- // --- DOM Elements ---
const accountSelect = document.getElementById('account-select') as HTMLSelectElement; const accountSelect = document.getElementById("account-select");
const currentAccountNameSpan = document.getElementById('current-account-name'); const currentAccountNameSpan = document.getElementById(
const addTransactionSection = document.getElementById('add-transaction-section'); "current-account-name",
const toggleAddTxnBtn = document.getElementById('toggle-add-txn-btn'); );
const addTransactionSection = document.getElementById(
"add-transaction-section",
);
const toggleAddTxnBtn = document.getElementById("toggle-add-txn-btn");
console.log("Initial setup - Account:", initialAccountData);
console.log("Initial setup - Transactions:", initialTransactionsData);
// --- Helper Functions --- // --- Helper Functions ---
async function fetchAccountDetails(accountId: string): Promise<Account | null> { async function fetchAccountDetails(accountId) {
console.log("Fetching details for account:", accountId);
try { try {
const response = await fetch(`/api/accounts/${accountId}`); const response = await fetch(`/api/accounts/${accountId}`);
if (!response.ok) throw new Error('Failed to fetch account details'); if (!response.ok)
throw new Error("Failed to fetch account details");
return await response.json(); return await response.json();
} catch (error) { } catch (error) {
console.error('Error fetching account:', error); console.error("Error fetching account:", error);
return null; return null;
} }
} }
// --- Update UI Function (Further Simplified) --- // --- Update UI Function ---
async function updateUIForAccount(accountId: string): Promise<void> { async function updateUIForAccount(accountId) {
console.log("Updating Account Header for account:", accountId); console.log("Updating UI for account:", accountId);
// Update the store with the current account ID // Update the store with the current account ID
currentAccountId.set(accountId); currentAccountId.set(accountId);
// Only update the non-React part (header span) // Only update the non-React part (header span)
currentAccountNameSpan?.classList.add('loading-inline'); currentAccountNameSpan?.classList.add("loading-inline");
try { try {
const account = await fetchAccountDetails(accountId); const account = await fetchAccountDetails(accountId);
if (!account || !currentAccountNameSpan) { if (!account || !currentAccountNameSpan) {
console.error("Account data or header element not found!"); console.error("Account data or header element not found!");
if (currentAccountNameSpan) currentAccountNameSpan.textContent = 'Error'; if (currentAccountNameSpan)
currentAccountNameSpan.textContent = "Error";
return; return;
} }
// Update header // Update header - use accountNumber instead of last4
currentAccountNameSpan.textContent = `${account.name} (***${account.last4})`; currentAccountNameSpan.textContent = `${account.name} (***${account.accountNumber.slice(-3)})`;
// Load transactions for this account
console.log("Loading transactions for account:", accountId);
await loadTransactionsForAccount(accountId);
} catch (error) { } catch (error) {
console.error('Error updating account header:', error); console.error("Error updating account header:", error);
if (currentAccountNameSpan) currentAccountNameSpan.textContent = 'Error'; if (currentAccountNameSpan)
currentAccountNameSpan.textContent = "Error";
} finally { } finally {
currentAccountNameSpan?.classList.remove('loading-inline'); currentAccountNameSpan?.classList.remove("loading-inline");
} }
} }
// --- Transaction Actions --- // --- Transaction Actions ---
async function handleEditTransaction(txnId: string): Promise<void> { async function handleEditTransaction(txnId) {
console.log("Edit transaction requested:", txnId);
try { try {
const accountId = currentAccountId.get(); const accountId = currentAccountId.get();
if (!accountId) return; if (!accountId) {
console.error("No account selected for editing transaction");
return;
}
const response = await fetch(`/api/accounts/${accountId}/transactions`); const response = await fetch(
if (!response.ok) throw new Error('Failed to fetch transactions for edit'); `/api/accounts/${accountId}/transactions`,
const transactions: Transaction[] = await response.json(); );
const transaction = transactions.find(t => t.id === txnId); if (!response.ok)
throw new Error("Failed to fetch transactions for edit");
const transactions = await response.json();
const transaction = transactions.find((t) => t.id === txnId);
if (!transaction) { if (!transaction) {
throw new Error('Transaction not found for editing'); throw new Error("Transaction not found for editing");
} }
startEditingTransaction(transaction); startEditingTransaction(transaction);
// Manually expand the form section if it's collapsed // Manually expand the form section if it's collapsed
if (addTransactionSection?.classList.contains('collapsed')) { if (addTransactionSection?.classList.contains("collapsed")) {
addTransactionSection.classList.replace('collapsed', 'expanded'); addTransactionSection.classList.replace(
toggleAddTxnBtn?.setAttribute('aria-expanded', 'true'); "collapsed",
addTransactionSection.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); "expanded",
);
toggleAddTxnBtn?.setAttribute("aria-expanded", "true");
addTransactionSection.scrollIntoView({
behavior: "smooth",
block: "nearest",
});
} }
} catch (error) { } catch (error) {
alert(error instanceof Error ? error.message : 'Failed to load transaction for editing'); alert(
error instanceof Error
? error.message
: "Failed to load transaction for editing",
);
} }
} }
// --- Event Listeners --- // --- Event Listeners ---
if (accountSelect) { if (accountSelect) {
accountSelect.addEventListener('change', (event: Event) => { accountSelect.addEventListener("change", (event) => {
const target = event.target as HTMLSelectElement; const target = event.target;
if (target && target.value) {
updateUIForAccount(target.value); updateUIForAccount(target.value);
}
}); });
} }
document.addEventListener("click", (event) => {
const target = event.target;
if (target && target.classList.contains("edit-btn")) {
const row = target.closest("[data-txn-id]");
if (row) {
const txnId = row.dataset.txnId;
if (txnId) handleEditTransaction(txnId);
}
}
});
// --- Initial Load --- // --- Initial Load ---
const initialAccountIdValue = accountSelect?.value; // Add the initial data to the page for client-side scripts to access
if (initialAccountIdValue) { if (!document.getElementById("initial-account-data")) {
updateUIForAccount(initialAccountIdValue); const accountDataEl = document.createElement("script");
accountDataEl.id = "initial-account-data";
accountDataEl.type = "application/json";
accountDataEl.setAttribute(
"data-account",
JSON.stringify(initialAccountData || {}),
);
document.body.appendChild(accountDataEl);
}
if (!document.getElementById("initial-transactions-data")) {
const txnDataEl = document.createElement("script");
txnDataEl.id = "initial-transactions-data";
txnDataEl.type = "application/json";
txnDataEl.setAttribute(
"data-transactions",
JSON.stringify(initialTransactionsData || []),
);
document.body.appendChild(txnDataEl);
}
// Initialize state on page load with server data
window.addEventListener("DOMContentLoaded", () => {
// Initialize with first account
if (initialAccountData?.id) {
console.log("Setting initial account ID:", initialAccountData.id);
// Update current account in store
currentAccountId.set(initialAccountData.id);
// Set initial transactions if we have them
if (initialTransactionsData && initialTransactionsData.length > 0) {
console.log(
"Setting initial transactions:",
initialTransactionsData.length,
);
setTransactions(initialTransactionsData);
} else { } else {
currentAccountId.set(null); console.log("No initial transactions, fetching from API");
loadTransactionsForAccount(initialAccountData.id);
}
} else {
console.log("No initial account data available");
}
});
// Set initial account as soon as possible
if (initialAccountData?.id) {
console.log("Setting account ID immediately:", initialAccountData.id);
currentAccountId.set(initialAccountData.id);
// Also set initial transactions
if (initialTransactionsData && initialTransactionsData.length > 0) {
console.log(
"Setting transactions immediately:",
initialTransactionsData.length,
);
setTransactions(initialTransactionsData);
}
} }
</script> </script>
<script
id="initial-account-data"
type="application/json"
set:html={JSON.stringify(initialAccount)}
data-account={JSON.stringify(initialAccount)}
/>
<script
id="initial-transactions-data"
type="application/json"
set:html={JSON.stringify(initialTransactions)}
data-transactions={JSON.stringify(initialTransactions)}
/>

View File

@@ -4,43 +4,179 @@ import type { Transaction } from '../types';
// Atom to hold the ID of the currently selected account // Atom to hold the ID of the currently selected account
export const currentAccountId = atom<string | null>(null); export const currentAccountId = atom<string | null>(null);
// Atom to hold the current transactions
export const currentTransactions = atom<Transaction[]>([]);
// Atom to hold the transaction object when editing, or null otherwise // Atom to hold the transaction object when editing, or null otherwise
export const transactionToEdit = atom<Transaction | null>(null); export const transactionToEdit = atom<Transaction | null>(null);
// Atom to trigger refreshes in components that depend on external changes // Atom to trigger refreshes in components that depend on external changes
export const refreshKey = atom<number>(0); export const refreshKey = atom<number>(0);
// Action to set the current transactions
export function setTransactions(transactions: Transaction[]) {
console.log('Setting transactions in store:', transactions.length, transactions);
currentTransactions.set(transactions);
}
// Action to increment the refresh key, forcing dependent effects to re-run // Action to increment the refresh key, forcing dependent effects to re-run
export function triggerRefresh() { export function triggerRefresh() {
console.log('Triggering transaction refresh');
refreshKey.set(refreshKey.get() + 1); refreshKey.set(refreshKey.get() + 1);
} }
// Action to set the transaction to be edited // Action to set the transaction to be edited
export function startEditingTransaction(transaction: Transaction) { export function startEditingTransaction(transaction: Transaction) {
transactionToEdit.set(transaction); console.log('Setting transaction to edit:', transaction);
// Optionally, trigger UI changes like expanding the form here
// document.getElementById('add-transaction-section')?.classList.replace('collapsed', 'expanded'); // Create a clean copy of the transaction to avoid reference issues
// document.getElementById('toggle-add-txn-btn')?.setAttribute('aria-expanded', 'true'); const transactionCopy = { ...transaction };
// Force update to ensure subscribers get notified
transactionToEdit.set(null);
// Set after a small delay to ensure state change is detected
setTimeout(() => {
transactionToEdit.set(transactionCopy);
console.log('Transaction edit state updated:', transactionToEdit.get());
}, 0);
} }
// Action to clear the edit state // Action to clear the edit state
export function cancelEditingTransaction() { export function cancelEditingTransaction() {
console.log('Canceling transaction edit');
transactionToEdit.set(null); transactionToEdit.set(null);
// Optionally, collapse the form
// document.getElementById('add-transaction-section')?.classList.replace('expanded', 'collapsed');
// document.getElementById('toggle-add-txn-btn')?.setAttribute('aria-expanded', 'false');
} }
// Action triggered after a transaction is saved (created or updated) // Action triggered after a transaction is saved (created or updated)
export function transactionSaved(transaction: Transaction) { export function transactionSaved(transaction: Transaction) {
console.log('Transaction saved:', transaction);
// Clear edit state if the saved transaction was the one being edited // Clear edit state if the saved transaction was the one being edited
if (transactionToEdit.get()?.id === transaction.id) { if (transactionToEdit.get()?.id === transaction.id) {
transactionToEdit.set(null); transactionToEdit.set(null);
} }
// Potentially trigger UI updates or refreshes here
// This might involve dispatching a custom event or calling a refresh function
document.dispatchEvent(new CustomEvent('transactionSaved', { detail: { transaction } }));
// Trigger a general refresh after saving too, to update balance // Add/update the transaction in the current list
const currentList = currentTransactions.get();
const existingIndex = currentList.findIndex((t) => t.id === transaction.id);
if (existingIndex >= 0) {
// Update existing transaction
const updatedList = [...currentList];
updatedList[existingIndex] = transaction;
currentTransactions.set(updatedList);
} else {
// Add new transaction
currentTransactions.set([transaction, ...currentList]);
}
// Trigger a general refresh after saving
triggerRefresh(); triggerRefresh();
} }
// Helper function to load transactions for an account
export async function loadTransactionsForAccount(accountId: string) {
console.log('loadTransactionsForAccount called with ID:', accountId);
try {
if (!accountId) {
console.warn('No account ID provided, clearing transactions');
currentTransactions.set([]);
return [];
}
console.log(`Fetching transactions from API for account: ${accountId}`);
const response = await fetch(`/api/accounts/${accountId}/transactions`);
if (!response.ok) {
console.error('API error:', response.status, response.statusText);
const errorText = await response.text();
console.error('Error response:', errorText);
throw new Error(`Failed to fetch transactions: ${response.statusText}`);
}
const transactions: Transaction[] = await response.json();
console.log(
`Loaded ${transactions.length} transactions for account ${accountId}:`,
transactions,
);
// Set transactions in the store
currentTransactions.set(transactions);
return transactions;
} catch (error) {
console.error('Error loading transactions:', error);
// Don't clear transactions on error, to avoid flickering UI
throw error;
}
}
// Helper to create a new transaction
export async function createTransaction(transaction: Omit<Transaction, 'id'>) {
try {
console.log('Creating new transaction:', transaction);
const response = await fetch('/api/transactions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(transaction),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({ error: 'Unknown error' }));
throw new Error(errorData.error || `Failed to create transaction: ${response.statusText}`);
}
const newTransaction = await response.json();
console.log('Transaction created successfully:', newTransaction);
// Add the new transaction to the existing list
const currentList = currentTransactions.get();
currentTransactions.set([newTransaction, ...currentList]);
// Trigger refresh to update other components
triggerRefresh();
return newTransaction;
} catch (error) {
console.error('Error creating transaction:', error);
throw error;
}
}
// Helper to update an existing transaction
export async function updateTransaction(id: string, transaction: Partial<Transaction>) {
try {
console.log(`Updating transaction ${id}:`, transaction);
const response = await fetch(`/api/transactions/${id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(transaction),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({ error: 'Unknown error' }));
throw new Error(errorData.error || `Failed to update transaction: ${response.statusText}`);
}
const updatedTransaction = await response.json();
console.log('Transaction updated successfully:', updatedTransaction);
// Update the transaction in the existing list
const currentList = currentTransactions.get();
const updatedList = currentList.map((t) =>
t.id === updatedTransaction.id ? updatedTransaction : t,
);
currentTransactions.set(updatedList);
// Trigger refresh to update other components
triggerRefresh();
return updatedTransaction;
} catch (error) {
console.error('Error updating transaction:', error);
throw error;
}
}

View File

@@ -1,65 +0,0 @@
import { describe, expect, it } from 'vitest';
import { GET as getAccount } from '../pages/api/accounts/[id]/index';
import { GET as listTransactions } from '../pages/api/accounts/[id]/transactions/index';
import { GET as listAccounts } from '../pages/api/accounts/index';
import { createMockAPIContext } from './setup';
describe('Accounts API', () => {
describe('GET /api/accounts', () => {
it('should return all accounts', async () => {
const response = await listAccounts(createMockAPIContext());
const accounts = await response.json();
expect(response.status).toBe(200);
expect(accounts).toHaveLength(2);
expect(accounts[0]).toHaveProperty('id', '1');
expect(accounts[1]).toHaveProperty('id', '2');
});
});
describe('GET /api/accounts/:id', () => {
it('should return a specific account', async () => {
const response = await getAccount(
createMockAPIContext({ params: { id: '1' } }) as APIContext,
);
const account = await response.json();
expect(response.status).toBe(200);
expect(account).toHaveProperty('id', '1');
expect(account).toHaveProperty('name', 'Test Checking');
});
it('should return 404 for non-existent account', async () => {
const response = await getAccount(
createMockAPIContext({ params: { id: '999' } }) as APIContext,
);
const error = await response.json();
expect(response.status).toBe(404);
expect(error).toHaveProperty('error', 'Account not found');
});
});
describe('GET /api/accounts/:id/transactions', () => {
it('should return transactions for an account', async () => {
const response = await listTransactions(
createMockAPIContext({ params: { id: '1' } }) as APIContext,
);
const transactions = await response.json();
expect(response.status).toBe(200);
expect(transactions).toHaveLength(1);
expect(transactions[0]).toHaveProperty('accountId', '1');
});
it('should return empty array for account with no transactions', async () => {
const response = await listTransactions(
createMockAPIContext({ params: { id: '999' } }) as APIContext,
);
const transactions = await response.json();
expect(response.status).toBe(200);
expect(transactions).toHaveLength(0);
});
});
});

View File

@@ -0,0 +1,237 @@
import supertest from 'supertest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { accountService, transactionService } from '../data/db.service';
import { prisma } from '../data/prisma';
// Define a test server
const BASE_URL = 'http://localhost:4322';
const request = supertest(BASE_URL);
// Test variables to store IDs across tests
let testAccountId = '';
let testTransactionId = '';
// Skip these tests if we detect we're not in an environment with a database
// This helps avoid failing tests in CI/CD environments without DB setup
const shouldSkipTests = process.env.NODE_ENV === 'test' && !process.env.DATABASE_URL;
// Run this entire test suite conditionally
describe('Database Integration Tests', () => {
// Setup before all tests
beforeAll(async () => {
if (shouldSkipTests) {
console.warn('Skipping database tests: No database connection available');
return;
}
// Verify database connection
try {
await prisma.$connect();
console.log('Database connection successful');
// Create a test account
const testAccount = await accountService.create({
bankName: 'Test Bank',
accountNumber: '123456',
name: 'Test Account',
type: 'CHECKING',
balance: 1000,
notes: 'Created for automated testing',
});
testAccountId = testAccount.id;
console.log(`Created test account with ID: ${testAccountId}`);
} catch (error) {
console.error('Database connection failed:', error);
// We'll check this in the first test and skip as needed
}
});
// Cleanup after all tests
afterAll(async () => {
if (shouldSkipTests) return;
try {
// Clean up the test data
if (testTransactionId) {
await transactionService.delete(testTransactionId);
console.log(`Cleaned up test transaction: ${testTransactionId}`);
}
if (testAccountId) {
await accountService.delete(testAccountId);
console.log(`Cleaned up test account: ${testAccountId}`);
}
await prisma.$disconnect();
} catch (error) {
console.error('Cleanup failed:', error);
}
});
// Conditional test execution - checks if DB is available
it('should connect to the database', () => {
if (shouldSkipTests) {
return it.skip('Database tests are disabled in this environment');
}
// This is just a placeholder test - real connection check happens in beforeAll
expect(prisma).toBeDefined();
});
// Test Account API
describe('Account API', () => {
it('should get all accounts', async () => {
if (shouldSkipTests) return;
const response = await request.get('/api/accounts');
expect(response.status).toBe(200);
expect(Array.isArray(response.body)).toBe(true);
expect(response.body.length).toBeGreaterThan(0);
// Check if our test account is in the response
const foundAccount = response.body.find((account: any) => account.id === testAccountId);
expect(foundAccount).toBeDefined();
});
it('should get a single account by ID', async () => {
if (shouldSkipTests) return;
const response = await request.get(`/api/accounts/${testAccountId}`);
expect(response.status).toBe(200);
expect(response.body.id).toBe(testAccountId);
expect(response.body.name).toBe('Test Account');
expect(response.body.bankName).toBe('Test Bank');
expect(response.body.accountNumber).toBe('123456');
});
});
// Test Transaction API
describe('Transaction API', () => {
it('should create a new transaction', async () => {
if (shouldSkipTests) return;
const transactionData = {
accountId: testAccountId,
date: new Date().toISOString().split('T')[0],
description: 'Test Transaction',
amount: -50.25,
category: 'Testing',
type: 'WITHDRAWAL',
};
const response = await request
.post('/api/transactions')
.send(transactionData)
.set('Content-Type', 'application/json')
.set('Accept', 'application/json');
expect(response.status).toBe(201);
expect(response.body.id).toBeDefined();
expect(response.body.description).toBe('Test Transaction');
expect(response.body.amount).toBe(-50.25);
// Save the transaction ID for later tests
testTransactionId = response.body.id;
});
it('should get account transactions', async () => {
if (shouldSkipTests) return;
const response = await request.get(`/api/accounts/${testAccountId}/transactions`);
expect(response.status).toBe(200);
expect(Array.isArray(response.body)).toBe(true);
// Check if our test transaction is in the response
const foundTransaction = response.body.find((txn: any) => txn.id === testTransactionId);
expect(foundTransaction).toBeDefined();
expect(foundTransaction.description).toBe('Test Transaction');
});
it('should update a transaction', async () => {
if (shouldSkipTests) return;
const updateData = {
description: 'Updated Transaction',
amount: -75.5,
};
const response = await request
.put(`/api/transactions/${testTransactionId}`)
.send(updateData)
.set('Content-Type', 'application/json')
.set('Accept', 'application/json');
expect(response.status).toBe(200);
expect(response.body.description).toBe('Updated Transaction');
expect(response.body.amount).toBe(-75.5);
});
it('should verify account balance updates after transaction changes', async () => {
if (shouldSkipTests) return;
// Get the latest account data
const account = await accountService.getById(testAccountId);
expect(account).toBeDefined();
if (account) {
// The account should have been debited by the transaction amount
expect(Number(account.balance)).toBe(1000 - 75.5);
}
});
it('should delete a transaction', async () => {
if (shouldSkipTests) return;
// Get the initial account data
const accountBefore = await accountService.getById(testAccountId);
const initialBalance = Number(accountBefore?.balance || 0);
const response = await request.delete(`/api/transactions/${testTransactionId}`);
expect(response.status).toBe(204);
// Verify the transaction is gone
const transactionCheck = await transactionService.getById(testTransactionId);
expect(transactionCheck).toBeNull();
// Verify account balance was restored
const accountAfter = await accountService.getById(testAccountId);
expect(Number(accountAfter?.balance)).toBe(initialBalance + 75.5);
// Clear the testTransactionId since it's been deleted
testTransactionId = '';
});
});
// Test error handling
describe('Error Handling', () => {
it('should handle invalid transaction creation', async () => {
if (shouldSkipTests) return;
// Missing required fields
const invalidData = {
accountId: testAccountId,
// Missing date, description, amount
};
const response = await request
.post('/api/transactions')
.send(invalidData)
.set('Content-Type', 'application/json');
expect(response.status).toBe(400);
expect(response.body.error).toBeDefined();
});
it('should handle non-existent account', async () => {
if (shouldSkipTests) return;
const response = await request.get('/api/accounts/non-existent-id');
expect(response.status).toBe(404);
expect(response.body.error).toBeDefined();
});
});
});

View File

@@ -1,11 +1,5 @@
import '@testing-library/jest-dom'; import '@testing-library/jest-dom';
import type { APIContext } from 'astro'; import type { APIContext } from 'astro';
import { beforeEach, vi } from 'vitest';
import { accounts, transactions } from '../data/store';
// Setup JSDOM globals needed for React testing
// @ts-ignore - vi.stubGlobal is not in the types
vi.stubGlobal('fetch', vi.fn());
// Create a mock APIContext factory // Create a mock APIContext factory
export function createMockAPIContext(options: Partial<APIContext> = {}): APIContext { export function createMockAPIContext(options: Partial<APIContext> = {}): APIContext {
@@ -22,46 +16,3 @@ export function createMockAPIContext(options: Partial<APIContext> = {}): APICont
...options, ...options,
}; };
} }
// Reset test data before each test
beforeEach(() => {
// Reset accounts to initial state
accounts.length = 0;
accounts.push(
{
id: '1',
name: 'Test Checking',
last4: '1234',
balance: 1000.0,
},
{
id: '2',
name: 'Test Savings',
last4: '5678',
balance: 5000.0,
},
);
// Reset transactions to initial state
transactions.length = 0;
transactions.push(
{
id: '1',
accountId: '1',
date: '2025-04-24',
description: 'Test Transaction 1',
amount: -50.0,
},
{
id: '2',
accountId: '2',
date: '2025-04-24',
description: 'Test Transaction 2',
amount: 100.0,
},
);
// Reset fetch mock
// @ts-ignore - vi.fn() is not in the types
fetch.mockReset();
});

View File

@@ -1,325 +0,0 @@
// TODO: Testing Improvements
// - Add integration tests for API endpoints
// - Add end-to-end tests with Playwright/Cypress
// - Add performance testing
// - Add accessibility testing with axe-core
// - Add visual regression testing
// - Add load testing for API endpoints
// - Implement test data factories
import { describe, expect, it } from 'vitest';
import { accounts, transactions } from '../data/store';
import {
DELETE as deleteTransaction,
PUT as updateTransaction,
} from '../pages/api/transactions/[id]/index';
import { POST as createTransaction } from '../pages/api/transactions/index';
import type { Transaction } from '../types';
import { createMockAPIContext } from './setup';
describe('Transactions API', () => {
describe('POST /api/transactions', () => {
it('should create a new transaction', async () => {
const initialBalance = accounts[0].balance;
const newTransaction = {
accountId: '1',
date: '2025-04-24',
description: 'Test New Transaction',
amount: -25.0,
};
const ctx = createMockAPIContext() as APIContext;
ctx.request = new Request('http://localhost:4321/api/transactions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newTransaction),
});
const response = await createTransaction(ctx);
const result = await response.json();
expect(response.status).toBe(201);
expect(result).toHaveProperty('id');
expect(result.description).toBe(newTransaction.description);
expect(accounts[0].balance).toBe(initialBalance + newTransaction.amount);
});
it('should reject transaction with missing fields', async () => {
const invalidTransaction = {
accountId: '1',
// Missing required fields
};
const ctx = createMockAPIContext() as APIContext;
ctx.request = new Request('http://localhost:4321/api/transactions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(invalidTransaction),
});
const response = await createTransaction(ctx);
const error = await response.json();
expect(response.status).toBe(400);
expect(error).toHaveProperty('error', 'Missing required fields');
});
it('should reject transaction with invalid account', async () => {
const invalidTransaction = {
accountId: '999',
date: '2025-04-24',
description: 'Invalid Account Test',
amount: 100,
};
const ctx = createMockAPIContext() as APIContext;
ctx.request = new Request('http://localhost:4321/api/transactions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(invalidTransaction),
});
const response = await createTransaction(ctx);
const error = await response.json();
expect(response.status).toBe(404);
expect(error).toHaveProperty('error', 'Account not found');
});
it('should reject invalid request body', async () => {
const ctx = createMockAPIContext() as APIContext;
ctx.request = new Request('http://localhost:4321/api/transactions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: 'invalid json',
});
const response = await createTransaction(ctx);
const error = await response.json();
expect(response.status).toBe(400);
expect(error).toHaveProperty('error', 'Invalid request body');
});
});
describe('PUT /api/transactions/:id', () => {
it('should update an existing transaction', async () => {
const initialBalance = accounts[0].balance;
const originalAmount = transactions[0].amount;
const updates = {
description: 'Updated Description',
amount: -75.0,
};
const ctx = createMockAPIContext({ params: { id: '1' } }) as APIContext;
ctx.request = new Request('http://localhost:4321/api/transactions/1', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates),
});
const response = await updateTransaction(ctx);
const result = await response.json();
expect(response.status).toBe(200);
expect(result.description).toBe(updates.description);
expect(result.amount).toBe(updates.amount);
expect(accounts[0].balance).toBe(initialBalance - originalAmount + updates.amount);
});
it('should reject update with invalid request body', async () => {
const ctx = createMockAPIContext({ params: { id: '1' } }) as APIContext;
ctx.request = new Request('http://localhost:4321/api/transactions/1', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: 'invalid json',
});
const response = await updateTransaction(ctx);
const error = await response.json();
expect(response.status).toBe(400);
expect(error).toHaveProperty('error', 'Invalid request body');
});
it('should reject update for non-existent transaction', async () => {
const ctx = createMockAPIContext({ params: { id: '999' } }) as APIContext;
ctx.request = new Request('http://localhost:4321/api/transactions/999', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ description: 'Test' }),
});
const response = await updateTransaction(ctx);
const error = await response.json();
expect(response.status).toBe(404);
expect(error).toHaveProperty('error', 'Transaction not found');
});
it('should reject update for non-existent account', async () => {
// First update the transaction to point to a non-existent account
transactions[0].accountId = '999';
const ctx = createMockAPIContext({ params: { id: '1' } }) as APIContext;
ctx.request = new Request('http://localhost:4321/api/transactions/1', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ amount: -100 }),
});
const response = await updateTransaction(ctx);
const error = await response.json();
expect(response.status).toBe(404);
expect(error).toHaveProperty('error', 'Account not found');
// Reset account ID for other tests
transactions[0].accountId = '1';
});
it('should handle account balance updates correctly when switching accounts', async () => {
// Create initial state
const oldAccount = accounts[0];
const newAccount = accounts[1];
const initialOldBalance = oldAccount.balance;
const initialNewBalance = newAccount.balance;
const oldTransaction = transactions.find((t) => t.id === '1');
if (!oldTransaction) throw new Error('Test transaction not found');
const ctx = createMockAPIContext({ params: { id: '1' } }) as APIContext;
ctx.request = new Request('http://localhost:4321/api/transactions/1', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
accountId: newAccount.id,
amount: -100,
}),
});
const response = await updateTransaction(ctx);
const result = await response.json();
expect(response.status).toBe(200);
expect(result.accountId).toBe(newAccount.id);
// Old account should have the old amount removed
expect(oldAccount.balance).toBe(initialOldBalance + Math.abs(oldTransaction.amount));
// New account should have the new amount added
expect(newAccount.balance).toBe(initialNewBalance - 100);
});
it('should reject update without transaction ID', async () => {
const ctx = createMockAPIContext() as APIContext;
ctx.request = new Request('http://localhost:4321/api/transactions/undefined', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ description: 'Test' }),
});
const response = await updateTransaction(ctx);
const error = await response.json();
expect(response.status).toBe(400);
expect(error).toHaveProperty('error', 'Transaction ID is required');
});
it('should reject update when old account is missing', async () => {
// Store current accounts and clear the array
const savedAccounts = [...accounts];
accounts.length = 0;
const ctx = createMockAPIContext({ params: { id: '1' } }) as APIContext;
ctx.request = new Request('http://localhost:4321/api/transactions/1', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ amount: -100 }),
});
const response = await updateTransaction(ctx);
const error = await response.json();
expect(response.status).toBe(404);
expect(error).toHaveProperty('error', 'Account not found');
// Restore accounts
accounts.push(...savedAccounts);
});
it("should reject update when new account doesn't exist", async () => {
const ctx = createMockAPIContext({ params: { id: '1' } }) as APIContext;
ctx.request = new Request('http://localhost:4321/api/transactions/1', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
accountId: '999', // Non-existent account
amount: -100,
}),
});
const response = await updateTransaction(ctx);
const error = await response.json();
expect(response.status).toBe(404);
expect(error).toHaveProperty('error', 'Account not found');
});
});
describe('DELETE /api/transactions/:id', () => {
it('should delete a transaction', async () => {
const initialBalance = accounts[0].balance;
const transactionAmount = transactions[0].amount;
const initialCount = transactions.length;
const response = await deleteTransaction(
createMockAPIContext({ params: { id: '1' } }) as any,
);
expect(response.status).toBe(204);
expect(transactions).toHaveLength(initialCount - 1);
expect(accounts[0].balance).toBe(initialBalance - transactionAmount);
});
it('should reject delete without transaction ID', async () => {
const response = await deleteTransaction(createMockAPIContext() as APIContext);
const error = await response.json();
expect(response.status).toBe(400);
expect(error).toHaveProperty('error', 'Transaction ID is required');
});
it('should return 404 for non-existent transaction', async () => {
const response = await deleteTransaction(
createMockAPIContext({ params: { id: '999' } }) as any,
);
const error = await response.json();
expect(response.status).toBe(404);
expect(error).toHaveProperty('error', 'Transaction not found');
});
it('should handle deletion with non-existent account', async () => {
// Create a transaction then remove its account
const testTransaction: Transaction = {
id: 'test-delete',
accountId: 'test-account',
date: '2025-04-24',
description: 'Test Delete',
amount: 100,
};
transactions.push(testTransaction);
const response = await deleteTransaction(
createMockAPIContext({ params: { id: 'test-delete' } }) as any,
);
const error = await response.json();
expect(response.status).toBe(404);
expect(error).toHaveProperty('error', 'Account not found');
});
});
});

View File

@@ -1,14 +1,28 @@
export interface Account { export interface Account {
id: string; id: string;
name: string; bankName: string;
last4: string; accountNumber: string; // Last 6 digits
balance: number; name: string; // Friendly name
type?: string; // CHECKING, SAVINGS, etc.
status?: string; // ACTIVE, CLOSED
currency?: string; // Default: USD
balance: number; // Current balance
notes?: string; // Optional notes
createdAt?: Date;
updatedAt?: Date;
} }
export interface Transaction { export interface Transaction {
id: string; id: string;
accountId: string; accountId: string;
date: string; // ISO date string e.g., "2023-11-28" date: string | Date; // ISO date string or Date object
description: string; description: string;
amount: number; amount: number;
category?: string; // Optional category
status?: string; // PENDING, CLEARED
type?: string; // DEPOSIT, WITHDRAWAL, TRANSFER
notes?: string; // Optional notes
tags?: string; // Optional comma-separated tags
createdAt?: Date;
updatedAt?: Date;
} }

View File

@@ -6,12 +6,32 @@ export function formatCurrency(amount: number): string {
}).format(amount); }).format(amount);
} }
// Basic date formatting // Enhanced date formatting with error handling
export function formatDate(dateString: string): string { export function formatDate(dateString: string | Date | null): string {
const date = new Date(`${dateString}T00:00:00`); // Ensure correct parsing as local date if (!dateString) {
return 'Invalid date';
}
try {
// Handle Date objects directly
const date =
dateString instanceof Date
? dateString
: new Date(typeof dateString === 'string' ? dateString : '');
// Check for invalid date
if (Number.isNaN(date.getTime())) {
console.warn('Invalid date encountered:', dateString);
return 'Invalid date';
}
return new Intl.DateTimeFormat('en-US', { return new Intl.DateTimeFormat('en-US', {
year: 'numeric', year: 'numeric',
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',
}).format(date); }).format(date);
} catch (error) {
console.error('Error formatting date:', error);
return 'Invalid date';
}
} }

14
tsconfig.node.json Normal file
View File

@@ -0,0 +1,14 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"target": "ES2020",
"outDir": "dist",
"esModuleInterop": true
},
"ts-node": {
"esm": true,
"experimentalSpecifierResolution": "node"
}
}

View File

@@ -13,9 +13,11 @@ export default defineConfig({
// Testing environment setup // Testing environment setup
environment: 'jsdom', environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'], setupFiles: ['./src/test/setup.ts'],
// Test file patterns // Ensure we're using the right environment
include: ['src/test/**/*.{test,spec}.{ts,tsx}'], environment: 'node',
// Coverage configuration // Only include database integration tests
include: ['src/test/db-integration.test.ts'],
// Configure coverage collection
coverage: { coverage: {
provider: 'v8', provider: 'v8',
reporter: ['text', 'json', 'html'], reporter: ['text', 'json', 'html'],