diff --git a/gratipay/exceptions.py b/gratipay/exceptions.py index 9d73afb151..e84b2f7480 100644 --- a/gratipay/exceptions.py +++ b/gratipay/exceptions.py @@ -52,6 +52,7 @@ def __str__(self): class TooGreedy(Exception): pass class NoSelfTipping(Exception): pass class NoTippee(Exception): pass +class NoTeam(Exception): pass class BadAmount(Exception): pass class FailedToReserveUsername(Exception): pass diff --git a/gratipay/models/participant.py b/gratipay/models/participant.py index d82beadd63..6b1a272cc4 100644 --- a/gratipay/models/participant.py +++ b/gratipay/models/participant.py @@ -35,6 +35,7 @@ UsernameAlreadyTaken, NoSelfTipping, NoTippee, + NoTeam, BadAmount, EmailAlreadyTaken, CannotRemovePrimaryEmail, @@ -46,6 +47,7 @@ from gratipay.models._mixin_team import MixinTeam from gratipay.models.account_elsewhere import AccountElsewhere from gratipay.models.exchange_route import ExchangeRoute +from gratipay.models.team import Team from gratipay.security.crypto import constant_time_compare from gratipay.utils import i18n, is_card_expiring, emails, notifications, pricing from gratipay.utils.username import safely_reserve_a_username @@ -1108,6 +1110,87 @@ def update_is_free_rider(self, is_free_rider, cursor=None): self.set_attributes(is_free_rider=is_free_rider) + # 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. + + 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 dict returned represents the row inserted in the subscriptions + table. + + """ + assert self.is_claimed # sanity check + + if not isinstance(team, Team): + team, slug = Team.from_slug(team), team + if not team: + raise NoTeam(slug) + + amount = Decimal(amount) # May raise InvalidOperation + if (amount < gratipay.MIN_TIP) or (amount > gratipay.MAX_TIP): + raise BadAmount + + # Insert subscription + NEW_SUBSCRIPTION = """\ + + INSERT INTO subscriptions + (ctime, subscriber, team, amount) + VALUES ( COALESCE (( SELECT ctime + FROM subscriptions + WHERE (subscriber=%(subscriber)s AND team=%(team)s) + LIMIT 1 + ), CURRENT_TIMESTAMP) + , %(subscriber)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) + + if update_self: + # Update giving amount of subscriber + 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 + self.update_is_free_rider(None if amount == 0 else False, cursor) + + return t._asdict() + + + def get_subscription_to(self, team): + """Given a slug, returns a dict. + """ + + if not isinstance(team, Team): + team, slug = Team.from_slug(team), team + if not slug: + raise NoTeam(slug) + + default = dict(amount=Decimal('0.00'), is_funded=False) + return self.db.one("""\ + + SELECT * + FROM subscriptions + WHERE subscriber=%s + AND team=%s + ORDER BY mtime DESC + LIMIT 1 + + """, (self.username, team.slug), back_as=dict, default=default) + + + # Old payday system, deprecated and going away ... + def set_tip_to(self, tippee, amount, update_self=True, update_tippee=True, cursor=None): """Given a Participant or username, and amount as str, returns a dict. diff --git a/gratipay/models/team.py b/gratipay/models/team.py index 63b7b40aa0..6d891f4d6c 100644 --- a/gratipay/models/team.py +++ b/gratipay/models/team.py @@ -50,3 +50,7 @@ def create_new(cls, owner, fields): fields['product_or_service'], fields['getting_involved'], fields['getting_paid'], owner.username)) + + def update_receiving(self, cursor=None): + # Stubbed out for now. Migrate this over from Participant. + pass diff --git a/tests/py/test_participant.py b/tests/py/test_participant.py index b013439480..166c96e662 100644 --- a/tests/py/test_participant.py +++ b/tests/py/test_participant.py @@ -17,6 +17,7 @@ UsernameIsRestricted, NoSelfTipping, NoTippee, + NoTeam, BadAmount, ) from gratipay.models.account_elsewhere import AccountElsewhere @@ -449,6 +450,68 @@ def test_can_go_plural(self): bob.update_number('plural') assert Participant.from_username('bob').number == 'plural' + + # set_subscription_to - sst + + def test_sst_sets_subscription_to(self): + alice = self.make_participant('alice', claimed_time='now', last_bill_result='') + team = self.make_team() + alice.set_subscription_to(team, '1.00') + + actual = alice.get_subscription_to(team)['amount'] + assert actual == Decimal('1.00') + + def test_sst_returns_a_dict(self): + alice = self.make_participant('alice', claimed_time='now', last_bill_result='') + team = self.make_team() + actual = alice.set_subscription_to(team, '1.00') + assert isinstance(actual, dict) + assert isinstance(actual['amount'], Decimal) + assert actual['amount'] == 1 + + def test_sst_allows_up_to_a_thousand(self): + alice = self.make_participant('alice', claimed_time='now', last_bill_result='') + team = self.make_team() + alice.set_subscription_to(team, '1000.00') + + def test_sst_doesnt_allow_a_penny_more(self): + alice = self.make_participant('alice', claimed_time='now', last_bill_result='') + team = self.make_team() + self.assertRaises(BadAmount, alice.set_subscription_to, team, '1000.01') + + def test_sst_allows_a_zero_subscription(self): + alice = self.make_participant('alice', claimed_time='now', last_bill_result='') + team = self.make_team() + alice.set_subscription_to(team, '0.00') + + def test_sst_doesnt_allow_a_penny_less(self): + alice = self.make_participant('alice', claimed_time='now', last_bill_result='') + team = self.make_team() + self.assertRaises(BadAmount, alice.set_subscription_to, team, '-0.01') + + def test_sst_fails_to_subscribe_to_an_unknown_team(self): + alice = self.make_participant('alice', claimed_time='now', last_bill_result='') + self.assertRaises(NoTeam, alice.set_subscription_to, 'The B Team', '1.00') + + def test_sst_is_free_rider_defaults_to_none(self): + alice = self.make_participant('alice', claimed_time='now', last_bill_result='') + assert alice.is_free_rider is None + + def test_sst_sets_is_free_rider_to_false(self): + alice = self.make_participant('alice', claimed_time='now', last_bill_result='') + gratipay = self.make_team('Gratipay', owner=self.make_participant('Gratipay').username) + alice.set_subscription_to(gratipay, '0.01') + assert alice.is_free_rider is False + assert Participant.from_username('alice').is_free_rider is False + + def test_sst_resets_is_free_rider_to_null(self): + alice = self.make_participant('alice', claimed_time='now', last_bill_result='') + gratipay = self.make_team('Gratipay', owner=self.make_participant('Gratipay').username) + alice.set_subscription_to(gratipay, '0.00') + assert alice.is_free_rider is None + assert Participant.from_username('alice').is_free_rider is None + + # set_tip_to - stt def test_stt_sets_tip_to(self):