From 468c2e650c854ffa691afc73aa1f41ce61283cdb Mon Sep 17 00:00:00 2001 From: Jan Szejko Date: Wed, 4 Jul 2018 16:28:35 +0200 Subject: [PATCH] paypal subscriptions - stub --- requirements/requirements.txt | 6 +- src/paypal/__init__.py | 0 src/paypal/forms.py | 10 ++ src/paypal/migrations/0001_initial.py | 41 +++++++ .../migrations/0002_billingagreement_token.py | 20 +++ src/paypal/migrations/__init__.py | 0 src/paypal/models.py | 20 +++ src/paypal/nvp_soap.py | 61 ++++++++++ src/paypal/rest.py | 114 ++++++++++++++++++ src/paypal/templates/paypal/cancel.html | 6 + src/paypal/templates/paypal/error.html | 5 + src/paypal/templates/paypal/error_page.html | 5 + src/paypal/templates/paypal/form.html | 13 ++ src/paypal/templates/paypal/return.html | 10 ++ src/paypal/urls.py | 12 ++ src/paypal/views.py | 50 ++++++++ src/wolnelektury/settings/__init__.py | 1 + src/wolnelektury/settings/contrib.py | 6 + src/wolnelektury/urls.py | 1 + 19 files changed, 380 insertions(+), 1 deletion(-) create mode 100644 src/paypal/__init__.py create mode 100644 src/paypal/forms.py create mode 100644 src/paypal/migrations/0001_initial.py create mode 100644 src/paypal/migrations/0002_billingagreement_token.py create mode 100644 src/paypal/migrations/__init__.py create mode 100644 src/paypal/models.py create mode 100644 src/paypal/nvp_soap.py create mode 100644 src/paypal/rest.py create mode 100644 src/paypal/templates/paypal/cancel.html create mode 100644 src/paypal/templates/paypal/error.html create mode 100644 src/paypal/templates/paypal/error_page.html create mode 100644 src/paypal/templates/paypal/form.html create mode 100644 src/paypal/templates/paypal/return.html create mode 100644 src/paypal/urls.py create mode 100644 src/paypal/views.py diff --git a/requirements/requirements.txt b/requirements/requirements.txt index 06ea294a6..631f473f0 100644 --- a/requirements/requirements.txt +++ b/requirements/requirements.txt @@ -62,4 +62,8 @@ django-ssify>=0.2.1,<0.3 raven -mailchimp3 \ No newline at end of file +mailchimp3 + +requests + +paypalrestsdk \ No newline at end of file diff --git a/src/paypal/__init__.py b/src/paypal/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/paypal/forms.py b/src/paypal/forms.py new file mode 100644 index 000000000..849810d1a --- /dev/null +++ b/src/paypal/forms.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# This file is part of Wolnelektury, licensed under GNU Affero GPLv3 or later. +# Copyright © Fundacja Nowoczesna Polska. See NOTICE for more information. +# +from django import forms +from django.utils.translation import ugettext_lazy as _ + + +class PaypalSubscriptionForm(forms.Form): + amount = forms.IntegerField(min_value=10, max_value=30000, initial=20, label=_('amount')) diff --git a/src/paypal/migrations/0001_initial.py b/src/paypal/migrations/0001_initial.py new file mode 100644 index 000000000..879901ae8 --- /dev/null +++ b/src/paypal/migrations/0001_initial.py @@ -0,0 +1,41 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models +from django.conf import settings + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='BillingAgreement', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('agreement_id', models.CharField(max_length=32)), + ('active', models.BooleanField(max_length=32)), + ], + ), + migrations.CreateModel( + name='BillingPlan', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('plan_id', models.CharField(max_length=32)), + ('amount', models.IntegerField(unique=True, db_index=True)), + ], + ), + migrations.AddField( + model_name='billingagreement', + name='plan', + field=models.ForeignKey(to='paypal.BillingPlan'), + ), + migrations.AddField( + model_name='billingagreement', + name='user', + field=models.ForeignKey(to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/src/paypal/migrations/0002_billingagreement_token.py b/src/paypal/migrations/0002_billingagreement_token.py new file mode 100644 index 000000000..7a45e87a0 --- /dev/null +++ b/src/paypal/migrations/0002_billingagreement_token.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('paypal', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='billingagreement', + name='token', + field=models.CharField(default='', max_length=32), + preserve_default=False, + ), + ] diff --git a/src/paypal/migrations/__init__.py b/src/paypal/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/paypal/models.py b/src/paypal/models.py new file mode 100644 index 000000000..527e80458 --- /dev/null +++ b/src/paypal/models.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# This file is part of Wolnelektury, licensed under GNU Affero GPLv3 or later. +# Copyright © Fundacja Nowoczesna Polska. See NOTICE for more information. +# +# from django.contrib.auth import get_user_model +from django.contrib.auth.models import User +from django.db import models + + +class BillingPlan(models.Model): + plan_id = models.CharField(max_length=32) + amount = models.IntegerField(db_index=True, unique=True) + + +class BillingAgreement(models.Model): + agreement_id = models.CharField(max_length=32) + user = models.ForeignKey(User) + plan = models.ForeignKey(BillingPlan) + active = models.BooleanField(max_length=32) + token = models.CharField(max_length=32) diff --git a/src/paypal/nvp_soap.py b/src/paypal/nvp_soap.py new file mode 100644 index 000000000..09f8f62f3 --- /dev/null +++ b/src/paypal/nvp_soap.py @@ -0,0 +1,61 @@ +# -*- coding: utf-8 -*- +# This file is part of Wolnelektury, licensed under GNU Affero GPLv3 or later. +# Copyright © Fundacja Nowoczesna Polska. See NOTICE for more information. +# +# UNUSED +import requests +import urlparse +from django.conf import settings + +DESC = 'Wolne Lektury subscription' + + +def paypal_request(data): + request_data = { + 'USER': settings.PAYPAL['user'], + 'PWD': settings.PAYPAL['password'], + 'SIGNATURE': settings.PAYPAL['signature'], + 'SUBJECT': settings.PAYPAL['email'], + 'VERSION': 93, + } + request_data.update(data) + + response = requests.post(settings.PAYPAL['api-url'], data=request_data) + return dict(urlparse.parse_qsl(response.text)) + + +def set_express_checkout(amount): + response = paypal_request({ + 'METHOD': 'SetExpressCheckout', + 'PAYMENTREQUEST_0_PAYMENTACTION': 'SALE', + 'PAYMENTREQUEST_0_AMT': amount, + 'PAYMENTREQUEST_0_CURRENCYCODE': 'PLN', + 'L_BILLINGTYPE0': 'RecurringPayments', + 'L_BILLINGAGREEMENTDESCRIPTION0': DESC, + 'RETURNURL': settings.PAYPAL['return-url'], + 'CANCELURL': settings.PAYPAL['cancel-url'], + }) + return response.get('TOKEN') + + +def create_profile(token, amount): + response = paypal_request({ + 'METHOD': 'CreateRecurringPaymentsProfile', + 'TOKEN': token, + 'PROFILESTARTDATE': '2011-03-11T00:00:00Z', + 'DESC': DESC, + 'MAXFAILEDPAYMENTS': 3, + 'AUTOBILLAMT': 'AddToNextBilling', + 'BILLINGPERIOD': 'Month', # or 30 Days? + 'BILLINGFREQUENCY': 1, + 'AMT': amount, + 'CURRENCYCODE': 'PLN', + 'L_PAYMENTREQUEST_0_ITEMCATEGORY0': 'Digital', + 'L_PAYMENTREQUEST_0_NAME0': 'Subskrypcja Wolnych Lektur', + 'L_PAYMENTREQUEST_0_AMT0': amount, + 'L_PAYMENTREQUEST_0_QTY0': 1, + }) + return response.get('PROFILEID') + + +# min amount: 10, max amount: 30000 diff --git a/src/paypal/rest.py b/src/paypal/rest.py new file mode 100644 index 000000000..68ac30ff5 --- /dev/null +++ b/src/paypal/rest.py @@ -0,0 +1,114 @@ +# -*- coding: utf-8 -*- +# This file is part of Wolnelektury, licensed under GNU Affero GPLv3 or later. +# Copyright © Fundacja Nowoczesna Polska. See NOTICE for more information. +# +from datetime import timedelta + +import paypalrestsdk +import pytz +from django.contrib.sites.models import Site +from django.core.urlresolvers import reverse +from django.utils import timezone +from paypalrestsdk import BillingPlan, BillingAgreement, ResourceNotFound +from django.conf import settings +from .models import BillingPlan as BillingPlanModel + +paypalrestsdk.configure(settings.PAYPAL_CONFIG) + + +class PaypalError(Exception): + pass + + +def absolute_url(url_name): + return "http://%s%s" % (Site.objects.get_current().domain, reverse(url_name)) + + +def create_plan(amount): + billing_plan = BillingPlan({ + "name": "Cykliczna darowizna na Wolne Lektury: %s zł" % amount, + "description": "Cykliczna darowizna na wsparcie Wolnych Lektur", + "merchant_preferences": { + "auto_bill_amount": "yes", + "return_url": absolute_url('paypal_return'), + "cancel_url": absolute_url('paypal_cancel'), + # "initial_fail_amount_action": "continue", + "max_fail_attempts": "3", + }, + "payment_definitions": [ + { + "amount": { + "currency": "PLN", + "value": str(amount), + }, + "cycles": "0", + "frequency": "MONTH", + "frequency_interval": "1", + "name": "Cykliczna darowizna", + "type": "REGULAR", + } + ], + "type": "INFINITE", + }) + + if not billing_plan.create(): + raise PaypalError(billing_plan.error) + if not billing_plan.activate(): + raise PaypalError(billing_plan.error) + plan, created = BillingPlanModel.objects.get_or_create(amount=amount, defaults={'plan_id': billing_plan.id}) + return plan.plan_id + + +def get_link(links, rel): + for link in links: + if link.rel == rel: + return link.href + + +def create_agreement(amount): + try: + plan = BillingPlanModel.objects.get(amount=amount) + except BillingPlanModel.DoesNotExist: + plan_id = create_plan(amount) + else: + plan_id = plan.plan_id + start = (timezone.now() + timedelta(0, 3600*24)).astimezone(pytz.utc).strftime('%Y-%m-%dT%H:%M:%SZ') + billing_agreement = BillingAgreement({ + "name": "Subskrypcja klubu WL", + "description": "Cykliczne wspieranie Wolnych Lektur kwotą %s złotych" % amount, + "start_date": start, + "plan": { + "id": plan_id, + }, + "payer": { + "payment_method": "paypal" + }, + }) + + response = billing_agreement.create() + if response: + return billing_agreement + else: + raise PaypalError(billing_agreement.error) + + +def agreement_approval_url(amount): + agreement = create_agreement(amount) + return get_link(agreement.links, 'approval_url') + + +def get_agreement(agreement_id): + try: + return BillingAgreement.find(agreement_id) + except ResourceNotFound: + return None + + +def check_agreement(agreement_id): + a = get_agreement(agreement_id) + if a: + return a.state == 'Active' + + +def execute_agreement(token): + return BillingAgreement.execute(token) diff --git a/src/paypal/templates/paypal/cancel.html b/src/paypal/templates/paypal/cancel.html new file mode 100644 index 000000000..459979975 --- /dev/null +++ b/src/paypal/templates/paypal/cancel.html @@ -0,0 +1,6 @@ +{% extends "base/base.html" %} +{% load i18n %} + +{% block body %} +

{% trans "Zrezygnowano z płatności :(" %}

+{% endblock %} \ No newline at end of file diff --git a/src/paypal/templates/paypal/error.html b/src/paypal/templates/paypal/error.html new file mode 100644 index 000000000..557284ba8 --- /dev/null +++ b/src/paypal/templates/paypal/error.html @@ -0,0 +1,5 @@ +

{% trans "PayPal Error" %}: {{ error.message }}

+{% for detail in error.details %} +

{{ detail.field }}: {{ detail.issue }}

+{% endfor %} +

{% trans "Learn more" %}

\ No newline at end of file diff --git a/src/paypal/templates/paypal/error_page.html b/src/paypal/templates/paypal/error_page.html new file mode 100644 index 000000000..499ae1d56 --- /dev/null +++ b/src/paypal/templates/paypal/error_page.html @@ -0,0 +1,5 @@ +{% extends "base/base.html" %} + +{% block body %} + {% include "paypal/error.html" %} +{% endblock %} \ No newline at end of file diff --git a/src/paypal/templates/paypal/form.html b/src/paypal/templates/paypal/form.html new file mode 100644 index 000000000..b2e6a0c7d --- /dev/null +++ b/src/paypal/templates/paypal/form.html @@ -0,0 +1,13 @@ +{% extends "base/base.html" %} +{% load i18n %} + +{% block title %}{% trans "Subscription" %}{% endblock %} + +{% block body %} +
+ {% csrf_token %} + {{ form.as_p }} + {# paypal submit button #} + +
+{% endblock %} \ No newline at end of file diff --git a/src/paypal/templates/paypal/return.html b/src/paypal/templates/paypal/return.html new file mode 100644 index 000000000..4e967fb0f --- /dev/null +++ b/src/paypal/templates/paypal/return.html @@ -0,0 +1,10 @@ +{% extends "base/base.html" %} +{% load i18n %} + +{% block body %} + {% if resource.error %} + {% include "paypal/error.html" with error=resource.error %} + {% else %} +

{% trans "Płatność potwierdzona i zlecona do wykonania." %}

+ {% endif %} +{% endblock %} \ No newline at end of file diff --git a/src/paypal/urls.py b/src/paypal/urls.py new file mode 100644 index 000000000..924cedd95 --- /dev/null +++ b/src/paypal/urls.py @@ -0,0 +1,12 @@ +# -*- coding: utf-8 -*- +# This file is part of Wolnelektury, licensed under GNU Affero GPLv3 or later. +# Copyright © Fundacja Nowoczesna Polska. See NOTICE for more information. +# +from django.conf.urls import url +from . import views + +urlpatterns = ( + url(r'^form/$', views.paypal_form, name='paypal_form'), + url(r'^return/$', views.paypal_return, name='paypal_return'), + url(r'^cancel/$', views.paypal_cancel, name='paypal_cancel'), +) diff --git a/src/paypal/views.py b/src/paypal/views.py new file mode 100644 index 000000000..a4c04ce08 --- /dev/null +++ b/src/paypal/views.py @@ -0,0 +1,50 @@ +# -*- coding: utf-8 -*- +# This file is part of Wolnelektury, licensed under GNU Affero GPLv3 or later. +# Copyright © Fundacja Nowoczesna Polska. See NOTICE for more information. +# +from decimal import Decimal + +from django.contrib.auth.decorators import login_required +from django.http import Http404 +from django.http.response import HttpResponseRedirect +from django.shortcuts import render + +from paypal.forms import PaypalSubscriptionForm +from paypal.rest import execute_agreement, check_agreement, agreement_approval_url, PaypalError +from paypal.models import BillingAgreement as BillingAgreementModel, BillingPlan + + +@login_required +def paypal_form(request): + if request.POST: + form = PaypalSubscriptionForm(data=request.POST) + if form.is_valid(): + amount = form.cleaned_data['amount'] + try: + approval_url = agreement_approval_url(amount) + except PaypalError as e: + return render(request, 'paypal/error_page.html', {'error': e.message}) + return HttpResponseRedirect(approval_url) + else: + form = PaypalSubscriptionForm() + return render(request, 'paypal/form.html', {'form': form}) + + +@login_required +def paypal_return(request): + token = request.GET.get('token') + if not token: + raise Http404 + if not BillingAgreementModel.objects.filter(token=token): + resource = execute_agreement(token) + if resource.id: + amount = int(Decimal(resource.plan.payment_definitions[0].amount['value'])) + plan = BillingPlan.objects.get(amount=amount) + active = check_agreement(resource.id) + BillingAgreementModel.objects.create( + agreement_id=resource.id, user=request.user, plan=plan, active=active, token=token) + return render(request, 'paypal/return.html', {'resource': resource}) + + +def paypal_cancel(request): + return render(request, 'paypal/cancel.html', {}) diff --git a/src/wolnelektury/settings/__init__.py b/src/wolnelektury/settings/__init__.py index ca70e9f6d..54dca9c09 100644 --- a/src/wolnelektury/settings/__init__.py +++ b/src/wolnelektury/settings/__init__.py @@ -60,6 +60,7 @@ INSTALLED_APPS_OUR = [ 'newsletter', 'contact', 'isbn', + 'paypal', ] GETPAID_BACKENDS = ( diff --git a/src/wolnelektury/settings/contrib.py b/src/wolnelektury/settings/contrib.py index 4bb78bcfe..e7047c006 100644 --- a/src/wolnelektury/settings/contrib.py +++ b/src/wolnelektury/settings/contrib.py @@ -19,3 +19,9 @@ GETPAID_ORDER_DESCRIPTION = "{% load funding_tags %}{{ order|sanitize_payment_ti PIWIK_URL = '' PIWIK_SITE_ID = 0 PIWIK_TOKEN = '' + +PAYPAL_CONFIG = { + 'mode': 'sandbox', # sandbox or live + 'client_id': '', + 'client_secret': '', +} diff --git a/src/wolnelektury/urls.py b/src/wolnelektury/urls.py index bf95306d9..0cbaced13 100644 --- a/src/wolnelektury/urls.py +++ b/src/wolnelektury/urls.py @@ -50,6 +50,7 @@ urlpatterns += [ url(r'^newsletter/', include('newsletter.urls')), url(r'^formularz/', include('contact.urls')), url(r'^isbn/', include('isbn.urls')), + url(r'^paypal/', include('paypal.urls')), # Admin panel url(r'^admin/catalogue/book/import$', catalogue.views.import_book, name='import_book'), -- 2.20.1