diff --git a/ccvpn/settings.py b/ccvpn/settings.py index a6c939f..fa8504e 100644 --- a/ccvpn/settings.py +++ b/ccvpn/settings.py @@ -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}, } diff --git a/lambdainst/admin.py b/lambdainst/admin.py index 98c02d6..01f6d2c 100644 --- a/lambdainst/admin.py +++ b/lambdainst/admin.py @@ -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('{}', 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('{}', 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) - diff --git a/lambdainst/migrations/0005_migrate_coupons.py b/lambdainst/migrations/0005_migrate_coupons.py new file mode 100644 index 0000000..4c163b5 --- /dev/null +++ b/lambdainst/migrations/0005_migrate_coupons.py @@ -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', + ), + ], + ), + ] diff --git a/lambdainst/models.py b/lambdainst/models.py index 15dc720..0a993fd 100644 --- a/lambdainst/models.py +++ b/lambdainst/models.py @@ -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 diff --git a/lambdainst/tasks.py b/lambdainst/tasks.py index 16a5715..f8aa3ec 100644 --- a/lambdainst/tasks.py +++ b/lambdainst/tasks.py @@ -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 diff --git a/payments/tests/online/stripe.py b/lambdainst/tests/online.py similarity index 85% rename from payments/tests/online/stripe.py rename to lambdainst/tests/online.py index c05b57b..a2ed413 100644 --- a/payments/tests/online/stripe.py +++ b/lambdainst/tests/online.py @@ -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() diff --git a/lambdainst/tests.py b/lambdainst/tests/units.py similarity index 62% rename from lambdainst/tests.py rename to lambdainst/tests/units.py index 6e62ab2..e392288 100644 --- a/lambdainst/tests.py +++ b/lambdainst/tests/units.py @@ -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() diff --git a/lambdainst/urls.py b/lambdainst/urls.py index 4daebfc..7edaa29 100644 --- a/lambdainst/urls.py +++ b/lambdainst/urls.py @@ -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'), ] diff --git a/lambdainst/views.py b/lambdainst/views.py index ba732dd..c6ede44 100644 --- a/lambdainst/views.py +++ b/lambdainst/views.py @@ -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( diff --git a/payments/__init__.py b/payments/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/payments/admin.py b/payments/admin.py deleted file mode 100644 index c0ff01e..0000000 --- a/payments/admin.py +++ /dev/null @@ -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('{}', url, text) - -def json_format(code): - j = json.dumps(code, indent=2) - return format_html("
{}", 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) - diff --git a/payments/backends/__init__.py b/payments/backends/__init__.py deleted file mode 100644 index b0f5ef2..0000000 --- a/payments/backends/__init__.py +++ /dev/null @@ -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 - diff --git a/payments/backends/base.py b/payments/backends/base.py deleted file mode 100644 index 82c7170..0000000 --- a/payments/backends/base.py +++ /dev/null @@ -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") - - diff --git a/payments/backends/bitcoin.py b/payments/backends/bitcoin.py deleted file mode 100644 index 26a075b..0000000 --- a/payments/backends/bitcoin.py +++ /dev/null @@ -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 - - diff --git a/payments/backends/coinpayments.py b/payments/backends/coinpayments.py deleted file mode 100644 index e86c5e4..0000000 --- a/payments/backends/coinpayments.py +++ /dev/null @@ -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 = ' - - ''' - 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/backends/paypal.py b/payments/backends/paypal.py deleted file mode 100644 index 91dc0fb..0000000 --- a/payments/backends/paypal.py +++ /dev/null @@ -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) - diff --git a/payments/backends/stripe.py b/payments/backends/stripe.py deleted file mode 100644 index 7311c51..0000000 --- a/payments/backends/stripe.py +++ /dev/null @@ -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 ''' - - - - '''.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: - # the subscription does not exist - # checkout.confirmed event will create it and handle the initial payment - # return True - raise Exception("Unknown subscription for invoice %r" % invoice['id']) - - # Prevent making duplicate Payments if event is received twice - pc = Payment.objects.filter(backend_extid=invoice['id']).count() - if pc > 0: - return - - payment = subscr.create_payment() - payment.status = 'confirmed' - payment.paid_amount = payment.amount - payment.backend_extid = invoice['id'] - if invoice['subscription']: - if isinstance(invoice['subscription'], str): - payment.backend_sub_id = invoice['subscription'] - else: - payment.backend_sub_id = invoice['subscription']['id'] - payment.set_data('event_id', event['id']) - payment.set_data('sub_id', payment.backend_sub_id) - payment.save() - - payment.user.vpnuser.add_paid_time(payment.time) - payment.user.vpnuser.on_payment_confirmed(payment) - payment.user.vpnuser.save() - payment.save() - - payment.user.vpnuser.lcore_sync() - - def webhook_subscr_update(self, event): - from payments.models import Subscription - stripe_sub = event['data']['object'] - sub = Subscription.objects.get(backend_id='stripe', backend_extid=stripe_sub['id']) - - if not sub: - return - - if stripe_sub['status'] == 'canceled': - sub.status = 'cancelled' - if stripe_sub['status'] == 'past_due': - sub.status = 'error' - sub.save() - - def webhook(self, request): - payload = request.body - sig_header = request.META['HTTP_STRIPE_SIGNATURE'] - - try: - event = self.stripe.Webhook.construct_event( - payload, sig_header, self.wh_key, - ) - except (ValueError, self.stripe.error.InvalidRequestError, self.stripe.error.SignatureVerificationError): - return False - - if event['type'] == 'invoice.payment_succeeded': - self.webhook_payment_succeeded(event) - if event['type'] == 'checkout.session.completed': - self.webhook_session_completed(event) - if event['type'] == 'customer.subscription.deleted': - self.webhook_subscr_update(event) - return True - - def get_ext_url(self, payment): - extid = payment.backend_extid - - if not extid: - return None - - if extid.startswith('in_'): - return 'https://dashboard.stripe.com/invoices/%s' % extid - if extid.startswith('ch_'): - return 'https://dashboard.stripe.com/payments/%s' % extid - - def get_subscr_ext_url(self, subscr): - extid = subscr.backend_extid - - if not extid: - return None - - if extid.startswith('sub_') and self.stripe: - livemode = False - try: - sub = self.stripe.Subscription.retrieve(extid) - livemode = sub['livemode'] - except Exception: - pass - if livemode: - return 'https://dashboard.stripe.com/subscriptions/' + extid - else: - return 'https://dashboard.stripe.com/test/subscriptions/' + extid - - if extid.startswith('cus_'): - return 'https://dashboard.stripe.com/customers/%s' % subscr.backend_extid diff --git a/payments/forms.py b/payments/forms.py deleted file mode 100644 index b2245a5..0000000 --- a/payments/forms.py +++ /dev/null @@ -1,16 +0,0 @@ -from django import forms -from .models import BACKEND_CHOICES - - -class NewPaymentForm(forms.Form): - TIME_CHOICES = ( - ('1', '1'), - ('3', '3'), - ('6', '6'), - ('12', '12'), - ) - - subscr = forms.ChoiceField(choices=(('0', 'no'), ('1', 'yes'))) - time = forms.ChoiceField(choices=TIME_CHOICES) - method = forms.ChoiceField(choices=BACKEND_CHOICES) - diff --git a/payments/management/__init__.py b/payments/management/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/payments/management/commands/__init__.py b/payments/management/commands/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/payments/management/commands/bitcoin_info.py b/payments/management/commands/bitcoin_info.py deleted file mode 100644 index 66265fd..0000000 --- a/payments/management/commands/bitcoin_info.py +++ /dev/null @@ -1,15 +0,0 @@ -from django.core.management.base import BaseCommand, CommandError - -from payments.models import ACTIVE_BACKENDS - - -class Command(BaseCommand): - help = "Get bitcoind info" - - def handle(self, *args, **options): - if 'bitcoin' not in ACTIVE_BACKENDS: - raise CommandError("bitcoin backend not active.") - - backend = ACTIVE_BACKENDS['bitcoin'] - for key, value in backend.get_info(): - self.stdout.write("%s: %s" % (key, value)) diff --git a/payments/management/commands/check_btc_payments.py b/payments/management/commands/check_btc_payments.py deleted file mode 100644 index 0a0e68b..0000000 --- a/payments/management/commands/check_btc_payments.py +++ /dev/null @@ -1,28 +0,0 @@ -from django.core.management.base import BaseCommand, CommandError - -from payments.models import Payment, ACTIVE_BACKENDS - - -class Command(BaseCommand): - help = "Check bitcoin payments status" - - def handle(self, *args, **options): - if 'bitcoin' not in ACTIVE_BACKENDS: - raise CommandError("bitcoin backend not active.") - - backend = ACTIVE_BACKENDS['bitcoin'] - - payments = Payment.objects.filter(backend_id='bitcoin', status='new') - - self.stdout.write("Found %d active unconfirmed payments." % len(payments)) - - for p in payments: - self.stdout.write("Checking payment #%d... " % p.id, ending="") - backend.check(p) - - if p.status == 'confirmed': - self.stdout.write("OK.") - else: - self.stdout.write("Waiting") - - diff --git a/payments/management/commands/confirm_payment.py b/payments/management/commands/confirm_payment.py deleted file mode 100644 index 9cbbe4a..0000000 --- a/payments/management/commands/confirm_payment.py +++ /dev/null @@ -1,52 +0,0 @@ -from django.core.management.base import BaseCommand -from django.utils import timezone -from django.utils.dateparse import parse_duration - -from payments.models import Payment - - -class Command(BaseCommand): - help = "Manually confirm a Payment" - - def add_arguments(self, parser): - parser.add_argument('id', action='store', type=int, help="Payment ID") - parser.add_argument('--paid-amount', dest='amount', action='store', type=int, help="Paid amount") - parser.add_argument('--extid', dest='extid', action='store', type=str) - parser.add_argument('-n', dest='sim', action='store_true', help="Simulate") - - def handle(self, *args, **options): - try: - p = Payment.objects.get(id=options['id']) - except Payment.DoesNotExist: - self.stderr.write("Cannot find payment #%d" % options['id']) - return - - print("Payment #%d by %s (amount=%d; paid_amount=%d)" % (p.id, p.user.username, p.amount, p.paid_amount)) - - if options['amount']: - pa = options['amount'] - else: - pa = p.amount - - extid = options['extid'] - - print("Status -> confirmed") - print("Paid amount -> %d" % pa) - if extid: - print("Ext ID -> %s" % extid) - - print("Confirm? [y/n] ") - i = input() - if i.lower().strip() == 'y': - p.user.vpnuser.add_paid_time(p.time) - p.user.vpnuser.on_payment_confirmed(p) - p.user.vpnuser.save() - - p.paid_amount = pa - p.status = 'confirmed' - if extid: - p.backend_extid = extid - p.save() - else: - print("aborted.") - diff --git a/payments/management/commands/expire_payments.py b/payments/management/commands/expire_payments.py deleted file mode 100644 index deaf1e0..0000000 --- a/payments/management/commands/expire_payments.py +++ /dev/null @@ -1,31 +0,0 @@ -from django.core.management.base import BaseCommand -from django.utils import timezone -from django.utils.dateparse import parse_duration - -from payments.models import Payment - - -class Command(BaseCommand): - help = "Cancels expired Payments" - - def add_arguments(self, parser): - parser.add_argument('-n', dest='sim', action='store_true', help="Simulate") - parser.add_argument('-e', '--exp-time', action='store', - help="Expiration time.", default='3 00:00:00') - - def handle(self, *args, **options): - now = timezone.now() - expdate = now - parse_duration(options['exp_time']) - - self.stdout.write("Now: " + now.isoformat()) - self.stdout.write("Exp: " + expdate.isoformat()) - - expired = Payment.objects.filter(created__lte=expdate, status='new', - paid_amount=0) - - for p in expired: - self.stdout.write("Payment #%d (%s): %s" % (p.id, p.user.username, p.created)) - if not options['sim']: - p.status = 'cancelled' - p.save() - diff --git a/payments/management/commands/update_stripe_plans.py b/payments/management/commands/update_stripe_plans.py deleted file mode 100644 index 444dc69..0000000 --- a/payments/management/commands/update_stripe_plans.py +++ /dev/null @@ -1,70 +0,0 @@ -from django.core.management.base import BaseCommand, CommandError -from django.conf import settings - -from ccvpn.common import get_price -from payments.models import ACTIVE_BACKENDS, SUBSCR_PERIOD_CHOICES, period_months - -CURRENCY_CODE, CURRENCY_NAME = settings.PAYMENTS_CURRENCY - - -class Command(BaseCommand): - help = "Update Stripe plans" - - def add_arguments(self, parser): - parser.add_argument('--force-run', action='store_true', - help="Run even when Stripe backend is disabled") - parser.add_argument('--force-update', action='store_true', - help="Replace plans, including matching ones") - - def handle(self, *args, **options): - if 'stripe' not in ACTIVE_BACKENDS and options['force-run'] is False: - raise CommandError("stripe backend not active.") - - backend = ACTIVE_BACKENDS['stripe'] - stripe = backend.stripe - - for period_id, period_name in SUBSCR_PERIOD_CHOICES: - plan_id = backend.get_plan_id(period_id) - months = period_months(period_id) - amount = months * get_price() - - kwargs = dict( - id=plan_id, - amount=amount, - interval='month', - interval_count=months, - name="VPN Subscription (%s)" % period_id, - currency=CURRENCY_CODE, - ) - - self.stdout.write('Plan %s: %d months for %.2f %s (%s)... ' % ( - plan_id, months, amount / 100, CURRENCY_NAME, CURRENCY_CODE), ending='') - self.stdout.flush() - - try: - plan = stripe.Plan.retrieve(plan_id) - except stripe.error.InvalidRequestError: - plan = None - - def is_valid_plan(): - if not plan: - return False - for k, v in kwargs.items(): - if getattr(plan, k) != v: - return False - return True - - if plan: - if is_valid_plan() and not options['force_update']: - self.stdout.write(self.style.SUCCESS('[ok]')) - continue - plan.delete() - update = True - else: - update = False - - stripe.Plan.create(**kwargs) - if update: - self.stdout.write(self.style.WARNING('[updated]')) - else: - self.stdout.write(self.style.WARNING('[created]')) diff --git a/payments/migrations/0001_initial.py b/payments/migrations/0001_initial.py deleted file mode 100644 index 3425c63..0000000 --- a/payments/migrations/0001_initial.py +++ /dev/null @@ -1,60 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.db import migrations, models -import jsonfield.fields -from django.conf import settings - - -class Migration(migrations.Migration): - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.CreateModel( - name='Payment', - fields=[ - ('id', models.AutoField(verbose_name='ID', primary_key=True, serialize=False, auto_created=True)), - ('backend_id', models.CharField(choices=[('bitcoin', 'Bitcoin'), ('coinbase', 'Coinbase'), ('manual', 'Manual'), ('paypal', 'PayPal'), ('stripe', 'Stripe')], max_length=16)), - ('status', models.CharField(choices=[('new', 'Waiting for payment'), ('confirmed', 'Confirmed'), ('cancelled', 'Cancelled'), ('rejected', 'Rejected by processor'), ('error', 'Payment processing failed')], max_length=16)), - ('created', models.DateTimeField(auto_now_add=True)), - ('modified', models.DateTimeField(auto_now=True)), - ('confirmed_on', models.DateTimeField(null=True, blank=True)), - ('amount', models.IntegerField()), - ('paid_amount', models.IntegerField(default=0)), - ('time', models.DurationField()), - ('status_message', models.TextField(null=True, blank=True)), - ('backend_extid', models.CharField(null=True, max_length=64, blank=True)), - ('backend_data', jsonfield.fields.JSONField(blank=True, default=dict)), - ], - options={ - 'ordering': ('-created',), - }, - ), - migrations.CreateModel( - name='RecurringPaymentSource', - fields=[ - ('id', models.AutoField(verbose_name='ID', primary_key=True, serialize=False, auto_created=True)), - ('backend', models.CharField(choices=[('bitcoin', 'Bitcoin'), ('coinbase', 'Coinbase'), ('manual', 'Manual'), ('paypal', 'PayPal'), ('stripe', 'Stripe')], max_length=16)), - ('created', models.DateTimeField(auto_now_add=True)), - ('modified', models.DateTimeField(auto_now=True)), - ('period', models.CharField(choices=[('monthly', 'Monthly'), ('biannually', 'Bianually'), ('yearly', 'Yearly')], max_length=16)), - ('last_confirmed_payment', models.DateTimeField(null=True, blank=True)), - ('backend_id', models.CharField(null=True, max_length=64, blank=True)), - ('backend_data', jsonfield.fields.JSONField(blank=True, default=dict)), - ('user', models.ForeignKey(to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE)), - ], - ), - migrations.AddField( - model_name='payment', - name='recurring_source', - field=models.ForeignKey(null=True, to='payments.RecurringPaymentSource', blank=True, on_delete=models.CASCADE), - ), - migrations.AddField( - model_name='payment', - name='user', - field=models.ForeignKey(to=settings.AUTH_USER_MODEL, on_delete=models.SET_NULL), - ), - ] diff --git a/payments/migrations/0002_auto_20151204_0341.py b/payments/migrations/0002_auto_20151204_0341.py deleted file mode 100644 index 8755270..0000000 --- a/payments/migrations/0002_auto_20151204_0341.py +++ /dev/null @@ -1,19 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('payments', '0001_initial'), - ] - - operations = [ - migrations.AlterField( - model_name='recurringpaymentsource', - name='period', - field=models.CharField(max_length=16, choices=[('6m', 'Every 6 months'), ('1year', 'Yearly')]), - ), - ] diff --git a/payments/migrations/0003_auto_20151209_0440.py b/payments/migrations/0003_auto_20151209_0440.py deleted file mode 100644 index 312fb3f..0000000 --- a/payments/migrations/0003_auto_20151209_0440.py +++ /dev/null @@ -1,20 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.9 on 2015-12-09 04:40 -from __future__ import unicode_literals - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('payments', '0002_auto_20151204_0341'), - ] - - operations = [ - migrations.AlterField( - model_name='payment', - name='status', - field=models.CharField(choices=[('new', 'Waiting for payment'), ('confirmed', 'Confirmed'), ('cancelled', 'Cancelled'), ('rejected', 'Rejected by processor'), ('error', 'Payment processing failed')], default='new', max_length=16), - ), - ] diff --git a/payments/migrations/0004_auto_20160904_0048.py b/payments/migrations/0004_auto_20160904_0048.py deleted file mode 100644 index c7db118..0000000 --- a/payments/migrations/0004_auto_20160904_0048.py +++ /dev/null @@ -1,49 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.9.5 on 2016-09-04 00:48 -from __future__ import unicode_literals - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion -import jsonfield.fields - - -class Migration(migrations.Migration): - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('payments', '0003_auto_20151209_0440'), - ] - - operations = [ - migrations.CreateModel( - name='Subscription', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('backend_id', models.CharField(choices=[('bitcoin', 'Bitcoin'), ('coinbase', 'Coinbase'), ('manual', 'Manual'), ('paypal', 'PayPal'), ('stripe', 'Stripe')], max_length=16)), - ('created', models.DateTimeField(auto_now_add=True)), - ('period', models.CharField(choices=[('3m', 'Every 3 months'), ('6m', 'Every 6 months'), ('12m', 'Every year')], max_length=16)), - ('last_confirmed_payment', models.DateTimeField(blank=True, null=True)), - ('status', models.CharField(choices=[('new', 'Waiting for payment'), ('active', 'Active'), ('cancelled', 'Cancelled')], default='new', max_length=16)), - ('backend_extid', models.CharField(blank=True, max_length=64, null=True)), - ('backend_data', jsonfield.fields.JSONField(blank=True, default=dict)), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), - ], - ), - migrations.RemoveField( - model_name='recurringpaymentsource', - name='user', - ), - migrations.RemoveField( - model_name='payment', - name='recurring_source', - ), - migrations.DeleteModel( - name='RecurringPaymentSource', - ), - migrations.AddField( - model_name='payment', - name='subscription', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='payments.Subscription'), - ), - ] diff --git a/payments/migrations/0005_auto_20160907_0018.py b/payments/migrations/0005_auto_20160907_0018.py deleted file mode 100644 index 93d608c..0000000 --- a/payments/migrations/0005_auto_20160907_0018.py +++ /dev/null @@ -1,20 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.9.5 on 2016-09-07 00:18 -from __future__ import unicode_literals - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('payments', '0004_auto_20160904_0048'), - ] - - operations = [ - migrations.AlterField( - model_name='subscription', - name='status', - field=models.CharField(choices=[('new', 'Created'), ('unconfirmed', 'Waiting for payment'), ('active', 'Active'), ('cancelled', 'Cancelled'), ('error', 'Error')], default='new', max_length=16), - ), - ] diff --git a/payments/migrations/0006_auto_20190907_2029.py b/payments/migrations/0006_auto_20190907_2029.py deleted file mode 100644 index d19c545..0000000 --- a/payments/migrations/0006_auto_20190907_2029.py +++ /dev/null @@ -1,26 +0,0 @@ -# Generated by Django 2.2.1 on 2019-09-07 20:29 - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('payments', '0005_auto_20160907_0018'), - ] - - operations = [ - migrations.CreateModel( - name='Feedback', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created', models.DateTimeField(auto_now_add=True)), - ('message', models.TextField()), - ('subscription', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='payments.Subscription')), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), - ], - ), - ] diff --git a/payments/migrations/0007_auto_20201114_1730.py b/payments/migrations/0007_auto_20201114_1730.py deleted file mode 100644 index a275da1..0000000 --- a/payments/migrations/0007_auto_20201114_1730.py +++ /dev/null @@ -1,26 +0,0 @@ -# Generated by Django 3.1.2 on 2020-11-14 17:30 - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('payments', '0006_auto_20190907_2029'), - ] - - operations = [ - migrations.AlterField( - model_name='payment', - name='backend_extid', - field=models.CharField(blank=True, max_length=256, null=True), - ), - migrations.AlterField( - model_name='subscription', - name='backend_extid', - field=models.CharField(blank=True, max_length=256, null=True), - ), - ] diff --git a/payments/migrations/0008_auto_20210721_1931.py b/payments/migrations/0008_auto_20210721_1931.py deleted file mode 100644 index c00c579..0000000 --- a/payments/migrations/0008_auto_20210721_1931.py +++ /dev/null @@ -1,99 +0,0 @@ -# Generated by Django 3.2.4 on 2021-07-21 19:31 - -from datetime import timedelta -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion -import jsonfield.fields - -def field_to_plan_id(model_name, field_name): - def fun(apps, schema_editor): - keys = settings.PLANS.keys() - model = apps.get_model('payments', model_name) - for s in model.objects.all(): - d = getattr(s, field_name) - if s.plan_id is not None: - continue - s.plan_id = str(round(d / timedelta(days=30))) + 'm' - if s.plan_id not in keys: - raise Exception(f"unknown plan: {s.plan_id}") - s.save() - return fun - - -class Migration(migrations.Migration): - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('payments', '0007_auto_20201114_1730'), - ] - - operations = [ - migrations.RenameField("Subscription", 'period', 'plan_id'), - - # Add plan_id to payments and convert from the time field - migrations.AddField( - model_name='payment', - name='plan_id', - field=models.CharField(choices=[('1m', 'Every 1 month'), ('3m', 'Every 3 months'), ('6m', 'Every 6 months'), ('12m', 'Every 12 months')], max_length=16, null=True), - ), - migrations.RunPython(field_to_plan_id('Payment', 'time'), lambda x, y: ()), - - # Make those two columns non-null once converted - migrations.AlterField( - model_name='payment', - name='plan_id', - field=models.CharField(choices=[('1m', 'Every 1 month'), ('3m', 'Every 3 months'), ('6m', 'Every 6 months'), ('12m', 'Every 12 months')], max_length=16), - ), - migrations.AlterField( - model_name='subscription', - name='plan_id', - field=models.CharField(choices=[('1m', 'Every 1 month'), ('3m', 'Every 3 months'), ('6m', 'Every 6 months'), ('12m', 'Every 12 months')], max_length=16), - ), - - migrations.AddField( - model_name='payment', - name='ip_address', - field=models.GenericIPAddressField(blank=True, null=True), - ), - migrations.AddField( - model_name='payment', - name='refund_date', - field=models.DateTimeField(blank=True, null=True), - ), - migrations.AddField( - model_name='payment', - name='refund_text', - field=models.CharField(blank=True, max_length=200), - ), - migrations.AddField( - model_name='subscription', - name='next_payment_date', - field=models.DateTimeField(blank=True, null=True), - ), - migrations.AlterField( - model_name='payment', - name='backend_data', - field=jsonfield.fields.JSONField(blank=True), - ), - migrations.AlterField( - model_name='payment', - name='backend_id', - field=models.CharField(choices=[('bitcoin', 'Bitcoin'), ('paypal', 'PayPal'), ('stripe', 'Stripe')], max_length=16), - ), - migrations.AlterField( - model_name='payment', - name='user', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), - ), - migrations.AlterField( - model_name='subscription', - name='backend_data', - field=jsonfield.fields.JSONField(blank=True), - ), - migrations.AlterField( - model_name='subscription', - name='backend_id', - field=models.CharField(choices=[('bitcoin', 'Bitcoin'), ('paypal', 'PayPal'), ('stripe', 'Stripe')], max_length=16), - ), - ] diff --git a/payments/migrations/__init__.py b/payments/migrations/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/payments/models.py b/payments/models.py deleted file mode 100644 index 5606389..0000000 --- a/payments/models.py +++ /dev/null @@ -1,272 +0,0 @@ -import logging -from django.db import models -from django.conf import settings -from django.utils.translation import ugettext_lazy as _ -from jsonfield import JSONField -from datetime import timedelta -import json - -from ccvpn.common import get_price -from .backends import BackendBase - -logger = logging.getLogger(__name__) - -backends_settings = settings.PAYMENTS_BACKENDS -assert isinstance(backends_settings, dict) - -CURRENCY_CODE, CURRENCY_NAME = settings.PAYMENTS_CURRENCY - -STATUS_CHOICES = ( - ('new', _("Waiting for payment")), - ('confirmed', _("Confirmed")), - ('cancelled', _("Cancelled")), - ('rejected', _("Rejected by processor")), - ('error', _("Payment processing failed")), -) - -# A Subscription is created with status='new'. When getting back from PayPal, -# it may get upgraded to 'unconfirmed'. It will be set 'active' with the first -# confirmed payment. -# 'unconfirmed' exists to prevent creation of a second Subscription while -# waiting for the first one to be confirmed. -SUBSCR_STATUS_CHOICES = ( - ('new', _("Created")), - ('unconfirmed', _("Waiting for payment")), - ('active', _("Active")), - ('cancelled', _("Cancelled")), - ('error', _("Error")), -) - -SUBSCR_PERIOD_CHOICES = ( - ('3m', _("Every 3 months")), - ('6m', _("Every 6 months")), - ('12m', _("Every year")), -) - -BACKEND_CLASSES = BackendBase.__subclasses__() - -# All known backends (classes) -BACKENDS = {} -BACKEND_CHOICES = [] - -# All enabled backends (configured instances) -ACTIVE_BACKENDS = {} -ACTIVE_BACKEND_CHOICES = [] - -logger.info("loading payment backends...") -for cls in BACKEND_CLASSES: - name = cls.backend_id - assert isinstance(name, str) - - if name not in backends_settings: - logger.info("payments: ☐ %s disabled (no settings)", name) - continue - - backend_settings = backends_settings.get(name, {}) - for k, v in backend_settings.items(): - if hasattr(v, '__call__'): - backend_settings[k] = v() - - if not backend_settings.get('enabled'): - logger.info("payments: ☐ %s disabled (by settings)", name) - continue - - obj = cls(backend_settings) - - BACKENDS[name] = obj - BACKEND_CHOICES.append((name, cls.backend_verbose_name)) - - if obj.backend_enabled: - ACTIVE_BACKENDS[name] = obj - ACTIVE_BACKEND_CHOICES.append((name, cls.backend_verbose_name)) - logger.info("payments: ☑ %s initialized", name) - else: - logger.info("payments: ☒ %s disabled (initialization failed)", name) - -BACKEND_CHOICES = sorted(BACKEND_CHOICES, key=lambda x: x[0]) -ACTIVE_BACKEND_CHOICES = sorted(ACTIVE_BACKEND_CHOICES, key=lambda x: x[0]) - -logger.info("payments: finished. %d/%d backends active", len(ACTIVE_BACKENDS), len(BACKEND_CLASSES)) - - -class PlanBase(object): - def __init__(self, name, months, monthly, saves="", default=False): - self.name = name - self.months = months - self.monthly = monthly - self.saves = saves - self.default = default - @property - def due_amount(self): - return round(self.months * self.monthly, 2) - @property - def time_display(self): - return "%d month%s" % (self.months, 's' if self.months > 1 else '') - def json(self): - return {'total': self.due_amount} - - -PLANS = {k: PlanBase(name=k, **settings.PLANS[k]) for k, v in settings.PLANS.items()} -PLAN_CHOICES = [(k, "Every " + p.time_display) for k, p in PLANS.items()] -SUBSCR_PLAN_CHOICES = PLAN_CHOICES - -def period_months(p): - return { - '3m': 3, - '6m': 6, - '12m': 12, - }[p] - - -class BackendData: - backend_data = None - - def set_data(self, key, value): - """ adds a backend data key to this instance's dict """ - if not self.backend_data: - self.backend_data = {} - - if isinstance(self.backend_data, str): - self.backend_data = json.loads(self.backend_data) or {} - - if not isinstance(self.backend_data, dict): - raise Exception("self.backend_data is not a dict (%r)" % self.backend_data) - self.backend_data[key] = value - - -class Payment(models.Model, BackendData): - """ Just a payment. - If subscription is not null, it has been automatically issued. - backend_extid is the external transaction ID, backend_data is other - things that should only be used by the associated backend. - """ - user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) - backend_id = models.CharField(max_length=16, choices=BACKEND_CHOICES) - status = models.CharField(max_length=16, choices=STATUS_CHOICES, default='new') - created = models.DateTimeField(auto_now_add=True) - modified = models.DateTimeField(auto_now=True) - confirmed_on = models.DateTimeField(null=True, blank=True) - amount = models.IntegerField() - paid_amount = models.IntegerField(default=0) - time = models.DurationField() - plan_id = models.CharField(max_length=16, choices=SUBSCR_PLAN_CHOICES) - subscription = models.ForeignKey('Subscription', null=True, blank=True, on_delete=models.CASCADE) - status_message = models.TextField(blank=True, null=True) - ip_address = models.GenericIPAddressField(blank=True, null=True) - - backend_extid = models.CharField(max_length=256, null=True, blank=True) - backend_data = JSONField(blank=True) - - refund_date = models.DateTimeField(blank=True, null=True) - refund_text = models.CharField(max_length=200, blank=True) - - @property - def currency_code(self): - return CURRENCY_CODE - - @property - def currency_name(self): - return CURRENCY_NAME - - @property - def backend(self): - """ Returns a global instance of the backend - :rtype: BackendBase - """ - return BACKENDS[self.backend_id] - - def get_amount_display(self): - return '%.2f %s' % (self.amount / 100, CURRENCY_NAME) - - @property - 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', ) - - @classmethod - def create_payment(self, backend_id, user, months): - payment = Payment( - user=user, - backend_id=backend_id, - status='new', - time=timedelta(days=30 * months), - amount=get_price() * months - ) - return payment - - -class Subscription(models.Model, BackendData): - """ Recurring payment subscription. """ - user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) - backend_id = models.CharField(max_length=16, choices=BACKEND_CHOICES) - created = models.DateTimeField(auto_now_add=True) - plan_id = models.CharField(max_length=16, choices=SUBSCR_PLAN_CHOICES) - next_payment_date = models.DateTimeField(blank=True, null=True) - last_confirmed_payment = models.DateTimeField(blank=True, null=True) - status = models.CharField(max_length=16, choices=SUBSCR_STATUS_CHOICES, default='new') - - backend_extid = models.CharField(max_length=256, null=True, blank=True) - backend_data = JSONField(blank=True) - - @property - def backend(self): - """ Returns a global instance of the backend - :rtype: BackendBase - """ - return BACKENDS[self.backend_id] - - @property - def months(self): - return period_months(self.period) - - @property - def period_amount(self): - return self.months * get_price() - - @property - def next_renew(self): - """ Approximate date of the next payment """ - if self.last_confirmed_payment: - return self.last_confirmed_payment + timedelta(days=self.months * 30) - return self.created + timedelta(days=self.months * 30) - - @property - def monthly_amount(self): - return get_price() - - def create_payment(self): - payment = Payment( - user=self.user, - backend_id=self.backend_id, - status='new', - time=timedelta(days=30 * self.months), - amount=get_price() * self.months, - subscription=self, - ) - return payment - - -class Feedback(models.Model): - user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) - subscription = models.ForeignKey('Subscription', null=True, blank=True, on_delete=models.SET_NULL) - created = models.DateTimeField(auto_now_add=True) - message = models.TextField() - diff --git a/payments/tasks.py b/payments/tasks.py deleted file mode 100644 index 2430f95..0000000 --- a/payments/tasks.py +++ /dev/null @@ -1,33 +0,0 @@ -import logging -from datetime import timedelta -from django.utils import timezone -from celery import task - -from payments.models import Payment -from .models import Payment, Subscription, ACTIVE_BACKENDS - -logger = logging.getLogger(__name__) - -@task -def check_subscriptions(): - logger.debug("checking subscriptions") - subs = Subscription.objects.filter(status='active', backend_id='stripe').all() - for sub in subs: - logger.debug("checking subscription #%s on %s", sub.id, sub.backend_id) - sub.refresh_from_db() - ACTIVE_BACKENDS['stripe'].refresh_subscription(sub) - sub.save() - -@task -def cancel_old_payments(): - expdate = timezone.now() - timedelta(days=3) - - expired = Payment.objects.filter(created__lte=expdate, status='new', - paid_amount=0) - - logger.info("cancelling %d pending payments older than 3 days (%s)", len(expired), expdate.isoformat()) - - for p in expired: - logger.debug("cancelling payment #%d (%s): created on %s", p.id, p.user.username, p.created) - p.status = 'cancelled' - p.save() diff --git a/payments/tests/__init__.py b/payments/tests/__init__.py deleted file mode 100644 index 118c9ab..0000000 --- a/payments/tests/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -# flake8: noqa - -from .bitcoin import * -from .paypal import * -from .coingate import * - -from django.conf import settings -if settings.RUN_ONLINE_TESTS: - from .online.stripe import * - diff --git a/payments/tests/bitcoin.py b/payments/tests/bitcoin.py deleted file mode 100644 index 44ec080..0000000 --- a/payments/tests/bitcoin.py +++ /dev/null @@ -1,118 +0,0 @@ -from datetime import timedelta - -from django.test import TestCase -from django.http import HttpResponseRedirect -from django.contrib.auth.models import User -from constance.test import override_config - -from payments.models import Payment -from payments.backends import BitcoinBackend - -from decimal import Decimal - - -class FakeBTCRPCNew: - def getnewaddress(self, account): - return 'TEST_ADDRESS' - - -class FakeBTCRPCUnpaid: - def getreceivedbyaddress(self, address): - assert address == 'TEST_ADDRESS' - return Decimal('0') - - -class FakeBTCRPCPartial: - def getreceivedbyaddress(self, address): - assert address == 'TEST_ADDRESS' - return Decimal('0.5') * 100000000 - - -class FakeBTCRPCPaid: - def getreceivedbyaddress(self, address): - assert address == 'TEST_ADDRESS' - return Decimal('1') * 100000000 - - -class BitcoinBackendTest(TestCase): - def setUp(self): - self.user = User.objects.create_user('test', 'test_user@example.com', None) - - self.p = Payment.objects.create( - user=self.user, time=timedelta(days=30), backend_id='bitcoin', - amount=300) - - @override_config(BTC_EUR_VALUE=300) - def test_new(self): - backend = BitcoinBackend(dict(URL='')) - backend.make_rpc = FakeBTCRPCNew - - backend.new_payment(self.p) - redirect = backend.new_payment(self.p) - self.assertEqual(self.p.backend_extid, 'TEST_ADDRESS') - self.assertEqual(self.p.status, 'new') - self.assertIn('btc_price', self.p.backend_data) - self.assertIn('btc_address', self.p.backend_data) - self.assertEqual(self.p.backend_data['btc_address'], 'TEST_ADDRESS') - self.assertIsInstance(redirect, HttpResponseRedirect) - self.assertEqual(redirect.url, '/payments/view/%d' % self.p.id) - self.assertEqual(self.p.status_message, "Please send 1.00000 BTC to TEST_ADDRESS") - - def test_rounding(self): - """ Rounding test - 300 / 300 = 1 => 1.00000 BTC - 300 / 260 = Decimal('1.153846153846153846153846154') => 1.15385 BTC - """ - with override_config(BTC_EUR_VALUE=300): - backend = BitcoinBackend(dict(URL='')) - backend.make_rpc = FakeBTCRPCNew - backend.new_payment(self.p) - self.assertEqual(self.p.status_message, "Please send 1.00000 BTC to TEST_ADDRESS") - - with override_config(BTC_EUR_VALUE=260): - backend = BitcoinBackend(dict(URL='')) - backend.make_rpc = FakeBTCRPCNew - backend.new_payment(self.p) - self.assertEqual(self.p.status_message, "Please send 1.15385 BTC to TEST_ADDRESS") - - -class BitcoinBackendConfirmTest(TestCase): - @override_config(BTC_EUR_VALUE=300) - def setUp(self): - self.user = User.objects.create_user('test', 'test_user@example.com', None) - - self.p = Payment.objects.create( - user=self.user, time=timedelta(days=30), backend_id='bitcoin', - amount=300) - - # call new_payment - backend = BitcoinBackend(dict(URL='')) - backend.make_rpc = FakeBTCRPCNew - backend.new_payment(self.p) - - @override_config(BTC_EUR_VALUE=300) - def test_check_unpaid(self): - backend = BitcoinBackend(dict(URL='')) - backend.make_rpc = FakeBTCRPCUnpaid - - backend.check(self.p) - self.assertEqual(self.p.status, 'new') - self.assertEqual(self.p.paid_amount, 0) - - @override_config(BTC_EUR_VALUE=300) - def test_check_partially_paid(self): - backend = BitcoinBackend(dict(URL='')) - backend.make_rpc = FakeBTCRPCPartial - backend.check(self.p) - self.assertEqual(self.p.status, 'new') - self.assertEqual(self.p.paid_amount, 150) - - @override_config(BTC_EUR_VALUE=300) - def test_check_paid(self): - backend = BitcoinBackend(dict(URL='')) - backend.make_rpc = FakeBTCRPCPaid - backend.check(self.p) - self.assertEqual(self.p.paid_amount, 300) - self.assertEqual(self.p.status, 'confirmed') - - diff --git a/payments/tests/coingate.py b/payments/tests/coingate.py deleted file mode 100644 index 3c9150d..0000000 --- a/payments/tests/coingate.py +++ /dev/null @@ -1,75 +0,0 @@ -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/tests/paypal.py b/payments/tests/paypal.py deleted file mode 100644 index 96dcda4..0000000 --- a/payments/tests/paypal.py +++ /dev/null @@ -1,325 +0,0 @@ -from datetime import timedelta -from urllib.parse import parse_qs - -from django.test import TestCase, RequestFactory -from django.http import HttpResponseRedirect -from django.contrib.auth.models import User - -from payments.models import Payment, Subscription -from payments.backends import PaypalBackend - - -PAYPAL_IPN_TEST = '''\ -mc_gross=3.00&\ -protection_eligibility=Eligible&\ -address_status=confirmed&\ -payer_id=LPLWNMTBWMFAY&\ -tax=0.00&\ -address_street=1+Main+St&\ -payment_date=20%3A12%3A59+Jan+13%2C+2009+PST&\ -payment_status=Completed&\ -charset=windows-1252&\ -address_zip=95131&\ -first_name=Test&\ -mc_fee=0.88&\ -address_country_code=US&\ -address_name=Test+User&\ -notify_version=2.6&\ -custom=&\ -payer_status=verified&\ -address_country=United+States&\ -address_city=San+Jose&\ -quantity=1&\ -payer_email=test_user@example.com&\ -txn_id=61E67681CH3238416&\ -payment_type=instant&\ -last_name=User&\ -address_state=CA&\ -receiver_email=test_business@example.com&\ -payment_fee=0.88&\ -receiver_id=S8XGHLYDW9T3S&\ -txn_type=express_checkout&\ -item_name=&\ -mc_currency=EUR&\ -item_number=&\ -residence_country=US&\ -test_ipn=1&\ -handling_amount=0.00&\ -transaction_subject=&\ -payment_gross=3.00&\ -shipping=0.00''' - -PAYPAL_IPN_SUBSCR_PAYMENT = '''\ -transaction_subject=VPN+Payment&\ -payment_date=11%3A19%3A00+Sep+04%2C+2016+PDT&\ -txn_type=subscr_payment&\ -subscr_id=I-1S262863X133&\ -last_name=buyer&\ -residence_country=FR&\ -item_name=VPN+Payment&\ -payment_gross=&\ -mc_currency=EUR&\ -business=test_business@example.com&\ -payment_type=instant&\ -protection_eligibility=Ineligible&\ -payer_status=verified&\ -test_ipn=1&\ -payer_email=test_user@example.com&\ -txn_id=097872679P963871Y&\ -receiver_email=test_business@example.com&\ -first_name=test&\ -payer_id=APYYVSFLNPWUU&\ -receiver_id=MGT8TQ8GC4944&\ -payment_status=Completed&\ -payment_fee=&\ -mc_fee=0.56&\ -mc_gross=9.00&\ -charset=windows-1252&\ -notify_version=3.8&\ -ipn_track_id=546a4aa4300a0''' - - -PAYPAL_IPN_SUBSCR_CANCEL = '''\ -txn_type=subscr_cancel&\ -subscr_id=I-E5SCT6936H40&\ -last_name=buyer&\ -residence_country=FR&\ -mc_currency=EUR&\ -item_name=VPN+Payment&\ -business=test_business@example.com&\ -recurring=1&\ -payer_status=verified&\ -test_ipn=1&\ -payer_email=test_user@example.com&\ -first_name=test&\ -receiver_email=test_business@example.com&\ -payer_id=APYYVSFLNPWUU&\ -reattempt=1&\ -subscr_date=17%3A35%3A14+Sep+04%2C+2016+PDT&\ -charset=windows-1252&\ -notify_version=3.8&\ -period3=3+M&\ -mc_amount3=9.00&\ -ipn_track_id=474870d13b375''' - - -PAYPAL_IPN_SUBSCR_SIGNUP = '''\ -txn_type=subscr_signup&\ -subscr_id=I-1S262863X133&\ -last_name=buyer&\ -residence_country=FR&\ -mc_currency=EUR&\ -item_name=VPN+Payment&\ -business=test_business@example.com&\ -recurring=1&\ -payer_status=verified&\ -test_ipn=1&\ -payer_email=test_user@example.com&\ -first_name=test&\ -receiver_email=test_business@example.com&\ -payer_id=APYYVSFLNPWUU&\ -reattempt=1&\ -subscr_date=11%3A18%3A57+Sep+04%2C+2016+PDT&\ -charset=windows-1252&\ -notify_version=3.8&\ -period3=3+M&\ -mc_amount3=9.00&\ -ipn_track_id=546a4aa4300a0''' - - -class PaypalBackendTest(TestCase): - def setUp(self): - self.user = User.objects.create_user('test', 'test_user@example.com', None) - - def test_paypal(self): - # TODO: This checks the most simple and perfect payment that could - # happen, but not errors or other/invalid IPN - - payment = Payment.objects.create( - user=self.user, - time=timedelta(days=30), - backend_id='paypal', - amount=300 - ) - - settings = dict( - test=True, - title='Test Title', - currency='EUR', - address='test_business@example.com', - ) - - with self.settings(ROOT_URL='root'): - backend = PaypalBackend(settings) - redirect = backend.new_payment(payment) - - self.assertIsInstance(redirect, HttpResponseRedirect) - - host, params = redirect.url.split('?', 1) - params = parse_qs(params) - - expected_notify_url = 'root/payments/callback/paypal/%d' % payment.id - expected_return_url = 'root/payments/view/%d' % payment.id - expected_cancel_url = 'root/payments/cancel/%d' % payment.id - - self.assertEqual(params['cmd'][0], '_xclick') - self.assertEqual(params['notify_url'][0], expected_notify_url) - self.assertEqual(params['return'][0], expected_return_url) - self.assertEqual(params['cancel_return'][0], expected_cancel_url) - self.assertEqual(params['business'][0], 'test_business@example.com') - self.assertEqual(params['currency_code'][0], 'EUR') - self.assertEqual(params['amount'][0], '3.00') - self.assertEqual(params['item_name'][0], 'Test Title') - - # Replace PaypalBackend.verify_ipn to not call the PayPal API - # we will assume the IPN is authentic - backend.verify_ipn = lambda request: True - - ipn_url = '/payments/callback/paypal/%d' % payment.id - ipn_request = RequestFactory().post( - ipn_url, - content_type='application/x-www-form-urlencoded', - data=PAYPAL_IPN_TEST) - r = backend.callback(payment, ipn_request) - - self.assertTrue(r) - self.assertEqual(payment.status, 'confirmed') - self.assertEqual(payment.paid_amount, 300) - self.assertEqual(payment.backend_extid, '61E67681CH3238416') - - def test_paypal_ipn_error(self): - payment = Payment.objects.create( - user=self.user, - time=timedelta(days=30), - backend_id='paypal', - amount=300 - ) - - settings = dict( - test=True, - title='Test Title', - currency='EUR', - address='test_business@example.com', - ) - - with self.settings(ROOT_URL='root'): - backend = PaypalBackend(settings) - redirect = backend.new_payment(payment) - - self.assertIsInstance(redirect, HttpResponseRedirect) - - host, params = redirect.url.split('?', 1) - params = parse_qs(params) - - # Replace PaypalBackend.verify_ipn to not call the PayPal API - # we will assume the IPN is authentic - backend.verify_ipn = lambda request: True - - ipn_url = '/payments/callback/paypal/%d' % payment.id - ipn_request = RequestFactory().post( - ipn_url, - content_type='application/x-www-form-urlencoded', - data=PAYPAL_IPN_TEST) - r = backend.callback(payment, ipn_request) - - self.assertTrue(r) - self.assertEqual(payment.status, 'confirmed') - self.assertEqual(payment.paid_amount, 300) - self.assertEqual(payment.backend_extid, '61E67681CH3238416') - - def test_paypal_subscr(self): - subscription = Subscription.objects.create( - user=self.user, - backend_id='paypal', - period='3m' - ) - - settings = dict( - test=True, - title='Test Title', - currency='EUR', - address='test_business@example.com', - ) - - with self.settings(ROOT_URL='root'): - backend = PaypalBackend(settings) - redirect = backend.new_subscription(subscription) - - self.assertIsInstance(redirect, HttpResponseRedirect) - - host, params = redirect.url.split('?', 1) - params = parse_qs(params) - - expected_notify_url = 'root/payments/callback/paypal_subscr/%d' % subscription.id - expected_return_url = 'root/payments/return_subscr/%d' % subscription.id - expected_cancel_url = 'root/account/' - - self.assertEqual(params['cmd'][0], '_xclick-subscriptions') - self.assertEqual(params['notify_url'][0], expected_notify_url) - self.assertEqual(params['return'][0], expected_return_url) - self.assertEqual(params['cancel_return'][0], expected_cancel_url) - self.assertEqual(params['business'][0], 'test_business@example.com') - self.assertEqual(params['currency_code'][0], 'EUR') - self.assertEqual(params['a3'][0], '9.00') - self.assertEqual(params['p3'][0], '3') - self.assertEqual(params['t3'][0], 'M') - self.assertEqual(params['item_name'][0], 'Test Title') - - # Replace PaypalBackend.verify_ipn to not call the PayPal API - # we will assume the IPN is authentic - backend.verify_ipn = lambda request: True - - self.assertEqual(subscription.status, 'new') - - # 1. the subscr_payment IPN - ipn_url = '/payments/callback/paypal_subscr/%d' % subscription.id - ipn_request = RequestFactory().post( - ipn_url, - content_type='application/x-www-form-urlencoded', - data=PAYPAL_IPN_SUBSCR_PAYMENT) - r = backend.callback_subscr(subscription, ipn_request) - - self.assertTrue(r) - self.assertEqual(subscription.status, 'active') - self.assertEqual(subscription.backend_extid, 'I-1S262863X133') - - payments = Payment.objects.filter(subscription=subscription).all() - self.assertEqual(len(payments), 1) - self.assertEqual(payments[0].amount, 900) - self.assertEqual(payments[0].paid_amount, 900) - self.assertEqual(payments[0].backend_extid, '097872679P963871Y') - - # 2. the subscr_signup IPN - # We don't expect anything to happen here - ipn_url = '/payments/callback/paypal_subscr/%d' % subscription.id - ipn_request = RequestFactory().post( - ipn_url, - content_type='application/x-www-form-urlencoded', - data=PAYPAL_IPN_SUBSCR_SIGNUP) - r = backend.callback_subscr(subscription, ipn_request) - - self.assertTrue(r) - self.assertEqual(subscription.status, 'active') - self.assertEqual(subscription.backend_extid, 'I-1S262863X133') - - payments = Payment.objects.filter(subscription=subscription).all() - self.assertEqual(len(payments), 1) - self.assertEqual(payments[0].amount, 900) - self.assertEqual(payments[0].paid_amount, 900) - self.assertEqual(payments[0].backend_extid, '097872679P963871Y') - - # 3. the subscr_cancel IPN - ipn_url = '/payments/callback/paypal_subscr/%d' % subscription.id - ipn_request = RequestFactory().post( - ipn_url, - content_type='application/x-www-form-urlencoded', - data=PAYPAL_IPN_SUBSCR_CANCEL) - r = backend.callback_subscr(subscription, ipn_request) - - self.assertTrue(r) - self.assertEqual(subscription.status, 'cancelled') - self.assertEqual(subscription.backend_extid, 'I-1S262863X133') - - payments = Payment.objects.filter(subscription=subscription).all() - self.assertEqual(len(payments), 1) - diff --git a/payments/urls.py b/payments/urls.py deleted file mode 100644 index 2567584..0000000 --- a/payments/urls.py +++ /dev/null @@ -1,23 +0,0 @@ -from django.conf.urls import url -from . import views - -app_name = 'payments' - -urlpatterns = [ - url(r'^new$', views.new), - url(r'^view/(?P