beancount-gocardless 0.1.12__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.
@@ -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)
@@ -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, transaction: BankTransaction, custom_metadata: Dict[str, Any]
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
- # Transaction ID
156
- if transaction.transaction_id:
157
- metakv["nordref"] = transaction.transaction_id
158
-
159
- # Names
160
- if transaction.creditor_name:
161
- metakv["creditorName"] = transaction.creditor_name
162
- if transaction.debtor_name:
163
- metakv["debtorName"] = transaction.debtor_name
164
-
165
- # Currency exchange
166
- if (
167
- transaction.currency_exchange
168
- and transaction.currency_exchange[0].instructed_amount
169
- ):
170
- instructedAmount = transaction.currency_exchange[0].instructed_amount
171
- metakv["original"] = (
172
- f"{instructedAmount.currency} {instructedAmount.amount}"
173
- )
174
-
175
- if transaction.booking_date:
176
- metakv["bookingDate"] = transaction.booking_date
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)
@@ -400,36 +419,61 @@ class GoCardLessImporter(beangulp.Importer):
400
419
  for b in balances.balances
401
420
  ],
402
421
  )
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
422
 
411
- if expected_balance:
423
+ # Prioritized balance selection
424
+ PRIORITY = {
425
+ "expected": 0,
426
+ "closingBooked": 1,
427
+ "interimBooked": 2,
428
+ "interimAvailable": 3,
429
+ "openingBooked": 4,
430
+ }
431
+ if account.preferred_balance_type:
432
+ PRIORITY[account.preferred_balance_type] = -1
433
+
434
+ # Sort balances based on priority, with unknown types at the end
435
+ sorted_balances = sorted(
436
+ balances.balances, key=lambda b: PRIORITY.get(b.balance_type, 99)
437
+ )
438
+
439
+ if sorted_balances:
440
+ selected_balance = sorted_balances[0]
412
441
  balance_amount = amount.Amount(
413
- D(str(expected_balance.balance_amount.amount)),
414
- expected_balance.balance_amount.currency,
442
+ D(str(selected_balance.balance_amount.amount)),
443
+ selected_balance.balance_amount.currency,
415
444
  )
445
+
446
+ # Determine balance date
447
+ if selected_balance.reference_date:
448
+ try:
449
+ balance_date = date.fromisoformat(
450
+ selected_balance.reference_date
451
+ ) + timedelta(days=1)
452
+ except ValueError:
453
+ balance_date = date.today() + timedelta(days=1)
454
+ else:
455
+ balance_date = date.today() + timedelta(days=1)
456
+
416
457
  balance_meta = {}
417
- balance_amounts = [
418
- D(str(b.balance_amount.amount)) for b in balances.balances
419
- ]
420
- if len(set(balance_amounts)) > 1:
421
- detail_parts = [
422
- f"{b.balance_type}: {b.balance_amount.amount} {b.balance_amount.currency}"
423
- for b in balances.balances
424
- ]
425
- detail = " / ".join(detail_parts)
426
- balance_meta["detail"] = detail
458
+
459
+ # Collect all distinct balance values for metadata
460
+ distinct_details = []
461
+ seen_values = set()
462
+ for b in sorted_balances:
463
+ val_str = f"{b.balance_amount.amount} {b.balance_amount.currency}"
464
+ if val_str not in seen_values:
465
+ distinct_details.append(f"{b.balance_type}: {val_str}")
466
+ seen_values.add(val_str)
467
+
468
+ balance_meta["detail"] = " / ".join(distinct_details)
469
+
427
470
  # Include custom metadata from config for consistency with transactions
428
471
  balance_meta.update(custom_metadata)
429
472
  meta = data.new_metadata("", 0, balance_meta)
473
+
430
474
  balance_entry = data.Balance(
431
475
  meta=meta,
432
- date=date.today() + timedelta(days=1),
476
+ date=balance_date,
433
477
  account=asset_account,
434
478
  amount=balance_amount,
435
479
  tolerance=None,
@@ -437,26 +481,13 @@ class GoCardLessImporter(beangulp.Importer):
437
481
  )
438
482
  entries.append(balance_entry)
439
483
  logger.debug(
440
- "Added balance assertion for account %s using expected balance: %s %s",
484
+ "Added balance assertion for account %s using %s balance: %s %s",
441
485
  account_id,
486
+ selected_balance.balance_type,
442
487
  balance_amount,
443
- date.today() + timedelta(days=1),
488
+ balance_date,
444
489
  )
445
490
 
446
- # Log other balances if they differ from expected
447
- expected_amount = D(str(expected_balance.balance_amount.amount))
448
- for bal in other_balances:
449
- other_amount = D(str(bal.balance_amount.amount))
450
- if other_amount != expected_amount:
451
- logger.info(
452
- "Account %s has different balance for type %s: %s %s vs expected %s",
453
- account_id,
454
- bal.balance_type,
455
- other_amount,
456
- bal.balance_amount.currency,
457
- expected_amount,
458
- )
459
-
460
491
  logger.info(
461
492
  "Processed %d total transactions across %d accounts, created %d entries",
462
493
  total_transactions,
@@ -465,4 +496,42 @@ class GoCardLessImporter(beangulp.Importer):
465
496
  )
466
497
  return entries
467
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
+
468
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()