Repository: liberapay/liberapay.com

Issue:
Teams are limited to a single currency
This is a known shortcoming but we didn't have an issue for it.


Discussion:

Resolving patch:
diff --git a/liberapay/models/_mixin_team.py b/liberapay/models/_mixin_team.py
--- a/liberapay/models/_mixin_team.py
+++ b/liberapay/models/_mixin_team.py
@@ -1,3 +1,5 @@
+# coding: utf8
+
 """Teams are groups of participants.
 """
 from __future__ import division, print_function, unicode_literals
@@ -37,7 +39,7 @@ def add_member(self, member, cursor=None):
             raise MemberLimitReached
         if member.status != 'active':
             raise InactiveParticipantAdded
-        self.set_take_for(member, ZERO[self.main_currency], self, cursor=cursor)
+        self.set_take_for(member, ZERO[member.main_currency], self, cursor=cursor)
 
     def remove_all_members(self, cursor=None):
         (cursor or self.db).run("""
@@ -62,7 +64,7 @@ def get_takes_last_week(self):
         """Get the users' nominal takes last week. Used in throttling.
         """
         assert self.kind == 'group'
-        takes = {t.member: t.amount for t in self.db.all("""
+        takes = OrderedDict((t.member, t.amount) for t in self.db.all("""
 
             SELECT DISTINCT ON (member) member, amount, mtime
               FROM takes
@@ -75,7 +77,9 @@ def get_takes_last_week(self):
                    )
           ORDER BY member, mtime DESC
 
-        """, (self.id,)) if t.amount}
+        """, (self.id,)) if t.amount)
+        takes.sum = MoneyBasket(takes.values())
+        takes.initial_leftover = self.get_exact_receiving() - takes.sum
         return takes
 
     def get_take_for(self, member):
@@ -86,25 +90,17 @@ def get_take_for(self, member):
             (member.id, self.id)
         )
 
-    def compute_max_this_week(self, member_id, last_week):
+    def compute_max_this_week(self, member_id, last_week, currency):
         """2x the member's take last week, or the member's take last week + the
-        leftover, or last week's median take, or 1.00, or infinity if the takes
-        were all zero last week or if throttling is disabled.
+        leftover, or last week's median take, or one currency unit (e.g. €1.00).
         """
-        if not self.throttle_takes:
-            return
-        sum_last_week = sum(last_week.values())
-        if sum_last_week == 0:
-            return
-        sum_last_week = sum_last_week.convert(self.main_currency)
-        initial_leftover = self.receiving - sum_last_week
-        nonzero_last_week = [a.convert(self.main_currency).amount for a in last_week.values() if a]
-        member_last_week = last_week.get(member_id, ZERO[self.main_currency]).convert(self.main_currency)
+        nonzero_last_week = [a.convert(currency).amount for a in last_week.values() if a]
+        member_last_week = last_week.get(member_id, ZERO[currency]).convert(currency)
         return max(
             member_last_week * 2,
-            member_last_week + initial_leftover,
-            Money(median(nonzero_last_week or (0,)), self.main_currency),
-            TAKE_THROTTLING_THRESHOLD[self.main_currency]
+            member_last_week + last_week.initial_leftover.fuzzy_sum(currency),
+            Money(median(nonzero_last_week or (0,)), currency),
+            TAKE_THROTTLING_THRESHOLD[currency]
         )
 
     def set_take_for(self, member, take, recorder, check_max=True, cursor=None):
@@ -123,12 +119,15 @@ def set_take_for(self, member, take, recorder, check_max=True, cursor=None):
             # Lock to avoid race conditions
             cursor.run("LOCK TABLE takes IN EXCLUSIVE MODE")
             # Throttle the new take, if there is more than one member
-            threshold = TAKE_THROTTLING_THRESHOLD[self.main_currency]
-            if take and check_max and self.nmembers > 1 and take > threshold:
-                last_week = self.get_takes_last_week()
-                max_this_week = self.compute_max_this_week(member.id, last_week)
-                if max_this_week is not None and take > max_this_week:
-                    take = max_this_week
+            if take and check_max and self.throttle_takes and self.nmembers > 1:
+                if take > TAKE_THROTTLING_THRESHOLD[take.currency]:
+                    last_week = self.get_takes_last_week()
+                    if last_week.sum:
+                        max_this_week = self.compute_max_this_week(
+                            member.id, last_week, take.currency
+                        )
+                        if take > max_this_week:
+                            take = max_this_week
             # Insert the new take
             cursor.run("""
 
@@ -278,7 +277,9 @@ def get_members(self):
         """Return an OrderedDict of member dicts.
         """
         takes = self.get_current_takes()
+        nmembers = len(takes)
         last_week = self.get_takes_last_week()
+        compute_max = self.throttle_takes and nmembers > 1 and last_week.sum
         members = OrderedDict()
         members.leftover = self.leftover
         zero = ZERO[self.main_currency]
@@ -286,10 +287,13 @@ def get_members(self):
             member = {}
             m_id = member['id'] = take['member_id']
             member['username'] = take['member_name']
-            member['nominal_take'] = take['amount'].amount
+            member['nominal_take'] = take['amount']
             member['actual_amount'] = take['actual_amount']
-            member['last_week'] = last_week.get(m_id, zero).amount
-            x = self.compute_max_this_week(m_id, last_week)
+            member['last_week'] = last_week.get(m_id, zero)
+            if compute_max:
+                x = self.compute_max_this_week(m_id, last_week, take['amount'].currency)
+            else:
+                x = None
             member['max_this_week'] = x
             members[member['id']] = member
         return members
diff --git a/liberapay/models/participant.py b/liberapay/models/participant.py
--- a/liberapay/models/participant.py
+++ b/liberapay/models/participant.py
@@ -1428,8 +1428,6 @@ def change_main_currency(self, new_currency, recorder):
         with self.db.get_cursor() as cursor:
             if not recorder.is_admin:
                 cursor.hit_rate_limit('change_currency', self.id, TooManyCurrencyChanges)
-            if self.kind == 'group':
-                cursor.run("LOCK TABLE takes IN EXCLUSIVE MODE")
             r = cursor.one("""
                 UPDATE participants
                    SET main_currency = %(new_currency)s
@@ -1445,25 +1443,6 @@ def change_main_currency(self, new_currency, recorder):
             if not r:
                 return
             self.set_attributes(main_currency=new_currency)
-            if self.kind == 'group':
-                members = cursor.all("""
-                    INSERT INTO takes
-                                (ctime, member, team, amount, actual_amount, recorder)
-                         SELECT t.ctime, t.member, t.team
-                              , convert(amount, %(new_currency)s)
-                              , zero(%(new_currency)s::currency)
-                              , %(recorder_id)s
-                           FROM current_takes t
-                          WHERE t.team = %(p_id)s
-                      RETURNING (SELECT p FROM participants p WHERE p.id = takes.member)
-                """, locals())
-                self.recompute_actual_takes(cursor)
-                for m in members:
-                    m.notify(
-                        'team_currency_change', email=False, web=True,
-                        team_name=self.username, old_currency=old_currency,
-                        new_currency=new_currency, changed_by=recorder.username,
-                    )
             self.add_event(cursor, 'change_main_currency', dict(
                 new_currency=new_currency, old_currency=old_currency
             ), recorder=recorder_id)
@@ -1680,6 +1659,14 @@ def get_receiving_in(self, currency, cursor=None):
         """.format(currency), (self.id,)) or D_ZERO, currency)
         return r
 
+    def get_exact_receiving(self):
+        return self.db.one("""
+            SELECT basket_sum(t.amount)
+              FROM current_tips t
+             WHERE t.tippee = %s
+               AND t.is_funded
+        """, (self.id,))
+
     def update_giving_and_tippees(self, cursor):
         updated_tips = self.update_giving(cursor)
         for tip in updated_tips:


Test patch:
diff --git a/tests/py/test_payday.py b/tests/py/test_payday.py
--- a/tests/py/test_payday.py
+++ b/tests/py/test_payday.py
@@ -704,6 +704,26 @@ def test_transfer_takes_with_two_currencies(self):
         actual = self.get_balances()
         assert expected == actual
 
+    def test_transfer_takes_with_two_currencies_on_both_sides(self):
+        self.set_up_team_with_two_currencies()
+        self.team.set_take_for(self.alice, USD('0.01'), self.alice)
+        self.team.set_take_for(self.bob, EUR('0.01'), self.bob)
+        self.donor1_eur.set_tip_to(self.team, EUR('0.01'))
+        self.donor2_usd.set_tip_to(self.team, USD('0.01'))
+
+        Payday.start().shuffle()
+
+        expected = {
+            'alice': MoneyBasket(EUR('0.01')),
+            'bob': MoneyBasket(USD('0.01')),
+            'donor1_eur': MoneyBasket(EUR('99.99')),
+            'donor2_usd': MoneyBasket(USD('99.99')),
+            'donor3_eur': MoneyBasket(EUR('100')),
+            'donor4_usd': MoneyBasket(USD('100')),
+        }
+        actual = self.get_balances()
+        assert expected == actual
+
     def test_wellfunded_team_with_two_balanced_currencies(self):
         self.set_up_team_with_two_currencies()
         self.donor1_eur.set_tip_to(self.team, EUR('1.00'))
diff --git a/tests/py/test_take.py b/tests/py/test_take.py
--- a/tests/py/test_take.py
+++ b/tests/py/test_take.py
@@ -3,7 +3,7 @@
 from psycopg2 import InternalError
 
 from liberapay.billing.payday import Payday
-from liberapay.testing import EUR, Harness
+from liberapay.testing import EUR, USD, Harness
 from liberapay.models.participant import Participant
 from liberapay.utils.currencies import MoneyBasket
 
@@ -117,7 +117,7 @@ def test_get_members(self):
         members = team.get_members()
         assert len(members) == 1
         assert members[alice.id]['username'] == 'alice'
-        assert members[alice.id]['nominal_take'] == 42
+        assert members[alice.id]['nominal_take'] == EUR(42)
         assert members[alice.id]['actual_amount'] == MoneyBasket(EUR(42))
 
     def test_taking_and_receiving_are_updated_correctly(self):
@@ -188,6 +188,13 @@ def test_changes_to_others_take_can_increase_members_take(self):
         alice = Participant.from_username('alice')
         assert alice.receiving == alice.taking == 25
 
+    def test_can_change_take_currency(self):
+        team, alice, bob = self.make_team_of_two()
+        self.take_last_week(team, alice, EUR('30.00'))
+        team.set_take_for(alice, USD('20.00'), alice)
+        self.take_last_week(team, bob, USD('42.00'))
+        team.set_take_for(bob, EUR('70.00'), bob)
+
     # get_takes_last_week - gtlw
 
     def test_gtlwf_works_during_payday(self):