diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 363c765..7149ce8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI on: push: - branches: [ "main", "dev" ] + branches: ["main", "dev"] pull_request: - branches: [ "main", "dev" ] + branches: ["main", "dev"] jobs: test-python: @@ -43,8 +43,8 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '20' - cache: 'npm' + node-version: "20" + cache: "npm" cache-dependency-path: frontend/package-lock.json - name: Install dependencies diff --git a/.mcp.json b/.mcp.json index 771f770..a742b3b 100644 --- a/.mcp.json +++ b/.mcp.json @@ -2,16 +2,11 @@ "mcpServers": { "shadcn": { "command": "npx", - "args": [ - "shadcn@latest", - "mcp" - ] + "args": ["shadcn@latest", "mcp"] }, "playwright": { "command": "npx", - "args": [ - "@playwright/mcp@latest" - ] + "args": ["@playwright/mcp@latest"] } } } diff --git a/frontend/src/components/AccountSettings.tsx b/frontend/src/components/AccountSettings.tsx index e1f7665..b3594d2 100644 --- a/frontend/src/components/AccountSettings.tsx +++ b/frontend/src/components/AccountSettings.tsx @@ -202,8 +202,12 @@ export default function AccountSettings() { 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])); + console.warn( + `Failed to load bank logo for ${account.institution_id}: ${account.logo}`, + ); + setFailedImages( + (prev) => new Set([...prev, account.id]), + ); }} /> ) : ( diff --git a/frontend/src/components/AddBankAccountDrawer.tsx b/frontend/src/components/AddBankAccountDrawer.tsx new file mode 100644 index 0000000..378c0c0 --- /dev/null +++ b/frontend/src/components/AddBankAccountDrawer.tsx @@ -0,0 +1,203 @@ +import { useState } from "react"; +import { useQuery, useMutation } from "@tanstack/react-query"; +import { Plus, Building2, ExternalLink } from "lucide-react"; +import { apiClient } from "../lib/api"; +import { Button } from "./ui/button"; +import { Label } from "./ui/label"; +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from "./ui/drawer"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "./ui/select"; +import { Alert, AlertDescription } from "./ui/alert"; + +export default function AddBankAccountDrawer() { + const [open, setOpen] = useState(false); + const [selectedCountry, setSelectedCountry] = useState(""); + const [selectedBank, setSelectedBank] = useState(""); + + const { data: countries } = useQuery({ + queryKey: ["supportedCountries"], + queryFn: apiClient.getSupportedCountries, + }); + + const { data: banks, isLoading: banksLoading } = useQuery({ + queryKey: ["bankInstitutions", selectedCountry], + queryFn: () => apiClient.getBankInstitutions(selectedCountry), + enabled: !!selectedCountry, + }); + + const connectBankMutation = useMutation({ + mutationFn: (institutionId: string) => + apiClient.createBankConnection(institutionId), + onSuccess: (data) => { + // Redirect to the bank's authorization link + if (data.link) { + window.open(data.link, "_blank"); + setOpen(false); + } + }, + onError: (error) => { + console.error("Failed to create bank connection:", error); + }, + }); + + const handleCountryChange = (country: string) => { + setSelectedCountry(country); + setSelectedBank(""); + }; + + const handleConnect = () => { + if (selectedBank) { + connectBankMutation.mutate(selectedBank); + } + }; + + const resetForm = () => { + setSelectedCountry(""); + setSelectedBank(""); + }; + + return ( + { + setOpen(isOpen); + if (!isOpen) { + resetForm(); + } + }} + > + + + + + + Connect Bank Account + + Select your country and bank to connect your account to Leggen + + + +
+ {/* Country Selection */} +
+ + +
+ + {/* Bank Selection */} + {selectedCountry && ( +
+ + {banksLoading ? ( +
+ Loading banks... +
+ ) : banks && banks.length > 0 ? ( + + ) : ( + + + No banks available for the selected country. + + + )} +
+ )} + + {/* Instructions */} + {selectedBank && ( + + + You'll be redirected to your bank's website to authorize the + connection. After approval, you'll return to Leggen and your + account will start syncing. + + + )} + + {/* Error Display */} + {connectBankMutation.isError && ( + + + Failed to create bank connection. Please try again. + + + )} +
+ + +
+ + + + +
+
+
+
+ ); +} diff --git a/frontend/src/components/DiscordConfigDrawer.tsx b/frontend/src/components/DiscordConfigDrawer.tsx index 8828068..cbacd19 100644 --- a/frontend/src/components/DiscordConfigDrawer.tsx +++ b/frontend/src/components/DiscordConfigDrawer.tsx @@ -47,7 +47,10 @@ export default function DiscordConfigDrawer({ apiClient.updateNotificationSettings({ ...settings, discord: discordConfig, - filters: settings?.filters || { case_insensitive: [], case_sensitive: [] }, + filters: settings?.filters || { + case_insensitive: [], + case_sensitive: [], + }, }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["notificationSettings"] }); @@ -60,10 +63,12 @@ export default function DiscordConfigDrawer({ }); const testMutation = useMutation({ - mutationFn: () => apiClient.testNotification({ - service: "discord", - message: "Test notification from Leggen - Discord configuration is working!" - }), + mutationFn: () => + apiClient.testNotification({ + service: "discord", + message: + "Test notification from Leggen - Discord configuration is working!", + }), onSuccess: () => { console.log("Test Discord notification sent successfully"); }, @@ -81,13 +86,13 @@ export default function DiscordConfigDrawer({ testMutation.mutate(); }; - const isConfigValid = config.webhook.trim().length > 0 && config.webhook.includes('discord.com/api/webhooks'); + const isConfigValid = + config.webhook.trim().length > 0 && + config.webhook.includes("discord.com/api/webhooks"); return ( - - {trigger || } - + {trigger || }
@@ -103,7 +108,9 @@ export default function DiscordConfigDrawer({
{/* Enable/Disable Toggle */}
- + setConfig({ ...config, enabled })} @@ -118,11 +125,14 @@ export default function DiscordConfigDrawer({ type="url" placeholder="https://discord.com/api/webhooks/..." value={config.webhook} - onChange={(e) => setConfig({ ...config, webhook: e.target.value })} + onChange={(e) => + setConfig({ ...config, webhook: e.target.value }) + } disabled={!config.enabled} />

- Create a webhook in your Discord server settings under Integrations → Webhooks + Create a webhook in your Discord server settings under + Integrations → Webhooks

@@ -130,9 +140,13 @@ export default function DiscordConfigDrawer({ {config.enabled && (
-
+
- {isConfigValid ? 'Configuration Valid' : 'Invalid Webhook URL'} + {isConfigValid + ? "Configuration Valid" + : "Invalid Webhook URL"}
{!isConfigValid && config.webhook.trim().length > 0 && ( @@ -145,8 +159,13 @@ export default function DiscordConfigDrawer({
- {config.enabled && isConfigValid && (
@@ -157,16 +176,21 @@ export default function NotificationFiltersDrawer({
)) ) : ( - No filters added + + No filters added + )}
{/* Case Sensitive Filters */}
- +

- Filters that match exactly as typed (e.g., "AMAZON" only matches "AMAZON") + Filters that match exactly as typed (e.g., "AMAZON" only matches + "AMAZON")

@@ -181,7 +205,11 @@ export default function NotificationFiltersDrawer({ } }} /> -
@@ -204,7 +232,9 @@ export default function NotificationFiltersDrawer({
)) ) : ( - No filters added + + No filters added + )}
diff --git a/frontend/src/components/Settings.tsx b/frontend/src/components/Settings.tsx index fafd7fb..3bb06ee 100644 --- a/frontend/src/components/Settings.tsx +++ b/frontend/src/components/Settings.tsx @@ -10,7 +10,6 @@ import { Edit2, Check, X, - Plus, Bell, MessageSquare, Send, @@ -35,6 +34,7 @@ import AccountsSkeleton from "./AccountsSkeleton"; import NotificationFiltersDrawer from "./NotificationFiltersDrawer"; import DiscordConfigDrawer from "./DiscordConfigDrawer"; import TelegramConfigDrawer from "./TelegramConfigDrawer"; +import AddBankAccountDrawer from "./AddBankAccountDrawer"; import type { Account, Balance, @@ -120,6 +120,11 @@ export default function Settings() { queryFn: apiClient.getNotificationServices, }); + const { data: bankConnections } = useQuery({ + queryKey: ["bankConnections"], + queryFn: apiClient.getBankConnectionsStatus, + }); + // Account mutations const updateAccountMutation = useMutation({ mutationFn: ({ id, display_name }: { id: string; display_name: string }) => @@ -143,6 +148,16 @@ export default function Settings() { }, }); + // Bank connection mutations + const deleteBankConnectionMutation = useMutation({ + mutationFn: apiClient.deleteBankConnection, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["accounts"] }); + queryClient.invalidateQueries({ queryKey: ["bankConnections"] }); + queryClient.invalidateQueries({ queryKey: ["balances"] }); + }, + }); + // Account handlers const handleEditStart = (account: Account) => { setEditingAccountId(account.id); @@ -245,13 +260,6 @@ export default function Settings() {

Connect your first bank account to get started with Leggen.

- -

- Coming soon: Add new bank connections -

) : ( @@ -288,8 +296,12 @@ export default function Settings() { 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])); + console.warn( + `Failed to load bank logo for ${account.institution_id}: ${account.logo}`, + ); + setFailedImages( + (prev) => new Set([...prev, account.id]), + ); }} /> ) : ( @@ -417,30 +429,110 @@ export default function Settings() { )} - {/* Add Bank Section (Future Feature) */} + {/* Bank Connections Status */} - Add New Bank Account - - Connect additional bank accounts to track all your finances in - one place - - - -
-
- -

- Bank connection functionality is coming soon. Stay tuned for - updates! -

+
+
+ Bank Connections + + Status of all bank connection requests and their + authorization state +
- +
- + + + {!bankConnections || bankConnections.length === 0 ? ( + + +

+ No bank connections found +

+

+ Bank connection requests will appear here after you connect + accounts. +

+
+ ) : ( + +
+ {bankConnections.map((connection) => { + const statusColor = + connection.status.toLowerCase() === "ln" + ? "bg-green-500" + : connection.status.toLowerCase() === "cr" + ? "bg-amber-500" + : connection.status.toLowerCase() === "ex" + ? "bg-red-500" + : "bg-muted-foreground"; + + return ( +
+
+
+
+ +
+
+
+

+ {connection.bank_name} +

+
+
+

+ {connection.status_display} •{" "} + {connection.accounts_count} account + {connection.accounts_count !== 1 ? "s" : ""} +

+

+ ID: {connection.requisition_id} +

+
+
+ +
+
+

+ Created {formatDate(connection.created_at)} +

+
+ +
+
+
+ ); + })} +
+ + )} @@ -495,19 +587,21 @@ export default function Settings() { {service.name}
-
+
{service.enabled && service.configured - ? 'Active' + ? "Active" : service.enabled - ? 'Needs Configuration' - : 'Disabled'} + ? "Needs Configuration" + : "Disabled"}
@@ -516,9 +610,15 @@ export default function Settings() {
{service.name.toLowerCase().includes("discord") ? ( - - ) : service.name.toLowerCase().includes("telegram") ? ( - + + ) : service.name + .toLowerCase() + .includes("telegram") ? ( + ) : null}
@@ -580,25 +685,31 @@ export default function Settings() {
{notificationSettings.filters.case_sensitive && - notificationSettings.filters.case_sensitive.length > 0 ? ( - notificationSettings.filters.case_sensitive.map((filter, index) => ( - - {filter} - - )) + notificationSettings.filters.case_sensitive.length > + 0 ? ( + notificationSettings.filters.case_sensitive.map( + (filter, index) => ( + + {filter} + + ), + ) ) : ( -

None

+

+ None +

)}

- Filters determine which transaction descriptions will trigger notifications. - Add terms to exclude transactions containing those words. + Filters determine which transaction descriptions will + trigger notifications. Add terms to exclude transactions + containing those words.

) : ( @@ -608,7 +719,8 @@ export default function Settings() { No notification filters configured

- Set up filters to control which transactions trigger notifications. + Set up filters to control which transactions trigger + notifications.

diff --git a/frontend/src/components/SiteHeader.tsx b/frontend/src/components/SiteHeader.tsx index 2cc1700..69be7d3 100644 --- a/frontend/src/components/SiteHeader.tsx +++ b/frontend/src/components/SiteHeader.tsx @@ -30,8 +30,6 @@ export function SiteHeader() { refetchInterval: 30000, }); - - return (
diff --git a/frontend/src/components/System.tsx b/frontend/src/components/System.tsx index 019c465..3376c83 100644 --- a/frontend/src/components/System.tsx +++ b/frontend/src/components/System.tsx @@ -210,7 +210,8 @@ export default function System() { : "Sync Failed"} - {operation.trigger_type.charAt(0).toUpperCase() + operation.trigger_type.slice(1)} + {operation.trigger_type.charAt(0).toUpperCase() + + operation.trigger_type.slice(1)}
@@ -272,7 +273,8 @@ export default function System() { : "Sync Failed"} - {operation.trigger_type.charAt(0).toUpperCase() + operation.trigger_type.slice(1)} + {operation.trigger_type.charAt(0).toUpperCase() + + operation.trigger_type.slice(1)}
@@ -286,7 +288,9 @@ export default function System() { {startedAt.toLocaleDateString()}{" "} {startedAt.toLocaleTimeString()} - {duration && • {duration}} + {duration && ( + • {duration} + )}
@@ -296,7 +300,9 @@ export default function System() {
- {operation.transactions_added} new transactions + + {operation.transactions_added} new transactions +
diff --git a/frontend/src/components/TelegramConfigDrawer.tsx b/frontend/src/components/TelegramConfigDrawer.tsx index ea77988..5db3735 100644 --- a/frontend/src/components/TelegramConfigDrawer.tsx +++ b/frontend/src/components/TelegramConfigDrawer.tsx @@ -48,7 +48,10 @@ export default function TelegramConfigDrawer({ apiClient.updateNotificationSettings({ ...settings, telegram: telegramConfig, - filters: settings?.filters || { case_insensitive: [], case_sensitive: [] }, + filters: settings?.filters || { + case_insensitive: [], + case_sensitive: [], + }, }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["notificationSettings"] }); @@ -61,10 +64,12 @@ export default function TelegramConfigDrawer({ }); const testMutation = useMutation({ - mutationFn: () => apiClient.testNotification({ - service: "telegram", - message: "Test notification from Leggen - Telegram configuration is working!" - }), + mutationFn: () => + apiClient.testNotification({ + service: "telegram", + message: + "Test notification from Leggen - Telegram configuration is working!", + }), onSuccess: () => { console.log("Test Telegram notification sent successfully"); }, @@ -86,9 +91,7 @@ export default function TelegramConfigDrawer({ return ( - - {trigger || } - + {trigger || }
@@ -104,7 +107,9 @@ export default function TelegramConfigDrawer({ {/* Enable/Disable Toggle */}
- + setConfig({ ...config, enabled })} @@ -119,7 +124,9 @@ export default function TelegramConfigDrawer({ type="password" placeholder="123456789:ABCdefGHIjklMNOpqrsTUVwxyz" value={config.token} - onChange={(e) => setConfig({ ...config, token: e.target.value })} + onChange={(e) => + setConfig({ ...config, token: e.target.value }) + } disabled={!config.enabled} />

@@ -135,11 +142,18 @@ export default function TelegramConfigDrawer({ type="number" placeholder="123456789" value={config.chat_id || ""} - onChange={(e) => setConfig({ ...config, chat_id: parseInt(e.target.value) || 0 })} + onChange={(e) => + setConfig({ + ...config, + chat_id: parseInt(e.target.value) || 0, + }) + } disabled={!config.enabled} />

- Send a message to your bot and visit https://api.telegram.org/bot<token>/getUpdates to find your chat ID + Send a message to your bot and visit + https://api.telegram.org/bot<token>/getUpdates to find + your chat ID

@@ -147,23 +161,33 @@ export default function TelegramConfigDrawer({ {config.enabled && (
-
+
- {isConfigValid ? 'Configuration Valid' : 'Missing Token or Chat ID'} + {isConfigValid + ? "Configuration Valid" + : "Missing Token or Chat ID"}
- {!isConfigValid && (config.token.trim().length > 0 || config.chat_id !== 0) && ( -

- Both bot token and chat ID are required -

- )} + {!isConfigValid && + (config.token.trim().length > 0 || config.chat_id !== 0) && ( +

+ Both bot token and chat ID are required +

+ )}
)}
- {config.enabled && isConfigValid && ( +
+ + +
+ ); +} + +export const Route = createFileRoute("/bank-connected")({ + component: BankConnected, + validateSearch: (search: Record) => { + return { + bank: (search.bank as string) || undefined, + }; + }, +}); diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index d40597a..a6a7f4c 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -242,3 +242,38 @@ export interface SyncOperationsResponse { operations: SyncOperation[]; count: number; } + +// Bank-related types +export interface BankInstitution { + id: string; + name: string; + bic?: string; + transaction_total_days: number; + countries: string[]; + logo?: string; +} + +export interface BankRequisition { + id: string; + institution_id: string; + status: string; + status_display?: string; + created: string; + link: string; + accounts: string[]; +} + +export interface BankConnectionStatus { + bank_id: string; + bank_name: string; + status: string; + status_display: string; + created_at: string; + requisition_id: string; + accounts_count: number; +} + +export interface Country { + code: string; + name: string; +} diff --git a/leggen/api/routes/banks.py b/leggen/api/routes/banks.py index 03b5b52..dee9212 100644 --- a/leggen/api/routes/banks.py +++ b/leggen/api/routes/banks.py @@ -1,3 +1,4 @@ +import httpx from fastapi import APIRouter, HTTPException, Query from loguru import logger @@ -22,7 +23,11 @@ async def get_bank_institutions( """Get available bank institutions for a country""" try: institutions_response = await gocardless_service.get_institutions(country) - institutions_data = institutions_response.get("results", []) + # Handle both list and dict responses + if isinstance(institutions_response, list): + institutions_data = institutions_response + else: + institutions_data = institutions_response.get("results", []) institutions = [ BankInstitution( @@ -122,13 +127,36 @@ async def get_bank_connections_status() -> APIResponse: async def delete_bank_connection(requisition_id: str) -> APIResponse: """Delete a bank connection""" try: - # This would need to be implemented in GoCardlessService - # For now, return success + # Delete the requisition from GoCardless + result = await gocardless_service.delete_requisition(requisition_id) + + # GoCardless returns different responses for successful deletes + # We should check if the operation was actually successful + logger.info(f"GoCardless delete response for {requisition_id}: {result}") + return APIResponse( success=True, message=f"Bank connection {requisition_id} deleted successfully", ) + except httpx.HTTPStatusError as http_err: + logger.error( + f"HTTP error deleting bank connection {requisition_id}: {http_err}" + ) + if http_err.response.status_code == 404: + raise HTTPException( + status_code=404, detail=f"Bank connection {requisition_id} not found" + ) from http_err + elif http_err.response.status_code == 400: + raise HTTPException( + status_code=400, + detail=f"Invalid request to delete connection {requisition_id}", + ) from http_err + else: + raise HTTPException( + status_code=http_err.response.status_code, + detail=f"GoCardless API error: {http_err}", + ) from http_err except Exception as e: logger.error(f"Failed to delete bank connection {requisition_id}: {e}") raise HTTPException( diff --git a/leggen/services/gocardless_service.py b/leggen/services/gocardless_service.py index 43cd541..d83a8d3 100644 --- a/leggen/services/gocardless_service.py +++ b/leggen/services/gocardless_service.py @@ -144,6 +144,12 @@ class GoCardlessService: "GET", f"{self.base_url}/requisitions/" ) + async def delete_requisition(self, requisition_id: str) -> Dict[str, Any]: + """Delete a requisition""" + return await self._make_authenticated_request( + "DELETE", f"{self.base_url}/requisitions/{requisition_id}/" + ) + async def get_account_details(self, account_id: str) -> Dict[str, Any]: """Get account details""" return await self._make_authenticated_request(