From 4149d78e1ac296e231598e1c2ae029fadb82fa54 Mon Sep 17 00:00:00 2001 From: Jair Trejo Date: Sat, 24 Aug 2024 13:41:47 -0700 Subject: [PATCH] Jair's refactor - Move I/O into repositories - Create Tally concept to sum lists of stuff - Functional re-organizing --- oldabe/accounting_utils.py | 1 + oldabe/constants.py | 7 +- oldabe/distribution.py | 48 ++ oldabe/git.py | 3 + oldabe/models.py | 80 ++- oldabe/money_in.py | 784 +++++++++++------------------- oldabe/money_out.py | 80 +-- oldabe/repos.py | 163 +++++++ oldabe/tally.py | 30 ++ tests/integration/old_abe_test.py | 78 ++- 10 files changed, 650 insertions(+), 624 deletions(-) create mode 100644 oldabe/distribution.py create mode 100644 oldabe/repos.py create mode 100644 oldabe/tally.py diff --git a/oldabe/accounting_utils.py b/oldabe/accounting_utils.py index 7275b67..6d6672d 100644 --- a/oldabe/accounting_utils.py +++ b/oldabe/accounting_utils.py @@ -26,6 +26,7 @@ def correct_rounding_error(attributions, incoming_attribution): def assert_attributions_normalized(attributions): + print(_get_attributions_total(attributions)) assert _get_attributions_total(attributions) == Decimal("1") diff --git a/oldabe/constants.py b/oldabe/constants.py index a680e8e..d943070 100644 --- a/oldabe/constants.py +++ b/oldabe/constants.py @@ -1,4 +1,7 @@ import os +from decimal import Decimal + +ACCOUNTING_ZERO = Decimal("0.01") ABE_ROOT = './abe' PAYOUTS_DIR = os.path.join(ABE_ROOT, 'payouts') @@ -14,5 +17,5 @@ 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 = 'attributions.txt' -INSTRUMENTS_FILE = 'instruments.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 index 3c6638e..1bae937 100644 --- a/oldabe/git.py +++ b/oldabe/git.py @@ -1,5 +1,8 @@ import subprocess +from functools import cache + +@cache def get_git_revision_short_hash() -> str: """From https://stackoverflow.com/a/21901260""" return ( diff --git a/oldabe/models.py b/oldabe/models.py index 59c51df..d8b0904 100644 --- a/oldabe/models.py +++ b/oldabe/models.py @@ -1,26 +1,42 @@ -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): @@ -32,6 +48,12 @@ def is_fulfilled(self): 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). @@ -39,19 +61,22 @@ def amount_remaining(self): # sum all of their existing Advance objects. @dataclass class Advance: - 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 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 @@ -60,17 +85,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 01713c6..ede7f4e 100755 --- a/oldabe/money_in.py +++ b/oldabe/money_in.py @@ -1,49 +1,28 @@ #!/usr/bin/env python import csv -from collections import defaultdict -from decimal import Decimal, getcontext -from dataclasses import astuple +import dataclasses import re -import os -import subprocess -from .models import ( - Advance, Attribution, Debt, Payment, ItemizedPayment, Transaction -) -from .parsing import parse_percentage, serialize_proportion -from .git import get_git_revision_short_hash -from .accounting_utils import ( - get_rounding_difference, correct_rounding_error, - assert_attributions_normalized -) -from .constants import ( - ABE_ROOT, PAYMENTS_DIR, PAYOUTS_DIR, TRANSACTIONS_FILE, DEBTS_FILE, - ADVANCES_FILE, NONATTRIBUTABLE_PAYMENTS_DIR, UNPAYABLE_CONTRIBUTORS_FILE, - ITEMIZED_PAYMENTS_FILE, PRICE_FILE, VALUATION_FILE, ATTRIBUTIONS_FILE, - INSTRUMENTS_FILE -) - - -ACCOUNTING_ZERO = Decimal("0.01") - -# TODO standardize the parsing from text into python objects -# e.g. Decimal and DateTime - -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) - - -def read_price(): +from dataclasses import astuple, replace +from decimal import Decimal, getcontext +from itertools import accumulate +from typing import Iterable, List, Set, Tuple + +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() -> Decimal: with open(PRICE_FILE) as f: price = f.readline() price = Decimal(re.sub("[^0-9.]", "", price)) @@ -52,145 +31,261 @@ 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(): +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): - if row and row[0].strip(): - email, percentage = row - email = email.strip() - attributions[email] = parse_percentage(percentage) - if validate: - assert_attributions_normalized(attributions) - 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. """ - try: - payments = [ - read_payment(f, attributable=True) - for f in os.listdir(PAYMENTS_DIR) - if not os.path.isdir(os.path.join(PAYMENTS_DIR, f)) - ] - except FileNotFoundError: - payments = [] - 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(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] + ] + 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 - """ - recorded_payments = set() - try: - with open(TRANSACTIONS_FILE) as f: - for ( - _email, - _amount, - payment_file, - _commit_hash, - _created_at, - ) in csv.reader(f): - recorded_payments.add(payment_file) - except FileNotFoundError: - pass - all_payments = get_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): +def pay_outstanding_debts( + available_amount: Decimal, + all_debts: Iterable[Debt], + payable_contributors: Set[str], +) -> List[DebtPayment]: """ - Generate transactions reflecting the amount owed to each contributor from - a fresh payment amount -- one transaction per attributable contributor. + Given an available amount return debt payments for as many debts as can + be covered """ - assert amount > 0 - assert attributions - transactions = [] - for email, amount_owed in get_amounts_owed(amount, attributions): - t = Transaction(email, amount_owed, payment_file, commit_hash) - transactions.append(t) - return transactions + 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 + ] -def get_existing_itemized_payments(): - """ - Reads itemized payment files and returns all itemized payment objects. - """ - itemized_payments = [] - 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, - Decimal(fee_amount), - Decimal(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): + 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 ) - 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) + + incoming_investment = min( + total_attributable_payments - price, payment.amount + ) + return max(0, incoming_investment) @@ -208,56 +303,6 @@ def calculate_incoming_attribution( return None -def normalize(attributions): - total_share = sum(share for _, share in attributions.items()) - target_proportion = Decimal("1") / total_share - for email in attributions: - attributions[email] *= target_proportion - - -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() - ] - 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): - 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): - with open(ITEMIZED_PAYMENTS_FILE, 'a') as f: - writer = csv.writer(f) - for row in itemized_payments: - writer.writerow(astuple(row)) - - -def write_append_advances(advances): - with open(ADVANCES_FILE, 'a') as f: - writer = csv.writer(f) - for row in advances: - writer.writerow(astuple(row)) - - -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 dilute_attributions(incoming_attribution, attributions): """ Incorporate a fresh attributive share by diluting existing attributions, @@ -284,268 +329,6 @@ def dilute_attributions(incoming_attribution, attributions): 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 read_debts(): - debts = [] - try: - with open(DEBTS_FILE) as f: - for ( - email, - amount, - amount_paid, - payment_file, - commit_hash, - created_at, - ) in csv.reader(f): - debts.append(Debt(email, Decimal(amount), Decimal(amount_paid), payment_file, commit_hash, created_at)) - except FileNotFoundError: - pass - - return debts - - -def read_advances(attributions): - advances = defaultdict(list) - try: - with open(ADVANCES_FILE) as f: - for ( - email, - amount, - payment_file, - commit_hash, - created_at, - ) in csv.reader(f): - if email in attributions: - advances[email].append(Advance(email, Decimal(amount), payment_file, commit_hash, created_at)) - except FileNotFoundError: - pass - - return advances - - -def get_sum_of_advances_by_contributor(attributions): - """ - Sum all Advance objects for each contributor to get the total amount - that they currently have in advances and have not yet drawn down. - Return a dictionary with the contributor's email as the key and the - their advance amount as the value. - """ - all_advances = read_advances(attributions) - advance_totals = {email: sum(a.amount for a in advances) - for email, advances - in all_advances.items()} - return advance_totals - - -def get_payable_debts(unpayable_contributors, attributions): - debts = read_debts() - debts = [d for d in debts - if not d.is_fulfilled() - and d.email in attributions - and d.email not in unpayable_contributors] - return debts - - -def pay_debts(payable_debts, payment): - """ - Go through debts in chronological order, and pay each as much as possible, - stopping when either the money runs out, or there are no further debts. - Returns the updated debts reflecting fresh payments to be made this time, - and transactions representing those fresh payments. - """ - updated_debts = [] - transactions = [] - available_amount = payment.amount - for debt in sorted(payable_debts, key=lambda x: x.created_at): - payable_amount = min(available_amount, debt.amount_remaining()) - if payable_amount < ACCOUNTING_ZERO: - break - debt.amount_paid += payable_amount - available_amount -= payable_amount - transaction = Transaction(debt.email, payable_amount, payment.file, debt.commit_hash) - transactions.append(transaction) - updated_debts.append(debt) - - return updated_debts, transactions - - -def get_unpayable_contributors(): - """ - Read the unpayable_contributors file to get the list of contributors who - are unpayable. - """ - contributors = [] - try: - with open(UNPAYABLE_CONTRIBUTORS_FILE) as f: - for contributor in f: - contributor = contributor.strip() - if contributor: - contributors.append(contributor) - except FileNotFoundError: - pass - return contributors - - -def create_debts(amounts_owed, unpayable_contributors, payment_file): - """ - Create fresh debts (to unpayable contributors). - """ - amounts_unpayable = {email: amount - for email, amount in amounts_owed.items() - if email in unpayable_contributors} - debts = [] - commit_hash = get_git_revision_short_hash() - for email, amount in amounts_unpayable.items(): - debt = Debt(email, amount, payment_file=payment_file, commit_hash=commit_hash) - debts.append(debt) - - return debts - - -def write_debts(processed_debts): - """ - 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. - """ - existing_debts = read_debts() - processed_debts_hash = {debt.key(): debt for debt in processed_debts} - with open(DEBTS_FILE, 'w') as f: - writer = csv.writer(f) - for existing_debt in existing_debts: - # if the existing debt has been processed, write the processed version - # otherwise re-write the existing version - if processed_debt := processed_debts_hash.get(existing_debt.key()): - writer.writerow(astuple(processed_debt)) - del processed_debts_hash[processed_debt.key()] - else: - writer.writerow(astuple(existing_debt)) - for debt in processed_debts_hash.values(): - writer.writerow(astuple(debt)) - - -def renormalize(attributions, excluded_contributors): - target_proportion = 1 / (1 - sum(attributions[email] for email in excluded_contributors)) - remainder_attributions = {} - for email in attributions: - # renormalize to reflect dilution - remainder_attributions[email] = attributions[email] * target_proportion - return remainder_attributions - - -def get_amounts_owed(total_amount, attributions): - return {email: share * total_amount - for email, share in attributions.items()} - - -def redistribute_pot(redistribution_pot, attributions, unpayable_contributors, payment_file, amounts_payable): - """ - Redistribute the pot of remaining money over all payable contributors, according to attributions - share (normalized to 100%). Create advances for those amounts (because they are in excess - of the amount owed to each contributor from the original payment) and add the amounts to the - amounts_payable dictionary to keep track of the full amount we are about to pay everyone. - """ - fresh_advances = [] - payable_attributions = {email: share for email, share in attributions.items() if email not in unpayable_contributors} - normalize(payable_attributions) - for email, share in payable_attributions.items(): - advance_amount = redistribution_pot * share - fresh_advances.append(Advance(email=email, - amount=advance_amount, - payment_file=payment_file, - commit_hash=get_git_revision_short_hash())) - amounts_payable[email] += advance_amount - - return fresh_advances - - -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. - """ - - # 1. check payable outstanding debts - # 2. pay them off in chronological order (maybe partially) - # 3. (if leftover) identify unpayable people in the relevant attributions file - # 4. record debt for each of them according to their attribution - commit_hash = get_git_revision_short_hash() - unpayable_contributors = get_unpayable_contributors() - payable_debts = get_payable_debts(unpayable_contributors, attributions) - updated_debts, debt_transactions = pay_debts(payable_debts, payment) - # The "available" amount is what is left over after paying off debts - available_amount = payment.amount - sum(t.amount for t in debt_transactions) - - fresh_debts = [] - equity_transactions = [] - negative_advances = [] - fresh_advances = [] - if available_amount > ACCOUNTING_ZERO: - amounts_owed = get_amounts_owed(available_amount, attributions) - fresh_debts = create_debts(amounts_owed, - unpayable_contributors, - payment.file) - redistribution_pot = sum(d.amount for d in fresh_debts) - - # just retain payable people and their amounts owed - amounts_payable = {email: amount - for email, amount in amounts_owed.items() - if email not in unpayable_contributors} - - # use the amount owed to each contributor to draw down any advances - # they may already have and then decrement their amount payable accordingly - advance_totals = get_sum_of_advances_by_contributor(attributions) - for email, advance_total in advance_totals.items(): - amount_payable = amounts_payable.get(email, 0) - drawdown_amount = min(advance_total, amount_payable) - if drawdown_amount > ACCOUNTING_ZERO: - negative_advance = Advance(email=email, - amount=-drawdown_amount, # note minus sign - payment_file=payment.file, - commit_hash=commit_hash) - negative_advances.append(negative_advance) - amounts_payable[email] -= drawdown_amount - - # note that these are drawn down amounts and therefore have negative amounts - # and that's why we take the absolute value here - redistribution_pot += sum(abs(a.amount) for a in negative_advances) - - # redistribute the pot over all payable contributors - produce fresh advances and add to amounts payable - if redistribution_pot > ACCOUNTING_ZERO: - fresh_advances = redistribute_pot(redistribution_pot, - attributions, - unpayable_contributors, - payment.file, - amounts_payable) - - for email, amount in amounts_payable.items(): - new_equity_transaction = Transaction(email=email, - amount=amount, - payment_file=payment.file, - commit_hash=commit_hash) - equity_transactions.append(new_equity_transaction) - - debts = updated_debts + fresh_debts - transactions = equity_transactions + debt_transactions - advances = negative_advances + fresh_advances - - return debts, transactions, advances - - def handle_investment( payment, new_itemized_payments, attributions, price, prior_valuation ): @@ -560,9 +343,8 @@ 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 ) @@ -571,16 +353,6 @@ def handle_investment( return posterior_valuation -def _create_itemized_payment(payment, fee_amount): - return ItemizedPayment( - payment.email, - fee_amount, - payment.amount, # already has fees deducted - payment.attributable, - payment.file, - ) - - def process_payments(instruments, attributions): """ Process new payments by paying out instruments and then, from the amount @@ -591,35 +363,66 @@ 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 = find_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) - debts, transactions, advances = 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 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 -= fees_paid_out new_itemized_payments.append( - _create_itemized_payment(payment, fees_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: - debts, transactions, advances = 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_debts, new_transactions, valuation, new_itemized_payments, new_advances + return ( + new_debts, + new_debt_payments, + new_transactions, + valuation, + new_itemized_payments, + new_advances, + ) def process_payments_and_record_updates(): @@ -628,11 +431,14 @@ 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) ( - debts, + new_debts, + debt_payments, transactions, posterior_valuation, new_itemized_payments, @@ -642,12 +448,12 @@ def process_payments_and_record_updates(): # we only write the changes to disk at the end # so that if any errors are encountered, no # changes are made. - write_debts(debts) - 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) - write_append_advances(advances) + ItemizedPaymentsRepo().extend(new_itemized_payments) + AdvancesRepo().extend(advances) def main(): diff --git a/oldabe/money_out.py b/oldabe/money_out.py index 293d222..53e501a 100755 --- a/oldabe/money_out.py +++ b/oldabe/money_out.py @@ -1,46 +1,10 @@ #!/usr/bin/env python -from .models import Transaction, Debt, Advance -from datetime import datetime -import csv -import os -import re from collections import defaultdict from decimal import Decimal, getcontext -from .constants import ( - ABE_ROOT, PAYOUTS_DIR, TRANSACTIONS_FILE, DEBTS_FILE, ADVANCES_FILE -) - -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): @@ -49,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 @@ -60,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: @@ -73,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." @@ -103,16 +57,6 @@ def prepare_debts_message(outstanding_debts: dict): return "\r\n".join(line.strip() for line in message.split('\n')).strip() -def read_advance_amounts(): - advances = defaultdict(int) - with open(ADVANCES_FILE) as f: - for row in csv.reader(f): - a = Advance(*row) - a.amount = Decimal(a.amount) - advances[a.email] += a.amount - return advances - - def prepare_advances_message(advances: dict): """ A temporary message reporting aggregate advances, for testing purposes. """ @@ -152,14 +96,18 @@ def compile_outstanding_balances(): """ Read all accounting records and determine the total outstanding balances, debts, and advances for each contributor. """ - owed = read_transaction_amounts() - paid = read_payout_amounts() - balances = compute_balances(owed, paid) + # 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 = read_outstanding_debt_amounts() + + outstanding_debts = Tally((d.email, d.amount_remaining()) for d in DebtsRepo()) debts_message = prepare_debts_message(outstanding_debts) - advances = read_advance_amounts() + + 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) diff --git a/oldabe/repos.py b/oldabe/repos.py new file mode 100644 index 0000000..b3506aa --- /dev/null +++ b/oldabe/repos.py @@ -0,0 +1,163 @@ +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/tests/integration/old_abe_test.py b/tests/integration/old_abe_test.py index 429c6a0..c30e8a9 100644 --- a/tests/integration/old_abe_test.py +++ b/tests/integration/old_abe_test.py @@ -1,27 +1,26 @@ -import pytest +import os from datetime import datetime from decimal import localcontext -import time_machine from unittest.mock import patch -from oldabe.money_in import ( - process_payments_and_record_updates, -) -from oldabe.money_out import ( - compile_outstanding_balances, -) + +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 -import os class TestNoPayments: - @patch('oldabe.money_in.get_git_revision_short_hash', return_value='abcd123') + @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.money_in.get_git_revision_short_hash', return_value='abcd123') + @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() @@ -38,13 +37,13 @@ def test_compiled_outstanding_balances(self, mock_git_rev, abe_fs): class TestPaymentAbovePrice: @time_machine.travel(datetime(1985, 10, 26, 1, 24), tick=False) - @patch('oldabe.money_in.get_git_revision_short_hash', return_value='abcd123') + @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},dummydate") + 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() == ( @@ -56,13 +55,13 @@ def test_generates_transactions(self, mock_git_rev, abe_fs): ) @time_machine.travel(datetime(1985, 10, 26, 1, 24), tick=False) - @patch('oldabe.money_in.get_git_revision_short_hash', return_value='abcd123') + @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},dummydate") + 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() == ( @@ -73,13 +72,13 @@ def test_dilutes_attributions(self, mock_git_rev, abe_fs): ) @time_machine.travel(datetime(1985, 10, 26, 1, 24), tick=False) - @patch('oldabe.money_in.get_git_revision_short_hash', return_value='abcd123') + @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},dummydate") + contents=f"sam,036eaf6,{amount},1987-06-30 06:25:00") process_payments_and_record_updates() message = compile_outstanding_balances() @@ -92,13 +91,13 @@ def test_compiled_outstanding_balances(self, mock_git_rev, abe_fs): class TestPaymentBelowPrice: @time_machine.travel(datetime(1985, 10, 26, 1, 24), tick=False) - @patch('oldabe.money_in.get_git_revision_short_hash', return_value='abcd123') + @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},dummydate") + 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() == ( @@ -110,13 +109,13 @@ def test_generates_transactions(self, mock_git_rev, abe_fs): ) @time_machine.travel(datetime(1985, 10, 26, 1, 24), tick=False) - @patch('oldabe.money_in.get_git_revision_short_hash', return_value='abcd123') + @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},dummydate") + contents=f"sam,036eaf6,{amount},1987-06-30 06:25:00") process_payments_and_record_updates() message = compile_outstanding_balances() @@ -128,13 +127,13 @@ def test_compiled_outstanding_balances(self, mock_git_rev, abe_fs): class TestNonAttributablePayment: @time_machine.travel(datetime(1985, 10, 26, 1, 24), tick=False) - @patch('oldabe.money_in.get_git_revision_short_hash', return_value='abcd123') + @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},dummydate") + 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() == ( @@ -144,13 +143,13 @@ def test_does_not_dilute_attributions(self, mock_git_rev, abe_fs): ) @time_machine.travel(datetime(1985, 10, 26, 1, 24), tick=False) - @patch('oldabe.money_in.get_git_revision_short_hash', return_value='abcd123') + @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},dummydate") + 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() == ( @@ -162,13 +161,13 @@ def test_generates_transactions(self, mock_git_rev, abe_fs): ) @time_machine.travel(datetime(1985, 10, 26, 1, 24), tick=False) - @patch('oldabe.money_in.get_git_revision_short_hash', return_value='abcd123') + @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},dummydate") + contents=f"sam,036eaf6,{amount},1987-06-30 06:25:00") process_payments_and_record_updates() message = compile_outstanding_balances() @@ -184,13 +183,13 @@ def _call(self, abe_fs): context.prec = 2 amount = 100 abe_fs.create_file("./abe/payments/1.txt", - contents=f"sam,036eaf6,{amount},dummydate") + 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.money_in.get_git_revision_short_hash', return_value='abcd123') + @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: @@ -202,7 +201,7 @@ def test_generates_transactions(self, mock_git_rev, abe_fs): ) @time_machine.travel(datetime(1985, 10, 26, 1, 24), tick=False) - @patch('oldabe.money_in.get_git_revision_short_hash', return_value='abcd123') + @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: @@ -211,7 +210,7 @@ def test_records_debt(self, mock_git_rev, abe_fs): ) @time_machine.travel(datetime(1985, 10, 26, 1, 24), tick=False) - @patch('oldabe.money_in.get_git_revision_short_hash', return_value='abcd123') + @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 @@ -223,7 +222,7 @@ def test_records_advances(self, mock_git_rev, abe_fs): ) @time_machine.travel(datetime(1985, 10, 26, 1, 24), tick=False) - @patch('oldabe.money_in.get_git_revision_short_hash', return_value='abcd123') + @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() @@ -254,13 +253,13 @@ def _call(self, abe_fs): "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},dummydate") + contents=f"sam,036eaf6,{amount},1987-06-30 06:25:00") abe_fs.create_file("./abe/payments/2.txt", - contents=f"sam,036eaf6,{amount},dummydate") + 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.money_in.get_git_revision_short_hash', return_value='abcd123') + @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: @@ -269,7 +268,7 @@ def test_debt_paid(self, mock_git_rev, abe_fs): ) @time_machine.travel(datetime(1985, 10, 26, 1, 24), tick=False) - @patch('oldabe.money_in.get_git_revision_short_hash', return_value='abcd123') + @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 @@ -286,12 +285,11 @@ def test_transactions(self, mock_git_rev, abe_fs): "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,19,2.txt,abcd123,1985-10-26 01:24:00\n" - "ariana,19,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.money_in.get_git_revision_short_hash', return_value='abcd123') + @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: @@ -306,7 +304,7 @@ def test_advances(self, mock_git_rev, abe_fs): ) @time_machine.travel(datetime(1985, 10, 26, 1, 24), tick=False) - @patch('oldabe.money_in.get_git_revision_short_hash', return_value='abcd123') + @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()