diff --git a/ccvpn/settings.py b/ccvpn/settings.py index 170ea6f..e9cbe71 100644 --- a/ccvpn/settings.py +++ b/ccvpn/settings.py @@ -228,6 +228,13 @@ PAYMENTS_BACKENDS = { 'URL': 'http://test:test@127.0.0.1:18332/', 'BITCOIN_VALUE': 36000, # Value of one bitcoin in currency*100 }, + '_coingate': { + # 'SANDBOX': True, + # 'API_BASE': '', + 'API_TOKEN': '', + 'CURRENCY': '', + 'TITLE': '', + } } PAYMENTS_CURRENCY = ('eur', '€') diff --git a/lambdainst/models.py b/lambdainst/models.py index 329bd80..1cf6dde 100644 --- a/lambdainst/models.py +++ b/lambdainst/models.py @@ -51,7 +51,7 @@ class VPNUser(models.Model): @property def time_left(self): - return timezone.now() - self.expiration + return self.expiration - timezone.now() def add_paid_time(self, time): now = timezone.now() @@ -63,6 +63,17 @@ class VPNUser(models.Model): if core.VPN_AUTH_STORAGE == 'core': core.update_user_expiration(self.user) + def remove_paid_time(self, time): + now = timezone.now() + + if self.expiration < now: + return + + self.expiration -= time + + if core.VPN_AUTH_STORAGE == 'core': + core.update_user_expiration(self.user) + def give_trial_period(self): self.add_paid_time(get_trial_period_duration()) self.trial_periods_given += 1 diff --git a/payments/backends/__init__.py b/payments/backends/__init__.py index 32aa1d4..99efdb8 100644 --- a/payments/backends/__init__.py +++ b/payments/backends/__init__.py @@ -5,4 +5,5 @@ from .paypal import PaypalBackend from .bitcoin import BitcoinBackend from .stripe import StripeBackend from .coinbase import CoinbaseBackend +from .coingate import CoinGateBackend diff --git a/payments/backends/coingate.py b/payments/backends/coingate.py new file mode 100644 index 0000000..ae17e18 --- /dev/null +++ b/payments/backends/coingate.py @@ -0,0 +1,131 @@ +import string +import random +import requests +import logging + +from django.shortcuts import redirect +from django.utils.translation import ugettext_lazy as _ +from django.urls import reverse +from django.conf import settings as project_settings + +from .base import BackendBase + +logger = logging.getLogger(__name__) +prng = random.SystemRandom() + + +def generate_token(length=16): + charset = string.digits + string.ascii_letters + return ''.join([prng.choice(charset) for _ in range(length)]) + + +class CoinGateBackend(BackendBase): + backend_id = 'coingate' + backend_verbose_name = _("CoinGate") + backend_display_name = _("Cryptocurrencies") + backend_has_recurring = False + + def __init__(self, settings): + self.api_token = settings.get('API_TOKEN') + if not self.api_token: + return + + self.currency = settings.get('CURRENCY', 'EUR') + self.title = settings.get('TITLE', 'VPN Payment') + + if settings.get('SANDBOX'): + self.api_base = "https://api-sandbox.coingate.com" + else: + default_base = "https://api.coingate.com" + self.api_base = settings.get('API_BASE', default_base) + + self.backend_enabled = True + + def _post(self, endpoint, **kwargs): + headers = { + 'Authorization': 'Token ' + self.api_token, + } + url = self.api_base + endpoint + response = requests.post(url, headers=headers, **kwargs) + response.raise_for_status() + j = response.json() + return j + + def new_payment(self, payment): + root_url = project_settings.ROOT_URL + assert root_url + + token = generate_token() + + order = self._post('/v2/orders', data={ + 'order_id': str(payment.id), + 'price_amount': payment.amount / 100, + 'price_currency': self.currency, + 'receive_currency': self.currency, + 'title': self.title, + 'callback_url': root_url + reverse('payments:cb_coingate', args=(payment.id,)), + 'cancel_url': root_url + reverse('payments:cancel', args=(payment.id,)), + 'success_url': root_url + reverse('payments:view', args=(payment.id,)), + 'token': token, + }) + + url = order['payment_url'] + + payment.backend_data['coingate_id'] = order['id'] + payment.backend_data['coingate_url'] = url + payment.backend_data['coingate_token'] = token + payment.save() + + return redirect(url) + + def callback(self, payment, request): + post_data = request.POST + + # Verify callback authenticity + + saved_token = payment.backend_data.get('coingate_token') + if not saved_token: + logger.debug("payment does not have a coingate_token") + return False + + token = post_data.get('token') + if token != saved_token: + logger.debug("unexpected token (%r != %r)", token, saved_token) + return False + + order_id = post_data.get('order_id') + if order_id != str(payment.id): + logger.debug("unexpected order_id (%r != %r)", order_id, str(payment.id)) + return False + + # Handle event + status = post_data.get('status') + if status == 'new' or status == 'pending': + payment.update_status('new') + elif status == 'confirming': + payment.update_status('new', _("Confirming transaction")) + elif status == 'paid': + if payment.status in {'new', 'cancelled', 'error'}: + # we don't have the exact converted amount, but it's not + # important. settings to the requested amount for consistency + payment.paid_amount = payment.amount + payment.confirm() + elif status == 'invalid' or status == 'expired': + if payment.status != 'confirmed': + payment.update_status('cancelled') + elif status == 'refunded': + if payment.status == 'confirmed': + payment.refund() + else: + logger.debug("unexpected payment status: %r", status) + return False + + payment.save() + return True + + def get_ext_url(self, payment): + if not payment.backend_extid: + return None + return 'https://dashboard.stripe.com/payments/%s' % payment.backend_extid + + diff --git a/payments/models.py b/payments/models.py index c24454f..3791ed7 100644 --- a/payments/models.py +++ b/payments/models.py @@ -126,6 +126,22 @@ class Payment(models.Model): def is_confirmed(self): return self.status == 'confirmed' + def confirm(self): + self.user.vpnuser.add_paid_time(self.time) + self.user.vpnuser.on_payment_confirmed(self) + self.user.vpnuser.save() + self.update_status('confirmed') + + def refund(self): + self.user.vpnuser.remove_paid_time(self.time) + self.user.vpnuser.save() + self.update_status('refunded') + + def update_status(self, status, message=None): + assert any(c[0] == status for c in STATUS_CHOICES) + self.status = status + self.status_message = message + class Meta: ordering = ('-created', ) diff --git a/payments/tests/__init__.py b/payments/tests/__init__.py index 83908d3..3c9fba8 100644 --- a/payments/tests/__init__.py +++ b/payments/tests/__init__.py @@ -3,4 +3,5 @@ from .bitcoin import * from .paypal import * from .stripe import * +from .coingate import * diff --git a/payments/tests/coingate.py b/payments/tests/coingate.py new file mode 100644 index 0000000..6b4c6dd --- /dev/null +++ b/payments/tests/coingate.py @@ -0,0 +1,75 @@ +from datetime import timedelta + +from django.test import TestCase, RequestFactory +from django.http import HttpResponseRedirect +from django.contrib.auth.models import User + +from payments.models import Payment +from payments.backends import CoinGateBackend + + +class CoinGateBackendTest(TestCase): + def setUp(self): + self.user = User.objects.create_user('test', 'test_user@example.com', None) + + self.backend_settings = dict( + API_TOKEN='test', + TITLE='Test Title', + CURRENCY='EUR', + ) + + def test_payment(self): + payment = Payment.objects.create( + user=self.user, + time=timedelta(days=30), + backend_id='coingate', + amount=300 + ) + + def fake_post(_backend, *, data={}): + self.assertEqual(data['order_id'], '1') + self.assertEqual(data['price_amount'], 3.0) + self.assertEqual(data['price_currency'], 'EUR') + self.assertEqual(data['receive_currency'], 'EUR') + return {'id': 42, 'payment_url': 'http://testtoken/'} + + with self.settings(ROOT_URL='root'): + backend = CoinGateBackend(self.backend_settings) + backend._post = fake_post + redirect = backend.new_payment(payment) + + self.assertIsInstance(redirect, HttpResponseRedirect) + self.assertEqual(redirect.url, 'http://testtoken/') + self.assertEqual(payment.backend_data.get('coingate_id'), 42) + + + # Test a standard successful payment callback flow + + def post_callback(status): + callback_data = { + 'token': payment.backend_data['coingate_token'], + 'order_id': str(payment.id), + 'status': status, + } + ipn_url = '/payments/callback/coingate/%d' % payment.id + ipn_request = RequestFactory().post( + ipn_url, + data=callback_data) + return backend.callback(payment, ipn_request) + + r = post_callback('pending') + self.assertTrue(r) + self.assertEqual(payment.status, 'new') + + r = post_callback('confirming') + self.assertTrue(r) + self.assertEqual(payment.status, 'new') + + r = post_callback('paid') + self.assertTrue(r) + self.assertEqual(payment.status, 'confirmed') + self.assertEqual(payment.paid_amount, 300) + + time_left_s = self.user.vpnuser.time_left.total_seconds() + self.assertAlmostEqual(time_left_s, payment.time.total_seconds(), delta=60) + diff --git a/payments/urls.py b/payments/urls.py index 5df6e3c..e81a558 100644 --- a/payments/urls.py +++ b/payments/urls.py @@ -11,6 +11,7 @@ urlpatterns = [ url(r'^return_subscr/(?P[0-9]+)$', views.return_subscr, name='return_subscr'), url(r'^callback/paypal/(?P[0-9]+)$', views.callback_paypal, name='cb_paypal'), + 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/paypal_subscr/(?P[0-9]+)$', views.callback_paypal_subscr, name='cb_paypal_subscr'), diff --git a/payments/views.py b/payments/views.py index 6460e5a..b95f73f 100644 --- a/payments/views.py +++ b/payments/views.py @@ -90,6 +90,19 @@ def callback_stripe(request, id): return redirect(reverse('payments:view', args=(id,))) +@csrf_exempt +def callback_coingate(request, id): + """ CoinGate payment callback """ + if not BACKENDS['coingate'].backend_enabled: + return HttpResponseNotFound() + + p = Payment.objects.get(id=id) + if BACKENDS['coingate'].callback(p, request): + return HttpResponse() + else: + return HttpResponseBadRequest() + + @csrf_exempt def callback_coinbase(request): if not BACKENDS['coinbase'].backend_enabled: diff --git a/static/css/style.css b/static/css/style.css index 23d9241..df5d3a1 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -387,7 +387,7 @@ ul.errorlist { text-align: center; border: 1px solid #ccc; border-radius: 5px; - margin: 0 1em; + margin: 0.5em 1em; } .install-guides li a { line-height: 3em;