mirror of
https://github.com/elisiariocouto/leggen.git
synced 2025-12-14 16:02:25 +00:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da6c7bbf3e | ||
|
|
90e58734ad | ||
|
|
03e16a9b54 | ||
|
|
53e08e8e4b | ||
|
|
84fe79b37b | ||
|
|
1a6578100a | ||
|
|
3270dc4585 | ||
|
|
8fabaf7b86 | ||
|
|
8006e5e1f6 |
4
.github/workflows/release.yml
vendored
4
.github/workflows/release.yml
vendored
@@ -66,6 +66,8 @@ jobs:
|
|||||||
id: meta-backend
|
id: meta-backend
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
|
flavor: |
|
||||||
|
latest=false
|
||||||
# list of Docker images to use as base name for tags
|
# list of Docker images to use as base name for tags
|
||||||
images: |
|
images: |
|
||||||
elisiariocouto/leggen
|
elisiariocouto/leggen
|
||||||
@@ -110,6 +112,8 @@ jobs:
|
|||||||
id: meta-frontend
|
id: meta-frontend
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
|
flavor: |
|
||||||
|
latest=false
|
||||||
# list of Docker images to use as base name for tags
|
# list of Docker images to use as base name for tags
|
||||||
images: |
|
images: |
|
||||||
elisiariocouto/leggen
|
elisiariocouto/leggen
|
||||||
|
|||||||
@@ -38,4 +38,5 @@
|
|||||||
### General
|
### General
|
||||||
- **Formatting**: ruff for Python, ESLint for TypeScript
|
- **Formatting**: ruff for Python, ESLint for TypeScript
|
||||||
- **Commits**: Use conventional commits, run pre-commit hooks before pushing
|
- **Commits**: Use conventional commits, run pre-commit hooks before pushing
|
||||||
|
- Avoid including specific numbers, counts, or data-dependent information that may become outdated
|
||||||
- **Security**: Never log sensitive data, use environment variables for secrets
|
- **Security**: Never log sensitive data, use environment variables for secrets
|
||||||
|
|||||||
78
CHANGELOG.md
78
CHANGELOG.md
@@ -1,4 +1,82 @@
|
|||||||
|
|
||||||
|
## 2025.9.3 (2025/09/10)
|
||||||
|
|
||||||
|
### Miscellaneous Tasks
|
||||||
|
|
||||||
|
- **ci:** Fix GitHub Actions syntax. ([90e58734](https://github.com/elisiariocouto/leggen/commit/90e58734adb9638efd695719321874658529561d))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## 2025.9.3 (2025/09/10)
|
||||||
|
|
||||||
|
### Miscellaneous Tasks
|
||||||
|
|
||||||
|
- **ci:** Fix GitHub Actions syntax. ([90e58734](https://github.com/elisiariocouto/leggen/commit/90e58734adb9638efd695719321874658529561d))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## 2025.9.2 (2025/09/10)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **ci:** Prevent duplicate Docker tags in GitHub Actions ([53e08e8e](https://github.com/elisiariocouto/leggen/commit/53e08e8e4b909b4895b5a447cfbce515893d31a5))
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **docker:** Add Docker containerization for React frontend ([84fe79b3](https://github.com/elisiariocouto/leggen/commit/84fe79b37b4f154fa0758f8d037cdba0d166dd3b))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## 2025.9.2 (2025/09/10)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **ci:** Prevent duplicate Docker tags in GitHub Actions ([53e08e8e](https://github.com/elisiariocouto/leggen/commit/53e08e8e4b909b4895b5a447cfbce515893d31a5))
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **docker:** Add Docker containerization for React frontend ([84fe79b3](https://github.com/elisiariocouto/leggen/commit/84fe79b37b4f154fa0758f8d037cdba0d166dd3b))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## 2025.9.1 (2025/09/09)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Handle duplicate transactionId values in migration ([8fabaf7b](https://github.com/elisiariocouto/leggen/commit/8fabaf7b86fde921c61266568ecb0403d3102671))
|
||||||
|
|
||||||
|
|
||||||
|
### Miscellaneous Tasks
|
||||||
|
|
||||||
|
- Improve AGENTS.md. ([3270dc45](https://github.com/elisiariocouto/leggen/commit/3270dc4585e6b33d55aef0deecd849753d36fa74))
|
||||||
|
|
||||||
|
|
||||||
|
### Refactor
|
||||||
|
|
||||||
|
- Remove unused hide_missing_ids functionality ([8006e5e1](https://github.com/elisiariocouto/leggen/commit/8006e5e1f6373aae39d3c38068d694e142bc85a5))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## 2025.9.1 (2025/09/09)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Handle duplicate transactionId values in migration ([8fabaf7b](https://github.com/elisiariocouto/leggen/commit/8fabaf7b86fde921c61266568ecb0403d3102671))
|
||||||
|
|
||||||
|
|
||||||
|
### Miscellaneous Tasks
|
||||||
|
|
||||||
|
- Improve AGENTS.md. ([3270dc45](https://github.com/elisiariocouto/leggen/commit/3270dc4585e6b33d55aef0deecd849753d36fa74))
|
||||||
|
|
||||||
|
|
||||||
|
### Refactor
|
||||||
|
|
||||||
|
- Remove unused hide_missing_ids functionality ([8006e5e1](https://github.com/elisiariocouto/leggen/commit/8006e5e1f6373aae39d3c38068d694e142bc85a5))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## 2025.9.0 (2025/09/09)
|
## 2025.9.0 (2025/09/09)
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|||||||
52
README.md
52
README.md
@@ -64,8 +64,8 @@ git clone https://github.com/elisiariocouto/leggen.git
|
|||||||
cd leggen
|
cd leggen
|
||||||
|
|
||||||
# Create your configuration
|
# Create your configuration
|
||||||
mkdir -p leggen && cp config.example.toml leggen/config.toml
|
mkdir -p data && cp config.example.toml data/config.toml
|
||||||
# Edit leggen/config.toml with your GoCardless credentials
|
# Edit data/config.toml with your GoCardless credentials
|
||||||
|
|
||||||
# Start all services (frontend + backend)
|
# Start all services (frontend + backend)
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
@@ -74,6 +74,31 @@ docker compose up -d
|
|||||||
# API is available at http://localhost:8000
|
# API is available at http://localhost:8000
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### Production Deployment
|
||||||
|
|
||||||
|
For production deployment using published Docker images:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone the repository
|
||||||
|
git clone https://github.com/elisiariocouto/leggen.git
|
||||||
|
cd leggen
|
||||||
|
|
||||||
|
# Create your configuration
|
||||||
|
mkdir -p data && cp config.example.toml data/config.toml
|
||||||
|
# Edit data/config.toml with your GoCardless credentials
|
||||||
|
|
||||||
|
# Start production services
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
# Access the web interface at http://localhost:3000
|
||||||
|
# API is available at http://localhost:8000
|
||||||
|
```
|
||||||
|
|
||||||
|
### Development vs Production
|
||||||
|
|
||||||
|
- **Development**: Use `docker compose -f compose.dev.yml up -d` (builds from source)
|
||||||
|
- **Production**: Use `docker compose up -d` (uses published images)
|
||||||
|
|
||||||
#### Option 2: Local Development
|
#### Option 2: Local Development
|
||||||
For development or local installation:
|
For development or local installation:
|
||||||
|
|
||||||
@@ -90,7 +115,7 @@ uv run leggen --help
|
|||||||
|
|
||||||
### Configuration
|
### Configuration
|
||||||
|
|
||||||
Create a configuration file at `~/.config/leggen/config.toml`:
|
Create a configuration file at `./data/config.toml` (for Docker) or `~/.config/leggen/config.toml` (for local development):
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
[gocardless]
|
[gocardless]
|
||||||
@@ -188,8 +213,25 @@ leggen status
|
|||||||
|
|
||||||
### Docker Usage
|
### Docker Usage
|
||||||
|
|
||||||
|
#### Development (build from source)
|
||||||
```bash
|
```bash
|
||||||
# Start all services (frontend + backend)
|
# Start development services
|
||||||
|
docker compose -f compose.dev.yml up -d
|
||||||
|
|
||||||
|
# View service status
|
||||||
|
docker compose -f compose.dev.yml ps
|
||||||
|
|
||||||
|
# Check logs
|
||||||
|
docker compose -f compose.dev.yml logs frontend
|
||||||
|
docker compose -f compose.dev.yml logs leggend
|
||||||
|
|
||||||
|
# Stop development services
|
||||||
|
docker compose -f compose.dev.yml down
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Production (use published images)
|
||||||
|
```bash
|
||||||
|
# Start production services
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
|
|
||||||
# View service status
|
# View service status
|
||||||
@@ -202,7 +244,7 @@ docker compose logs leggend
|
|||||||
# Access the web interface at http://localhost:3000
|
# Access the web interface at http://localhost:3000
|
||||||
# API documentation at http://localhost:8000/docs
|
# API documentation at http://localhost:8000/docs
|
||||||
|
|
||||||
# Stop all services
|
# Stop production services
|
||||||
docker compose down
|
docker compose down
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
25
compose.dev.yml
Normal file
25
compose.dev.yml
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
services:
|
||||||
|
# React frontend service
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
restart: "unless-stopped"
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:3000:80"
|
||||||
|
environment:
|
||||||
|
- API_BACKEND_URL=${API_BACKEND_URL:-http://leggend:8000}
|
||||||
|
depends_on:
|
||||||
|
leggend:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
# FastAPI backend service
|
||||||
|
leggend:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
restart: "unless-stopped"
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:8000:8000"
|
||||||
|
volumes:
|
||||||
|
- "./data:/root/.config/leggen"
|
||||||
12
compose.yml
12
compose.yml
@@ -1,25 +1,19 @@
|
|||||||
services:
|
services:
|
||||||
# React frontend service
|
# React frontend service
|
||||||
frontend:
|
frontend:
|
||||||
build:
|
image: ghcr.io/elisiariocouto/leggen:latest-frontend
|
||||||
context: ./frontend
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
restart: "unless-stopped"
|
restart: "unless-stopped"
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:3000:80"
|
- "127.0.0.1:3000:80"
|
||||||
environment:
|
|
||||||
- API_BACKEND_URL=${API_BACKEND_URL:-http://leggend:8000}
|
|
||||||
depends_on:
|
depends_on:
|
||||||
leggend:
|
leggend:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
||||||
# FastAPI backend service
|
# FastAPI backend service
|
||||||
leggend:
|
leggend:
|
||||||
build:
|
image: ghcr.io/elisiariocouto/leggen:latest
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
restart: "unless-stopped"
|
restart: "unless-stopped"
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:8000:8000"
|
- "127.0.0.1:8000:8000"
|
||||||
volumes:
|
volumes:
|
||||||
- "./data:/root/.config/leggen"
|
- "./data:/root/.config/leggen" # Configuration and database directory
|
||||||
|
|||||||
30
config.example.toml
Normal file
30
config.example.toml
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
[gocardless]
|
||||||
|
key = "your-api-key"
|
||||||
|
secret = "your-secret-key"
|
||||||
|
url = "https://bankaccountdata.gocardless.com/api/v2"
|
||||||
|
|
||||||
|
[database]
|
||||||
|
sqlite = true
|
||||||
|
|
||||||
|
# Optional: Background sync scheduling
|
||||||
|
[scheduler.sync]
|
||||||
|
enabled = true
|
||||||
|
hour = 3 # 3 AM
|
||||||
|
minute = 0
|
||||||
|
# cron = "0 3 * * *" # Alternative: use cron expression
|
||||||
|
|
||||||
|
# Optional: Discord notifications
|
||||||
|
[notifications.discord]
|
||||||
|
webhook = "https://discord.com/api/webhooks/..."
|
||||||
|
enabled = true
|
||||||
|
|
||||||
|
# Optional: Telegram notifications
|
||||||
|
[notifications.telegram]
|
||||||
|
token = "your-bot-token"
|
||||||
|
chat_id = 12345
|
||||||
|
enabled = true
|
||||||
|
|
||||||
|
# Optional: Transaction filters for notifications
|
||||||
|
[filters]
|
||||||
|
case-insensitive = ["salary", "utility"]
|
||||||
|
case-sensitive = ["SpecificStore"]
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "VITE_API_URL=http://localhost:8000/api/v1 vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
|
|||||||
@@ -210,7 +210,6 @@ def get_transactions(
|
|||||||
min_amount=None,
|
min_amount=None,
|
||||||
max_amount=None,
|
max_amount=None,
|
||||||
search=None,
|
search=None,
|
||||||
hide_missing_ids=True,
|
|
||||||
):
|
):
|
||||||
"""Get transactions from SQLite database with optional filtering"""
|
"""Get transactions from SQLite database with optional filtering"""
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -250,11 +249,6 @@ def get_transactions(
|
|||||||
query += " AND description LIKE ?"
|
query += " AND description LIKE ?"
|
||||||
params.append(f"%{search}%")
|
params.append(f"%{search}%")
|
||||||
|
|
||||||
if hide_missing_ids:
|
|
||||||
query += (
|
|
||||||
" AND internalTransactionId IS NOT NULL AND internalTransactionId != ''"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Add ordering and pagination
|
# Add ordering and pagination
|
||||||
query += " ORDER BY transactionDate DESC"
|
query += " ORDER BY transactionDate DESC"
|
||||||
|
|
||||||
@@ -403,11 +397,6 @@ def get_transaction_count(account_id=None, **filters):
|
|||||||
query += " AND description LIKE ?"
|
query += " AND description LIKE ?"
|
||||||
params.append(f"%{filters['search']}%")
|
params.append(f"%{filters['search']}%")
|
||||||
|
|
||||||
if filters.get("hide_missing_ids", True):
|
|
||||||
query += (
|
|
||||||
" AND internalTransactionId IS NOT NULL AND internalTransactionId != ''"
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
cursor.execute(query, params)
|
cursor.execute(query, params)
|
||||||
count = cursor.fetchone()[0]
|
count = cursor.fetchone()[0]
|
||||||
|
|||||||
@@ -69,8 +69,13 @@ def save_transactions(ctx: click.Context, account: str) -> list:
|
|||||||
",".join(transaction.get("remittanceInformationUnstructuredArray", [])),
|
",".join(transaction.get("remittanceInformationUnstructuredArray", [])),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Extract transaction ID, using transactionId as fallback when internalTransactionId is missing
|
||||||
|
transaction_id = transaction.get("internalTransactionId") or transaction.get(
|
||||||
|
"transactionId"
|
||||||
|
)
|
||||||
|
|
||||||
t = {
|
t = {
|
||||||
"internalTransactionId": transaction.get("internalTransactionId"),
|
"internalTransactionId": transaction_id,
|
||||||
"institutionId": account_info["institution_id"],
|
"institutionId": account_info["institution_id"],
|
||||||
"iban": account_info.get("iban", "N/A"),
|
"iban": account_info.get("iban", "N/A"),
|
||||||
"transactionDate": min_date,
|
"transactionDate": min_date,
|
||||||
@@ -105,8 +110,13 @@ def save_transactions(ctx: click.Context, account: str) -> list:
|
|||||||
",".join(transaction.get("remittanceInformationUnstructuredArray", [])),
|
",".join(transaction.get("remittanceInformationUnstructuredArray", [])),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Extract transaction ID, using transactionId as fallback when internalTransactionId is missing
|
||||||
|
transaction_id = transaction.get("internalTransactionId") or transaction.get(
|
||||||
|
"transactionId"
|
||||||
|
)
|
||||||
|
|
||||||
t = {
|
t = {
|
||||||
"internalTransactionId": transaction.get("internalTransactionId"),
|
"internalTransactionId": transaction_id,
|
||||||
"institutionId": account_info["institution_id"],
|
"institutionId": account_info["institution_id"],
|
||||||
"iban": account_info.get("iban", "N/A"),
|
"iban": account_info.get("iban", "N/A"),
|
||||||
"transactionDate": min_date,
|
"transactionDate": min_date,
|
||||||
|
|||||||
@@ -18,9 +18,6 @@ async def get_all_transactions(
|
|||||||
summary_only: bool = Query(
|
summary_only: bool = Query(
|
||||||
default=True, description="Return transaction summaries only"
|
default=True, description="Return transaction summaries only"
|
||||||
),
|
),
|
||||||
hide_missing_ids: bool = Query(
|
|
||||||
default=True, description="Hide transactions without internalTransactionId"
|
|
||||||
),
|
|
||||||
date_from: Optional[str] = Query(
|
date_from: Optional[str] = Query(
|
||||||
default=None, description="Filter from date (YYYY-MM-DD)"
|
default=None, description="Filter from date (YYYY-MM-DD)"
|
||||||
),
|
),
|
||||||
@@ -50,7 +47,6 @@ async def get_all_transactions(
|
|||||||
min_amount=min_amount,
|
min_amount=min_amount,
|
||||||
max_amount=max_amount,
|
max_amount=max_amount,
|
||||||
search=search,
|
search=search,
|
||||||
hide_missing_ids=hide_missing_ids,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get total count for pagination info (respecting the same filters)
|
# Get total count for pagination info (respecting the same filters)
|
||||||
@@ -61,7 +57,6 @@ async def get_all_transactions(
|
|||||||
min_amount=min_amount,
|
min_amount=min_amount,
|
||||||
max_amount=max_amount,
|
max_amount=max_amount,
|
||||||
search=search,
|
search=search,
|
||||||
hide_missing_ids=hide_missing_ids,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get total count for pagination info
|
# Get total count for pagination info
|
||||||
@@ -126,9 +121,6 @@ async def get_all_transactions(
|
|||||||
async def get_transaction_stats(
|
async def get_transaction_stats(
|
||||||
days: int = Query(default=30, description="Number of days to include in stats"),
|
days: int = Query(default=30, description="Number of days to include in stats"),
|
||||||
account_id: Optional[str] = Query(default=None, description="Filter by account ID"),
|
account_id: Optional[str] = Query(default=None, description="Filter by account ID"),
|
||||||
hide_missing_ids: bool = Query(
|
|
||||||
default=True, description="Hide transactions without internalTransactionId"
|
|
||||||
),
|
|
||||||
) -> APIResponse:
|
) -> APIResponse:
|
||||||
"""Get transaction statistics for the last N days from database"""
|
"""Get transaction statistics for the last N days from database"""
|
||||||
try:
|
try:
|
||||||
@@ -146,7 +138,6 @@ async def get_transaction_stats(
|
|||||||
date_from=date_from,
|
date_from=date_from,
|
||||||
date_to=date_to,
|
date_to=date_to,
|
||||||
limit=None, # Get all matching transactions for stats
|
limit=None, # Get all matching transactions for stats
|
||||||
hide_missing_ids=hide_missing_ids,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Calculate stats
|
# Calculate stats
|
||||||
|
|||||||
@@ -93,8 +93,13 @@ class DatabaseService:
|
|||||||
",".join(transaction.get("remittanceInformationUnstructuredArray", [])),
|
",".join(transaction.get("remittanceInformationUnstructuredArray", [])),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Extract transaction ID, using transactionId as fallback when internalTransactionId is missing
|
||||||
|
transaction_id = transaction.get("internalTransactionId") or transaction.get(
|
||||||
|
"transactionId"
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"internalTransactionId": transaction.get("internalTransactionId"),
|
"internalTransactionId": transaction_id,
|
||||||
"institutionId": account_info["institution_id"],
|
"institutionId": account_info["institution_id"],
|
||||||
"iban": account_info.get("iban", "N/A"),
|
"iban": account_info.get("iban", "N/A"),
|
||||||
"transactionDate": min_date,
|
"transactionDate": min_date,
|
||||||
@@ -116,7 +121,6 @@ class DatabaseService:
|
|||||||
min_amount: Optional[float] = None,
|
min_amount: Optional[float] = None,
|
||||||
max_amount: Optional[float] = None,
|
max_amount: Optional[float] = None,
|
||||||
search: Optional[str] = None,
|
search: Optional[str] = None,
|
||||||
hide_missing_ids: bool = True,
|
|
||||||
) -> List[Dict[str, Any]]:
|
) -> List[Dict[str, Any]]:
|
||||||
"""Get transactions from SQLite database"""
|
"""Get transactions from SQLite database"""
|
||||||
if not self.sqlite_enabled:
|
if not self.sqlite_enabled:
|
||||||
@@ -133,7 +137,6 @@ class DatabaseService:
|
|||||||
min_amount=min_amount,
|
min_amount=min_amount,
|
||||||
max_amount=max_amount,
|
max_amount=max_amount,
|
||||||
search=search,
|
search=search,
|
||||||
hide_missing_ids=hide_missing_ids,
|
|
||||||
)
|
)
|
||||||
logger.debug(f"Retrieved {len(transactions)} transactions from database")
|
logger.debug(f"Retrieved {len(transactions)} transactions from database")
|
||||||
return transactions
|
return transactions
|
||||||
@@ -149,7 +152,6 @@ class DatabaseService:
|
|||||||
min_amount: Optional[float] = None,
|
min_amount: Optional[float] = None,
|
||||||
max_amount: Optional[float] = None,
|
max_amount: Optional[float] = None,
|
||||||
search: Optional[str] = None,
|
search: Optional[str] = None,
|
||||||
hide_missing_ids: bool = True,
|
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Get total count of transactions from SQLite database"""
|
"""Get total count of transactions from SQLite database"""
|
||||||
if not self.sqlite_enabled:
|
if not self.sqlite_enabled:
|
||||||
@@ -166,9 +168,7 @@ class DatabaseService:
|
|||||||
# Remove None values
|
# Remove None values
|
||||||
filters = {k: v for k, v in filters.items() if v is not None}
|
filters = {k: v for k, v in filters.items() if v is not None}
|
||||||
|
|
||||||
count = sqlite_db.get_transaction_count(
|
count = sqlite_db.get_transaction_count(account_id=account_id, **filters)
|
||||||
account_id=account_id, hide_missing_ids=hide_missing_ids, **filters
|
|
||||||
)
|
|
||||||
logger.debug(f"Total transaction count: {count}")
|
logger.debug(f"Total transaction count: {count}")
|
||||||
return count
|
return count
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -259,6 +259,7 @@ class DatabaseService:
|
|||||||
return
|
return
|
||||||
|
|
||||||
await self._migrate_balance_timestamps_if_needed()
|
await self._migrate_balance_timestamps_if_needed()
|
||||||
|
await self._migrate_null_transaction_ids_if_needed()
|
||||||
|
|
||||||
async def _migrate_balance_timestamps_if_needed(self):
|
async def _migrate_balance_timestamps_if_needed(self):
|
||||||
"""Check and migrate balance timestamps if needed"""
|
"""Check and migrate balance timestamps if needed"""
|
||||||
@@ -379,6 +380,145 @@ class DatabaseService:
|
|||||||
logger.error(f"Balance timestamp migration failed: {e}")
|
logger.error(f"Balance timestamp migration failed: {e}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
async def _migrate_null_transaction_ids_if_needed(self):
|
||||||
|
"""Check and migrate null transaction IDs if needed"""
|
||||||
|
try:
|
||||||
|
if await self._check_null_transaction_ids_migration_needed():
|
||||||
|
logger.info("Null transaction IDs migration needed, starting...")
|
||||||
|
await self._migrate_null_transaction_ids()
|
||||||
|
logger.info("Null transaction IDs migration completed")
|
||||||
|
else:
|
||||||
|
logger.info("No null transaction IDs found to migrate")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Null transaction IDs migration failed: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def _check_null_transaction_ids_migration_needed(self) -> bool:
|
||||||
|
"""Check if null transaction IDs need migration"""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
db_path = Path.home() / ".config" / "leggen" / "leggen.db"
|
||||||
|
if not db_path.exists():
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(str(db_path))
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Check for transactions with null or empty internalTransactionId
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM transactions
|
||||||
|
WHERE (internalTransactionId IS NULL OR internalTransactionId = '')
|
||||||
|
AND json_extract(rawTransaction, '$.transactionId') IS NOT NULL
|
||||||
|
""")
|
||||||
|
|
||||||
|
count = cursor.fetchone()[0]
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return count > 0
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to check null transaction IDs migration status: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def _migrate_null_transaction_ids(self):
|
||||||
|
"""Populate null internalTransactionId fields using transactionId from raw data"""
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
db_path = Path.home() / ".config" / "leggen" / "leggen.db"
|
||||||
|
if not db_path.exists():
|
||||||
|
logger.warning("Database file not found, skipping migration")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(str(db_path))
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Get all transactions with null/empty internalTransactionId but valid transactionId in raw data
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT rowid, json_extract(rawTransaction, '$.transactionId') as transactionId
|
||||||
|
FROM transactions
|
||||||
|
WHERE (internalTransactionId IS NULL OR internalTransactionId = '')
|
||||||
|
AND json_extract(rawTransaction, '$.transactionId') IS NOT NULL
|
||||||
|
ORDER BY rowid
|
||||||
|
""")
|
||||||
|
|
||||||
|
null_records = cursor.fetchall()
|
||||||
|
total_records = len(null_records)
|
||||||
|
|
||||||
|
if total_records == 0:
|
||||||
|
logger.info("No null transaction IDs found to migrate")
|
||||||
|
conn.close()
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Migrating {total_records} transaction records with null internalTransactionId"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update in batches
|
||||||
|
batch_size = 100
|
||||||
|
migrated_count = 0
|
||||||
|
skipped_duplicates = 0
|
||||||
|
|
||||||
|
for i in range(0, total_records, batch_size):
|
||||||
|
batch = null_records[i : i + batch_size]
|
||||||
|
|
||||||
|
for rowid, transaction_id in batch:
|
||||||
|
try:
|
||||||
|
# Check if this transactionId is already used by another record
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT COUNT(*) FROM transactions WHERE internalTransactionId = ?",
|
||||||
|
(str(transaction_id),),
|
||||||
|
)
|
||||||
|
existing_count = cursor.fetchone()[0]
|
||||||
|
|
||||||
|
if existing_count > 0:
|
||||||
|
# Generate a unique ID to avoid constraint violation
|
||||||
|
unique_id = f"{str(transaction_id)}_{uuid.uuid4().hex[:8]}"
|
||||||
|
logger.debug(
|
||||||
|
f"Generated unique ID for duplicate transactionId: {unique_id}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Use the original transactionId
|
||||||
|
unique_id = str(transaction_id)
|
||||||
|
|
||||||
|
# Update the record
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
UPDATE transactions
|
||||||
|
SET internalTransactionId = ?
|
||||||
|
WHERE rowid = ?
|
||||||
|
""",
|
||||||
|
(unique_id, rowid),
|
||||||
|
)
|
||||||
|
|
||||||
|
migrated_count += 1
|
||||||
|
|
||||||
|
if migrated_count % 100 == 0:
|
||||||
|
logger.info(
|
||||||
|
f"Migrated {migrated_count}/{total_records} transaction records"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to migrate record {rowid}: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Commit batch
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
logger.info(f"Successfully migrated {migrated_count} transaction records")
|
||||||
|
if skipped_duplicates > 0:
|
||||||
|
logger.info(
|
||||||
|
f"Generated unique IDs for {skipped_duplicates} duplicate transactionIds"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Null transaction IDs migration failed: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
def _unix_to_datetime_string(self, unix_timestamp: float) -> str:
|
def _unix_to_datetime_string(self, unix_timestamp: float) -> str:
|
||||||
"""Convert Unix timestamp to datetime string"""
|
"""Convert Unix timestamp to datetime string"""
|
||||||
dt = datetime.fromtimestamp(unix_timestamp)
|
dt = datetime.fromtimestamp(unix_timestamp)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "leggen"
|
name = "leggen"
|
||||||
version = "2025.9.0"
|
version = "2025.9.3"
|
||||||
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"
|
||||||
|
|||||||
@@ -166,7 +166,6 @@ class TestTransactionsAPI:
|
|||||||
min_amount=-50.0,
|
min_amount=-50.0,
|
||||||
max_amount=0.0,
|
max_amount=0.0,
|
||||||
search="Coffee",
|
search="Coffee",
|
||||||
hide_missing_ids=True,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_get_transactions_empty_result(
|
def test_get_transactions_empty_result(
|
||||||
|
|||||||
@@ -99,7 +99,6 @@ class TestDatabaseService:
|
|||||||
min_amount=None,
|
min_amount=None,
|
||||||
max_amount=None,
|
max_amount=None,
|
||||||
search=None,
|
search=None,
|
||||||
hide_missing_ids=True,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
async def test_get_transactions_from_db_with_filters(
|
async def test_get_transactions_from_db_with_filters(
|
||||||
@@ -130,7 +129,6 @@ class TestDatabaseService:
|
|||||||
min_amount=-50.0,
|
min_amount=-50.0,
|
||||||
max_amount=0.0,
|
max_amount=0.0,
|
||||||
search="Coffee",
|
search="Coffee",
|
||||||
hide_missing_ids=True,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
async def test_get_transactions_from_db_sqlite_disabled(self, database_service):
|
async def test_get_transactions_from_db_sqlite_disabled(self, database_service):
|
||||||
@@ -160,9 +158,7 @@ class TestDatabaseService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert result == 42
|
assert result == 42
|
||||||
mock_get_count.assert_called_once_with(
|
mock_get_count.assert_called_once_with(account_id="test-account-123")
|
||||||
account_id="test-account-123", hide_missing_ids=True
|
|
||||||
)
|
|
||||||
|
|
||||||
async def test_get_transaction_count_from_db_with_filters(self, database_service):
|
async def test_get_transaction_count_from_db_with_filters(self, database_service):
|
||||||
"""Test getting transaction count with filters."""
|
"""Test getting transaction count with filters."""
|
||||||
@@ -182,7 +178,6 @@ class TestDatabaseService:
|
|||||||
date_from="2025-09-01",
|
date_from="2025-09-01",
|
||||||
min_amount=-100.0,
|
min_amount=-100.0,
|
||||||
search="Coffee",
|
search="Coffee",
|
||||||
hide_missing_ids=True,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
async def test_get_transaction_count_from_db_sqlite_disabled(
|
async def test_get_transaction_count_from_db_sqlite_disabled(
|
||||||
|
|||||||
2
uv.lock
generated
2
uv.lock
generated
@@ -222,7 +222,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "leggen"
|
name = "leggen"
|
||||||
version = "2025.9.0"
|
version = "2025.9.3"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "apscheduler" },
|
{ name = "apscheduler" },
|
||||||
|
|||||||
Reference in New Issue
Block a user