beancount-gocardless 0.1.9__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 (25) hide show
  1. {beancount_gocardless-0.1.9 → beancount_gocardless-0.1.11}/PKG-INFO +6 -2
  2. {beancount_gocardless-0.1.9 → beancount_gocardless-0.1.11}/README.md +2 -1
  3. {beancount_gocardless-0.1.9 → beancount_gocardless-0.1.11}/pyproject.toml +5 -1
  4. {beancount_gocardless-0.1.9 → beancount_gocardless-0.1.11}/src/beancount_gocardless/__init__.py +0 -1
  5. {beancount_gocardless-0.1.9 → beancount_gocardless-0.1.11}/src/beancount_gocardless/cli.py +44 -12
  6. {beancount_gocardless-0.1.9 → beancount_gocardless-0.1.11}/src/beancount_gocardless/client.py +7 -3
  7. {beancount_gocardless-0.1.9 → beancount_gocardless-0.1.11}/src/beancount_gocardless/importer.py +36 -32
  8. {beancount_gocardless-0.1.9 → beancount_gocardless-0.1.11}/src/beancount_gocardless/models.py +26 -3
  9. {beancount_gocardless-0.1.9 → beancount_gocardless-0.1.11}/src/beancount_gocardless/tui.py +154 -256
  10. beancount_gocardless-0.1.11/tests/test_tui.py +85 -0
  11. beancount_gocardless-0.1.9/src/beancount_gocardless/tui2.py +0 -17
  12. {beancount_gocardless-0.1.9 → beancount_gocardless-0.1.11}/.github/workflows/publish.yml +0 -0
  13. {beancount_gocardless-0.1.9 → beancount_gocardless-0.1.11}/.gitignore +0 -0
  14. {beancount_gocardless-0.1.9 → beancount_gocardless-0.1.11}/.pre-commit-config.yaml +0 -0
  15. {beancount_gocardless-0.1.9 → beancount_gocardless-0.1.11}/.readthedocs.yaml +0 -0
  16. {beancount_gocardless-0.1.9 → beancount_gocardless-0.1.11}/LICENSE +0 -0
  17. {beancount_gocardless-0.1.9 → beancount_gocardless-0.1.11}/docs/Makefile +0 -0
  18. {beancount_gocardless-0.1.9 → beancount_gocardless-0.1.11}/docs/cli.rst +0 -0
  19. {beancount_gocardless-0.1.9 → beancount_gocardless-0.1.11}/docs/client.rst +0 -0
  20. {beancount_gocardless-0.1.9 → beancount_gocardless-0.1.11}/docs/conf.py +0 -0
  21. {beancount_gocardless-0.1.9 → beancount_gocardless-0.1.11}/docs/importer.rst +0 -0
  22. {beancount_gocardless-0.1.9 → beancount_gocardless-0.1.11}/docs/index.rst +0 -0
  23. {beancount_gocardless-0.1.9 → beancount_gocardless-0.1.11}/docs/make.bat +0 -0
  24. {beancount_gocardless-0.1.9 → beancount_gocardless-0.1.11}/src/beancount_gocardless/openapi/swagger.json +0 -0
  25. {beancount_gocardless-0.1.9 → beancount_gocardless-0.1.11}/tests/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: beancount-gocardless
3
- Version: 0.1.9
3
+ Version: 0.1.11
4
4
  License-Expression: Unlicense
5
5
  License-File: LICENSE
6
6
  Requires-Python: <4,>=3.12
@@ -20,6 +20,9 @@ Requires-Dist: sphinx; extra == 'dev'
20
20
  Requires-Dist: sphinx-rtd-theme; extra == 'dev'
21
21
  Provides-Extra: lint
22
22
  Requires-Dist: ruff>=0.9.8; extra == 'lint'
23
+ Provides-Extra: test
24
+ Requires-Dist: pytest; extra == 'test'
25
+ Requires-Dist: pytest-asyncio; extra == 'test'
23
26
  Description-Content-Type: text/markdown
24
27
 
25
28
  beancount-gocardless
@@ -94,7 +97,8 @@ cache_options: # by default, no caching if cache_options is not provided
94
97
 
95
98
  accounts:
96
99
  - id: <REDACTED_UUID>
97
- asset_account: "Assets:Banks:Revolut:Checking"
100
+ asset_account: "Assets:Banks:Revolut:Checking"
101
+ transaction_types: ["booked", "pending"] # optional, defaults to both
98
102
  ```
99
103
 
100
104
  ```python
@@ -70,7 +70,8 @@ cache_options: # by default, no caching if cache_options is not provided
70
70
 
71
71
  accounts:
72
72
  - id: <REDACTED_UUID>
73
- asset_account: "Assets:Banks:Revolut:Checking"
73
+ asset_account: "Assets:Banks:Revolut:Checking"
74
+ transaction_types: ["booked", "pending"] # optional, defaults to both
74
75
  ```
75
76
 
76
77
  ```python
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "beancount-gocardless"
3
- version = "0.1.9"
3
+ version = "0.1.11"
4
4
  description = ""
5
5
  authors = []
6
6
  readme = "README.md"
@@ -33,6 +33,10 @@ dev = [
33
33
  lint = [
34
34
  "ruff>=0.9.8",
35
35
  ]
36
+ test = [
37
+ "pytest",
38
+ "pytest-asyncio",
39
+ ]
36
40
 
37
41
  [build-system]
38
42
  requires = ["hatchling"]
@@ -5,6 +5,5 @@ Generated from swagger.json to provide a clean, typed interface.
5
5
 
6
6
  from .client import GoCardlessClient
7
7
  from .importer import GoCardLessImporter
8
- from .models import *
9
8
 
10
9
  __all__ = ["GoCardlessClient", "GoCardLessImporter"]
@@ -2,8 +2,9 @@ import argparse
2
2
  import sys
3
3
  import os
4
4
  import logging
5
- from beancount_gocardless.client import GoCardlessClient
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,21 +84,52 @@ def main():
83
84
  try:
84
85
  logger.debug("Initializing GoCardlessClient")
85
86
 
86
- # Build cache options
87
- cache_options = None
88
- if args.cache:
89
- cache_options = {
90
- "cache_name": args.cache_name,
87
+ cache_options = (
88
+ {
91
89
  "backend": args.cache_backend,
92
90
  "expire_after": args.cache_expire,
93
- "old_data_on_error": True,
94
- "match_headers": False,
95
- "cache_control": False,
91
+ "cache_name": args.cache_name,
96
92
  }
93
+ if args.cache
94
+ else {}
95
+ )
97
96
 
98
- # client = GoCardlessClient(
99
- # args.secret_id, args.secret_key, cache_options=cache_options
100
- # )
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
+ )
101
133
 
102
134
  except Exception as e:
103
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()
@@ -8,7 +8,7 @@ from beancount.core import amount, data, flags
8
8
  from beancount.core.number import D
9
9
 
10
10
  from .client import GoCardlessClient
11
- from .models import BankTransaction
11
+ from .models import BankTransaction, GoCardlessConfig
12
12
 
13
13
  logger = logging.getLogger(__name__)
14
14
 
@@ -34,14 +34,14 @@ class GoCardLessImporter(beangulp.Importer):
34
34
  An importer for GoCardless API with improved structure and extensibility.
35
35
 
36
36
  Attributes:
37
- config (Optional[Dict[str, Any]]): Configuration loaded from the YAML file.
37
+ config (Optional[GoCardlessConfig]): Configuration loaded from the YAML file.
38
38
  _client (Optional[GoCardlessClient]): Instance of the GoCardless API client.
39
39
  """
40
40
 
41
41
  def __init__(self) -> None:
42
42
  """Initialize the GoCardLessImporter."""
43
43
  logger.debug("Initializing GoCardLessImporter")
44
- self.config: Optional[Dict[str, Any]] = None
44
+ self.config: Optional[GoCardlessConfig] = None
45
45
  self._client: Optional[GoCardlessClient] = None
46
46
 
47
47
  @property
@@ -59,9 +59,9 @@ class GoCardLessImporter(beangulp.Importer):
59
59
  if not self.config:
60
60
  raise ValueError("Config not loaded. Call load_config() first.")
61
61
  self._client = GoCardlessClient(
62
- self.config["secret_id"],
63
- self.config["secret_key"],
64
- cache_options=self.config.get("cache_options", None),
62
+ self.config.secret_id,
63
+ self.config.secret_key,
64
+ cache_options=self.config.cache_options or None,
65
65
  )
66
66
 
67
67
  return self._client
@@ -93,7 +93,7 @@ class GoCardLessImporter(beangulp.Importer):
93
93
  logger.debug("Returning account for %s: ''", filepath)
94
94
  return "" # We get the account from the config file
95
95
 
96
- def load_config(self, filepath: str) -> Optional[Dict[str, Any]]:
96
+ def load_config(self, filepath: str) -> Optional[GoCardlessConfig]:
97
97
  """
98
98
  Loads configuration from the specified YAML file.
99
99
 
@@ -101,35 +101,35 @@ class GoCardLessImporter(beangulp.Importer):
101
101
  filepath (str): The path to the YAML configuration file.
102
102
 
103
103
  Returns:
104
- Dict[str, Any]: The loaded configuration dictionary. Also sets the `self.config` attribute.
104
+ GoCardlessConfig: The loaded configuration. Also sets the `self.config` attribute.
105
105
  """
106
106
  logger.debug("Loading config from %s", filepath)
107
107
  with open(filepath, "r") as f:
108
108
  raw_config = f.read()
109
- expanded_config = path.expandvars(
110
- raw_config
111
- ) # Handle environment variables
112
- self.config = yaml.safe_load(expanded_config)
109
+ expanded_config = path.expandvars(raw_config)
110
+ self.config = GoCardlessConfig(**yaml.safe_load(expanded_config))
113
111
 
114
112
  return self.config
115
113
 
116
114
  def get_all_transactions(
117
- self, booked: List[BankTransaction], pending: List[BankTransaction]
115
+ self, transactions_dict: Dict[str, List[BankTransaction]], types: List[str]
118
116
  ) -> List[Tuple[BankTransaction, str]]:
119
117
  """
120
- Combines booked and pending transactions and sorts them by date.
118
+ Combines transactions of specified types and sorts them by date.
121
119
 
122
120
  Args:
123
- booked (List[BankTransaction]): The booked transaction data from the API.
124
- pending (List[BankTransaction]): The pending transaction data from the API.
121
+ transactions_dict (Dict[str, List[BankTransaction]]): Transactions by type.
122
+ types (List[str]): Types to include.
125
123
 
126
124
  Returns:
127
- List[Tuple[BankTransaction, str]]: A sorted list of tuples, where each tuple contains
128
- a transaction and its status ('booked' or 'pending').
125
+ List[Tuple[BankTransaction, str]]: Sorted list of (transaction, type) tuples.
129
126
  """
130
- all_transactions = [(tx, "booked") for tx in booked] + [
131
- (tx, "pending") for tx in pending
132
- ]
127
+ all_transactions = []
128
+ for tx_type in types:
129
+ if tx_type in transactions_dict:
130
+ all_transactions.extend(
131
+ [(tx, tx_type) for tx in transactions_dict[tx_type]]
132
+ )
133
133
  return sorted(
134
134
  all_transactions,
135
135
  key=lambda x: x[0].value_date or x[0].booking_date or "",
@@ -348,27 +348,31 @@ class GoCardLessImporter(beangulp.Importer):
348
348
  raise ValueError("No config loaded from YAML file")
349
349
 
350
350
  entries: data.Entries = []
351
- accounts = self.config.get("accounts", [])
351
+ accounts = self.config.accounts
352
352
  total_transactions = 0
353
353
  logger.info("Processing %d accounts", len(accounts))
354
354
  for account in accounts:
355
- account_id = account["id"]
356
- asset_account = account["asset_account"]
357
- # Use get() with a default empty dict for custom_metadata
358
- custom_metadata = account.get("metadata", {})
355
+ account_id = account.id
356
+ asset_account = account.asset_account
357
+ custom_metadata = account.metadata
359
358
 
360
359
  logger.debug("Fetching transactions for account %s", account_id)
361
360
  account_transactions = self.client.get_account_transactions(account_id)
362
- booked = account_transactions.transactions.get("booked", [])
363
- pending = account_transactions.transactions.get("pending", [])
364
- all_transactions = self.get_all_transactions(booked, pending)
361
+ transactions_dict = account_transactions.transactions
362
+ all_transactions = self.get_all_transactions(
363
+ transactions_dict, account.transaction_types
364
+ )
365
+ booked_count = len(transactions_dict.get("booked", []))
366
+ pending_count = len(transactions_dict.get("pending", []))
365
367
  logger.debug(
366
368
  "Fetched %d booked and %d pending transactions for account %s",
367
- len(booked),
368
- len(pending),
369
+ booked_count,
370
+ pending_count,
369
371
  account_id,
370
372
  )
371
- total_transactions += len(booked) + len(pending)
373
+ total_transactions += sum(
374
+ len(transactions_dict.get(t, [])) for t in account.transaction_types
375
+ )
372
376
 
373
377
  skipped = 0
374
378
  for transaction, status in all_transactions:
@@ -4,7 +4,7 @@ Complete coverage of all schemas from swagger.json
4
4
  """
5
5
 
6
6
  from typing import Optional, List, Dict, Any, TypedDict
7
- from pydantic import BaseModel, Field, ConfigDict
7
+ from pydantic import BaseModel, Field, ConfigDict, validator
8
8
  from pydantic.alias_generators import to_camel
9
9
  from enum import Enum
10
10
 
@@ -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
@@ -536,3 +536,26 @@ class IntegrationRetrieve(BaseModel):
536
536
  supported_payments: Optional[Dict[str, Any]] = None
537
537
  supported_features: Optional[List[str]] = None
538
538
  identification_codes: Optional[List[str]] = None
539
+
540
+
541
+ class AccountConfig(BaseModel):
542
+ id: str
543
+ asset_account: str
544
+ metadata: Dict[str, Any] = {}
545
+ transaction_types: List[str] = ["booked", "pending"]
546
+
547
+ @validator("transaction_types")
548
+ def validate_transaction_types(cls, v):
549
+ allowed = {"booked", "pending"}
550
+ if not set(v).issubset(allowed):
551
+ raise ValueError(
552
+ f"Invalid transaction types: {v}. Must be subset of {allowed}"
553
+ )
554
+ return v
555
+
556
+
557
+ class GoCardlessConfig(BaseModel):
558
+ secret_id: str
559
+ secret_key: str
560
+ cache_options: Dict[str, Any] = {}
561
+ accounts: List[AccountConfig]
@@ -18,7 +18,7 @@ logger = logging.getLogger(__name__)
18
18
  class ActionMessage(Message):
19
19
  """Custom message to signal actions within the TUI."""
20
20
 
21
- def __init__(self, action: str, payload: dict = None) -> None:
21
+ def __init__(self, action: str, payload: Optional[dict] = None) -> None:
22
22
  super().__init__()
23
23
  self.action = action
24
24
  self.payload = payload or {}
@@ -47,16 +47,13 @@ class BaseSubView(Static):
47
47
  """Base class for sub-views with a back button."""
48
48
 
49
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
- )
50
+ yield from self.compose_content()
54
51
 
55
52
  def compose_content(self) -> ComposeResult:
56
53
  # To be overridden by subclasses
57
54
  yield Static("Content goes here")
58
55
 
59
- def on_button_pressed(self, event: Button.Pressed) -> None:
56
+ async def on_button_pressed(self, event: Button.Pressed) -> None:
60
57
  if event.button.id == "back_to_menu":
61
58
  self.post_message(ActionMessage("show_menu"))
62
59
  else:
@@ -110,10 +107,13 @@ class BanksView(BaseSubView):
110
107
  try:
111
108
  if not self.all_banks:
112
109
  self.status_message.update("Loading all banks...")
113
- self.all_banks = self.client.list_banks(country=None)
110
+ self.all_banks = [
111
+ inst.model_dump()
112
+ for inst in self.client.get_institutions(country=None)
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 = [
@@ -164,7 +164,7 @@ class BanksView(BaseSubView):
164
164
  country_code=country_code if not omit_country else None,
165
165
  )
166
166
 
167
- def on_button_pressed(self, event: Button.Pressed) -> None:
167
+ async def on_button_pressed(self, event: Button.Pressed) -> None:
168
168
  """Handle button presses within this view."""
169
169
  if event.button.id == "back_to_menu":
170
170
  self.post_message(ActionMessage("show_menu"))
@@ -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
@@ -205,7 +224,8 @@ class AccountsView(BaseSubView):
205
224
  """Loads and displays the linked accounts with detailed information."""
206
225
  try:
207
226
  self.status_message.update("Loading accounts...")
208
- accounts = self.client.list_accounts()
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:
@@ -260,249 +311,79 @@ class AccountsView(BaseSubView):
260
311
  except Exception as e:
261
312
  self.status_message.update(Text(f"Unexpected error: {e}", style="bold red"))
262
313
 
263
- def on_button_pressed(self, event: Button.Pressed) -> None:
314
+ async def on_button_pressed(self, event: Button.Pressed) -> None:
264
315
  """Handle button presses within this view."""
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
-
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
-
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)
377
325
  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"))
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}"
383
335
  )
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
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
-
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"))
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
506
387
  elif event.button.id == "do_create_link_button":
507
388
  await self.process_create_link()
508
389
 
@@ -527,7 +408,20 @@ class LinkView(BaseSubView):
527
408
  create_button.disabled = True
528
409
  result_message_widget.update("Creating link...")
529
410
 
530
- link_info = self.client.create_link(reference, bank_id) # API call
411
+ link_url = self.client.create_bank_link(reference, bank_id) # API call
412
+
413
+ if link_url:
414
+ link_info = {
415
+ "status": "created",
416
+ "message": "Link created successfully",
417
+ "link": link_url,
418
+ }
419
+ else:
420
+ link_info = {
421
+ "status": "exists",
422
+ "message": "Link already exists for this reference",
423
+ "link": None,
424
+ }
531
425
 
532
426
  msg_parts = [
533
427
  Text(f"Status: {link_info.get('status', 'N/A')}\n", style="bold")
@@ -577,9 +471,10 @@ class DeleteLinkView(BaseSubView):
577
471
  Static(id="delete_link_result_message", classes="result_message_area"),
578
472
  id="delete_link_form_area",
579
473
  )
474
+ yield Button("Back to Menu", id="back_to_menu", classes="back_button")
580
475
 
581
476
  async def on_mount(self) -> None:
582
- await self.query_one("#del_ref_input", Input).focus()
477
+ self.query_one("#del_ref_input", Input).focus()
583
478
 
584
479
  async def on_button_pressed(self, event: Button.Pressed) -> None:
585
480
  if event.button.id == "back_to_menu":
@@ -607,14 +502,17 @@ class DeleteLinkView(BaseSubView):
607
502
  f"Deleting link with reference '{reference}'..."
608
503
  )
609
504
 
610
- response = self.client.delete_link(reference) # API call
505
+ req = self.client.find_requisition_by_reference(reference)
506
+ if req:
507
+ self.client.delete_requisition(req.id) # API call
508
+ result = {"status": "deleted", "message": "Link deleted successfully"}
509
+ else:
510
+ result = {"status": "not_found", "message": "Link not found"}
611
511
 
612
- style = (
613
- "bold green" if response.get("status") == "deleted" else "bold yellow"
614
- )
512
+ style = "bold green" if result["status"] == "deleted" else "bold yellow"
615
513
  result_message_widget.update(
616
514
  Text(
617
- f"Status: {response.get('status', 'N/A')}\nMessage: {response.get('message', 'N/A')}",
515
+ f"Status: {result['status']}\nMessage: {result['message']}",
618
516
  style=style,
619
517
  )
620
518
  )
@@ -767,5 +665,5 @@ def main():
767
665
  app.run()
768
666
 
769
667
 
770
- # if __name__ == "__main__":
771
- # main()
668
+ if __name__ == "__main__":
669
+ main()
@@ -0,0 +1,85 @@
1
+ import pytest
2
+ from unittest.mock import Mock
3
+ from beancount_gocardless.tui import (
4
+ GoCardLessApp,
5
+ MenuView,
6
+ AccountsView,
7
+ BalanceView,
8
+ LinkView,
9
+ DeleteLinkView,
10
+ )
11
+ from beancount_gocardless.client import GoCardlessClient
12
+
13
+
14
+ @pytest.fixture
15
+ def mock_client():
16
+ client = Mock(spec=GoCardlessClient)
17
+ client.get_institutions.return_value = [
18
+ Mock(name="Test Bank", id="BANK1", countries=["GB"])
19
+ ]
20
+ client.get_all_accounts.return_value = [
21
+ {
22
+ "id": "ACC1",
23
+ "name": "Test Account",
24
+ "institution_id": "BANK1",
25
+ "iban": "GB123",
26
+ }
27
+ ]
28
+ client.get_account_balances.return_value = Mock(
29
+ balances=[
30
+ Mock(
31
+ balance_type="closingAvailable",
32
+ balance_amount=Mock(amount="100.00", currency="GBP"),
33
+ )
34
+ ]
35
+ )
36
+ client.create_bank_link.return_value = "http://link.com"
37
+ client.find_requisition_by_reference.return_value = Mock(id="REQ1")
38
+ return client
39
+
40
+
41
+ def test_app_initialization(mock_client):
42
+ app = GoCardLessApp(secret_id="test", secret_key="test")
43
+ app.client = mock_client
44
+ assert app.client == mock_client
45
+
46
+
47
+ def test_menu_view_compose():
48
+ view = MenuView()
49
+ # Just check compose doesn't fail
50
+ content = list(view.compose())
51
+ assert len(content) == 1
52
+
53
+
54
+ @pytest.mark.skip(reason="Hard to mock Textual context")
55
+ def test_banks_view_load(mock_client):
56
+ pass
57
+
58
+
59
+ def test_accounts_view_load(mock_client):
60
+ view = AccountsView(mock_client)
61
+ # Mock the table update
62
+ view.accounts_table = Mock()
63
+ view.status_message = Mock()
64
+ import asyncio
65
+
66
+ asyncio.run(view.load_accounts())
67
+ view.accounts_table.update.assert_called()
68
+
69
+
70
+ def test_balance_view(mock_client):
71
+ view = BalanceView(mock_client)
72
+ content = list(view.compose_content())
73
+ assert len(content) == 4 # label, static, vertical, button
74
+
75
+
76
+ def test_link_view(mock_client):
77
+ view = LinkView(mock_client)
78
+ content = list(view.compose_content())
79
+ assert len(content) == 3 # label, vertical, button
80
+
81
+
82
+ def test_delete_link_view(mock_client):
83
+ view = DeleteLinkView(mock_client)
84
+ content = list(view.compose_content())
85
+ assert len(content) == 3 # label, vertical, button
@@ -1,17 +0,0 @@
1
- from textual.app import App, ComposeResult
2
- from textual.widgets import Header, Footer, Label
3
-
4
-
5
- raise Exception
6
-
7
-
8
- class MinimalApp(App):
9
- TITLE = "Minimal Test App"
10
-
11
- def compose(self) -> ComposeResult:
12
- yield Header()
13
- yield Label("If you see this, MinimalApp from tui.py is running!")
14
- yield Footer()
15
-
16
-
17
- app = MinimalApp()