beancount-gocardless 0.1.11__tar.gz → 0.1.13__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 (26) hide show
  1. {beancount_gocardless-0.1.11 → beancount_gocardless-0.1.13}/PKG-INFO +13 -35
  2. {beancount_gocardless-0.1.11 → beancount_gocardless-0.1.13}/README.md +12 -34
  3. {beancount_gocardless-0.1.11 → beancount_gocardless-0.1.13}/docs/importer.rst +14 -3
  4. {beancount_gocardless-0.1.11 → beancount_gocardless-0.1.13}/pyproject.toml +1 -1
  5. {beancount_gocardless-0.1.11 → beancount_gocardless-0.1.13}/src/beancount_gocardless/importer.py +49 -33
  6. {beancount_gocardless-0.1.11 → beancount_gocardless-0.1.13}/src/beancount_gocardless/models.py +36 -2
  7. beancount_gocardless-0.1.13/tests/test_importer.py +150 -0
  8. beancount_gocardless-0.1.13/tests/test_models.py +147 -0
  9. {beancount_gocardless-0.1.11 → beancount_gocardless-0.1.13}/tests/test_tui.py +10 -10
  10. {beancount_gocardless-0.1.11 → beancount_gocardless-0.1.13}/.github/workflows/publish.yml +0 -0
  11. {beancount_gocardless-0.1.11 → beancount_gocardless-0.1.13}/.gitignore +0 -0
  12. {beancount_gocardless-0.1.11 → beancount_gocardless-0.1.13}/.pre-commit-config.yaml +0 -0
  13. {beancount_gocardless-0.1.11 → beancount_gocardless-0.1.13}/.readthedocs.yaml +0 -0
  14. {beancount_gocardless-0.1.11 → beancount_gocardless-0.1.13}/LICENSE +0 -0
  15. {beancount_gocardless-0.1.11 → beancount_gocardless-0.1.13}/docs/Makefile +0 -0
  16. {beancount_gocardless-0.1.11 → beancount_gocardless-0.1.13}/docs/cli.rst +0 -0
  17. {beancount_gocardless-0.1.11 → beancount_gocardless-0.1.13}/docs/client.rst +0 -0
  18. {beancount_gocardless-0.1.11 → beancount_gocardless-0.1.13}/docs/conf.py +0 -0
  19. {beancount_gocardless-0.1.11 → beancount_gocardless-0.1.13}/docs/index.rst +0 -0
  20. {beancount_gocardless-0.1.11 → beancount_gocardless-0.1.13}/docs/make.bat +0 -0
  21. {beancount_gocardless-0.1.11 → beancount_gocardless-0.1.13}/src/beancount_gocardless/__init__.py +0 -0
  22. {beancount_gocardless-0.1.11 → beancount_gocardless-0.1.13}/src/beancount_gocardless/cli.py +0 -0
  23. {beancount_gocardless-0.1.11 → beancount_gocardless-0.1.13}/src/beancount_gocardless/client.py +0 -0
  24. {beancount_gocardless-0.1.11 → beancount_gocardless-0.1.13}/src/beancount_gocardless/openapi/swagger.json +0 -0
  25. {beancount_gocardless-0.1.11 → beancount_gocardless-0.1.13}/src/beancount_gocardless/tui.py +0 -0
  26. {beancount_gocardless-0.1.11 → beancount_gocardless-0.1.13}/tests/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: beancount-gocardless
3
- Version: 0.1.11
3
+ Version: 0.1.13
4
4
  License-Expression: Unlicense
5
5
  License-File: LICENSE
6
6
  Requires-Python: <4,>=3.12
@@ -52,30 +52,8 @@ You'll need to create a GoCardLess account on https://bankaccountdata.gocardless
52
52
 
53
53
  ### API Coverage
54
54
 
55
- The GoCardless client provides **complete API coverage** with Pydantic models for all endpoints and data structures:
56
-
57
- **🏦 Core Banking:**
58
- - **Accounts**: Full account metadata, balances, details, and transactions
59
- - **Institutions**: Bank/institution information and capabilities
60
- - **Requisitions**: Bank link management with full lifecycle support
61
-
62
- **📋 Agreements & Permissions:**
63
- - **End User Agreements**: Complete agreement lifecycle management
64
- - **Access Scopes**: Granular permission control
65
- - **Reconfirmations**: Agreement renewal workflows
66
-
67
- **🔧 Advanced Features:**
68
- - **Integrations**: Institution capability discovery
69
- - **Token Management**: JWT token handling (internal)
70
- - **Pagination**: Full paginated response support
71
- - **Error Handling**: Error response models
72
-
73
- **📊 Rich Data Models:**
74
- - **Transactions**: Complete transaction details with currency exchange, balances, and metadata
75
- - **Balances**: Multi-currency balance information with transaction impact
76
- - **Account Details**: Extensive account information including ownership and addresses
77
-
78
- Models manually recreated from the swagger spec, providing type-safe access to every API feature.
55
+ The GoCardless client tries to provide complete API coverage with Pydantic models for all endpoints and data structures.
56
+ Models are manually recreated from the swagger spec, providing type-safe access to every API feature.
79
57
 
80
58
  **Installation:**
81
59
 
@@ -98,7 +76,8 @@ cache_options: # by default, no caching if cache_options is not provided
98
76
  accounts:
99
77
  - id: <REDACTED_UUID>
100
78
  asset_account: "Assets:Banks:Revolut:Checking"
101
- transaction_types: ["booked", "pending"] # optional, defaults to both
79
+ transaction_types: ["booked", "pending"] # optional list, defaults to both
80
+ preferred_balance_type: "interimAvailable" # optional, use specific balance type
102
81
  ```
103
82
 
104
83
  ```python
@@ -107,20 +86,19 @@ accounts:
107
86
 
108
87
  import beangulp
109
88
  from beancount_gocardless import GoCardLessImporter
110
- from smart_importer import apply_hooks, PredictPostings, PredictPayees
89
+ from smart_importer import PredictPostings, PredictPayees
111
90
 
112
91
  importers = [
113
- apply_hooks(
114
- GoCardLessImporter(),
115
- [
116
- PredictPostings(),
117
- PredictPayees(),
118
- ],
119
- )
92
+ GoCardLessImporter()
93
+ ]
94
+
95
+ hooks = [
96
+ PredictPostings().hook,
97
+ PredictPayees().hook,
120
98
  ]
121
99
 
122
100
  if __name__ == "__main__":
123
- ingest = beangulp.Ingest(importers)
101
+ ingest = beangulp.Ingest(importers, hooks=hooks)
124
102
  ingest()
125
103
  ```
126
104
 
@@ -25,30 +25,8 @@ You'll need to create a GoCardLess account on https://bankaccountdata.gocardless
25
25
 
26
26
  ### API Coverage
27
27
 
28
- The GoCardless client provides **complete API coverage** with Pydantic models for all endpoints and data structures:
29
-
30
- **🏦 Core Banking:**
31
- - **Accounts**: Full account metadata, balances, details, and transactions
32
- - **Institutions**: Bank/institution information and capabilities
33
- - **Requisitions**: Bank link management with full lifecycle support
34
-
35
- **📋 Agreements & Permissions:**
36
- - **End User Agreements**: Complete agreement lifecycle management
37
- - **Access Scopes**: Granular permission control
38
- - **Reconfirmations**: Agreement renewal workflows
39
-
40
- **🔧 Advanced Features:**
41
- - **Integrations**: Institution capability discovery
42
- - **Token Management**: JWT token handling (internal)
43
- - **Pagination**: Full paginated response support
44
- - **Error Handling**: Error response models
45
-
46
- **📊 Rich Data Models:**
47
- - **Transactions**: Complete transaction details with currency exchange, balances, and metadata
48
- - **Balances**: Multi-currency balance information with transaction impact
49
- - **Account Details**: Extensive account information including ownership and addresses
50
-
51
- Models manually recreated from the swagger spec, providing type-safe access to every API feature.
28
+ The GoCardless client tries to provide complete API coverage with Pydantic models for all endpoints and data structures.
29
+ Models are manually recreated from the swagger spec, providing type-safe access to every API feature.
52
30
 
53
31
  **Installation:**
54
32
 
@@ -71,7 +49,8 @@ cache_options: # by default, no caching if cache_options is not provided
71
49
  accounts:
72
50
  - id: <REDACTED_UUID>
73
51
  asset_account: "Assets:Banks:Revolut:Checking"
74
- transaction_types: ["booked", "pending"] # optional, defaults to both
52
+ transaction_types: ["booked", "pending"] # optional list, defaults to both
53
+ preferred_balance_type: "interimAvailable" # optional, use specific balance type
75
54
  ```
76
55
 
77
56
  ```python
@@ -80,20 +59,19 @@ accounts:
80
59
 
81
60
  import beangulp
82
61
  from beancount_gocardless import GoCardLessImporter
83
- from smart_importer import apply_hooks, PredictPostings, PredictPayees
62
+ from smart_importer import PredictPostings, PredictPayees
84
63
 
85
64
  importers = [
86
- apply_hooks(
87
- GoCardLessImporter(),
88
- [
89
- PredictPostings(),
90
- PredictPayees(),
91
- ],
92
- )
65
+ GoCardLessImporter()
66
+ ]
67
+
68
+ hooks = [
69
+ PredictPostings().hook,
70
+ PredictPayees().hook,
93
71
  ]
94
72
 
95
73
  if __name__ == "__main__":
96
- ingest = beangulp.Ingest(importers)
74
+ ingest = beangulp.Ingest(importers, hooks=hooks)
97
75
  ingest()
98
76
  ```
99
77
 
@@ -23,6 +23,8 @@ YAML file:
23
23
  - id: Account ID
24
24
  - asset_account: Beancount asset account
25
25
  - filing_account (opt): For hooks
26
+ - preferred_balance_type (opt): Preferred type for balance assertions, availability depends on the bank (e.g. "expected", "closingBooked", "interimBooked", "interimAvailable", "available", "booked")
27
+ - transaction_types (opt): List of types to fetch ("booked" and/or "pending")
26
28
 
27
29
  Usage
28
30
  -----
@@ -44,17 +46,26 @@ Example
44
46
  accounts:
45
47
  - id: <REDACTED_UUID>
46
48
  asset_account: "Assets:Banks:Revolut:Checking"
49
+ preferred_balance_type: "expected"
50
+ transaction_types: ["booked", "pending"]
47
51
 
48
52
  .. code-block:: python
49
53
 
50
54
  import beangulp
51
55
  from beancount_gocardless import GoCardLessImporter
52
- from smart_importer import apply_hooks, PredictPostings, PredictPayees
56
+ from smart_importer import PredictPostings, PredictPayees
53
57
 
54
- importers = [apply_hooks(GoCardLessImporter(), [PredictPostings(), PredictPayees()])]
58
+ importers = [
59
+ GoCardLessImporter()
60
+ ]
61
+
62
+ hooks = [
63
+ PredictPostings().hook,
64
+ PredictPayees().hook,
65
+ ]
55
66
 
56
67
  if __name__ == "__main__":
57
- ingest = beangulp.Ingest(importers)
68
+ ingest = beangulp.Ingest(importers, hooks=hooks)
58
69
  ingest()
59
70
 
60
71
  .. code-block:: bash
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "beancount-gocardless"
3
- version = "0.1.11"
3
+ version = "0.1.13"
4
4
  description = ""
5
5
  authors = []
6
6
  readme = "README.md"
@@ -400,32 +400,61 @@ class GoCardLessImporter(beangulp.Importer):
400
400
  for b in balances.balances
401
401
  ],
402
402
  )
403
- expected_balance = None
404
- other_balances = []
405
- for bal in balances.balances:
406
- if bal.balance_type == "expected":
407
- expected_balance = bal
408
- else:
409
- other_balances.append(bal)
410
403
 
411
- if expected_balance:
404
+ # Prioritized balance selection
405
+ PRIORITY = {
406
+ "expected": 0,
407
+ "closingBooked": 1,
408
+ "interimBooked": 2,
409
+ "interimAvailable": 3,
410
+ "openingBooked": 4,
411
+ }
412
+ if account.preferred_balance_type:
413
+ PRIORITY[account.preferred_balance_type] = -1
414
+
415
+ # Sort balances based on priority, with unknown types at the end
416
+ sorted_balances = sorted(
417
+ balances.balances, key=lambda b: PRIORITY.get(b.balance_type, 99)
418
+ )
419
+
420
+ if sorted_balances:
421
+ selected_balance = sorted_balances[0]
412
422
  balance_amount = amount.Amount(
413
- D(str(expected_balance.balance_amount.amount)),
414
- expected_balance.balance_amount.currency,
423
+ D(str(selected_balance.balance_amount.amount)),
424
+ selected_balance.balance_amount.currency,
415
425
  )
426
+
427
+ # Determine balance date
428
+ if selected_balance.reference_date:
429
+ try:
430
+ balance_date = date.fromisoformat(
431
+ selected_balance.reference_date
432
+ ) + timedelta(days=1)
433
+ except ValueError:
434
+ balance_date = date.today() + timedelta(days=1)
435
+ else:
436
+ balance_date = date.today() + timedelta(days=1)
437
+
416
438
  balance_meta = {}
417
- detail_parts = [
418
- f"{b.balance_type}: {b.balance_amount.amount} {b.balance_amount.currency}"
419
- for b in balances.balances
420
- ]
421
- detail = " / ".join(detail_parts)
422
- balance_meta["detail"] = detail
439
+
440
+ # Collect all distinct balance values for metadata
441
+ distinct_details = []
442
+ seen_values = set()
443
+ for b in sorted_balances:
444
+ val_str = f"{b.balance_amount.amount} {b.balance_amount.currency}"
445
+ if val_str not in seen_values:
446
+ distinct_details.append(f"{b.balance_type}: {val_str}")
447
+ seen_values.add(val_str)
448
+
449
+ balance_meta["detail"] = " / ".join(distinct_details)
450
+
423
451
  # Include custom metadata from config for consistency with transactions
424
452
  balance_meta.update(custom_metadata)
425
453
  meta = data.new_metadata("", 0, balance_meta)
454
+
426
455
  balance_entry = data.Balance(
427
456
  meta=meta,
428
- date=date.today() + timedelta(days=1),
457
+ date=balance_date,
429
458
  account=asset_account,
430
459
  amount=balance_amount,
431
460
  tolerance=None,
@@ -433,26 +462,13 @@ class GoCardLessImporter(beangulp.Importer):
433
462
  )
434
463
  entries.append(balance_entry)
435
464
  logger.debug(
436
- "Added balance assertion for account %s using expected balance: %s %s",
465
+ "Added balance assertion for account %s using %s balance: %s %s",
437
466
  account_id,
467
+ selected_balance.balance_type,
438
468
  balance_amount,
439
- date.today() + timedelta(days=1),
469
+ balance_date,
440
470
  )
441
471
 
442
- # Log other balances if they differ from expected
443
- expected_amount = D(str(expected_balance.balance_amount.amount))
444
- for bal in other_balances:
445
- other_amount = D(str(bal.balance_amount.amount))
446
- if other_amount != expected_amount:
447
- logger.info(
448
- "Account %s has different balance for type %s: %s %s vs expected %s",
449
- account_id,
450
- bal.balance_type,
451
- other_amount,
452
- bal.balance_amount.currency,
453
- expected_amount,
454
- )
455
-
456
472
  logger.info(
457
473
  "Processed %d total transactions across %d accounts, created %d entries",
458
474
  total_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, validator
7
+ from pydantic import BaseModel, Field, ConfigDict, field_validator
8
8
  from pydantic.alias_generators import to_camel
9
9
  from enum import Enum
10
10
 
@@ -166,6 +166,8 @@ class AccountDetail(BaseModel):
166
166
  class TransactionAmountSchema(BaseModel):
167
167
  """Transaction amount schema."""
168
168
 
169
+ model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
170
+
169
171
  amount: str
170
172
  currency: str
171
173
 
@@ -173,6 +175,8 @@ class TransactionAmountSchema(BaseModel):
173
175
  class InstructedAmount(BaseModel):
174
176
  """Instructed amount schema."""
175
177
 
178
+ model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
179
+
176
180
  amount: str
177
181
  currency: str
178
182
 
@@ -180,6 +184,8 @@ class InstructedAmount(BaseModel):
180
184
  class CurrencyExchangeSchema(BaseModel):
181
185
  """Currency exchange schema."""
182
186
 
187
+ model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
188
+
183
189
  source_currency: str
184
190
  exchange_rate: Optional[str] = None
185
191
  unit_currency: Optional[str] = None
@@ -192,6 +198,8 @@ class CurrencyExchangeSchema(BaseModel):
192
198
  class BalanceAfterTransactionSchema(BaseModel):
193
199
  """Balance after transaction schema."""
194
200
 
201
+ model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
202
+
195
203
  balance_after_transaction: Optional[BalanceAmountSchema] = None
196
204
  balance_type: Optional[str] = None
197
205
 
@@ -206,6 +214,19 @@ class TransactionSchema(BaseModel):
206
214
  value_date_time: Optional[str] = Field(None, description="Value date and time.")
207
215
  transaction_amount: TransactionAmountSchema
208
216
  currency_exchange: Optional[List[CurrencyExchangeSchema]] = None
217
+
218
+ model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
219
+
220
+ @field_validator("currency_exchange", mode="before")
221
+ @classmethod
222
+ def normalize_currency_exchange(cls, v):
223
+ """Normalize currency_exchange to always be a list."""
224
+ if v is None:
225
+ return None
226
+ if isinstance(v, dict):
227
+ return [v]
228
+ return v
229
+
209
230
  creditor_name: Optional[str] = Field(None, description="Creditor name.")
210
231
  creditor_account: Optional[AccountSchema] = None
211
232
  creditor_agent: Optional[str] = Field(None, description="Creditor agent.")
@@ -260,6 +281,17 @@ class BankTransaction(BaseModel):
260
281
  creditor_name: Optional[str] = Field(None, description="Creditor name.")
261
282
  creditor_account: Optional[AccountSchema] = None
262
283
  currency_exchange: Optional[List[CurrencyExchangeSchema]] = None
284
+
285
+ @field_validator("currency_exchange", mode="before")
286
+ @classmethod
287
+ def normalize_currency_exchange(cls, v):
288
+ """Normalize currency_exchange to always be a list."""
289
+ if v is None:
290
+ return None
291
+ if isinstance(v, dict):
292
+ return [v]
293
+ return v
294
+
263
295
  balance_after_transaction: Optional[BalanceAfterTransactionSchema] = None
264
296
  bank_transaction_code: Optional[str] = Field(
265
297
  None, description="Bank transaction code."
@@ -543,8 +575,10 @@ class AccountConfig(BaseModel):
543
575
  asset_account: str
544
576
  metadata: Dict[str, Any] = {}
545
577
  transaction_types: List[str] = ["booked", "pending"]
578
+ preferred_balance_type: Optional[str] = None
546
579
 
547
- @validator("transaction_types")
580
+ @field_validator("transaction_types")
581
+ @classmethod
548
582
  def validate_transaction_types(cls, v):
549
583
  allowed = {"booked", "pending"}
550
584
  if not set(v).issubset(allowed):
@@ -0,0 +1,150 @@
1
+ import pytest
2
+ from unittest.mock import Mock, patch
3
+ from datetime import date, timedelta
4
+ from beancount.core import data
5
+ from beancount_gocardless.importer import GoCardLessImporter
6
+ from beancount_gocardless.models import (
7
+ AccountBalance,
8
+ BalanceSchema,
9
+ BalanceAmountSchema,
10
+ )
11
+
12
+
13
+ @pytest.fixture
14
+ def importer():
15
+ imp = GoCardLessImporter()
16
+ imp.config = Mock()
17
+ imp.config.secret_id = "test_id"
18
+ imp.config.secret_key = "test_key"
19
+ imp.config.cache_options = {}
20
+
21
+ mock_account = Mock()
22
+ mock_account.id = "ACC1"
23
+ mock_account.asset_account = "Assets:Bank:Test"
24
+ mock_account.metadata = {"test": "meta"}
25
+ mock_account.transaction_types = ["booked"]
26
+
27
+ imp.config.accounts = [mock_account]
28
+ return imp
29
+
30
+
31
+ def test_extract_balance_assertion_priority(importer):
32
+ """Test that balance assertion uses prioritized types."""
33
+ with patch("beancount_gocardless.importer.GoCardlessClient") as mock_client_cls:
34
+ mock_client = mock_client_cls.return_value
35
+
36
+ # Mock transactions (empty)
37
+ mock_tx_resp = Mock()
38
+ mock_tx_resp.transactions = {"booked": []}
39
+ mock_client.get_account_transactions.return_value = mock_tx_resp
40
+
41
+ # Mock balances - no 'expected', but has 'interimAvailable'
42
+ mock_balances = AccountBalance(
43
+ balances=[
44
+ BalanceSchema(
45
+ balance_amount=BalanceAmountSchema(amount="100.00", currency="EUR"),
46
+ balance_type="interimAvailable",
47
+ reference_date="2026-01-15",
48
+ )
49
+ ]
50
+ )
51
+ mock_client.get_account_balances.return_value = mock_balances
52
+
53
+ # We need to set the internal client to our mock
54
+ importer._client = mock_client
55
+ importer.load_config = Mock()
56
+
57
+ entries = importer.extract("gocardless.yaml", [])
58
+
59
+ # Should have one entry: the balance assertion
60
+ balance_entries = [e for e in entries if isinstance(e, data.Balance)]
61
+ assert len(balance_entries) == 1
62
+ assert balance_entries[0].account == "Assets:Bank:Test"
63
+ assert balance_entries[0].amount.number == 100
64
+ # Date should be reference_date + 1 day
65
+ assert balance_entries[0].date == date(2026, 1, 16)
66
+ assert balance_entries[0].meta["test"] == "meta"
67
+ assert "interimAvailable: 100.00 EUR" in balance_entries[0].meta["detail"]
68
+
69
+
70
+ def test_extract_balance_assertion_multiple_distinct(importer):
71
+ """Test that balance assertion shows all distinct balance values."""
72
+ with patch("beancount_gocardless.importer.GoCardlessClient") as mock_client_cls:
73
+ mock_client = mock_client_cls.return_value
74
+ mock_tx_resp = Mock()
75
+ mock_tx_resp.transactions = {"booked": []}
76
+ mock_client.get_account_transactions.return_value = mock_tx_resp
77
+
78
+ # Multiple balances with different values
79
+ mock_balances = AccountBalance(
80
+ balances=[
81
+ BalanceSchema(
82
+ balance_amount=BalanceAmountSchema(amount="100.00", currency="EUR"),
83
+ balance_type="expected",
84
+ reference_date="2026-01-15",
85
+ ),
86
+ BalanceSchema(
87
+ balance_amount=BalanceAmountSchema(amount="105.00", currency="EUR"),
88
+ balance_type="interimAvailable",
89
+ reference_date="2026-01-15",
90
+ ),
91
+ BalanceSchema(
92
+ balance_amount=BalanceAmountSchema(amount="100.00", currency="EUR"),
93
+ balance_type="closingBooked",
94
+ reference_date="2026-01-15",
95
+ ),
96
+ ]
97
+ )
98
+ mock_client.get_account_balances.return_value = mock_balances
99
+ importer._client = mock_client
100
+ importer.load_config = Mock()
101
+
102
+ entries = importer.extract("gocardless.yaml", [])
103
+
104
+ balance_entries = [e for e in entries if isinstance(e, data.Balance)]
105
+ assert len(balance_entries) == 1
106
+ assert balance_entries[0].amount.number == 100
107
+ # Detail should contain expected and interimAvailable, but NOT closingBooked (as it has same value as expected)
108
+ detail = balance_entries[0].meta["detail"]
109
+ assert "expected: 100.00 EUR" in detail
110
+ assert "interimAvailable: 105.00 EUR" in detail
111
+ assert "closingBooked" not in detail
112
+
113
+
114
+ def test_extract_balance_assertion_preferred(importer):
115
+ """Test that balance assertion respects preferred_balance_type."""
116
+ with patch("beancount_gocardless.importer.GoCardlessClient") as mock_client_cls:
117
+ mock_client = mock_client_cls.return_value
118
+ mock_tx_resp = Mock()
119
+ mock_tx_resp.transactions = {"booked": []}
120
+ mock_client.get_account_transactions.return_value = mock_tx_resp
121
+
122
+ # Multiple balances, interimAvailable is preferred
123
+ mock_balances = AccountBalance(
124
+ balances=[
125
+ BalanceSchema(
126
+ balance_amount=BalanceAmountSchema(amount="100.00", currency="EUR"),
127
+ balance_type="expected",
128
+ reference_date="2026-01-15",
129
+ ),
130
+ BalanceSchema(
131
+ balance_amount=BalanceAmountSchema(amount="105.00", currency="EUR"),
132
+ balance_type="interimAvailable",
133
+ reference_date="2026-01-15",
134
+ ),
135
+ ]
136
+ )
137
+ mock_client.get_account_balances.return_value = mock_balances
138
+ importer._client = mock_client
139
+ importer.load_config = Mock()
140
+
141
+ # Set preferred balance type in config
142
+ importer.config.accounts[0].preferred_balance_type = "interimAvailable"
143
+
144
+ entries = importer.extract("gocardless.yaml", [])
145
+
146
+ balance_entries = [e for e in entries if isinstance(e, data.Balance)]
147
+ assert len(balance_entries) == 1
148
+ # Should use interimAvailable (105.00) even though expected (100.00) exists
149
+ assert balance_entries[0].amount.number == 105
150
+ assert "interimAvailable: 105.00 EUR" in balance_entries[0].meta["detail"]
@@ -0,0 +1,147 @@
1
+ import pytest
2
+ from beancount_gocardless.models import (
3
+ AccountTransactions,
4
+ BankTransaction,
5
+ TransactionSchema,
6
+ )
7
+
8
+
9
+ def test_currency_exchange_normalization_dict():
10
+ """Test that a dictionary in currency_exchange is normalized to a list."""
11
+ test_data = {
12
+ "transactions": {
13
+ "booked": [
14
+ {
15
+ "transaction_id": "tx1",
16
+ "transaction_amount": {"amount": "-50.00", "currency": "GBP"},
17
+ "currency_exchange": {
18
+ "source_currency": "GBP",
19
+ "exchange_rate": "1.0",
20
+ },
21
+ }
22
+ ],
23
+ "pending": [],
24
+ }
25
+ }
26
+
27
+ # Test AccountTransactions
28
+ model = AccountTransactions(**test_data)
29
+ booked_tx = model.transactions["booked"][0]
30
+ assert isinstance(booked_tx.currency_exchange, list)
31
+ assert len(booked_tx.currency_exchange) == 1
32
+ assert booked_tx.currency_exchange[0].source_currency == "GBP"
33
+
34
+
35
+ def test_currency_exchange_normalization_with_aliases():
36
+ """Test normalization using camelCase aliases (user provided example)."""
37
+ test_data = {
38
+ "transactions": {
39
+ "booked": [
40
+ {
41
+ "transactionId": "tx1",
42
+ "transactionAmount": {"amount": "-50.00", "currency": "GBP"},
43
+ "currencyExchange": {
44
+ "sourceCurrency": "GBP",
45
+ "exchangeRate": "1.0",
46
+ },
47
+ }
48
+ ],
49
+ "pending": [],
50
+ }
51
+ }
52
+
53
+ model = AccountTransactions(**test_data)
54
+ booked_tx = model.transactions["booked"][0]
55
+ assert isinstance(booked_tx.currency_exchange, list)
56
+ assert len(booked_tx.currency_exchange) == 1
57
+ assert booked_tx.currency_exchange[0].source_currency == "GBP"
58
+
59
+
60
+ def test_currency_exchange_none():
61
+ """Test that None in currency_exchange remains None."""
62
+ test_data = {
63
+ "transaction_id": "tx1",
64
+ "transaction_amount": {"amount": "-50.00", "currency": "GBP"},
65
+ "currency_exchange": None,
66
+ }
67
+
68
+ model = BankTransaction(**test_data)
69
+ assert model.currency_exchange is None
70
+
71
+
72
+ def test_currency_exchange_list():
73
+ """Test that a list in currency_exchange remains a list."""
74
+ test_data = {
75
+ "transaction_id": "tx1",
76
+ "transaction_amount": {"amount": "-50.00", "currency": "GBP"},
77
+ "currency_exchange": [{"source_currency": "GBP", "exchange_rate": "1.0"}],
78
+ }
79
+
80
+ model = BankTransaction(**test_data)
81
+ assert isinstance(model.currency_exchange, list)
82
+ assert len(model.currency_exchange) == 1
83
+ assert model.currency_exchange[0].source_currency == "GBP"
84
+
85
+
86
+ def test_transaction_schema_normalization():
87
+ """Test normalization in TransactionSchema."""
88
+ test_data = {
89
+ "transaction_id": "tx1",
90
+ "transaction_amount": {"amount": "-50.00", "currency": "GBP"},
91
+ "currency_exchange": {"source_currency": "GBP", "exchange_rate": "1.0"},
92
+ }
93
+
94
+ model = TransactionSchema(**test_data)
95
+ assert isinstance(model.currency_exchange, list)
96
+ assert len(model.currency_exchange) == 1
97
+
98
+
99
+ def test_complex_multi_currency_normalization():
100
+ """Test normalization with the user's complex multi-currency JSON sample."""
101
+ test_data = {
102
+ "transactions": {
103
+ "booked": [
104
+ {
105
+ "transactionId": "uuid",
106
+ "bookingDate": "2026-01-05",
107
+ "valueDate": "2026-01-06",
108
+ "bookingDateTime": "2026-01-05T12:12:09.133455Z",
109
+ "valueDateTime": "2026-01-06T02:47:00.1234324Z",
110
+ "transactionAmount": {"amount": "-4.07", "currency": "EUR"},
111
+ "currencyExchange": {
112
+ "instructedAmount": {"amount": "4.07", "currency": "EUR"},
113
+ "sourceCurrency": "EUR",
114
+ "exchangeRate": "1.15",
115
+ "unitCurrency": "EUR",
116
+ "targetCurrency": "USD",
117
+ },
118
+ "creditorName": "Dunkin Donuts",
119
+ "remittanceInformationUnstructuredArray": ["Dunkin Donuts"],
120
+ "proprietaryBankTransactionCode": "CARD_PAYMENT",
121
+ "balanceAfterTransaction": {
122
+ "balanceAmount": {"amount": "9.52", "currency": "EUR"},
123
+ "balanceType": "InterimBooked",
124
+ },
125
+ "additionalDataStructured": {
126
+ "cardInstrument": {
127
+ "cardSchemeName": "MASTERCARD",
128
+ "name": "John Doe",
129
+ "identification": "1234",
130
+ }
131
+ },
132
+ "internalTransactionId": "85ecaab0e28caccd799bb8b331285ba5",
133
+ }
134
+ ],
135
+ "pending": [],
136
+ }
137
+ }
138
+
139
+ # This should not raise ValidationError
140
+ model = AccountTransactions(**test_data)
141
+ booked_tx = model.transactions["booked"][0]
142
+
143
+ assert isinstance(booked_tx.currency_exchange, list)
144
+ assert len(booked_tx.currency_exchange) == 1
145
+ assert booked_tx.currency_exchange[0].source_currency == "EUR"
146
+ assert booked_tx.currency_exchange[0].instructed_amount.amount == "4.07"
147
+ assert booked_tx.currency_exchange[0].target_currency == "USD"
@@ -4,8 +4,8 @@ from beancount_gocardless.tui import (
4
4
  GoCardLessApp,
5
5
  MenuView,
6
6
  AccountsView,
7
- BalanceView,
8
- LinkView,
7
+ # BalanceView,
8
+ # LinkView,
9
9
  DeleteLinkView,
10
10
  )
11
11
  from beancount_gocardless.client import GoCardlessClient
@@ -67,16 +67,16 @@ def test_accounts_view_load(mock_client):
67
67
  view.accounts_table.update.assert_called()
68
68
 
69
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
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
74
 
75
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
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
80
 
81
81
 
82
82
  def test_delete_link_view(mock_client):