Example #1
0
 def test_MoneyBasket_comparisons(self):
     b = MoneyBasket()
     assert b == 0
     b = MoneyBasket(USD=1)
     assert b > 0
     b = MoneyBasket()
     b2 = MoneyBasket(EUR=1, USD=1)
     assert not (b >= b2)
Example #2
0
 def test_MoneyBasket_currencies_present(self):
     b = MoneyBasket()
     assert b.currencies_present == []
     b = MoneyBasket(USD=1)
     assert b.currencies_present == ['USD']
     b = MoneyBasket(EUR=0, USD=1)
     assert b.currencies_present == ['USD']
     b = MoneyBasket(EUR=-1, USD=1)
     assert b.currencies_present == ['USD']
     b = MoneyBasket(EUR=10, USD=1)
     assert b.currencies_present == ['EUR', 'USD']
Example #3
0
 def test_get_members(self):
     team, alice = self.make_team_of_one()
     self.take_last_week(team, alice, EUR('40.00'))
     team.set_take_for(alice, EUR('42.00'), team)
     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]['actual_amount'] == MoneyBasket(EUR(42))
Example #4
0
    def get_takes_last_week(self):
        """Get the users' nominal takes last week. Used in throttling.
        """
        assert self.kind == 'group'
        takes = OrderedDict((t.member, t.amount) for t in self.db.all("""

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

        """, (self.id,)) if t.amount)
        takes.sum = MoneyBasket(takes.values())
        takes.initial_leftover = self.get_exact_receiving() - takes.sum
        return takes
Example #5
0
    def test_underfunded_team_with_two_unbalanced_currencies(self):
        self.set_up_team_with_two_currencies()
        self.donor1_eur.set_tip_to(self.team, EUR('0.10'))
        self.donor2_usd.set_tip_to(self.team, USD('0.25'))
        self.donor3_eur.set_tip_to(self.team, EUR('0.10'))
        self.donor4_usd.set_tip_to(self.team, USD('0.25'))

        Payday.start().shuffle()

        expected = {
            'alice': MoneyBasket(EUR('0.20'), USD('0.13')),
            'bob': MoneyBasket(USD('0.37')),
            'donor1_eur': MoneyBasket(EUR('99.90')),
            'donor2_usd': MoneyBasket(USD('99.75')),
            'donor3_eur': MoneyBasket(EUR('99.90')),
            'donor4_usd': MoneyBasket(USD('99.75')),
        }
        actual = self.get_balances()
        assert expected == actual
Example #6
0
    def test_transfer_takes_with_two_currencies(self):
        self.set_up_team_with_two_currencies()
        self.donor1_eur.set_tip_to(self.team, EUR('0.50'))
        self.donor2_usd.set_tip_to(self.team, USD('0.60'))
        self.donor3_eur.set_tip_to(self.team, EUR('0.50'))
        self.donor4_usd.set_tip_to(self.team, USD('0.60'))

        Payday.start().shuffle()

        expected = {
            'alice': MoneyBasket(EUR('1.00')),
            'bob': MoneyBasket(USD('1.20')),
            'donor1_eur': MoneyBasket(EUR('99.50')),
            'donor2_usd': MoneyBasket(USD('99.40')),
            'donor3_eur': MoneyBasket(EUR('99.50')),
            'donor4_usd': MoneyBasket(USD('99.40')),
        }
        actual = self.get_balances()
        assert expected == actual
Example #7
0
    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
Example #8
0
 def cast_currency_basket(v, cursor):
     if v is None:
         return None
     eur, usd = v[1:-1].split(',')
     return MoneyBasket(EUR=Decimal(eur), USD=Decimal(usd))
Example #9
0
 def cast_currency_basket(v, cursor):
     if v is None:
         return None
     eur, usd = v[1:-1].split(',')
     return MoneyBasket(Money(eur, 'EUR'), Money(usd, 'USD'))
Example #10
0
    def test_swap_currencies(self, TR_save):
        TR_save.side_effect = fake_transfer

        self.make_exchange('mango-cc', EUR('10.00'), 0, self.janet)
        self.make_exchange('mango-cc', USD('7.00'), 0, self.homer)
        start_balances = {
            'janet': EUR('10.00'),
            'homer': USD('7.00'),
            'david': MoneyBasket(),
        }
        balances = self.get_balances()
        assert balances == start_balances

        # Test failure when there isn't enough money in the 1st wallet
        with self.assertRaises(AssertionError):
            swap_currencies(self.db, self.janet, self.homer, EUR('100.00'), USD('120.00'))
        balances = self.get_balances()
        assert balances == start_balances

        # Test failure when there isn't enough money in the 2nd wallet
        with self.assertRaises(AssertionError):
            swap_currencies(self.db, self.janet, self.homer, EUR('10.00'), USD('12.00'))
        balances = self.get_balances()
        assert balances == start_balances

        # Test failure of the 1st `prepare_transfer()` call
        with patch('liberapay.billing.transactions.lock_bundles') as lock_bundles:
            lock_bundles.side_effect = NegativeBalance
            with self.assertRaises(NegativeBalance):
                swap_currencies(self.db, self.janet, self.homer, EUR('3.00'), USD('3.00'))
        balances = self.get_balances()
        assert balances == start_balances

        # Test failure of the 2nd `prepare_transfer()` call
        cash_bundle = self.db.one("SELECT * FROM cash_bundles WHERE owner = %s", (self.homer.id,))
        self.db.run("UPDATE cash_bundles SET amount = %s WHERE id = %s",
                    (USD('0.01'), cash_bundle.id))
        with self.assertRaises(NegativeBalance):
            swap_currencies(self.db, self.janet, self.homer, EUR('5.00'), USD('6.99'))
        self.db.run("UPDATE cash_bundles SET amount = %s WHERE id = %s",
                    (cash_bundle.amount, cash_bundle.id))
        balances = self.get_balances()
        assert balances == start_balances

        # Test failure of the 1st `initiate_transfer()` call
        self.transfer_mock.side_effect = Foobar
        with self.assertRaises(TransferError):
            swap_currencies(self.db, self.janet, self.homer, EUR('4.25'), USD('5.55'))
        balances = self.get_balances()
        assert balances == start_balances

        # Test failure of the 2nd `initiate_transfer()` call
        def fail_on_second(tr):
            if getattr(fail_on_second, 'called', False):
                raise Foobar
            fail_on_second.called = True
            fake_transfer(tr)
        self.transfer_mock.side_effect = fail_on_second
        self.db.run("ALTER SEQUENCE transfers_id_seq RESTART WITH 1")
        with patch('mangopay.resources.Transfer.get') as T_get:
            T_get.return_value = Transfer(Id=-1, AuthorId=self.janet_id, Tag='1')
            with self.assertRaises(TransferError):
                swap_currencies(self.db, self.janet, self.homer, EUR('0.01'), USD('0.01'))
        balances = self.get_balances()
        assert balances == start_balances

        # Test success
        self.transfer_mock.side_effect = fake_transfer
        swap_currencies(self.db, self.janet, self.homer, EUR('5.00'), USD('6.00'))
        balances = self.get_balances()
        assert balances == {
            'janet': MoneyBasket(EUR('5.00'), USD('6.00')),
            'homer': MoneyBasket(EUR('5.00'), USD('1.00')),
            'david': MoneyBasket(),
        }
Example #11
0
    def notify_participants(self):
        previous_ts_end = self.db.one("""
            SELECT ts_end
              FROM paydays
             WHERE ts_start < %s
          ORDER BY ts_end DESC
             LIMIT 1
        """, (self.ts_start, ),
                                      default=constants.BIRTHDAY)

        # Income notifications
        n = 0
        r = self.db.all(
            """
            SELECT tippee, json_agg(t) AS transfers
              FROM transfers t
             WHERE "timestamp" > %s
               AND "timestamp" <= %s
               AND context IN ('tip', 'take', 'final-gift')
               AND status = 'succeeded'
          GROUP BY tippee
        """, (previous_ts_end, self.ts_end))
        for tippee_id, transfers in r:
            p = Participant.from_id(tippee_id)
            for t in transfers:
                t['amount'] = Money(**t['amount'])
            by_team = {
                k: (MoneyBasket(t['amount']
                                for t in v), len(set(t['tipper'] for t in v)))
                for k, v in group_by(transfers, 'team').items()
            }
            total = sum((t[0] for t in by_team.values()), MoneyBasket())
            nothing = (MoneyBasket(), 0)
            personal, personal_npatrons = by_team.pop(None, nothing)
            teams = p.get_teams()
            team_ids = set(t.id for t in teams) | set(by_team.keys())
            team_names = {t.id: t.name for t in teams}
            get_username = lambda i: team_names.get(i) or self.db.one(
                "SELECT username FROM participants WHERE id = %s", (i, ))
            by_team = {
                get_username(t_id): by_team.get(t_id, nothing)
                for t_id in team_ids
            }
            p.notify(
                'income~v2',
                total=total.fuzzy_sum(p.main_currency),
                personal=personal,
                personal_npatrons=personal_npatrons,
                by_team=by_team,
                mangopay_balance=p.get_balances(),
            )
            n += 1
        log("Sent %i income notifications." % n)

        # Donation renewal notifications
        n = 0
        participants = self.db.all("""
            SELECT (SELECT p FROM participants p WHERE p.id = t.tipper) AS p
                 , json_agg((SELECT a FROM (
                       SELECT t.periodic_amount, t.tippee_username
                   ) a))
              FROM (
                     SELECT t.*, p2.username AS tippee_username
                       FROM current_tips t
                       JOIN participants p2 ON p2.id = t.tippee
                      WHERE t.amount > 0
                        AND ( t.paid_in_advance IS NULL OR
                              t.paid_in_advance < t.amount
                            )
                        AND p2.status = 'active'
                        AND p2.is_suspended IS NOT true
                        AND p2.has_payment_account
                   ) t
             WHERE EXISTS (
                     SELECT 1
                       FROM transfers tr
                      WHERE tr.tipper = t.tipper
                        AND COALESCE(tr.team, tr.tippee) = t.tippee
                        AND tr.context IN ('tip', 'take')
                        AND tr.status = 'succeeded'
                        AND tr.timestamp >= (current_timestamp - interval '9 weeks')
                   )
               AND (
                     SELECT count(*)
                       FROM notifications n
                      WHERE n.participant = t.tipper
                        AND n.event = 'donate_reminder'
                        AND n.is_new
                   ) < 2
          GROUP BY t.tipper
          ORDER BY t.tipper
        """)
        for p, donations in participants:
            for tip in donations:
                tip['periodic_amount'] = Money(**tip['periodic_amount'])
            p.notify('donate_reminder', donations=donations)
            n += 1
        log("Sent %i donate_reminder notifications." % n)
Example #12
0
 def resolve_takes(tips, takes, ref_currency):
     """Resolve many-to-many donations (team takes)
     """
     total_income = MoneyBasket(t.full_amount for t in tips)
     if total_income == 0:
         return (), total_income
     takes = [t for t in takes if t.amount > 0]
     total_takes = MoneyBasket(t.amount for t in takes)
     if total_takes == 0:
         return (), total_income
     fuzzy_income_sum = total_income.fuzzy_sum(ref_currency)
     fuzzy_takes_sum = total_takes.fuzzy_sum(ref_currency)
     tips_by_currency = group_by(tips, lambda t: t.full_amount.currency)
     takes_by_preferred_currency = group_by(takes,
                                            lambda t: t.main_currency)
     takes_by_secondary_currency = {c: [] for c in tips_by_currency}
     takes_ratio = min(fuzzy_income_sum / fuzzy_takes_sum, 1)
     for take in takes:
         take.amount = (take.amount * takes_ratio).round_up()
         take.accepted_currencies = take.accepted_currencies.split(',')
         for accepted in take.accepted_currencies:
             skip = (accepted == take.main_currency
                     or accepted not in takes_by_secondary_currency)
             if skip:
                 continue
             takes_by_secondary_currency[accepted].append(take)
     tips_ratio = min(fuzzy_takes_sum / fuzzy_income_sum, 1)
     adjust_tips = tips_ratio != 1
     if adjust_tips:
         # The team has a leftover, so donation amounts can be adjusted.
         # In the following loop we compute the "weeks" count of each tip.
         # For example the `weeks` value is 2.5 for a donation currently at
         # 10€/week which has distributed 25€ in the past.
         for tip in tips:
             tip.weeks = round_up(tip.past_transfers_sum / tip.full_amount)
         max_weeks = max(tip.weeks for tip in tips)
         min_weeks = min(tip.weeks for tip in tips)
         adjust_tips = max_weeks != min_weeks
         if adjust_tips:
             # Some donors have given fewer weeks worth of money than others,
             # we want to adjust the amounts so that the weeks count will
             # eventually be the same for every donation.
             min_tip_ratio = tips_ratio * Decimal('0.1')
             # Loop: compute how many "weeks" each tip is behind the "oldest"
             # tip, as well as a naive ratio and amount based on that number
             # of weeks
             for tip in tips:
                 tip.weeks_to_catch_up = max_weeks - tip.weeks
                 tip.ratio = min(min_tip_ratio + tip.weeks_to_catch_up, 1)
                 tip.amount = (tip.full_amount * tip.ratio).round_up()
             naive_amounts_sum = MoneyBasket(
                 tip.amount for tip in tips).fuzzy_sum(ref_currency)
             total_to_transfer = min(fuzzy_takes_sum, fuzzy_income_sum)
             delta = total_to_transfer - naive_amounts_sum
             if delta == 0:
                 # The sum of the naive amounts computed in the previous loop
                 # matches the end target, we got very lucky and no further
                 # adjustments are required
                 adjust_tips = False
             else:
                 # Loop: compute the "leeway" of each tip, i.e. how much it
                 # can be increased or decreased to fill the `delta` gap
                 if delta < 0:
                     # The naive amounts are too high: we want to lower the
                     # amounts of the tips that have a "high" ratio, leaving
                     # untouched the ones that are already low
                     for tip in tips:
                         if tip.ratio > min_tip_ratio:
                             min_tip_amount = (tip.full_amount *
                                               min_tip_ratio).round_up()
                             tip.leeway = min_tip_amount - tip.amount
                         else:
                             tip.leeway = tip.amount.zero()
                 else:
                     # The naive amounts are too low: we can raise all the
                     # tips that aren't already at their maximum
                     for tip in tips:
                         tip.leeway = tip.full_amount - tip.amount
                 leeway = MoneyBasket(
                     tip.leeway for tip in tips).fuzzy_sum(ref_currency)
                 if leeway == 0:
                     # We don't actually have any leeway, give up
                     adjust_tips = False
                 else:
                     leeway_ratio = min(delta / leeway, 1)
                     tips = sorted(tips,
                                   key=lambda tip:
                                   (-tip.weeks_to_catch_up, tip.id))
     # Loop: compute the adjusted donation amounts, and do the transfers
     transfers = []
     for tip in tips:
         if adjust_tips:
             tip_amount = (tip.amount +
                           tip.leeway * leeway_ratio).round_up()
             if tip_amount == 0:
                 continue
             assert tip_amount > 0
             assert tip_amount <= tip.full_amount
             tip.amount = tip_amount
         else:
             tip.amount = (tip.full_amount * tips_ratio).round_up()
         tip_currency = tip.amount.currency
         sorted_takes = chain(
             takes_by_preferred_currency.get(tip_currency, ()),
             takes_by_secondary_currency.get(tip_currency, ()))
         for take in sorted_takes:
             if take.amount == 0 or tip.tipper == take.member:
                 continue
             transfer_amount = min(tip.amount,
                                   take.amount.convert(tip_currency))
             transfers.append(
                 TakeTransfer(tip.tipper, take.member, transfer_amount))
             if transfer_amount == tip.amount:
                 take.amount -= transfer_amount.convert(
                     take.amount.currency)
             else:
                 take.amount = take.amount.zero()
             tip.amount -= transfer_amount
             if tip.amount == 0:
                 break
     leftover = total_income - MoneyBasket(t.amount for t in transfers)
     return transfers, leftover
Example #13
0
    def notify_participants(self):
        previous_ts_end = self.db.one("""
            SELECT ts_end
              FROM paydays
             WHERE ts_start < %s
          ORDER BY ts_end DESC
             LIMIT 1
        """, (self.ts_start,), default=constants.BIRTHDAY)

        # Income notifications
        n = 0
        get_username = lambda i: self.db.one(
            "SELECT username FROM participants WHERE id = %s", (i,)
        )
        r = self.db.all("""
            SELECT tippee, json_agg(t) AS transfers
              FROM transfers t
             WHERE "timestamp" > %s
               AND "timestamp" <= %s
               AND context IN ('tip', 'take', 'final-gift')
               AND status = 'succeeded'
          GROUP BY tippee
        """, (previous_ts_end, self.ts_end))
        for tippee_id, transfers in r:
            p = Participant.from_id(tippee_id)
            for t in transfers:
                t['amount'] = Money(**t['amount'])
            by_team = {k: MoneyBasket(t['amount'] for t in v)
                       for k, v in group_by(transfers, 'team').items()}
            total = sum(by_team.values(), MoneyBasket())
            personal = by_team.pop(None, 0)
            by_team = {get_username(k): v for k, v in by_team.items()}
            p.notify(
                'income',
                total=total.fuzzy_sum(p.main_currency),
                personal=personal,
                by_team=by_team,
                new_balance=p.get_balances(),
            )
            n += 1
        log("Sent %i income notifications." % n)

        # Identity-required notifications
        n = 0
        participants = self.db.all("""
            SELECT p
              FROM participants p
             WHERE mangopay_user_id IS NULL
               AND kind IN ('individual', 'organization')
               AND (p.goal IS NULL OR p.goal >= 0)
               AND EXISTS (
                     SELECT 1
                       FROM current_tips t
                       JOIN participants p2 ON p2.id = t.tipper
                      WHERE t.tippee = p.id
                        AND t.amount > 0
                        AND t.is_funded
                   )
               AND NOT EXISTS (
                     SELECT 1
                       FROM notifications n
                      WHERE n.participant = p.id
                        AND n.event = 'identity_required'
                        AND n.ts > (current_timestamp - interval '6 months')
                        AND (n.is_new OR n.email_sent IS TRUE)
                   )
        """)
        for p in participants:
            p.notify('identity_required', force_email=True)
            n += 1
        log("Sent %i identity_required notifications." % n)

        # Low-balance notifications
        n = 0
        participants = self.db.all("""
            SELECT p, COALESCE(w.balance, zero(needed)) AS balance, needed
              FROM (
                     SELECT t.tipper, sum(t.amount) AS needed
                       FROM current_tips t
                       JOIN participants p2 ON p2.id = t.tippee
                      WHERE (p2.mangopay_user_id IS NOT NULL OR p2.kind = 'group')
                        AND p2.status = 'active'
                        AND p2.is_suspended IS NOT true
                   GROUP BY t.tipper, t.amount::currency
                   ) a
              JOIN participants p ON p.id = a.tipper
         LEFT JOIN wallets w ON w.owner = p.id
                            AND w.balance::currency = needed::currency
                            AND w.is_current IS TRUE
             WHERE COALESCE(w.balance, zero(needed)) < needed
               AND EXISTS (
                     SELECT 1
                       FROM transfers t
                      WHERE t.tipper = p.id
                        AND t.timestamp > %s
                        AND t.timestamp <= %s
                        AND t.status = 'succeeded'
                        AND t.amount::currency = needed::currency
                   )
        """, (previous_ts_end, self.ts_end))
        for p, balance, needed in participants:
            p.notify('low_balance', low_balance=balance, needed=needed)
            n += 1
        log("Sent %i low_balance notifications." % n)
Example #14
0
    def recompute_actual_takes(self, cursor, member=None):
        """Get the tips and takes for this team and recompute the actual amounts.

        To avoid deadlocks the given `cursor` should have already acquired an
        exclusive lock on the `takes` table.
        """
        from liberapay.billing.payday import Payday
        tips = [NS(t._asdict()) for t in cursor.all("""
            SELECT t.id, t.tipper, t.amount AS full_amount, t.paid_in_advance
                 , ( SELECT basket_sum(w.balance)
                       FROM wallets w
                      WHERE w.owner = t.tipper
                        AND w.is_current
                   ) AS balances
                 , coalesce_currency_amount((
                       SELECT sum(tr.amount, t.amount::currency)
                         FROM transfers tr
                        WHERE tr.tipper = t.tipper
                          AND tr.team = %(team_id)s
                          AND tr.context = 'take'
                          AND tr.status = 'succeeded'
                   ), t.amount::currency) AS past_transfers_sum
              FROM current_tips t
              JOIN participants p ON p.id = t.tipper
             WHERE t.tippee = %(team_id)s
               AND t.is_funded
               AND p.is_suspended IS NOT true
        """, dict(team_id=self.id))]
        takes = [NS(r._asdict()) for r in (cursor or self.db).all("""
            SELECT t.*, p.main_currency, p.accepted_currencies
              FROM current_takes t
              JOIN participants p ON p.id = t.member
             WHERE t.team = %s
               AND p.is_suspended IS NOT true
               AND p.mangopay_user_id IS NOT NULL
        """, (self.id,))]
        # Recompute the takes
        transfers, new_leftover = Payday.resolve_takes(tips, takes, self.main_currency)
        transfers_by_member = group_by(transfers, lambda t: t.member)
        takes_sum = {k: MoneyBasket(t.amount for t in tr_list)
                     for k, tr_list in transfers_by_member.items()}
        tippers = {k: set(t.tipper for t in tr_list)
                   for k, tr_list in transfers_by_member.items()}
        # Update the leftover
        cursor.run("UPDATE participants SET leftover = %s WHERE id = %s",
                   (new_leftover, self.id))
        self.set_attributes(leftover=new_leftover)
        # Update the cached amounts (actual_amount, taking, and receiving)
        zero = MoneyBasket()
        for take in takes:
            member_id = take.member
            old_amount = take.actual_amount or zero
            new_amount = takes_sum.get(take.member, zero)
            diff = new_amount - old_amount
            if diff != 0:
                take.actual_amount = new_amount
                cursor.run("""
                    UPDATE takes
                       SET actual_amount = %(actual_amount)s
                     WHERE id = %(id)s
                """, take.__dict__)
                ntippers = len(tippers.get(member_id, ()))
                member_currency, old_taking = cursor.one(
                    "SELECT main_currency, taking FROM participants WHERE id = %s", (member_id,)
                )
                diff = diff.fuzzy_sum(member_currency)
                if old_taking + diff < 0:
                    # Make sure currency fluctuation doesn't result in a negative number
                    diff = -old_taking
                cursor.run("""
                    UPDATE participants
                       SET taking = (taking + %(diff)s)
                         , receiving = (receiving + %(diff)s)
                         , nteampatrons = (
                               CASE WHEN (receiving + %(diff)s) = 0 THEN 0
                                    WHEN nteampatrons < %(ntippers)s THEN %(ntippers)s
                                    ELSE nteampatrons
                               END
                           )
                     WHERE id=%(member_id)s
                """, dict(member_id=member_id, diff=diff, ntippers=ntippers))
            if member and member.id == member_id:
                r = cursor.one(
                    "SELECT taking, receiving FROM participants WHERE id = %s",
                    (member_id,)
                )
                member.set_attributes(**r._asdict())
        return takes
Example #15
0
    def notify_participants(self):
        previous_ts_end = self.db.one("""
            SELECT ts_end
              FROM paydays
             WHERE ts_start < %s
          ORDER BY ts_end DESC
             LIMIT 1
        """, (self.ts_start,), default=constants.BIRTHDAY)

        # Income notifications
        n = 0
        get_username = lambda i: self.db.one(
            "SELECT username FROM participants WHERE id = %s", (i,)
        )
        r = self.db.all("""
            SELECT tippee, json_agg(t) AS transfers
              FROM transfers t
             WHERE "timestamp" > %s
               AND "timestamp" <= %s
               AND context IN ('tip', 'take', 'final-gift')
               AND status = 'succeeded'
          GROUP BY tippee
        """, (previous_ts_end, self.ts_end))
        for tippee_id, transfers in r:
            p = Participant.from_id(tippee_id)
            for t in transfers:
                t['amount'] = Money(**t['amount'])
            by_team = {k: MoneyBasket.sum(t['amount'] for t in v)
                       for k, v in group_by(transfers, 'team').items()}
            total = sum(by_team.values(), MoneyBasket())
            personal = by_team.pop(None, 0)
            by_team = {get_username(k): v for k, v in by_team.items()}
            p.notify(
                'income',
                total=total.fuzzy_sum(p.main_currency),
                personal=personal,
                by_team=by_team,
                new_balance=p.get_balances(),
            )
            n += 1
        log("Sent %i income notifications." % n)

        # Identity-required notifications
        n = 0
        participants = self.db.all("""
            SELECT p
              FROM participants p
             WHERE mangopay_user_id IS NULL
               AND kind IN ('individual', 'organization')
               AND (p.goal IS NULL OR p.goal >= 0)
               AND EXISTS (
                     SELECT 1
                       FROM current_tips t
                       JOIN participants p2 ON p2.id = t.tipper
                      WHERE t.tippee = p.id
                        AND t.amount > 0
                        AND t.is_funded
                   )
        """)
        for p in participants:
            p.notify('identity_required', force_email=True)
            n += 1
        log("Sent %i identity_required notifications." % n)

        # Low-balance notifications
        n = 0
        participants = self.db.all("""
            SELECT p, COALESCE(w.balance, zero(needed)) AS balance, needed
              FROM (
                     SELECT t.tipper, sum(t.amount) AS needed
                       FROM current_tips t
                       JOIN participants p2 ON p2.id = t.tippee
                      WHERE p2.mangopay_user_id IS NOT NULL
                        AND p2.status = 'active'
                        AND p2.is_suspended IS NOT true
                   GROUP BY t.tipper, t.amount::currency
                   ) a
              JOIN participants p ON p.id = a.tipper
         LEFT JOIN wallets w ON w.owner = p.id
                            AND w.balance::currency = needed::currency
                            AND w.is_current IS TRUE
             WHERE COALESCE(w.balance, zero(needed)) < needed
               AND EXISTS (
                     SELECT 1
                       FROM transfers t
                      WHERE t.tipper = p.id
                        AND t.timestamp > %s
                        AND t.timestamp <= %s
                        AND t.status = 'succeeded'
                        AND t.amount::currency = needed::currency
                   )
        """, (previous_ts_end, self.ts_end))
        for p, balance, needed in participants:
            p.notify('low_balance', low_balance=balance, needed=needed)
            n += 1
        log("Sent %i low_balance notifications." % n)