Recurring payments!

master
Alice 8 years ago
parent 957737be1a
commit ffa2f00f67

@ -6,7 +6,6 @@ from django.db.models import Q, F
from django.conf import settings from django.conf import settings
from django.utils import timezone from django.utils import timezone
from django.template.loader import get_template from django.template.loader import get_template
from django.template import Context
from django.core.mail import send_mass_mail from django.core.mail import send_mass_mail
from lambdainst.models import VPNUser from lambdainst.models import VPNUser
@ -46,11 +45,17 @@ class Command(BaseCommand):
qs = get_next_expirations(v) qs = get_next_expirations(v)
users = list(qs) users = list(qs)
for u in users: for u in users:
ctx = Context(dict(site_name=SITE_NAME, user=u.user, # Ignore users with active subscriptions
exp=u.expiration, url=ROOT_URL)) # 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) text = get_template('lambdainst/mail_expire_soon.txt').render(ctx)
emails.append(("CCVPN Expiration", text, from_email, [u.user.email])) 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) send_mass_mail(emails)
qs.update(last_expiry_notice=timezone.now()) qs.update(last_expiry_notice=timezone.now())

@ -9,6 +9,8 @@ from django.db.models.signals import post_save
from django.dispatch import receiver from django.dispatch import receiver
from . import core from . import core
from payments.models import Subscription
assert isinstance(settings.TRIAL_PERIOD, timedelta) assert isinstance(settings.TRIAL_PERIOD, timedelta)
assert isinstance(settings.TRIAL_PERIOD_LIMIT, int) assert isinstance(settings.TRIAL_PERIOD_LIMIT, int)
@ -82,6 +84,18 @@ class VPNUser(models.Model):
self.referrer.vpnuser.save() self.referrer.vpnuser.save()
self.referrer_used = True 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): def __str__(self):
return self.user.username return self.user.username

@ -1,10 +1,13 @@
from datetime import timedelta, datetime from datetime import timedelta
from django.test import TestCase from django.test import TestCase
from django.utils import timezone 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 .forms import SignupForm
from .models import VPNUser, User, random_gift_code, GiftCode, GiftCodeUser from .models import VPNUser, User, random_gift_code, GiftCode, GiftCodeUser
from payments.models import Payment from payments.models import Payment, Subscription
class UserTestMixin: class UserTestMixin:
@ -272,3 +275,79 @@ class CACrtViewTest(TestCase):
self.assertEqual(response.content, b'test ca') 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))

@ -155,13 +155,27 @@ def index(request):
'related': 'CCrypto_VPN,CCrypto_org' '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( context = dict(
title=_("Account"), title=_("Account"),
ref_url=ref_url, ref_url=ref_url,
twitter_link=twitter_url + urlencode(twitter_args), twitter_link=twitter_url + urlencode(twitter_args),
subscription=request.user.vpnuser.get_subscription(include_unconfirmed=True),
backends=sorted(ACTIVE_BACKENDS.values(), key=lambda x: x.backend_id), backends=sorted(ACTIVE_BACKENDS.values(), key=lambda x: x.backend_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', default_backend='paypal',
recaptcha_site_key=project_settings.RECAPTCHA_SITE_KEY, recaptcha_site_key=project_settings.RECAPTCHA_SITE_KEY,
price=price_fn(),
) )
return render(request, 'lambdainst/account.html', context) return render(request, 'lambdainst/account.html', context)

@ -2,7 +2,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: \n" "Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \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" "PO-Revision-Date: 2016-04-07 01:32+0000\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: \n" "Language-Team: \n"
@ -98,27 +98,27 @@ msgstr "E-Mail"
msgid "Passwords are not the same" msgid "Passwords are not the same"
msgstr "Les mots de passe de correspondent pas" msgstr "Les mots de passe de correspondent pas"
#: lambdainst/models.py:25 #: lambdainst/models.py:27
msgid "VPN User" msgid "VPN User"
msgstr "VPN User" msgstr "VPN User"
#: lambdainst/models.py:26 #: lambdainst/models.py:28
msgid "VPN Users" msgid "VPN Users"
msgstr "VPN Users" msgstr "VPN Users"
#: lambdainst/models.py:97 #: lambdainst/models.py:111
msgid "Gift Code" msgid "Gift Code"
msgstr "Code cadeau" msgstr "Code cadeau"
#: lambdainst/models.py:98 #: lambdainst/models.py:112
msgid "Gift Codes" msgid "Gift Codes"
msgstr "Codes cadeau" msgstr "Codes cadeau"
#: lambdainst/models.py:143 #: lambdainst/models.py:157
msgid "Gift Code User" msgid "Gift Code User"
msgstr "Utilisateur de codes" msgstr "Utilisateur de codes"
#: lambdainst/models.py:144 #: lambdainst/models.py:158
msgid "Gift Code Users" msgid "Gift Code Users"
msgstr "Utilisateurs de codes" msgstr "Utilisateurs de codes"
@ -205,46 +205,46 @@ msgstr ""
msgid "Awesome VPN! 3€ per month, with a free 7 days trial!" msgid "Awesome VPN! 3€ per month, with a free 7 days trial!"
msgstr "" 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 #: templates/account_layout.html:11 templates/lambdainst/account.html.py:10
msgid "Account" msgid "Account"
msgstr "Compte" 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!" msgid "OK!"
msgstr "OK!" msgstr "OK!"
#: lambdainst/views.py:200 #: lambdainst/views.py:214
msgid "Invalid captcha" msgid "Invalid captcha"
msgstr "Captcha invalide" msgstr "Captcha invalide"
#: lambdainst/views.py:214 #: lambdainst/views.py:228
msgid "Passwords do not match" msgid "Passwords do not match"
msgstr "Les mots de passe ne correspondent pas" 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 #: templates/lambdainst/settings.html:6
msgid "Settings" msgid "Settings"
msgstr "Options" msgstr "Options"
#: lambdainst/views.py:242 #: lambdainst/views.py:256
msgid "Gift code not found or already used." msgid "Gift code not found or already used."
msgstr "Code inconnu ou déjà utilisé." msgstr "Code inconnu ou déjà utilisé."
#: lambdainst/views.py:244 #: lambdainst/views.py:258
msgid "Gift code only available to free accounts." msgid "Gift code only available to free accounts."
msgstr "Code uniquement disponible pour les nouveaux comptes." 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 #: templates/lambdainst/logs.html:6
msgid "Logs" msgid "Logs"
msgstr "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" msgid "Config"
msgstr "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/admin/index.html:53 templates/admin/index.html.py:56
#: templates/lambdainst/admin_ref.html:16 #: templates/lambdainst/admin_ref.html:16
#: templates/lambdainst/admin_status.html:16 templates/payments/list.html:13 #: templates/lambdainst/admin_status.html:16 templates/payments/list.html:13
@ -252,56 +252,60 @@ msgstr "Config"
msgid "Status" msgid "Status"
msgstr "État" 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" msgid "Payment Data"
msgstr "Données de paiement" msgstr "Données de paiement"
#: payments/admin.py:39 #: payments/admin.py:45
msgid "Amount" msgid "Amount"
msgstr "Montant" msgstr "Montant"
#: payments/admin.py:43 #: payments/admin.py:49
msgid "Paid amount" msgid "Paid amount"
msgstr "Montant payé" msgstr "Montant payé"
#: payments/backends.py:47 payments/backends.py:48 #: payments/backends.py:56 payments/backends.py:57
msgid "Bitcoin" msgid "Bitcoin"
msgstr "" msgstr ""
#: payments/backends.py:81 #: payments/backends.py:90
#, python-format #, python-format
msgid "Please send %(amount)s BTC to %(address)s" msgid "Please send %(amount)s BTC to %(address)s"
msgstr "Envoyez %(amount)s BTC à %(address)s" msgstr "Envoyez %(amount)s BTC à %(address)s"
#: payments/backends.py:125 #: payments/backends.py:134
msgid "Bitcoin value" msgid "Bitcoin value"
msgstr "Valeur du Bitcoin" msgstr "Valeur du Bitcoin"
#: payments/backends.py:126 #: payments/backends.py:135
msgid "Testnet" msgid "Testnet"
msgstr "" msgstr ""
#: payments/backends.py:127 #: payments/backends.py:136
msgid "Balance" msgid "Balance"
msgstr "Balance" msgstr "Balance"
#: payments/backends.py:128 #: payments/backends.py:137
msgid "Blocks" msgid "Blocks"
msgstr "Blocks" msgstr "Blocks"
#: payments/backends.py:129 #: payments/backends.py:138
msgid "Bitcoind version" msgid "Bitcoind version"
msgstr "Version de Bitcoind" msgstr "Version de Bitcoind"
#: payments/backends.py:147 #: payments/backends.py:156
msgid "Manual" msgid "Manual"
msgstr "Manuel" msgstr "Manuel"
#: payments/backends.py:152 payments/backends.py:153 #: payments/backends.py:161 payments/backends.py:162
msgid "PayPal" msgid "PayPal"
msgstr "PayPal" msgstr "PayPal"
#: payments/backends.py:189 #: payments/backends.py:199
msgid "" msgid ""
"Waiting for PayPal to confirm the transaction... It can take up to a few " "Waiting for PayPal to confirm the transaction... It can take up to a few "
"minutes..." "minutes..."
@ -309,61 +313,81 @@ msgstr ""
"En attente de la confirmation par Paypal... Cette étape peut durer quelques " "En attente de la confirmation par Paypal... Cette étape peut durer quelques "
"minutes..." "minutes..."
#: payments/backends.py:258 #: payments/backends.py:355
msgid "Stripe" msgid "Stripe"
msgstr "Stripe" msgstr "Stripe"
#: payments/backends.py:259 #: payments/backends.py:356
msgid "Credit Card or Alipay (Stripe)" msgid "Credit Card"
msgstr "Carte ou Alipay (Stripe)" msgstr "Carte"
#: payments/backends.py:312 #: payments/backends.py:461
msgid "No payment information was received." msgid "No payment information was received."
msgstr "Aucune information de paiement reçue." msgstr "Aucune information de paiement reçue."
#: payments/backends.py:329 #: payments/backends.py:478
msgid "The payment has been refunded or rejected." msgid "The payment has been refunded or rejected."
msgstr "Le paiement a été remboursé ou rejeté." 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." msgid "The paid amount is under the required amount."
msgstr "La montant payé est inférieur au montant requis." msgstr "La montant payé est inférieur au montant requis."
#: payments/backends.py:361 #: payments/backends.py:574
msgid "Coinbase" msgid "Coinbase"
msgstr "Coinbase" msgstr "Coinbase"
#: payments/backends.py:362 #: payments/backends.py:575
msgid "Bitcoin with CoinBase" msgid "Bitcoin with CoinBase"
msgstr "Bitcoin avec CoinBase" msgstr "Bitcoin avec CoinBase"
#: payments/models.py:14 #: payments/models.py:16 payments/models.py:30
msgid "Waiting for payment" msgid "Waiting for payment"
msgstr "En attente du paiement" msgstr "En attente du paiement"
#: payments/models.py:15 #: payments/models.py:17
msgid "Confirmed" msgid "Confirmed"
msgstr "Confirmé" msgstr "Confirmé"
#: payments/models.py:16 #: payments/models.py:18 payments/models.py:32
msgid "Cancelled" msgid "Cancelled"
msgstr "Annullé" msgstr "Annullé"
#: payments/models.py:17 #: payments/models.py:19
msgid "Rejected by processor" msgid "Rejected by processor"
msgstr "Rejeté" msgstr "Rejeté"
#: payments/models.py:18 #: payments/models.py:20
msgid "Payment processing failed" msgid "Payment processing failed"
msgstr "Traitement échoué" 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" msgid "Every 6 months"
msgstr "Tous les 6 mois" msgstr "Tous les 6 mois"
#: payments/models.py:23 #: payments/models.py:39
msgid "Yearly" msgid "Every year"
msgstr "Annuel" msgstr "Tous les ans"
#: payments/views.py:167
msgid "Subscription cancelled!"
msgstr "Abonnement annulé!"
#: templates/account_layout.html:12 #: templates/account_layout.html:12
msgid "Config Download" msgid "Config Download"
@ -378,8 +402,7 @@ msgstr "Paiements"
msgid "Models in the %(name)s application" msgid "Models in the %(name)s application"
msgstr "" msgstr ""
#: templates/admin/index.html:31 templates/lambdainst/account.html.py:61 #: templates/admin/index.html:31 templates/lambdainst/account.html.py:141
#: templates/lambdainst/account.html:83
msgid "Add" msgid "Add"
msgstr "Ajouter" msgstr "Ajouter"
@ -607,17 +630,47 @@ msgstr "Mot de passe oublié ?"
msgid "Need help?" msgid "Need help?"
msgstr "Besoin d'aide ?" 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 #, python-format
msgid "Your account is paid until %(until)s" msgid "Your account is paid until %(until)s"
msgstr "Votre compte est activé jusqu'au %(until)s" msgstr "Votre compte est activé jusqu'au %(until)s"
#: templates/lambdainst/account.html:18 #: templates/lambdainst/account.html:54
#, python-format #, python-format
msgid "(%(left)s left)" msgid "(%(left)s left)"
msgstr "(%(left)s restant)" msgstr "(%(left)s restant)"
#: templates/lambdainst/account.html:24 #: templates/lambdainst/account.html:60
msgid "" msgid ""
"You can activate your free trial account for two hours periods for up to one " "You can activate your free trial account for two hours periods for up to one "
"week, by clicking this button:" "week, by clicking this button:"
@ -625,46 +678,75 @@ msgstr ""
"Vous pouvez activez votre compte d'essai pour des périodes de deux heures " "Vous pouvez activez votre compte d'essai pour des périodes de deux heures "
"pendant jusqu'à une semaine avec ce bouton:" "pendant jusqu'à une semaine avec ce bouton:"
#: templates/lambdainst/account.html:33 #: templates/lambdainst/account.html:69
msgid "Activate" msgid "Activate"
msgstr "Activer" msgstr "Activer"
#: templates/lambdainst/account.html:50 #: templates/lambdainst/account.html:86
msgid "Your account is not paid." msgid "Your account is not paid."
msgstr "Votre compte n'est pas payé." msgstr "Votre compte n'est pas payé."
#: templates/lambdainst/account.html:63 #: templates/lambdainst/account.html:95 templates/payments/list.html.py:6
msgid "month" msgid "Subscription"
msgstr "mois" 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:105 templates/lambdainst/account.html:106
#: templates/lambdainst/account.html:66 #: templates/lambdainst/account.html:107 templates/lambdainst/account.html:144
#: templates/lambdainst/account.html:145 templates/lambdainst/account.html:146
msgid "months" msgid "months"
msgstr "mois" msgstr "mois"
#: templates/lambdainst/account.html:71 #: templates/lambdainst/account.html:112 templates/lambdainst/account.html:151
msgid "with" msgid "with"
msgstr "avec" 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" msgid "Gift code"
msgstr "Code cadeau" msgstr "Code cadeau"
#: templates/lambdainst/account.html:99 #: templates/lambdainst/account.html:182
msgid "Use" msgid "Redeem gift code"
msgstr "Utiliser" msgstr "Récupérer le code cadeau"
#: templates/lambdainst/account.html:108 #: templates/lambdainst/account.html:192
msgid "" msgid ""
"Get two weeks for free for every referral that takes at least one month!" "Get two weeks for free for every referral that takes at least one month!"
msgstr "" msgstr ""
"Gagnez deux semaines gratuites pour chaque client qui a utilisé ce lien !" "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" msgid "Share this link"
msgstr "Partagez ce lien" msgstr "Partagez ce lien"
#: templates/lambdainst/account.html:115 #: templates/lambdainst/account.html:199
msgid "tweet" msgid "tweet"
msgstr "" msgstr ""
@ -909,10 +991,6 @@ msgstr "Une question ? <b>Contactez nous</b>"
msgid "Payment" msgid "Payment"
msgstr "Paiement" msgstr "Paiement"
#: templates/payments/list.html:6
msgid "Subscription"
msgstr "Abonnement"
#: templates/payments/list.html:12 #: templates/payments/list.html:12
msgid "Value" msgid "Value"
msgstr "Valeur" msgstr "Valeur"
@ -1152,3 +1230,6 @@ msgstr "Peut envoyer des messages privés"
#: tickets/models.py:56 #: tickets/models.py:56
msgid "Waiting for staff" msgid "Waiting for staff"
msgstr "En attente du support" msgstr "En attente du support"
#~ msgid "year"
#~ msgstr "an"

@ -1,7 +1,12 @@
from django.shortcuts import resolve_url from django.shortcuts import resolve_url
from django.contrib import admin from django.contrib import admin
from django.utils.translation import ugettext_lazy as _ 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): class PaymentAdmin(admin.ModelAdmin):
@ -11,16 +16,17 @@ class PaymentAdmin(admin.ModelAdmin):
fieldsets = ( fieldsets = (
(None, { (None, {
'fields': ('backend', 'user_link', 'time', 'status', 'status_message'), 'fields': ('backend', 'user_link', 'subscription_link', 'time', 'status',
'status_message'),
}), }),
(_("Payment Data"), { (_("Payment Data"), {
'fields': ('amount_fmt', 'paid_amount_fmt', 'recurring_source', 'fields': ('amount_fmt', 'paid_amount_fmt',
'backend_extid_link', 'backend_data'), 'backend_extid_link', 'backend_data'),
}), }),
) )
readonly_fields = ('backend', 'user_link', 'time', 'status', 'status_message', 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') 'backend_extid_link', 'backend_data')
search_fields = ('user__username', 'user__email', 'backend_extid', '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.allow_tags = True
user_link.short_description = 'User' 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(Payment, PaymentAdmin)
admin.site.register(RecurringPaymentSource, RecurringPaymentSourceAdmin) admin.site.register(Subscription, SubscriptionAdmin)

@ -15,6 +15,7 @@ class BackendBase:
backend_verbose_name = "" backend_verbose_name = ""
backend_display_name = "" backend_display_name = ""
backend_enabled = False backend_enabled = False
backend_has_recurring = False
def __init__(self, settings): def __init__(self, settings):
pass pass
@ -30,6 +31,14 @@ class BackendBase:
""" Handle a callback """ """ Handle a callback """
raise NotImplementedError() 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): def get_info(self):
""" Returns some status (key, value) list """ """ Returns some status (key, value) list """
return () return ()
@ -38,6 +47,10 @@ class BackendBase:
""" Returns URL to external payment view, or None """ """ Returns URL to external payment view, or None """
return None return None
def get_subscr_ext_url(self, subscr):
""" Returns URL to external payment view, or None """
return None
class BitcoinBackend(BackendBase): class BitcoinBackend(BackendBase):
""" Bitcoin backend. """ Bitcoin backend.
@ -151,6 +164,7 @@ class PaypalBackend(BackendBase):
backend_id = 'paypal' backend_id = 'paypal'
backend_verbose_name = _("PayPal") backend_verbose_name = _("PayPal")
backend_display_name = _("PayPal") backend_display_name = _("PayPal")
backend_has_recurring = True
def __init__(self, settings): def __init__(self, settings):
self.test = settings.get('TEST', False) self.test = settings.get('TEST', False)
@ -191,6 +205,37 @@ class PaypalBackend(BackendBase):
return redirect(self.api_base + '/cgi-bin/webscr?' + urlencode(params)) 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): def handle_verified_callback(self, payment, params):
if self.test and params['test_ipn'] != '1': if self.test and params['test_ipn'] != '1':
raise ValueError('Test IPN') raise ValueError('Test IPN')
@ -205,6 +250,44 @@ class PaypalBackend(BackendBase):
payment.status_message = None payment.status_message = None
elif params['payment_status'] == 'Completed': 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']: if self.receiver_address != params['receiver_email']:
raise ValueError('Wrong receiver: ' + params['receiver_email']) raise ValueError('Wrong receiver: ' + params['receiver_email'])
if self.currency.lower() != params['mc_currency'].lower(): if self.currency.lower() != params['mc_currency'].lower():
@ -222,15 +305,16 @@ class PaypalBackend(BackendBase):
payment.status = 'confirmed' payment.status = 'confirmed'
payment.status_message = None payment.status_message = None
payment.save() payment.save()
return True
def verify_ipn(self, payment, request): def verify_ipn(self, request):
v_url = self.api_base + '/cgi-bin/webscr?cmd=_notify-validate' v_url = self.api_base + '/cgi-bin/webscr?cmd=_notify-validate'
v_req = urlopen(v_url, data=request.body, timeout=5) v_req = urlopen(v_url, data=request.body, timeout=5)
v_res = v_req.read() v_res = v_req.read()
return v_res == b'VERIFIED' return v_res == b'VERIFIED'
def callback(self, payment, request): def callback(self, payment, request):
if not self.verify_ipn(payment, request): if not self.verify_ipn(request):
return False return False
params = request.POST params = request.POST
@ -246,6 +330,23 @@ class PaypalBackend(BackendBase):
payment.save() payment.save()
raise 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): def get_ext_url(self, payment):
if not payment.backend_extid: if not payment.backend_extid:
return None return None
@ -256,7 +357,11 @@ class PaypalBackend(BackendBase):
class StripeBackend(BackendBase): class StripeBackend(BackendBase):
backend_id = 'stripe' backend_id = 'stripe'
backend_verbose_name = _("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): def __init__(self, settings):
if 'API_KEY' not in settings or 'PUBLIC_KEY' not in settings: if 'API_KEY' not in settings or 'PUBLIC_KEY' not in settings:
@ -303,6 +408,54 @@ class StripeBackend(BackendBase):
curr=self.currency, 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): def callback(self, payment, request):
post_data = request.POST post_data = request.POST
@ -350,11 +503,80 @@ class StripeBackend(BackendBase):
payment.status_message = e.json_body['error']['message'] payment.status_message = e.json_body['error']['message']
payment.save() 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): def get_ext_url(self, payment):
if not payment.backend_extid: if not payment.backend_extid:
return None return None
return 'https://dashboard.stripe.com/payments/%s' % payment.backend_extid 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): class CoinbaseBackend(BackendBase):
backend_id = 'coinbase' backend_id = 'coinbase'
@ -454,3 +676,4 @@ class CoinbaseBackend(BackendBase):
payment.user.vpnuser.save() payment.user.vpnuser.save()
return True return True

@ -10,6 +10,7 @@ class NewPaymentForm(forms.Form):
('12', '12'), ('12', '12'),
) )
subscr = forms.ChoiceField(choices=(('0', 'no'), ('1', 'yes')))
time = forms.ChoiceField(choices=TIME_CHOICES) time = forms.ChoiceField(choices=TIME_CHOICES)
method = forms.ChoiceField(choices=BACKEND_CHOICES) method = forms.ChoiceField(choices=BACKEND_CHOICES)

@ -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]'))

@ -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'),
),
]

@ -2,6 +2,7 @@ from django.db import models
from django.conf import settings from django.conf import settings
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from jsonfield import JSONField from jsonfield import JSONField
from datetime import timedelta
from .backends import BackendBase from .backends import BackendBase
@ -9,6 +10,7 @@ backend_settings = settings.PAYMENTS_BACKENDS
assert isinstance(backend_settings, dict) assert isinstance(backend_settings, dict)
CURRENCY_CODE, CURRENCY_NAME = settings.PAYMENTS_CURRENCY CURRENCY_CODE, CURRENCY_NAME = settings.PAYMENTS_CURRENCY
MONTHLY_PRICE = settings.PAYMENTS_MONTHLY_PRICE
STATUS_CHOICES = ( STATUS_CHOICES = (
('new', _("Waiting for payment")), ('new', _("Waiting for payment")),
@ -18,9 +20,23 @@ STATUS_CHOICES = (
('error', _("Payment processing failed")), ('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")), ('6m', _("Every 6 months")),
('1year', _("Yearly")), ('12m', _("Every year")),
) )
# All known backends (classes) # 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]) 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): class Payment(models.Model):
""" Just a payment. """ Just a payment.
If recurring_source is not null, it has been automatically issued. If subscription is not null, it has been automatically issued.
backend_id is the external transaction ID, backend_data is other backend_extid is the external transaction ID, backend_data is other
things that should only be used by the associated backend. things that should only be used by the associated backend.
""" """
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
@ -66,7 +90,7 @@ class Payment(models.Model):
amount = models.IntegerField() amount = models.IntegerField()
paid_amount = models.IntegerField(default=0) paid_amount = models.IntegerField(default=0)
time = models.DurationField() 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) status_message = models.TextField(blank=True, null=True)
backend_extid = models.CharField(max_length=64, null=True, blank=True) backend_extid = models.CharField(max_length=64, null=True, blank=True)
@ -97,18 +121,65 @@ class Payment(models.Model):
class Meta: class Meta:
ordering = ('-created', ) 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. class Subscription(models.Model):
They use the same backends. """ Recurring payment subscription. """
"""
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) 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) created = models.DateTimeField(auto_now_add=True)
modified = models.DateTimeField(auto_now=True) period = models.CharField(max_length=16, choices=SUBSCR_PERIOD_CHOICES)
period = models.CharField(max_length=16, choices=PERIOD_CHOICES)
last_confirmed_payment = 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_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) 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

@ -5,7 +5,7 @@ from django.test import TestCase, RequestFactory
from django.http import HttpResponseRedirect from django.http import HttpResponseRedirect
from django.contrib.auth.models import User from django.contrib.auth.models import User
from .models import Payment from .models import Payment, Subscription
from .backends import BitcoinBackend, PaypalBackend, StripeBackend from .backends import BitcoinBackend, PaypalBackend, StripeBackend
from decimal import Decimal from decimal import Decimal
@ -55,7 +55,6 @@ payer_status=verified&\
address_country=United+States&\ address_country=United+States&\
address_city=San+Jose&\ address_city=San+Jose&\
quantity=1&\ quantity=1&\
verify_sign=AtkOfCXbDm2hu0ZELryHFjY-Vb7PAUvS6nMXgysbElEn9v-1XcmSoGtf&\
payer_email=test_user@example.com&\ payer_email=test_user@example.com&\
txn_id=61E67681CH3238416&\ txn_id=61E67681CH3238416&\
payment_type=instant&\ payment_type=instant&\
@ -75,6 +74,83 @@ transaction_subject=&\
payment_gross=3.00&\ payment_gross=3.00&\
shipping=0.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): class BitcoinBackendTest(TestCase):
def setUp(self): def setUp(self):
@ -197,7 +273,7 @@ class BackendTest(TestCase):
# Replace PaypalBackend.verify_ipn to not call the PayPal API # Replace PaypalBackend.verify_ipn to not call the PayPal API
# we will assume the IPN is authentic # 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_url = '/payments/callback/paypal/%d' % payment.id
ipn_request = RequestFactory().post( ipn_request = RequestFactory().post(
@ -239,7 +315,7 @@ class BackendTest(TestCase):
# Replace PaypalBackend.verify_ipn to not call the PayPal API # Replace PaypalBackend.verify_ipn to not call the PayPal API
# we will assume the IPN is authentic # 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_url = '/payments/callback/paypal/%d' % payment.id
ipn_request = RequestFactory().post( ipn_request = RequestFactory().post(
@ -253,6 +329,102 @@ class BackendTest(TestCase):
self.assertEqual(payment.paid_amount, 300) self.assertEqual(payment.paid_amount, 300)
self.assertEqual(payment.backend_extid, '61E67681CH3238416') 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): def test_stripe(self):
payment = Payment.objects.create( payment = Payment.objects.create(
user=self.user, user=self.user,

@ -5,10 +5,16 @@ urlpatterns = [
url(r'^new$', views.new), url(r'^new$', views.new),
url(r'^view/(?P<id>[0-9]+)$', views.view, name='view'), 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/(?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/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/stripe/(?P<id>[0-9]+)$', views.callback_stripe, name='cb_stripe'),
url(r'^callback/coinbase/$', views.callback_coinbase, name='cb_coinbase'), 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), url(r'^$', views.list_payments),
] ]

@ -6,9 +6,11 @@ from django.contrib.auth.decorators import login_required
from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseNotFound from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseNotFound
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
from django.utils import timezone from django.utils import timezone
from django.contrib import messages
from django.utils.translation import ugettext_lazy as _
from .forms import NewPaymentForm from .forms import NewPaymentForm
from .models import Payment, BACKENDS from .models import Payment, Subscription, BACKENDS, ACTIVE_BACKENDS
monthly_price = settings.PAYMENTS_MONTHLY_PRICE monthly_price = settings.PAYMENTS_MONTHLY_PRICE
@ -24,18 +26,31 @@ def new(request):
if not form.is_valid(): if not form.is_valid():
return redirect('account:index') 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']) months = int(form.cleaned_data['time'])
payment = Payment(
if backend_id not in ACTIVE_BACKENDS:
return HttpResponseNotFound()
if subscr:
if months not in (3, 6, 12):
return redirect('account:index')
rps = Subscription(
user=request.user, user=request.user,
backend_id=form.cleaned_data['method'], backend_id=backend_id,
status='new', period=str(months) + 'm',
time=timedelta(days=30 * months),
amount=monthly_price * months
) )
rps.save()
if not payment.backend.backend_enabled: r = rps.backend.new_subscription(rps)
return HttpResponseNotFound()
else:
payment = Payment.create_payment(backend_id, request.user, months)
payment.save() payment.save()
r = payment.backend.new_payment(payment) r = payment.backend.new_payment(payment)
@ -89,6 +104,42 @@ def callback_coinbase(request):
return HttpResponseBadRequest() 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 @login_required
@csrf_exempt @csrf_exempt
def view(request, id): def view(request, id):
@ -105,6 +156,29 @@ def cancel(request, id):
return render(request, 'payments/view.html', dict(payment=p)) 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 @login_required
def list_payments(request): def list_payments(request):
# Only show recent cancelled payments # Only show recent cancelled payments

@ -34,7 +34,7 @@ pre {
margin-bottom: 1em; margin-bottom: 1em;
} }
div#captcha > div > div { div#captcha > div {
margin: 1em auto; margin: 1em auto;
} }
@ -478,7 +478,7 @@ a.home-signup-button {
.account-status { .account-status {
text-align: center; text-align: center;
margin-bottom: 3em; margin-bottom: 2em;
} }
.account-status-paid, .account-status-disabled { .account-status-paid, .account-status-disabled {
font-weight: bold; font-weight: bold;
@ -493,13 +493,98 @@ a.home-signup-button {
margin: 2em 0 0 0; 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; 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) { @media screen and (min-width: 64em) {
.account-payment-box { .account-giftcode-box {
border-right: 1px solid #1c619a; padding-top: 4em;
} }
} }
@ -665,6 +750,11 @@ div.ticket-message-private {
} }
.stripe-button-el {
display: block !important;
margin: 4em auto 3em auto;
}
/***************************************************/ /***************************************************/
/********************* Fonts */ /********************* Fonts */

@ -10,7 +10,43 @@
<h1>{% trans 'Account' %} : {{user.username}}</h1> <h1>{% trans 'Account' %} : {{user.username}}</h1>
<div class="account-status"> <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"> <p class="account-status-paid">
{% blocktrans trimmed with until=user.vpnuser.expiration|date:'DATETIME_FORMAT' %} {% blocktrans trimmed with until=user.vpnuser.expiration|date:'DATETIME_FORMAT' %}
Your account is paid until {{until}} Your account is paid until {{until}}
@ -51,19 +87,63 @@
{% endif %} {% endif %}
</div> </div>
{% if not subscription %}
<div class="pure-g"> <div class="pure-g">
<div class="pure-u-1 pure-u-lg-1-2 account-payment-box"> <div class="pure-u-1 pure-u-lg-1-2 account-payment-box account-payment-tabs">
<form action="/payments/new" method="post" class="pure-form pure-form-aligned"> <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 '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 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 '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 %} {% csrf_token %}
<input type="hidden" name="subscr" value="0" />
<fieldset> <fieldset>
<div class="pure-control-group"> <div class="pure-control-group">
<label for="ino_time">{% trans 'Add' %}</label> <label for="ino_time">{% trans 'Add' %}</label>
<select id="ino_time" name="time" class="pure-input-1-2"> <select id="ino_time" name="time" class="pure-input-1-2">
<option value="1">1 {% trans 'month' %}</option> <option value="1">1 {% trans 'month' %} ({{price.1}})</option>
<option value="3">3 {% trans 'months' %}</option> <option value="3">3 {% trans 'months' %} ({{price.3}})</option>
<option value="6">6 {% trans 'months' %}</option> <option value="6">6 {% trans 'months' %} ({{price.6}})</option>
<option value="12">12 {% trans 'months' %}</option> <option value="12">12 {% trans 'months' %} ({{price.12}})</option>
</select> </select>
</div> </div>
@ -80,13 +160,16 @@
<div class="pure-controls"> <div class="pure-controls">
<input type="submit" class="pure-button pure-button-primary" <input type="submit" class="pure-button pure-button-primary"
value="{% trans 'Add' %}" /> value="{% trans 'Buy Now' %}" />
</div> </div>
<p>{% trans 'If you still have time, it will be added.' %}</p>
</fieldset> </fieldset>
</form> </form>
</div> </div>
</div>
</div>
<div class="pure-u-1 pure-u-lg-1-2 account-giftcode-box"> <div class="pure-u-1 pure-u-lg-1-2 account-giftcode-box">
<form action="/account/gift_code" method="post" class="pure-form pure-form-aligned"> <form action="/account/gift_code" method="post" class="pure-form pure-form-aligned centered-form">
{% csrf_token %} {% csrf_token %}
<fieldset> <fieldset>
<div class="pure-control-group"> <div class="pure-control-group">
@ -96,12 +179,13 @@
</div> </div>
<div class="pure-controls"> <div class="pure-controls">
<input type="submit" class="pure-button pure-button-primary" <input type="submit" class="pure-button pure-button-primary"
value="{% trans 'Use' %}" /> value="{% trans 'Redeem gift code' %}" />
</div> </div>
</fieldset> </fieldset>
</form> </form>
</div> </div>
</div> </div>
{% endif %}
<div class="account-aff-box"> <div class="account-aff-box">
<p> <p>

Loading…
Cancel
Save