Browse Source

Recurring payments!

master
Alice 5 years ago
parent
commit
ffa2f00f67
16 changed files with 1251 additions and 170 deletions
  1. +9
    -4
      lambdainst/management/commands/expire_notify.py
  2. +14
    -0
      lambdainst/models.py
  3. +81
    -2
      lambdainst/tests.py
  4. +14
    -0
      lambdainst/views.py
  5. +153
    -72
      locale/fr/LC_MESSAGES/django.po
  6. +57
    -9
      payments/admin.py
  7. +240
    -17
      payments/backends.py
  8. +1
    -0
      payments/forms.py
  9. +70
    -0
      payments/management/commands/update_stripe_plans.py
  10. +49
    -0
      payments/migrations/0004_auto_20160904_0048.py
  11. +84
    -13
      payments/models.py
  12. +176
    -4
      payments/tests.py
  13. +6
    -0
      payments/urls.py
  14. +86
    -12
      payments/views.py
  15. +95
    -5
      static/css/style.css
  16. +116
    -32
      templates/lambdainst/account.html

+ 9
- 4
lambdainst/management/commands/expire_notify.py View File

@@ -6,7 +6,6 @@ from django.db.models import Q, F
from django.conf import settings
from django.utils import timezone
from django.template.loader import get_template
from django.template import Context
from django.core.mail import send_mass_mail

from lambdainst.models import VPNUser
@@ -46,11 +45,17 @@ class Command(BaseCommand):
qs = get_next_expirations(v)
users = list(qs)
for u in users:
ctx = Context(dict(site_name=SITE_NAME, user=u.user,
exp=u.expiration, url=ROOT_URL))
# Ignore users with active subscriptions
# They will get notified only if it gets cancelled (payments
# processors will cancel after a few failed payments)
if u.get_subscription():
continue

ctx = dict(site_name=SITE_NAME, user=u.user,
exp=u.expiration, url=ROOT_URL)
text = get_template('lambdainst/mail_expire_soon.txt').render(ctx)
emails.append(("CCVPN Expiration", text, from_email, [u.user.email]))
print("sending -%d days notify to %s ..." % (v, u.user.email))
self.stdout.write("sending -%d days notify to %s ..." % (v, u.user.email))

send_mass_mail(emails)
qs.update(last_expiry_notice=timezone.now())

+ 14
- 0
lambdainst/models.py View File

@@ -9,6 +9,8 @@ from django.db.models.signals import post_save
from django.dispatch import receiver
from . import core

from payments.models import Subscription

assert isinstance(settings.TRIAL_PERIOD, timedelta)
assert isinstance(settings.TRIAL_PERIOD_LIMIT, int)

@@ -82,6 +84,18 @@ class VPNUser(models.Model):
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

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



+ 81
- 2
lambdainst/tests.py View File

@@ -1,10 +1,13 @@
from datetime import timedelta, datetime
from datetime import timedelta
from django.test import TestCase
from django.utils import timezone
from django.core.management import call_command
from django.core import mail
from django.utils.six import StringIO

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


class UserTestMixin:
@@ -272,3 +275,79 @@ class CACrtViewTest(TestCase):
self.assertEqual(response.content, b'test ca')


def email_text(body):
return body.replace('\n', ' ') \
.replace('\xa0', ' ') # nbsp


class ExpireNotifyTest(TestCase):
def setUp(self):
pass

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.save()

call_command('expire_notify', stdout=out)
self.assertEqual(len(mail.outbox), 1)
self.assertEqual(mail.outbox[0].to, ['test@example.com'])
self.assertIn('expire in 1 day', email_text(mail.outbox[0].body))

u = User.objects.get(username='test_username')
self.assertAlmostEqual(u.vpnuser.last_expiry_notice, timezone.now(),
delta=timedelta(minutes=1))

def test_notify_second(self):
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.save()

call_command('expire_notify', stdout=out)
self.assertEqual(len(mail.outbox), 1)
self.assertEqual(mail.outbox[0].to, ['test@example.com'])
self.assertIn('expire in 23 hours, 59 minutes', email_text(mail.outbox[0].body))

u = User.objects.get(username='test_username')
self.assertAlmostEqual(u.vpnuser.last_expiry_notice, timezone.now(),
delta=timedelta(minutes=1))

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.save()

s = Subscription(user=u, backend_id='paypal', status='active')
s.save()

call_command('expire_notify', stdout=out)
self.assertEqual(len(mail.outbox), 0)

u = User.objects.get(username='test_username')
# FIXME:
# self.assertNotAlmostEqual(u.vpnuser.last_expiry_notice, timezone.now(),
# delta=timedelta(minutes=1))

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.last_expiry_notice = timezone.now() - timedelta(days=5)
u.vpnuser.save()

s = Subscription(user=u, backend_id='paypal', status='new')
s.save()

call_command('expire_notify', stdout=out)
self.assertEqual(len(mail.outbox), 1)

u = User.objects.get(username='test_username')
# FIXME:
# self.assertNotAlmostEqual(u.vpnuser.last_expiry_notice, timezone.now(),
# delta=timedelta(minutes=1))



+ 14
- 0
lambdainst/views.py View File

@@ -155,13 +155,27 @@ def index(request):
'related': 'CCrypto_VPN,CCrypto_org'
}

class price_fn:
""" Clever hack to get the price in templates with {{price.3}} with
3 an arbitrary number of months
"""
def __getitem__(self, months):
n = int(months) * project_settings.PAYMENTS_MONTHLY_PRICE / 100
c = project_settings.PAYMENTS_CURRENCY[1]
return '%.2f %s' % (n, c)

context = dict(
title=_("Account"),
ref_url=ref_url,
twitter_link=twitter_url + urlencode(twitter_args),
subscription=request.user.vpnuser.get_subscription(include_unconfirmed=True),
backends=sorted(ACTIVE_BACKENDS.values(), key=lambda x: x.backend_id),
subscr_backends=sorted((b for b in ACTIVE_BACKENDS.values()
if b.backend_has_recurring),
key=lambda x: x.backend_id),
default_backend='paypal',
recaptcha_site_key=project_settings.RECAPTCHA_SITE_KEY,
price=price_fn(),
)
return render(request, 'lambdainst/account.html', context)



+ 153
- 72
locale/fr/LC_MESSAGES/django.po View File

@@ -2,7 +2,7 @@ msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2016-09-01 03:18+0000\n"
"POT-Creation-Date: 2016-09-06 15:15+0000\n"
"PO-Revision-Date: 2016-04-07 01:32+0000\n"
"Last-Translator: \n"
"Language-Team: \n"
@@ -98,27 +98,27 @@ msgstr "E-Mail"
msgid "Passwords are not the same"
msgstr "Les mots de passe de correspondent pas"

#: lambdainst/models.py:25
#: lambdainst/models.py:27
msgid "VPN User"
msgstr "VPN User"

#: lambdainst/models.py:26
#: lambdainst/models.py:28
msgid "VPN Users"
msgstr "VPN Users"

#: lambdainst/models.py:97
#: lambdainst/models.py:111
msgid "Gift Code"
msgstr "Code cadeau"

#: lambdainst/models.py:98
#: lambdainst/models.py:112
msgid "Gift Codes"
msgstr "Codes cadeau"

#: lambdainst/models.py:143
#: lambdainst/models.py:157
msgid "Gift Code User"
msgstr "Utilisateur de codes"

#: lambdainst/models.py:144
#: lambdainst/models.py:158
msgid "Gift Code Users"
msgstr "Utilisateurs de codes"

@@ -205,46 +205,46 @@ msgstr ""
msgid "Awesome VPN! 3€ per month, with a free 7 days trial!"
msgstr ""

#: lambdainst/views.py:159 templates/account_layout.html.py:9
#: lambdainst/views.py:168 templates/account_layout.html.py:9
#: templates/account_layout.html:11 templates/lambdainst/account.html.py:10
msgid "Account"
msgstr "Compte"

#: lambdainst/views.py:198 lambdainst/views.py:221 lambdainst/views.py:246
#: lambdainst/views.py:212 lambdainst/views.py:235 lambdainst/views.py:260
msgid "OK!"
msgstr "OK!"

#: lambdainst/views.py:200
#: lambdainst/views.py:214
msgid "Invalid captcha"
msgstr "Captcha invalide"

#: lambdainst/views.py:214
#: lambdainst/views.py:228
msgid "Passwords do not match"
msgstr "Les mots de passe ne correspondent pas"

#: lambdainst/views.py:231 templates/account_layout.html.py:13
#: lambdainst/views.py:245 templates/account_layout.html.py:13
#: templates/lambdainst/settings.html:6
msgid "Settings"
msgstr "Options"

#: lambdainst/views.py:242
#: lambdainst/views.py:256
msgid "Gift code not found or already used."
msgstr "Code inconnu ou déjà utilisé."

#: lambdainst/views.py:244
#: lambdainst/views.py:258
msgid "Gift code only available to free accounts."
msgstr "Code uniquement disponible pour les nouveaux comptes."

#: lambdainst/views.py:266 templates/account_layout.html.py:15
#: lambdainst/views.py:280 templates/account_layout.html.py:15
#: templates/lambdainst/logs.html:6
msgid "Logs"
msgstr "Logs"

#: lambdainst/views.py:273 templates/lambdainst/config.html.py:7
#: lambdainst/views.py:287 templates/lambdainst/config.html.py:7
msgid "Config"
msgstr "Config"

#: lambdainst/views.py:377 payments/backends.py:120 payments/backends.py:122
#: lambdainst/views.py:391 payments/backends.py:129 payments/backends.py:131
#: templates/admin/index.html:53 templates/admin/index.html.py:56
#: templates/lambdainst/admin_ref.html:16
#: templates/lambdainst/admin_status.html:16 templates/payments/list.html:13
@@ -252,56 +252,60 @@ msgstr "Config"
msgid "Status"
msgstr "État"

#: payments/admin.py:16
#: payments/admin.py:9
msgid "Mark as cancelled (do not actually cancel)"
msgstr "Marquer comme annulé (n'annule pas)"

#: payments/admin.py:22 payments/admin.py:76
msgid "Payment Data"
msgstr "Données de paiement"

#: payments/admin.py:39
#: payments/admin.py:45
msgid "Amount"
msgstr "Montant"

#: payments/admin.py:43
#: payments/admin.py:49
msgid "Paid amount"
msgstr "Montant payé"

#: payments/backends.py:47 payments/backends.py:48
#: payments/backends.py:56 payments/backends.py:57
msgid "Bitcoin"
msgstr ""

#: payments/backends.py:81
#: payments/backends.py:90
#, python-format
msgid "Please send %(amount)s BTC to %(address)s"
msgstr "Envoyez %(amount)s BTC à %(address)s"

#: payments/backends.py:125
#: payments/backends.py:134
msgid "Bitcoin value"
msgstr "Valeur du Bitcoin"

#: payments/backends.py:126
#: payments/backends.py:135
msgid "Testnet"
msgstr ""

#: payments/backends.py:127
#: payments/backends.py:136
msgid "Balance"
msgstr "Balance"

#: payments/backends.py:128
#: payments/backends.py:137
msgid "Blocks"
msgstr "Blocks"

#: payments/backends.py:129
#: payments/backends.py:138
msgid "Bitcoind version"
msgstr "Version de Bitcoind"

#: payments/backends.py:147
#: payments/backends.py:156
msgid "Manual"
msgstr "Manuel"

#: payments/backends.py:152 payments/backends.py:153
#: payments/backends.py:161 payments/backends.py:162
msgid "PayPal"
msgstr "PayPal"

#: payments/backends.py:189
#: payments/backends.py:199
msgid ""
"Waiting for PayPal to confirm the transaction... It can take up to a few "
"minutes..."
@@ -309,61 +313,81 @@ msgstr ""
"En attente de la confirmation par Paypal... Cette étape peut durer quelques "
"minutes..."

#: payments/backends.py:258
#: payments/backends.py:355
msgid "Stripe"
msgstr "Stripe"

#: payments/backends.py:259
msgid "Credit Card or Alipay (Stripe)"
msgstr "Carte ou Alipay (Stripe)"
#: payments/backends.py:356
msgid "Credit Card"
msgstr "Carte"

#: payments/backends.py:312
#: payments/backends.py:461
msgid "No payment information was received."
msgstr "Aucune information de paiement reçue."

#: payments/backends.py:329
#: payments/backends.py:478
msgid "The payment has been refunded or rejected."
msgstr "Le paiement a été remboursé ou rejeté."

#: payments/backends.py:337
#: payments/backends.py:486
msgid "The paid amount is under the required amount."
msgstr "La montant payé est inférieur au montant requis."

#: payments/backends.py:361
#: payments/backends.py:574
msgid "Coinbase"
msgstr "Coinbase"

#: payments/backends.py:362
#: payments/backends.py:575
msgid "Bitcoin with CoinBase"
msgstr "Bitcoin avec CoinBase"

#: payments/models.py:14
#: payments/models.py:16 payments/models.py:30
msgid "Waiting for payment"
msgstr "En attente du paiement"

#: payments/models.py:15
#: payments/models.py:17
msgid "Confirmed"
msgstr "Confirmé"

#: payments/models.py:16
#: payments/models.py:18 payments/models.py:32
msgid "Cancelled"
msgstr "Annullé"

#: payments/models.py:17
#: payments/models.py:19
msgid "Rejected by processor"
msgstr "Rejeté"

#: payments/models.py:18
#: payments/models.py:20
msgid "Payment processing failed"
msgstr "Traitement échoué"

#: payments/models.py:22
#: payments/models.py:29
msgid "Created"
msgstr "Crée"

#: payments/models.py:31
msgid "Active"
msgstr "Actif"

#: payments/models.py:33
msgid "Error"
msgstr "Error"

#: payments/models.py:37
msgid "Every 3 months"
msgstr "Tous les 3 mois"

#: payments/models.py:38
msgid "Every 6 months"
msgstr "Tous les 6 mois"

#: payments/models.py:23
msgid "Yearly"
msgstr "Annuel"
#: payments/models.py:39
msgid "Every year"
msgstr "Tous les ans"

#: payments/views.py:167
msgid "Subscription cancelled!"
msgstr "Abonnement annulé!"

#: templates/account_layout.html:12
msgid "Config Download"
@@ -378,8 +402,7 @@ msgstr "Paiements"
msgid "Models in the %(name)s application"
msgstr ""

#: templates/admin/index.html:31 templates/lambdainst/account.html.py:61
#: templates/lambdainst/account.html:83
#: templates/admin/index.html:31 templates/lambdainst/account.html.py:141
msgid "Add"
msgstr "Ajouter"

@@ -607,17 +630,47 @@ msgstr "Mot de passe oublié ?"
msgid "Need help?"
msgstr "Besoin d'aide ?"

#: templates/lambdainst/account.html:15
#: templates/lambdainst/account.html:16
#, python-format
msgid ""
"Your account is active. Your subscription will automatically renew on "
"%(until)s (%(backend)s)."
msgstr ""
"Votre compte est actif. Votre abonnement sera automatiquement renouvellé "
"le %(until)s via %(backend)s."

#: templates/lambdainst/account.html:21
msgid "You can cancel it from PayPal account."
msgstr "Vous pouvez annuler depuis votre compte PayPal."

#: templates/lambdainst/account.html:28
msgid "Cancel Subscription"
msgstr "Annuler l'abonnement"

#: templates/lambdainst/account.html:36
msgid "Do you really want to cancel your subscription?"
msgstr "Voulez-vous vraimer annuler votre abonnement ?"

#: templates/lambdainst/account.html:43
#, python-format
msgid ""
"Your subscription is waiting for confirmation by %(backend)s. It may take up "
"to a few minutes."
msgstr ""
"Votre abonnement est en attente de confirmation par %(backend)s. Cette étape "
"peut prendre jusqu'à quelques minutes..."

#: templates/lambdainst/account.html:51
#, python-format
msgid "Your account is paid until %(until)s"
msgstr "Votre compte est activé jusqu'au %(until)s"

#: templates/lambdainst/account.html:18
#: templates/lambdainst/account.html:54
#, python-format
msgid "(%(left)s left)"
msgstr "(%(left)s restant)"

#: templates/lambdainst/account.html:24
#: templates/lambdainst/account.html:60
msgid ""
"You can activate your free trial account for two hours periods for up to one "
"week, by clicking this button:"
@@ -625,46 +678,75 @@ msgstr ""
"Vous pouvez activez votre compte d'essai pour des périodes de deux heures "
"pendant jusqu'à une semaine avec ce bouton:"

#: templates/lambdainst/account.html:33
#: templates/lambdainst/account.html:69
msgid "Activate"
msgstr "Activer"

#: templates/lambdainst/account.html:50
#: templates/lambdainst/account.html:86
msgid "Your account is not paid."
msgstr "Votre compte n'est pas payé."

#: templates/lambdainst/account.html:63
msgid "month"
msgstr "mois"
#: templates/lambdainst/account.html:95 templates/payments/list.html.py:6
msgid "Subscription"
msgstr "Abonnement"

#: templates/lambdainst/account.html:103
msgid "Pay every"
msgstr "Payer tous les"

#: templates/lambdainst/account.html:64 templates/lambdainst/account.html:65
#: templates/lambdainst/account.html:66
#: templates/lambdainst/account.html:105 templates/lambdainst/account.html:106
#: templates/lambdainst/account.html:107 templates/lambdainst/account.html:144
#: templates/lambdainst/account.html:145 templates/lambdainst/account.html:146
msgid "months"
msgstr "mois"

#: templates/lambdainst/account.html:71
#: templates/lambdainst/account.html:112 templates/lambdainst/account.html:151
msgid "with"
msgstr "avec"

#: templates/lambdainst/account.html:93
#: templates/lambdainst/account.html:124
msgid "Subscribe"
msgstr "S'abonner"

#: templates/lambdainst/account.html:126
msgid "You can cancel at any time."
msgstr "Vous pouvez annuler à n'importe quel moment."

#: templates/lambdainst/account.html:133
msgid "One-time payment"
msgstr "Paiement ponctuel"

#: templates/lambdainst/account.html:143
msgid "month"
msgstr "mois"

#: templates/lambdainst/account.html:163
msgid "Buy Now"
msgstr "Acheter"

#: templates/lambdainst/account.html:165
msgid "If you still have time, it will be added."
msgstr "Si il vous reste du temps, il sera ajouté."

#: templates/lambdainst/account.html:176
msgid "Gift code"
msgstr "Code cadeau"

#: templates/lambdainst/account.html:99
msgid "Use"
msgstr "Utiliser"
#: templates/lambdainst/account.html:182
msgid "Redeem gift code"
msgstr "Récupérer le code cadeau"

#: templates/lambdainst/account.html:108
#: templates/lambdainst/account.html:192
msgid ""
"Get two weeks for free for every referral that takes at least one month!"
msgstr ""
"Gagnez deux semaines gratuites pour chaque client qui a utilisé ce lien !"

#: templates/lambdainst/account.html:111
#: templates/lambdainst/account.html:195
msgid "Share this link"
msgstr "Partagez ce lien"

#: templates/lambdainst/account.html:115
#: templates/lambdainst/account.html:199
msgid "tweet"
msgstr ""

@@ -909,10 +991,6 @@ msgstr "Une question ? <b>Contactez nous</b>"
msgid "Payment"
msgstr "Paiement"

#: templates/payments/list.html:6
msgid "Subscription"
msgstr "Abonnement"

#: templates/payments/list.html:12
msgid "Value"
msgstr "Valeur"
@@ -1152,3 +1230,6 @@ msgstr "Peut envoyer des messages privés"
#: tickets/models.py:56
msgid "Waiting for staff"
msgstr "En attente du support"

#~ msgid "year"
#~ msgstr "an"

+ 57
- 9
payments/admin.py View File

@@ -1,7 +1,12 @@
from django.shortcuts import resolve_url
from django.contrib import admin
from django.utils.translation import ugettext_lazy as _
from .models import Payment, RecurringPaymentSource
from .models import Payment, Subscription


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)")


class PaymentAdmin(admin.ModelAdmin):
@@ -11,16 +16,17 @@ class PaymentAdmin(admin.ModelAdmin):

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

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

@@ -48,13 +54,55 @@ class PaymentAdmin(admin.ModelAdmin):
user_link.allow_tags = True
user_link.short_description = 'User'

def subscription_link(self, object):
change_url = resolve_url('admin:payments_subscription_change',
object.subscription.id)
return '<a href="%s">%s</a>' % (change_url, object.subscription.id)
subscription_link.allow_tags = True
subscription_link.short_description = 'Subscription'

class RecurringPaymentSourceAdmin(admin.ModelAdmin):
model = RecurringPaymentSource
list_display = ('user', 'backend', 'created')
readonly_fields = ('user', 'backend', 'created', 'last_confirmed_payment')

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

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

def user_link(self, object):
change_url = resolve_url('admin:auth_user_change', object.user.id)
return '<a href="%s">%s</a>' % (change_url, object.user.username)
user_link.allow_tags = True
user_link.short_description = 'User'

def payments_links(self, object):
fmt = '<a href="%s?subscription__id__exact=%d">%d payments</a>'
payments_url = resolve_url('admin:payments_payment_changelist')
count = Payment.objects.filter(subscription=object).count()
return fmt % (payments_url, object.id, count)
payments_links.allow_tags = True
payments_links.short_description = 'Payments'

def backend_extid_link(self, object):
ext_url = object.backend.get_subscr_ext_url(object)
if ext_url:
return '<a href="%s">%s</a>' % (ext_url, object.backend_extid)
return object.backend_extid
backend_extid_link.allow_tags = True

admin.site.register(Payment, PaymentAdmin)
admin.site.register(RecurringPaymentSource, RecurringPaymentSourceAdmin)
admin.site.register(Subscription, SubscriptionAdmin)


+ 240
- 17
payments/backends.py View File

@@ -15,6 +15,7 @@ class BackendBase:
backend_verbose_name = ""
backend_display_name = ""
backend_enabled = False
backend_has_recurring = False

def __init__(self, settings):
pass
@@ -30,6 +31,14 @@ class BackendBase:
""" 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 ()
@@ -38,6 +47,10 @@ class BackendBase:
""" 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 BitcoinBackend(BackendBase):
""" Bitcoin backend.
@@ -151,6 +164,7 @@ 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)
@@ -191,6 +205,37 @@ class PaypalBackend(BackendBase):

return redirect(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(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')
@@ -205,32 +250,71 @@ class PaypalBackend(BackendBase):
payment.status_message = None

elif params['payment_status'] == 'Completed':
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'])
self.handle_completed_payment(payment, params)

payment.paid_amount = int(float(params['mc_gross']) * 100)
if payment.paid_amount < payment.amount:
raise ValueError('Not fully paid.')
def handle_verified_callback_subscr(self, subscr, params):
if self.test and params['test_ipn'] != '1':
raise ValueError('Test IPN')

payment.user.vpnuser.add_paid_time(payment.time)
payment.user.vpnuser.on_payment_confirmed(payment)
payment.user.vpnuser.save()
txn_type = params.get('txn_type')
if not txn_type.startswith('subscr_'):
# Not handled here and can be ignored
return

payment.backend_extid = params['txn_id']
payment.status = 'confirmed'
payment.status_message = None
payment.save()
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.')

def verify_ipn(self, payment, request):
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()
return True

def verify_ipn(self, request):
v_url = 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(payment, request):
if not self.verify_ipn(request):
return False

params = request.POST
@@ -246,6 +330,23 @@ class PaypalBackend(BackendBase):
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 get_ext_url(self, payment):
if not payment.backend_extid:
return None
@@ -256,7 +357,11 @@ class PaypalBackend(BackendBase):
class StripeBackend(BackendBase):
backend_id = 'stripe'
backend_verbose_name = _("Stripe")
backend_display_name = _("Credit Card or Alipay (Stripe)")
backend_display_name = _("Credit Card")
backend_has_recurring = True

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

def __init__(self, settings):
if 'API_KEY' not in settings or 'PUBLIC_KEY' not in settings:
@@ -303,6 +408,54 @@ class StripeBackend(BackendBase):
curr=self.currency,
)

def new_subscription(self, subscr):
desc = 'Subscription (' + str(subscr.period) + ') for ' + subscr.user.username
form = '''
<form action="{post}" method="POST">
<script
src="https://checkout.stripe.com/checkout.js" class="stripe-button"
data-key="{pubkey}"
data-image="{img}"
data-name="{name}"
data-currency="{curr}"
data-description="{desc}"
data-amount="{amount}"
data-email="{email}"
data-locale="auto"
data-zip-code="true"
data-alipay="true">
</script>
</form>
'''
return form.format(
post=reverse('payments:cb_stripe_subscr', args=(subscr.id,)),
pubkey=self.pubkey,
img=self.header_image,
email=subscr.user.email or '',
name=self.name,
desc=desc,
amount=subscr.period_amount,
curr=self.currency,
)

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

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

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

def callback(self, payment, request):
post_data = request.POST

@@ -350,11 +503,80 @@ class StripeBackend(BackendBase):
payment.status_message = e.json_body['error']['message']
payment.save()

def callback_subscr(self, subscr, request):
post_data = request.POST
token = post_data.get('stripeToken')
if not token:
subscr.status = 'cancelled'
subscr.save()
return

try:
cust = self.stripe.Customer.create(
source=token,
plan=self.get_plan_id(subscr.period),
)
except self.stripe.error.InvalidRequestError:
return

try:
if subscr.status == 'new':
subscr.status = 'unconfirmed'
subscr.backend_extid = cust['id']
subscr.save()
except (self.stripe.error.InvalidRequestError, self.stripe.error.CardError) as e:
subscr.status = 'error'
subscr.backend_data['stripe_error'] = e.json_body['error']['message']
subscr.save()

def webhook_payment_succeeded(self, event):
from payments.models import Subscription, Payment

invoice = event['data']['object']
customer_id = invoice['customer']

# Prevent making duplicate Payments if event is received twice
pc = Payment.objects.filter(backend_extid=invoice['id']).count()
if pc > 0:
return

subscr = Subscription.objects.get(backend_extid=customer_id)
payment = subscr.create_payment()
payment.status = 'confirmed'
payment.paid_amount = invoice['total']
payment.backend_extid = invoice['id']
payment.backend_data = {'event_id': event['id']}
payment.save()

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

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

def webhook(self, request):
try:
event_json = json.loads(request.body.decode('utf-8'))
event = self.stripe.Event.retrieve(event_json["id"])
except (ValueError, self.stripe.error.InvalidRequestError):
return False

if event['type'] == 'invoice.payment_succeeded':
self.webhook_payment_succeeded(event)
return True

def get_ext_url(self, payment):
if not payment.backend_extid:
return None
return 'https://dashboard.stripe.com/payments/%s' % payment.backend_extid

def get_subscr_ext_url(self, subscr):
if not subscr.backend_extid:
return None
return 'https://dashboard.stripe.com/customers/%s' % subscr.backend_extid


class CoinbaseBackend(BackendBase):
backend_id = 'coinbase'
@@ -454,3 +676,4 @@ class CoinbaseBackend(BackendBase):
payment.user.vpnuser.save()
return True



+ 1
- 0
payments/forms.py View File

@@ -10,6 +10,7 @@ class NewPaymentForm(forms.Form):
('12', '12'),
)

subscr = forms.ChoiceField(choices=(('0', 'no'), ('1', 'yes')))
time = forms.ChoiceField(choices=TIME_CHOICES)
method = forms.ChoiceField(choices=BACKEND_CHOICES)


+ 70
- 0
payments/management/commands/update_stripe_plans.py View File

@@ -0,0 +1,70 @@
from django.core.management.base import BaseCommand, CommandError
from django.conf import settings

from payments.models import ACTIVE_BACKENDS, SUBSCR_PERIOD_CHOICES, period_months

CURRENCY_CODE, CURRENCY_NAME = settings.PAYMENTS_CURRENCY
MONTHLY_PRICE = settings.PAYMENTS_MONTHLY_PRICE


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 * MONTHLY_PRICE

kwargs = dict(
id=plan_id,
amount=months * MONTHLY_PRICE,
interval='month',
interval_count=months,
name=backend.name + " (%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]'))

+ 49
- 0
payments/migrations/0004_auto_20160904_0048.py View File

@@ -0,0 +1,49 @@
# -*- 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'),
),
]

+ 84
- 13
payments/models.py View File

@@ -2,6 +2,7 @@ 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

from .backends import BackendBase

@@ -9,6 +10,7 @@ backend_settings = settings.PAYMENTS_BACKENDS
assert isinstance(backend_settings, dict)

CURRENCY_CODE, CURRENCY_NAME = settings.PAYMENTS_CURRENCY
MONTHLY_PRICE = settings.PAYMENTS_MONTHLY_PRICE

STATUS_CHOICES = (
('new', _("Waiting for payment")),
@@ -18,9 +20,23 @@ STATUS_CHOICES = (
('error', _("Payment processing failed")),
)

PERIOD_CHOICES = (
# 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")),
('1year', _("Yearly")),
('12m', _("Every year")),
)

# All known backends (classes)
@@ -51,10 +67,18 @@ BACKEND_CHOICES = sorted(BACKEND_CHOICES, key=lambda x: x[0])
ACTIVE_BACKEND_CHOICES = sorted(ACTIVE_BACKEND_CHOICES, key=lambda x: x[0])


def period_months(p):
return {
'3m': 3,
'6m': 6,
'12m': 12,
}[p]


class Payment(models.Model):
""" Just a payment.
If recurring_source is not null, it has been automatically issued.
backend_id is the external transaction ID, backend_data is other
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)
@@ -66,7 +90,7 @@ class Payment(models.Model):
amount = models.IntegerField()
paid_amount = models.IntegerField(default=0)
time = models.DurationField()
recurring_source = models.ForeignKey('RecurringPaymentSource', null=True, blank=True)
subscription = models.ForeignKey('Subscription', null=True, blank=True)
status_message = models.TextField(blank=True, null=True)

backend_extid = models.CharField(max_length=64, null=True, blank=True)
@@ -97,18 +121,65 @@ class Payment(models.Model):
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=MONTHLY_PRICE * months
)
return payment

class RecurringPaymentSource(models.Model):
""" Used as a source to periodically make Payments.
They use the same backends.
"""

class Subscription(models.Model):
""" Recurring payment subscription. """
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
backend = models.CharField(max_length=16, choices=BACKEND_CHOICES)
backend_id = models.CharField(max_length=16, choices=BACKEND_CHOICES)
created = models.DateTimeField(auto_now_add=True)
modified = models.DateTimeField(auto_now=True)
period = models.CharField(max_length=16, choices=PERIOD_CHOICES)
period = models.CharField(max_length=16, choices=SUBSCR_PERIOD_CHOICES)
last_confirmed_payment = models.DateTimeField(blank=True, null=True)
status = models.CharField(max_length=16, choices=SUBSCR_STATUS_CHOICES, default='new')

backend_id = models.CharField(max_length=64, null=True, blank=True)
backend_extid = models.CharField(max_length=64, 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 * MONTHLY_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 MONTHLY_PRICE

def create_payment(self):
payment = Payment(
user=self.user,
backend_id=self.backend_id,
status='new',
time=timedelta(days=30 * self.months),
amount=MONTHLY_PRICE * self.months,
subscription=self,
)
return payment



+ 176
- 4
payments/tests.py View File

@@ -5,7 +5,7 @@ from django.test import TestCase, RequestFactory
from django.http import HttpResponseRedirect
from django.contrib.auth.models import User

from .models import Payment
from .models import Payment, Subscription
from .backends import BitcoinBackend, PaypalBackend, StripeBackend

from decimal import Decimal
@@ -55,7 +55,6 @@ payer_status=verified&\
address_country=United+States&\
address_city=San+Jose&\
quantity=1&\
verify_sign=AtkOfCXbDm2hu0ZELryHFjY-Vb7PAUvS6nMXgysbElEn9v-1XcmSoGtf&\
payer_email=test_user@example.com&\
txn_id=61E67681CH3238416&\
payment_type=instant&\
@@ -75,6 +74,83 @@ 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 BitcoinBackendTest(TestCase):
def setUp(self):
@@ -197,7 +273,7 @@ class BackendTest(TestCase):

# Replace PaypalBackend.verify_ipn to not call the PayPal API
# we will assume the IPN is authentic
backend.verify_ipn = lambda payment, request: True
backend.verify_ipn = lambda request: True

ipn_url = '/payments/callback/paypal/%d' % payment.id
ipn_request = RequestFactory().post(
@@ -239,7 +315,7 @@ class BackendTest(TestCase):

# Replace PaypalBackend.verify_ipn to not call the PayPal API
# we will assume the IPN is authentic
backend.verify_ipn = lambda payment, request: True
backend.verify_ipn = lambda request: True

ipn_url = '/payments/callback/paypal/%d' % payment.id
ipn_request = RequestFactory().post(
@@ -253,6 +329,102 @@ class BackendTest(TestCase):
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='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)

def test_stripe(self):
payment = Payment.objects.create(
user=self.user,


+ 6
- 0
payments/urls.py View File

@@ -5,10 +5,16 @@ urlpatterns = [
url(r'^new$', views.new),
url(r'^view/(?P<id>[0-9]+)$', views.view, name='view'),
url(r'^cancel/(?P<id>[0-9]+)$', views.cancel, name='cancel'),
url(r'^cancel_subscr/(?P<id>[0-9]+)$', views.cancel_subscr, name='cancel_subscr'),
url(r'^return_subscr/(?P<id>[0-9]+)$', views.return_subscr, name='return_subscr'),

url(r'^callback/paypal/(?P<id>[0-9]+)$', views.callback_paypal, name='cb_paypal'),
url(r'^callback/stripe/(?P<id>[0-9]+)$', views.callback_stripe, name='cb_stripe'),
url(r'^callback/coinbase/$', views.callback_coinbase, name='cb_coinbase'),
url(r'^callback/paypal_subscr/(?P<id>[0-9]+)$', views.callback_paypal_subscr, name='cb_paypal_subscr'),
url(r'^callback/stripe_subscr/(?P<id>[0-9]+)$', views.callback_stripe_subscr, name='cb_stripe_subscr'),

url(r'^callback/stripe_hook$', views.stripe_hook, name='stripe_hook'),

url(r'^$', views.list_payments),
]

+ 86
- 12
payments/views.py View File

@@ -6,9 +6,11 @@ from django.contrib.auth.decorators import login_required
from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseNotFound
from django.views.decorators.csrf import csrf_exempt
from django.utils import timezone
from django.contrib import messages
from django.utils.translation import ugettext_lazy as _

from .forms import NewPaymentForm
from .models import Payment, BACKENDS
from .models import Payment, Subscription, BACKENDS, ACTIVE_BACKENDS


monthly_price = settings.PAYMENTS_MONTHLY_PRICE
@@ -24,21 +26,34 @@ def new(request):
if not form.is_valid():
return redirect('account:index')

if request.user.vpnuser.get_subscription() is not None:
return redirect('account:index')

subscr = form.cleaned_data['subscr'] == '1'
backend_id = form.cleaned_data['method']
months = int(form.cleaned_data['time'])
payment = Payment(
user=request.user,
backend_id=form.cleaned_data['method'],
status='new',
time=timedelta(days=30 * months),
amount=monthly_price * months
)

if not payment.backend.backend_enabled:

if backend_id not in ACTIVE_BACKENDS:
return HttpResponseNotFound()

payment.save()
if subscr:
if months not in (3, 6, 12):
return redirect('account:index')

r = payment.backend.new_payment(payment)
rps = Subscription(
user=request.user,
backend_id=backend_id,
period=str(months) + 'm',
)
rps.save()

r = rps.backend.new_subscription(rps)

else:
payment = Payment.create_payment(backend_id, request.user, months)
payment.save()

r = payment.backend.new_payment(payment)

if not r:
payment.status = 'error'
@@ -89,6 +104,42 @@ def callback_coinbase(request):
return HttpResponseBadRequest()


@csrf_exempt
def callback_paypal_subscr(request, id):
""" PayPal Subscription IPN """
if not BACKENDS['paypal'].backend_enabled:
return HttpResponseNotFound()

p = Subscription.objects.get(id=id)
if BACKENDS['paypal'].callback_subscr(p, request):
return HttpResponse()
else:
return HttpResponseBadRequest()


@csrf_exempt
@login_required
def callback_stripe_subscr(request, id):
""" Stripe subscription form target """
if not BACKENDS['stripe'].backend_enabled:
return HttpResponseNotFound()

p = Subscription.objects.get(id=id)
BACKENDS['stripe'].callback_subscr(p, request)
return redirect(reverse('account:index'))


@csrf_exempt
def stripe_hook(request):
if not BACKENDS['stripe'].backend_enabled:
return HttpResponseNotFound()

if BACKENDS['stripe'].webhook(request):
return HttpResponse()
else:
return HttpResponseBadRequest()


@login_required
@csrf_exempt
def view(request, id):
@@ -105,6 +156,29 @@ def cancel(request, id):
return render(request, 'payments/view.html', dict(payment=p))


@login_required
def cancel_subscr(request, id):
if request.method != 'POST':
return redirect('account:index')

p = Subscription.objects.get(id=id, user=request.user)
try:
p.backend.cancel_subscription(p)
messages.add_message(request, messages.INFO, _("Subscription cancelled!"))
except NotImplementedError:
pass
return redirect('account:index')


@login_required
def return_subscr(request, id):
p = Subscription.objects.get(id=id, user=request.user)
if p.status == 'new':
p.status = 'unconfirmed'
p.save()
return redirect('account:index')


@login_required
def list_payments(request):
# Only show recent cancelled payments


+ 95
- 5
static/css/style.css View File

@@ -34,7 +34,7 @@ pre {
margin-bottom: 1em;
}

div#captcha > div > div {
div#captcha > div {
margin: 1em auto;
}

@@ -478,7 +478,7 @@ a.home-signup-button {

.account-status {
text-align: center;
margin-bottom: 3em;
margin-bottom: 2em;
}
.account-status-paid, .account-status-disabled {
font-weight: bold;
@@ -493,13 +493,98 @@ a.home-signup-button {
margin: 2em 0 0 0;
}

.account-payment-box label, .account-giftcode-box label {
.account-payment-box form label, .account-giftcode-box form label {
width: 8em;
}
.account-payment-box h3 {
text-align: center;
}
.account-payment-box {
margin-bottom: 2em;
}


.pure-form-aligned.centered-form .pure-controls {
width: 100%;
margin-left: 0;
}
.pure-form-aligned.centered-form .pure-controls input[type=submit] {
display: block;
margin: auto;
}

.account-giftcode-box .pure-control-group {
display: flex;
align-items: center;
justify-content: center;
}

.account-giftcode-box .pure-form-aligned.centered-form .pure-control-group label {
width: auto;
}

.account-payment-tabs {
float : left;
padding: 1em;
}

.account-payment-tab > label {
width: 50%;
text-align: center;
display: block;
float: left;
cursor: pointer;
-moz-user-select: none;
-ms-user-select: none;
-webkit-user-select: none;
height: 2em;
border: 1px solid #ccc;
border-bottom: 0;
border-radius: 5px 5px 0px 0px;
-moz-border-radius: 5px 5px 0px 0px;
-webkit-border-radius: 5px 5px 0px 0px;
background: #eee;
}
.account-payment-tab > label span {
display: block;
width: 100%;
height: 100%;
padding: 0.3em 0.5em 0 0.5em;
position: relative;
top: 1px;
}
.account-payment-tab [id^="tab"]:checked + label span {
background: #fff;
}
.account-payment-tab [id^="tab"]:checked + label {
background: #fff;
}
.account-payment-tab > input[type="radio"] {
display: none;
}
.account-payment-tab .tab-content {
display: none;
border: 1px solid #ccc;
float: right;
width: 100%;
margin: 2em 0 0 -100%;
padding: 1em 1em 0 1em;
}
.account-payment-tab [id^="tab"]:checked ~ .tab-content {
display: block;
}
.account-payment-tab .tab-content p {
margin: 0.5em 0 0 0;
text-align: center;
font-size: 0.8em;
color: #666;
}



@media screen and (min-width: 64em) {
.account-payment-box {
border-right: 1px solid #1c619a;
.account-giftcode-box {
padding-top: 4em;
}
}

@@ -665,6 +750,11 @@ div.ticket-message-private {
}


.stripe-button-el {
display: block !important;
margin: 4em auto 3em auto;
}


/***************************************************/
/********************* Fonts */


+ 116
- 32
templates/lambdainst/account.html View File

@@ -10,7 +10,43 @@
<h1>{% trans 'Account' %} : {{user.username}}</h1>

<div class="account-status">
{% if user.vpnuser.is_paid %}
{% if subscription %}
{% if subscription.status == 'active' %}
<p class="account-status-paid">
{% blocktrans trimmed with until=subscription.next_renew|date:'DATE_FORMAT' backend=subscription.backend.backend_verbose_name %}
Your account is active. Your subscription will automatically renew on {{until}} ({{backend}}).
{% endblocktrans %}
</p>
{% if subscription.backend_id == 'paypal' %}
<p>{% trans 'You can cancel it from PayPal account.' %}</p>
{% else %}
<form action="/payments/cancel_subscr/{{subscription.id}}" method="post" class="pure-form centered-form" id="cancel-form">
{% csrf_token %}
<fieldset>
<div class="pure-controls">
<input type="submit" class="pure-button pure-button-primary"
value="{% trans 'Cancel Subscription' %}" />
</div>
</fieldset>
</form>
<script type="text/javascript">
(function() {
var e = document.getElementById("cancel-form");
e.onsubmit = function() {
return confirm("{% trans 'Do you really want to cancel your subscription?' %}");
};
})();
</script>
{% endif %}
{% else %}
<p class="account-status-paid">
{% blocktrans trimmed with backend=subscription.backend.backend_verbose_name %}
Your subscription is waiting for confirmation by {{backend}}.
It may take up to a few minutes.
{% endblocktrans %}
</p>
{% endif %}
{% elif user.vpnuser.is_paid %}
<p class="account-status-paid">
{% blocktrans trimmed with until=user.vpnuser.expiration|date:'DATETIME_FORMAT' %}
Your account is paid until {{until}}
@@ -51,42 +87,89 @@
{% endif %}
</div>

{% if not subscription %}
<div class="pure-g">
<div class="pure-u-1 pure-u-lg-1-2 account-payment-box">
<form action="/payments/new" method="post" class="pure-form pure-form-aligned">
{% csrf_token %}
<div class="pure-u-1 pure-u-lg-1-2 account-payment-box account-payment-tabs">
<div class="account-payment-tab">
<input type="radio" name="type" id="tab_subscr" value="subscr" checked />
<label for="tab_subscr"><span>{% trans 'Subscription' %}</span></label>
<div class="tab-content">
<form action="/payments/new" method="post" class="pure-form pure-form-aligned centered-form">
{% csrf_token %}
<input type="hidden" name="subscr" value="1" />

<fieldset>
<div class="pure-control-group">
<label for="ino_time">{% trans 'Add' %}</label>
<select id="ino_time" name="time" class="pure-input-1-2">
<option value="1">1 {% trans 'month' %}</option>
<option value="3">3 {% trans 'months' %}</option>
<option value="6">6 {% trans 'months' %}</option>
<option value="12">12 {% trans 'months' %}</option>
</select>
</div>
<fieldset>
<div class="pure-control-group">
<label for="ino_time">{% trans 'Pay every' %}</label>
<select id="ino_time" name="time" class="pure-input-1-2">
<option value="3">3 {% trans 'months' %} ({{price.3}})</option>
<option value="6">6 {% trans 'months' %} ({{price.6}})</option>
<option value="12">12 {% trans 'months' %} ({{price.12}})</option>
</select>
</div>

<div class="pure-control-group">
<label for="ino_method">{% trans 'with' %}</label>
<select id="ino_method" name="method" class="pure-input-1-2">
{% for backend in backends %}
<option value="{{ backend.backend_id }}" {% if backend.backend_id == default_backend %}selected{% endif %}>
{{ backend.backend_display_name }}
</option>
{% endfor %}
</select>
</div>
<div class="pure-control-group">
<label for="ino_method">{% trans 'with' %}</label>
<select id="ino_method" name="method" class="pure-input-1-2">
{% for backend in subscr_backends %}
<option value="{{ backend.backend_id }}" {% if backend.backend_id == default_backend %}selected{% endif %}>
{{ backend.backend_display_name }}
</option>
{% endfor %}
</select>
</div>

<div class="pure-controls">
<input type="submit" class="pure-button pure-button-primary"
value="{% trans 'Add' %}" />
</div>
</fieldset>
</form>
<div class="pure-controls">
<input type="submit" class="pure-button pure-button-primary"
value="{% trans 'Subscribe' %}" />
</div>
<p>{% trans 'You can cancel at any time.' %}</p>
</fieldset>
</form>
</div>
</div>
<div class="account-payment-tab">
<input type="radio" name="type" id="tab_onetime" value="onetime" />
<label for="tab_onetime"><span>{% trans 'One-time payment' %}</span></label>
<div class="tab-content">
<form action="/payments/new" method="post" class="pure-form pure-form-aligned centered-form">
{% csrf_token %}
<input type="hidden" name="subscr" value="0" />

<fieldset>
<div class="pure-control-group">
<label for="ino_time">{% trans 'Add' %}</label>
<select id="ino_time" name="time" class="pure-input-1-2">
<option value="1">1 {% trans 'month' %} ({{price.1}})</option>
<option value="3">3 {% trans 'months' %} ({{price.3}})</option>
<option value="6">6 {% trans 'months' %} ({{price.6}})</option>
<option value="12">12 {% trans 'months' %} ({{price.12}})</option>
</select>
</div>

<div class="pure-control-group">
<label for="ino_method">{% trans 'with' %}</label>
<select id="ino_method" name="method" class="pure-input-1-2">
{% for backend in backends %}
<option value="{{ backend.backend_id }}" {% if backend.backend_id == default_backend %}selected{% endif %}>
{{ backend.backend_display_name }}
</option>
{% endfor %}
</select>