beancount-gocardless 0.1.10__tar.gz → 0.1.11__tar.gz

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.
Files changed (24) hide show
  1. {beancount_gocardless-0.1.10 → beancount_gocardless-0.1.11}/PKG-INFO +1 -1
  2. {beancount_gocardless-0.1.10 → beancount_gocardless-0.1.11}/pyproject.toml +1 -1
  3. {beancount_gocardless-0.1.10 → beancount_gocardless-0.1.11}/src/beancount_gocardless/cli.py +47 -1
  4. {beancount_gocardless-0.1.10 → beancount_gocardless-0.1.11}/src/beancount_gocardless/client.py +7 -3
  5. {beancount_gocardless-0.1.10 → beancount_gocardless-0.1.11}/src/beancount_gocardless/models.py +2 -2
  6. {beancount_gocardless-0.1.10 → beancount_gocardless-0.1.11}/src/beancount_gocardless/tui.py +118 -238
  7. {beancount_gocardless-0.1.10 → beancount_gocardless-0.1.11}/.github/workflows/publish.yml +0 -0
  8. {beancount_gocardless-0.1.10 → beancount_gocardless-0.1.11}/.gitignore +0 -0
  9. {beancount_gocardless-0.1.10 → beancount_gocardless-0.1.11}/.pre-commit-config.yaml +0 -0
  10. {beancount_gocardless-0.1.10 → beancount_gocardless-0.1.11}/.readthedocs.yaml +0 -0
  11. {beancount_gocardless-0.1.10 → beancount_gocardless-0.1.11}/LICENSE +0 -0
  12. {beancount_gocardless-0.1.10 → beancount_gocardless-0.1.11}/README.md +0 -0
  13. {beancount_gocardless-0.1.10 → beancount_gocardless-0.1.11}/docs/Makefile +0 -0
  14. {beancount_gocardless-0.1.10 → beancount_gocardless-0.1.11}/docs/cli.rst +0 -0
  15. {beancount_gocardless-0.1.10 → beancount_gocardless-0.1.11}/docs/client.rst +0 -0
  16. {beancount_gocardless-0.1.10 → beancount_gocardless-0.1.11}/docs/conf.py +0 -0
  17. {beancount_gocardless-0.1.10 → beancount_gocardless-0.1.11}/docs/importer.rst +0 -0
  18. {beancount_gocardless-0.1.10 → beancount_gocardless-0.1.11}/docs/index.rst +0 -0
  19. {beancount_gocardless-0.1.10 → beancount_gocardless-0.1.11}/docs/make.bat +0 -0
  20. {beancount_gocardless-0.1.10 → beancount_gocardless-0.1.11}/src/beancount_gocardless/__init__.py +0 -0
  21. {beancount_gocardless-0.1.10 → beancount_gocardless-0.1.11}/src/beancount_gocardless/importer.py +0 -0
  22. {beancount_gocardless-0.1.10 → beancount_gocardless-0.1.11}/src/beancount_gocardless/openapi/swagger.json +0 -0
  23. {beancount_gocardless-0.1.10 → beancount_gocardless-0.1.11}/tests/__init__.py +0 -0
  24. {beancount_gocardless-0.1.10 → beancount_gocardless-0.1.11}/tests/test_tui.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: beancount-gocardless
3
- Version: 0.1.10
3
+ Version: 0.1.11
4
4
  License-Expression: Unlicense
5
5
  License-File: LICENSE
6
6
  Requires-Python: <4,>=3.12
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "beancount-gocardless"
3
- version = "0.1.10"
3
+ version = "0.1.11"
4
4
  description = ""
5
5
  authors = []
6
6
  readme = "README.md"
@@ -4,6 +4,7 @@ import os
4
4
  import logging
5
5
 
6
6
  from beancount_gocardless.models import AccountInfo
7
+ from beancount_gocardless.client import GoCardlessClient
7
8
 
8
9
 
9
10
  logging.basicConfig(level=os.environ.get("LOGLEVEL", logging.INFO))
@@ -83,7 +84,52 @@ def main():
83
84
  try:
84
85
  logger.debug("Initializing GoCardlessClient")
85
86
 
86
- # TODO: Implement client initialization with cache options
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
+ )
87
133
 
88
134
  except Exception as e:
89
135
  logger.error(f"Error: {e}")
@@ -239,9 +239,9 @@ class GoCardlessClient:
239
239
  """Get institutions for a country"""
240
240
  logger.debug("Getting institutions for country %s", country)
241
241
  params = {"country": country} if country else {}
242
- data = self.get("/institutions/", params=params)
243
- logger.debug("Fetched %d institutions", len(data))
244
- return [Institution(**inst) for inst in data]
242
+ institutions_data = self.get("/institutions/", params=params)
243
+ logger.debug("Fetched %d institutions", len(institutions_data))
244
+ return [Institution(**inst) for inst in institutions_data]
245
245
 
246
246
  def get_institution(self, institution_id: str) -> Institution:
247
247
  """Get specific institution"""
@@ -422,3 +422,7 @@ class GoCardlessClient:
422
422
  # Skip accounts that can't be accessed
423
423
  continue
424
424
  return accounts
425
+
426
+ def list_accounts(self) -> List[AccountInfo]:
427
+ """Alias for get_all_accounts"""
428
+ return self.get_all_accounts()
@@ -351,7 +351,7 @@ class Requisition(BaseModel):
351
351
  id: str
352
352
  created: str
353
353
  redirect: str
354
- status: StatusEnum
354
+ status: str
355
355
  institution_id: str
356
356
  agreement: Optional[str] = None
357
357
  reference: str
@@ -376,7 +376,7 @@ class SpectacularRequisition(BaseModel):
376
376
  id: str
377
377
  created: str
378
378
  redirect: str
379
- status: StatusEnum
379
+ status: str
380
380
  institution_id: str
381
381
  agreement: Optional[str] = None
382
382
  reference: str
@@ -113,7 +113,7 @@ class BanksView(BaseSubView):
113
113
  ]
114
114
 
115
115
  self.banks_table.update(
116
- f"Loading accounts... {name_filter} {country_code} {len(self.all_banks)}"
116
+ f"Loading banks... {name_filter} {country_code} {len(self.all_banks)}"
117
117
  )
118
118
 
119
119
  name_filtered = [
@@ -180,6 +180,8 @@ class AccountsView(BaseSubView):
180
180
  "Loading accounts...", id="accounts_status_message"
181
181
  )
182
182
  self.accounts_table = Static("", id="accounts_table")
183
+ self.pending_delete_ref = None
184
+ self.accounts_list = []
183
185
 
184
186
  def compose_content(self) -> ComposeResult:
185
187
  yield Label("Linked Accounts", classes="view_title")
@@ -189,6 +191,23 @@ class AccountsView(BaseSubView):
189
191
  id="accounts_scrollable_area",
190
192
  classes="horizontal-scroll",
191
193
  )
194
+ yield Vertical(
195
+ Label("Select Account by # and Delete Link"),
196
+ Input(placeholder="Enter account #", id="select_account_input"),
197
+ Button("Select", id="select_account_button"),
198
+ Input(placeholder="Reference", id="delete_ref_input", disabled=True),
199
+ Button(
200
+ "Delete Link", id="delete_link_button", variant="error", disabled=True
201
+ ),
202
+ Button(
203
+ "Confirm Delete",
204
+ id="confirm_delete_button",
205
+ variant="error",
206
+ disabled=True,
207
+ ),
208
+ Button("Cancel", id="cancel_delete_button", disabled=True),
209
+ id="delete_section",
210
+ )
192
211
  yield Button("Back to Menu", id="back_to_menu", classes="back_button")
193
212
 
194
213
  # Override the base class compose to avoid duplicate back button
@@ -206,6 +225,7 @@ class AccountsView(BaseSubView):
206
225
  try:
207
226
  self.status_message.update("Loading accounts...")
208
227
  accounts = self.client.get_all_accounts()
228
+ self.accounts_list = accounts
209
229
  if not accounts:
210
230
  self.accounts_table.update("No accounts found.")
211
231
  self.status_message.update("")
@@ -215,7 +235,7 @@ class AccountsView(BaseSubView):
215
235
  table = Table(title=None, expand=True, min_width=100)
216
236
  table.add_column("#", style="dim", width=3)
217
237
  table.add_column("Bank", overflow="ellipsis", min_width=20)
218
- table.add_column("Name", overflow="crop", min_width=20)
238
+ table.add_column("Name", overflow="ellipsis", min_width=20)
219
239
  table.add_column("ID", overflow="ellipsis", min_width=20)
220
240
  table.add_column("IBAN", overflow="ellipsis", min_width=20)
221
241
  table.add_column("Reference", overflow="ellipsis", min_width=15)
@@ -224,14 +244,12 @@ class AccountsView(BaseSubView):
224
244
 
225
245
  # Format and add accounts to the table
226
246
  for idx, account in enumerate(accounts, start=1):
227
- # import pdb
228
- # pdb.set_trace()
229
247
  # Get values with fallbacks for any missing keys
230
248
  institution_id = account.get("institution_id", "N/A")
231
249
  name = account.get("name", account.get("owner_name", "N/A"))
232
250
  iban = account.get("iban", "N/A")
233
- account_id = account.get("account_id", "N/A")
234
- reference = account.get("reference", "N/A")
251
+ account_id = account.get("id", "N/A")
252
+ reference = account.get("requisition_reference", "N/A")
235
253
  status = account.get("status", "N/A")
236
254
  last_accessed = account.get("last_accessed", "")
237
255
  if last_accessed:
@@ -253,6 +271,39 @@ class AccountsView(BaseSubView):
253
271
 
254
272
  # Update the table content
255
273
  self.accounts_table.update(table)
274
+ if not accounts:
275
+ self.accounts_table.add_row(
276
+ "No accounts found.", "", "", "", "", "", "", ""
277
+ )
278
+ self.status_message.update("")
279
+ return
280
+
281
+ # Format and add accounts to the table
282
+ for idx, account in enumerate(accounts, start=1):
283
+ # Get values with fallbacks for any missing keys
284
+ institution_id = account.get("institution_id", "N/A")
285
+ name = account.get("name", account.get("owner_name", "N/A"))
286
+ iban = account.get("iban", "N/A")
287
+ account_id = account.get("id", "N/A")
288
+ reference = account.get("requisition_reference", "N/A")
289
+ status = account.get("status", "N/A")
290
+ last_accessed = account.get("last_accessed", "")
291
+ if last_accessed:
292
+ try:
293
+ last_accessed = last_accessed.split("T")[0]
294
+ except Exception:
295
+ pass
296
+
297
+ self.accounts_table.add_row(
298
+ str(idx),
299
+ institution_id,
300
+ name,
301
+ account_id,
302
+ iban,
303
+ reference,
304
+ status,
305
+ last_accessed,
306
+ )
256
307
  self.status_message.update("")
257
308
 
258
309
  except HttpServiceException as e:
@@ -265,245 +316,74 @@ class AccountsView(BaseSubView):
265
316
  if event.button.id == "back_to_menu":
266
317
  self.post_message(ActionMessage("show_menu"))
267
318
 
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
- yield Button("Back to Menu", id="back_to_menu", classes="back_button")
298
-
299
- async def on_mount(self) -> None:
300
- # Query elements only after they are mounted
301
- content_area = self.query_one("#balance_content_area", Vertical)
302
- status_message = self.query_one("#balance_status_message", Static)
303
- status_message.update(
304
- "Loading accounts for selection..."
305
- ) # Set initial operational message
306
- await self.load_accounts_for_selection(content_area, status_message)
307
-
308
- async def load_accounts_for_selection(
309
- self, content_area: Vertical, status_message: Static
310
- ):
311
- try:
312
- # status_message.update("Loading accounts...") # Message set by on_mount or can be refined here
313
- self.accounts_data = self.client.get_all_accounts()
314
- await (
315
- content_area.remove_children()
316
- ) # Clear "Loading..." or previous content from content_area
317
-
318
- if not self.accounts_data:
319
- await content_area.mount(
320
- Static("No accounts linked. Cannot fetch balance.")
321
- )
322
- status_message.update(
323
- "No accounts available to display."
324
- ) # Update persistent status
325
- return
326
-
327
- table = Table(title="Select Account by Index for Balance", expand=True)
328
- table.add_column("#", style="dim")
329
- table.add_column("Name", overflow="fold")
330
- table.add_column("Institution ID", overflow="fold")
331
- for idx, a in enumerate(self.accounts_data):
332
- table.add_row(str(idx), a["name"], a["institution_id"])
333
-
334
- await content_area.mount(table) # Mount Table widget directly
335
- idx_input = Input(
336
- placeholder="Enter account # and press Enter", id="acct_idx_input"
337
- )
338
- await content_area.mount(idx_input)
339
- idx_input.focus()
340
- status_message.update(
341
- "Please select an account from the list."
342
- ) # Clear or update status message
343
- except HttpServiceException as e: # Make sure HttpServiceException is defined
344
- status_message.update(
345
- Text(f"Error loading accounts: {e}", style="bold red")
346
- )
347
- await content_area.remove_children()
348
- await content_area.mount(
349
- Static(
350
- Text(
351
- "Failed to load accounts. Check connection or try again later.",
352
- style="yellow",
353
- )
354
- )
355
- )
356
- except Exception as e:
357
- status_message.update(Text(f"Unexpected error: {e}", style="bold red"))
358
- await content_area.remove_children()
359
- await content_area.mount(
360
- Static(
361
- Text(
362
- "An unexpected error occurred while loading accounts.",
363
- style="yellow",
364
- )
365
- )
366
- )
367
-
368
- async def on_input_submitted(self, event: Input.Submitted) -> None:
369
- if event.input.id == "acct_idx_input":
370
- content_area = self.query_one("#balance_content_area", Vertical)
371
- # status_message is now persistent and queried once
372
- status_message = self.query_one("#balance_status_message", Static)
373
-
374
- await (
375
- content_area.remove_children()
376
- ) # Clear account list and input from content_area
377
-
319
+ async def on_button_pressed(self, event: Button.Pressed) -> None:
320
+ """Handle button presses within this view."""
321
+ if event.button.id == "back_to_menu":
322
+ self.post_message(ActionMessage("show_menu"))
323
+ elif event.button.id == "select_account_button":
324
+ select_input = self.query_one("#select_account_input", Input)
378
325
  try:
379
- idx = int(event.value)
380
- if not (0 <= idx < len(self.accounts_data)):
381
- # Display temporary error message in content_area
382
- await content_area.mount(
383
- Static(Text("Invalid account index.", style="bold red"))
326
+ idx = int(select_input.value.strip()) - 1 # 1-based to 0-based
327
+ if 0 <= idx < len(self.accounts_list):
328
+ reference = self.accounts_list[idx].get("requisition_reference", "")
329
+ ref_input = self.query_one("#delete_ref_input", Input)
330
+ ref_input.value = reference
331
+ delete_btn = self.query_one("#delete_link_button", Button)
332
+ delete_btn.disabled = False
333
+ self.status_message.update(
334
+ f"Selected account {idx + 1}, reference: {reference}"
384
335
  )
385
- status_message.update(
386
- Text(
387
- "Selection out of range. Please try again.", style="yellow"
388
- )
389
- )
390
- # Reload account selection UI
391
- await self.load_accounts_for_selection(content_area, status_message)
392
- return
393
-
394
- account_to_check = self.accounts_data[idx]
395
- acct_id = account_to_check["id"]
396
-
397
- status_message.update(
398
- f"Fetching balances for {account_to_check['name']} ({acct_id})..."
399
- )
400
- # Optionally, display a "Fetching..." message in content_area as well
401
- await content_area.mount(Static("Fetching balances..."))
402
-
403
- balances_data = self.client.get_account_balances(acct_id)
404
- await content_area.remove_children() # Clear "Fetching..." message
405
-
406
- table = Table(
407
- title=f"Balances for {account_to_check['name']}", expand=True
408
- )
409
- table.add_column("Type", overflow="fold")
410
- table.add_column("Amount")
411
- table.add_column("Currency")
412
- if balances_data.balances:
413
- for b in balances_data.balances:
414
- table.add_row(
415
- b.balance_type,
416
- b.balance_amount.amount,
417
- b.balance_amount.currency,
418
- )
419
336
  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
-
337
+ self.status_message.update("Invalid account number.")
428
338
  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
- )
339
+ self.status_message.update("Please enter a valid number.")
340
+ elif event.button.id == "delete_link_button":
341
+ ref_input = self.query_one("#delete_ref_input", Input)
342
+ reference = ref_input.value.strip()
343
+ if reference:
344
+ self.pending_delete_ref = reference
345
+ self.status_message.update(
346
+ Text(
347
+ f"Are you sure to delete link with reference '{reference}'?",
348
+ style="bold yellow",
453
349
  )
454
350
  )
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
- )
351
+ confirm_btn = self.query_one("#confirm_delete_button", Button)
352
+ cancel_btn = self.query_one("#cancel_delete_button", Button)
353
+ confirm_btn.disabled = False
354
+ cancel_btn.disabled = False
355
+ else:
356
+ self.status_message.update(
357
+ Text("No reference selected.", style="bold yellow")
468
358
  )
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
- yield Button("Back to Menu", id="back_to_menu", classes="back_button")
500
-
501
- async def on_mount(self) -> None:
502
- self.query_one("#bank_id_input", Input).focus()
503
-
504
- async def on_button_pressed(self, event: Button.Pressed) -> None:
505
- if event.button.id == "back_to_menu":
506
- self.post_message(ActionMessage("show_menu"))
359
+ elif event.button.id == "confirm_delete_button":
360
+ if self.pending_delete_ref:
361
+ await self.process_delete_link(self.pending_delete_ref)
362
+ select_input = self.query_one("#select_account_input", Input)
363
+ ref_input = self.query_one("#delete_ref_input", Input)
364
+ select_input.value = ""
365
+ ref_input.value = ""
366
+ delete_btn = self.query_one("#delete_link_button", Button)
367
+ delete_btn.disabled = True
368
+ self.pending_delete_ref = None
369
+ confirm_btn = self.query_one("#confirm_delete_button", Button)
370
+ cancel_btn = self.query_one("#cancel_delete_button", Button)
371
+ confirm_btn.disabled = True
372
+ cancel_btn.disabled = True
373
+ elif event.button.id == "cancel_delete_button":
374
+ self.pending_delete_ref = None
375
+ self.status_message.update("Delete cancelled.")
376
+ confirm_btn = self.query_one("#confirm_delete_button", Button)
377
+ cancel_btn = self.query_one("#cancel_delete_button", Button)
378
+ confirm_btn.disabled = True
379
+ cancel_btn.disabled = True
380
+ elif event.button.id == "cancel_delete_button":
381
+ self.pending_delete_ref = None
382
+ self.status_message.update("Delete cancelled.")
383
+ confirm_btn = self.query_one("#confirm_delete_button", Button)
384
+ cancel_btn = self.query_one("#cancel_delete_button", Button)
385
+ confirm_btn.disabled = True
386
+ cancel_btn.disabled = True
507
387
  elif event.button.id == "do_create_link_button":
508
388
  await self.process_create_link()
509
389