diff --git a/bin/masspay.py b/bin/masspay.py index 0f2e2d1613..2b477d70d6 100755 --- a/bin/masspay.py +++ b/bin/masspay.py @@ -29,6 +29,7 @@ import requests from gratipay import wireup +from gratipay.billing.exchanges import get_ready_payout_routes_by_network from httplib import IncompleteRead @@ -121,49 +122,29 @@ def assess_fee(self): def compute_input_csv(): db = wireup.db(wireup.env()) - participants = db.all(""" - - SELECT p.*, r.address AS paypal_email, r.fee_cap AS paypal_fee_cap - FROM exchange_routes r - JOIN participants p ON p.id = r.participant - WHERE r.network = 'paypal' - AND p.balance > 0 - - ---- Only include team owners - ---- TODO: Include members on payroll once process_payroll is implemented - - AND ( SELECT count(*) - FROM teams t - WHERE t.owner = p.username - AND t.is_approved IS TRUE - AND t.is_closed IS NOT TRUE - ) > 0 - - ORDER BY p.balance DESC - - """) + routes = get_ready_payout_routes_by_network(db, 'paypal') writer = csv.writer(open(INPUT_CSV, 'w+')) print_rule(88) headers = "username", "email", "fee cap", "balance", "tips", "amount" print("{:<24}{:<32} {:^7} {:^7} {:^7} {:^7}".format(*headers)) print_rule(88) total_gross = 0 - for participant in participants: - total = participant.giving - amount = participant.balance - total + for route in routes: + total = route.participant.giving + amount = route.participant.balance - total if amount < 0.50: # Minimum payout of 50 cents. I think that otherwise PayPal upcharges to a penny. # See https://github.com/gratipay/gratipay.com/issues/1958. continue total_gross += amount - print("{:<24}{:<32} {:>7} {:>7} {:>7} {:>7}".format( participant.username - , participant.paypal_email - , participant.paypal_fee_cap - , participant.balance + print("{:<24}{:<32} {:>7} {:>7} {:>7} {:>7}".format( route.participant.username + , route.address + , route.fee_cap + , route.participant.balance , total , amount )) - row = (participant.username, participant.paypal_email, participant.paypal_fee_cap, amount) + row = (route.username, route.address, route.fee_cap, amount) writer.writerow(row) print(" "*80, "-"*7) print("{:>88}".format(total_gross)) diff --git a/gratipay/billing/exchanges.py b/gratipay/billing/exchanges.py index 9583759f15..408060a9f6 100644 --- a/gratipay/billing/exchanges.py +++ b/gratipay/billing/exchanges.py @@ -293,6 +293,44 @@ def _prep_hit(unrounded): return cents, amount_str, upcharged, fee +def get_ready_payout_routes_by_network(db, network): + hack = db.all(""" + SELECT p.*::participants, r.*::exchange_routes + FROM participants p + JOIN current_exchange_routes r ON p.id = r.participant + WHERE p.balance > 0 + AND r.network = %s + AND ( + + ----- Include team owners + + (SELECT count(*) + FROM teams t + WHERE t.owner = p.username + AND t.is_approved IS TRUE + AND t.is_closed IS NOT TRUE + ) > 0 + + + OR -- Include green-lit Gratipay 1.0 balances + + p.status_of_1_0_balance='pending-payout' + + + ----- TODO: Include members on payroll once process_payroll is implemented + + ) + """, (network,)) + + # Work around lack of proper nesting in postgres.orm. + out = [] + for participant, route in hack: + route.__dict__['participant'] = participant + out.append(route) + + return out + + def record_exchange(db, route, amount, fee, participant, status, error=None): """Given a Bunch of Stuff, return an int (exchange_id). diff --git a/gratipay/billing/payday.py b/gratipay/billing/payday.py index 60c47a4b76..b0cc4bc5b9 100644 --- a/gratipay/billing/payday.py +++ b/gratipay/billing/payday.py @@ -19,7 +19,8 @@ import aspen.utils from aspen import log from gratipay.billing.exchanges import ( - ach_credit, cancel_card_hold, capture_card_hold, create_card_hold, upcharge + ach_credit, cancel_card_hold, capture_card_hold, create_card_hold, upcharge, + get_ready_payout_routes_by_network ) from gratipay.exceptions import NegativeBalance from gratipay.models import check_db @@ -406,36 +407,17 @@ def payout(self): bank accounts of participants. """ log("Starting payout loop.") - participants = self.db.all(""" - SELECT p.*::participants - FROM participants p - WHERE balance > 0 - AND ( SELECT count(*) - FROM exchange_routes r - WHERE r.participant = p.id - AND network = 'balanced-ba' - ) > 0 - - ---- Only include team owners - ---- TODO: Include members on payroll once process_payroll is implemented - - AND ( SELECT count(*) - FROM teams t - WHERE t.owner = p.username - AND t.is_approved IS TRUE - AND t.is_closed IS NOT TRUE - ) > 0 - """) - def credit(participant): - if participant.is_suspicious is None: - log("UNREVIEWED: %s" % participant.username) + routes = get_ready_payout_routes_by_network(self.db, 'balanced-ba') + def credit(route): + if route.participant.is_suspicious is None: + log("UNREVIEWED: %s" % route.participant.username) return - withhold = participant.giving - error = ach_credit(self.db, participant, withhold) + withhold = route.participant.giving + error = ach_credit(self.db, route.participant, withhold) if error: self.mark_ach_failed() - threaded_map(credit, participants) - log("Did payout for %d participants." % len(participants)) + threaded_map(credit, routes) + log("Did payout for %d participants." % len(routes)) self.db.self_check() log("Checked the DB.") diff --git a/sql/branch.sql b/sql/branch.sql new file mode 100644 index 0000000000..5d6a1d888f --- /dev/null +++ b/sql/branch.sql @@ -0,0 +1,26 @@ +BEGIN; + + CREATE TYPE status_of_1_0_balance AS ENUM + ('unresolved', 'pending-payout', 'resolved'); + + ALTER TABLE participants + ADD COLUMN status_of_1_0_balance status_of_1_0_balance + NOT NULL + DEFAULT 'unresolved'; + + CREATE FUNCTION set_status_of_1_0_balance_to_resolved() RETURNS trigger AS $$ + BEGIN + UPDATE participants + SET status_of_1_0_balance='resolved' + WHERE id = NEW.id; + RETURN NULL; + END; + $$ LANGUAGE plpgsql; + + CREATE TRIGGER update_status_of_1_0_balance + AFTER UPDATE OF balance ON participants + FOR EACH ROW + WHEN (OLD.balance > 0 AND NEW.balance = 0) + EXECUTE PROCEDURE set_status_of_1_0_balance_to_resolved(); + +END; diff --git a/tests/py/test_billing_payday.py b/tests/py/test_billing_payday.py index 9ed460c769..b4facf8a5f 100644 --- a/tests/py/test_billing_payday.py +++ b/tests/py/test_billing_payday.py @@ -564,6 +564,42 @@ def test_payout_ach_error_gets_recorded(self, ach_credit): payday = self.fetch_payday() assert payday['nach_failing'] == 1 + @mock.patch('gratipay.billing.payday.ach_credit') + def test_payout_pays_out_Gratipay_1_0_balance(self, ach): + alice = self.make_participant('alice', claimed_time='now', is_suspicious=False, + balanced_customer_href='foo', last_ach_result='', + balance=20, status_of_1_0_balance='pending-payout') + Payday.start().payout() + + assert ach.call_count == 1 + assert ach.call_args_list[0][0][1].id == alice.id + assert ach.call_args_list[0][0][2] == 0 + + @mock.patch('balanced.BankAccount.credit') + def test_paying_out_sets_1_0_status_to_resolved(self, credit): + alice = self.make_participant('alice', claimed_time='now', is_suspicious=False, + balanced_customer_href='foo', last_ach_result='', + balance=0, status_of_1_0_balance='pending-payout') + self.make_exchange('balanced-cc', 20, 0, alice) # sets balance, and satisfies self_check + Payday.start().payout() + alice = Participant.from_username('alice') + assert alice.status_of_1_0_balance == 'resolved' + assert alice.balance == 0 + + @mock.patch('balanced.BankAccount.credit') + def test_payout_ignores_unresolved(self, credit): + bob = self.make_participant('bob', claimed_time='now', is_suspicious=False, + balanced_customer_href='foo', last_ach_result='', + balance=13, status_of_1_0_balance='unresolved') + alice = self.make_participant('alice', claimed_time='now', is_suspicious=False, + balanced_customer_href='foo', last_ach_result='', + balance=0, status_of_1_0_balance='pending-payout') + self.make_exchange('balanced-cc', 20, 0, alice) + Payday.start().payout() + bob = Participant.from_username('bob') + assert bob.status_of_1_0_balance == 'unresolved' + assert bob.balance == 13 + class TestNotifyParticipants(EmailHarness):