diff --git a/ccvpn/settings.py b/ccvpn/settings.py index b6063cc..6c3f417 100644 --- a/ccvpn/settings.py +++ b/ccvpn/settings.py @@ -249,6 +249,14 @@ PAYMENTS_BACKENDS = { 'currency': 'EUR', # 'title': '', }, + 'coinpayments': { + 'enabled': False, + 'merchant_id': '', + 'secret': '', + 'currency': 'EUR', + # 'api_base': '', + # 'title': '', + }, } PAYMENTS_CURRENCY = ('eur', '€') diff --git a/lambdainst/views.py b/lambdainst/views.py index b1e1aad..49b723b 100644 --- a/lambdainst/views.py +++ b/lambdainst/views.py @@ -174,7 +174,7 @@ def index(request): ref_url=ref_url, twitter_link=twitter_url + urlencode(twitter_args), subscription=request.user.vpnuser.get_subscription(include_unconfirmed=True), - backends=sorted(ACTIVE_BACKENDS.values(), key=lambda x: x.backend_id), + backends=sorted(ACTIVE_BACKENDS.values(), key=lambda x: x.backend_display_name), subscr_backends=sorted((b for b in ACTIVE_BACKENDS.values() if b.backend_has_recurring), key=lambda x: x.backend_id), diff --git a/payments/backends/__init__.py b/payments/backends/__init__.py index 99efdb8..b08334d 100644 --- a/payments/backends/__init__.py +++ b/payments/backends/__init__.py @@ -6,4 +6,5 @@ from .bitcoin import BitcoinBackend from .stripe import StripeBackend from .coinbase import CoinbaseBackend from .coingate import CoinGateBackend +from .coinpayments import CoinPaymentsBackend diff --git a/payments/backends/coinpayments.py b/payments/backends/coinpayments.py new file mode 100644 index 0000000..e86c5e4 --- /dev/null +++ b/payments/backends/coinpayments.py @@ -0,0 +1,301 @@ +import math +from decimal import Decimal + +from django.shortcuts import redirect +from django.utils.translation import ugettext_lazy as _ +from django.urls import reverse +from constance import config as site_config + +from django.conf import settings as project_settings +from .base import BackendBase + + +import hmac +import hashlib +import requests +import logging +import json +from urllib.parse import urlencode +logger = logging.getLogger(__name__) + + +class CoinPaymentsError(Exception): + pass + + +class CoinPayments: + def __init__(self, pkey, skey, api_url=None): + self.public_key = pkey + self.secret_key = skey.encode('utf-8') + self.api_url = api_url or 'https://www.coinpayments.net/api.php' + + def _sign(self, params): + body = urlencode(params).encode('utf-8') + mac = hmac.new(self.secret_key, body, hashlib.sha512) + return body, mac.hexdigest() + + def _request(self, cmd, params): + params.update({ + 'cmd': cmd, + 'key': self.public_key, + 'format': 'json', + 'version': 1, + }) + print(params) + post_body, mac = self._sign(params) + + headers = { + 'HMAC': mac, + 'Content-Type': 'application/x-www-form-urlencoded', + } + + r = requests.post(self.api_url, data=post_body, + headers=headers) + try: + r.raise_for_status() + j = r.json() + except Exception as e: + raise CoinPaymentsError(str(e)) from e + + if j.get('error') == 'ok': + return j.get('result') + else: + raise CoinPaymentsError(j.get('error')) + + def create_transaction(self, **params): + assert 'amount' in params + assert 'currency1' in params + assert 'currency2' in params + return self._request('create_transaction', params) + + def get_account_info(self, **params): + return self._request('get_basic_info', params) + + def get_rates(self, **params): + return self._request('rates', params) + + def get_balances(self, **params): + return self._request('balances', params) + + def get_deposit_address(self, **params): + assert 'currency' in params + return self._request('get_deposit_address', params) + + def get_callback_address(self, **params): + assert 'currency' in params + return self._request('get_callback_address', params) + + def get_tx_info(self, **params): + assert 'txid' in params + return self._request('get_tx_info', params) + + def get_tx_info_multi(self, ids=None, **params): + if ids is not None: + params['txid'] = '|'.join(str(i) for i in ids) + assert 'txid' in params + return self._request('get_tx_info_multi', params) + + def get_tx_ids(self, **params): + return self._request('get_tx_ids', params) + + def create_transfer(self, **params): + assert 'amount' in params + assert 'currency' in params + assert 'merchant' in params or 'pbntag' in params + return self._request('create_transfer', params) + + def create_withdrawal(self, **params): + assert 'amount' in params + assert 'currency' in params + assert 'address' in params or 'pbntag' in params + return self._request('create_withdrawal', params) + + def create_mass_withdrawal(self, **params): + assert 'wd' in params + return self._request('create_mass_withdrawal', params) + + def convert(self, **params): + assert 'amount' in params + assert 'from' in params + assert 'to' in params + return self._request('convert', params) + + def get_withdrawal_history(self, **params): + return self._request('get_withdrawal_history', params) + + def get_withdrawal_info(self, **params): + assert 'id' in params + return self._request('get_withdrawal_info', params) + + def get_conversion_info(self, **params): + assert 'id' in params + return self._request('get_conversion_info', params) + + def get_pbn_info(self, **params): + assert 'pbntag' in params + return self._request('get_pbn_info', params) + + def get_pbn_list(self, **params): + return self._request('get_pbn_list', params) + + def update_pbn_tag(self, **params): + assert 'tagid' in params + return self._request('update_pbn_tag', params) + + def claim_pbn_tag(self, **params): + assert 'tagid' in params + assert 'name' in params + return self._request('claim_pbn_tag', params) + + +class IpnError(Exception): + pass + + +def ipn_assert(request, remote, local, key=None, delta=None): + if (delta is None and remote != local) or (delta is not None and not math.isclose(remote, local, abs_tol=delta)): + logger.debug("Invalid IPN %r: local=%r remote=%r", + key, local, remote) + raise IpnError("Unexpected value: %s" % key) + + +def ipn_assert_post(request, key, local): + remote = request.POST.get(key) + ipn_assert(request, remote, local, key=key) + + +class CoinPaymentsBackend(BackendBase): + backend_id = 'coinpayments' + backend_verbose_name = _("CoinPayments") + backend_display_name = _("Cryptocurrencies") + backend_has_recurring = False + + def __init__(self, settings): + self.merchant_id = settings.get('merchant_id') + self.currency = settings.get('currency', 'EUR') + self.api_base = settings.get('api_base', None) + self.title = settings.get('title', 'VPN Payment') + self.secret = settings.get('secret', '').encode('utf-8') + + if self.merchant_id and self.secret: + self.backend_enabled = True + + def new_payment(self, payment): + ROOT_URL = project_settings.ROOT_URL + params = { + 'cmd': '_pay', + 'reset': '1', + 'want_shipping': '0', + 'merchant': self.merchant_id, + 'currency': self.currency, + 'amountf': '%.2f' % (payment.amount / 100), + 'item_name': self.title, + 'ipn_url': ROOT_URL + reverse('payments:cb_coinpayments', args=(payment.id,)), + 'success_url': ROOT_URL + reverse('payments:view', args=(payment.id,)), + 'cancel_url': ROOT_URL + reverse('payments:cancel', args=(payment.id,)), + } + + payment.status_message = _("Waiting for CoinPayments to confirm the transaction... " + + "It can take up to a few minutes...") + payment.save() + + form = '
' + for k, v in params.items(): + form += '' % (k, v) + form += ''' + redirecting... + +
+ + ''' + return form + + def handle_ipn(self, payment, request): + sig = request.META.get('HTTP_HMAC') + if not sig: + raise IpnError("Missing HMAC") + + mac = hmac.new(self.secret, request.body, hashlib.sha512).hexdigest() + + # Sanity checks, if it fails the IPN is to be ignored + ipn_assert(request, sig, mac, 'HMAC') + ipn_assert_post(request, 'ipn_mode', 'hmac') + ipn_assert_post(request, 'merchant', self.merchant_id) + + try: + status = int(request.POST.get('status')) + except ValueError: + raise IpnError("Invalid status (%r)" % status) + + # Some states are final (can't cancel a timeout or refund) + if payment.status not in ('new', 'confirmed', 'error'): + m = "Unexpected state change for %s: is %s, received status=%r" % ( + payment.id, payment.status, status + ) + raise IpnError(m) + + # whatever the status, we can safely update the text and save the tx id + payment.status_text = request.POST.get('status_text') or payment.status_text + payment.backend_extid = request.POST.get('txn_id') + + received_amount = request.POST.get('amount1') + if received_amount: + payment.paid_amount = float(received_amount) * 100 + + # And now the actual processing + if status == 1: # A payment is confirmed paid + if payment.status != 'confirmed': + if payment.paid_amount != payment.amount: + ipn_assert(request, payment.paid_amount, payment.amount, 'paid', + delta=10) + vpnuser = payment.user.vpnuser + vpnuser.add_paid_time(payment.time) + vpnuser.on_payment_confirmed(payment) + vpnuser.save() + + # We save the new state *at the end* + # (it will be retried if there's an error) + payment.status = 'confirmed' + payment.status_message = None + payment.save() + + elif status > 1: # Waiting (that's further confirmation about funds getting moved) + # we have nothing to do, except updating status_text + payment.save() + return + + elif status == -1: # Cancel / Time out + payment.status = 'cancelled' + payment.save() + + elif status == -2: # A refund + if payment.status == 'confirmed': # (paid -> refunded) + payment.status = 'refunded' + # TODO + + elif status <= -3: # Unknown error + payment.status = 'error' + payment.save() + + def callback(self, payment, request): + try: + self.handle_ipn(payment, request) + return True + except IpnError as e: + payment.status = 'error' + payment.status_message = ("Error processing the payment. " + "Please contact support.") + payment.backend_data['ipn_exception'] = repr(e) + payment.backend_data['ipn_last_data'] = repr(request.POST) + payment.save() + logger.warn("IPN error: %s", e) + raise + + diff --git a/payments/urls.py b/payments/urls.py index e81a558..ea737cf 100644 --- a/payments/urls.py +++ b/payments/urls.py @@ -14,6 +14,7 @@ urlpatterns = [ url(r'^callback/coingate/(?P[0-9]+)$', views.callback_coingate, name='cb_coingate'), url(r'^callback/stripe/(?P[0-9]+)$', views.callback_stripe, name='cb_stripe'), url(r'^callback/coinbase/$', views.callback_coinbase, name='cb_coinbase'), + url(r'^callback/coinpayments/(?P[0-9]+)$', views.callback_coinpayments, name='cb_coinpayments'), url(r'^callback/paypal_subscr/(?P[0-9]+)$', views.callback_paypal_subscr, name='cb_paypal_subscr'), url(r'^callback/stripe_subscr/(?P[0-9]+)$', views.callback_stripe_subscr, name='cb_stripe_subscr'), diff --git a/payments/views.py b/payments/views.py index f26e110..b5a4d1b 100644 --- a/payments/views.py +++ b/payments/views.py @@ -119,6 +119,18 @@ def callback_coinbase(request): return HttpResponseBadRequest() +@csrf_exempt +def callback_coinpayments(request, id): + """ CoinPayments payment callback """ + backend = require_backend('coinpayments') + + p = Payment.objects.get(id=id) + if backend.callback(p, request): + return HttpResponse() + else: + return HttpResponseBadRequest() + + @csrf_exempt def callback_paypal_subscr(request, id): """ PayPal Subscription IPN """ diff --git a/static/img/spinner.gif b/static/img/spinner.gif new file mode 100644 index 0000000..332cab3 Binary files /dev/null and b/static/img/spinner.gif differ