diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d4b7ccc..cf794a9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,10 +11,9 @@ jobs: strategy: matrix: py: - - "3.9" - - "pypy3.9" + - "3.11" os: - - "macos-latest" + - "ubuntu-latest" architecture: - x64 name: "Python: ${{ matrix.py }}-${{ matrix.architecture }} on ${{ matrix.os }}" @@ -29,9 +28,9 @@ jobs: - name: Install dependencies run: make build-for-test - name: Run tests - run: make test-matrix + run: pytest coverage: - runs-on: macos-latest + runs-on: ubuntu-latest name: Report coverage env: COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_REPO_TOKEN }} @@ -42,21 +41,21 @@ jobs: - name: Setup python uses: actions/setup-python@v4 with: - python-version: 3.9 + python-version: 3.11 architecture: x64 - name: Install dependencies run: make build-for-test - name: Report coverage run: make cover-coveralls lint: - runs-on: macos-latest + runs-on: ubuntu-latest name: Lint the package steps: - uses: actions/checkout@v3 - name: Setup python uses: actions/setup-python@v4 with: - python-version: 3.9 + python-version: 3.11 architecture: x64 - name: Install dependencies run: make build diff --git a/.ignore b/.ignore new file mode 100644 index 0000000..1902214 --- /dev/null +++ b/.ignore @@ -0,0 +1 @@ +!/.github/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e65f135..d0a8f45 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,13 +6,37 @@ You could do this in a virtual environment or at the system / user level if you $ make build ``` +You may also need to run: + +``` +$ make build-for-test +``` + +Together, these should ensure that you have all development dependencies so that the rest of the `make` targets should work. + # Running Tests +## Unit Tests + ``` $ make test ``` -## Debugging +## Integration Tests + +``` +$ pytest tests/integration +``` + +NOTE: We do have a `make` target for this: + +``` +$ make test-integration +``` + +But this does not work even though it runs a command identical to the above, due to some weird dependency issue with pytest. For now, just use the earlier command in the shell directly. + +# Debugging To debug a test execution using a step debugger, put this in the body of the test you'd like to debug: @@ -20,6 +44,12 @@ To debug a test execution using a step debugger, put this in the body of the tes import pudb; pudb.set_trace() ``` +or simply, + +``` +import pudb; pu.db +``` + Now, when you run `make test` (for example), it will put you in the debugger, allowing you to step through the execution of the code. Here are some things you can do: * `n` - next @@ -32,8 +62,6 @@ Now, when you run `make test` (for example), it will put you in the debugger, al There's more handy stuff that you can do like setting breakpoints and visiting other modules. Press `?` to see all the options. -*Note*: the official docs for `pudb` say that we could also use `pu.db()` instead of `pudb.set_trace()`, but this doesn't seem to work. - # Linting Linter: diff --git a/DEV.md b/DEV.md deleted file mode 100644 index c30c670..0000000 --- a/DEV.md +++ /dev/null @@ -1,22 +0,0 @@ -# Install for Development - -``` -make build -``` - -# Running Tests - -Refer to the Makefile for all things related to the development workflow. E.g. - -``` -make test -``` - -# Drafting a Release - -``` -# increment the version -bump2version [major|minor|patch] -# push new release tag upstream -git push --follow-tags -``` diff --git a/Makefile b/Makefile index 0c1d35f..5ce0315 100644 --- a/Makefile +++ b/Makefile @@ -5,6 +5,7 @@ DOCS-PATH=docs export PYTEST_DISABLE_PLUGIN_AUTOLOAD = 1 UNIT_TESTS_PATH = tests/unit +INTEGRATION_TESTS_PATH = tests/integration help: @echo "clean - remove all build, test, coverage and Python artifacts" @@ -23,6 +24,7 @@ help: @echo "lint - alias for lint-source" @echo "black - run black auto-formatting on all code" @echo "test-unit - run unit tests" + @echo "test-integration - run integration tests" @echo "test - run specified tests, e.g.:" @echo " make test DEST=tests/unit/my_module.py" @echo " (defaults to unit tests if none specified)" @@ -93,7 +95,12 @@ black: test-unit: python setup.py test --addopts $(UNIT_TESTS_PATH) -test-all: clean-test test-unit +# NOTE: does not work! Only works when this identical +# command is run directly at the command line +test-integration: + pytest $(INTEGRATION_TESTS_PATH) + +test-all: clean-test test-unit test-integration test: ifdef DEST @@ -146,4 +153,4 @@ sdist: clean python setup.py sdist ls -l dist -.PHONY: help build build-for-test docs clean clean-build clean-pyc clean-test lint-source lint-tests lint-all lint black test-unit test-all test test-stop test-debug test-matrix test-tldr test-wiki debug coverage cover-coveralls sdist +.PHONY: help build build-for-test docs clean clean-build clean-pyc clean-test lint-source lint-tests lint-all lint black test-unit test-integration test-all test test-stop test-debug test-matrix test-tldr test-wiki debug coverage cover-coveralls sdist diff --git a/docs/assets/img/logo.png b/docs/assets/img/logo.png new file mode 100644 index 0000000..a830def Binary files /dev/null and b/docs/assets/img/logo.png differ diff --git a/docs/oldabe.scrbl b/docs/oldabe.scrbl index 14cc7c0..0615306 100644 --- a/docs/oldabe.scrbl +++ b/docs/oldabe.scrbl @@ -1,7 +1,14 @@ #lang scribble/manual +@require[racket/runtime-path] + @title{Old Abe: The Accountant for All of your ABE Needs} +@(define-runtime-path logo-path "assets/img/logo.png") +@(if (file-exists? logo-path) + (image logo-path #:scale 1.0) + (printf "[WARNING] No ~a file found!~%" logo-path)) + @table-of-contents[] @section{Intro} @@ -25,6 +32,8 @@ Old Abe considers precisely three inputs in doing all of its accounting, and it @item{Attributions -- an association of contributor to percentage of value allocated from the value represented by the project as a whole.} +@item{Instruments -- an association of an instrument to percentage of value allocated from the value represented by the project as a whole.} + @item{Price -- a generic "fair market value" provided by the project to its users (Like many concepts, this concept named price has a distinct role in ABE from its traditional role in capitalism).} @item{Valuation -- the assessed present value of the project as a whole.} @@ -34,81 +43,24 @@ All of these are determined through the process of Dialectical Inheritance Attri @section{Accounting Flows} -There are several actions ("accountable actions") pertaining to a project that trigger accounting by Old Abe. These are: +Current accounting flows are a mix of manual and automated actions. Old Abe is not directly connected to any financial systems, so its primary role is to run the accounting logic, keep track of any project investors, and tell the maintainer how much to pay project contributors. It is up to the maintainer to record incoming payments (@code{abe/payments/}) and outgoing payouts (@code{abe/payouts/}). @itemlist[ #:style 'ordered -@item{Work is done for the project.} +@item{Recording a Payment - triggers a GitHub Action that runs the accounting logic (details below) and produces a report of all Outstanding Balances as a GitHub Issue. The maintainer can refer to the Issue to find out how much to pay.} -@item{A financial contribution is made to the project.} - -@item{An appointed project representative fulfills a payout to a contributor.} - -@item{A "fiat" change is made to one of the inputs, i.e. either attributions, price or valuation, which is typically a resolution by DIA.} +@item{Recording a Payout - triggers a GitHub Action that simply updates the Outstanding Balances issue to reflect the updated amounts owed.} ] -We will learn more about each of these, in turn. - -@subsection{Work} - -Work done could be either labor, capital, or ideas, as defined in the @hyperlink["https://github.com/drym-org/finance/blob/main/finance.md"]{ABE financial model}. Regardless of what kind of work it is, its appraisal takes the form of an "incoming attribution," which is an association of a set of contributors to percentage of value contributed, as judged in related to existing attribution allocations in the project. - -Old Abe will account this by "renormalizing" the attributions to total to 100% after incorporating the fresh values. - -TODO: flesh out - @subsection{Payment} -When a payment comes in, we first pay out any @tech{instruments}. Then, with the remaining amount, we pay project contributors. - -TODO: flesh out - -@subsection{Payout} - -TODO: flesh out - -@subsection{Fiat Change in Inputs} - -TODO: flesh out, including backpropagation - -@section{Modules} - -The accounting flows mentioned earlier correspond to distinct modules that handle them. - -@subsection{Money In} - -This module handles incoming payments. - -First it finds all payments that have not already been processed, that -is, which do not appear in the transactions file. - -For each of these payments, it consults the current attributions for -the project, and does three things. - -First, it figures out how much each person in the attributions file is -owed from this fresh payment, generating a transaction for each -stakeholder. - -Second, it determines how much of the incoming payment can be -considered an "investment" by comparing the project price with the -total amount paid by this payer up to this point -- the excess, if -any, is investment. +When someone makes a payment to a project, Old Abe allocates portions of that payment to project contributors and creates a report that tells maintainers how much money the project owes to each individual contributor. We'll get deep into the weeds of how it does that in a moment. If the incoming payment represents an investment (that is, it brings the payer's total amount paid above the project price), the payer is considered a project contributor. The system adds them to the attributions file with a share equal to their investment (or increases their pre-existing attributive share). The project valuation is increased by the investment amount and all existing attributive shares are diluted so that percentage shares still add up 100%. -Third, it increases the current valuation by the investment amount -determined, and, at the same time, "dilutes" the attributions by -making the payer an attributive stakeholder with a share proportionate -to their incoming investment amount (or if the payer is already a -stakeholder, increases their existing share) in relation to the -valuation. +Now, let's talk about how Old Abe allocates an incoming payment. -@subsection{Money Out} +First, we pay off any processing fees (found in @code{instruments.txt}). These fees have fixed percentages that apply to every incoming payment and do not get diluted by investments. They are somewhat analogous to credit card fees. They go towards the Old Abe system itself and to those who have contributed to the DIA process for this project. -This module determines outstanding balances owed. +Next, we divide the remainder among the contributors in the attributions file. Ideally, this is as simple as dividing the amount according to each contributor's attributive share. However, sometimes certain contributors are temporarily unpayable (e.g. they might not have provided their payment information yet, etc.). In that case, we record the amount owed to that contributor as a "debt" so that the project can pay them later. To avoid having money sitting around in maintainers' accounts, we divide any amount left over among payable contributors, according to attributive share. Any amount we pay someone in excess of what we owed them originally, we record as an "advance." The idea here is that when someone eventually becomes payable, we can prioritize paying off the debt we owe them by allocating money to them first whenever a new payment comes in. Anyone who has been accumulating advances will receive a little less than their attributive share until their total advance amount has been "drawn down" and the scales have been balanced between debts and advances. -First, it reads all generated transactions from payments that have come in to -determine the amount owed to each contributor. Then, it looks at all recorded -payouts to see the total amounts that have already been paid out. It then -reports the difference of these values, by contributor, as the balance still -owed. diff --git a/entrypoint.sh b/entrypoint.sh index a10f6d6..3765867 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -1,8 +1,8 @@ -#!/bin/sh -l +#!/bin/bash # ensure that any errors encountered cause immediate # termination with a non-zero exit code -set -e +set -eo pipefail echo "PWD is: " echo $(pwd) @@ -23,21 +23,17 @@ echo "... done." # Note that running this locally would cause your global # git config to be modified -echo "Committing updated transactions and attributions back to repo..." +echo "Committing updated accounting records back to repo..." git config --global user.email "abe@drym.org" git config --global user.name "Old Abe" -git add abe/transactions.txt abe/attributions.txt abe/valuation.txt abe/itemized_payments.txt +git add abe/transactions.txt abe/attributions.txt abe/valuation.txt abe/itemized_payments.txt abe/advances.txt abe/debts.txt -set +e - -git commit -m "Updated transactions and attributions" +git commit -m "Updated accounting records" git fetch git rebase origin/`git remote set-head origin -a | cut -d' ' -f4` git push origin `git remote set-head origin -a | cut -d' ' -f4` echo "... done." -set -e - echo "Running money_out script..." echo balances=$(python -m oldabe.money_out) >> $GITHUB_OUTPUT echo "... done." diff --git a/oldabe/accounting_utils.py b/oldabe/accounting_utils.py new file mode 100644 index 0000000..6d6672d --- /dev/null +++ b/oldabe/accounting_utils.py @@ -0,0 +1,34 @@ +from decimal import Decimal + +ROUNDING_TOLERANCE = Decimal("0.000001") + + +def get_rounding_difference(attributions): + """ + Get the difference of the total of the attributions from 1, which is + expected to occur due to finite precision. If the difference exceeds the + expected error tolerance, an error is signaled. + """ + total = _get_attributions_total(attributions) + difference = total - Decimal("1") + assert abs(difference) <= ROUNDING_TOLERANCE + return difference + + +def correct_rounding_error(attributions, incoming_attribution): + """Due to finite precision, the Decimal module will round up or down + on the last decimal place. This could result in the aggregate value not + quite totaling to 1. This corrects that total by either adding or + subtracting the difference from the incoming attribution (by convention). + """ + difference = get_rounding_difference(attributions) + attributions[incoming_attribution.email] -= difference + + +def assert_attributions_normalized(attributions): + print(_get_attributions_total(attributions)) + assert _get_attributions_total(attributions) == Decimal("1") + + +def _get_attributions_total(attributions): + return sum(attributions.values()) diff --git a/oldabe/constants.py b/oldabe/constants.py new file mode 100644 index 0000000..2a71c61 --- /dev/null +++ b/oldabe/constants.py @@ -0,0 +1,23 @@ +import os +from decimal import Decimal + +ACCOUNTING_ZERO = Decimal("0.01") + +ABE_ROOT = './abe' +PAYOUTS_DIR = os.path.join(ABE_ROOT, 'payouts') +PAYMENTS_DIR = os.path.join(ABE_ROOT, 'payments') +NONATTRIBUTABLE_PAYMENTS_DIR = os.path.join( + ABE_ROOT, 'payments', 'nonattributable' +) + +TRANSACTIONS_FILE = os.path.join(ABE_ROOT, 'transactions.txt') +DEBTS_FILE = os.path.join(ABE_ROOT, 'debts.txt') +ADVANCES_FILE = os.path.join(ABE_ROOT, 'advances.txt') +UNPAYABLE_CONTRIBUTORS_FILE = os.path.join( + ABE_ROOT, 'unpayable_contributors.txt' +) +ITEMIZED_PAYMENTS_FILE = os.path.join(ABE_ROOT, 'itemized_payments.txt') +PRICE_FILE = os.path.join(ABE_ROOT, 'price.txt') +VALUATION_FILE = os.path.join(ABE_ROOT, 'valuation.txt') +ATTRIBUTIONS_FILE = os.path.join(ABE_ROOT, 'attributions.txt') +INSTRUMENTS_FILE = os.path.join(ABE_ROOT, 'instruments.txt') diff --git a/oldabe/distribution.py b/oldabe/distribution.py new file mode 100644 index 0000000..6f1bb12 --- /dev/null +++ b/oldabe/distribution.py @@ -0,0 +1,48 @@ +from decimal import Decimal +from typing import Set + + +class Distribution(dict[str | None, Decimal]): + """ + A dictionary of shareholders to proportions + + None can be used as a shareholder to omit a percentage from distribution + and enumeration. + """ + + def _normalized(self) -> "Distribution": + """ + Return a distribution with the same proportions that adds up to 1 + """ + total = sum(self.values()) + + return Distribution( + { + shareholder: share * (Decimal("1") / total) + for shareholder, share in self.items() + } + ) + + def without(self, exclude: Set[str]) -> "Distribution": + """ + Return a distribution without the shareholders in exclude + + Other shareholders retain their relative proportions. + """ + return Distribution( + { + shareholder: share + for shareholder, share in self.items() + if shareholder not in exclude + } + ) + + def distribute(self, amount: Decimal) -> dict[str, Decimal]: + """ + Distribute an amount amongst shareholders + """ + return { + shareholder: amount * share + for shareholder, share in self._normalized().items() + if shareholder is not None + } diff --git a/oldabe/git.py b/oldabe/git.py new file mode 100644 index 0000000..1bae937 --- /dev/null +++ b/oldabe/git.py @@ -0,0 +1,12 @@ +import subprocess +from functools import cache + + +@cache +def get_git_revision_short_hash() -> str: + """From https://stackoverflow.com/a/21901260""" + return ( + subprocess.check_output(['git', 'rev-parse', '--short', 'HEAD']) + .decode('ascii') + .strip() + ) diff --git a/oldabe/models.py b/oldabe/models.py index e95a810..7426b52 100644 --- a/oldabe/models.py +++ b/oldabe/models.py @@ -1,35 +1,84 @@ -from datetime import datetime from dataclasses import dataclass, field +from datetime import datetime from decimal import Decimal +from oldabe.git import get_git_revision_short_hash +from oldabe.parsing import parse_percentage + + +# Wrapping so that tests can mock it +def default_commit_hash(): + return get_git_revision_short_hash() + @dataclass class Transaction: - email: str = None - amount: Decimal = 0 - payment_file: str = None - commit_hash: str = None + email: str + amount: Decimal + payment_file: str + commit_hash: str = field(default_factory=lambda: default_commit_hash()) + created_at: datetime = field(default_factory=datetime.utcnow) + + +@dataclass +class Payout: + name: str + email: str + amount: Decimal created_at: datetime = field(default_factory=datetime.utcnow) @dataclass class Debt: - email: str = None - amount: Decimal = None + email: str + amount: Decimal # amount_paid is a running tally of how much of this debt has been paid # in future will link to Transaction objects instead - amount_paid: Decimal = 0 - payment_file: str = None - commit_hash: str = None + amount_paid: Decimal + payment_file: str + commit_hash: str = field(default_factory=lambda: default_commit_hash()) + created_at: datetime = field(default_factory=datetime.utcnow) + + def key(self): + return (self.email, self.payment_file) + + def is_fulfilled(self): + return self.amount_paid == self.amount + + def amount_remaining(self): + return self.amount - self.amount_paid + + +# These are not recorded (yet?), they just represent an intention +# to record a payment +@dataclass +class DebtPayment: + debt: Debt + amount: Decimal + + +# Individual advances can have a positive or negative amount (to +# indicate an actual advance payment, or a drawn down advance). +# To find the current advance amount for a given contributor, +# sum all of their existing Advance objects. +@dataclass +class Advance: + email: str + amount: Decimal + payment_file: str + commit_hash: str = field(default_factory=lambda: default_commit_hash()) created_at: datetime = field(default_factory=datetime.utcnow) @dataclass class Payment: - email: str = None - amount: Decimal = 0 + email: str + name: str + amount: Decimal + # Should move this, but it will invalidate existing files + created_at: datetime = field(default_factory=datetime.utcnow) attributable: bool = True - file: str = None + file: str = '' # ItemizedPayment acts as a proxy for a Payment object that keeps track @@ -38,17 +87,18 @@ class Payment: # avoid mutating Payment records. @dataclass class ItemizedPayment: - email: str = None - fee_amount: Decimal = 0 # instruments - project_amount: Decimal = 0 # attributions - attributable: bool = True - payment_file: str = ( - None # acts like a foreign key to original payment object - ) + email: str + fee_amount: Decimal # instruments + project_amount: Decimal # attributions + attributable: bool + payment_file: str # acts like a foreign key to original payment object @dataclass class Attribution: - email: str = None - share: Decimal = 0 - dilutable: bool = True + email: str + share: str + + @property + def decimal_share(self): + return parse_percentage(self.share) diff --git a/oldabe/money_in.py b/oldabe/money_in.py index 93af750..8376615 100755 --- a/oldabe/money_in.py +++ b/oldabe/money_in.py @@ -1,96 +1,50 @@ #!/usr/bin/env python import csv -from decimal import Decimal, getcontext -from dataclasses import astuple +import dataclasses import re -import os -import subprocess -from .models import Attribution, Payment, ItemizedPayment, Transaction - -ABE_ROOT = 'abe' -PAYMENTS_DIR = os.path.join(ABE_ROOT, 'payments') -NONATTRIBUTABLE_PAYMENTS_DIR = os.path.join( - ABE_ROOT, 'payments', 'nonattributable' -) -TRANSACTIONS_FILE = 'transactions.txt' -ITEMIZED_PAYMENTS_FILE = 'itemized_payments.txt' -PRICE_FILE = 'price.txt' -VALUATION_FILE = 'valuation.txt' -ATTRIBUTIONS_FILE = 'attributions.txt' -INSTRUMENTS_FILE = 'instruments.txt' - -ROUNDING_TOLERANCE = Decimal("0.000001") -ACCOUNTING_ZERO = Decimal("0.01") - - -def parse_percentage(value): - """ - Translates values expressed in percentage format (75.234) into - their decimal equivalents (0.75234). This effectively divides - the value by 100 without losing precision. - """ - value = re.sub("[^0-9.]", "", value) - value = "00" + value - if "." not in value: - value = value + ".0" - value = re.sub( - r"(?P
\d{2})\.(?P\d+)", r".\g
\g", value
-    )
-    value = Decimal(value)
-    return value
-
+from dataclasses import astuple
+from decimal import Decimal, getcontext
+from itertools import accumulate
+from typing import Iterable, List, Set, Tuple
 
-def serialize_proportion(value):
-    """
-    Translates values expressed in decimal format (0.75234) into
-    their percentage equivalents (75.234). This effectively multiplies
-    the value by 100 without losing precision.
-    """
-    # otherwise, decimal gets translated '2E-7.0'
-    value = format(value, "f")
-    if "." in value:
-        value = value + "00"
-    else:
-        value = value + ".00"
-    value = re.sub(
-        r"(?P
\d)\.(?P\d{2})(?P\d*)",
-        # match a number followed by a decimal point
-        # followed by at least two digits
-        r"\g
\g.\g",
-        # move the decimal point two places to the right
-        value,
-    )
-    # strip leading insignificant zeroes
-    value = value.lstrip("0")
-    # ensure there's a single leading zero if it is
-    # a decimal value less than 1
-    value = re.sub(r"^\.", r"0.", value)
-    if "." in value:
-        # strip trailing insignificant zeroes
-        value = value.rstrip("0")
-        # remove decimal point if whole number
-        value = re.sub(r"\.$", r"", value)
-    return value
-
-
-def read_payment(payment_file, attributable=True):
-    """
-    Reads a payment file and uses the contents to create a Payment object.
-    """
-    payments_dir = (
-        PAYMENTS_DIR if attributable else NONATTRIBUTABLE_PAYMENTS_DIR
-    )
-    with open(os.path.join(payments_dir, payment_file)) as f:
-        for row in csv.reader(f, skipinitialspace=True):
-            name, _email, amount, _date = row
-            amount = re.sub("[^0-9.]", "", amount)
-            return Payment(name, Decimal(amount), attributable, payment_file)
+from .accounting_utils import (
+    assert_attributions_normalized,
+    correct_rounding_error,
+)
+from .constants import (
+    ACCOUNTING_ZERO,
+    ATTRIBUTIONS_FILE,
+    DEBTS_FILE,
+    PRICE_FILE,
+    VALUATION_FILE,
+)
+from .distribution import Distribution
+from .models import (
+    Advance,
+    Attribution,
+    Debt,
+    DebtPayment,
+    ItemizedPayment,
+    Payment,
+    Transaction,
+)
+from .parsing import serialize_proportion
+from .repos import (
+    AdvancesRepo,
+    AllPaymentsRepo,
+    AttributionsRepo,
+    DebtsRepo,
+    InstrumentsRepo,
+    ItemizedPaymentsRepo,
+    TransactionsRepo,
+    UnpayableContributorsRepo,
+)
+from .tally import Tally
 
 
-def read_price():
-    price_file = os.path.join(ABE_ROOT, PRICE_FILE)
-    with open(price_file) as f:
+def read_price() -> Decimal:
+    with open(PRICE_FILE) as f:
         price = f.readline()
         price = Decimal(re.sub("[^0-9.]", "", price))
         return price
@@ -98,140 +52,263 @@ def read_price():
 
 # note that commas are used as a decimal separator in some languages
 # (e.g. Spain Spanish), so that would need to be handled at some point
-def read_valuation():
-    valuation_file = os.path.join(ABE_ROOT, VALUATION_FILE)
-    with open(valuation_file) as f:
+def read_valuation() -> Decimal:
+    with open(VALUATION_FILE) as f:
         valuation = f.readline()
         valuation = Decimal(re.sub("[^0-9.]", "", valuation))
         return valuation
 
 
-def read_attributions(attributions_filename, validate=True):
-    attributions = {}
-    attributions_file = os.path.join(ABE_ROOT, attributions_filename)
-    with open(attributions_file) as f:
-        for row in csv.reader(f):
-            email, percentage = row
-            attributions[email] = parse_percentage(percentage)
-    if validate:
-        assert _get_attributions_total(attributions) == Decimal("1")
-    return attributions
+def write_valuation(valuation):
+    rounded_valuation = f"{valuation:.2f}"
+    with open(VALUATION_FILE, "w") as f:
+        writer = csv.writer(f)
+        writer.writerow((rounded_valuation,))
+
+
+def write_attributions(attributions):
+    # don't write attributions if they aren't normalized
+    assert_attributions_normalized(attributions)
+    # format for output as percentages
+    attributions = [
+        (email, serialize_proportion(share))
+        for email, share in attributions.items()
+    ]
+    with open(ATTRIBUTIONS_FILE, "w") as f:
+        writer = csv.writer(f)
+        for row in attributions:
+            writer.writerow(row)
 
 
-def get_all_payments():
+def write_debts(new_debts, debt_payments):
     """
-    Reads payment files and returns all existing payment objects.
+    1. Build a hash of all the processed debts, generating an id for each
+       (based on email and payment file).
+    2. read the existing debts file, row by row.
+    3. if the debt in the row is in the "processed" hash, then write the
+       processed version instead of the input version and remove it from the
+       hash, otherwise write the input version.
+    4. write the debts that remain in the processed hash.
     """
-    payments = [
-        read_payment(f, attributable=True)
-        for f in os.listdir(PAYMENTS_DIR)
-        if not os.path.isdir(os.path.join(PAYMENTS_DIR, f))
+    print(new_debts, debt_payments)
+    total_debt_payments = Tally(
+        (dp.debt.key(), dp.amount) for dp in debt_payments
+    )
+    replacement = [
+        (
+            dataclasses.replace(
+                debt,
+                amount_paid=debt.amount_paid + total_debt_payments[debt.key()],
+            )
+            if debt.key() in total_debt_payments
+            else debt
+        )
+        for debt in [*DebtsRepo(), *new_debts]
     ]
-    try:
-        payments += [
-            read_payment(f, attributable=False)
-            for f in os.listdir(NONATTRIBUTABLE_PAYMENTS_DIR)
-            if not os.path.isdir(os.path.join(NONATTRIBUTABLE_PAYMENTS_DIR, f))
-        ]
-    except FileNotFoundError:
-        pass
-    return payments
+    print(total_debt_payments, list(DebtsRepo()), replacement)
 
+    with open(DEBTS_FILE, "w") as f:
+        writer = csv.writer(f)
+        for debt in replacement:
+            writer.writerow(astuple(debt))
 
-def find_unprocessed_payments():
-    """
-    1. Read the transactions file to find out which payments are already
-       recorded as transactions
-    2. Read the payments folder to get all payments, as Payment objects
-    3. Return those which haven't been recorded in a transaction
 
-    Return type: list of Payment objects
+def pay_outstanding_debts(
+    available_amount: Decimal,
+    all_debts: Iterable[Debt],
+    payable_contributors: Set[str],
+) -> List[DebtPayment]:
     """
-    recorded_payments = set()
-    transactions_file = os.path.join(ABE_ROOT, TRANSACTIONS_FILE)
-    with open(transactions_file) as f:
-        for (
-            _email,
-            _amount,
-            payment_file,
-            _commit_hash,
-            _created_at,
-        ) in csv.reader(f):
-            recorded_payments.add(payment_file)
-    all_payments = get_all_payments()
-    print("all payments")
-    print(all_payments)
-    return [p for p in all_payments if p.file not in recorded_payments]
-
-
-def generate_transactions(amount, attributions, payment_file, commit_hash):
+    Given an available amount return debt payments for as many debts as can
+    be covered
     """
-    Generate transactions reflecting the amount owed to each contributor from
-    a fresh payment amount -- one transaction per attributable contributor.
-    """
-    assert amount > 0
-    assert attributions
-    transactions = []
-    for email, share in attributions.items():
-        t = Transaction(email, amount * share, payment_file, commit_hash)
-        transactions.append(t)
-    return transactions
-
-
-def get_existing_itemized_payments():
-    # TODO
-    itemized_payments = []
-    itemized_payments_file = os.path.join(ABE_ROOT, ITEMIZED_PAYMENTS_FILE)
-    try:
-        with open(itemized_payments_file) as f:
-            for (
-                email,
-                fee_amount,
-                project_amount,
-                attributable,
-                payment_file,
-            ) in csv.reader(f):
-                itemized_payment = ItemizedPayment(
-                    email,
-                    fee_amount,
-                    project_amount,
-                    attributable,
-                    payment_file,
-                )
-                itemized_payments.append(itemized_payment)
-    except FileNotFoundError:
-        itemized_payments = []
-    return itemized_payments
-
-
-def total_amount_paid_to_project(for_email, new_itemized_payments):
+    payable_debts = [
+        d
+        for d in all_debts
+        if not d.is_fulfilled() and d.email in payable_contributors
+    ]
+
+    cummulative_debt = [
+        amount
+        for amount in accumulate(
+            (d.amount_remaining() for d in payable_debts), initial=0
+        )
+        if amount <= available_amount
+    ]
+
+    return [
+        DebtPayment(
+            debt=d,
+            amount=min(d.amount_remaining(), available_amount - already_paid),
+        )
+        for d, already_paid in zip(payable_debts, cummulative_debt)
+    ]
+
+
+def create_debts(
+    available_amount: Decimal,
+    distribution: Distribution,
+    payable_contributors: Set[str],
+    payment: Payment,
+):
+    return [
+        Debt(
+            email=email,
+            amount=amount,
+            amount_paid=Decimal(0),
+            payment_file=payment.file,
+        )
+        for email, amount in distribution.distribute(available_amount).items()
+        if email not in payable_contributors
+    ]
+
+
+def distribute_payment(
+    payment: Payment, distribution: Distribution
+) -> Tuple[List[Debt], List[DebtPayment], List[Transaction], List[Advance]]:
     """
-    Calculates the sum of a single user's attributable payments (minus
-    fees paid towards instruments) for determining how much the user
-    has invested in the project so far. Non-attributable payments do
-    not count towards investment.
+    Generate transactions to contributors from a (new) payment.
+
+    We consult the attribution file and determine how much is owed
+    to each contributor based on the current percentages, generating a
+    fresh entry in the transactions file for each contributor.
     """
-    all_itemized_payments = (
-        get_existing_itemized_payments() + new_itemized_payments
+
+    # 1. check payable outstanding debts
+    # 2. pay them off in chronological order (maybe partially)
+    # 3. (if leftover) identify unpayable people in the relevant
+    #    distribution file
+    # 4. record debt for each of them according to their attribution
+
+    unpayable_contributors = set(UnpayableContributorsRepo())
+    payable_contributors = {
+        email
+        for email in distribution
+        if email and email not in unpayable_contributors
+    }
+
+    #
+    # Pay as many outstanding debts as possible
+    #
+
+    debt_payments = pay_outstanding_debts(
+        payment.amount, DebtsRepo(), payable_contributors
     )
-    return sum(
-        p.project_amount
-        for p in all_itemized_payments
-        if p.attributable and p.email == for_email
+
+    # The "available" amount is what is left over after paying off debts
+    available_amount = payment.amount - sum(dp.amount for dp in debt_payments)
+
+    #
+    # Create fresh debts for anyone we can't pay
+    #
+    # TODO: Make it clearer that some people get debts and the others
+    # get N advances (maybe zero)
+
+    fresh_debts = create_debts(
+        available_amount, distribution, payable_contributors, payment
+    )
+
+    #
+    # Draw dawn contributor's existing advances first, before paying them
+    #
+
+    advance_totals = Tally((a.email, a.amount) for a in AdvancesRepo())
+
+    negative_advances = [
+        Advance(
+            email=email,
+            amount=-min(
+                payable_amount, advance_totals[email]
+            ),  # Note the negative sign
+            payment_file=payment.file,
+        )
+        for email, payable_amount in distribution.without(
+            unpayable_contributors
+        )
+        .distribute(available_amount)
+        .items()
+        if advance_totals[email] > ACCOUNTING_ZERO
+    ]
+
+    #
+    # Advance payable contributors any extra money
+    #
+
+    redistribution_pot = Decimal(
+        # amount we will not pay because we created debts instead
+        sum(d.amount for d in fresh_debts)
+        # amount we will not pay because we drew down advances instead
+        # these are negative amounts, hence the abs
+        + sum(abs(a.amount) for a in negative_advances)
     )
 
+    fresh_advances = (
+        [
+            Advance(
+                email=email,
+                amount=amount,
+                payment_file=payment.file,
+            )
+            for email, amount in distribution.without(unpayable_contributors)
+            .distribute(redistribution_pot)
+            .items()
+        ]
+        if redistribution_pot > ACCOUNTING_ZERO
+        else []
+    )
+
+    #
+    # Create equity transactions for the total amounts of outgoing money
+    #
+
+    negative_advance_totals = Tally(
+        (a.email, a.amount) for a in negative_advances
+    )
+    fresh_advance_totals = Tally((a.email, a.amount) for a in fresh_advances)
+    debt_payments_totals = Tally(
+        (dp.debt.email, dp.amount) for dp in debt_payments
+    )
+
+    transactions = [
+        Transaction(
+            email=email,
+            payment_file=payment.file,
+            amount=(
+                # what you would normally get
+                equity
+                # minus amount drawn from your advances
+                - abs(negative_advance_totals[email])
+                # plus new advances from the pot
+                + fresh_advance_totals[email]
+                # plus any payments for old debts
+                + debt_payments_totals[email]
+            ),
+        )
+        for email, equity in distribution.distribute(available_amount).items()
+        if email in payable_contributors
+    ]
+
+    processed_debts = fresh_debts
+    advances = negative_advances + fresh_advances
+
+    return processed_debts, debt_payments, transactions, advances
+
 
 def calculate_incoming_investment(payment, price, new_itemized_payments):
     """
     If the payment brings the aggregate amount paid by the payee
     above the price, then that excess is treated as investment.
     """
-    total_payments = total_amount_paid_to_project(
-        payment.email, new_itemized_payments
+    total_attributable_payments = sum(
+        p.project_amount
+        for p in [*ItemizedPaymentsRepo(), *new_itemized_payments]
+        if p.attributable and p.email == payment.email
+    )
+
+    incoming_investment = min(
+        total_attributable_payments - price, payment.amount
     )
-    previous_total = total_payments - payment.amount  # fees already deducted
-    # how much of the incoming amount goes towards investment?
-    incoming_investment = total_payments - max(price, previous_total)
+
     return max(0, incoming_investment)
 
 
@@ -249,24 +326,11 @@ def calculate_incoming_attribution(
         return None
 
 
-def _get_attributions_total(attributions):
-    return sum(attributions.values())
-
-
-def get_rounding_difference(attributions):
-    """
-    Get the difference of the total of the attributions from 1, which is
-    expected to occur due to finite precision. If the difference exceeds the
-    expected error tolerance, an error is signaled.
+def dilute_attributions(incoming_attribution, attributions):
     """
-    total = _get_attributions_total(attributions)
-    difference = total - Decimal("1")
-    assert abs(difference) <= ROUNDING_TOLERANCE
-    return difference
-
+    Incorporate a fresh attributive share by diluting existing attributions,
+    and correcting any rounding error that may arise from this.
 
-def renormalize(attributions, incoming_attribution):
-    """
     The incoming attribution is determined as a proportion of the total
     posterior valuation.  As the existing attributions total to 1 and don't
     account for it, they must be proportionately scaled so that their new total
@@ -278,106 +342,16 @@ def renormalize(attributions, incoming_attribution):
     for email in attributions:
         # renormalize to reflect dilution
         attributions[email] *= target_proportion
+
     # add incoming share to existing investor or record new investor
     existing_attribution = attributions.get(incoming_attribution.email, None)
     attributions[incoming_attribution.email] = (
         existing_attribution if existing_attribution else 0
     ) + incoming_attribution.share
 
-
-def correct_rounding_error(attributions, incoming_attribution):
-    """Due to finite precision, the Decimal module will round up or down
-    on the last decimal place. This could result in the aggregate value not
-    quite totaling to 1. This corrects that total by either adding or
-    subtracting the difference from the incoming attribution (by convention).
-    """
-    difference = get_rounding_difference(attributions)
-    attributions[incoming_attribution.email] -= difference
-
-
-def write_attributions(attributions):
-    # don't write attributions if they aren't normalized
-    assert _get_attributions_total(attributions) == Decimal("1")
-    # format for output as percentages
-    attributions = [
-        (email, serialize_proportion(share))
-        for email, share in attributions.items()
-    ]
-    attributions_file = os.path.join(ABE_ROOT, ATTRIBUTIONS_FILE)
-    with open(attributions_file, 'w') as f:
-        writer = csv.writer(f)
-        for row in attributions:
-            writer.writerow(row)
-
-
-def write_append_transactions(transactions):
-    transactions_file = os.path.join(ABE_ROOT, TRANSACTIONS_FILE)
-    with open(transactions_file, 'a') as f:
-        writer = csv.writer(f)
-        for row in transactions:
-            writer.writerow(astuple(row))
-
-
-def write_append_itemized_payments(itemized_payments):
-    itemized_payments_file = os.path.join(ABE_ROOT, ITEMIZED_PAYMENTS_FILE)
-    with open(itemized_payments_file, 'a') as f:
-        writer = csv.writer(f)
-        for row in itemized_payments:
-            writer.writerow(astuple(row))
-
-
-def write_valuation(valuation):
-    rounded_valuation = f"{valuation:.2f}"
-    valuation_file = os.path.join(ABE_ROOT, VALUATION_FILE)
-    with open(valuation_file, 'w') as f:
-        writer = csv.writer(f)
-        writer.writerow((rounded_valuation,))
-
-
-def dilute_attributions(incoming_attribution, attributions):
-    """
-    Incorporate a fresh attributive share by diluting existing attributions,
-    and correcting any rounding error that may arise from this.
-    """
-    renormalize(attributions, incoming_attribution)
     correct_rounding_error(attributions, incoming_attribution)
 
 
-def inflate_valuation(valuation, amount):
-    """
-    Determine the posterior valuation as the fresh investment amount
-    added to the prior valuation.
-    """
-    return valuation + amount
-
-
-def get_git_revision_short_hash() -> str:
-    """From https://stackoverflow.com/a/21901260"""
-    return (
-        subprocess.check_output(['git', 'rev-parse', '--short', 'HEAD'])
-        .decode('ascii')
-        .strip()
-    )
-
-
-def distribute_payment(payment, attributions):
-    """
-    Generate transactions to contributors from a (new) payment.
-
-    We consult the attribution file and determine how much is owed
-    to each contributor based on the current percentages, generating a
-    fresh entry in the transactions file for each contributor.
-    """
-    commit_hash = get_git_revision_short_hash()
-
-    # figure out how much each person in the attributions file is owed from
-    # this payment, generating a transaction for each stakeholder.
-    transactions = generate_transactions(
-        payment.amount, attributions, payment.file, commit_hash
-    )
-    return transactions
-
-
 def handle_investment(
     payment, new_itemized_payments, attributions, price, prior_valuation
 ):
@@ -392,36 +366,16 @@ def handle_investment(
         payment, price, new_itemized_payments
     )
     # inflate valuation by the amount of the fresh investment
-    posterior_valuation = inflate_valuation(
-        prior_valuation, incoming_investment
-    )
+    posterior_valuation = prior_valuation + incoming_investment
+
     incoming_attribution = calculate_incoming_attribution(
         payment.email, incoming_investment, posterior_valuation
     )
-    if incoming_attribution:
+    if incoming_attribution and incoming_attribution.share > ACCOUNTING_ZERO:
         dilute_attributions(incoming_attribution, attributions)
     return posterior_valuation
 
 
-def _get_unprocessed_payments():
-    try:
-        unprocessed_payments = find_unprocessed_payments()
-    except FileNotFoundError:
-        unprocessed_payments = []
-    print(unprocessed_payments)
-    return unprocessed_payments
-
-
-def _create_itemized_payment(payment, fee_amount):
-    return ItemizedPayment(
-        payment.email,
-        fee_amount,
-        payment.amount,
-        payment.attributable,
-        payment.file,
-    )
-
-
 def process_payments(instruments, attributions):
     """
     Process new payments by paying out instruments and then, from the amount
@@ -431,29 +385,67 @@ def process_payments(instruments, attributions):
     """
     price = read_price()
     valuation = read_valuation()
+    new_debts = []
+    new_debt_payments = []
+    new_advances = []
     new_transactions = []
     new_itemized_payments = []
-    unprocessed_payments = _get_unprocessed_payments()
+
+    processed_payment_files = {t.payment_file for t in TransactionsRepo()}
+    unprocessed_payments = [
+        p for p in AllPaymentsRepo() if p.file not in processed_payment_files
+    ]
+
     for payment in unprocessed_payments:
         # first, process instruments (i.e. pay fees)
-        transactions = distribute_payment(payment, instruments)
+        debts, debt_payments, transactions, advances = distribute_payment(
+            payment,
+            Distribution(
+                # The missing percentage in the instruments file
+                # should not be distributed to anyone (shareholder: None)
+                # TODO: Move to process_payments_and_record_updates
+                {**instruments, None: Decimal(1) - sum(instruments.values())}
+            ),
+        )
         new_transactions += transactions
-        amount_paid_out = sum(t.amount for t in transactions)
+        new_debts += debts
+        new_debt_payments += debt_payments
+        new_advances += advances
+        fees_paid_out = sum(t.amount for t in transactions)
         # deduct the amount paid out to instruments before
         # processing it for attributions
-        payment.amount -= amount_paid_out
+        payment.amount -= fees_paid_out
         new_itemized_payments.append(
-            _create_itemized_payment(payment, amount_paid_out)
+            ItemizedPayment(
+                payment.email,
+                fees_paid_out,
+                payment.amount,  # already has fees deducted
+                payment.attributable,
+                payment.file,
+            )
         )
         # next, process attributions - using the amount owed to the project
         # (which is the amount leftover after paying instruments/fees)
         if payment.amount > ACCOUNTING_ZERO:
-            new_transactions += distribute_payment(payment, attributions)
+            debts, debt_payments, transactions, advances = distribute_payment(
+                payment, Distribution(attributions)
+            )
+            new_transactions += transactions
+            new_debts += debts
+            new_debt_payments += debt_payments
+            new_advances += advances
         if payment.attributable:
             valuation = handle_investment(
                 payment, new_itemized_payments, attributions, price, valuation
             )
-    return new_transactions, valuation, new_itemized_payments
+    return (
+        new_debts,
+        new_debt_payments,
+        new_transactions,
+        valuation,
+        new_itemized_payments,
+        new_advances,
+    )
 
 
 def process_payments_and_record_updates():
@@ -462,22 +454,29 @@ def process_payments_and_record_updates():
     and attributions files. Record updated transactions, valuation, and
     renormalized attributions only after all payments have been processed.
     """
-    instruments = read_attributions(INSTRUMENTS_FILE, validate=False)
-    attributions = read_attributions(ATTRIBUTIONS_FILE)
+    instruments = {a.email: a.decimal_share for a in InstrumentsRepo()}
+    attributions = {a.email: a.decimal_share for a in AttributionsRepo()}
+
+    assert_attributions_normalized(attributions)
 
     (
+        new_debts,
+        debt_payments,
         transactions,
         posterior_valuation,
         new_itemized_payments,
+        advances,
     ) = process_payments(instruments, attributions)
 
     # we only write the changes to disk at the end
     # so that if any errors are encountered, no
     # changes are made.
-    write_append_transactions(transactions)
+    write_debts(new_debts, debt_payments)
+    TransactionsRepo().extend(transactions)
     write_attributions(attributions)
     write_valuation(posterior_valuation)
-    write_append_itemized_payments(new_itemized_payments)
+    ItemizedPaymentsRepo().extend(new_itemized_payments)
+    AdvancesRepo().extend(advances)
 
 
 def main():
diff --git a/oldabe/money_out.py b/oldabe/money_out.py
index 0109395..ad7e9a5 100755
--- a/oldabe/money_out.py
+++ b/oldabe/money_out.py
@@ -1,48 +1,10 @@
 #!/usr/bin/env python
 
-from .models import Transaction, Debt
-from datetime import datetime
-import csv
-import os
-import re
 from collections import defaultdict
 from decimal import Decimal, getcontext
 
-ABE_ROOT = 'abe'
-PAYOUTS_DIR = os.path.join(ABE_ROOT, 'payouts')
-TRANSACTIONS_FILE = os.path.join(ABE_ROOT, 'transactions.txt')
-DEBTS_FILE = os.path.join(ABE_ROOT, 'debts.txt')
-
-
-def read_transaction_amounts():
-    balances = defaultdict(int)
-    with open(TRANSACTIONS_FILE) as f:
-        for row in csv.reader(f):
-            t = Transaction(*row)
-            t.amount = Decimal(t.amount)
-            t.created_at = datetime.fromisoformat(t.created_at)
-            balances[t.email] += t.amount
-    return balances
-
-
-def read_payout(payout_file):
-    with open(os.path.join(PAYOUTS_DIR, payout_file)) as f:
-        for row in csv.reader(f):
-            name, _email, amount, _date = row
-            amount = Decimal(re.sub("[^0-9.]", "", amount))
-            return name, amount
-
-
-def read_payout_amounts():
-    balances = defaultdict(int)
-    try:
-        payout_files = os.listdir(PAYOUTS_DIR)
-    except FileNotFoundError:
-        payout_files = []
-    for payout_file in payout_files:
-        name, amount = read_payout(payout_file)
-        balances[name] += amount
-    return balances
+from .repos import AdvancesRepo, DebtsRepo, PayoutsRepo, TransactionsRepo
+from .tally import Tally
 
 
 def compute_balances(owed: dict, paid: dict):
@@ -51,6 +13,7 @@ def compute_balances(owed: dict, paid: dict):
     """
     balances = defaultdict(int)
     for email in owed.keys():
+        # TODO: We are not testing alreayd paid
         balance = owed[email] - paid[email]
         if balance > Decimal("0"):
             balances[email] = balance
@@ -62,7 +25,8 @@ def prepare_balances_message(balances: dict):
         return "There are no outstanding (payable) balances."
     balances_table = ""
     for name, balance in balances.items():
-        balances_table += f"{name} | {balance:.2f}\n"
+        if balance > 0:
+            balances_table += f"{name} | {balance:.2f}\n"
     message = f"""
     The current outstanding (payable) balances are:
 
@@ -75,18 +39,6 @@ def prepare_balances_message(balances: dict):
     return "\r\n".join(line.strip() for line in message.split('\n')).strip()
 
 
-def read_outstanding_debt_amounts():
-    outstanding_debts = defaultdict(int)
-    with open(DEBTS_FILE) as f:
-        for row in csv.reader(f):
-            d = Debt(*row)
-            d.amount = Decimal(d.amount)
-            d.amount_paid = Decimal(d.amount_paid)
-            outstanding_amount = d.amount - d.amount_paid
-            outstanding_debts[d.email] += outstanding_amount
-    return outstanding_debts
-
-
 def prepare_debts_message(outstanding_debts: dict):
     if not outstanding_debts:
         return "There are no outstanding (unpayable) debts."
@@ -105,31 +57,69 @@ def prepare_debts_message(outstanding_debts: dict):
     return "\r\n".join(line.strip() for line in message.split('\n')).strip()
 
 
-def combined_message(balances_message, debts_message):
+def prepare_advances_message(advances: dict):
+    """A temporary message reporting aggregate advances, for testing
+    purposes."""
+    if not advances:
+        return "There are no advances."
+    advances_table = ""
+    for name, advance in advances.items():
+        advances_table += f"{name} | {advance:.2f}\n"
+    message = f"""
+    The current advances are:
+
+    | Name | Advance |
+    | ---- | ------- |
+    {advances_table}
+
+    **Total** = {sum(advances.values()):.2f}
+    """
+    return "\r\n".join(line.strip() for line in message.split('\n')).strip()
+
+
+def combined_message(balances_message, debts_message, advances_message):
     message = f"""
     {balances_message}
 
     ----------------------------
 
     {debts_message}
+
+    ----------------------------
+
+    {advances_message}
     """
     return "\r\n".join(line.strip() for line in message.split('\n')).strip()
 
 
+def compile_outstanding_balances():
+    """Read all accounting records and determine the total outstanding
+    balances, debts, and advances for each contributor.
+    """
+    # owed = read_owed_amounts()
+    owed = Tally((t.email, t.amount) for t in TransactionsRepo())
+    paid = Tally((p.email, p.amount) for p in PayoutsRepo())
+    balances = owed - paid
+    balances_message = prepare_balances_message(balances)
+
+    outstanding_debts = Tally(
+        (d.email, d.amount_remaining()) for d in DebtsRepo()
+    )
+    debts_message = prepare_debts_message(outstanding_debts)
+
+    advances = Tally((a.email, a.amount) for a in AdvancesRepo())
+    advances_message = prepare_advances_message(advances)
+
+    return combined_message(balances_message, debts_message, advances_message)
+
+
 def main():
     # set decimal precision at 10 to ensure
     # that it is the same everywhere
     # and large enough to represent a sufficiently
     # large number of contributors
     getcontext().prec = 10
-
-    owed = read_transaction_amounts()
-    paid = read_payout_amounts()
-    balances = compute_balances(owed, paid)
-    balances_message = prepare_balances_message(balances)
-    outstanding_debts = read_outstanding_debt_amounts()
-    debts_message = prepare_debts_message(outstanding_debts)
-    print(combined_message(balances_message, debts_message))
+    print(compile_outstanding_balances())
 
 
 if __name__ == "__main__":
diff --git a/oldabe/parsing.py b/oldabe/parsing.py
new file mode 100644
index 0000000..4f3e6ae
--- /dev/null
+++ b/oldabe/parsing.py
@@ -0,0 +1,52 @@
+from decimal import Decimal
+import re
+
+
+def parse_percentage(value):
+    """
+    Translates values expressed in percentage format (75.234) into
+    their decimal equivalents (0.75234). This effectively divides
+    the value by 100 without losing precision.
+    """
+    value = re.sub("[^0-9.]", "", value)
+    value = "00" + value
+    if "." not in value:
+        value = value + ".0"
+    value = re.sub(
+        r"(?P
\d{2})\.(?P\d+)", r".\g
\g", value
+    )
+    value = Decimal(value)
+    return value
+
+
+def serialize_proportion(value):
+    """
+    Translates values expressed in decimal format (0.75234) into
+    their percentage equivalents (75.234). This effectively multiplies
+    the value by 100 without losing precision.
+    """
+    # otherwise, decimal gets translated '2E-7.0'
+    value = format(value, "f")
+    if "." in value:
+        value = value + "00"
+    else:
+        value = value + ".00"
+    value = re.sub(
+        r"(?P
\d)\.(?P\d{2})(?P\d*)",
+        # match a number followed by a decimal point
+        # followed by at least two digits
+        r"\g
\g.\g",
+        # move the decimal point two places to the right
+        value,
+    )
+    # strip leading insignificant zeroes
+    value = value.lstrip("0")
+    # ensure there's a single leading zero if it is
+    # a decimal value less than 1
+    value = re.sub(r"^\.", r"0.", value)
+    if "." in value:
+        # strip trailing insignificant zeroes
+        value = value.rstrip("0")
+        # remove decimal point if whole number
+        value = re.sub(r"\.$", r"", value)
+    return value
diff --git a/oldabe/repos.py b/oldabe/repos.py
new file mode 100644
index 0000000..1ab97e4
--- /dev/null
+++ b/oldabe/repos.py
@@ -0,0 +1,179 @@
+import csv
+import dataclasses
+import os
+import re
+from datetime import datetime
+from decimal import Decimal
+from typing import Any, Generic, Iterable, Iterator, List, Type, TypeVar
+
+from oldabe.constants import (
+    ADVANCES_FILE,
+    ATTRIBUTIONS_FILE,
+    DEBTS_FILE,
+    INSTRUMENTS_FILE,
+    ITEMIZED_PAYMENTS_FILE,
+    NONATTRIBUTABLE_PAYMENTS_DIR,
+    PAYMENTS_DIR,
+    PAYOUTS_DIR,
+    TRANSACTIONS_FILE,
+    UNPAYABLE_CONTRIBUTORS_FILE,
+)
+from oldabe.models import (
+    Advance,
+    Attribution,
+    Debt,
+    ItemizedPayment,
+    Payment,
+    Payout,
+    Transaction,
+)
+
+
+def fix_types(row: List[str], Model: type) -> List[Any]:
+    """
+    Cast string field values from the CSV into the proper types
+    """
+
+    def _cast(field, value):
+        if field.type is Decimal:
+            return Decimal(re.sub("[^0-9.]", "", value))
+        elif field.type is datetime:
+            return datetime.fromisoformat(value)
+        else:
+            return value
+
+    return [
+        _cast(field, value)
+        for field, value in zip(dataclasses.fields(Model), row)
+    ]
+
+
+T = TypeVar('T')
+
+
+class FileRepo(Generic[T]):
+    """
+    A sequence of dataclass instances stored as rows in a CSV
+    """
+
+    filename: str
+    Model: Type[T]
+
+    def __iter__(self) -> Iterator[T]:
+        objs = []
+        try:
+            with open(self.filename) as f:
+                for row in csv.reader(f, skipinitialspace=True):
+                    if dataclasses.is_dataclass(self.Model):
+                        row = fix_types(row, self.Model)
+                    obj = self.Model(*row)
+                    objs.append(obj)
+        except FileNotFoundError:
+            pass
+
+        yield from objs
+
+    def extend(self, objs: Iterable[T]):
+        with open(self.filename, "a") as f:
+            writer = csv.writer(f)
+            for obj in objs:
+                writer.writerow(dataclasses.astuple(obj))
+
+
+class DirRepo(Generic[T]):
+    """
+    A sequence of dataclass instances stored as single row CSV files in a dir
+    """
+
+    dirname: str
+    Model: Type[T]
+
+    def __iter__(self) -> Iterator[T]:
+        objs = []
+        try:
+            filenames = [
+                f
+                for f in os.listdir(self.dirname)
+                if not os.path.isdir(os.path.join(self.dirname, f))
+            ]
+        except FileNotFoundError:
+            filenames = []
+
+        for filename in filenames:
+            with open(os.path.join(self.dirname, filename)) as f:
+                row = next(csv.reader(f, skipinitialspace=True))
+                if dataclasses.is_dataclass(self.Model):
+                    row = fix_types(row, self.Model)
+                obj = self.Model(*row)
+                setattr(obj, "file", filename)
+                objs.append(obj)
+
+        yield from objs
+
+
+class TransactionsRepo(FileRepo[Transaction]):
+    filename = TRANSACTIONS_FILE
+    Model = Transaction
+
+
+class PayoutsRepo(DirRepo):
+    dirname = PAYOUTS_DIR
+    Model = Payout
+
+
+class DebtsRepo(FileRepo[Debt]):
+    filename = DEBTS_FILE
+    Model = Debt
+
+
+class AdvancesRepo(FileRepo[Advance]):
+    filename = ADVANCES_FILE
+    Model = Advance
+
+
+class AttributionsRepo(FileRepo[Attribution]):
+    filename = ATTRIBUTIONS_FILE
+    Model = Attribution
+
+
+class InstrumentsRepo(FileRepo[Attribution]):
+    filename = INSTRUMENTS_FILE
+    Model = Attribution
+
+
+class AttributablePaymentsRepo(DirRepo[Payment]):
+    dirname = PAYMENTS_DIR
+    Model = Payment
+
+    def __iter__(self):
+        yield from (
+            dataclasses.replace(obj, attributable=True)
+            for obj in super().__iter__()
+        )
+
+
+class NonAttributablePaymentsRepo(DirRepo[Payment]):
+    dirname = NONATTRIBUTABLE_PAYMENTS_DIR
+    Model = Payment
+
+    def __iter__(self):
+        yield from (
+            dataclasses.replace(obj, attributable=False)
+            for obj in super().__iter__()
+        )
+
+
+class AllPaymentsRepo:
+    def __iter__(self):
+        yield from AttributablePaymentsRepo()
+        yield from NonAttributablePaymentsRepo()
+
+
+class ItemizedPaymentsRepo(FileRepo[ItemizedPayment]):
+    filename = ITEMIZED_PAYMENTS_FILE
+    Model = ItemizedPayment
+
+
+class UnpayableContributorsRepo(FileRepo[str]):
+    filename = UNPAYABLE_CONTRIBUTORS_FILE
+    Model = str
diff --git a/oldabe/tally.py b/oldabe/tally.py
new file mode 100644
index 0000000..41a49c9
--- /dev/null
+++ b/oldabe/tally.py
@@ -0,0 +1,30 @@
+from collections import defaultdict
+from decimal import Decimal
+from operator import sub
+
+
+class Tally(defaultdict[str, Decimal]):
+    """
+    A dictionary for keeping the tally of an amount
+
+    Inspired by collections.Counter, but instead of a count of ocurrences it
+    keeps the sum of an amount.
+    """
+
+    def __init__(self, source=[]):
+        if type(source) is dict:
+            super().__init__(Decimal, source)
+            return
+
+        super().__init__(Decimal)
+        for key, amount in source:
+            self[key] += amount
+
+    def combine(self, combinator, other):
+        result = Tally()
+        for key in dict(**self, **other).keys():
+            result[key] = combinator(self[key], other[key])
+        return result
+
+    def __sub__(self, other):
+        return self.combine(sub, other)
diff --git a/setup.py b/setup.py
index 9eb4951..93f81cd 100644
--- a/setup.py
+++ b/setup.py
@@ -1,18 +1,20 @@
 from setuptools import setup
 
-requirements = ['click']
+requirements = []
 
 test_requirements = [
     'pytest',
     'pytest-pudb',
     'pytest-sugar',
     'pytest-tldr',
+    'pyfakefs',
+    'time_machine',
     'tox',
     'tox-gh-actions',
     'coveralls',
 ]
 
-dev_requirements = ['flake8', 'bump2version', 'sphinx', 'pre-commit', 'black']
+dev_requirements = ['flake8', 'black']
 
 setup_requirements = ['pytest-runner']
 
@@ -28,6 +30,5 @@
     test_suite='tests',
     install_requires=requirements,
     setup_requires=setup_requirements,
-    tests_require=test_requirements,
     extras_require={'dev': dev_requirements, 'test': test_requirements},
 )
diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/integration/fixtures.py b/tests/integration/fixtures.py
new file mode 100644
index 0000000..549ea7b
--- /dev/null
+++ b/tests/integration/fixtures.py
@@ -0,0 +1,13 @@
+import pytest
+
+
+@pytest.fixture
+def abe_fs(fs):
+    fs.create_file("./abe/price.txt", contents="10")
+    fs.create_file("./abe/valuation.txt", contents="100000")
+    fs.create_file("./abe/instruments.txt", contents=("old abe,1\n" "DIA,5\n"))
+    fs.create_file(
+        "./abe/attributions.txt",
+        contents=("sid,50\n" "jair,30\n" "ariana,20\n"),
+    )
+    return fs
diff --git a/tests/integration/old_abe_test.py b/tests/integration/old_abe_test.py
new file mode 100644
index 0000000..8626ec0
--- /dev/null
+++ b/tests/integration/old_abe_test.py
@@ -0,0 +1,342 @@
+import os
+from datetime import datetime
+from decimal import localcontext
+from unittest.mock import patch
+
+import pytest
+import time_machine
+
+from oldabe.money_in import process_payments_and_record_updates
+from oldabe.money_out import compile_outstanding_balances
+
+from .fixtures import abe_fs
+
+
+class TestNoPayments:
+
+    @patch('oldabe.models.default_commit_hash', return_value='abcd123')
+    def test_no_transactions_generated(self, mock_git_rev, abe_fs):
+        process_payments_and_record_updates()
+        with open('./abe/transactions.txt') as f:
+            assert f.read() == ""
+
+    @patch('oldabe.models.default_commit_hash', return_value='abcd123')
+    def test_compiled_outstanding_balances(self, mock_git_rev, abe_fs):
+        process_payments_and_record_updates()
+        message = compile_outstanding_balances()
+        assert "There are no outstanding (payable) balances." in message
+        assert "There are no outstanding (unpayable) debts." in message
+        assert "There are no advances." in message
+
+
+# TODO: in some cases even though the value is e.g. 1,
+# it's writing out 3 decimal places, like 1.000. We should
+# figure out why this is happening (and whether it's OK)
+# and decide on appropriate handling
+
+
+class TestPaymentAbovePrice:
+
+    @time_machine.travel(datetime(1985, 10, 26, 1, 24), tick=False)
+    @patch('oldabe.models.default_commit_hash', return_value='abcd123')
+    def test_generates_transactions(self, mock_git_rev, abe_fs):
+        with localcontext() as context:
+            context.prec = 2
+            amount = 100
+            abe_fs.create_file(
+                "./abe/payments/1.txt",
+                contents=f"sam,036eaf6,{amount},1987-06-30 06:25:00",
+            )
+            process_payments_and_record_updates()
+            with open('./abe/transactions.txt') as f:
+                assert f.read() == (
+                    "old abe,1.0,1.txt,abcd123,1985-10-26 01:24:00\n"
+                    "DIA,5.0,1.txt,abcd123,1985-10-26 01:24:00\n"
+                    "sid,47,1.txt,abcd123,1985-10-26 01:24:00\n"
+                    "jair,28,1.txt,abcd123,1985-10-26 01:24:00\n"
+                    "ariana,19,1.txt,abcd123,1985-10-26 01:24:00\n"
+                )
+
+    @time_machine.travel(datetime(1985, 10, 26, 1, 24), tick=False)
+    @patch('oldabe.models.default_commit_hash', return_value='abcd123')
+    def test_dilutes_attributions(self, mock_git_rev, abe_fs):
+        with localcontext() as context:
+            context.prec = 2
+            amount = 10000
+            abe_fs.create_file(
+                "./abe/payments/1.txt",
+                contents=f"sam,036eaf6,{amount},1987-06-30 06:25:00",
+            )
+            process_payments_and_record_updates()
+            with open('./abe/attributions.txt') as f:
+                assert f.read() == (
+                    "sid,46\n" "jair,28\n" "ariana,18\n" "sam,8.5\n"
+                )
+
+    @time_machine.travel(datetime(1985, 10, 26, 1, 24), tick=False)
+    @patch('oldabe.models.default_commit_hash', return_value='abcd123')
+    def test_compiled_outstanding_balances(self, mock_git_rev, abe_fs):
+        with localcontext() as context:
+            context.prec = 2
+            amount = 100
+            abe_fs.create_file(
+                "./abe/payments/1.txt",
+                contents=f"sam,036eaf6,{amount},1987-06-30 06:25:00",
+            )
+            process_payments_and_record_updates()
+
+            message = compile_outstanding_balances()
+
+            assert (
+                "| Name | Balance |\r\n"
+                "| ---- | --- |\r\n"
+                "old abe | 1.00\r\n"
+            ) in message
+
+
+class TestPaymentBelowPrice:
+
+    @time_machine.travel(datetime(1985, 10, 26, 1, 24), tick=False)
+    @patch('oldabe.models.default_commit_hash', return_value='abcd123')
+    def test_generates_transactions(self, mock_git_rev, abe_fs):
+        with localcontext() as context:
+            context.prec = 2
+            amount = 1
+            abe_fs.create_file(
+                "./abe/payments/1.txt",
+                contents=f"sam,036eaf6,{amount},1987-06-30 06:25:00",
+            )
+            process_payments_and_record_updates()
+            with open('./abe/transactions.txt') as f:
+                assert f.read() == (
+                    "old abe,0.010,1.txt,abcd123,1985-10-26 01:24:00\n"
+                    "DIA,0.050,1.txt,abcd123,1985-10-26 01:24:00\n"
+                    "sid,0.47,1.txt,abcd123,1985-10-26 01:24:00\n"
+                    "jair,0.28,1.txt,abcd123,1985-10-26 01:24:00\n"
+                    "ariana,0.19,1.txt,abcd123,1985-10-26 01:24:00\n"
+                )
+
+    @time_machine.travel(datetime(1985, 10, 26, 1, 24), tick=False)
+    @patch('oldabe.models.default_commit_hash', return_value='abcd123')
+    def test_compiled_outstanding_balances(self, mock_git_rev, abe_fs):
+        with localcontext() as context:
+            context.prec = 2
+            amount = 1
+            abe_fs.create_file(
+                "./abe/payments/1.txt",
+                contents=f"sam,036eaf6,{amount},1987-06-30 06:25:00",
+            )
+            process_payments_and_record_updates()
+            message = compile_outstanding_balances()
+
+            assert (
+                "| Name | Balance |\r\n"
+                "| ---- | --- |\r\n"
+                "old abe | 0.01\r\n"
+            ) in message
+
+
+class TestNonAttributablePayment:
+
+    @time_machine.travel(datetime(1985, 10, 26, 1, 24), tick=False)
+    @patch('oldabe.models.default_commit_hash', return_value='abcd123')
+    def test_does_not_dilute_attributions(self, mock_git_rev, abe_fs):
+        with localcontext() as context:
+            context.prec = 2
+            amount = 100
+            abe_fs.create_file(
+                "./abe/payments/nonattributable/1.txt",
+                contents=f"sam,036eaf6,{amount},1987-06-30 06:25:00",
+            )
+            process_payments_and_record_updates()
+            with open('./abe/attributions.txt') as f:
+                assert f.read() == ("sid,50\n" "jair,30\n" "ariana,20\n")
+
+    @time_machine.travel(datetime(1985, 10, 26, 1, 24), tick=False)
+    @patch('oldabe.models.default_commit_hash', return_value='abcd123')
+    def test_generates_transactions(self, mock_git_rev, abe_fs):
+        with localcontext() as context:
+            context.prec = 2
+            amount = 100
+            abe_fs.create_file(
+                "./abe/payments/1.txt",
+                contents=f"sam,036eaf6,{amount},1987-06-30 06:25:00",
+            )
+            process_payments_and_record_updates()
+            with open('./abe/transactions.txt') as f:
+                assert f.read() == (
+                    "old abe,1.0,1.txt,abcd123,1985-10-26 01:24:00\n"
+                    "DIA,5.0,1.txt,abcd123,1985-10-26 01:24:00\n"
+                    "sid,47,1.txt,abcd123,1985-10-26 01:24:00\n"
+                    "jair,28,1.txt,abcd123,1985-10-26 01:24:00\n"
+                    "ariana,19,1.txt,abcd123,1985-10-26 01:24:00\n"
+                )
+
+    @time_machine.travel(datetime(1985, 10, 26, 1, 24), tick=False)
+    @patch('oldabe.models.default_commit_hash', return_value='abcd123')
+    def test_compiled_outstanding_balances(self, mock_git_rev, abe_fs):
+        with localcontext() as context:
+            context.prec = 2
+            amount = 100
+            abe_fs.create_file(
+                "./abe/payments/nonattributable/1.txt",
+                contents=f"sam,036eaf6,{amount},1987-06-30 06:25:00",
+            )
+            process_payments_and_record_updates()
+            message = compile_outstanding_balances()
+
+            assert (
+                "| Name | Balance |\r\n"
+                "| ---- | --- |\r\n"
+                "old abe | 1.00\r\n"
+            ) in message
+
+
+class TestUnpayableContributor:
+
+    def _call(self, abe_fs):
+        with localcontext() as context:
+            context.prec = 2
+            amount = 100
+            abe_fs.create_file(
+                "./abe/payments/1.txt",
+                contents=f"sam,036eaf6,{amount},1987-06-30 06:25:00",
+            )
+            abe_fs.create_file(
+                "./abe/unpayable_contributors.txt", contents=f"ariana"
+            )
+            process_payments_and_record_updates()
+
+    @time_machine.travel(datetime(1985, 10, 26, 1, 24), tick=False)
+    @patch('oldabe.models.default_commit_hash', return_value='abcd123')
+    def test_generates_transactions(self, mock_git_rev, abe_fs):
+        self._call(abe_fs)
+        with open('./abe/transactions.txt') as f:
+            assert f.read() == (
+                "old abe,1.0,1.txt,abcd123,1985-10-26 01:24:00\n"
+                "DIA,5.0,1.txt,abcd123,1985-10-26 01:24:00\n"
+                "sid,58,1.txt,abcd123,1985-10-26 01:24:00\n"
+                "jair,35,1.txt,abcd123,1985-10-26 01:24:00\n"
+            )
+
+    @time_machine.travel(datetime(1985, 10, 26, 1, 24), tick=False)
+    @patch('oldabe.models.default_commit_hash', return_value='abcd123')
+    def test_records_debt(self, mock_git_rev, abe_fs):
+        self._call(abe_fs)
+        with open('./abe/debts.txt') as f:
+            assert f.read() == (
+                "ariana,19,0,1.txt,abcd123,1985-10-26 01:24:00\n"
+            )
+
+    @time_machine.travel(datetime(1985, 10, 26, 1, 24), tick=False)
+    @patch('oldabe.models.default_commit_hash', return_value='abcd123')
+    def test_records_advances(self, mock_git_rev, abe_fs):
+        # advances for payable people
+        # and none for unpayable
+        self._call(abe_fs)
+        with open('./abe/advances.txt') as f:
+            assert f.read() == (
+                "sid,11,1.txt,abcd123,1985-10-26 01:24:00\n"
+                "jair,6.8,1.txt,abcd123,1985-10-26 01:24:00\n"
+            )
+
+    @time_machine.travel(datetime(1985, 10, 26, 1, 24), tick=False)
+    @patch('oldabe.models.default_commit_hash', return_value='abcd123')
+    def test_compiled_outstanding_balances(self, mock_git_rev, abe_fs):
+        self._call(abe_fs)
+        message = compile_outstanding_balances()
+        assert (
+            "| Name | Debt |\r\n" "| ---- | --- |\r\n" "ariana | 19.00\r\n"
+        ) in message
+
+
+class TestUnpayableContributorBecomesPayable:
+
+    def _call(self, abe_fs):
+        with localcontext() as context:
+            context.prec = 2
+            amount = 100
+            abe_fs.create_file(
+                './abe/transactions.txt',
+                contents=(
+                    "old abe,1.0,1.txt,abcd123,1985-10-26 01:24:00\n"
+                    "DIA,5.0,1.txt,abcd123,1985-10-26 01:24:00\n"
+                    "sid,58,1.txt,abcd123,1985-10-26 01:24:00\n"
+                    "jair,35,1.txt,abcd123,1985-10-26 01:24:00\n"
+                ),
+            )
+
+            abe_fs.create_file(
+                "./abe/debts.txt",
+                contents="ariana,19,0,1.txt,abcd123,1985-10-26 01:24:00\n",
+            )
+            abe_fs.create_file(
+                "./abe/advances.txt",
+                contents=(
+                    "sid,11,1.txt,abcd123,1985-10-26 01:24:00\n"
+                    "jair,6.8,1.txt,abcd123,1985-10-26 01:24:00\n"
+                ),
+            )
+            abe_fs.create_file(
+                "./abe/payments/1.txt",
+                contents=f"sam,036eaf6,{amount},1987-06-30 06:25:00",
+            )
+            abe_fs.create_file(
+                "./abe/payments/2.txt",
+                contents=f"sam,036eaf6,{amount},1987-06-30 06:25:00",
+            )
+            process_payments_and_record_updates()
+
+    @time_machine.travel(datetime(1985, 10, 26, 1, 24), tick=False)
+    @patch('oldabe.models.default_commit_hash', return_value='abcd123')
+    def test_debt_paid(self, mock_git_rev, abe_fs):
+        self._call(abe_fs)
+        with open('./abe/debts.txt') as f:
+            assert f.read() == (
+                "ariana,19,19,1.txt,abcd123,1985-10-26 01:24:00\n"
+            )
+
+    @time_machine.travel(datetime(1985, 10, 26, 1, 24), tick=False)
+    @patch('oldabe.models.default_commit_hash', return_value='abcd123')
+    def test_transactions(self, mock_git_rev, abe_fs):
+        # here, because the two payment amounts are the same,
+        # it ends up correcting immediately. We might consider
+        # more tests where the second amount is larger, or
+        # where there are more debts
+        self._call(abe_fs)
+        with open('./abe/transactions.txt') as f:
+            assert f.read() == (
+                "old abe,1.0,1.txt,abcd123,1985-10-26 01:24:00\n"
+                "DIA,5.0,1.txt,abcd123,1985-10-26 01:24:00\n"
+                "sid,58,1.txt,abcd123,1985-10-26 01:24:00\n"
+                "jair,35,1.txt,abcd123,1985-10-26 01:24:00\n"
+                "old abe,1.0,2.txt,abcd123,1985-10-26 01:24:00\n"
+                "DIA,5.0,2.txt,abcd123,1985-10-26 01:24:00\n"
+                "sid,36,2.txt,abcd123,1985-10-26 01:24:00\n"
+                "jair,20,2.txt,abcd123,1985-10-26 01:24:00\n"
+                "ariana,38,2.txt,abcd123,1985-10-26 01:24:00\n"
+            )
+
+    @time_machine.travel(datetime(1985, 10, 26, 1, 24), tick=False)
+    @patch('oldabe.models.default_commit_hash', return_value='abcd123')
+    def test_advances(self, mock_git_rev, abe_fs):
+        self._call(abe_fs)
+        with open('./abe/advances.txt') as f:
+            assert f.read() == (
+                "sid,11,1.txt,abcd123,1985-10-26 01:24:00\n"
+                "jair,6.8,1.txt,abcd123,1985-10-26 01:24:00\n"
+                "sid,-11,2.txt,abcd123,1985-10-26 01:24:00\n"
+                "jair,-6.8,2.txt,abcd123,1985-10-26 01:24:00\n"
+                "sid,9.0,2.txt,abcd123,1985-10-26 01:24:00\n"
+                "jair,5.4,2.txt,abcd123,1985-10-26 01:24:00\n"
+                "ariana,3.6,2.txt,abcd123,1985-10-26 01:24:00\n"
+            )
+
+    @time_machine.travel(datetime(1985, 10, 26, 1, 24), tick=False)
+    @patch('oldabe.models.default_commit_hash', return_value='abcd123')
+    def test_compiled_outstanding_balances(self, mock_git_rev, abe_fs):
+        self._call(abe_fs)
+        message = compile_outstanding_balances()
+        assert (
+            "| Name | Debt |\r\n" "| ---- | --- |\r\n" "ariana | 0.00\r\n"
+        ) in message
diff --git a/tests/unit/accounting_utils_test.py b/tests/unit/accounting_utils_test.py
new file mode 100644
index 0000000..8fde629
--- /dev/null
+++ b/tests/unit/accounting_utils_test.py
@@ -0,0 +1,48 @@
+import pytest
+from decimal import Decimal
+from oldabe.accounting_utils import (
+    get_rounding_difference,
+    correct_rounding_error,
+    ROUNDING_TOLERANCE,
+)
+from .fixtures import normalized_attributions  # noqa
+
+
+class TestGetRoundingDifference:
+    def test_attributions_already_one(self, normalized_attributions):
+        attributions = normalized_attributions
+        test_diff = Decimal("0")
+        difference = get_rounding_difference(attributions)
+        assert difference == test_diff
+
+    def test_attributions_exceed_below_tolerance(
+        self, normalized_attributions
+    ):
+        attributions = normalized_attributions
+        test_diff = ROUNDING_TOLERANCE / Decimal("2")
+        attributions['c@d.com'] = test_diff
+        difference = get_rounding_difference(attributions)
+        assert difference == test_diff
+
+    def test_attributions_exceed_above_tolerance(
+        self, normalized_attributions
+    ):
+        attributions = normalized_attributions
+        test_diff = ROUNDING_TOLERANCE * Decimal("2")
+        attributions['c@d.com'] = test_diff
+        with pytest.raises(AssertionError):
+            _ = get_rounding_difference(attributions)
+
+    def test_attributions_exceed_by_tolerance(self, normalized_attributions):
+        attributions = normalized_attributions
+        test_diff = ROUNDING_TOLERANCE
+        attributions['c@d.com'] = test_diff
+        difference = get_rounding_difference(attributions)
+        assert difference == test_diff
+
+    def test_attributions_below_one(self, normalized_attributions):
+        attributions = normalized_attributions
+        test_diff = ROUNDING_TOLERANCE / Decimal("2")
+        attributions['b@c.com'] -= test_diff
+        difference = get_rounding_difference(attributions)
+        assert difference == -test_diff
diff --git a/tests/unit/money_in_test.py b/tests/unit/money_in_test.py
index 4fb1c90..cec8fb7 100644
--- a/tests/unit/money_in_test.py
+++ b/tests/unit/money_in_test.py
@@ -1,479 +1,10 @@
 from decimal import Decimal
-from oldabe.money_in import (
-    calculate_incoming_investment,
-    parse_percentage,
-    serialize_proportion,
-    total_amount_paid_to_project,
-    calculate_incoming_attribution,
-    correct_rounding_error,
-    generate_transactions,
-    get_rounding_difference,
-    ROUNDING_TOLERANCE,
-    renormalize,
-    inflate_valuation,
-    process_payments,
-)
-from oldabe.models import Attribution, Payment
-import pytest
-from unittest.mock import patch
-from .utils import call_sequence
-from .fixtures import (
-    instruments,
-    normalized_attributions,
-    excess_attributions,
-    shortfall_attributions,
-    empty_attributions,
-    single_contributor_attributions,
-    itemized_payments,
-    new_itemized_payments,
-)  # noqa
 
+# See integration tests for now.
 
-class TestParsePercentage:
-    def test_an_integer(self):
-        assert parse_percentage('75') == Decimal('0.75')
 
-    def test_non_integer_greater_than_1(self):
-        assert parse_percentage('75.334455') == Decimal('0.75334455')
-
-    def test_non_integer_less_than_1(self):
-        assert parse_percentage('0.334455') == Decimal('0.00334455')
-
-    def test_decimal_places_at_precision_context(self):
-        assert parse_percentage('5.1234567891') == Decimal('0.051234567891')
-
-    def test_very_small_number(self):
-        assert parse_percentage('0.000001') == Decimal('0.00000001')
-
-    def test_0(self):
-        assert parse_percentage('0') == Decimal('0')
-
-    def test_0_point_0(self):
-        assert parse_percentage('0.0') == Decimal('0')
-
-    def test_100(self):
-        assert parse_percentage('100') == Decimal('1')
-
-    def test_100_point_0(self):
-        assert parse_percentage('100.0') == Decimal('1')
-
-
-class TestSerializeProportion:
-    def test_0(self):
-        assert serialize_proportion(Decimal('0')) == '0'
-
-    def test_0_point_0(self):
-        assert serialize_proportion(Decimal('0.0')) == '0'
-
-    def test_1(self):
-        assert serialize_proportion(Decimal('1')) == '100'
-
-    def test_1_point_0(self):
-        assert serialize_proportion(Decimal('1.0')) == '100'
-
-    def test_almost_1(self):
-        assert serialize_proportion(Decimal('0.9523452')) == '95.23452'
-
-    def test_low_precision_number(self):
-        assert serialize_proportion(Decimal('0.2')) == '20'
-
-    def test_very_small_number(self):
-        assert serialize_proportion(Decimal('0.0000002')) == '0.00002'
-
-    def test_decimal_places_at_precision_context(self):
-        assert serialize_proportion(Decimal('0.1234567891')) == '12.34567891'
-
-
-class TestGenerateTransactions:
-    def test_zero_amount(self, normalized_attributions):
-        with pytest.raises(AssertionError):
-            generate_transactions(
-                0, normalized_attributions, 'payment-1.txt', 'abc123'
-            )
-
-    def test_empty_attributions(self, empty_attributions):
-        with pytest.raises(AssertionError):
-            generate_transactions(
-                100, empty_attributions, 'payment-1.txt', 'abc123'
-            )
-
-    def test_single_contributor_attributions(
-        self, single_contributor_attributions
-    ):
-        amount = 100
-        payment_file = 'payment-1.txt'
-        commit_hash = 'abc123'
-        result = generate_transactions(
-            amount, single_contributor_attributions, payment_file, commit_hash
-        )
-        t = result[0]
-        assert t.amount == amount
-
-    def test_as_many_transactions_as_attributions(
-        self, normalized_attributions
-    ):
-        amount = 100
-        payment_file = 'payment-1.txt'
-        commit_hash = 'abc123'
-        result = generate_transactions(
-            amount, normalized_attributions, payment_file, commit_hash
-        )
-        assert len(result) == len(normalized_attributions)
-
-    def test_transactions_reflect_attributions(self, normalized_attributions):
-        amount = 100
-        payment_file = 'payment-1.txt'
-        commit_hash = 'abc123'
-        result = generate_transactions(
-            amount, normalized_attributions, payment_file, commit_hash
-        )
-        for t in result:
-            assert t.amount == normalized_attributions[t.email] * amount
-
-    def test_everyone_in_attributions_are_represented(
-        self, normalized_attributions
-    ):
-        amount = 100
-        payment_file = 'payment-1.txt'
-        commit_hash = 'abc123'
-        result = generate_transactions(
-            amount, normalized_attributions, payment_file, commit_hash
-        )
-        emails = [t.email for t in result]
-        for contributor in normalized_attributions:
-            assert contributor in emails
-
-    def test_transaction_refers_to_payment(self, normalized_attributions):
-        amount = 100
-        payment_file = 'payment-1.txt'
-        commit_hash = 'abc123'
-        result = generate_transactions(
-            amount, normalized_attributions, payment_file, commit_hash
-        )
-        t = result[0]
-        assert t.payment_file == payment_file
-
-    def test_transaction_refers_to_commit(self, normalized_attributions):
-        amount = 100
-        payment_file = 'payment-1.txt'
-        commit_hash = 'abc123'
-        result = generate_transactions(
-            amount, normalized_attributions, payment_file, commit_hash
-        )
-        t = result[0]
-        assert t.commit_hash == commit_hash
-
-
-class TestProcessPayments:
-    @patch('oldabe.money_in._get_unprocessed_payments')
-    @patch('oldabe.money_in.get_all_payments')
-    @patch('oldabe.money_in.read_valuation')
-    @patch('oldabe.money_in.read_price')
-    def test_collects_transactions_for_all_payments(
-        self,
-        mock_read_price,
-        mock_read_valuation,
-        mock_get_all_payments,
-        mock_unprocessed_files,
-        normalized_attributions,
-        instruments,
-    ):
-        price = 100
-        valuation = 1000
-        payments = [
-            Payment('a@b.com', 100, True),
-            Payment('a@b.com', 200, False),
-        ]
-        mock_read_price.return_value = price
-        mock_read_valuation.return_value = valuation
-        mock_unprocessed_files.return_value = payments
-        mock_get_all_payments.return_value = payments
-
-        transactions, _, _ = process_payments(
-            instruments, normalized_attributions
-        )
-        # generates a transaction for each payment and each contributor in the
-        # attributions and instruments files
-        expected_total = len(payments) * (
-            len(normalized_attributions) + len(instruments)
-        )
-        assert len(transactions) == expected_total
-
-    # def test_attributable_payments_update_valuation
-    # def test_non_attributable_payments_do_not_update_valuation
-    # def test_attributions_are_renormalized_by_attributable_payments
-    # def test_instruments_are_not_renormalized_by_attributable_payments
-
-
-class TestCalculateIncomingAttribution:
-    def test_incoming_investment_less_than_zero(self, normalized_attributions):
-        assert (
-            calculate_incoming_attribution(
-                'a@b.co', Decimal('-50'), Decimal('10000')
-            )
-            == None
-        )
-
-    def test_incoming_investment_is_zero(self, normalized_attributions):
-        assert (
-            calculate_incoming_attribution(
-                'a@b.co', Decimal('0'), Decimal('10000')
-            )
-            == None
-        )
-
-    def test_normal_incoming_investment(self, normalized_attributions):
-        assert calculate_incoming_attribution(
-            'a@b.co', Decimal('50'), Decimal('10000')
-        ) == Attribution(
-            'a@b.co',
-            Decimal('0.005'),
-        )
-
-    def test_large_incoming_investment(self, normalized_attributions):
-        assert calculate_incoming_attribution(
-            'a@b.co', Decimal('5000'), Decimal('10000')
-        ) == Attribution(
-            'a@b.co',
-            Decimal('0.5'),
-        )
-
-
-class TestGetRoundingDifference:
-    def test_attributions_already_one(self, normalized_attributions):
-        attributions = normalized_attributions
-        test_diff = Decimal("0")
-        difference = get_rounding_difference(attributions)
-        assert difference == test_diff
-
-    def test_attributions_exceed_below_tolerance(
-        self, normalized_attributions
-    ):
-        attributions = normalized_attributions
-        test_diff = ROUNDING_TOLERANCE / Decimal("2")
-        attributions['c@d.com'] = test_diff
-        difference = get_rounding_difference(attributions)
-        assert difference == test_diff
-
-    def test_attributions_exceed_above_tolerance(
-        self, normalized_attributions
-    ):
-        attributions = normalized_attributions
-        test_diff = ROUNDING_TOLERANCE * Decimal("2")
-        attributions['c@d.com'] = test_diff
-        with pytest.raises(AssertionError):
-            _ = get_rounding_difference(attributions)
-
-    def test_attributions_exceed_by_tolerance(self, normalized_attributions):
-        attributions = normalized_attributions
-        test_diff = ROUNDING_TOLERANCE
-        attributions['c@d.com'] = test_diff
-        difference = get_rounding_difference(attributions)
-        assert difference == test_diff
-
-    def test_attributions_below_one(self, normalized_attributions):
-        attributions = normalized_attributions
-        test_diff = ROUNDING_TOLERANCE / Decimal("2")
-        attributions['b@c.com'] -= test_diff
-        difference = get_rounding_difference(attributions)
-        assert difference == -test_diff
-
-
-class TestRenormalize:
-    @pytest.mark.parametrize(
-        "incoming_attribution, renormalized_attributions",
-        [
-            # new investor
-            (
-                Attribution('c@d.com', Decimal('0.05')),
-                {
-                    'a@b.com': Decimal('0.19'),
-                    'b@c.com': Decimal('0.76'),
-                    'c@d.com': Decimal('0.05'),
-                },
-            ),
-            (
-                Attribution('c@d.com', Decimal('0.5')),
-                {
-                    'a@b.com': Decimal('0.1'),
-                    'b@c.com': Decimal('0.4'),
-                    'c@d.com': Decimal('0.5'),
-                },
-            ),
-            (
-                Attribution('c@d.com', Decimal('0.7')),
-                {
-                    'a@b.com': Decimal('0.06'),
-                    'b@c.com': Decimal('0.24'),
-                    'c@d.com': Decimal('0.7'),
-                },
-            ),
-            # existing investor
-            (
-                Attribution('b@c.com', Decimal('0.05')),
-                {
-                    'a@b.com': Decimal('0.19'),
-                    'b@c.com': Decimal('0.81'),
-                },
-            ),
-            (
-                Attribution('b@c.com', Decimal('0.5')),
-                {
-                    'a@b.com': Decimal('0.1'),
-                    'b@c.com': Decimal('0.9'),
-                },
-            ),
-            (
-                Attribution('b@c.com', Decimal('0.7')),
-                {
-                    'a@b.com': Decimal('0.06'),
-                    'b@c.com': Decimal('0.94'),
-                },
-            ),
-        ],
-    )
-    def test_matrix(
-        self,
-        incoming_attribution,
-        renormalized_attributions,
-        normalized_attributions,
-    ):
-        attributions = normalized_attributions
-        renormalize(attributions, incoming_attribution)
-        assert attributions == renormalized_attributions
-
-
-class TestCorrectRoundingError:
-    @pytest.mark.parametrize(
-        "attributions, test_diff",
-        [
-            ("normalized_attributions", Decimal("0")),
-            ("excess_attributions", Decimal("0")),
-            ("shortfall_attributions", Decimal("0")),
-            ("normalized_attributions", Decimal("0.1")),
-            ("excess_attributions", Decimal("0.1")),
-            ("shortfall_attributions", Decimal("0.1")),
-            ("normalized_attributions", Decimal("-0.1")),
-            ("excess_attributions", Decimal("-0.1")),
-            ("shortfall_attributions", Decimal("-0.1")),
-        ],
-    )
-    @patch('oldabe.money_in.get_rounding_difference')
-    def test_incoming_is_adjusted_by_rounding_error(
-        self, mock_rounding_difference, attributions, test_diff, request
-    ):
-        # Re: request, see: https://stackoverflow.com/q/42014484
-        attributions = request.getfixturevalue(attributions)
-        mock_rounding_difference.return_value = test_diff
-        incoming_email = 'a@b.com'
-        incoming_attribution = Attribution(
-            incoming_email, attributions[incoming_email]
-        )
-        other_attributions = attributions.copy()
-        other_attributions.pop(incoming_attribution.email)
-        original_share = incoming_attribution.share
-        correct_rounding_error(attributions, incoming_attribution)
-        assert (
-            attributions[incoming_attribution.email]
-            == original_share - test_diff
-        )
-
-    @pytest.mark.parametrize(
-        "attributions, test_diff",
-        [
-            ("normalized_attributions", Decimal("0")),
-            ("excess_attributions", Decimal("0")),
-            ("shortfall_attributions", Decimal("0")),
-            ("normalized_attributions", Decimal("0.1")),
-            ("excess_attributions", Decimal("0.1")),
-            ("shortfall_attributions", Decimal("0.1")),
-            ("normalized_attributions", Decimal("-0.1")),
-            ("excess_attributions", Decimal("-0.1")),
-            ("shortfall_attributions", Decimal("-0.1")),
-        ],
-    )
-    @patch('oldabe.money_in.get_rounding_difference')
-    def test_existing_attributions_are_unchanged(
-        self, mock_rounding_difference, attributions, test_diff, request
-    ):
-        attributions = request.getfixturevalue(attributions)
-        mock_rounding_difference.return_value = test_diff
-        incoming_email = 'a@b.com'
-        incoming_attribution = Attribution(
-            incoming_email, attributions[incoming_email]
-        )
-        other_attributions = attributions.copy()
-        other_attributions.pop(incoming_attribution.email)
-        correct_rounding_error(attributions, incoming_attribution)
-        attributions.pop(incoming_attribution.email)
-        assert attributions == other_attributions
-
-
-class TestInflateValuation:
-    @patch('oldabe.money_in.open')
-    def test_valuation_inflates_by_fresh_value(self, mock_open):
-        amount = 100
-        valuation = 1000
-        new_valuation = inflate_valuation(valuation, amount)
-        assert new_valuation == amount + valuation
-
-
-class TotalAmountPaidToProject:
-    @patch('oldabe.money_in.get_existing_itemized_payments')
-    def test_total_amount_for_a_all_attributable(
-        self,
-        get_existing_itemized_payments,
-        itemized_payments,
-        new_itemized_payments,
-    ):
-        get_existing_itemized_payments.return_value = itemized_payments
-        total = total_amount_paid_to_project('a@b.com', new_itemized_payments)
-        assert total == 194
-
-    @patch('oldabe.money_in.get_existing_itemized_payments')
-    def test_total_amount_for_c_one_non_attributable(
-        self,
-        get_existing_itemized_payments,
-        itemized_payments,
-        new_itemized_payments,
-    ):
-        get_existing_itemized_payments.return_value = itemized_payments
-        total = total_amount_paid_to_project('c@d.com', new_itemized_payments)
-        assert total == 30
-
-
-class TestCalculateIncomingInvestment:
-    @pytest.mark.parametrize(
-        "prior_contribution, incoming_amount, price, expected_investment",
-        [
-            (Decimal("0"), Decimal("0"), Decimal("100"), Decimal("0")),
-            (Decimal("0"), Decimal("20"), Decimal("100"), Decimal("0")),
-            (Decimal("0"), Decimal("100"), Decimal("100"), Decimal("0")),
-            (Decimal("0"), Decimal("120"), Decimal("100"), Decimal("20")),
-            (Decimal("20"), Decimal("0"), Decimal("100"), Decimal("0")),
-            (Decimal("20"), Decimal("20"), Decimal("100"), Decimal("0")),
-            (Decimal("20"), Decimal("80"), Decimal("100"), Decimal("0")),
-            (Decimal("20"), Decimal("100"), Decimal("100"), Decimal("20")),
-            (Decimal("100"), Decimal("0"), Decimal("100"), Decimal("0")),
-            (Decimal("100"), Decimal("20"), Decimal("100"), Decimal("20")),
-            (Decimal("120"), Decimal("0"), Decimal("100"), Decimal("0")),
-            (Decimal("120"), Decimal("20"), Decimal("100"), Decimal("20")),
-        ],
-    )
-    @patch('oldabe.money_in.total_amount_paid_to_project')
-    def test_matrix(
+class TestPlaceholder:
+    def test_something(
         self,
-        total_amount_paid_to_project,
-        prior_contribution,
-        incoming_amount,
-        price,
-        expected_investment,
     ):
-        email = 'dummy@abe.org'
-        payment = Payment(email, incoming_amount)
-        # this is the total amount paid _including_ the incoming amount
-        total_amount_paid_to_project.return_value = (
-            prior_contribution + incoming_amount
-        )
-        result = calculate_incoming_investment(payment, price, [])
-        assert result == expected_investment
+        assert True
diff --git a/tests/unit/parsing_test.py b/tests/unit/parsing_test.py
new file mode 100644
index 0000000..31328e5
--- /dev/null
+++ b/tests/unit/parsing_test.py
@@ -0,0 +1,61 @@
+import pytest
+from decimal import Decimal
+from oldabe.parsing import (
+    parse_percentage,
+    serialize_proportion,
+)
+
+
+class TestParsePercentage:
+    def test_an_integer(self):
+        assert parse_percentage('75') == Decimal('0.75')
+
+    def test_non_integer_greater_than_1(self):
+        assert parse_percentage('75.334455') == Decimal('0.75334455')
+
+    def test_non_integer_less_than_1(self):
+        assert parse_percentage('0.334455') == Decimal('0.00334455')
+
+    def test_decimal_places_at_precision_context(self):
+        assert parse_percentage('5.1234567891') == Decimal('0.051234567891')
+
+    def test_very_small_number(self):
+        assert parse_percentage('0.000001') == Decimal('0.00000001')
+
+    def test_0(self):
+        assert parse_percentage('0') == Decimal('0')
+
+    def test_0_point_0(self):
+        assert parse_percentage('0.0') == Decimal('0')
+
+    def test_100(self):
+        assert parse_percentage('100') == Decimal('1')
+
+    def test_100_point_0(self):
+        assert parse_percentage('100.0') == Decimal('1')
+
+
+class TestSerializeProportion:
+    def test_0(self):
+        assert serialize_proportion(Decimal('0')) == '0'
+
+    def test_0_point_0(self):
+        assert serialize_proportion(Decimal('0.0')) == '0'
+
+    def test_1(self):
+        assert serialize_proportion(Decimal('1')) == '100'
+
+    def test_1_point_0(self):
+        assert serialize_proportion(Decimal('1.0')) == '100'
+
+    def test_almost_1(self):
+        assert serialize_proportion(Decimal('0.9523452')) == '95.23452'
+
+    def test_low_precision_number(self):
+        assert serialize_proportion(Decimal('0.2')) == '20'
+
+    def test_very_small_number(self):
+        assert serialize_proportion(Decimal('0.0000002')) == '0.00002'
+
+    def test_decimal_places_at_precision_context(self):
+        assert serialize_proportion(Decimal('0.1234567891')) == '12.34567891'