Compare commits

..

9 Commits

Author SHA1 Message Date
Elisiário Couto
da6c7bbf3e chore(ci): Bump version to 2025.9.3 2025-09-10 01:21:49 +01:00
Elisiário Couto
90e58734ad chore(ci): Fix GitHub Actions syntax. 2025-09-10 01:21:39 +01:00
Elisiário Couto
03e16a9b54 chore(ci): Bump version to 2025.9.2 2025-09-10 01:12:08 +01:00
Elisiário Couto
53e08e8e4b fix(ci): Prevent duplicate Docker tags in GitHub Actions
- Add latest=false flavor to both backend and frontend jobs
- Fix confusion between latest and latest-frontend tags
- Ensure proper image separation in Docker registries
2025-09-10 01:11:38 +01:00
Elisiário Couto
84fe79b37b feat(docker): Add Docker containerization for React frontend
- Add production compose.yml using published ghcr.io images
- Rename compose.yml to compose.dev.yml for development
- Create config.example.toml configuration template
- Update README.md with Docker setup instructions
- Use ./data directory for configuration and database storage
- Separate development and production Docker workflows
2025-09-10 00:53:49 +01:00
Elisiário Couto
1a6578100a chore(ci): Bump version to 2025.9.1 2025-09-10 00:40:37 +01:00
Elisiário Couto
3270dc4585 chore: Improve AGENTS.md. 2025-09-10 00:39:46 +01:00
Elisiário Couto
8fabaf7b86 fix: handle duplicate transactionId values in migration
- Fix UNIQUE constraint violation in null transaction ID migration
- Generate unique IDs for records with duplicate transactionId values
- Use pattern: original_transactionId + '_' + 8_char_hex_suffix
- Successfully migrated records with duplicate IDs
- All transaction records now have valid internalTransactionId values

The migration now handles cases where multiple transactions have the same
transactionId in their raw data by generating unique identifiers.
2025-09-10 00:39:46 +01:00
Elisiário Couto
8006e5e1f6 refactor: remove unused hide_missing_ids functionality
- Remove hide_missing_ids parameter from all database functions
- Remove hide_missing_ids from API routes and query parameters
- Remove hide_missing_ids filtering logic from SQLite queries
- Update all tests to remove hide_missing_ids assertions
- Clean up codebase since internalTransactionId extraction is now fixed

This functionality was added as a workaround for missing internalTransactionId
values, but we've now fixed the root cause by properly extracting transaction
IDs from raw data during sync, making this workaround unnecessary.
2025-09-10 00:39:45 +01:00
16 changed files with 431 additions and 133 deletions

View File

@@ -45,87 +45,91 @@ jobs:
push-docker-backend: push-docker-backend:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub - name: Login to Docker Hub
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
username: elisiariocouto username: elisiariocouto
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Docker meta backend - name: Docker meta backend
id: meta-backend id: meta-backend
uses: docker/metadata-action@v5 uses: docker/metadata-action@v5
with: with:
# list of Docker images to use as base name for tags flavor: |
images: | latest=false
elisiariocouto/leggen # list of Docker images to use as base name for tags
ghcr.io/elisiariocouto/leggen images: |
# generate Docker tags based on the following events/attributes elisiariocouto/leggen
tags: | ghcr.io/elisiariocouto/leggen
type=ref,event=tag # generate Docker tags based on the following events/attributes
type=semver,pattern={{version}} tags: |
type=semver,pattern={{major}}.{{minor}} type=ref,event=tag
type=raw,value=latest type=semver,pattern={{version}}
- name: Build and push backend type=semver,pattern={{major}}.{{minor}}
uses: docker/build-push-action@v5 type=raw,value=latest
with: - name: Build and push backend
context: . uses: docker/build-push-action@v5
file: ./Dockerfile with:
platforms: linux/amd64,linux/arm64 context: .
push: true file: ./Dockerfile
tags: ${{ steps.meta-backend.outputs.tags }} platforms: linux/amd64,linux/arm64
labels: ${{ steps.meta-backend.outputs.labels }} push: true
tags: ${{ steps.meta-backend.outputs.tags }}
labels: ${{ steps.meta-backend.outputs.labels }}
push-docker-frontend: push-docker-frontend:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub - name: Login to Docker Hub
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
username: elisiariocouto username: elisiariocouto
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Docker meta frontend - name: Docker meta frontend
id: meta-frontend id: meta-frontend
uses: docker/metadata-action@v5 uses: docker/metadata-action@v5
with: with:
# list of Docker images to use as base name for tags flavor: |
images: | latest=false
elisiariocouto/leggen # list of Docker images to use as base name for tags
ghcr.io/elisiariocouto/leggen images: |
# generate Docker tags based on the following events/attributes elisiariocouto/leggen
tags: | ghcr.io/elisiariocouto/leggen
type=ref,event=tag,suffix=-frontend # generate Docker tags based on the following events/attributes
type=semver,pattern={{version}},suffix=-frontend tags: |
type=semver,pattern={{major}}.{{minor}},suffix=-frontend type=ref,event=tag,suffix=-frontend
type=raw,value=latest-frontend type=semver,pattern={{version}},suffix=-frontend
- name: Build and push frontend type=semver,pattern={{major}}.{{minor}},suffix=-frontend
uses: docker/build-push-action@v5 type=raw,value=latest-frontend
with: - name: Build and push frontend
context: ./frontend uses: docker/build-push-action@v5
file: ./frontend/Dockerfile with:
platforms: linux/amd64,linux/arm64 context: ./frontend
push: true file: ./frontend/Dockerfile
tags: ${{ steps.meta-frontend.outputs.tags }} platforms: linux/amd64,linux/arm64
labels: ${{ steps.meta-frontend.outputs.labels }} push: true
tags: ${{ steps.meta-frontend.outputs.tags }}
labels: ${{ steps.meta-frontend.outputs.labels }}

View File

@@ -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

View File

@@ -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

View File

@@ -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
View 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"

View File

@@ -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
View 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"]

View File

@@ -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"

View File

@@ -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]

View File

@@ -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,

View File

@@ -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

View File

@@ -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)

View File

@@ -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"

View File

@@ -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(

View File

@@ -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
View File

@@ -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" },