mirror of
https://github.com/elisiariocouto/leggen.git
synced 2025-12-14 15:32:21 +00:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3eab6ae26 | ||
|
|
a5d10b3539 | ||
|
|
1c901a9dda | ||
|
|
1e94333d8f | ||
|
|
4006dd128e | ||
|
|
7d9744a40e | ||
|
|
8654471042 | ||
|
|
e9711339bd | ||
|
|
0c030efef2 | ||
|
|
e4e04ea34e | ||
|
|
f4bf549b99 | ||
|
|
8cc4f567f8 | ||
|
|
a939b841f3 | ||
|
|
caa43e8eb0 | ||
|
|
0a8750ea36 | ||
|
|
2d6800eff8 | ||
|
|
544527f282 | ||
|
|
91020e32ea |
55
.github/workflows/ci.yml
vendored
Normal file
55
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ "main", "dev" ]
|
||||||
|
pull_request:
|
||||||
|
branches: [ "main", "dev" ]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test-python:
|
||||||
|
name: Test Python
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install uv
|
||||||
|
uses: astral-sh/setup-uv@v5
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version-file: "pyproject.toml"
|
||||||
|
|
||||||
|
- name: Create config directory for tests
|
||||||
|
run: |
|
||||||
|
mkdir -p ~/.config/leggen
|
||||||
|
cp config.example.toml ~/.config/leggen/config.toml
|
||||||
|
|
||||||
|
- name: Run Python tests
|
||||||
|
run: uv run pytest
|
||||||
|
|
||||||
|
test-frontend:
|
||||||
|
name: Test Frontend
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: ./frontend
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
cache-dependency-path: frontend/package-lock.json
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm install
|
||||||
|
|
||||||
|
- name: Run lint
|
||||||
|
run: npm run lint
|
||||||
|
|
||||||
|
- name: Run build
|
||||||
|
run: npm run build
|
||||||
31
.github/workflows/release.yml
vendored
31
.github/workflows/release.yml
vendored
@@ -133,3 +133,34 @@ jobs:
|
|||||||
push: true
|
push: true
|
||||||
tags: ${{ steps.meta-frontend.outputs.tags }}
|
tags: ${{ steps.meta-frontend.outputs.tags }}
|
||||||
labels: ${{ steps.meta-frontend.outputs.labels }}
|
labels: ${{ steps.meta-frontend.outputs.labels }}
|
||||||
|
|
||||||
|
create-github-release:
|
||||||
|
name: Create GitHub Release
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [build, publish-to-pypi, push-docker-backend, push-docker-frontend]
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Install git-cliff
|
||||||
|
run: |
|
||||||
|
wget -qO- https://github.com/orhun/git-cliff/releases/latest/download/git-cliff-2.10.0-x86_64-unknown-linux-gnu.tar.gz | tar xz
|
||||||
|
sudo mv git-cliff-*/git-cliff /usr/local/bin/
|
||||||
|
|
||||||
|
- name: Generate release notes
|
||||||
|
id: release_notes
|
||||||
|
run: |
|
||||||
|
echo "notes<<EOF" >> $GITHUB_OUTPUT
|
||||||
|
git-cliff --current >> $GITHUB_OUTPUT
|
||||||
|
echo "EOF" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Create Release
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
tag_name: ${{ github.ref_name }}
|
||||||
|
name: Release ${{ github.ref_name }}
|
||||||
|
body: ${{ steps.release_notes.outputs.notes }}
|
||||||
|
draft: false
|
||||||
|
prerelease: false
|
||||||
|
|||||||
94
CHANGELOG.md
94
CHANGELOG.md
@@ -1,4 +1,98 @@
|
|||||||
|
|
||||||
|
## 2025.9.9 (2025/09/11)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **core:** Handle permission errors gracefully in database path creation. ([4006dd12](https://github.com/elisiariocouto/leggen/commit/4006dd128e0896b338cb93fad60a1eca90c1873d))
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **frontend:** Improve transactions table mobile UX with responsive card layout ([1e94333d](https://github.com/elisiariocouto/leggen/commit/1e94333d8f0275542ae7fd6e49fb8b7f03ad3d11))
|
||||||
|
- **frontend:** Improve transactions table mobile UX with responsive card layout ([1c901a9d](https://github.com/elisiariocouto/leggen/commit/1c901a9ddab0f6515dce56df8cce74518805a6bb))
|
||||||
|
- Remove config.toml file - should be created when needed ([a5d10b35](https://github.com/elisiariocouto/leggen/commit/a5d10b3539e7cfc649b0fee05b12c4a03681e135))
|
||||||
|
|
||||||
|
|
||||||
|
### Refactor
|
||||||
|
|
||||||
|
- **core:** Integrate directory creation with database path retrieval and remove backup file. ([7d9744a4](https://github.com/elisiariocouto/leggen/commit/7d9744a40e7898e5bbe52e2e9f54317aa5c1cdd6))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## 2025.9.9 (2025/09/11)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **core:** Handle permission errors gracefully in database path creation. ([4006dd12](https://github.com/elisiariocouto/leggen/commit/4006dd128e0896b338cb93fad60a1eca90c1873d))
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **frontend:** Improve transactions table mobile UX with responsive card layout ([1e94333d](https://github.com/elisiariocouto/leggen/commit/1e94333d8f0275542ae7fd6e49fb8b7f03ad3d11))
|
||||||
|
- **frontend:** Improve transactions table mobile UX with responsive card layout ([1c901a9d](https://github.com/elisiariocouto/leggen/commit/1c901a9ddab0f6515dce56df8cce74518805a6bb))
|
||||||
|
- Remove config.toml file - should be created when needed ([a5d10b35](https://github.com/elisiariocouto/leggen/commit/a5d10b3539e7cfc649b0fee05b12c4a03681e135))
|
||||||
|
|
||||||
|
|
||||||
|
### Refactor
|
||||||
|
|
||||||
|
- **core:** Integrate directory creation with database path retrieval and remove backup file. ([7d9744a4](https://github.com/elisiariocouto/leggen/commit/7d9744a40e7898e5bbe52e2e9f54317aa5c1cdd6))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## 2025.9.8 (2025/09/11)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Change branch name from develop to dev in CI workflow ([f4bf549b](https://github.com/elisiariocouto/leggen/commit/f4bf549b99197d70104abf5731ab1ccb67cc9a69))
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- Update CI workflow to use Node.js 20 instead of 18 ([e4e04ea3](https://github.com/elisiariocouto/leggen/commit/e4e04ea34ea568c08292562243b6e6c08234d918))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## 2025.9.8 (2025/09/11)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Change branch name from develop to dev in CI workflow ([f4bf549b](https://github.com/elisiariocouto/leggen/commit/f4bf549b99197d70104abf5731ab1ccb67cc9a69))
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- Update CI workflow to use Node.js 20 instead of 18 ([e4e04ea3](https://github.com/elisiariocouto/leggen/commit/e4e04ea34ea568c08292562243b6e6c08234d918))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## 2025.9.7 (2025/09/11)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Simplify notification settings and fix notification test on dashboard. ([91020e32](https://github.com/elisiariocouto/leggen/commit/91020e32ea836ee8af4aeaf5d49525c24b566aed))
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **frontend:** Implement TanStack Table for transactions view ([544527f2](https://github.com/elisiariocouto/leggen/commit/544527f28284fb9644bec6e721fa5da8ce10739f))
|
||||||
|
- Improve transactions API pagination and search ([2d6800ef](https://github.com/elisiariocouto/leggen/commit/2d6800eff8e484d3d175225f94d854706584a773))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## 2025.9.7 (2025/09/11)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Simplify notification settings and fix notification test on dashboard. ([91020e32](https://github.com/elisiariocouto/leggen/commit/91020e32ea836ee8af4aeaf5d49525c24b566aed))
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **frontend:** Implement TanStack Table for transactions view ([544527f2](https://github.com/elisiariocouto/leggen/commit/544527f28284fb9644bec6e721fa5da8ce10739f))
|
||||||
|
- Improve transactions API pagination and search ([2d6800ef](https://github.com/elisiariocouto/leggen/commit/2d6800eff8e484d3d175225f94d854706584a773))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## 2025.9.6 (2025/09/10)
|
## 2025.9.6 (2025/09/10)
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|||||||
@@ -357,6 +357,10 @@ tests/ # Test suite
|
|||||||
3. Make your changes with tests
|
3. Make your changes with tests
|
||||||
4. Submit a pull request
|
4. Submit a pull request
|
||||||
|
|
||||||
|
The repository uses GitHub Actions for CI/CD:
|
||||||
|
- **CI**: Runs Python tests (`uv run pytest`) and frontend linting/build on every push
|
||||||
|
- **Release**: Creates GitHub releases with changelog when tags are pushed
|
||||||
|
|
||||||
## ⚠️ Notes
|
## ⚠️ Notes
|
||||||
- This project is in active development
|
- This project is in active development
|
||||||
- GoCardless API rate limits apply
|
- GoCardless API rate limits apply
|
||||||
|
|||||||
34
frontend/package-lock.json
generated
34
frontend/package-lock.json
generated
@@ -11,6 +11,7 @@
|
|||||||
"@tailwindcss/forms": "^0.5.10",
|
"@tailwindcss/forms": "^0.5.10",
|
||||||
"@tanstack/react-query": "^5.87.1",
|
"@tanstack/react-query": "^5.87.1",
|
||||||
"@tanstack/react-router": "^1.131.36",
|
"@tanstack/react-router": "^1.131.36",
|
||||||
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"@tanstack/router-cli": "^1.131.36",
|
"@tanstack/router-cli": "^1.131.36",
|
||||||
"autoprefixer": "^10.4.21",
|
"autoprefixer": "^10.4.21",
|
||||||
"axios": "^1.11.0",
|
"axios": "^1.11.0",
|
||||||
@@ -1609,6 +1610,26 @@
|
|||||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tanstack/react-table": {
|
||||||
|
"version": "8.21.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz",
|
||||||
|
"integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/table-core": "8.21.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8",
|
||||||
|
"react-dom": ">=16.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tanstack/router-cli": {
|
"node_modules/@tanstack/router-cli": {
|
||||||
"version": "1.131.36",
|
"version": "1.131.36",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/router-cli/-/router-cli-1.131.36.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/router-cli/-/router-cli-1.131.36.tgz",
|
||||||
@@ -1777,6 +1798,19 @@
|
|||||||
"url": "https://github.com/sponsors/tannerlinsley"
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tanstack/table-core": {
|
||||||
|
"version": "8.21.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz",
|
||||||
|
"integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tanstack/virtual-file-routes": {
|
"node_modules/@tanstack/virtual-file-routes": {
|
||||||
"version": "1.131.2",
|
"version": "1.131.2",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/virtual-file-routes/-/virtual-file-routes-1.131.2.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/virtual-file-routes/-/virtual-file-routes-1.131.2.tgz",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"@tailwindcss/forms": "^0.5.10",
|
"@tailwindcss/forms": "^0.5.10",
|
||||||
"@tanstack/react-query": "^5.87.1",
|
"@tanstack/react-query": "^5.87.1",
|
||||||
"@tanstack/react-router": "^1.131.36",
|
"@tanstack/react-router": "^1.131.36",
|
||||||
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"@tanstack/router-cli": "^1.131.36",
|
"@tanstack/router-cli": "^1.131.36",
|
||||||
"autoprefixer": "^10.4.21",
|
"autoprefixer": "^10.4.21",
|
||||||
"axios": "^1.11.0",
|
"axios": "^1.11.0",
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ export default function Notifications() {
|
|||||||
if (!testService) return;
|
if (!testService) return;
|
||||||
|
|
||||||
testMutation.mutate({
|
testMutation.mutate({
|
||||||
service: testService,
|
service: testService.toLowerCase(),
|
||||||
message: testMessage,
|
message: testMessage,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -113,7 +113,7 @@ export default function Notifications() {
|
|||||||
`Are you sure you want to delete the ${serviceName} notification service?`,
|
`Are you sure you want to delete the ${serviceName} notification service?`,
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
deleteServiceMutation.mutate(serviceName);
|
deleteServiceMutation.mutate(serviceName.toLowerCase());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { apiClient } from "../lib/api";
|
|||||||
import { formatCurrency, formatDate } from "../lib/utils";
|
import { formatCurrency, formatDate } from "../lib/utils";
|
||||||
import LoadingSpinner from "./LoadingSpinner";
|
import LoadingSpinner from "./LoadingSpinner";
|
||||||
import RawTransactionModal from "./RawTransactionModal";
|
import RawTransactionModal from "./RawTransactionModal";
|
||||||
import type { Account, Transaction } from "../types/api";
|
import type { Account, Transaction, ApiResponse } from "../types/api";
|
||||||
|
|
||||||
export default function TransactionsList() {
|
export default function TransactionsList() {
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
@@ -33,11 +33,11 @@ export default function TransactionsList() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: transactions,
|
data: transactionsResponse,
|
||||||
isLoading: transactionsLoading,
|
isLoading: transactionsLoading,
|
||||||
error: transactionsError,
|
error: transactionsError,
|
||||||
refetch: refetchTransactions,
|
refetch: refetchTransactions,
|
||||||
} = useQuery<Transaction[]>({
|
} = useQuery<ApiResponse<Transaction[]>>({
|
||||||
queryKey: ["transactions", selectedAccount, startDate, endDate],
|
queryKey: ["transactions", selectedAccount, startDate, endDate],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
apiClient.getTransactions({
|
apiClient.getTransactions({
|
||||||
@@ -48,30 +48,34 @@ export default function TransactionsList() {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const filteredTransactions = (transactions || []).filter((transaction) => {
|
const transactions = transactionsResponse?.data || [];
|
||||||
// Additional validation (API client should have already filtered out invalid ones)
|
|
||||||
if (!transaction || !transaction.account_id) {
|
|
||||||
console.warn(
|
|
||||||
"Invalid transaction found after API filtering:",
|
|
||||||
transaction,
|
|
||||||
);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const description = transaction.description || "";
|
const filteredTransactions = (transactions || []).filter(
|
||||||
const creditorName = transaction.creditor_name || "";
|
(transaction: Transaction) => {
|
||||||
const debtorName = transaction.debtor_name || "";
|
// Additional validation (API client should have already filtered out invalid ones)
|
||||||
const reference = transaction.reference || "";
|
if (!transaction || !transaction.account_id) {
|
||||||
|
console.warn(
|
||||||
|
"Invalid transaction found after API filtering:",
|
||||||
|
transaction,
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
const matchesSearch =
|
const description = transaction.description || "";
|
||||||
searchTerm === "" ||
|
const creditorName = transaction.creditor_name || "";
|
||||||
description.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
const debtorName = transaction.debtor_name || "";
|
||||||
creditorName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
const reference = transaction.reference || "";
|
||||||
debtorName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
||||||
reference.toLowerCase().includes(searchTerm.toLowerCase());
|
|
||||||
|
|
||||||
return matchesSearch;
|
const matchesSearch =
|
||||||
});
|
searchTerm === "" ||
|
||||||
|
description.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
creditorName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
debtorName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
reference.toLowerCase().includes(searchTerm.toLowerCase());
|
||||||
|
|
||||||
|
return matchesSearch;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const clearFilters = () => {
|
const clearFilters = () => {
|
||||||
setSearchTerm("");
|
setSearchTerm("");
|
||||||
@@ -260,7 +264,7 @@ export default function TransactionsList() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="bg-white rounded-lg shadow divide-y divide-gray-200">
|
<div className="bg-white rounded-lg shadow divide-y divide-gray-200">
|
||||||
{filteredTransactions.map((transaction) => {
|
{filteredTransactions.map((transaction: Transaction) => {
|
||||||
const account = accounts?.find(
|
const account = accounts?.find(
|
||||||
(acc) => acc.id === transaction.account_id,
|
(acc) => acc.id === transaction.account_id,
|
||||||
);
|
);
|
||||||
|
|||||||
891
frontend/src/components/TransactionsTable.tsx
Normal file
891
frontend/src/components/TransactionsTable.tsx
Normal file
@@ -0,0 +1,891 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
useReactTable,
|
||||||
|
getCoreRowModel,
|
||||||
|
getSortedRowModel,
|
||||||
|
getFilteredRowModel,
|
||||||
|
flexRender,
|
||||||
|
} from "@tanstack/react-table";
|
||||||
|
import type {
|
||||||
|
ColumnDef,
|
||||||
|
SortingState,
|
||||||
|
ColumnFiltersState,
|
||||||
|
} from "@tanstack/react-table";
|
||||||
|
import {
|
||||||
|
Filter,
|
||||||
|
Search,
|
||||||
|
TrendingUp,
|
||||||
|
TrendingDown,
|
||||||
|
Calendar,
|
||||||
|
RefreshCw,
|
||||||
|
AlertCircle,
|
||||||
|
X,
|
||||||
|
Eye,
|
||||||
|
ChevronUp,
|
||||||
|
ChevronDown,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { apiClient } from "../lib/api";
|
||||||
|
import { formatCurrency, formatDate } from "../lib/utils";
|
||||||
|
import LoadingSpinner from "./LoadingSpinner";
|
||||||
|
import RawTransactionModal from "./RawTransactionModal";
|
||||||
|
import type { Account, Transaction, ApiResponse } from "../types/api";
|
||||||
|
|
||||||
|
export default function TransactionsTable() {
|
||||||
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
const [selectedAccount, setSelectedAccount] = useState<string>("");
|
||||||
|
const [startDate, setStartDate] = useState("");
|
||||||
|
const [endDate, setEndDate] = useState("");
|
||||||
|
const [minAmount, setMinAmount] = useState("");
|
||||||
|
const [maxAmount, setMaxAmount] = useState("");
|
||||||
|
const [showFilters, setShowFilters] = useState(false);
|
||||||
|
const [showRawModal, setShowRawModal] = useState(false);
|
||||||
|
const [selectedTransaction, setSelectedTransaction] =
|
||||||
|
useState<Transaction | null>(null);
|
||||||
|
|
||||||
|
// Pagination state
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [perPage, setPerPage] = useState(50);
|
||||||
|
|
||||||
|
// Debounced search state
|
||||||
|
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(searchTerm);
|
||||||
|
|
||||||
|
// Table state (remove pagination from table)
|
||||||
|
const [sorting, setSorting] = useState<SortingState>([]);
|
||||||
|
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||||
|
|
||||||
|
// Debounce search term to prevent excessive API calls
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setDebouncedSearchTerm(searchTerm);
|
||||||
|
}, 300); // 300ms delay
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [searchTerm]);
|
||||||
|
|
||||||
|
// Reset pagination when search term changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (debouncedSearchTerm !== searchTerm) {
|
||||||
|
setCurrentPage(1);
|
||||||
|
}
|
||||||
|
}, [debouncedSearchTerm, searchTerm]);
|
||||||
|
|
||||||
|
const { data: accounts } = useQuery<Account[]>({
|
||||||
|
queryKey: ["accounts"],
|
||||||
|
queryFn: apiClient.getAccounts,
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: transactionsResponse,
|
||||||
|
isLoading: transactionsLoading,
|
||||||
|
error: transactionsError,
|
||||||
|
refetch: refetchTransactions,
|
||||||
|
} = useQuery<ApiResponse<Transaction[]>>({
|
||||||
|
queryKey: [
|
||||||
|
"transactions",
|
||||||
|
selectedAccount,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
currentPage,
|
||||||
|
perPage,
|
||||||
|
debouncedSearchTerm,
|
||||||
|
],
|
||||||
|
queryFn: () =>
|
||||||
|
apiClient.getTransactions({
|
||||||
|
accountId: selectedAccount || undefined,
|
||||||
|
startDate: startDate || undefined,
|
||||||
|
endDate: endDate || undefined,
|
||||||
|
page: currentPage,
|
||||||
|
perPage: perPage,
|
||||||
|
search: debouncedSearchTerm || undefined,
|
||||||
|
summaryOnly: false,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const transactions = transactionsResponse?.data || [];
|
||||||
|
const pagination = transactionsResponse?.pagination;
|
||||||
|
|
||||||
|
// Check if search is currently debouncing
|
||||||
|
const isSearchLoading = searchTerm !== debouncedSearchTerm;
|
||||||
|
|
||||||
|
// Reset pagination when total becomes 0 (no results)
|
||||||
|
useEffect(() => {
|
||||||
|
if (pagination && pagination.total === 0 && currentPage > 1) {
|
||||||
|
setCurrentPage(1);
|
||||||
|
}
|
||||||
|
}, [pagination, currentPage]);
|
||||||
|
|
||||||
|
const clearFilters = () => {
|
||||||
|
setSearchTerm("");
|
||||||
|
setSelectedAccount("");
|
||||||
|
setStartDate("");
|
||||||
|
setEndDate("");
|
||||||
|
setMinAmount("");
|
||||||
|
setMaxAmount("");
|
||||||
|
setColumnFilters([]);
|
||||||
|
setCurrentPage(1); // Reset to first page when clearing filters
|
||||||
|
};
|
||||||
|
|
||||||
|
const setQuickDateFilter = (days: number) => {
|
||||||
|
const endDate = new Date();
|
||||||
|
const startDate = new Date();
|
||||||
|
startDate.setDate(endDate.getDate() - days);
|
||||||
|
|
||||||
|
setStartDate(startDate.toISOString().split("T")[0]);
|
||||||
|
setEndDate(endDate.toISOString().split("T")[0]);
|
||||||
|
setCurrentPage(1); // Reset to first page when changing date filters
|
||||||
|
};
|
||||||
|
|
||||||
|
const setThisMonthFilter = () => {
|
||||||
|
const now = new Date();
|
||||||
|
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||||
|
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0);
|
||||||
|
|
||||||
|
setStartDate(startOfMonth.toISOString().split("T")[0]);
|
||||||
|
setEndDate(endOfMonth.toISOString().split("T")[0]);
|
||||||
|
setCurrentPage(1); // Reset to first page when changing date filters
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reset pagination when account filter changes
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentPage(1);
|
||||||
|
}, [selectedAccount]);
|
||||||
|
|
||||||
|
// Reset pagination when date filters change
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentPage(1);
|
||||||
|
}, [startDate, endDate]);
|
||||||
|
|
||||||
|
const handleViewRaw = (transaction: Transaction) => {
|
||||||
|
setSelectedTransaction(transaction);
|
||||||
|
setShowRawModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseModal = () => {
|
||||||
|
setShowRawModal(false);
|
||||||
|
setSelectedTransaction(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasActiveFilters =
|
||||||
|
searchTerm ||
|
||||||
|
selectedAccount ||
|
||||||
|
startDate ||
|
||||||
|
endDate ||
|
||||||
|
minAmount ||
|
||||||
|
maxAmount;
|
||||||
|
|
||||||
|
// Define columns
|
||||||
|
const columns: ColumnDef<Transaction>[] = [
|
||||||
|
{
|
||||||
|
accessorKey: "description",
|
||||||
|
header: "Description",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const transaction = row.original;
|
||||||
|
const account = accounts?.find(
|
||||||
|
(acc) => acc.id === transaction.account_id,
|
||||||
|
);
|
||||||
|
const isPositive = transaction.transaction_value > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-start space-x-3">
|
||||||
|
<div
|
||||||
|
className={`p-2 rounded-full ${
|
||||||
|
isPositive ? "bg-green-100" : "bg-red-100"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isPositive ? (
|
||||||
|
<TrendingUp className="h-4 w-4 text-green-600" />
|
||||||
|
) : (
|
||||||
|
<TrendingDown className="h-4 w-4 text-red-600" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h4 className="text-sm font-medium text-gray-900 truncate">
|
||||||
|
{transaction.description}
|
||||||
|
</h4>
|
||||||
|
<div className="text-xs text-gray-500 space-y-1">
|
||||||
|
{account && (
|
||||||
|
<p className="truncate">
|
||||||
|
{account.name || "Unnamed Account"} •{" "}
|
||||||
|
{account.institution_id}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{(transaction.creditor_name || transaction.debtor_name) && (
|
||||||
|
<p className="truncate">
|
||||||
|
{isPositive ? "From: " : "To: "}
|
||||||
|
{transaction.creditor_name || transaction.debtor_name}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{transaction.reference && (
|
||||||
|
<p className="truncate">Ref: {transaction.reference}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "transaction_value",
|
||||||
|
header: "Amount",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const transaction = row.original;
|
||||||
|
const isPositive = transaction.transaction_value > 0;
|
||||||
|
return (
|
||||||
|
<div className="text-right">
|
||||||
|
<p
|
||||||
|
className={`text-lg font-semibold ${
|
||||||
|
isPositive ? "text-green-600" : "text-red-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isPositive ? "+" : ""}
|
||||||
|
{formatCurrency(
|
||||||
|
transaction.transaction_value,
|
||||||
|
transaction.transaction_currency,
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
sortingFn: "basic",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "transaction_date",
|
||||||
|
header: "Date",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const transaction = row.original;
|
||||||
|
return (
|
||||||
|
<div className="text-sm text-gray-900">
|
||||||
|
{transaction.transaction_date
|
||||||
|
? formatDate(transaction.transaction_date)
|
||||||
|
: "No date"}
|
||||||
|
{transaction.booking_date &&
|
||||||
|
transaction.booking_date !== transaction.transaction_date && (
|
||||||
|
<p className="text-xs text-gray-400">
|
||||||
|
Booked: {formatDate(transaction.booking_date)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
sortingFn: "datetime",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
header: "",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const transaction = row.original;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={() => handleViewRaw(transaction)}
|
||||||
|
className="inline-flex items-center px-2 py-1 text-xs bg-gray-100 text-gray-700 rounded hover:bg-gray-200 transition-colors"
|
||||||
|
title="View raw transaction data"
|
||||||
|
>
|
||||||
|
<Eye className="h-3 w-3 mr-1" />
|
||||||
|
Raw
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: transactions,
|
||||||
|
columns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getSortedRowModel: getSortedRowModel(),
|
||||||
|
getFilteredRowModel: getFilteredRowModel(),
|
||||||
|
onSortingChange: setSorting,
|
||||||
|
onColumnFiltersChange: setColumnFilters,
|
||||||
|
state: {
|
||||||
|
sorting,
|
||||||
|
columnFilters,
|
||||||
|
globalFilter: searchTerm,
|
||||||
|
},
|
||||||
|
onGlobalFilterChange: setSearchTerm,
|
||||||
|
globalFilterFn: (row, _columnId, filterValue) => {
|
||||||
|
// Custom global filter that searches multiple fields
|
||||||
|
const transaction = row.original;
|
||||||
|
const searchLower = filterValue.toLowerCase();
|
||||||
|
|
||||||
|
const description = transaction.description || "";
|
||||||
|
const creditorName = transaction.creditor_name || "";
|
||||||
|
const debtorName = transaction.debtor_name || "";
|
||||||
|
const reference = transaction.reference || "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
description.toLowerCase().includes(searchLower) ||
|
||||||
|
creditorName.toLowerCase().includes(searchLower) ||
|
||||||
|
debtorName.toLowerCase().includes(searchLower) ||
|
||||||
|
reference.toLowerCase().includes(searchLower)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (transactionsLoading) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-lg shadow">
|
||||||
|
<LoadingSpinner message="Loading transactions..." />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (transactionsError) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-lg shadow p-6">
|
||||||
|
<div className="flex items-center justify-center text-center">
|
||||||
|
<div>
|
||||||
|
<AlertCircle className="h-12 w-12 text-red-400 mx-auto mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||||
|
Failed to load transactions
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-600 mb-4">
|
||||||
|
Unable to fetch transactions from the Leggen API.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => refetchTransactions()}
|
||||||
|
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-4 w-4 mr-2" />
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="bg-white rounded-lg shadow">
|
||||||
|
<div className="px-6 py-4 border-b border-gray-200">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-lg font-medium text-gray-900">Transactions</h3>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<button
|
||||||
|
onClick={clearFilters}
|
||||||
|
className="inline-flex items-center px-3 py-1 text-sm bg-gray-100 text-gray-700 rounded-full hover:bg-gray-200 transition-colors"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3 mr-1" />
|
||||||
|
Clear filters
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => setShowFilters(!showFilters)}
|
||||||
|
className="inline-flex items-center px-3 py-2 bg-blue-100 text-blue-700 rounded-md hover:bg-blue-200 transition-colors"
|
||||||
|
>
|
||||||
|
<Filter className="h-4 w-4 mr-2" />
|
||||||
|
Filters
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showFilters && (
|
||||||
|
<div className="px-6 py-4 border-b border-gray-200 bg-gray-50">
|
||||||
|
{/* Quick Date Filters */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Quick Filters
|
||||||
|
</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setQuickDateFilter(7)}
|
||||||
|
className="px-3 py-1 text-sm bg-blue-100 text-blue-700 rounded-full hover:bg-blue-200 transition-colors"
|
||||||
|
>
|
||||||
|
Last 7 days
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setQuickDateFilter(30)}
|
||||||
|
className="px-3 py-1 text-sm bg-blue-100 text-blue-700 rounded-full hover:bg-blue-200 transition-colors"
|
||||||
|
>
|
||||||
|
Last 30 days
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={setThisMonthFilter}
|
||||||
|
className="px-3 py-1 text-sm bg-blue-100 text-blue-700 rounded-full hover:bg-blue-200 transition-colors"
|
||||||
|
>
|
||||||
|
This month
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
{/* Search */}
|
||||||
|
<div className="sm:col-span-2 lg:col-span-1">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Search
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
placeholder="Description, name, reference..."
|
||||||
|
className="pl-10 pr-3 py-2 w-full border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
/>
|
||||||
|
{isSearchLoading && (
|
||||||
|
<div className="absolute right-3 top-1/2 transform -translate-y-1/2">
|
||||||
|
<div className="animate-spin h-4 w-4 border-2 border-gray-300 border-t-blue-500 rounded-full"></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Account Filter */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Account
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={selectedAccount}
|
||||||
|
onChange={(e) => setSelectedAccount(e.target.value)}
|
||||||
|
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
>
|
||||||
|
<option value="">All accounts</option>
|
||||||
|
{accounts?.map((account) => (
|
||||||
|
<option key={account.id} value={account.id}>
|
||||||
|
{account.name || "Unnamed Account"} ({account.institution_id})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Start Date */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Start Date
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Calendar className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={startDate}
|
||||||
|
onChange={(e) => setStartDate(e.target.value)}
|
||||||
|
className="pl-10 pr-3 py-2 w-full border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* End Date */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
End Date
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Calendar className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={endDate}
|
||||||
|
onChange={(e) => setEndDate(e.target.value)}
|
||||||
|
className="pl-10 pr-3 py-2 w-full border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Amount Range Filters */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mt-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Min Amount
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={minAmount}
|
||||||
|
onChange={(e) => setMinAmount(e.target.value)}
|
||||||
|
placeholder="0.00"
|
||||||
|
step="0.01"
|
||||||
|
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Max Amount
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={maxAmount}
|
||||||
|
onChange={(e) => setMaxAmount(e.target.value)}
|
||||||
|
placeholder="1000.00"
|
||||||
|
step="0.01"
|
||||||
|
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Results Summary */}
|
||||||
|
<div className="px-6 py-3 bg-gray-50 border-b border-gray-200">
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
Showing {transactions.length} transaction
|
||||||
|
{transactions.length !== 1 ? "s" : ""} (
|
||||||
|
{pagination ? (
|
||||||
|
<>
|
||||||
|
{(pagination.page - 1) * pagination.per_page + 1}-
|
||||||
|
{Math.min(
|
||||||
|
pagination.page * pagination.per_page,
|
||||||
|
pagination.total,
|
||||||
|
)}{" "}
|
||||||
|
of {pagination.total}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"loading..."
|
||||||
|
)}
|
||||||
|
)
|
||||||
|
{selectedAccount && accounts && (
|
||||||
|
<span className="ml-1">
|
||||||
|
for {accounts.find((acc) => acc.id === selectedAccount)?.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Responsive Table/Cards */}
|
||||||
|
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||||
|
{/* Desktop Table View (hidden on mobile) */}
|
||||||
|
<div className="hidden md:block">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
<tr key={headerGroup.id}>
|
||||||
|
{headerGroup.headers.map((header) => (
|
||||||
|
<th
|
||||||
|
key={header.id}
|
||||||
|
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100"
|
||||||
|
onClick={header.column.getToggleSortingHandler()}
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-1">
|
||||||
|
<span>
|
||||||
|
{header.isPlaceholder
|
||||||
|
? null
|
||||||
|
: flexRender(
|
||||||
|
header.column.columnDef.header,
|
||||||
|
header.getContext(),
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{header.column.getCanSort() && (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<ChevronUp
|
||||||
|
className={`h-3 w-3 ${
|
||||||
|
header.column.getIsSorted() === "asc"
|
||||||
|
? "text-blue-600"
|
||||||
|
: "text-gray-400"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<ChevronDown
|
||||||
|
className={`h-3 w-3 -mt-1 ${
|
||||||
|
header.column.getIsSorted() === "desc"
|
||||||
|
? "text-blue-600"
|
||||||
|
: "text-gray-400"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
|
{table.getRowModel().rows.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
colSpan={columns.length}
|
||||||
|
className="px-6 py-12 text-center"
|
||||||
|
>
|
||||||
|
<div className="text-gray-400 mb-4">
|
||||||
|
<TrendingUp className="h-12 w-12 mx-auto" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||||
|
No transactions found
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-600">
|
||||||
|
{hasActiveFilters
|
||||||
|
? "Try adjusting your filters to see more results."
|
||||||
|
: "No transactions are available for the selected criteria."}
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
table.getRowModel().rows.map((row) => (
|
||||||
|
<tr key={row.id} className="hover:bg-gray-50">
|
||||||
|
{row.getVisibleCells().map((cell) => (
|
||||||
|
<td key={cell.id} className="px-6 py-4 whitespace-nowrap">
|
||||||
|
{flexRender(
|
||||||
|
cell.column.columnDef.cell,
|
||||||
|
cell.getContext(),
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile Card View (visible only on mobile) */}
|
||||||
|
<div className="md:hidden">
|
||||||
|
{table.getRowModel().rows.length === 0 ? (
|
||||||
|
<div className="px-6 py-12 text-center">
|
||||||
|
<div className="text-gray-400 mb-4">
|
||||||
|
<TrendingUp className="h-12 w-12 mx-auto" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||||
|
No transactions found
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-600">
|
||||||
|
{hasActiveFilters
|
||||||
|
? "Try adjusting your filters to see more results."
|
||||||
|
: "No transactions are available for the selected criteria."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-gray-200">
|
||||||
|
{table.getRowModel().rows.map((row) => {
|
||||||
|
const transaction = row.original;
|
||||||
|
const account = accounts?.find(
|
||||||
|
(acc) => acc.id === transaction.account_id,
|
||||||
|
);
|
||||||
|
const isPositive = transaction.transaction_value > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={row.id}
|
||||||
|
className="p-4 hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-start space-x-3">
|
||||||
|
<div
|
||||||
|
className={`p-2 rounded-full flex-shrink-0 ${
|
||||||
|
isPositive ? "bg-green-100" : "bg-red-100"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isPositive ? (
|
||||||
|
<TrendingUp className="h-4 w-4 text-green-600" />
|
||||||
|
) : (
|
||||||
|
<TrendingDown className="h-4 w-4 text-red-600" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h4 className="text-sm font-medium text-gray-900 break-words">
|
||||||
|
{transaction.description}
|
||||||
|
</h4>
|
||||||
|
<div className="text-xs text-gray-500 space-y-1 mt-1">
|
||||||
|
{account && (
|
||||||
|
<p className="break-words">
|
||||||
|
{account.name || "Unnamed Account"} •{" "}
|
||||||
|
{account.institution_id}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{(transaction.creditor_name || transaction.debtor_name) && (
|
||||||
|
<p className="break-words">
|
||||||
|
{isPositive ? "From: " : "To: "}
|
||||||
|
{transaction.creditor_name || transaction.debtor_name}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{transaction.reference && (
|
||||||
|
<p className="break-words">Ref: {transaction.reference}</p>
|
||||||
|
)}
|
||||||
|
<p className="text-gray-400">
|
||||||
|
{transaction.transaction_date
|
||||||
|
? formatDate(transaction.transaction_date)
|
||||||
|
: "No date"}
|
||||||
|
{transaction.booking_date &&
|
||||||
|
transaction.booking_date !== transaction.transaction_date && (
|
||||||
|
<span className="ml-2">
|
||||||
|
(Booked: {formatDate(transaction.booking_date)})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right ml-3 flex-shrink-0">
|
||||||
|
<p
|
||||||
|
className={`text-lg font-semibold mb-1 ${
|
||||||
|
isPositive ? "text-green-600" : "text-red-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isPositive ? "+" : ""}
|
||||||
|
{formatCurrency(
|
||||||
|
transaction.transaction_value,
|
||||||
|
transaction.transaction_currency,
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => handleViewRaw(transaction)}
|
||||||
|
className="inline-flex items-center px-2 py-1 text-xs bg-gray-100 text-gray-700 rounded hover:bg-gray-200 transition-colors"
|
||||||
|
title="View raw transaction data"
|
||||||
|
>
|
||||||
|
<Eye className="h-3 w-3 mr-1" />
|
||||||
|
Raw
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{pagination && (
|
||||||
|
<div className="bg-white px-4 py-3 flex flex-col sm:flex-row items-center justify-between border-t border-gray-200 space-y-3 sm:space-y-0">
|
||||||
|
{/* Mobile pagination controls */}
|
||||||
|
<div className="flex justify-between w-full sm:hidden">
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentPage(1)}
|
||||||
|
disabled={pagination.page === 1}
|
||||||
|
className="relative inline-flex items-center px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
First
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
setCurrentPage((prev) => Math.max(1, prev - 1))
|
||||||
|
}
|
||||||
|
disabled={!pagination.has_prev}
|
||||||
|
className="relative inline-flex items-center px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentPage((prev) => prev + 1)}
|
||||||
|
disabled={!pagination.has_next}
|
||||||
|
className="relative inline-flex items-center px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentPage(pagination.total_pages)}
|
||||||
|
disabled={pagination.page === pagination.total_pages}
|
||||||
|
className="relative inline-flex items-center px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Last
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile pagination info */}
|
||||||
|
<div className="text-center w-full sm:hidden">
|
||||||
|
<p className="text-sm text-gray-700">
|
||||||
|
Page <span className="font-medium">{pagination.page}</span> of{" "}
|
||||||
|
<span className="font-medium">{pagination.total_pages}</span>
|
||||||
|
<br />
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
Showing {(pagination.page - 1) * pagination.per_page + 1}-
|
||||||
|
{Math.min(pagination.page * pagination.per_page, pagination.total)} of {pagination.total}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop pagination */}
|
||||||
|
<div className="hidden sm:flex sm:flex-1 sm:items-center sm:justify-between">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<p className="text-sm text-gray-700">
|
||||||
|
Showing{" "}
|
||||||
|
<span className="font-medium">
|
||||||
|
{(pagination.page - 1) * pagination.per_page + 1}
|
||||||
|
</span>{" "}
|
||||||
|
to{" "}
|
||||||
|
<span className="font-medium">
|
||||||
|
{Math.min(
|
||||||
|
pagination.page * pagination.per_page,
|
||||||
|
pagination.total,
|
||||||
|
)}
|
||||||
|
</span>{" "}
|
||||||
|
of <span className="font-medium">{pagination.total}</span>{" "}
|
||||||
|
results
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<label className="text-sm text-gray-700">
|
||||||
|
Rows per page:
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={perPage}
|
||||||
|
onChange={(e) => {
|
||||||
|
setPerPage(Number(e.target.value));
|
||||||
|
setCurrentPage(1); // Reset to first page when changing page size
|
||||||
|
}}
|
||||||
|
className="border border-gray-300 rounded px-2 py-1 text-sm"
|
||||||
|
>
|
||||||
|
{[10, 25, 50, 100].map((pageSize) => (
|
||||||
|
<option key={pageSize} value={pageSize}>
|
||||||
|
{pageSize}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentPage(1)}
|
||||||
|
disabled={pagination.page === 1}
|
||||||
|
className="relative inline-flex items-center px-2 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
First
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
setCurrentPage((prev) => Math.max(1, prev - 1))
|
||||||
|
}
|
||||||
|
disabled={!pagination.has_prev}
|
||||||
|
className="relative inline-flex items-center px-2 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
<span className="text-sm text-gray-700">
|
||||||
|
Page <span className="font-medium">{pagination.page}</span>{" "}
|
||||||
|
of{" "}
|
||||||
|
<span className="font-medium">
|
||||||
|
{pagination.total_pages}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentPage((prev) => prev + 1)}
|
||||||
|
disabled={!pagination.has_next}
|
||||||
|
className="relative inline-flex items-center px-2 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentPage(pagination.total_pages)}
|
||||||
|
disabled={pagination.page === pagination.total_pages}
|
||||||
|
className="relative inline-flex items-center px-2 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Last
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Raw Transaction Modal */}
|
||||||
|
<RawTransactionModal
|
||||||
|
isOpen={showRawModal}
|
||||||
|
onClose={handleCloseModal}
|
||||||
|
rawTransaction={selectedTransaction?.raw_transaction}
|
||||||
|
transactionId={selectedTransaction?.transaction_id || "unknown"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -70,12 +70,12 @@ export const apiClient = {
|
|||||||
perPage?: number;
|
perPage?: number;
|
||||||
search?: string;
|
search?: string;
|
||||||
summaryOnly?: boolean;
|
summaryOnly?: boolean;
|
||||||
}): Promise<Transaction[]> => {
|
}): Promise<ApiResponse<Transaction[]>> => {
|
||||||
const queryParams = new URLSearchParams();
|
const queryParams = new URLSearchParams();
|
||||||
|
|
||||||
if (params?.accountId) queryParams.append("account_id", params.accountId);
|
if (params?.accountId) queryParams.append("account_id", params.accountId);
|
||||||
if (params?.startDate) queryParams.append("start_date", params.startDate);
|
if (params?.startDate) queryParams.append("date_from", params.startDate);
|
||||||
if (params?.endDate) queryParams.append("end_date", params.endDate);
|
if (params?.endDate) queryParams.append("date_to", params.endDate);
|
||||||
if (params?.page) queryParams.append("page", params.page.toString());
|
if (params?.page) queryParams.append("page", params.page.toString());
|
||||||
if (params?.perPage)
|
if (params?.perPage)
|
||||||
queryParams.append("per_page", params.perPage.toString());
|
queryParams.append("per_page", params.perPage.toString());
|
||||||
@@ -87,7 +87,7 @@ export const apiClient = {
|
|||||||
const response = await api.get<ApiResponse<Transaction[]>>(
|
const response = await api.get<ApiResponse<Transaction[]>>(
|
||||||
`/transactions?${queryParams.toString()}`,
|
`/transactions?${queryParams.toString()}`,
|
||||||
);
|
);
|
||||||
return response.data.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get transaction by ID
|
// Get transaction by ID
|
||||||
|
|||||||
@@ -3,29 +3,31 @@ import { useState } from "react";
|
|||||||
import Sidebar from "../components/Sidebar";
|
import Sidebar from "../components/Sidebar";
|
||||||
import Header from "../components/Header";
|
import Header from "../components/Header";
|
||||||
|
|
||||||
export const Route = createRootRoute({
|
function RootLayout() {
|
||||||
component: () => {
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen bg-gray-100">
|
<div className="flex h-screen bg-gray-100">
|
||||||
<Sidebar sidebarOpen={sidebarOpen} setSidebarOpen={setSidebarOpen} />
|
<Sidebar sidebarOpen={sidebarOpen} setSidebarOpen={setSidebarOpen} />
|
||||||
|
|
||||||
{/* Mobile overlay */}
|
{/* Mobile overlay */}
|
||||||
{sidebarOpen && (
|
{sidebarOpen && (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-40 bg-gray-600 bg-opacity-75 lg:hidden"
|
className="fixed inset-0 z-40 bg-gray-600 bg-opacity-75 lg:hidden"
|
||||||
onClick={() => setSidebarOpen(false)}
|
onClick={() => setSidebarOpen(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex flex-col flex-1 overflow-hidden">
|
<div className="flex flex-col flex-1 overflow-hidden">
|
||||||
<Header setSidebarOpen={setSidebarOpen} />
|
<Header setSidebarOpen={setSidebarOpen} />
|
||||||
<main className="flex-1 overflow-y-auto p-6">
|
<main className="flex-1 overflow-y-auto p-6">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
</div>
|
||||||
},
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Route = createRootRoute({
|
||||||
|
component: RootLayout,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import TransactionsList from "../components/TransactionsList";
|
import TransactionsTable from "../components/TransactionsTable";
|
||||||
|
|
||||||
export const Route = createFileRoute("/transactions")({
|
export const Route = createFileRoute("/transactions")({
|
||||||
component: TransactionsList,
|
component: TransactionsTable,
|
||||||
validateSearch: (search) => ({
|
validateSearch: (search) => ({
|
||||||
accountId: search.accountId as string | undefined,
|
accountId: search.accountId as string | undefined,
|
||||||
startDate: search.startDate as string | undefined,
|
startDate: search.startDate as string | undefined,
|
||||||
|
|||||||
@@ -124,6 +124,14 @@ export interface ApiResponse<T> {
|
|||||||
data: T;
|
data: T;
|
||||||
message?: string;
|
message?: string;
|
||||||
success: boolean;
|
success: boolean;
|
||||||
|
pagination?: {
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
per_page: number;
|
||||||
|
total_pages: number;
|
||||||
|
has_next: boolean;
|
||||||
|
has_prev: boolean;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PaginatedResponse<T> {
|
export interface PaginatedResponse<T> {
|
||||||
|
|||||||
@@ -4,8 +4,5 @@ import { TanStackRouterVite } from "@tanstack/router-vite-plugin";
|
|||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [
|
plugins: [TanStackRouterVite(), react()],
|
||||||
TanStackRouterVite(),
|
|
||||||
react(),
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
|
|||||||
65
leggen/commands/generate_sample_db.py
Normal file
65
leggen/commands/generate_sample_db.py
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
"""Generate sample database command."""
|
||||||
|
|
||||||
|
import click
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from leggen.utils.paths import path_manager
|
||||||
|
|
||||||
|
|
||||||
|
@click.command()
|
||||||
|
@click.option(
|
||||||
|
"--database",
|
||||||
|
type=click.Path(path_type=Path),
|
||||||
|
help="Path to database file (default: uses LEGGEN_DATABASE_PATH or ~/.config/leggen/leggen-dev.db)",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--accounts",
|
||||||
|
type=int,
|
||||||
|
default=3,
|
||||||
|
help="Number of sample accounts to generate (default: 3)",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--transactions",
|
||||||
|
type=int,
|
||||||
|
default=50,
|
||||||
|
help="Number of transactions per account (default: 50)",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--force",
|
||||||
|
is_flag=True,
|
||||||
|
help="Overwrite existing database without confirmation",
|
||||||
|
)
|
||||||
|
@click.pass_context
|
||||||
|
def generate_sample_db(ctx: click.Context, database: Path, accounts: int, transactions: int, force: bool):
|
||||||
|
"""Generate a sample database with realistic financial data for testing."""
|
||||||
|
|
||||||
|
# Import here to avoid circular imports
|
||||||
|
import sys
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path as PathlibPath
|
||||||
|
|
||||||
|
# Get the script path
|
||||||
|
script_path = PathlibPath(__file__).parent.parent.parent / "scripts" / "generate_sample_db.py"
|
||||||
|
|
||||||
|
# Build command arguments
|
||||||
|
cmd = [sys.executable, str(script_path)]
|
||||||
|
|
||||||
|
if database:
|
||||||
|
cmd.extend(["--database", str(database)])
|
||||||
|
|
||||||
|
cmd.extend(["--accounts", str(accounts)])
|
||||||
|
cmd.extend(["--transactions", str(transactions)])
|
||||||
|
|
||||||
|
if force:
|
||||||
|
cmd.append("--force")
|
||||||
|
|
||||||
|
# Execute the script
|
||||||
|
try:
|
||||||
|
subprocess.run(cmd, check=True)
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
click.echo(f"Error generating sample database: {e}")
|
||||||
|
ctx.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
# Export the command
|
||||||
|
generate_sample_db = generate_sample_db
|
||||||
@@ -5,14 +5,12 @@ from sqlite3 import IntegrityError
|
|||||||
import click
|
import click
|
||||||
|
|
||||||
from leggen.utils.text import success, warning
|
from leggen.utils.text import success, warning
|
||||||
|
from leggen.utils.paths import path_manager
|
||||||
|
|
||||||
|
|
||||||
def persist_balances(ctx: click.Context, balance: dict):
|
def persist_balances(ctx: click.Context, balance: dict):
|
||||||
# Connect to SQLite database
|
# Connect to SQLite database
|
||||||
from pathlib import Path
|
db_path = path_manager.get_database_path()
|
||||||
|
|
||||||
db_path = Path.home() / ".config" / "leggen" / "leggen.db"
|
|
||||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
conn = sqlite3.connect(str(db_path))
|
conn = sqlite3.connect(str(db_path))
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
||||||
@@ -108,10 +106,7 @@ def persist_balances(ctx: click.Context, balance: dict):
|
|||||||
|
|
||||||
def persist_transactions(ctx: click.Context, account: str, transactions: list) -> list:
|
def persist_transactions(ctx: click.Context, account: str, transactions: list) -> list:
|
||||||
# Connect to SQLite database
|
# Connect to SQLite database
|
||||||
from pathlib import Path
|
db_path = path_manager.get_database_path()
|
||||||
|
|
||||||
db_path = Path.home() / ".config" / "leggen" / "leggen.db"
|
|
||||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
conn = sqlite3.connect(str(db_path))
|
conn = sqlite3.connect(str(db_path))
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
||||||
@@ -216,9 +211,7 @@ def get_transactions(
|
|||||||
search=None,
|
search=None,
|
||||||
):
|
):
|
||||||
"""Get transactions from SQLite database with optional filtering"""
|
"""Get transactions from SQLite database with optional filtering"""
|
||||||
from pathlib import Path
|
db_path = path_manager.get_database_path()
|
||||||
|
|
||||||
db_path = Path.home() / ".config" / "leggen" / "leggen.db"
|
|
||||||
if not db_path.exists():
|
if not db_path.exists():
|
||||||
return []
|
return []
|
||||||
conn = sqlite3.connect(str(db_path))
|
conn = sqlite3.connect(str(db_path))
|
||||||
@@ -288,9 +281,7 @@ def get_transactions(
|
|||||||
|
|
||||||
def get_balances(account_id=None):
|
def get_balances(account_id=None):
|
||||||
"""Get latest balances from SQLite database"""
|
"""Get latest balances from SQLite database"""
|
||||||
from pathlib import Path
|
db_path = path_manager.get_database_path()
|
||||||
|
|
||||||
db_path = Path.home() / ".config" / "leggen" / "leggen.db"
|
|
||||||
if not db_path.exists():
|
if not db_path.exists():
|
||||||
return []
|
return []
|
||||||
conn = sqlite3.connect(str(db_path))
|
conn = sqlite3.connect(str(db_path))
|
||||||
@@ -329,9 +320,7 @@ def get_balances(account_id=None):
|
|||||||
|
|
||||||
def get_account_summary(account_id):
|
def get_account_summary(account_id):
|
||||||
"""Get basic account info from transactions table (avoids GoCardless API call)"""
|
"""Get basic account info from transactions table (avoids GoCardless API call)"""
|
||||||
from pathlib import Path
|
db_path = path_manager.get_database_path()
|
||||||
|
|
||||||
db_path = Path.home() / ".config" / "leggen" / "leggen.db"
|
|
||||||
if not db_path.exists():
|
if not db_path.exists():
|
||||||
return None
|
return None
|
||||||
conn = sqlite3.connect(str(db_path))
|
conn = sqlite3.connect(str(db_path))
|
||||||
@@ -365,9 +354,7 @@ def get_account_summary(account_id):
|
|||||||
|
|
||||||
def get_transaction_count(account_id=None, **filters):
|
def get_transaction_count(account_id=None, **filters):
|
||||||
"""Get total count of transactions matching filters"""
|
"""Get total count of transactions matching filters"""
|
||||||
from pathlib import Path
|
db_path = path_manager.get_database_path()
|
||||||
|
|
||||||
db_path = Path.home() / ".config" / "leggen" / "leggen.db"
|
|
||||||
if not db_path.exists():
|
if not db_path.exists():
|
||||||
return 0
|
return 0
|
||||||
conn = sqlite3.connect(str(db_path))
|
conn = sqlite3.connect(str(db_path))
|
||||||
@@ -414,10 +401,7 @@ def get_transaction_count(account_id=None, **filters):
|
|||||||
|
|
||||||
def persist_account(account_data: dict):
|
def persist_account(account_data: dict):
|
||||||
"""Persist account details to SQLite database"""
|
"""Persist account details to SQLite database"""
|
||||||
from pathlib import Path
|
db_path = path_manager.get_database_path()
|
||||||
|
|
||||||
db_path = Path.home() / ".config" / "leggen" / "leggen.db"
|
|
||||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
conn = sqlite3.connect(str(db_path))
|
conn = sqlite3.connect(str(db_path))
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
||||||
@@ -485,9 +469,7 @@ def persist_account(account_data: dict):
|
|||||||
|
|
||||||
def get_accounts(account_ids=None):
|
def get_accounts(account_ids=None):
|
||||||
"""Get account details from SQLite database"""
|
"""Get account details from SQLite database"""
|
||||||
from pathlib import Path
|
db_path = path_manager.get_database_path()
|
||||||
|
|
||||||
db_path = Path.home() / ".config" / "leggen" / "leggen.db"
|
|
||||||
if not db_path.exists():
|
if not db_path.exists():
|
||||||
return []
|
return []
|
||||||
conn = sqlite3.connect(str(db_path))
|
conn = sqlite3.connect(str(db_path))
|
||||||
@@ -519,9 +501,7 @@ def get_accounts(account_ids=None):
|
|||||||
|
|
||||||
def get_account(account_id: str):
|
def get_account(account_id: str):
|
||||||
"""Get specific account details from SQLite database"""
|
"""Get specific account details from SQLite database"""
|
||||||
from pathlib import Path
|
db_path = path_manager.get_database_path()
|
||||||
|
|
||||||
db_path = Path.home() / ".config" / "leggen" / "leggen.db"
|
|
||||||
if not db_path.exists():
|
if not db_path.exists():
|
||||||
return None
|
return None
|
||||||
conn = sqlite3.connect(str(db_path))
|
conn = sqlite3.connect(str(db_path))
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import click
|
|||||||
|
|
||||||
from leggen.utils.config import load_config
|
from leggen.utils.config import load_config
|
||||||
from leggen.utils.text import error
|
from leggen.utils.text import error
|
||||||
|
from leggen.utils.paths import path_manager
|
||||||
|
|
||||||
cmd_folder = os.path.abspath(os.path.join(os.path.dirname(__file__), "commands"))
|
cmd_folder = os.path.abspath(os.path.join(os.path.dirname(__file__), "commands"))
|
||||||
|
|
||||||
@@ -77,7 +78,7 @@ class Group(click.Group):
|
|||||||
"-c",
|
"-c",
|
||||||
"--config",
|
"--config",
|
||||||
type=click.Path(dir_okay=False),
|
type=click.Path(dir_okay=False),
|
||||||
default=Path.home() / ".config" / "leggen" / "config.toml",
|
default=lambda: str(path_manager.get_config_file_path()),
|
||||||
show_default=True,
|
show_default=True,
|
||||||
callback=load_config,
|
callback=load_config,
|
||||||
is_eager=True,
|
is_eager=True,
|
||||||
@@ -86,6 +87,20 @@ class Group(click.Group):
|
|||||||
show_envvar=True,
|
show_envvar=True,
|
||||||
help="Path to TOML configuration file",
|
help="Path to TOML configuration file",
|
||||||
)
|
)
|
||||||
|
@click.option(
|
||||||
|
"--config-dir",
|
||||||
|
type=click.Path(exists=False, file_okay=False, path_type=Path),
|
||||||
|
envvar="LEGGEN_CONFIG_DIR",
|
||||||
|
show_envvar=True,
|
||||||
|
help="Directory containing configuration files (default: ~/.config/leggen)",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--database",
|
||||||
|
type=click.Path(dir_okay=False, path_type=Path),
|
||||||
|
envvar="LEGGEN_DATABASE_PATH",
|
||||||
|
show_envvar=True,
|
||||||
|
help="Path to SQLite database file (default: <config-dir>/leggen.db)",
|
||||||
|
)
|
||||||
@click.option(
|
@click.option(
|
||||||
"--api-url",
|
"--api-url",
|
||||||
type=str,
|
type=str,
|
||||||
@@ -100,7 +115,7 @@ class Group(click.Group):
|
|||||||
)
|
)
|
||||||
@click.version_option(package_name="leggen")
|
@click.version_option(package_name="leggen")
|
||||||
@click.pass_context
|
@click.pass_context
|
||||||
def cli(ctx: click.Context, api_url: str):
|
def cli(ctx: click.Context, config_dir: Path, database: Path, api_url: str):
|
||||||
"""
|
"""
|
||||||
Leggen: An Open Banking CLI
|
Leggen: An Open Banking CLI
|
||||||
"""
|
"""
|
||||||
@@ -109,5 +124,11 @@ def cli(ctx: click.Context, api_url: str):
|
|||||||
if "--help" in sys.argv[1:] or "-h" in sys.argv[1:]:
|
if "--help" in sys.argv[1:] or "-h" in sys.argv[1:]:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Set up path manager with user-provided paths
|
||||||
|
if config_dir:
|
||||||
|
path_manager.set_config_dir(config_dir)
|
||||||
|
if database:
|
||||||
|
path_manager.set_database_path(database)
|
||||||
|
|
||||||
# Store API URL in context for commands to use
|
# Store API URL in context for commands to use
|
||||||
ctx.obj["api_url"] = api_url
|
ctx.obj["api_url"] = api_url
|
||||||
|
|||||||
81
leggen/utils/paths.py
Normal file
81
leggen/utils/paths.py
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
"""Centralized path management for Leggen."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
class PathManager:
|
||||||
|
"""Manages configurable paths for config and database files."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._config_dir: Optional[Path] = None
|
||||||
|
self._database_path: Optional[Path] = None
|
||||||
|
|
||||||
|
def get_config_dir(self) -> Path:
|
||||||
|
"""Get the configuration directory."""
|
||||||
|
if self._config_dir is not None:
|
||||||
|
return self._config_dir
|
||||||
|
|
||||||
|
# Check environment variable first
|
||||||
|
config_dir = os.environ.get("LEGGEN_CONFIG_DIR")
|
||||||
|
if config_dir:
|
||||||
|
return Path(config_dir)
|
||||||
|
|
||||||
|
# Default to ~/.config/leggen
|
||||||
|
return Path.home() / ".config" / "leggen"
|
||||||
|
|
||||||
|
def set_config_dir(self, path: Path) -> None:
|
||||||
|
"""Set the configuration directory."""
|
||||||
|
self._config_dir = Path(path)
|
||||||
|
|
||||||
|
def get_config_file_path(self) -> Path:
|
||||||
|
"""Get the configuration file path."""
|
||||||
|
return self.get_config_dir() / "config.toml"
|
||||||
|
|
||||||
|
def get_database_path(self) -> Path:
|
||||||
|
"""Get the database file path and ensure the directory exists."""
|
||||||
|
if self._database_path is not None:
|
||||||
|
db_path = self._database_path
|
||||||
|
else:
|
||||||
|
# Check environment variable first
|
||||||
|
database_path = os.environ.get("LEGGEN_DATABASE_PATH")
|
||||||
|
if database_path:
|
||||||
|
db_path = Path(database_path)
|
||||||
|
else:
|
||||||
|
# Default to config_dir/leggen.db
|
||||||
|
db_path = self.get_config_dir() / "leggen.db"
|
||||||
|
|
||||||
|
# Try to ensure the directory exists, but handle permission errors gracefully
|
||||||
|
try:
|
||||||
|
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
except (PermissionError, OSError):
|
||||||
|
# If we can't create the directory, continue anyway
|
||||||
|
# This allows tests and error cases to work as expected
|
||||||
|
pass
|
||||||
|
|
||||||
|
return db_path
|
||||||
|
|
||||||
|
def set_database_path(self, path: Path) -> None:
|
||||||
|
"""Set the database file path."""
|
||||||
|
self._database_path = Path(path)
|
||||||
|
|
||||||
|
def get_auth_file_path(self) -> Path:
|
||||||
|
"""Get the authentication file path."""
|
||||||
|
return self.get_config_dir() / "auth.json"
|
||||||
|
|
||||||
|
def ensure_config_dir_exists(self) -> None:
|
||||||
|
"""Ensure the configuration directory exists."""
|
||||||
|
self.get_config_dir().mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
def ensure_database_dir_exists(self) -> None:
|
||||||
|
"""Ensure the database directory exists.
|
||||||
|
|
||||||
|
Note: get_database_path() now automatically ensures the directory exists,
|
||||||
|
so this method is mainly for explicit directory creation in tests.
|
||||||
|
"""
|
||||||
|
self.get_database_path().parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
# Global instance for the application
|
||||||
|
path_manager = PathManager()
|
||||||
@@ -36,14 +36,11 @@ async def get_notification_settings() -> APIResponse:
|
|||||||
if discord_config.get("webhook")
|
if discord_config.get("webhook")
|
||||||
else None,
|
else None,
|
||||||
telegram=TelegramConfig(
|
telegram=TelegramConfig(
|
||||||
token="***"
|
token="***" if telegram_config.get("api-key") else "",
|
||||||
if (telegram_config.get("token") or telegram_config.get("api-key"))
|
chat_id=telegram_config.get("chat-id", 0),
|
||||||
else "",
|
|
||||||
chat_id=telegram_config.get("chat_id")
|
|
||||||
or telegram_config.get("chat-id", 0),
|
|
||||||
enabled=telegram_config.get("enabled", True),
|
enabled=telegram_config.get("enabled", True),
|
||||||
)
|
)
|
||||||
if (telegram_config.get("token") or telegram_config.get("api-key"))
|
if telegram_config.get("api-key")
|
||||||
else None,
|
else None,
|
||||||
filters=NotificationFilters(
|
filters=NotificationFilters(
|
||||||
case_insensitive=filters_config.get("case-insensitive", []),
|
case_insensitive=filters_config.get("case-insensitive", []),
|
||||||
@@ -79,8 +76,8 @@ async def update_notification_settings(settings: NotificationSettings) -> APIRes
|
|||||||
|
|
||||||
if settings.telegram:
|
if settings.telegram:
|
||||||
notifications_config["telegram"] = {
|
notifications_config["telegram"] = {
|
||||||
"token": settings.telegram.token,
|
"api-key": settings.telegram.token,
|
||||||
"chat_id": settings.telegram.chat_id,
|
"chat-id": settings.telegram.chat_id,
|
||||||
"enabled": settings.telegram.enabled,
|
"enabled": settings.telegram.enabled,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,24 +152,12 @@ async def get_notification_services() -> APIResponse:
|
|||||||
"telegram": {
|
"telegram": {
|
||||||
"name": "Telegram",
|
"name": "Telegram",
|
||||||
"enabled": bool(
|
"enabled": bool(
|
||||||
(
|
notifications_config.get("telegram", {}).get("api-key")
|
||||||
notifications_config.get("telegram", {}).get("token")
|
and notifications_config.get("telegram", {}).get("chat-id")
|
||||||
or notifications_config.get("telegram", {}).get("api-key")
|
|
||||||
)
|
|
||||||
and (
|
|
||||||
notifications_config.get("telegram", {}).get("chat_id")
|
|
||||||
or notifications_config.get("telegram", {}).get("chat-id")
|
|
||||||
)
|
|
||||||
),
|
),
|
||||||
"configured": bool(
|
"configured": bool(
|
||||||
(
|
notifications_config.get("telegram", {}).get("api-key")
|
||||||
notifications_config.get("telegram", {}).get("token")
|
and notifications_config.get("telegram", {}).get("chat-id")
|
||||||
or notifications_config.get("telegram", {}).get("api-key")
|
|
||||||
)
|
|
||||||
and (
|
|
||||||
notifications_config.get("telegram", {}).get("chat_id")
|
|
||||||
or notifications_config.get("telegram", {}).get("chat-id")
|
|
||||||
)
|
|
||||||
),
|
),
|
||||||
"active": notifications_config.get("telegram", {}).get("enabled", True),
|
"active": notifications_config.get("telegram", {}).get("enabled", True),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from datetime import datetime, timedelta
|
|||||||
from fastapi import APIRouter, HTTPException, Query
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from leggend.api.models.common import APIResponse
|
from leggend.api.models.common import APIResponse, PaginatedResponse
|
||||||
from leggend.api.models.accounts import Transaction, TransactionSummary
|
from leggend.api.models.accounts import Transaction, TransactionSummary
|
||||||
from leggend.services.database_service import DatabaseService
|
from leggend.services.database_service import DatabaseService
|
||||||
|
|
||||||
@@ -11,10 +11,10 @@ router = APIRouter()
|
|||||||
database_service = DatabaseService()
|
database_service = DatabaseService()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/transactions", response_model=APIResponse)
|
@router.get("/transactions", response_model=PaginatedResponse)
|
||||||
async def get_all_transactions(
|
async def get_all_transactions(
|
||||||
limit: Optional[int] = Query(default=100, le=500),
|
page: int = Query(default=1, ge=1, description="Page number (1-based)"),
|
||||||
offset: Optional[int] = Query(default=0, ge=0),
|
per_page: int = Query(default=50, le=500, description="Items per page"),
|
||||||
summary_only: bool = Query(
|
summary_only: bool = Query(
|
||||||
default=True, description="Return transaction summaries only"
|
default=True, description="Return transaction summaries only"
|
||||||
),
|
),
|
||||||
@@ -34,9 +34,13 @@ async def get_all_transactions(
|
|||||||
default=None, description="Search in transaction descriptions"
|
default=None, description="Search in transaction descriptions"
|
||||||
),
|
),
|
||||||
account_id: Optional[str] = Query(default=None, description="Filter by account ID"),
|
account_id: Optional[str] = Query(default=None, description="Filter by account ID"),
|
||||||
) -> APIResponse:
|
) -> PaginatedResponse:
|
||||||
"""Get all transactions from database with filtering options"""
|
"""Get all transactions from database with filtering options"""
|
||||||
try:
|
try:
|
||||||
|
# Calculate offset from page and per_page
|
||||||
|
offset = (page - 1) * per_page
|
||||||
|
limit = per_page
|
||||||
|
|
||||||
# Get transactions from database instead of GoCardless API
|
# Get transactions from database instead of GoCardless API
|
||||||
db_transactions = await database_service.get_transactions_from_db(
|
db_transactions = await database_service.get_transactions_from_db(
|
||||||
account_id=account_id,
|
account_id=account_id,
|
||||||
@@ -59,16 +63,6 @@ async def get_all_transactions(
|
|||||||
search=search,
|
search=search,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get total count for pagination info
|
|
||||||
total_transactions = await database_service.get_transaction_count_from_db(
|
|
||||||
account_id=account_id,
|
|
||||||
date_from=date_from,
|
|
||||||
date_to=date_to,
|
|
||||||
min_amount=min_amount,
|
|
||||||
max_amount=max_amount,
|
|
||||||
search=search,
|
|
||||||
)
|
|
||||||
|
|
||||||
data: Union[List[TransactionSummary], List[Transaction]]
|
data: Union[List[TransactionSummary], List[Transaction]]
|
||||||
|
|
||||||
if summary_only:
|
if summary_only:
|
||||||
@@ -105,11 +99,19 @@ async def get_all_transactions(
|
|||||||
for txn in db_transactions
|
for txn in db_transactions
|
||||||
]
|
]
|
||||||
|
|
||||||
actual_offset = offset or 0
|
total_pages = (total_transactions + per_page - 1) // per_page
|
||||||
return APIResponse(
|
|
||||||
|
return PaginatedResponse(
|
||||||
success=True,
|
success=True,
|
||||||
data=data,
|
data=data,
|
||||||
message=f"Retrieved {len(data)} transactions (showing {actual_offset + 1}-{actual_offset + len(data)} of {total_transactions})",
|
pagination={
|
||||||
|
"total": total_transactions,
|
||||||
|
"page": page,
|
||||||
|
"per_page": per_page,
|
||||||
|
"total_pages": total_pages,
|
||||||
|
"has_next": page < total_pages,
|
||||||
|
"has_prev": page > 1,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from pathlib import Path
|
|||||||
from typing import Dict, Any, Optional
|
from typing import Dict, Any, Optional
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
from leggen.utils.paths import path_manager
|
||||||
|
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
@@ -23,9 +24,10 @@ class Config:
|
|||||||
|
|
||||||
if config_path is None:
|
if config_path is None:
|
||||||
config_path = os.environ.get(
|
config_path = os.environ.get(
|
||||||
"LEGGEN_CONFIG_FILE",
|
"LEGGEN_CONFIG_FILE"
|
||||||
str(Path.home() / ".config" / "leggen" / "config.toml"),
|
|
||||||
)
|
)
|
||||||
|
if not config_path:
|
||||||
|
config_path = str(path_manager.get_config_file_path())
|
||||||
|
|
||||||
self._config_path = config_path
|
self._config_path = config_path
|
||||||
|
|
||||||
@@ -53,9 +55,10 @@ class Config:
|
|||||||
|
|
||||||
if config_path is None:
|
if config_path is None:
|
||||||
config_path = self._config_path or os.environ.get(
|
config_path = self._config_path or os.environ.get(
|
||||||
"LEGGEN_CONFIG_FILE",
|
"LEGGEN_CONFIG_FILE"
|
||||||
str(Path.home() / ".config" / "leggen" / "config.toml"),
|
|
||||||
)
|
)
|
||||||
|
if not config_path:
|
||||||
|
config_path = str(path_manager.get_config_file_path())
|
||||||
|
|
||||||
if config_path is None:
|
if config_path is None:
|
||||||
raise ValueError("No config path specified")
|
raise ValueError("No config path specified")
|
||||||
|
|||||||
@@ -121,6 +121,8 @@ def create_app() -> FastAPI:
|
|||||||
|
|
||||||
def main():
|
def main():
|
||||||
import argparse
|
import argparse
|
||||||
|
from pathlib import Path
|
||||||
|
from leggen.utils.paths import path_manager
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(description="Start the Leggend API service")
|
parser = argparse.ArgumentParser(description="Start the Leggend API service")
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
@@ -132,8 +134,24 @@ def main():
|
|||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--port", type=int, default=8000, help="Port to bind to (default: 8000)"
|
"--port", type=int, default=8000, help="Port to bind to (default: 8000)"
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--config-dir",
|
||||||
|
type=Path,
|
||||||
|
help="Directory containing configuration files (default: ~/.config/leggen)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--database",
|
||||||
|
type=Path,
|
||||||
|
help="Path to SQLite database file (default: <config-dir>/leggen.db)",
|
||||||
|
)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# Set up path manager with user-provided paths
|
||||||
|
if args.config_dir:
|
||||||
|
path_manager.set_config_dir(args.config_dir)
|
||||||
|
if args.database:
|
||||||
|
path_manager.set_database_path(args.database)
|
||||||
|
|
||||||
if args.reload:
|
if args.reload:
|
||||||
# Use string import for reload to work properly
|
# Use string import for reload to work properly
|
||||||
uvicorn.run(
|
uvicorn.run(
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from loguru import logger
|
|||||||
|
|
||||||
from leggend.config import config
|
from leggend.config import config
|
||||||
import leggen.database.sqlite as sqlite_db
|
import leggen.database.sqlite as sqlite_db
|
||||||
|
from leggen.utils.paths import path_manager
|
||||||
|
|
||||||
|
|
||||||
class DatabaseService:
|
class DatabaseService:
|
||||||
@@ -280,9 +281,7 @@ class DatabaseService:
|
|||||||
|
|
||||||
async def _check_balance_timestamp_migration_needed(self) -> bool:
|
async def _check_balance_timestamp_migration_needed(self) -> bool:
|
||||||
"""Check if balance timestamps need migration"""
|
"""Check if balance timestamps need migration"""
|
||||||
from pathlib import Path
|
db_path = path_manager.get_database_path()
|
||||||
|
|
||||||
db_path = Path.home() / ".config" / "leggen" / "leggen.db"
|
|
||||||
if not db_path.exists():
|
if not db_path.exists():
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -310,9 +309,7 @@ class DatabaseService:
|
|||||||
|
|
||||||
async def _migrate_balance_timestamps(self):
|
async def _migrate_balance_timestamps(self):
|
||||||
"""Convert all Unix timestamps to datetime strings"""
|
"""Convert all Unix timestamps to datetime strings"""
|
||||||
from pathlib import Path
|
db_path = path_manager.get_database_path()
|
||||||
|
|
||||||
db_path = Path.home() / ".config" / "leggen" / "leggen.db"
|
|
||||||
if not db_path.exists():
|
if not db_path.exists():
|
||||||
logger.warning("Database file not found, skipping migration")
|
logger.warning("Database file not found, skipping migration")
|
||||||
return
|
return
|
||||||
@@ -399,9 +396,7 @@ class DatabaseService:
|
|||||||
|
|
||||||
async def _check_null_transaction_ids_migration_needed(self) -> bool:
|
async def _check_null_transaction_ids_migration_needed(self) -> bool:
|
||||||
"""Check if null transaction IDs need migration"""
|
"""Check if null transaction IDs need migration"""
|
||||||
from pathlib import Path
|
db_path = path_manager.get_database_path()
|
||||||
|
|
||||||
db_path = Path.home() / ".config" / "leggen" / "leggen.db"
|
|
||||||
if not db_path.exists():
|
if not db_path.exists():
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -429,9 +424,8 @@ class DatabaseService:
|
|||||||
async def _migrate_null_transaction_ids(self):
|
async def _migrate_null_transaction_ids(self):
|
||||||
"""Populate null internalTransactionId fields using transactionId from raw data"""
|
"""Populate null internalTransactionId fields using transactionId from raw data"""
|
||||||
import uuid
|
import uuid
|
||||||
from pathlib import Path
|
|
||||||
|
db_path = path_manager.get_database_path()
|
||||||
db_path = Path.home() / ".config" / "leggen" / "leggen.db"
|
|
||||||
if not db_path.exists():
|
if not db_path.exists():
|
||||||
logger.warning("Database file not found, skipping migration")
|
logger.warning("Database file not found, skipping migration")
|
||||||
return
|
return
|
||||||
@@ -538,9 +532,7 @@ class DatabaseService:
|
|||||||
|
|
||||||
async def _check_composite_key_migration_needed(self) -> bool:
|
async def _check_composite_key_migration_needed(self) -> bool:
|
||||||
"""Check if composite key migration is needed"""
|
"""Check if composite key migration is needed"""
|
||||||
from pathlib import Path
|
db_path = path_manager.get_database_path()
|
||||||
|
|
||||||
db_path = Path.home() / ".config" / "leggen" / "leggen.db"
|
|
||||||
if not db_path.exists():
|
if not db_path.exists():
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -586,9 +578,7 @@ class DatabaseService:
|
|||||||
|
|
||||||
async def _migrate_to_composite_key(self):
|
async def _migrate_to_composite_key(self):
|
||||||
"""Migrate transactions table to use composite primary key (accountId, transactionId)"""
|
"""Migrate transactions table to use composite primary key (accountId, transactionId)"""
|
||||||
from pathlib import Path
|
db_path = path_manager.get_database_path()
|
||||||
|
|
||||||
db_path = Path.home() / ".config" / "leggen" / "leggen.db"
|
|
||||||
if not db_path.exists():
|
if not db_path.exists():
|
||||||
logger.warning("Database file not found, skipping migration")
|
logger.warning("Database file not found, skipping migration")
|
||||||
return
|
return
|
||||||
@@ -704,10 +694,7 @@ class DatabaseService:
|
|||||||
try:
|
try:
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
|
||||||
from pathlib import Path
|
db_path = path_manager.get_database_path()
|
||||||
|
|
||||||
db_path = Path.home() / ".config" / "leggen" / "leggen.db"
|
|
||||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
conn = sqlite3.connect(str(db_path))
|
conn = sqlite3.connect(str(db_path))
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
||||||
@@ -786,10 +773,7 @@ class DatabaseService:
|
|||||||
import sqlite3
|
import sqlite3
|
||||||
import json
|
import json
|
||||||
|
|
||||||
from pathlib import Path
|
db_path = path_manager.get_database_path()
|
||||||
|
|
||||||
db_path = Path.home() / ".config" / "leggen" / "leggen.db"
|
|
||||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
conn = sqlite3.connect(str(db_path))
|
conn = sqlite3.connect(str(db_path))
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
||||||
@@ -888,11 +872,6 @@ class DatabaseService:
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Persist account details to SQLite"""
|
"""Persist account details to SQLite"""
|
||||||
try:
|
try:
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
db_path = Path.home() / ".config" / "leggen" / "leggen.db"
|
|
||||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
# Use the sqlite_db module function
|
# Use the sqlite_db module function
|
||||||
sqlite_db.persist_account(account_data)
|
sqlite_db.persist_account(account_data)
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from typing import Dict, Any, List
|
|||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from leggend.config import config
|
from leggend.config import config
|
||||||
|
from leggen.utils.paths import path_manager
|
||||||
|
|
||||||
|
|
||||||
def _log_rate_limits(response):
|
def _log_rate_limits(response):
|
||||||
@@ -39,8 +40,8 @@ class GoCardlessService:
|
|||||||
if self._token:
|
if self._token:
|
||||||
return self._token
|
return self._token
|
||||||
|
|
||||||
# Use ~/.config/leggen for consistency with main config
|
# Use path manager for auth file
|
||||||
auth_file = Path.home() / ".config" / "leggen" / "auth.json"
|
auth_file = path_manager.get_auth_file_path()
|
||||||
|
|
||||||
if auth_file.exists():
|
if auth_file.exists():
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -109,9 +109,8 @@ class NotificationService:
|
|||||||
"""Check if Telegram notifications are enabled"""
|
"""Check if Telegram notifications are enabled"""
|
||||||
telegram_config = self.notifications_config.get("telegram", {})
|
telegram_config = self.notifications_config.get("telegram", {})
|
||||||
return bool(
|
return bool(
|
||||||
telegram_config.get("token")
|
telegram_config.get("api-key")
|
||||||
or telegram_config.get("api-key")
|
and telegram_config.get("chat-id")
|
||||||
and (telegram_config.get("chat_id") or telegram_config.get("chat-id"))
|
|
||||||
and telegram_config.get("enabled", True)
|
and telegram_config.get("enabled", True)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -174,10 +173,8 @@ class NotificationService:
|
|||||||
ctx.obj = {
|
ctx.obj = {
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"telegram": {
|
"telegram": {
|
||||||
"api-key": telegram_config.get("token")
|
"api-key": telegram_config.get("api-key"),
|
||||||
or telegram_config.get("api-key"),
|
"chat-id": telegram_config.get("chat-id"),
|
||||||
"chat-id": telegram_config.get("chat_id")
|
|
||||||
or telegram_config.get("chat-id"),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "leggen"
|
name = "leggen"
|
||||||
version = "2025.9.6"
|
version = "2025.9.9"
|
||||||
description = "An Open Banking CLI"
|
description = "An Open Banking CLI"
|
||||||
authors = [{ name = "Elisiário Couto", email = "elisiario@couto.io" }]
|
authors = [{ name = "Elisiário Couto", email = "elisiario@couto.io" }]
|
||||||
requires-python = "~=3.13.0"
|
requires-python = "~=3.13.0"
|
||||||
|
|||||||
426
scripts/generate_sample_db.py
Executable file
426
scripts/generate_sample_db.py
Executable file
@@ -0,0 +1,426 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Sample database generator for Leggen testing and development."""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import random
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Dict, Any
|
||||||
|
|
||||||
|
# Add the project root to the Python path
|
||||||
|
project_root = Path(__file__).parent.parent
|
||||||
|
sys.path.insert(0, str(project_root))
|
||||||
|
|
||||||
|
import click
|
||||||
|
from leggen.utils.paths import path_manager
|
||||||
|
|
||||||
|
|
||||||
|
class SampleDataGenerator:
|
||||||
|
"""Generates realistic sample data for testing Leggen."""
|
||||||
|
|
||||||
|
def __init__(self, db_path: Path):
|
||||||
|
self.db_path = db_path
|
||||||
|
self.institutions = [
|
||||||
|
{
|
||||||
|
"id": "REVOLUT_REVOLT21",
|
||||||
|
"name": "Revolut",
|
||||||
|
"bic": "REVOLT21",
|
||||||
|
"country": "LT",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "BANCOBPI_BBPIPTPL",
|
||||||
|
"name": "Banco BPI",
|
||||||
|
"bic": "BBPIPTPL",
|
||||||
|
"country": "PT",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "MONZO_MONZGB2L",
|
||||||
|
"name": "Monzo Bank",
|
||||||
|
"bic": "MONZGB2L",
|
||||||
|
"country": "GB",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "NUBANK_NUPBBR25",
|
||||||
|
"name": "Nu Pagamentos",
|
||||||
|
"bic": "NUPBBR25",
|
||||||
|
"country": "BR",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
self.transaction_types = [
|
||||||
|
{"description": "Grocery Store", "amount_range": (-150, -20), "frequency": 0.3},
|
||||||
|
{"description": "Coffee Shop", "amount_range": (-15, -3), "frequency": 0.2},
|
||||||
|
{"description": "Gas Station", "amount_range": (-80, -30), "frequency": 0.1},
|
||||||
|
{"description": "Online Shopping", "amount_range": (-200, -25), "frequency": 0.15},
|
||||||
|
{"description": "Restaurant", "amount_range": (-60, -15), "frequency": 0.15},
|
||||||
|
{"description": "Salary", "amount_range": (2500, 5000), "frequency": 0.02},
|
||||||
|
{"description": "ATM Withdrawal", "amount_range": (-200, -20), "frequency": 0.05},
|
||||||
|
{"description": "Transfer to Savings", "amount_range": (-1000, -100), "frequency": 0.03},
|
||||||
|
]
|
||||||
|
|
||||||
|
def ensure_database_dir(self):
|
||||||
|
"""Ensure database directory exists."""
|
||||||
|
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
def create_tables(self):
|
||||||
|
"""Create database tables."""
|
||||||
|
conn = sqlite3.connect(str(self.db_path))
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Create accounts table
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS accounts (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
institution_id TEXT,
|
||||||
|
status TEXT,
|
||||||
|
iban TEXT,
|
||||||
|
name TEXT,
|
||||||
|
currency TEXT,
|
||||||
|
created DATETIME,
|
||||||
|
last_accessed DATETIME,
|
||||||
|
last_updated DATETIME
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Create transactions table with composite primary key
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS transactions (
|
||||||
|
accountId TEXT NOT NULL,
|
||||||
|
transactionId TEXT NOT NULL,
|
||||||
|
internalTransactionId TEXT,
|
||||||
|
institutionId TEXT,
|
||||||
|
iban TEXT,
|
||||||
|
transactionDate DATETIME,
|
||||||
|
description TEXT,
|
||||||
|
transactionValue REAL,
|
||||||
|
transactionCurrency TEXT,
|
||||||
|
transactionStatus TEXT,
|
||||||
|
rawTransaction JSON,
|
||||||
|
PRIMARY KEY (accountId, transactionId)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Create balances table
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS balances (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
account_id TEXT,
|
||||||
|
bank TEXT,
|
||||||
|
status TEXT,
|
||||||
|
iban TEXT,
|
||||||
|
amount REAL,
|
||||||
|
currency TEXT,
|
||||||
|
type TEXT,
|
||||||
|
timestamp DATETIME
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Create indexes
|
||||||
|
cursor.execute("CREATE INDEX IF NOT EXISTS idx_transactions_internal_id ON transactions(internalTransactionId)")
|
||||||
|
cursor.execute("CREATE INDEX IF NOT EXISTS idx_transactions_date ON transactions(transactionDate)")
|
||||||
|
cursor.execute("CREATE INDEX IF NOT EXISTS idx_transactions_account_date ON transactions(accountId, transactionDate)")
|
||||||
|
cursor.execute("CREATE INDEX IF NOT EXISTS idx_transactions_amount ON transactions(transactionValue)")
|
||||||
|
cursor.execute("CREATE INDEX IF NOT EXISTS idx_balances_account_id ON balances(account_id)")
|
||||||
|
cursor.execute("CREATE INDEX IF NOT EXISTS idx_balances_timestamp ON balances(timestamp)")
|
||||||
|
cursor.execute("CREATE INDEX IF NOT EXISTS idx_balances_account_type_timestamp ON balances(account_id, type, timestamp)")
|
||||||
|
cursor.execute("CREATE INDEX IF NOT EXISTS idx_accounts_institution_id ON accounts(institution_id)")
|
||||||
|
cursor.execute("CREATE INDEX IF NOT EXISTS idx_accounts_status ON accounts(status)")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def generate_iban(self, country_code: str) -> str:
|
||||||
|
"""Generate a realistic IBAN for the given country."""
|
||||||
|
ibans = {
|
||||||
|
"LT": lambda: f"LT{random.randint(10, 99)}{random.randint(10000, 99999)}{random.randint(10000000, 99999999)}",
|
||||||
|
"PT": lambda: f"PT{random.randint(10, 99)}{random.randint(1000, 9999)}{random.randint(1000, 9999)}{random.randint(10000000000, 99999999999)}",
|
||||||
|
"GB": lambda: f"GB{random.randint(10, 99)}MONZ{random.randint(100000, 999999)}{random.randint(100000, 999999)}",
|
||||||
|
"BR": lambda: f"BR{random.randint(10, 99)}{random.randint(10000000, 99999999)}{random.randint(1000, 9999)}{random.randint(10000000, 99999999)}",
|
||||||
|
}
|
||||||
|
return ibans.get(country_code, lambda: f"{country_code}{random.randint(1000000000000000, 9999999999999999)}")()
|
||||||
|
|
||||||
|
def generate_accounts(self, num_accounts: int = 3) -> List[Dict[str, Any]]:
|
||||||
|
"""Generate sample accounts."""
|
||||||
|
accounts = []
|
||||||
|
base_date = datetime.now() - timedelta(days=90)
|
||||||
|
|
||||||
|
for i in range(num_accounts):
|
||||||
|
institution = random.choice(self.institutions)
|
||||||
|
account_id = f"account-{i+1:03d}-{random.randint(1000, 9999)}"
|
||||||
|
|
||||||
|
account = {
|
||||||
|
"id": account_id,
|
||||||
|
"institution_id": institution["id"],
|
||||||
|
"status": "READY",
|
||||||
|
"iban": self.generate_iban(institution["country"]),
|
||||||
|
"name": f"Personal Account {i+1}",
|
||||||
|
"currency": "EUR",
|
||||||
|
"created": (base_date + timedelta(days=random.randint(0, 30))).isoformat(),
|
||||||
|
"last_accessed": (datetime.now() - timedelta(hours=random.randint(1, 48))).isoformat(),
|
||||||
|
"last_updated": datetime.now().isoformat(),
|
||||||
|
}
|
||||||
|
accounts.append(account)
|
||||||
|
|
||||||
|
return accounts
|
||||||
|
|
||||||
|
def generate_transactions(self, accounts: List[Dict[str, Any]], num_transactions_per_account: int = 50) -> List[Dict[str, Any]]:
|
||||||
|
"""Generate sample transactions for accounts."""
|
||||||
|
transactions = []
|
||||||
|
base_date = datetime.now() - timedelta(days=60)
|
||||||
|
|
||||||
|
for account in accounts:
|
||||||
|
account_transactions = []
|
||||||
|
current_balance = random.uniform(500, 3000)
|
||||||
|
|
||||||
|
for i in range(num_transactions_per_account):
|
||||||
|
# Choose transaction type based on frequency weights
|
||||||
|
transaction_type = random.choices(
|
||||||
|
self.transaction_types,
|
||||||
|
weights=[t["frequency"] for t in self.transaction_types]
|
||||||
|
)[0]
|
||||||
|
|
||||||
|
# Generate transaction amount
|
||||||
|
min_amount, max_amount = transaction_type["amount_range"]
|
||||||
|
amount = round(random.uniform(min_amount, max_amount), 2)
|
||||||
|
|
||||||
|
# Generate transaction date (more recent transactions are more likely)
|
||||||
|
days_ago = random.choices(
|
||||||
|
range(60),
|
||||||
|
weights=[1.5 ** (60 - d) for d in range(60)]
|
||||||
|
)[0]
|
||||||
|
transaction_date = base_date + timedelta(days=days_ago, hours=random.randint(6, 22), minutes=random.randint(0, 59))
|
||||||
|
|
||||||
|
# Generate transaction IDs
|
||||||
|
transaction_id = f"bank-txn-{account['id']}-{i+1:04d}"
|
||||||
|
internal_transaction_id = f"int-txn-{random.randint(100000, 999999)}"
|
||||||
|
|
||||||
|
# Create realistic descriptions
|
||||||
|
descriptions = {
|
||||||
|
"Grocery Store": ["TESCO", "SAINSBURY'S", "LIDL", "ALDI", "WALMART", "CARREFOUR"],
|
||||||
|
"Coffee Shop": ["STARBUCKS", "COSTA COFFEE", "PRET A MANGER", "LOCAL CAFE"],
|
||||||
|
"Gas Station": ["BP", "SHELL", "ESSO", "GALP", "PETROBRAS"],
|
||||||
|
"Online Shopping": ["AMAZON", "EBAY", "ZALANDO", "ASOS", "APPLE"],
|
||||||
|
"Restaurant": ["PIZZA HUT", "MCDONALD'S", "BURGER KING", "LOCAL RESTAURANT"],
|
||||||
|
"Salary": ["MONTHLY SALARY", "PAYROLL DEPOSIT", "SALARY PAYMENT"],
|
||||||
|
"ATM Withdrawal": ["ATM WITHDRAWAL", "CASH WITHDRAWAL"],
|
||||||
|
"Transfer to Savings": ["SAVINGS TRANSFER", "INVESTMENT TRANSFER"],
|
||||||
|
}
|
||||||
|
|
||||||
|
specific_descriptions = descriptions.get(transaction_type["description"], [transaction_type["description"]])
|
||||||
|
description = random.choice(specific_descriptions)
|
||||||
|
|
||||||
|
# Create raw transaction (simplified GoCardless format)
|
||||||
|
raw_transaction = {
|
||||||
|
"transactionId": transaction_id,
|
||||||
|
"bookingDate": transaction_date.strftime("%Y-%m-%d"),
|
||||||
|
"valueDate": transaction_date.strftime("%Y-%m-%d"),
|
||||||
|
"transactionAmount": {
|
||||||
|
"amount": str(amount),
|
||||||
|
"currency": account["currency"]
|
||||||
|
},
|
||||||
|
"remittanceInformationUnstructured": description,
|
||||||
|
"bankTransactionCode": "PMNT" if amount < 0 else "RCDT",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Determine status (most are booked, some recent ones might be pending)
|
||||||
|
status = "pending" if days_ago < 2 and random.random() < 0.1 else "booked"
|
||||||
|
|
||||||
|
transaction = {
|
||||||
|
"accountId": account["id"],
|
||||||
|
"transactionId": transaction_id,
|
||||||
|
"internalTransactionId": internal_transaction_id,
|
||||||
|
"institutionId": account["institution_id"],
|
||||||
|
"iban": account["iban"],
|
||||||
|
"transactionDate": transaction_date.isoformat(),
|
||||||
|
"description": description,
|
||||||
|
"transactionValue": amount,
|
||||||
|
"transactionCurrency": account["currency"],
|
||||||
|
"transactionStatus": status,
|
||||||
|
"rawTransaction": raw_transaction,
|
||||||
|
}
|
||||||
|
|
||||||
|
account_transactions.append(transaction)
|
||||||
|
current_balance += amount
|
||||||
|
|
||||||
|
# Sort transactions by date for realistic ordering
|
||||||
|
account_transactions.sort(key=lambda x: x["transactionDate"])
|
||||||
|
transactions.extend(account_transactions)
|
||||||
|
|
||||||
|
return transactions
|
||||||
|
|
||||||
|
def generate_balances(self, accounts: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||||
|
"""Generate sample balances for accounts."""
|
||||||
|
balances = []
|
||||||
|
|
||||||
|
for account in accounts:
|
||||||
|
# Calculate balance from transactions (simplified)
|
||||||
|
base_balance = random.uniform(500, 2000)
|
||||||
|
|
||||||
|
balance_types = ["interimAvailable", "closingBooked", "authorised"]
|
||||||
|
|
||||||
|
for balance_type in balance_types:
|
||||||
|
# Add some variation to balance types
|
||||||
|
variation = random.uniform(-50, 50) if balance_type != "interimAvailable" else 0
|
||||||
|
balance_amount = base_balance + variation
|
||||||
|
|
||||||
|
balance = {
|
||||||
|
"account_id": account["id"],
|
||||||
|
"bank": account["institution_id"],
|
||||||
|
"status": account["status"],
|
||||||
|
"iban": account["iban"],
|
||||||
|
"amount": round(balance_amount, 2),
|
||||||
|
"currency": account["currency"],
|
||||||
|
"type": balance_type,
|
||||||
|
"timestamp": datetime.now().isoformat(),
|
||||||
|
}
|
||||||
|
balances.append(balance)
|
||||||
|
|
||||||
|
return balances
|
||||||
|
|
||||||
|
def insert_data(self, accounts: List[Dict[str, Any]], transactions: List[Dict[str, Any]], balances: List[Dict[str, Any]]):
|
||||||
|
"""Insert generated data into the database."""
|
||||||
|
conn = sqlite3.connect(str(self.db_path))
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Insert accounts
|
||||||
|
for account in accounts:
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT OR REPLACE INTO accounts
|
||||||
|
(id, institution_id, status, iban, name, currency, created, last_accessed, last_updated)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""", (
|
||||||
|
account["id"], account["institution_id"], account["status"], account["iban"],
|
||||||
|
account["name"], account["currency"], account["created"],
|
||||||
|
account["last_accessed"], account["last_updated"]
|
||||||
|
))
|
||||||
|
|
||||||
|
# Insert transactions
|
||||||
|
for transaction in transactions:
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT OR REPLACE INTO transactions
|
||||||
|
(accountId, transactionId, internalTransactionId, institutionId, iban,
|
||||||
|
transactionDate, description, transactionValue, transactionCurrency,
|
||||||
|
transactionStatus, rawTransaction)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""", (
|
||||||
|
transaction["accountId"], transaction["transactionId"],
|
||||||
|
transaction["internalTransactionId"], transaction["institutionId"],
|
||||||
|
transaction["iban"], transaction["transactionDate"], transaction["description"],
|
||||||
|
transaction["transactionValue"], transaction["transactionCurrency"],
|
||||||
|
transaction["transactionStatus"], json.dumps(transaction["rawTransaction"])
|
||||||
|
))
|
||||||
|
|
||||||
|
# Insert balances
|
||||||
|
for balance in balances:
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT INTO balances
|
||||||
|
(account_id, bank, status, iban, amount, currency, type, timestamp)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""", (
|
||||||
|
balance["account_id"], balance["bank"], balance["status"], balance["iban"],
|
||||||
|
balance["amount"], balance["currency"], balance["type"], balance["timestamp"]
|
||||||
|
))
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def generate_sample_database(self, num_accounts: int = 3, num_transactions_per_account: int = 50):
|
||||||
|
"""Generate complete sample database."""
|
||||||
|
click.echo(f"🗄️ Creating sample database at: {self.db_path}")
|
||||||
|
|
||||||
|
self.ensure_database_dir()
|
||||||
|
self.create_tables()
|
||||||
|
|
||||||
|
click.echo(f"👥 Generating {num_accounts} sample accounts...")
|
||||||
|
accounts = self.generate_accounts(num_accounts)
|
||||||
|
|
||||||
|
click.echo(f"💳 Generating {num_transactions_per_account} transactions per account...")
|
||||||
|
transactions = self.generate_transactions(accounts, num_transactions_per_account)
|
||||||
|
|
||||||
|
click.echo("💰 Generating account balances...")
|
||||||
|
balances = self.generate_balances(accounts)
|
||||||
|
|
||||||
|
click.echo("💾 Inserting data into database...")
|
||||||
|
self.insert_data(accounts, transactions, balances)
|
||||||
|
|
||||||
|
# Print summary
|
||||||
|
click.echo("\n✅ Sample database created successfully!")
|
||||||
|
click.echo(f"📊 Summary:")
|
||||||
|
click.echo(f" - Accounts: {len(accounts)}")
|
||||||
|
click.echo(f" - Transactions: {len(transactions)}")
|
||||||
|
click.echo(f" - Balances: {len(balances)}")
|
||||||
|
click.echo(f" - Database: {self.db_path}")
|
||||||
|
|
||||||
|
# Show account details
|
||||||
|
click.echo(f"\n📋 Sample accounts:")
|
||||||
|
for account in accounts:
|
||||||
|
institution_name = next(inst["name"] for inst in self.institutions if inst["id"] == account["institution_id"])
|
||||||
|
click.echo(f" - {account['id']} ({institution_name}) - {account['iban']}")
|
||||||
|
|
||||||
|
|
||||||
|
@click.command()
|
||||||
|
@click.option(
|
||||||
|
"--database",
|
||||||
|
type=click.Path(path_type=Path),
|
||||||
|
help="Path to database file (default: uses LEGGEN_DATABASE_PATH or ~/.config/leggen/leggen-dev.db)",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--accounts",
|
||||||
|
type=int,
|
||||||
|
default=3,
|
||||||
|
help="Number of sample accounts to generate (default: 3)",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--transactions",
|
||||||
|
type=int,
|
||||||
|
default=50,
|
||||||
|
help="Number of transactions per account (default: 50)",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--force",
|
||||||
|
is_flag=True,
|
||||||
|
help="Overwrite existing database without confirmation",
|
||||||
|
)
|
||||||
|
def main(database: Path, accounts: int, transactions: int, force: bool):
|
||||||
|
"""Generate a sample database with realistic financial data for testing Leggen."""
|
||||||
|
|
||||||
|
# Determine database path
|
||||||
|
if database:
|
||||||
|
db_path = database
|
||||||
|
else:
|
||||||
|
# Use development database by default to avoid overwriting production data
|
||||||
|
import os
|
||||||
|
env_path = os.environ.get("LEGGEN_DATABASE_PATH")
|
||||||
|
if env_path:
|
||||||
|
db_path = Path(env_path)
|
||||||
|
else:
|
||||||
|
# Default to development database in config directory
|
||||||
|
db_path = path_manager.get_config_dir() / "leggen-dev.db"
|
||||||
|
|
||||||
|
# Check if database exists and ask for confirmation
|
||||||
|
if db_path.exists() and not force:
|
||||||
|
click.echo(f"⚠️ Database already exists: {db_path}")
|
||||||
|
if not click.confirm("Do you want to overwrite it?"):
|
||||||
|
click.echo("Aborted.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Generate the sample database
|
||||||
|
generator = SampleDataGenerator(db_path)
|
||||||
|
generator.generate_sample_database(accounts, transactions)
|
||||||
|
|
||||||
|
# Show usage instructions
|
||||||
|
click.echo(f"\n🚀 Usage instructions:")
|
||||||
|
click.echo(f"To use this sample database with leggen commands:")
|
||||||
|
click.echo(f" export LEGGEN_DATABASE_PATH={db_path}")
|
||||||
|
click.echo(f" leggen transactions")
|
||||||
|
click.echo(f"")
|
||||||
|
click.echo(f"To use this sample database with leggend API:")
|
||||||
|
click.echo(f" leggend --database {db_path}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -86,23 +86,17 @@ def api_client(fastapi_app):
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def mock_db_path(temp_db_path):
|
def mock_db_path(temp_db_path):
|
||||||
"""Mock the database path to use temporary database for testing."""
|
"""Mock the database path to use temporary database for testing."""
|
||||||
from pathlib import Path
|
from leggen.utils.paths import path_manager
|
||||||
|
|
||||||
# Create the expected directory structure
|
# Set the path manager to use the temporary database
|
||||||
temp_home = temp_db_path.parent
|
original_database_path = path_manager._database_path
|
||||||
config_dir = temp_home / ".config" / "leggen"
|
path_manager.set_database_path(temp_db_path)
|
||||||
config_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
try:
|
||||||
# Create the expected database path
|
yield temp_db_path
|
||||||
expected_db_path = config_dir / "leggen.db"
|
finally:
|
||||||
|
# Restore original path
|
||||||
# Mock Path.home to return our temp directory
|
path_manager._database_path = original_database_path
|
||||||
def mock_home():
|
|
||||||
return temp_home
|
|
||||||
|
|
||||||
# Patch Path.home in the main pathlib module
|
|
||||||
with patch.object(Path, "home", staticmethod(mock_home)):
|
|
||||||
yield expected_db_path
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|||||||
@@ -153,8 +153,8 @@ class TestTransactionsAPI:
|
|||||||
"min_amount=-50.0&"
|
"min_amount=-50.0&"
|
||||||
"max_amount=0.0&"
|
"max_amount=0.0&"
|
||||||
"search=Coffee&"
|
"search=Coffee&"
|
||||||
"limit=10&"
|
"page=2&"
|
||||||
"offset=5"
|
"per_page=10"
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
@@ -165,7 +165,7 @@ class TestTransactionsAPI:
|
|||||||
mock_get_transactions.assert_called_once_with(
|
mock_get_transactions.assert_called_once_with(
|
||||||
account_id="test-account-123",
|
account_id="test-account-123",
|
||||||
limit=10,
|
limit=10,
|
||||||
offset=5,
|
offset=10, # (page-1) * per_page = (2-1) * 10 = 10
|
||||||
date_from="2025-09-01",
|
date_from="2025-09-01",
|
||||||
date_to="2025-09-02",
|
date_to="2025-09-02",
|
||||||
min_amount=-50.0,
|
min_amount=-50.0,
|
||||||
@@ -194,7 +194,9 @@ class TestTransactionsAPI:
|
|||||||
data = response.json()
|
data = response.json()
|
||||||
assert data["success"] is True
|
assert data["success"] is True
|
||||||
assert len(data["data"]) == 0
|
assert len(data["data"]) == 0
|
||||||
assert "0 transactions" in data["message"]
|
assert data["pagination"]["total"] == 0
|
||||||
|
assert data["pagination"]["page"] == 1
|
||||||
|
assert data["pagination"]["total_pages"] == 0
|
||||||
|
|
||||||
def test_get_transactions_database_error(
|
def test_get_transactions_database_error(
|
||||||
self, api_client, mock_config, mock_auth_token
|
self, api_client, mock_config, mock_auth_token
|
||||||
|
|||||||
162
tests/unit/test_configurable_paths.py
Normal file
162
tests/unit/test_configurable_paths.py
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
"""Integration tests for configurable paths."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import tempfile
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from leggen.utils.paths import path_manager
|
||||||
|
from leggen.database.sqlite import persist_balances, get_balances
|
||||||
|
|
||||||
|
|
||||||
|
class MockContext:
|
||||||
|
"""Mock context for testing."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestConfigurablePaths:
|
||||||
|
"""Test configurable path management."""
|
||||||
|
|
||||||
|
def test_default_paths(self):
|
||||||
|
"""Test that default paths are correctly set."""
|
||||||
|
# Reset path manager
|
||||||
|
original_config = path_manager._config_dir
|
||||||
|
original_db = path_manager._database_path
|
||||||
|
|
||||||
|
try:
|
||||||
|
path_manager._config_dir = None
|
||||||
|
path_manager._database_path = None
|
||||||
|
|
||||||
|
# Test defaults
|
||||||
|
config_dir = path_manager.get_config_dir()
|
||||||
|
db_path = path_manager.get_database_path()
|
||||||
|
|
||||||
|
assert config_dir == Path.home() / ".config" / "leggen"
|
||||||
|
assert db_path == Path.home() / ".config" / "leggen" / "leggen.db"
|
||||||
|
finally:
|
||||||
|
path_manager._config_dir = original_config
|
||||||
|
path_manager._database_path = original_db
|
||||||
|
|
||||||
|
def test_environment_variables(self):
|
||||||
|
"""Test that environment variables override defaults."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
test_config_dir = Path(tmpdir) / "test-config"
|
||||||
|
test_db_path = Path(tmpdir) / "test.db"
|
||||||
|
|
||||||
|
with patch.dict(os.environ, {
|
||||||
|
'LEGGEN_CONFIG_DIR': str(test_config_dir),
|
||||||
|
'LEGGEN_DATABASE_PATH': str(test_db_path)
|
||||||
|
}):
|
||||||
|
# Reset path manager to pick up environment variables
|
||||||
|
original_config = path_manager._config_dir
|
||||||
|
original_db = path_manager._database_path
|
||||||
|
|
||||||
|
try:
|
||||||
|
path_manager._config_dir = None
|
||||||
|
path_manager._database_path = None
|
||||||
|
|
||||||
|
config_dir = path_manager.get_config_dir()
|
||||||
|
db_path = path_manager.get_database_path()
|
||||||
|
|
||||||
|
assert config_dir == test_config_dir
|
||||||
|
assert db_path == test_db_path
|
||||||
|
finally:
|
||||||
|
path_manager._config_dir = original_config
|
||||||
|
path_manager._database_path = original_db
|
||||||
|
|
||||||
|
def test_explicit_path_setting(self):
|
||||||
|
"""Test explicitly setting paths."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
test_config_dir = Path(tmpdir) / "explicit-config"
|
||||||
|
test_db_path = Path(tmpdir) / "explicit.db"
|
||||||
|
|
||||||
|
# Save original paths
|
||||||
|
original_config = path_manager._config_dir
|
||||||
|
original_db = path_manager._database_path
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Set explicit paths
|
||||||
|
path_manager.set_config_dir(test_config_dir)
|
||||||
|
path_manager.set_database_path(test_db_path)
|
||||||
|
|
||||||
|
assert path_manager.get_config_dir() == test_config_dir
|
||||||
|
assert path_manager.get_database_path() == test_db_path
|
||||||
|
assert path_manager.get_config_file_path() == test_config_dir / "config.toml"
|
||||||
|
assert path_manager.get_auth_file_path() == test_config_dir / "auth.json"
|
||||||
|
finally:
|
||||||
|
# Restore original paths
|
||||||
|
path_manager._config_dir = original_config
|
||||||
|
path_manager._database_path = original_db
|
||||||
|
|
||||||
|
def test_database_operations_with_custom_path(self):
|
||||||
|
"""Test that database operations work with custom paths."""
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as tmp_file:
|
||||||
|
test_db_path = Path(tmp_file.name)
|
||||||
|
|
||||||
|
# Save original database path
|
||||||
|
original_db = path_manager._database_path
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Set custom database path
|
||||||
|
path_manager.set_database_path(test_db_path)
|
||||||
|
|
||||||
|
# Test database operations
|
||||||
|
ctx = MockContext()
|
||||||
|
balance = {
|
||||||
|
"account_id": "test-account",
|
||||||
|
"bank": "TEST_BANK",
|
||||||
|
"status": "active",
|
||||||
|
"iban": "TEST_IBAN",
|
||||||
|
"amount": 1000.0,
|
||||||
|
"currency": "EUR",
|
||||||
|
"type": "available",
|
||||||
|
"timestamp": "2023-01-01T00:00:00",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Persist balance
|
||||||
|
persist_balances(ctx, balance)
|
||||||
|
|
||||||
|
# Retrieve balances
|
||||||
|
balances = get_balances()
|
||||||
|
|
||||||
|
assert len(balances) == 1
|
||||||
|
assert balances[0]["account_id"] == "test-account"
|
||||||
|
assert balances[0]["amount"] == 1000.0
|
||||||
|
|
||||||
|
# Verify database file exists at custom location
|
||||||
|
assert test_db_path.exists()
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# Restore original path and cleanup
|
||||||
|
path_manager._database_path = original_db
|
||||||
|
if test_db_path.exists():
|
||||||
|
test_db_path.unlink()
|
||||||
|
|
||||||
|
def test_directory_creation(self):
|
||||||
|
"""Test that directories are created as needed."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
test_config_dir = Path(tmpdir) / "new" / "config" / "dir"
|
||||||
|
test_db_path = Path(tmpdir) / "new" / "db" / "dir" / "test.db"
|
||||||
|
|
||||||
|
# Save original paths
|
||||||
|
original_config = path_manager._config_dir
|
||||||
|
original_db = path_manager._database_path
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Set paths to non-existent directories
|
||||||
|
path_manager.set_config_dir(test_config_dir)
|
||||||
|
path_manager.set_database_path(test_db_path)
|
||||||
|
|
||||||
|
# Ensure directories are created
|
||||||
|
path_manager.ensure_config_dir_exists()
|
||||||
|
path_manager.ensure_database_dir_exists()
|
||||||
|
|
||||||
|
assert test_config_dir.exists()
|
||||||
|
assert test_db_path.parent.exists()
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# Restore original paths
|
||||||
|
path_manager._config_dir = original_config
|
||||||
|
path_manager._database_path = original_db
|
||||||
@@ -21,14 +21,18 @@ def temp_db_path():
|
|||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def mock_home_db_path(temp_db_path):
|
def mock_home_db_path(temp_db_path):
|
||||||
"""Mock the home database path to use temp file."""
|
"""Mock the database path to use temp file."""
|
||||||
config_dir = temp_db_path.parent / ".config" / "leggen"
|
from leggen.utils.paths import path_manager
|
||||||
config_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
db_file = config_dir / "leggen.db"
|
# Set the path manager to use the temporary database
|
||||||
|
original_database_path = path_manager._database_path
|
||||||
with patch("pathlib.Path.home") as mock_home:
|
path_manager.set_database_path(temp_db_path)
|
||||||
mock_home.return_value = temp_db_path.parent
|
|
||||||
yield db_file
|
try:
|
||||||
|
yield temp_db_path
|
||||||
|
finally:
|
||||||
|
# Restore original path
|
||||||
|
path_manager._database_path = original_database_path
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -90,18 +94,14 @@ class TestSQLiteDatabase:
|
|||||||
"""Test persisting transactions to database."""
|
"""Test persisting transactions to database."""
|
||||||
ctx = MockContext()
|
ctx = MockContext()
|
||||||
|
|
||||||
# Mock the database path
|
# Persist transactions
|
||||||
with patch("pathlib.Path.home") as mock_home:
|
new_transactions = sqlite_db.persist_transactions(
|
||||||
mock_home.return_value = mock_home_db_path.parent / ".."
|
ctx, "test-account-123", sample_transactions
|
||||||
|
)
|
||||||
|
|
||||||
# Persist transactions
|
# Should return all transactions as new
|
||||||
new_transactions = sqlite_db.persist_transactions(
|
assert len(new_transactions) == 2
|
||||||
ctx, "test-account-123", sample_transactions
|
assert new_transactions[0]["internalTransactionId"] == "txn-001"
|
||||||
)
|
|
||||||
|
|
||||||
# Should return all transactions as new
|
|
||||||
assert len(new_transactions) == 2
|
|
||||||
assert new_transactions[0]["internalTransactionId"] == "txn-001"
|
|
||||||
|
|
||||||
def test_persist_transactions_duplicates(
|
def test_persist_transactions_duplicates(
|
||||||
self, mock_home_db_path, sample_transactions
|
self, mock_home_db_path, sample_transactions
|
||||||
@@ -109,40 +109,34 @@ class TestSQLiteDatabase:
|
|||||||
"""Test handling duplicate transactions."""
|
"""Test handling duplicate transactions."""
|
||||||
ctx = MockContext()
|
ctx = MockContext()
|
||||||
|
|
||||||
with patch("pathlib.Path.home") as mock_home:
|
# Insert transactions twice
|
||||||
mock_home.return_value = mock_home_db_path.parent / ".."
|
new_transactions_1 = sqlite_db.persist_transactions(
|
||||||
|
ctx, "test-account-123", sample_transactions
|
||||||
|
)
|
||||||
|
new_transactions_2 = sqlite_db.persist_transactions(
|
||||||
|
ctx, "test-account-123", sample_transactions
|
||||||
|
)
|
||||||
|
|
||||||
# Insert transactions twice
|
# First time should return all as new
|
||||||
new_transactions_1 = sqlite_db.persist_transactions(
|
assert len(new_transactions_1) == 2
|
||||||
ctx, "test-account-123", sample_transactions
|
# Second time should also return all (INSERT OR REPLACE behavior with composite key)
|
||||||
)
|
assert len(new_transactions_2) == 2
|
||||||
new_transactions_2 = sqlite_db.persist_transactions(
|
|
||||||
ctx, "test-account-123", sample_transactions
|
|
||||||
)
|
|
||||||
|
|
||||||
# First time should return all as new
|
|
||||||
assert len(new_transactions_1) == 2
|
|
||||||
# Second time should also return all (INSERT OR REPLACE behavior with composite key)
|
|
||||||
assert len(new_transactions_2) == 2
|
|
||||||
|
|
||||||
def test_get_transactions_all(self, mock_home_db_path, sample_transactions):
|
def test_get_transactions_all(self, mock_home_db_path, sample_transactions):
|
||||||
"""Test retrieving all transactions."""
|
"""Test retrieving all transactions."""
|
||||||
ctx = MockContext()
|
ctx = MockContext()
|
||||||
|
|
||||||
with patch("pathlib.Path.home") as mock_home:
|
# Insert test data
|
||||||
mock_home.return_value = mock_home_db_path.parent / ".."
|
sqlite_db.persist_transactions(ctx, "test-account-123", sample_transactions)
|
||||||
|
|
||||||
# Insert test data
|
# Get all transactions
|
||||||
sqlite_db.persist_transactions(ctx, "test-account-123", sample_transactions)
|
transactions = sqlite_db.get_transactions()
|
||||||
|
|
||||||
# Get all transactions
|
assert len(transactions) == 2
|
||||||
transactions = sqlite_db.get_transactions()
|
assert (
|
||||||
|
transactions[0]["internalTransactionId"] == "txn-002"
|
||||||
assert len(transactions) == 2
|
) # Ordered by date DESC
|
||||||
assert (
|
assert transactions[1]["internalTransactionId"] == "txn-001"
|
||||||
transactions[0]["internalTransactionId"] == "txn-002"
|
|
||||||
) # Ordered by date DESC
|
|
||||||
assert transactions[1]["internalTransactionId"] == "txn-001"
|
|
||||||
|
|
||||||
def test_get_transactions_filtered_by_account(
|
def test_get_transactions_filtered_by_account(
|
||||||
self, mock_home_db_path, sample_transactions
|
self, mock_home_db_path, sample_transactions
|
||||||
|
|||||||
2
uv.lock
generated
2
uv.lock
generated
@@ -220,7 +220,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "leggen"
|
name = "leggen"
|
||||||
version = "2025.9.6"
|
version = "2025.9.9"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "apscheduler" },
|
{ name = "apscheduler" },
|
||||||
|
|||||||
Reference in New Issue
Block a user