Files @ 5a8da108b983
Branch filter:

Location: NPO-Accounting/conservancy_beancount/tests/test_opening_balances.py

bsturmfels
statement_reconciler: Add initial Chase bank CSV statement matching

We currently don't have many examples to work with, so haven't done any
significant testing of the matching accuracy between statement and books.
"""test_tools_opening_balances.py - Unit tests for opening balance generation"""
# Copyright © 2020  Brett Smith
# License: AGPLv3-or-later WITH Beancount-Plugin-Additional-Permission-1.0
#
# Full copyright and licensing details can be found at toplevel file
# LICENSE.txt in the repository.

import collections
import copy
import datetime
import io

import pytest

from . import testutil

from beancount import loader as bc_loader
from conservancy_beancount.tools import opening_balances as openbalmod

from decimal import Decimal
from typing import NamedTuple, Optional

A_CHECKING = 'Assets:Checking'
A_EUR = 'Assets:EUR'
A_PREPAID = 'Assets:Prepaid:Expenses'
A_RECEIVABLE = 'Assets:Receivable:Accounts'
A_RESTRICTED = 'Equity:Funds:Restricted'
A_UNRESTRICTED = 'Equity:Funds:Unrestricted'
A_CURRCONV = 'Equity:Realized:CurrencyConversion'
A_CREDITCARD = 'Liabilities:CreditCard'
A_PAYABLE = 'Liabilities:Payable:Accounts'
A_UNEARNED = 'Liabilities:UnearnedIncome'

class FlatPosting(NamedTuple):
    account: str
    units_number: Decimal
    units_currency: str
    cost_number: Optional[Decimal]
    cost_currency: Optional[str]
    cost_date: Optional[datetime.date]
    project: Optional[str]

    def __repr__(self):
        cost_s = f' {{{self.cost_number} {self.cost_currency}, {self.cost_date}}}'
        if cost_s == ' {None None, None}':
            cost_s = ''
        return f'<{self.account}  {self.units_number} {self.units_currency}{cost_s}>'

    @classmethod
    def make(cls,
             account,
             units_number,
             units_currency='USD',
             cost_number=None,
             cost_date=None,
             project=None,
             cost_currency=None,
    ):
        if cost_number is not None:
            cost_number = Decimal(cost_number)
            if cost_currency is None:
                cost_currency = 'USD'
            if isinstance(cost_date, str):
                cost_date = datetime.datetime.strptime(cost_date, '%Y-%m-%d').date()
        return cls(account, Decimal(units_number), units_currency,
                   cost_number, cost_currency, cost_date, project)

    @classmethod
    def from_beancount(cls, posting):
        units_number, units_currency = posting.units
        if posting.cost is None:
            cost_number = cost_currency = cost_date = None
        else:
            cost_number, cost_currency, cost_date, _ = posting.cost
        try:
            project = posting.meta['project']
        except (AttributeError, KeyError):
            project = None
        return cls(posting.account, units_number, units_currency,
                   cost_number, cost_currency, cost_date, project)

    @classmethod
    def from_output(cls, output):
        entries, _, _ = bc_loader.load_string(output.read())
        return (cls.from_beancount(post) for post in entries[-1].postings)


def run_main(arglist, config=None):
    if config is None:
        config = testutil.TestConfig(
            books_path=testutil.test_path('books/fund.beancount'),
        )
    output = io.StringIO()
    errors = io.StringIO()
    retcode = openbalmod.main(arglist, output, errors, config)
    output.seek(0)
    return retcode, output, errors

@pytest.mark.parametrize('arg', ['2018', '2018-03-01'])
def test_2018_opening(arg):
    retcode, output, errors = run_main([arg])
    assert not errors.getvalue()
    assert retcode == 0
    assert list(FlatPosting.from_output(output)) == [
        FlatPosting.make(A_CHECKING, 10000),
        FlatPosting.make(A_RESTRICTED, -3000, project='Alpha'),
        FlatPosting.make(A_RESTRICTED, -2000, project='Bravo'),
        FlatPosting.make(A_RESTRICTED, -1000, project='Charlie'),
        FlatPosting.make(A_UNRESTRICTED, -4000, project='Conservancy'),
    ]

@pytest.mark.parametrize('arg', ['2019', '2019-03-01'])
def test_2019_opening(arg):
    retcode, output, errors = run_main([arg])
    assert not errors.getvalue()
    assert retcode == 0
    assert list(FlatPosting.from_output(output)) == [
        FlatPosting.make(A_CHECKING, '10050.40'),
        FlatPosting.make(A_PREPAID, 20, project='Alpha'),
        FlatPosting.make(A_RECEIVABLE, 32, 'EUR', '1.25', '2018-03-03', 'Conservancy'),
        FlatPosting.make(A_RESTRICTED, -3060, project='Alpha'),
        FlatPosting.make(A_RESTRICTED, -1980, project='Bravo'),
        FlatPosting.make(A_RESTRICTED, -1000, project='Charlie'),
        FlatPosting.make(A_RESTRICTED, '-.40', project='Delta'),
        FlatPosting.make(A_UNRESTRICTED, -4036, project='Conservancy'),
        FlatPosting.make(A_PAYABLE, -4, project='Conservancy'),
        FlatPosting.make(A_UNEARNED, -30, project='Alpha'),
    ]

@pytest.mark.parametrize('arg', ['2020', '2020-12-31'])
def test_2020_opening(arg):
    retcode, output, errors = run_main([arg])
    assert not errors.getvalue()
    assert retcode == 0
    assert list(FlatPosting.from_output(output)) == [
        FlatPosting.make(A_CHECKING, 10281),
        FlatPosting.make(A_EUR, 32, 'EUR', '1.5', '2019-03-03'),
        FlatPosting.make(A_RESTRICTED, -3064, project='Alpha'),
        FlatPosting.make(A_RESTRICTED, -2180, project='Bravo'),
        FlatPosting.make(A_RESTRICTED, -900, project='Charlie'),
        FlatPosting.make(A_RESTRICTED, -5, project='Delta'),
        FlatPosting.make(A_UNRESTRICTED, -4180, project='Conservancy'),
    ]