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__
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
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_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
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
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
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
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
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, }
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("")
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(), }
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)
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