beancount-gocardless 0.1.13__py3-none-any.whl → 0.1.14__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- beancount_gocardless/__main__.py +4 -0
- beancount_gocardless/cli.py +614 -113
- beancount_gocardless/client.py +13 -0
- beancount_gocardless/importer.py +96 -39
- beancount_gocardless/mock_client.py +220 -0
- beancount_gocardless/models.py +19 -8
- beancount_gocardless/utils.py +39 -0
- beancount_gocardless-0.1.14.dist-info/METADATA +252 -0
- beancount_gocardless-0.1.14.dist-info/RECORD +14 -0
- {beancount_gocardless-0.1.13.dist-info → beancount_gocardless-0.1.14.dist-info}/entry_points.txt +0 -1
- beancount_gocardless-0.1.14.dist-info/licenses/LICENSE +21 -0
- beancount_gocardless/tui.py +0 -669
- beancount_gocardless-0.1.13.dist-info/METADATA +0 -108
- beancount_gocardless-0.1.13.dist-info/RECORD +0 -12
- beancount_gocardless-0.1.13.dist-info/licenses/LICENSE +0 -24
- {beancount_gocardless-0.1.13.dist-info → beancount_gocardless-0.1.14.dist-info}/WHEEL +0 -0
beancount_gocardless/client.py
CHANGED
|
@@ -404,17 +404,30 @@ class GoCardlessClient:
|
|
|
404
404
|
|
|
405
405
|
def get_all_accounts(self) -> List[AccountInfo]:
|
|
406
406
|
"""Get all accounts from all requisitions"""
|
|
407
|
+
from datetime import datetime, timedelta
|
|
408
|
+
|
|
407
409
|
accounts = []
|
|
408
410
|
for req in self.get_requisitions():
|
|
409
411
|
for account_id in req.accounts:
|
|
410
412
|
try:
|
|
411
413
|
account = self.get_account(account_id)
|
|
412
414
|
account_dict = account.model_dump()
|
|
415
|
+
|
|
416
|
+
access_valid_days = req.access_valid_for_days or 90
|
|
417
|
+
created_date = datetime.fromisoformat(
|
|
418
|
+
req.created.replace("Z", "+00:00")
|
|
419
|
+
)
|
|
420
|
+
expiry_date = created_date + timedelta(days=access_valid_days)
|
|
421
|
+
is_expired = req.status == "EX"
|
|
422
|
+
|
|
413
423
|
account_dict.update(
|
|
414
424
|
{
|
|
415
425
|
"requisition_id": req.id,
|
|
416
426
|
"requisition_reference": req.reference,
|
|
417
427
|
"institution_id": req.institution_id,
|
|
428
|
+
"requisition_status": req.status,
|
|
429
|
+
"access_valid_until": expiry_date.isoformat(),
|
|
430
|
+
"is_expired": is_expired,
|
|
418
431
|
}
|
|
419
432
|
)
|
|
420
433
|
accounts.append(account_dict)
|
beancount_gocardless/importer.py
CHANGED
|
@@ -8,11 +8,23 @@ 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, GoCardlessConfig
|
|
11
|
+
from .models import BankTransaction, GoCardlessConfig, AccountConfig
|
|
12
12
|
|
|
13
13
|
logger = logging.getLogger(__name__)
|
|
14
14
|
|
|
15
15
|
|
|
16
|
+
def _flatten_dict(d: Dict[str, Any], prefix: str = "") -> Dict[str, Any]:
|
|
17
|
+
"""Recursively flatten a nested dict to dotted paths."""
|
|
18
|
+
result: Dict[str, Any] = {}
|
|
19
|
+
for key, value in d.items():
|
|
20
|
+
new_key = f"{prefix}.{key}" if prefix else key
|
|
21
|
+
if isinstance(value, dict):
|
|
22
|
+
result.update(_flatten_dict(value, new_key))
|
|
23
|
+
else:
|
|
24
|
+
result[new_key] = value
|
|
25
|
+
return result
|
|
26
|
+
|
|
27
|
+
|
|
16
28
|
class ReferenceDuplicatesComparator:
|
|
17
29
|
def __init__(self, refs: List[str] = ["ref"]) -> None:
|
|
18
30
|
self.refs = refs
|
|
@@ -38,6 +50,13 @@ class GoCardLessImporter(beangulp.Importer):
|
|
|
38
50
|
_client (Optional[GoCardlessClient]): Instance of the GoCardless API client.
|
|
39
51
|
"""
|
|
40
52
|
|
|
53
|
+
DEFAULT_METADATA_FIELDS: Dict[str, str] = {
|
|
54
|
+
"nordref": "transactionId",
|
|
55
|
+
"creditorName": "creditorName",
|
|
56
|
+
"debtorName": "debtorName",
|
|
57
|
+
"bookingDate": "bookingDate",
|
|
58
|
+
}
|
|
59
|
+
|
|
41
60
|
def __init__(self) -> None:
|
|
42
61
|
"""Initialize the GoCardLessImporter."""
|
|
43
62
|
logger.debug("Initializing GoCardLessImporter")
|
|
@@ -136,47 +155,45 @@ class GoCardLessImporter(beangulp.Importer):
|
|
|
136
155
|
)
|
|
137
156
|
|
|
138
157
|
def add_metadata(
|
|
139
|
-
self,
|
|
158
|
+
self,
|
|
159
|
+
transaction: BankTransaction,
|
|
160
|
+
custom_metadata: Dict[str, Any],
|
|
161
|
+
account_config: Optional[AccountConfig] = None,
|
|
140
162
|
) -> Dict[str, Any]:
|
|
141
|
-
"""
|
|
142
|
-
Extracts metadata from a transaction and returns it as a dictionary.
|
|
143
|
-
|
|
144
|
-
This method can be overridden in subclasses to customize metadata extraction.
|
|
145
|
-
|
|
146
|
-
Args:
|
|
147
|
-
transaction (BankTransaction): The transaction data from the API.
|
|
148
|
-
custom_metadata (Dict[str, Any]): Custom metadata from the config file.
|
|
149
|
-
|
|
150
|
-
Returns:
|
|
151
|
-
Dict[str, Any]: A dictionary of metadata key-value pairs.
|
|
152
|
-
"""
|
|
153
163
|
metakv: Dict[str, Any] = {}
|
|
154
164
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
)
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
165
|
+
exclude_fields: List[str] = []
|
|
166
|
+
custom_fields: Dict[str, str] = {}
|
|
167
|
+
|
|
168
|
+
if account_config is not None:
|
|
169
|
+
exclude_fields = account_config.exclude_default_metadata or []
|
|
170
|
+
custom_fields = account_config.metadata_fields or {}
|
|
171
|
+
|
|
172
|
+
# Start with defaults, merge with custom fields
|
|
173
|
+
fields = dict(self.DEFAULT_METADATA_FIELDS)
|
|
174
|
+
fields.update(custom_fields)
|
|
175
|
+
|
|
176
|
+
# Remove excluded fields
|
|
177
|
+
for key in exclude_fields:
|
|
178
|
+
fields.pop(key, None)
|
|
179
|
+
|
|
180
|
+
for out_key, gcl_path in fields.items():
|
|
181
|
+
if gcl_path is None:
|
|
182
|
+
continue
|
|
183
|
+
val = self._get_gcl_path(transaction, gcl_path)
|
|
184
|
+
if val is None:
|
|
185
|
+
continue
|
|
186
|
+
|
|
187
|
+
if (
|
|
188
|
+
out_key == "original"
|
|
189
|
+
and hasattr(val, "currency")
|
|
190
|
+
and hasattr(val, "amount")
|
|
191
|
+
):
|
|
192
|
+
metakv[out_key] = f"{val.currency} {val.amount}"
|
|
193
|
+
else:
|
|
194
|
+
metakv[out_key] = val
|
|
177
195
|
|
|
178
196
|
metakv.update(custom_metadata)
|
|
179
|
-
|
|
180
197
|
return metakv
|
|
181
198
|
|
|
182
199
|
def get_narration(self, transaction: BankTransaction) -> str:
|
|
@@ -264,6 +281,7 @@ class GoCardLessImporter(beangulp.Importer):
|
|
|
264
281
|
status: str,
|
|
265
282
|
asset_account: str,
|
|
266
283
|
custom_metadata: Dict[str, Any],
|
|
284
|
+
account_config: Optional[AccountConfig] = None,
|
|
267
285
|
) -> Optional[data.Transaction]:
|
|
268
286
|
"""
|
|
269
287
|
Creates a Beancount transaction entry from a GoCardless transaction.
|
|
@@ -275,6 +293,7 @@ class GoCardLessImporter(beangulp.Importer):
|
|
|
275
293
|
status (str): The transaction status ('booked' or 'pending').
|
|
276
294
|
asset_account (str): The Beancount asset account.
|
|
277
295
|
custom_metadata (Dict[str, Any]): Custom metadata from config
|
|
296
|
+
account_config (Optional[AccountConfig]): Account configuration for metadata options.
|
|
278
297
|
|
|
279
298
|
Returns:
|
|
280
299
|
Optional[data.Transaction]: The created Beancount transaction entry, or None if date is invalid.
|
|
@@ -282,7 +301,7 @@ class GoCardLessImporter(beangulp.Importer):
|
|
|
282
301
|
logger.debug(
|
|
283
302
|
"Creating entry for transaction %s (%s)", transaction.transaction_id, status
|
|
284
303
|
)
|
|
285
|
-
metakv = self.add_metadata(transaction, custom_metadata)
|
|
304
|
+
metakv = self.add_metadata(transaction, custom_metadata, account_config)
|
|
286
305
|
meta = data.new_metadata("", 0, metakv)
|
|
287
306
|
|
|
288
307
|
trx_date = self.get_transaction_date(transaction)
|
|
@@ -377,7 +396,7 @@ class GoCardLessImporter(beangulp.Importer):
|
|
|
377
396
|
skipped = 0
|
|
378
397
|
for transaction, status in all_transactions:
|
|
379
398
|
entry = self.create_transaction_entry(
|
|
380
|
-
transaction, status, asset_account, custom_metadata
|
|
399
|
+
transaction, status, asset_account, custom_metadata, account
|
|
381
400
|
)
|
|
382
401
|
if entry is not None:
|
|
383
402
|
entries.append(entry)
|
|
@@ -477,4 +496,42 @@ class GoCardLessImporter(beangulp.Importer):
|
|
|
477
496
|
)
|
|
478
497
|
return entries
|
|
479
498
|
|
|
499
|
+
def _get_gcl_path(self, root: Any, dotted: str) -> Any:
|
|
500
|
+
cur: Any = root
|
|
501
|
+
for seg in dotted.split("."):
|
|
502
|
+
if cur is None:
|
|
503
|
+
return None
|
|
504
|
+
|
|
505
|
+
if isinstance(cur, list):
|
|
506
|
+
if not seg.isdigit():
|
|
507
|
+
return None
|
|
508
|
+
idx = int(seg)
|
|
509
|
+
if idx >= len(cur):
|
|
510
|
+
return None
|
|
511
|
+
cur = cur[idx]
|
|
512
|
+
continue
|
|
513
|
+
|
|
514
|
+
if isinstance(cur, dict):
|
|
515
|
+
cur = cur.get(seg)
|
|
516
|
+
continue
|
|
517
|
+
|
|
518
|
+
if hasattr(cur, seg):
|
|
519
|
+
cur = getattr(cur, seg)
|
|
520
|
+
continue
|
|
521
|
+
|
|
522
|
+
if hasattr(type(cur), "model_fields"):
|
|
523
|
+
model_fields = type(cur).model_fields
|
|
524
|
+
name = next(
|
|
525
|
+
(n for n, f in model_fields.items() if f.alias == seg), None
|
|
526
|
+
)
|
|
527
|
+
if name and hasattr(cur, name):
|
|
528
|
+
cur = getattr(cur, name)
|
|
529
|
+
continue
|
|
530
|
+
|
|
531
|
+
return None
|
|
532
|
+
|
|
533
|
+
if isinstance(cur, (dict, list)):
|
|
534
|
+
return None
|
|
535
|
+
return cur
|
|
536
|
+
|
|
480
537
|
cmp = ReferenceDuplicatesComparator(["nordref"])
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Mock client for testing without real API calls.
|
|
3
|
+
Returns synthetic demo data for CLI testing.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import logging
|
|
7
|
+
from typing import Optional, List, Dict, Any
|
|
8
|
+
from datetime import datetime, timedelta
|
|
9
|
+
|
|
10
|
+
from .client import GoCardlessClient
|
|
11
|
+
from .models import (
|
|
12
|
+
Institution,
|
|
13
|
+
Requisition,
|
|
14
|
+
Account,
|
|
15
|
+
AccountBalance,
|
|
16
|
+
AccountInfo,
|
|
17
|
+
BalanceSchema,
|
|
18
|
+
BalanceAmountSchema,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class MockGoCardlessClient(GoCardlessClient):
|
|
25
|
+
"""Mock client that returns synthetic demo data instead of making API calls."""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
secret_id: str,
|
|
30
|
+
secret_key: str,
|
|
31
|
+
cache_options: Optional[Dict[str, Any]] = None,
|
|
32
|
+
):
|
|
33
|
+
"""Initialize mock client."""
|
|
34
|
+
logger.info("Initializing MockGoCardlessClient")
|
|
35
|
+
self._token = "mock-token"
|
|
36
|
+
self.secret_id = secret_id
|
|
37
|
+
self.secret_key = secret_key
|
|
38
|
+
|
|
39
|
+
def list_banks(self, country: Optional[str] = None) -> List[str]:
|
|
40
|
+
"""Get bank names."""
|
|
41
|
+
institutions = self.get_institutions(country)
|
|
42
|
+
return [inst.name for inst in institutions]
|
|
43
|
+
|
|
44
|
+
def get_institutions(self, country: Optional[str] = None) -> List[Institution]:
|
|
45
|
+
"""Get demo institutions."""
|
|
46
|
+
logger.debug(f"MockClient: Getting institutions for country {country}")
|
|
47
|
+
institutions = [
|
|
48
|
+
Institution(
|
|
49
|
+
id="SOGEFRPP",
|
|
50
|
+
name="Société Générale",
|
|
51
|
+
bic="SOGEFRPP",
|
|
52
|
+
transaction_total_days="730",
|
|
53
|
+
countries=["FR"],
|
|
54
|
+
logo=None,
|
|
55
|
+
),
|
|
56
|
+
Institution(
|
|
57
|
+
id="BNPAFRPP",
|
|
58
|
+
name="BNP Paribas",
|
|
59
|
+
bic="BNPAFRPP",
|
|
60
|
+
transaction_total_days="730",
|
|
61
|
+
countries=["FR"],
|
|
62
|
+
logo=None,
|
|
63
|
+
),
|
|
64
|
+
Institution(
|
|
65
|
+
id="DEUTSCHE",
|
|
66
|
+
name="Deutsche Bank",
|
|
67
|
+
bic="DEUTSCH",
|
|
68
|
+
transaction_total_days="730",
|
|
69
|
+
countries=["DE"],
|
|
70
|
+
logo=None,
|
|
71
|
+
),
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
if country:
|
|
75
|
+
institutions = [i for i in institutions if country in i.countries]
|
|
76
|
+
|
|
77
|
+
return institutions
|
|
78
|
+
|
|
79
|
+
def get_institution(self, institution_id: str) -> Institution:
|
|
80
|
+
"""Get specific institution."""
|
|
81
|
+
logger.debug(f"MockClient: Getting institution {institution_id}")
|
|
82
|
+
institutions = self.get_institutions()
|
|
83
|
+
for inst in institutions:
|
|
84
|
+
if inst.id == institution_id:
|
|
85
|
+
return inst
|
|
86
|
+
raise ValueError(f"Institution {institution_id} not found")
|
|
87
|
+
|
|
88
|
+
def get_requisitions(self) -> List[Requisition]:
|
|
89
|
+
"""Get demo requisitions."""
|
|
90
|
+
logger.debug("MockClient: Getting requisitions")
|
|
91
|
+
accounts = self.get_accounts()
|
|
92
|
+
if not accounts:
|
|
93
|
+
return []
|
|
94
|
+
|
|
95
|
+
requisitions = []
|
|
96
|
+
references = [
|
|
97
|
+
"main-checking",
|
|
98
|
+
"savings-account",
|
|
99
|
+
"joint-account",
|
|
100
|
+
"business-account",
|
|
101
|
+
]
|
|
102
|
+
|
|
103
|
+
for i in range(0, len(accounts), 2):
|
|
104
|
+
ref_idx = (i // 2) % len(references)
|
|
105
|
+
requisitions.append(
|
|
106
|
+
Requisition(
|
|
107
|
+
id=f"req_{i}",
|
|
108
|
+
created=datetime.now().isoformat(),
|
|
109
|
+
redirect="http://localhost",
|
|
110
|
+
reference=references[ref_idx],
|
|
111
|
+
status="LINKED",
|
|
112
|
+
institution_id="SOGEFRPP" if i % 2 == 0 else "BNPAFRPP",
|
|
113
|
+
accounts=[acc.id for acc in accounts[i : i + 2]],
|
|
114
|
+
access_valid_for_days=90,
|
|
115
|
+
)
|
|
116
|
+
)
|
|
117
|
+
return requisitions
|
|
118
|
+
|
|
119
|
+
def get_account(self, account_id: str) -> Account:
|
|
120
|
+
"""Get demo account."""
|
|
121
|
+
logger.debug(f"MockClient: Getting account {account_id}")
|
|
122
|
+
accounts = self.get_accounts()
|
|
123
|
+
for acc in accounts:
|
|
124
|
+
if acc.id == account_id:
|
|
125
|
+
return acc
|
|
126
|
+
raise ValueError(f"Account {account_id} not found")
|
|
127
|
+
|
|
128
|
+
def get_accounts(self) -> List[Account]:
|
|
129
|
+
"""Get demo accounts."""
|
|
130
|
+
return [
|
|
131
|
+
Account(
|
|
132
|
+
id="acc_001",
|
|
133
|
+
created=datetime.now().isoformat(),
|
|
134
|
+
status="READY",
|
|
135
|
+
institution_id="SOGEFRPP",
|
|
136
|
+
name="Main Checking",
|
|
137
|
+
iban="FR1420041010050500013M02606",
|
|
138
|
+
),
|
|
139
|
+
Account(
|
|
140
|
+
id="acc_002",
|
|
141
|
+
created=datetime.now().isoformat(),
|
|
142
|
+
status="READY",
|
|
143
|
+
institution_id="BNPAFRPP",
|
|
144
|
+
name="Savings Account",
|
|
145
|
+
iban="FR7613807000013000060004391",
|
|
146
|
+
),
|
|
147
|
+
]
|
|
148
|
+
|
|
149
|
+
def get_account_balances(self, account_id: str) -> AccountBalance:
|
|
150
|
+
"""Get demo account balances."""
|
|
151
|
+
logger.debug(f"MockClient: Getting balances for account {account_id}")
|
|
152
|
+
return AccountBalance(
|
|
153
|
+
balances=[
|
|
154
|
+
BalanceSchema(
|
|
155
|
+
balance_amount=BalanceAmountSchema(
|
|
156
|
+
amount="2547.83", currency="EUR"
|
|
157
|
+
),
|
|
158
|
+
balance_type="interimAvailable",
|
|
159
|
+
),
|
|
160
|
+
BalanceSchema(
|
|
161
|
+
balance_amount=BalanceAmountSchema(
|
|
162
|
+
amount="2490.00", currency="EUR"
|
|
163
|
+
),
|
|
164
|
+
balance_type="closingBooked",
|
|
165
|
+
),
|
|
166
|
+
]
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
def list_accounts(self) -> List[AccountInfo]:
|
|
170
|
+
"""Get all accounts with expiry info."""
|
|
171
|
+
logger.debug("MockClient: Getting all accounts")
|
|
172
|
+
accounts = self.get_accounts()
|
|
173
|
+
requisitions = self.get_requisitions()
|
|
174
|
+
result = []
|
|
175
|
+
|
|
176
|
+
for i, account in enumerate(accounts):
|
|
177
|
+
account_dict = account.model_dump()
|
|
178
|
+
req = requisitions[i // 2] if requisitions else None
|
|
179
|
+
req_status = req.status if req else "LINKED"
|
|
180
|
+
req_created = req.created if req else datetime.now().isoformat()
|
|
181
|
+
req_id = req.id if req else f"mock-req-{i // 2}"
|
|
182
|
+
req_ref = req.reference if req else f"mock-ref-{i // 2}"
|
|
183
|
+
access_days = (
|
|
184
|
+
req.access_valid_for_days if req and req.access_valid_for_days else 90
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
created_date = datetime.fromisoformat(req_created.replace("Z", "+00:00"))
|
|
188
|
+
expiry_date = created_date + timedelta(days=access_days)
|
|
189
|
+
is_expired = req_status == "EX"
|
|
190
|
+
|
|
191
|
+
account_dict.update(
|
|
192
|
+
{
|
|
193
|
+
"requisition_id": req_id,
|
|
194
|
+
"requisition_reference": req_ref,
|
|
195
|
+
"institution_id": account.institution_id or "mock-inst",
|
|
196
|
+
"requisition_status": req_status,
|
|
197
|
+
"access_valid_until": expiry_date.isoformat(),
|
|
198
|
+
"is_expired": is_expired,
|
|
199
|
+
}
|
|
200
|
+
)
|
|
201
|
+
result.append(account_dict)
|
|
202
|
+
return result
|
|
203
|
+
|
|
204
|
+
def create_requisition(self, *args, **kwargs):
|
|
205
|
+
raise NotImplementedError("MockClient does not support creating requisitions")
|
|
206
|
+
|
|
207
|
+
def create_bank_link(self, *args, **kwargs):
|
|
208
|
+
raise NotImplementedError("MockClient does not support creating bank links")
|
|
209
|
+
|
|
210
|
+
def delete_requisition(self, *args, **kwargs):
|
|
211
|
+
raise NotImplementedError("MockClient does not support deleting requisitions")
|
|
212
|
+
|
|
213
|
+
def find_requisition_by_reference(self, reference: str) -> Optional[Requisition]:
|
|
214
|
+
"""Find requisition by reference."""
|
|
215
|
+
requisitions = self.get_requisitions()
|
|
216
|
+
return next((req for req in requisitions if req.reference == reference), None)
|
|
217
|
+
|
|
218
|
+
def get_all_accounts(self) -> List[AccountInfo]:
|
|
219
|
+
"""Alias for list_accounts."""
|
|
220
|
+
return self.list_accounts()
|
beancount_gocardless/models.py
CHANGED
|
@@ -37,8 +37,10 @@ class StatusEnum(str, Enum):
|
|
|
37
37
|
class BalanceAmountSchema(BaseModel):
|
|
38
38
|
"""Balance amount schema."""
|
|
39
39
|
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
41
|
+
|
|
42
|
+
amount: str = Field(default=None)
|
|
43
|
+
currency: str = Field(default=None)
|
|
42
44
|
|
|
43
45
|
|
|
44
46
|
class BalanceSchema(BaseModel):
|
|
@@ -198,10 +200,12 @@ class CurrencyExchangeSchema(BaseModel):
|
|
|
198
200
|
class BalanceAfterTransactionSchema(BaseModel):
|
|
199
201
|
"""Balance after transaction schema."""
|
|
200
202
|
|
|
201
|
-
model_config = ConfigDict(
|
|
203
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
202
204
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
+
balance_amount: Optional[BalanceAmountSchema] = Field(
|
|
206
|
+
default=None, validation_alias="balanceAmount"
|
|
207
|
+
)
|
|
208
|
+
balance_type: Optional[str] = Field(default=None, validation_alias="balanceType")
|
|
205
209
|
|
|
206
210
|
|
|
207
211
|
class TransactionSchema(BaseModel):
|
|
@@ -280,6 +284,9 @@ class BankTransaction(BaseModel):
|
|
|
280
284
|
)
|
|
281
285
|
creditor_name: Optional[str] = Field(None, description="Creditor name.")
|
|
282
286
|
creditor_account: Optional[AccountSchema] = None
|
|
287
|
+
ultimate_creditor: Optional[str] = Field(
|
|
288
|
+
default=None, description="Ultimate creditor."
|
|
289
|
+
)
|
|
283
290
|
currency_exchange: Optional[List[CurrencyExchangeSchema]] = None
|
|
284
291
|
|
|
285
292
|
@field_validator("currency_exchange", mode="before")
|
|
@@ -308,11 +315,13 @@ class BankTransaction(BaseModel):
|
|
|
308
315
|
booking_date_time: Optional[str] = Field(None, description="Booking date and time.")
|
|
309
316
|
value_date_time: Optional[str] = Field(None, description="Value date and time.")
|
|
310
317
|
entry_reference: Optional[str] = Field(None, description="Entry reference.")
|
|
311
|
-
|
|
312
|
-
None,
|
|
318
|
+
additional_data_structured: Optional[Dict[str, Any]] = Field(
|
|
319
|
+
default=None,
|
|
320
|
+
description="Additional structured information.",
|
|
313
321
|
)
|
|
314
322
|
card_transaction: Optional[Dict[str, Any]] = Field(
|
|
315
|
-
None,
|
|
323
|
+
default=None,
|
|
324
|
+
description="Card transaction details.",
|
|
316
325
|
)
|
|
317
326
|
merchant_category_code: Optional[str] = Field(
|
|
318
327
|
None, description="Merchant category code."
|
|
@@ -576,6 +585,8 @@ class AccountConfig(BaseModel):
|
|
|
576
585
|
metadata: Dict[str, Any] = {}
|
|
577
586
|
transaction_types: List[str] = ["booked", "pending"]
|
|
578
587
|
preferred_balance_type: Optional[str] = None
|
|
588
|
+
exclude_default_metadata: List[str] = []
|
|
589
|
+
metadata_fields: Optional[Dict[str, str]] = None
|
|
579
590
|
|
|
580
591
|
@field_validator("transaction_types")
|
|
581
592
|
@classmethod
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def load_dotenv(dotenv_path: Optional[str] = None) -> None:
|
|
7
|
+
"""
|
|
8
|
+
Simple .env loader that searches in current and parent directories.
|
|
9
|
+
Only handles simple KEY=VALUE pairs and ignores comments.
|
|
10
|
+
"""
|
|
11
|
+
if dotenv_path:
|
|
12
|
+
search_paths = [Path(dotenv_path)]
|
|
13
|
+
else:
|
|
14
|
+
# Search in current and parent directories
|
|
15
|
+
current = Path.cwd().resolve()
|
|
16
|
+
search_paths = [current / ".env"] + [p / ".env" for p in current.parents]
|
|
17
|
+
|
|
18
|
+
for path in search_paths:
|
|
19
|
+
if path.exists() and path.is_file():
|
|
20
|
+
try:
|
|
21
|
+
with open(path, "r") as f:
|
|
22
|
+
for line in f:
|
|
23
|
+
line = line.strip()
|
|
24
|
+
# Ignore comments and empty lines
|
|
25
|
+
if not line or line.startswith("#"):
|
|
26
|
+
continue
|
|
27
|
+
# Basic KEY=VALUE parsing
|
|
28
|
+
if "=" in line:
|
|
29
|
+
key, value = line.split("=", 1)
|
|
30
|
+
key = key.strip()
|
|
31
|
+
# Strip quotes and whitespace
|
|
32
|
+
value = value.strip().strip("'\"")
|
|
33
|
+
# Only set if not already present in environment
|
|
34
|
+
if key and key not in os.environ:
|
|
35
|
+
os.environ[key] = value
|
|
36
|
+
return # Stop after the first .env file found and successfully read
|
|
37
|
+
except Exception:
|
|
38
|
+
# Silently fail if we can't read a specific .env file
|
|
39
|
+
continue
|