Repository: gratipay/gratipay.com

Issue:
Add exchange routes to record-an-exchange.spt
Reticketed from #3282



Discussion:

Resolving patch:
diff --git a/bin/masspay.py b/bin/masspay.py
--- a/bin/masspay.py
+++ b/bin/masspay.py
@@ -50,6 +50,7 @@ def print_rule(w=80):
 
 class Payee(object):
     username = None
+    route_id = None
     email = None
     gross = None
     gross_perc = None
@@ -58,7 +59,7 @@ class Payee(object):
     additional_note = ""
 
     def __init__(self, rec):
-        self.username, self.email, fee_cap, amount = rec
+        self.username, self.route_id, self.email, fee_cap, amount = rec
         self.gross = D(amount)
         self.fee = D(0)
         self.fee_cap = D(fee_cap)
@@ -141,7 +142,7 @@ def compute_input_csv():
                                                            , route.fee_cap
                                                            , amount
                                                             ))
-        row = (route.participant.username, route.address, route.fee_cap, amount)
+        row = (route.participant.username, route.id, route.address, route.fee_cap, amount)
         writer.writerow(row)
     print(" "*64, "-"*7)
     print("{:>72}".format(total_gross))
@@ -164,6 +165,7 @@ def compute_output_csvs():
     for payee in payees:
         paypal_csv.writerow((payee.email, payee.net, "usd"))
         gratipay_csv.writerow(( payee.username
+                            , payee.route_id
                             , payee.email
                             , payee.gross
                             , payee.fee
@@ -206,7 +208,7 @@ def post_back_to_gratipay():
     statuses = load_statuses()
 
     nposts = 0
-    for username, email, gross, fee, net, additional_note in csv.reader(open(GRATIPAY_CSV)):
+    for username, route_id, email, gross, fee, net, additional_note in csv.reader(open(GRATIPAY_CSV)):
         url = '{}/~{}/history/record-an-exchange'.format(gratipay_base_url, username)
         note = 'PayPal MassPay to {}.'.format(email)
         if additional_note:
@@ -214,7 +216,7 @@ def post_back_to_gratipay():
         print(note)
         status = statuses[email]
 
-        data = {'amount': '-' + net, 'fee': fee, 'note': note, 'status': status}
+        data = {'amount': '-' + net, 'fee': fee, 'note': note, 'status': status, 'route_id': route_id}
         try:
             response = requests.post(url, auth=(gratipay_api_key, ''), data=data)
         except IncompleteRead:
diff --git a/gratipay/billing/exchanges.py b/gratipay/billing/exchanges.py
--- a/gratipay/billing/exchanges.py
+++ b/gratipay/billing/exchanges.py
@@ -275,6 +275,8 @@ def record_exchange(db, route, amount, fee, participant, status, error=None):
 
     """
 
+    assert route.participant.id == participant.id
+
     with db.get_cursor() as cursor:
 
         exchange_id = cursor.one("""


Test patch:
diff --git a/tests/py/test_billing_exchanges.py b/tests/py/test_billing_exchanges.py
--- a/tests/py/test_billing_exchanges.py
+++ b/tests/py/test_billing_exchanges.py
@@ -236,6 +236,52 @@ def test_skim_credit(self):
 
 class TestRecordExchange(Harness):
 
+    def test_record_exchange_records_exchange(self):
+        alice = self.make_participant('alice', last_bill_result='')
+        record_exchange( self.db
+                       , ExchangeRoute.from_network(alice, 'braintree-cc')
+                       , amount=D("0.59")
+                       , fee=D("0.41")
+                       , participant=alice
+                       , status='pre'
+                        )
+        actual = self.db.one("""
+            SELECT amount, fee, participant, status, route
+              FROM exchanges
+        """, back_as=dict)
+        expected = { "amount": D('0.59')
+                   , "fee": D('0.41')
+                   , "participant": "alice"
+                   , "status": 'pre'
+                   , "route": ExchangeRoute.from_network(alice, 'braintree-cc').id
+                    }
+        assert actual == expected
+
+    def test_record_exchange_requires_valid_route(self):
+        alice = self.make_participant('alice', last_bill_result='')
+        bob = self.make_participant('bob', last_bill_result='')
+        with self.assertRaises(AssertionError):
+            record_exchange( self.db
+                           , ExchangeRoute.from_network(bob, 'braintree-cc')
+                           , amount=D("0.59")
+                           , fee=D("0.41")
+                           , participant=alice
+                           , status='pre'
+                            )
+
+    def test_record_exchange_stores_error_in_note(self):
+        alice = self.make_participant('alice', last_bill_result='')
+        record_exchange( self.db
+                       , ExchangeRoute.from_network(alice, 'braintree-cc')
+                       , amount=D("0.59")
+                       , fee=D("0.41")
+                       , participant=alice
+                       , status='pre'
+                       , error='Card payment failed'
+                        )
+        exchange = self.db.one("SELECT * FROM exchanges")
+        assert exchange.note == 'Card payment failed'
+
     def test_record_exchange_doesnt_update_balance_for_positive_amounts(self):
         alice = self.make_participant('alice', last_bill_result='')
         record_exchange( self.db
@@ -297,7 +343,7 @@ def test_record_exchange_result_doesnt_restore_balance_on_success(self):
 
     def test_record_exchange_result_updates_balance_for_positive_amounts(self):
         alice = self.make_participant('alice', balance=4, last_bill_result='')
-        cc = ExchangeRoute.from_network(alice, 'braintree-cc')        
+        cc = ExchangeRoute.from_network(alice, 'braintree-cc')
         e_id = record_exchange(self.db, cc, D('31.59'), D('0.01'), alice, 'pre')
         assert alice.balance == D('4.00')
         record_exchange_result(self.db, e_id, 'succeeded', None, alice)
diff --git a/tests/py/test_record_an_exchange.py b/tests/py/test_record_an_exchange.py
--- a/tests/py/test_record_an_exchange.py
+++ b/tests/py/test_record_an_exchange.py
@@ -1,8 +1,10 @@
 from __future__ import unicode_literals
 from decimal import Decimal
 
+from psycopg2 import IntegrityError
 from aspen.utils import utcnow
 from gratipay.testing import Harness
+from gratipay.models.exchange_route import ExchangeRoute
 
 
 class TestRecordAnExchange(Harness):
@@ -13,65 +15,108 @@ class TestRecordAnExchange(Harness):
     def make_participants(self):
         now = utcnow()
         self.make_participant('alice', claimed_time=now, is_admin=True)
-        self.make_participant('bob', claimed_time=now)
+        self.bob = self.make_participant('bob', claimed_time=now)
 
-    def record_an_exchange(self, amount, fee, note, status='succeeded', make_participants=True):
+    def record_an_exchange(self, data, make_participants=True):
         if make_participants:
             self.make_participants()
-        data = {'amount': amount, 'fee': fee, 'note': note}
-        if status is not None:
-            data['status'] = status
+
+        data.setdefault('status', 'succeeded')
+        data.setdefault('note', 'noted')
+
+        if 'route_id' not in data:
+            try:
+                data['route_id'] = ExchangeRoute.insert(self.bob, 'paypal', 'bob@gmail.com').id
+            except IntegrityError:
+                data['route_id'] = ExchangeRoute.from_network(self.bob, 'paypal').id
+
+        if data['status'] is None:
+            del(data['status'])
+        if data['route_id'] is None:
+            del(data['route_id'])
+
         return self.client.PxST('/~bob/history/record-an-exchange', data, auth_as='alice')
 
+
     # tests
     # =====
 
     def test_success_is_302(self):
-        actual = self.record_an_exchange('10', '0', 'foo').code
-        assert actual == 302
+        response = self.record_an_exchange({'amount': '10', 'fee': '0'})
+        assert response.code == 302
+        assert response.headers['location'] == '/bob/history/'
 
     def test_non_admin_is_403(self):
         self.make_participant('alice', claimed_time=utcnow())
-        self.make_participant('bob', claimed_time=utcnow())
-        actual = self.record_an_exchange('10', '0', 'foo', make_participants=False).code
+        self.bob = self.make_participant('bob', claimed_time=utcnow())
+        actual = self.record_an_exchange({'amount': '10', 'fee': '0'}, False).code
         assert actual == 403
 
     def test_bad_amount_is_400(self):
-        actual = self.record_an_exchange('cheese', '0', 'foo').code
-        assert actual == 400
+        response = self.record_an_exchange({'amount': 'cheese', 'fee': '0'})
+        assert response.code == 400
+        assert response.body == "Invalid amount/fee"
 
     def test_bad_fee_is_400(self):
-        actual = self.record_an_exchange('10', 'cheese', 'foo').code
-        assert actual == 400
+        response = self.record_an_exchange({'amount': '10', 'fee': 'cheese'})
+        assert response.code == 400
+        assert response.body == "Invalid amount/fee"
 
     def test_no_note_is_400(self):
-        actual = self.record_an_exchange('10', '0', '').code
-        assert actual == 400
+        response = self.record_an_exchange({'amount': '10', 'fee': '0', 'note': ''})
+        assert response.code == 400
+        assert response.body == "Invalid note"
 
     def test_whitespace_note_is_400(self):
-        actual = self.record_an_exchange('10', '0', '    ').code
-        assert actual == 400
+        response = self.record_an_exchange({'amount': '10', 'fee': '0', 'note': '   '})
+        assert response.code == 400
+        assert response.body == "Invalid note"
+
+    def test_no_route_id_is_400(self):
+        response = self.record_an_exchange({'amount': '10', 'fee': '0', 'route_id': None})
+        assert response.code == 400
+        assert response.body == "Invalid route_id"
+
+    def test_bad_route_id_is_400(self):
+        response = self.record_an_exchange({'amount': '10', 'fee': '0', 'route_id': 'foo'})
+        assert response.code == 400
+        assert response.body == "Invalid route_id"
+
+    def test_non_existent_route_id_is_400(self):
+        response = self.record_an_exchange({'amount': '10', 'fee': '0', 'route_id': '123456'})
+        assert response.code == 400
+        assert response.body == "Route doesn't exist"
+
+    def test_route_should_belong_to_user_else_400(self):
+        alice = self.make_participant('alice', claimed_time=utcnow(), is_admin=True)
+        self.make_participant('bob', claimed_time=utcnow())
+        route = ExchangeRoute.insert(alice, 'paypal', 'alice@gmail.com')
+
+        response = self.record_an_exchange({'amount': '10', 'fee': '0', 'route_id': route.id}, False)
+        assert response.code == 400
+        assert response.body == "Route doesn't exist"
 
     def test_dropping_balance_below_zero_is_allowed_in_this_context(self):
-        self.record_an_exchange('-10', '0', 'noted')
+        self.record_an_exchange({'amount': '-10', 'fee': '0'})
         actual = self.db.one("SELECT balance FROM participants WHERE username='bob'")
         assert actual == Decimal('-10.00')
 
     def test_success_records_exchange(self):
-        self.record_an_exchange('10', '0.50', 'noted')
+        self.record_an_exchange({'amount': '10', 'fee': '0.50'})
         expected = { "amount": Decimal('10.00')
                    , "fee": Decimal('0.50')
                    , "participant": "bob"
                    , "recorder": "alice"
                    , "note": "noted"
+                   , "route": ExchangeRoute.from_network(self.bob, 'paypal').id
                     }
-        SQL = "SELECT amount, fee, participant, recorder, note " \
+        SQL = "SELECT amount, fee, participant, recorder, note, route " \
               "FROM exchanges"
         actual = self.db.one(SQL, back_as=dict)
         assert actual == expected
 
     def test_success_updates_balance(self):
-        self.record_an_exchange('10', '0', 'noted')
+        self.record_an_exchange({'amount': '10', 'fee': '0'})
         expected = Decimal('10.00')
         SQL = "SELECT balance FROM participants WHERE username='bob'"
         actual = self.db.one(SQL)
@@ -79,8 +124,10 @@ def test_success_updates_balance(self):
 
     def test_withdrawals_work(self):
         self.make_participant('alice', claimed_time=utcnow(), is_admin=True)
-        self.make_participant('bob', claimed_time=utcnow(), balance=20)
-        self.record_an_exchange('-7', '0', 'noted', make_participants=False)
+        self.bob = self.make_participant('bob', claimed_time=utcnow(), balance=20)
+
+        self.record_an_exchange({'amount': '-7', 'fee': '0'}, make_participants=False)
+
         expected = Decimal('13.00')
         SQL = "SELECT balance FROM participants WHERE username='bob'"
         actual = self.db.one(SQL)
@@ -88,20 +135,20 @@ def test_withdrawals_work(self):
 
     def test_withdrawals_take_fee_out_of_balance(self):
         self.make_participant('alice', claimed_time=utcnow(), is_admin=True)
-        self.make_participant('bob', claimed_time=utcnow(), balance=20)
-        self.record_an_exchange('-7', '1.13', 'noted', make_participants=False)
+        self.bob = self.make_participant('bob', claimed_time=utcnow(), balance=20)
+        self.bob = self.record_an_exchange({'amount': '-7', 'fee': '1.13'}, False)
         SQL = "SELECT balance FROM participants WHERE username='bob'"
         assert self.db.one(SQL) == Decimal('11.87')
 
     def test_can_set_status(self):
         self.make_participants()
         for status in ('pre', 'pending', 'failed', 'succeeded'):
-            self.record_an_exchange('10', '0', 'noted', status, False)
+            self.record_an_exchange({'amount': '10', 'fee': '0', 'status': status}, False)
             actual = self.db.one("SELECT status FROM exchanges ORDER BY timestamp desc LIMIT 1")
             assert actual == status
 
     def test_cant_record_new_exchanges_with_None_status(self):
-        r = self.record_an_exchange('10', '0', 'noted', None)
+        r = self.record_an_exchange({'amount': '10', 'fee': '0', 'status': None})
         assert r.code == 400
         assert self.db.one("SELECT count(*) FROM exchanges") == 0
 
@@ -109,26 +156,38 @@ def test_succeeded_affects_balance(self):
         self.make_participants()
         balance = 0
         for amount in ('10', '-10'):
-            self.record_an_exchange(amount, '0', 'noted', 'succeeded', False)
+            self.record_an_exchange({'amount': amount, 'fee': '0'}, False)
             balance += int(amount)
             assert self.db.one("SELECT balance FROM participants WHERE username='bob'") == balance
 
     def test_failed_doesnt_affect_balance(self):
         self.make_participants()
         for amount in ('10', '-10'):
-            self.record_an_exchange(amount, '0', 'noted', 'failed', False)
+            self.record_an_exchange({
+                'amount': amount,
+                'fee': '0',
+                'status': 'failed'
+            }, False)
             assert self.db.one("SELECT balance FROM participants WHERE username='bob'") == 0
 
     def test_other_statuses_dont_affect_balance_for_payins(self):
         self.make_participants()
         for status in ('pre', 'pending'):
-            self.record_an_exchange('10', '0', 'noted', status, False)
+            self.record_an_exchange({
+                'amount': '10',
+                'fee': '0',
+                'status': status
+            }, False)
             assert self.db.one("SELECT balance FROM participants WHERE username='bob'") == 0
 
     def test_other_statuses_affect_balance_for_payouts(self):
         self.make_participants()
         balance = 0
         for status in ('pre', 'pending'):
-            self.record_an_exchange('-10', '0', 'noted', status, False)
+            self.record_an_exchange({
+                'amount': '-10',
+                'fee': '0',
+                'status': status
+            }, False)
             balance -= 10
             assert self.db.one("SELECT balance FROM participants WHERE username='bob'") == balance