diff --git a/coinbase/__init__.py b/coinbase/__init__.py index 8530ba5..be7c31e 100644 --- a/coinbase/__init__.py +++ b/coinbase/__init__.py @@ -40,12 +40,14 @@ import json import os import inspect +from urllib import urlencode +import hashlib +import hmac +import time -#TODO: Switch to decimals from floats -#from decimal import Decimal -from coinbase.config import COINBASE_ENDPOINT -from coinbase.models import CoinbaseAmount, CoinbaseTransaction, CoinbaseUser, CoinbaseTransfer, CoinbaseError +from .config import COINBASE_ENDPOINT, COINBASE_ITEMS_PER_PAGE +from .models import CoinbaseAmount, CoinbaseTransaction, CoinbaseUser, CoinbaseTransfer, CoinbaseError, CoinbaseButton, CoinbaseOrder class CoinbaseAccount(object): @@ -57,7 +59,7 @@ class CoinbaseAccount(object): def __init__(self, oauth2_credentials=None, - api_key=None): + api_key=None, api_secret=None): """ :param oauth2_credentials: JSON representation of Coinbase oauth2 credentials @@ -96,18 +98,15 @@ def __init__(self, #Set our request parameters to be empty self.global_request_params = {} - elif api_key: - if type(api_key) is str: - + elif api_key and api_secret: + if type(api_key) is str and type(api_secret) is str: #Set our API Key self.api_key = api_key - - #Set our global_request_params - self.global_request_params = {'api_key':api_key} + self.api_secret = api_secret else: - print "Your api_key must be a string" + print "Your api_key and api_secret must be strings" else: - print "You must pass either an api_key or oauth_credentials" + print "You must pass either api_key and api_secret or oauth_credentials" def _check_oauth_expired(self): """ @@ -158,18 +157,57 @@ def _prepare_request(self): #Check if the oauth token is expired and refresh it if necessary self._check_oauth_expired() + _get = lambda self, url, data=None, params=None: self.make_request(self.session.get , url, data, params) + _post = lambda self, url, data=None, params=None: self.make_request(self.session.post , url, data, params) + _put = lambda self, url, data=None, params=None: self.make_request(self.session.put , url, data, params) + _delete = lambda self, url, data=None, params=None: self.make_request(self.session.delete, url, data, params) + + def make_request(self, request_func, url, data=None, params=None): + # We need body as a string to compute the hmac signature + body = json.dumps(data) if data else '' + # We also need the full url, so we urlencode the params here + url = COINBASE_ENDPOINT + url + ('?' + urlencode(params) if params else '') + + if hasattr(self, 'api_key'): + nonce = str(int(time.time() * 1e6)) + + message = nonce + url + body + signature = hmac.new(self.api_secret, message, hashlib.sha256).hexdigest() + self.session.headers.update({ + 'ACCESS_KEY': self.api_key, + 'ACCESS_SIGNATURE': signature, + 'ACCESS_NONCE': nonce + }) + + response = request_func(url, data=body) + response_parsed = response.json() + + if response.status_code != 200: + if 'error' in response_parsed: + raise CoinbaseError(response_parsed['error']) + else: + raise CoinbaseError('Response code not 200, was {}'.format(response.status_code)) + + if 'success' in response_parsed and not response_parsed['success']: + if 'error' in response_parsed: + raise CoinbaseError(response_parsed['error']) + elif 'errors' in response_parsed: + raise CoinbaseError(response_parsed['errors']) + else: + raise CoinbaseError('Success was false in response, unknown error') + + return response_parsed + + @property def balance(self): """ Retrieve coinbase's account balance - :return: CoinbaseAmount (float) with currency attribute + :return: CoinbaseAmount with currency attribute """ - - url = COINBASE_ENDPOINT + '/account/balance' - response = self.session.get(url, params=self.global_request_params) - results = response.json() - return CoinbaseAmount(results['amount'], results['currency']) + response_parsed = self._get('/account/balance') + return CoinbaseAmount(response_parsed['amount'], response_parsed['currency']) @property def receive_address(self): @@ -178,9 +216,8 @@ def receive_address(self): :return: String address of account """ - url = COINBASE_ENDPOINT + '/account/receive_address' - response = self.session.get(url, params=self.global_request_params) - return response.json()['address'] + response_parsed = self._get('/account/receive_address') + return response_parsed['address'] @property def contacts(self): @@ -189,66 +226,41 @@ def contacts(self): :return: List of contacts in the account """ - url = COINBASE_ENDPOINT + '/contacts' - response = self.session.get(url, params=self.global_request_params) - return [contact['contact'] for contact in response.json()['contacts']] - - - - + response_parsed = self._get('/contacts') + return [contact['contact'] for contact in response_parsed['contacts']] def buy_price(self, qty=1): """ Return the buy price of BitCoin in USD :param qty: Quantity of BitCoin to price - :return: CoinbaseAmount (float) with currency attribute + :return: CoinbaseAmount with currency attribute """ - url = COINBASE_ENDPOINT + '/prices/buy' - params = {'qty': qty} - params.update(self.global_request_params) - response = self.session.get(url, params=params) - results = response.json() - return CoinbaseAmount(results['amount'], results['currency']) + response_parsed = self._get('/prices/buy', params={"qty": qty}) + return CoinbaseAmount(response_parsed['amount'], response_parsed['currency']) def sell_price(self, qty=1): """ Return the sell price of BitCoin in USD :param qty: Quantity of BitCoin to price - :return: CoinbaseAmount (float) with currency attribute + :return: CoinbaseAmount with currency attribute """ - url = COINBASE_ENDPOINT + '/prices/sell' - params = {'qty': qty} - params.update(self.global_request_params) - response = self.session.get(url, params=params) - results = response.json() - return CoinbaseAmount(results['amount'], results['currency']) - - # @property - # def user(self): - # url = COINBASE_ENDPOINT + '/account/receive_address' - # response = self.session.get(url) - # return response.json() - + response_parsed = self._get('/prices/sell', params={"qty": qty}) + return CoinbaseAmount(response_parsed['amount'], response_parsed['currency']) def buy_btc(self, qty, pricevaries=False): """ Buy BitCoin from Coinbase for USD :param qty: BitCoin quantity to be bought :param pricevaries: Boolean value that indicates whether or not the transaction should - be processed if Coinbase cannot gaurentee the current price. + be processed if Coinbase cannot guarantee the current price. :return: CoinbaseTransfer with all transfer details on success or CoinbaseError with the error list received from Coinbase on failure """ - url = COINBASE_ENDPOINT + '/buys' request_data = { "qty": qty, "agree_btc_amount_varies": pricevaries } - response = self.session.post(url=url, data=json.dumps(request_data), params=self.global_request_params) - response_parsed = response.json() - if response_parsed['success'] == False: - return CoinbaseError(response_parsed['errors']) - + response_parsed = self._post('/buys', data=json.dumps(request_data)) return CoinbaseTransfer(response_parsed['transfer']) @@ -259,15 +271,7 @@ def sell_btc(self, qty): :return: CoinbaseTransfer with all transfer details on success or CoinbaseError with the error list received from Coinbase on failure """ - url = COINBASE_ENDPOINT + '/sells' - request_data = { - "qty": qty, - } - response = self.session.post(url=url, data=json.dumps(request_data), params=self.global_request_params) - response_parsed = response.json() - if response_parsed['success'] == False: - return CoinbaseError(response_parsed['errors']) - + response_parsed = self._post('/sells', data=json.dumps({"qty": qty})) return CoinbaseTransfer(response_parsed['transfer']) @@ -280,7 +284,6 @@ def request(self, from_email, amount, notes='', currency='BTC'): :param currency: Currency of the request :return: CoinbaseTransaction with status and details """ - url = COINBASE_ENDPOINT + '/transactions/request_money' if currency == 'BTC': request_data = { @@ -300,12 +303,7 @@ def request(self, from_email, amount, notes='', currency='BTC'): } } - response = self.session.post(url=url, data=json.dumps(request_data), params=self.global_request_params) - response_parsed = response.json() - if response_parsed['success'] == False: - pass - #DO ERROR HANDLING and raise something - + response_parsed = self._post('/transactions/request_money', data=json.dumps(request_data)) return CoinbaseTransaction(response_parsed['transaction']) def send(self, to_address, amount, notes='', currency='BTC'): @@ -317,7 +315,6 @@ def send(self, to_address, amount, notes='', currency='BTC'): :param currency: Currency to send :return: CoinbaseTransaction with status and details """ - url = COINBASE_ENDPOINT + '/transactions/send_money' if currency == 'BTC': request_data = { @@ -338,12 +335,7 @@ def send(self, to_address, amount, notes='', currency='BTC'): } } - response = self.session.post(url=url, data=json.dumps(request_data), params=self.global_request_params) - response_parsed = response.json() - - if response_parsed['success'] == False: - raise RuntimeError('Transaction Failed') - + response_parsed = self._post('/transactions/send_money', data=json.dumps(request_data)) return CoinbaseTransaction(response_parsed['transaction']) @@ -353,7 +345,6 @@ def transactions(self, count=30): :param count: How many transactions to retrieve :return: List of CoinbaseTransaction objects """ - url = COINBASE_ENDPOINT + '/transactions' pages = count / 30 + 1 transactions = [] @@ -362,15 +353,12 @@ def transactions(self, count=30): for page in xrange(1, pages + 1): if not reached_final_page: - params = {'page': page} - params.update(self.global_request_params) - response = self.session.get(url=url, params=params) - parsed_transactions = response.json() + response_parsed = self._get('/transactions', params={'page': page}) - if parsed_transactions['num_pages'] == page: + if response_parsed['num_pages'] == page: reached_final_page = True - for transaction in parsed_transactions['transactions']: + for transaction in response_parsed['transactions']: transactions.append(CoinbaseTransaction(transaction['transaction'])) return transactions @@ -381,7 +369,6 @@ def transfers(self, count=30): :param count: How many transfers to retrieve :return: List of CoinbaseTransfer objects """ - url = COINBASE_ENDPOINT + '/transfers' pages = count / 30 + 1 transfers = [] @@ -390,15 +377,12 @@ def transfers(self, count=30): for page in xrange(1, pages + 1): if not reached_final_page: - params = {'page': page} - params.update(self.global_request_params) - response = self.session.get(url=url, params=params) - parsed_transfers = response.json() + response_parsed = self._get('/transfers', params={'page': page}) - if parsed_transfers['num_pages'] == page: + if response_parsed['num_pages'] == page: reached_final_page = True - for transfer in parsed_transfers['transfers']: + for transfer in response_parsed['transfers']: transfers.append(CoinbaseTransfer(transfer['transfer'])) return transfers @@ -409,15 +393,8 @@ def get_transaction(self, transaction_id): :param transaction_id: Unique transaction identifier :return: CoinbaseTransaction object with transaction details """ - url = COINBASE_ENDPOINT + '/transactions/' + str(transaction_id) - response = self.session.get(url, params=self.global_request_params) - results = response.json() - - if results.get('success', True) == False: - pass - #TODO: Add error handling - - return CoinbaseTransaction(results['transaction']) + response_parsed = self._get('/transactions/{id}'.format(id=transaction_id)) + return CoinbaseTransaction(response_parsed['transaction']) def get_user_details(self): """ @@ -425,11 +402,9 @@ def get_user_details(self): :return: CoinbaseUser object with user details """ - url = COINBASE_ENDPOINT + '/users' - response = self.session.get(url, params=self.global_request_params) - results = response.json() + response_parsed = self._get('/users') - user_details = results['users'][0]['user'] + user_details = response_parsed['users'][0]['user'] #Convert our balance and limits to proper amounts balance = CoinbaseAmount(user_details['balance']['amount'], user_details['balance']['currency']) @@ -455,14 +430,128 @@ def generate_receive_address(self, callback_url=None): :param callback_url: The URL to receive instant payment notifications :return: The new string address """ - url = COINBASE_ENDPOINT + '/account/generate_receive_address' request_data = { "address": { "callback_url": callback_url } } - response = self.session.post(url=url, data=json.dumps(request_data), params=self.global_request_params) - return response.json()['address'] + response_parsed = self._post('/account/generate_receive_address', data=json.dumps(request_data)) + return response_parsed['address'] + + def create_button(self, name, + price, + currency='BTC', + type=None, + style=None, + text=None, + description=None, + custom=None, + callback_url=None, + success_url=None, + cancel_url=None, + info_url=None, + variable_price=None, + choose_price=None, + include_address=None, + include_email=None, + price1=None, price2=None, price3=None, price4=None, price5=None): + """ + Create a new button + :param name: The name of the item for which you are collecting bitcoin. + :param price: The price of the item + :param currency: The currency to charge + :param type: One of buy_now, donation, and subscription. Default is buy_now. + :param style: One of buy_now_large, buy_now_small, donation_large, donation_small, subscription_large, subscription_small, custom_large, custom_small, and none. Default is buy_now_large. none is used if you plan on triggering the payment modal yourself using your own button or link. + :param text: Allows you to customize the button text on custom_large and custom_small styles. Default is Pay With Bitcoin. + :param description: Longer description of the item in case you want it added to the user's transaction notes. + :param custom: An optional custom parameter. Usually an Order, User, or Product ID corresponding to a record in your database. + :param callback_url: A custom callback URL specific to this button. + :param success_url: A custom success URL specific to this button. The user will be redirected to this URL after a successful payment. + :param cancel_url: A custom cancel URL specific to this button. The user will be redirected to this URL after a canceled order. + :param info_url: A custom info URL specific to this button. Displayed to the user after a successful purchase for sharing. + :param variable_price: Allow users to change the price on the generated button. + :param choose_price: Show some suggested prices + :param include_address: Collect shipping address from customer (not for use with inline iframes). + :param include_email: Collect email address from customer (not for use with inline iframes). + :param price1: Suggested price 1 + :param price2: Suggested price 2 + :param price3: Suggested price 3 + :param price4: Suggested price 4 + :param price5: Suggested price 5 + :return: A CoinbaseButton object + """ + request_data = { + "button": { + "name": name, + "price": str(price), + "price_string": str(price), + "currency": currency, + "price_currency_iso": currency, + "type": type, + "style": style, + "text": text, + "description": description, + "custom": custom, + "callback_url": callback_url, + "success_url": success_url, + "cancel_url": cancel_url, + "info_url": info_url, + "variable_price": variable_price, + "choose_price": choose_price, + "include_address": include_address, + "include_email": include_email, + "price1": price1, + "price2": price2, + "price3": price3, + "price4": price4, + "price5": price5 + } + } + none_keys = [key for key in request_data['button'].keys() if request_data['button'][key] is None] + for key in none_keys: + del request_data['button'][key] + response_parsed = self._post('/buttons', data=json.dumps(request_data)) + return CoinbaseButton(response_parsed['button']) + + def create_order(self, code): + """ + Generate a new order from a button + :param code: The code of the button for which you wish to create an order + :return: A CoinbaseOrder object + """ + response_parsed = self._post('/buttons/{code}/create_order'.format(code=code)) + return CoinbaseOrder(response_parsed['order']) + + def get_order(self, order_id): + """ + Get an order by id + :param order_id: The order id to be retrieved + :return: A CoinbaseOrder object + """ + response_parsed = self._get('/orders/{id}'.format(id=order_id)) + return CoinbaseOrder(response_parsed['order']) + + def orders(self, count=30): + """ + Retrieve the list of orders for the current account + :param count: How many orders to retrieve + :return: List of CoinbaseOrder objects + """ + pages = count / 30 + 1 + orders = [] + + reached_final_page = False + + for page in xrange(1, pages + 1): + + if not reached_final_page: + response_parsed = self._get('/orders', params={'page': page}) + + if response_parsed['num_pages'] == page: + reached_final_page = True + for order in response_parsed['orders']: + orders.append(CoinbaseOrder(order['order'])) + return orders diff --git a/coinbase/config.py b/coinbase/config.py index b4af013..4dccee3 100644 --- a/coinbase/config.py +++ b/coinbase/config.py @@ -7,4 +7,8 @@ TEMP_CREDENTIALS = ''' -{"_module": "oauth2client.client", "token_expiry": "2013-03-24T02:37:50Z", "access_token": "2a02d1fc82b1c42d4ea94d6866b5a232b53a3a50ad4ee899ead9afa6144c2ca3", "token_uri": "https://www.coinbase.com/oauth/token", "invalid": false, "token_response": {"access_token": "2a02d1fc82b1c42d4ea94d6866b5a232b53a3a50ad4ee899ead9afa6144c2ca3", "token_type": "bearer", "expires_in": 7200, "refresh_token": "ffec0153da773468c8cb418d07ced54c13ca8deceae813c9be0b90d25e7c3d71", "scope": "all"}, "client_id": "2df06cb383f4ffffac20e257244708c78a1150d128f37d420f11fdc069a914fc", "id_token": null, "client_secret": "7caedd79052d7e29aa0f2700980247e499ce85381e70e4a44de0c08f25bded8a", "revoke_uri": "https://accounts.google.com/o/oauth2/revoke", "_class": "OAuth2Credentials", "refresh_token": "ffec0153da773468c8cb418d07ced54c13ca8deceae813c9be0b90d25e7c3d71", "user_agent": null}''' \ No newline at end of file +{"_module": "oauth2client.client", "token_expiry": "2013-03-24T02:37:50Z", "access_token": "2a02d1fc82b1c42d4ea94d6866b5a232b53a3a50ad4ee899ead9afa6144c2ca3", "token_uri": "https://www.coinbase.com/oauth/token", "invalid": false, "token_response": {"access_token": "2a02d1fc82b1c42d4ea94d6866b5a232b53a3a50ad4ee899ead9afa6144c2ca3", "token_type": "bearer", "expires_in": 7200, "refresh_token": "ffec0153da773468c8cb418d07ced54c13ca8deceae813c9be0b90d25e7c3d71", "scope": "all"}, "client_id": "2df06cb383f4ffffac20e257244708c78a1150d128f37d420f11fdc069a914fc", "id_token": null, "client_secret": "7caedd79052d7e29aa0f2700980247e499ce85381e70e4a44de0c08f25bded8a", "revoke_uri": "https://accounts.google.com/o/oauth2/revoke", "_class": "OAuth2Credentials", "refresh_token": "ffec0153da773468c8cb418d07ced54c13ca8deceae813c9be0b90d25e7c3d71", "user_agent": null}''' + +CENTS_PER_BITCOIN = 100000000 +CENTS_PER_OTHER = 100 +COINBASE_ITEMS_PER_PAGE = 30 \ No newline at end of file diff --git a/coinbase/models/__init__.py b/coinbase/models/__init__.py index 1c5a3ca..7689ea4 100644 --- a/coinbase/models/__init__.py +++ b/coinbase/models/__init__.py @@ -5,4 +5,6 @@ from transfer import CoinbaseTransfer from contact import CoinbaseContact from user import CoinbaseUser -from error import CoinbaseError \ No newline at end of file +from error import CoinbaseError +from button import CoinbaseButton +from order import CoinbaseOrder \ No newline at end of file diff --git a/coinbase/models/amount.py b/coinbase/models/amount.py index e940dc5..d748b38 100644 --- a/coinbase/models/amount.py +++ b/coinbase/models/amount.py @@ -1,10 +1,25 @@ __author__ = 'gsibble' -class CoinbaseAmount(float): +from decimal import Decimal +from coinbase.config import CENTS_PER_BITCOIN, CENTS_PER_OTHER + +class CoinbaseAmount(Decimal): def __new__(self, amount, currency): - return float.__new__(self, amount) + return Decimal.__new__(self, amount) def __init__(self, amount, currency): super(CoinbaseAmount, self).__init__() self.currency = currency + + def __eq__(self, other, *args, **kwargs): + if isinstance(other, self.__class__) and self.currency != other.currency: + return False + return super(CoinbaseAmount, self).__eq__(other, *args, **kwargs) + + @classmethod + def from_cents(cls, cents, currency): + if currency == 'BTC': + return cls(Decimal(cents)/CENTS_PER_BITCOIN, currency) + else: + return cls(Decimal(cents)/CENTS_PER_OTHER, currency) diff --git a/coinbase/models/button.py b/coinbase/models/button.py new file mode 100644 index 0000000..30deb9f --- /dev/null +++ b/coinbase/models/button.py @@ -0,0 +1,43 @@ +__author__ = 'vkhougaz' + +from amount import CoinbaseAmount + +class CoinbaseButton(object): + + def __init__(self, button): + self.data = button + # Sometimes it's called code (create button) and sometimes id (sub item of create button) + # so we map them together + if 'id' in button: + self.button_id = button['id'] + elif 'code' in button: + self.button_id = button['code'] + else: + self.button_id = None + self.code = self.button_id + + self.name = button['name'] + if 'price' in button: + price_cents = button['price']['cents'] + price_currency_iso = button['price']['currency_iso'] + self.price = CoinbaseAmount.from_cents(price_cents, price_currency_iso) + else: + self.price = None + self.type = button.get('type', None) + self.style = button.get('style', None) + self.text = button.get('text', None) + self.description = button.get('description', None) + self.custom = button.get('custom', None) + self.callback_url = button.get('callback_url', None) + self.success_url = button.get('success_url', None) + self.cancel_url = button.get('cancel_url', None) + self.info_url = button.get('info_url', None) + self.variable_price = button.get('variable_price', None) + self.choose_price = button.get('choose_price', None) + self.include_address = button.get('include_address', None) + self.include_email = button.get('include_email', None) + self.price1 = button.get('price1', None) + self.price2 = button.get('price2', None) + self.price3 = button.get('price3', None) + self.price4 = button.get('price4', None) + self.price5 = button.get('price4', None) diff --git a/coinbase/models/error.py b/coinbase/models/error.py index 4231b45..1289cbe 100644 --- a/coinbase/models/error.py +++ b/coinbase/models/error.py @@ -1,8 +1,12 @@ __author__ = 'kroberts' - - -class CoinbaseError(object): +class CoinbaseError(BaseException): def __init__(self, errorList): self.error = errorList + + def __unicode__(self): + return unicode(self.error) + + def __str__(self): + return str(self.error) \ No newline at end of file diff --git a/coinbase/models/order.py b/coinbase/models/order.py new file mode 100644 index 0000000..02bab3c --- /dev/null +++ b/coinbase/models/order.py @@ -0,0 +1,31 @@ +__author__ = 'vkhougaz' + +from amount import CoinbaseAmount +from button import CoinbaseButton +from transaction import CoinbaseTransaction +# from datetime import datetime + +class CoinbaseOrder(object): + def __init__(self, order): + self.data = order + + self.order_id = order['id'] + # TODO: Account for timezone properly + #self.created_at = datetime.strptime(order['created_at'], '%Y-%m-%dT%H:%M:%S-08:00') + self.created_at = order['created_at'] + self.status = order['status'] + self.custom = order['custom'] + + btc_cents = order['total_btc']['cents'] + btc_currency_iso = order['total_btc']['currency_iso'] + self.total_btc = CoinbaseAmount.from_cents(btc_cents, btc_currency_iso) + + native_cents = order['total_native']['cents'] + native_currency_iso = order['total_native']['currency_iso'] + self.total_native = CoinbaseAmount.from_cents(native_cents, native_currency_iso) + + self.button = CoinbaseButton(order['button']) + if 'transaction' in order and order['transaction'] is not None: + self.transaction = CoinbaseTransaction(order['transaction']) + else: + self.transaction = None diff --git a/coinbase/models/transaction.py b/coinbase/models/transaction.py index c5db34a..878c7a0 100644 --- a/coinbase/models/transaction.py +++ b/coinbase/models/transaction.py @@ -6,18 +6,21 @@ class CoinbaseTransaction(object): def __init__(self, transaction): + self.data = transaction self.transaction_id = transaction['id'] - self.created_at = transaction['created_at'] - self.notes = transaction['notes'] + self.created_at = transaction.get('created_at', None) + self.notes = transaction.get('notes', '') - transaction_amount = transaction['amount']['amount'] - transaction_currency = transaction['amount']['currency'] - - self.amount = CoinbaseAmount(transaction_amount, transaction_currency) + if 'amount' in transaction: + transaction_amount = transaction['amount']['amount'] + transaction_currency = transaction['amount']['currency'] + self.amount = CoinbaseAmount(transaction_amount, transaction_currency) + else: + self.amount = None - self.status = transaction['status'] - self.request = transaction['request'] + self.status = transaction.get('status', None) + self.request = transaction.get('request', None) #Sender Information diff --git a/coinbase/models/transfer.py b/coinbase/models/transfer.py index 0fdacb5..844ceba 100644 --- a/coinbase/models/transfer.py +++ b/coinbase/models/transfer.py @@ -5,20 +5,22 @@ class CoinbaseTransfer(object): def __init__(self, transfer): + self.data = transfer + self.type = transfer['type'] self.code = transfer['code'] self.created_at = transfer['created_at'] fees_coinbase_cents = transfer['fees']['coinbase']['cents'] fees_coinbase_currency_iso = transfer['fees']['coinbase']['currency_iso'] - self.fees_coinbase = CoinbaseAmount(fees_coinbase_cents, fees_coinbase_currency_iso) + self.fees_coinbase = CoinbaseAmount.from_cents(fees_coinbase_cents, fees_coinbase_currency_iso) fees_bank_cents = transfer['fees']['bank']['cents'] fees_bank_currency_iso = transfer['fees']['bank']['currency_iso'] - self.fees_bank = CoinbaseAmount(fees_bank_cents, fees_bank_currency_iso) + self.fees_bank = CoinbaseAmount.from_cents(fees_bank_cents, fees_bank_currency_iso) self.payout_date = transfer['payout_date'] - self.transaction_id = transfer.get('transaction_id','') + self.transaction_id = transfer.get('transaction_id', '') self.status = transfer['status'] btc_amount = transfer['btc']['amount'] @@ -33,7 +35,7 @@ def __init__(self, transfer): total_currency = transfer['total']['currency'] self.total_amount = CoinbaseAmount(total_amount, total_currency) - self.description = transfer.get('description','') + self.description = transfer.get('description', '') def refresh(self): pass diff --git a/coinbase/tests.py b/coinbase/tests.py index ad22826..944f5f7 100644 --- a/coinbase/tests.py +++ b/coinbase/tests.py @@ -5,8 +5,9 @@ import unittest from httpretty import HTTPretty, httprettified -from coinbase import CoinbaseAccount -from models import CoinbaseAmount +from coinbase import CoinbaseAccount, CoinbaseError +from coinbase.models import CoinbaseAmount +from decimal import Decimal TEMP_CREDENTIALS = '''{"_module": "oauth2client.client", "token_expiry": "2014-03-31T23:27:40Z", "access_token": "c15a9f84e471db9b0b8fb94f3cb83f08867b4e00cb823f49ead771e928af5c79", "token_uri": "https://www.coinbase.com/oauth/token", "invalid": false, "token_response": {"access_token": "c15a9f84e471db9b0b8fb94f3cb83f08867b4e00cb823f49ead771e928af5c79", "token_type": "bearer", "expires_in": 7200, "refresh_token": "90cb2424ddc39f6668da41a7b46dfd5a729ac9030e19e05fd95bb1880ad07e65", "scope": "all"}, "client_id": "2df06cb383f4ffffac20e257244708c78a1150d128f37d420f11fdc069a914fc", "id_token": null, "client_secret": "7caedd79052d7e29aa0f2700980247e499ce85381e70e4a44de0c08f25bded8a", "revoke_uri": "https://accounts.google.com/o/oauth2/revoke", "_class": "OAuth2Credentials", "refresh_token": "90cb2424ddc39f6668da41a7b46dfd5a729ac9030e19e05fd95bb1880ad07e65", "user_agent": null}''' @@ -16,13 +17,20 @@ def setUp(self): self.cb_amount = CoinbaseAmount(1, 'BTC') def test_cb_amount_class(self): - this(self.cb_amount).should.equal(1) + this(self.cb_amount).should.be.a(Decimal) + this(Decimal(self.cb_amount)).should.equal(Decimal('1')) this(self.cb_amount.currency).should.equal('BTC') + def test_cb_amount_equality(self): + this(CoinbaseAmount(1, 'BTC')).should.equal(CoinbaseAmount(1, 'BTC')) + this(CoinbaseAmount(1, 'BTC')).doesnt.equal(CoinbaseAmount(1, 'USD')) + assert CoinbaseAmount(1, 'BTC') == 1 + class CoinBaseAPIKeyTests(unittest.TestCase): def setUp(self): - self.account = CoinbaseAccount(api_key='f64223978e5fd99d07cded069db2189a38c17142fee35625f6ab3635585f61ab') + self.account = CoinbaseAccount(api_key='f64223978e5fd99d07cded069db2189a38c17142fee35625f6ab3635585f61ab', + api_secret='made up string') @httprettified def test_api_key_balance(self): @@ -31,13 +39,44 @@ def test_api_key_balance(self): body='''{"amount":"1.00000000","currency":"BTC"}''', content_type='text/json') - this(self.account.balance).should.equal(1.0) + this(float(self.account.balance)).should.equal(1) class CoinBaseLibraryTests(unittest.TestCase): def setUp(self): self.account = CoinbaseAccount(oauth2_credentials=TEMP_CREDENTIALS) + @httprettified + def test_status_error(self): + + HTTPretty.register_uri(HTTPretty.GET, "https://coinbase.com/api/v1/account/balance", + body='''{"error": "Invalid api_key"}''', + content_type='text/json', + status=401) + + try: + self.account.balance + except CoinbaseError as e: + e.error.should.equal("Invalid api_key") + unicode(e).should.equal(u"Invalid api_key") + str(e).should.equal("Invalid api_key") + except Exception: + assert False + + @httprettified + def test_success_false(self): + # Success is added when posting, putting or deleting + HTTPretty.register_uri(HTTPretty.GET, "https://coinbase.com/api/v1/account/balance", + body='''{"success": false, "errors":["Error 1","Error 2"]}''', + content_type='text/json') + + try: + self.account.balance + except CoinbaseError as e: + e.error.should.equal(["Error 1", "Error 2"]) + except Exception: + assert False + @httprettified def test_retrieve_balance(self): @@ -45,13 +84,9 @@ def test_retrieve_balance(self): body='''{"amount":"0.00000000","currency":"BTC"}''', content_type='text/json') - this(self.account.balance).should.equal(0.0) + this(float(self.account.balance)).should.equal(0.0) this(self.account.balance.currency).should.equal('BTC') - #TODO: Switch to decimals - #this(self.account.balance).should.equal(CoinbaseAmount('0.00000000', 'USD')) - #this(self.account.balance.currency).should.equal(CoinbaseAmount('0.00000000', 'USD').currency) - @httprettified def test_receive_addresses(self): @@ -76,7 +111,7 @@ def test_buy_price_1(self): content_type='text/json') buy_price_1 = self.account.buy_price(1) - this(buy_price_1).should.be.an(float) + this(buy_price_1).should.be.a(Decimal) this(buy_price_1).should.be.lower_than(100) this(buy_price_1.currency).should.equal('USD') @@ -98,7 +133,7 @@ def test_sell_price(self): content_type='text/json') sell_price_1 = self.account.sell_price(1) - this(sell_price_1).should.be.an(float) + this(sell_price_1).should.be.a(Decimal) this(sell_price_1).should.be.lower_than(100) this(sell_price_1.currency).should.equal('USD') @@ -120,7 +155,7 @@ def test_request_bitcoin(self): new_request = self.account.request('george@atlasr.com', 1, 'Testing') - this(new_request.amount).should.equal(1) + this(new_request.amount).should.equal(CoinbaseAmount(Decimal(1), 'BTC')) this(new_request.request).should.equal(True) this(new_request.sender.email).should.equal('george@atlasr.com') this(new_request.recipient.email).should.equal('gsibble@gmail.com') @@ -135,15 +170,14 @@ def test_send_bitcoin(self): new_transaction_with_btc_address = self.account.send('15yHmnB5vY68sXpAU9pR71rnyPAGLLWeRP', amount=0.1) - this(new_transaction_with_btc_address.amount).should.equal(-0.1) + this(new_transaction_with_btc_address.amount).should.equal(CoinbaseAmount(Decimal('-0.1'), 'BTC')) this(new_transaction_with_btc_address.request).should.equal(False) this(new_transaction_with_btc_address.sender.email).should.equal('gsibble@gmail.com') this(new_transaction_with_btc_address.recipient).should.equal(None) this(new_transaction_with_btc_address.recipient_address).should.equal('15yHmnB5vY68sXpAU9pR71rnyPAGLLWeRP') HTTPretty.register_uri(HTTPretty.POST, "https://coinbase.com/api/v1/transactions/send_money", - body='''{"success":true,"transaction":{"id":"5158b2920b974ea4cb000003","created_at":"2013-03-31T15:02:58-07:00","hsh":null,"notes":"","amount":{"amount":"-0.10000000","currency":"BTC"},"request":false,"status":"pending","sender":{"id":"509e01ca12838e0200000212","email":"gsibble@gmail.com","name":"gsibble@gmail.com"},"recipient":{"id":"4efec8d7bedd320001000003","email":"brian@coinbase.com","name":"Brian Armstrong"},"recipient_address":"brian@coinbase.com"}} -''', + body='''{"success":true,"transaction":{"id":"5158b2920b974ea4cb000003","created_at":"2013-03-31T15:02:58-07:00","hsh":null,"notes":"","amount":{"amount":"-0.10000000","currency":"BTC"},"request":false,"status":"pending","sender":{"id":"509e01ca12838e0200000212","email":"gsibble@gmail.com","name":"gsibble@gmail.com"},"recipient":{"id":"4efec8d7bedd320001000003","email":"brian@coinbase.com","name":"Brian Armstrong"},"recipient_address":"brian@coinbase.com"}}''', content_type='text/json') new_transaction_with_email = self.account.send('brian@coinbase.com', amount=0.1) @@ -155,11 +189,11 @@ def test_transaction_list(self): HTTPretty.register_uri(HTTPretty.GET, "https://coinbase.com/api/v1/transactions", body='''{"current_user":{"id":"509e01ca12838e0200000212","email":"gsibble@gmail.com","name":"gsibble@gmail.com"},"balance":{"amount":"0.00000000","currency":"BTC"},"total_count":4,"num_pages":1,"current_page":1,"transactions":[{"transaction":{"id":"514e4c37802e1bf69100000e","created_at":"2013-03-23T17:43:35-07:00","hsh":null,"notes":"Testing","amount":{"amount":"1.00000000","currency":"BTC"},"request":true,"status":"pending","sender":{"id":"514e4c1c802e1bef9800001e","email":"george@atlasr.com","name":"george@atlasr.com"},"recipient":{"id":"509e01ca12838e0200000212","email":"gsibble@gmail.com","name":"gsibble@gmail.com"}}},{"transaction":{"id":"514e4c1c802e1bef98000020","created_at":"2013-03-23T17:43:08-07:00","hsh":null,"notes":"Testing","amount":{"amount":"1.00000000","currency":"BTC"},"request":true,"status":"pending","sender":{"id":"514e4c1c802e1bef9800001e","email":"george@atlasr.com","name":"george@atlasr.com"},"recipient":{"id":"509e01ca12838e0200000212","email":"gsibble@gmail.com","name":"gsibble@gmail.com"}}},{"transaction":{"id":"514b9fb1b8377ee36500000d","created_at":"2013-03-21T17:02:57-07:00","hsh":"42dd65a18dbea0779f32021663e60b1fab8ee0f859db7172a078d4528e01c6c8","notes":"You gave me this a while ago. It's turning into a fair amount of cash and thought you might want it back :) Building something on your API this weekend. Take care!","amount":{"amount":"-1.00000000","currency":"BTC"},"request":false,"status":"complete","sender":{"id":"509e01ca12838e0200000212","email":"gsibble@gmail.com","name":"gsibble@gmail.com"},"recipient":{"id":"4efec8d7bedd320001000003","email":"brian@coinbase.com","name":"Brian Armstrong"},"recipient_address":"brian@coinbase.com"}},{"transaction":{"id":"509e01cb12838e0200000224","created_at":"2012-11-09T23:27:07-08:00","hsh":"ac9b0ffbe36dbe12c5ca047a5bdf9cadca3c9b89b74751dff83b3ac863ccc0b3","notes":"","amount":{"amount":"1.00000000","currency":"BTC"},"request":false,"status":"complete","sender":{"id":"4efec8d7bedd320001000003","email":"brian@coinbase.com","name":"Brian Armstrong"},"recipient":{"id":"509e01ca12838e0200000212","email":"gsibble@gmail.com","name":"gsibble@gmail.com"},"recipient_address":"gsibble@gmail.com"}}]}''', - content_type='text/json') + content_type='text/json') transaction_list = self.account.transactions() - this(transaction_list).should.be.an(list) + this(transaction_list).should.be.a(list) @httprettified def test_getting_transaction(self): @@ -171,7 +205,7 @@ def test_getting_transaction(self): transaction = self.account.get_transaction('5158b227802669269c000009') this(transaction.status).should.equal('pending') - this(transaction.amount).should.equal(-0.1) + this(transaction.amount).should.equal(CoinbaseAmount(Decimal("-0.1"), "BTC")) @httprettified def test_getting_user_details(self): @@ -183,4 +217,72 @@ def test_getting_user_details(self): user = self.account.get_user_details() this(user.id).should.equal("509f01da12837e0201100212") - this(user.balance).should.equal(1225.86084181) \ No newline at end of file + this(user.balance).should.equal(CoinbaseAmount(Decimal("1225.86084181"), "BTC")) + + # The following tests use the example request/responses from the coinbase API docs: + # test_creating_button, test_creating_order, test_getting_order + @httprettified + def test_creating_button(self): + HTTPretty.register_uri(HTTPretty.POST, "https://coinbase.com/api/v1/buttons", + body='''{"success":true,"button":{"code":"93865b9cae83706ae59220c013bc0afd","type":"buy_now","style":"custom_large","text":"Pay With Bitcoin","name":"test","description":"Sample description","custom":"Order123","callback_url":"http://www.example.com/my_custom_button_callback","price":{"cents":123,"currency_iso":"USD"}}}''', + content_type='text/json') + + button = self.account.create_button( + name="test", + type="buy_now", + price=1.23, + currency="USD", + custom="Order123", + callback_url="http://www.example.com/my_custom_button_callback", + description="Sample description", + style="custom_large", + include_email=True + ) + this(button.code).should.equal("93865b9cae83706ae59220c013bc0afd") + this(button.name).should.equal("test") + this(button.price).should.equal(CoinbaseAmount(Decimal('1.23'), "USD")) + this(button.include_address).should.equal(None) + + @httprettified + def test_creating_order(self): + + HTTPretty.register_uri(HTTPretty.POST, "https://coinbase.com/api/v1/buttons/93865b9cae83706ae59220c013bc0afd/create_order", + body='''{"success":true,"order":{"id":"7RTTRDVP","created_at":"2013-11-09T22:47:10-08:00","status":"new","total_btc":{"cents":100000000,"currency_iso":"BTC"},"total_native":{"cents":100000000,"currency_iso":"BTC"},"custom":"Order123","receive_address":"mgrmKftH5CeuFBU3THLWuTNKaZoCGJU5jQ","button":{"type":"buy_now","name":"test","description":"Sample description","id":"93865b9cae83706ae59220c013bc0afd"},"transaction":null}}''', + content_type='text/json') + + order = self.account.create_order("93865b9cae83706ae59220c013bc0afd") + + this(order.order_id).should.equal("7RTTRDVP") + this(order.total_btc).should.equal(CoinbaseAmount(1, 'BTC')) + + this(order.button.button_id).should.equal("93865b9cae83706ae59220c013bc0afd") + this(order.transaction).should.equal(None) + + @httprettified + def test_order_list(self): + + HTTPretty.register_uri(HTTPretty.GET, "https://coinbase.com/api/v1/orders", + body='''{"orders":[{"order":{"id":"A7C52JQT","created_at":"2013-03-11T22:04:37-07:00","status":"completed","total_btc":{"cents":100000000,"currency_iso":"BTC"},"total_native":{"cents":3000,"currency_iso":"USD"},"custom":"","button":{"type":"buy_now","name":"Order #1234","description":"order description","id":"eec6d08e9e215195a471eae432a49fc7"},"transaction":{"id":"513eb768f12a9cf27400000b","hash":"4cc5eec20cd692f3cdb7fc264a0e1d78b9a7e3d7b862dec1e39cf7e37ababc14","confirmations":0}}}],"total_count":1,"num_pages":1,"current_page":1}''', + content_type='text/json') + + orders = self.account.orders() + this(orders).should.be.a(list) + this(orders[0].order_id).should.equal("A7C52JQT") + + @httprettified + def test_getting_order(self): + + HTTPretty.register_uri(HTTPretty.GET, "https://coinbase.com/api/v1/orders/A7C52JQT", + body='''{"order":{"id":"A7C52JQT","created_at":"2013-03-11T22:04:37-07:00","status":"completed","total_btc":{"cents":10000000,"currency_iso":"BTC"},"total_native":{"cents":10000000,"currency_iso":"BTC"},"custom":"","button":{"type":"buy_now","name":"test","description":"","id":"eec6d08e9e215195a471eae432a49fc7"},"transaction":{"id":"513eb768f12a9cf27400000b","hash":"4cc5eec20cd692f3cdb7fc264a0e1d78b9a7e3d7b862dec1e39cf7e37ababc14","confirmations":0}}}''', + content_type='text/json') + + order = self.account.get_order("A7C52JQT") + + this(order.order_id).should.equal("A7C52JQT") + this(order.status).should.equal("completed") + this(order.total_btc).should.equal(CoinbaseAmount(Decimal(".1"), "BTC")) + this(order.button.name).should.equal("test") + this(order.transaction.transaction_id).should.equal("513eb768f12a9cf27400000b") + +if __name__ == '__main__': + unittest.main()