Changeset - 032175cd26d9
[Not reviewed]
0 5 0
Joar Wandborg - 11 years ago 2013-12-11 14:12:08
joar@wandborg.se
Added transaction IDs and metadata
5 files changed with 93 insertions and 28 deletions:
0 comments (0 inline, 0 general)
accounting/__init__.py
Show inline comments
...
 
@@ -26,216 +26,259 @@ class Ledger:
 
    @contextmanager
 
    def locked_process(self):
 
        if self.locked:
 
            raise RuntimeError('The process has already been locked,'
 
                               ' something\'s out of order.')
 

	
 
            # XXX: This code has no purpose in a single-threaded process
 
            timeout = 5  # Seconds
 

	
 
            for i in range(1, timeout + 2):
 
                if i > timeout:
 
                    raise RuntimeError('Ledger process is already locked')
 

	
 
                if not self.locked:
 
                    break
 
                else:
 
                    _log.info('Waiting for one second... %d/%d', i, timeout)
 
                    time.sleep(1)
 

	
 
        process = self.get_process()
 

	
 
        self.locked = True
 
        _log.debug('Lock enabled')
 

	
 
        yield process
 

	
 
        self.locked = False
 
        _log.debug('Lock disabled')
 

	
 
    def assemble_arguments(self):
 
        return [
 
            self.ledger_bin,
 
            '-f',
 
            self.ledger_file,
 
        ]
 

	
 
    def init_process(self):
 
        _log.debug('Starting ledger process...')
 
        self.ledger_process = subprocess.Popen(
 
            self.assemble_arguments(),
 
            stdout=subprocess.PIPE,
 
            stdin=subprocess.PIPE,
 
            stderr=subprocess.PIPE)
 

	
 
        # Swallow the banner
 
        with self.locked_process() as p:
 
            self.read_until_prompt(p)
 

	
 
        return self.ledger_process
 

	
 
    def get_process(self):
 
        return self.ledger_process or self.init_process()
 

	
 
    def read_until_prompt(self, p):
 
        output = b''
 

	
 
        while True:
 
            line = p.stdout.read(1)  # XXX: This is a hack
 

	
 
            output += line
 

	
 
            if b'\n] ' in output:
 
                _log.debug('Found prompt!')
 
                break
 

	
 
        output = output[:-3]  # Cut away the prompt
 

	
 
        _log.debug('output: %s', output)
 

	
 
        return output
 

	
 
    def send_command(self, command):
 
        output = None
 

	
 
        with self.locked_process() as p:
 
            if isinstance(command, str):
 
                command = command.encode('utf8')
 

	
 
            p.stdin.write(command + b'\n')
 
            p.stdin.flush()
 

	
 
            output = self.read_until_prompt(p)
 

	
 
            self.ledger_process.send_signal(subprocess.signal.SIGTERM)
 
            _log.debug('Waiting for ledger to shut down')
 
            self.ledger_process.wait()
 
            self.ledger_process = None
 

	
 
            return output
 

	
 
    def add_transaction(self, transaction):
 
        '''
 
        Writes a transaction to the ledger file by opening it in 'ab' mode and
 
        writing a ledger transaction based on the Transaction instance in
 
        ``transaction``.
 
        '''
 
        if not transaction.metadata.get('Id'):
 
            transaction.generate_id()
 

	
 
        transaction_template = ('\n{date} {t.payee}\n'
 
                                '{tags}'
 
                                '{postings}')
 

	
 
        metadata_template = '   ;{0}: {1}\n'
 

	
 
        # TODO: Generate metadata for postings
 
        posting_template = ('  {account} {p.amount.symbol}'
 
                            ' {p.amount.amount}\n')
 

	
 
        output  = b''
 

	
 
        # XXX: Even I hardly understands what this does, however I indent it it
 
        # stays unreadable.
 
        output += transaction_template.format(
 
            date=transaction.date.strftime('%Y-%m-%d'),
 
            t=transaction,
 
            tags=''.join([
 
                metadata_template.format(k, v) \
 
                    for k, v in transaction.metadata.items()]),
 
            postings=''.join([posting_template.format(
 
                p=p,
 
                account=p.account + ' ' * (
 
                    80 - (len(p.account) + len(p.amount.symbol) +
 
                    len(str(p.amount.amount)) + 1 + 2)
 
                )) for p in transaction.postings])).encode('utf8')
 
                )) for p in transaction.postings
 
            ])
 
        ).encode('utf8')
 

	
 
        with open(self.ledger_file, 'ab') as f:
 
            f.write(output)
 

	
 
        _log.debug('written to file: %s', output)
 

	
 
    def bal(self):
 
        output = self.send_command('xml')
 

	
 
        if output is None:
 
            raise RuntimeError('bal call returned no output')
 

	
 
        accounts = []
 

	
 
        xml = ElementTree.fromstring(output.decode('utf8'))
 

	
 
        accounts = self._recurse_accounts(xml.find('./accounts'))
 

	
 
        return accounts
 

	
 
    def _recurse_accounts(self, root):
 
        accounts = []
 

	
 
        for account in root.findall('./account'):
 
            name = account.find('./fullname').text
 

	
 
            amounts = []
 

	
 
            # Try to find an account total value, then try to find the account
 
            # balance
 
            account_amounts = account.findall(
 
                './account-total/balance/amount') or \
 
                    account.findall('./account-amount/amount') or \
 
                    account.findall('./account-total/amount')
 

	
 
            if account_amounts:
 
                for amount in account_amounts:
 
                    quantity = amount.find('./quantity').text
 
                    symbol = amount.find('./commodity/symbol').text
 

	
 
                    amounts.append(Amount(amount=quantity, symbol=symbol))
 
            else:
 
                _log.warning('Account %s does not have any amounts', name)
 

	
 
            accounts.append(Account(name=name,
 
                                    amounts=amounts,
 
                                    accounts=self._recurse_accounts(account)))
 

	
 
        return accounts
 

	
 
    def reg(self):
 
        output = self.send_command('xml')
 

	
 
        if output is None:
 
            raise RuntimeError('reg call returned no output')
 

	
 
        entries = []
 

	
 
        reg_xml = ElementTree.fromstring(output.decode('utf8'))
 

	
 
        for transaction in reg_xml.findall('./transactions/transaction'):
 
            date = datetime.strptime(transaction.find('./date').text,
 
                                     '%Y/%m/%d')
 
            payee = transaction.find('./payee').text
 

	
 
            postings = []
 

	
 
            for posting in transaction.findall('./postings/posting'):
 
                account = posting.find('./account/name').text
 
                amount = posting.find('./post-amount/amount/quantity').text
 
                symbol = posting.find(
 
                    './post-amount/amount/commodity/symbol').text
 

	
 
                # Get the posting metadata
 
                metadata = {}
 

	
 
                values = posting.findall('./metadata/value')
 
                if values:
 
                    for value in values:
 
                        key = value.get('key')
 
                        value = value.find('./string').text
 

	
 
                        _log.debug('metadata: %s: %s', key, value)
 

	
 
                        metadata.update({key: value})
 

	
 
                postings.append(
 
                    Posting(account=account,
 
                            metadata=metadata,
 
                            amount=Amount(amount=amount, symbol=symbol)))
 

	
 
            # Get the transaction metadata
 
            metadata = {}
 

	
 
            values = transaction.findall('./metadata/value')
 
            if values:
 
                for value in values:
 
                    key = value.get('key')
 
                    value = value.find('./string').text
 

	
 
                    _log.debug('metadata: %s: %s', key, value)
 

	
 
                    metadata.update({key: value})
 

	
 
            # Add a Transaction instance to the list
 
            entries.append(
 
                Transaction(date=date, payee=payee, postings=postings))
 
                Transaction(date=date, payee=payee, postings=postings,
 
                            metadata=metadata))
 

	
 
        return entries
 

	
 

	
 
def main(argv=None):
 
    import argparse
 
    if argv is None:
 
        argv = sys.argv
 

	
 
    parser = argparse.ArgumentParser()
 
    parser.add_argument('-v', '--verbosity',
 
                        default='INFO',
 
                        help=('Filter logging output. Possible values:' +
 
                        ' CRITICAL, ERROR, WARNING, INFO, DEBUG'))
 

	
 
    args = parser.parse_args(argv[1:])
 
    logging.basicConfig(level=getattr(logging, args.verbosity, 'INFO'))
 
    ledger = Ledger(ledger_file='non-profit-test-data.ledger')
 
    print(ledger.bal())
 
    print(ledger.reg())
 

	
 

	
 
if __name__ == '__main__':
 
    sys.exit(main())
accounting/client.py
Show inline comments
 
import sys
 
import argparse
 
import json
 
import logging
 

	
 
from datetime import datetime
 
from decimal import Decimal
 

	
 
import requests
 

	
 
from accounting.models import Transaction, Posting, Amount
 
from accounting.transport import AccountingDecoder, AccountingEncoder
 

	
 
# TODO: Client should be a class
 

	
 
HOST = None
 

	
 

	
 
def insert_paypal_transaction(amount):
 
    t = Transaction(
 
        date=datetime.today(),
 
        payee='PayPal donation',
 
        postings=[
 
            Posting(account='Income:Donations:PayPal',
 
                    amount=Amount(symbol='$', amount=-amount)),
 
            Posting(account='Assets:Checking',
 
                    amount=Amount(symbol='$', amount=amount))
 
        ]
 
    )
 

	
 
    response = requests.post(HOST + '/transaction',
 
                             headers={'Content-Type': 'application/json'},
 
                             data=json.dumps({'transactions': [t]},
 
                                             cls=AccountingEncoder))
 

	
 
    print(response.json(cls=AccountingDecoder))
 

	
 

	
 
def get_balance():
 
    response = requests.get(HOST + '/balance')
 

	
 
    balance = response.json(cls=AccountingDecoder)
 

	
 
    _recurse_accounts(balance['balance_report'])
 

	
 

	
 
def _recurse_accounts(accounts, level=0):
 
    for account in accounts:
 
        print(' ' * level + ' + {account.name}'.format(account=account) +
 
              ' ' + '-' * (80 - len(str(account.name)) - level))
 
        for amount in account.amounts:
 
            print(' ' * level + '   {amount.symbol} {amount.amount}'.format(
 
                amount=amount))
 
        _recurse_accounts(account.accounts, level+1)
 

	
 

	
 
def get_register():
 
    response = requests.get(HOST + '/register')
 

	
 
    register = response.json(cls=AccountingDecoder)
 

	
 
    for transaction in register['register_report']:
 
        print('{date} {t.payee:.<69}'.format(
 
            date=transaction.date.strftime('%Y-%m-%d'),
 
            t=transaction))
 

	
 
        for posting in transaction.postings:
 
            print(' ' + posting.account +
 
                  ' ' * (80 - len(posting.account) - len(posting.amount.symbol) -
 
                         len(str(posting.amount.amount)) - 1 - 1) +
 
                  posting.amount.symbol + ' ' + str(posting.amount.amount))
 

	
 

	
 
def main(argv=None, prog=None):
 
    global HOST
 
    if argv is None:
 
        prog = sys.argv[0]
 
        argv = sys.argv[1:]
 

	
 
    parser = argparse.ArgumentParser(prog=prog)
 
    parser.add_argument('-p', '--paypal', type=Decimal)
 
    parser.add_argument('-b', '--balance', action='store_true')
 
    parser.add_argument('-r', '--register', action='store_true')
 
    parser.add_argument('-v', '--verbosity',
 
                        default='WARNING',
 
                        help=('Filter logging output. Possible values:' +
 
                        ' CRITICAL, ERROR, WARNING, INFO, DEBUG'))
 
    parser.add_argument('-b', '--balance', action='store_true')
 
    parser.add_argument('--host', default='http://localhost:5000')
 
    args = parser.parse_args(argv)
 

	
 
    HOST = args.host
 

	
 
    logging.basicConfig(level=getattr(logging, args.verbosity))
 

	
 
    if args.paypal:
 
        insert_paypal_transaction(args.paypal)
 
    elif args.balance:
 
        get_balance()
 
    elif args.register:
 
        get_register()
 

	
 
if __name__ == '__main__':
 
    sys.exit(main())
accounting/models.py
Show inline comments
 
import uuid
 
from decimal import Decimal
 

	
 

	
 
class Transaction:
 
    def __init__(self, date=None, payee=None, postings=None):
 
    def __init__(self, date=None, payee=None, postings=None, metadata=None,
 
                 _generate_id=False):
 
        self.date = date
 
        self.payee = payee
 
        self.postings = postings
 
        self.metadata = metadata if metadata is not None else {}
 

	
 
        if _generate_id:
 
            self.generate_id()
 

	
 
    def generate_id(self):
 
        self.metadata.update({'Id': uuid.uuid4()})
 

	
 
    def __repr__(self):
 
        return ('<{self.__class__.__name__} {date}' +
 
                ' {self.payee} {self.postings}').format(
 
                    self=self,
 
                    date=self.date.strftime('%Y-%m-%d'))
 

	
 

	
 
class Posting:
 
    def __init__(self, account=None, amount=None):
 
    def __init__(self, account=None, amount=None, metadata=None):
 
        self.account = account
 
        self.amount = amount
 
        self.metadata = metadata if metadata is not None else {}
 

	
 
    def __repr__(self):
 
        return ('<{self.__class__.__name__} "{self.account}"' +
 
                ' {self.amount}>').format(self=self)
 

	
 

	
 
class Amount:
 
    def __init__(self, amount=None, symbol=None):
 
        self.amount = Decimal(amount)
 
        self.symbol = symbol
 

	
 
    def __repr__(self):
 
        return ('<{self.__class__.__name__} {self.symbol}' +
 
                ' {self.amount}>').format(self=self)
 

	
 

	
 
class Account:
 
    def __init__(self, name=None, amounts=None, accounts=None):
 
        self.name = name
 
        self.amounts = amounts
 
        self.accounts = accounts
 

	
 
    def __repr__(self):
 
        return ('<{self.__class__.__name__} "{self.name}" {self.amounts}' +
 
                ' {self.accounts}>').format(self=self)
accounting/transport.py
Show inline comments
 
from datetime import datetime
 

	
 
from flask import json
 

	
 
from accounting.models import Amount, Transaction, Posting, Account
 

	
 
class AccountingEncoder(json.JSONEncoder):
 
    def default(self, o):
 
        if isinstance(o, Account):
 
            return dict(
 
                __type__=o.__class__.__name__,
 
                name=o.name,
 
                amounts=o.amounts,
 
                accounts=o.accounts
 
            )
 
        elif isinstance(o, Transaction):
 
            return dict(
 
                __type__=o.__class__.__name__,
 
                date=o.date.strftime('%Y-%m-%d'),
 
                payee=o.payee,
 
                postings=o.postings
 
                postings=o.postings,
 
                metadata=o.metadata
 
            )
 
        elif isinstance(o, Posting):
 
            return dict(
 
                __type__=o.__class__.__name__,
 
                account=o.account,
 
                amount=o.amount,
 
                metadata=o.metadata
 
            )
 
        elif isinstance(o, Amount):
 
            return dict(
 
                __type__=o.__class__.__name__,
 
                amount=str(o.amount),
 
                symbol=o.symbol
 
            )
 
        elif isinstance(o, Exception):
 
            return dict(
 
                __type__=o.__class__.__name__,
 
                args=o.args
 
            )
 

	
 
        return json.JSONEncoder.default(self, o)
 

	
 
class AccountingDecoder(json.JSONDecoder):
 
    def __init__(self):
 
        json.JSONDecoder.__init__(self, object_hook=self.dict_to_object)
 

	
 
    def dict_to_object(self, d):
 
        if '__type__' not in d:
 
            return d
 

	
 
        types = {c.__name__ : c for c in [Amount, Transaction, Posting,
 
                                          Account]}
 

	
 
        _type = d.pop('__type__')
 

	
 
        if _type == 'Transaction':
 
            d['date'] = datetime.strptime(d['date'], '%Y-%m-%d')
 

	
 
        return types[_type](**d)
non-profit-test-data.ledger
Show inline comments
 

	
 
2010/01/01 Kindly T. Donor
 
    ;Id: Ids can be anything
 
   Income:Foo:Donation   $-100.00
 
      ;Invoice: Projects/Foo/Invoices/Invoice20100101.pdf
 
   Assets:Checking       $100.00
 

	
 

	
 
2011/03/15 Another J. Donor
 
    ;Id: but mind you if they collide.
 
   Income:Foo:Donation   $-400.00
 
      ;Approval: Projects/Foo/earmark-record.txt
 
   Assets:Checking       $400.00
 

	
 
2011/04/20 (1) Baz Hosting Services, LLC
 
    ;Id: always make sure your IDs are unique
 
   Expenses:Foo:Hosting   $250.00
 
      ;Receipt: Projects/Foo/Expenses/hosting/AprilHostingReceipt.pdf
 
   Assets:Checking       $-250.00
 

	
 
2011/05/10 Donation to General Fund
 
    ;Id: if you have two transactions with the same ID, bad things may happen
 
   Income:Donation   $-50.00
 
      ;Invoice: Financial/Invoices/Invoice20110510.pdf
 
   Assets:Checking   $50.00
 

	
 
2011/04/20 (2) Baz Hosting Services, LLC
 
    ;Id: this is probably unique
 
   Expenses:Blah:Hosting   $250.00
 
      ;Receipt: Projects/Blah/Expenses/hosting/AprilHostingReceipt.pdf
 
      ;Invoice: Projects/Blah/Expenses/hosting/april-invoice.pdf
 
   Assets:Checking       $-250.00
 
      ;Statement: Financial/BankStuff/bank-statement.pdf
 

	
 
2011-04-25 A transaction with ISO date
 
    ;Id: I'm a snowflake!
 
   Income:Karma              KARMA-10
 
   Assets:Karma Account      KARMA 10
 

	
 
2013-01-01 Kindly T. Donor
 
  Income:Foo:Donation                                                      $ -100
 
  Assets:Checking                                                           $ 100
 

	
 
2013-03-15 Another J. Donor
 
  Income:Foo:Donation                                                      $ -400
 
  Assets:Checking                                                           $ 400
 

	
 
2013-12-11 PayPal donation
 
  Income:Donations:PayPal                                                  $ -100
 
  Assets:Checking                                                           $ 100
 

	
 
2013-12-11 PayPal donation
 
  Income:Donations:PayPal                                                 $ -1000
 
  Assets:Checking                                                          $ 1000
 

	
 
2013-12-11 PayPal donation
 
  Income:Donations:PayPal                                                 $ -0.25
 
  Assets:Checking                                                          $ 0.25
 
   ;Id: bd7f6781-fdc6-4111-b3ad-bee2247e426d
 
  Income:Donations:PayPal                                                $ -20.17
 
  Assets:Checking                                                         $ 20.17
 

	
 
2013-12-11 PayPal donation
 
  Income:Donations:PayPal                                                $ -0.252
 
  Assets:Checking                                                         $ 0.252
 
   ;Id: 31048b9d-a5b6-41d7-951a-e7128e7c53c0
 
  Income:Donations:PayPal                                                $ -20.18
 
  Assets:Checking                                                         $ 20.18
0 comments (0 inline, 0 general)