Repository: liberapay/liberapay.com

Issue:
Improve Wallet page
- [x] only show the balance at the ~~beginning and~~ end of each day
- [x] make it look like a single table: don't visually split each day
- [x] drop the inside/outside separation
- [x] merge "credit card" column into "bank account"
- [x] merge "credits" and "debits" columns as "Liberapay wallet"
- [x] add some colours (use bootstrap classes)

Raw user IDs are shown to users in wallet history
@SimonSarazin has just reported in IRC that user IDs show up in the `tippee` column of CSV exports. We should add a column with the tippee's current username and replace the ID with a stable URL (`https://liberapay/com/~$id/`). Moreover, we should probably rename `tippee` to `donee`.

Technical background: Gratipay used to have `username` as primary key of the `participants`, that was a bad design so I switched to `id` before launching Liberapay, however I forgot to replace IDs with more user-friendly identifiers in a few places.



Discussion:

Resolving patch:
diff --git a/liberapay/utils/fake_data.py b/liberapay/utils/fake_data.py
--- a/liberapay/utils/fake_data.py
+++ b/liberapay/utils/fake_data.py
@@ -165,6 +165,13 @@ def populate_db(website, num_participants=100, num_tips=200, num_teams=5, num_tr
     """
     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):
diff --git a/liberapay/utils/history.py b/liberapay/utils/history.py
--- a/liberapay/utils/history.py
+++ b/liberapay/utils/history.py
@@ -4,6 +4,8 @@
 from pando import Response
 from psycopg2 import IntegrityError
 
+from ..website import website
+
 
 def get_end_of_year_balance(db, participant, year, current_year):
     if year == current_year:
@@ -101,6 +103,8 @@ def iter_payday_events(db, participant, year=None):
                 t['amount'] for t in transfers
                 if t['tippee'] == id and t['status'] == 'succeeded' and t['context'] != 'refund'
             ),
+            npatrons=len(set(t['tipper'] for t in transfers if t['tipper'] != id)),
+            ntippees=len(set(t['tippee'] for t in transfers if t['tippee'] != id)),
         )
 
     payday_dates = db.all("""
@@ -113,6 +117,7 @@ def iter_payday_events(db, participant, year=None):
     prev_date = None
     get_timestamp = lambda e: e['timestamp']
     events = sorted(exchanges+transfers, key=get_timestamp, reverse=True)
+    day_events, day_open = None, None  # for pyflakes
     for event in events:
 
         event['balance'] = balance
@@ -120,63 +125,80 @@ def iter_payday_events(db, participant, year=None):
         event_date = event['timestamp'].date()
         if event_date != prev_date:
             if prev_date:
+                day_open['wallet_delta'] = day_open['balance'] - balance
+                yield day_open
+                for e in day_events:
+                    yield e
                 yield dict(kind='day-close', balance=balance)
+            day_events = []
             day_open = dict(kind='day-open', date=event_date, balance=balance)
             if payday_dates:
                 while payday_dates and payday_dates[-1] > event_date:
                     payday_dates.pop()
                 payday_date = payday_dates[-1] if payday_dates else None
                 if event_date == payday_date:
-                    day_open['payday_number'] = len(payday_dates) - 1
-            yield day_open
+                    day_open['payday_number'] = len(payday_dates)
             prev_date = event_date
 
         if 'fee' in event:
             if event['amount'] > 0:
                 kind = 'payout-refund' if event['refund_ref'] else 'charge'
+                event['bank_delta'] = -event['amount'] - max(event['fee'], 0)
+                event['wallet_delta'] = event['amount'] - min(event['fee'], 0)
                 if event['status'] == 'succeeded':
-                    balance -= event['amount'] - min(event['fee'], 0)
+                    balance -= event['wallet_delta']
             else:
                 kind = 'payin-refund' if event['refund_ref'] else 'credit'
+                event['bank_delta'] = -event['amount'] - min(event['fee'], 0)
+                event['wallet_delta'] = event['amount'] - max(event['fee'], 0)
                 if event['status'] != 'failed':
-                    balance -= event['amount'] - max(event['fee'], 0)
+                    balance -= event['wallet_delta']
         else:
             kind = 'transfer'
-            if event['status'] != 'succeeded':
-                pass
-            elif event['tippee'] == id:
-                balance -= event['amount']
+            if event['tippee'] == id:
+                event['wallet_delta'] = event['amount']
             else:
-                balance += event['amount']
+                event['wallet_delta'] = -event['amount']
+            if event['status'] == 'succeeded':
+                balance -= event['wallet_delta']
         event['kind'] = kind
 
-        yield event
+        day_events.append(event)
 
+    day_open['wallet_delta'] = day_open['balance'] - balance
+    yield day_open
+    for e in day_events:
+        yield e
     yield dict(kind='day-close', balance=balance)
 
 
 def export_history(participant, year, mode, key, back_as='namedtuple', require_key=False):
     db = participant.db
-    params = dict(id=participant.id, year=year)
+    base_url = website.canonical_url + '/~'
+    params = dict(id=participant.id, year=year, base_url=base_url)
     out = {}
     if mode == 'aggregate':
         out['given'] = lambda: db.all("""
-            SELECT tippee, sum(amount) AS amount
-              FROM transfers
-             WHERE tipper = %(id)s
-               AND extract(year from timestamp) = %(year)s
-               AND status = 'succeeded'
-               AND context <> 'refund'
-          GROUP BY tippee
+            SELECT (%(base_url)s || t.tippee::text) AS donee_url,
+                   min(p.username) AS donee_username, sum(t.amount) AS amount
+              FROM transfers t
+              JOIN participants p ON p.id = t.tippee
+             WHERE t.tipper = %(id)s
+               AND extract(year from t.timestamp) = %(year)s
+               AND t.status = 'succeeded'
+               AND t.context <> 'refund'
+          GROUP BY t.tippee
         """, params, back_as=back_as)
         out['taken'] = lambda: db.all("""
-            SELECT team, sum(amount) AS amount
-              FROM transfers
-             WHERE tippee = %(id)s
-               AND context = 'take'
-               AND extract(year from timestamp) = %(year)s
-               AND status = 'succeeded'
-          GROUP BY team
+            SELECT (%(base_url)s || t.team::text) AS team_url,
+                   min(p.username) AS team_username, sum(t.amount) AS amount
+              FROM transfers t
+              JOIN participants p ON p.id = t.team
+             WHERE t.tippee = %(id)s
+               AND t.context = 'take'
+               AND extract(year from t.timestamp) = %(year)s
+               AND t.status = 'succeeded'
+          GROUP BY t.team
         """, params, back_as=back_as)
     else:
         out['exchanges'] = lambda: db.all("""
@@ -187,21 +209,25 @@ def export_history(participant, year, mode, key, back_as='namedtuple', require_k
           ORDER BY id ASC
         """, params, back_as=back_as)
         out['given'] = lambda: db.all("""
-            SELECT timestamp, tippee, amount, context
-              FROM transfers
-             WHERE tipper = %(id)s
-               AND extract(year from timestamp) = %(year)s
-               AND status = 'succeeded'
-          ORDER BY id ASC
+            SELECT timestamp, (%(base_url)s || t.tippee::text) AS donee_url,
+                   p.username AS donee_username, t.amount, t.context
+              FROM transfers t
+              JOIN participants p ON p.id = t.tippee
+             WHERE t.tipper = %(id)s
+               AND extract(year from t.timestamp) = %(year)s
+               AND t.status = 'succeeded'
+          ORDER BY t.id ASC
         """, params, back_as=back_as)
         out['taken'] = lambda: db.all("""
-            SELECT timestamp, team, amount
-              FROM transfers
-             WHERE tippee = %(id)s
-               AND context = 'take'
-               AND extract(year from timestamp) = %(year)s
-               AND status = 'succeeded'
-          ORDER BY id ASC
+            SELECT timestamp, (%(base_url)s || t.team::text) AS team_url,
+                   p.username AS team_username, t.amount
+              FROM transfers t
+              JOIN participants p ON p.id = t.team
+             WHERE t.tippee = %(id)s
+               AND t.context = 'take'
+               AND extract(year from t.timestamp) = %(year)s
+               AND t.status = 'succeeded'
+          ORDER BY t.id ASC
         """, params, back_as=back_as)
         out['received'] = lambda: db.all("""
             SELECT timestamp, amount, context
diff --git a/liberapay/utils/i18n.py b/liberapay/utils/i18n.py
--- a/liberapay/utils/i18n.py
+++ b/liberapay/utils/i18n.py
@@ -10,7 +10,7 @@
 
 from aspen.simplates.pagination import parse_specline, split_and_escape
 from babel.core import LOCALE_ALIASES, Locale
-from babel.dates import format_datetime, format_timedelta
+from babel.dates import format_date, format_datetime, format_timedelta
 from babel.messages.extract import extract_python
 from babel.messages.pofile import Catalog
 from babel.numbers import (
@@ -275,9 +275,15 @@ def add_helpers_to_context(context, loc):
     context['format_currency'] = lambda *a, **kw: format_money(*a, locale=loc, **kw)
     context['format_percent'] = lambda *a: format_percent(*a, locale=loc)
     context['format_datetime'] = lambda *a: format_datetime(*a, locale=loc)
+    context['format_date'] = lambda *a: format_date(*a, locale=loc)
     context['get_lang_options'] = lambda *a, **kw: get_lang_options(context['request'], loc, *a, **kw)
     context['to_age'] = to_age
 
+    def format_delta(s, *a):
+        return format_decimal(s, *a, format='+#,##0.00;-#,##0.00', locale=loc)
+
+    context['format_delta'] = format_delta
+
     def parse_decimal_or_400(s, *a):
         try:
             return parse_decimal(s, *a, locale=loc)


Test patch:
diff --git a/tests/py/test_history.py b/tests/py/test_history.py
--- a/tests/py/test_history.py
+++ b/tests/py/test_history.py
@@ -94,7 +94,7 @@ def test_iter_payday_events(self):
         assert events[0]['given'] == 0
         assert events[0]['received'] == 12
         assert events[1]['kind'] == 'day-open'
-        assert events[1]['payday_number'] == 2
+        assert events[1]['payday_number'] == 3
         assert events[2]['balance'] == 12
         assert events[-1]['kind'] == 'day-close'
         assert events[-1]['balance'] == 0