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

New payroll #3450

Closed
wants to merge 14 commits into from
22 changes: 12 additions & 10 deletions bin/masspay.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,16 +128,18 @@ def compute_input_csv():
JOIN participants p ON p.id = r.participant
WHERE r.network = 'paypal'
AND p.balance > 0

---- Only include team owners
---- TODO: Include members on payroll once process_payroll is implemented

AND ( SELECT count(*)
FROM teams t
WHERE t.owner = p.username
AND t.is_approved IS TRUE
AND t.is_closed IS NOT TRUE
) > 0
AND ( SELECT count(*)
FROM teams t
WHERE t.owner = p.username
AND t.is_approved IS TRUE
AND t.is_closed IS NOT TRUE
) + (
SELECT count(*)
FROM current_payroll pr
JOIN teams t ON t.slug = pr.team
WHERE pr.member = p.username
AND t.is_approved IS TRUE
) > 0

ORDER BY p.balance DESC

Expand Down
53 changes: 28 additions & 25 deletions gratipay/billing/payday.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ class Payday(object):
prepare
create_card_holds
process_subscriptions
transfer_takes
process_payroll
process_draws
settle_card_holds
update_balances
Expand Down Expand Up @@ -161,7 +161,7 @@ def payin(self):
self.prepare(cursor, self.ts_start)
holds = self.create_card_holds(cursor)
self.process_subscriptions(cursor)
self.transfer_takes(cursor, self.ts_start)
self.process_payroll(cursor, self.ts_start)
self.process_draws(cursor)
payments = cursor.all("""
SELECT * FROM payments WHERE "timestamp" > %s
Expand Down Expand Up @@ -280,28 +280,27 @@ def process_subscriptions(cursor):


@staticmethod
def transfer_takes(cursor, ts_start):
return # XXX Bring me back!
def process_payroll(cursor, ts_start):
cursor.run("""

INSERT INTO payday_takes
SELECT team, member, amount
FROM ( SELECT DISTINCT ON (team, member)
team, member, amount, ctime
FROM takes
WHERE mtime < %(ts_start)s
ORDER BY team, member, mtime DESC
) t
WHERE t.amount > 0
AND t.team IN (SELECT username FROM payday_participants)
AND t.member IN (SELECT username FROM payday_participants)
AND ( SELECT id
FROM payday_transfers_done t2
WHERE t.team = t2.tipper
AND t.member = t2.tippee
AND context = 'take'
) IS NULL
ORDER BY t.team, t.ctime DESC;
SELECT team, member, amount
FROM ( SELECT DISTINCT ON (team, member)
team, member, amount, ctime
FROM payroll
WHERE mtime < %(ts_start)s
ORDER BY team, member, mtime DESC
) pr
WHERE pr.amount > 0
AND pr.team IN (SELECT slug FROM payday_teams)
AND pr.member IN (SELECT username FROM payday_participants)
AND ( SELECT id
FROM payday_payments_done done
WHERE pr.team = done.team
AND pr.member = done.participant
AND direction = 'to-participant'
) IS NULL
ORDER BY pr.team, pr.ctime DESC;

""", dict(ts_start=ts_start))

Expand Down Expand Up @@ -413,6 +412,8 @@ def take_over_balances(self):
def payout(self):
"""This is the second stage of payday in which we send money out to the
bank accounts of participants.

We only payout to team owners/members.
"""
log("Starting payout loop.")
participants = self.db.all("""
Expand All @@ -424,15 +425,17 @@ def payout(self):
WHERE r.participant = p.id
AND network = 'balanced-ba'
) > 0

---- Only include team owners
---- TODO: Include members on payroll once process_payroll is implemented

AND ( SELECT count(*)
FROM teams t
WHERE t.owner = p.username
AND t.is_approved IS TRUE
AND t.is_closed IS NOT TRUE
) + (
SELECT count(*)
FROM current_payroll pr
JOIN teams t ON t.slug = pr.team
WHERE pr.member = p.username
AND t.is_approved IS TRUE
) > 0
""")
def credit(participant):
Expand Down
1 change: 0 additions & 1 deletion gratipay/models/_mixin_team.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,6 @@ def remove_all_members(self, cursor=None):
def member_of(self, team):
"""Given a Participant object, return a boolean.
"""
assert team.IS_PLURAL
for take in team.get_current_takes():
if take['member'] == self.username:
return True
Expand Down
223 changes: 222 additions & 1 deletion gratipay/models/team.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
"""Teams on Gratipay are plural participants with members.
"""
from collections import OrderedDict
from decimal import Decimal

from postgres.orm import Model

class MemberLimitReached(Exception): pass

class StubParticipantAdded(Exception): pass

class Team(Model):
"""Represent a Gratipay team.
Expand Down Expand Up @@ -78,10 +84,225 @@ def update_receiving(self, cursor=None):
# Stubbed out for now. Migrate this over from Participant.
pass


@property
def status(self):
return { None: 'unreviewed'
, False: 'rejected'
, True: 'approved'
}[self.is_approved]

# Members
# =======

def add_member(self, member):
"""Add a member to this team.
"""
if len(self.get_current_takes()) == 149:
raise MemberLimitReached
if not member.is_claimed:
raise StubParticipantAdded
self.__set_take_for(member, Decimal('0.01'), self.owner)

def remove_member(self, member):
"""Remove a member from this team.
"""
self.__set_take_for(member, Decimal('0.00'), self.owner)

def remove_all_members(self, cursor=None):
(cursor or self.db).run("""
INSERT INTO payroll
(ctime, member, team, amount, recorder)
(
SELECT ctime, member, %(team)s, 0.00, %(recorder)s
FROM current_payroll
WHERE team=%(team)s
AND amount > 0
);
""", dict(team=self.slug, recorder=self.owner))

@property
def nmembers(self):
return self.db.one("""
SELECT COUNT(*)
FROM current_payroll
WHERE team=%s
""", (self.slug, ))

def get_members(self, current_participant=None):
"""Return a list of member dicts.
"""
takes = self.compute_actual_takes()
members = []
for take in takes.values():
member = {}
member['username'] = take['member']
member['take'] = take['nominal_amount']
member['balance'] = take['balance']
member['percentage'] = take['percentage']

member['removal_allowed'] = (current_participant.username == self.owner)
member['editing_allowed'] = False
member['is_current_user'] = False
if current_participant is not None:
if member['username'] == current_participant.username:
member['is_current_user'] = True
if take['ctime'] is not None:
# current user, but not the team itself
member['editing_allowed']= True

member['last_week'] = last_week = self.get_take_last_week_for(member)
member['max_this_week'] = self.compute_max_this_week(last_week)
members.append(member)
return members


# Takes
# =====

def get_take_last_week_for(self, member):
"""Get the user's nominal take last week. Used in throttling.
"""
membername = member.username if hasattr(member, 'username') else member['username']
return self.db.one("""

SELECT amount
FROM current_payroll
WHERE team=%s AND member=%s
AND mtime < (
SELECT ts_start
FROM paydays
WHERE ts_end > ts_start
ORDER BY ts_start DESC LIMIT 1
)
ORDER BY mtime DESC LIMIT 1

""", (self.slug, membername), default=Decimal('0.00'))

def get_take_for(self, member):
"""Return a Decimal representation of the take for this member, or 0.
"""
return self.db.one("""

SELECT amount
FROM current_payroll
WHERE member=%s
AND team=%s

""", (member.username, self.slug), default=Decimal('0.00'))

def compute_max_this_week(self, last_week):
"""2x last week's take, but at least a dollar.
"""
return max(last_week * Decimal('2'), Decimal('1.00'))

def set_take_for(self, member, take, recorder, cursor=None):
"""Sets member's take from the team pool.
"""

assert hasattr(member, 'username')
assert recorder == self.owner or hasattr(recorder, 'username')
assert isinstance(take, Decimal)

last_week = self.get_take_last_week_for(member)
max_this_week = self.compute_max_this_week(last_week)
if take > max_this_week:
take = max_this_week

self.__set_take_for(member, take, recorder, cursor)
return take

def __set_take_for(self, member, amount, recorder, cursor=None):
# XXX Factored out for testing purposes only! :O Use .set_take_for.
with self.db.get_cursor(cursor) as cursor:
# Lock to avoid race conditions
cursor.run("LOCK TABLE payroll IN EXCLUSIVE MODE")
# Compute the current takes
old_takes = self.compute_actual_takes(cursor)
# Insert the new take
recordername = recorder.username if hasattr(recorder, 'username') else recorder
cursor.run("""

INSERT INTO payroll (ctime, member, team, amount, recorder)
VALUES ( COALESCE (( SELECT ctime
FROM payroll
WHERE member=%(member)s
AND team=%(team)s
LIMIT 1
), CURRENT_TIMESTAMP)
, %(member)s
, %(team)s
, %(amount)s
, %(recorder)s
)

""", dict(member=member.username, team=self.slug, amount=amount,
recorder=recordername))
# Compute the new takes
new_takes = self.compute_actual_takes(cursor)
# Update receiving amounts in the participants table
self.update_taking(old_takes, new_takes, cursor, member)
# Update is_funded on member's tips
member.update_giving(cursor)

def update_taking(self, old_takes, new_takes, cursor=None, member=None):
"""Update `taking` amounts based on the difference between `old_takes`
and `new_takes`.
"""
for username in set(old_takes.keys()).union(new_takes.keys()):
if username == self.slug:
continue
old = old_takes.get(username, {}).get('actual_amount', Decimal(0))
new = new_takes.get(username, {}).get('actual_amount', Decimal(0))
diff = new - old
if diff != 0:
r = (cursor or self.db).one("""
UPDATE participants
SET taking = (taking + %(diff)s)
, receiving = (receiving + %(diff)s)
WHERE username=%(username)s
RETURNING taking, receiving
""", dict(username=username, diff=diff))
if member and username == member.username:
member.set_attributes(**r._asdict())

def get_current_takes(self, cursor=None):
"""Return a list of member takes for a team.
"""
TAKES = """
SELECT member, amount, ctime, mtime
FROM current_payroll
WHERE team=%(team)s
ORDER BY ctime DESC
"""
records = (cursor or self.db).all(TAKES, dict(team=self.slug))
return [r._asdict() for r in records]

def get_team_take(self, cursor=None):
"""Return a single take for a team, the team itself's take.
"""
TAKE = "SELECT sum(amount) FROM current_payroll WHERE team=%s"
total_take = (cursor or self.db).one(TAKE, (self.slug,), default=0)
team_take = max(self.receiving - total_take, 0)
membership = { "ctime": None
, "mtime": None
, "member": self.slug
, "amount": team_take
}
return membership

def compute_actual_takes(self, cursor=None):
"""Get the takes, compute the actual amounts, and return an OrderedDict.
"""
actual_takes = OrderedDict()
nominal_takes = self.get_current_takes(cursor=cursor)
nominal_takes.append(self.get_team_take(cursor=cursor))
budget = balance = self.receiving
for take in nominal_takes:
nominal_amount = take['nominal_amount'] = take.pop('amount')
actual_amount = take['actual_amount'] = min(nominal_amount, balance)
if take['member'] != self.slug:
balance -= actual_amount
take['balance'] = balance
take['percentage'] = (actual_amount / budget) if budget > 0 else 0
actual_takes[take['member']] = take
return actual_takes
8 changes: 4 additions & 4 deletions sql/payday.sql
Original file line number Diff line number Diff line change
Expand Up @@ -173,16 +173,16 @@ CREATE OR REPLACE FUNCTION process_take() RETURNS trigger AS $$
team_balance numeric(35,2);
BEGIN
team_balance := (
SELECT new_balance
FROM payday_participants
WHERE username = NEW.team
SELECT balance
FROM payday_teams
WHERE slug = NEW.team
);
IF (team_balance <= 0) THEN RETURN NULL; END IF;
actual_amount := NEW.amount;
IF (team_balance < NEW.amount) THEN
actual_amount := team_balance;
END IF;
EXECUTE transfer(NEW.team, NEW.member, actual_amount, 'take');
EXECUTE pay(NEW.member, NEW.team, actual_amount, 'to-participant');
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
Expand Down
Loading