beancount-gocardless 0.1.13__py3-none-any.whl → 0.1.14__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/__main__.py +4 -0
- beancount_gocardless/cli.py +614 -113
- beancount_gocardless/client.py +13 -0
- beancount_gocardless/importer.py +96 -39
- beancount_gocardless/mock_client.py +220 -0
- beancount_gocardless/models.py +19 -8
- beancount_gocardless/utils.py +39 -0
- beancount_gocardless-0.1.14.dist-info/METADATA +252 -0
- beancount_gocardless-0.1.14.dist-info/RECORD +14 -0
- {beancount_gocardless-0.1.13.dist-info → beancount_gocardless-0.1.14.dist-info}/entry_points.txt +0 -1
- beancount_gocardless-0.1.14.dist-info/licenses/LICENSE +21 -0
- beancount_gocardless/tui.py +0 -669
- beancount_gocardless-0.1.13.dist-info/METADATA +0 -108
- beancount_gocardless-0.1.13.dist-info/RECORD +0 -12
- beancount_gocardless-0.1.13.dist-info/licenses/LICENSE +0 -24
- {beancount_gocardless-0.1.13.dist-info → beancount_gocardless-0.1.14.dist-info}/WHEEL +0 -0
beancount_gocardless/cli.py
CHANGED
|
@@ -1,139 +1,640 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
"""
|
|
2
|
+
Interactive CLI for GoCardless Bank Account Data API.
|
|
3
|
+
|
|
4
|
+
A polished, modern CLI experience with arrow-key navigation.
|
|
5
|
+
Uses rich for beautiful output and questionary for interactive prompts.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import Optional, Union
|
|
9
|
+
from datetime import datetime
|
|
3
10
|
import os
|
|
4
|
-
import
|
|
11
|
+
import sys
|
|
12
|
+
import argparse
|
|
5
13
|
|
|
6
|
-
from
|
|
7
|
-
from
|
|
14
|
+
from rich.console import Console
|
|
15
|
+
from rich.panel import Panel
|
|
16
|
+
from rich.text import Text
|
|
17
|
+
from rich.table import Table
|
|
18
|
+
from rich import box
|
|
19
|
+
import questionary
|
|
8
20
|
|
|
21
|
+
from .client import GoCardlessClient
|
|
22
|
+
from .mock_client import MockGoCardlessClient
|
|
23
|
+
from .models import AccountInfo, Institution
|
|
24
|
+
from .utils import load_dotenv
|
|
9
25
|
|
|
10
|
-
logging.basicConfig(level=os.environ.get("LOGLEVEL", logging.INFO))
|
|
11
|
-
logger = logging.getLogger(__name__)
|
|
12
26
|
|
|
27
|
+
class CLI:
|
|
28
|
+
"""Interactive CLI for managing GoCardless bank connections."""
|
|
13
29
|
|
|
14
|
-
def
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
f"{index}: {institution_id} {name}: {iban} {currency} ({requisition_ref}/{account_id})"
|
|
24
|
-
)
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
secret_id: Optional[str] = None,
|
|
33
|
+
secret_key: Optional[str] = None,
|
|
34
|
+
mock: bool = False,
|
|
35
|
+
env_file: Optional[str] = None,
|
|
36
|
+
):
|
|
37
|
+
self.console = Console()
|
|
38
|
+
self.mock = mock
|
|
25
39
|
|
|
40
|
+
if env_file:
|
|
41
|
+
load_dotenv(env_file)
|
|
26
42
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
"
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
43
|
+
self.secret_id = secret_id or os.getenv("GOCARDLESS_SECRET_ID")
|
|
44
|
+
self.secret_key = secret_key or os.getenv("GOCARDLESS_SECRET_KEY")
|
|
45
|
+
self._init_client()
|
|
46
|
+
|
|
47
|
+
def _init_client(self) -> None:
|
|
48
|
+
"""Initialize the GoCardless client (real or mock)."""
|
|
49
|
+
if self.mock:
|
|
50
|
+
self.console.print("[dim]Using mock client[/dim]")
|
|
51
|
+
self.client: Union[GoCardlessClient, MockGoCardlessClient] = (
|
|
52
|
+
MockGoCardlessClient(
|
|
53
|
+
"mock-id",
|
|
54
|
+
"mock-key",
|
|
55
|
+
)
|
|
56
|
+
)
|
|
57
|
+
else:
|
|
58
|
+
if not self.secret_id or not self.secret_key:
|
|
59
|
+
self.console.print(
|
|
60
|
+
"[red]Error: Secret ID and Secret Key are required[/red]\n"
|
|
61
|
+
"Set GOCARDLESS_SECRET_ID and GOCARDLESS_SECRET_KEY environment variables\n"
|
|
62
|
+
"or pass --secret-id and --secret-key arguments."
|
|
63
|
+
)
|
|
64
|
+
sys.exit(1)
|
|
65
|
+
self.client = GoCardlessClient(self.secret_id, self.secret_key)
|
|
66
|
+
|
|
67
|
+
def _print_header(self, title: str) -> None:
|
|
68
|
+
"""Print a styled header."""
|
|
69
|
+
self.console.print()
|
|
70
|
+
self.console.print(
|
|
71
|
+
Panel(
|
|
72
|
+
Text(title, style="bold"),
|
|
73
|
+
box=box.ROUNDED,
|
|
74
|
+
border_style="blue",
|
|
75
|
+
)
|
|
76
|
+
)
|
|
77
|
+
self.console.print()
|
|
78
|
+
|
|
79
|
+
def _print_success(self, message: str) -> None:
|
|
80
|
+
"""Print a success message."""
|
|
81
|
+
self.console.print(f"[green]✓[/green] {message}")
|
|
82
|
+
|
|
83
|
+
def _print_error(self, message: str) -> None:
|
|
84
|
+
"""Print an error message."""
|
|
85
|
+
self.console.print(f"[red]✗[/red] {message}")
|
|
86
|
+
|
|
87
|
+
def _print_info(self, message: str) -> None:
|
|
88
|
+
"""Print an info message."""
|
|
89
|
+
self.console.print(f"[dim]{message}[/dim]")
|
|
90
|
+
|
|
91
|
+
def _format_expiry_status(self, account: AccountInfo) -> str:
|
|
92
|
+
"""Format expiry status for display in account list."""
|
|
93
|
+
is_expired = account.get("is_expired", False)
|
|
94
|
+
if is_expired:
|
|
95
|
+
return "[EXPIRED]"
|
|
96
|
+
|
|
97
|
+
access_valid_until = account.get("access_valid_until")
|
|
98
|
+
if access_valid_until:
|
|
99
|
+
try:
|
|
100
|
+
expiry = datetime.fromisoformat(
|
|
101
|
+
access_valid_until.replace("Z", "+00:00")
|
|
102
|
+
)
|
|
103
|
+
days_remaining = (expiry - datetime.now(expiry.tzinfo)).days
|
|
104
|
+
if days_remaining <= 7 and days_remaining >= 0:
|
|
105
|
+
return f"[{days_remaining}d left]"
|
|
106
|
+
except (ValueError, TypeError):
|
|
107
|
+
pass
|
|
108
|
+
return ""
|
|
109
|
+
|
|
110
|
+
def _show_expiry_details(self, account: AccountInfo) -> None:
|
|
111
|
+
"""Show detailed expiry information in a table."""
|
|
112
|
+
access_valid_until = account.get("access_valid_until")
|
|
113
|
+
is_expired = account.get("is_expired", False)
|
|
114
|
+
status = account.get("requisition_status", "Unknown")
|
|
115
|
+
|
|
116
|
+
table = Table(box=box.ROUNDED, show_header=False, border_style="blue")
|
|
117
|
+
table.add_column("Property", style="cyan")
|
|
118
|
+
table.add_column("Value")
|
|
119
|
+
|
|
120
|
+
table.add_row("Status", status)
|
|
121
|
+
|
|
122
|
+
if access_valid_until:
|
|
123
|
+
try:
|
|
124
|
+
expiry = datetime.fromisoformat(
|
|
125
|
+
access_valid_until.replace("Z", "+00:00")
|
|
126
|
+
)
|
|
127
|
+
days_remaining = (expiry - datetime.now(expiry.tzinfo)).days
|
|
128
|
+
expiry_str = expiry.strftime("%Y-%m-%d %H:%M")
|
|
129
|
+
|
|
130
|
+
if is_expired:
|
|
131
|
+
table.add_row("Access", f"[red]Expired on {expiry_str}[/red]")
|
|
132
|
+
elif days_remaining < 0:
|
|
133
|
+
table.add_row("Access", "[green]Valid[/green]")
|
|
134
|
+
elif days_remaining <= 7:
|
|
135
|
+
table.add_row(
|
|
136
|
+
"Access",
|
|
137
|
+
f"[yellow]Expires in {days_remaining} days ({expiry_str})[/yellow]",
|
|
138
|
+
)
|
|
139
|
+
else:
|
|
140
|
+
table.add_row(
|
|
141
|
+
"Access",
|
|
142
|
+
f"[green]Valid until {expiry_str} ({days_remaining} days left)[/green]",
|
|
143
|
+
)
|
|
144
|
+
except (ValueError, TypeError):
|
|
145
|
+
table.add_row("Access", "Unknown")
|
|
146
|
+
else:
|
|
147
|
+
table.add_row("Access", "Not available")
|
|
148
|
+
|
|
149
|
+
self.console.print(table)
|
|
150
|
+
|
|
151
|
+
def run(self) -> None:
|
|
152
|
+
"""Main entry point for the interactive CLI."""
|
|
153
|
+
self._print_header("GoCardless Bank Manager")
|
|
154
|
+
|
|
155
|
+
while True:
|
|
156
|
+
action = questionary.select(
|
|
157
|
+
"What would you like to do?",
|
|
158
|
+
choices=[
|
|
159
|
+
questionary.Choice("List accounts", value="list"),
|
|
160
|
+
questionary.Choice("Add account", value="add"),
|
|
161
|
+
questionary.Choice("List banks (browse)", value="banks"),
|
|
162
|
+
questionary.Choice("Exit", value="exit"),
|
|
163
|
+
],
|
|
164
|
+
pointer=">",
|
|
165
|
+
).ask()
|
|
166
|
+
|
|
167
|
+
if action is None or action == "exit":
|
|
168
|
+
self.console.print("\n[dim]Goodbye![/dim]")
|
|
169
|
+
break
|
|
170
|
+
|
|
171
|
+
try:
|
|
172
|
+
if action == "list":
|
|
173
|
+
self.list_accounts_interactive()
|
|
174
|
+
elif action == "add":
|
|
175
|
+
self.add_account_interactive()
|
|
176
|
+
elif action == "banks":
|
|
177
|
+
self.list_banks_interactive()
|
|
178
|
+
except KeyboardInterrupt:
|
|
179
|
+
self.console.print("\n[dim]Cancelled[/dim]")
|
|
180
|
+
continue
|
|
181
|
+
except Exception as e:
|
|
182
|
+
self._print_error(f"Error: {e}")
|
|
183
|
+
continue
|
|
184
|
+
|
|
185
|
+
self.console.print()
|
|
186
|
+
continue_choice = questionary.select(
|
|
187
|
+
"What next?",
|
|
188
|
+
choices=[
|
|
189
|
+
questionary.Choice("Continue", value="continue"),
|
|
190
|
+
questionary.Choice("Exit", value="exit"),
|
|
191
|
+
],
|
|
192
|
+
default="continue",
|
|
193
|
+
pointer=">",
|
|
194
|
+
).ask()
|
|
195
|
+
|
|
196
|
+
if continue_choice == "exit":
|
|
197
|
+
self.console.print("\n[dim]Goodbye![/dim]")
|
|
198
|
+
break
|
|
199
|
+
|
|
200
|
+
def list_accounts_interactive(self) -> None:
|
|
201
|
+
"""List all connected accounts with arrow-key selection."""
|
|
202
|
+
self._print_header("Connected Accounts")
|
|
203
|
+
|
|
204
|
+
accounts = self.client.list_accounts()
|
|
205
|
+
|
|
206
|
+
if not accounts:
|
|
207
|
+
self.console.print("[dim]No accounts found.[/dim]")
|
|
208
|
+
return
|
|
209
|
+
|
|
210
|
+
account_map: dict[str, dict] = {}
|
|
211
|
+
choices: list[str] = []
|
|
212
|
+
for acc in accounts:
|
|
213
|
+
iban = acc.get("iban", "no-iban")
|
|
214
|
+
name = acc.get("name", "no-name")
|
|
215
|
+
institution = acc.get("institution_id", "unknown")
|
|
216
|
+
expiry_status = self._format_expiry_status(acc)
|
|
217
|
+
display = f"{institution} - {name} ({iban}){expiry_status}"
|
|
218
|
+
account_map[display] = acc
|
|
219
|
+
choices.append(display)
|
|
220
|
+
|
|
221
|
+
choices.append("Back")
|
|
222
|
+
|
|
223
|
+
selected = questionary.select(
|
|
224
|
+
"Select an account:",
|
|
225
|
+
choices=choices,
|
|
226
|
+
pointer=">",
|
|
227
|
+
).ask()
|
|
228
|
+
|
|
229
|
+
if selected is None or selected == "Back":
|
|
230
|
+
return
|
|
231
|
+
|
|
232
|
+
self._show_account_menu(account_map[selected])
|
|
233
|
+
|
|
234
|
+
def _show_account_menu(self, account: AccountInfo) -> None:
|
|
235
|
+
"""Show the action menu for a selected account."""
|
|
236
|
+
account_id = account.get("id", "unknown")
|
|
237
|
+
iban = account.get("iban", "no-iban")
|
|
238
|
+
name = account.get("name", "no-name")
|
|
239
|
+
institution = account.get("institution_id", "unknown")
|
|
240
|
+
requisition_ref = account.get("requisition_reference", "no-ref")
|
|
241
|
+
is_expired = account.get("is_expired", False)
|
|
242
|
+
|
|
243
|
+
self.console.print()
|
|
244
|
+
table = Table(box=box.ROUNDED, show_header=False, border_style="blue")
|
|
245
|
+
table.add_column("Property", style="cyan")
|
|
246
|
+
table.add_column("Value")
|
|
247
|
+
table.add_row("ID", account_id)
|
|
248
|
+
table.add_row("Name", name)
|
|
249
|
+
table.add_row("IBAN", iban)
|
|
250
|
+
table.add_row("Institution", institution)
|
|
251
|
+
table.add_row("Reference", requisition_ref)
|
|
252
|
+
self.console.print(table)
|
|
253
|
+
|
|
254
|
+
self._show_expiry_details(account)
|
|
255
|
+
self.console.print()
|
|
256
|
+
|
|
257
|
+
choices = [
|
|
258
|
+
questionary.Choice("View balance", value="balance"),
|
|
259
|
+
]
|
|
260
|
+
|
|
261
|
+
if is_expired:
|
|
262
|
+
choices.append(questionary.Choice("[Renew connection]", value="renew"))
|
|
263
|
+
|
|
264
|
+
choices.extend(
|
|
265
|
+
[
|
|
266
|
+
questionary.Choice("Delete link", value="delete"),
|
|
267
|
+
questionary.Choice("← Back", value="back"),
|
|
268
|
+
]
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
action = questionary.select(
|
|
272
|
+
"Choose an action:",
|
|
273
|
+
choices=choices,
|
|
274
|
+
pointer=">",
|
|
275
|
+
).ask()
|
|
276
|
+
|
|
277
|
+
if action == "balance":
|
|
278
|
+
self._view_balance(account_id)
|
|
279
|
+
elif action == "renew":
|
|
280
|
+
if institution:
|
|
281
|
+
self._renew_connection(requisition_ref, institution)
|
|
282
|
+
else:
|
|
283
|
+
self._print_error("Cannot renew: institution ID not found")
|
|
284
|
+
elif action == "delete":
|
|
285
|
+
self._delete_link(requisition_ref)
|
|
286
|
+
|
|
287
|
+
def _renew_connection(self, reference: str, institution_id: Optional[str]) -> None:
|
|
288
|
+
if not institution_id:
|
|
289
|
+
self._print_error("Cannot renew: institution ID not available")
|
|
290
|
+
return
|
|
291
|
+
|
|
292
|
+
if self.mock:
|
|
293
|
+
self._print_error("Mock client does not support renewing connections")
|
|
294
|
+
return
|
|
295
|
+
|
|
296
|
+
confirm = questionary.confirm(
|
|
297
|
+
"Create new authorization link to replace the expired one?",
|
|
298
|
+
default=False,
|
|
299
|
+
).ask()
|
|
300
|
+
|
|
301
|
+
if not confirm:
|
|
302
|
+
self._print_info("Renewal cancelled")
|
|
303
|
+
return
|
|
304
|
+
|
|
305
|
+
try:
|
|
306
|
+
old_req = self.client.find_requisition_by_reference(reference)
|
|
307
|
+
link = self.client.create_bank_link(reference, institution_id)
|
|
308
|
+
|
|
309
|
+
if link:
|
|
310
|
+
self._print_success("New bank link created!")
|
|
311
|
+
self.console.print()
|
|
312
|
+
self.console.print(
|
|
313
|
+
Panel(
|
|
314
|
+
f"[bold]Authorization Link:[/bold]\n\n{link}",
|
|
315
|
+
box=box.ROUNDED,
|
|
316
|
+
border_style="green",
|
|
317
|
+
)
|
|
318
|
+
)
|
|
319
|
+
self.console.print()
|
|
320
|
+
self.console.print(
|
|
321
|
+
"[dim]Open this link in your browser to authorize the connection.[/dim]"
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
if old_req:
|
|
325
|
+
self.console.print()
|
|
326
|
+
delete_old = questionary.confirm(
|
|
327
|
+
"Have you authorized the new link? Delete the old expired one now?",
|
|
328
|
+
default=False,
|
|
329
|
+
).ask()
|
|
330
|
+
if delete_old:
|
|
331
|
+
self.client.delete_requisition(old_req.id)
|
|
332
|
+
self._print_success("Old expired link deleted")
|
|
333
|
+
else:
|
|
334
|
+
self._print_error("Could not create new bank link")
|
|
335
|
+
|
|
336
|
+
except Exception as e:
|
|
337
|
+
self._print_error(f"Error renewing connection: {e}")
|
|
338
|
+
|
|
339
|
+
def _view_balance(self, account_id: str) -> None:
|
|
340
|
+
"""View balance for an account."""
|
|
341
|
+
try:
|
|
342
|
+
balances = self.client.get_account_balances(account_id)
|
|
343
|
+
|
|
344
|
+
self.console.print()
|
|
345
|
+
table = Table(
|
|
346
|
+
title="Account Balances",
|
|
347
|
+
box=box.ROUNDED,
|
|
348
|
+
border_style="green",
|
|
349
|
+
)
|
|
350
|
+
table.add_column("Type", style="cyan")
|
|
351
|
+
table.add_column("Amount", style="green")
|
|
352
|
+
table.add_column("Currency")
|
|
353
|
+
|
|
354
|
+
for balance in balances.balances:
|
|
355
|
+
amount = balance.balance_amount.amount
|
|
356
|
+
currency = balance.balance_amount.currency
|
|
357
|
+
table.add_row(balance.balance_type, amount, currency)
|
|
358
|
+
|
|
359
|
+
self.console.print(table)
|
|
360
|
+
|
|
361
|
+
except Exception as e:
|
|
362
|
+
self._print_error(f"Could not fetch balance: {e}")
|
|
363
|
+
|
|
364
|
+
def _delete_link(self, reference: str) -> None:
|
|
365
|
+
"""Delete a bank link by reference."""
|
|
366
|
+
if self.mock:
|
|
367
|
+
self._print_error("Mock client does not support deleting links")
|
|
368
|
+
return
|
|
369
|
+
|
|
370
|
+
confirm = questionary.confirm(
|
|
371
|
+
f"Are you sure you want to delete the link '{reference}'?",
|
|
372
|
+
default=False,
|
|
373
|
+
).ask()
|
|
374
|
+
|
|
375
|
+
if not confirm:
|
|
376
|
+
self._print_info("Deletion cancelled")
|
|
377
|
+
return
|
|
378
|
+
|
|
379
|
+
try:
|
|
380
|
+
req = self.client.find_requisition_by_reference(reference)
|
|
381
|
+
if req:
|
|
382
|
+
self.client.delete_requisition(req.id)
|
|
383
|
+
self._print_success(f"Deleted link '{reference}'")
|
|
384
|
+
else:
|
|
385
|
+
self._print_error(f"No link found with reference '{reference}'")
|
|
386
|
+
except Exception as e:
|
|
387
|
+
self._print_error(f"Could not delete link: {e}")
|
|
388
|
+
|
|
389
|
+
def list_banks_interactive(self) -> None:
|
|
390
|
+
"""List and browse available banks by country."""
|
|
391
|
+
self._print_header("Browse Banks")
|
|
392
|
+
|
|
393
|
+
country = self._select_country()
|
|
394
|
+
if not country:
|
|
395
|
+
return
|
|
396
|
+
|
|
397
|
+
self._print_info(f"Loading banks for {country}...")
|
|
398
|
+
|
|
399
|
+
try:
|
|
400
|
+
institutions = self.client.get_institutions(country)
|
|
401
|
+
except Exception as e:
|
|
402
|
+
self._print_error(f"Could not load banks: {e}")
|
|
403
|
+
return
|
|
404
|
+
|
|
405
|
+
if not institutions:
|
|
406
|
+
self._print_error(f"No banks found for country {country}")
|
|
407
|
+
return
|
|
408
|
+
|
|
409
|
+
choices = []
|
|
410
|
+
for inst in institutions:
|
|
411
|
+
display = f"{inst.name}"
|
|
412
|
+
if inst.bic:
|
|
413
|
+
display += f" (BIC: {inst.bic})"
|
|
414
|
+
choices.append(questionary.Choice(display, value=inst))
|
|
415
|
+
|
|
416
|
+
choices.append(questionary.Choice("← Back", value=None))
|
|
417
|
+
|
|
418
|
+
self.console.print(f"\n[dim]Found {len(institutions)} banks.[/dim]\n")
|
|
419
|
+
|
|
420
|
+
selected = questionary.select(
|
|
421
|
+
"Select a bank to view details:",
|
|
422
|
+
choices=choices,
|
|
423
|
+
pointer=">",
|
|
424
|
+
).ask()
|
|
425
|
+
|
|
426
|
+
if selected is None:
|
|
427
|
+
return
|
|
428
|
+
|
|
429
|
+
self._show_bank_details(selected)
|
|
430
|
+
|
|
431
|
+
def _show_bank_details(self, institution: Institution) -> None:
|
|
432
|
+
"""Show details for a selected bank."""
|
|
433
|
+
self.console.print()
|
|
434
|
+
table = Table(box=box.ROUNDED, show_header=False, border_style="blue")
|
|
435
|
+
table.add_column("Property", style="cyan")
|
|
436
|
+
table.add_column("Value")
|
|
437
|
+
table.add_row("Name", institution.name)
|
|
438
|
+
table.add_row("ID", institution.id)
|
|
439
|
+
table.add_row("BIC", institution.bic or "N/A")
|
|
440
|
+
table.add_row(
|
|
441
|
+
"Countries",
|
|
442
|
+
", ".join(institution.countries) if institution.countries else "N/A",
|
|
443
|
+
)
|
|
444
|
+
table.add_row("Transaction Days", institution.transaction_total_days or "N/A")
|
|
445
|
+
self.console.print(table)
|
|
446
|
+
self.console.print()
|
|
447
|
+
|
|
448
|
+
action = questionary.select(
|
|
449
|
+
"What would you like to do?",
|
|
450
|
+
choices=[
|
|
451
|
+
questionary.Choice("Create link for this bank", value="link"),
|
|
452
|
+
questionary.Choice("← Back to bank list", value="back"),
|
|
453
|
+
],
|
|
454
|
+
pointer=">",
|
|
455
|
+
).ask()
|
|
456
|
+
|
|
457
|
+
if action == "link":
|
|
458
|
+
reference = questionary.text(
|
|
459
|
+
"Enter a unique reference for this connection:",
|
|
460
|
+
default="my-bank",
|
|
461
|
+
).ask()
|
|
462
|
+
if reference:
|
|
463
|
+
self._create_bank_link(reference, institution.id)
|
|
464
|
+
|
|
465
|
+
def add_account_interactive(self) -> None:
|
|
466
|
+
"""Add a new bank account interactively."""
|
|
467
|
+
self._print_header("Add New Account")
|
|
468
|
+
|
|
469
|
+
if self.mock:
|
|
470
|
+
self._print_error("Mock client does not support adding accounts")
|
|
471
|
+
return
|
|
472
|
+
|
|
473
|
+
while True:
|
|
474
|
+
country = self._select_country()
|
|
475
|
+
if not country:
|
|
476
|
+
return
|
|
477
|
+
|
|
478
|
+
institution = self._select_bank(country)
|
|
479
|
+
if institution is None:
|
|
480
|
+
return
|
|
481
|
+
|
|
482
|
+
reference = questionary.text(
|
|
483
|
+
"Enter a unique reference for this connection:",
|
|
484
|
+
default="my-bank",
|
|
485
|
+
).ask()
|
|
486
|
+
|
|
487
|
+
if not reference:
|
|
488
|
+
self._print_info("Cancelled")
|
|
489
|
+
return
|
|
490
|
+
|
|
491
|
+
self._create_bank_link(reference, institution.id)
|
|
492
|
+
return
|
|
493
|
+
|
|
494
|
+
def _select_country(self) -> Optional[str]:
|
|
495
|
+
"""Let user select a country from common options."""
|
|
496
|
+
country_map = {
|
|
497
|
+
"United Kingdom": "GB",
|
|
498
|
+
"France": "FR",
|
|
499
|
+
"Germany": "DE",
|
|
500
|
+
"Spain": "ES",
|
|
501
|
+
"Italy": "IT",
|
|
502
|
+
"Netherlands": "NL",
|
|
503
|
+
"Belgium": "BE",
|
|
504
|
+
"Portugal": "PT",
|
|
505
|
+
"Austria": "AT",
|
|
506
|
+
"Ireland": "IE",
|
|
507
|
+
"Other (enter code)": "other",
|
|
508
|
+
"Back": None,
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
selected = questionary.autocomplete(
|
|
512
|
+
"Select your country (type to filter):",
|
|
513
|
+
choices=list(country_map.keys()),
|
|
514
|
+
ignore_case=True,
|
|
515
|
+
).ask()
|
|
516
|
+
|
|
517
|
+
if selected is None:
|
|
518
|
+
return None
|
|
519
|
+
|
|
520
|
+
value = country_map.get(selected)
|
|
521
|
+
if value is None:
|
|
522
|
+
return None
|
|
523
|
+
|
|
524
|
+
if value == "other":
|
|
525
|
+
country = questionary.text(
|
|
526
|
+
"Enter 2-letter country code (e.g., US, CA, AU):",
|
|
527
|
+
).ask()
|
|
528
|
+
return country.upper() if country else None
|
|
529
|
+
|
|
530
|
+
return value
|
|
531
|
+
|
|
532
|
+
def _select_bank(self, country: str) -> Optional[Institution]:
|
|
533
|
+
"""Search and select a bank for the given country."""
|
|
534
|
+
self.console.print(f"\n[dim]Loading banks for {country}...[/dim]")
|
|
535
|
+
|
|
536
|
+
try:
|
|
537
|
+
institutions = self.client.get_institutions(country)
|
|
538
|
+
except Exception as e:
|
|
539
|
+
self._print_error(f"Could not load banks: {e}")
|
|
540
|
+
return None
|
|
541
|
+
|
|
542
|
+
if not institutions:
|
|
543
|
+
self._print_error(f"No banks found for country {country}")
|
|
544
|
+
return None
|
|
545
|
+
|
|
546
|
+
bank_map: dict[str, Institution] = {}
|
|
547
|
+
choices: list[str] = []
|
|
548
|
+
for inst in institutions:
|
|
549
|
+
display = f"{inst.name}"
|
|
550
|
+
if inst.bic:
|
|
551
|
+
display += f" (BIC: {inst.bic})"
|
|
552
|
+
bank_map[display] = inst
|
|
553
|
+
choices.append(display)
|
|
554
|
+
|
|
555
|
+
choices.append("Back to country selection")
|
|
556
|
+
|
|
557
|
+
self.console.print(f"\n[dim]Loaded {len(institutions)} banks[/dim]\n")
|
|
558
|
+
|
|
559
|
+
selected = questionary.autocomplete(
|
|
560
|
+
"Select your bank (type to filter by name or BIC):",
|
|
561
|
+
choices=choices,
|
|
562
|
+
ignore_case=True,
|
|
563
|
+
).ask()
|
|
564
|
+
|
|
565
|
+
if selected is None or selected == "Back to country selection":
|
|
566
|
+
return None
|
|
567
|
+
|
|
568
|
+
return bank_map.get(selected)
|
|
569
|
+
|
|
570
|
+
def _create_bank_link(self, reference: str, bank_id: str) -> None:
|
|
571
|
+
"""Create a bank link and display the authorization URL."""
|
|
572
|
+
try:
|
|
573
|
+
existing = self.client.find_requisition_by_reference(reference)
|
|
574
|
+
if existing:
|
|
575
|
+
self._print_error(f"A link with reference '{reference}' already exists")
|
|
576
|
+
return
|
|
577
|
+
|
|
578
|
+
link = self.client.create_bank_link(reference, bank_id)
|
|
579
|
+
|
|
580
|
+
if link:
|
|
581
|
+
self._print_success("Bank link created successfully!")
|
|
582
|
+
self.console.print()
|
|
583
|
+
self.console.print(
|
|
584
|
+
Panel(
|
|
585
|
+
f"[bold]Authorization Link:[/bold]\n\n{link}",
|
|
586
|
+
box=box.ROUNDED,
|
|
587
|
+
border_style="green",
|
|
588
|
+
)
|
|
589
|
+
)
|
|
590
|
+
self.console.print()
|
|
591
|
+
self.console.print(
|
|
592
|
+
"[dim]Open this link in your browser to authorize the connection.[/dim]"
|
|
593
|
+
)
|
|
594
|
+
self.console.print(
|
|
595
|
+
"[dim]After authorization, your account will appear in the list.[/dim]"
|
|
596
|
+
)
|
|
597
|
+
else:
|
|
598
|
+
self._print_error("Could not create bank link")
|
|
599
|
+
|
|
600
|
+
except Exception as e:
|
|
601
|
+
self._print_error(f"Error creating link: {e}")
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def main():
|
|
605
|
+
"""Entry point for the interactive CLI."""
|
|
606
|
+
parser = argparse.ArgumentParser(
|
|
607
|
+
description="Interactive CLI for GoCardless Bank Account Data",
|
|
39
608
|
)
|
|
40
609
|
parser.add_argument(
|
|
41
|
-
"--
|
|
610
|
+
"--secret-id",
|
|
42
611
|
default=os.getenv("GOCARDLESS_SECRET_ID"),
|
|
43
612
|
help="API secret ID (defaults to env var GOCARDLESS_SECRET_ID)",
|
|
44
613
|
)
|
|
45
614
|
parser.add_argument(
|
|
46
|
-
"--
|
|
615
|
+
"--secret-key",
|
|
47
616
|
default=os.getenv("GOCARDLESS_SECRET_KEY"),
|
|
48
617
|
help="API secret key (defaults to env var GOCARDLESS_SECRET_KEY)",
|
|
49
618
|
)
|
|
50
619
|
parser.add_argument(
|
|
51
|
-
"--
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
"--reference", default="beancount", help="Unique reference for bank linking"
|
|
55
|
-
)
|
|
56
|
-
parser.add_argument("--bank", help="Bank ID for linking")
|
|
57
|
-
parser.add_argument("--account", help="Account ID for operations")
|
|
58
|
-
parser.add_argument("--cache", action="store_true", help="Enable caching")
|
|
59
|
-
parser.add_argument(
|
|
60
|
-
"--cache_backend", default="sqlite", help="Cache backend (sqlite, memory, etc.)"
|
|
61
|
-
)
|
|
62
|
-
parser.add_argument(
|
|
63
|
-
"--cache_expire",
|
|
64
|
-
type=int,
|
|
65
|
-
default=0,
|
|
66
|
-
help="Cache expiration in seconds (0 = never expire)",
|
|
620
|
+
"--mock",
|
|
621
|
+
action="store_true",
|
|
622
|
+
help="Use mock client with fixture data (for testing)",
|
|
67
623
|
)
|
|
68
624
|
parser.add_argument(
|
|
69
|
-
"--
|
|
625
|
+
"--env-file",
|
|
626
|
+
help="Path to a .env file to load environment variables from",
|
|
70
627
|
)
|
|
71
628
|
|
|
72
|
-
|
|
629
|
+
args = parser.parse_args()
|
|
73
630
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
)
|
|
82
|
-
sys.exit(1)
|
|
83
|
-
|
|
84
|
-
try:
|
|
85
|
-
logger.debug("Initializing GoCardlessClient")
|
|
86
|
-
|
|
87
|
-
cache_options = (
|
|
88
|
-
{
|
|
89
|
-
"backend": args.cache_backend,
|
|
90
|
-
"expire_after": args.cache_expire,
|
|
91
|
-
"cache_name": args.cache_name,
|
|
92
|
-
}
|
|
93
|
-
if args.cache
|
|
94
|
-
else {}
|
|
95
|
-
)
|
|
96
|
-
|
|
97
|
-
client = GoCardlessClient(args.secret_id, args.secret_key, cache_options)
|
|
98
|
-
|
|
99
|
-
if args.mode == "list_banks":
|
|
100
|
-
banks = client.list_banks(args.country)
|
|
101
|
-
for bank in banks:
|
|
102
|
-
logger.info(bank)
|
|
103
|
-
elif args.mode == "create_link":
|
|
104
|
-
if not args.bank:
|
|
105
|
-
logger.error("Error: --bank is required for create_link")
|
|
106
|
-
sys.exit(1)
|
|
107
|
-
link = client.create_bank_link(args.reference, args.bank)
|
|
108
|
-
if link:
|
|
109
|
-
logger.info(f"Bank link created: {link}")
|
|
110
|
-
else:
|
|
111
|
-
logger.info(f"Link already exists for reference '{args.reference}'")
|
|
112
|
-
elif args.mode == "list_accounts":
|
|
113
|
-
accounts = client.list_accounts()
|
|
114
|
-
for i, account in enumerate(accounts, 1):
|
|
115
|
-
display_account(i, account)
|
|
116
|
-
elif args.mode == "delete_link":
|
|
117
|
-
req = client.find_requisition_by_reference(args.reference)
|
|
118
|
-
if req:
|
|
119
|
-
client.delete_requisition(req.id)
|
|
120
|
-
logger.info(f"Deleted requisition '{args.reference}'")
|
|
121
|
-
else:
|
|
122
|
-
logger.error(f"No requisition found with reference '{args.reference}'")
|
|
123
|
-
sys.exit(1)
|
|
124
|
-
elif args.mode == "balance":
|
|
125
|
-
if not args.account:
|
|
126
|
-
logger.error("Error: --account is required for balance")
|
|
127
|
-
sys.exit(1)
|
|
128
|
-
balances = client.get_account_balances(args.account)
|
|
129
|
-
for balance in balances.balances:
|
|
130
|
-
logger.info(
|
|
131
|
-
f"{balance.balance_type}: {balance.balance_amount.amount} {balance.balance_amount.currency}"
|
|
132
|
-
)
|
|
133
|
-
|
|
134
|
-
except Exception as e:
|
|
135
|
-
logger.error(f"Error: {e}")
|
|
136
|
-
sys.exit(1)
|
|
631
|
+
cli = CLI(
|
|
632
|
+
secret_id=args.secret_id,
|
|
633
|
+
secret_key=args.secret_key,
|
|
634
|
+
mock=args.mock,
|
|
635
|
+
env_file=args.env_file,
|
|
636
|
+
)
|
|
637
|
+
cli.run()
|
|
137
638
|
|
|
138
639
|
|
|
139
640
|
if __name__ == "__main__":
|