Ejemplo n.º 1
0
 def test_cbor_serialization_of_MoneyBasket_with_extra_attribute(self):
     expected = MoneyBasket(EUR=Decimal('10.01'), JPY=Decimal('1300'))
     expected.foo = 'bar'
     actual = cbor.loads(cbor.dumps(expected))
     assert expected.amounts == actual.amounts
     assert expected.__dict__ == {'foo': 'bar'}
     assert expected.__dict__ == actual.__dict__
Ejemplo n.º 2
0
    def test_exactly_funded_team_with_two_unbalanced_currencies(self):
        self.set_up_team_with_two_currencies()
        self.donor1_eur.set_tip_to(self.team, EUR('0.75'))
        self.donor2_usd.set_tip_to(self.team, USD('0.30'))
        self.donor3_eur.set_tip_to(self.team, EUR('0.75'))
        self.donor4_usd.set_tip_to(self.team, USD('0.30'))

        Payday.start().shuffle()

        expected = {
            'alice': MoneyBasket(EUR('1.00')),
            'bob': MoneyBasket(EUR('0.50'), USD('0.60')),
            'donor1_eur': MoneyBasket(EUR('99.25')),
            'donor2_usd': MoneyBasket(USD('99.70')),
            'donor3_eur': MoneyBasket(EUR('99.25')),
            'donor4_usd': MoneyBasket(USD('99.70')),
        }
        actual = self.get_balances()
        assert expected == actual
Ejemplo n.º 3
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
Ejemplo n.º 4
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
Ejemplo n.º 5
0
 def test_merge_two_baskets(self):
     # Merge empty basket left
     expected = MoneyBasket(GBP=D('1.05'))
     actual = self.db.one("SELECT empty_currency_basket() + %s AS x", (expected,))
     assert expected == actual
     # Merge empty basket right
     expected = MoneyBasket(GBP=D('1.06'))
     actual = self.db.one("SELECT %s + empty_currency_basket() AS x", (expected,))
     assert expected == actual
     # Merge non-empty baskets
     b1 = MoneyBasket(JPY=D('101'))
     b2 = MoneyBasket(EUR=D('1.02'), JPY=D('101'))
     expected = b1 + b2
     actual = self.db.one("SELECT %s + %s AS x", (b1, b2))
     assert expected == actual
     # Merge empty legacy basket
     b1 = MoneyBasket(EUR=D('1.01'))
     b2 = MoneyBasket(EUR=D('1.02'), JPY=D('45'))
     expected = b1 + b2
     actual = self.db.one("""
         SELECT (%s,'0.00',NULL)::currency_basket + %s AS x
     """, (b1.amounts['EUR'], b2))
     assert expected == actual
Ejemplo n.º 6
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
                        AND %(use_mangopay)s
                   ) 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, use_mangopay=mangopay.sandbox))
        ]
        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
        """, (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
Ejemplo n.º 7
0
 def test_cbor_serialization_of_MoneyBasket(self):
     original = MoneyBasket(EUR=Decimal('10.01'), JPY=Decimal('1300'))
     serialized = cbor.dumps(original)
     recreated = cbor.loads(serialized)
     assert len(serialized) < 30
     assert recreated == original
Ejemplo n.º 8
0
 def get_current_takes_for_payment(self, currency, provider, weekly_amount):
     """
     Return the list of current takes with the extra information that the
     `liberapay.payin.common.resolve_take_amounts` function needs to compute
     transfer amounts.
     """
     takes = self.db.all("""
         SELECT t.member
              , t.ctime
              , t.amount
              , (coalesce_currency_amount((
                    SELECT sum(pt.amount - coalesce(pt.reversed_amount, zero(pt.amount)), %(currency)s)
                      FROM payin_transfers pt
                     WHERE pt.recipient = t.member
                       AND pt.team = t.team
                       AND pt.context = 'team-donation'
                       AND pt.status = 'succeeded'
                ), %(currency)s) + coalesce_currency_amount((
                    SELECT sum(tr.amount, %(currency)s)
                      FROM transfers tr
                     WHERE tr.tippee = t.member
                       AND tr.team = t.team
                       AND tr.context IN ('take', 'take-in-advance')
                       AND tr.status = 'succeeded'
                       AND tr.virtual IS NOT true
                ), %(currency)s)) AS received_sum
              , (coalesce_currency_amount((
                    SELECT sum(t2.amount, %(currency)s)
                      FROM ( SELECT ( SELECT t2.amount
                                        FROM takes t2
                                       WHERE t2.member = t.member
                                         AND t2.team = t.team
                                         AND t2.mtime < coalesce(
                                                 payday.ts_start, current_timestamp
                                             )
                                    ORDER BY t2.mtime DESC
                                       LIMIT 1
                                    ) AS amount
                               FROM paydays payday
                           ) t2
                     WHERE t2.amount > 0
                ), %(currency)s)) AS takes_sum
              , p.is_suspended
              , ( CASE WHEN %(provider)s = 'mangopay' THEN p.mangopay_user_id IS NOT NULL
                  ELSE EXISTS (
                    SELECT true
                      FROM payment_accounts a
                     WHERE a.participant = t.member
                       AND a.provider = %(provider)s
                       AND a.is_current
                       AND a.verified
                       AND coalesce(a.charges_enabled, true)
                  )
                  END
                ) AS has_payment_account
           FROM current_takes t
           JOIN participants p ON p.id = t.member
          WHERE t.team = %(team_id)s
     """, dict(currency=currency, team_id=self.id, provider=provider))
     zero = Money.ZEROS[currency]
     income_amount = self.receiving.convert(currency) + weekly_amount.convert(currency)
     if income_amount == 0:
         income_amount = Money.MINIMUMS[currency]
     manual_takes_sum = MoneyBasket(t.amount for t in takes if t.amount > 0)
     auto_take = income_amount - manual_takes_sum.fuzzy_sum(currency)
     if auto_take < 0:
         auto_take = zero
     for t in takes:
         t.amount = auto_take if t.amount < 0 else t.amount.convert(currency)
     return takes
Ejemplo n.º 9
0
    def test_takes_paid_in_advance_to_now_inactive_members(self):
        team = self.make_participant('team', kind='group', accepted_currencies=None)
        alice = self.make_participant('alice', main_currency='EUR', accepted_currencies=None)
        team.set_take_for(alice, EUR('1.00'), team)
        bob = self.make_participant('bob', main_currency='USD', accepted_currencies=None)
        team.set_take_for(bob, USD('1.00'), team)

        stripe_account_alice = self.add_payment_account(
            alice, 'stripe', default_currency='EUR'
        )
        stripe_account_bob = self.add_payment_account(
            bob, 'stripe', country='US', default_currency='USD'
        )

        carl = self.make_participant('carl')
        carl.set_tip_to(team, JPY('250'))

        carl_card = ExchangeRoute.insert(
            carl, 'stripe-card', 'x', 'chargeable', remote_user_id='x'
        )
        payin, pt = self.make_payin_and_transfer(carl_card, team, JPY('1250'))
        assert pt.destination == stripe_account_alice.pk
        payin, pt = self.make_payin_and_transfer(carl_card, team, JPY('1250'))
        assert pt.destination == stripe_account_bob.pk

        team.set_take_for(alice, EUR('0.00'), team)
        team.set_take_for(bob, None, team)
        takes = dict(self.db.all("""
            SELECT DISTINCT ON (member)
                   member, paid_in_advance
              FROM takes
          ORDER BY member, mtime DESC
        """))
        assert takes == {
            alice.id: EUR('10.00'),
            bob.id: USD('12.00'),
        }

        Payday.start().run()

        transfers = self.db.all("SELECT * FROM transfers ORDER BY id")
        assert len(transfers) == 2
        assert transfers[0].virtual is True
        assert transfers[0].tipper == carl.id
        assert transfers[0].tippee == alice.id
        assert transfers[0].amount == JPY('125')
        assert transfers[1].virtual is True
        assert transfers[1].tipper == carl.id
        assert transfers[1].tippee == bob.id
        assert transfers[1].amount == JPY('125')

        takes = dict(self.db.all("""
            SELECT DISTINCT ON (member)
                   member, paid_in_advance
              FROM takes
          ORDER BY member, mtime DESC
        """))
        assert takes == {
            alice.id: EUR('9.00'),
            bob.id: USD('10.80'),
        }

        notifications = self.db.all("SELECT * FROM notifications")
        assert len(notifications) == 0

        leftovers = dict(self.db.all("SELECT username, leftover FROM participants"))
        assert leftovers == {
            'team': MoneyBasket(JPY('250.00')),
            'alice': None,
            'bob': None,
            'carl': None,
        }
Ejemplo n.º 10
0
def populate_db(website, num_participants=100, num_tips=200, num_teams=5, num_transfers=5000, num_communities=20):
    """Populate DB with fake data.
    """
    db = website.db

    # Speed things up
    db.run("""
        DO $$ BEGIN
            EXECUTE 'ALTER DATABASE '||current_database()||' SET synchronous_commit TO off';
        END $$
    """)

    print("Making Participants")
    participants = []
    for i in range(num_participants):
        participants.append(fake_participant(db))

    print("Making Teams")
    teams = []
    for i in range(num_teams):
        team = fake_participant(db, kind="group")
        # Add 1 to 3 members to the team
        members = random.sample(participants, random.randint(1, 3))
        for p in members:
            team.add_member(p)
        teams.append(team)
    participants.extend(teams)

    print("Making Elsewheres")
    platforms = [p.name for p in website.platforms]
    for p in participants:
        # All participants get between 0 and 3 elsewheres
        num_elsewheres = random.randint(0, 3)
        for platform_name in random.sample(platforms, num_elsewheres):
            fake_elsewhere(db, p, platform_name)

    print("Making Communities")
    for i in range(num_communities):
        creator = random.sample(participants, 1)
        community = fake_community(db, creator[0])

        members = random.sample(participants, random.randint(1, 3))
        for p in members:
            p.upsert_community_membership(True, community.id)

    print("Making Tips")
    tips = []
    for i in range(num_tips):
        tipper, tippee = random.sample(participants, 2)
        tips.append(fake_tip(db, tipper, tippee))

    # Transfers
    min_amount, max_amount = [l.amount for l in DONATION_LIMITS['EUR']['weekly']]
    transfers = []
    for i in range(num_transfers):
        tipper, tippee = random.sample(participants, 2)
        while tipper.kind in ('group', 'community') or \
              tippee.kind in ('group', 'community'):
            tipper, tippee = random.sample(participants, 2)
        sys.stdout.write("\rMaking Transfers (%i/%i)" % (i+1, num_transfers))
        sys.stdout.flush()
        amount = Money(random_money_amount(min_amount, max_amount), 'EUR')
        zero = amount.zero()
        ts = faker.date_time_this_year()
        fake_exchange(db, tipper, amount, zero, zero, (ts - datetime.timedelta(days=1)))
        transfers.append(fake_transfer(db, tipper, tippee, amount, ts))
    print("")

    # Paydays
    # First determine the boundaries - min and max date
    min_date = min(min(x.ctime for x in tips),
                   min(x.timestamp for x in transfers))
    max_date = max(max(x.ctime for x in tips),
                   max(x.timestamp for x in transfers))
    # iterate through min_date, max_date one week at a time
    payday_counter = 1
    date = min_date
    paydays_total = (max_date - min_date).days/7 + 1
    while date < max_date:
        sys.stdout.write("\rMaking Paydays (%i/%i)" % (payday_counter, paydays_total))
        sys.stdout.flush()
        payday_counter += 1
        end_date = date + datetime.timedelta(days=7)
        week_tips = [x for x in tips if date < x.ctime < end_date]
        week_transfers = [x for x in transfers if date < x.timestamp < end_date]
        week_participants = [x for x in participants if x.join_time < end_date]
        actives = set()
        tippers = set()
        for xfers in week_tips, week_transfers:
            actives.update(x.tipper for x in xfers)
            actives.update(x.tippee for x in xfers)
            tippers.update(x.tipper for x in xfers)
        payday = {
            'ts_start': date,
            'ts_end': end_date,
            'ntips': len(week_tips),
            'ntransfers': len(week_transfers),
            'nparticipants': len(week_participants),
            'ntippers': len(tippers),
            'nactive': len(actives),
            'transfer_volume': MoneyBasket(x.amount for x in week_transfers),
            'public_log': '',
        }
        _fake_thing(db, "paydays", **payday)
        date = end_date
    print("")
Ejemplo n.º 11
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(),
        }
Ejemplo n.º 12
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)
            if p.status != 'active' or not p.accepts_tips:
                continue
            for t in transfers:
                t['amount'] = Money(**t['amount'])
            by_team = {
                k: (
                    MoneyBasket(t['amount'] for t in v).fuzzy_sum(p.main_currency),
                    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.renewal_mode > 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.payment_providers > 0
                        AND (p2.goal IS NULL OR p2.goal >= 0)
                   ) 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
               AND NOT EXISTS (
                     SELECT 1
                       FROM payin_transfers pt
                      WHERE pt.payer = t.tipper
                        AND COALESCE(pt.team, pt.recipient) = t.tippee
                        AND pt.context IN ('personal-donation', 'team-donation')
                        AND pt.status = 'pending'
                        AND pt.ctime >= (current_timestamp - interval '9 weeks')
                   )
          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)
Ejemplo n.º 13
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()
         if take.paid_in_advance is None:
             take.paid_in_advance = take.amount.zero()
         if take.accepted_currencies is None:
             take.accepted_currencies = constants.CURRENCIES
         else:
             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 tip.paid_in_advance is None:
             tip.paid_in_advance = tip.full_amount.zero()
         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
             fuzzy_take_amount = take.amount.convert(tip_currency)
             in_advance_amount = min(
                 tip.amount,
                 fuzzy_take_amount,
                 max(tip.paid_in_advance, 0),
                 max(take.paid_in_advance.convert(tip_currency), 0),
             )
             on_time_amount = min(
                 max(tip.amount - in_advance_amount, 0),
                 max(fuzzy_take_amount - in_advance_amount, 0),
                 tip.balances[tip_currency],
             )
             transfer_amount = in_advance_amount + on_time_amount
             if transfer_amount == 0:
                 continue
             transfers.append(TakeTransfer(tip.tipper, take.member, transfer_amount))
             if transfer_amount == fuzzy_take_amount:
                 take.amount = take.amount.zero()
             else:
                 take.amount -= transfer_amount.convert(take.amount.currency)
             if in_advance_amount:
                 tip.paid_in_advance -= in_advance_amount
                 take.paid_in_advance -= in_advance_amount.convert(take.amount.currency)
             if on_time_amount:
                 tip.balances -= on_time_amount
             tip.amount -= transfer_amount
             if tip.amount == 0:
                 break
     leftover = total_income - MoneyBasket(t.amount for t in transfers)
     return transfers, leftover