pax_global_header00006660000000000000000000000064150513673050014517gustar00rootroot0000000000000052 comment=e17debc6cdeed8ec3bb84a6804d87edaff63f2d4 beancount-periodic-0.2.1/000077500000000000000000000000001505136730500152715ustar00rootroot00000000000000beancount-periodic-0.2.1/.github/000077500000000000000000000000001505136730500166315ustar00rootroot00000000000000beancount-periodic-0.2.1/.github/workflows/000077500000000000000000000000001505136730500206665ustar00rootroot00000000000000beancount-periodic-0.2.1/.github/workflows/pytest.yml000066400000000000000000000011311505136730500227350ustar00rootroot00000000000000name: Python Test on: push: branches: - main pull_request: workflow_dispatch: jobs: test: runs-on: ubuntu-latest steps: - name: Check out code uses: actions/checkout@v3 with: ref: ${{ github.ref }} - name: Set up Python uses: actions/setup-python@v4 with: python-version: '3.9' - name: Install dependencies run: | python -m pip install --upgrade pip pip install -r requirements.txt - name: Install pytest run: python -m pip install pytest - name: Run unit tests run: pytest beancount-periodic-0.2.1/.github/workflows/release.yml000066400000000000000000000022321505136730500230300ustar00rootroot00000000000000name: Publish to PyPI on: release: types: - published jobs: test: runs-on: ubuntu-latest steps: - name: Check out code uses: actions/checkout@v3 with: ref: ${{ github.ref }} - name: Set up Python uses: actions/setup-python@v4 with: python-version: '3.9' - name: Install dependencies run: | python -m pip install --upgrade pip pip install -r requirements.txt - name: Install pytest run: python -m pip install pytest - name: Run unit tests run: pytest publish: needs: test runs-on: ubuntu-latest steps: - name: Check out code uses: actions/checkout@v3 with: ref: ${{ github.ref }} - name: Set up Python uses: actions/setup-python@v4 with: python-version: '3.9' - name: Install build tools run: | python -m pip install --upgrade pip pip install build twine - name: Build package run: python -m build - name: Publish to PyPI env: TWINE_USERNAME: __token__ TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} run: twine upload dist/* beancount-periodic-0.2.1/.gitignore000066400000000000000000000000541505136730500172600ustar00rootroot00000000000000.idea */__pycache__ *.egg-info dist venv beancount-periodic-0.2.1/LICENSE000066400000000000000000000022731505136730500163020ustar00rootroot00000000000000This is free and unencumbered software released into the public domain. Anyone is free to copy, modify, publish, use, compile, sell, or distribute this software, either in source code form or as a compiled binary, for any purpose, commercial or non-commercial, and by any means. In jurisdictions that recognize copyright laws, the author or authors of this software dedicate any and all copyright interest in the software to the public domain. We make this dedication for the benefit of the public at large and to the detriment of our heirs and successors. We intend this dedication to be an overt act of relinquishment in perpetuity of all present and future rights to this software under copyright law. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. For more information, please refer to beancount-periodic-0.2.1/MANIFEST.in000066400000000000000000000000301505136730500170200ustar00rootroot00000000000000include requirements.txtbeancount-periodic-0.2.1/README.md000066400000000000000000000164721505136730500165620ustar00rootroot00000000000000# Beancount plugin to generate periodic transactions ## Usage ### Install ```python pip3 install beancount-periodic ``` ### Examples #### `recur` `main.bean` ``` plugin "beancount_periodic.recur" ``` ```beancount 2022-03-31 * "Provider" "Net Fee" recur: "1 Year /Monthly" Liabilities:CreditCard:0001 -50 USD Expenses:Home:CommunicationFee ``` Then this plugin will transform the transaction into: ```beancount 2022-03-31 * "Provider" "Net Fee Recurring(1/12)" Liabilities:CreditCard:0001 -50 USD Expenses:Home:CommunicationFee 2022-04-30 * "Provider" "Net Fee Recurring(2/12)" Liabilities:CreditCard:0001 -50 USD Expenses:Home:CommunicationFee 2022-05-31 * "Provider" "Net Fee Recurring(3/12)" Liabilities:CreditCard:0001 -50 USD Expenses:Home:CommunicationFee ;... 2023-02-28 * "Provider" "Net Fee Recurring(12/12)" Liabilities:CreditCard:0001 -50 USD Expenses:Home:CommunicationFee ``` ### `split` Similar to recur, except instead of just repeating the same transaction multiple times, the original transaction is split into multiple, smaller transactions that sum up to the same postings as the original. `main.bean` ``` plugin "beancount_periodic.split" ``` ```beancount 2025-01-01 * "Tax Estimate" split: "Year / Monthly" Liabilities:Tax Expenses:Tax:Income 4380.00 USD; 365*12 ``` Then this plugin will transform the transaction into: ```beancount ; The amounts are not simply 365 USD per month, since some months are longer ; than others 2025-01-01 * "Tax Estimate Split(1/12)" Liabilities:Tax -372 USD Expenses:Tax:Income 372 USD 2025-02-01 * "Tax Estimate Split(2/12)" Liabilities:Tax -336 USD Expenses:Tax:Income 336 USD ;... 2025-12-01 * "Tax Estimate Split(1/12)" Liabilities:Tax -372 USD Expenses:Tax:Income 372 USD ``` #### `amortize` `main.bean` ``` plugin "beancount_periodic.amortize" ``` ```beancount 2022-03-31 * "Landlord" "2022-04 Rent" Liabilities:CreditCard:0001 -12000 USD Expenses:Home:Rent amortize: "1 Year @2022-04-01 /Monthly" ``` Then this plugin will transform the transaction into: ```beancount 2022-03-31 * "Landlord" "2022-04 Rent" Liabilities:CreditCard:0001 -12000 USD Equity:Amortization:Home:Rent amortize: "1 Year @2022-04-01 /Monthly" 2022-04-01 * "Landlord" "2022-04 Rent Amortized(1/12)" Equity:Amortization:Home:Rent -1000 USD Expenses:Home:CommunicationFee 2022-05-01 * "Landlord" "2022-04 Rent Amortized(2/12)" Equity:Amortization:Home:Rent -1000 USD Expenses:Home:CommunicationFee 2022-06-01 * "Landlord" "2022-04 Rent Amortized(3/12)" Equity:Amortization:Home:Rent -1000 USD Expenses:Home:CommunicationFee ;... 2023-03-01 * "Landlord" "2022-04 Rent Amortized(12/12)" Equity:Amortization:Home:Rent -1000 USD Expenses:Home:CommunicationFee ``` #### `depreciate` `main.bean` ``` plugin "beancount_periodic.depreciate" ``` ```beancount 2022-03-31 * "Tesla" "Model X" Liabilities:CreditCard:0001 -200000 USD Assets:Car:ModelX depreciate: "5 Year /Yearly =80000" ``` Then this plugin will transform the transaction into: ```beancount 2022-03-31 * "Tesla" "Model X" Liabilities:CreditCard:0001 -200000 USD Assets:Car:ModelX depreciate: "5 Year /Yearly =80000" 2022-03-31 * "Tesla" "Model X Depreciated(1/5)" Assets:Car:ModelX -24000 USD Expenses:Depreciation:Car:ModelX 2023-03-31 * "Tesla" "Model X Depreciated(2/5)" Assets:Car:ModelX -24000 USD Expenses:Depreciation:Car:ModelX ;... 2026-03-31 * "Tesla" "Model X Depreciated(5/5)" Assets:Car:ModelX -24000 USD Expenses:Depreciation:Car:ModelX ``` At last, the balance of the account `Assets:Car:ModelX` is 80000 USD. To change the depreciation expense account, add the `depreciate_account` meta to the `open` statement of the depreciated asset account: ```beancount 1900-01-01 open Assets:Car:ModelX EUR depreciate_account: "Expenses:Car:Value" 2022-03-31 * "Tesla" "Model X" Liabilities:CreditCard:0001 -200000 USD Assets:Car:ModelX depreciate: "5 Year /Yearly =80000" ; generated transation 2022-03-31 * "Tesla" "Model X Depreciated(1/5)" Assets:Car:ModelX -24000 USD Expenses:Car:Value ; ... ``` ### Plugin Configuration All plugins support the following configuration options, which can be specified in the `plugin` directive: ```beancount plugin "beancount_periodic.recur" "{...}" plugin "beancount_periodic.split" "{...}" plugin "beancount_periodic.amortize" "{...}" plugin "beancount_periodic.depreciate" "{...}" ``` #### generate_until The `generate_until` configuration option prevents the plugin from generating transactions which occur after the given date. It supports a ISO 8601 date string or the string literal 'today', which is replaced with today's date. ```beancount ; the plugin will only generate transactions until today plugin "beancount_periodic.amortize" "{'generate_until':'today'}" ; the plugin will only generate transactions up until (including) 2025-01-01 plugin "beancount_periodic.amortize" "{'generate_until':'2025-01-01'}" ``` ### Config string in meta All settings follow the same rules. These are some examples: ``` "200000- 5 Years @2022-03-31 /Yearly *line =80000" "200000- @2022-03-31~2027-03-30 /Year *line =80000" "200000 - 5 Year @2022-03-31 /1 Year *line =80000" "200000 - 5 Y @2022-03-31 /12 Months =80000" "5Y @ 2022-03-31 / 12M = 80000" "5Y / 12M =80000" "5Y / 12M" ``` #### Total value `200000-` means that the total value is `200000`. The default value of total is same as the account of posting if missing. #### Duration & Start date `5 Years` means the duration is 5 years, and `@2022-03-31` means the first transformed transaction will start at 2022-03-31. `5 Years @2022-03-31` is same as `@2022-03-31~2027-03-30`. You can also use `Day` and others. ``` "6 Months @2022-03-31" "6 M @2022-03-31" "5 Y @2022-03-31" ``` And the start date is optional, using the entry date as default value if missing. ``` "6 Months" ``` The default value of duration is 1 month if missing. #### Step `Yearly` means one transformed transaction per year. You can also use `Daily`, `Monthly`, `Day` and others. If step string ends with `!` means that the amount of every step will be calculated with real days. For example: ```beancount 2022-01-01 * Liabilities:CreditCard:0001 -365 USD Expenses:BlaBla amortize: "1 Year /Monthly!" ``` Then this plugin will transform the transaction into: ```beancount 2022-01-01 * Liabilities:CreditCard:0001 -365 USD Expenses:BlaBla amortize: "1 Year /Monthly!" 2022-01-01 * "Amortized(1/12)" Equity:Amortization:BlaBla -31 USD Expenses:BlaBla 2022-02-01 * "Amortized(2/12)" Equity:Amortization:BlaBla -28 USD Expenses:BlaBla 2022-03-01 * "Amortized(3/12)" Equity:Amortization:BlaBla -31 USD Expenses:BlaBla ;... 2022-12-01 * "Amortized(12/12)" Equity:Amortization:BlaBla -31 USD Expenses:BlaBla ``` The default value of step is 1 day if missing. #### Formula(not yet implemented) `*line` means that the formula is `line`. You can also use `linear`, `straight`, `line`, `load`, `work-load`, `accelerated-sum`, `sum`, `accelerated-declining`. The default value of formula is `line`. - [x] line - [] linear - [] straight - [] load - [] work-load - [] accelerated-sum - [] sum - [] accelerated-declining #### Salvage value `=80000` means that the salvage value is 80000. The default value of salvage value is 0 if missing. beancount-periodic-0.2.1/beancount_periodic/000077500000000000000000000000001505136730500211255ustar00rootroot00000000000000beancount-periodic-0.2.1/beancount_periodic/__init__.py000066400000000000000000000000001505136730500232240ustar00rootroot00000000000000beancount-periodic-0.2.1/beancount_periodic/amortize.py000066400000000000000000000062011505136730500233300ustar00rootroot00000000000000from beancount.core import data, account, account_types from beancount.parser import options from .common.config import PluginConfig from .common.utils import build_steps from .common.utils import create_meta from .common.utils import select_periodic_posting_groups __plugins__ = ('amortize',) def amortize(entries: data.Entries, unused_options_map, config_string=""): plugin_config = PluginConfig.from_string(config_string) new_entries = [] errors = [] account_types_option = options.get_account_types(unused_options_map) for entry in entries: if isinstance(entry, data.Transaction): selected_postings_groups = select_periodic_posting_groups(entry, 'amortize', errors) postings_to_insert_original_entry = [] for selected_postings in selected_postings_groups: new_postings_config = [] for i, config, config_str in selected_postings: posting: data.Posting = entry.postings[i] if account_types.is_account_type(account_types_option.expenses, posting.account): new_account = str.join(account.sep, [account_types_option.equity, 'Amortization', account.sans_root(posting.account)]) elif account_types.is_account_type(account_types_option.income, posting.account): new_account = str.join(account.sep, [account_types_option.equity, 'Received', account.sans_root(posting.account)]) else: continue total = config.total - config.salvage_value new_posting_meta = create_meta(posting.meta, deletions=['amortize', 'narration']) if total == posting.units.number: entry.postings[i] = posting._replace(account=new_account) else: entry.postings[i] = posting._replace( units=data.Amount(posting.units.number - total, posting.units.currency)) postings_to_insert_original_entry.append(( i + 1, posting._replace(account=new_account, units=data.Amount(total, posting.units.currency), meta=new_posting_meta) )) new_postings_config.append((config, posting, new_account)) new_entries.extend( build_steps('amortize', entry, new_postings_config, positive=True, narration_suffix='Amortized(%d/%d)', generate_until=plugin_config.generate_until)) postings_to_insert_original_entry.reverse() for i, element in postings_to_insert_original_entry: entry.postings.insert(i, element) if new_entries: entries.extend(new_entries) entries.sort(key=data.entry_sortkey) return entries, errors beancount-periodic-0.2.1/beancount_periodic/common/000077500000000000000000000000001505136730500224155ustar00rootroot00000000000000beancount-periodic-0.2.1/beancount_periodic/common/__init__.py000066400000000000000000000006711505136730500245320ustar00rootroot00000000000000import collections from typing import List from datetime import date from decimal import Decimal from typing import NamedTuple PeriodicConfig = NamedTuple('PeriodicConfig', [ ('total', Decimal), ('start', date), ('duration', int), ('steps', List), ('equal_amount', bool), ('salvage_value', Decimal), ('formula', str) ]) PeriodicConfigError = collections.namedtuple('ReserveConfigError', 'source message entry') beancount-periodic-0.2.1/beancount_periodic/common/config.py000066400000000000000000000257131505136730500242440ustar00rootroot00000000000000import datetime import re import sys import ast from typing import Tuple, Optional from dataclasses import dataclass from . import * from dateutil.relativedelta import relativedelta try: from beancount.utils.date_utils import parse_date_liberally except (ImportError, ModuleNotFoundError): from beangulp.date_utils import parse_date as parse_date_liberally RE_TOTAL = '\\s*(?P\\d+(?:\\.\\d+)?)\\s*-' PART_DURATION_NAMED = "(?:Day|Week|Month|Quarter|Year)" PART_DURATION_NAMED_SHORTEN = "[DWMQY]" DURATION_NUM = f'(?P\\d+)?\\s*(?:(?P{PART_DURATION_NAMED}s?)|(?P' \ f'{PART_DURATION_NAMED_SHORTEN}))?' RE_DATE_START = "@\\s*(?P\\d{4}-\\d{2}-\\d{2})" RE_DATE_END = "~\\s*(?P\\d{4}-\\d{2}-\\d{2})" RE_DURATION = f'(?:{DURATION_NUM}\\s*(?:{RE_DATE_START}\\s*)?(?:{RE_DATE_END})?)' RE_STEP_A = f'(?P\\d+)\\s*(?:(?P{PART_DURATION_NAMED}s?[!]?)|(?P' \ f'{PART_DURATION_NAMED_SHORTEN}[!]?))?' RE_STEP_B = f'(?P(?:Dai|Week|Month|Quarter|Year)ly[!]?)' RE_STEP = f'{RE_STEP_A}|{RE_STEP_B}' RE_VALUE = "[\\+\\=]\\s*(?P\\d+(?:\\.\\d+)?%?)" RE_FORMULA = "\\s*\\*(?Plinear|straight|line|load|work-load|accelerated-sum|sum|accelerated-declining" \ "|declining)" RE = fr'^\s*(?:{RE_TOTAL}\s*)?(?:{RE_DURATION}\s*)?(?:/\s*(?:{RE_STEP})\s*)?(?:{RE_FORMULA}\s*)?(?:{RE_VALUE}\s*)?$' # sys.stderr.write('%s\n' % RE) CONFIG_STR_PATTERN = re.compile(RE) CONFIG_STR_PATTERN_DURATION = re.compile(fr'{RE_DURATION}') CONFIG_STR_PATTERN_STEP = re.compile(fr'{RE_STEP}') def get_duration(start_date, num, unit_named, unit_named_shorten): """ Calculate the number of days by natural date :param start_date: :param num: :param unit_named: :param unit_named_shorten: :return: days num """ unit = unit_named if unit_named else unit_named_shorten if unit: key_letter = unit[:1] if key_letter == 'D': return 1 * num elif key_letter == 'W': return 7 * num elif key_letter == 'M': delta = (start_date + relativedelta(months=num)) - start_date return delta.days elif key_letter == 'Q': delta = (start_date + relativedelta(months=num * 3)) - start_date return delta.days pass elif key_letter == 'Y': delta = (start_date + relativedelta(years=num)) - start_date return delta.days else: return num def get_steps(start_date, duration, num, unit_named, unit_named_shorten): """ Calculate the step length of each settlement based on the natural date (the actual number of days) :param start_date: :param duration: :param num: :param unit_named: :param unit_named_shorten: :return: """ unit = unit_named if unit_named else unit_named_shorten if unit: key_letter = unit[:1] if key_letter == 'D': return get_steps_simple(duration, num) elif key_letter == 'W': return get_steps_simple(duration, 7 * num) elif key_letter == 'M': return __get_steps(start_date, duration, lambda i: relativedelta(months=i)) elif key_letter == 'Q': return __get_steps(start_date, duration, lambda i: relativedelta(months=i * 3)) elif key_letter == 'Y': return __get_steps(start_date, duration, lambda i: relativedelta(years=i)) else: return get_steps_simple(duration, num) def get_steps_simple(duration, step): """ Simple calculation of the step length for each settlement :param duration: :param step: :return: """ steps = [] remainder = duration while True: if step <= remainder: steps.append((step, 1)) remainder -= step else: if remainder > 0: steps.append((remainder, Decimal(remainder) / step)) break return steps def __get_steps(start_date, duration, delta_callback): steps = [] start = start_date remainder = duration for i in range(duration): tail_date = start_date + delta_callback(i + 1) delta = tail_date - start if delta.days <= remainder: steps.append((delta.days, 1)) start = tail_date remainder -= delta.days else: if remainder > 0: steps.append((remainder, Decimal(remainder) / delta.days)) break return steps def parse( config, default_total, default_start_date: datetime.date, default_duration_str: str, default_step_str: str, default_value: Decimal, default_formula_str, ) -> Tuple[PeriodicConfig, PeriodicConfigError]: """ Parse the configuration :param config: :param default_total: :param default_start_date: :param default_duration_str: :param default_step_str: :param default_value: :param default_formula_str: :return: """ if isinstance(config, str): match = CONFIG_STR_PATTERN.search(config) if match: total = match.group('total') num = match.group('num') unit_named = match.group('unit_named') unit_named_shorten = match.group('unit_named_shorten') date_start = match.group('date_start') date_end = match.group('date_end') step_num = match.group('step_num') step_unit_named = match.group('step_unit_named') step_unit_named_shorten = match.group('step_unit_named_shorten') step_named = match.group('step_named') value = match.group('value') formula = match.group('formula') __print_config_match_result(date_end, date_start, formula, num, step_named, step_num, step_unit_named, step_unit_named_shorten, total, unit_named, unit_named_shorten, value) total = Decimal(total if total else default_total) if value: if value.endswith('%'): salvage_value = Decimal(value[:-1]) / 100 * total else: salvage_value = Decimal(value) else: salvage_value = default_value start_date = parse_date_liberally(date_start, {}) if date_start else default_start_date if num or unit_named or unit_named_shorten: num = int(num) if num else 1 duration = get_duration(start_date, num, unit_named, unit_named_shorten) elif date_end: duration = (parse_date_liberally(date_end, {}) - start_date).days else: duration_math = CONFIG_STR_PATTERN_DURATION.match(default_duration_str) if duration_math: num = duration_math.group('num') unit_named = duration_math.group('unit_named') unit_named_shorten = duration_math.group('unit_named_shorten') num = int(num) if num else 1 duration = get_duration(start_date, num, unit_named, unit_named_shorten) else: return None, PeriodicConfigError(None, 'fail to parse default duration: %s' % default_duration_str, None) if step_num or step_named or step_unit_named or step_unit_named_shorten: if step_named: steps = get_steps(start_date, duration, 1, step_named, None) else: step_num = int(step_num) if step_num else 1 steps = get_steps(start_date, duration, step_num, step_unit_named, step_unit_named_shorten) else: step_match = CONFIG_STR_PATTERN_STEP.match(default_step_str) if step_match: step_num = step_match.group('step_num') step_unit_named = step_match.group('step_unit_named') step_unit_named_shorten = step_match.group('step_unit_named_shorten') step_named = step_match.group('step_named') if step_named: steps = get_steps(start_date, duration, 1, step_named, None) else: step_num = int(step_num) if step_num else 1 steps = get_steps(start_date, duration, step_num, step_unit_named, step_unit_named_shorten) else: return None, PeriodicConfigError(None, 'fail to parse default steps: %s' % default_step_str, None) use_real_days_for_step_amount = next( (unit for unit in [step_named, step_unit_named, step_unit_named_shorten] if unit is not None), '').endswith('!') config_obj = PeriodicConfig( total=total, start=start_date, duration=duration, steps=steps, equal_amount=not use_real_days_for_step_amount, salvage_value=salvage_value, formula=formula if formula else default_formula_str ) return config_obj, None else: return None, PeriodicConfigError(None, 'fail to parse: %s' % config, None) return None, None def __print_config_match_result(date_end, date_start, formula, num, step_named, step_num, step_unit_named, step_unit_named_shorten, total, unit_named, unit_named_shorten, value): # sys.stderr.write('total: %s\n' % total) # sys.stderr.write('num: %s\n' % num) # sys.stderr.write('unit_named: %s\n' % unit_named) # sys.stderr.write('unit_named_shorten: %s\n' % unit_named_shorten) # sys.stderr.write('date_start: %s\n' % date_start) # sys.stderr.write('date_end: %s\n' % date_end) # sys.stderr.write('step_num: %s\n' % step_num) # sys.stderr.write('step_unit_named: %s\n' % step_unit_named) # sys.stderr.write('step_unit_named_shorten: %s\n' % step_unit_named_shorten) # sys.stderr.write('step_named: %s\n' % step_named) # sys.stderr.write('value: %s\n' % value) # sys.stderr.write('formula: %s\n' % ear) pass @dataclass class PluginConfig: generate_until: Optional[datetime.date] = None @staticmethod def from_string(config_str: str) -> 'PluginConfig': ret = PluginConfig() if not config_str: return ret config_dict = ast.literal_eval(config_str) try: generate_until_str = config_dict.get('generate_until', None) if generate_until_str == 'today': ret.generate_until = datetime.date.today() elif generate_until_str: ret.generate_until = datetime.date.fromisoformat(generate_until_str) except (ValueError, TypeError): raise RuntimeError('Bad "generate_until" value - it must be a valid date, formatted in ISO 8601 (e.g. ' '"2024-12-31") or the literal "today".') return ret beancount-periodic-0.2.1/beancount_periodic/common/config_test.py000066400000000000000000000065571505136730500253100ustar00rootroot00000000000000import unittest from .config import * class MyTestCase(unittest.TestCase): def test_parse(self): default_start_date = datetime.datetime.strptime('2021-01-31', '%Y-%m-%d') config_1, config_err_1 = parse('90', default_total=1000, default_start_date=default_start_date, default_duration_str='1M', default_step_str='1D', default_value=Decimal('0.1'), default_formula_str='line') self.assertEqual(config_1.duration, 90) config_2, config_err_2 = parse('2000 - 3 Months @ 2021-10-10 / Weekly +20%', default_total=1000, default_start_date=default_start_date, default_duration_str='1M', default_step_str='1D', default_value=Decimal('0.1'), default_formula_str='line') self.assertEqual(config_2.duration, 92) self.assertEqual(config_2.steps, [(7, 1), (7, 1), (7, 1), (7, 1), (7, 1), (7, 1), (7, 1), (7, 1), (7, 1), (7, 1), (7, 1), (7, 1), (7, 1), (1, Decimal('0.1428571428571428571428571429'))]) def test_get_duration(self): self.assertEqual(get_duration(datetime.datetime.strptime('2021-01-31', '%Y-%m-%d'), 1, 'Months', None), 28) self.assertEqual(get_duration(datetime.datetime.strptime('2021-01-31', '%Y-%m-%d'), 2, 'M', None), 59) self.assertEqual(get_duration(datetime.datetime.strptime('2021-01-31', '%Y-%m-%d'), 2, 'W', None), 14) self.assertEqual(get_duration(datetime.datetime.strptime('2021-01-31', '%Y-%m-%d'), 10, None, None), 10) self.assertEqual(get_duration(datetime.datetime.strptime('2021-01-31', '%Y-%m-%d'), 2, 'Year', None), 730) self.assertEqual(get_duration(datetime.datetime.strptime('2021-01-31', '%Y-%m-%d'), 4, 'Year', None), 1461) def test_get_steps(self): self.assertEqual(get_steps(datetime.datetime.strptime('2021-01-31', '%Y-%m-%d'), 10, 1, None, None), [(1, 1), (1, 1), (1, 1), (1, 1), (1, 1), (1, 1), (1, 1), (1, 1), (1, 1), (1, 1)]) self.assertEqual(get_steps(datetime.datetime.strptime('2021-01-01', '%Y-%m-%d'), 365, 1, 'Month', None), [(31, 1), (28, 1), (31, 1), (30, 1), (31, 1), (30, 1), (31, 1), (31, 1), (30, 1), (31, 1), (30, 1), (31, 1)]) self.assertEqual(get_steps(datetime.datetime.strptime('2020-11-30', '%Y-%m-%d'), 365, 1, 'Month', None), [(30, 1), (31, 1), (29, 1), (30, 1), (31, 1), (30, 1), (31, 1), (30, 1), (31, 1), (31, 1), (30, 1), (31, 1)]) self.assertEqual(get_steps(datetime.datetime.strptime('2020-11-30', '%Y-%m-%d'), 365, 1, 'Month', None), [(30, 1), (31, 1), (29, 1), (30, 1), (31, 1), (30, 1), (31, 1), (30, 1), (31, 1), (31, 1), (30, 1), (31, 1)]) if __name__ == '__main__': unittest.main() beancount-periodic-0.2.1/beancount_periodic/common/number.py000066400000000000000000000012201505136730500242520ustar00rootroot00000000000000from decimal import Decimal, DecimalException import math def smart_place_num(ref: Decimal, div: Decimal): round_place_added = int(round(math.log10(abs(div)), 0)) + 1 if div != 0 else 0 return min(max(- ref.as_tuple().exponent + round_place_added, 2), 6) def round_and_remainder(target: Decimal, place_num): try: round_value = round(target, place_num) remainder = target - round_value return round_value, remainder except DecimalException as e: print(e) return target, 0 def remove_exponent_zero(num: Decimal): integral = num.to_integral() return integral if integral == num else num.normalize() beancount-periodic-0.2.1/beancount_periodic/common/number_test.py000066400000000000000000000006451505136730500253230ustar00rootroot00000000000000import unittest from .number import * class MyTestCase(unittest.TestCase): def test_remove_exponent_zero(self): self.assertEqual(remove_exponent_zero(Decimal('0.11100000')), Decimal('0.111')) self.assertEqual(remove_exponent_zero(Decimal('100.000000')), Decimal('100')) self.assertEqual(str(remove_exponent_zero(Decimal('0.100000'))), '0.1') if __name__ == '__main__': unittest.main() beancount-periodic-0.2.1/beancount_periodic/common/plugin_utils.py000066400000000000000000000004301505136730500255020ustar00rootroot00000000000000def read_config(config_string): if len(config_string) == 0: config = {} else: config = eval(config_string, {}, {}) if not isinstance(config, dict): raise RuntimeError("Invalid plugin configuration: should be a single dict.") return config beancount-periodic-0.2.1/beancount_periodic/common/utils.py000066400000000000000000000171031505136730500241310ustar00rootroot00000000000000import datetime from decimal import Decimal from typing import Optional from beancount.core import data from .config import parse from .number import remove_exponent_zero from .number import round_and_remainder from .number import smart_place_num def select_periodic_posting_groups(entry, meta_name, errors): config_group_postings = {} entry_config_str = entry.meta.get(meta_name) if entry.meta else None if entry_config_str: entry_config, entry_config_err = parse(entry_config_str, Decimal('0'), entry.date, 'M', 'D', Decimal('0'), 'line') if entry_config: config_group_postings[entry_config_str] = [] else: errors.append(entry_config_err._replace(source=entry.meta)) for i, posting in enumerate(entry.postings): config_str = posting.meta.get(meta_name) if posting.meta else None if config_str: config, config_err = parse(config_str, posting.units.number, entry.date, 'M', 'D', Decimal('0'), 'line') if config: if config_str not in config_group_postings: config_group_postings[config_str] = [] config_group_postings[config_str].append((i, config, config_str)) else: errors.append(config_err._replace(source=posting.meta)) continue elif entry_config_str and entry_config: config_group_postings[entry_config_str].append( (i, entry_config._replace(total=posting.units.number), entry_config_str)) return [v for k, v in config_group_postings.items()] def build_steps(meta_key, entry, new_postings_config, positive=True, narration_suffix='(% d / % d)', generate_until: Optional[datetime.date] = None): new_entries = [] if len(new_postings_config) == 1: posting = new_postings_config[0][1]; narration = next( n for n in [posting.meta.get('narration') if posting.meta else None, entry.narration] if n is not None) new_entry_meta = create_meta(entry.meta, deletions=[meta_key], extends={'lineno': posting.meta['lineno']}) else: narration = entry.narration new_entry_meta = create_meta(entry.meta, deletions=[meta_key]) new_entry_narration_template = (narration + ' ' if narration else '') + narration_suffix for config, posting, new_account in new_postings_config: total = config.total - config.salvage_value amount_remainder = remove_exponent_zero(total) start_date = config.start new_posting_meta = create_meta(posting.meta, deletions=[meta_key, 'narration']) if config.equal_amount: place_num = smart_place_num(total, len(config.steps)) else: place_num = smart_place_num(total, config.duration) round_remainder = Decimal('0') step_num = sum_step_ratio(config) for step_i, (step_days, step_ratio) in enumerate(config.steps): # skip all steps that are past the given date if generate_until and start_date > generate_until: break if step_i < len(config.steps) - 1: if config.equal_amount: step_amount, remainder = round_and_remainder(step_ratio * total / step_num, place_num) else: step_amount, remainder = round_and_remainder(Decimal(step_days) / config.duration * total, place_num) round_remainder_amount, round_remainder_remainder = round_and_remainder(round_remainder, place_num) if abs(round_remainder_amount) > 0: step_amount += round_remainder_amount round_remainder = round_remainder_remainder round_remainder += remainder amount_remainder -= step_amount else: # the last step step_amount = amount_remainder step_amount = remove_exponent_zero(step_amount) end_date = start_date + datetime.timedelta(days=step_days) new_entry_narration = new_entry_narration_template % (step_i + 1, len(config.steps)) new_postings = create_step_postings(posting, new_account, new_posting_meta, step_amount if positive else -step_amount) if len(new_entries) > step_i and new_entries[step_i]: new_entry = new_entries[step_i] combine_to_entry_posting(new_entry, new_postings, new_account) else: new_entry = create_step_entry(entry, start_date, new_entry_meta, new_entry_narration, new_postings) new_entries.append(new_entry) start_date = end_date return new_entries def combine_to_entry_posting(entry: data.Transaction, new_postings, new_account): existing_index = next( (i for i, posting in enumerate(entry.postings) if posting.account == new_account), None) if existing_index is not None: new_postings_to_extended = [] for new_posting in new_postings: existing_posting: data.Posting = entry.postings[existing_index] if new_posting.account == new_account \ and new_posting.units.currency == existing_posting.units.currency \ and new_posting.cost == existing_posting.cost \ and new_posting.price == existing_posting.price: entry.postings[existing_index] = existing_posting._replace( units=data.Amount(existing_posting.units.number + new_posting.units.number, existing_posting.units.currency)) else: new_postings_to_extended.append(new_posting) entry.postings.extend(new_postings_to_extended) else: entry.postings.extend(new_postings) def sum_step_ratio(config): step_num = Decimal('0') for step_days, step_ratio in config.steps: step_num += step_ratio return step_num def create_step_postings(posting_template: data.Posting, new_account, posting_meta, amount): amount = remove_exponent_zero(amount) new_postings = [data.Posting( account=new_account, units=data.Amount(-amount, posting_template.units.currency), cost=posting_template.cost, price=posting_template.price, flag=posting_template.flag, meta=data.new_metadata(posting_template.meta['filename'], posting_template.meta['lineno']) ), posting_template._replace(units=data.Amount(amount, posting_template.units.currency), meta=posting_meta)] return new_postings def create_step_entry(entry_template, entry_date, entry_meta, entry_narration, entry_postings): new_entry = data.Transaction( date=entry_date, meta=entry_meta, flag=entry_template.flag, payee=entry_template.payee, narration=entry_narration, tags=entry_template.tags, links=entry_template.links, postings=entry_postings ) return new_entry def create_meta(template_meta, deletions, extends={}): new_meta = {} new_meta.update(template_meta) for key in deletions: if key in new_meta: del new_meta[key] for k, v in extends.items(): new_meta[k] = v return new_meta beancount-periodic-0.2.1/beancount_periodic/depreciate.py000066400000000000000000000050671505136730500236140ustar00rootroot00000000000000from typing import Dict, Tuple import beancount.core.getters from beancount.core import data, account, account_types from beancount.parser import options from .common.config import PluginConfig from .common.utils import build_steps from .common.utils import select_periodic_posting_groups __plugins__ = ('depreciate',) def get_depreciation_account( accounts_open_close: Dict[str, Tuple[beancount.core.data.Open, beancount.core.data.Close]], expenses_parent: str, asset_account: beancount.core.data.Account, ) -> str: new_account = str.join( account.sep, [expenses_parent, 'Depreciation', account.sans_root(asset_account)] ) asset_account_open_statement = accounts_open_close.get(asset_account, None)[0] open_meta = asset_account_open_statement.meta depreciate_account = open_meta.get('depreciate_account', None) if depreciate_account: return depreciate_account return new_account def depreciate(entries: data.Entries, unused_options_map, config_string=""): plugin_config = PluginConfig.from_string(config_string) new_entries = [] errors = [] account_types_option = options.get_account_types(unused_options_map) accounts_open_close = beancount.core.getters.get_account_open_close(entries) for entry in entries: if isinstance(entry, data.Transaction): selected_postings_groups = select_periodic_posting_groups(entry, 'depreciate', errors) for selected_postings in selected_postings_groups: new_postings_config = [] for i, config, config_str in selected_postings: posting: data.Posting = entry.postings[i] if account_types.is_account_type(account_types_option.assets, posting.account): new_account = get_depreciation_account( accounts_open_close, account_types_option.expenses, posting.account ) else: continue new_postings_config.append((config, posting, new_account)) new_entries.extend( build_steps('depreciate', entry, new_postings_config, positive=False, narration_suffix='Depreciated(%d/%d)', generate_until=plugin_config.generate_until)) if new_entries: entries.extend(new_entries) entries.sort(key=data.entry_sortkey) return entries, errors beancount-periodic-0.2.1/beancount_periodic/recur.py000066400000000000000000000045721505136730500226270ustar00rootroot00000000000000import datetime from decimal import Decimal from beancount.core import data, account, account_types from beancount.parser import options from .common.utils import create_meta from .common.utils import create_step_entry from .common.config import parse, PluginConfig __plugins__ = ('recur',) def recur(entries: data.Entries, unused_options_map, config_string=""): plugin_config = PluginConfig.from_string(config_string) new_entries = [] errors = [] account_types_option = options.get_account_types(unused_options_map) entries_to_remove = [] for entry in entries: if isinstance(entry, data.Transaction): entry_config_str = entry.meta.get('recur') if entry.meta else None if entry_config_str: entry_config, entry_config_err = parse(entry_config_str, Decimal('0'), entry.date, 'M', 'D', Decimal('0'),'line') if entry_config: new_entry_meta = create_meta(entry.meta, deletions= ['recur', 'narration']) start_date = entry_config.start new_entry_narration_template = (entry.narration + ' ' if entry.narration else '') + 'Recurring(%d/%d)' for step_i, (step_days, step_ratio) in enumerate(entry_config.steps): # skip all steps that are past the given date if plugin_config.generate_until and start_date > plugin_config.generate_until: break new_entry_narration = new_entry_narration_template % (step_i + 1, len(entry_config.steps)) end_date = start_date + datetime.timedelta(days=step_days) new_entry = create_step_entry(entry, start_date, new_entry_meta, new_entry_narration, entry.postings) new_entries.append(new_entry) start_date = end_date pass pass entries_to_remove.append(entry) else: errors.append(entry_config_err._replace(source=entry.meta)) continue pass pass for entry_to_remove in entries_to_remove: entries.remove(entry_to_remove) if new_entries: entries.extend(new_entries) entries.sort(key=data.entry_sortkey) return entries, errors beancount-periodic-0.2.1/beancount_periodic/split.py000066400000000000000000000057331505136730500226420ustar00rootroot00000000000000import datetime from decimal import Decimal from typing import List, Tuple, Optional from beancount.core import data, account, account_types from beancount.parser import options from .common.utils import create_meta from .common.utils import create_step_entry from .common.config import parse, PluginConfig, PeriodicConfig __plugins__ = ("split",) def _create_split_step(entry, step_i, total_steps, date, step_multiplier, entry_meta): new_entry_narration = (entry.narration + " " if entry.narration else "") + f"Split({step_i + 1}/{total_steps})" return create_step_entry( entry, date, entry_meta, new_entry_narration, [ posting._replace( units=data.Amount( posting.units.number * step_multiplier, posting.units.currency, ) ) for posting in entry.postings ], ) def _split_entry(entry: data.Transaction, plugin_config: PluginConfig) -> Tuple[List[data.Transaction], List[str]]: entry_config, entry_config_err = parse( entry.meta.get("split"), Decimal("0"), entry.date, "M", "D", Decimal("0"), "line", ) if not entry_config: return [], [entry_config_err._replace(source=entry.meta)] new_entries = [] new_entry_meta = create_meta(entry.meta, deletions=["split", "narration"]) start_date = entry_config.start total_days = Decimal(sum(step_days for (step_days, _) in entry_config.steps)) for step_i, (step_days, step_ratio) in enumerate(entry_config.steps): # skip all steps that are past the given date if plugin_config.generate_until and start_date > plugin_config.generate_until: break new_entry = _create_split_step( entry, step_i, len(entry_config.steps), start_date, step_days / total_days, new_entry_meta, ) new_entries.append(new_entry) start_date += datetime.timedelta(days=step_days) return new_entries, [] def split(entries: data.Entries, unused_options_map, config_string=""): plugin_config = PluginConfig.from_string(config_string) new_entries = [] errors = [] entries_to_remove = [] splittable_entries = [ entry for entry in entries if isinstance(entry, data.Transaction) and entry.meta and "split" in entry.meta ] for entry in splittable_entries: entry_new_entries, entry_errors = _split_entry(entry, plugin_config) new_entries.extend(entry_new_entries) errors.extend(entry_errors) if entry_new_entries: # Only remove the original entry if we created new ones entries_to_remove.append(entry) for entry_to_remove in entries_to_remove: entries.remove(entry_to_remove) if new_entries: entries.extend(new_entries) entries.sort(key=data.entry_sortkey) return entries, errors beancount-periodic-0.2.1/dev-requirements.txt000066400000000000000000000000251505136730500213260ustar00rootroot00000000000000twine pipreqs pytest beancount-periodic-0.2.1/requirements.txt000066400000000000000000000000701505136730500205520ustar00rootroot00000000000000beancount>=2.3.4 beangulp>=0.1.1 python-dateutil~=2.9.0 beancount-periodic-0.2.1/setup.py000066400000000000000000000020311505136730500167770ustar00rootroot00000000000000from setuptools import setup from setuptools import find_packages VERSION = '0.2.1' with open('README.md', 'r', encoding='UTF-8') as f: LONG_DESCRIPTION = f.read() with open('requirements.txt', 'r') as f: REQUIREMENTS = list(filter(None, f.read().split('\n'))) setup( name='beancount-periodic', version=VERSION, url='https://github.com/dallaslu/beancount-periodic', project_urls={ "Issue tracker": "https://github.com/dallaslu/beancount-periodic/issues", }, author='Dallas Lu', author_email='914202+dallaslu@users.noreply.github.com', description='Beancount plugin to generate periodic transactions #Amortize #Depreciate #Recur', long_description=LONG_DESCRIPTION, long_description_content_type='text/markdown', packages=find_packages(), install_requires=REQUIREMENTS, classifiers=[ 'Programming Language :: Python :: 3', 'Operating System :: OS Independent', 'Topic :: Office/Business :: Financial :: Accounting', ], python_requires='>=3.6', ) beancount-periodic-0.2.1/tests/000077500000000000000000000000001505136730500164335ustar00rootroot00000000000000beancount-periodic-0.2.1/tests/test_amortize.py000066400000000000000000000077431505136730500217110ustar00rootroot00000000000000import datetime import unittest from beancount.core.compare import compare_entries from beancount.core.data import Transaction from beancount.loader import load_string import tests.util def tx_normal(date: datetime.date, narration: str) -> Transaction: return tests.util.make_transaction( date=date, payee='Landlord', narration=narration, account_from='Liabilities:CreditCard:0001', account_to='Equity:Amortization:Home:Rent', amount='12000', ) def tx_amortized(date: datetime.date, narration: str) -> Transaction: return tests.util.make_transaction( date=date, payee='Landlord', narration=narration, account_from='Equity:Amortization:Home:Rent', account_to='Expenses:Home:Rent', amount='1000', ) class AmortizeTest(unittest.TestCase): def test_simple(self): journal_str = """ plugin "beancount_periodic.amortize" 1900-01-01 open Liabilities:CreditCard:0001 USD 1900-01-01 open Expenses:Home:Rent USD 1900-01-01 open Equity:Amortization:Home:Rent USD 2022-03-31 * "Landlord" "2022-04 Rent" Liabilities:CreditCard:0001 -12000 USD Expenses:Home:Rent amortize: "1 Year @2022-04-01 /Monthly" """ entries, errors, options_map = load_string(journal_str) self.assertEqual(len(errors), 0) expected_entries = [ tx_normal(datetime.date(2022, 3, 31), '2022-04 Rent'), tx_amortized(datetime.date(2022, 4, 1), '2022-04 Rent Amortized(1/12)'), tx_amortized(datetime.date(2022, 5, 1), '2022-04 Rent Amortized(2/12)'), tx_amortized(datetime.date(2022, 6, 1), '2022-04 Rent Amortized(3/12)'), tx_amortized(datetime.date(2022, 7, 1), '2022-04 Rent Amortized(4/12)'), tx_amortized(datetime.date(2022, 8, 1), '2022-04 Rent Amortized(5/12)'), tx_amortized(datetime.date(2022, 9, 1), '2022-04 Rent Amortized(6/12)'), tx_amortized(datetime.date(2022, 10, 1), '2022-04 Rent Amortized(7/12)'), tx_amortized(datetime.date(2022, 11, 1), '2022-04 Rent Amortized(8/12)'), tx_amortized(datetime.date(2022, 12, 1), '2022-04 Rent Amortized(9/12)'), tx_amortized(datetime.date(2023, 1, 1), '2022-04 Rent Amortized(10/12)'), tx_amortized(datetime.date(2023, 2, 1), '2022-04 Rent Amortized(11/12)'), tx_amortized(datetime.date(2023, 3, 1), '2022-04 Rent Amortized(12/12)'), ] same, missing1, missing2 = compare_entries(list(tests.util.get_transactions_cleaned(entries)), expected_entries) self.assertTrue(same) def test_generate_until_01(self): journal_str = """ plugin "beancount_periodic.amortize" "{'generate_until':'2022-10-01'}" 1900-01-01 open Liabilities:CreditCard:0001 USD 1900-01-01 open Expenses:Home:Rent USD 1900-01-01 open Equity:Amortization:Home:Rent USD 2022-03-31 * "Landlord" "2022-04 Rent" Liabilities:CreditCard:0001 -12000 USD Expenses:Home:Rent amortize: "1 Year @2022-04-01 /Monthly" """ entries, errors, options_map = load_string(journal_str) self.assertEqual(len(errors), 0) expected_entries = [ tx_normal(datetime.date(2022, 3, 31), '2022-04 Rent'), tx_amortized(datetime.date(2022, 4, 1), '2022-04 Rent Amortized(1/12)'), tx_amortized(datetime.date(2022, 5, 1), '2022-04 Rent Amortized(2/12)'), tx_amortized(datetime.date(2022, 6, 1), '2022-04 Rent Amortized(3/12)'), tx_amortized(datetime.date(2022, 7, 1), '2022-04 Rent Amortized(4/12)'), tx_amortized(datetime.date(2022, 8, 1), '2022-04 Rent Amortized(5/12)'), tx_amortized(datetime.date(2022, 9, 1), '2022-04 Rent Amortized(6/12)'), tx_amortized(datetime.date(2022, 10, 1), '2022-04 Rent Amortized(7/12)'), ] same, missing1, missing2 = compare_entries(list(tests.util.get_transactions_cleaned(entries)), expected_entries) self.assertTrue(same) if __name__ == '__main__': unittest.main() beancount-periodic-0.2.1/tests/test_depreciate.py000066400000000000000000000060111505136730500221470ustar00rootroot00000000000000import datetime import unittest from beancount.core.compare import compare_entries from beancount.core.data import Transaction from beancount.loader import load_string import tests.util def tx_normal(date: datetime.date, narration: str) -> Transaction: return tests.util.make_transaction( date=date, payee='Tesla', narration=narration, account_from='Liabilities:CreditCard:0001', account_to='Assets:Car:ModelX', amount='200000', ) def tx_depreciated(date: datetime.date, narration: str) -> Transaction: return tests.util.make_transaction( date=date, payee='Tesla', narration=narration, account_from='Assets:Car:ModelX', account_to='Expenses:Depreciation:Car:ModelX', amount='24000', ) class DepreciateTest(unittest.TestCase): def test_simple(self): journal_str = """ plugin "beancount_periodic.depreciate" 1900-01-01 open Liabilities:CreditCard:0001 USD 1900-01-01 open Assets:Car:ModelX USD 1900-01-01 open Expenses:Depreciation:Car:ModelX USD 2022-03-31 * "Tesla" "Model X" Liabilities:CreditCard:0001 -200000 USD Assets:Car:ModelX depreciate: "5 Year /Yearly =80000" """ entries, errors, options_map = load_string(journal_str) self.assertEqual(len(errors), 0) expected_entries = [ tx_normal(datetime.date(2022, 3, 31), 'Model X'), tx_depreciated(datetime.date(2022, 3, 31), 'Model X Depreciated(1/5)'), tx_depreciated(datetime.date(2023, 3, 31), 'Model X Depreciated(2/5)'), tx_depreciated(datetime.date(2024, 3, 31), 'Model X Depreciated(3/5)'), tx_depreciated(datetime.date(2025, 3, 31), 'Model X Depreciated(4/5)'), tx_depreciated(datetime.date(2026, 3, 31), 'Model X Depreciated(5/5)'), ] same, missing1, missing2 = compare_entries(list(tests.util.get_transactions_cleaned(entries)), expected_entries) self.assertTrue(same) def test_generate_until_01(self): journal_str = """ plugin "beancount_periodic.depreciate" "{'generate_until':'2024-03-31'}" 1900-01-01 open Liabilities:CreditCard:0001 USD 1900-01-01 open Assets:Car:ModelX USD 1900-01-01 open Expenses:Depreciation:Car:ModelX USD 2022-03-31 * "Tesla" "Model X" Liabilities:CreditCard:0001 -200000 USD Assets:Car:ModelX depreciate: "5 Year /Yearly =80000" """ entries, errors, options_map = load_string(journal_str) self.assertEqual(len(errors), 0) expected_entries = [ tx_normal(datetime.date(2022, 3, 31), 'Model X'), tx_depreciated(datetime.date(2022, 3, 31), 'Model X Depreciated(1/5)'), tx_depreciated(datetime.date(2023, 3, 31), 'Model X Depreciated(2/5)'), tx_depreciated(datetime.date(2024, 3, 31), 'Model X Depreciated(3/5)'), ] same, missing1, missing2 = compare_entries(list(tests.util.get_transactions_cleaned(entries)), expected_entries) self.assertTrue(same) if __name__ == '__main__': unittest.main() beancount-periodic-0.2.1/tests/test_recur.py000066400000000000000000000070101505136730500211620ustar00rootroot00000000000000import datetime import unittest from beancount.core.compare import compare_entries from beancount.core.data import Transaction from beancount.loader import load_string import tests.util def tx_recurring(date: datetime.date, narration: str) -> Transaction: return tests.util.make_transaction( date=date, payee='Provider', narration=narration, account_from='Liabilities:CreditCard:0001', account_to='Expenses:Home:CommunicationFee', amount='50', ) class RecurTest(unittest.TestCase): def test_simple(self): journal_str = """ plugin "beancount_periodic.recur" 1900-01-01 open Liabilities:CreditCard:0001 USD 1900-01-01 open Expenses:Home:CommunicationFee USD 2022-03-31 * "Provider" "Net Fee" recur: "1 Year /Monthly" Liabilities:CreditCard:0001 -50 USD Expenses:Home:CommunicationFee """ entries, errors, options_map = load_string(journal_str) self.assertEqual(len(errors), 0) expected_entries = [ tx_recurring(datetime.date(2022, 3, 31), 'Net Fee Recurring(1/12)'), tx_recurring(datetime.date(2022, 4, 30), 'Net Fee Recurring(2/12)'), tx_recurring(datetime.date(2022, 5, 31), 'Net Fee Recurring(3/12)'), tx_recurring(datetime.date(2022, 6, 30), 'Net Fee Recurring(4/12)'), tx_recurring(datetime.date(2022, 7, 31), 'Net Fee Recurring(5/12)'), tx_recurring(datetime.date(2022, 8, 31), 'Net Fee Recurring(6/12)'), tx_recurring(datetime.date(2022, 9, 30), 'Net Fee Recurring(7/12)'), tx_recurring(datetime.date(2022, 10, 31), 'Net Fee Recurring(8/12)'), tx_recurring(datetime.date(2022, 11, 30), 'Net Fee Recurring(9/12)'), tx_recurring(datetime.date(2022, 12, 31), 'Net Fee Recurring(10/12)'), tx_recurring(datetime.date(2023, 1, 31), 'Net Fee Recurring(11/12)'), tx_recurring(datetime.date(2023, 2, 28), 'Net Fee Recurring(12/12)'), ] same, missing1, missing2 = compare_entries(list(tests.util.get_transactions_cleaned(entries)), expected_entries) self.assertTrue(same) def test_generate_until_01(self): journal_str = """ plugin "beancount_periodic.recur" "{'generate_until':'2022-11-30'}" 1900-01-01 open Liabilities:CreditCard:0001 USD 1900-01-01 open Expenses:Home:CommunicationFee USD 2022-03-31 * "Provider" "Net Fee" recur: "1 Year /Monthly" Liabilities:CreditCard:0001 -50 USD Expenses:Home:CommunicationFee """ entries, errors, options_map = load_string(journal_str) self.assertEqual(len(errors), 0) expected_entries = [ tx_recurring(datetime.date(2022, 3, 31), 'Net Fee Recurring(1/12)'), tx_recurring(datetime.date(2022, 4, 30), 'Net Fee Recurring(2/12)'), tx_recurring(datetime.date(2022, 5, 31), 'Net Fee Recurring(3/12)'), tx_recurring(datetime.date(2022, 6, 30), 'Net Fee Recurring(4/12)'), tx_recurring(datetime.date(2022, 7, 31), 'Net Fee Recurring(5/12)'), tx_recurring(datetime.date(2022, 8, 31), 'Net Fee Recurring(6/12)'), tx_recurring(datetime.date(2022, 9, 30), 'Net Fee Recurring(7/12)'), tx_recurring(datetime.date(2022, 10, 31), 'Net Fee Recurring(8/12)'), tx_recurring(datetime.date(2022, 11, 30), 'Net Fee Recurring(9/12)'), ] same, missing1, missing2 = compare_entries(list(tests.util.get_transactions_cleaned(entries)), expected_entries) self.assertTrue(same) if __name__ == '__main__': unittest.main() beancount-periodic-0.2.1/tests/test_split.py000066400000000000000000000067011505136730500212030ustar00rootroot00000000000000import datetime import unittest from beancount.core.compare import compare_entries from beancount.core.data import Transaction from beancount.loader import load_string import tests.util def tx_split(date: datetime.date, narration: str, amount: str) -> Transaction: return tests.util.make_transaction( date=date, payee=None, narration=narration, account_from='Liabilities:Tax', account_to='Expenses:Tax:Income', amount=amount, ) class SplitTest(unittest.TestCase): def test_simple(self): journal_str = """ plugin "beancount_periodic.split" 1900-01-01 open Liabilities:Tax USD 1900-01-01 open Expenses:Tax:Income USD 2025-01-01 * "Tax Estimate" split: "Year / Monthly" Liabilities:Tax Expenses:Tax:Income 4380 USD """ entries, errors, options_map = load_string(journal_str) self.assertEqual(len(errors), 0) expected_entries = [ tx_split(datetime.date(2025, 1, 1), 'Tax Estimate Split(1/12)', '372.0000000000000000000000000'), tx_split(datetime.date(2025, 2, 1), 'Tax Estimate Split(2/12)', '336.0000000000000000000000000'), tx_split(datetime.date(2025, 3, 1), 'Tax Estimate Split(3/12)', '372.0000000000000000000000000'), tx_split(datetime.date(2025, 4, 1), 'Tax Estimate Split(4/12)', '360.0000000000000000000000000'), tx_split(datetime.date(2025, 5, 1), 'Tax Estimate Split(5/12)', '372.0000000000000000000000000'), tx_split(datetime.date(2025, 6, 1), 'Tax Estimate Split(6/12)', '360.0000000000000000000000000'), tx_split(datetime.date(2025, 7, 1), 'Tax Estimate Split(7/12)', '372.0000000000000000000000000'), tx_split(datetime.date(2025, 8, 1), 'Tax Estimate Split(8/12)', '372.0000000000000000000000000'), tx_split(datetime.date(2025, 9, 1), 'Tax Estimate Split(9/12)', '360.0000000000000000000000000'), tx_split(datetime.date(2025, 10, 1), 'Tax Estimate Split(10/12)', '372.0000000000000000000000000'), tx_split(datetime.date(2025, 11, 1), 'Tax Estimate Split(11/12)', '360.0000000000000000000000000'), tx_split(datetime.date(2025, 12, 1), 'Tax Estimate Split(12/12)', '372.0000000000000000000000000'), ] same, missing1, missing2 = compare_entries(list(tests.util.get_transactions_cleaned(entries)), expected_entries) self.assertTrue(same) def test_generate_until_01(self): journal_str = """ plugin "beancount_periodic.split" "{'generate_until':'2025-04-30'}" 1900-01-01 open Liabilities:Tax USD 1900-01-01 open Expenses:Tax:Income USD 2025-01-01 * "Tax Estimate" split: "Year / Monthly" Liabilities:Tax Expenses:Tax:Income 4380 USD """ entries, errors, options_map = load_string(journal_str) self.assertEqual(len(errors), 0) expected_entries = [ tx_split(datetime.date(2025, 1, 1), 'Tax Estimate Split(1/12)', '372.0000000000000000000000000'), tx_split(datetime.date(2025, 2, 1), 'Tax Estimate Split(2/12)', '336.0000000000000000000000000'), tx_split(datetime.date(2025, 3, 1), 'Tax Estimate Split(3/12)', '372.0000000000000000000000000'), tx_split(datetime.date(2025, 4, 1), 'Tax Estimate Split(4/12)', '360.0000000000000000000000000'), ] same, missing1, missing2 = compare_entries(list(tests.util.get_transactions_cleaned(entries)), expected_entries) self.assertTrue(same) if __name__ == '__main__': unittest.main() beancount-periodic-0.2.1/tests/util.py000066400000000000000000000027611505136730500177700ustar00rootroot00000000000000import datetime from decimal import Decimal from typing import List from beancount.core.amount import Amount from beancount.core.data import Transaction, Posting def get_transactions_cleaned(entries: List) -> List[Transaction]: for e in entries: if not isinstance(e, Transaction): continue e = e._replace(meta={'lineno': 0}) e = e._replace(postings=[ p._replace(meta={'lineno': 0}) for p in e.postings ]) yield e def make_transaction(date: datetime.date, payee: str, narration: str, account_from: str, account_to: str, amount: str) -> Transaction: tx = Transaction( date=date, flag='*', payee=payee, narration=narration, postings=[ Posting( account=account_from, units=Amount( number=-Decimal(amount), currency='USD' ), cost=None, price=None, flag=None, meta={'lineno': 0}, ), Posting( account=account_to, units=Amount( number=Decimal(amount), currency='USD' ), cost=None, price=None, flag=None, meta={'lineno': 0}, ), ], meta={'lineno': 0}, tags=frozenset(), links=frozenset(), ) return tx