mirror of
https://github.com/elisiariocouto/leggen.git
synced 2025-12-13 13:42:19 +00:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d3a1696d4d | ||
|
|
24792744f9 | ||
|
|
b9ca74e7e6 | ||
|
|
a8f704129b | ||
|
|
62cd55e48f | ||
|
|
e4e3f885ea | ||
|
|
36d698f7ce |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -165,3 +165,4 @@ leggen.db
|
||||
*.db
|
||||
config.toml
|
||||
.claude/
|
||||
.playwright-mcp/
|
||||
|
||||
@@ -7,10 +7,10 @@
|
||||
"mcp"
|
||||
]
|
||||
},
|
||||
"browsermcp": {
|
||||
"playwright": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"@browsermcp/mcp@latest"
|
||||
"@playwright/mcp@latest"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,3 +138,4 @@ This repository follows conventional changelog practices. Refer to `CONTRIBUTING
|
||||
- Commit message format and scoping
|
||||
- Release process using `scripts/release.sh`
|
||||
- Pre-commit hooks setup with `pre-commit install`
|
||||
- When the pre-commit fails, the commit is canceled
|
||||
|
||||
42
CHANGELOG.md
42
CHANGELOG.md
@@ -1,4 +1,46 @@
|
||||
|
||||
## 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
|
||||
|
||||
@@ -78,6 +78,7 @@ export default function AccountSettings() {
|
||||
|
||||
const [editingAccountId, setEditingAccountId] = useState<string | null>(null);
|
||||
const [editingName, setEditingName] = useState("");
|
||||
const [failedImages, setFailedImages] = useState<Set<string>>(new Set());
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -194,8 +195,20 @@ export default function AccountSettings() {
|
||||
{/* Mobile layout - stack vertically */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4">
|
||||
<div className="flex items-start sm:items-center space-x-3 sm:space-x-4 min-w-0 flex-1">
|
||||
<div className="flex-shrink-0 p-2 sm:p-3 bg-muted rounded-full">
|
||||
<Building2 className="h-5 w-5 sm:h-6 sm:w-6 text-muted-foreground" />
|
||||
<div className="flex-shrink-0 w-10 h-10 sm:w-12 sm:h-12 rounded-full overflow-hidden bg-muted flex items-center justify-center">
|
||||
{account.logo && !failedImages.has(account.id) ? (
|
||||
<img
|
||||
src={account.logo}
|
||||
alt={`${account.institution_id} logo`}
|
||||
className="w-full h-full object-contain"
|
||||
onError={() => {
|
||||
console.warn(`Failed to load bank logo for ${account.institution_id}: ${account.logo}`);
|
||||
setFailedImages(prev => new Set([...prev, account.id]));
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Building2 className="h-5 w-5 sm:h-6 sm:w-6 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
{editingAccountId === account.id ? (
|
||||
|
||||
@@ -79,6 +79,7 @@ const getStatusIndicator = (status: string) => {
|
||||
export default function Settings() {
|
||||
const [editingAccountId, setEditingAccountId] = useState<string | null>(null);
|
||||
const [editingName, setEditingName] = useState("");
|
||||
const [failedImages, setFailedImages] = useState<Set<string>>(new Set());
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -280,8 +281,20 @@ export default function Settings() {
|
||||
{/* Mobile layout - stack vertically */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4">
|
||||
<div className="flex items-start sm:items-center space-x-3 sm:space-x-4 min-w-0 flex-1">
|
||||
<div className="flex-shrink-0 p-2 sm:p-3 bg-muted rounded-full">
|
||||
<Building2 className="h-5 w-5 sm:h-6 sm:w-6 text-muted-foreground" />
|
||||
<div className="flex-shrink-0 w-10 h-10 sm:w-12 sm:h-12 rounded-full overflow-hidden bg-muted flex items-center justify-center">
|
||||
{account.logo && !failedImages.has(account.id) ? (
|
||||
<img
|
||||
src={account.logo}
|
||||
alt={`${account.institution_id} logo`}
|
||||
className="w-6 h-6 sm:w-8 sm:h-8 object-contain"
|
||||
onError={() => {
|
||||
console.warn(`Failed to load bank logo for ${account.institution_id}: ${account.logo}`);
|
||||
setFailedImages(prev => new Set([...prev, account.id]));
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Building2 className="h-5 w-5 sm:h-6 sm:w-6 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
{editingAccountId === account.id ? (
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Clock,
|
||||
TrendingUp,
|
||||
User,
|
||||
FileText,
|
||||
} from "lucide-react";
|
||||
import { apiClient } from "../lib/api";
|
||||
import {
|
||||
@@ -19,7 +20,73 @@ import {
|
||||
import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
|
||||
import { Button } from "./ui/button";
|
||||
import { Badge } from "./ui/badge";
|
||||
import type { SyncOperationsResponse } from "../types/api";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "./ui/dialog";
|
||||
import { ScrollArea } from "./ui/scroll-area";
|
||||
import type { SyncOperationsResponse, SyncOperation } from "../types/api";
|
||||
|
||||
// Component for viewing sync operation logs
|
||||
function LogsDialog({ operation }: { operation: SyncOperation }) {
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="shrink-0">
|
||||
<FileText className="h-3 w-3 mr-1" />
|
||||
<span className="hidden sm:inline">View Logs</span>
|
||||
<span className="sm:hidden">Logs</span>
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-4xl max-h-[80vh]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Sync Operation Logs</DialogTitle>
|
||||
<DialogDescription>
|
||||
Operation #{operation.id} - Started at{" "}
|
||||
{new Date(operation.started_at).toLocaleString()}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<ScrollArea className="h-[60vh] w-full rounded border p-4">
|
||||
<div className="space-y-2">
|
||||
{operation.logs.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">No logs available</p>
|
||||
) : (
|
||||
operation.logs.map((log, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="text-sm font-mono bg-muted/50 p-2 rounded text-wrap break-all"
|
||||
>
|
||||
{log}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
{operation.errors.length > 0 && (
|
||||
<>
|
||||
<div className="mt-4 mb-2 text-sm font-semibold text-destructive">
|
||||
Errors:
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{operation.errors.map((error, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="text-sm font-mono bg-destructive/10 border border-destructive/20 p-2 rounded text-wrap break-all text-destructive"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default function System() {
|
||||
const {
|
||||
@@ -111,68 +178,128 @@ export default function System() {
|
||||
return (
|
||||
<div
|
||||
key={operation.id}
|
||||
className="flex items-center justify-between p-4 border rounded-lg hover:bg-accent transition-colors"
|
||||
className="border rounded-lg hover:bg-accent transition-colors"
|
||||
>
|
||||
<div className="flex items-center space-x-4">
|
||||
<div
|
||||
className={`p-2 rounded-full ${
|
||||
isRunning
|
||||
? "bg-blue-100 text-blue-600"
|
||||
: operation.success
|
||||
? "bg-green-100 text-green-600"
|
||||
: "bg-red-100 text-red-600"
|
||||
}`}
|
||||
>
|
||||
{isRunning ? (
|
||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||
) : operation.success ? (
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
) : (
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<h4 className="text-sm font-medium text-foreground">
|
||||
{isRunning
|
||||
? "Sync Running"
|
||||
{/* Desktop Layout */}
|
||||
<div className="hidden md:flex items-center justify-between p-4">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div
|
||||
className={`p-2 rounded-full ${
|
||||
isRunning
|
||||
? "bg-blue-100 text-blue-600"
|
||||
: operation.success
|
||||
? "Sync Completed"
|
||||
: "Sync Failed"}
|
||||
</h4>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{operation.trigger_type}
|
||||
</Badge>
|
||||
? "bg-green-100 text-green-600"
|
||||
: "bg-red-100 text-red-600"
|
||||
}`}
|
||||
>
|
||||
{isRunning ? (
|
||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||
) : operation.success ? (
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
) : (
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center space-x-4 mt-1 text-xs text-muted-foreground">
|
||||
<span className="flex items-center space-x-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
<span>
|
||||
{startedAt.toLocaleDateString()}{" "}
|
||||
{startedAt.toLocaleTimeString()}
|
||||
<div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<h4 className="text-sm font-medium text-foreground">
|
||||
{isRunning
|
||||
? "Sync Running"
|
||||
: operation.success
|
||||
? "Sync Completed"
|
||||
: "Sync Failed"}
|
||||
</h4>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{operation.trigger_type}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4 mt-1 text-xs text-muted-foreground">
|
||||
<span className="flex items-center space-x-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
<span>
|
||||
{startedAt.toLocaleDateString()}{" "}
|
||||
{startedAt.toLocaleTimeString()}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
{duration && <span>Duration: {duration}</span>}
|
||||
{duration && <span>Duration: {duration}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="text-right text-sm text-muted-foreground">
|
||||
<div className="flex items-center space-x-2">
|
||||
<User className="h-3 w-3" />
|
||||
<span>{operation.accounts_processed} accounts</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 mt-1">
|
||||
<TrendingUp className="h-3 w-3" />
|
||||
<span>
|
||||
{operation.transactions_added} new transactions
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<LogsDialog operation={operation} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right text-sm text-muted-foreground">
|
||||
<div className="flex items-center space-x-2">
|
||||
<User className="h-3 w-3" />
|
||||
<span>{operation.accounts_processed} accounts</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 mt-1">
|
||||
<TrendingUp className="h-3 w-3" />
|
||||
<span>
|
||||
{operation.transactions_added} new transactions
|
||||
</span>
|
||||
</div>
|
||||
{operation.errors.length > 0 && (
|
||||
<div className="flex items-center space-x-2 mt-1 text-red-600">
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
<span>{operation.errors.length} errors</span>
|
||||
|
||||
{/* Mobile Layout */}
|
||||
<div className="md:hidden p-4 space-y-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div
|
||||
className={`p-2 rounded-full ${
|
||||
isRunning
|
||||
? "bg-blue-100 text-blue-600"
|
||||
: operation.success
|
||||
? "bg-green-100 text-green-600"
|
||||
: "bg-red-100 text-red-600"
|
||||
}`}
|
||||
>
|
||||
{isRunning ? (
|
||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||
) : operation.success ? (
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
) : (
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-foreground">
|
||||
{isRunning
|
||||
? "Sync Running"
|
||||
: operation.success
|
||||
? "Sync Completed"
|
||||
: "Sync Failed"}
|
||||
</h4>
|
||||
<Badge variant="outline" className="text-xs mt-1">
|
||||
{operation.trigger_type}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<LogsDialog operation={operation} />
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-muted-foreground space-y-2">
|
||||
<div className="flex items-center space-x-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
<span>
|
||||
{startedAt.toLocaleDateString()}{" "}
|
||||
{startedAt.toLocaleTimeString()}
|
||||
</span>
|
||||
{duration && <span className="ml-2">• {duration}</span>}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div className="flex items-center space-x-1">
|
||||
<User className="h-3 w-3" />
|
||||
<span>{operation.accounts_processed} accounts</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
<TrendingUp className="h-3 w-3" />
|
||||
<span>{operation.transactions_added} new transactions</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -190,8 +190,7 @@ export default function TransactionsTable() {
|
||||
<div className="text-xs text-muted-foreground space-y-1">
|
||||
{account && (
|
||||
<p className="truncate">
|
||||
{account.name || "Unnamed Account"} •{" "}
|
||||
{account.institution_id}
|
||||
{account.display_name || "Unnamed Account"}
|
||||
</p>
|
||||
)}
|
||||
{(transaction.creditor_name || transaction.debtor_name) && (
|
||||
@@ -486,8 +485,7 @@ export default function TransactionsTable() {
|
||||
<div className="text-xs text-muted-foreground space-y-1 mt-1">
|
||||
{account && (
|
||||
<p className="break-words">
|
||||
{account.name || "Unnamed Account"} •{" "}
|
||||
{account.institution_id}
|
||||
{account.display_name || "Unnamed Account"}
|
||||
</p>
|
||||
)}
|
||||
{(transaction.creditor_name ||
|
||||
|
||||
21
frontend/src/components/ui/scroll-area.tsx
Normal file
21
frontend/src/components/ui/scroll-area.tsx
Normal 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 };
|
||||
@@ -13,6 +13,7 @@ export interface Account {
|
||||
name?: string;
|
||||
display_name?: string;
|
||||
currency?: string;
|
||||
logo?: string;
|
||||
created: string;
|
||||
last_accessed?: string;
|
||||
balances: AccountBalance[];
|
||||
|
||||
@@ -26,6 +26,7 @@ class AccountDetails(BaseModel):
|
||||
name: Optional[str] = None
|
||||
display_name: Optional[str] = None
|
||||
currency: Optional[str] = None
|
||||
logo: Optional[str] = None
|
||||
created: datetime
|
||||
last_accessed: Optional[datetime] = None
|
||||
balances: List[AccountBalance] = []
|
||||
|
||||
@@ -55,6 +55,7 @@ async def get_all_accounts() -> APIResponse:
|
||||
name=db_account.get("name"),
|
||||
display_name=db_account.get("display_name"),
|
||||
currency=db_account.get("currency"),
|
||||
logo=db_account.get("logo"),
|
||||
created=db_account["created"],
|
||||
last_accessed=db_account.get("last_accessed"),
|
||||
balances=balances,
|
||||
@@ -115,6 +116,7 @@ async def get_account_details(account_id: str) -> APIResponse:
|
||||
name=db_account.get("name"),
|
||||
display_name=db_account.get("display_name"),
|
||||
currency=db_account.get("currency"),
|
||||
logo=db_account.get("logo"),
|
||||
created=db_account["created"],
|
||||
last_accessed=db_account.get("last_accessed"),
|
||||
balances=balances,
|
||||
|
||||
@@ -21,14 +21,15 @@ async def get_bank_institutions(
|
||||
) -> APIResponse:
|
||||
"""Get available bank institutions for a country"""
|
||||
try:
|
||||
institutions_data = await gocardless_service.get_institutions(country)
|
||||
institutions_response = await gocardless_service.get_institutions(country)
|
||||
institutions_data = institutions_response.get("results", [])
|
||||
|
||||
institutions = [
|
||||
BankInstitution(
|
||||
id=inst["id"],
|
||||
name=inst["name"],
|
||||
bic=inst.get("bic"),
|
||||
transaction_total_days=inst["transaction_total_days"],
|
||||
transaction_total_days=int(inst["transaction_total_days"]),
|
||||
countries=inst["countries"],
|
||||
logo=inst.get("logo"),
|
||||
)
|
||||
|
||||
@@ -112,7 +112,7 @@ class BackgroundScheduler:
|
||||
|
||||
# Send notification about the failure
|
||||
try:
|
||||
await self.notification_service.send_expiry_notification(
|
||||
await self.notification_service.send_sync_failure_notification(
|
||||
{
|
||||
"type": "sync_failure",
|
||||
"error": str(e),
|
||||
@@ -145,7 +145,7 @@ class BackgroundScheduler:
|
||||
logger.error("Maximum retries exceeded for sync job")
|
||||
# Send final failure notification
|
||||
try:
|
||||
await self.notification_service.send_expiry_notification(
|
||||
await self.notification_service.send_sync_failure_notification(
|
||||
{
|
||||
"type": "sync_final_failure",
|
||||
"error": str(e),
|
||||
|
||||
@@ -55,3 +55,44 @@ def send_transactions_message(ctx: click.Context, transactions: list):
|
||||
response.raise_for_status()
|
||||
except Exception as e:
|
||||
raise Exception(f"Discord notification failed: {e}\n{response.text}") from e
|
||||
|
||||
|
||||
def send_sync_failure_notification(ctx: click.Context, notification: dict):
|
||||
info("Sending sync failure notification to Discord")
|
||||
webhook = DiscordWebhook(url=ctx.obj["notifications"]["discord"]["webhook"])
|
||||
|
||||
# Determine color and title based on failure type
|
||||
if notification.get("type") == "sync_final_failure":
|
||||
color = "ff0000" # Red for final failure
|
||||
title = "🚨 Sync Final Failure"
|
||||
description = (
|
||||
f"Sync failed permanently after {notification['retry_count']} attempts"
|
||||
)
|
||||
else:
|
||||
color = "ffaa00" # Orange for retry
|
||||
title = "⚠️ Sync Failure"
|
||||
description = f"Sync failed (attempt {notification['retry_count']}/{notification['max_retries']}). Will retry automatically..."
|
||||
|
||||
embed = DiscordEmbed(
|
||||
title=title,
|
||||
description=description,
|
||||
color=color,
|
||||
)
|
||||
embed.set_author(
|
||||
name="Leggen",
|
||||
url="https://github.com/elisiariocouto/leggen",
|
||||
)
|
||||
embed.add_embed_field(
|
||||
name="Error",
|
||||
value=notification["error"][:1024], # Discord has field value limits
|
||||
inline=False,
|
||||
)
|
||||
embed.set_footer(text="Sync failure notification")
|
||||
embed.set_timestamp()
|
||||
|
||||
webhook.add_embed(embed)
|
||||
response = webhook.execute()
|
||||
try:
|
||||
response.raise_for_status()
|
||||
except Exception as e:
|
||||
raise Exception(f"Discord notification failed: {e}\n{response.text}") from e
|
||||
|
||||
@@ -79,3 +79,38 @@ def send_transaction_message(ctx: click.Context, transactions: list):
|
||||
res.raise_for_status()
|
||||
except Exception as e:
|
||||
raise Exception(f"Telegram notification failed: {e}\n{res.text}") from e
|
||||
|
||||
|
||||
def send_sync_failure_notification(ctx: click.Context, notification: dict):
|
||||
token = ctx.obj["notifications"]["telegram"]["token"]
|
||||
chat_id = ctx.obj["notifications"]["telegram"]["chat_id"]
|
||||
bot_url = f"https://api.telegram.org/bot{token}/sendMessage"
|
||||
info("Sending sync failure notification to Telegram")
|
||||
|
||||
message = "*🚨 [Leggen](https://github.com/elisiariocouto/leggen)*\n"
|
||||
message += "*Sync Failed*\n\n"
|
||||
message += escape_markdown(f"Error: {notification['error']}\n")
|
||||
|
||||
if notification.get("type") == "sync_final_failure":
|
||||
message += escape_markdown(
|
||||
f"❌ Final failure after {notification['retry_count']} attempts\n"
|
||||
)
|
||||
else:
|
||||
message += escape_markdown(
|
||||
f"🔄 Attempt {notification['retry_count']}/{notification['max_retries']}\n"
|
||||
)
|
||||
message += escape_markdown("Will retry automatically...\n")
|
||||
|
||||
res = requests.post(
|
||||
bot_url,
|
||||
json={
|
||||
"chat_id": chat_id,
|
||||
"text": message,
|
||||
"parse_mode": "MarkdownV2",
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
res.raise_for_status()
|
||||
except Exception as e:
|
||||
raise Exception(f"Telegram notification failed: {e}\n{res.text}") from e
|
||||
|
||||
@@ -217,6 +217,7 @@ class DatabaseService:
|
||||
await self._migrate_to_composite_key_if_needed()
|
||||
await self._migrate_add_display_name_if_needed()
|
||||
await self._migrate_add_sync_operations_if_needed()
|
||||
await self._migrate_add_logo_if_needed()
|
||||
|
||||
async def _migrate_balance_timestamps_if_needed(self):
|
||||
"""Check and migrate balance timestamps if needed"""
|
||||
@@ -1133,7 +1134,8 @@ class DatabaseService:
|
||||
created DATETIME,
|
||||
last_accessed DATETIME,
|
||||
last_updated DATETIME,
|
||||
display_name TEXT
|
||||
display_name TEXT,
|
||||
logo TEXT
|
||||
)"""
|
||||
)
|
||||
|
||||
@@ -1170,8 +1172,9 @@ class DatabaseService:
|
||||
created,
|
||||
last_accessed,
|
||||
last_updated,
|
||||
display_name
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
display_name,
|
||||
logo
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
account_data["id"],
|
||||
account_data["institution_id"],
|
||||
@@ -1183,6 +1186,7 @@ class DatabaseService:
|
||||
account_data.get("last_accessed"),
|
||||
account_data.get("last_updated", account_data["created"]),
|
||||
display_name,
|
||||
account_data.get("logo"),
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
@@ -1516,6 +1520,79 @@ class DatabaseService:
|
||||
logger.error(f"Sync operations table migration failed: {e}")
|
||||
raise
|
||||
|
||||
async def _migrate_add_logo_if_needed(self):
|
||||
"""Check and add logo column to accounts table if needed"""
|
||||
try:
|
||||
if await self._check_logo_migration_needed():
|
||||
logger.info("Logo column migration needed, starting...")
|
||||
await self._migrate_add_logo()
|
||||
logger.info("Logo column migration completed")
|
||||
else:
|
||||
logger.info("Logo column already exists")
|
||||
except Exception as e:
|
||||
logger.error(f"Logo column migration failed: {e}")
|
||||
raise
|
||||
|
||||
async def _check_logo_migration_needed(self) -> bool:
|
||||
"""Check if logo column needs to be added to accounts table"""
|
||||
db_path = path_manager.get_database_path()
|
||||
if not db_path.exists():
|
||||
return False
|
||||
|
||||
try:
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Check if accounts table exists
|
||||
cursor.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='accounts'"
|
||||
)
|
||||
if not cursor.fetchone():
|
||||
conn.close()
|
||||
return False
|
||||
|
||||
# Check if logo column exists
|
||||
cursor.execute("PRAGMA table_info(accounts)")
|
||||
columns = cursor.fetchall()
|
||||
|
||||
# Check if logo column exists
|
||||
has_logo = any(col[1] == "logo" for col in columns)
|
||||
|
||||
conn.close()
|
||||
return not has_logo
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to check logo migration status: {e}")
|
||||
return False
|
||||
|
||||
async def _migrate_add_logo(self):
|
||||
"""Add logo column to accounts table"""
|
||||
db_path = path_manager.get_database_path()
|
||||
if not db_path.exists():
|
||||
logger.warning("Database file not found, skipping migration")
|
||||
return
|
||||
|
||||
try:
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
cursor = conn.cursor()
|
||||
|
||||
logger.info("Adding logo column to accounts table...")
|
||||
|
||||
# Add the logo column
|
||||
cursor.execute("""
|
||||
ALTER TABLE accounts
|
||||
ADD COLUMN logo TEXT
|
||||
""")
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
logger.info("Logo column migration completed successfully")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Logo column migration failed: {e}")
|
||||
raise
|
||||
|
||||
async def persist_sync_operation(self, sync_operation: Dict[str, Any]) -> int:
|
||||
"""Persist sync operation to database and return the ID"""
|
||||
if not self.sqlite_enabled:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
from typing import Any, Dict
|
||||
|
||||
import httpx
|
||||
from loguru import logger
|
||||
@@ -11,14 +11,13 @@ from leggen.utils.paths import path_manager
|
||||
|
||||
def _log_rate_limits(response):
|
||||
"""Log GoCardless API rate limit headers"""
|
||||
limit = response.headers.get("X-RateLimit-Limit")
|
||||
remaining = response.headers.get("X-RateLimit-Remaining")
|
||||
reset = response.headers.get("X-RateLimit-Reset")
|
||||
account_success_reset = response.headers.get("X-RateLimit-Account-Success-Reset")
|
||||
limit = response.headers.get("http_x_ratelimit_limit")
|
||||
remaining = response.headers.get("http_x_ratelimit_remaining")
|
||||
reset = response.headers.get("http_x_ratelimit_reset")
|
||||
|
||||
if limit or remaining or reset or account_success_reset:
|
||||
if limit or remaining or reset:
|
||||
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
|
||||
|
||||
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]:
|
||||
"""Get authentication headers for GoCardless API"""
|
||||
token = await self._get_token()
|
||||
@@ -102,74 +122,48 @@ class GoCardlessService:
|
||||
with open(auth_file, "w") as f:
|
||||
json.dump(auth_data, f)
|
||||
|
||||
async def get_institutions(self, country: str = "PT") -> List[Dict[str, Any]]:
|
||||
async def get_institutions(self, country: str = "PT") -> Dict[str, Any]:
|
||||
"""Get available bank institutions for a country"""
|
||||
headers = await self._get_auth_headers()
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(
|
||||
f"{self.base_url}/institutions/",
|
||||
headers=headers,
|
||||
params={"country": country},
|
||||
)
|
||||
_log_rate_limits(response)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
return await self._make_authenticated_request(
|
||||
"GET", f"{self.base_url}/institutions/", params={"country": country}
|
||||
)
|
||||
|
||||
async def create_requisition(
|
||||
self, institution_id: str, redirect_url: str
|
||||
) -> Dict[str, Any]:
|
||||
"""Create a bank connection requisition"""
|
||||
headers = await self._get_auth_headers()
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
f"{self.base_url}/requisitions/",
|
||||
headers=headers,
|
||||
json={"institution_id": institution_id, "redirect": redirect_url},
|
||||
)
|
||||
_log_rate_limits(response)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
return await self._make_authenticated_request(
|
||||
"POST",
|
||||
f"{self.base_url}/requisitions/",
|
||||
json={"institution_id": institution_id, "redirect": redirect_url},
|
||||
)
|
||||
|
||||
async def get_requisitions(self) -> Dict[str, Any]:
|
||||
"""Get all requisitions"""
|
||||
headers = await self._get_auth_headers()
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(
|
||||
f"{self.base_url}/requisitions/", headers=headers
|
||||
)
|
||||
_log_rate_limits(response)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
return await self._make_authenticated_request(
|
||||
"GET", f"{self.base_url}/requisitions/"
|
||||
)
|
||||
|
||||
async def get_account_details(self, account_id: str) -> Dict[str, Any]:
|
||||
"""Get account details"""
|
||||
headers = await self._get_auth_headers()
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(
|
||||
f"{self.base_url}/accounts/{account_id}/", headers=headers
|
||||
)
|
||||
_log_rate_limits(response)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
return await self._make_authenticated_request(
|
||||
"GET", f"{self.base_url}/accounts/{account_id}/"
|
||||
)
|
||||
|
||||
async def get_account_balances(self, account_id: str) -> Dict[str, Any]:
|
||||
"""Get account balances"""
|
||||
headers = await self._get_auth_headers()
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(
|
||||
f"{self.base_url}/accounts/{account_id}/balances/", headers=headers
|
||||
)
|
||||
_log_rate_limits(response)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
return await self._make_authenticated_request(
|
||||
"GET", f"{self.base_url}/accounts/{account_id}/balances/"
|
||||
)
|
||||
|
||||
async def get_account_transactions(self, account_id: str) -> Dict[str, Any]:
|
||||
"""Get account transactions"""
|
||||
headers = await self._get_auth_headers()
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(
|
||||
f"{self.base_url}/accounts/{account_id}/transactions/", headers=headers
|
||||
)
|
||||
_log_rate_limits(response)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
return await self._make_authenticated_request(
|
||||
"GET", f"{self.base_url}/accounts/{account_id}/transactions/"
|
||||
)
|
||||
|
||||
async def get_institution_details(self, institution_id: str) -> Dict[str, Any]:
|
||||
"""Get institution details by ID"""
|
||||
return await self._make_authenticated_request(
|
||||
"GET", f"{self.base_url}/institutions/{institution_id}/"
|
||||
)
|
||||
|
||||
@@ -289,3 +289,69 @@ class NotificationService:
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send Telegram expiry notification: {e}")
|
||||
raise
|
||||
|
||||
async def send_sync_failure_notification(
|
||||
self, notification_data: Dict[str, Any]
|
||||
) -> None:
|
||||
"""Send notification about sync failure"""
|
||||
if self._is_discord_enabled():
|
||||
await self._send_discord_sync_failure(notification_data)
|
||||
|
||||
if self._is_telegram_enabled():
|
||||
await self._send_telegram_sync_failure(notification_data)
|
||||
|
||||
async def _send_discord_sync_failure(
|
||||
self, notification_data: Dict[str, Any]
|
||||
) -> None:
|
||||
"""Send Discord sync failure notification"""
|
||||
try:
|
||||
import click
|
||||
|
||||
from leggen.notifications.discord import send_sync_failure_notification
|
||||
|
||||
# Create a mock context with the webhook
|
||||
ctx = click.Context(click.Command("sync_failure"))
|
||||
ctx.obj = {
|
||||
"notifications": {
|
||||
"discord": {
|
||||
"webhook": self.notifications_config.get("discord", {}).get(
|
||||
"webhook"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Send sync failure notification using the actual implementation
|
||||
send_sync_failure_notification(ctx, notification_data)
|
||||
logger.info(f"Sent Discord sync failure notification: {notification_data}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send Discord sync failure notification: {e}")
|
||||
raise
|
||||
|
||||
async def _send_telegram_sync_failure(
|
||||
self, notification_data: Dict[str, Any]
|
||||
) -> None:
|
||||
"""Send Telegram sync failure notification"""
|
||||
try:
|
||||
import click
|
||||
|
||||
from leggen.notifications.telegram import send_sync_failure_notification
|
||||
|
||||
# Create a mock context with the telegram config
|
||||
ctx = click.Context(click.Command("sync_failure"))
|
||||
telegram_config = self.notifications_config.get("telegram", {})
|
||||
ctx.obj = {
|
||||
"notifications": {
|
||||
"telegram": {
|
||||
"token": telegram_config.get("token"),
|
||||
"chat_id": telegram_config.get("chat_id"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Send sync failure notification using the actual implementation
|
||||
send_sync_failure_notification(ctx, notification_data)
|
||||
logger.info(f"Sent Telegram sync failure notification: {notification_data}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send Telegram sync failure notification: {e}")
|
||||
raise
|
||||
|
||||
@@ -15,6 +15,7 @@ class SyncService:
|
||||
self.database = DatabaseService()
|
||||
self.notifications = NotificationService()
|
||||
self._sync_status = SyncStatus(is_running=False)
|
||||
self._institution_logos = {} # Cache for institution logos
|
||||
|
||||
async def get_sync_status(self) -> SyncStatus:
|
||||
"""Get current sync status"""
|
||||
@@ -77,7 +78,7 @@ class SyncService:
|
||||
# Get balances to extract currency information
|
||||
balances = await self.gocardless.get_account_balances(account_id)
|
||||
|
||||
# Enrich account details with currency and persist
|
||||
# Enrich account details with currency and institution logo
|
||||
if account_details and balances:
|
||||
enriched_account_details = account_details.copy()
|
||||
|
||||
@@ -90,6 +91,26 @@ class SyncService:
|
||||
if currency:
|
||||
enriched_account_details["currency"] = currency
|
||||
|
||||
# Get institution details to fetch logo
|
||||
institution_id = enriched_account_details.get("institution_id")
|
||||
if institution_id:
|
||||
try:
|
||||
institution_details = (
|
||||
await self.gocardless.get_institution_details(
|
||||
institution_id
|
||||
)
|
||||
)
|
||||
enriched_account_details["logo"] = (
|
||||
institution_details.get("logo", "")
|
||||
)
|
||||
logger.info(
|
||||
f"Fetched logo for institution {institution_id}: {enriched_account_details.get('logo', 'No logo')}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to fetch institution details for {institution_id}: {e}"
|
||||
)
|
||||
|
||||
# Persist enriched account details to database
|
||||
await self.database.persist_account_details(
|
||||
enriched_account_details
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "leggen"
|
||||
version = "2025.9.21"
|
||||
version = "2025.9.22"
|
||||
description = "An Open Banking CLI"
|
||||
authors = [{ name = "Elisiário Couto", email = "elisiario@couto.io" }]
|
||||
requires-python = "~=3.13.0"
|
||||
|
||||
@@ -103,22 +103,24 @@ def mock_db_path(temp_db_path):
|
||||
@pytest.fixture
|
||||
def sample_bank_data():
|
||||
"""Sample bank/institution data for testing."""
|
||||
return [
|
||||
{
|
||||
"id": "REVOLUT_REVOLT21",
|
||||
"name": "Revolut",
|
||||
"bic": "REVOLT21",
|
||||
"transaction_total_days": 90,
|
||||
"countries": ["GB", "LT"],
|
||||
},
|
||||
{
|
||||
"id": "BANCOBPI_BBPIPTPL",
|
||||
"name": "Banco BPI",
|
||||
"bic": "BBPIPTPL",
|
||||
"transaction_total_days": 90,
|
||||
"countries": ["PT"],
|
||||
},
|
||||
]
|
||||
return {
|
||||
"results": [
|
||||
{
|
||||
"id": "REVOLUT_REVOLT21",
|
||||
"name": "Revolut",
|
||||
"bic": "REVOLT21",
|
||||
"transaction_total_days": 90,
|
||||
"countries": ["GB", "LT"],
|
||||
},
|
||||
{
|
||||
"id": "BANCOBPI_BBPIPTPL",
|
||||
"name": "Banco BPI",
|
||||
"bic": "BBPIPTPL",
|
||||
"transaction_total_days": 90,
|
||||
"countries": ["PT"],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
@@ -50,7 +50,7 @@ class TestBanksAPI:
|
||||
|
||||
# Mock empty institutions response for invalid country
|
||||
respx.get("https://bankaccountdata.gocardless.com/api/v2/institutions/").mock(
|
||||
return_value=httpx.Response(200, json=[])
|
||||
return_value=httpx.Response(200, json={"results": []})
|
||||
)
|
||||
|
||||
with patch("leggen.utils.config.config", mock_config):
|
||||
|
||||
@@ -37,9 +37,12 @@ class TestLeggenAPIClient:
|
||||
"""Test getting institutions via API client."""
|
||||
client = LeggenAPIClient("http://localhost:8000")
|
||||
|
||||
# The API returns processed institutions, not raw GoCardless data
|
||||
processed_institutions = sample_bank_data["results"]
|
||||
|
||||
api_response = {
|
||||
"success": True,
|
||||
"data": sample_bank_data,
|
||||
"data": processed_institutions,
|
||||
"message": "Found 2 institutions for PT",
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user