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.
- {beancount_gocardless-0.1.11 → beancount_gocardless-0.1.13}/PKG-INFO +13 -35
- {beancount_gocardless-0.1.11 → beancount_gocardless-0.1.13}/README.md +12 -34
- {beancount_gocardless-0.1.11 → beancount_gocardless-0.1.13}/docs/importer.rst +14 -3
- {beancount_gocardless-0.1.11 → beancount_gocardless-0.1.13}/pyproject.toml +1 -1
- {beancount_gocardless-0.1.11 → beancount_gocardless-0.1.13}/src/beancount_gocardless/importer.py +49 -33
- {beancount_gocardless-0.1.11 → beancount_gocardless-0.1.13}/src/beancount_gocardless/models.py +36 -2
- beancount_gocardless-0.1.13/tests/test_importer.py +150 -0
- beancount_gocardless-0.1.13/tests/test_models.py +147 -0
- {beancount_gocardless-0.1.11 → beancount_gocardless-0.1.13}/tests/test_tui.py +10 -10
- {beancount_gocardless-0.1.11 → beancount_gocardless-0.1.13}/.github/workflows/publish.yml +0 -0
- {beancount_gocardless-0.1.11 → beancount_gocardless-0.1.13}/.gitignore +0 -0
- {beancount_gocardless-0.1.11 → beancount_gocardless-0.1.13}/.pre-commit-config.yaml +0 -0
- {beancount_gocardless-0.1.11 → beancount_gocardless-0.1.13}/.readthedocs.yaml +0 -0
- {beancount_gocardless-0.1.11 → beancount_gocardless-0.1.13}/LICENSE +0 -0
- {beancount_gocardless-0.1.11 → beancount_gocardless-0.1.13}/docs/Makefile +0 -0
- {beancount_gocardless-0.1.11 → beancount_gocardless-0.1.13}/docs/cli.rst +0 -0
- {beancount_gocardless-0.1.11 → beancount_gocardless-0.1.13}/docs/client.rst +0 -0
- {beancount_gocardless-0.1.11 → beancount_gocardless-0.1.13}/docs/conf.py +0 -0
- {beancount_gocardless-0.1.11 → beancount_gocardless-0.1.13}/docs/index.rst +0 -0
- {beancount_gocardless-0.1.11 → beancount_gocardless-0.1.13}/docs/make.bat +0 -0
- {beancount_gocardless-0.1.11 → beancount_gocardless-0.1.13}/src/beancount_gocardless/__init__.py +0 -0
- {beancount_gocardless-0.1.11 → beancount_gocardless-0.1.13}/src/beancount_gocardless/cli.py +0 -0
- {beancount_gocardless-0.1.11 → beancount_gocardless-0.1.13}/src/beancount_gocardless/client.py +0 -0
- {beancount_gocardless-0.1.11 → beancount_gocardless-0.1.13}/src/beancount_gocardless/openapi/swagger.json +0 -0
- {beancount_gocardless-0.1.11 → beancount_gocardless-0.1.13}/src/beancount_gocardless/tui.py +0 -0
- {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.
|
|
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
|
|
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
|
|
89
|
+
from smart_importer import PredictPostings, PredictPayees
|
|
111
90
|
|
|
112
91
|
importers = [
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
|
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
|
|
62
|
+
from smart_importer import PredictPostings, PredictPayees
|
|
84
63
|
|
|
85
64
|
importers = [
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
|
56
|
+
from smart_importer import PredictPostings, PredictPayees
|
|
53
57
|
|
|
54
|
-
importers = [
|
|
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
|
{beancount_gocardless-0.1.11 → beancount_gocardless-0.1.13}/src/beancount_gocardless/importer.py
RENAMED
|
@@ -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
|
-
|
|
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(
|
|
414
|
-
|
|
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
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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=
|
|
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
|
|
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
|
-
|
|
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,
|
{beancount_gocardless-0.1.11 → beancount_gocardless-0.1.13}/src/beancount_gocardless/models.py
RENAMED
|
@@ -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, 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
|
-
@
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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):
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{beancount_gocardless-0.1.11 → beancount_gocardless-0.1.13}/src/beancount_gocardless/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{beancount_gocardless-0.1.11 → beancount_gocardless-0.1.13}/src/beancount_gocardless/client.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|