diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..3e4ba12 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,13 @@ +os: osx +branches: + only: + - master +before_install: + - python2.7 --version + - brew update + - brew install ledger + - ledger --version +install: "" +script: ./bin/test.py +notifications: + email: false diff --git a/2012/06.dat b/2012/06.dat new file mode 100644 index 0000000..0e118b9 --- /dev/null +++ b/2012/06.dat @@ -0,0 +1,210 @@ +2012-06-01 Opening Balance + Assets:Operations:New Alliance $ 102.45 + Equity:Owners:Chad Whitacre + +; Gittip Payday 0 +2012-06-01 Users + Assets:Escrow:Samurai $ 2.96 + Assets:Fee Buffer:Samurai $ 0.34 + Income:Fee Buffer:Samurai -$ 0.34 + Income:Escrow:Samurai +2012-06-01 Retained Earnings + Income:Escrow:Samurai $ 2.96 + Income:Fee Buffer:Samurai $ 0.34 + Equity:Retained Earnings -$ 0.34 + Liabilities:Escrow + +2012-06-04 Settlement + ; We expected $3.30, but saw $7.56 instead: an overpayment of $4.26. + Assets:Escrow:New Alliance $ 2.96 + Assets:Fee Buffer:New Alliance $ 0.34 + Assets:Operations:New Alliance $ 4.26 + Income:Operations:Errors:Samurai -$ 4.26 + Assets:Fee Buffer:Samurai -$ 0.34 + Assets:Escrow:Samurai +2012-06-04 Retained Earnings + Income:Operations:Errors:Samurai $ 4.26 + Equity:Retained Earnings + +2012-06-04 Samurai + Expenses:Fee Buffer:Samurai $ 0.08 + Assets:Fee Buffer:New Alliance +2012-06-04 Retained Earnings + Equity:Retained Earnings $ 0.08 + Expenses:Fee Buffer:Samurai + +2012-06-04 Samurai + Expenses:Fee Buffer:Samurai $ 31.35 + Assets:Fee Buffer:New Alliance +2012-06-04 Retained Earnings + Equity:Retained Earnings $ 31.35 + Expenses:Fee Buffer:Samurai + +; Gittip Payday 1 +2012-06-08 Users + Assets:Escrow:Samurai $ 23.17 + Assets:Fee Buffer:Samurai $ 2.11 + Income:Fee Buffer:Samurai -$ 2.11 + Income:Escrow:Samurai +2012-06-08 Retained Earnings + Income:Escrow:Samurai $ 23.17 + Income:Fee Buffer:Samurai $ 2.11 + Equity:Retained Earnings -$ 2.11 + Liabilities:Escrow + +2012-06-11 Settlement + ; AMEX + ; We expected 0.61, and we saw 0.61. + Assets:Escrow:New Alliance $ 0.48 + Assets:Fee Buffer:New Alliance $ 0.13 + Assets:Fee Buffer:Samurai -$ 0.13 + Assets:Escrow:Samurai + +2012-06-11 Settlement + ; VISA + MasterCard + ; We expected $24.67, but only saw $24.11: an underpayment of $0.56. + Assets:Escrow:New Alliance $ 22.69 + Assets:Fee Buffer:New Alliance $ 1.98 + Expenses:Operations:Errors:Samurai $ 0.56 + Assets:Operations:New Alliance -$ 0.56 + Assets:Fee Buffer:Samurai -$ 1.98 + Assets:Escrow:Samurai +2012-06-11 Retained Earnings + Equity:Retained Earnings $ 0.56 + Expenses:Operations:Errors:Samurai + +2012-06-11 Samurai + Expenses:Fee Buffer:Samurai $ 2.00 + Assets:Fee Buffer:New Alliance +2012-06-11 Retained Earnings + Equity:Retained Earnings $ 2.00 + Expenses:Fee Buffer:Samurai + +; Gittip Payday 2 +2012-06-15 Users + Assets:Escrow:Samurai $ 1.36 + Assets:Fee Buffer:Samurai $ 0.69 + Income:Fee Buffer:Samurai -$ 0.69 + Income:Escrow:Samurai +2012-06-15 Retained Earnings + Income:Escrow:Samurai $ 1.36 + Income:Fee Buffer:Samurai $ 0.69 + Equity:Retained Earnings -$ 0.69 + Liabilities:Escrow + +; Our first payout! Chad took money out of his pocket and put it in Steve Klabnik's hand. +2012-06-15 Owners + ; Chad increases our escrow with cash from his pocket, pushing above what we need. + Assets:Escrow:Cash $ 1.50 + Equity:Owners:Chad Whitacre +2012-06-15 Escrow + ; We bleed off the excess escrow over to operations(!?). + Assets:Operations:New Alliance $ 1.50 + Assets:Escrow:New Alliance +2012-06-15 Users + ; Now we can do the payout ... + Expenses:Escrow:Cash $ 1.50 + Income:Fee Buffer:Cash $ 0.00 + Assets:Fee Buffer:Cash -$ 0.00 + Assets:Escrow:Cash +2012-06-15 Retained Earnings + Liabilities:Escrow $ 1.50 + Equity:Retained Earnings $ 0.00 + Expenses:Fee Buffer:Cash -$ 0.00 + Expenses:Escrow:Cash + +2012-06-18 Settlement + ; We expected $2.05, but only saw $2.01: an underpayment of $0.04. + Assets:Escrow:New Alliance $ 1.36 + Assets:Fee Buffer:New Alliance $ 0.69 + Expenses:Operations:Errors:Samurai $ 0.04 + Assets:Operations:New Alliance -$ 0.04 + Assets:Fee Buffer:Samurai -$ 0.69 + Assets:Escrow:Samurai +2012-06-18 Retained Earnings + Equity:Retained Earnings $ 0.04 + Expenses:Operations:Errors:Samurai + +; Testing out Stripe +2012-06-15 Users + Assets:Operations:Stripe $ 0.54 + Income:Operations:Verification & Testing +2012-06-15 Retained Earnings + Income:Operations:Verification & Testing $ 0.54 + Equity:Retained Earnings + +2012-06-22 Stripe + Expenses:Operations:Processing:Stripe $ 0.32 + Assets:Operations:Stripe +2012-06-22 Retained Earnings + Equity:Retained Earnings $ 0.32 + Expenses:Operations:Processing:Stripe + +2012-06-22 Settlement + Assets:Operations:New Alliance $ 0.22 + Assets:Operations:Stripe + +; Gittip Payday 3 +2012-06-22 Users + Assets:Escrow:Stripe $ 20.67 + Assets:Fee Buffer:Stripe $ 4.62 + Income:Fee Buffer:Stripe -$ 4.62 + Income:Escrow:Stripe +2012-06-22 Retained Earnings + Income:Escrow:Stripe $ 20.67 + Income:Fee Buffer:Stripe $ 4.62 + Equity:Retained Earnings -$ 4.62 + Liabilities:Escrow + +2012-06-22 Stripe + Expenses:Fee Buffer:Stripe $ 4.32 + Assets:Fee Buffer:Stripe +2012-06-22 Retained Earnings + Equity:Retained Earnings $ 4.32 + Expenses:Fee Buffer:Stripe + +2012-06-28 Settlement + Assets:Escrow:New Alliance $ 20.67 + Assets:Fee Buffer:New Alliance $ 0.30 + Assets:Fee Buffer:Stripe -$ 0.30 + Assets:Escrow:Stripe +2012-06-28 Settlement + +; Gittip Payday 4 +2012-06-29 Users + Assets:Escrow:Stripe $ 95.24 + Assets:Fee Buffer:Stripe $ 15.76 + Income:Fee Buffer:Stripe -$ 15.76 + Income:Escrow:Stripe +2012-06-29 Retained Earnings + Income:Escrow:Stripe $ 95.24 + Income:Fee Buffer:Stripe $ 15.76 + Equity:Retained Earnings -$ 15.76 + Liabilities:Escrow + +2012-06-29 Stripe + Expenses:Fee Buffer:Stripe $ 14.63 + Assets:Fee Buffer:Stripe +2012-06-29 Retained Earnings + Equity:Retained Earnings $ 14.63 + Expenses:Fee Buffer:Stripe + +; IHasAMoney.com +2012-06-18 Users + Assets:Operations:Samurai $ 2.92 + Income:Operations:IHasAMoney.com +2012-06-18 Retained Earnings + Income:Operations:IHasAMoney.com $ 2.92 + Equity:Retained Earnings +2012-06-18 Settlement + Assets:Operations:New Alliance $ 2.92 + Assets:Operations:Samurai +2012-06-25 Users + Assets:Operations:Samurai $ 2.99 + Income:Operations:IHasAMoney.com +2012-06-25 Retained Earnings + Income:Operations:IHasAMoney.com $ 2.99 + Equity:Retained Earnings +2012-06-25 Settlement + Assets:Operations:New Alliance $ 2.99 + Assets:Operations:Samurai diff --git a/README.md b/README.md index c4b4ea0..e947da1 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,95 @@ -This is Gratipay's financial accounting system, which is based on [Ledger -3](http://ledger-cli.org/). We have a directory for each year, and a `NN.dat` -file for each month. Scripts are in the `bin/` directory. +# Gratipay Finances + +This is [Gratipay](https://gratipay.com/)'s financial accounting system, which +is based on [Ledger](http://ledger-cli.org/). We have a directory for each +year, and an `NN.dat` file for each month. Our wrapper scripts are in the +`bin/` directory; add it to your `PATH` for best results. Each month gets [a +PR](https://github.com/gratipay/finances/pulls). + +[![status](https://api.travis-ci.org/gratipay/finances.svg)](https://travis-ci.org/gratipay/finances) + + +## How Our Books are Organized + +The biggest reality in our finances is that we have **operations**—*our* +money—and then we have **escrow**—*other people's* money. Never the +twain shall meet (more or less). Beyond the basic accounting principle that +assets must equal liabilities plus equity, nearly as important for Gratipay is +that escrow assets must always equal escrow liability: when people think we're +holding their money, we better be holding their money! + +Actually, though, our income from processing fees comes to us from our upstream +processors commingled with escrow, *and* we want to keep our fee *income* as +close to our fee *expenses* as possible (our *operating* income, of course, +comes [through Gratipay](https://gratipay.com/Gratipay/) just like any other +Gratipay Team). To deal with this dual reality, we use a **fee buffer**. +Ideally the balance in the fee buffer is zero, though of course it varies in +practice. + +You'll see, then, that the assets on our balance sheet, as well as our income +and expenses on our income statement, are broken down according to these three +second-level categories: escrow, fee buffer, and operations. The fee buffer and +operations on the income statement hit retained earnings on the balance sheet. +Escrow on the income statement hits escrow liability on the balance sheet. + +Whereas the second-level categories are *logical*, our actual *physical* bank +and processor accounts end up as third-level categories. So, for example, our +actual balance at New Alliance Federal Credit Union is equal to the sum of +these three balance sheet accounts: + + - Assets:Escrow:New Alliance + - Assets:Fee Buffer:New Alliance + - Assets:Operations:New Alliance + + +## Working on the Finances + +First, you'll need [Ledger](http://ledger-cli.org/) (v3), +[Python](https://www.python.org/) (v2.7), a [text +editor](https://en.wikipedia.org/wiki/Text_editor), and a [command +line](https://en.wikipedia.org/wiki/Command-line_interface). Then basically +what you're gonna do is edit the dat file for the month you're working on, and +then, from the root of your clone of this repo, run: + +``` +clear && test.py && balance-sheet.py && income-statement.py +``` + +That'll check for errors (we also have CI set up [at +Travis](https://travis-ci.org/gratipay/finances)) and then show you a balance +sheet and income statement. + + +### Style + +Here are some style notes for the dat files: + + 1. Group transactions together conceptually. + + 1. Transactions should be generally date-sorted, but it's okay to fudge that a + little for the sake of (1). + + 1. Record debits first. + + 1. Symmetry is nice. + + 1. Use comments! Especially for weird stuff. + + +### Access + +Many accounting tasks require access to Gratipay's bank and payment processor +statements. If you're interested in helping out with such tasks, read [Inside +Gratipay](http://inside.gratipay.com/) and then introduce yourself on [the +Radar](http://inside.gratipay.com/howto/sweep-the-radar). + + +# Help + +[Open an issue](https://github.com/gratipay/finances/issues/new) if you're having problems. + + +# Legal + +The scripts and data in this repo are released into the public domain to the +extent possible under [CC0](http://creativecommons.org/publicdomain/zero/1.0/). diff --git a/bin/balance-sheet.py b/bin/balance-sheet.py new file mode 100755 index 0000000..3c72433 --- /dev/null +++ b/bin/balance-sheet.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python2.7 +from __future__ import absolute_import, division, print_function, unicode_literals + +import sys +from os import path +import calendar + +ourdir = path.realpath(path.dirname(__file__)) +sys.path.insert(0, ourdir) + +import reporting + +cmd = [ 'ledger' + , 'balance' + , '--prepend-width=0' # this is here to satisfy ledger on Travis + , '--sort "account =~ /^Assets.*/ ? 0 : ' + , '(account =~ /^Liabilities.*/ ? 1 : ' + , '(account =~ /^Equity.*/ ? 2 : 3))"' + ] +cmd += sys.argv[1:] + +year, month = reporting.parse(sys.argv[1:])[1] + +# Each year opens with a carryover balance, so we don't have to go further back than that. +start = [year, None] +end = [year, month] +for datfile in reporting.list_datfiles(start, end): + cmd.append('-f {}'.format(datfile)) + +print() +print("BALANCE SHEET".center(42)) +print("as of {} {}, {}".format( calendar.month_name[int(end[1])] + , calendar.monthrange(int(end[0]), int(end[1]))[1] + , end[0] + ).center(42)) +reporting.report(cmd) diff --git a/bin/income-statement.py b/bin/income-statement.py new file mode 100755 index 0000000..9cabc21 --- /dev/null +++ b/bin/income-statement.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python2.7 +from __future__ import absolute_import, division, print_function, unicode_literals + +import sys +from os import path +import calendar + +ourdir = path.realpath(path.dirname(__file__)) +sys.path.insert(0, ourdir) + +import reporting + +base = path.realpath(path.join(path.dirname(__file__), '..')) +cmd = [ 'ledger' + , 'balance' + , '^Income' + , '^Expense' + , '--prepend-width=0' # this is here to satisfy ledger on Travis + , '--limit "not (payee =~ /^Retained Earnings$/)"' + , '--sort "account =~ /^Income.*/ ? 0 : ' + , '(account =~ /^Expense.*/ ? 1 : 2))"' + ] +cmd += sys.argv[1:] + +start, end = reporting.parse(sys.argv[1:]) +for datfile in reporting.list_datfiles(start, end): + cmd.append('-f {}'.format(datfile)) + +print() +print("INCOME STATEMENT".center(42)) +if start == end: + print("for {}, {}".format(calendar.month_name[int(end[1])], end[0]).center(42)) +elif start[0] == end[0]: + print("for {} through {}, {}".format( calendar.month_name[int(start[1])] + , calendar.month_name[int(end[1])] + , end[0]).center(42) + ) +else: + print("for {}, {} through {}, {}".format( calendar.month_name[int(start[1])] + , start[0] + , calendar.month_name[int(end[1])] + , end[0] + ).center(42)) +reporting.report(cmd) diff --git a/bin/register.py b/bin/register.py new file mode 100755 index 0000000..2e7647b --- /dev/null +++ b/bin/register.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python2.7 +from __future__ import absolute_import, division, print_function, unicode_literals + +import sys +from os import path +import calendar + +ourdir = path.realpath(path.dirname(__file__)) +sys.path.insert(0, ourdir) + +import reporting + +base = path.realpath(path.join(path.dirname(__file__), '..')) +cmd = [ 'ledger' + , 'register' + ] +cmd += sys.argv[1:] + +start, end = reporting.parse(sys.argv[1:]) +for datfile in reporting.list_datfiles(start, end): + cmd.append('-f {}'.format(datfile)) + +print() +print("REGISTER".center(80)) +if start == end: + print("for {}, {}".format(calendar.month_name[int(end[1])], end[0]).center(80)) +elif start[0] == end[0]: + print("for {} through {}, {}".format( calendar.month_name[int(start[1])] + , calendar.month_name[int(end[1])] + , end[0]).center(80) + ) +else: + print("for {}, {} through {}, {}".format( calendar.month_name[int(start[1])] + , start[0] + , calendar.month_name[int(end[1])] + , end[0] + ).center(80)) +print() +print("Month Payee Account Amount Balance") +reporting.report(cmd) diff --git a/bin/rename.py b/bin/rename.py new file mode 100755 index 0000000..2ce8266 --- /dev/null +++ b/bin/rename.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python2.7 +from __future__ import absolute_import, division, print_function, unicode_literals + +import sys +from os import path, chdir + +ourdir = path.realpath(path.dirname(__file__)) +sys.path.insert(0, ourdir) + +import reporting + +OLD, NEW = sys.argv[1:3] + +chdir(reporting.root) +for filename in reporting.list_datfiles(): + with open(filename, 'r') as fp: + contents = fp.read() + for tmpl in (" {:<64}$", " {:<63}-$", " {}\n"): + old = tmpl.format(OLD) + new = tmpl.format(NEW) + contents = contents.replace(old, new) + with open(filename, 'w+') as fp: + fp.write(contents) diff --git a/bin/reporting.py b/bin/reporting.py new file mode 100644 index 0000000..2e4bffc --- /dev/null +++ b/bin/reporting.py @@ -0,0 +1,92 @@ +"""This is a library to support Gratipay's financial reporting. +""" +from __future__ import absolute_import, division, print_function, unicode_literals + +import argparse +import subprocess +import os +import re +import sys +from os import path + +root = path.realpath(path.join(path.dirname(__file__), '..')) + + +def parse(argv): + """Given a command-line argument vector, return a (year, month) tuple. + + Raises SystemExit if the argument is in a bad format. + + """ + parser = argparse.ArgumentParser(argument_default='') + parser.add_argument('-b', '--begin', default='') + parser.add_argument('-e', '--end', default='') + date_range, _ = parser.parse_known_args(argv) + return (parse_one(date_range.begin), parse_one(date_range.end)) + + +def parse_one(arg): + patterns = (r'^\d\d\d\d-\d\d-\d\d', r'^\d\d\d\d-\d\d$', r'^\d\d\d\d$', r'^\d\d$') + if arg and not any([re.match(p, arg) for p in patterns]): + sys.exit("Bad argument, must be YYYY-MM-DD, YYYY-MM, YYYY, or MM.") + return { 0: [None, None] + , 2: [None, arg] + , 4: [arg, None] + , 7: arg.split('-') + , 10: arg.split('-')[:2] + }[len(arg)] + + +def list_datfiles(start=None, end=None): + """Given two (year, month) tuples, yield filepaths. + + Raises SystemExit if we don't have a datfile for a month in the + range specified. + + """ + start = start or [None, None] + end = end or start + + years = [y for y in sorted(os.listdir(root)) if y.isdigit()] + if start[0] is None: start[0] = years[-1] + if end[0] is None: end[0] = start[0] + + for year in start[0], end[0]: + if year not in years: + sys.exit("Sorry, we don't have any data for {}.".format(year)) + + filtered = [] + for year in years: + if year < start[0]: continue + elif year > end[0]: break + + months = [f[:2] for f in sorted(os.listdir(path.join(root, year))) if f.endswith('.dat')] + + def check(month): + if month[1] not in months: + sys.exit("Sorry, we don't have any data for {}-{}.".format(*month)) + + if start[0] == year: + if start[1] is None: start[1] = months[0] + check(start) + if end[0] == year: + if end[1] is None: end[1] = months[-1] + check(end) + + for month in months: + if start[1] == year and month < start[1]: continue + elif end[1] == year and month > end[1]: break + + filtered.append('{}/{}.dat'.format(year, month)) + + if end < start: + sys.exit('Error: {}-{} comes before {}-{}.'.format(*(end + start))) + + return filtered + + +def report(cmd): + print() + retcode = subprocess.call(' '.join(cmd), shell=True) + if retcode != 0: + raise SystemExit(retcode) diff --git a/bin/sum-stripe-fees.py b/bin/sum-stripe-fees.py new file mode 100755 index 0000000..bc6ef8f --- /dev/null +++ b/bin/sum-stripe-fees.py @@ -0,0 +1,17 @@ +#!/usr/bin/env python2 +"""Given a Stripe payments CSV export, sum the fees. +""" +from __future__ import absolute_import, division, print_function, unicode_literals +import csv, sys +from decimal import Decimal as D + +inp = csv.reader(open(sys.argv[1])) +headers = next(inp) + +fees = D(0) + +for row in inp: + rec = dict(zip(headers, row)) + fees += D(rec['Fee']) + +print(fees) diff --git a/bin/test.py b/bin/test.py new file mode 100755 index 0000000..5f65389 --- /dev/null +++ b/bin/test.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python2.7 -u +from __future__ import absolute_import, division, print_function, unicode_literals + +import commands +import sys +import traceback +from os import path +from decimal import Decimal as D + + +root = path.realpath(path.dirname(__file__)) +report_scripts = { 'balance sheet': path.join(root, 'balance-sheet.py') + , 'income statement': path.join(root, 'income-statement.py') + } + + +def report(name, just_accounts=False): + status, report = commands.getstatusoutput(report_scripts[name] + ' --flat') + if status > 0: + raise SystemExit(report) + for line in report.splitlines(): + if just_accounts: + if line.startswith('------------') or not line: break + yield line.split(None, 2) + else: + yield line.strip() + + +def accounts(name): + return report(name, just_accounts=True) + + +def test_escrow_balances(): + escrow_assets = escrow_liability = D(0) + + for currency, amount, account in accounts('balance sheet'): + if account.startswith('Assets:Escrow:'): + escrow_assets += D(amount) + if account.startswith('Liabilities:Escrow'): + escrow_liability += D(amount) + + print(escrow_assets, escrow_liability) + assert escrow_assets + escrow_liability == 0 + + +def test_income_balances(): + for currency, amount, account in accounts('balance sheet'): + assert not account.startswith('Income:') + print('good') + + +def test_expenses_balance(): + for currency, amount, account in accounts('balance sheet'): + assert not account.startswith('Expenses:') + print('good') + + +def test_fee_buffer_reconciles(): + + fee_income = fee_expense = fee_buffer = D(0) + + for currency, amount, account in accounts('balance sheet'): + if account.startswith('Assets:Fee Buffer:'): + fee_buffer += D(amount) + + for currency, amount, account in accounts('income statement'): + if account.startswith('Income:Fee Buffer:'): + fee_income -= D(amount) + if account.startswith('Expenses:Fee Buffer:'): + fee_expense -= D(amount) + + delta = fee_income + fee_expense + print(fee_income, fee_expense, delta, fee_buffer, abs(delta-fee_buffer)) + assert delta == fee_buffer + + +def test_net_income_reconciles_with_retained_earnings(): + + retained_earnings = net_income = D(0) + + for currency, amount, account in accounts('balance sheet'): + if account == 'Liabilities:Escrow': + retained_earnings += D(amount) + if account == 'Equity:Retained Earnings': + retained_earnings += D(amount) + + total = D(0) + for line in report('income statement'): + if line.startswith('$'): + try: + currency, total, _ = line.split(None, 2) + except ValueError: + currency, total = line.split() + break + net_income = D(total) + + print(retained_earnings, net_income) + assert retained_earnings == net_income + + +if __name__ == '__main__': + nfailures = 0 + filt = sys.argv[1] if len(sys.argv) > 1 else '' + for name, test_func in globals().items(): + if name.startswith('test_') and filt in name: + print(name, "... ", end='') + try: + test_func() + except: + nfailures += 1 + traceback.print_exc() + print() + raise SystemExit(nfailures)