/beancount_import/remove_transfer_account.py

https://github.com/jbms/beancount-import · Python · 208 lines · 174 code · 33 blank · 1 comment · 38 complexity · e43124942dd14fe95efcaa2594ed6359 MD5 · raw file

  1. """Interactive tool for removing an account that was used for pending transfers."""
  2. from typing import List, Set, Tuple, NamedTuple, Union, Dict
  3. import argparse
  4. import datetime
  5. from beancount.core.data import Transaction, Posting, Directive, Entries
  6. from beancount.core.number import MISSING, ZERO
  7. from beancount.core.amount import Amount
  8. import beancount.parser.printer
  9. from . import journal_editor
  10. from . import matching
  11. from .posting_date import get_posting_date
  12. PendingEntry = NamedTuple('PendingEntry', [
  13. ('date', datetime.date),
  14. ('transaction', Transaction),
  15. ('posting', Posting),
  16. ])
  17. def format_transaction(transaction: Transaction) -> str:
  18. printer = beancount.parser.printer.EntryPrinter()
  19. return printer(transaction)
  20. CHANGE_TYPE_INDICATOR = {0: ' ', -1: '-', 1: '+'}
  21. def get_matchable_posting_for_merge(
  22. x: PendingEntry) -> matching.MatchablePosting:
  23. if x.posting.units.number < ZERO:
  24. if len(x.transaction.postings) == 2:
  25. for p in x.transaction.postings:
  26. if id(p) != id(x.posting):
  27. posting = p
  28. break
  29. else:
  30. posting = Posting(
  31. account=x.posting.account,
  32. units=-x.posting.units,
  33. cost=None,
  34. price=None,
  35. flag=None,
  36. meta=None)
  37. return matching.MatchablePosting(
  38. posting=posting,
  39. weight=matching.get_posting_weight(posting),
  40. source_postings=[
  41. p for p in x.transaction.postings if id(p) != id(x.posting)
  42. ],
  43. )
  44. return matching.MatchablePosting(
  45. posting=x.posting,
  46. weight=matching.get_posting_weight(x.posting),
  47. source_postings=[x.posting],
  48. )
  49. def delete_posting_account(pending_entry: PendingEntry) -> PendingEntry:
  50. meta = pending_entry.posting.meta.copy()
  51. for k in list(meta.keys()):
  52. if k.startswith('ofx_'):
  53. del meta[k]
  54. new_posting = pending_entry.posting._replace(
  55. account=matching.FIXME_ACCOUNT, meta=meta)
  56. new_transaction = pending_entry.transaction._replace(postings=[
  57. new_posting if id(p) == id(pending_entry.posting) else p
  58. for p in pending_entry.transaction.postings
  59. ])
  60. return pending_entry._replace(
  61. posting=new_posting, transaction=new_transaction)
  62. def process(journal_path: str,
  63. account: str,
  64. max_day_offset: int = 30):
  65. editor = journal_editor.JournalEditor(journal_path)
  66. postings_by_units = dict(
  67. ) # type: Dict[Amount, List[PendingEntry]]
  68. pending_entries = [] # type: List[PendingEntry]
  69. def add_directive(entry: Directive):
  70. if not isinstance(entry, Transaction): return
  71. for posting in entry.postings:
  72. if posting.account != account: continue
  73. if posting.units is MISSING or posting.units is None: continue
  74. date = get_posting_date(entry, posting)
  75. pending_entry = PendingEntry(date, entry, posting)
  76. pending_entries.append(pending_entry)
  77. postings_by_units.setdefault(posting.units,
  78. []).append(pending_entry)
  79. removed_transactions = set() # type: Set[int]
  80. removed_postings = set() # type: Set[int]
  81. def remove_directive(entry: Directive):
  82. if not isinstance(entry, Transaction): return
  83. removed_transactions.add(id(entry))
  84. for posting in entry.postings:
  85. if posting.account != account: continue
  86. removed_postings.add(id(posting))
  87. for entry in editor.entries:
  88. add_directive(entry)
  89. pending_entries.sort(key=lambda x: x.date)
  90. for pending in pending_entries:
  91. if id(pending.transaction) in removed_transactions: continue
  92. if id(pending.posting) in removed_postings: continue
  93. possible = [
  94. x for x in postings_by_units.get(-pending.posting.units, [])
  95. if (id(x.transaction) not in removed_transactions and
  96. id(x.posting) not in removed_postings and
  97. abs(x.date - pending.date).days < max_day_offset)
  98. ]
  99. possible.sort(key=lambda x: abs(x.date - pending.date))
  100. candidates = [] # type: List[journal_editor.StagedChanges]
  101. for x in possible:
  102. stage = editor.stage_changes()
  103. a = pending
  104. b = x
  105. if (len(b.transaction.postings) > len(a.transaction.postings) or
  106. (len(b.transaction.postings) == len(a.transaction.postings) and
  107. len(b.transaction.narration) > len(a.transaction.narration))):
  108. a, b = b, a
  109. a_modified = delete_posting_account(a)
  110. b_modified = delete_posting_account(b)
  111. removals = []
  112. for x in a_modified, b_modified:
  113. if x.posting.units.number < ZERO:
  114. removals.append(
  115. matching.MatchablePosting(
  116. x.posting,
  117. weight=matching.get_posting_weight(x.posting),
  118. source_postings=[x.posting]))
  119. merged_transaction = matching.combine_transactions_using_match_set(
  120. (a_modified.transaction, b_modified.transaction),
  121. match_set=matching.PostingMatchSet(
  122. matches=[
  123. (get_matchable_posting_for_merge(a_modified),
  124. get_matchable_posting_for_merge(b_modified)),
  125. ],
  126. removals=removals),
  127. is_cleared=lambda x: False,
  128. )
  129. merged_transaction = matching.normalize_transaction(
  130. merged_transaction)
  131. stage.change_entry(a.transaction, merged_transaction)
  132. stage.remove_entry(b.transaction)
  133. candidates.append(stage)
  134. print('TRANSACTION')
  135. print(format_transaction(pending.transaction))
  136. print()
  137. print('Found %d matches' % len(candidates))
  138. for i, candidate in enumerate(candidates):
  139. print('\nCANDIATE %d' % (i + 1))
  140. for change_type, line in candidate.get_combined_changes():
  141. print(' %s%s' % (CHANGE_TYPE_INDICATOR[change_type], line))
  142. print('\n')
  143. default_choice = min(1, len(candidates))
  144. raw_choice = input('Choose candidates [%d]: ' % default_choice)
  145. raw_choice = raw_choice.strip()
  146. if len(raw_choice) == 0:
  147. choice = default_choice
  148. else:
  149. choice = int(choice)
  150. if choice == 0:
  151. continue
  152. candidate = candidates[choice - 1]
  153. _, old_entries, new_entries = candidate.get_diff()
  154. candidate.apply()
  155. for entry in old_entries:
  156. remove_directive(entry)
  157. for entry in new_entries:
  158. add_directive(entry)
  159. pending_entries.sort(key=lambda x: x.date)
  160. def main():
  161. ap = argparse.ArgumentParser()
  162. ap.add_argument('journal', help='Path to beancount journal file.')
  163. ap.add_argument('account', help='Transfer account to remove.')
  164. ap.add_argument(
  165. '--max-day-offset',
  166. type=int,
  167. default=30,
  168. help='Maximum number of days over which matches are permitted.')
  169. args = ap.parse_args()
  170. process(
  171. args.journal,
  172. args.account,
  173. max_day_offset=args.max_day_offset)
  174. if __name__ == '__main__':
  175. main()