From ffa2f00f67bf6a47fdb4b1581a7cef2b63701bc9 Mon Sep 17 00:00:00 2001 From: Alice Date: Tue, 6 Sep 2016 23:02:42 +0000 Subject: [PATCH] Recurring payments! --- .../management/commands/expire_notify.py | 13 +- lambdainst/models.py | 14 + lambdainst/tests.py | 83 +++++- lambdainst/views.py | 14 + locale/fr/LC_MESSAGES/django.po | 225 ++++++++++----- payments/admin.py | 66 ++++- payments/backends.py | 257 ++++++++++++++++-- payments/forms.py | 1 + .../commands/update_stripe_plans.py | 70 +++++ .../migrations/0004_auto_20160904_0048.py | 49 ++++ payments/models.py | 97 ++++++- payments/tests.py | 180 +++++++++++- payments/urls.py | 6 + payments/views.py | 98 ++++++- static/css/style.css | 100 ++++++- templates/lambdainst/account.html | 148 +++++++--- 16 files changed, 1251 insertions(+), 170 deletions(-) create mode 100644 payments/management/commands/update_stripe_plans.py create mode 100644 payments/migrations/0004_auto_20160904_0048.py diff --git a/lambdainst/management/commands/expire_notify.py b/lambdainst/management/commands/expire_notify.py index e9d70bf..8c41880 100644 --- a/lambdainst/management/commands/expire_notify.py +++ b/lambdainst/management/commands/expire_notify.py @@ -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()) diff --git a/lambdainst/models.py b/lambdainst/models.py index a50405a..5d54e91 100644 --- a/lambdainst/models.py +++ b/lambdainst/models.py @@ -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 diff --git a/lambdainst/tests.py b/lambdainst/tests.py index 7af92fb..05c0610 100644 --- a/lambdainst/tests.py +++ b/lambdainst/tests.py @@ -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)) + + diff --git a/lambdainst/views.py b/lambdainst/views.py index 59a8681..a94a557 100644 --- a/lambdainst/views.py +++ b/lambdainst/views.py @@ -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) diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po index 463516b..59ce0e9 100644 --- a/locale/fr/LC_MESSAGES/django.po +++ b/locale/fr/LC_MESSAGES/django.po @@ -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 ? Contactez nous" 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" diff --git a/payments/admin.py b/payments/admin.py index f56ed5d..3d8db2a 100644 --- a/payments/admin.py +++ b/payments/admin.py @@ -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 '%s' % (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 '%s' % (change_url, object.user.username) + user_link.allow_tags = True + user_link.short_description = 'User' + + def payments_links(self, object): + fmt = '%d payments' + 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 '%s' % (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) diff --git a/payments/backends.py b/payments/backends.py index 749e276..e712959 100644 --- a/payments/backends.py +++ b/payments/backends.py @@ -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 = ''' +
+ +
+ ''' + 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 + diff --git a/payments/forms.py b/payments/forms.py index 65d9c92..b2245a5 100644 --- a/payments/forms.py +++ b/payments/forms.py @@ -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) diff --git a/payments/management/commands/update_stripe_plans.py b/payments/management/commands/update_stripe_plans.py new file mode 100644 index 0000000..ccade31 --- /dev/null +++ b/payments/management/commands/update_stripe_plans.py @@ -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]')) diff --git a/payments/migrations/0004_auto_20160904_0048.py b/payments/migrations/0004_auto_20160904_0048.py new file mode 100644 index 0000000..c7db118 --- /dev/null +++ b/payments/migrations/0004_auto_20160904_0048.py @@ -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'), + ), + ] diff --git a/payments/models.py b/payments/models.py index 2138251..01bc82c 100644 --- a/payments/models.py +++ b/payments/models.py @@ -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 + + diff --git a/payments/tests.py b/payments/tests.py index eae9e4c..7df023e 100644 --- a/payments/tests.py +++ b/payments/tests.py @@ -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, diff --git a/payments/urls.py b/payments/urls.py index 0be8fe6..a227769 100644 --- a/payments/urls.py +++ b/payments/urls.py @@ -5,10 +5,16 @@ urlpatterns = [ url(r'^new$', views.new), url(r'^view/(?P[0-9]+)$', views.view, name='view'), url(r'^cancel/(?P[0-9]+)$', views.cancel, name='cancel'), + url(r'^cancel_subscr/(?P[0-9]+)$', views.cancel_subscr, name='cancel_subscr'), + url(r'^return_subscr/(?P[0-9]+)$', views.return_subscr, name='return_subscr'), url(r'^callback/paypal/(?P[0-9]+)$', views.callback_paypal, name='cb_paypal'), url(r'^callback/stripe/(?P[0-9]+)$', views.callback_stripe, name='cb_stripe'), url(r'^callback/coinbase/$', views.callback_coinbase, name='cb_coinbase'), + url(r'^callback/paypal_subscr/(?P[0-9]+)$', views.callback_paypal_subscr, name='cb_paypal_subscr'), + url(r'^callback/stripe_subscr/(?P[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), ] diff --git a/payments/views.py b/payments/views.py index 2b6d1dc..5ca5dd5 100644 --- a/payments/views.py +++ b/payments/views.py @@ -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 diff --git a/static/css/style.css b/static/css/style.css index ce83887..fc7fb5c 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -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 */ diff --git a/templates/lambdainst/account.html b/templates/lambdainst/account.html index 3d35df2..0194aee 100644 --- a/templates/lambdainst/account.html +++ b/templates/lambdainst/account.html @@ -10,7 +10,43 @@

{% trans 'Account' %} : {{user.username}}

+ {% if not subscription %}
- + {% endif %}