Skip to content
This repository has been archived by the owner on Feb 8, 2018. It is now read-only.

Commit

Permalink
Implement some basic journal logic
Browse files Browse the repository at this point in the history
  • Loading branch information
chadwhitacre committed Jul 14, 2015
1 parent 33c42b2 commit 2db0989
Show file tree
Hide file tree
Showing 3 changed files with 191 additions and 13 deletions.
1 change: 1 addition & 0 deletions gratipay/testing/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ def clear_tables(self):
except (IntegrityError, InternalError):
tablenames.insert(0, tablename)
self.db.run("ALTER SEQUENCE participants_id_seq RESTART WITH 1")
self.db.run("SELECT create_system_accounts()") # repopulate the `accounts` table


def make_elsewhere(self, platform, user_id, user_name, **kw):
Expand Down
98 changes: 88 additions & 10 deletions sql/branch.sql
Original file line number Diff line number Diff line change
Expand Up @@ -2,32 +2,64 @@ BEGIN;

DROP TABLE payments;


-- Accounts

CREATE TYPE account_type AS ENUM ('asset', 'liability', 'income', 'expense');

CREATE TABLE accounts
( id serial PRIMARY KEY
, type account_type NOT NULL
, system text DEFAULT NULL UNIQUE
, participant text DEFAULT NULL UNIQUE REFERENCES participants
ON UPDATE CASCADE ON DELETE RESTRICT
, team text DEFAULT NULL UNIQUE REFERENCES teams
ON UPDATE CASCADE ON DELETE RESTRICT
, system text DEFAULT NULL UNIQUE

, CONSTRAINT exactly_one_foreign_key CHECK (
CASE WHEN system IS NULL THEN 0 ELSE 1 END +
CASE WHEN participant IS NULL THEN 0 ELSE 1 END +
CASE WHEN team IS NULL THEN 0 ELSE 1 END +
CASE WHEN system IS NULL THEN 0 ELSE 1 END = 1
CASE WHEN team IS NULL THEN 0 ELSE 1 END = 1
)
);

INSERT INTO accounts (type, system) VALUES ('asset', 'escrow');
INSERT INTO accounts (type, system) VALUES ('asset', 'escrow receivable');
INSERT INTO accounts (type, system) VALUES ('liability', 'escrow payable');
INSERT INTO accounts (type, system) VALUES ('income', 'processing fee revenues');
INSERT INTO accounts (type, system) VALUES ('expense', 'processing fee expenses');
INSERT INTO accounts (type, system) VALUES ('income', 'earned interest');
INSERT INTO accounts (type, system) VALUES ('expense', 'chargeback expenses');
CREATE FUNCTION create_system_accounts() RETURNS void AS $$
BEGIN
INSERT INTO accounts (type, system) VALUES ('asset', 'cash');
INSERT INTO accounts (type, system) VALUES ('asset', 'accounts receivable');
INSERT INTO accounts (type, system) VALUES ('liability', 'accounts payable');
INSERT INTO accounts (type, system) VALUES ('income', 'processing fee revenues');
INSERT INTO accounts (type, system) VALUES ('expense', 'processing fee expenses');
INSERT INTO accounts (type, system) VALUES ('income', 'earned interest');
INSERT INTO accounts (type, system) VALUES ('expense', 'chargeback expenses');
END;
$$ LANGUAGE plpgsql;
SELECT create_system_accounts();


CREATE FUNCTION create_account_for_participant() RETURNS trigger AS $$
BEGIN
INSERT INTO accounts (type, participant) VALUES ('liability', NEW.username);
RETURN NULL;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER create_account_for_participant AFTER INSERT ON participants
FOR EACH ROW EXECUTE PROCEDURE create_account_for_participant();


CREATE FUNCTION create_account_for_team() RETURNS trigger AS $$
BEGIN
INSERT INTO accounts (type, team) VALUES ('liability', NEW.slug);
RETURN NULL;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER create_account_for_team AFTER INSERT ON teams
FOR EACH ROW EXECUTE PROCEDURE create_account_for_team();


-- The Journal

CREATE TABLE journal
( id bigserial PRIMARY KEY
Expand All @@ -42,11 +74,57 @@ BEGIN;
ON UPDATE CASCADE ON DELETE RESTRICT
);

CREATE FUNCTION update_balance() RETURNS trigger AS $$
DECLARE
to_debit text;
to_credit text;
to_update text;
delta numeric(35, 2);
BEGIN

to_debit = (SELECT participant FROM accounts WHERE id=NEW.debit);
to_credit = (SELECT participant FROM accounts WHERE id=NEW.credit);

IF (to_debit IS NULL) AND (to_credit IS NULL) THEN
-- No participants involved in this journal entry.

This comment has been minimized.

Copy link
@chadwhitacre

chadwhitacre Jul 14, 2015

Author Contributor

This is not a bug because we have accounts payable/receivable. See #3616 (comment).

RETURN NULL;
END IF;

IF (to_debit IS NOT NULL) AND (to_credit IS NOT NULL) THEN
-- Two participants involved in this journal entry!
-- This is a bug: we don't allow direct transfers from one ~user to another.
RAISE USING MESSAGE =
'Both ' || to_debit || ' and ' || to_credit || ' are participants.';
END IF;

IF to_debit IS NOT NULL THEN
-- Debiting a liability decreases it.
to_update = to_debit;
delta = -NEW.amount;
ELSE
-- Crediting a liability increases it.
to_update = to_credit;
delta = NEW.amount;
END IF;

UPDATE participants SET balance = balance + delta WHERE username=to_update;

RETURN NULL;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER update_balance AFTER INSERT ON journal
FOR EACH ROW EXECUTE PROCEDURE update_balance();


-- Journal Notes

CREATE TABLE journal_notes
( id bigserial PRIMARY KEY
, body text NOT NULL
, author text NOT NULL REFERENCES participants
ON UPDATE CASCADE ON DELETE RESTRICT
, is_private boolean NOT NULL DEFAULT TRUE
);

END;
105 changes: 102 additions & 3 deletions tests/py/test_journal.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,105 @@
from gratipay.journal import Journal
from __future__ import absolute_import, division, print_function, unicode_literals

from decimal import Decimal as D

def test_journal_can_be_instantiated():
assert Journal().__class__.__name__ == 'Journal'
from gratipay.testing import Harness
from gratipay.models.participant import Participant
from pytest import raises
from psycopg2 import IntegrityError


class TestJournal(Harness):

def test_journal_has_system_accounts(self):
system_accounts = self.db.all('SELECT type, system FROM accounts')
assert system_accounts == [ ('asset', 'cash')
, ('asset', 'accounts receivable')
, ('liability', 'accounts payable')
, ('income', 'processing fee revenues')
, ('expense', 'processing fee expenses')
, ('income', 'earned interest')
, ('expense', 'chargeback expenses')
]

def test_journal_creates_accounts_automatically_for_participants(self):
self.make_participant('alice')
account = self.db.one("SELECT * FROM accounts WHERE participant IS NOT NULL")
assert account.participant == 'alice'
assert account.type == 'liability'

def test_journal_creates_accounts_automatically_for_teams(self):
self.make_team()
account = self.db.one("SELECT * FROM accounts WHERE team IS NOT NULL")
assert account.team == 'TheATeam'
assert account.type == 'liability'

def test_journal_is_okay_with_teams_and_participants_with_same_name(self):
self.make_participant('alice')
account = self.db.one("SELECT * FROM accounts WHERE participant IS NOT NULL")
assert account.participant == 'alice'

self.make_team('alice')
account = self.db.one("SELECT * FROM accounts WHERE team IS NOT NULL")
assert account.team == 'alice'

def test_journal_catches_system_account_collision(self):
with raises(IntegrityError):
self.db.one("INSERT INTO accounts (type, system) VALUES ('asset', 'cash')")

def test_journal_catches_participant_account_collision(self):
self.make_participant('alice')
with raises(IntegrityError):
self.db.one("INSERT INTO accounts (type, participant) VALUES ('liability', 'alice')")

def test_journal_catches_team_account_collision(self):
self.make_team()
with raises(IntegrityError):
self.db.one("INSERT INTO accounts (type, team) VALUES ('liability', 'TheATeam')")

def test_journal_increments_participant_balance(self):
self.make_team()
alice = self.make_participant('alice')
assert alice.balance == 0

self.db.run("""
INSERT INTO journal
(amount, debit, credit)
VALUES ( 10.77
, (SELECT id FROM accounts WHERE team='TheATeam')
, (SELECT id FROM accounts WHERE participant='alice')
)
""")

assert Participant.from_username('alice').balance == D('10.77')

def test_journal_decrements_participant_balance(self):
self.make_team()
alice = self.make_participant('alice', balance=20)
assert alice.balance == D('20.00')

self.db.run("""
INSERT INTO journal
(amount, debit, credit)
VALUES ( 10.77
, (SELECT id FROM accounts WHERE participant='alice')
, (SELECT id FROM accounts WHERE team='TheATeam')
)
""")

assert Participant.from_username('alice').balance == D('9.23')

def test_journal_allows_negative_balance(self):
self.make_team()
alice = self.make_participant('alice', balance=10)
assert alice.balance == D('10.00')

self.db.run("""
INSERT INTO journal
(amount, debit, credit)
VALUES ( 10.77
, (SELECT id FROM accounts WHERE participant='alice')
, (SELECT id FROM accounts WHERE team='TheATeam')
)
""")

assert Participant.from_username('alice').balance == D('-0.77')

0 comments on commit 2db0989

Please sign in to comment.