mirror of
https://github.com/elisiariocouto/leggen.git
synced 2025-12-29 14:29:06 +00:00
Compare commits
4 Commits
2025.9.21
...
a8f704129b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a8f704129b | ||
|
|
62cd55e48f | ||
|
|
e4e3f885ea | ||
|
|
36d698f7ce |
@@ -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
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useState } from "react";
|
||||||
import {
|
import {
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
@@ -7,6 +8,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 +21,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 +179,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 };
|
||||||
@@ -32,7 +32,7 @@ async def get_bank_institutions(
|
|||||||
countries=inst["countries"],
|
countries=inst["countries"],
|
||||||
logo=inst.get("logo"),
|
logo=inst.get("logo"),
|
||||||
)
|
)
|
||||||
for inst in institutions_data
|
for inst in institutions_data.get("results", [])
|
||||||
]
|
]
|
||||||
|
|
||||||
return APIResponse(
|
return APIResponse(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -30,6 +30,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 +123,42 @@ 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
|
|
||||||
)
|
|
||||||
_log_rate_limits(response)
|
|
||||||
response.raise_for_status()
|
|
||||||
return response.json()
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user