Repository: liberapay/liberapay.com

Issue:
Fuzzy balance is confusing
https://cybre.space/@LogicalDash/99150136094694403
Fuzzy balance is confusing
https://cybre.space/@LogicalDash/99150136094694403


Discussion:

Resolving patch:
diff --git a/liberapay/billing/payday.py b/liberapay/billing/payday.py
--- a/liberapay/billing/payday.py
+++ b/liberapay/billing/payday.py
@@ -19,6 +19,7 @@
 from liberapay.exceptions import NegativeBalance
 from liberapay.models.participant import Participant
 from liberapay.utils import NS, group_by
+from liberapay.utils.currencies import MoneyBasket
 from liberapay.website import website
 
 
@@ -817,32 +818,33 @@ def notify_participants(self):
         """, (self.ts_start,), default=constants.BIRTHDAY)
 
         # Income notifications
+        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:
-            successes = [t for t in transfers if t['status'] == 'succeeded']
-            if not successes:
-                continue
             p = Participant.from_id(tippee_id)
             for t in transfers:
                 t['amount'] = Money(**t['amount'])
-                t['converted_amount'] = t['amount'].convert(p.main_currency)
-            by_team = {k: sum(t['converted_amount'] if k is None else t['amount'] for t in v)
-                       for k, v in group_by(successes, 'team').items()}
+            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 = {Participant.from_id(k).username: v for k, v in by_team.items()}
+            by_team = {get_username(k): v for k, v in by_team.items()}
             p.notify(
                 'income',
-                total=sum(t['converted_amount'] for t in successes),
+                total=total.fuzzy_sum(p.main_currency),
                 personal=personal,
                 by_team=by_team,
-                new_balance=p.balance,
+                new_balance=p.get_balances(),
             )
 
         # Identity-required notifications
@@ -866,7 +868,7 @@ def notify_participants(self):
 
         # Low-balance notifications
         participants = self.db.all("""
-            SELECT p, needed
+            SELECT p, COALESCE(w.balance, zero(needed)) AS balance, needed
               FROM (
                      SELECT t.tipper, sum(t.amount) AS needed
                        FROM current_tips t
@@ -886,10 +888,11 @@ def notify_participants(self):
                         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, needed in participants:
-            p.notify('low_balance', low_balance=p.balance, needed=needed)
+        for p, balance, needed in participants:
+            p.notify('low_balance', low_balance=balance, needed=needed)
 
 
 def create_payday_issue():
diff --git a/liberapay/utils/currencies.py b/liberapay/utils/currencies.py
--- a/liberapay/utils/currencies.py
+++ b/liberapay/utils/currencies.py
@@ -104,6 +104,19 @@ def __bool__(self):
     def currencies_present(self):
         return [m.currency for m in self if m.amount]
 
+    def fuzzy_sum(self, currency):
+        a = ZERO[currency].amount
+        fuzzy = False
+        for m in self:
+            if m.currency == currency:
+                a += m.amount
+            else:
+                a += m.amount * website.currency_exchange_rates[(m.currency, currency)]
+                fuzzy = True
+        r = Money(a, currency)
+        r.fuzzy = fuzzy
+        return r
+
     @classmethod
     def sum(cls, amounts):
         r = cls(Money('0.00', 'EUR'), usd=Money('0.00', 'USD'))
diff --git a/liberapay/utils/i18n.py b/liberapay/utils/i18n.py
--- a/liberapay/utils/i18n.py
+++ b/liberapay/utils/i18n.py
@@ -30,7 +30,7 @@
 
 
 def LegacyMoney(o):
-    return o if isinstance(o, Money) else Money(o, 'EUR')
+    return o if isinstance(o, (Money, MoneyBasket)) else Money(o, 'EUR')
 
 
 Wrap = namedtuple('Wrap', 'value wrapper')
@@ -75,11 +75,31 @@ def format_datetime(self, *a):
     def format_decimal(self, *a):
         return format_decimal(*a, locale=self)
 
-    def format_money_basket(self, basket):
-        return ' + '.join(
+    def format_list(self, l):
+        n = len(l)
+        if n > 2:
+            last = n - 2
+            r = l[0]
+            for i, item in enumerate(l[1:]):
+                r = self.list_patterns[
+                    'start' if i == 0 else 'end' if i == last else 'middle'
+                ].format(r, item)
+            return r
+        elif n == 2:
+            return self.list_patterns['2'].format(*l)
+        else:
+            return l[0] if n == 1 else None
+
+    def format_money_basket(self, basket, sep=','):
+        items = (
             format_currency(money.amount, money.currency, locale=self)
             for money in basket if money
-        ) or '0'
+        )
+        if sep == ',':
+            r = self.format_list(list(items))
+        else:
+            r = sep.join(items)
+        return r or '0'
 
     def format_money_delta(self, money, *a):
         return format_currency(
@@ -382,6 +402,7 @@ def add_helpers_to_context(context, loc):
         format_date=loc.format_date,
         format_datetime=loc.format_datetime,
         format_decimal=loc.format_decimal,
+        format_list=loc.format_list,
         format_money=loc.format_money,
         format_money_delta=loc.format_money_delta,
         format_number=loc.format_number,


Test patch: