Skip to content
This repository has been archived by the owner on Feb 8, 2018. It is now read-only.

Add coinbase payin support #2841

Closed
wants to merge 6 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions branch.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
BEGIN;
ALTER TABLE participants ADD COLUMN last_coinbase_result text DEFAULT NULL;
END;
6 changes: 3 additions & 3 deletions fake_payday.sql
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ CREATE TEMPORARY TABLE temp_participants ON COMMIT DROP AS
, 0::numeric(35,2) AS receiving
, 0 as npatrons
, goal
, COALESCE(last_bill_result = '', false) AS credit_card_ok
, COALESCE(last_bill_result = '' OR last_coinbase_result = '', false) AS has_funding
FROM participants
WHERE is_suspicious IS NOT true;

Expand Down Expand Up @@ -47,7 +47,7 @@ CREATE OR REPLACE FUNCTION fake_tip() RETURNS trigger AS $$
FROM temp_participants p
WHERE username = NEW.tipper
);
IF (NEW.amount > tipper.fake_balance AND NOT tipper.credit_card_ok) THEN
IF (NEW.amount > tipper.fake_balance AND NOT tipper.has_funding) THEN
RETURN NULL;
END IF;
IF (NEW.claimed) THEN
Expand Down Expand Up @@ -143,7 +143,7 @@ UPDATE temp_tips t
SET is_funded = true
FROM temp_participants p
WHERE p.username = t.tipper
AND p.credit_card_ok;
AND p.has_funding;

SELECT settle_tip_graph();

Expand Down
25 changes: 20 additions & 5 deletions gratipay/billing/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,15 @@ def store_result(db, thing, participant, new_result):

Also update receiving amounts of the participant's tippees.
"""
assert thing in ("credit card", "bank account"), thing
column = 'last_%s_result' % ('bill' if thing == 'credit card' else 'ach')
assert thing in ("credit card", "bank account", "coinbase account"), thing
if thing == "credit card":
x = "bill"
elif thing == "bank account":
x = "ach"
else:
assert thing == "coinbase account"
x = "coinbase"
column = 'last_%s_result' % x
old_result = getattr(participant, column)

# Update last_thing_result in the DB
Expand All @@ -42,7 +49,7 @@ def store_result(db, thing, participant, new_result):
participant.set_attributes(**{column: new_result})

# Update the receiving amounts of tippees if requested and necessary
if thing != "credit card":
if thing == "bank account":
return
if participant.is_suspicious or new_result == old_result:
return
Expand Down Expand Up @@ -78,6 +85,8 @@ def associate(db, thing, participant, balanced_account, balanced_thing_uri):
try:
if thing == "credit card":
obj = balanced.Card.fetch(balanced_thing_uri)
elif thing == "coinbase account":
obj = balanced.ExternalAccount.fetch(balanced_thing_uri)
else:
assert thing == "bank account", thing # sanity check
obj = balanced.BankAccount.fetch(balanced_thing_uri)
Expand All @@ -88,6 +97,7 @@ def associate(db, thing, participant, balanced_account, balanced_thing_uri):
error = ''
typecheck(error, unicode)

#TODO - modify store_result to handle Coinbase
store_result(db, thing, participant, error)
return error

Expand All @@ -101,10 +111,15 @@ def invalidate_on_balanced(thing, customer):
See: https://github.com/balanced/balanced-api/issues/22

"""
assert thing in ("credit card", "bank account")
assert thing in ("credit card", "bank account", "coinbase account")
typecheck(customer, balanced.Customer)

things = customer.cards if thing == "credit card" else customer.bank_accounts
if thing == "credit card":
things = customer.cards
elif thing == "coinbase account":
things = customer.external_accounts
else:
things = customer.bank_accounts

for _thing in things:
_thing.unstore()
Expand Down
5 changes: 4 additions & 1 deletion gratipay/models/participant.py
Original file line number Diff line number Diff line change
Expand Up @@ -680,6 +680,9 @@ def get_teams(self):
def accepts_tips(self):
return (self.goal is None) or (self.goal >= 0)

@property
def has_funding(self):
return self.last_bill_result == '' or self.last_coinbase_result == ''

def insert_into_communities(self, is_member, name, slug):
participant_id = self.id
Expand Down Expand Up @@ -785,7 +788,7 @@ def update_is_closed(self, is_closed, cursor=None):

def update_giving(self, cursor=None):
# Update is_funded on tips
if self.last_bill_result == '':
if self.has_funding:
(cursor or self.db).run("""
UPDATE current_tips
SET is_funded = true
Expand Down
85 changes: 85 additions & 0 deletions js/gratipay/payments.js
Original file line number Diff line number Diff line change
Expand Up @@ -452,3 +452,88 @@ Gratipay.payments.cc.handleResponse = function(response) {
, detailedFeedback
);
};

// Coinbase Accounts
// =================

Gratipay.payments.cb = {};

Gratipay.payments.cb.init = function() {
$('#cb-button').click(Gratipay.payments.cb.toggleAccount);

// Lazily depend on Balanced.
var balanced_js = "https://js.balancedpayments.com/1.1/balanced.min.js";
jQuery.getScript(balanced_js, function() {
Gratipay.havePayments = true;
});
};

Gratipay.payments.cb.toggleAccount = function(e) {
e.preventDefault();

// TODO - disable the button until action is done.
if ($(this).data("action") == "add") {
Gratipay.payments.cb.addAccount();
}
else {
var msg = "Really disconnect your Coinbase account?";
if (confirm(msg)) {
Gratipay.payments.cb.removeAccount();
}
}
}

Gratipay.payments.cb.addAccount = function() {
balanced.externalAccount.create('coinbase', Gratipay.payments.cb.handleResponse);
};

Gratipay.payments.cb.removeAccount = function() {
jQuery.ajax(
{ url: '/coinbase.json'
, data: {action: "delete"}
, type: "POST"
, success: function() {
Gratipay.notification("Disconnected your coinbase account", 'success');
$('#cb-status').text("");
$('#cb-button').text("+ Add");
$('#cb-button').data("action", "add");
}
, error: function() {
Gratipay.notification("Sorry, something went wrong deleting your Coinbase account", 'error');
}
}
);
};

Gratipay.payments.cb.handleResponse = function(response) {
if (response.status_code !== 201) {
// TODO - Is there any reason to store the error?
Gratipay.notification("Coinbase authorization failed", 'error')
return;
}

/* The request to create the token succeeded. We now have a single-use
* token associated with the Coinbase account. This token can be
* used to associate the account with a customer. This happens on the
* server side.
*/

function onError() {
Gratipay.notification("Oops, couldn't connect your Coinbase account!", 'error')
}

function onSuccess() {
Gratipay.notification("Your coinbase account is now connected.", 'success')
$('#cb-status').text("Your coinbase account is connected.");
$('#cb-button').text("Remove");
$('#cb-button').data("action", "remove");
}

jQuery.ajax({ url: "/coinbase.json"
, type: "POST"
, data: {account_href: response.external_accounts[0].href}
, dataType: "json"
, success: onSuccess
, error: onError
});
};
44 changes: 44 additions & 0 deletions tests/py/test_billing_payday.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,50 @@ def check():
Payday.start().update_cached_amounts()
check()

def test_update_cached_amounts_considers_coinbase(self):
alice = self.make_participant('alice', claimed_time='now', last_bill_result='')
bob = self.make_participant('bob', claimed_time='now', last_bill_result=None,
last_coinbase_result='')
carl = self.make_participant('carl', claimed_time='now', last_bill_result="Fail!",
last_coinbase_result='Fail!')
dana = self.make_participant('dana', claimed_time='now')
alice.set_tip_to(dana, '1.00')
bob.set_tip_to(dana, '2.00')
carl.set_tip_to(dana, '3.00')

def check():
alice = Participant.from_username('alice')
bob = Participant.from_username('bob')
carl = Participant.from_username('carl')
dana = Participant.from_username('dana')
assert alice.giving == D('1.00')
assert bob.giving == D('2.00')
assert carl.giving == D('0.00')
assert dana.receiving == D('3.00')
assert dana.npatrons == 2
funded_tips = self.db.all("SELECT amount FROM tips WHERE is_funded ORDER BY id")
assert funded_tips == [1, 2]

# Pre-test check
check()

# Check that update_cached_amounts doesn't mess anything up
Payday.start().update_cached_amounts()
check()

# Check that update_cached_amounts actually updates amounts
self.db.run("""
UPDATE tips SET is_funded = false;
UPDATE participants
SET giving = 0
, npatrons = 0
, pledging = 0
, receiving = 0
, taking = 0;
""")
Payday.start().update_cached_amounts()
check()

@mock.patch('gratipay.billing.payday.log')
def test_start_prepare(self, log):
self.clear_tables()
Expand Down
14 changes: 14 additions & 0 deletions tests/py/test_pages.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,3 +165,17 @@ def test_new_participant_can_edit_profile(self):
self.make_participant('alice', claimed_time='now')
body = self.client.GET("/alice/", auth_as="alice").body
assert b'Edit' in body

def test_account_page_coinbase_account_connected(self):
self.make_participant('alice', claimed_time='now')
self.db.run("UPDATE participants SET last_coinbase_result='' WHERE username = 'alice'")
actual = self.client.GET("/alice/account/", auth_as="alice").body
expected = "Your coinbase account is connected"
assert expected in actual

def test_account_page_coinbase_account_failing(self):
self.make_participant('alice', claimed_time='now')
self.db.run("UPDATE participants SET last_coinbase_result='error' WHERE username = 'alice'")
actual = self.client.GET("/alice/account/", auth_as="alice").body
expected = "Your coinbase account is <b>failing</b>"
assert expected in actual
20 changes: 20 additions & 0 deletions tests/py/test_participant.py
Original file line number Diff line number Diff line change
Expand Up @@ -540,6 +540,26 @@ def test_only_funded_tips_count(self):
funded_tips = self.db.all("SELECT amount FROM tips WHERE is_funded ORDER BY id")
assert funded_tips == [3, 6, 5]

def test_coinbase_result_changes_is_funded(self):
alice = self.make_participant('alice', claimed_time='now', last_coinbase_result='')
bob = self.make_participant('bob', claimed_time='now', last_coinbase_result=None)
carl = self.make_participant('carl', claimed_time='now', last_coinbase_result="Fail!")
raj = self.make_participant('raj', claimed_time='now', last_coinbase_result="Fail!", last_bill_result='')
dana = self.make_participant('dana', claimed_time='now')
alice.set_tip_to(dana, '1.00') # Funded
bob.set_tip_to(dana, '2.00') # Not Funded
carl.set_tip_to(dana, '3.00') # Not Funded
raj.set_tip_to(dana, '4.00') # Funded by CC

assert alice.giving == Decimal('1.00')
assert bob.giving == Decimal('0.00')
assert carl.giving == Decimal('0.00')
assert raj.giving == Decimal('4.00')
assert dana.receiving == Decimal('5.00')

funded_tips = self.db.all("SELECT amount FROM tips WHERE is_funded ORDER BY id")
assert funded_tips == [1, 4]

def test_only_latest_tip_counts(self):
alice = self.make_participant('alice', claimed_time='now', last_bill_result='')
bob = self.make_participant('bob', claimed_time='now', last_bill_result='')
Expand Down
32 changes: 32 additions & 0 deletions www/%username/account/index.html.spt
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import balanced

from gratipay.utils import get_participant
from cgi import escape
Expand All @@ -17,6 +18,15 @@ emails = participant.get_emails()

{% block scripts %}
<script>$(document).ready(Gratipay.account.init);</script>

{% if not user.ANON %}
<script>
$(document).ready(function() {
Gratipay.payments.cb.init();
});
</script>
{% endif %}

{{ super() }}
{% endblock %}

Expand Down Expand Up @@ -93,6 +103,28 @@ emails = participant.get_emails()
{% endif %}
</td>
</tr>
<tr>
<td class="account-type">
<img src="{{ website.asset_url }}/coinbase.jpg" />
</td>
<td class="account-details">
<div class="account-type">{{ _("Coinbase Account") }}</div>
{% if participant.last_coinbase_result == "" %}
<span id="cb-status">{{ _("Your coinbase account is {0}connected{1}", "", "") }}</span>
{% elif participant.last_coinbase_result %}
<span id="cb-status">{{ _("Your coinbase account is {0}failing{1}", "<b>", "</b>") }}</span>
{% else %}
<span id="cb-status"></span>
{% endif %}
</td>
<td class="account-action">
{% if participant.last_coinbase_result is none %}
<a class="button auth-button" href="#" id="cb-button" data-action="add">{{ _("+ Add") }}</a>
{% else %}
<a class="button auth-button" href="#" id="cb-button" data-action="remove">{{ _("Remove") }}</a>
{% endif %}
</td>
</tr>
</table>

<h2>{{ _("Withdrawing Money") }}
Expand Down
Binary file added www/assets/%version/coinbase.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
48 changes: 48 additions & 0 deletions www/coinbase.json.spt
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
"""

When a user connects her Coinbase account, Balanced always gives us a
single-use token in return, provided that the required permissions were granted.
This script takes the token and tries to associate it with
a Balanced account object (creating one as needed).

"""
from aspen import Response
from gratipay import billing

[-----------------------------------------------------------------------------]

if user.ANON:
raise Response(404)

request.allow('POST')
out = {}

if body.get('action') == 'delete':
billing.clear( website.db
, u"coinbase account"
, user.participant.username
, user.participant.balanced_customer_href
)
# TODO
#elif body.get('action') == 'store-error':
# billing.store_result(website.db, u"credit card", user.participant.username, body['msg'])
else:

# Associate the single-use token representing the coinbase account.

account_href = body['account_href']

error = billing.associate( website.db
, u"coinbase account"
, user.participant.username
, user.participant.balanced_customer_href
, account_href
)

if error:
out = {"problem": "Problem", "error": error}
else:
out = {"problem": ""}

[---] application/json via json_dump
out