diff --git a/gratipay/__init__.py b/gratipay/__init__.py index 1f182064f2..b2058a7f08 100644 --- a/gratipay/__init__.py +++ b/gratipay/__init__.py @@ -48,8 +48,8 @@ class NotSane(Exception): # gratipay.postgres.PostgresManager. -MAX_TIP = Decimal('1000.00') -MIN_TIP = Decimal('0.00') +MAX_TIP = MAX_PAYMENT = Decimal('1000.00') +MIN_TIP = MIN_PAYMENT = Decimal('0.00') RESTRICTED_IDS = None diff --git a/gratipay/billing/payday.py b/gratipay/billing/payday.py index f27a3db242..ab29ad2801 100644 --- a/gratipay/billing/payday.py +++ b/gratipay/billing/payday.py @@ -73,7 +73,7 @@ class Payday(object): payin prepare create_card_holds - process_subscriptions + process_payment_instructions transfer_takes process_draws settle_card_holds @@ -156,7 +156,7 @@ def payin(self): with self.db.get_cursor() as cursor: self.prepare(cursor, self.ts_start) holds = self.create_card_holds(cursor) - self.process_subscriptions(cursor) + self.process_payment_instructions(cursor) self.transfer_takes(cursor, self.ts_start) self.process_draws(cursor) payments = cursor.all(""" @@ -259,11 +259,12 @@ def f(p): @staticmethod - def process_subscriptions(cursor): - """Trigger the process_subscription function for each row in payday_subscriptions. + def process_payment_instructions(cursor): + """Trigger the process_payment_instructions function for each row in + payday_payment_instructions. """ - log("Processing subscriptions.") - cursor.run("UPDATE payday_subscriptions SET is_funded=true;") + log("Processing payment instructions.") + cursor.run("UPDATE payday_payment_instructions SET is_funded=true;") @staticmethod @@ -503,9 +504,9 @@ def notify_participants(self): WITH tippees AS ( SELECT t.slug, amount FROM ( SELECT DISTINCT ON (team) team, amount - FROM subscriptions + FROM payment_instructions WHERE mtime < %(ts_start)s - AND subscriber = %(username)s + AND participant = %(username)s ORDER BY team, mtime DESC ) s JOIN teams t ON s.team = t.slug diff --git a/gratipay/models/participant.py b/gratipay/models/participant.py index 720f7f8e57..d63463ece6 100644 --- a/gratipay/models/participant.py +++ b/gratipay/models/participant.py @@ -323,7 +323,7 @@ def close(self): """Close the participant's account. """ with self.db.get_cursor() as cursor: - self.clear_subscriptions(cursor) + self.clear_payment_instructions(cursor) self.clear_personal_information(cursor) self.final_check(cursor) self.update_is_closed(True, cursor) @@ -341,8 +341,8 @@ def update_is_closed(self, is_closed, cursor=None): self.set_attributes(is_closed=is_closed) - def clear_subscriptions(self, cursor): - """Zero out the participant's subscriptions. + def clear_payment_instructions(self, cursor): + """Zero out the participant's payment_instructions. """ teams = cursor.all(""" @@ -350,13 +350,13 @@ def clear_subscriptions(self, cursor): FROM teams WHERE slug=team ) AS team - FROM current_subscriptions - WHERE subscriber = %s + FROM current_payment_instructions + WHERE participant = %s AND amount > 0 """, (self.username,)) for team in teams: - self.set_subscription_to(team, '0.00', update_self=False, cursor=cursor) + self.set_payment_instruction(team, '0.00', update_self=False, cursor=cursor) def clear_takes(self, cursor): @@ -934,9 +934,9 @@ def update_giving(self, cursor=None): # Update is_funded on tips if self.get_credit_card_error() == '': updated = (cursor or self.db).all(""" - UPDATE current_subscriptions + UPDATE current_payment_instructions SET is_funded = true - WHERE subscriber = %s + WHERE participant = %s AND is_funded IS NOT true RETURNING * """, (self.username,)) @@ -945,9 +945,9 @@ def update_giving(self, cursor=None): UPDATE participants p SET giving = COALESCE(( SELECT sum(amount) - FROM current_subscriptions s + FROM current_payment_instructions s JOIN teams t ON t.slug=s.team - WHERE subscriber=%(username)s + WHERE participant=%(username)s AND amount > 0 AND is_funded AND t.is_approved @@ -1001,16 +1001,17 @@ def update_is_free_rider(self, is_free_rider, cursor=None): # New payday system - def set_subscription_to(self, team, amount, update_self=True, update_team=True, cursor=None): - """Given a Team or username, and amount as str, returns a dict. + def set_payment_instruction(self, team, amount, update_self=True, update_team=True, + cursor=None): + """Given a Team or slug, and amount as str, returns a dict. We INSERT instead of UPDATE, so that we have history to explore. The COALESCE function returns the first of its arguments that is not NULL. - The effect here is to stamp all tips with the timestamp of the first - tip from this user to that. I believe this is used to determine the - order of payments during payday. + The effect here is to stamp all payment instructions with the timestamp + of the first instruction from this ~user to that Team. I believe this + is used to determine the order of payments during payday. - The dict returned represents the row inserted in the subscriptions + The dict returned represents the row inserted in the payment_instructions table. """ @@ -1022,41 +1023,41 @@ def set_subscription_to(self, team, amount, update_self=True, update_team=True, raise NoTeam(slug) amount = Decimal(amount) # May raise InvalidOperation - if (amount < gratipay.MIN_TIP) or (amount > gratipay.MAX_TIP): + if (amount < gratipay.MIN_PAYMENT) or (amount > gratipay.MAX_PAYMENT): raise BadAmount - # Insert subscription - NEW_SUBSCRIPTION = """\ + # Insert payment instruction + NEW_PAYMENT_INSTRUCTION = """\ - INSERT INTO subscriptions - (ctime, subscriber, team, amount) + INSERT INTO payment_instructions + (ctime, participant, team, amount) VALUES ( COALESCE (( SELECT ctime - FROM subscriptions - WHERE (subscriber=%(subscriber)s AND team=%(team)s) + FROM payment_instructions + WHERE (participant=%(participant)s AND team=%(team)s) LIMIT 1 ), CURRENT_TIMESTAMP) - , %(subscriber)s, %(team)s, %(amount)s + , %(participant)s, %(team)s, %(amount)s ) RETURNING * """ - args = dict(subscriber=self.username, team=team.slug, amount=amount) - t = (cursor or self.db).one(NEW_SUBSCRIPTION, args) + args = dict(participant=self.username, team=team.slug, amount=amount) + t = (cursor or self.db).one(NEW_PAYMENT_INSTRUCTION, args) if update_self: - # Update giving amount of subscriber + # Update giving amount of participant self.update_giving(cursor) if update_team: # Update receiving amount of team team.update_receiving(cursor) if team.slug == 'Gratipay': - # Update whether the subscriber is using Gratipay for free + # Update whether the participant is using Gratipay for free self.update_is_free_rider(None if amount == 0 else False, cursor) return t._asdict() - def get_subscription_to(self, team): + def get_payment_instruction(self, team): """Given a slug, returns a dict. """ @@ -1069,8 +1070,8 @@ def get_subscription_to(self, team): return self.db.one("""\ SELECT * - FROM subscriptions - WHERE subscriber=%s + FROM payment_instructions + WHERE participant=%s AND team=%s ORDER BY mtime DESC LIMIT 1 @@ -1218,11 +1219,11 @@ def get_tip_distribution(self): return tip_amounts, npatrons, contributed - def get_subscriptions_for_profile(self): + def get_giving_for_profile(self): """Return a list and a Decimal. """ - SUBSCRIPTIONS = """\ + GIVING = """\ SELECT * FROM ( SELECT DISTINCT ON (s.team) @@ -1231,9 +1232,9 @@ def get_subscriptions_for_profile(self): , s.ctime , s.mtime , t.name as team_name - FROM subscriptions s + FROM payment_instructions s JOIN teams t ON s.team = t.slug - WHERE subscriber = %s + WHERE participant = %s AND t.is_approved is true AND t.is_closed is not true ORDER BY s.team @@ -1243,18 +1244,18 @@ def get_subscriptions_for_profile(self): , team_slug """ - subscriptions = self.db.all(SUBSCRIPTIONS, (self.username,)) + giving = self.db.all(GIVING, (self.username,)) # Compute the total. # ================== - total = sum([s.amount for s in subscriptions]) + total = sum([rec.amount for rec in giving]) if not total: # If tips is an empty list, total is int 0. We want a Decimal. total = Decimal('0.00') - return subscriptions, total + return giving, total def get_current_tips(self): """Get the tips this participant is currently sending to others. diff --git a/gratipay/models/team.py b/gratipay/models/team.py index 0c94b5dbba..008754a03e 100644 --- a/gratipay/models/team.py +++ b/gratipay/models/team.py @@ -87,23 +87,23 @@ def status(self): }[self.is_approved] def migrate_tips(self): - subscriptions = self.db.all(""" - SELECT s.* - FROM subscriptions s - JOIN teams t ON t.slug = s.team + payment_instructions = self.db.all(""" + SELECT pi.* + FROM payment_instructions pi + JOIN teams t ON t.slug = pi.team JOIN participants p ON t.owner = p.username WHERE p.username = %s - AND s.ctime < t.ctime + AND pi.ctime < t.ctime """, (self.owner, )) # Make sure the migration hasn't been done already - if subscriptions: + if payment_instructions: raise AlreadyMigrated self.db.run(""" - INSERT INTO subscriptions - (ctime, mtime, subscriber, team, amount, is_funded) + INSERT INTO payment_instructions + (ctime, mtime, participant, team, amount, is_funded) SELECT ct.ctime , ct.mtime , ct.tipper diff --git a/gratipay/utils/fake_data.py b/gratipay/utils/fake_data.py index 87204b53c1..9321318ba7 100644 --- a/gratipay/utils/fake_data.py +++ b/gratipay/utils/fake_data.py @@ -81,7 +81,7 @@ def fake_team(db, teamowner): productorservice = ['Product','Service'] teamname = faker.first_name() + fake_text_id(3) - teamslugname = faker.city() + teamslugname = faker.city() try: #using community.slugize @@ -105,15 +105,15 @@ def fake_team(db, teamowner): return Team.from_slug(teamslug) -def fake_subscription(db, subscriber, subscribee): - """Create a fake subscription +def fake_payment_instruction(db, participant, team): + """Create a fake payment_instruction """ return _fake_thing( db - , "subscriptions" + , "payment_instructions" , ctime=faker.date_time_this_year() , mtime=faker.date_time_this_month() - , subscriber=subscriber.username - , team=subscribee.slug + , participant=participant.username + , team=team.slug , amount=fake_tip_amount() ) @@ -272,7 +272,7 @@ def clean_db(db): """) -def populate_db(db, num_participants=100, num_tips=200, num_teams=5, num_transfers=5000, num_communities=20): +def populate_db(db, num_participants=100, ntips=200, num_teams=5, num_transfers=5000, num_communities=20): """Populate DB with fake data. """ print("Making Participants") @@ -286,19 +286,19 @@ def populate_db(db, num_participants=100, num_tips=200, num_teams=5, num_transfe for teamowner in teamowners: teams.append(fake_team(db, teamowner)) - print("Making Subscriptions") - subscriptioncount = 0 + print("Making Payment Instructions") + npayment_instructions = 0 for participant in participants: for team in teams: - #eliminate self-subscription + #eliminate self-payment if participant.username != team.owner: - subscriptioncount += 1 - if subscriptioncount > num_tips: + npayment_instructions += 1 + if npayment_instructions > ntips: break - fake_subscription(db, participant, team) - if subscriptioncount > num_tips: + fake_payment_instruction(db, participant, team) + if npayment_instructions > ntips: break - + print("Making Elsewheres") for p in participants: @@ -318,7 +318,7 @@ def populate_db(db, num_participants=100, num_tips=200, num_teams=5, num_transfe print("Making Tips") tips = [] - for i in xrange(num_tips): + for i in xrange(ntips): tipper, tippee = random.sample(participants, 2) tips.append(fake_tip(db, tipper, tippee)) diff --git a/js/gratipay/giving.js b/js/gratipay/giving.js new file mode 100644 index 0000000000..93db2ccd2e --- /dev/null +++ b/js/gratipay/giving.js @@ -0,0 +1,29 @@ +Gratipay.giving = {} + +Gratipay.giving.init = function() { + Gratipay.giving.activateTab('active'); + $('.giving #tab-nav a').on('click', Gratipay.giving.handleClick); +} + +Gratipay.giving.handleClick = function(e) { + e.preventDefault(); + var $target = $(e.target); + Gratipay.giving.activateTab($target.data('tab')); +} + +Gratipay.giving.activateTab = function(tab) { + $.each($('.giving #tab-nav a'), function(i, obj) { + var $obj = $(obj); + if ($obj.data('tab') == tab) { + $obj.addClass('selected'); + } else { + $obj.removeClass('selected'); + } + }) + + $.each($('.giving .tab'), function(i, obj) { + var $obj = $(obj); + if ($obj.data('tab') == tab) { $obj.show(); } else { $obj.hide(); } + }) +} + diff --git a/js/gratipay/payments.js b/js/gratipay/payments.js index b8cf3e5a5c..b4ed5e2fbb 100644 --- a/js/gratipay/payments.js +++ b/js/gratipay/payments.js @@ -78,7 +78,7 @@ Gratipay.payments.afterTipChange = function(data) { Gratipay.payments.set = function(team, amount, callback) { // send request to set up a recurring payment - $.post('/' + team + '/subscription.json', { amount: amount }, function(data) { + $.post('/' + team + '/payment-instruction.json', { amount: amount }, function(data) { if (callback) callback(data); Gratipay.payments.afterTipChange(data); }) diff --git a/js/gratipay/subscriptions.js b/js/gratipay/subscriptions.js deleted file mode 100644 index 2b34562631..0000000000 --- a/js/gratipay/subscriptions.js +++ /dev/null @@ -1,29 +0,0 @@ -Gratipay.subscriptions = {} - -Gratipay.subscriptions.init = function() { - Gratipay.subscriptions.activateTab('active'); - $('.subscriptions #tab-nav a').on('click', Gratipay.subscriptions.handleClick); -} - -Gratipay.subscriptions.handleClick = function(e) { - e.preventDefault(); - var $target = $(e.target); - Gratipay.subscriptions.activateTab($target.data('tab')); -} - -Gratipay.subscriptions.activateTab = function(tab) { - $.each($('.subscriptions #tab-nav a'), function(i, obj) { - var $obj = $(obj); - if ($obj.data('tab') == tab) { - $obj.addClass('selected'); - } else { - $obj.removeClass('selected'); - } - }) - - $.each($('.subscriptions .tab'), function(i, obj) { - var $obj = $(obj); - if ($obj.data('tab') == tab) { $obj.show(); } else { $obj.hide(); } - }) -} - diff --git a/payday.py b/payday.py deleted file mode 100644 index d961aedc7e..0000000000 --- a/payday.py +++ /dev/null @@ -1,103 +0,0 @@ -#!/usr/bin/env python -"""Sandbox for exploring the new Payday algorithm. -""" -from __future__ import print_function, unicode_literals - -from collections import defaultdict - - -# Classes - -class _Thing(object): - def __init__(self, name): - self.name = name - self.values = list() - def __setitem__(self, k, v): - self.values.append((k,v)) - def __repr__(self): - return(self.name) - def __str__(self): - return '\n'.join(['{} {}'.format(repr(k),v) for k,v in self.values]) - -class Participant(_Thing): - pass - -class Team(_Thing): - owner = None - - -# Universe - -a, b, c, d, e = [Participant(x) for x in 'abcde'] -A, B, C, D, E = [Team(x) for x in 'ABCDE'] - - -# subscriptions - -a[A] = 1 -a[B] = 1 -a[C] = 1 -a[E] = 1 - -b - -c[A] = 1 -c[C] = 1 -c[E] = 1 - -d[D] = 1 - -e[D] = 1 - - -# payroll - -A[b] = 1 -A[c] = 1 -A.owner = c - -B.owner = c - -C.owner = a - -D.owner = d - -E[c] = 1 -E.owner = e - - -def payday(participants, teams): - """Given a list of participants and a list of teams, return a list. - - The list we return contains instructions for funds transfer, both card - captures (positive) and bank deposits (negative). - - """ - t_balances = defaultdict(int) - p_balances = defaultdict(int) - p_holding = defaultdict(int) - - # Transfer Tips - for p in participants: - for t, amount in p.values: - t_balances[t] += amount - p_holding[p] += amount - - # Transfer Takes - for t in teams: - for p, amount in t.values: - t_balances[t] -= amount - p_balances[p] += amount - - # Drain balance to owner - for t in teams: - p_balances[t.owner] += t_balances[t] - t_balances[t] -= t_balances[t] - - assert sum(t_balances.values()) == 0 - - return [(p, p_balances[p] - p_holding[p]) for p in participants] - - -for participant, instruction in payday([a,b,c,d,e], [A,B,C,D,E]): - print("{} {:2}".format(participant.name, instruction)) diff --git a/scss/gratipay.scss b/scss/gratipay.scss index c2e4c5fe69..8caec97ae2 100644 --- a/scss/gratipay.scss +++ b/scss/gratipay.scss @@ -57,7 +57,7 @@ @import "pages/history"; @import "pages/team"; @import "pages/profile-edit"; -@import "pages/subscriptions"; +@import "pages/giving"; @import "pages/settings"; @import "pages/cc-ba"; @import "pages/on-confirm"; diff --git a/scss/pages/subscriptions.scss b/scss/pages/giving.scss similarity index 77% rename from scss/pages/subscriptions.scss rename to scss/pages/giving.scss index 3508be0c6a..2c66bb7328 100644 --- a/scss/pages/subscriptions.scss +++ b/scss/pages/giving.scss @@ -1,4 +1,4 @@ -.subscriptions { +.giving { .note { font: italic 12px/14px $Ideal; } diff --git a/sql/branch.sql b/sql/branch.sql new file mode 100644 index 0000000000..f85bde811d --- /dev/null +++ b/sql/branch.sql @@ -0,0 +1,37 @@ +BEGIN; + + -- https://github.com/gratipay/inside.gratipay.com/issues/117 + -- payment_instructions - A user instructs Gratipay to make voluntary payments to a Team. + ALTER TABLE subscriptions RENAME COLUMN subscriber TO participant; + ALTER TABLE subscriptions RENAME CONSTRAINT subscriptions_subscriber_fkey + TO payment_instructions_participant_fkey; + ALTER TABLE subscriptions RENAME CONSTRAINT subscriptions_team_fkey + TO payment_instructions_team_fkey; + ALTER TABLE subscriptions RENAME TO payment_instructions; + ALTER INDEX subscriptions_pkey RENAME TO payment_instructions_pkey; + ALTER INDEX subscriptions_all RENAME TO payment_instructions_all; + ALTER SEQUENCE subscriptions_id_seq RENAME TO payment_instructions_id_seq; + + DROP TRIGGER update_current_subscription ON current_subscriptions; + DROP VIEW current_subscriptions; + CREATE VIEW current_payment_instructions AS + SELECT DISTINCT ON (participant, team) * + FROM payment_instructions + ORDER BY participant, team, mtime DESC; + + -- Allow updating is_funded via the current_payment_instructions view for convenience + DROP FUNCTION update_subscription(); + CREATE FUNCTION update_payment_instruction() RETURNS trigger AS $$ + BEGIN + UPDATE payment_instructions + SET is_funded = NEW.is_funded + WHERE id = NEW.id; + RETURN NULL; + END; + $$ LANGUAGE plpgsql; + + CREATE TRIGGER update_current_payment_instruction + INSTEAD OF UPDATE ON current_payment_instructions + FOR EACH ROW EXECUTE PROCEDURE update_payment_instruction(); + +END; diff --git a/sql/payday.sql b/sql/payday.sql index 5c89a6de33..a3caa3d438 100644 --- a/sql/payday.sql +++ b/sql/payday.sql @@ -53,35 +53,35 @@ CREATE TABLE payday_payments_done AS FROM payments p WHERE p.timestamp > %(ts_start)s; -DROP TABLE IF EXISTS payday_subscriptions; -CREATE TABLE payday_subscriptions AS - SELECT subscriber, team, amount - FROM ( SELECT DISTINCT ON (subscriber, team) * - FROM subscriptions +DROP TABLE IF EXISTS payday_payment_instructions; +CREATE TABLE payday_payment_instructions AS + SELECT participant, team, amount + FROM ( SELECT DISTINCT ON (participant, team) * + FROM payment_instructions WHERE mtime < %(ts_start)s - ORDER BY subscriber, team, mtime DESC + ORDER BY participant, team, mtime DESC ) s - JOIN payday_participants p ON p.username = s.subscriber + JOIN payday_participants p ON p.username = s.participant JOIN payday_teams t ON t.slug = s.team WHERE s.amount > 0 AND ( SELECT id FROM payday_payments_done done - WHERE s.subscriber = done.participant + WHERE s.participant = done.participant AND s.team = done.team AND direction = 'to-team' ) IS NULL ORDER BY p.claimed_time ASC, s.ctime ASC; -CREATE INDEX ON payday_subscriptions (subscriber); -CREATE INDEX ON payday_subscriptions (team); -ALTER TABLE payday_subscriptions ADD COLUMN is_funded boolean; +CREATE INDEX ON payday_payment_instructions (participant); +CREATE INDEX ON payday_payment_instructions (team); +ALTER TABLE payday_payment_instructions ADD COLUMN is_funded boolean; ALTER TABLE payday_participants ADD COLUMN giving_today numeric(35,2); UPDATE payday_participants SET giving_today = COALESCE(( SELECT sum(amount) - FROM payday_subscriptions - WHERE subscriber = username + FROM payday_payment_instructions + WHERE participant = username ), 0); DROP TABLE IF EXISTS payday_takes; @@ -142,29 +142,29 @@ RETURNS void AS $$ $$ LANGUAGE plpgsql; --- Create a trigger to process subscriptions +-- Create a trigger to process payment_instructions -CREATE OR REPLACE FUNCTION process_subscription() RETURNS trigger AS $$ +CREATE OR REPLACE FUNCTION process_payment_instruction() RETURNS trigger AS $$ DECLARE - subscriber payday_participants; + participant payday_participants; BEGIN - subscriber := ( + participant := ( SELECT p.*::payday_participants FROM payday_participants p - WHERE username = NEW.subscriber + WHERE username = NEW.participant ); - IF (NEW.amount <= subscriber.new_balance OR subscriber.card_hold_ok) THEN - EXECUTE pay(NEW.subscriber, NEW.team, NEW.amount, 'to-team'); + IF (NEW.amount <= participant.new_balance OR participant.card_hold_ok) THEN + EXECUTE pay(NEW.participant, NEW.team, NEW.amount, 'to-team'); RETURN NEW; END IF; RETURN NULL; END; $$ LANGUAGE plpgsql; -CREATE TRIGGER process_subscription BEFORE UPDATE OF is_funded ON payday_subscriptions +CREATE TRIGGER process_payment_instruction BEFORE UPDATE OF is_funded ON payday_payment_instructions FOR EACH ROW WHEN (NEW.is_funded IS true AND OLD.is_funded IS NOT true) - EXECUTE PROCEDURE process_subscription(); + EXECUTE PROCEDURE process_payment_instruction(); -- Create a trigger to process takes diff --git a/templates/subscriptions-table.html b/templates/giving-table.html similarity index 68% rename from templates/subscriptions-table.html rename to templates/giving-table.html index 7f966f88fb..5e50f8d682 100644 --- a/templates/subscriptions-table.html +++ b/templates/giving-table.html @@ -1,4 +1,4 @@ -{% macro subscriptions_table(state, subscriptions, total) %} +{% macro giving_table(state, giving, total) %}
- {{ subscription.team_name }} + {{ payment_instruction.team_name }} | {% if state != 'cancelled' %} -{{ subscription.amount }} | +{{ payment_instruction.amount }} | {% endif %} -{{ to_age(subscription.mtime) }} | -{{ to_age(subscription.ctime) }} | +{{ to_age(payment_instruction.mtime) }} | +{{ to_age(payment_instruction.ctime) }} |