mirror of
https://github.com/acedanger/finance.git
synced 2025-12-05 22:50:12 -08:00
Merge pull request #32 from acedanger/feature/database-integration
Replace in-memory store with database persistence
This commit is contained in:
35
.github/copilot-instructions.md
vendored
35
.github/copilot-instructions.md
vendored
@@ -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
1
.node-version
Normal file
@@ -0,0 +1 @@
|
|||||||
|
20.19.0
|
||||||
17
.vscode/settings.json
vendored
17
.vscode/settings.json
vendored
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
155
docs/db-integration-testing-plan.md
Normal file
155
docs/db-integration-testing-plan.md
Normal 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
|
||||||
413
overview.md
413
overview.md
@@ -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
2840
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
16
package.json
16
package.json
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
61
prisma/migrations/20250505192354_initial/migration.sql
Normal file
61
prisma/migrations/20250505192354_initial/migration.sql
Normal 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;
|
||||||
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal 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"
|
||||||
@@ -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
87
prisma/seed.js
Normal 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
87
prisma/seed.ts
Normal 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();
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
275
src/data/db.service.ts
Normal 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
13
src/data/prisma.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 });
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
/>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
237
src/test/db-integration.test.ts
Normal file
237
src/test/db-integration.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
22
src/types.ts
22
src/types.ts
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
26
src/utils.ts
26
src/utils.ts
@@ -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
14
tsconfig.node.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'],
|
||||||
|
|||||||
Reference in New Issue
Block a user