Compare commits

..

3 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
0cef352a6b refactor: Replace magic numbers with named constants.
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-12-07 01:28:39 +00:00
copilot-swe-agent[bot]
4c56c50638 feat: Add sync error and account expiry notifications.
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-12-07 01:26:11 +00:00
copilot-swe-agent[bot]
aff1e70fbf Initial plan 2025-12-07 01:19:27 +00:00
29 changed files with 147 additions and 290 deletions

View File

@@ -23,7 +23,6 @@ import {
import { Button } from "./ui/button";
import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
import AccountsSkeleton from "./AccountsSkeleton";
import { BlurredValue } from "./ui/blurred-value";
import type { Account, Balance } from "../types/api";
// Helper function to get status indicator color and styles
@@ -159,7 +158,7 @@ export default function AccountsOverview() {
Total Balance
</p>
<p className="text-2xl font-bold text-foreground">
<BlurredValue>{formatCurrency(totalBalance)}</BlurredValue>
{formatCurrency(totalBalance)}
</p>
</div>
<div className="p-3 bg-green-100 dark:bg-green-900/20 rounded-full">
@@ -370,9 +369,7 @@ export default function AccountsOverview() {
isPositive ? "text-green-600" : "text-red-600"
}`}
>
<BlurredValue>
{formatCurrency(balance, currency)}
</BlurredValue>
{formatCurrency(balance, currency)}
</p>
</div>
</div>

View File

@@ -16,7 +16,6 @@ import { apiClient } from "../lib/api";
import { formatCurrency } from "../lib/utils";
import { useState } from "react";
import type { Account } from "../types/api";
import { BlurredValue } from "./ui/blurred-value";
import {
Sidebar,
SidebarContent,
@@ -131,7 +130,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
<div className="px-3 pb-2">
<p className="text-xl font-bold text-foreground">
<BlurredValue>{formatCurrency(totalBalance)}</BlurredValue>
{formatCurrency(totalBalance)}
</p>
<p className="text-sm text-muted-foreground">
{accounts?.length || 0} accounts
@@ -164,9 +163,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
"Unnamed Account"}
</p>
<p className="text-xs font-semibold text-foreground">
<BlurredValue>
{formatCurrency(primaryBalance, currency)}
</BlurredValue>
{formatCurrency(primaryBalance, currency)}
</p>
</div>
</div>

View File

@@ -34,7 +34,6 @@ import { Button } from "./ui/button";
import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
import { Label } from "./ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "./ui/tabs";
import { BlurredValue } from "./ui/blurred-value";
import AccountsSkeleton from "./AccountsSkeleton";
import NotificationFiltersDrawer from "./NotificationFiltersDrawer";
import DiscordConfigDrawer from "./DiscordConfigDrawer";
@@ -492,13 +491,13 @@ export default function Settings() {
) : (
<TrendingDown className="h-4 w-4 text-red-500" />
)}
<BlurredValue
<p
className={`text-base sm:text-lg font-semibold ${
isPositive ? "text-green-600" : "text-red-600"
}`}
>
{formatCurrency(balance, currency)}
</BlurredValue>
</p>
</div>
</div>
</div>

View File

@@ -3,7 +3,6 @@ import { Activity, Wifi, WifiOff } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { apiClient } from "../lib/api";
import { ThemeToggle } from "./ui/theme-toggle";
import { BalanceToggle } from "./ui/balance-toggle";
import { Separator } from "./ui/separator";
import { SidebarTrigger } from "./ui/sidebar";
@@ -78,7 +77,6 @@ export function SiteHeader() {
</>
)}
</div>
<BalanceToggle />
<ThemeToggle />
</div>
</div>

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useMemo } from "react";
import { useState, useEffect } from "react";
import { useQuery } from "@tanstack/react-query";
import {
useReactTable,
@@ -31,8 +31,7 @@ import { DataTablePagination } from "./ui/data-table-pagination";
import { Card } from "./ui/card";
import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
import { Button } from "./ui/button";
import { BlurredValue } from "./ui/blurred-value";
import type { Account, Transaction, PaginatedResponse } from "../types/api";
import type { Account, Transaction, ApiResponse } from "../types/api";
export default function TransactionsTable() {
// Filter state consolidated into a single object
@@ -103,7 +102,7 @@ export default function TransactionsTable() {
isLoading: transactionsLoading,
error: transactionsError,
refetch: refetchTransactions,
} = useQuery<PaginatedResponse<Transaction>>({
} = useQuery<ApiResponse<Transaction[]>>({
queryKey: [
"transactions",
filterState.selectedAccount,
@@ -126,20 +125,7 @@ export default function TransactionsTable() {
});
const transactions = transactionsResponse?.data || [];
const pagination = useMemo(
() =>
transactionsResponse
? {
page: transactionsResponse.page,
total_pages: transactionsResponse.total_pages,
per_page: transactionsResponse.per_page,
total: transactionsResponse.total,
has_next: transactionsResponse.has_next,
has_prev: transactionsResponse.has_prev,
}
: undefined,
[transactionsResponse],
);
const pagination = transactionsResponse?.pagination;
// Check if search is currently debouncing
const isSearchLoading = filterState.searchTerm !== debouncedSearchTerm;
@@ -235,13 +221,11 @@ export default function TransactionsTable() {
isPositive ? "text-green-600" : "text-red-600"
}`}
>
<BlurredValue>
{isPositive ? "+" : ""}
{formatCurrency(
transaction.transaction_value,
transaction.transaction_currency,
)}
</BlurredValue>
{isPositive ? "+" : ""}
{formatCurrency(
transaction.transaction_value,
transaction.transaction_currency,
)}
</p>
</div>
);
@@ -541,13 +525,11 @@ export default function TransactionsTable() {
isPositive ? "text-green-600" : "text-red-600"
}`}
>
<BlurredValue>
{isPositive ? "+" : ""}
{formatCurrency(
transaction.transaction_value,
transaction.transaction_currency,
)}
</BlurredValue>
{isPositive ? "+" : ""}
{formatCurrency(
transaction.transaction_value,
transaction.transaction_currency,
)}
</p>
<Button
onClick={() => handleViewRaw(transaction)}

View File

@@ -8,8 +8,6 @@ import {
ResponsiveContainer,
Legend,
} from "recharts";
import { useBalanceVisibility } from "../../contexts/BalanceVisibilityContext";
import { cn } from "../../lib/utils";
import type { Balance, Account } from "../../types/api";
interface BalanceChartProps {
@@ -44,8 +42,6 @@ export default function BalanceChart({
accounts,
className,
}: BalanceChartProps) {
const { isBalanceVisible } = useBalanceVisibility();
// Create a lookup map for account info
const accountMap = accounts.reduce(
(map, account) => {
@@ -153,7 +149,7 @@ export default function BalanceChart({
<h3 className="text-lg font-medium text-foreground mb-4">
Balance Progress Over Time
</h3>
<div className={cn("h-80", !isBalanceVisible && "blur-md select-none")}>
<div className="h-80">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={finalData}>
<CartesianGrid strokeDasharray="3 3" />

View File

@@ -8,8 +8,6 @@ import {
ResponsiveContainer,
} from "recharts";
import { useQuery } from "@tanstack/react-query";
import { useBalanceVisibility } from "../../contexts/BalanceVisibilityContext";
import { cn } from "../../lib/utils";
import apiClient from "../../lib/api";
interface MonthlyTrendsProps {
@@ -31,8 +29,6 @@ export default function MonthlyTrends({
className,
days = 365,
}: MonthlyTrendsProps) {
const { isBalanceVisible } = useBalanceVisibility();
// Get pre-calculated monthly stats from the new endpoint
const { data: monthlyData, isLoading } = useQuery({
queryKey: ["monthly-stats", days],
@@ -107,7 +103,7 @@ export default function MonthlyTrends({
<h3 className="text-lg font-medium text-foreground mb-4">
{getTitle(days)}
</h3>
<div className={cn("h-80", !isBalanceVisible && "blur-md select-none")}>
<div className="h-80">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={displayData}

View File

@@ -1,6 +1,5 @@
import type { LucideIcon } from "lucide-react";
import { Card, CardContent } from "../ui/card";
import { BlurredValue } from "../ui/blurred-value";
import { cn } from "../../lib/utils";
interface StatCardProps {
@@ -14,7 +13,6 @@ interface StatCardProps {
};
className?: string;
iconColor?: "green" | "blue" | "red" | "purple" | "orange" | "default";
shouldBlur?: boolean;
}
export default function StatCard({
@@ -25,7 +23,6 @@ export default function StatCard({
trend,
className,
iconColor = "default",
shouldBlur = false,
}: StatCardProps) {
return (
<Card className={cn(className)}>
@@ -34,9 +31,7 @@ export default function StatCard({
<div>
<p className="text-sm font-medium text-muted-foreground">{title}</p>
<div className="flex items-baseline">
<p className="text-2xl font-bold text-foreground">
{shouldBlur ? <BlurredValue>{value}</BlurredValue> : value}
</p>
<p className="text-2xl font-bold text-foreground">{value}</p>
{trend && (
<div
className={cn(

View File

@@ -6,7 +6,6 @@ import {
Tooltip,
Legend,
} from "recharts";
import { BlurredValue } from "../ui/blurred-value";
import type { Account } from "../../types/api";
interface TransactionDistributionProps {
@@ -86,8 +85,7 @@ export default function TransactionDistribution({
<div className="bg-card p-3 border rounded shadow-lg">
<p className="font-medium text-foreground">{data.name}</p>
<p className="text-primary">
Balance:{" "}
<BlurredValue>{data.value.toLocaleString()}</BlurredValue>
Balance: {data.value.toLocaleString()}
</p>
<p className="text-muted-foreground">{percentage}% of total</p>
</div>
@@ -140,7 +138,7 @@ export default function TransactionDistribution({
<span className="text-foreground">{item.name}</span>
</div>
<span className="font-medium text-foreground">
<BlurredValue>{item.value.toLocaleString()}</BlurredValue>
{item.value.toLocaleString()}
</span>
</div>
))}

View File

@@ -1,26 +0,0 @@
import { Eye, EyeOff } from "lucide-react";
import { Button } from "./button";
import { useBalanceVisibility } from "../../contexts/BalanceVisibilityContext";
export function BalanceToggle() {
const { isBalanceVisible, toggleBalanceVisibility } = useBalanceVisibility();
return (
<Button
variant="outline"
size="icon"
onClick={toggleBalanceVisibility}
className="h-8 w-8"
title={isBalanceVisible ? "Hide balances" : "Show balances"}
>
{isBalanceVisible ? (
<Eye className="h-4 w-4" />
) : (
<EyeOff className="h-4 w-4" />
)}
<span className="sr-only">
{isBalanceVisible ? "Hide balances" : "Show balances"}
</span>
</Button>
);
}

View File

@@ -1,23 +0,0 @@
import React from "react";
import { useBalanceVisibility } from "../../contexts/BalanceVisibilityContext";
import { cn } from "../../lib/utils";
interface BlurredValueProps {
children: React.ReactNode;
className?: string;
}
export function BlurredValue({ children, className }: BlurredValueProps) {
const { isBalanceVisible } = useBalanceVisibility();
return (
<span
className={cn(
isBalanceVisible ? "" : "blur-md select-none",
className,
)}
>
{children}
</span>
);
}

View File

@@ -1,48 +0,0 @@
import React, { createContext, useContext, useEffect, useState } from "react";
interface BalanceVisibilityContextType {
isBalanceVisible: boolean;
toggleBalanceVisibility: () => void;
}
const BalanceVisibilityContext = createContext<
BalanceVisibilityContextType | undefined
>(undefined);
export function BalanceVisibilityProvider({
children,
}: {
children: React.ReactNode;
}) {
const [isBalanceVisible, setIsBalanceVisible] = useState<boolean>(() => {
const stored = localStorage.getItem("balanceVisible");
// Default to true (visible) if not set
return stored === null ? true : stored === "true";
});
useEffect(() => {
localStorage.setItem("balanceVisible", String(isBalanceVisible));
}, [isBalanceVisible]);
const toggleBalanceVisibility = () => {
setIsBalanceVisible((prev) => !prev);
};
return (
<BalanceVisibilityContext.Provider
value={{ isBalanceVisible, toggleBalanceVisibility }}
>
{children}
</BalanceVisibilityContext.Provider>
);
}
export function useBalanceVisibility() {
const context = useContext(BalanceVisibilityContext);
if (context === undefined) {
throw new Error(
"useBalanceVisibility must be used within a BalanceVisibilityProvider",
);
}
return context;
}

View File

@@ -288,14 +288,11 @@ export const apiClient = {
return response.data;
},
testBackupConnection: async (
test: BackupTest,
): Promise<{ connected?: boolean; success?: boolean; message?: string }> => {
const response = await api.post<{
connected?: boolean;
success?: boolean;
message?: string;
}>("/backup/test", test);
testBackupConnection: async (test: BackupTest): Promise<{ connected?: boolean }> => {
const response = await api.post<{ connected?: boolean }>(
"/backup/test",
test,
);
return response.data;
},
@@ -304,20 +301,11 @@ export const apiClient = {
return response.data;
},
performBackupOperation: async (
operation: BackupOperation,
): Promise<{
operation: string;
completed: boolean;
success?: boolean;
message?: string;
}> => {
const response = await api.post<{
operation: string;
completed: boolean;
success?: boolean;
message?: string;
}>("/backup/operation", operation);
performBackupOperation: async (operation: BackupOperation): Promise<{ operation: string; completed: boolean }> => {
const response = await api.post<{ operation: string; completed: boolean }>(
"/backup/operation",
operation,
);
return response.data;
},
};

View File

@@ -3,7 +3,6 @@ import { createRoot } from "react-dom/client";
import { createRouter, RouterProvider } from "@tanstack/react-router";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ThemeProvider } from "./contexts/ThemeContext";
import { BalanceVisibilityProvider } from "./contexts/BalanceVisibilityContext";
import "./index.css";
import { routeTree } from "./routeTree.gen";
import { registerSW } from "virtual:pwa-register";
@@ -74,9 +73,7 @@ createRoot(document.getElementById("root")!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<BalanceVisibilityProvider>
<RouterProvider router={router} />
</BalanceVisibilityProvider>
<RouterProvider router={router} />
</ThemeProvider>
</QueryClientProvider>
</StrictMode>,

View File

@@ -88,7 +88,6 @@ function AnalyticsDashboard() {
subtitle="Inflows this period"
icon={TrendingUp}
iconColor="green"
shouldBlur={true}
/>
<StatCard
title="Total Expenses"
@@ -96,7 +95,6 @@ function AnalyticsDashboard() {
subtitle="Outflows this period"
icon={TrendingDown}
iconColor="red"
shouldBlur={true}
/>
</div>
@@ -108,7 +106,6 @@ function AnalyticsDashboard() {
subtitle="Income minus expenses"
icon={CreditCard}
iconColor={(stats?.net_change || 0) >= 0 ? "green" : "red"}
shouldBlur={true}
/>
<StatCard
title="Average Transaction"
@@ -116,7 +113,6 @@ function AnalyticsDashboard() {
subtitle="Per transaction"
icon={Activity}
iconColor="purple"
shouldBlur={true}
/>
<StatCard
title="Active Accounts"

View File

@@ -1,4 +1,4 @@
from typing import Generic, List, TypeVar
from typing import Any, Dict, Generic, List, TypeVar
from pydantic import BaseModel

View File

@@ -246,6 +246,11 @@ async def get_account_transactions(
offset=offset,
)
# Get total count for pagination info
total_transactions = await database_service.get_transaction_count_from_db(
account_id=account_id,
)
data: Union[List[TransactionSummary], List[Transaction]]
if summary_only:
@@ -294,7 +299,9 @@ async def get_account_transactions(
@router.put("/accounts/{account_id}")
async def update_account_details(account_id: str, update_data: AccountUpdate) -> dict:
async def update_account_details(
account_id: str, update_data: AccountUpdate
) -> dict:
"""Update account details (currently only display_name)"""
try:
# Get current account details

View File

@@ -129,7 +129,9 @@ async def test_backup_connection(test_request: BackupTest) -> dict:
success = await backup_service.test_connection(s3_config)
if not success:
raise HTTPException(status_code=400, detail="S3 connection test failed")
raise HTTPException(
status_code=400, detail="S3 connection test failed"
)
return {"connected": True}
@@ -191,7 +193,9 @@ async def backup_operation(operation_request: BackupOperation) -> dict:
success = await backup_service.backup_database(database_path)
if not success:
raise HTTPException(status_code=500, detail="Database backup failed")
raise HTTPException(
status_code=500, detail="Database backup failed"
)
return {"operation": "backup", "completed": True}
@@ -209,7 +213,9 @@ async def backup_operation(operation_request: BackupOperation) -> dict:
)
if not success:
raise HTTPException(status_code=500, detail="Database restore failed")
raise HTTPException(
status_code=500, detail="Database restore failed"
)
return {"operation": "restore", "completed": True}
else:

View File

@@ -3,7 +3,7 @@ from typing import Optional
from fastapi import APIRouter, BackgroundTasks, HTTPException
from loguru import logger
from leggen.api.models.sync import SchedulerConfig, SyncRequest, SyncResult, SyncStatus
from leggen.api.models.sync import SchedulerConfig, SyncRequest
from leggen.background.scheduler import scheduler
from leggen.services.sync_service import SyncService
from leggen.utils.config import config
@@ -13,7 +13,7 @@ sync_service = SyncService()
@router.get("/sync/status")
async def get_sync_status() -> SyncStatus:
async def get_sync_status() -> dict:
"""Get current sync status"""
try:
status = await sync_service.get_sync_status()
@@ -78,7 +78,7 @@ async def trigger_sync(
@router.post("/sync/now")
async def sync_now(sync_request: Optional[SyncRequest] = None) -> SyncResult:
async def sync_now(sync_request: Optional[SyncRequest] = None) -> dict:
"""Run sync synchronously and return results (slower, for testing)"""
try:
if sync_request and sync_request.account_ids:

View File

@@ -64,9 +64,11 @@ async def get_all_transactions(
search=search,
)
data: Union[List[TransactionSummary], List[Transaction]]
if summary_only:
# Return simplified transaction summaries
data: list[TransactionSummary | Transaction] = [
data = [
TransactionSummary(
transaction_id=txn["transactionId"], # NEW: stable bank-provided ID
internal_transaction_id=txn.get("internalTransactionId"),

View File

@@ -5,19 +5,11 @@ import random
import sqlite3
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any, TypedDict
from typing import Any
import click
class TransactionType(TypedDict):
"""Type definition for transaction type configuration."""
description: str
amount_range: tuple[float, float]
frequency: float
class SampleDataGenerator:
"""Generates realistic sample data for testing Leggen."""
@@ -50,7 +42,7 @@ class SampleDataGenerator:
},
]
self.transaction_types: list[TransactionType] = [
self.transaction_types = [
{
"description": "Grocery Store",
"amount_range": (-150, -20),
@@ -235,8 +227,6 @@ class SampleDataGenerator:
)[0]
# Generate transaction amount
min_amount: float
max_amount: float
min_amount, max_amount = transaction_type["amount_range"]
amount = round(random.uniform(min_amount, max_amount), 2)
@@ -255,7 +245,7 @@ class SampleDataGenerator:
internal_transaction_id = f"int-txn-{random.randint(100000, 999999)}"
# Create realistic descriptions
descriptions: dict[str, list[str]] = {
descriptions = {
"Grocery Store": [
"TESCO",
"SAINSBURY'S",
@@ -283,7 +273,7 @@ class SampleDataGenerator:
"Transfer to Savings": ["SAVINGS TRANSFER", "INVESTMENT TRANSFER"],
}
specific_descriptions: list[str] = descriptions.get(
specific_descriptions = descriptions.get(
transaction_type["description"], [transaction_type["description"]]
)
description = random.choice(specific_descriptions)

View File

@@ -61,13 +61,17 @@ 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"])
color = "ffaa00" # Orange for sync failure
title = "⚠️ Sync Failure"
# Build description with account info if available
description = "Account sync failed"
if notification.get("account_id"):
description = f"Account {notification['account_id']} sync failed"
# 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,

View File

@@ -87,15 +87,20 @@ def send_sync_failure_notification(ctx: click.Context, notification: dict):
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 = "*🚨 [Leggen](https://github.com/elisiariocouto/leggen)*\n"
message += "*Sync Failed*\n\n"
# Add account info if available
if notification.get("account_id"):
message += escape_markdown(f"Account: {notification['account_id']}\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={

View File

@@ -52,17 +52,11 @@ class NotificationService:
async def send_expiry_notification(self, notification_data: Dict[str, Any]) -> None:
"""Send notification about account expiry"""
try:
if self._is_discord_enabled():
await self._send_discord_expiry(notification_data)
except Exception as e:
logger.error(f"Failed to send Discord expiry notification: {e}")
if self._is_discord_enabled():
await self._send_discord_expiry(notification_data)
try:
if self._is_telegram_enabled():
await self._send_telegram_expiry(notification_data)
except Exception as e:
logger.error(f"Failed to send Telegram expiry notification: {e}")
if self._is_telegram_enabled():
await self._send_telegram_expiry(notification_data)
def _filter_transactions(
self, transactions: List[Dict[str, Any]]
@@ -268,6 +262,7 @@ class NotificationService:
logger.info(f"Sent Discord expiry notification: {notification_data}")
except Exception as e:
logger.error(f"Failed to send Discord expiry notification: {e}")
raise
async def _send_telegram_expiry(self, notification_data: Dict[str, Any]) -> None:
"""Send Telegram expiry notification"""
@@ -293,22 +288,17 @@ class NotificationService:
logger.info(f"Sent Telegram expiry notification: {notification_data}")
except Exception as e:
logger.error(f"Failed to send Telegram expiry notification: {e}")
raise
async def send_sync_failure_notification(
self, notification_data: Dict[str, Any]
) -> None:
"""Send notification about sync failure"""
try:
if self._is_discord_enabled():
await self._send_discord_sync_failure(notification_data)
except Exception as e:
logger.error(f"Failed to send Discord sync failure notification: {e}")
if self._is_discord_enabled():
await self._send_discord_sync_failure(notification_data)
try:
if self._is_telegram_enabled():
await self._send_telegram_sync_failure(notification_data)
except Exception as e:
logger.error(f"Failed to send Telegram sync failure notification: {e}")
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]
@@ -336,6 +326,7 @@ class NotificationService:
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]
@@ -363,3 +354,4 @@ class NotificationService:
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

@@ -10,6 +10,8 @@ from leggen.services.notification_service import NotificationService
# Constants for notification
EXPIRED_DAYS_LEFT = 0
ACCOUNT_SYNC_RETRY_COUNT = 1
ACCOUNT_SYNC_MAX_RETRIES = 1
class SyncService:
@@ -173,13 +175,20 @@ class SyncService:
logs.append(error_msg)
# Send notification for account sync failure
await self.notifications.send_sync_failure_notification(
{
"account_id": account_id,
"error": error_msg,
"type": "account_sync_failure",
}
)
try:
await self.notifications.send_sync_failure_notification(
{
"account_id": account_id,
"error": error_msg,
"type": "account_sync_failure",
"retry_count": ACCOUNT_SYNC_RETRY_COUNT,
"max_retries": ACCOUNT_SYNC_MAX_RETRIES,
}
)
except Exception as notif_error:
logger.error(
f"Failed to send sync failure notification: {notif_error}"
)
end_time = datetime.now()
duration = (end_time - start_time).total_seconds()
@@ -268,11 +277,7 @@ class SyncService:
self._sync_status.is_running = False
async def _check_requisition_expiry(self, requisitions: List[dict]) -> None:
"""Check requisitions for expiry and send notifications.
Args:
requisitions: List of requisition dictionaries to check
"""
"""Check requisitions for expiry and send notifications"""
for req in requisitions:
requisition_id = req.get("id", "unknown")
institution_id = req.get("institution_id", "unknown")
@@ -283,14 +288,17 @@ class SyncService:
logger.warning(
f"Requisition {requisition_id} for {institution_id} has expired"
)
await self.notifications.send_expiry_notification(
{
"bank": institution_id,
"requisition_id": requisition_id,
"status": "expired",
"days_left": EXPIRED_DAYS_LEFT,
}
)
try:
await self.notifications.send_expiry_notification(
{
"bank": institution_id,
"requisition_id": requisition_id,
"status": "expired",
"days_left": EXPIRED_DAYS_LEFT,
}
)
except Exception as e:
logger.error(f"Failed to send expiry notification: {e}")
async def sync_specific_accounts(
self, account_ids: List[str], force: bool = False, trigger_type: str = "manual"

View File

@@ -8,10 +8,8 @@ from pathlib import Path
from unittest.mock import patch
import pytest
import tomli_w
from fastapi.testclient import TestClient
from leggen.commands.server import create_app
from leggen.utils.config import Config
# Create test config before any imports that might load it
@@ -29,12 +27,15 @@ _config_data = {
"scheduler": {"sync": {"enabled": True, "hour": 3, "minute": 0}},
}
import tomli_w
with open(_test_config_path, "wb") as f:
tomli_w.dump(_config_data, f)
# Set environment variables to point to test config BEFORE importing the app
os.environ["LEGGEN_CONFIG_FILE"] = str(_test_config_path)
from leggen.commands.server import create_app
def pytest_configure(config):
"""Pytest hook called before test collection."""
@@ -113,9 +114,7 @@ def mock_auth_token(temp_config_dir):
def fastapi_app(mock_db_path):
"""Create FastAPI test application."""
# Patch the database path for the app
with patch(
"leggen.utils.paths.path_manager.get_database_path", return_value=mock_db_path
):
with patch("leggen.utils.paths.path_manager.get_database_path", return_value=mock_db_path):
app = create_app()
yield app

View File

@@ -211,7 +211,10 @@ class TestBackupAPI:
assert response.status_code == 200
data = response.json()
assert len(data) == 2
assert data[0]["key"] == "leggen_backups/database_backup_20250101_120000.db"
assert (
data[0]["key"]
== "leggen_backups/database_backup_20250101_120000.db"
)
def test_list_backups_no_config(self, api_client, mock_config):
"""Test backup listing with no configuration."""

View File

@@ -157,6 +157,7 @@ class TestTransactionsAPI:
)
assert response.status_code == 200
data = response.json()
# Verify the database service was called with correct filters
mock_get_transactions.assert_called_once_with(

View File

@@ -217,11 +217,8 @@ class TestSyncNotifications:
sync_service.gocardless, "get_account_details"
) as mock_get_details,
patch.object(
sync_service.notifications, "_send_discord_sync_failure"
) as mock_discord_notification,
patch.object(
sync_service.notifications, "_send_telegram_sync_failure"
) as mock_telegram_notification,
sync_service.notifications, "send_sync_failure_notification"
) as mock_send_notification,
patch.object(
sync_service.database, "persist_sync_operation", return_value=1
),
@@ -241,14 +238,15 @@ class TestSyncNotifications:
# Make account details fail
mock_get_details.side_effect = Exception("API Error")
# Make both notification methods fail
mock_discord_notification.side_effect = Exception("Discord Error")
mock_telegram_notification.side_effect = Exception("Telegram Error")
# Make notification sending fail
mock_send_notification.side_effect = Exception("Notification Error")
# Execute: Run sync - should not raise exception from notification
result = await sync_service.sync_all_accounts()
# The sync should complete with errors but not crash from notifications
assert result.success is False
assert len(result.errors) > 0
assert "API Error" in result.errors[0]
try:
result = await sync_service.sync_all_accounts()
# The sync should complete with errors but not crash
assert result.success is False
assert len(result.errors) > 0
except Exception as e:
# If exception is raised, it should not be the notification error
assert "Notification Error" not in str(e)