Compare commits

..

30 Commits

Author SHA1 Message Date
Elisiário Couto
0205e5be0d chore(ci): Bump version to 2025.10.0 2025-10-01 11:30:48 +01:00
Elisiário Couto
ca7968cc3c fix(gocardless): Increase timeout to 30 seconds, some requests take some time. 2025-10-01 11:05:52 +01:00
Elisiário Couto
e6da6ee9ab chore(ci): Bump version to 2025.9.26 2025-09-30 14:09:57 +01:00
Elisiário Couto
8802d24789 debug: Log different sets of GoCardless rate limits. 2025-09-30 14:07:10 +01:00
Elisiário Couto
d3954f079b chore(ci): Bump version to 2025.9.25 2025-09-30 10:49:00 +01:00
Elisiário Couto
0b68038739 Lock dependencies before commiting next version. 2025-09-30 10:48:49 +01:00
Elisiário Couto
d36568da54 chore: Log more rate limit headers. 2025-09-30 10:46:13 +01:00
Elisiário Couto
473f126d3e feat(frontend): Add ability to list backups and create a backup on demand. 2025-09-28 23:23:44 +01:00
Elisiário Couto
222bb2ec64 Lint and reformat. 2025-09-28 23:23:44 +01:00
Elisiário Couto
22ec0e36b1 fix(api): Fix S3 backup path-style configuration and improve UX.
- Fix critical S3 client configuration bug for path-style addressing
- Add toast notifications for better user feedback on S3 config operations
- Set up Toaster component in root layout for app-wide notifications
- Clean up unused imports in test files

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-28 23:23:44 +01:00
copilot-swe-agent[bot]
0122913052 feat(frontend): Add S3 backup UI and complete backup functionality
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-28 23:23:44 +01:00
copilot-swe-agent[bot]
7f2a4634c5 feat(api): Add S3 backup functionality to backend
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-28 23:23:44 +01:00
Elisiário Couto
704c3d4cb7 chore(ci): Bump version to 2025.9.24 2025-09-25 12:10:40 +01:00
Elisiário Couto
ef7c026db9 feat(frontend): Add comprehensive bank account management system.
- Add drawer-based bank account connection flow with country/bank selection
- Create bank connection success page with redirect handling
- Add bank connections status card showing all requisitions and their states
- Move account management actions to appropriate sections (add to connections, edit in accounts)
- Implement proper delete functionality for bank connections via GoCardless API
- Add proper TypeScript types for all bank-related data structures
- Improve error handling for bank connection operations with specific HTTP status codes
- Remove transaction data when disconnecting accounts while preserving history

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-25 11:31:15 +01:00
Elisiário Couto
dc3522220a chore(ci): Bump version to 2025.9.23 2025-09-25 00:34:45 +01:00
Elisiário Couto
1693b3a50d Resolve test issues. 2025-09-25 00:02:42 +01:00
Elisiário Couto
460c5af6ea fix: Correct sync trigger types from manual to scheduled/retry.
Fixed scheduled syncs being incorrectly saved as "manual" in database.
Now properly identifies scheduled syncs as "scheduled" and retry
attempts as "retry". Updated frontend to capitalize trigger type
badges for better display.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-24 23:58:43 +01:00
Elisiário Couto
5a8614e019 Small fixes. 2025-09-24 23:52:51 +01:00
Elisiário Couto
ae5d034d4b fix(cli): Fix API URL handling for subpaths and improve client robustness.
- Automatically append /api/v1 to base URL if not present
- Fix URL construction to handle subpaths correctly
- Update health check to parse new nested response format
- Refactor bank delete command to use API client instead of direct requests
- Remove redundant /api/v1 prefixes from endpoint calls

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-24 23:52:34 +01:00
copilot-swe-agent[bot]
d4edf69f2c feat(frontend): Add version-based cache invalidation for PWA updates
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-24 21:46:12 +01:00
Elisiário Couto
d3a1696d4d chore(ci): Bump version to 2025.9.22 2025-09-24 20:08:20 +01:00
Elisiário Couto
24792744f9 fix(api): Fix banks API test fixtures to match GoCardless response format.
Updated test fixtures to correctly mock GoCardless API response format
with "results" key for institutions data. Fixed API client test to use
processed institutions data instead of raw GoCardless format.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-24 20:04:44 +01:00
Elisiário Couto
b9ca74e7e6 feat(api): Add bank logo support and fix banks endpoint type errors.
Backend changes:
- Add logo field to AccountDetails model
- Update accounts API endpoints to include logo data
- Add database migration for logo column in accounts table
- Implement institution details fetching from GoCardless API
- Enrich account data with institution logos during sync
- Fix type errors in banks endpoint with proper response parsing

Frontend changes:
- Add failedImages state to track logo loading failures
- Implement conditional rendering to show bank logos when available
- Add proper error handling with fallback to Building2 icon
- Fix image sizing to w-6 h-6 sm:w-8 sm:h-8 for proper display
- Update Account interface to include optional logo field
- Remove unused useState import from System component

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-24 19:57:03 +01:00
Elisiário Couto
a8f704129b chore: Add pre-commit instructions to AGENTS.md. 2025-09-24 15:20:50 +01:00
Elisiário Couto
62cd55e48f feat(frontend): Improve System page and TransactionsTable UX.
System page improvements:
- Add View Logs button to each sync operation with modal dialog
- Implement responsive design for mobile devices
- Remove redundant error count indicators
- Show full transaction text on mobile ("X new transactions")

TransactionsTable improvements:
- Use display_name instead of name • institution_id format
- Show only clean account display names in transaction rows

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-24 15:20:08 +01:00
Elisiário Couto
e4e3f885ea feat(api): Add separate sync failure notifications.
- Create dedicated sync failure notification templates for Telegram and Discord
- Add send_sync_failure_notification method to NotificationService
- Update scheduler to use proper notification method instead of expiry notifications
- Telegram: Shows error details with retry count and failure status
- Discord: Color-coded embeds (orange for retries, red for final failures)
- Fixes KeyError: 'bank' when sync failures occur

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-24 15:04:01 +01:00
Elisiário Couto
36d698f7ce fix(api): Add automatic token refresh on 401 errors in GoCardless service.
- Add _make_authenticated_request helper that automatically handles 401 errors
- Clear token cache and retry once when encountering expired tokens
- Refactor all API methods to use centralized request handling
- Fix banks API to properly handle institutions response structure
- Eliminates need for container restarts when tokens expire

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-24 14:58:45 +01:00
Elisiário Couto
d211a14703 chore(ci): Bump version to 2025.9.21 2025-09-23 00:50:13 +01:00
Elisiário Couto
c332642e64 feat(frontend): Implement notification settings with separate drawers and improved design.
- Add shadcn/ui drawer and switch components
- Create NotificationFiltersDrawer for editing notification filters
- Create DiscordConfigDrawer with test functionality
- Create TelegramConfigDrawer with test functionality
- Add reusable EditButton component for consistent design language
- Refactor Settings page to use separate drawers per configuration type
- Remove test notifications card, integrate testing into service drawers
- Simplify notification service status indicators for cleaner UI
- Remove redundant service descriptions for streamlined layout

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-23 00:49:07 +01:00
copilot-swe-agent[bot]
27f3f2dbba fix(frontend): Remove duplicate padding from Analytics page for consistent layout
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-09-22 23:25:10 +01:00
59 changed files with 4157 additions and 417 deletions

View File

@@ -2,9 +2,9 @@ name: CI
on:
push:
branches: [ "main", "dev" ]
branches: ["main", "dev"]
pull_request:
branches: [ "main", "dev" ]
branches: ["main", "dev"]
jobs:
test-python:
@@ -43,8 +43,8 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
node-version: "20"
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- name: Install dependencies

1
.gitignore vendored
View File

@@ -165,3 +165,4 @@ leggen.db
*.db
config.toml
.claude/
.playwright-mcp/

View File

@@ -2,16 +2,11 @@
"mcpServers": {
"shadcn": {
"command": "npx",
"args": [
"shadcn@latest",
"mcp"
]
"args": ["shadcn@latest", "mcp"]
},
"browsermcp": {
"playwright": {
"command": "npx",
"args": [
"@browsermcp/mcp@latest"
]
"args": ["@playwright/mcp@latest"]
}
}
}

View File

@@ -138,3 +138,4 @@ This repository follows conventional changelog practices. Refer to `CONTRIBUTING
- Commit message format and scoping
- Release process using `scripts/release.sh`
- Pre-commit hooks setup with `pre-commit install`
- When the pre-commit fails, the commit is canceled

View File

@@ -1,4 +1,152 @@
## 2025.10.0 (2025/10/01)
### Bug Fixes
- **gocardless:** Increase timeout to 30 seconds, some requests take some time. ([ca7968cc](https://github.com/elisiariocouto/leggen/commit/ca7968cc3c625e243fe2d75590a9e56f3100072b))
## 2025.9.26 (2025/09/30)
### Debug
- Log different sets of GoCardless rate limits. ([8802d247](https://github.com/elisiariocouto/leggen/commit/8802d24789cbb8e854d857a0d7cc89a25a26f378))
## 2025.9.25 (2025/09/30)
### Bug Fixes
- **api:** Fix S3 backup path-style configuration and improve UX. ([22ec0e36](https://github.com/elisiariocouto/leggen/commit/22ec0e36b11e5b017075bee51de0423a53ec4648))
### Features
- **api:** Add S3 backup functionality to backend ([7f2a4634](https://github.com/elisiariocouto/leggen/commit/7f2a4634c51814b6785433a25ce42d20aea0558c))
- **frontend:** Add S3 backup UI and complete backup functionality ([01229130](https://github.com/elisiariocouto/leggen/commit/0122913052793bcbf011cb557ef182be21c5de93))
- **frontend:** Add ability to list backups and create a backup on demand. ([473f126d](https://github.com/elisiariocouto/leggen/commit/473f126d3e699521172539f2ca0bff0579ccee51))
### Miscellaneous Tasks
- Log more rate limit headers. ([d36568da](https://github.com/elisiariocouto/leggen/commit/d36568da540d4fb4ae1fa10b322a3fa77dcc5360))
## 2025.9.24 (2025/09/25)
### Features
- **frontend:** Add comprehensive bank account management system. ([ef7c026d](https://github.com/elisiariocouto/leggen/commit/ef7c026db9911cc3be8d5f48e42a4d7beb4b9d0a))
## 2025.9.24 (2025/09/25)
### Features
- **frontend:** Add comprehensive bank account management system. ([ef7c026d](https://github.com/elisiariocouto/leggen/commit/ef7c026db9911cc3be8d5f48e42a4d7beb4b9d0a))
## 2025.9.23 (2025/09/24)
### Bug Fixes
- **cli:** Fix API URL handling for subpaths and improve client robustness. ([ae5d034d](https://github.com/elisiariocouto/leggen/commit/ae5d034d4b1da785e3dc240c1d60c2cae7de8010))
- Correct sync trigger types from manual to scheduled/retry. ([460c5af6](https://github.com/elisiariocouto/leggen/commit/460c5af6ea343ef5685b716413d01d7a30fa9acf))
### Features
- **frontend:** Add version-based cache invalidation for PWA updates ([d4edf69f](https://github.com/elisiariocouto/leggen/commit/d4edf69f2cea2515a00435ee974116948057148d))
## 2025.9.23 (2025/09/24)
### Bug Fixes
- **cli:** Fix API URL handling for subpaths and improve client robustness. ([ae5d034d](https://github.com/elisiariocouto/leggen/commit/ae5d034d4b1da785e3dc240c1d60c2cae7de8010))
- Correct sync trigger types from manual to scheduled/retry. ([460c5af6](https://github.com/elisiariocouto/leggen/commit/460c5af6ea343ef5685b716413d01d7a30fa9acf))
### Features
- **frontend:** Add version-based cache invalidation for PWA updates ([d4edf69f](https://github.com/elisiariocouto/leggen/commit/d4edf69f2cea2515a00435ee974116948057148d))
## 2025.9.22 (2025/09/24)
### Bug Fixes
- **api:** Add automatic token refresh on 401 errors in GoCardless service. ([36d698f7](https://github.com/elisiariocouto/leggen/commit/36d698f7ce05c7db0e4b07dd07979de2c70b053e))
- **api:** Fix banks API test fixtures to match GoCardless response format. ([24792744](https://github.com/elisiariocouto/leggen/commit/24792744f9660063e1a3abb9ed8e925fea9a5e60))
### Features
- **api:** Add separate sync failure notifications. ([e4e3f885](https://github.com/elisiariocouto/leggen/commit/e4e3f885eab1d45b0e10465ca04eb3f74e9c5a4d))
- **api:** Add bank logo support and fix banks endpoint type errors. ([b9ca74e7](https://github.com/elisiariocouto/leggen/commit/b9ca74e7e67c3877728b749a42f15f0c0d906561))
- **frontend:** Improve System page and TransactionsTable UX. ([62cd55e4](https://github.com/elisiariocouto/leggen/commit/62cd55e48fff7c2f5db9dd8230a7bd500e8f6eed))
### Miscellaneous Tasks
- Add pre-commit instructions to AGENTS.md. ([a8f70412](https://github.com/elisiariocouto/leggen/commit/a8f704129b2453e604cf2ab776791ba1e91e6fc7))
## 2025.9.22 (2025/09/24)
### Bug Fixes
- **api:** Add automatic token refresh on 401 errors in GoCardless service. ([36d698f7](https://github.com/elisiariocouto/leggen/commit/36d698f7ce05c7db0e4b07dd07979de2c70b053e))
- **api:** Fix banks API test fixtures to match GoCardless response format. ([24792744](https://github.com/elisiariocouto/leggen/commit/24792744f9660063e1a3abb9ed8e925fea9a5e60))
### Features
- **api:** Add separate sync failure notifications. ([e4e3f885](https://github.com/elisiariocouto/leggen/commit/e4e3f885eab1d45b0e10465ca04eb3f74e9c5a4d))
- **api:** Add bank logo support and fix banks endpoint type errors. ([b9ca74e7](https://github.com/elisiariocouto/leggen/commit/b9ca74e7e67c3877728b749a42f15f0c0d906561))
- **frontend:** Improve System page and TransactionsTable UX. ([62cd55e4](https://github.com/elisiariocouto/leggen/commit/62cd55e48fff7c2f5db9dd8230a7bd500e8f6eed))
### Miscellaneous Tasks
- Add pre-commit instructions to AGENTS.md. ([a8f70412](https://github.com/elisiariocouto/leggen/commit/a8f704129b2453e604cf2ab776791ba1e91e6fc7))
## 2025.9.21 (2025/09/22)
### Bug Fixes
- **frontend:** Remove duplicate padding from Analytics page for consistent layout ([27f3f2db](https://github.com/elisiariocouto/leggen/commit/27f3f2dbba91777234769cca08de5dbe8b378f10))
### Features
- **frontend:** Implement notification settings with separate drawers and improved design. ([c332642e](https://github.com/elisiariocouto/leggen/commit/c332642e648cb0a29100b500c03e17ae322845f8))
## 2025.9.21 (2025/09/22)
### Bug Fixes
- **frontend:** Remove duplicate padding from Analytics page for consistent layout ([27f3f2db](https://github.com/elisiariocouto/leggen/commit/27f3f2dbba91777234769cca08de5dbe8b378f10))
### Features
- **frontend:** Implement notification settings with separate drawers and improved design. ([c332642e](https://github.com/elisiariocouto/leggen/commit/c332642e648cb0a29100b500c03e17ae322845f8))
## 2025.9.20 (2025/09/22)
### Features

View File

@@ -28,3 +28,13 @@ enabled = true
[filters]
case_insensitive = ["salary", "utility"]
case_sensitive = ["SpecificStore"]
# Optional: S3 backup configuration
[backup.s3]
access_key_id = "your-s3-access-key"
secret_access_key = "your-s3-secret-key"
bucket_name = "your-bucket-name"
region = "us-east-1"
# endpoint_url = "https://custom-s3-endpoint.com" # Optional: for custom S3-compatible endpoints
path_style = false # Set to true for path-style addressing
enabled = true

View File

@@ -22,6 +22,7 @@
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
@@ -4195,6 +4196,35 @@
}
}
},
"node_modules/@radix-ui/react-switch": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.6.tgz",
"integrity": "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-use-previous": "1.1.1",
"@radix-ui/react-use-size": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tabs": {
"version": "1.1.13",
"resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz",

View File

@@ -24,6 +24,7 @@
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",

View File

@@ -78,6 +78,7 @@ export default function AccountSettings() {
const [editingAccountId, setEditingAccountId] = useState<string | null>(null);
const [editingName, setEditingName] = useState("");
const [failedImages, setFailedImages] = useState<Set<string>>(new Set());
const queryClient = useQueryClient();
@@ -194,8 +195,24 @@ export default function AccountSettings() {
{/* Mobile layout - stack vertically */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4">
<div className="flex items-start sm:items-center space-x-3 sm:space-x-4 min-w-0 flex-1">
<div className="flex-shrink-0 p-2 sm:p-3 bg-muted rounded-full">
<Building2 className="h-5 w-5 sm:h-6 sm:w-6 text-muted-foreground" />
<div className="flex-shrink-0 w-10 h-10 sm:w-12 sm:h-12 rounded-full overflow-hidden bg-muted flex items-center justify-center">
{account.logo && !failedImages.has(account.id) ? (
<img
src={account.logo}
alt={`${account.institution_id} logo`}
className="w-full h-full object-contain"
onError={() => {
console.warn(
`Failed to load bank logo for ${account.institution_id}: ${account.logo}`,
);
setFailedImages(
(prev) => new Set([...prev, account.id]),
);
}}
/>
) : (
<Building2 className="h-5 w-5 sm:h-6 sm:w-6 text-muted-foreground" />
)}
</div>
<div className="flex-1 min-w-0">
{editingAccountId === account.id ? (

View File

@@ -0,0 +1,203 @@
import { useState } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { Plus, Building2, ExternalLink } from "lucide-react";
import { apiClient } from "../lib/api";
import { Button } from "./ui/button";
import { Label } from "./ui/label";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "./ui/drawer";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "./ui/select";
import { Alert, AlertDescription } from "./ui/alert";
export default function AddBankAccountDrawer() {
const [open, setOpen] = useState(false);
const [selectedCountry, setSelectedCountry] = useState<string>("");
const [selectedBank, setSelectedBank] = useState<string>("");
const { data: countries } = useQuery({
queryKey: ["supportedCountries"],
queryFn: apiClient.getSupportedCountries,
});
const { data: banks, isLoading: banksLoading } = useQuery({
queryKey: ["bankInstitutions", selectedCountry],
queryFn: () => apiClient.getBankInstitutions(selectedCountry),
enabled: !!selectedCountry,
});
const connectBankMutation = useMutation({
mutationFn: (institutionId: string) =>
apiClient.createBankConnection(institutionId),
onSuccess: (data) => {
// Redirect to the bank's authorization link
if (data.link) {
window.open(data.link, "_blank");
setOpen(false);
}
},
onError: (error) => {
console.error("Failed to create bank connection:", error);
},
});
const handleCountryChange = (country: string) => {
setSelectedCountry(country);
setSelectedBank("");
};
const handleConnect = () => {
if (selectedBank) {
connectBankMutation.mutate(selectedBank);
}
};
const resetForm = () => {
setSelectedCountry("");
setSelectedBank("");
};
return (
<Drawer
open={open}
onOpenChange={(isOpen) => {
setOpen(isOpen);
if (!isOpen) {
resetForm();
}
}}
>
<DrawerTrigger asChild>
<Button size="sm">
<Plus className="h-4 w-4 mr-2" />
Add New Account
</Button>
</DrawerTrigger>
<DrawerContent className="max-h-[80vh]">
<DrawerHeader>
<DrawerTitle>Connect Bank Account</DrawerTitle>
<DrawerDescription>
Select your country and bank to connect your account to Leggen
</DrawerDescription>
</DrawerHeader>
<div className="px-6 space-y-6 overflow-y-auto">
{/* Country Selection */}
<div className="space-y-2">
<Label htmlFor="country">Country</Label>
<Select value={selectedCountry} onValueChange={handleCountryChange}>
<SelectTrigger>
<SelectValue placeholder="Select your country" />
</SelectTrigger>
<SelectContent>
{countries?.map((country) => (
<SelectItem key={country.code} value={country.code}>
{country.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Bank Selection */}
{selectedCountry && (
<div className="space-y-2">
<Label htmlFor="bank">Bank</Label>
{banksLoading ? (
<div className="p-4 text-center text-muted-foreground">
Loading banks...
</div>
) : banks && banks.length > 0 ? (
<Select value={selectedBank} onValueChange={setSelectedBank}>
<SelectTrigger>
<SelectValue placeholder="Select your bank" />
</SelectTrigger>
<SelectContent>
{banks.map((bank) => (
<SelectItem key={bank.id} value={bank.id}>
<div className="flex items-center space-x-2">
{bank.logo ? (
<img
src={bank.logo}
alt={`${bank.name} logo`}
className="w-4 h-4 object-contain"
/>
) : (
<Building2 className="w-4 h-4" />
)}
<span>{bank.name}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Alert>
<AlertDescription>
No banks available for the selected country.
</AlertDescription>
</Alert>
)}
</div>
)}
{/* Instructions */}
{selectedBank && (
<Alert>
<AlertDescription>
You'll be redirected to your bank's website to authorize the
connection. After approval, you'll return to Leggen and your
account will start syncing.
</AlertDescription>
</Alert>
)}
{/* Error Display */}
{connectBankMutation.isError && (
<Alert variant="destructive">
<AlertDescription>
Failed to create bank connection. Please try again.
</AlertDescription>
</Alert>
)}
</div>
<DrawerFooter>
<div className="flex space-x-2">
<Button
onClick={handleConnect}
disabled={!selectedBank || connectBankMutation.isPending}
className="flex-1"
>
<ExternalLink className="h-4 w-4 mr-2" />
{connectBankMutation.isPending
? "Connecting..."
: "Open Bank Authorization"}
</Button>
<DrawerClose asChild>
<Button
variant="outline"
disabled={connectBankMutation.isPending}
>
Cancel
</Button>
</DrawerClose>
</div>
</DrawerFooter>
</DrawerContent>
</Drawer>
);
}

View File

@@ -0,0 +1,200 @@
import { useState, useEffect } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { MessageSquare, TestTube } from "lucide-react";
import { apiClient } from "../lib/api";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { Label } from "./ui/label";
import { Switch } from "./ui/switch";
import { EditButton } from "./ui/edit-button";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "./ui/drawer";
import type { NotificationSettings, DiscordConfig } from "../types/api";
interface DiscordConfigDrawerProps {
settings: NotificationSettings | undefined;
trigger?: React.ReactNode;
}
export default function DiscordConfigDrawer({
settings,
trigger,
}: DiscordConfigDrawerProps) {
const [open, setOpen] = useState(false);
const [config, setConfig] = useState<DiscordConfig>({
webhook: "",
enabled: true,
});
const queryClient = useQueryClient();
useEffect(() => {
if (settings?.discord) {
setConfig({ ...settings.discord });
}
}, [settings]);
const updateMutation = useMutation({
mutationFn: (discordConfig: DiscordConfig) =>
apiClient.updateNotificationSettings({
...settings,
discord: discordConfig,
filters: settings?.filters || {
case_insensitive: [],
case_sensitive: [],
},
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["notificationSettings"] });
queryClient.invalidateQueries({ queryKey: ["notificationServices"] });
setOpen(false);
},
onError: (error) => {
console.error("Failed to update Discord configuration:", error);
},
});
const testMutation = useMutation({
mutationFn: () =>
apiClient.testNotification({
service: "discord",
message:
"Test notification from Leggen - Discord configuration is working!",
}),
onSuccess: () => {
console.log("Test Discord notification sent successfully");
},
onError: (error) => {
console.error("Failed to send test Discord notification:", error);
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
updateMutation.mutate(config);
};
const handleTest = () => {
testMutation.mutate();
};
const isConfigValid =
config.webhook.trim().length > 0 &&
config.webhook.includes("discord.com/api/webhooks");
return (
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild>{trigger || <EditButton />}</DrawerTrigger>
<DrawerContent>
<div className="mx-auto w-full max-w-md">
<DrawerHeader>
<DrawerTitle className="flex items-center space-x-2">
<MessageSquare className="h-5 w-5 text-primary" />
<span>Discord Configuration</span>
</DrawerTitle>
<DrawerDescription>
Configure Discord webhook notifications for transaction alerts
</DrawerDescription>
</DrawerHeader>
<form onSubmit={handleSubmit} className="p-4 space-y-6">
{/* Enable/Disable Toggle */}
<div className="flex items-center justify-between">
<Label className="text-base font-medium">
Enable Discord Notifications
</Label>
<Switch
checked={config.enabled}
onCheckedChange={(enabled) => setConfig({ ...config, enabled })}
/>
</div>
{/* Webhook URL */}
<div className="space-y-2">
<Label htmlFor="discord-webhook">Discord Webhook URL</Label>
<Input
id="discord-webhook"
type="url"
placeholder="https://discord.com/api/webhooks/..."
value={config.webhook}
onChange={(e) =>
setConfig({ ...config, webhook: e.target.value })
}
disabled={!config.enabled}
/>
<p className="text-xs text-muted-foreground">
Create a webhook in your Discord server settings under
Integrations Webhooks
</p>
</div>
{/* Configuration Status */}
{config.enabled && (
<div className="p-3 bg-muted rounded-md">
<div className="flex items-center space-x-2">
<div
className={`w-2 h-2 rounded-full ${isConfigValid ? "bg-green-500" : "bg-red-500"}`}
/>
<span className="text-sm font-medium">
{isConfigValid
? "Configuration Valid"
: "Invalid Webhook URL"}
</span>
</div>
{!isConfigValid && config.webhook.trim().length > 0 && (
<p className="text-xs text-muted-foreground mt-1">
Please enter a valid Discord webhook URL
</p>
)}
</div>
)}
<DrawerFooter className="px-0">
<div className="flex space-x-2">
<Button
type="submit"
disabled={updateMutation.isPending || !config.enabled}
>
{updateMutation.isPending
? "Saving..."
: "Save Configuration"}
</Button>
{config.enabled && isConfigValid && (
<Button
type="button"
variant="outline"
onClick={handleTest}
disabled={testMutation.isPending}
>
{testMutation.isPending ? (
<>
<TestTube className="h-4 w-4 mr-2 animate-spin" />
Testing...
</>
) : (
<>
<TestTube className="h-4 w-4 mr-2" />
Test
</>
)}
</Button>
)}
</div>
<DrawerClose asChild>
<Button variant="ghost">Cancel</Button>
</DrawerClose>
</DrawerFooter>
</form>
</div>
</DrawerContent>
</Drawer>
);
}

View File

@@ -0,0 +1,255 @@
import { useState, useEffect } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Plus, X } from "lucide-react";
import { apiClient } from "../lib/api";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { Label } from "./ui/label";
import { EditButton } from "./ui/edit-button";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "./ui/drawer";
import type { NotificationSettings, NotificationFilters } from "../types/api";
interface NotificationFiltersDrawerProps {
settings: NotificationSettings | undefined;
trigger?: React.ReactNode;
}
export default function NotificationFiltersDrawer({
settings,
trigger,
}: NotificationFiltersDrawerProps) {
const [open, setOpen] = useState(false);
const [filters, setFilters] = useState<NotificationFilters>({
case_insensitive: [],
case_sensitive: [],
});
const [newCaseInsensitive, setNewCaseInsensitive] = useState("");
const [newCaseSensitive, setNewCaseSensitive] = useState("");
const queryClient = useQueryClient();
useEffect(() => {
if (settings?.filters) {
setFilters({
case_insensitive: [...(settings.filters.case_insensitive || [])],
case_sensitive: [...(settings.filters.case_sensitive || [])],
});
}
}, [settings]);
const updateMutation = useMutation({
mutationFn: (updatedFilters: NotificationFilters) =>
apiClient.updateNotificationSettings({
...settings,
filters: updatedFilters,
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["notificationSettings"] });
setOpen(false);
},
onError: (error) => {
console.error("Failed to update notification filters:", error);
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
updateMutation.mutate(filters);
};
const addCaseInsensitiveFilter = () => {
if (
newCaseInsensitive.trim() &&
!filters.case_insensitive.includes(newCaseInsensitive.trim())
) {
setFilters({
...filters,
case_insensitive: [
...filters.case_insensitive,
newCaseInsensitive.trim(),
],
});
setNewCaseInsensitive("");
}
};
const addCaseSensitiveFilter = () => {
if (
newCaseSensitive.trim() &&
!filters.case_sensitive?.includes(newCaseSensitive.trim())
) {
setFilters({
...filters,
case_sensitive: [
...(filters.case_sensitive || []),
newCaseSensitive.trim(),
],
});
setNewCaseSensitive("");
}
};
const removeCaseInsensitiveFilter = (index: number) => {
setFilters({
...filters,
case_insensitive: filters.case_insensitive.filter((_, i) => i !== index),
});
};
const removeCaseSensitiveFilter = (index: number) => {
setFilters({
...filters,
case_sensitive:
filters.case_sensitive?.filter((_, i) => i !== index) || [],
});
};
return (
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild>{trigger || <EditButton />}</DrawerTrigger>
<DrawerContent>
<div className="mx-auto w-full max-w-2xl">
<DrawerHeader>
<DrawerTitle>Notification Filters</DrawerTitle>
<DrawerDescription>
Configure which transaction descriptions should trigger
notifications
</DrawerDescription>
</DrawerHeader>
<form onSubmit={handleSubmit} className="p-4 space-y-6">
{/* Case Insensitive Filters */}
<div className="space-y-3">
<Label className="text-base font-medium">
Case Insensitive Filters
</Label>
<p className="text-sm text-muted-foreground">
Filters that match regardless of capitalization (e.g., "AMAZON"
matches "amazon")
</p>
<div className="flex space-x-2">
<Input
placeholder="Add filter term..."
value={newCaseInsensitive}
onChange={(e) => setNewCaseInsensitive(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
addCaseInsensitiveFilter();
}
}}
/>
<Button
type="button"
onClick={addCaseInsensitiveFilter}
size="sm"
>
<Plus className="h-4 w-4" />
</Button>
</div>
<div className="flex flex-wrap gap-2 min-h-[2rem] p-3 bg-muted rounded-md">
{filters.case_insensitive.length > 0 ? (
filters.case_insensitive.map((filter, index) => (
<div
key={index}
className="flex items-center space-x-1 bg-secondary text-secondary-foreground px-2 py-1 rounded-md text-sm"
>
<span>{filter}</span>
<button
type="button"
onClick={() => removeCaseInsensitiveFilter(index)}
className="text-secondary-foreground hover:text-foreground"
>
<X className="h-3 w-3" />
</button>
</div>
))
) : (
<span className="text-muted-foreground text-sm">
No filters added
</span>
)}
</div>
</div>
{/* Case Sensitive Filters */}
<div className="space-y-3">
<Label className="text-base font-medium">
Case Sensitive Filters
</Label>
<p className="text-sm text-muted-foreground">
Filters that match exactly as typed (e.g., "AMAZON" only matches
"AMAZON")
</p>
<div className="flex space-x-2">
<Input
placeholder="Add filter term..."
value={newCaseSensitive}
onChange={(e) => setNewCaseSensitive(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
addCaseSensitiveFilter();
}
}}
/>
<Button
type="button"
onClick={addCaseSensitiveFilter}
size="sm"
>
<Plus className="h-4 w-4" />
</Button>
</div>
<div className="flex flex-wrap gap-2 min-h-[2rem] p-3 bg-muted rounded-md">
{filters.case_sensitive && filters.case_sensitive.length > 0 ? (
filters.case_sensitive.map((filter, index) => (
<div
key={index}
className="flex items-center space-x-1 bg-secondary text-secondary-foreground px-2 py-1 rounded-md text-sm"
>
<span>{filter}</span>
<button
type="button"
onClick={() => removeCaseSensitiveFilter(index)}
className="text-secondary-foreground hover:text-foreground"
>
<X className="h-3 w-3" />
</button>
</div>
))
) : (
<span className="text-muted-foreground text-sm">
No filters added
</span>
)}
</div>
</div>
<DrawerFooter className="px-0">
<Button type="submit" disabled={updateMutation.isPending}>
{updateMutation.isPending ? "Saving..." : "Save Filters"}
</Button>
<DrawerClose asChild>
<Button variant="outline">Cancel</Button>
</DrawerClose>
</DrawerFooter>
</form>
</div>
</DrawerContent>
</Drawer>
);
}

View File

@@ -0,0 +1,273 @@
import { useState, useEffect } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Cloud, TestTube } from "lucide-react";
import { toast } from "sonner";
import { apiClient } from "../lib/api";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { Label } from "./ui/label";
import { Switch } from "./ui/switch";
import { EditButton } from "./ui/edit-button";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "./ui/drawer";
import type { BackupSettings, S3Config } from "../types/api";
interface S3BackupConfigDrawerProps {
settings?: BackupSettings;
trigger?: React.ReactNode;
}
export default function S3BackupConfigDrawer({
settings,
trigger,
}: S3BackupConfigDrawerProps) {
const [open, setOpen] = useState(false);
const [config, setConfig] = useState<S3Config>({
access_key_id: "",
secret_access_key: "",
bucket_name: "",
region: "us-east-1",
endpoint_url: "",
path_style: false,
enabled: true,
});
const queryClient = useQueryClient();
useEffect(() => {
if (settings?.s3) {
setConfig({ ...settings.s3 });
}
}, [settings]);
const updateMutation = useMutation({
mutationFn: (s3Config: S3Config) =>
apiClient.updateBackupSettings({
s3: s3Config,
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["backupSettings"] });
setOpen(false);
toast.success("S3 backup configuration saved successfully");
},
onError: (error: Error & { response?: { data?: { detail?: string } } }) => {
console.error("Failed to update S3 backup configuration:", error);
const message =
error?.response?.data?.detail ||
"Failed to save S3 configuration. Please check your settings and try again.";
toast.error(message);
},
});
const testMutation = useMutation({
mutationFn: () =>
apiClient.testBackupConnection({
service: "s3",
config: config,
}),
onSuccess: (response) => {
if (response.success) {
console.log("S3 connection test successful");
toast.success(
"S3 connection test successful! Your configuration is working correctly.",
);
} else {
console.error("S3 connection test failed:", response.message);
toast.error(response.message || "S3 connection test failed. Please verify your credentials and settings.");
}
},
onError: (error: Error & { response?: { data?: { detail?: string } } }) => {
console.error("Failed to test S3 connection:", error);
const message =
error?.response?.data?.detail ||
"S3 connection test failed. Please verify your credentials and settings.";
toast.error(message);
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
updateMutation.mutate(config);
};
const handleTest = () => {
testMutation.mutate();
};
const isConfigValid =
config.access_key_id.trim().length > 0 &&
config.secret_access_key.trim().length > 0 &&
config.bucket_name.trim().length > 0;
return (
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild>{trigger || <EditButton />}</DrawerTrigger>
<DrawerContent>
<div className="mx-auto w-full max-w-sm">
<DrawerHeader>
<DrawerTitle className="flex items-center space-x-2">
<Cloud className="h-5 w-5 text-primary" />
<span>S3 Backup Configuration</span>
</DrawerTitle>
<DrawerDescription>
Configure S3 settings for automatic database backups
</DrawerDescription>
</DrawerHeader>
<form onSubmit={handleSubmit} className="px-4 space-y-4">
<div className="flex items-center space-x-2">
<Switch
id="enabled"
checked={config.enabled}
onCheckedChange={(checked) =>
setConfig({ ...config, enabled: checked })
}
/>
<Label htmlFor="enabled">Enable S3 backups</Label>
</div>
{config.enabled && (
<>
<div className="space-y-2">
<Label htmlFor="access_key_id">Access Key ID</Label>
<Input
id="access_key_id"
type="text"
value={config.access_key_id}
onChange={(e) =>
setConfig({ ...config, access_key_id: e.target.value })
}
placeholder="Your AWS Access Key ID"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="secret_access_key">Secret Access Key</Label>
<Input
id="secret_access_key"
type="password"
value={config.secret_access_key}
onChange={(e) =>
setConfig({
...config,
secret_access_key: e.target.value,
})
}
placeholder="Your AWS Secret Access Key"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="bucket_name">Bucket Name</Label>
<Input
id="bucket_name"
type="text"
value={config.bucket_name}
onChange={(e) =>
setConfig({ ...config, bucket_name: e.target.value })
}
placeholder="my-backup-bucket"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="region">Region</Label>
<Input
id="region"
type="text"
value={config.region}
onChange={(e) =>
setConfig({ ...config, region: e.target.value })
}
placeholder="us-east-1"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="endpoint_url">
Custom Endpoint URL (Optional)
</Label>
<Input
id="endpoint_url"
type="url"
value={config.endpoint_url || ""}
onChange={(e) =>
setConfig({ ...config, endpoint_url: e.target.value })
}
placeholder="https://custom-s3-endpoint.com"
/>
<p className="text-xs text-muted-foreground">
For S3-compatible services like MinIO or DigitalOcean Spaces
</p>
</div>
<div className="flex items-center space-x-2">
<Switch
id="path_style"
checked={config.path_style}
onCheckedChange={(checked) =>
setConfig({ ...config, path_style: checked })
}
/>
<Label htmlFor="path_style">Use path-style addressing</Label>
</div>
<p className="text-xs text-muted-foreground">
Enable for older S3 implementations or certain S3-compatible
services
</p>
</>
)}
<DrawerFooter className="px-0">
<div className="flex space-x-2">
<Button
type="submit"
disabled={updateMutation.isPending || !config.enabled}
>
{updateMutation.isPending
? "Saving..."
: "Save Configuration"}
</Button>
{config.enabled && isConfigValid && (
<Button
type="button"
variant="outline"
onClick={handleTest}
disabled={testMutation.isPending}
>
{testMutation.isPending ? (
<>
<TestTube className="h-4 w-4 mr-2 animate-spin" />
Testing...
</>
) : (
<>
<TestTube className="h-4 w-4 mr-2" />
Test
</>
)}
</Button>
)}
</div>
<DrawerClose asChild>
<Button variant="ghost">Cancel</Button>
</DrawerClose>
</DrawerFooter>
</form>
</div>
</DrawerContent>
</Drawer>
);
}

View File

@@ -10,16 +10,17 @@ import {
Edit2,
Check,
X,
Plus,
Bell,
MessageSquare,
Send,
Trash2,
TestTube,
Settings as SettingsIcon,
User,
CheckCircle,
Filter,
Cloud,
Archive,
Eye,
} from "lucide-react";
import { toast } from "sonner";
import { apiClient } from "../lib/api";
import { formatCurrency, formatDate } from "../lib/utils";
import {
@@ -31,23 +32,21 @@ import {
} from "./ui/card";
import { Button } from "./ui/button";
import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
import { Input } from "./ui/input";
import { Label } from "./ui/label";
import { Badge } from "./ui/badge";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "./ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "./ui/tabs";
import AccountsSkeleton from "./AccountsSkeleton";
import NotificationFiltersDrawer from "./NotificationFiltersDrawer";
import DiscordConfigDrawer from "./DiscordConfigDrawer";
import TelegramConfigDrawer from "./TelegramConfigDrawer";
import AddBankAccountDrawer from "./AddBankAccountDrawer";
import S3BackupConfigDrawer from "./S3BackupConfigDrawer";
import type {
Account,
Balance,
NotificationSettings,
NotificationService,
BackupSettings,
BackupInfo,
} from "../types/api";
// Helper function to get status indicator color and styles
@@ -87,10 +86,8 @@ const getStatusIndicator = (status: string) => {
export default function Settings() {
const [editingAccountId, setEditingAccountId] = useState<string | null>(null);
const [editingName, setEditingName] = useState("");
const [testService, setTestService] = useState("");
const [testMessage, setTestMessage] = useState(
"Test notification from Leggen",
);
const [failedImages, setFailedImages] = useState<Set<string>>(new Set());
const [showBackups, setShowBackups] = useState(false);
const queryClient = useQueryClient();
@@ -131,6 +128,33 @@ export default function Settings() {
queryFn: apiClient.getNotificationServices,
});
const { data: bankConnections } = useQuery({
queryKey: ["bankConnections"],
queryFn: apiClient.getBankConnectionsStatus,
});
// Backup queries
const {
data: backupSettings,
isLoading: backupLoading,
error: backupError,
refetch: refetchBackup,
} = useQuery<BackupSettings>({
queryKey: ["backupSettings"],
queryFn: apiClient.getBackupSettings,
});
const {
data: backups,
isLoading: backupsLoading,
error: backupsError,
refetch: refetchBackups,
} = useQuery<BackupInfo[]>({
queryKey: ["backups"],
queryFn: apiClient.listBackups,
enabled: showBackups,
});
// Account mutations
const updateAccountMutation = useMutation({
mutationFn: ({ id, display_name }: { id: string; display_name: string }) =>
@@ -146,16 +170,6 @@ export default function Settings() {
});
// Notification mutations
const testMutation = useMutation({
mutationFn: apiClient.testNotification,
onSuccess: () => {
console.log("Test notification sent successfully");
},
onError: (error) => {
console.error("Failed to send test notification:", error);
},
});
const deleteServiceMutation = useMutation({
mutationFn: apiClient.deleteNotificationService,
onSuccess: () => {
@@ -164,6 +178,36 @@ export default function Settings() {
},
});
// Bank connection mutations
const deleteBankConnectionMutation = useMutation({
mutationFn: apiClient.deleteBankConnection,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["accounts"] });
queryClient.invalidateQueries({ queryKey: ["bankConnections"] });
queryClient.invalidateQueries({ queryKey: ["balances"] });
},
});
// Backup mutations
const createBackupMutation = useMutation({
mutationFn: () => apiClient.performBackupOperation({ operation: "backup" }),
onSuccess: (response) => {
if (response.success) {
toast.success(response.message || "Backup created successfully!");
queryClient.invalidateQueries({ queryKey: ["backups"] });
} else {
toast.error(response.message || "Failed to create backup.");
}
},
onError: (error: Error & { response?: { data?: { detail?: string } } }) => {
console.error("Failed to create backup:", error);
const message =
error?.response?.data?.detail ||
"Failed to create backup. Please check your S3 configuration.";
toast.error(message);
},
});
// Account handlers
const handleEditStart = (account: Account) => {
setEditingAccountId(account.id);
@@ -185,15 +229,6 @@ export default function Settings() {
};
// Notification handlers
const handleTestNotification = () => {
if (!testService) return;
testMutation.mutate({
service: testService.toLowerCase(),
message: testMessage,
});
};
const handleDeleteService = (serviceName: string) => {
if (
confirm(
@@ -204,8 +239,27 @@ export default function Settings() {
}
};
const isLoading = accountsLoading || settingsLoading || servicesLoading;
const hasError = accountsError || settingsError || servicesError;
// Backup handlers
const handleCreateBackup = () => {
if (!backupSettings?.s3?.enabled) {
toast.error("S3 backup is not enabled. Please configure and enable S3 backup first.");
return;
}
createBackupMutation.mutate();
};
const handleViewBackups = () => {
if (!backupSettings?.s3?.enabled) {
toast.error("S3 backup is not enabled. Please configure and enable S3 backup first.");
return;
}
setShowBackups(true);
};
const isLoading =
accountsLoading || settingsLoading || servicesLoading || backupLoading;
const hasError =
accountsError || settingsError || servicesError || backupError;
if (isLoading) {
return <AccountsSkeleton />;
@@ -226,6 +280,7 @@ export default function Settings() {
refetchAccounts();
refetchSettings();
refetchServices();
refetchBackup();
}}
variant="outline"
size="sm"
@@ -241,7 +296,7 @@ export default function Settings() {
return (
<div className="space-y-6">
<Tabs defaultValue="accounts" className="space-y-6">
<TabsList className="grid w-full grid-cols-2">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="accounts" className="flex items-center space-x-2">
<User className="h-4 w-4" />
<span>Accounts</span>
@@ -253,6 +308,10 @@ export default function Settings() {
<Bell className="h-4 w-4" />
<span>Notifications</span>
</TabsTrigger>
<TabsTrigger value="backup" className="flex items-center space-x-2">
<Cloud className="h-4 w-4" />
<span>Backup</span>
</TabsTrigger>
</TabsList>
<TabsContent value="accounts" className="space-y-6">
@@ -275,13 +334,6 @@ export default function Settings() {
<p className="text-muted-foreground mb-4">
Connect your first bank account to get started with Leggen.
</p>
<Button disabled className="flex items-center space-x-2">
<Plus className="h-4 w-4" />
<span>Add Bank Account</span>
</Button>
<p className="text-xs text-muted-foreground mt-2">
Coming soon: Add new bank connections
</p>
</CardContent>
) : (
<CardContent className="p-0">
@@ -311,8 +363,24 @@ export default function Settings() {
{/* Mobile layout - stack vertically */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4">
<div className="flex items-start sm:items-center space-x-3 sm:space-x-4 min-w-0 flex-1">
<div className="flex-shrink-0 p-2 sm:p-3 bg-muted rounded-full">
<Building2 className="h-5 w-5 sm:h-6 sm:w-6 text-muted-foreground" />
<div className="flex-shrink-0 w-10 h-10 sm:w-12 sm:h-12 rounded-full overflow-hidden bg-muted flex items-center justify-center">
{account.logo && !failedImages.has(account.id) ? (
<img
src={account.logo}
alt={`${account.institution_id} logo`}
className="w-6 h-6 sm:w-8 sm:h-8 object-contain"
onError={() => {
console.warn(
`Failed to load bank logo for ${account.institution_id}: ${account.logo}`,
);
setFailedImages(
(prev) => new Set([...prev, account.id]),
);
}}
/>
) : (
<Building2 className="h-5 w-5 sm:h-6 sm:w-6 text-muted-foreground" />
)}
</div>
<div className="flex-1 min-w-0">
{editingAccountId === account.id ? (
@@ -435,91 +503,114 @@ export default function Settings() {
)}
</Card>
{/* Add Bank Section (Future Feature) */}
{/* Bank Connections Status */}
<Card>
<CardHeader>
<CardTitle>Add New Bank Account</CardTitle>
<CardDescription>
Connect additional bank accounts to track all your finances in
one place
</CardDescription>
</CardHeader>
<CardContent className="p-6">
<div className="text-center space-y-4">
<div className="p-4 bg-muted rounded-lg">
<Plus className="h-8 w-8 text-muted-foreground mx-auto mb-2" />
<p className="text-sm text-muted-foreground">
Bank connection functionality is coming soon. Stay tuned for
updates!
</p>
<div className="flex items-center justify-between">
<div>
<CardTitle>Bank Connections</CardTitle>
<CardDescription>
Status of all bank connection requests and their
authorization state
</CardDescription>
</div>
<Button disabled variant="outline">
<Plus className="h-4 w-4 mr-2" />
Connect Bank Account
</Button>
<AddBankAccountDrawer />
</div>
</CardContent>
</CardHeader>
{!bankConnections || bankConnections.length === 0 ? (
<CardContent className="p-6 text-center">
<Building2 className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-medium text-foreground mb-2">
No bank connections found
</h3>
<p className="text-muted-foreground">
Bank connection requests will appear here after you connect
accounts.
</p>
</CardContent>
) : (
<CardContent className="p-0">
<div className="divide-y divide-border">
{bankConnections.map((connection) => {
const statusColor =
connection.status.toLowerCase() === "ln"
? "bg-green-500"
: connection.status.toLowerCase() === "cr"
? "bg-amber-500"
: connection.status.toLowerCase() === "ex"
? "bg-red-500"
: "bg-muted-foreground";
return (
<div
key={connection.requisition_id}
className="p-4 sm:p-6 hover:bg-accent transition-colors"
>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4 min-w-0 flex-1">
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-muted flex items-center justify-center">
<Building2 className="h-5 w-5 text-muted-foreground" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center space-x-2">
<h4 className="text-base font-medium text-foreground truncate">
{connection.bank_name}
</h4>
<div
className={`w-3 h-3 rounded-full ${statusColor}`}
title={connection.status_display}
/>
</div>
<p className="text-sm text-muted-foreground">
{connection.status_display} {" "}
{connection.accounts_count} account
{connection.accounts_count !== 1 ? "s" : ""}
</p>
<p className="text-xs text-muted-foreground font-mono">
ID: {connection.requisition_id}
</p>
</div>
</div>
<div className="flex items-center space-x-2 flex-shrink-0">
<div className="text-right">
<p className="text-xs text-muted-foreground">
Created {formatDate(connection.created_at)}
</p>
</div>
<button
onClick={() => {
const isWorking =
connection.status.toLowerCase() === "ln";
const message = isWorking
? `Are you sure you want to disconnect "${connection.bank_name}"? This will stop syncing new transactions but keep your existing transaction history.`
: `Delete connection to ${connection.bank_name}?`;
if (confirm(message)) {
deleteBankConnectionMutation.mutate(
connection.requisition_id,
);
}
}}
disabled={deleteBankConnectionMutation.isPending}
className="p-1 text-muted-foreground hover:text-destructive transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title="Delete connection"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
</div>
);
})}
</div>
</CardContent>
)}
</Card>
</TabsContent>
<TabsContent value="notifications" className="space-y-6">
{/* Test Notification Section */}
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<TestTube className="h-5 w-5 text-primary" />
<span>Test Notifications</span>
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label htmlFor="service" className="text-foreground">
Service
</Label>
<Select value={testService} onValueChange={setTestService}>
<SelectTrigger>
<SelectValue placeholder="Select a service..." />
</SelectTrigger>
<SelectContent>
{services?.map((service) => (
<SelectItem key={service.name} value={service.name}>
{service.name}{" "}
{service.enabled ? "(Enabled)" : "(Disabled)"}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="message" className="text-foreground">
Message
</Label>
<Input
id="message"
type="text"
value={testMessage}
onChange={(e) => setTestMessage(e.target.value)}
placeholder="Test message..."
/>
</div>
</div>
<div className="mt-4">
<Button
onClick={handleTestNotification}
disabled={!testService || testMutation.isPending}
>
<Send className="h-4 w-4 mr-2" />
{testMutation.isPending
? "Sending..."
: "Send Test Notification"}
</Button>
</div>
</CardContent>
</Card>
{/* Notification Services */}
<Card>
<CardHeader>
@@ -564,45 +655,56 @@ export default function Settings() {
<Bell className="h-6 w-6 text-muted-foreground" />
)}
</div>
<div>
<h4 className="text-lg font-medium text-foreground capitalize">
{service.name}
</h4>
<div className="flex items-center space-x-2 mt-1">
<Badge
variant={
service.enabled ? "default" : "destructive"
}
>
{service.enabled ? (
<CheckCircle className="h-3 w-3 mr-1" />
) : (
<AlertCircle className="h-3 w-3 mr-1" />
)}
{service.enabled ? "Enabled" : "Disabled"}
</Badge>
<Badge
variant={
service.configured ? "secondary" : "outline"
}
>
{service.configured
? "Configured"
: "Not Configured"}
</Badge>
<div className="flex-1">
<div className="flex items-center space-x-3">
<h4 className="text-lg font-medium text-foreground capitalize">
{service.name}
</h4>
<div className="flex items-center space-x-2">
<div
className={`w-2 h-2 rounded-full ${
service.enabled && service.configured
? "bg-green-500"
: service.enabled
? "bg-amber-500"
: "bg-muted-foreground"
}`}
/>
<span className="text-sm text-muted-foreground">
{service.enabled && service.configured
? "Active"
: service.enabled
? "Needs Configuration"
: "Disabled"}
</span>
</div>
</div>
</div>
</div>
<Button
onClick={() => handleDeleteService(service.name)}
disabled={deleteServiceMutation.isPending}
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive hover:bg-destructive/10"
>
<Trash2 className="h-4 w-4" />
</Button>
<div className="flex items-center space-x-2">
{service.name.toLowerCase().includes("discord") ? (
<DiscordConfigDrawer
settings={notificationSettings}
/>
) : service.name
.toLowerCase()
.includes("telegram") ? (
<TelegramConfigDrawer
settings={notificationSettings}
/>
) : null}
<Button
onClick={() => handleDeleteService(service.name)}
disabled={deleteServiceMutation.isPending}
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive hover:bg-destructive/10"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</div>
))}
@@ -611,60 +713,258 @@ export default function Settings() {
)}
</Card>
{/* Notification Settings */}
{/* Notification Filters */}
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<SettingsIcon className="h-5 w-5 text-primary" />
<span>Notification Settings</span>
</CardTitle>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center space-x-2">
<Filter className="h-5 w-5 text-primary" />
<span>Notification Filters</span>
</CardTitle>
<NotificationFiltersDrawer settings={notificationSettings} />
</div>
</CardHeader>
<CardContent>
{notificationSettings && (
{notificationSettings?.filters ? (
<div className="space-y-4">
<div>
<h4 className="text-sm font-medium text-foreground mb-2">
Filters
</h4>
<div className="bg-muted rounded-md p-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<Label className="text-xs font-medium text-muted-foreground mb-1 block">
Case Insensitive Filters
</Label>
<p className="text-sm text-foreground">
{notificationSettings.filters.case_insensitive
.length > 0
? notificationSettings.filters.case_insensitive.join(
", ",
)
: "None"}
</p>
<div className="bg-muted rounded-md p-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<Label className="text-xs font-medium text-muted-foreground mb-2 block">
Case Insensitive Filters
</Label>
<div className="min-h-[2rem] flex flex-wrap gap-1">
{notificationSettings.filters.case_insensitive
.length > 0 ? (
notificationSettings.filters.case_insensitive.map(
(filter, index) => (
<span
key={index}
className="inline-flex items-center px-2 py-1 bg-secondary text-secondary-foreground rounded text-xs"
>
{filter}
</span>
),
)
) : (
<p className="text-sm text-muted-foreground">
None
</p>
)}
</div>
<div>
<Label className="text-xs font-medium text-muted-foreground mb-1 block">
Case Sensitive Filters
</Label>
<p className="text-sm text-foreground">
{notificationSettings.filters.case_sensitive &&
notificationSettings.filters.case_sensitive.length >
0
? notificationSettings.filters.case_sensitive.join(
", ",
)
: "None"}
</p>
</div>
<div>
<Label className="text-xs font-medium text-muted-foreground mb-2 block">
Case Sensitive Filters
</Label>
<div className="min-h-[2rem] flex flex-wrap gap-1">
{notificationSettings.filters.case_sensitive &&
notificationSettings.filters.case_sensitive.length >
0 ? (
notificationSettings.filters.case_sensitive.map(
(filter, index) => (
<span
key={index}
className="inline-flex items-center px-2 py-1 bg-secondary text-secondary-foreground rounded text-xs"
>
{filter}
</span>
),
)
) : (
<p className="text-sm text-muted-foreground">
None
</p>
)}
</div>
</div>
</div>
</div>
<p className="text-sm text-muted-foreground">
Filters determine which transaction descriptions will
trigger notifications. Add terms to exclude transactions
containing those words.
</p>
</div>
) : (
<div className="text-center py-8">
<Filter className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-medium text-foreground mb-2">
No notification filters configured
</h3>
<p className="text-muted-foreground mb-4">
Set up filters to control which transactions trigger
notifications.
</p>
<NotificationFiltersDrawer settings={notificationSettings} />
</div>
)}
</CardContent>
</Card>
</TabsContent>
<div className="text-sm text-muted-foreground">
<p>
Configure notification settings through your backend API
to customize filters and service configurations.
</p>
<TabsContent value="backup" className="space-y-6">
{/* S3 Backup Configuration */}
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<Cloud className="h-5 w-5 text-primary" />
<span>S3 Backup Configuration</span>
</CardTitle>
<CardDescription>
Configure automatic database backups to Amazon S3 or
S3-compatible storage
</CardDescription>
</CardHeader>
<CardContent>
{!backupSettings?.s3 ? (
<div className="text-center py-8">
<Cloud className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-medium text-foreground mb-2">
No S3 backup configured
</h3>
<p className="text-muted-foreground mb-4">
Set up S3 backup to automatically backup your database to
the cloud.
</p>
<S3BackupConfigDrawer settings={backupSettings} />
</div>
) : (
<div className="space-y-4">
<div className="flex items-center justify-between p-4 border rounded-lg">
<div className="flex items-center space-x-4">
<div className="p-3 bg-muted rounded-full">
<Cloud className="h-6 w-6 text-muted-foreground" />
</div>
<div>
<div className="flex items-center space-x-3">
<h4 className="text-lg font-medium text-foreground">
S3 Backup
</h4>
<div className="flex items-center space-x-2">
<div
className={`w-2 h-2 rounded-full ${
backupSettings.s3.enabled
? "bg-green-500"
: "bg-muted-foreground"
}`}
/>
<span className="text-sm text-muted-foreground">
{backupSettings.s3.enabled
? "Enabled"
: "Disabled"}
</span>
</div>
</div>
<div className="mt-2 space-y-1">
<p className="text-sm text-muted-foreground">
<span className="font-medium">Bucket:</span>{" "}
{backupSettings.s3.bucket_name}
</p>
<p className="text-sm text-muted-foreground">
<span className="font-medium">Region:</span>{" "}
{backupSettings.s3.region}
</p>
{backupSettings.s3.endpoint_url && (
<p className="text-sm text-muted-foreground">
<span className="font-medium">Endpoint:</span>{" "}
{backupSettings.s3.endpoint_url}
</p>
)}
</div>
</div>
</div>
<S3BackupConfigDrawer settings={backupSettings} />
</div>
<div className="p-4 bg-muted rounded-lg">
<h5 className="font-medium mb-2">Backup Information</h5>
<p className="text-sm text-muted-foreground mb-3">
Database backups are stored in the "leggen_backups/"
folder in your S3 bucket. Backups include the complete
SQLite database file.
</p>
<div className="flex space-x-2">
<Button
size="sm"
variant="outline"
onClick={handleCreateBackup}
disabled={createBackupMutation.isPending}
>
{createBackupMutation.isPending ? (
<>
<Archive className="h-4 w-4 mr-2 animate-spin" />
Creating...
</>
) : (
<>
<Archive className="h-4 w-4 mr-2" />
Create Backup Now
</>
)}
</Button>
<Button
size="sm"
variant="outline"
onClick={handleViewBackups}
>
<Eye className="h-4 w-4 mr-2" />
View Backups
</Button>
</div>
</div>
{/* Backup List Modal/View */}
{showBackups && (
<div className="mt-6 p-4 border rounded-lg bg-background">
<div className="flex items-center justify-between mb-4">
<h5 className="font-medium">Available Backups</h5>
<Button
size="sm"
variant="ghost"
onClick={() => setShowBackups(false)}
>
<X className="h-4 w-4" />
</Button>
</div>
{backupsLoading ? (
<p className="text-sm text-muted-foreground">Loading backups...</p>
) : backupsError ? (
<div className="space-y-2">
<p className="text-sm text-destructive">Failed to load backups</p>
<Button
size="sm"
variant="outline"
onClick={() => refetchBackups()}
>
<RefreshCw className="h-4 w-4 mr-2" />
Retry
</Button>
</div>
) : !backups || backups.length === 0 ? (
<p className="text-sm text-muted-foreground">No backups found</p>
) : (
<div className="space-y-2">
{backups.map((backup, index) => (
<div
key={backup.key || index}
className="flex items-center justify-between p-3 border rounded bg-muted/50"
>
<div>
<p className="text-sm font-medium">{backup.key}</p>
<div className="flex items-center space-x-4 text-xs text-muted-foreground mt-1">
<span>Modified: {formatDate(backup.last_modified)}</span>
<span>Size: {(backup.size / 1024 / 1024).toFixed(2)} MB</span>
</div>
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
)}
</CardContent>

View File

@@ -30,8 +30,6 @@ export function SiteHeader() {
refetchInterval: 30000,
});
return (
<header className="flex h-16 shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear pt-safe-top">
<div className="flex w-full items-center gap-1 px-4 lg:gap-2 lg:px-6">

View File

@@ -7,6 +7,7 @@ import {
Clock,
TrendingUp,
User,
FileText,
} from "lucide-react";
import { apiClient } from "../lib/api";
import {
@@ -19,7 +20,73 @@ import {
import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
import { Button } from "./ui/button";
import { Badge } from "./ui/badge";
import type { SyncOperationsResponse } from "../types/api";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "./ui/dialog";
import { ScrollArea } from "./ui/scroll-area";
import type { SyncOperationsResponse, SyncOperation } from "../types/api";
// Component for viewing sync operation logs
function LogsDialog({ operation }: { operation: SyncOperation }) {
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" size="sm" className="shrink-0">
<FileText className="h-3 w-3 mr-1" />
<span className="hidden sm:inline">View Logs</span>
<span className="sm:hidden">Logs</span>
</Button>
</DialogTrigger>
<DialogContent className="max-w-4xl max-h-[80vh]">
<DialogHeader>
<DialogTitle>Sync Operation Logs</DialogTitle>
<DialogDescription>
Operation #{operation.id} - Started at{" "}
{new Date(operation.started_at).toLocaleString()}
</DialogDescription>
</DialogHeader>
<ScrollArea className="h-[60vh] w-full rounded border p-4">
<div className="space-y-2">
{operation.logs.length === 0 ? (
<p className="text-muted-foreground text-sm">No logs available</p>
) : (
operation.logs.map((log, index) => (
<div
key={index}
className="text-sm font-mono bg-muted/50 p-2 rounded text-wrap break-all"
>
{log}
</div>
))
)}
</div>
{operation.errors.length > 0 && (
<>
<div className="mt-4 mb-2 text-sm font-semibold text-destructive">
Errors:
</div>
<div className="space-y-2">
{operation.errors.map((error, index) => (
<div
key={index}
className="text-sm font-mono bg-destructive/10 border border-destructive/20 p-2 rounded text-wrap break-all text-destructive"
>
{error}
</div>
))}
</div>
</>
)}
</ScrollArea>
</DialogContent>
</Dialog>
);
}
export default function System() {
const {
@@ -111,68 +178,134 @@ export default function System() {
return (
<div
key={operation.id}
className="flex items-center justify-between p-4 border rounded-lg hover:bg-accent transition-colors"
className="border rounded-lg hover:bg-accent transition-colors"
>
<div className="flex items-center space-x-4">
<div
className={`p-2 rounded-full ${
isRunning
? "bg-blue-100 text-blue-600"
: operation.success
? "bg-green-100 text-green-600"
: "bg-red-100 text-red-600"
}`}
>
{isRunning ? (
<RefreshCw className="h-4 w-4 animate-spin" />
) : operation.success ? (
<CheckCircle className="h-4 w-4" />
) : (
<AlertCircle className="h-4 w-4" />
)}
</div>
<div>
<div className="flex items-center space-x-2">
<h4 className="text-sm font-medium text-foreground">
{isRunning
? "Sync Running"
{/* Desktop Layout */}
<div className="hidden md:flex items-center justify-between p-4">
<div className="flex items-center space-x-4">
<div
className={`p-2 rounded-full ${
isRunning
? "bg-blue-100 text-blue-600"
: operation.success
? "Sync Completed"
: "Sync Failed"}
</h4>
<Badge variant="outline" className="text-xs">
{operation.trigger_type}
</Badge>
? "bg-green-100 text-green-600"
: "bg-red-100 text-red-600"
}`}
>
{isRunning ? (
<RefreshCw className="h-4 w-4 animate-spin" />
) : operation.success ? (
<CheckCircle className="h-4 w-4" />
) : (
<AlertCircle className="h-4 w-4" />
)}
</div>
<div className="flex items-center space-x-4 mt-1 text-xs text-muted-foreground">
<span className="flex items-center space-x-1">
<Clock className="h-3 w-3" />
<span>
{startedAt.toLocaleDateString()}{" "}
{startedAt.toLocaleTimeString()}
<div>
<div className="flex items-center space-x-2">
<h4 className="text-sm font-medium text-foreground">
{isRunning
? "Sync Running"
: operation.success
? "Sync Completed"
: "Sync Failed"}
</h4>
<Badge variant="outline" className="text-xs">
{operation.trigger_type.charAt(0).toUpperCase() +
operation.trigger_type.slice(1)}
</Badge>
</div>
<div className="flex items-center space-x-4 mt-1 text-xs text-muted-foreground">
<span className="flex items-center space-x-1">
<Clock className="h-3 w-3" />
<span>
{startedAt.toLocaleDateString()}{" "}
{startedAt.toLocaleTimeString()}
</span>
</span>
</span>
{duration && <span>Duration: {duration}</span>}
{duration && <span>Duration: {duration}</span>}
</div>
</div>
</div>
<div className="flex items-center space-x-4">
<div className="text-right text-sm text-muted-foreground">
<div className="flex items-center space-x-2">
<User className="h-3 w-3" />
<span>{operation.accounts_processed} accounts</span>
</div>
<div className="flex items-center space-x-2 mt-1">
<TrendingUp className="h-3 w-3" />
<span>
{operation.transactions_added} new transactions
</span>
</div>
</div>
<LogsDialog operation={operation} />
</div>
</div>
<div className="text-right text-sm text-muted-foreground">
<div className="flex items-center space-x-2">
<User className="h-3 w-3" />
<span>{operation.accounts_processed} accounts</span>
</div>
<div className="flex items-center space-x-2 mt-1">
<TrendingUp className="h-3 w-3" />
<span>
{operation.transactions_added} new transactions
</span>
</div>
{operation.errors.length > 0 && (
<div className="flex items-center space-x-2 mt-1 text-red-600">
<AlertCircle className="h-3 w-3" />
<span>{operation.errors.length} errors</span>
{/* Mobile Layout */}
<div className="md:hidden p-4 space-y-3">
<div className="flex items-start justify-between">
<div className="flex items-center space-x-3">
<div
className={`p-2 rounded-full ${
isRunning
? "bg-blue-100 text-blue-600"
: operation.success
? "bg-green-100 text-green-600"
: "bg-red-100 text-red-600"
}`}
>
{isRunning ? (
<RefreshCw className="h-4 w-4 animate-spin" />
) : operation.success ? (
<CheckCircle className="h-4 w-4" />
) : (
<AlertCircle className="h-4 w-4" />
)}
</div>
<div>
<h4 className="text-sm font-medium text-foreground">
{isRunning
? "Sync Running"
: operation.success
? "Sync Completed"
: "Sync Failed"}
</h4>
<Badge variant="outline" className="text-xs mt-1">
{operation.trigger_type.charAt(0).toUpperCase() +
operation.trigger_type.slice(1)}
</Badge>
</div>
</div>
)}
<LogsDialog operation={operation} />
</div>
<div className="text-xs text-muted-foreground space-y-2">
<div className="flex items-center space-x-1">
<Clock className="h-3 w-3" />
<span>
{startedAt.toLocaleDateString()}{" "}
{startedAt.toLocaleTimeString()}
</span>
{duration && (
<span className="ml-2"> {duration}</span>
)}
</div>
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="flex items-center space-x-1">
<User className="h-3 w-3" />
<span>{operation.accounts_processed} accounts</span>
</div>
<div className="flex items-center space-x-1">
<TrendingUp className="h-3 w-3" />
<span>
{operation.transactions_added} new transactions
</span>
</div>
</div>
</div>
</div>
</div>
);

View File

@@ -0,0 +1,222 @@
import { useState, useEffect } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Send, TestTube } from "lucide-react";
import { apiClient } from "../lib/api";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { Label } from "./ui/label";
import { Switch } from "./ui/switch";
import { EditButton } from "./ui/edit-button";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "./ui/drawer";
import type { NotificationSettings, TelegramConfig } from "../types/api";
interface TelegramConfigDrawerProps {
settings: NotificationSettings | undefined;
trigger?: React.ReactNode;
}
export default function TelegramConfigDrawer({
settings,
trigger,
}: TelegramConfigDrawerProps) {
const [open, setOpen] = useState(false);
const [config, setConfig] = useState<TelegramConfig>({
token: "",
chat_id: 0,
enabled: true,
});
const queryClient = useQueryClient();
useEffect(() => {
if (settings?.telegram) {
setConfig({ ...settings.telegram });
}
}, [settings]);
const updateMutation = useMutation({
mutationFn: (telegramConfig: TelegramConfig) =>
apiClient.updateNotificationSettings({
...settings,
telegram: telegramConfig,
filters: settings?.filters || {
case_insensitive: [],
case_sensitive: [],
},
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["notificationSettings"] });
queryClient.invalidateQueries({ queryKey: ["notificationServices"] });
setOpen(false);
},
onError: (error) => {
console.error("Failed to update Telegram configuration:", error);
},
});
const testMutation = useMutation({
mutationFn: () =>
apiClient.testNotification({
service: "telegram",
message:
"Test notification from Leggen - Telegram configuration is working!",
}),
onSuccess: () => {
console.log("Test Telegram notification sent successfully");
},
onError: (error) => {
console.error("Failed to send test Telegram notification:", error);
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
updateMutation.mutate(config);
};
const handleTest = () => {
testMutation.mutate();
};
const isConfigValid = config.token.trim().length > 0 && config.chat_id !== 0;
return (
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild>{trigger || <EditButton />}</DrawerTrigger>
<DrawerContent>
<div className="mx-auto w-full max-w-md">
<DrawerHeader>
<DrawerTitle className="flex items-center space-x-2">
<Send className="h-5 w-5 text-primary" />
<span>Telegram Configuration</span>
</DrawerTitle>
<DrawerDescription>
Configure Telegram bot notifications for transaction alerts
</DrawerDescription>
</DrawerHeader>
<form onSubmit={handleSubmit} className="p-4 space-y-6">
{/* Enable/Disable Toggle */}
<div className="flex items-center justify-between">
<Label className="text-base font-medium">
Enable Telegram Notifications
</Label>
<Switch
checked={config.enabled}
onCheckedChange={(enabled) => setConfig({ ...config, enabled })}
/>
</div>
{/* Bot Token */}
<div className="space-y-2">
<Label htmlFor="telegram-token">Bot Token</Label>
<Input
id="telegram-token"
type="password"
placeholder="123456789:ABCdefGHIjklMNOpqrsTUVwxyz"
value={config.token}
onChange={(e) =>
setConfig({ ...config, token: e.target.value })
}
disabled={!config.enabled}
/>
<p className="text-xs text-muted-foreground">
Create a bot using @BotFather on Telegram to get your token
</p>
</div>
{/* Chat ID */}
<div className="space-y-2">
<Label htmlFor="telegram-chat-id">Chat ID</Label>
<Input
id="telegram-chat-id"
type="number"
placeholder="123456789"
value={config.chat_id || ""}
onChange={(e) =>
setConfig({
...config,
chat_id: parseInt(e.target.value) || 0,
})
}
disabled={!config.enabled}
/>
<p className="text-xs text-muted-foreground">
Send a message to your bot and visit
https://api.telegram.org/bot&lt;token&gt;/getUpdates to find
your chat ID
</p>
</div>
{/* Configuration Status */}
{config.enabled && (
<div className="p-3 bg-muted rounded-md">
<div className="flex items-center space-x-2">
<div
className={`w-2 h-2 rounded-full ${isConfigValid ? "bg-green-500" : "bg-red-500"}`}
/>
<span className="text-sm font-medium">
{isConfigValid
? "Configuration Valid"
: "Missing Token or Chat ID"}
</span>
</div>
{!isConfigValid &&
(config.token.trim().length > 0 || config.chat_id !== 0) && (
<p className="text-xs text-muted-foreground mt-1">
Both bot token and chat ID are required
</p>
)}
</div>
)}
<DrawerFooter className="px-0">
<div className="flex space-x-2">
<Button
type="submit"
disabled={updateMutation.isPending || !config.enabled}
>
{updateMutation.isPending
? "Saving..."
: "Save Configuration"}
</Button>
{config.enabled && isConfigValid && (
<Button
type="button"
variant="outline"
onClick={handleTest}
disabled={testMutation.isPending}
>
{testMutation.isPending ? (
<>
<TestTube className="h-4 w-4 mr-2 animate-spin" />
Testing...
</>
) : (
<>
<TestTube className="h-4 w-4 mr-2" />
Test
</>
)}
</Button>
)}
</div>
<DrawerClose asChild>
<Button variant="ghost">Cancel</Button>
</DrawerClose>
</DrawerFooter>
</form>
</div>
</DrawerContent>
</Drawer>
);
}

View File

@@ -190,8 +190,7 @@ export default function TransactionsTable() {
<div className="text-xs text-muted-foreground space-y-1">
{account && (
<p className="truncate">
{account.name || "Unnamed Account"} {" "}
{account.institution_id}
{account.display_name || "Unnamed Account"}
</p>
)}
{(transaction.creditor_name || transaction.debtor_name) && (
@@ -486,8 +485,7 @@ export default function TransactionsTable() {
<div className="text-xs text-muted-foreground space-y-1 mt-1">
{account && (
<p className="break-words">
{account.name || "Unnamed Account"} {" "}
{account.institution_id}
{account.display_name || "Unnamed Account"}
</p>
)}
{(transaction.creditor_name ||

View File

@@ -15,7 +15,9 @@ export default function TimePeriodFilter({
className = "",
}: TimePeriodFilterProps) {
return (
<div className={`flex flex-col sm:flex-row sm:items-center gap-4 ${className}`}>
<div
className={`flex flex-col sm:flex-row sm:items-center gap-4 ${className}`}
>
<div className="flex items-center gap-2 text-foreground">
<Calendar size={20} />
<span className="font-medium">Time Period:</span>

View File

@@ -0,0 +1,116 @@
import * as React from "react";
import { Drawer as DrawerPrimitive } from "vaul";
import { cn } from "@/lib/utils";
const Drawer = ({
shouldScaleBackground = true,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
<DrawerPrimitive.Root
shouldScaleBackground={shouldScaleBackground}
{...props}
/>
);
Drawer.displayName = "Drawer";
const DrawerTrigger = DrawerPrimitive.Trigger;
const DrawerPortal = DrawerPrimitive.Portal;
const DrawerClose = DrawerPrimitive.Close;
const DrawerOverlay = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Overlay
ref={ref}
className={cn("fixed inset-0 z-50 bg-black/80", className)}
{...props}
/>
));
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName;
const DrawerContent = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DrawerPortal>
<DrawerOverlay />
<DrawerPrimitive.Content
ref={ref}
className={cn(
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background",
className,
)}
{...props}
>
<div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
));
DrawerContent.displayName = "DrawerContent";
const DrawerHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)}
{...props}
/>
);
DrawerHeader.displayName = "DrawerHeader";
const DrawerFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
);
DrawerFooter.displayName = "DrawerFooter";
const DrawerTitle = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className,
)}
{...props}
/>
));
DrawerTitle.displayName = DrawerPrimitive.Title.displayName;
const DrawerDescription = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
DrawerDescription.displayName = DrawerPrimitive.Description.displayName;
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
};

View File

@@ -0,0 +1,45 @@
import { Edit3 } from "lucide-react";
import { Button } from "./button";
import { cn } from "../../lib/utils";
interface EditButtonProps {
onClick?: () => void;
disabled?: boolean;
className?: string;
size?: "default" | "sm" | "lg" | "icon";
variant?:
| "default"
| "destructive"
| "outline"
| "secondary"
| "ghost"
| "link";
children?: React.ReactNode;
}
export function EditButton({
onClick,
disabled = false,
className,
size = "sm",
variant = "outline",
children,
...props
}: EditButtonProps) {
return (
<Button
onClick={onClick}
disabled={disabled}
size={size}
variant={variant}
className={cn(
"h-8 px-3 text-muted-foreground hover:text-foreground transition-colors",
className,
)}
{...props}
>
<Edit3 className="h-4 w-4" />
<span className="ml-2">{children || "Edit"}</span>
</Button>
);
}

View File

@@ -0,0 +1,21 @@
import * as React from "react";
import { cn } from "../../lib/utils";
const ScrollArea = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, children, ...props }, ref) => (
<div
ref={ref}
className={cn(
"relative overflow-auto scrollbar-thin scrollbar-thumb-gray-300 scrollbar-track-gray-100",
className,
)}
{...props}
>
{children}
</div>
));
ScrollArea.displayName = "ScrollArea";
export { ScrollArea };

View File

@@ -0,0 +1,27 @@
import * as React from "react";
import * as SwitchPrimitives from "@radix-ui/react-switch";
import { cn } from "@/lib/utils";
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className,
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0",
)}
/>
</SwitchPrimitives.Root>
));
Switch.displayName = SwitchPrimitives.Root.displayName;
export { Switch };

View File

@@ -3,6 +3,7 @@ import { useEffect, useState } from "react";
interface PWAUpdate {
updateAvailable: boolean;
updateSW: () => Promise<void>;
forceReload: () => Promise<void>;
}
export function usePWA(): PWAUpdate {
@@ -11,6 +12,35 @@ export function usePWA(): PWAUpdate {
() => async () => {},
);
const forceReload = async (): Promise<void> => {
try {
// Clear all caches
if ("caches" in window) {
const cacheNames = await caches.keys();
await Promise.all(
cacheNames.map((cacheName) => caches.delete(cacheName)),
);
console.log("All caches cleared");
}
// Unregister service worker
if ("serviceWorker" in navigator) {
const registrations = await navigator.serviceWorker.getRegistrations();
await Promise.all(
registrations.map((registration) => registration.unregister()),
);
console.log("All service workers unregistered");
}
// Force reload
window.location.reload();
} catch (error) {
console.error("Error during force reload:", error);
// Fallback: just reload the page
window.location.reload();
}
};
useEffect(() => {
// Check if SW registration is available
if ("serviceWorker" in navigator) {
@@ -37,5 +67,6 @@ export function usePWA(): PWAUpdate {
return {
updateAvailable,
updateSW,
forceReload,
};
}

View File

@@ -0,0 +1,39 @@
import { useEffect } from "react";
import { useQuery } from "@tanstack/react-query";
import { apiClient } from "../lib/api";
const VERSION_STORAGE_KEY = "leggen_app_version";
export function useVersionCheck(forceReload: () => Promise<void>) {
const { data: healthStatus, isSuccess: healthSuccess } = useQuery({
queryKey: ["health"],
queryFn: apiClient.getHealth,
refetchInterval: 30000,
retry: false,
staleTime: 0, // Always consider data stale to ensure fresh version checks
});
useEffect(() => {
if (healthSuccess && healthStatus?.version) {
const currentVersion = healthStatus.version;
const storedVersion = localStorage.getItem(VERSION_STORAGE_KEY);
if (storedVersion && storedVersion !== currentVersion) {
console.log(
`Version mismatch detected: stored=${storedVersion}, current=${currentVersion}`,
);
console.log("Clearing cache and reloading...");
// Update stored version first
localStorage.setItem(VERSION_STORAGE_KEY, currentVersion);
// Force reload to clear cache
forceReload();
} else if (!storedVersion) {
// First time loading, store the version
localStorage.setItem(VERSION_STORAGE_KEY, currentVersion);
console.log(`Version stored: ${currentVersion}`);
}
}
}, [healthSuccess, healthStatus?.version, forceReload]);
}

View File

@@ -13,6 +13,14 @@ import type {
AccountUpdate,
TransactionStats,
SyncOperationsResponse,
BankInstitution,
BankConnectionStatus,
BankRequisition,
Country,
BackupSettings,
BackupTest,
BackupInfo,
BackupOperation,
} from "../types/api";
// Use VITE_API_URL for development, relative URLs for production
@@ -168,8 +176,6 @@ export const apiClient = {
return response.data.data;
},
// Analytics endpoints
getTransactionStats: async (days?: number): Promise<TransactionStats> => {
const queryParams = new URLSearchParams();
@@ -231,6 +237,79 @@ export const apiClient = {
);
return response.data.data;
},
// Bank management endpoints
getBankInstitutions: async (country: string): Promise<BankInstitution[]> => {
const response = await api.get<ApiResponse<BankInstitution[]>>(
`/banks/institutions?country=${country}`,
);
return response.data.data;
},
getBankConnectionsStatus: async (): Promise<BankConnectionStatus[]> => {
const response =
await api.get<ApiResponse<BankConnectionStatus[]>>("/banks/status");
return response.data.data;
},
createBankConnection: async (
institutionId: string,
redirectUrl?: string,
): Promise<BankRequisition> => {
// If no redirect URL provided, construct it from current location
const finalRedirectUrl =
redirectUrl || `${window.location.origin}/bank-connected`;
const response = await api.post<ApiResponse<BankRequisition>>(
"/banks/connect",
{
institution_id: institutionId,
redirect_url: finalRedirectUrl,
},
);
return response.data.data;
},
deleteBankConnection: async (requisitionId: string): Promise<void> => {
await api.delete(`/banks/connections/${requisitionId}`);
},
getSupportedCountries: async (): Promise<Country[]> => {
const response = await api.get<ApiResponse<Country[]>>("/banks/countries");
return response.data.data;
},
// Backup endpoints
getBackupSettings: async (): Promise<BackupSettings> => {
const response =
await api.get<ApiResponse<BackupSettings>>("/backup/settings");
return response.data.data;
},
updateBackupSettings: async (
settings: BackupSettings,
): Promise<BackupSettings> => {
const response = await api.put<ApiResponse<BackupSettings>>(
"/backup/settings",
settings,
);
return response.data.data;
},
testBackupConnection: async (test: BackupTest): Promise<ApiResponse<{ connected?: boolean }>> => {
const response = await api.post<ApiResponse<{ connected?: boolean }>>("/backup/test", test);
return response.data;
},
listBackups: async (): Promise<BackupInfo[]> => {
const response = await api.get<ApiResponse<BackupInfo[]>>("/backup/list");
return response.data.data;
},
performBackupOperation: async (operation: BackupOperation): Promise<ApiResponse<{ operation: string; completed: boolean }>> => {
const response = await api.post<ApiResponse<{ operation: string; completed: boolean }>>("/backup/operation", operation);
return response.data;
},
};
export default apiClient;

View File

@@ -13,6 +13,7 @@ import { Route as TransactionsRouteImport } from './routes/transactions'
import { Route as SystemRouteImport } from './routes/system'
import { Route as SettingsRouteImport } from './routes/settings'
import { Route as NotificationsRouteImport } from './routes/notifications'
import { Route as BankConnectedRouteImport } from './routes/bank-connected'
import { Route as AnalyticsRouteImport } from './routes/analytics'
import { Route as IndexRouteImport } from './routes/index'
@@ -36,6 +37,11 @@ const NotificationsRoute = NotificationsRouteImport.update({
path: '/notifications',
getParentRoute: () => rootRouteImport,
} as any)
const BankConnectedRoute = BankConnectedRouteImport.update({
id: '/bank-connected',
path: '/bank-connected',
getParentRoute: () => rootRouteImport,
} as any)
const AnalyticsRoute = AnalyticsRouteImport.update({
id: '/analytics',
path: '/analytics',
@@ -50,6 +56,7 @@ const IndexRoute = IndexRouteImport.update({
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/analytics': typeof AnalyticsRoute
'/bank-connected': typeof BankConnectedRoute
'/notifications': typeof NotificationsRoute
'/settings': typeof SettingsRoute
'/system': typeof SystemRoute
@@ -58,6 +65,7 @@ export interface FileRoutesByFullPath {
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/analytics': typeof AnalyticsRoute
'/bank-connected': typeof BankConnectedRoute
'/notifications': typeof NotificationsRoute
'/settings': typeof SettingsRoute
'/system': typeof SystemRoute
@@ -67,6 +75,7 @@ export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/analytics': typeof AnalyticsRoute
'/bank-connected': typeof BankConnectedRoute
'/notifications': typeof NotificationsRoute
'/settings': typeof SettingsRoute
'/system': typeof SystemRoute
@@ -77,6 +86,7 @@ export interface FileRouteTypes {
fullPaths:
| '/'
| '/analytics'
| '/bank-connected'
| '/notifications'
| '/settings'
| '/system'
@@ -85,6 +95,7 @@ export interface FileRouteTypes {
to:
| '/'
| '/analytics'
| '/bank-connected'
| '/notifications'
| '/settings'
| '/system'
@@ -93,6 +104,7 @@ export interface FileRouteTypes {
| '__root__'
| '/'
| '/analytics'
| '/bank-connected'
| '/notifications'
| '/settings'
| '/system'
@@ -102,6 +114,7 @@ export interface FileRouteTypes {
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
AnalyticsRoute: typeof AnalyticsRoute
BankConnectedRoute: typeof BankConnectedRoute
NotificationsRoute: typeof NotificationsRoute
SettingsRoute: typeof SettingsRoute
SystemRoute: typeof SystemRoute
@@ -138,6 +151,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof NotificationsRouteImport
parentRoute: typeof rootRouteImport
}
'/bank-connected': {
id: '/bank-connected'
path: '/bank-connected'
fullPath: '/bank-connected'
preLoaderRoute: typeof BankConnectedRouteImport
parentRoute: typeof rootRouteImport
}
'/analytics': {
id: '/analytics'
path: '/analytics'
@@ -158,6 +178,7 @@ declare module '@tanstack/react-router' {
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
AnalyticsRoute: AnalyticsRoute,
BankConnectedRoute: BankConnectedRoute,
NotificationsRoute: NotificationsRoute,
SettingsRoute: SettingsRoute,
SystemRoute: SystemRoute,

View File

@@ -3,10 +3,15 @@ import { AppSidebar } from "../components/AppSidebar";
import { SiteHeader } from "../components/SiteHeader";
import { PWAInstallPrompt, PWAUpdatePrompt } from "../components/PWAPrompts";
import { usePWA } from "../hooks/usePWA";
import { useVersionCheck } from "../hooks/useVersionCheck";
import { SidebarInset, SidebarProvider } from "../components/ui/sidebar";
import { Toaster } from "../components/ui/sonner";
function RootLayout() {
const { updateAvailable, updateSW } = usePWA();
const { updateAvailable, updateSW, forceReload } = usePWA();
// Check for version mismatches and force reload if needed
useVersionCheck(forceReload);
const handlePWAInstall = () => {
console.log("PWA installed successfully");
@@ -44,6 +49,9 @@ function RootLayout() {
updateAvailable={updateAvailable}
onUpdate={handlePWAUpdate}
/>
{/* Toast Notifications */}
<Toaster />
</SidebarProvider>
);
}

View File

@@ -44,7 +44,7 @@ function AnalyticsDashboard() {
if (isLoading) {
return (
<div className="p-6">
<div className="space-y-8">
<div className="animate-pulse">
<div className="h-8 bg-muted rounded w-48 mb-6"></div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
@@ -62,7 +62,7 @@ function AnalyticsDashboard() {
}
return (
<div className="p-6 space-y-8">
<div className="space-y-8">
{/* Time Period Filter */}
<Card>
<CardContent className="p-4">

View File

@@ -0,0 +1,57 @@
import { createFileRoute, useSearch } from "@tanstack/react-router";
import { CheckCircle, ArrowLeft } from "lucide-react";
import { Button } from "../components/ui/button";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "../components/ui/card";
function BankConnected() {
const search = useSearch({ from: "/bank-connected" });
return (
<div className="min-h-screen flex items-center justify-center bg-background p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center pb-2">
<div className="mx-auto mb-4">
<CheckCircle className="h-16 w-16 text-green-500" />
</div>
<CardTitle className="text-2xl">Account Connected!</CardTitle>
</CardHeader>
<CardContent className="text-center space-y-4">
<p className="text-muted-foreground">
Your bank account has been successfully connected to Leggen. We'll
start syncing your transactions shortly.
</p>
{search?.bank && (
<p className="text-sm text-muted-foreground">
Connected to: <strong>{search.bank}</strong>
</p>
)}
<div className="pt-4">
<Button
onClick={() => (window.location.href = "/settings")}
className="w-full"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Go to Settings
</Button>
</div>
</CardContent>
</Card>
</div>
);
}
export const Route = createFileRoute("/bank-connected")({
component: BankConnected,
validateSearch: (search: Record<string, unknown>) => {
return {
bank: (search.bank as string) || undefined,
};
},
});

View File

@@ -13,6 +13,7 @@ export interface Account {
name?: string;
display_name?: string;
currency?: string;
logo?: string;
created: string;
last_accessed?: string;
balances: AccountBalance[];
@@ -241,3 +242,69 @@ export interface SyncOperationsResponse {
operations: SyncOperation[];
count: number;
}
// Bank-related types
export interface BankInstitution {
id: string;
name: string;
bic?: string;
transaction_total_days: number;
countries: string[];
logo?: string;
}
export interface BankRequisition {
id: string;
institution_id: string;
status: string;
status_display?: string;
created: string;
link: string;
accounts: string[];
}
export interface BankConnectionStatus {
bank_id: string;
bank_name: string;
status: string;
status_display: string;
created_at: string;
requisition_id: string;
accounts_count: number;
}
export interface Country {
code: string;
name: string;
}
// Backup types
export interface S3Config {
access_key_id: string;
secret_access_key: string;
bucket_name: string;
region: string;
endpoint_url?: string;
path_style: boolean;
enabled: boolean;
}
export interface BackupSettings {
s3?: S3Config;
}
export interface BackupTest {
service: string;
config: S3Config;
}
export interface BackupInfo {
key: string;
last_modified: string;
size: number;
}
export interface BackupOperation {
operation: string;
backup_key?: string;
}

View File

@@ -1,12 +1,12 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { TanStackRouterVite } from "@tanstack/router-vite-plugin";
import { tanstackRouter } from "@tanstack/router-vite-plugin";
import { VitePWA } from "vite-plugin-pwa";
// https://vite.dev/config/
export default defineConfig({
plugins: [
TanStackRouterVite(),
tanstackRouter(),
react(),
VitePWA({
registerType: "autoUpdate",

View File

@@ -26,6 +26,7 @@ class AccountDetails(BaseModel):
name: Optional[str] = None
display_name: Optional[str] = None
currency: Optional[str] = None
logo: Optional[str] = None
created: datetime
last_accessed: Optional[datetime] = None
balances: List[AccountBalance] = []

View File

@@ -0,0 +1,49 @@
"""API models for backup endpoints."""
from typing import Optional
from pydantic import BaseModel, Field
class S3Config(BaseModel):
"""S3 backup configuration model for API."""
access_key_id: str = Field(..., description="AWS S3 access key ID")
secret_access_key: str = Field(..., description="AWS S3 secret access key")
bucket_name: str = Field(..., description="S3 bucket name")
region: str = Field(default="us-east-1", description="AWS S3 region")
endpoint_url: Optional[str] = Field(
default=None, description="Custom S3 endpoint URL"
)
path_style: bool = Field(default=False, description="Use path-style addressing")
enabled: bool = Field(default=True, description="Enable S3 backups")
class BackupSettings(BaseModel):
"""Backup settings model for API."""
s3: Optional[S3Config] = None
class BackupTest(BaseModel):
"""Backup connection test request model."""
service: str = Field(..., description="Backup service type (s3)")
config: S3Config = Field(..., description="S3 configuration to test")
class BackupInfo(BaseModel):
"""Backup file information model."""
key: str = Field(..., description="S3 object key")
last_modified: str = Field(..., description="Last modified timestamp (ISO format)")
size: int = Field(..., description="File size in bytes")
class BackupOperation(BaseModel):
"""Backup operation request model."""
operation: str = Field(..., description="Operation type (backup, restore)")
backup_key: Optional[str] = Field(
default=None, description="Backup key for restore operations"
)

View File

@@ -18,7 +18,7 @@ class SyncOperation(BaseModel):
duration_seconds: Optional[float] = None
errors: list[str] = []
logs: list[str] = []
trigger_type: str = "manual" # manual, scheduled, api
trigger_type: str = "manual" # manual, scheduled, retry, api
class Config:
json_encoders = {datetime: lambda v: v.isoformat() if v else None}

View File

@@ -55,6 +55,7 @@ async def get_all_accounts() -> APIResponse:
name=db_account.get("name"),
display_name=db_account.get("display_name"),
currency=db_account.get("currency"),
logo=db_account.get("logo"),
created=db_account["created"],
last_accessed=db_account.get("last_accessed"),
balances=balances,
@@ -115,6 +116,7 @@ async def get_account_details(account_id: str) -> APIResponse:
name=db_account.get("name"),
display_name=db_account.get("display_name"),
currency=db_account.get("currency"),
logo=db_account.get("logo"),
created=db_account["created"],
last_accessed=db_account.get("last_accessed"),
balances=balances,

264
leggen/api/routes/backup.py Normal file
View File

@@ -0,0 +1,264 @@
"""API routes for backup management."""
from fastapi import APIRouter, HTTPException
from loguru import logger
from leggen.api.models.backup import (
BackupOperation,
BackupSettings,
BackupTest,
S3Config,
)
from leggen.api.models.common import APIResponse
from leggen.models.config import S3BackupConfig
from leggen.services.backup_service import BackupService
from leggen.utils.config import config
from leggen.utils.paths import path_manager
router = APIRouter()
@router.get("/backup/settings", response_model=APIResponse)
async def get_backup_settings() -> APIResponse:
"""Get current backup settings."""
try:
backup_config = config.backup_config
# Build response safely without exposing secrets
s3_config = backup_config.get("s3", {})
settings = BackupSettings(
s3=S3Config(
access_key_id="***" if s3_config.get("access_key_id") else "",
secret_access_key="***" if s3_config.get("secret_access_key") else "",
bucket_name=s3_config.get("bucket_name", ""),
region=s3_config.get("region", "us-east-1"),
endpoint_url=s3_config.get("endpoint_url"),
path_style=s3_config.get("path_style", False),
enabled=s3_config.get("enabled", True),
)
if s3_config.get("bucket_name")
else None,
)
return APIResponse(
success=True,
data=settings,
message="Backup settings retrieved successfully",
)
except Exception as e:
logger.error(f"Failed to get backup settings: {e}")
raise HTTPException(
status_code=500, detail=f"Failed to get backup settings: {str(e)}"
) from e
@router.put("/backup/settings", response_model=APIResponse)
async def update_backup_settings(settings: BackupSettings) -> APIResponse:
"""Update backup settings."""
try:
# First test the connection if S3 config is provided
if settings.s3:
# Convert API model to config model
s3_config = S3BackupConfig(
access_key_id=settings.s3.access_key_id,
secret_access_key=settings.s3.secret_access_key,
bucket_name=settings.s3.bucket_name,
region=settings.s3.region,
endpoint_url=settings.s3.endpoint_url,
path_style=settings.s3.path_style,
enabled=settings.s3.enabled,
)
# Test connection
backup_service = BackupService()
connection_success = await backup_service.test_connection(s3_config)
if not connection_success:
raise HTTPException(
status_code=400,
detail="S3 connection test failed. Please check your configuration.",
)
# Update backup config
backup_config = {}
if settings.s3:
backup_config["s3"] = {
"access_key_id": settings.s3.access_key_id,
"secret_access_key": settings.s3.secret_access_key,
"bucket_name": settings.s3.bucket_name,
"region": settings.s3.region,
"endpoint_url": settings.s3.endpoint_url,
"path_style": settings.s3.path_style,
"enabled": settings.s3.enabled,
}
# Save to config
if backup_config:
config.update_section("backup", backup_config)
return APIResponse(
success=True,
data={"updated": True},
message="Backup settings updated successfully",
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to update backup settings: {e}")
raise HTTPException(
status_code=500, detail=f"Failed to update backup settings: {str(e)}"
) from e
@router.post("/backup/test", response_model=APIResponse)
async def test_backup_connection(test_request: BackupTest) -> APIResponse:
"""Test backup connection."""
try:
if test_request.service != "s3":
raise HTTPException(
status_code=400, detail="Only 's3' service is supported"
)
# Convert API model to config model
s3_config = S3BackupConfig(
access_key_id=test_request.config.access_key_id,
secret_access_key=test_request.config.secret_access_key,
bucket_name=test_request.config.bucket_name,
region=test_request.config.region,
endpoint_url=test_request.config.endpoint_url,
path_style=test_request.config.path_style,
enabled=test_request.config.enabled,
)
backup_service = BackupService()
success = await backup_service.test_connection(s3_config)
if success:
return APIResponse(
success=True,
data={"connected": True},
message="S3 connection test successful",
)
else:
return APIResponse(
success=False,
message="S3 connection test failed",
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to test backup connection: {e}")
raise HTTPException(
status_code=500, detail=f"Failed to test backup connection: {str(e)}"
) from e
@router.get("/backup/list", response_model=APIResponse)
async def list_backups() -> APIResponse:
"""List available backups."""
try:
backup_config = config.backup_config.get("s3", {})
if not backup_config.get("bucket_name"):
return APIResponse(
success=True,
data=[],
message="No S3 backup configuration found",
)
# Convert config to model
s3_config = S3BackupConfig(**backup_config)
backup_service = BackupService(s3_config)
backups = await backup_service.list_backups()
return APIResponse(
success=True,
data=backups,
message=f"Found {len(backups)} backups",
)
except Exception as e:
logger.error(f"Failed to list backups: {e}")
raise HTTPException(
status_code=500, detail=f"Failed to list backups: {str(e)}"
) from e
@router.post("/backup/operation", response_model=APIResponse)
async def backup_operation(operation_request: BackupOperation) -> APIResponse:
"""Perform backup operation (backup or restore)."""
try:
backup_config = config.backup_config.get("s3", {})
if not backup_config.get("bucket_name"):
raise HTTPException(status_code=400, detail="S3 backup is not configured")
# Convert config to model with validation
try:
s3_config = S3BackupConfig(**backup_config)
except Exception as e:
raise HTTPException(
status_code=400, detail=f"Invalid S3 configuration: {str(e)}"
) from e
backup_service = BackupService(s3_config)
if operation_request.operation == "backup":
# Backup database
database_path = path_manager.get_database_path()
success = await backup_service.backup_database(database_path)
if success:
return APIResponse(
success=True,
data={"operation": "backup", "completed": True},
message="Database backup completed successfully",
)
else:
return APIResponse(
success=False,
message="Database backup failed",
)
elif operation_request.operation == "restore":
if not operation_request.backup_key:
raise HTTPException(
status_code=400,
detail="backup_key is required for restore operation",
)
# Restore database
database_path = path_manager.get_database_path()
success = await backup_service.restore_database(
operation_request.backup_key, database_path
)
if success:
return APIResponse(
success=True,
data={"operation": "restore", "completed": True},
message="Database restore completed successfully",
)
else:
return APIResponse(
success=False,
message="Database restore failed",
)
else:
raise HTTPException(
status_code=400, detail="Invalid operation. Use 'backup' or 'restore'"
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to perform backup operation: {e}")
raise HTTPException(
status_code=500, detail=f"Failed to perform backup operation: {str(e)}"
) from e

View File

@@ -1,3 +1,4 @@
import httpx
from fastapi import APIRouter, HTTPException, Query
from loguru import logger
@@ -21,14 +22,19 @@ async def get_bank_institutions(
) -> APIResponse:
"""Get available bank institutions for a country"""
try:
institutions_data = await gocardless_service.get_institutions(country)
institutions_response = await gocardless_service.get_institutions(country)
# Handle both list and dict responses
if isinstance(institutions_response, list):
institutions_data = institutions_response
else:
institutions_data = institutions_response.get("results", [])
institutions = [
BankInstitution(
id=inst["id"],
name=inst["name"],
bic=inst.get("bic"),
transaction_total_days=inst["transaction_total_days"],
transaction_total_days=int(inst["transaction_total_days"]),
countries=inst["countries"],
logo=inst.get("logo"),
)
@@ -121,13 +127,36 @@ async def get_bank_connections_status() -> APIResponse:
async def delete_bank_connection(requisition_id: str) -> APIResponse:
"""Delete a bank connection"""
try:
# This would need to be implemented in GoCardlessService
# For now, return success
# Delete the requisition from GoCardless
result = await gocardless_service.delete_requisition(requisition_id)
# GoCardless returns different responses for successful deletes
# We should check if the operation was actually successful
logger.info(f"GoCardless delete response for {requisition_id}: {result}")
return APIResponse(
success=True,
message=f"Bank connection {requisition_id} deleted successfully",
)
except httpx.HTTPStatusError as http_err:
logger.error(
f"HTTP error deleting bank connection {requisition_id}: {http_err}"
)
if http_err.response.status_code == 404:
raise HTTPException(
status_code=404, detail=f"Bank connection {requisition_id} not found"
) from http_err
elif http_err.response.status_code == 400:
raise HTTPException(
status_code=400,
detail=f"Invalid request to delete connection {requisition_id}",
) from http_err
else:
raise HTTPException(
status_code=http_err.response.status_code,
detail=f"GoCardless API error: {http_err}",
) from http_err
except Exception as e:
logger.error(f"Failed to delete bank connection {requisition_id}: {e}")
raise HTTPException(

View File

@@ -1,6 +1,6 @@
import os
from typing import Any, Dict, List, Optional, Union
from urllib.parse import urljoin
from urllib.parse import urljoin, urlparse
import requests
@@ -13,11 +13,22 @@ class LeggenAPIClient:
base_url: str
def __init__(self, base_url: Optional[str] = None):
self.base_url = (
raw_url = (
base_url
or os.environ.get("LEGGEN_API_URL", "http://localhost:8000")
or "http://localhost:8000"
)
# Ensure base_url includes /api/v1 path if not already present
parsed = urlparse(raw_url)
if not parsed.path or parsed.path == "/":
# No path or just root, add /api/v1
self.base_url = f"{raw_url.rstrip('/')}/api/v1"
elif not parsed.path.startswith("/api/v1"):
# Has a path but not /api/v1, add it
self.base_url = f"{raw_url.rstrip('/')}/api/v1"
else:
# Already has /api/v1 path
self.base_url = raw_url.rstrip("/")
self.session = requests.Session()
self.session.headers.update(
{"Content-Type": "application/json", "Accept": "application/json"}
@@ -25,7 +36,14 @@ class LeggenAPIClient:
def _make_request(self, method: str, endpoint: str, **kwargs) -> Dict[str, Any]:
"""Make HTTP request to the API"""
url = urljoin(self.base_url, endpoint)
# Construct URL by joining base_url with endpoint
# Handle both relative endpoints (starting with /) and paths
if endpoint.startswith("/"):
# Absolute endpoint path - append to base_url
url = f"{self.base_url}{endpoint}"
else:
# Relative endpoint, use urljoin
url = urljoin(f"{self.base_url}/", endpoint)
try:
response = self.session.request(method, url, **kwargs)
@@ -52,7 +70,9 @@ class LeggenAPIClient:
"""Check if the leggen server is healthy"""
try:
response = self._make_request("GET", "/health")
return response.get("status") == "healthy"
# The API now returns nested data structure
data = response.get("data", {})
return data.get("status") == "healthy"
except Exception:
return False
@@ -60,7 +80,7 @@ class LeggenAPIClient:
def get_institutions(self, country: str = "PT") -> List[Dict[str, Any]]:
"""Get bank institutions for a country"""
response = self._make_request(
"GET", "/api/v1/banks/institutions", params={"country": country}
"GET", "/banks/institutions", params={"country": country}
)
return response.get("data", [])
@@ -70,35 +90,35 @@ class LeggenAPIClient:
"""Connect to a bank"""
response = self._make_request(
"POST",
"/api/v1/banks/connect",
"/banks/connect",
json={"institution_id": institution_id, "redirect_url": redirect_url},
)
return response.get("data", {})
def get_bank_status(self) -> List[Dict[str, Any]]:
"""Get bank connection status"""
response = self._make_request("GET", "/api/v1/banks/status")
response = self._make_request("GET", "/banks/status")
return response.get("data", [])
def get_supported_countries(self) -> List[Dict[str, Any]]:
"""Get supported countries"""
response = self._make_request("GET", "/api/v1/banks/countries")
response = self._make_request("GET", "/banks/countries")
return response.get("data", [])
# Account endpoints
def get_accounts(self) -> List[Dict[str, Any]]:
"""Get all accounts"""
response = self._make_request("GET", "/api/v1/accounts")
response = self._make_request("GET", "/accounts")
return response.get("data", [])
def get_account_details(self, account_id: str) -> Dict[str, Any]:
"""Get account details"""
response = self._make_request("GET", f"/api/v1/accounts/{account_id}")
response = self._make_request("GET", f"/accounts/{account_id}")
return response.get("data", {})
def get_account_balances(self, account_id: str) -> List[Dict[str, Any]]:
"""Get account balances"""
response = self._make_request("GET", f"/api/v1/accounts/{account_id}/balances")
response = self._make_request("GET", f"/accounts/{account_id}/balances")
return response.get("data", [])
def get_account_transactions(
@@ -107,7 +127,7 @@ class LeggenAPIClient:
"""Get account transactions"""
response = self._make_request(
"GET",
f"/api/v1/accounts/{account_id}/transactions",
f"/accounts/{account_id}/transactions",
params={"limit": limit, "summary_only": summary_only},
)
return response.get("data", [])
@@ -120,7 +140,7 @@ class LeggenAPIClient:
params = {"limit": limit, "summary_only": summary_only}
params.update(filters)
response = self._make_request("GET", "/api/v1/transactions", params=params)
response = self._make_request("GET", "/transactions", params=params)
return response.get("data", [])
def get_transaction_stats(
@@ -131,15 +151,13 @@ class LeggenAPIClient:
if account_id:
params["account_id"] = account_id
response = self._make_request(
"GET", "/api/v1/transactions/stats", params=params
)
response = self._make_request("GET", "/transactions/stats", params=params)
return response.get("data", {})
# Sync endpoints
def get_sync_status(self) -> Dict[str, Any]:
"""Get sync status"""
response = self._make_request("GET", "/api/v1/sync/status")
response = self._make_request("GET", "/sync/status")
return response.get("data", {})
def trigger_sync(
@@ -150,7 +168,7 @@ class LeggenAPIClient:
if account_ids:
data["account_ids"] = account_ids
response = self._make_request("POST", "/api/v1/sync", json=data)
response = self._make_request("POST", "/sync", json=data)
return response.get("data", {})
def sync_now(
@@ -161,12 +179,12 @@ class LeggenAPIClient:
if account_ids:
data["account_ids"] = account_ids
response = self._make_request("POST", "/api/v1/sync/now", json=data)
response = self._make_request("POST", "/sync/now", json=data)
return response.get("data", {})
def get_scheduler_config(self) -> Dict[str, Any]:
"""Get scheduler configuration"""
response = self._make_request("GET", "/api/v1/sync/scheduler")
response = self._make_request("GET", "/sync/scheduler")
return response.get("data", {})
def update_scheduler_config(
@@ -185,5 +203,5 @@ class LeggenAPIClient:
if cron:
data["cron"] = cron
response = self._make_request("PUT", "/api/v1/sync/scheduler", json=data)
response = self._make_request("PUT", "/sync/scheduler", json=data)
return response.get("data", {})

View File

@@ -102,17 +102,19 @@ class BackgroundScheduler:
async def _run_sync(self, retry_count: int = 0):
"""Run sync with enhanced error handling and retry logic"""
try:
logger.info("Starting scheduled sync job")
await self.sync_service.sync_all_accounts()
logger.info("Scheduled sync job completed successfully")
trigger_type = "retry" if retry_count > 0 else "scheduled"
logger.info(f"Starting {trigger_type} sync job")
await self.sync_service.sync_all_accounts(trigger_type=trigger_type)
logger.info(f"{trigger_type.capitalize()} sync job completed successfully")
except Exception as e:
trigger_type = "retry" if retry_count > 0 else "scheduled"
logger.error(
f"Scheduled sync job failed (attempt {retry_count + 1}/{self.max_retries}): {e}"
f"{trigger_type.capitalize()} sync job failed (attempt {retry_count + 1}/{self.max_retries}): {e}"
)
# Send notification about the failure
try:
await self.notification_service.send_expiry_notification(
await self.notification_service.send_sync_failure_notification(
{
"type": "sync_failure",
"error": str(e),
@@ -145,7 +147,7 @@ class BackgroundScheduler:
logger.error("Maximum retries exceeded for sync job")
# Send final failure notification
try:
await self.notification_service.send_expiry_notification(
await self.notification_service.send_sync_failure_notification(
{
"type": "sync_final_failure",
"error": str(e),

View File

@@ -1,5 +1,6 @@
import click
from leggen.api_client import LeggenAPIClient
from leggen.main import cli
from leggen.utils.text import info, success
@@ -15,12 +16,11 @@ def delete(ctx, requisition_id: str):
Check `leggen status` to get the REQUISITION_ID
"""
import requests
api_client = LeggenAPIClient(ctx.obj.get("api_url"))
info(f"Deleting Bank Requisition: {requisition_id}")
api_url = ctx.obj.get("api_url", "http://localhost:8000")
res = requests.delete(f"{api_url}/requisitions/{requisition_id}")
res.raise_for_status()
# Use API client to make the delete request
api_client._make_request("DELETE", f"/requisitions/{requisition_id}")
success(f"Bank Requisition {requisition_id} deleted")

View File

@@ -7,7 +7,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from loguru import logger
from leggen.api.routes import accounts, banks, notifications, sync, transactions
from leggen.api.routes import accounts, backup, banks, notifications, sync, transactions
from leggen.background.scheduler import scheduler
from leggen.utils.config import config
from leggen.utils.paths import path_manager
@@ -81,6 +81,7 @@ def create_app() -> FastAPI:
app.include_router(transactions.router, prefix="/api/v1", tags=["transactions"])
app.include_router(sync.router, prefix="/api/v1", tags=["sync"])
app.include_router(notifications.router, prefix="/api/v1", tags=["notifications"])
app.include_router(backup.router, prefix="/api/v1", tags=["backup"])
@app.get("/api/v1/health")
async def health():

View File

@@ -32,6 +32,22 @@ class NotificationConfig(BaseModel):
telegram: Optional[TelegramNotificationConfig] = None
class S3BackupConfig(BaseModel):
access_key_id: str = Field(..., description="AWS S3 access key ID")
secret_access_key: str = Field(..., description="AWS S3 secret access key")
bucket_name: str = Field(..., description="S3 bucket name")
region: str = Field(default="us-east-1", description="AWS S3 region")
endpoint_url: Optional[str] = Field(
default=None, description="Custom S3 endpoint URL"
)
path_style: bool = Field(default=False, description="Use path-style addressing")
enabled: bool = Field(default=True, description="Enable S3 backups")
class BackupConfig(BaseModel):
s3: Optional[S3BackupConfig] = None
class FilterConfig(BaseModel):
case_insensitive: Optional[List[str]] = Field(default_factory=list)
case_sensitive: Optional[List[str]] = Field(default_factory=list)
@@ -56,3 +72,4 @@ class Config(BaseModel):
notifications: Optional[NotificationConfig] = None
filters: Optional[FilterConfig] = None
scheduler: SchedulerConfig = Field(default_factory=SchedulerConfig)
backup: Optional[BackupConfig] = None

View File

@@ -55,3 +55,44 @@ def send_transactions_message(ctx: click.Context, transactions: list):
response.raise_for_status()
except Exception as e:
raise Exception(f"Discord notification failed: {e}\n{response.text}") from e
def send_sync_failure_notification(ctx: click.Context, notification: dict):
info("Sending sync failure notification to Discord")
webhook = DiscordWebhook(url=ctx.obj["notifications"]["discord"]["webhook"])
# Determine color and title based on failure type
if notification.get("type") == "sync_final_failure":
color = "ff0000" # Red for final failure
title = "🚨 Sync Final Failure"
description = (
f"Sync failed permanently after {notification['retry_count']} attempts"
)
else:
color = "ffaa00" # Orange for retry
title = "⚠️ Sync Failure"
description = f"Sync failed (attempt {notification['retry_count']}/{notification['max_retries']}). Will retry automatically..."
embed = DiscordEmbed(
title=title,
description=description,
color=color,
)
embed.set_author(
name="Leggen",
url="https://github.com/elisiariocouto/leggen",
)
embed.add_embed_field(
name="Error",
value=notification["error"][:1024], # Discord has field value limits
inline=False,
)
embed.set_footer(text="Sync failure notification")
embed.set_timestamp()
webhook.add_embed(embed)
response = webhook.execute()
try:
response.raise_for_status()
except Exception as e:
raise Exception(f"Discord notification failed: {e}\n{response.text}") from e

View File

@@ -79,3 +79,38 @@ def send_transaction_message(ctx: click.Context, transactions: list):
res.raise_for_status()
except Exception as e:
raise Exception(f"Telegram notification failed: {e}\n{res.text}") from e
def send_sync_failure_notification(ctx: click.Context, notification: dict):
token = ctx.obj["notifications"]["telegram"]["token"]
chat_id = ctx.obj["notifications"]["telegram"]["chat_id"]
bot_url = f"https://api.telegram.org/bot{token}/sendMessage"
info("Sending sync failure notification to Telegram")
message = "*🚨 [Leggen](https://github.com/elisiariocouto/leggen)*\n"
message += "*Sync Failed*\n\n"
message += escape_markdown(f"Error: {notification['error']}\n")
if notification.get("type") == "sync_final_failure":
message += escape_markdown(
f"❌ Final failure after {notification['retry_count']} attempts\n"
)
else:
message += escape_markdown(
f"🔄 Attempt {notification['retry_count']}/{notification['max_retries']}\n"
)
message += escape_markdown("Will retry automatically...\n")
res = requests.post(
bot_url,
json={
"chat_id": chat_id,
"text": message,
"parse_mode": "MarkdownV2",
},
)
try:
res.raise_for_status()
except Exception as e:
raise Exception(f"Telegram notification failed: {e}\n{res.text}") from e

View File

@@ -0,0 +1,192 @@
"""Backup service for S3 storage."""
import tempfile
from datetime import datetime
from pathlib import Path
from typing import Optional
import boto3
from botocore.exceptions import ClientError, NoCredentialsError
from loguru import logger
from leggen.models.config import S3BackupConfig
class BackupService:
"""Service for managing S3 backups."""
def __init__(self, s3_config: Optional[S3BackupConfig] = None):
"""Initialize backup service with S3 configuration."""
self.s3_config = s3_config
self._s3_client = None
def _get_s3_client(self, config: Optional[S3BackupConfig] = None):
"""Get or create S3 client with current configuration."""
current_config = config or self.s3_config
if not current_config:
raise ValueError("S3 configuration is required")
# Create S3 client with configuration
session = boto3.Session(
aws_access_key_id=current_config.access_key_id,
aws_secret_access_key=current_config.secret_access_key,
region_name=current_config.region,
)
s3_kwargs = {}
if current_config.endpoint_url:
s3_kwargs["endpoint_url"] = current_config.endpoint_url
if current_config.path_style:
from botocore.config import Config
s3_kwargs["config"] = Config(s3={"addressing_style": "path"})
return session.client("s3", **s3_kwargs)
async def test_connection(self, config: S3BackupConfig) -> bool:
"""Test S3 connection with provided configuration.
Args:
config: S3 configuration to test
Returns:
True if connection successful, False otherwise
"""
try:
s3_client = self._get_s3_client(config)
# Try to list objects in the bucket (limited to 1 to minimize cost)
s3_client.list_objects_v2(Bucket=config.bucket_name, MaxKeys=1)
logger.info(
f"S3 connection test successful for bucket: {config.bucket_name}"
)
return True
except NoCredentialsError:
logger.error("S3 credentials not found or invalid")
return False
except ClientError as e:
error_code = e.response["Error"]["Code"]
logger.error(
f"S3 connection test failed: {error_code} - {e.response['Error']['Message']}"
)
return False
except Exception as e:
logger.error(f"Unexpected error during S3 connection test: {str(e)}")
return False
async def backup_database(self, database_path: Path) -> bool:
"""Backup database file to S3.
Args:
database_path: Path to the SQLite database file
Returns:
True if backup successful, False otherwise
"""
if not self.s3_config or not self.s3_config.enabled:
logger.warning("S3 backup is not configured or disabled")
return False
if not database_path.exists():
logger.error(f"Database file not found: {database_path}")
return False
try:
s3_client = self._get_s3_client()
# Generate backup filename with timestamp
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
backup_key = f"leggen_backups/database_backup_{timestamp}.db"
# Upload database file
logger.info(f"Starting database backup to S3: {backup_key}")
s3_client.upload_file(
str(database_path), self.s3_config.bucket_name, backup_key
)
logger.info(f"Database backup completed successfully: {backup_key}")
return True
except Exception as e:
logger.error(f"Database backup failed: {str(e)}")
return False
async def list_backups(self) -> list[dict]:
"""List available backups in S3.
Returns:
List of backup metadata dictionaries
"""
if not self.s3_config or not self.s3_config.enabled:
logger.warning("S3 backup is not configured or disabled")
return []
try:
s3_client = self._get_s3_client()
# List objects with backup prefix
response = s3_client.list_objects_v2(
Bucket=self.s3_config.bucket_name, Prefix="leggen_backups/"
)
backups = []
for obj in response.get("Contents", []):
backups.append(
{
"key": obj["Key"],
"last_modified": obj["LastModified"].isoformat(),
"size": obj["Size"],
}
)
# Sort by last modified (newest first)
backups.sort(key=lambda x: x["last_modified"], reverse=True)
return backups
except Exception as e:
logger.error(f"Failed to list backups: {str(e)}")
return []
async def restore_database(self, backup_key: str, restore_path: Path) -> bool:
"""Restore database from S3 backup.
Args:
backup_key: S3 key of the backup to restore
restore_path: Path where to restore the database
Returns:
True if restore successful, False otherwise
"""
if not self.s3_config or not self.s3_config.enabled:
logger.warning("S3 backup is not configured or disabled")
return False
try:
s3_client = self._get_s3_client()
# Download backup file
logger.info(f"Starting database restore from S3: {backup_key}")
# Create parent directory if it doesn't exist
restore_path.parent.mkdir(parents=True, exist_ok=True)
# Download to temporary file first, then move to final location
with tempfile.NamedTemporaryFile(delete=False) as temp_file:
s3_client.download_file(
self.s3_config.bucket_name, backup_key, temp_file.name
)
# Move temp file to final location
temp_path = Path(temp_file.name)
temp_path.replace(restore_path)
logger.info(f"Database restore completed successfully: {restore_path}")
return True
except Exception as e:
logger.error(f"Database restore failed: {str(e)}")
return False

View File

@@ -217,6 +217,7 @@ class DatabaseService:
await self._migrate_to_composite_key_if_needed()
await self._migrate_add_display_name_if_needed()
await self._migrate_add_sync_operations_if_needed()
await self._migrate_add_logo_if_needed()
async def _migrate_balance_timestamps_if_needed(self):
"""Check and migrate balance timestamps if needed"""
@@ -1133,7 +1134,8 @@ class DatabaseService:
created DATETIME,
last_accessed DATETIME,
last_updated DATETIME,
display_name TEXT
display_name TEXT,
logo TEXT
)"""
)
@@ -1170,8 +1172,9 @@ class DatabaseService:
created,
last_accessed,
last_updated,
display_name
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
display_name,
logo
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
account_data["id"],
account_data["institution_id"],
@@ -1183,6 +1186,7 @@ class DatabaseService:
account_data.get("last_accessed"),
account_data.get("last_updated", account_data["created"]),
display_name,
account_data.get("logo"),
),
)
conn.commit()
@@ -1516,6 +1520,79 @@ class DatabaseService:
logger.error(f"Sync operations table migration failed: {e}")
raise
async def _migrate_add_logo_if_needed(self):
"""Check and add logo column to accounts table if needed"""
try:
if await self._check_logo_migration_needed():
logger.info("Logo column migration needed, starting...")
await self._migrate_add_logo()
logger.info("Logo column migration completed")
else:
logger.info("Logo column already exists")
except Exception as e:
logger.error(f"Logo column migration failed: {e}")
raise
async def _check_logo_migration_needed(self) -> bool:
"""Check if logo column needs to be added to accounts table"""
db_path = path_manager.get_database_path()
if not db_path.exists():
return False
try:
conn = sqlite3.connect(str(db_path))
cursor = conn.cursor()
# Check if accounts table exists
cursor.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='accounts'"
)
if not cursor.fetchone():
conn.close()
return False
# Check if logo column exists
cursor.execute("PRAGMA table_info(accounts)")
columns = cursor.fetchall()
# Check if logo column exists
has_logo = any(col[1] == "logo" for col in columns)
conn.close()
return not has_logo
except Exception as e:
logger.error(f"Failed to check logo migration status: {e}")
return False
async def _migrate_add_logo(self):
"""Add logo column to accounts table"""
db_path = path_manager.get_database_path()
if not db_path.exists():
logger.warning("Database file not found, skipping migration")
return
try:
conn = sqlite3.connect(str(db_path))
cursor = conn.cursor()
logger.info("Adding logo column to accounts table...")
# Add the logo column
cursor.execute("""
ALTER TABLE accounts
ADD COLUMN logo TEXT
""")
conn.commit()
conn.close()
logger.info("Logo column migration completed successfully")
except Exception as e:
logger.error(f"Logo column migration failed: {e}")
raise
async def persist_sync_operation(self, sync_operation: Dict[str, Any]) -> int:
"""Persist sync operation to database and return the ID"""
if not self.sqlite_enabled:

View File

@@ -1,6 +1,6 @@
import json
from pathlib import Path
from typing import Any, Dict, List
from typing import Any, Dict
import httpx
from loguru import logger
@@ -9,17 +9,21 @@ from leggen.utils.config import config
from leggen.utils.paths import path_manager
def _log_rate_limits(response):
def _log_rate_limits(response, method, url):
"""Log GoCardless API rate limit headers"""
limit = response.headers.get("X-RateLimit-Limit")
remaining = response.headers.get("X-RateLimit-Remaining")
reset = response.headers.get("X-RateLimit-Reset")
account_success_reset = response.headers.get("X-RateLimit-Account-Success-Reset")
limit = response.headers.get("http_x_ratelimit_limit")
remaining = response.headers.get("http_x_ratelimit_remaining")
reset = response.headers.get("http_x_ratelimit_reset")
if limit or remaining or reset or account_success_reset:
logger.info(
f"GoCardless rate limits - Limit: {limit}, Remaining: {remaining}, Reset: {reset}s, Account Success Reset: {account_success_reset}"
)
account_limit = response.headers.get("http_x_ratelimit_account_success_limit")
account_remaining = response.headers.get(
"http_x_ratelimit_account_success_remaining"
)
account_reset = response.headers.get("http_x_ratelimit_account_success_reset")
logger.debug(
f"{method} {url} Limit/Remaining/Reset (Global: {limit}/{remaining}/{reset}s) (Account: {account_limit}/{account_remaining}/{account_reset}s)"
)
class GoCardlessService:
@@ -30,6 +34,31 @@ class GoCardlessService:
)
self._token = None
async def _make_authenticated_request(
self, method: str, url: str, **kwargs
) -> Dict[str, Any]:
"""Make authenticated request with automatic token refresh on 401"""
headers = await self._get_auth_headers()
async with httpx.AsyncClient() as client:
response = await client.request(
method, url, headers=headers, timeout=30, **kwargs
)
_log_rate_limits(response, method, url)
# If we get 401, clear token cache and retry once
if response.status_code == 401:
logger.warning("Got 401, clearing token cache and retrying")
self._token = None
headers = await self._get_auth_headers()
response = await client.request(
method, url, headers=headers, timeout=30, **kwargs
)
_log_rate_limits(response, method, url)
response.raise_for_status()
return response.json()
async def _get_auth_headers(self) -> Dict[str, str]:
"""Get authentication headers for GoCardless API"""
token = await self._get_token()
@@ -56,7 +85,9 @@ class GoCardlessService:
f"{self.base_url}/token/refresh/",
json={"refresh": auth["refresh"]},
)
_log_rate_limits(response)
_log_rate_limits(
response, "POST", f"{self.base_url}/token/refresh/"
)
response.raise_for_status()
auth.update(response.json())
self._save_auth(auth)
@@ -84,7 +115,7 @@ class GoCardlessService:
"secret_key": self.config["secret"],
},
)
_log_rate_limits(response)
_log_rate_limits(response, "POST", f"{self.base_url}/token/new/")
response.raise_for_status()
auth = response.json()
self._save_auth(auth)
@@ -102,74 +133,54 @@ class GoCardlessService:
with open(auth_file, "w") as f:
json.dump(auth_data, f)
async def get_institutions(self, country: str = "PT") -> List[Dict[str, Any]]:
async def get_institutions(self, country: str = "PT") -> Dict[str, Any]:
"""Get available bank institutions for a country"""
headers = await self._get_auth_headers()
async with httpx.AsyncClient() as client:
response = await client.get(
f"{self.base_url}/institutions/",
headers=headers,
params={"country": country},
)
_log_rate_limits(response)
response.raise_for_status()
return response.json()
return await self._make_authenticated_request(
"GET", f"{self.base_url}/institutions/", params={"country": country}
)
async def create_requisition(
self, institution_id: str, redirect_url: str
) -> Dict[str, Any]:
"""Create a bank connection requisition"""
headers = await self._get_auth_headers()
async with httpx.AsyncClient() as client:
response = await client.post(
f"{self.base_url}/requisitions/",
headers=headers,
json={"institution_id": institution_id, "redirect": redirect_url},
)
_log_rate_limits(response)
response.raise_for_status()
return response.json()
return await self._make_authenticated_request(
"POST",
f"{self.base_url}/requisitions/",
json={"institution_id": institution_id, "redirect": redirect_url},
)
async def get_requisitions(self) -> Dict[str, Any]:
"""Get all requisitions"""
headers = await self._get_auth_headers()
async with httpx.AsyncClient() as client:
response = await client.get(
f"{self.base_url}/requisitions/", headers=headers
)
_log_rate_limits(response)
response.raise_for_status()
return response.json()
return await self._make_authenticated_request(
"GET", f"{self.base_url}/requisitions/"
)
async def delete_requisition(self, requisition_id: str) -> Dict[str, Any]:
"""Delete a requisition"""
return await self._make_authenticated_request(
"DELETE", f"{self.base_url}/requisitions/{requisition_id}/"
)
async def get_account_details(self, account_id: str) -> Dict[str, Any]:
"""Get account details"""
headers = await self._get_auth_headers()
async with httpx.AsyncClient() as client:
response = await client.get(
f"{self.base_url}/accounts/{account_id}/", headers=headers
)
_log_rate_limits(response)
response.raise_for_status()
return response.json()
return await self._make_authenticated_request(
"GET", f"{self.base_url}/accounts/{account_id}/"
)
async def get_account_balances(self, account_id: str) -> Dict[str, Any]:
"""Get account balances"""
headers = await self._get_auth_headers()
async with httpx.AsyncClient() as client:
response = await client.get(
f"{self.base_url}/accounts/{account_id}/balances/", headers=headers
)
_log_rate_limits(response)
response.raise_for_status()
return response.json()
return await self._make_authenticated_request(
"GET", f"{self.base_url}/accounts/{account_id}/balances/"
)
async def get_account_transactions(self, account_id: str) -> Dict[str, Any]:
"""Get account transactions"""
headers = await self._get_auth_headers()
async with httpx.AsyncClient() as client:
response = await client.get(
f"{self.base_url}/accounts/{account_id}/transactions/", headers=headers
)
_log_rate_limits(response)
response.raise_for_status()
return response.json()
return await self._make_authenticated_request(
"GET", f"{self.base_url}/accounts/{account_id}/transactions/"
)
async def get_institution_details(self, institution_id: str) -> Dict[str, Any]:
"""Get institution details by ID"""
return await self._make_authenticated_request(
"GET", f"{self.base_url}/institutions/{institution_id}/"
)

View File

@@ -289,3 +289,69 @@ class NotificationService:
except Exception as e:
logger.error(f"Failed to send Telegram expiry notification: {e}")
raise
async def send_sync_failure_notification(
self, notification_data: Dict[str, Any]
) -> None:
"""Send notification about sync failure"""
if self._is_discord_enabled():
await self._send_discord_sync_failure(notification_data)
if self._is_telegram_enabled():
await self._send_telegram_sync_failure(notification_data)
async def _send_discord_sync_failure(
self, notification_data: Dict[str, Any]
) -> None:
"""Send Discord sync failure notification"""
try:
import click
from leggen.notifications.discord import send_sync_failure_notification
# Create a mock context with the webhook
ctx = click.Context(click.Command("sync_failure"))
ctx.obj = {
"notifications": {
"discord": {
"webhook": self.notifications_config.get("discord", {}).get(
"webhook"
)
}
}
}
# Send sync failure notification using the actual implementation
send_sync_failure_notification(ctx, notification_data)
logger.info(f"Sent Discord sync failure notification: {notification_data}")
except Exception as e:
logger.error(f"Failed to send Discord sync failure notification: {e}")
raise
async def _send_telegram_sync_failure(
self, notification_data: Dict[str, Any]
) -> None:
"""Send Telegram sync failure notification"""
try:
import click
from leggen.notifications.telegram import send_sync_failure_notification
# Create a mock context with the telegram config
ctx = click.Context(click.Command("sync_failure"))
telegram_config = self.notifications_config.get("telegram", {})
ctx.obj = {
"notifications": {
"telegram": {
"token": telegram_config.get("token"),
"chat_id": telegram_config.get("chat_id"),
}
}
}
# Send sync failure notification using the actual implementation
send_sync_failure_notification(ctx, notification_data)
logger.info(f"Sent Telegram sync failure notification: {notification_data}")
except Exception as e:
logger.error(f"Failed to send Telegram sync failure notification: {e}")
raise

View File

@@ -15,6 +15,7 @@ class SyncService:
self.database = DatabaseService()
self.notifications = NotificationService()
self._sync_status = SyncStatus(is_running=False)
self._institution_logos = {} # Cache for institution logos
async def get_sync_status(self) -> SyncStatus:
"""Get current sync status"""
@@ -77,7 +78,7 @@ class SyncService:
# Get balances to extract currency information
balances = await self.gocardless.get_account_balances(account_id)
# Enrich account details with currency and persist
# Enrich account details with currency and institution logo
if account_details and balances:
enriched_account_details = account_details.copy()
@@ -90,6 +91,26 @@ class SyncService:
if currency:
enriched_account_details["currency"] = currency
# Get institution details to fetch logo
institution_id = enriched_account_details.get("institution_id")
if institution_id:
try:
institution_details = (
await self.gocardless.get_institution_details(
institution_id
)
)
enriched_account_details["logo"] = (
institution_details.get("logo", "")
)
logger.info(
f"Fetched logo for institution {institution_id}: {enriched_account_details.get('logo', 'No logo')}"
)
except Exception as e:
logger.warning(
f"Failed to fetch institution details for {institution_id}: {e}"
)
# Persist enriched account details to database
await self.database.persist_account_details(
enriched_account_details

View File

@@ -39,13 +39,11 @@ class Config:
try:
with open(config_path, "rb") as f:
raw_config = tomllib.load(f)
logger.info(f"Configuration loaded from {config_path}")
# Validate configuration using Pydantic
try:
self._config_model = ConfigModel(**raw_config)
self._config = self._config_model.dict(by_alias=True, exclude_none=True)
logger.info("Configuration validation successful")
except ValidationError as e:
logger.error(f"Configuration validation failed: {e}")
raise ValueError(f"Invalid configuration: {e}") from e
@@ -164,6 +162,11 @@ class Config:
}
return self.config.get("scheduler", default_schedule)
@property
def backup_config(self) -> Dict[str, Any]:
"""Get backup configuration"""
return self.config.get("backup", {})
def load_config(ctx: click.Context, _, filename):
try:

View File

@@ -1,6 +1,6 @@
[project]
name = "leggen"
version = "2025.9.20"
version = "2025.10.0"
description = "An Open Banking CLI"
authors = [{ name = "Elisiário Couto", email = "elisiario@couto.io" }]
requires-python = "~=3.13.0"
@@ -35,6 +35,7 @@ dependencies = [
"tomli-w>=1.0.0,<2",
"httpx>=0.28.1",
"pydantic>=2.0.0,<3",
"boto3>=1.35.0,<2",
]
[project.urls]
@@ -88,5 +89,5 @@ markers = [
]
[[tool.mypy.overrides]]
module = ["apscheduler.*", "discord_webhook.*"]
module = ["apscheduler.*", "discord_webhook.*", "botocore.*", "boto3.*"]
ignore_missing_imports = true

View File

@@ -42,6 +42,9 @@ echo " > Version bumped to $NEXT_VERSION"
echo "Updating CHANGELOG.md"
git-cliff --unreleased --tag "$NEXT_VERSION" --prepend CHANGELOG.md > /dev/null
echo "Locking dependencies"
uv lock
echo " > Commiting changes and adding git tag"
git add pyproject.toml CHANGELOG.md uv.lock
git commit -m "chore(ci): Bump version to $NEXT_VERSION"

View File

@@ -103,22 +103,24 @@ def mock_db_path(temp_db_path):
@pytest.fixture
def sample_bank_data():
"""Sample bank/institution data for testing."""
return [
{
"id": "REVOLUT_REVOLT21",
"name": "Revolut",
"bic": "REVOLT21",
"transaction_total_days": 90,
"countries": ["GB", "LT"],
},
{
"id": "BANCOBPI_BBPIPTPL",
"name": "Banco BPI",
"bic": "BBPIPTPL",
"transaction_total_days": 90,
"countries": ["PT"],
},
]
return {
"results": [
{
"id": "REVOLUT_REVOLT21",
"name": "Revolut",
"bic": "REVOLT21",
"transaction_total_days": 90,
"countries": ["GB", "LT"],
},
{
"id": "BANCOBPI_BBPIPTPL",
"name": "Banco BPI",
"bic": "BBPIPTPL",
"transaction_total_days": 90,
"countries": ["PT"],
},
]
}
@pytest.fixture

View File

@@ -0,0 +1,303 @@
"""Tests for backup API endpoints."""
from unittest.mock import patch
import pytest
@pytest.mark.api
class TestBackupAPI:
"""Test backup-related API endpoints."""
def test_get_backup_settings_no_config(self, api_client, mock_config):
"""Test getting backup settings with no configuration."""
# Mock empty backup config by updating the config dict
mock_config._config["backup"] = {}
with patch("leggen.utils.config.config", mock_config):
response = api_client.get("/api/v1/backup/settings")
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert data["data"]["s3"] is None
def test_get_backup_settings_with_s3_config(self, api_client, mock_config):
"""Test getting backup settings with S3 configuration."""
# Mock S3 backup config (with masked credentials)
mock_config._config["backup"] = {
"s3": {
"access_key_id": "AKIATEST123",
"secret_access_key": "secret123",
"bucket_name": "test-bucket",
"region": "us-east-1",
"endpoint_url": None,
"path_style": False,
"enabled": True,
}
}
with patch("leggen.utils.config.config", mock_config):
response = api_client.get("/api/v1/backup/settings")
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert data["data"]["s3"] is not None
s3_config = data["data"]["s3"]
assert s3_config["access_key_id"] == "***" # Masked
assert s3_config["secret_access_key"] == "***" # Masked
assert s3_config["bucket_name"] == "test-bucket"
assert s3_config["region"] == "us-east-1"
assert s3_config["enabled"] is True
@patch("leggen.services.backup_service.BackupService.test_connection")
def test_update_backup_settings_success(
self, mock_test_connection, api_client, mock_config
):
"""Test successful backup settings update."""
mock_test_connection.return_value = True
mock_config._config["backup"] = {}
request_data = {
"s3": {
"access_key_id": "AKIATEST123",
"secret_access_key": "secret123",
"bucket_name": "test-bucket",
"region": "us-east-1",
"endpoint_url": None,
"path_style": False,
"enabled": True,
}
}
with patch("leggen.utils.config.config", mock_config):
response = api_client.put("/api/v1/backup/settings", json=request_data)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert data["data"]["updated"] is True
# Verify connection test was called
mock_test_connection.assert_called_once()
@patch("leggen.services.backup_service.BackupService.test_connection")
def test_update_backup_settings_connection_failure(
self, mock_test_connection, api_client, mock_config
):
"""Test backup settings update with connection test failure."""
mock_test_connection.return_value = False
mock_config._config["backup"] = {}
request_data = {
"s3": {
"access_key_id": "AKIATEST123",
"secret_access_key": "secret123",
"bucket_name": "invalid-bucket",
"region": "us-east-1",
"endpoint_url": None,
"path_style": False,
"enabled": True,
}
}
with patch("leggen.utils.config.config", mock_config):
response = api_client.put("/api/v1/backup/settings", json=request_data)
assert response.status_code == 400
data = response.json()
assert "S3 connection test failed" in data["detail"]
@patch("leggen.services.backup_service.BackupService.test_connection")
def test_test_backup_connection_success(self, mock_test_connection, api_client):
"""Test successful backup connection test."""
mock_test_connection.return_value = True
request_data = {
"service": "s3",
"config": {
"access_key_id": "AKIATEST123",
"secret_access_key": "secret123",
"bucket_name": "test-bucket",
"region": "us-east-1",
"endpoint_url": None,
"path_style": False,
"enabled": True,
},
}
response = api_client.post("/api/v1/backup/test", json=request_data)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert data["data"]["connected"] is True
# Verify connection test was called
mock_test_connection.assert_called_once()
@patch("leggen.services.backup_service.BackupService.test_connection")
def test_test_backup_connection_failure(self, mock_test_connection, api_client):
"""Test failed backup connection test."""
mock_test_connection.return_value = False
request_data = {
"service": "s3",
"config": {
"access_key_id": "AKIATEST123",
"secret_access_key": "secret123",
"bucket_name": "invalid-bucket",
"region": "us-east-1",
"endpoint_url": None,
"path_style": False,
"enabled": True,
},
}
response = api_client.post("/api/v1/backup/test", json=request_data)
assert response.status_code == 200
data = response.json()
assert data["success"] is False
def test_test_backup_connection_invalid_service(self, api_client):
"""Test backup connection test with invalid service."""
request_data = {
"service": "invalid",
"config": {
"access_key_id": "AKIATEST123",
"secret_access_key": "secret123",
"bucket_name": "test-bucket",
"region": "us-east-1",
"endpoint_url": None,
"path_style": False,
"enabled": True,
},
}
response = api_client.post("/api/v1/backup/test", json=request_data)
assert response.status_code == 400
data = response.json()
assert "Only 's3' service is supported" in data["detail"]
@patch("leggen.services.backup_service.BackupService.list_backups")
def test_list_backups_success(self, mock_list_backups, api_client, mock_config):
"""Test successful backup listing."""
mock_list_backups.return_value = [
{
"key": "leggen_backups/database_backup_20250101_120000.db",
"last_modified": "2025-01-01T12:00:00",
"size": 1024,
},
{
"key": "leggen_backups/database_backup_20250101_110000.db",
"last_modified": "2025-01-01T11:00:00",
"size": 512,
},
]
mock_config._config["backup"] = {
"s3": {
"access_key_id": "AKIATEST123",
"secret_access_key": "secret123",
"bucket_name": "test-bucket",
"region": "us-east-1",
"enabled": True,
}
}
with patch("leggen.utils.config.config", mock_config):
response = api_client.get("/api/v1/backup/list")
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert len(data["data"]) == 2
assert (
data["data"][0]["key"]
== "leggen_backups/database_backup_20250101_120000.db"
)
def test_list_backups_no_config(self, api_client, mock_config):
"""Test backup listing with no configuration."""
mock_config._config["backup"] = {}
with patch("leggen.utils.config.config", mock_config):
response = api_client.get("/api/v1/backup/list")
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert data["data"] == []
@patch("leggen.services.backup_service.BackupService.backup_database")
@patch("leggen.utils.paths.path_manager.get_database_path")
def test_backup_operation_success(
self, mock_get_db_path, mock_backup_db, api_client, mock_config
):
"""Test successful backup operation."""
from pathlib import Path
mock_get_db_path.return_value = Path("/test/database.db")
mock_backup_db.return_value = True
mock_config._config["backup"] = {
"s3": {
"access_key_id": "AKIATEST123",
"secret_access_key": "secret123",
"bucket_name": "test-bucket",
"region": "us-east-1",
"enabled": True,
}
}
request_data = {"operation": "backup"}
with patch("leggen.utils.config.config", mock_config):
response = api_client.post("/api/v1/backup/operation", json=request_data)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert data["data"]["operation"] == "backup"
assert data["data"]["completed"] is True
# Verify backup was called
mock_backup_db.assert_called_once()
def test_backup_operation_no_config(self, api_client, mock_config):
"""Test backup operation with no configuration."""
mock_config._config["backup"] = {}
request_data = {"operation": "backup"}
with patch("leggen.utils.config.config", mock_config):
response = api_client.post("/api/v1/backup/operation", json=request_data)
assert response.status_code == 400
data = response.json()
assert "S3 backup is not configured" in data["detail"]
def test_backup_operation_invalid_operation(self, api_client, mock_config):
"""Test backup operation with invalid operation type."""
mock_config._config["backup"] = {
"s3": {
"access_key_id": "AKIATEST123",
"secret_access_key": "secret123",
"bucket_name": "test-bucket",
"region": "us-east-1",
"enabled": True,
}
}
request_data = {"operation": "invalid"}
with patch("leggen.utils.config.config", mock_config):
response = api_client.post("/api/v1/backup/operation", json=request_data)
assert response.status_code == 400
data = response.json()
assert "Invalid operation" in data["detail"]

View File

@@ -50,7 +50,7 @@ class TestBanksAPI:
# Mock empty institutions response for invalid country
respx.get("https://bankaccountdata.gocardless.com/api/v2/institutions/").mock(
return_value=httpx.Response(200, json=[])
return_value=httpx.Response(200, json={"results": []})
)
with patch("leggen.utils.config.config", mock_config):

View File

@@ -18,7 +18,10 @@ class TestLeggenAPIClient:
client = LeggenAPIClient("http://localhost:8000")
with requests_mock.Mocker() as m:
m.get("http://localhost:8000/health", json={"status": "healthy"})
m.get(
"http://localhost:8000/api/v1/health",
json={"data": {"status": "healthy"}},
)
result = client.health_check()
assert result is True
@@ -37,9 +40,12 @@ class TestLeggenAPIClient:
"""Test getting institutions via API client."""
client = LeggenAPIClient("http://localhost:8000")
# The API returns processed institutions, not raw GoCardless data
processed_institutions = sample_bank_data["results"]
api_response = {
"success": True,
"data": sample_bank_data,
"data": processed_institutions,
"message": "Found 2 institutions for PT",
}
@@ -109,13 +115,13 @@ class TestLeggenAPIClient:
custom_url = "http://custom-host:9000"
client = LeggenAPIClient(custom_url)
assert client.base_url == custom_url
assert client.base_url == f"{custom_url}/api/v1"
def test_environment_variable_url(self):
"""Test using environment variable for API URL."""
with patch.dict("os.environ", {"LEGGEN_API_URL": "http://env-host:7000"}):
client = LeggenAPIClient()
assert client.base_url == "http://env-host:7000"
assert client.base_url == "http://env-host:7000/api/v1"
def test_sync_with_options(self):
"""Test sync with various options."""

View File

@@ -0,0 +1,226 @@
"""Tests for backup service functionality."""
import tempfile
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from botocore.exceptions import ClientError, NoCredentialsError
from leggen.models.config import S3BackupConfig
from leggen.services.backup_service import BackupService
@pytest.mark.unit
class TestBackupService:
"""Test backup service functionality."""
def test_backup_service_initialization(self):
"""Test backup service can be initialized."""
service = BackupService()
assert service.s3_config is None
assert service._s3_client is None
def test_backup_service_with_config(self):
"""Test backup service initialization with config."""
s3_config = S3BackupConfig(
access_key_id="test-key",
secret_access_key="test-secret",
bucket_name="test-bucket",
region="us-east-1",
)
service = BackupService(s3_config)
assert service.s3_config == s3_config
@pytest.mark.asyncio
async def test_test_connection_success(self):
"""Test successful S3 connection test."""
s3_config = S3BackupConfig(
access_key_id="test-key",
secret_access_key="test-secret",
bucket_name="test-bucket",
region="us-east-1",
)
service = BackupService()
# Mock S3 client
with patch("boto3.Session") as mock_session:
mock_client = MagicMock()
mock_session.return_value.client.return_value = mock_client
# Mock successful list_objects_v2 call
mock_client.list_objects_v2.return_value = {"Contents": []}
result = await service.test_connection(s3_config)
assert result is True
# Verify the client was called correctly
mock_client.list_objects_v2.assert_called_once_with(
Bucket="test-bucket", MaxKeys=1
)
@pytest.mark.asyncio
async def test_test_connection_no_credentials(self):
"""Test S3 connection test with no credentials."""
s3_config = S3BackupConfig(
access_key_id="test-key",
secret_access_key="test-secret",
bucket_name="test-bucket",
region="us-east-1",
)
service = BackupService()
# Mock S3 client to raise NoCredentialsError
with patch("boto3.Session") as mock_session:
mock_client = MagicMock()
mock_session.return_value.client.return_value = mock_client
mock_client.list_objects_v2.side_effect = NoCredentialsError()
result = await service.test_connection(s3_config)
assert result is False
@pytest.mark.asyncio
async def test_test_connection_client_error(self):
"""Test S3 connection test with client error."""
s3_config = S3BackupConfig(
access_key_id="test-key",
secret_access_key="test-secret",
bucket_name="test-bucket",
region="us-east-1",
)
service = BackupService()
# Mock S3 client to raise ClientError
with patch("boto3.Session") as mock_session:
mock_client = MagicMock()
mock_session.return_value.client.return_value = mock_client
error_response = {
"Error": {"Code": "NoSuchBucket", "Message": "Bucket not found"}
}
mock_client.list_objects_v2.side_effect = ClientError(
error_response, "ListObjectsV2"
)
result = await service.test_connection(s3_config)
assert result is False
@pytest.mark.asyncio
async def test_backup_database_no_config(self):
"""Test backup database with no configuration."""
service = BackupService()
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "test.db"
db_path.write_text("test database content")
result = await service.backup_database(db_path)
assert result is False
@pytest.mark.asyncio
async def test_backup_database_disabled(self):
"""Test backup database with disabled configuration."""
s3_config = S3BackupConfig(
access_key_id="test-key",
secret_access_key="test-secret",
bucket_name="test-bucket",
region="us-east-1",
enabled=False,
)
service = BackupService(s3_config)
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "test.db"
db_path.write_text("test database content")
result = await service.backup_database(db_path)
assert result is False
@pytest.mark.asyncio
async def test_backup_database_file_not_found(self):
"""Test backup database with non-existent file."""
s3_config = S3BackupConfig(
access_key_id="test-key",
secret_access_key="test-secret",
bucket_name="test-bucket",
region="us-east-1",
)
service = BackupService(s3_config)
non_existent_path = Path("/non/existent/path.db")
result = await service.backup_database(non_existent_path)
assert result is False
@pytest.mark.asyncio
async def test_backup_database_success(self):
"""Test successful database backup."""
s3_config = S3BackupConfig(
access_key_id="test-key",
secret_access_key="test-secret",
bucket_name="test-bucket",
region="us-east-1",
)
service = BackupService(s3_config)
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "test.db"
db_path.write_text("test database content")
# Mock S3 client
with patch("boto3.Session") as mock_session:
mock_client = MagicMock()
mock_session.return_value.client.return_value = mock_client
result = await service.backup_database(db_path)
assert result is True
# Verify upload_file was called
mock_client.upload_file.assert_called_once()
args = mock_client.upload_file.call_args[0]
assert args[0] == str(db_path) # source file
assert args[1] == "test-bucket" # bucket name
assert args[2].startswith("leggen_backups/database_backup_") # key
@pytest.mark.asyncio
async def test_list_backups_success(self):
"""Test successful backup listing."""
s3_config = S3BackupConfig(
access_key_id="test-key",
secret_access_key="test-secret",
bucket_name="test-bucket",
region="us-east-1",
)
service = BackupService(s3_config)
# Mock S3 client response
with patch("boto3.Session") as mock_session:
mock_client = MagicMock()
mock_session.return_value.client.return_value = mock_client
from datetime import datetime
mock_response = {
"Contents": [
{
"Key": "leggen_backups/database_backup_20250101_120000.db",
"LastModified": datetime(2025, 1, 1, 12, 0, 0),
"Size": 1024,
},
{
"Key": "leggen_backups/database_backup_20250101_130000.db",
"LastModified": datetime(2025, 1, 1, 13, 0, 0),
"Size": 2048,
},
]
}
mock_client.list_objects_v2.return_value = mock_response
backups = await service.list_backups()
assert len(backups) == 2
# Check that backups are sorted by last modified (newest first)
assert backups[0]["last_modified"] > backups[1]["last_modified"]
assert backups[0]["size"] == 2048
assert backups[1]["size"] == 1024

74
uv.lock generated
View File

@@ -36,6 +36,34 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d0/ae/9a053dd9229c0fde6b1f1f33f609ccff1ee79ddda364c756a924c6d8563b/APScheduler-3.11.0-py3-none-any.whl", hash = "sha256:fc134ca32e50f5eadcc4938e3a4545ab19131435e851abb40b34d63d5141c6da", size = 64004, upload-time = "2024-11-24T19:39:24.442Z" },
]
[[package]]
name = "boto3"
version = "1.40.36"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "botocore" },
{ name = "jmespath" },
{ name = "s3transfer" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8d/21/7bc857b155e8264c92b6fa8e0860a67dc01a19cbe6ba4342500299f2ae5b/boto3-1.40.36.tar.gz", hash = "sha256:bfc1f3d5c4f5d12b8458406b8972f8794ac57e2da1ee441469e143bc0440a5c3", size = 111552, upload-time = "2025-09-22T19:26:17.357Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/70/4c/428b728d5cf9003f83f735d10dd522945ab20c7d67e6c987909f29be12a0/boto3-1.40.36-py3-none-any.whl", hash = "sha256:d7c1fe033f491f560cd26022a9dcf28baf877ae854f33bc64fffd0df3b9c98be", size = 139345, upload-time = "2025-09-22T19:26:15.194Z" },
]
[[package]]
name = "botocore"
version = "1.40.36"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jmespath" },
{ name = "python-dateutil" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/4b/30/75fdc75933d3bc1c8dd7fbaee771438328b518936906b411075b1eacac93/botocore-1.40.36.tar.gz", hash = "sha256:93386a8dc54173267ddfc6cd8636c9171e021f7c032aa1df3af7de816e3df616", size = 14349583, upload-time = "2025-09-22T19:26:05.957Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d8/51/95c0324ac20b5bbafad4c89dd610c8e0dd6cbadbb2c8ca66dc95ccde98b8/botocore-1.40.36-py3-none-any.whl", hash = "sha256:d6edf75875e4013cb7078875a1d6c289afb4cc6675d99d80700c692d8d8e0b72", size = 14020478, upload-time = "2025-09-22T19:26:02.054Z" },
]
[[package]]
name = "certifi"
version = "2025.8.3"
@@ -218,12 +246,22 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" },
]
[[package]]
name = "jmespath"
version = "1.0.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/00/2a/e867e8531cf3e36b41201936b7fa7ba7b5702dbef42922193f05c8976cd6/jmespath-1.0.1.tar.gz", hash = "sha256:90261b206d6defd58fdd5e85f478bf633a2901798906be2ad389150c5c60edbe", size = 25843, upload-time = "2022-06-17T18:00:12.224Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/31/b4/b9b800c45527aadd64d5b442f9b932b00648617eb5d63d2c7a6587b7cafc/jmespath-1.0.1-py3-none-any.whl", hash = "sha256:02e2e4cc71b5bcab88332eebf907519190dd9e6e82107fa7f83b1003a6252980", size = 20256, upload-time = "2022-06-17T18:00:10.251Z" },
]
[[package]]
name = "leggen"
version = "2025.9.20"
version = "2025.10.0"
source = { editable = "." }
dependencies = [
{ name = "apscheduler" },
{ name = "boto3" },
{ name = "click" },
{ name = "discord-webhook" },
{ name = "fastapi" },
@@ -253,6 +291,7 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "apscheduler", specifier = ">=3.10.0,<4" },
{ name = "boto3", specifier = ">=1.35.0,<2" },
{ name = "click", specifier = ">=8.1.7,<9" },
{ name = "discord-webhook", specifier = ">=1.3.1,<2" },
{ name = "fastapi", specifier = ">=0.104.0,<1" },
@@ -474,6 +513,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/2b/b3/7fefc43fb706380144bcd293cc6e446e6f637ddfa8b83f48d1734156b529/pytest_mock-3.15.0-py3-none-any.whl", hash = "sha256:ef2219485fb1bd256b00e7ad7466ce26729b30eadfc7cbcdb4fa9a92ca68db6f", size = 10050, upload-time = "2025-09-04T20:57:47.274Z" },
]
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
]
[[package]]
name = "python-dotenv"
version = "1.1.1"
@@ -565,6 +616,27 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e1/a3/03216a6a86c706df54422612981fb0f9041dbb452c3401501d4a22b942c9/ruff-0.13.0-py3-none-win_arm64.whl", hash = "sha256:ab80525317b1e1d38614addec8ac954f1b3e662de9d59114ecbf771d00cf613e", size = 12312357, upload-time = "2025-09-10T16:25:35.595Z" },
]
[[package]]
name = "s3transfer"
version = "0.14.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "botocore" },
]
sdist = { url = "https://files.pythonhosted.org/packages/62/74/8d69dcb7a9efe8baa2046891735e5dfe433ad558ae23d9e3c14c633d1d58/s3transfer-0.14.0.tar.gz", hash = "sha256:eff12264e7c8b4985074ccce27a3b38a485bb7f7422cc8046fee9be4983e4125", size = 151547, upload-time = "2025-09-09T19:23:31.089Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/48/f0/ae7ca09223a81a1d890b2557186ea015f6e0502e9b8cb8e1813f1d8cfa4e/s3transfer-0.14.0-py3-none-any.whl", hash = "sha256:ea3b790c7077558ed1f02a3072fb3cb992bbbd253392f4b6e9e8976941c7d456", size = 85712, upload-time = "2025-09-09T19:23:30.041Z" },
]
[[package]]
name = "six"
version = "1.17.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
]
[[package]]
name = "sniffio"
version = "1.3.1"