beancount-gocardless 0.1.8__py3-none-any.whl → 0.1.9__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.
@@ -1,37 +1,64 @@
1
- from datetime import date
1
+ import logging
2
+ from datetime import date, timedelta
2
3
  from os import path
4
+ from typing import Dict, List, Optional, Any, Tuple
3
5
  import beangulp
4
6
  import yaml
5
7
  from beancount.core import amount, data, flags
6
8
  from beancount.core.number import D
7
- from .client import NordigenClient
8
9
 
10
+ from .client import GoCardlessClient
11
+ from .models import BankTransaction
9
12
 
10
- class NordigenImporter(beangulp.Importer):
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ class ReferenceDuplicatesComparator:
17
+ def __init__(self, refs: List[str] = ["ref"]) -> None:
18
+ self.refs = refs
19
+
20
+ def __call__(self, entry1: data.Transaction, entry2: data.Transaction) -> bool:
21
+ entry1Refs = set()
22
+ entry2Refs = set()
23
+ for ref in self.refs:
24
+ if ref in entry1.meta:
25
+ entry1Refs.add(entry1.meta[ref])
26
+ if ref in entry2.meta:
27
+ entry2Refs.add(entry2.meta[ref])
28
+
29
+ return bool(entry1Refs & entry2Refs)
30
+
31
+
32
+ class GoCardLessImporter(beangulp.Importer):
11
33
  """
12
- An importer for Nordigen API with improved structure and extensibility.
34
+ An importer for GoCardless API with improved structure and extensibility.
13
35
 
14
36
  Attributes:
15
- config (dict): Configuration loaded from the YAML file.
16
- _client (NordigenClient): Instance of the Nordigen API client.
17
-
37
+ config (Optional[Dict[str, Any]]): Configuration loaded from the YAML file.
38
+ _client (Optional[GoCardlessClient]): Instance of the GoCardless API client.
18
39
  """
19
40
 
20
- def __init__(self):
21
- """Initialize the NordigenImporter."""
22
- self.config = None
23
- self._client = None
41
+ def __init__(self) -> None:
42
+ """Initialize the GoCardLessImporter."""
43
+ logger.debug("Initializing GoCardLessImporter")
44
+ self.config: Optional[Dict[str, Any]] = None
45
+ self._client: Optional[GoCardlessClient] = None
24
46
 
25
47
  @property
26
- def client(self):
48
+ def client(self) -> GoCardlessClient:
27
49
  """
28
- Lazily initializes and returns the Nordigen API client.
50
+ Lazily initializes and returns the GoCardless API client.
29
51
 
30
52
  Returns:
31
- NordigenClient: The initialized Nordigen API client.
53
+ GoCardlessClient: The initialized GoCardless API client.
54
+
55
+ Raises:
56
+ ValueError: If config is not loaded.
32
57
  """
33
58
  if not self._client:
34
- self._client = NordigenClient(
59
+ if not self.config:
60
+ raise ValueError("Config not loaded. Call load_config() first.")
61
+ self._client = GoCardlessClient(
35
62
  self.config["secret_id"],
36
63
  self.config["secret_key"],
37
64
  cache_options=self.config.get("cache_options", None),
@@ -41,29 +68,32 @@ class NordigenImporter(beangulp.Importer):
41
68
 
42
69
  def identify(self, filepath: str) -> bool:
43
70
  """
44
- Identifies if the given file is a Nordigen configuration file.
71
+ Identifies if the given file is a GoCardless configuration file.
45
72
 
46
73
  Args:
47
74
  filepath (str): The path to the file.
48
75
 
49
76
  Returns:
50
- bool: True if the file is a Nordigen configuration file, False otherwise.
77
+ bool: True if the file is a GoCardless configuration file, False otherwise.
51
78
  """
52
- return path.basename(filepath).endswith("nordigen.yaml")
79
+ result = path.basename(filepath).endswith("gocardless.yaml")
80
+ logger.debug("Identifying file %s: %s", filepath, result)
81
+ return result
53
82
 
54
83
  def account(self, filepath: str) -> str:
55
84
  """
56
85
  Returns an empty string as account (not directly used in this importer).
57
86
 
58
87
  Args:
59
- filepath (str): The path to the file. Not used in this implementation.
88
+ filepath (str): The path to the file. Not used in this implementation.
60
89
 
61
90
  Returns:
62
91
  str: An empty string.
63
92
  """
93
+ logger.debug("Returning account for %s: ''", filepath)
64
94
  return "" # We get the account from the config file
65
95
 
66
- def load_config(self, filepath: str):
96
+ def load_config(self, filepath: str) -> Optional[Dict[str, Any]]:
67
97
  """
68
98
  Loads configuration from the specified YAML file.
69
99
 
@@ -71,8 +101,9 @@ class NordigenImporter(beangulp.Importer):
71
101
  filepath (str): The path to the YAML configuration file.
72
102
 
73
103
  Returns:
74
- dict: The loaded configuration dictionary. Also sets the `self.config` attribute.
104
+ Dict[str, Any]: The loaded configuration dictionary. Also sets the `self.config` attribute.
75
105
  """
106
+ logger.debug("Loading config from %s", filepath)
76
107
  with open(filepath, "r") as f:
77
108
  raw_config = f.read()
78
109
  expanded_config = path.expandvars(
@@ -82,142 +113,145 @@ class NordigenImporter(beangulp.Importer):
82
113
 
83
114
  return self.config
84
115
 
85
- def get_transactions_data(self, account_id):
86
- """
87
- Retrieves transaction data for a given account ID from the Nordigen API.
88
-
89
- Args:
90
- account_id (str): The Nordigen account ID.
91
-
92
- Returns:
93
- dict: The transaction data returned by the API.
94
- """
95
- transactions_data = self.client.get_transactions(account_id)
96
-
97
- return transactions_data
98
-
99
- def get_all_transactions(self, transactions_data):
116
+ def get_all_transactions(
117
+ self, booked: List[BankTransaction], pending: List[BankTransaction]
118
+ ) -> List[Tuple[BankTransaction, str]]:
100
119
  """
101
120
  Combines booked and pending transactions and sorts them by date.
102
121
 
103
122
  Args:
104
- transactions_data (dict): The transaction data from the API,
105
- containing 'booked' and 'pending' lists.
123
+ booked (List[BankTransaction]): The booked transaction data from the API.
124
+ pending (List[BankTransaction]): The pending transaction data from the API.
106
125
 
107
126
  Returns:
108
- list: A sorted list of tuples, where each tuple contains
109
- a transaction dictionary and its status ('booked' or 'pending').
127
+ List[Tuple[BankTransaction, str]]: A sorted list of tuples, where each tuple contains
128
+ a transaction and its status ('booked' or 'pending').
110
129
  """
111
- all_transactions = [
112
- (tx, "booked") for tx in transactions_data.get("booked", [])
113
- ] + [(tx, "pending") for tx in transactions_data.get("pending", [])]
130
+ all_transactions = [(tx, "booked") for tx in booked] + [
131
+ (tx, "pending") for tx in pending
132
+ ]
114
133
  return sorted(
115
134
  all_transactions,
116
- key=lambda x: x[0].get("valueDate") or x[0].get("bookingDate"),
135
+ key=lambda x: x[0].value_date or x[0].booking_date or "",
117
136
  )
118
137
 
119
- def add_metadata(self, transaction, custom_metadata):
138
+ def add_metadata(
139
+ self, transaction: BankTransaction, custom_metadata: Dict[str, Any]
140
+ ) -> Dict[str, Any]:
120
141
  """
121
142
  Extracts metadata from a transaction and returns it as a dictionary.
122
143
 
123
144
  This method can be overridden in subclasses to customize metadata extraction.
124
145
 
125
146
  Args:
126
- transaction (dict): The transaction data from the API.
127
- custom_metadata (dict): Custom metadata from the config file.
147
+ transaction (BankTransaction): The transaction data from the API.
148
+ custom_metadata (Dict[str, Any]): Custom metadata from the config file.
128
149
 
129
150
  Returns:
130
- dict: A dictionary of metadata key-value pairs.
151
+ Dict[str, Any]: A dictionary of metadata key-value pairs.
131
152
  """
132
- metakv = {}
153
+ metakv: Dict[str, Any] = {}
133
154
 
134
155
  # Transaction ID
135
- if "transactionId" in transaction:
136
- metakv["nordref"] = transaction["transactionId"]
156
+ if transaction.transaction_id:
157
+ metakv["nordref"] = transaction.transaction_id
137
158
 
138
159
  # Names
139
- if "creditorName" in transaction:
140
- metakv["creditorName"] = transaction["creditorName"]
141
- if "debtorName" in transaction:
142
- metakv["debtorName"] = transaction["debtorName"]
160
+ if transaction.creditor_name:
161
+ metakv["creditorName"] = transaction.creditor_name
162
+ if transaction.debtor_name:
163
+ metakv["debtorName"] = transaction.debtor_name
143
164
 
144
165
  # Currency exchange
145
- if "currencyExchange" in transaction:
146
- instructedAmount = transaction["currencyExchange"]["instructedAmount"]
166
+ if (
167
+ transaction.currency_exchange
168
+ and transaction.currency_exchange[0].instructed_amount
169
+ ):
170
+ instructedAmount = transaction.currency_exchange[0].instructed_amount
147
171
  metakv["original"] = (
148
- f"{instructedAmount['currency']} {instructedAmount['amount']}"
172
+ f"{instructedAmount.currency} {instructedAmount.amount}"
149
173
  )
150
174
 
151
- if transaction.get("bookingDate"):
152
- metakv["bookingDate"] = transaction["bookingDate"]
175
+ if transaction.booking_date:
176
+ metakv["bookingDate"] = transaction.booking_date
153
177
 
154
178
  metakv.update(custom_metadata)
155
179
 
156
180
  return metakv
157
181
 
158
- def get_narration(self, transaction):
182
+ def get_narration(self, transaction: BankTransaction) -> str:
159
183
  """
160
184
  Extracts the narration from a transaction.
161
185
 
162
186
  This method can be overridden in subclasses to customize narration extraction.
163
187
 
164
188
  Args:
165
- transaction (dict): The transaction data from the API.
189
+ transaction (BankTransaction): The transaction data from the API.
166
190
 
167
191
  Returns:
168
192
  str: The extracted narration.
169
193
  """
170
194
  narration = ""
171
195
 
172
- if "remittanceInformationUnstructured" in transaction:
173
- narration += transaction["remittanceInformationUnstructured"]
196
+ if transaction.remittance_information_unstructured:
197
+ narration += transaction.remittance_information_unstructured
174
198
 
175
- if "remittanceInformationUnstructuredArray" in transaction:
176
- narration += " ".join(transaction["remittanceInformationUnstructuredArray"])
199
+ if transaction.remittance_information_unstructured_array:
200
+ narration += " ".join(transaction.remittance_information_unstructured_array)
177
201
 
178
202
  return narration
179
203
 
180
- def get_payee(self, transaction):
204
+ def get_payee(self, transaction: BankTransaction) -> str:
181
205
  """
182
206
  Extracts the payee from a transaction.
183
207
 
184
- This method can be overridden in subclasses to customize payee extraction. The default
208
+ This method can be overridden in subclasses to customize payee extraction. The default
185
209
  implementation returns an empty string.
186
210
 
187
211
  Args:
188
- transaction (dict): The transaction data from the API.
212
+ transaction (Dict[str, Any]): The transaction data from the API.
189
213
 
190
214
  Returns:
191
215
  str: The extracted payee (or an empty string by default).
192
-
193
216
  """
194
217
  return ""
195
218
 
196
- def get_transaction_date(self, transaction):
219
+ def get_transaction_date(self, transaction: BankTransaction) -> Optional[date]:
197
220
  """
198
- Extracts the transaction date from a transaction. Prefers 'valueDate',
221
+ Extracts the transaction date from a transaction. Prefers 'valueDate',
199
222
  falls back to 'bookingDate'.
200
223
 
201
224
  This method can be overridden in subclasses to customize date extraction.
202
225
 
203
226
  Args:
204
- transaction (dict): The transaction data from the API.
227
+ transaction (BankTransaction): The transaction data from the API.
205
228
 
206
229
  Returns:
207
- date: The extracted transaction date, or None if no date is found.
230
+ Optional[date]: The extracted transaction date, or None if no date is found.
208
231
  """
209
- date_str = transaction.get("valueDate") or transaction.get("bookingDate")
232
+ date_str = transaction.value_date or transaction.booking_date
210
233
  return date.fromisoformat(date_str) if date_str else None
211
234
 
212
- def get_transaction_status(self, status):
235
+ def get_transaction_status(
236
+ self,
237
+ transaction: BankTransaction,
238
+ status: str,
239
+ metakv: Dict[str, Any],
240
+ tx_amount: amount.Amount,
241
+ asset_account: str,
242
+ ) -> str:
213
243
  """
214
- Determines the Beancount transaction flag based on the transaction status.
244
+ Determines the Beancount transaction flag based on transaction context.
215
245
 
216
246
  This method can be overridden in subclasses to customize flag assignment. The default
217
- implementation returns FLAG_OKAY for all transactions.
247
+ implementation returns FLAG_OKAY for booked transactions and FLAG_WARNING for pending.
218
248
 
219
249
  Args:
250
+ transaction (Dict[str, Any]): The transaction data from the API.
220
251
  status (str): The transaction status ('booked' or 'pending').
252
+ metakv (Dict[str, Any]): Transaction metadata.
253
+ tx_amount (amount.Amount): Transaction amount.
254
+ asset_account (str): The Beancount asset account.
221
255
 
222
256
  Returns:
223
257
  str: The Beancount transaction flag.
@@ -225,34 +259,55 @@ class NordigenImporter(beangulp.Importer):
225
259
  return flags.FLAG_OKAY if status == "booked" else flags.FLAG_WARNING
226
260
 
227
261
  def create_transaction_entry(
228
- self, transaction, status, asset_account, custom_metadata
229
- ):
262
+ self,
263
+ transaction: BankTransaction,
264
+ status: str,
265
+ asset_account: str,
266
+ custom_metadata: Dict[str, Any],
267
+ ) -> Optional[data.Transaction]:
230
268
  """
231
- Creates a Beancount transaction entry from a Nordigen transaction.
269
+ Creates a Beancount transaction entry from a GoCardless transaction.
232
270
 
233
271
  This method can be overridden in subclasses to customize entry creation.
234
272
 
235
273
  Args:
236
- transaction (dict): The transaction data from the API.
274
+ transaction (Dict[str, Any]): The transaction data from the API.
237
275
  status (str): The transaction status ('booked' or 'pending').
238
276
  asset_account (str): The Beancount asset account.
239
- custom_metadata (dict): Custom metadata from config
277
+ custom_metadata (Dict[str, Any]): Custom metadata from config
240
278
 
241
279
  Returns:
242
- data.Transaction: The created Beancount transaction entry.
280
+ Optional[data.Transaction]: The created Beancount transaction entry, or None if date is invalid.
243
281
  """
282
+ logger.debug(
283
+ "Creating entry for transaction %s (%s)", transaction.transaction_id, status
284
+ )
244
285
  metakv = self.add_metadata(transaction, custom_metadata)
245
286
  meta = data.new_metadata("", 0, metakv)
246
287
 
247
288
  trx_date = self.get_transaction_date(transaction)
289
+ if trx_date is None:
290
+ logger.debug(
291
+ "Skipping transaction %s with invalid date", transaction.transaction_id
292
+ )
293
+ return None
294
+
248
295
  narration = self.get_narration(transaction)
249
296
  payee = self.get_payee(transaction)
250
- flag = self.get_transaction_status(status)
251
297
 
252
298
  # Get transaction amount
299
+ if transaction.transaction_amount is None:
300
+ logger.debug(
301
+ "Skipping transaction %s with no amount", transaction.transaction_id
302
+ )
303
+ return None
253
304
  tx_amount = amount.Amount(
254
- D(str(transaction["transactionAmount"]["amount"])),
255
- transaction["transactionAmount"]["currency"],
305
+ D(str(transaction.transaction_amount.amount)),
306
+ transaction.transaction_amount.currency,
307
+ )
308
+
309
+ flag = self.get_transaction_status(
310
+ transaction, status, metakv, tx_amount, asset_account
256
311
  )
257
312
 
258
313
  return data.Transaction(
@@ -277,63 +332,129 @@ class NordigenImporter(beangulp.Importer):
277
332
 
278
333
  def extract(self, filepath: str, existing: data.Entries) -> data.Entries:
279
334
  """
280
- Extracts Beancount entries from Nordigen transactions.
335
+ Extracts Beancount entries from GoCardless transactions.
281
336
 
282
337
  Args:
283
338
  filepath (str): The path to the YAML configuration file.
284
- existing (data.Entries): Existing Beancount entries (not used in this implementation).
339
+ existing (data.Entries): Existing Beancount entries (not used in this implementation).
285
340
 
286
341
  Returns:
287
342
  data.Entries: A list of Beancount transaction entries.
288
343
  """
344
+ logger.info("Starting extraction from %s", filepath)
289
345
  self.load_config(filepath)
290
346
 
291
- entries = []
292
- for account in self.config["accounts"]:
347
+ if not self.config:
348
+ raise ValueError("No config loaded from YAML file")
349
+
350
+ entries: data.Entries = []
351
+ accounts = self.config.get("accounts", [])
352
+ total_transactions = 0
353
+ logger.info("Processing %d accounts", len(accounts))
354
+ for account in accounts:
293
355
  account_id = account["id"]
294
356
  asset_account = account["asset_account"]
295
357
  # Use get() with a default empty dict for custom_metadata
296
358
  custom_metadata = account.get("metadata", {})
297
359
 
298
- transactions_data = self.get_transactions_data(account_id)
299
- all_transactions = self.get_all_transactions(transactions_data)
360
+ logger.debug("Fetching transactions for account %s", account_id)
361
+ account_transactions = self.client.get_account_transactions(account_id)
362
+ booked = account_transactions.transactions.get("booked", [])
363
+ pending = account_transactions.transactions.get("pending", [])
364
+ all_transactions = self.get_all_transactions(booked, pending)
365
+ logger.debug(
366
+ "Fetched %d booked and %d pending transactions for account %s",
367
+ len(booked),
368
+ len(pending),
369
+ account_id,
370
+ )
371
+ total_transactions += len(booked) + len(pending)
300
372
 
373
+ skipped = 0
301
374
  for transaction, status in all_transactions:
302
375
  entry = self.create_transaction_entry(
303
376
  transaction, status, asset_account, custom_metadata
304
377
  )
305
- entries.append(entry)
306
-
307
- return entries
308
-
309
- def cmp(self, entry1: data.Transaction, entry2: data.Transaction):
310
- """
311
- Compares two transactions based on their 'nordref' metadata.
378
+ if entry is not None:
379
+ entries.append(entry)
380
+ else:
381
+ skipped += 1
382
+ if skipped > 0:
383
+ logger.warning(
384
+ "Skipped %d invalid transactions for account %s",
385
+ skipped,
386
+ account_id,
387
+ )
312
388
 
313
- Used for sorting transactions. This assumes that 'nordref' is a unique
314
- identifier for each transaction.
389
+ # Add balance assertion at the end of the account's transactions
390
+ balances = self.client.get_account_balances(account_id)
391
+ logger.debug(
392
+ "Available balances for account %s: %s",
393
+ account_id,
394
+ [
395
+ (b.balance_type, b.balance_amount.amount, b.balance_amount.currency)
396
+ for b in balances.balances
397
+ ],
398
+ )
399
+ expected_balance = None
400
+ other_balances = []
401
+ for bal in balances.balances:
402
+ if bal.balance_type == "expected":
403
+ expected_balance = bal
404
+ else:
405
+ other_balances.append(bal)
406
+
407
+ if expected_balance:
408
+ balance_amount = amount.Amount(
409
+ D(str(expected_balance.balance_amount.amount)),
410
+ expected_balance.balance_amount.currency,
411
+ )
412
+ balance_meta = {}
413
+ detail_parts = [
414
+ f"{b.balance_type}: {b.balance_amount.amount} {b.balance_amount.currency}"
415
+ for b in balances.balances
416
+ ]
417
+ detail = " / ".join(detail_parts)
418
+ balance_meta["detail"] = detail
419
+ # Include custom metadata from config for consistency with transactions
420
+ balance_meta.update(custom_metadata)
421
+ meta = data.new_metadata("", 0, balance_meta)
422
+ balance_entry = data.Balance(
423
+ meta=meta,
424
+ date=date.today() + timedelta(days=1),
425
+ account=asset_account,
426
+ amount=balance_amount,
427
+ tolerance=None,
428
+ diff_amount=None,
429
+ )
430
+ entries.append(balance_entry)
431
+ logger.debug(
432
+ "Added balance assertion for account %s using expected balance: %s %s",
433
+ account_id,
434
+ balance_amount,
435
+ date.today() + timedelta(days=1),
436
+ )
315
437
 
316
- Args:
317
- entry1 (data.Transaction): The first transaction.
318
- entry2 (data.Transaction): The second transaction.
438
+ # Log other balances if they differ from expected
439
+ expected_amount = D(str(expected_balance.balance_amount.amount))
440
+ for bal in other_balances:
441
+ other_amount = D(str(bal.balance_amount.amount))
442
+ if other_amount != expected_amount:
443
+ logger.info(
444
+ "Account %s has different balance for type %s: %s %s vs expected %s",
445
+ account_id,
446
+ bal.balance_type,
447
+ other_amount,
448
+ bal.balance_amount.currency,
449
+ expected_amount,
450
+ )
451
+
452
+ logger.info(
453
+ "Processed %d total transactions across %d accounts, created %d entries",
454
+ total_transactions,
455
+ len(accounts),
456
+ len(entries),
457
+ )
458
+ return entries
319
459
 
320
- Returns:
321
- int: -1 if entry1 < entry2, 0 if entry1 == entry2, 1 if entry1 > entry2.
322
- Returns 0 if 'nordref' is not present in both.
323
- """
324
- if (
325
- "nordref" in entry1.meta
326
- and "nordref" in entry2.meta
327
- and entry1.meta["nordref"] == entry2.meta["nordref"]
328
- ):
329
- return 0 # Consider them equal if nordref matches
330
- elif (
331
- "nordref" in entry1.meta
332
- and "nordref" in entry2.meta
333
- and entry1.meta["nordref"] < entry2.meta["nordref"]
334
- ):
335
- return -1
336
- elif "nordref" in entry1.meta and "nordref" in entry2.meta:
337
- return 1
338
- else:
339
- return 0
460
+ cmp = ReferenceDuplicatesComparator(["nordref"])