Browse Source

switch to a new version of payments module

up to date with payment processors, pull-focused, and as a reusable app
master
alice 2 months ago
parent
commit
72bc2d34a4
51 changed files with 537 additions and 3343 deletions
  1. +49
    -7
      ccvpn/settings.py
  2. +3
    -60
      lambdainst/admin.py
  3. +76
    -0
      lambdainst/migrations/0005_migrate_coupons.py
  4. +22
    -104
      lambdainst/models.py
  5. +5
    -5
      lambdainst/tasks.py
  6. +25
    -11
      lambdainst/tests/online.py
  7. +9
    -153
      lambdainst/tests/units.py
  8. +0
    -2
      lambdainst/urls.py
  9. +6
    -41
      lambdainst/views.py
  10. +0
    -0
      payments/__init__.py
  11. +0
    -137
      payments/admin.py
  12. +0
    -8
      payments/backends/__init__.py
  13. +0
    -56
      payments/backends/base.py
  14. +0
    -108
      payments/backends/bitcoin.py
  15. +0
    -301
      payments/backends/coinpayments.py
  16. +0
    -252
      payments/backends/paypal.py
  17. +0
    -304
      payments/backends/stripe.py
  18. +0
    -16
      payments/forms.py
  19. +0
    -0
      payments/management/__init__.py
  20. +0
    -0
      payments/management/commands/__init__.py
  21. +0
    -15
      payments/management/commands/bitcoin_info.py
  22. +0
    -28
      payments/management/commands/check_btc_payments.py
  23. +0
    -52
      payments/management/commands/confirm_payment.py
  24. +0
    -31
      payments/management/commands/expire_payments.py
  25. +0
    -70
      payments/management/commands/update_stripe_plans.py
  26. +0
    -60
      payments/migrations/0001_initial.py
  27. +0
    -19
      payments/migrations/0002_auto_20151204_0341.py
  28. +0
    -20
      payments/migrations/0003_auto_20151209_0440.py
  29. +0
    -49
      payments/migrations/0004_auto_20160904_0048.py
  30. +0
    -20
      payments/migrations/0005_auto_20160907_0018.py
  31. +0
    -26
      payments/migrations/0006_auto_20190907_2029.py
  32. +0
    -26
      payments/migrations/0007_auto_20201114_1730.py
  33. +0
    -99
      payments/migrations/0008_auto_20210721_1931.py
  34. +0
    -0
      payments/migrations/__init__.py
  35. +0
    -272
      payments/models.py
  36. +0
    -33
      payments/tasks.py
  37. +0
    -10
      payments/tests/__init__.py
  38. +0
    -118
      payments/tests/bitcoin.py
  39. +0
    -75
      payments/tests/coingate.py
  40. +0
    -325
      payments/tests/paypal.py
  41. +0
    -23
      payments/urls.py
  42. +0
    -189
      payments/views.py
  43. +315
    -196
      poetry.lock
  44. +3
    -2
      pyproject.toml
  45. +3
    -3
      static/css/style.css
  46. +1
    -1
      templates/ccvpn/signup.html
  47. +17
    -15
      templates/lambdainst/account.html
  48. +1
    -1
      templates/payments/cancel_subscr.html
  49. +2
    -0
      templates/payments/order-pay.html
  50. +0
    -0
      templates/payments/payments.html
  51. +0
    -0
      templates/payments/success.html

+ 49
- 7
ccvpn/settings.py View File

@@ -43,7 +43,6 @@ INSTALLED_APPS = (
'django.contrib.humanize',
'django_countries',
'django_lcore',
'django_celery_results',
'django_celery_beat',
'lambdainst',
'payments',
@@ -117,6 +116,51 @@ DATABASES = {
}
}

LOGGING = {
'version': 1,
'disable_existing_loggers': True,
'handlers': {
'console': {
'class': 'logging.StreamHandler',
'formatter': 'console',
},
},
'loggers': {
'root': {
'handlers': ['console'],
'level': 'DEBUG' if DEBUG else 'INFO',
},
'urllib3.connectionpool': {
'handlers': ['console'],
#'level': 'DEBUG' if DEBUG else 'INFO',
'level': 'INFO',
'propagate': False,
},
'stripe': {
'handlers': ['console'],
#'level': 'DEBUG' if DEBUG else 'INFO',
'level': 'INFO',
'propagate': False,
},
'payments': {
'handlers': ['console'],
'level': 'DEBUG' if DEBUG else 'INFO',
'propagate': False,
},
},
'formatters': {
'console': {
'format': '%(asctime)s %(levelname).4s %(name)s: %(message)s',
},
},
}

CACHES = {
'default': {
'BACKEND': 'django.core.cache.backends.filebased.FileBasedCache',
'LOCATION': os.path.join(BASE_DIR, ".cache"),
}
}

# Internationalization
# https://docs.djangoproject.com/en/1.8/topics/i18n/
@@ -196,8 +240,6 @@ ROOT_URL = ''
REAL_IP_HEADER_NAME = None

# Celery defaults
#CELERY_BEAT_SCHEDULER = 'django_celery_beat.schedulers:DatabaseScheduler'
CELERY_RESULT_BACKEND = 'django-db'
CELERY_CACHE_BACKEND = 'django-cache'
CELERY_BROKER_URL = 'redis://localhost/1'
CELERY_TIMEZONE = 'UTC'
@@ -283,10 +325,10 @@ PAYMENTS_BACKENDS = {
PAYMENTS_CURRENCY = ('eur', '€')

PLANS = {
'1m': {'monthly': 3.00, 'months': 1, },
'3m': {'monthly': 9.00, 'months': 3, },
'6m': {'monthly': 18.00, 'months': 6, },
'12m': {'monthly': 36.00, 'months': 12, 'default': 1},
'1m': {'monthly': 3.00, 'months': 1, },
'3m': {'monthly': 3.00, 'months': 3, },
'6m': {'monthly': 3.00, 'months': 6, },
'12m': {'monthly': 3.00, 'months': 12, 'default': 1},
}




+ 3
- 60
lambdainst/admin.py View File

@@ -10,25 +10,14 @@ from django.utils.html import format_html
import django_lcore
import lcoreapi

from lambdainst.models import VPNUser, GiftCode, GiftCodeUser
from lambdainst.models import VPNUser
from payments.admin import UserCouponInline, UserLedgerInline


def make_user_link(user):
change_url = resolve_url('admin:auth_user_change', user.id)
return format_html('<a href="{}">{}</a>', change_url, user.username)


class GiftCodeAdminForm(forms.ModelForm):
def clean(self):
input_code = self.cleaned_data.get('code', '')
code_charset = string.ascii_letters + string.digits
if any(c not in code_charset for c in input_code):
raise forms.ValidationError(_("Code must be [a-zA-Z0-9]"))
if not 1 <= len(input_code) <= 32:
raise forms.ValidationError(_("Code must be between 1 and 32 characters"))
return self.cleaned_data


class VPNUserInline(admin.StackedInline):
model = VPNUser
can_delete = False
@@ -58,31 +47,8 @@ class VPNUserInline(admin.StackedInline):
is_paid.short_description = _("Is paid?")


class GiftCodeUserAdmin(admin.TabularInline):
model = GiftCodeUser
fields = ('user_link', 'code_link', 'date')
readonly_fields = ('user_link', 'code_link', 'date')
list_display = ('user', )
original = False

def user_link(self, object):
return make_user_link(object.user)
user_link.short_description = 'User'

def code_link(self, object):
change_url = resolve_url('admin:lambdainst_giftcode_change', object.code.id)
return format_html('<a href="{}">{}</a>', change_url, object.code.code)
code_link.short_description = 'Code'

def has_add_permission(self, request, obj):
return False

def has_delete_permission(self, request, obj=None):
return False


class UserAdmin(UserAdmin):
inlines = (VPNUserInline, GiftCodeUserAdmin)
inlines = (VPNUserInline, UserLedgerInline, UserCouponInline)
list_display = ('username', 'email', 'is_staff', 'date_joined', 'is_paid')
ordering = ('-date_joined', )
fieldsets = (
@@ -124,28 +90,5 @@ class UserAdmin(UserAdmin):
super().delete_model(request, obj)


class GiftCodeAdmin(admin.ModelAdmin):
fields = ('code', 'time', 'created', 'created_by', 'single_use', 'free_only',
'available', 'comment')
readonly_fields = ('created', 'created_by')
list_display = ('code', 'time', 'comment_head', 'available')
search_fields = ('code', 'comment', 'users__username')
inlines = (GiftCodeUserAdmin,)
list_filter = ('available', 'time')
form = GiftCodeAdminForm

def comment_head(self, object):
return object.comment_head
comment_head.short_description = _("Comment")

def save_model(self, request, obj, form, change):
if not change:
obj.created_by = request.user
obj.save()


admin.site.unregister(User)
admin.site.register(User, UserAdmin)

admin.site.register(GiftCode, GiftCodeAdmin)


+ 76
- 0
lambdainst/migrations/0005_migrate_coupons.py View File

@@ -0,0 +1,76 @@
# Generated by Django 3.2.5 on 2021-07-22 00:43

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('lambdainst', '0004_auto_20200829_2054'),
]

operations = [
migrations.RenameField(
model_name='giftcode',
old_name='free_only',
new_name='new_clients_only',
),
migrations.RemoveField(
model_name='giftcode',
name='created_by',
),
migrations.AddField(
model_name='giftcode',
name='for_plans',
field=models.CharField(blank=True, help_text='Valid only for the following plans, separated by commas. ("1m,6m", "12m", ...) (empty: any plan allowed)', max_length=100),
),
migrations.AddField(
model_name='giftcode',
name='valid_after',
field=models.DateTimeField(blank=True, help_text='Valid only after the date. (empty: no limit)', null=True),
),
migrations.AddField(
model_name='giftcode',
name='valid_before',
field=models.DateTimeField(blank=True, help_text='Valid only before the date. (empty: no limit)', null=True),
),
migrations.AddField(
model_name='giftcode',
name='value',
field=models.IntegerField(blank=True, help_text='Integer between 0 and 100. If the total is below 0.50$, it will be free and skip the payment', null=True, verbose_name='Discount %'),
),
migrations.AlterField(
model_name='giftcode',
name='available',
field=models.BooleanField(default=True, help_text='Coupon invalid when unchecked.'),
),

migrations.SeparateDatabaseAndState(
state_operations=[
migrations.RemoveField(
model_name='giftcodeuser',
name='code',
),
migrations.RemoveField(
model_name='giftcodeuser',
name='user',
),
migrations.DeleteModel(
name='GiftCode',
),
migrations.DeleteModel(
name='GiftCodeUser',
),
],
database_operations=[
migrations.AlterModelTable(
name='GiftCode',
table='payments_coupon',
),
migrations.AlterModelTable(
name='GiftCodeUser',
table='payments_couponuser',
),
],
),
]

+ 22
- 104
lambdainst/models.py View File

@@ -1,16 +1,10 @@
import random
from datetime import timedelta
from django.db import models
from django.db import models, IntegrityError
from django.contrib.auth.models import User
from django.utils.translation import ugettext as _
from django.db.models.signals import post_save
from django.db import IntegrityError
from django.dispatch import receiver
from constance import config as site_config
from django_lcore.core import LcoreUserProfileMethods, setup_sync_hooks, VPN_AUTH_STORAGE

from ccvpn.common import get_trial_period_duration
from payments.models import Subscription
from payments.models import BaseSubUser

prng = random.SystemRandom()

@@ -20,7 +14,7 @@ def random_gift_code():
return ''.join([prng.choice(charset) for n in range(10)])


class VPNUser(models.Model, LcoreUserProfileMethods):
class VPNUser(models.Model, BaseSubUser, LcoreUserProfileMethods):
class Meta:
verbose_name = _("VPN User")
verbose_name_plural = _("VPN Users")
@@ -53,39 +47,12 @@ class VPNUser(models.Model, LcoreUserProfileMethods):
self.referrer_used = False
self.campaign = None

def give_trial_period(self):
self.add_paid_time(get_trial_period_duration())
self.trial_periods_given += 1

@property
def can_have_trial(self):
if self.trial_periods_given >= site_config.TRIAL_PERIOD_MAX:
return False
if self.user.payment_set.filter(status='confirmed').count() > 0:
return False
return True

@property
def remaining_trial_periods(self):
return site_config.TRIAL_PERIOD_MAX - self.trial_periods_given

def on_payment_confirmed(self, payment):
if self.referrer and not self.referrer_used:
self.referrer.vpnuser.add_paid_time(timedelta(days=30))
self.referrer.vpnuser.add_paid_months(1, 'referrer', f"rewarded for {self.user.username} (payment #{payment.id})")
self.referrer.vpnuser.save()
self.referrer_used = True

def get_subscription(self, include_unconfirmed=False):
""" Tries to get the active (and most recent) Subscription """
s = Subscription.objects.filter(user=self.user, status='active') \
.order_by('-id') \
.first()
if s is not None or include_unconfirmed is False:
return s
s = Subscription.objects.filter(user=self.user, status='unconfirmed') \
.order_by('-id') \
.first()
return s
self.save()

def lcore_sync(self):
if VPN_AUTH_STORAGE == 'inst':
@@ -93,75 +60,26 @@ class VPNUser(models.Model, LcoreUserProfileMethods):
from lambdainst.tasks import push_user
push_user.delay(user_id=self.user.id)

def __str__(self):
return self.user.username

setup_sync_hooks(User, VPNUser)
def notify_payment(self, payment):
return

@receiver(post_save, sender=User)
def create_vpnuser(sender, instance, created, **kwargs):
if created:
try:
VPNUser.objects.create(user=instance)
except IntegrityError:
pass
def notify_subscription(self, sub):
return


class GiftCode(models.Model):
class Meta:
verbose_name = _("Gift Code")
verbose_name_plural = _("Gift Codes")

code = models.CharField(max_length=32, default=random_gift_code)
time = models.DurationField(default=timedelta(days=30))
created = models.DateTimeField(auto_now_add=True, null=True, blank=True)
created_by = models.ForeignKey(User, related_name='created_giftcode_set',
on_delete=models.CASCADE, null=True, blank=True)
single_use = models.BooleanField(default=True)
free_only = models.BooleanField(default=True)
available = models.BooleanField(default=True)
comment = models.TextField(blank=True)
users = models.ManyToManyField(User, through='GiftCodeUser')

def use_on(self, user):
if not self.available:
return False
if self.free_only and user.vpnuser.is_paid:
return False

link = GiftCodeUser(user=user, code=self)
link.save()

user.vpnuser.add_paid_time(self.time)
user.vpnuser.save()

if self.single_use:
self.available = False

self.save()

return True

@property
def comment_head(self):
head = self.comment.split('\n', 1)[0]
if len(head) > 80:
head = head[:80] + "..."
return head
def notify_refund(self, payment):
return

def __str__(self):
return self.code


class GiftCodeUser(models.Model):
class Meta:
verbose_name = _("Gift Code User")
verbose_name_plural = _("Gift Code Users")

user = models.ForeignKey(User, on_delete=models.CASCADE)
code = models.ForeignKey(GiftCode, on_delete=models.CASCADE)
date = models.DateTimeField(auto_now_add=True, null=True, blank=True)
return self.user.username

def __str__(self):
return "%s (%s)" % (self.user.username, self.code.code)
setup_sync_hooks(User, VPNUser)

#from django.db.models.signals import post_save
#from django.dispatch import receiver
#@receiver(post_save, sender=User)
#def create_vpnuser(sender, instance, created, **kwargs):
# if created:
# try:
# VPNUser.objects.create(user=instance)
# except IntegrityError:
# pass

+ 5
- 5
lambdainst/tasks.py View File

@@ -2,7 +2,7 @@ import logging
import itertools
import time
from datetime import timedelta, datetime
from celery import task
from celery import shared_task
from django.db.models import Q, F
from django.db import transaction
from django.conf import settings
@@ -22,7 +22,7 @@ SITE_NAME = settings.TICKETS_SITE_NAME
logger = logging.getLogger(__name__)


@task(autoretry_for=(Exception,), default_retry_delay=60 * 60)
@shared_task(autoretry_for=(Exception,), default_retry_delay=60 * 60)
def push_all_users():
count = 0
for u in User.objects.all():
@@ -37,14 +37,14 @@ def push_all_users():
logger.info("pushed %d users", count)


@task(autoretry_for=(Exception,), max_retries=10, retry_backoff=True)
@shared_task(autoretry_for=(Exception,), max_retries=10, retry_backoff=True)
def push_user(user_id):
user = User.objects.get(id=user_id)
logger.info("pushing user #%d %r", user.id, user)
django_lcore.sync_user(user.vpnuser, fail_silently=False)


@task
@shared_task
def notify_vpn_auth_fails():
"""
due to the high number of regular authentication fails, we'll start sending
@@ -179,7 +179,7 @@ def notify_vpn_auth_fails():
examine_users()


@task
@shared_task
def notify_account_expiration():
""" Notify users near the end of their subscription """
from_email = settings.DEFAULT_FROM_EMAIL


payments/tests/online/stripe.py → lambdainst/tests/online.py View File

@@ -28,10 +28,10 @@ class BaseOnlineTest(StaticLiveServerTestCase):
def setUpClass(cls):
super().setUpClass()
cls.selenium = WebDriver(firefox_binary="/usr/bin/firefox")
cls.selenium.implicitly_wait(6)
cls.selenium.implicitly_wait(12)

cls.root_url = settings.ROOT_URL
cls.wait = WebDriverWait(cls.selenium, 6)
cls.wait = WebDriverWait(cls.selenium, 20)

@classmethod
def tearDownClass(cls):
@@ -49,7 +49,7 @@ class BaseOnlineTest(StaticLiveServerTestCase):
self.selenium.find_element_by_xpath('//input[@value="Sign up"]').click()


def wait(f, initial=5, between=3):
def wait(f, initial=3, between=2):
time.sleep(initial)
print("waiting for confirmation...")
while True:
@@ -71,15 +71,15 @@ class OnlineStripeTests(BaseOnlineTest):
self.wait.until(
EC.visibility_of(
self.selenium.find_element_by_xpath(
'//label[@for="tab_onetime"]/..//select[@name="method"]'
'//label[@for="tab_onetime"]/..//select[@name="payment-method"]'
)
)
)
self.selenium.find_element_by_xpath(
'//label[@for="tab_onetime"]/..//select[@name="time"]/option[@value="3"]'
'//label[@for="tab_onetime"]/..//select[@name="plan"]/option[@value="3m"]'
).click()
self.selenium.find_element_by_xpath(
'//label[@for="tab_onetime"]/..//select[@name="method"]/option[@value="stripe"]'
'//label[@for="tab_onetime"]/..//select[@name="payment-method"]/option[@value="stripe"]'
).click()
self.selenium.find_element_by_xpath(
'//label[@for="tab_onetime"]/..//input[@value="Buy Now"]'
@@ -102,7 +102,15 @@ class OnlineStripeTests(BaseOnlineTest):
)
self.selenium.find_element_by_xpath('//button[@type="submit"]').click()

self.wait.until(
EC.presence_of_element_located((By.XPATH, '//*[contains(text(), "Waiting for payment")]'))
)

def check_active():
# refresh payment as we dont have a worker
p = Payment.objects.order_by('id').first()
p.refresh()

self.selenium.refresh()
self.selenium.find_element_by_xpath('//h2[contains(text(),"Confirmed")]')

@@ -119,15 +127,15 @@ class OnlineStripeTests(BaseOnlineTest):
self.wait.until(
EC.visibility_of(
self.selenium.find_element_by_xpath(
'//label[@for="tab_subscr"]/..//select[@name="method"]'
'//label[@for="tab_subscr"]/..//select[@name="payment-method"]'
)
)
)
self.selenium.find_element_by_xpath(
'//label[@for="tab_subscr"]/..//select[@name="time"]/option[@value="12"]'
'//label[@for="tab_subscr"]/..//select[@name="plan"]/option[@value="12m"]'
).click()
self.selenium.find_element_by_xpath(
'//label[@for="tab_subscr"]/..//select[@name="method"]/option[@value="stripe"]'
'//label[@for="tab_subscr"]/..//select[@name="payment-method"]/option[@value="stripe"]'
).click()
self.selenium.find_element_by_xpath(
'//label[@for="tab_subscr"]/..//input[@value="Subscribe"]'
@@ -150,7 +158,15 @@ class OnlineStripeTests(BaseOnlineTest):
)
self.selenium.find_element_by_xpath('//button[@type="submit"]').click()

self.wait.until(
EC.presence_of_element_located((By.XPATH, '//*[contains(text(), "Your subscription is processing.")]'))
)

def check_active():
# refresh sub as we dont have a worker
p = Subscription.objects.order_by('id').first()
p.refresh()

self.selenium.refresh()
sub_status = self.selenium.find_element_by_xpath(
'//td[text()="Subscription"]//following-sibling::td'
@@ -166,7 +182,6 @@ class OnlineStripeTests(BaseOnlineTest):

user = User.objects.get(username="test-user")
assert user.vpnuser.is_paid
assert user.vpnuser.expiration >= (timezone.now() + timedelta(days=359))

sub_status.find_element_by_xpath('a[text()="cancel"]').click()
self.selenium.find_element_by_xpath(
@@ -180,5 +195,4 @@ class OnlineStripeTests(BaseOnlineTest):

user = User.objects.get(username="test-user")
assert user.vpnuser.is_paid
assert user.vpnuser.expiration >= (timezone.now() + timedelta(days=359))
assert not user.vpnuser.get_subscription()

lambdainst/tests.py → lambdainst/tests/units.py View File

@@ -7,108 +7,19 @@ from io import StringIO
from constance import config as site_config
from constance.test import override_config

from .forms import SignupForm
from .models import VPNUser, User, random_gift_code, GiftCode, GiftCodeUser
from lambdainst.forms import SignupForm
from lambdainst.models import VPNUser, User
from payments.models import Payment, Subscription


class UserTestMixin:
def assertRemaining(self, vpnuser, time):
def assertRemaining(self, vpnuser, time, delta=5):
""" Check that the vpnuser will expire in time (+/- 5 seconds) """
exp = vpnuser.expiration or timezone.now()
seconds = (exp - timezone.now() - time).total_seconds()
self.assertAlmostEqual(seconds, 0, delta=5)
self.assertAlmostEqual(seconds, 0, delta=delta)


class UserModelTest(TestCase, UserTestMixin):
def setUp(self):
User.objects.create_user('aaa')

def test_add_time(self):
u = User.objects.get(username='aaa')
vu = u.vpnuser
p = timedelta(days=1)

self.assertFalse(vu.is_paid)

vu.expiration = timezone.now()
vu.add_paid_time(p)
final = vu.expiration

self.assertRemaining(vu, p)
self.assertGreater(final, timezone.now())
self.assertTrue(vu.is_paid)

def test_add_time_past(self):
u = User.objects.get(username='aaa')
vu = u.vpnuser
p = timedelta(days=1)

self.assertFalse(vu.is_paid)

vu.expiration = timezone.now() - timedelta(days=135)
vu.add_paid_time(p)
final = vu.expiration

self.assertRemaining(vu, p)
self.assertGreater(final, timezone.now())
self.assertTrue(vu.is_paid)

def test_add_time_initial(self):
u = User.objects.get(username='aaa')
vu = u.vpnuser
p = timedelta(days=1)

self.assertFalse(vu.is_paid)

vu.add_paid_time(p)
self.assertTrue(vu.is_paid)

def test_paid_between_subscr_payments(self):
u = User.objects.get(username='aaa')
vu = u.vpnuser
s = Subscription(user=u, backend_id='paypal', status='new')
s.save()

self.assertFalse(vu.is_paid)

s.status = 'active'
s.save()

self.assertTrue(vu.is_paid)

def test_grant_trial(self):
p = timedelta(days=1)
u = User.objects.get(username='aaa')
vu = u.vpnuser

with override_config(TRIAL_PERIOD_HOURS=24, TRIAL_PERIOD_MAX=2):
self.assertEqual(vu.remaining_trial_periods, 2)
self.assertTrue(vu.can_have_trial)
vu.give_trial_period()
self.assertRemaining(vu, p)

self.assertEqual(vu.remaining_trial_periods, 1)
self.assertTrue(vu.can_have_trial)
vu.give_trial_period()
self.assertRemaining(vu, p * 2)

self.assertEqual(vu.remaining_trial_periods, 0)
self.assertFalse(vu.can_have_trial)

def test_trial_refused(self):
p = timedelta(days=1)
u = User.objects.get(username='aaa')
payment = Payment.objects.create(user=u, status='confirmed', amount=300,
time=timedelta(days=30))
payment.save()

vu = u.vpnuser

with override_config(TRIAL_PERIOD_HOURS=24, TRIAL_PERIOD_MAX=2):
self.assertEqual(vu.remaining_trial_periods, 2)
self.assertFalse(vu.can_have_trial)


class UserModelReferrerTest(TestCase, UserTestMixin):
def setUp(self):
@@ -129,14 +40,7 @@ class UserModelReferrerTest(TestCase, UserTestMixin):
self.with_ref.vpnuser.on_payment_confirmed(self.payment)
self.assertTrue(self.with_ref.vpnuser.referrer_used)
self.assertEqual(self.with_ref.vpnuser.referrer, self.referrer)
self.assertRemaining(self.referrer.vpnuser, timedelta(days=14))


class GCModelTest(TestCase):
def test_generator(self):
c = random_gift_code()
self.assertEqual(len(c), 10)
self.assertNotEqual(c, random_gift_code())
self.assertRemaining(self.referrer.vpnuser, timedelta(days=30), delta=24*3600*3)


class SignupViewTest(TestCase):
@@ -242,54 +146,6 @@ class AccountViewsTest(TestCase, UserTestMixin):
self.assertFalse(user.check_password('new_test_pw2'))
self.assertTrue(user.check_password('test_pw'))

def test_giftcode_use_single(self):
gc = GiftCode.objects.create(time=timedelta(days=42), single_use=True)

response = self.client.post('/account/gift_code', {'code': gc.code})
self.assertRedirects(response, '/account/')

user = User.objects.get(username='test')
self.assertRemaining(user.vpnuser, timedelta(days=42))

response = self.client.post('/account/gift_code', {'code': gc.code})
self.assertRedirects(response, '/account/')

user = User.objects.get(username='test')
self.assertRemaining(user.vpnuser, timedelta(days=42)) # same expiration

def test_giftcode_use_free_only(self):
gc = GiftCode.objects.create(time=timedelta(days=42), free_only=True)

response = self.client.post('/account/gift_code', {'code': gc.code})
self.assertRedirects(response, '/account/')

user = User.objects.get(username='test')
self.assertRemaining(user.vpnuser, timedelta(days=42))

def test_giftcode_use_free_only_fail(self):
gc = GiftCode.objects.create(time=timedelta(days=42), free_only=True)
user = User.objects.get(username='test')
user.vpnuser.add_paid_time(timedelta(days=1))
user.vpnuser.save()

response = self.client.post('/account/gift_code', {'code': gc.code})
self.assertRedirects(response, '/account/')

user = User.objects.get(username='test')
self.assertRemaining(user.vpnuser, timedelta(days=1))

def test_giftcode_create_gcu(self):
gc = GiftCode.objects.create(time=timedelta(days=42))

response = self.client.post('/account/gift_code', {'code': gc.code})
self.assertRedirects(response, '/account/')

user = User.objects.get(username='test')
gcu = GiftCodeUser.objects.get(user=user, code=gc)

self.assertRemaining(user.vpnuser, timedelta(days=42))
self.assertIn(gcu, user.giftcodeuser_set.all())


class CACrtViewTest(TestCase):
def test_ca_crt(self):
@@ -312,7 +168,7 @@ class ExpireNotifyTest(TestCase):
def test_notify_first(self):
out = StringIO()
u = User.objects.create_user('test_username', 'test@example.com', 'testpw')
u.vpnuser.add_paid_time(timedelta(days=2))
u.vpnuser.add_paid_time(timedelta(days=2), 'initial')
u.vpnuser.save()

call_command('expire_notify', stdout=out)
@@ -328,7 +184,7 @@ class ExpireNotifyTest(TestCase):
out = StringIO()
u = User.objects.create_user('test_username', 'test@example.com', 'testpw')
u.vpnuser.last_expiry_notice = timezone.now() - timedelta(days=2)
u.vpnuser.add_paid_time(timedelta(days=1))
u.vpnuser.add_paid_time(timedelta(days=1), 'initial')
u.vpnuser.save()

call_command('expire_notify', stdout=out)
@@ -343,7 +199,7 @@ class ExpireNotifyTest(TestCase):
def test_notify_subscription(self):
out = StringIO()
u = User.objects.create_user('test_username', 'test@example.com', 'testpw')
u.vpnuser.add_paid_time(timedelta(days=2))
u.vpnuser.add_paid_time(timedelta(days=2), 'initial')
u.vpnuser.save()

s = Subscription(user=u, backend_id='paypal', status='active')
@@ -360,7 +216,7 @@ class ExpireNotifyTest(TestCase):
def test_notify_subscription_new(self):
out = StringIO()
u = User.objects.create_user('test_username', 'test@example.com', 'testpw')
u.vpnuser.add_paid_time(timedelta(days=2))
u.vpnuser.add_paid_time(timedelta(days=2), 'initial')
u.vpnuser.last_expiry_notice = timezone.now() - timedelta(days=5)
u.vpnuser.save()


+ 0
- 2
lambdainst/urls.py View File

@@ -17,7 +17,5 @@ urlpatterns = [
path('config_dl', django_lcore.views.openvpn_dl),
path('wireguard', views.wireguard),
path('wireguard/new', views.wireguard_new, name='wireguard_new'),
path('gift_code', views.gift_code),
path('trial', views.trial),
path('', views.index, name='index'),
]

+ 6
- 41
lambdainst/views.py View File

@@ -28,7 +28,7 @@ import lcoreapi
from ccvpn.common import get_client_ip, get_price_float
from payments.models import ACTIVE_BACKENDS
from .forms import SignupForm, ReqEmailForm, WgPeerForm
from .models import GiftCode, VPNUser
from .models import VPNUser
from . import graphs


@@ -96,11 +96,7 @@ def signup(request):
pass

user.vpnuser.campaign = request.session.get('campaign')
user.vpnuser.add_paid_time(timedelta(days=7))

if site_config.TRIAL_ON_SIGNUP:
trial_time = timedelta(hours=site_config.TRIAL_ON_SIGNUP)
user.vpnuser.add_paid_time(trial_time)
user.vpnuser.add_paid_time(timedelta(days=7), 'trial')

user.vpnuser.save()
user.vpnuser.lcore_sync()
@@ -191,7 +187,7 @@ def index(request):
title=_("Account"),
ref_url=ref_url,
twitter_link=twitter_url + urlencode(twitter_args),
subscription=request.user.vpnuser.get_subscription(include_unconfirmed=True),
subscription=request.user.vpnuser.get_subscription(),
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),
@@ -207,6 +203,9 @@ def index(request):
def captcha_test(grr, request):
api_url = project_settings.HCAPTCHA_API

if not project_settings.HCAPTCHA_SITE_KEY:
return True

if api_url == 'TEST' and grr == 'TEST-TOKEN':
# FIXME: i'm sorry.
return True
@@ -223,22 +222,6 @@ def captcha_test(grr, request):
return False


@login_required
def trial(request):
if request.method != 'POST' or not request.user.vpnuser.can_have_trial:
return redirect('account:index')

grr = request.POST.get('g-recaptcha-response', '')
if captcha_test(grr, request):
request.user.vpnuser.give_trial_period()
request.user.vpnuser.save()
messages.success(request, _("OK!"))
else:
messages.error(request, _("Invalid captcha"))

return redirect('account:index')


def make_export_zip(user, name):
import io
import zipfile
@@ -337,7 +320,6 @@ def deactivate_user(user):
user.password = ""
user.save()

user.giftcodeuser_set.all().delete()
user.payment_set.update(backend_data="null")
user.subscription_set.update(backend_data="null")

@@ -405,23 +387,6 @@ def settings(request):
))


@login_required
def gift_code(request):
try:
code = GiftCode.objects.get(code=request.POST.get('code', '').strip(), available=True)
except GiftCode.DoesNotExist:
code = None

if code is None:
messages.error(request, _("Gift code not found or already used."))
elif not code.use_on(request.user):
messages.error(request, _("Gift code only available to free accounts."))
else:
messages.success(request, _("OK!"))

return redirect('account:index')


@login_required
def config(request):
return render(request, 'lambdainst/config.html', dict(


+ 0
- 0
payments/__init__.py View File


+ 0
- 137
payments/admin.py View File

@@ -1,137 +0,0 @@
import json
from django.shortcuts import resolve_url
from django.contrib import admin
from django.utils.html import format_html
from django.utils.translation import ugettext_lazy as _
from django.utils.text import Truncator
from .models import Payment, Subscription, Feedback


def subscr_mark_as_cancelled(modeladmin, request, queryset):
queryset.update(status='cancelled')
subscr_mark_as_cancelled.short_description = _("Mark as cancelled (do not actually cancel)")


def link(text, url):
if not url:
return text
if not text:
text = url
return format_html('<a href="{}">{}</a>', url, text)

def json_format(code):
j = json.dumps(code, indent=2)
return format_html("<pre>{}</pre>", j)


class PaymentAdmin(admin.ModelAdmin):
model = Payment
list_display = ('user', 'backend', 'status', 'amount', 'paid_amount', 'created')
list_filter = ('backend_id', 'status')

fieldsets = (
(None, {
'fields': ('backend', 'user_link', 'subscription_link', 'time', 'status',
'status_message'),
}),
(_("Payment Data"), {
'fields': ('amount_fmt', 'paid_amount_fmt',
'backend_extid_link', 'backend_data_fmt'),
}),
)

readonly_fields = ('backend', 'user_link', 'time', 'status', 'status_message',
'amount_fmt', 'paid_amount_fmt', 'subscription_link',
'backend_extid_link', 'backend_data_fmt')
search_fields = ('user__username', 'user__email', 'backend_extid', 'backend_data')

def backend(self, object):
try:
return object.backend.backend_verbose_name
except KeyError:
return "#" + object.backend_id

def backend_data_fmt(self, object):
return json_format(object.backend_data)

def backend_extid_link(self, object):
try:
ext_url = object.backend.get_ext_url(object)
return link(object.backend_extid, ext_url)
except KeyError:
return "#" + object.backend_id

def amount_fmt(self, object):
return '%.2f %s' % (object.amount / 100, object.currency_name)
amount_fmt.short_description = _("Amount")

def paid_amount_fmt(self, object):
return '%.2f %s' % (object.paid_amount / 100, object.currency_name)
paid_amount_fmt.short_description = _("Paid amount")

def user_link(self, object):
change_url = resolve_url('admin:auth_user_change', object.user.id)
return link(object.user.username, change_url)
user_link.short_description = 'User'

def subscription_link(self, object):
change_url = resolve_url('admin:payments_subscription_change',
object.subscription.id)
return link(object.subscription.id, change_url)
subscription_link.short_description = 'Subscription'


class SubscriptionAdmin(admin.ModelAdmin):
model = Subscription
list_display = ('user', 'created', 'status', 'backend', 'backend_extid')
list_filter = ('backend_id', 'status')
readonly_fields = ('user_link', 'backend', 'created', 'status',
'last_confirmed_payment', 'payments_links',
'backend_extid_link', 'backend_data_fmt')
search_fields = ('user__username', 'user__email', 'backend_extid', 'backend_data')
actions = (subscr_mark_as_cancelled,)
fieldsets = (
(None, {
'fields': ('backend', 'user_link', 'payments_links', 'status',
'last_confirmed_payment'),
}),
(_("Payment Data"), {
'fields': ('backend_extid_link', 'backend_data_fmt'),
}),
)

def backend(self, object):
return object.backend.backend_verbose_name

def backend_data_fmt(self, object):
return json_format(object.backend_data)

def user_link(self, object):
change_url = resolve_url('admin:auth_user_change', object.user.id)
return link(object.user.id, change_url)
user_link.short_description = 'User'

def payments_links(self, object):
count = Payment.objects.filter(subscription=object).count()
payments_url = resolve_url('admin:payments_payment_changelist')
url = "%s?subscription__id__exact=%s" % (payments_url, object.id)
return link("%d payment(s)" % count, url)
payments_links.short_description = 'Payments'

def backend_extid_link(self, object):
ext_url = object.backend.get_subscr_ext_url(object)
return link(object.backend_extid, ext_url)
backend_extid_link.allow_tags = True

class FeedbackAdmin(admin.ModelAdmin):
model = Feedback
list_display = ('user', 'created', 'short_message')
readonly_fields = ('user', 'created', 'message', 'subscription')

def short_message(self, obj):
return Truncator(obj.message).chars(80)

admin.site.register(Payment, PaymentAdmin)
admin.site.register(Subscription, SubscriptionAdmin)
admin.site.register(Feedback, FeedbackAdmin)


+ 0
- 8
payments/backends/__init__.py View File

@@ -1,8 +0,0 @@
# flake8: noqa

from .base import BackendBase, ManualBackend
from .paypal import PaypalBackend
from .bitcoin import BitcoinBackend
from .stripe import StripeBackend
from .coinpayments import CoinPaymentsBackend


+ 0
- 56
payments/backends/base.py View File

@@ -1,56 +0,0 @@
from django.utils.translation import ugettext_lazy as _


class BackendBase:
backend_id = None
backend_verbose_name = ""
backend_display_name = ""
backend_enabled = False
backend_has_recurring = False

def __init__(self, settings):
pass

def new_payment(self, payment):
""" Initialize a payment and returns an URL to redirect the user.
Can return a HTML string that will be sent back to the user in a
default template (like a form) or a HTTP response (like a redirect).
"""
raise NotImplementedError()

def callback(self, payment, request):
""" Handle a callback """
raise NotImplementedError()

def callback_subscr(self, payment, request):
""" Handle a callback (recurring payments) """
raise NotImplementedError()

def cancel_subscription(self, subscr):
""" Cancel a subscription """
raise NotImplementedError()

def get_info(self):
""" Returns some status (key, value) list """
return ()

def get_ext_url(self, payment):
""" Returns URL to external payment view, or None """
return None

def get_subscr_ext_url(self, subscr):
""" Returns URL to external payment view, or None """
return None


class ManualBackend(BackendBase):
""" Manual backend used to store and display informations about a
payment processed manually.
More a placeholder than an actual payment beckend, everything raises
NotImplementedError().
"""

backend_id = 'manual'
backend_verbose_name = _("Manual")



+ 0
- 108
payments/backends/bitcoin.py View File

@@ -1,108 +0,0 @@
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 .base import BackendBase


class BitcoinBackend(BackendBase):
""" Bitcoin backend.
Connects to a bitcoind.
"""
backend_id = 'bitcoin'
backend_verbose_name = _("Bitcoin")
backend_display_name = _("Bitcoin")

COIN = 100000000

def __init__(self, settings):
from bitcoin import SelectParams
from bitcoin.rpc import Proxy

self.account = settings.get('account', 'ccvpn3')

chain = settings.get('chain')
if chain:
SelectParams(chain)

self.url = settings.get('url')
if not self.url:
return

self.make_rpc = lambda: Proxy(self.url)
self.rpc = self.make_rpc()
self.backend_enabled = True

@property
def btc_value(self):
return site_config.BTC_EUR_VALUE

def new_payment(self, payment):
rpc = self.make_rpc()

# bitcoins amount = (amount in cents) / (cents per bitcoin)
btc_price = round(Decimal(payment.amount) / self.btc_value, 5)

address = str(rpc.getnewaddress(self.account))

msg = _("Please send %(amount)s BTC to %(address)s")
payment.status_message = msg % dict(amount=str(btc_price), address=address)
payment.backend_extid = address
payment.backend_data = dict(btc_price=str(btc_price), btc_address=address)
payment.save()
return redirect(reverse('payments:view', args=(payment.id,)))

def check(self, payment):
rpc = self.make_rpc()

if payment.status != 'new':
return

btc_price = payment.backend_data.get('btc_price')
address = payment.backend_data.get('btc_address')
if not btc_price or not address:
return

btc_price = Decimal(btc_price)

received = Decimal(rpc.getreceivedbyaddress(address)) / self.COIN
payment.paid_amount = int(received * self.btc_value)
payment.backend_data['btc_paid_price'] = str(received)

if received >= btc_price:
payment.user.vpnuser.add_paid_time(payment.time)
payment.user.vpnuser.on_payment_confirmed(payment)
payment.user.vpnuser.save()

payment.status = 'confirmed'

payment.save()

def get_info(self):
rpc = self.make_rpc()

try:
info = rpc.getinfo()
if not info:
return [(_("Status"), "Error: got None")]
except Exception as e:
return [(_("Status"), "Error: " + repr(e))]
v = info.get('version', 0)
return (
(_("Bitcoin value"), "%.2f €" % (self.btc_value / 100)),
(_("Testnet"), info['testnet']),
(_("Balance"), '{:f}'.format(info['balance'] / self.COIN)),
(_("Blocks"), info['blocks']),
(_("Bitcoind version"), '.'.join(str(v // 10 ** (2 * i) % 10 ** (2 * i))
for i in range(3, -1, -1))),
)

def get_ext_url(self, payment):
if not payment.backend_extid:
return None
return 'https://blockstream.info/address/%s' % payment.backend_extid



+ 0
- 301
payments/backends/coinpayments.py View File

@@ -1,301 +0,0 @@
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 = '<form action="https://www.coinpayments.net/index.php" method="POST" id="cp-form">'
for k, v in params.items():
form += '<input type="hidden" name="%s" value="%s" />' % (k, v)
form += '''
<img src="/static/img/spinner.gif" style="margin: auto;" alt="redirecting..." id="cp-spinner" />
<input type="submit" class="button" name="submitbutton" value="Continue" />
</form>
<script>
document.addEventListener("DOMContentLoaded", function(event) {{
var f = document.getElementById("cp-form");
f.elements["submitbutton"].style.display = "none";
document.getElementById("cp-spinner").style.display = "block";
f.submit();
}});
</script>
'''
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



+ 0
- 252
payments/backends/paypal.py View File

@@ -1,252 +0,0 @@
from django.shortcuts import redirect
from django.utils.translation import ugettext_lazy as _
from urllib.parse import urlencode
from urllib.request import urlopen
from django.urls import reverse
from django.conf import settings as project_settings
import requests

from .base import BackendBase


def urljoin(a, b):
if b.startswith('/') and a.endswith('/'):
return a + b[1:]
if b.startswith('/') or a.endswith('/'):
return a + b
return a + "/" + b


class PaypalBackend(BackendBase):
backend_id = 'paypal'
backend_verbose_name = _("PayPal")
backend_display_name = _("PayPal")
backend_has_recurring = True

def __init__(self, settings):
self.test = settings.get('test', False)
self.header_image = settings.get('header_image', None)
self.title = settings.get('title', 'VPN Payment')
self.currency = settings.get('currency', 'EUR')
self.account_address = settings.get('address')
self.receiver_address = settings.get('receiver', self.account_address)

self.api_username = settings.get('api_username')
self.api_password = settings.get('api_password')
self.api_sig = settings.get('api_sig')

if self.test:
default_nvp = 'https://api-3t.sandbox.paypal.com/nvp'
default_api = 'https://www.sandbox.paypal.com/'
else:
default_nvp = 'https://api-3t.paypal.com/nvp'
default_api = 'https://www.paypal.com/'
self.api_base = settings.get('api_base', default_api)
self.nvp_api_base = settings.get('nvp_api_base', default_nvp)

if self.account_address and self.api_username and self.api_password and self.api_sig:
self.backend_enabled = True

def new_payment(self, payment):
ROOT_URL = project_settings.ROOT_URL
params = {
'cmd': '_xclick',
'notify_url': ROOT_URL + reverse('payments:cb_paypal', args=(payment.id,)),
'item_name': self.title,
'amount': '%.2f' % (payment.amount / 100),
'currency_code': self.currency,
'business': self.account_address,
'no_shipping': '1',
'return': ROOT_URL + reverse('payments:view', args=(payment.id,)),
'cancel_return': ROOT_URL + reverse('payments:cancel', args=(payment.id,)),
}

if self.header_image:
params['cpp_header_image'] = self.header_image

payment.status_message = _("Waiting for PayPal to confirm the transaction... " +
"It can take up to a few minutes...")
payment.save()

return redirect(urljoin(self.api_base, '/cgi-bin/webscr?' + urlencode(params)))

def new_subscription(self, rps):
months = {
'3m': 3,
'6m': 6,
'12m': 12,
}[rps.period]

ROOT_URL = project_settings.ROOT_URL
params = {
'cmd': '_xclick-subscriptions',
'notify_url': ROOT_URL + reverse('payments:cb_paypal_subscr', args=(rps.id,)),
'item_name': self.title,
'currency_code': self.currency,
'business': self.account_address,
'no_shipping': '1',
'return': ROOT_URL + reverse('payments:return_subscr', args=(rps.id,)),
'cancel_return': ROOT_URL + reverse('account:index'),

'a3': '%.2f' % (rps.period_amount / 100),
'p3': str(months),
't3': 'M',
'src': '1',
}

if self.header_image:
params['cpp_header_image'] = self.header_image

rps.save()

return redirect(urljoin(self.api_base, '/cgi-bin/webscr?' + urlencode(params)))

def handle_verified_callback(self, payment, params):
if self.test and params['test_ipn'] != '1':
raise ValueError('Test IPN')

txn_type = params.get('txn_type')
if txn_type not in (None, 'web_accept', 'express_checkout'):
# Not handled here and can be ignored
return

if params['payment_status'] == 'Refunded':
payment.status = 'refunded'
payment.status_message = None

elif params['payment_status'] == 'Completed':
self.handle_completed_payment(payment, params)

def handle_verified_callback_subscr(self, subscr, params):
if self.test and params['test_ipn'] != '1':
raise ValueError('Test IPN')

txn_type = params.get('txn_type')
if not txn_type.startswith('subscr_'):
# Not handled here and can be ignored
return

if txn_type == 'subscr_payment':
if params['payment_status'] == 'Refunded':
# FIXME: Find the payment and do something
pass

elif params['payment_status'] == 'Completed':
payment = subscr.create_payment()
if not self.handle_completed_payment(payment, params):
return

subscr.last_confirmed_payment = payment.created
subscr.backend_extid = params.get('subscr_id', '')
if subscr.status == 'new' or subscr.status == 'unconfirmed':
subscr.status = 'active'
subscr.save()
elif txn_type == 'subscr_cancel' or txn_type == 'subscr_eot':
subscr.status = 'cancelled'
subscr.save()

def handle_completed_payment(self, payment, params):
from payments.models import Payment

# Prevent making duplicate Payments if IPN is received twice
pc = Payment.objects.filter(backend_extid=params['txn_id']).count()
if pc > 0:
return False

if self.receiver_address != params['receiver_email']:
raise ValueError('Wrong receiver: ' + params['receiver_email'])
if self.currency.lower() != params['mc_currency'].lower():
raise ValueError('Wrong currency: ' + params['mc_currency'])

payment.paid_amount = int(float(params['mc_gross']) * 100)
if payment.paid_amount < payment.amount:
raise ValueError('Not fully paid.')

payment.user.vpnuser.add_paid_time(payment.time)
payment.user.vpnuser.on_payment_confirmed(payment)
payment.user.vpnuser.save()

payment.backend_extid = params['txn_id']
payment.status = 'confirmed'
payment.status_message = None
payment.save()

payment.user.vpnuser.lcore_sync()
return True

def verify_ipn(self, request):
v_url = urljoin(self.api_base, '/cgi-bin/webscr?cmd=_notify-validate')
v_req = urlopen(v_url, data=request.body, timeout=5)
v_res = v_req.read()
return v_res == b'VERIFIED'

def callback(self, payment, request):
if not self.verify_ipn(request):
return False

params = request.POST

try:
self.handle_verified_callback(payment, params)
return True
except (KeyError, ValueError) as e:
payment.status = 'error'
payment.status_message = None
payment.backend_data['ipn_exception'] = repr(e)
payment.backend_data['ipn_last_data'] = repr(request.POST)
payment.save()
raise

def callback_subscr(self, subscr, request):
if not self.verify_ipn(request):
return False

params = request.POST

try:
self.handle_verified_callback_subscr(subscr, params)
return True
except (KeyError, ValueError) as e:
subscr.status = 'error'
subscr.status_message = None
subscr.backend_data['ipn_exception'] = repr(e)
subscr.backend_data['ipn_last_data'] = repr(request.POST)
subscr.save()
raise

def cancel_subscription(self, subscr):
if not subscr.backend_extid:
return False

try:
r = requests.post(self.nvp_api_base, data={
"METHOD": "ManageRecurringPaymentsProfileStatus",
"PROFILEID": subscr.backend_extid,
"ACTION": "cancel",
"USER": self.api_username,
"PWD": self.api_password,
"SIGNATURE": self.api_sig,
"VERSION": "204.0",
})
r.raise_for_status()
print(r.text)

subscr.status = 'cancelled'
subscr.save()
return True
except Exception as e:
print(e)
return False

def get_ext_url(self, payment):
if not payment.backend_extid:
return None
url = 'https://history.paypal.com/webscr?cmd=_history-details-from-hub&id=%s'
return url % payment.backend_extid

def get_subscr_ext_url(self, subscr):
if not subscr.backend_extid:
return None
return ('https://www.paypal.com/fr/cgi-bin/webscr?cmd=_profile-recurring-payments'
'&encrypted_profile_id=%s' % subscr.backend_extid)


+ 0
- 304
payments/backends/stripe.py View File

@@ -1,304 +0,0 @@
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


class StripeBackend(BackendBase):
backend_id = 'stripe'
backend_verbose_name = _("Stripe")
backend_display_name = _("Credit Card")
backend_has_recurring = True

def get_plan_id(self, period):
return 'ccvpn_' + period

def __init__(self, settings):
self.public_key = settings.get('public_key')
self.secret_key = settings.get('secret_key')
self.wh_key = settings.get('wh_key')

if not self.public_key or not self.secret_key or not self.wh_key:
raise Exception("Missing keys for stripe backend")

import stripe
self.stripe = stripe
stripe.api_key = self.secret_key

self.header_image = settings.get('header_image', '')
self.currency = settings.get('currency', 'EUR')
self.title = settings.get('title', 'VPN Payment')
self.backend_enabled = True

def make_redirect(self, session):
return '''
<script src="https://js.stripe.com/v3/"></script>
<script type="text/javascript">
document.write("<p>Redirecting to the payment page...</p>");
var stripe = Stripe("{pk}");
stripe.redirectToCheckout({{
sessionId: "{sess}"
}});
</script>
<noscript><p>Please enable JavaScript to use the payment form.</p></noscript>
'''.format(pk=self.public_key, sess=session['id'])

def new_payment(self, payment):
root_url = project_settings.ROOT_URL
assert root_url

months = payment.time.days // 30
if months > 1:
desc = '{} months for {}'.format(months, payment.user.username)
else:
desc = 'One month for {}'.format(payment.user.username)

session = self.stripe.checkout.Session.create(
success_url=root_url + reverse('payments:view', args=(payment.id,)),
cancel_url=root_url + reverse('payments:cancel', args=(payment.id,)),
payment_method_types=['card'],
line_items=[
{
'amount': payment.amount,
'currency': self.currency.lower(),
'name': self.title,
'description': desc,
'quantity': 1,
}
],
)
payment.backend_extid = session['id']
payment.backend_data = {'session_id': session['id']}
payment.save()
return self.make_redirect(session)

def new_subscription(self, subscr):
root_url = project_settings.ROOT_URL
assert root_url

session = self.stripe.checkout.Session.create(
success_url=root_url + reverse('payments:return_subscr', args=(subscr.id,)),
cancel_url=root_url + reverse('payments:cancel_subscr', args=(subscr.id,)),
client_reference_id='sub_%d'%subscr.id,
payment_method_types=['card'],
subscription_data={
'items': [{
'plan': self.get_plan_id(subscr.period),
'quantity': 1,
}],
},
)
subscr.backend_data = {'session_id': session['id']}
subscr.save()
return self.make_redirect(session)

def cancel_subscription(self, subscr):
if subscr.status not in ('new', 'unconfirmed', 'active'):
return

if subscr.backend_extid.startswith('pi'):
# a session that didn't create a subscription yet (intent)
pass
elif subscr.backend_extid.startswith('sub_'):
# Subscription object
try:
self.stripe.Subscription.delete(subscr.backend_extid)
except self.stripe.error.InvalidRequestError:
pass
elif subscr.backend_extid.startswith('cus_'):
# Legacy Customer object
try:
cust = self.stripe.Customer.retrieve(subscr.backend_extid)
except self.stripe.error.InvalidRequestError:
return

try:
# Delete customer and cancel any active subscription
cust.delete()
except self.stripe.error.InvalidRequestError:
pass
else:
raise Exception("Failed to cancel subscription %r" % subscr.backend_extid)

subscr.status = 'cancelled'
subscr.save()
return True

def refresh_subscription(self, subscr):
if subscr.backend_extid.startswith('cus_'):
customer = self.stripe.Customer.retrieve(subscr.backend_extid)
for s in customer['subscriptions']['data']:
if s['status'] == 'active':
sub = s
break
else:
return
elif subscr.backend_extid.startswith('sub_'):
sub = self.stripe.Subscription.retrieve(subscr.backend_extid)
else:
print("unhandled subscription backend extid: {}".format(subscr.backend_extid))
return

if sub['status'] == 'canceled':
subscr.status = 'cancelled'
if sub['status'] == 'past_due':
subscr.status = 'error'

def webhook_session_completed(self, event):
session = event['data']['object']

if session['subscription']:
# Subscription creation
from payments.models import Payment, Subscription

sub_id = session['subscription']
assert sub_id

parts = session['client_reference_id'].split('_')
if len(parts) != 2 or parts[0] != 'sub':
raise Exception("invalid reference id")
sub_internal_id = int(parts[1])

# Fetch sub by ID and confirm it
subscr = Subscription.objects.get(id=sub_internal_id)
subscr.status = 'active'
subscr.backend_extid = sub_id
subscr.set_data('subscription_id', sub_id)
subscr.save()

else:
from payments.models import Payment
payment = Payment.objects.filter(backend_extid=session['id']).get()

# the amount is provided server-side, we do not have to check
payment.paid_amount = payment.amount

payment.status = 'confirmed'
payment.status_message = None
payment.save()
payment.user.vpnuser.add_paid_time(payment.time)
payment.user.vpnuser.on_payment_confirmed(payment)
payment.user.vpnuser.save()

payment.user.vpnuser.lcore_sync()

def get_subscription_from_invoice(self, invoice):
from payments.models import Subscription

subscription_id = invoice['subscription']
customer_id = invoice['customer']

# once it's confirmed, the id to the subscription is stored as extid
subscr = Subscription.objects.filter(backend_extid=subscription_id).first()
if subscr:
return subscr

# older subscriptions will have a customer id instead
subscr = Subscription.objects.filter(backend_extid=customer_id).first()
if subscr:
return subscr

return None

def webhook_payment_succeeded(self, event):
""" webhook event for a subscription's succeeded payment """
from payments.models import Payment

invoice = event['data']['object']
subscr = self.get_subscription_from_invoice(invoice)
if not subscr: