paypal subscriptions - stub
authorJan Szejko <janek37@gmail.com>
Wed, 4 Jul 2018 14:28:35 +0000 (16:28 +0200)
committerJan Szejko <janek37@gmail.com>
Wed, 4 Jul 2018 14:28:35 +0000 (16:28 +0200)
19 files changed:
requirements/requirements.txt
src/paypal/__init__.py [new file with mode: 0644]
src/paypal/forms.py [new file with mode: 0644]
src/paypal/migrations/0001_initial.py [new file with mode: 0644]
src/paypal/migrations/0002_billingagreement_token.py [new file with mode: 0644]
src/paypal/migrations/__init__.py [new file with mode: 0644]
src/paypal/models.py [new file with mode: 0644]
src/paypal/nvp_soap.py [new file with mode: 0644]
src/paypal/rest.py [new file with mode: 0644]
src/paypal/templates/paypal/cancel.html [new file with mode: 0644]
src/paypal/templates/paypal/error.html [new file with mode: 0644]
src/paypal/templates/paypal/error_page.html [new file with mode: 0644]
src/paypal/templates/paypal/form.html [new file with mode: 0644]
src/paypal/templates/paypal/return.html [new file with mode: 0644]
src/paypal/urls.py [new file with mode: 0644]
src/paypal/views.py [new file with mode: 0644]
src/wolnelektury/settings/__init__.py
src/wolnelektury/settings/contrib.py
src/wolnelektury/urls.py

index 06ea294..631f473 100644 (file)
@@ -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 (file)
index 0000000..e69de29
diff --git a/src/paypal/forms.py b/src/paypal/forms.py
new file mode 100644 (file)
index 0000000..849810d
--- /dev/null
@@ -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 (file)
index 0000000..879901a
--- /dev/null
@@ -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 (file)
index 0000000..7a45e87
--- /dev/null
@@ -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 (file)
index 0000000..e69de29
diff --git a/src/paypal/models.py b/src/paypal/models.py
new file mode 100644 (file)
index 0000000..527e804
--- /dev/null
@@ -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 (file)
index 0000000..09f8f62
--- /dev/null
@@ -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 (file)
index 0000000..68ac30f
--- /dev/null
@@ -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 (file)
index 0000000..4599799
--- /dev/null
@@ -0,0 +1,6 @@
+{% extends "base/base.html" %}
+{% load i18n %}
+
+{% block body %}
+  <p>{% trans "Zrezygnowano z płatności :(" %}</p>
+{% 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 (file)
index 0000000..557284b
--- /dev/null
@@ -0,0 +1,5 @@
+<h1>{% trans "PayPal Error" %}: {{ error.message }}</h1>
+{% for detail in error.details %}
+  <p>{{ detail.field }}: {{ detail.issue }}</p>
+{% endfor %}
+<p><a href="{{ error.information_link }}">{% trans "Learn more" %}</a></p>
\ 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 (file)
index 0000000..499ae1d
--- /dev/null
@@ -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 (file)
index 0000000..b2e6a0c
--- /dev/null
@@ -0,0 +1,13 @@
+{% extends "base/base.html" %}
+{% load i18n %}
+
+{% block title %}{% trans "Subscription" %}{% endblock %}
+
+{% block body %}
+  <form method="post">
+    {% csrf_token %}
+    {{ form.as_p }}
+    {# paypal submit button #}
+    <button type="submit">{% trans "Subscribe with PayPal" %}</button>
+  </form>
+{% 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 (file)
index 0000000..4e967fb
--- /dev/null
@@ -0,0 +1,10 @@
+{% extends "base/base.html" %}
+{% load i18n %}
+
+{% block body %}
+  {% if resource.error %}
+    {% include "paypal/error.html" with error=resource.error %}
+  {% else %}
+    <p>{% trans "Płatność potwierdzona i zlecona do wykonania." %}</p>
+  {% endif %}
+{% endblock %}
\ No newline at end of file
diff --git a/src/paypal/urls.py b/src/paypal/urls.py
new file mode 100644 (file)
index 0000000..924cedd
--- /dev/null
@@ -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 (file)
index 0000000..a4c04ce
--- /dev/null
@@ -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', {})
index ca70e9f..54dca9c 100644 (file)
@@ -60,6 +60,7 @@ INSTALLED_APPS_OUR = [
     'newsletter',
     'contact',
     'isbn',
+    'paypal',
 ]
 
 GETPAID_BACKENDS = (
index 4bb78bc..e7047c0 100644 (file)
@@ -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': '',
+}
index bf95306..0cbaced 100644 (file)
@@ -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'),