beancount-gocardless 0.1.7__py3-none-any.whl → 0.1.9__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- beancount_gocardless/__init__.py +10 -2
- beancount_gocardless/cli.py +70 -44
- beancount_gocardless/client.py +364 -308
- beancount_gocardless/importer.py +246 -125
- beancount_gocardless/models.py +538 -0
- beancount_gocardless/openapi/swagger.json +5344 -0
- beancount_gocardless/tui.py +771 -0
- beancount_gocardless/tui2.py +17 -0
- beancount_gocardless-0.1.9.dist-info/METADATA +126 -0
- beancount_gocardless-0.1.9.dist-info/RECORD +13 -0
- {beancount_gocardless-0.1.7.dist-info → beancount_gocardless-0.1.9.dist-info}/WHEEL +1 -1
- beancount_gocardless-0.1.9.dist-info/entry_points.txt +3 -0
- beancount_gocardless-0.1.9.dist-info/licenses/LICENSE +24 -0
- beancount_gocardless-0.1.7.dist-info/LICENSE +0 -19
- beancount_gocardless-0.1.7.dist-info/METADATA +0 -92
- beancount_gocardless-0.1.7.dist-info/RECORD +0 -9
- beancount_gocardless-0.1.7.dist-info/entry_points.txt +0 -3
|
@@ -0,0 +1,771 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
from textual.app import App, ComposeResult
|
|
4
|
+
from textual.containers import Container, Vertical, ScrollableContainer
|
|
5
|
+
from textual.widgets import Header, Footer, Button, Static, Input, Label
|
|
6
|
+
from textual.message import Message
|
|
7
|
+
from textual.binding import Binding
|
|
8
|
+
from rich.table import Table
|
|
9
|
+
from rich.text import Text
|
|
10
|
+
from typing import Optional
|
|
11
|
+
from beancount_gocardless.client import GoCardlessClient
|
|
12
|
+
from requests.exceptions import HTTPError as HttpServiceException
|
|
13
|
+
import logging
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ActionMessage(Message):
|
|
19
|
+
"""Custom message to signal actions within the TUI."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, action: str, payload: dict = None) -> None:
|
|
22
|
+
super().__init__()
|
|
23
|
+
self.action = action
|
|
24
|
+
self.payload = payload or {}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class MenuView(Static):
|
|
28
|
+
"""Main menu view."""
|
|
29
|
+
|
|
30
|
+
def compose(self) -> ComposeResult:
|
|
31
|
+
yield Vertical(
|
|
32
|
+
Label("GoCardLess TUI - Main Menu", id="title"),
|
|
33
|
+
Button("List Banks", id="list_banks", variant="primary"),
|
|
34
|
+
Button("List Linked Accounts", id="list_accounts", variant="primary"),
|
|
35
|
+
Button("Get Account Balance", id="get_balance", variant="primary"),
|
|
36
|
+
Button("Create New Link", id="create_link", variant="success"),
|
|
37
|
+
Button("Delete Existing Link", id="delete_link", variant="error"),
|
|
38
|
+
Button("Quit", id="quit", variant="default"),
|
|
39
|
+
id="menu_view_vertical",
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
43
|
+
self.post_message(ActionMessage(event.button.id))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class BaseSubView(Static):
|
|
47
|
+
"""Base class for sub-views with a back button."""
|
|
48
|
+
|
|
49
|
+
def compose(self) -> ComposeResult:
|
|
50
|
+
yield Vertical(
|
|
51
|
+
self.compose_content(),
|
|
52
|
+
Button("Back to Menu", id="back_to_menu", classes="back_button"),
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
def compose_content(self) -> ComposeResult:
|
|
56
|
+
# To be overridden by subclasses
|
|
57
|
+
yield Static("Content goes here")
|
|
58
|
+
|
|
59
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
60
|
+
if event.button.id == "back_to_menu":
|
|
61
|
+
self.post_message(ActionMessage("show_menu"))
|
|
62
|
+
else:
|
|
63
|
+
# Allow subclasses to handle other buttons
|
|
64
|
+
pass
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class BanksView(BaseSubView):
|
|
68
|
+
"""View to display list of banks with country and name filtering."""
|
|
69
|
+
|
|
70
|
+
def __init__(self, client: GoCardlessClient, country: Optional[str] = None):
|
|
71
|
+
super().__init__()
|
|
72
|
+
self.client = client
|
|
73
|
+
self.country = country
|
|
74
|
+
self.all_banks = None # Store all banks
|
|
75
|
+
self.status_message = Static("Loading banks...", id="banks_status_message")
|
|
76
|
+
self.banks_table = Static("", id="banks_table")
|
|
77
|
+
|
|
78
|
+
def compose_content(self) -> ComposeResult:
|
|
79
|
+
yield Label("Banks", classes="view_title")
|
|
80
|
+
yield Input(placeholder="Filter banks by name...", id="bank_filter_input")
|
|
81
|
+
yield Input(
|
|
82
|
+
value=self.country,
|
|
83
|
+
placeholder="Country (e.g. FR)",
|
|
84
|
+
id="country_filter_input",
|
|
85
|
+
max_length=2,
|
|
86
|
+
)
|
|
87
|
+
yield self.status_message
|
|
88
|
+
yield ScrollableContainer(
|
|
89
|
+
self.banks_table,
|
|
90
|
+
id="banks_scrollable_area",
|
|
91
|
+
classes="horizontal-scroll",
|
|
92
|
+
)
|
|
93
|
+
yield Button("Back to Menu", id="back_to_menu", classes="back_button")
|
|
94
|
+
|
|
95
|
+
# Override the base class compose to avoid duplicate back button
|
|
96
|
+
def compose(self) -> ComposeResult:
|
|
97
|
+
yield Vertical(
|
|
98
|
+
*self.compose_content(),
|
|
99
|
+
id="banks_view_vertical",
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
async def on_mount(self) -> None:
|
|
103
|
+
"""Load all banks on mount."""
|
|
104
|
+
await self.filter_banks("", "")
|
|
105
|
+
|
|
106
|
+
async def filter_banks(
|
|
107
|
+
self, name_filter: Optional[str] = "", country_code: Optional[str] = None
|
|
108
|
+
) -> None:
|
|
109
|
+
"""Filters the banks based on current country and name filter."""
|
|
110
|
+
try:
|
|
111
|
+
if not self.all_banks:
|
|
112
|
+
self.status_message.update("Loading all banks...")
|
|
113
|
+
self.all_banks = self.client.list_banks(country=None)
|
|
114
|
+
|
|
115
|
+
self.banks_table.update(
|
|
116
|
+
f"Loading accounts... {name_filter} {country_code} {len(self.all_banks)}"
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
name_filtered = [
|
|
120
|
+
b
|
|
121
|
+
for b in self.all_banks
|
|
122
|
+
if (name_filter.upper() in b.get("name", "").upper() or not name_filter)
|
|
123
|
+
and (country_code in b.get("countries", []) or not country_code)
|
|
124
|
+
]
|
|
125
|
+
|
|
126
|
+
if not name_filtered:
|
|
127
|
+
filter_msg = f" matching '{name_filter}'" if name_filter else ""
|
|
128
|
+
self.banks_table.update(
|
|
129
|
+
f"No banks found in {country_code}{filter_msg}. Total: {len(self.all_banks)} available."
|
|
130
|
+
)
|
|
131
|
+
self.status_message.update("")
|
|
132
|
+
return
|
|
133
|
+
|
|
134
|
+
# Create a formatted table for display
|
|
135
|
+
table = Table(title=None, expand=True, min_width=80)
|
|
136
|
+
table.add_column("Name", overflow="ellipsis", min_width=30)
|
|
137
|
+
table.add_column("ID", overflow="ellipsis", min_width=20)
|
|
138
|
+
table.add_column("Countries", overflow="ellipsis", min_width=15)
|
|
139
|
+
|
|
140
|
+
for bank in name_filtered:
|
|
141
|
+
countries = ", ".join(bank.get("countries", []))
|
|
142
|
+
table.add_row(bank.get("name", "N/A"), bank.get("id", "N/A"), countries)
|
|
143
|
+
|
|
144
|
+
# Update the table content
|
|
145
|
+
self.banks_table.update(table)
|
|
146
|
+
self.status_message.update(f"Showing {len(name_filtered)} banks")
|
|
147
|
+
|
|
148
|
+
except Exception as e:
|
|
149
|
+
self.status_message.update(
|
|
150
|
+
Text(f"Error filtering banks: {e}", style="bold red")
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
async def on_input_changed(self, event: Input.Changed) -> None:
|
|
154
|
+
"""Event handler for input changes in either filter input."""
|
|
155
|
+
input_id = event.input.id
|
|
156
|
+
if input_id in ["country_filter_input", "bank_filter_input"]:
|
|
157
|
+
name_input = self.query_one("#bank_filter_input", Input)
|
|
158
|
+
country_input = self.query_one("#country_filter_input", Input)
|
|
159
|
+
country_code = country_input.value.upper()
|
|
160
|
+
omit_country = len(country_code) != 2
|
|
161
|
+
|
|
162
|
+
await self.filter_banks(
|
|
163
|
+
name_filter=name_input.value,
|
|
164
|
+
country_code=country_code if not omit_country else None,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
168
|
+
"""Handle button presses within this view."""
|
|
169
|
+
if event.button.id == "back_to_menu":
|
|
170
|
+
self.post_message(ActionMessage("show_menu"))
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class AccountsView(BaseSubView):
|
|
174
|
+
"""View to display linked accounts with complete information."""
|
|
175
|
+
|
|
176
|
+
def __init__(self, client: GoCardlessClient):
|
|
177
|
+
super().__init__()
|
|
178
|
+
self.client = client
|
|
179
|
+
self.status_message = Static(
|
|
180
|
+
"Loading accounts...", id="accounts_status_message"
|
|
181
|
+
)
|
|
182
|
+
self.accounts_table = Static("", id="accounts_table")
|
|
183
|
+
|
|
184
|
+
def compose_content(self) -> ComposeResult:
|
|
185
|
+
yield Label("Linked Accounts", classes="view_title")
|
|
186
|
+
yield self.status_message
|
|
187
|
+
yield ScrollableContainer(
|
|
188
|
+
self.accounts_table,
|
|
189
|
+
id="accounts_scrollable_area",
|
|
190
|
+
classes="horizontal-scroll",
|
|
191
|
+
)
|
|
192
|
+
yield Button("Back to Menu", id="back_to_menu", classes="back_button")
|
|
193
|
+
|
|
194
|
+
# Override the base class compose to avoid duplicate back button
|
|
195
|
+
def compose(self) -> ComposeResult:
|
|
196
|
+
yield Vertical(
|
|
197
|
+
*self.compose_content(),
|
|
198
|
+
id="accounts_view_vertical",
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
async def on_mount(self) -> None:
|
|
202
|
+
await self.load_accounts()
|
|
203
|
+
|
|
204
|
+
async def load_accounts(self) -> None:
|
|
205
|
+
"""Loads and displays the linked accounts with detailed information."""
|
|
206
|
+
try:
|
|
207
|
+
self.status_message.update("Loading accounts...")
|
|
208
|
+
accounts = self.client.list_accounts()
|
|
209
|
+
if not accounts:
|
|
210
|
+
self.accounts_table.update("No accounts found.")
|
|
211
|
+
self.status_message.update("")
|
|
212
|
+
return
|
|
213
|
+
|
|
214
|
+
# Create a formatted table for display
|
|
215
|
+
table = Table(title=None, expand=True, min_width=100)
|
|
216
|
+
table.add_column("#", style="dim", width=3)
|
|
217
|
+
table.add_column("Bank", overflow="ellipsis", min_width=20)
|
|
218
|
+
table.add_column("Name", overflow="crop", min_width=20)
|
|
219
|
+
table.add_column("ID", overflow="ellipsis", min_width=20)
|
|
220
|
+
table.add_column("IBAN", overflow="ellipsis", min_width=20)
|
|
221
|
+
table.add_column("Reference", overflow="ellipsis", min_width=15)
|
|
222
|
+
table.add_column("Status", width=6)
|
|
223
|
+
table.add_column("Last Accessed", min_width=12)
|
|
224
|
+
|
|
225
|
+
# Format and add accounts to the table
|
|
226
|
+
for idx, account in enumerate(accounts, start=1):
|
|
227
|
+
# import pdb
|
|
228
|
+
# pdb.set_trace()
|
|
229
|
+
# Get values with fallbacks for any missing keys
|
|
230
|
+
institution_id = account.get("institution_id", "N/A")
|
|
231
|
+
name = account.get("name", account.get("owner_name", "N/A"))
|
|
232
|
+
iban = account.get("iban", "N/A")
|
|
233
|
+
account_id = account.get("account_id", "N/A")
|
|
234
|
+
reference = account.get("reference", "N/A")
|
|
235
|
+
status = account.get("status", "N/A")
|
|
236
|
+
last_accessed = account.get("last_accessed", "")
|
|
237
|
+
if last_accessed:
|
|
238
|
+
try:
|
|
239
|
+
last_accessed = last_accessed.split("T")[0]
|
|
240
|
+
except Exception:
|
|
241
|
+
pass
|
|
242
|
+
|
|
243
|
+
table.add_row(
|
|
244
|
+
str(idx),
|
|
245
|
+
institution_id,
|
|
246
|
+
name,
|
|
247
|
+
account_id,
|
|
248
|
+
iban,
|
|
249
|
+
reference,
|
|
250
|
+
status,
|
|
251
|
+
last_accessed,
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
# Update the table content
|
|
255
|
+
self.accounts_table.update(table)
|
|
256
|
+
self.status_message.update("")
|
|
257
|
+
|
|
258
|
+
except HttpServiceException as e:
|
|
259
|
+
self.status_message.update(Text(f"API Error: {e}", style="bold red"))
|
|
260
|
+
except Exception as e:
|
|
261
|
+
self.status_message.update(Text(f"Unexpected error: {e}", style="bold red"))
|
|
262
|
+
|
|
263
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
264
|
+
"""Handle button presses within this view."""
|
|
265
|
+
if event.button.id == "back_to_menu":
|
|
266
|
+
self.post_message(ActionMessage("show_menu"))
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
# ... existing code ...
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
class BalanceView(BaseSubView):
|
|
273
|
+
"""View to get and display account balance."""
|
|
274
|
+
|
|
275
|
+
def __init__(
|
|
276
|
+
self, client: "GoCardlessClient"
|
|
277
|
+
): # Forward reference GoCardlessClient if not imported yet
|
|
278
|
+
super().__init__()
|
|
279
|
+
self.client = client
|
|
280
|
+
self.accounts_data = []
|
|
281
|
+
|
|
282
|
+
def compose(self) -> ComposeResult:
|
|
283
|
+
"""Override compose to yield widgets from compose_content."""
|
|
284
|
+
for widget in self.compose_content():
|
|
285
|
+
yield widget
|
|
286
|
+
|
|
287
|
+
def compose_content(self) -> ComposeResult:
|
|
288
|
+
yield Label("Account Balance", classes="view_title")
|
|
289
|
+
# Moved status message to be a direct child of BalanceView, and sibling to content_area
|
|
290
|
+
yield Static(
|
|
291
|
+
"", id="balance_status_message"
|
|
292
|
+
) # Initialize empty or with a generic placeholder
|
|
293
|
+
yield Vertical(
|
|
294
|
+
# The initial Static message that was here is removed
|
|
295
|
+
id="balance_content_area",
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
async def on_mount(self) -> None:
|
|
299
|
+
# Query elements only after they are mounted
|
|
300
|
+
content_area = self.query_one("#balance_content_area", Vertical)
|
|
301
|
+
status_message = self.query_one("#balance_status_message", Static)
|
|
302
|
+
status_message.update(
|
|
303
|
+
"Loading accounts for selection..."
|
|
304
|
+
) # Set initial operational message
|
|
305
|
+
await self.load_accounts_for_selection(content_area, status_message)
|
|
306
|
+
|
|
307
|
+
async def load_accounts_for_selection(
|
|
308
|
+
self, content_area: Vertical, status_message: Static
|
|
309
|
+
):
|
|
310
|
+
try:
|
|
311
|
+
# status_message.update("Loading accounts...") # Message set by on_mount or can be refined here
|
|
312
|
+
self.accounts_data = self.client.list_accounts()
|
|
313
|
+
await (
|
|
314
|
+
content_area.remove_children()
|
|
315
|
+
) # Clear "Loading..." or previous content from content_area
|
|
316
|
+
|
|
317
|
+
if not self.accounts_data:
|
|
318
|
+
await content_area.mount(
|
|
319
|
+
Static("No accounts linked. Cannot fetch balance.")
|
|
320
|
+
)
|
|
321
|
+
status_message.update(
|
|
322
|
+
"No accounts available to display."
|
|
323
|
+
) # Update persistent status
|
|
324
|
+
return
|
|
325
|
+
|
|
326
|
+
table = Table(title="Select Account by Index for Balance", expand=True)
|
|
327
|
+
table.add_column("#", style="dim")
|
|
328
|
+
table.add_column("Name", overflow="fold")
|
|
329
|
+
table.add_column("Institution ID", overflow="fold")
|
|
330
|
+
for idx, a in enumerate(self.accounts_data):
|
|
331
|
+
table.add_row(str(idx), a["name"], a["institution_id"])
|
|
332
|
+
|
|
333
|
+
await content_area.mount(table) # Mount Table widget directly
|
|
334
|
+
idx_input = Input(
|
|
335
|
+
placeholder="Enter account # and press Enter", id="acct_idx_input"
|
|
336
|
+
)
|
|
337
|
+
await content_area.mount(idx_input)
|
|
338
|
+
idx_input.focus()
|
|
339
|
+
status_message.update(
|
|
340
|
+
"Please select an account from the list."
|
|
341
|
+
) # Clear or update status message
|
|
342
|
+
except HttpServiceException as e: # Make sure HttpServiceException is defined
|
|
343
|
+
status_message.update(
|
|
344
|
+
Text(f"Error loading accounts: {e}", style="bold red")
|
|
345
|
+
)
|
|
346
|
+
await content_area.remove_children()
|
|
347
|
+
await content_area.mount(
|
|
348
|
+
Static(
|
|
349
|
+
Text(
|
|
350
|
+
"Failed to load accounts. Check connection or try again later.",
|
|
351
|
+
style="yellow",
|
|
352
|
+
)
|
|
353
|
+
)
|
|
354
|
+
)
|
|
355
|
+
except Exception as e:
|
|
356
|
+
status_message.update(Text(f"Unexpected error: {e}", style="bold red"))
|
|
357
|
+
await content_area.remove_children()
|
|
358
|
+
await content_area.mount(
|
|
359
|
+
Static(
|
|
360
|
+
Text(
|
|
361
|
+
"An unexpected error occurred while loading accounts.",
|
|
362
|
+
style="yellow",
|
|
363
|
+
)
|
|
364
|
+
)
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
async def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
368
|
+
if event.input.id == "acct_idx_input":
|
|
369
|
+
content_area = self.query_one("#balance_content_area", Vertical)
|
|
370
|
+
# status_message is now persistent and queried once
|
|
371
|
+
status_message = self.query_one("#balance_status_message", Static)
|
|
372
|
+
|
|
373
|
+
await (
|
|
374
|
+
content_area.remove_children()
|
|
375
|
+
) # Clear account list and input from content_area
|
|
376
|
+
|
|
377
|
+
try:
|
|
378
|
+
idx = int(event.value)
|
|
379
|
+
if not (0 <= idx < len(self.accounts_data)):
|
|
380
|
+
# Display temporary error message in content_area
|
|
381
|
+
await content_area.mount(
|
|
382
|
+
Static(Text("Invalid account index.", style="bold red"))
|
|
383
|
+
)
|
|
384
|
+
status_message.update(
|
|
385
|
+
Text(
|
|
386
|
+
"Selection out of range. Please try again.", style="yellow"
|
|
387
|
+
)
|
|
388
|
+
)
|
|
389
|
+
# Reload account selection UI
|
|
390
|
+
await self.load_accounts_for_selection(content_area, status_message)
|
|
391
|
+
return
|
|
392
|
+
|
|
393
|
+
account_to_check = self.accounts_data[idx]
|
|
394
|
+
acct_id = account_to_check["account_id"]
|
|
395
|
+
|
|
396
|
+
status_message.update(
|
|
397
|
+
f"Fetching balances for {account_to_check['name']} ({acct_id})..."
|
|
398
|
+
)
|
|
399
|
+
# Optionally, display a "Fetching..." message in content_area as well
|
|
400
|
+
await content_area.mount(Static("Fetching balances..."))
|
|
401
|
+
|
|
402
|
+
balances_data = self.client.get_balances(account_id=acct_id)
|
|
403
|
+
await content_area.remove_children() # Clear "Fetching..." message
|
|
404
|
+
|
|
405
|
+
table = Table(
|
|
406
|
+
title=f"Balances for {account_to_check['name']}", expand=True
|
|
407
|
+
)
|
|
408
|
+
table.add_column("Type", overflow="fold")
|
|
409
|
+
table.add_column("Amount")
|
|
410
|
+
table.add_column("Currency")
|
|
411
|
+
if "balances" in balances_data and balances_data["balances"]:
|
|
412
|
+
for b in balances_data["balances"]:
|
|
413
|
+
balance_amount_info = b.get("balanceAmount", {})
|
|
414
|
+
table.add_row(
|
|
415
|
+
b.get("balanceType", "N/A"),
|
|
416
|
+
str(balance_amount_info.get("amount", "N/A")),
|
|
417
|
+
balance_amount_info.get("currency", "N/A"),
|
|
418
|
+
)
|
|
419
|
+
else:
|
|
420
|
+
table.add_row("N/A", "No balance data found", "N/A")
|
|
421
|
+
|
|
422
|
+
await content_area.mount(table) # Mount the results table
|
|
423
|
+
status_message.update(
|
|
424
|
+
f"Balances for {account_to_check['name']} displayed."
|
|
425
|
+
)
|
|
426
|
+
# Consider how the user navigates back to account selection (e.g., a button, or re-selecting the view)
|
|
427
|
+
|
|
428
|
+
except ValueError:
|
|
429
|
+
# Display temporary error message in content_area
|
|
430
|
+
await content_area.mount(
|
|
431
|
+
Static(
|
|
432
|
+
Text("Invalid input. Please enter a number.", style="bold red")
|
|
433
|
+
)
|
|
434
|
+
)
|
|
435
|
+
status_message.update(
|
|
436
|
+
Text("Numeric input required for account index.", style="yellow")
|
|
437
|
+
)
|
|
438
|
+
await self.load_accounts_for_selection(
|
|
439
|
+
content_area, status_message
|
|
440
|
+
) # Reload selection
|
|
441
|
+
except (
|
|
442
|
+
HttpServiceException
|
|
443
|
+
) as e: # Make sure HttpServiceException is defined
|
|
444
|
+
status_message.update(
|
|
445
|
+
Text(f"API Error fetching balances: {e}", style="bold red")
|
|
446
|
+
)
|
|
447
|
+
await content_area.mount(
|
|
448
|
+
Static(
|
|
449
|
+
Text(
|
|
450
|
+
"Could not retrieve balances due to an API error.",
|
|
451
|
+
style="red",
|
|
452
|
+
)
|
|
453
|
+
)
|
|
454
|
+
)
|
|
455
|
+
# Optionally, reload selection UI so user can try again or select a different account
|
|
456
|
+
# await self.load_accounts_for_selection(content_area, status_message)
|
|
457
|
+
except Exception as e:
|
|
458
|
+
status_message.update(
|
|
459
|
+
Text(f"Error processing balance request: {e}", style="bold red")
|
|
460
|
+
)
|
|
461
|
+
await content_area.mount(
|
|
462
|
+
Static(
|
|
463
|
+
Text(
|
|
464
|
+
"An unexpected error occurred while fetching balances.",
|
|
465
|
+
style="red",
|
|
466
|
+
)
|
|
467
|
+
)
|
|
468
|
+
)
|
|
469
|
+
# Optionally, reload selection UI
|
|
470
|
+
# await self.load_accounts_for_selection(content_area, status_message)
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
class LinkView(BaseSubView):
|
|
474
|
+
"""View to create a new bank link."""
|
|
475
|
+
|
|
476
|
+
def __init__(self, client: GoCardlessClient):
|
|
477
|
+
super().__init__()
|
|
478
|
+
self.client = client
|
|
479
|
+
|
|
480
|
+
def compose(self) -> ComposeResult:
|
|
481
|
+
"""Override compose to yield widgets from compose_content."""
|
|
482
|
+
for widget in self.compose_content():
|
|
483
|
+
yield widget
|
|
484
|
+
|
|
485
|
+
def compose_content(self) -> ComposeResult:
|
|
486
|
+
yield Label("Create New Bank Link", classes="view_title")
|
|
487
|
+
yield Vertical(
|
|
488
|
+
Input(
|
|
489
|
+
placeholder="Bank ID (e.g., SANDBOXFINANCE_SFIN0000)",
|
|
490
|
+
id="bank_id_input",
|
|
491
|
+
),
|
|
492
|
+
Input(
|
|
493
|
+
placeholder="Unique Reference (e.g., mypc-savings-ref)", id="ref_input"
|
|
494
|
+
),
|
|
495
|
+
Button("Create Link", id="do_create_link_button", variant="success"),
|
|
496
|
+
Static(id="link_result_message", classes="result_message_area"),
|
|
497
|
+
id="link_form_area",
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
async def on_mount(self) -> None:
|
|
501
|
+
await self.query_one("#bank_id_input", Input).focus()
|
|
502
|
+
|
|
503
|
+
async def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
504
|
+
if event.button.id == "back_to_menu":
|
|
505
|
+
self.post_message(ActionMessage("show_menu"))
|
|
506
|
+
elif event.button.id == "do_create_link_button":
|
|
507
|
+
await self.process_create_link()
|
|
508
|
+
|
|
509
|
+
async def process_create_link(self) -> None:
|
|
510
|
+
bank_input = self.query_one("#bank_id_input", Input)
|
|
511
|
+
ref_input = self.query_one("#ref_input", Input)
|
|
512
|
+
result_message_widget = self.query_one("#link_result_message", Static)
|
|
513
|
+
create_button = self.query_one("#do_create_link_button", Button)
|
|
514
|
+
|
|
515
|
+
bank_id = bank_input.value.strip()
|
|
516
|
+
reference = ref_input.value.strip()
|
|
517
|
+
|
|
518
|
+
if not bank_id or not reference:
|
|
519
|
+
result_message_widget.update(
|
|
520
|
+
Text("Bank ID and Reference are required.", style="bold red")
|
|
521
|
+
)
|
|
522
|
+
return
|
|
523
|
+
|
|
524
|
+
try:
|
|
525
|
+
bank_input.disabled = True
|
|
526
|
+
ref_input.disabled = True
|
|
527
|
+
create_button.disabled = True
|
|
528
|
+
result_message_widget.update("Creating link...")
|
|
529
|
+
|
|
530
|
+
link_info = self.client.create_link(reference, bank_id) # API call
|
|
531
|
+
|
|
532
|
+
msg_parts = [
|
|
533
|
+
Text(f"Status: {link_info.get('status', 'N/A')}\n", style="bold")
|
|
534
|
+
]
|
|
535
|
+
if link_info.get("message"):
|
|
536
|
+
msg_parts.append(Text(f"Message: {link_info['message']}\n"))
|
|
537
|
+
if link_info.get("link"):
|
|
538
|
+
msg_parts.append(
|
|
539
|
+
Text(
|
|
540
|
+
"Link URL (copy and open in browser to authorize):\n",
|
|
541
|
+
style="bold yellow",
|
|
542
|
+
)
|
|
543
|
+
)
|
|
544
|
+
msg_parts.append(Text(f"{link_info['link']}", style="underline blue"))
|
|
545
|
+
|
|
546
|
+
result_message_widget.update(Text.assemble(*msg_parts))
|
|
547
|
+
|
|
548
|
+
except HttpServiceException as e:
|
|
549
|
+
result_message_widget.update(Text(f"API Error:\n{e}", style="bold red"))
|
|
550
|
+
except Exception as e:
|
|
551
|
+
result_message_widget.update(
|
|
552
|
+
Text(f"Unexpected Error:\n{e}", style="bold red")
|
|
553
|
+
)
|
|
554
|
+
finally:
|
|
555
|
+
bank_input.disabled = False
|
|
556
|
+
ref_input.disabled = False
|
|
557
|
+
create_button.disabled = False
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
class DeleteLinkView(BaseSubView):
|
|
561
|
+
"""View to delete an existing bank link."""
|
|
562
|
+
|
|
563
|
+
def __init__(self, client: GoCardlessClient):
|
|
564
|
+
super().__init__()
|
|
565
|
+
self.client = client
|
|
566
|
+
|
|
567
|
+
def compose(self) -> ComposeResult:
|
|
568
|
+
"""Override compose to yield widgets from compose_content."""
|
|
569
|
+
for widget in self.compose_content():
|
|
570
|
+
yield widget
|
|
571
|
+
|
|
572
|
+
def compose_content(self) -> ComposeResult:
|
|
573
|
+
yield Label("Delete Existing Link", classes="view_title")
|
|
574
|
+
yield Vertical(
|
|
575
|
+
Input(placeholder="Reference of link to delete", id="del_ref_input"),
|
|
576
|
+
Button("Delete Link", id="do_delete_link_button", variant="error"),
|
|
577
|
+
Static(id="delete_link_result_message", classes="result_message_area"),
|
|
578
|
+
id="delete_link_form_area",
|
|
579
|
+
)
|
|
580
|
+
|
|
581
|
+
async def on_mount(self) -> None:
|
|
582
|
+
await self.query_one("#del_ref_input", Input).focus()
|
|
583
|
+
|
|
584
|
+
async def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
585
|
+
if event.button.id == "back_to_menu":
|
|
586
|
+
self.post_message(ActionMessage("show_menu"))
|
|
587
|
+
elif event.button.id == "do_delete_link_button":
|
|
588
|
+
await self.process_delete_link()
|
|
589
|
+
|
|
590
|
+
async def process_delete_link(self) -> None:
|
|
591
|
+
ref_input = self.query_one("#del_ref_input", Input)
|
|
592
|
+
result_message_widget = self.query_one("#delete_link_result_message", Static)
|
|
593
|
+
delete_button = self.query_one("#do_delete_link_button", Button)
|
|
594
|
+
|
|
595
|
+
reference = ref_input.value.strip()
|
|
596
|
+
|
|
597
|
+
if not reference:
|
|
598
|
+
result_message_widget.update(
|
|
599
|
+
Text("Reference is required to delete a link.", style="bold red")
|
|
600
|
+
)
|
|
601
|
+
return
|
|
602
|
+
|
|
603
|
+
try:
|
|
604
|
+
ref_input.disabled = True
|
|
605
|
+
delete_button.disabled = True
|
|
606
|
+
result_message_widget.update(
|
|
607
|
+
f"Deleting link with reference '{reference}'..."
|
|
608
|
+
)
|
|
609
|
+
|
|
610
|
+
response = self.client.delete_link(reference) # API call
|
|
611
|
+
|
|
612
|
+
style = (
|
|
613
|
+
"bold green" if response.get("status") == "deleted" else "bold yellow"
|
|
614
|
+
)
|
|
615
|
+
result_message_widget.update(
|
|
616
|
+
Text(
|
|
617
|
+
f"Status: {response.get('status', 'N/A')}\nMessage: {response.get('message', 'N/A')}",
|
|
618
|
+
style=style,
|
|
619
|
+
)
|
|
620
|
+
)
|
|
621
|
+
|
|
622
|
+
except HttpServiceException as e:
|
|
623
|
+
result_message_widget.update(Text(f"API Error:\n{e}", style="bold red"))
|
|
624
|
+
except Exception as e:
|
|
625
|
+
result_message_widget.update(
|
|
626
|
+
Text(f"Unexpected Error:\n{e}", style="bold red")
|
|
627
|
+
)
|
|
628
|
+
finally:
|
|
629
|
+
ref_input.disabled = False
|
|
630
|
+
delete_button.disabled = False
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
class GoCardLessApp(App):
|
|
634
|
+
TITLE = "GoCardLess API TUI"
|
|
635
|
+
CSS = """
|
|
636
|
+
Screen {
|
|
637
|
+
align: center middle;
|
|
638
|
+
}
|
|
639
|
+
#app_container {
|
|
640
|
+
width: 100%;
|
|
641
|
+
max-width: 300; /* Max width for the main content container */
|
|
642
|
+
height: auto;
|
|
643
|
+
border: round $primary;
|
|
644
|
+
padding: 1 2;
|
|
645
|
+
}
|
|
646
|
+
#title { /* For MenuView title */
|
|
647
|
+
width: 100%;
|
|
648
|
+
text-align: center;
|
|
649
|
+
padding: 1 0 2 0;
|
|
650
|
+
text-style: bold;
|
|
651
|
+
}
|
|
652
|
+
.view_title { /* For sub-view titles */
|
|
653
|
+
width: 100%;
|
|
654
|
+
text-align: center;
|
|
655
|
+
padding: 0 0 1 0;
|
|
656
|
+
text-style: bold underline;
|
|
657
|
+
}
|
|
658
|
+
#menu_view_vertical > Button {
|
|
659
|
+
width: 100%;
|
|
660
|
+
margin-bottom: 1;
|
|
661
|
+
}
|
|
662
|
+
.back_button {
|
|
663
|
+
width: 100%;
|
|
664
|
+
margin-top: 2;
|
|
665
|
+
}
|
|
666
|
+
Input, Button { /* General spacing for inputs and buttons in forms */
|
|
667
|
+
margin-bottom: 1;
|
|
668
|
+
}
|
|
669
|
+
.result_message_area {
|
|
670
|
+
margin-top: 1;
|
|
671
|
+
padding: 1;
|
|
672
|
+
border: round $primary-background-darken-2;
|
|
673
|
+
min-height: 3;
|
|
674
|
+
width: 100%;
|
|
675
|
+
}
|
|
676
|
+
Vertical { /* Ensure vertical containers take full width by default */
|
|
677
|
+
width: 100%;
|
|
678
|
+
}
|
|
679
|
+
Table {
|
|
680
|
+
margin-top: 1;
|
|
681
|
+
}
|
|
682
|
+
"""
|
|
683
|
+
BINDINGS = [
|
|
684
|
+
Binding("q", "quit", "Quit App", show=True, priority=True),
|
|
685
|
+
Binding(
|
|
686
|
+
"escape", "show_menu_escape", "Back to Menu", show=True
|
|
687
|
+
), # Fixed: Always show, we'll control visibility elsewhere
|
|
688
|
+
]
|
|
689
|
+
|
|
690
|
+
def __init__(self, secret_id=None, secret_key=None):
|
|
691
|
+
super().__init__()
|
|
692
|
+
sid = secret_id or os.getenv("GOCARDLESS_SECRET_ID")
|
|
693
|
+
sk = secret_key or os.getenv("GOCARDLESS_SECRET_KEY")
|
|
694
|
+
if not sid or not sk:
|
|
695
|
+
logger.error(
|
|
696
|
+
"Error: GoCardLess credentials (GOCARDLESS_SECRET_ID, GOCARDLESS_SECRET_KEY) not found.",
|
|
697
|
+
)
|
|
698
|
+
logger.error(
|
|
699
|
+
"Please set them as environment variables or pass via arguments (not implemented in this TUI version).",
|
|
700
|
+
)
|
|
701
|
+
sys.exit(1) # Exit if no creds
|
|
702
|
+
self.client = GoCardlessClient(sid, sk, {})
|
|
703
|
+
self._current_view_is_menu = True
|
|
704
|
+
|
|
705
|
+
def compose(self) -> ComposeResult:
|
|
706
|
+
yield Header()
|
|
707
|
+
yield Container(MenuView(), id="app_container")
|
|
708
|
+
yield Footer()
|
|
709
|
+
|
|
710
|
+
async def on_mount(self) -> None:
|
|
711
|
+
# Fixed: removed the call to update_escape_binding since we're showing escape binding always
|
|
712
|
+
# and managing its behavior in action_show_menu_escape
|
|
713
|
+
pass
|
|
714
|
+
|
|
715
|
+
async def switch_view(self, new_view_widget: Static) -> None:
|
|
716
|
+
container = self.query_one("#app_container", Container)
|
|
717
|
+
await container.remove_children()
|
|
718
|
+
await container.mount(new_view_widget)
|
|
719
|
+
self._current_view_is_menu = isinstance(new_view_widget, MenuView)
|
|
720
|
+
|
|
721
|
+
async def action_show_menu_escape(self) -> None:
|
|
722
|
+
"""Handle Escape key press to go back to menu."""
|
|
723
|
+
if not self._current_view_is_menu:
|
|
724
|
+
await self.switch_view(MenuView())
|
|
725
|
+
|
|
726
|
+
async def on_action_message(self, msg: ActionMessage) -> None:
|
|
727
|
+
action_to_view_map = {
|
|
728
|
+
"show_menu": MenuView,
|
|
729
|
+
"list_banks": lambda: BanksView(self.client),
|
|
730
|
+
"list_accounts": lambda: AccountsView(self.client),
|
|
731
|
+
"get_balance": lambda: BalanceView(self.client),
|
|
732
|
+
"create_link": lambda: LinkView(self.client),
|
|
733
|
+
"delete_link": lambda: DeleteLinkView(self.client),
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
if msg.action == "quit":
|
|
737
|
+
self.exit()
|
|
738
|
+
return
|
|
739
|
+
|
|
740
|
+
if msg.action in action_to_view_map:
|
|
741
|
+
view_constructor = action_to_view_map[msg.action]
|
|
742
|
+
new_view = view_constructor()
|
|
743
|
+
await self.switch_view(new_view)
|
|
744
|
+
else:
|
|
745
|
+
# Fallback for unknown action, though should not happen with defined buttons
|
|
746
|
+
container = self.query_one("#app_container", Container)
|
|
747
|
+
await container.remove_children()
|
|
748
|
+
await container.mount(
|
|
749
|
+
Static(Text(f"Unknown action: {msg.action}", style="bold red"))
|
|
750
|
+
)
|
|
751
|
+
self._current_view_is_menu = False # Assuming it's not menu
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
logger.info(os.getenv("GOCARDLESS_SECRET_ID"), os.getenv("GOCARDLESS_SECRET_KEY"))
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
def main():
|
|
758
|
+
# For this TUI, credentials must be set as environment variables
|
|
759
|
+
# GOCARDLESS_SECRET_ID and GOCARDLESS_SECRET_KEY
|
|
760
|
+
if not os.getenv("GOCARDLESS_SECRET_ID") or not os.getenv("GOCARDLESS_SECRET_KEY"):
|
|
761
|
+
logger.error(
|
|
762
|
+
"Error: GOCARDLESS_SECRET_ID and GOCARDLESS_SECRET_KEY environment variables must be set.",
|
|
763
|
+
)
|
|
764
|
+
sys.exit(1)
|
|
765
|
+
|
|
766
|
+
app = GoCardLessApp()
|
|
767
|
+
app.run()
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
# if __name__ == "__main__":
|
|
771
|
+
# main()
|