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)
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']
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))
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
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
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_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 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))
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'))
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) 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)
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
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)
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
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)