Compare commits

..

16 Commits

Author SHA1 Message Date
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
42 changed files with 1722 additions and 364 deletions

1
.gitignore vendored
View File

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

View File

@@ -7,10 +7,10 @@
"mcp" "mcp"
] ]
}, },
"browsermcp": { "playwright": {
"command": "npx", "command": "npx",
"args": [ "args": [
"@browsermcp/mcp@latest" "@playwright/mcp@latest"
] ]
} }
} }

View File

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

View File

@@ -1,4 +1,100 @@
## 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) ## 2025.9.20 (2025/09/22)
### Features ### Features

View File

@@ -22,6 +22,7 @@
"@radix-ui/react-select": "^2.2.6", "@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toggle": "^1.1.10", "@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11", "@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": { "node_modules/@radix-ui/react-tabs": {
"version": "1.1.13", "version": "1.1.13",
"resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz", "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-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toggle": "^1.1.10", "@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11", "@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 [editingAccountId, setEditingAccountId] = useState<string | null>(null);
const [editingName, setEditingName] = useState(""); const [editingName, setEditingName] = useState("");
const [failedImages, setFailedImages] = useState<Set<string>>(new Set());
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -194,8 +195,20 @@ export default function AccountSettings() {
{/* Mobile layout - stack vertically */} {/* 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 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 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"> <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">
<Building2 className="h-5 w-5 sm:h-6 sm:w-6 text-muted-foreground" /> {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>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
{editingAccountId === account.id ? ( {editingAccountId === account.id ? (

View File

@@ -0,0 +1,181 @@
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,225 @@
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

@@ -15,10 +15,8 @@ import {
MessageSquare, MessageSquare,
Send, Send,
Trash2, Trash2,
TestTube,
Settings as SettingsIcon,
User, User,
CheckCircle, Filter,
} from "lucide-react"; } from "lucide-react";
import { apiClient } from "../lib/api"; import { apiClient } from "../lib/api";
import { formatCurrency, formatDate } from "../lib/utils"; import { formatCurrency, formatDate } from "../lib/utils";
@@ -31,18 +29,12 @@ import {
} from "./ui/card"; } from "./ui/card";
import { Button } from "./ui/button"; import { Button } from "./ui/button";
import { Alert, AlertDescription, AlertTitle } from "./ui/alert"; import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
import { Input } from "./ui/input";
import { Label } from "./ui/label"; 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 { Tabs, TabsContent, TabsList, TabsTrigger } from "./ui/tabs";
import AccountsSkeleton from "./AccountsSkeleton"; import AccountsSkeleton from "./AccountsSkeleton";
import NotificationFiltersDrawer from "./NotificationFiltersDrawer";
import DiscordConfigDrawer from "./DiscordConfigDrawer";
import TelegramConfigDrawer from "./TelegramConfigDrawer";
import type { import type {
Account, Account,
Balance, Balance,
@@ -87,10 +79,7 @@ const getStatusIndicator = (status: string) => {
export default function Settings() { export default function Settings() {
const [editingAccountId, setEditingAccountId] = useState<string | null>(null); const [editingAccountId, setEditingAccountId] = useState<string | null>(null);
const [editingName, setEditingName] = useState(""); const [editingName, setEditingName] = useState("");
const [testService, setTestService] = useState(""); const [failedImages, setFailedImages] = useState<Set<string>>(new Set());
const [testMessage, setTestMessage] = useState(
"Test notification from Leggen",
);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -146,16 +135,6 @@ export default function Settings() {
}); });
// Notification mutations // 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({ const deleteServiceMutation = useMutation({
mutationFn: apiClient.deleteNotificationService, mutationFn: apiClient.deleteNotificationService,
onSuccess: () => { onSuccess: () => {
@@ -185,15 +164,6 @@ export default function Settings() {
}; };
// Notification handlers // Notification handlers
const handleTestNotification = () => {
if (!testService) return;
testMutation.mutate({
service: testService.toLowerCase(),
message: testMessage,
});
};
const handleDeleteService = (serviceName: string) => { const handleDeleteService = (serviceName: string) => {
if ( if (
confirm( confirm(
@@ -311,8 +281,20 @@ export default function Settings() {
{/* Mobile layout - stack vertically */} {/* 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 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 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"> <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">
<Building2 className="h-5 w-5 sm:h-6 sm:w-6 text-muted-foreground" /> {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>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
{editingAccountId === account.id ? ( {editingAccountId === account.id ? (
@@ -463,63 +445,6 @@ export default function Settings() {
</TabsContent> </TabsContent>
<TabsContent value="notifications" className="space-y-6"> <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 */} {/* Notification Services */}
<Card> <Card>
<CardHeader> <CardHeader>
@@ -564,45 +489,48 @@ export default function Settings() {
<Bell className="h-6 w-6 text-muted-foreground" /> <Bell className="h-6 w-6 text-muted-foreground" />
)} )}
</div> </div>
<div> <div className="flex-1">
<h4 className="text-lg font-medium text-foreground capitalize"> <div className="flex items-center space-x-3">
{service.name} <h4 className="text-lg font-medium text-foreground capitalize">
</h4> {service.name}
<div className="flex items-center space-x-2 mt-1"> </h4>
<Badge <div className="flex items-center space-x-2">
variant={ <div className={`w-2 h-2 rounded-full ${
service.enabled ? "default" : "destructive" service.enabled && service.configured
} ? 'bg-green-500'
> : service.enabled
{service.enabled ? ( ? 'bg-amber-500'
<CheckCircle className="h-3 w-3 mr-1" /> : 'bg-muted-foreground'
) : ( }`} />
<AlertCircle className="h-3 w-3 mr-1" /> <span className="text-sm text-muted-foreground">
)} {service.enabled && service.configured
{service.enabled ? "Enabled" : "Disabled"} ? 'Active'
</Badge> : service.enabled
<Badge ? 'Needs Configuration'
variant={ : 'Disabled'}
service.configured ? "secondary" : "outline" </span>
} </div>
>
{service.configured
? "Configured"
: "Not Configured"}
</Badge>
</div> </div>
</div> </div>
</div> </div>
<Button <div className="flex items-center space-x-2">
onClick={() => handleDeleteService(service.name)} {service.name.toLowerCase().includes("discord") ? (
disabled={deleteServiceMutation.isPending} <DiscordConfigDrawer settings={notificationSettings} />
variant="ghost" ) : service.name.toLowerCase().includes("telegram") ? (
size="sm" <TelegramConfigDrawer settings={notificationSettings} />
className="text-destructive hover:text-destructive hover:bg-destructive/10" ) : null}
>
<Trash2 className="h-4 w-4" /> <Button
</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>
</div> </div>
))} ))}
@@ -611,60 +539,78 @@ export default function Settings() {
)} )}
</Card> </Card>
{/* Notification Settings */} {/* Notification Filters */}
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center space-x-2"> <div className="flex items-center justify-between">
<SettingsIcon className="h-5 w-5 text-primary" /> <CardTitle className="flex items-center space-x-2">
<span>Notification Settings</span> <Filter className="h-5 w-5 text-primary" />
</CardTitle> <span>Notification Filters</span>
</CardTitle>
<NotificationFiltersDrawer settings={notificationSettings} />
</div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{notificationSettings && ( {notificationSettings?.filters ? (
<div className="space-y-4"> <div className="space-y-4">
<div> <div className="bg-muted rounded-md p-4">
<h4 className="text-sm font-medium text-foreground mb-2"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
Filters <div>
</h4> <Label className="text-xs font-medium text-muted-foreground mb-2 block">
<div className="bg-muted rounded-md p-4"> Case Insensitive Filters
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> </Label>
<div> <div className="min-h-[2rem] flex flex-wrap gap-1">
<Label className="text-xs font-medium text-muted-foreground mb-1 block"> {notificationSettings.filters.case_insensitive.length > 0 ? (
Case Insensitive Filters notificationSettings.filters.case_insensitive.map((filter, index) => (
</Label> <span
<p className="text-sm text-foreground"> key={index}
{notificationSettings.filters.case_insensitive className="inline-flex items-center px-2 py-1 bg-secondary text-secondary-foreground rounded text-xs"
.length > 0 >
? notificationSettings.filters.case_insensitive.join( {filter}
", ", </span>
) ))
: "None"} ) : (
</p> <p className="text-sm text-muted-foreground">None</p>
)}
</div> </div>
<div> </div>
<Label className="text-xs font-medium text-muted-foreground mb-1 block"> <div>
Case Sensitive Filters <Label className="text-xs font-medium text-muted-foreground mb-2 block">
</Label> Case Sensitive Filters
<p className="text-sm text-foreground"> </Label>
{notificationSettings.filters.case_sensitive && <div className="min-h-[2rem] flex flex-wrap gap-1">
notificationSettings.filters.case_sensitive.length > {notificationSettings.filters.case_sensitive &&
0 notificationSettings.filters.case_sensitive.length > 0 ? (
? notificationSettings.filters.case_sensitive.join( notificationSettings.filters.case_sensitive.map((filter, index) => (
", ", <span
) key={index}
: "None"} className="inline-flex items-center px-2 py-1 bg-secondary text-secondary-foreground rounded text-xs"
</p> >
{filter}
</span>
))
) : (
<p className="text-sm text-muted-foreground">None</p>
)}
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<p className="text-sm text-muted-foreground">
<div className="text-sm text-muted-foreground"> Filters determine which transaction descriptions will trigger notifications.
<p> Add terms to exclude transactions containing those words.
Configure notification settings through your backend API </p>
to customize filters and service configurations. </div>
</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> </div>
)} )}
</CardContent> </CardContent>

View File

@@ -7,6 +7,7 @@ import {
Clock, Clock,
TrendingUp, TrendingUp,
User, User,
FileText,
} from "lucide-react"; } from "lucide-react";
import { apiClient } from "../lib/api"; import { apiClient } from "../lib/api";
import { import {
@@ -19,7 +20,73 @@ import {
import { Alert, AlertDescription, AlertTitle } from "./ui/alert"; import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
import { Button } from "./ui/button"; import { Button } from "./ui/button";
import { Badge } from "./ui/badge"; 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() { export default function System() {
const { const {
@@ -111,68 +178,128 @@ export default function System() {
return ( return (
<div <div
key={operation.id} 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"> {/* Desktop Layout */}
<div <div className="hidden md:flex items-center justify-between p-4">
className={`p-2 rounded-full ${ <div className="flex items-center space-x-4">
isRunning <div
? "bg-blue-100 text-blue-600" className={`p-2 rounded-full ${
: operation.success isRunning
? "bg-green-100 text-green-600" ? "bg-blue-100 text-blue-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"
: operation.success : operation.success
? "Sync Completed" ? "bg-green-100 text-green-600"
: "Sync Failed"} : "bg-red-100 text-red-600"
</h4> }`}
<Badge variant="outline" className="text-xs"> >
{operation.trigger_type} {isRunning ? (
</Badge> <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-4 mt-1 text-xs text-muted-foreground"> <div>
<span className="flex items-center space-x-1"> <div className="flex items-center space-x-2">
<Clock className="h-3 w-3" /> <h4 className="text-sm font-medium text-foreground">
<span> {isRunning
{startedAt.toLocaleDateString()}{" "} ? "Sync Running"
{startedAt.toLocaleTimeString()} : 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>
</span> {duration && <span>Duration: {duration}</span>}
{duration && <span>Duration: {duration}</span>} </div>
</div> </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>
<div className="text-right text-sm text-muted-foreground">
<div className="flex items-center space-x-2"> {/* Mobile Layout */}
<User className="h-3 w-3" /> <div className="md:hidden p-4 space-y-3">
<span>{operation.accounts_processed} accounts</span> <div className="flex items-start justify-between">
</div> <div className="flex items-center space-x-3">
<div className="flex items-center space-x-2 mt-1"> <div
<TrendingUp className="h-3 w-3" /> className={`p-2 rounded-full ${
<span> isRunning
{operation.transactions_added} new transactions ? "bg-blue-100 text-blue-600"
</span> : operation.success
</div> ? "bg-green-100 text-green-600"
{operation.errors.length > 0 && ( : "bg-red-100 text-red-600"
<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> {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> </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>
</div> </div>
); );

View File

@@ -0,0 +1,198 @@
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"> <div className="text-xs text-muted-foreground space-y-1">
{account && ( {account && (
<p className="truncate"> <p className="truncate">
{account.name || "Unnamed Account"} {" "} {account.display_name || "Unnamed Account"}
{account.institution_id}
</p> </p>
)} )}
{(transaction.creditor_name || transaction.debtor_name) && ( {(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"> <div className="text-xs text-muted-foreground space-y-1 mt-1">
{account && ( {account && (
<p className="break-words"> <p className="break-words">
{account.name || "Unnamed Account"} {" "} {account.display_name || "Unnamed Account"}
{account.institution_id}
</p> </p>
)} )}
{(transaction.creditor_name || {(transaction.creditor_name ||

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,39 @@
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 { interface PWAUpdate {
updateAvailable: boolean; updateAvailable: boolean;
updateSW: () => Promise<void>; updateSW: () => Promise<void>;
forceReload: () => Promise<void>;
} }
export function usePWA(): PWAUpdate { export function usePWA(): PWAUpdate {
@@ -11,6 +12,33 @@ export function usePWA(): PWAUpdate {
() => async () => {}, () => 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(() => { useEffect(() => {
// Check if SW registration is available // Check if SW registration is available
if ("serviceWorker" in navigator) { if ("serviceWorker" in navigator) {
@@ -37,5 +65,6 @@ export function usePWA(): PWAUpdate {
return { return {
updateAvailable, updateAvailable,
updateSW, updateSW,
forceReload,
}; };
} }

View File

@@ -0,0 +1,40 @@
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

@@ -3,10 +3,14 @@ import { AppSidebar } from "../components/AppSidebar";
import { SiteHeader } from "../components/SiteHeader"; import { SiteHeader } from "../components/SiteHeader";
import { PWAInstallPrompt, PWAUpdatePrompt } from "../components/PWAPrompts"; import { PWAInstallPrompt, PWAUpdatePrompt } from "../components/PWAPrompts";
import { usePWA } from "../hooks/usePWA"; import { usePWA } from "../hooks/usePWA";
import { useVersionCheck } from "../hooks/useVersionCheck";
import { SidebarInset, SidebarProvider } from "../components/ui/sidebar"; import { SidebarInset, SidebarProvider } from "../components/ui/sidebar";
function RootLayout() { function RootLayout() {
const { updateAvailable, updateSW } = usePWA(); const { updateAvailable, updateSW, forceReload } = usePWA();
// Check for version mismatches and force reload if needed
useVersionCheck(forceReload);
const handlePWAInstall = () => { const handlePWAInstall = () => {
console.log("PWA installed successfully"); console.log("PWA installed successfully");

View File

@@ -44,7 +44,7 @@ function AnalyticsDashboard() {
if (isLoading) { if (isLoading) {
return ( return (
<div className="p-6"> <div className="space-y-8">
<div className="animate-pulse"> <div className="animate-pulse">
<div className="h-8 bg-muted rounded w-48 mb-6"></div> <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"> <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 ( return (
<div className="p-6 space-y-8"> <div className="space-y-8">
{/* Time Period Filter */} {/* Time Period Filter */}
<Card> <Card>
<CardContent className="p-4"> <CardContent className="p-4">

View File

@@ -13,6 +13,7 @@ export interface Account {
name?: string; name?: string;
display_name?: string; display_name?: string;
currency?: string; currency?: string;
logo?: string;
created: string; created: string;
last_accessed?: string; last_accessed?: string;
balances: AccountBalance[]; balances: AccountBalance[];

View File

@@ -1,12 +1,12 @@
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import react from "@vitejs/plugin-react"; 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"; import { VitePWA } from "vite-plugin-pwa";
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [
TanStackRouterVite(), tanstackRouter(),
react(), react(),
VitePWA({ VitePWA({
registerType: "autoUpdate", registerType: "autoUpdate",

View File

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

View File

@@ -18,7 +18,7 @@ class SyncOperation(BaseModel):
duration_seconds: Optional[float] = None duration_seconds: Optional[float] = None
errors: list[str] = [] errors: list[str] = []
logs: list[str] = [] logs: list[str] = []
trigger_type: str = "manual" # manual, scheduled, api trigger_type: str = "manual" # manual, scheduled, retry, api
class Config: class Config:
json_encoders = {datetime: lambda v: v.isoformat() if v else None} 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"), name=db_account.get("name"),
display_name=db_account.get("display_name"), display_name=db_account.get("display_name"),
currency=db_account.get("currency"), currency=db_account.get("currency"),
logo=db_account.get("logo"),
created=db_account["created"], created=db_account["created"],
last_accessed=db_account.get("last_accessed"), last_accessed=db_account.get("last_accessed"),
balances=balances, balances=balances,
@@ -115,6 +116,7 @@ async def get_account_details(account_id: str) -> APIResponse:
name=db_account.get("name"), name=db_account.get("name"),
display_name=db_account.get("display_name"), display_name=db_account.get("display_name"),
currency=db_account.get("currency"), currency=db_account.get("currency"),
logo=db_account.get("logo"),
created=db_account["created"], created=db_account["created"],
last_accessed=db_account.get("last_accessed"), last_accessed=db_account.get("last_accessed"),
balances=balances, balances=balances,

View File

@@ -21,14 +21,15 @@ async def get_bank_institutions(
) -> APIResponse: ) -> APIResponse:
"""Get available bank institutions for a country""" """Get available bank institutions for a country"""
try: try:
institutions_data = await gocardless_service.get_institutions(country) institutions_response = await gocardless_service.get_institutions(country)
institutions_data = institutions_response.get("results", [])
institutions = [ institutions = [
BankInstitution( BankInstitution(
id=inst["id"], id=inst["id"],
name=inst["name"], name=inst["name"],
bic=inst.get("bic"), bic=inst.get("bic"),
transaction_total_days=inst["transaction_total_days"], transaction_total_days=int(inst["transaction_total_days"]),
countries=inst["countries"], countries=inst["countries"],
logo=inst.get("logo"), logo=inst.get("logo"),
) )

View File

@@ -1,6 +1,6 @@
import os import os
from typing import Any, Dict, List, Optional, Union from typing import Any, Dict, List, Optional, Union
from urllib.parse import urljoin from urllib.parse import urljoin, urlparse
import requests import requests
@@ -13,11 +13,22 @@ class LeggenAPIClient:
base_url: str base_url: str
def __init__(self, base_url: Optional[str] = None): def __init__(self, base_url: Optional[str] = None):
self.base_url = ( raw_url = (
base_url base_url
or os.environ.get("LEGGEN_API_URL", "http://localhost:8000") or os.environ.get("LEGGEN_API_URL", "http://localhost:8000")
or "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 = requests.Session()
self.session.headers.update( self.session.headers.update(
{"Content-Type": "application/json", "Accept": "application/json"} {"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]: def _make_request(self, method: str, endpoint: str, **kwargs) -> Dict[str, Any]:
"""Make HTTP request to the API""" """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: try:
response = self.session.request(method, url, **kwargs) response = self.session.request(method, url, **kwargs)
@@ -52,7 +70,9 @@ class LeggenAPIClient:
"""Check if the leggen server is healthy""" """Check if the leggen server is healthy"""
try: try:
response = self._make_request("GET", "/health") 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: except Exception:
return False return False
@@ -60,7 +80,7 @@ class LeggenAPIClient:
def get_institutions(self, country: str = "PT") -> List[Dict[str, Any]]: def get_institutions(self, country: str = "PT") -> List[Dict[str, Any]]:
"""Get bank institutions for a country""" """Get bank institutions for a country"""
response = self._make_request( response = self._make_request(
"GET", "/api/v1/banks/institutions", params={"country": country} "GET", "/banks/institutions", params={"country": country}
) )
return response.get("data", []) return response.get("data", [])
@@ -70,35 +90,35 @@ class LeggenAPIClient:
"""Connect to a bank""" """Connect to a bank"""
response = self._make_request( response = self._make_request(
"POST", "POST",
"/api/v1/banks/connect", "/banks/connect",
json={"institution_id": institution_id, "redirect_url": redirect_url}, json={"institution_id": institution_id, "redirect_url": redirect_url},
) )
return response.get("data", {}) return response.get("data", {})
def get_bank_status(self) -> List[Dict[str, Any]]: def get_bank_status(self) -> List[Dict[str, Any]]:
"""Get bank connection status""" """Get bank connection status"""
response = self._make_request("GET", "/api/v1/banks/status") response = self._make_request("GET", "/banks/status")
return response.get("data", []) return response.get("data", [])
def get_supported_countries(self) -> List[Dict[str, Any]]: def get_supported_countries(self) -> List[Dict[str, Any]]:
"""Get supported countries""" """Get supported countries"""
response = self._make_request("GET", "/api/v1/banks/countries") response = self._make_request("GET", "/banks/countries")
return response.get("data", []) return response.get("data", [])
# Account endpoints # Account endpoints
def get_accounts(self) -> List[Dict[str, Any]]: def get_accounts(self) -> List[Dict[str, Any]]:
"""Get all accounts""" """Get all accounts"""
response = self._make_request("GET", "/api/v1/accounts") response = self._make_request("GET", "/accounts")
return response.get("data", []) return response.get("data", [])
def get_account_details(self, account_id: str) -> Dict[str, Any]: def get_account_details(self, account_id: str) -> Dict[str, Any]:
"""Get account details""" """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", {}) return response.get("data", {})
def get_account_balances(self, account_id: str) -> List[Dict[str, Any]]: def get_account_balances(self, account_id: str) -> List[Dict[str, Any]]:
"""Get account balances""" """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", []) return response.get("data", [])
def get_account_transactions( def get_account_transactions(
@@ -107,7 +127,7 @@ class LeggenAPIClient:
"""Get account transactions""" """Get account transactions"""
response = self._make_request( response = self._make_request(
"GET", "GET",
f"/api/v1/accounts/{account_id}/transactions", f"/accounts/{account_id}/transactions",
params={"limit": limit, "summary_only": summary_only}, params={"limit": limit, "summary_only": summary_only},
) )
return response.get("data", []) return response.get("data", [])
@@ -120,7 +140,7 @@ class LeggenAPIClient:
params = {"limit": limit, "summary_only": summary_only} params = {"limit": limit, "summary_only": summary_only}
params.update(filters) 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", []) return response.get("data", [])
def get_transaction_stats( def get_transaction_stats(
@@ -131,15 +151,13 @@ class LeggenAPIClient:
if account_id: if account_id:
params["account_id"] = account_id params["account_id"] = account_id
response = self._make_request( response = self._make_request("GET", "/transactions/stats", params=params)
"GET", "/api/v1/transactions/stats", params=params
)
return response.get("data", {}) return response.get("data", {})
# Sync endpoints # Sync endpoints
def get_sync_status(self) -> Dict[str, Any]: def get_sync_status(self) -> Dict[str, Any]:
"""Get sync status""" """Get sync status"""
response = self._make_request("GET", "/api/v1/sync/status") response = self._make_request("GET", "/sync/status")
return response.get("data", {}) return response.get("data", {})
def trigger_sync( def trigger_sync(
@@ -150,7 +168,7 @@ class LeggenAPIClient:
if account_ids: if account_ids:
data["account_ids"] = 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", {}) return response.get("data", {})
def sync_now( def sync_now(
@@ -161,12 +179,12 @@ class LeggenAPIClient:
if account_ids: if account_ids:
data["account_ids"] = 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", {}) return response.get("data", {})
def get_scheduler_config(self) -> Dict[str, Any]: def get_scheduler_config(self) -> Dict[str, Any]:
"""Get scheduler configuration""" """Get scheduler configuration"""
response = self._make_request("GET", "/api/v1/sync/scheduler") response = self._make_request("GET", "/sync/scheduler")
return response.get("data", {}) return response.get("data", {})
def update_scheduler_config( def update_scheduler_config(
@@ -185,5 +203,5 @@ class LeggenAPIClient:
if cron: if cron:
data["cron"] = 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", {}) return response.get("data", {})

View File

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

View File

@@ -1,5 +1,6 @@
import click import click
from leggen.api_client import LeggenAPIClient
from leggen.main import cli from leggen.main import cli
from leggen.utils.text import info, success 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 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}") info(f"Deleting Bank Requisition: {requisition_id}")
api_url = ctx.obj.get("api_url", "http://localhost:8000") # Use API client to make the delete request
res = requests.delete(f"{api_url}/requisitions/{requisition_id}") api_client._make_request("DELETE", f"/requisitions/{requisition_id}")
res.raise_for_status()
success(f"Bank Requisition {requisition_id} deleted") success(f"Bank Requisition {requisition_id} deleted")

View File

@@ -55,3 +55,44 @@ def send_transactions_message(ctx: click.Context, transactions: list):
response.raise_for_status() response.raise_for_status()
except Exception as e: except Exception as e:
raise Exception(f"Discord notification failed: {e}\n{response.text}") from 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() res.raise_for_status()
except Exception as e: except Exception as e:
raise Exception(f"Telegram notification failed: {e}\n{res.text}") from 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

@@ -217,6 +217,7 @@ class DatabaseService:
await self._migrate_to_composite_key_if_needed() await self._migrate_to_composite_key_if_needed()
await self._migrate_add_display_name_if_needed() await self._migrate_add_display_name_if_needed()
await self._migrate_add_sync_operations_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): async def _migrate_balance_timestamps_if_needed(self):
"""Check and migrate balance timestamps if needed""" """Check and migrate balance timestamps if needed"""
@@ -1133,7 +1134,8 @@ class DatabaseService:
created DATETIME, created DATETIME,
last_accessed DATETIME, last_accessed DATETIME,
last_updated DATETIME, last_updated DATETIME,
display_name TEXT display_name TEXT,
logo TEXT
)""" )"""
) )
@@ -1170,8 +1172,9 @@ class DatabaseService:
created, created,
last_accessed, last_accessed,
last_updated, last_updated,
display_name display_name,
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", logo
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
( (
account_data["id"], account_data["id"],
account_data["institution_id"], account_data["institution_id"],
@@ -1183,6 +1186,7 @@ class DatabaseService:
account_data.get("last_accessed"), account_data.get("last_accessed"),
account_data.get("last_updated", account_data["created"]), account_data.get("last_updated", account_data["created"]),
display_name, display_name,
account_data.get("logo"),
), ),
) )
conn.commit() conn.commit()
@@ -1516,6 +1520,79 @@ class DatabaseService:
logger.error(f"Sync operations table migration failed: {e}") logger.error(f"Sync operations table migration failed: {e}")
raise 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: async def persist_sync_operation(self, sync_operation: Dict[str, Any]) -> int:
"""Persist sync operation to database and return the ID""" """Persist sync operation to database and return the ID"""
if not self.sqlite_enabled: if not self.sqlite_enabled:

View File

@@ -1,6 +1,6 @@
import json import json
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List from typing import Any, Dict
import httpx import httpx
from loguru import logger from loguru import logger
@@ -11,14 +11,13 @@ from leggen.utils.paths import path_manager
def _log_rate_limits(response): def _log_rate_limits(response):
"""Log GoCardless API rate limit headers""" """Log GoCardless API rate limit headers"""
limit = response.headers.get("X-RateLimit-Limit") limit = response.headers.get("http_x_ratelimit_limit")
remaining = response.headers.get("X-RateLimit-Remaining") remaining = response.headers.get("http_x_ratelimit_remaining")
reset = response.headers.get("X-RateLimit-Reset") reset = response.headers.get("http_x_ratelimit_reset")
account_success_reset = response.headers.get("X-RateLimit-Account-Success-Reset")
if limit or remaining or reset or account_success_reset: if limit or remaining or reset:
logger.info( logger.info(
f"GoCardless rate limits - Limit: {limit}, Remaining: {remaining}, Reset: {reset}s, Account Success Reset: {account_success_reset}" f"GoCardless rate limits - Limit: {limit}, Remaining: {remaining}, Reset: {reset}s"
) )
@@ -30,6 +29,27 @@ class GoCardlessService:
) )
self._token = None 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, **kwargs)
_log_rate_limits(response)
# 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, **kwargs)
_log_rate_limits(response)
response.raise_for_status()
return response.json()
async def _get_auth_headers(self) -> Dict[str, str]: async def _get_auth_headers(self) -> Dict[str, str]:
"""Get authentication headers for GoCardless API""" """Get authentication headers for GoCardless API"""
token = await self._get_token() token = await self._get_token()
@@ -102,74 +122,48 @@ class GoCardlessService:
with open(auth_file, "w") as f: with open(auth_file, "w") as f:
json.dump(auth_data, 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""" """Get available bank institutions for a country"""
headers = await self._get_auth_headers() return await self._make_authenticated_request(
async with httpx.AsyncClient() as client: "GET", f"{self.base_url}/institutions/", params={"country": country}
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()
async def create_requisition( async def create_requisition(
self, institution_id: str, redirect_url: str self, institution_id: str, redirect_url: str
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Create a bank connection requisition""" """Create a bank connection requisition"""
headers = await self._get_auth_headers() return await self._make_authenticated_request(
async with httpx.AsyncClient() as client: "POST",
response = await client.post( f"{self.base_url}/requisitions/",
f"{self.base_url}/requisitions/", json={"institution_id": institution_id, "redirect": redirect_url},
headers=headers, )
json={"institution_id": institution_id, "redirect": redirect_url},
)
_log_rate_limits(response)
response.raise_for_status()
return response.json()
async def get_requisitions(self) -> Dict[str, Any]: async def get_requisitions(self) -> Dict[str, Any]:
"""Get all requisitions""" """Get all requisitions"""
headers = await self._get_auth_headers() return await self._make_authenticated_request(
async with httpx.AsyncClient() as client: "GET", f"{self.base_url}/requisitions/"
response = await client.get( )
f"{self.base_url}/requisitions/", headers=headers
)
_log_rate_limits(response)
response.raise_for_status()
return response.json()
async def get_account_details(self, account_id: str) -> Dict[str, Any]: async def get_account_details(self, account_id: str) -> Dict[str, Any]:
"""Get account details""" """Get account details"""
headers = await self._get_auth_headers() return await self._make_authenticated_request(
async with httpx.AsyncClient() as client: "GET", f"{self.base_url}/accounts/{account_id}/"
response = await client.get( )
f"{self.base_url}/accounts/{account_id}/", headers=headers
)
_log_rate_limits(response)
response.raise_for_status()
return response.json()
async def get_account_balances(self, account_id: str) -> Dict[str, Any]: async def get_account_balances(self, account_id: str) -> Dict[str, Any]:
"""Get account balances""" """Get account balances"""
headers = await self._get_auth_headers() return await self._make_authenticated_request(
async with httpx.AsyncClient() as client: "GET", f"{self.base_url}/accounts/{account_id}/balances/"
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()
async def get_account_transactions(self, account_id: str) -> Dict[str, Any]: async def get_account_transactions(self, account_id: str) -> Dict[str, Any]:
"""Get account transactions""" """Get account transactions"""
headers = await self._get_auth_headers() return await self._make_authenticated_request(
async with httpx.AsyncClient() as client: "GET", f"{self.base_url}/accounts/{account_id}/transactions/"
response = await client.get( )
f"{self.base_url}/accounts/{account_id}/transactions/", headers=headers
) async def get_institution_details(self, institution_id: str) -> Dict[str, Any]:
_log_rate_limits(response) """Get institution details by ID"""
response.raise_for_status() return await self._make_authenticated_request(
return response.json() "GET", f"{self.base_url}/institutions/{institution_id}/"
)

View File

@@ -289,3 +289,69 @@ class NotificationService:
except Exception as e: except Exception as e:
logger.error(f"Failed to send Telegram expiry notification: {e}") logger.error(f"Failed to send Telegram expiry notification: {e}")
raise 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.database = DatabaseService()
self.notifications = NotificationService() self.notifications = NotificationService()
self._sync_status = SyncStatus(is_running=False) self._sync_status = SyncStatus(is_running=False)
self._institution_logos = {} # Cache for institution logos
async def get_sync_status(self) -> SyncStatus: async def get_sync_status(self) -> SyncStatus:
"""Get current sync status""" """Get current sync status"""
@@ -77,7 +78,7 @@ class SyncService:
# Get balances to extract currency information # Get balances to extract currency information
balances = await self.gocardless.get_account_balances(account_id) 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: if account_details and balances:
enriched_account_details = account_details.copy() enriched_account_details = account_details.copy()
@@ -90,6 +91,26 @@ class SyncService:
if currency: if currency:
enriched_account_details["currency"] = 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 # Persist enriched account details to database
await self.database.persist_account_details( await self.database.persist_account_details(
enriched_account_details enriched_account_details

View File

@@ -39,13 +39,11 @@ class Config:
try: try:
with open(config_path, "rb") as f: with open(config_path, "rb") as f:
raw_config = tomllib.load(f) raw_config = tomllib.load(f)
logger.info(f"Configuration loaded from {config_path}")
# Validate configuration using Pydantic # Validate configuration using Pydantic
try: try:
self._config_model = ConfigModel(**raw_config) self._config_model = ConfigModel(**raw_config)
self._config = self._config_model.dict(by_alias=True, exclude_none=True) self._config = self._config_model.dict(by_alias=True, exclude_none=True)
logger.info("Configuration validation successful")
except ValidationError as e: except ValidationError as e:
logger.error(f"Configuration validation failed: {e}") logger.error(f"Configuration validation failed: {e}")
raise ValueError(f"Invalid configuration: {e}") from e raise ValueError(f"Invalid configuration: {e}") from e

View File

@@ -1,6 +1,6 @@
[project] [project]
name = "leggen" name = "leggen"
version = "2025.9.20" version = "2025.9.23"
description = "An Open Banking CLI" description = "An Open Banking CLI"
authors = [{ name = "Elisiário Couto", email = "elisiario@couto.io" }] authors = [{ name = "Elisiário Couto", email = "elisiario@couto.io" }]
requires-python = "~=3.13.0" requires-python = "~=3.13.0"

View File

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

View File

@@ -50,7 +50,7 @@ class TestBanksAPI:
# Mock empty institutions response for invalid country # Mock empty institutions response for invalid country
respx.get("https://bankaccountdata.gocardless.com/api/v2/institutions/").mock( 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): with patch("leggen.utils.config.config", mock_config):

View File

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

2
uv.lock generated
View File

@@ -220,7 +220,7 @@ wheels = [
[[package]] [[package]]
name = "leggen" name = "leggen"
version = "2025.9.20" version = "2025.9.23"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "apscheduler" }, { name = "apscheduler" },