mirror of
https://github.com/elisiariocouto/leggen.git
synced 2025-12-14 10:52:18 +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
|
*.db
|
||||||
config.toml
|
config.toml
|
||||||
.claude/
|
.claude/
|
||||||
|
.playwright-mcp/
|
||||||
|
|||||||
@@ -7,10 +7,10 @@
|
|||||||
"mcp"
|
"mcp"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"browsermcp": {
|
"playwright": {
|
||||||
"command": "npx",
|
"command": "npx",
|
||||||
"args": [
|
"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
|
- 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
|
||||||
|
|||||||
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)
|
## 2025.9.21 (2025/09/22)
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
|||||||
@@ -79,6 +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 [failedImages, setFailedImages] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
@@ -280,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 ? (
|
||||||
|
|||||||
@@ -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}
|
||||||
|
</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}
|
||||||
|
</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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 ||
|
||||||
|
|||||||
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;
|
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[];
|
||||||
|
|||||||
@@ -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] = []
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ class BackgroundScheduler:
|
|||||||
|
|
||||||
# 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 +145,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),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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}/"
|
||||||
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "leggen"
|
name = "leggen"
|
||||||
version = "2025.9.21"
|
version = "2025.9.22"
|
||||||
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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -37,9 +37,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",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
2
uv.lock
generated
2
uv.lock
generated
@@ -220,7 +220,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "leggen"
|
name = "leggen"
|
||||||
version = "2025.9.21"
|
version = "2025.9.22"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "apscheduler" },
|
{ name = "apscheduler" },
|
||||||
|
|||||||
Reference in New Issue
Block a user