PayU payments working.
authorRadek Czajka <rczajka@rczajka.pl>
Tue, 16 Apr 2019 14:49:33 +0000 (16:49 +0200)
committerRadek Czajka <rczajka@rczajka.pl>
Tue, 16 Apr 2019 14:49:33 +0000 (16:49 +0200)
28 files changed:
src/club/admin.py
src/club/forms.py
src/club/helpers.py
src/club/migrations/0002_auto_20190416_1024.py [new file with mode: 0644]
src/club/migrations/0003_remove_payuorder_amount.py [new file with mode: 0644]
src/club/migrations/0004_payucardtoken_pos_id.py [new file with mode: 0644]
src/club/migrations/0005_auto_20190416_1052.py [new file with mode: 0644]
src/club/migrations/0006_auto_20190416_1236.py [new file with mode: 0644]
src/club/migrations/0007_auto_20190416_1625.py [new file with mode: 0644]
src/club/models.py
src/club/payment_methods.py
src/club/payu/__init__.py [new file with mode: 0644]
src/club/payu/forms.py [new file with mode: 0644]
src/club/payu/models.py [new file with mode: 0644]
src/club/payu/pos.py [new file with mode: 0644]
src/club/payu/tests/__init__.py [new file with mode: 0644]
src/club/payu/tests/integration.py [new file with mode: 0644]
src/club/payu/tests/res/first_response_3ds.json [new file with mode: 0644]
src/club/payu/tests/res/first_response_success.json [new file with mode: 0644]
src/club/payu/tests/tests.py [new file with mode: 0644]
src/club/payu/views.py [new file with mode: 0644]
src/club/templates/club/dummy_payment.html
src/club/templates/club/membership_form.html
src/club/templates/payu/rec_payment.html [new file with mode: 0644]
src/club/urls.py
src/club/views.py
src/social/migrations/0008_auto_20190403_1510.py [new file with mode: 0644]
src/wolnelektury/settings/custom.py

index 76cc71e..85b8520 100644 (file)
@@ -9,35 +9,44 @@ class PlanAdmin(admin.ModelAdmin):
 admin.site.register(models.Plan, PlanAdmin)
 
 
-class PaymentInline(admin.TabularInline):
-    model = models.Payment
+class PayUOrderInline(admin.TabularInline):
+    model = models.PayUOrder
     extra = 0
-    readonly_fields = ['payed_at']
+    show_change_link = True
+
+
+class PayUCardTokenInline(admin.TabularInline):
+    model = models.PayUCardToken
+    extra = 0
+    show_change_link = True
 
 
 class ScheduleAdmin(admin.ModelAdmin):
-    list_display = ['email', 'started_at', 'expires_at', 'plan', 'amount', 'is_active', 'is_cancelled']
+    list_display = ['email', 'started_at', 'expires_at', 'plan', 'amount', 'is_cancelled']
     list_search = ['email']
-    list_filter = ['is_active', 'is_cancelled']
+    list_filter = ['is_cancelled']
     date_hierarchy = 'started_at'
     raw_id_fields = ['membership']
-    inlines = [PaymentInline]
+    inlines = [PayUOrderInline, PayUCardTokenInline]
 
 admin.site.register(models.Schedule, ScheduleAdmin)
 
 
-class PaymentAdmin(admin.ModelAdmin):
-    list_display = ['payed_at', 'schedule']
-
-admin.site.register(models.Payment, PaymentAdmin)
-
+class ScheduleInline(admin.TabularInline):
+    model = models.Schedule
+    extra = 0
+    show_change_link = True
 
 class MembershipAdmin(admin.ModelAdmin):
     list_display = ['user']
     raw_id_fields = ['user']
     search_fields = ['user__username', 'user__email']
+    inlines = [ScheduleInline]
 
 admin.site.register(models.Membership, MembershipAdmin)
 
 
 admin.site.register(models.ReminderEmail, TranslationAdmin)
+
+
+admin.site.register(models.PayUNotification)
index c5d5781..bede0cb 100644 (file)
@@ -1,7 +1,7 @@
-# -*- coding: utf-8
 from django import forms
 from . import models
 from .payment_methods import method_by_slug 
+from .payu.forms import CardTokenForm
 
 
 class ScheduleForm(forms.ModelForm):
@@ -13,8 +13,9 @@ class ScheduleForm(forms.ModelForm):
             'method': forms.RadioSelect,
         }
 
-    def __init__(self, *args, **kwargs):
+    def __init__(self, *args, request=None, **kwargs):
         super(ScheduleForm, self).__init__(*args, **kwargs)
+        self.request = request
         self.fields['plan'].empty_label = None
 
     def clean(self):
@@ -26,3 +27,7 @@ class ScheduleForm(forms.ModelForm):
         if cleaned_data['amount'] < cleaned_data['plan'].min_amount:
             self.add_error('amount', 'Minimalna kwota dla tego planu to %d zł.' % cleaned_data['plan'].min_amount)
 
+
+class PayUCardTokenForm(CardTokenForm):
+    def get_queryset(self, view):
+        return view.get_schedule().payucardtoken_set
index b7592fa..4ece5f9 100644 (file)
@@ -5,5 +5,5 @@ from .models import Schedule
 def get_active_schedule(user):
     if not user.is_authenticated:
         return None
-    return Schedule.objects.filter(membership__user=user, is_active=True).exclude(expires_at__lt=now()).first()
+    return Schedule.objects.filter(membership__user=user, expires_at__gt=now()).first()
 
diff --git a/src/club/migrations/0002_auto_20190416_1024.py b/src/club/migrations/0002_auto_20190416_1024.py
new file mode 100644 (file)
index 0000000..9c876ad
--- /dev/null
@@ -0,0 +1,90 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.20 on 2019-04-16 08:24
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('club', '0001_initial'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='PayUCardToken',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('disposable_token', models.CharField(max_length=255, unique=True)),
+                ('reusable_token', models.CharField(blank=True, max_length=255, null=True, unique=True)),
+                ('created_at', models.DateTimeField(auto_now_add=True)),
+            ],
+            options={
+                'abstract': False,
+            },
+        ),
+        migrations.CreateModel(
+            name='PayUNotification',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('body', models.TextField()),
+                ('received_at', models.DateTimeField(auto_now_add=True)),
+            ],
+            options={
+                'abstract': False,
+            },
+        ),
+        migrations.CreateModel(
+            name='PayUOrder',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('pos_id', models.CharField(max_length=255)),
+                ('customer_ip', models.GenericIPAddressField()),
+                ('amount', models.PositiveIntegerField()),
+                ('order_id', models.CharField(blank=True, max_length=255)),
+                ('status', models.CharField(blank=True, choices=[('PENDING', 'Pending'), ('WAITING_FOR_CONFIRMATION', 'Waiting for confirmation'), ('COMPLETED', 'Completed'), ('CANCELED', 'Canceled'), ('REJECTED', 'Rejected')], max_length=128)),
+            ],
+            options={
+                'abstract': False,
+            },
+        ),
+        migrations.RemoveField(
+            model_name='payment',
+            name='schedule',
+        ),
+        migrations.AddField(
+            model_name='membership',
+            name='honorary',
+            field=models.BooleanField(default=False),
+        ),
+        migrations.AddField(
+            model_name='plan',
+            name='active',
+            field=models.BooleanField(default=True, verbose_name='active'),
+        ),
+        migrations.AlterField(
+            model_name='schedule',
+            name='method',
+            field=models.CharField(choices=[('payu-re', 'PayU Recurring'), ('paypal-re', 'PayPal Recurring')], max_length=255, verbose_name='method'),
+        ),
+        migrations.DeleteModel(
+            name='Payment',
+        ),
+        migrations.AddField(
+            model_name='payuorder',
+            name='schedule',
+            field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='club.Schedule'),
+        ),
+        migrations.AddField(
+            model_name='payunotification',
+            name='order',
+            field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='club.PayUOrder'),
+        ),
+        migrations.AddField(
+            model_name='payucardtoken',
+            name='schedule',
+            field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='club.Schedule'),
+        ),
+    ]
diff --git a/src/club/migrations/0003_remove_payuorder_amount.py b/src/club/migrations/0003_remove_payuorder_amount.py
new file mode 100644 (file)
index 0000000..0b39964
--- /dev/null
@@ -0,0 +1,19 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.20 on 2019-04-16 08:45
+from __future__ import unicode_literals
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('club', '0002_auto_20190416_1024'),
+    ]
+
+    operations = [
+        migrations.RemoveField(
+            model_name='payuorder',
+            name='amount',
+        ),
+    ]
diff --git a/src/club/migrations/0004_payucardtoken_pos_id.py b/src/club/migrations/0004_payucardtoken_pos_id.py
new file mode 100644 (file)
index 0000000..f432a0a
--- /dev/null
@@ -0,0 +1,21 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.20 on 2019-04-16 08:50
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('club', '0003_remove_payuorder_amount'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='payucardtoken',
+            name='pos_id',
+            field=models.CharField(default='', max_length=255),
+            preserve_default=False,
+        ),
+    ]
diff --git a/src/club/migrations/0005_auto_20190416_1052.py b/src/club/migrations/0005_auto_20190416_1052.py
new file mode 100644 (file)
index 0000000..111e93c
--- /dev/null
@@ -0,0 +1,29 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.20 on 2019-04-16 08:52
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('club', '0004_payucardtoken_pos_id'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='payucardtoken',
+            name='disposable_token',
+            field=models.CharField(max_length=255),
+        ),
+        migrations.AlterField(
+            model_name='payucardtoken',
+            name='reusable_token',
+            field=models.CharField(blank=True, max_length=255, null=True),
+        ),
+        migrations.AlterUniqueTogether(
+            name='payucardtoken',
+            unique_together=set([('pos_id', 'reusable_token'), ('pos_id', 'disposable_token')]),
+        ),
+    ]
diff --git a/src/club/migrations/0006_auto_20190416_1236.py b/src/club/migrations/0006_auto_20190416_1236.py
new file mode 100644 (file)
index 0000000..c5eaca5
--- /dev/null
@@ -0,0 +1,19 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.20 on 2019-04-16 10:36
+from __future__ import unicode_literals
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('club', '0005_auto_20190416_1052'),
+    ]
+
+    operations = [
+        migrations.AlterUniqueTogether(
+            name='payucardtoken',
+            unique_together=set([]),
+        ),
+    ]
diff --git a/src/club/migrations/0007_auto_20190416_1625.py b/src/club/migrations/0007_auto_20190416_1625.py
new file mode 100644 (file)
index 0000000..9730d44
--- /dev/null
@@ -0,0 +1,30 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.20 on 2019-04-16 14:25
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('club', '0006_auto_20190416_1236'),
+    ]
+
+    operations = [
+        migrations.RemoveField(
+            model_name='schedule',
+            name='is_active',
+        ),
+        migrations.AlterField(
+            model_name='payunotification',
+            name='order',
+            field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notification_set', to='club.PayUOrder'),
+        ),
+        migrations.AlterField(
+            model_name='schedule',
+            name='method',
+            field=models.CharField(choices=[('payu', 'PayU'), ('payu-re', 'PayU Recurring'), ('paypal-re', 'PayPal Recurring')], max_length=255, verbose_name='method'),
+        ),
+    ]
index ede1a8d..f5cbf6e 100644 (file)
@@ -1,14 +1,13 @@
-# -*- coding: utf-8
-from __future__ import unicode_literals
-
-from datetime import timedelta
+from datetime import datetime, timedelta
 from django.conf import settings
+from django.contrib.sites.models import Site
 from django.urls import reverse
 from django.db import models
 from django.utils.timezone import now
-from django.utils.translation import ugettext_lazy as _, ungettext
+from django.utils.translation import ugettext_lazy as _, ungettext, ugettext, get_language
 from catalogue.utils import get_random_hash
 from .payment_methods import methods, method_by_slug
+from .payu import models as payu_models
 
 
 class Plan(models.Model):
@@ -26,6 +25,7 @@ class Plan(models.Model):
     min_amount = models.DecimalField(_('min_amount'), max_digits=10, decimal_places=2)
     allow_recurring = models.BooleanField(_('allow recurring'))
     allow_one_time = models.BooleanField(_('allow one time'))
+    active = models.BooleanField(_('active'), default=True)
 
     class Meta:
         verbose_name = _('plan')
@@ -44,7 +44,7 @@ class Plan(models.Model):
 
     def get_next_installment(self, date):
         if self.interval == self.PERPETUAL:
-            return None
+            return datetime.max - timedelta(1)
         elif self.interval == self.YEAR:
             return date.replace(year=date.year + 1)
         elif self.interval == self.MONTH:
@@ -55,7 +55,6 @@ class Plan(models.Model):
             return date
             
 
-
 class Schedule(models.Model):
     """ Represents someone taking up a plan. """
     key = models.CharField(_('key'), max_length=255, unique=True)
@@ -64,11 +63,9 @@ class Schedule(models.Model):
     plan = models.ForeignKey(Plan, verbose_name=_('plan'), on_delete=models.PROTECT)
     amount = models.DecimalField(_('amount'), max_digits=10, decimal_places=2)
     method = models.CharField(_('method'), max_length=255, choices=[(method.slug, method.name) for method in methods])
-    is_active = models.BooleanField(_('active'), default=False)
     is_cancelled = models.BooleanField(_('cancelled'), default=False)
     started_at = models.DateTimeField(_('started at'), auto_now_add=True)
     expires_at = models.DateTimeField(_('expires_at'), null=True, blank=True)
-    # extra info?
 
     class Meta:
         verbose_name = _('schedule')
@@ -82,6 +79,12 @@ class Schedule(models.Model):
             self.key = get_random_hash(self.email)
         return super(Schedule, self).save(*args, **kwargs)
 
+    def initiate_payment(self, request):
+        return self.get_payment_method().initiate(request, self)
+
+    def pay(self, request):
+        return self.get_payment_method().pay(request, self)
+
     def get_absolute_url(self):
         return reverse('club_schedule', args=[self.key])
 
@@ -89,33 +92,17 @@ class Schedule(models.Model):
         return method_by_slug[self.method]
 
     def is_expired(self):
-        return self.expires_at is not None and self.expires_at < now()
+        return self.expires_at is not None and self.expires_at <= now()
 
-    def create_payment(self):
-        n = now()
-        self.expires_at = self.plan.get_next_installment(n)
-        self.is_active = True
-        self.save()
-        self.payment_set.create(payed_at=n)
-
-
-class Payment(models.Model):
-    schedule = models.ForeignKey(Schedule, verbose_name=_('schedule'), on_delete=models.PROTECT)
-    created_at = models.DateTimeField(_('created at'), auto_now_add=True)
-    payed_at = models.DateTimeField(_('payed at'), null=True, blank=True)
-
-    class Meta:
-        verbose_name = _('payment')
-        verbose_name_plural = _('payments')
-
-    def __str__(self):
-        return "%s %s" % (self.schedule, self.payed_at)
+    def is_active(self):
+        return self.expires_at is not None and self.expires_at > now()
 
 
 class Membership(models.Model):
     """ Represents a user being recognized as a member of the club. """
     user = models.OneToOneField(settings.AUTH_USER_MODEL, verbose_name=_('user'), on_delete=models.CASCADE)
     created_at = models.DateTimeField(_('created at'), auto_now_add=True)
+    honorary = models.BooleanField(default=False)
 
     class Meta:
         verbose_name = _('membership')
@@ -125,13 +112,18 @@ class Membership(models.Model):
         return u'tow. ' + str(self.user)
 
     @classmethod
-    def is_active_for(self, user):
+    def is_active_for(cls, user):
         if user.is_anonymous:
             return False
+        try:
+            membership = user.membership
+        except cls.DoesNotExist:
+            return False
+        if membership.honorary:
+            return True
         return Schedule.objects.filter(
-                models.Q(expires_at=None) | models.Q(expires_at__lt=now()),
+                expires_at__gt=now(),
                 membership__user=user,
-                is_active=True,
             ).exists()
 
 
@@ -151,3 +143,55 @@ class ReminderEmail(models.Model):
         else:
             return ungettext('a day after expiration', '%d days after expiration', n=-self.days_before)
 
+
+########
+#      #
+# PayU #
+#      #
+########
+
+class PayUOrder(payu_models.Order):
+    schedule = models.ForeignKey(Schedule, on_delete=models.CASCADE)
+
+    def get_amount(self):
+        return self.schedule.amount
+
+    def get_buyer(self):
+        return {
+            "email": self.schedule.email,
+            "language": get_language(),
+        }
+
+    def get_continue_url(self):
+        return "https://{}{}".format(
+            Site.objects.get_current().domain,
+            self.schedule.get_absolute_url())
+
+    def get_description(self):
+        return ugettext('Towarzystwo Wolnych Lektur')
+
+    def is_recurring(self):
+        return self.schedule.get_payment_method().is_recurring
+
+    def get_card_token(self):
+        return self.schedule.payucardtoken_set.order_by('-created_at').first()
+
+    def get_notify_url(self):
+        return "https://{}{}".format(
+            Site.objects.get_current().domain,
+            reverse('club_payu_notify', args=[self.pk]))
+
+    def status_updated(self):
+        if self.status == 'COMPLETED':
+            new_exp = self.schedule.plan.get_next_installment(now())
+            if self.schedule.expires_at is None or self.schedule.expires_at < new_exp:
+                self.schedule.expires_at = new_exp
+                self.schedule.save()
+
+
+class PayUCardToken(payu_models.CardToken):
+    schedule = models.ForeignKey(Schedule, on_delete=models.CASCADE)
+
+
+class PayUNotification(payu_models.Notification):
+    order = models.ForeignKey(PayUOrder, models.CASCADE, related_name='notification_set')
index 363d445..29221ee 100644 (file)
@@ -1,11 +1,11 @@
+from django.conf import settings
 from django.urls import reverse
 
 
 class PaymentMethod(object):
     is_recurring = False
 
-    @classmethod
-    def get_payment_url(cls, schedule):
+    def initiate(self, request, schedule):
         return reverse('club_dummy_payment', args=[schedule.key])
 
 
@@ -14,9 +14,18 @@ class PayU(PaymentMethod):
     name = 'PayU'
     template_name = 'club/payment/payu.html'
 
-    @classmethod
-    def get_payment_url(cls, schedule):
-        return reverse('club_dummy_payment', args=[schedule.key])
+    def __init__(self, pos_id):
+        self.pos_id = pos_id
+
+    def initiate(self, request, schedule):
+        # Create Order at once.
+        from .models import PayUOrder
+        order = PayUOrder.objects.create(
+            pos_id=self.pos_id,
+            customer_ip=request.META['REMOTE_ADDR'],
+            schedule=schedule,
+        )
+        return order.put()
 
 
 class PayURe(PaymentMethod):
@@ -25,10 +34,22 @@ class PayURe(PaymentMethod):
     template_name = 'club/payment/payu-re.html'
     is_recurring = True
 
-    @classmethod
-    def get_payment_url(cls, schedule):
-        return reverse('club_dummy_payment', args=[schedule.key])
+    def __init__(self, pos_id):
+        self.pos_id = pos_id
 
+    def initiate(self, request, schedule):
+        return reverse('club_payu_rec_payment', args=[schedule.key])
+
+    def pay(self, request, schedule):
+        # Create order, put it and see what happens next.
+        from .models import PayUOrder
+        order = PayUOrder.objects.create(
+            pos_id=self.pos_id,
+            customer_ip=request.META['REMOTE_ADDR'],
+            schedule=schedule,
+        )
+        return order.put()
+        
 
 class PayPalRe(PaymentMethod):
     slug='paypal-re'
@@ -36,16 +57,29 @@ class PayPalRe(PaymentMethod):
     template_name = 'club/payment/paypal-re.html'
     is_recurring = True
 
-    @classmethod
-    def get_payment_url(cls, schedule):
+    def initiate(self, request, schedule):
         return reverse('club_dummy_payment', args=[schedule.key])
 
 
-methods = [
-    PayU,
-    PayURe,
-    PayPalRe,
-]
+methods = []
+
+pos = getattr(settings, 'CLUB_PAYU_POS', None)
+if pos:
+    payu_method = PayU(pos)
+    methods.append(payu_method)
+else:
+    payu_method = None
+
+pos= getattr(settings, 'CLUB_PAYU_RECURRING_POS', None)
+if pos:
+    payure_method = PayURe(pos)
+    methods.append(payure_method)
+else:
+    payure_method = None
+
+
+methods.append(PayPalRe())
+
 
 method_by_slug = {
     m.slug: m
diff --git a/src/club/payu/__init__.py b/src/club/payu/__init__.py
new file mode 100644 (file)
index 0000000..b59b58e
--- /dev/null
@@ -0,0 +1,8 @@
+from django.conf import settings
+from .pos import POS
+
+
+POSS = {
+    k: POS(k, **v)
+    for (k, v) in settings.PAYU_POS.items()
+}
diff --git a/src/club/payu/forms.py b/src/club/payu/forms.py
new file mode 100644 (file)
index 0000000..24c24a0
--- /dev/null
@@ -0,0 +1,14 @@
+from django import forms
+
+
+class CardTokenForm(forms.Form):
+    token = forms.CharField(widget=forms.HiddenInput)
+
+    def get_queryset(self, view):
+        raise NotImplementedError()
+
+    def save(self, view):
+        self.instance, created = self.get_queryset(view).get_or_create(
+            pos_id=view.get_pos().pos_id,
+            disposable_token=self.cleaned_data['token']
+        )
diff --git a/src/club/payu/models.py b/src/club/payu/models.py
new file mode 100644 (file)
index 0000000..4ab2c0a
--- /dev/null
@@ -0,0 +1,142 @@
+import json
+from urllib.parse import urlencode
+from urllib.request import HTTPError
+from django.contrib.sites.models import Site
+from django.db import models
+from django.urls import reverse
+from . import POSS
+
+
+class CardToken(models.Model):
+    """ This should be attached to a payment schedule. """
+    pos_id = models.CharField(max_length=255)
+    disposable_token = models.CharField(max_length=255)
+    reusable_token = models.CharField(max_length=255, null=True, blank=True)
+    created_at = models.DateTimeField(auto_now_add=True)
+
+    class Meta:
+        abstract = True
+
+
+class Order(models.Model):
+    pos_id = models.CharField(max_length=255)   # TODO: redundant?
+    customer_ip = models.GenericIPAddressField()
+    order_id = models.CharField(max_length=255, blank=True)
+
+    status = models.CharField(max_length=128, blank=True, choices=[
+        ('PENDING', 'Pending'),
+        ('WAITING_FOR_CONFIRMATION', 'Waiting for confirmation'),
+        ('COMPLETED', 'Completed'),
+        ('CANCELED', 'Canceled'),
+        ('REJECTED', 'Rejected'),
+    ])
+
+    class Meta:
+        abstract = True
+
+    # These need to be provided in a subclass.
+
+    def get_amount(self):
+        raise NotImplementedError()
+
+    def get_buyer(self):
+        raise NotImplementedError()
+
+    def get_continue_url(self):
+        raise NotImplementedError()
+    
+    def get_description(self):
+        raise NotImplementedError()
+
+    def get_products(self):
+        """ At least: name, unitPrice, quantity. """
+        return [
+            {
+                'name': self.get_description(),
+                'unitPrice': str(int(self.get_amount() * 100)),
+                'quantity': '1',
+            },
+        ]
+
+    def is_recurring(self):
+        return False
+
+    def get_notify_url(self):
+        raise NotImplementedError
+
+    def status_updated(self):
+        pass
+
+    # 
+    
+    def get_pos(self):
+        return POSS[self.pos_id]
+
+    def get_representation(self, token=None):
+        rep = {
+            "notifyUrl": self.get_notify_url(),
+            "customerIp": self.customer_ip,
+            "merchantPosId": self.pos_id,
+            "currencyCode": self.get_pos().currency_code,
+            "totalAmount": str(int(self.get_amount() * 100)),
+            "extOrderId": "wolne-lektury-rcz-%d" % self.pk,
+
+            "buyer": self.get_buyer() or {},
+            "continueUrl": self.get_continue_url(),
+            "description": self.get_description(),
+            "products": self.get_products(),
+        }
+        if token:
+            token = self.get_card_token()
+            rep['recurring'] = 'STANDARD' if token.reusable_token else 'FIRST'
+            rep['payMethods'] = {
+                "payMethod": {
+                    "type": "CARD_TOKEN",
+                    "value": token.reusable_token or token.disposable_token
+                }
+            }
+        return rep
+
+    def put(self):
+        token = self.get_card_token() if self.is_recurring() else None
+        representation = self.get_representation(token)
+        try:
+            response = self.get_pos().request('POST', 'orders', representation)
+        except HTTPError as e:
+            resp = json.loads(e.read().decode('utf-8'))
+            if resp['status']['statusCode'] == 'ERROR_ORDER_NOT_UNIQUE':
+                pass
+            else:
+                raise
+
+        if token:
+            reusable_token = response.get('payMethods', {}).get('payMethod', {}).get('value', None)
+            if reusable_token:
+                token.reusable_token = reusable_token
+                token.save()
+            # else?
+
+        self.order_id = response['orderId']
+        self.save()
+
+        
+        return response.get('redirectUri', self.schedule.get_absolute_url())
+
+
+class Notification(models.Model):
+    """ Add `order` FK to real Order model. """
+    body = models.TextField()
+    received_at = models.DateTimeField(auto_now_add=True)
+
+    class Meta:
+        abstract = True
+
+    def get_status(self):
+        return json.loads(self.body)['order']['status']
+
+    def apply(self):
+        status = self.get_status()
+        if self.order.status not in (status, 'COMPLETED'):
+            self.order.status = status
+            self.order.save()
+            self.order.status_updated()
diff --git a/src/club/payu/pos.py b/src/club/payu/pos.py
new file mode 100644 (file)
index 0000000..c9dca99
--- /dev/null
@@ -0,0 +1,47 @@
+import requests
+
+
+class POS:
+    ACCESS_TOKEN_URL = '/pl/standard/user/oauth/authorize'
+    API_BASE = '/api/v2_1/'
+
+    def __init__(self, pos_id, client_secret, secondary_key, sandbox=False, currency_code='PLN'):
+        self.pos_id = pos_id
+        self.client_secret = client_secret
+        self.secondary_key = secondary_key
+        self.sandbox = sandbox
+        self.currency_code = currency_code
+
+    def get_api_host(self):
+        if self.sandbox:
+            return 'https://secure.snd.payu.com'
+        else:
+            return 'https://secure.payu.com'
+
+    def get_access_token(self):
+        response = requests.post(
+            self.get_api_host() + self.ACCESS_TOKEN_URL,
+            data={
+                'grant_type': 'client_credentials',
+                'client_id': self.pos_id,
+                'client_secret': self.client_secret
+            },
+        )
+        assert response.status_code == 200
+        data = response.json()
+        assert data['token_type'] == 'bearer'
+        assert data['grant_type'] == 'client_credentials'
+        return data['access_token']
+
+    def request(self, method, url, data):
+        access_token = self.get_access_token()
+
+        full_url = self.get_api_host() + self.API_BASE + url
+        response = requests.request(
+            method, full_url, json=data, headers={
+                'Authorization': 'Bearer ' + access_token,
+            },
+            allow_redirects=False
+        )
+        return response.json()
+
diff --git a/src/club/payu/tests/__init__.py b/src/club/payu/tests/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src/club/payu/tests/integration.py b/src/club/payu/tests/integration.py
new file mode 100644 (file)
index 0000000..3619b90
--- /dev/null
@@ -0,0 +1,20 @@
+from django.contrib.staticfiles.testing import StaticLiveServerTestCase
+from selenium.webdriver.firefox.webdriver import WebDriver
+
+
+class SandboxTestCase(StaticLiveServerTestCase):
+    @classmethod
+    def setUpClass(cls):
+        super().setUpClass()
+        cls.selenium = WebDriver()
+        cls.selenium.implicitly_wait(10)
+
+    @classmethod
+    def tearDownClass(cls):
+        cls.selenium.quit()
+        super().tearDownClass()
+
+    def test_payment(self):
+        self.selenium.get('%s%s' % (self.live_server_url, '/towarzystwo/'))
+        from time import sleep
+        sleep(10)
diff --git a/src/club/payu/tests/res/first_response_3ds.json b/src/club/payu/tests/res/first_response_3ds.json
new file mode 100644 (file)
index 0000000..64d3e0e
--- /dev/null
@@ -0,0 +1,8 @@
+{
+     "orderId": "ORDER_ID",
+     "status": {
+         "statusCode": "WARNING_CONTINUE_3DS",
+         "severity": "WARNING"
+     },
+     "redirectUri": "{redirectUri}"
+}
diff --git a/src/club/payu/tests/res/first_response_success.json b/src/club/payu/tests/res/first_response_success.json
new file mode 100644 (file)
index 0000000..324579f
--- /dev/null
@@ -0,0 +1,18 @@
+{
+     "orderId": "ORDER_ID",
+     "payMethods": {
+         "payMethod": {
+              "card": {
+                   "number": "424242******4242",
+                   "expirationMonth": "12",
+                   "expirationYear": "2017"
+               },
+               "type": "CARD_TOKEN",
+               "value": "TOKC_KPNZVSLJUNR4DHF5NPVKDPJGMX7"
+           }
+       },
+       "status": {
+           "statusCode": "SUCCESS",
+           "statusDesc": "Request successful"
+       }
+}
diff --git a/src/club/payu/tests/tests.py b/src/club/payu/tests/tests.py
new file mode 100644 (file)
index 0000000..bcfd951
--- /dev/null
@@ -0,0 +1,17 @@
+# 1. idziemy do płatności, wyświetlamy widget, klikamy w wydżet
+
+# (pierwsza płatność) OrderCreateRequest z tokenem jednorazowym
+#  < możliwa odpowiedź: WARNING_CONTINUE_3DS ([status]status_co
+#  < token wielorazowy
+#  < błąd http
+#  < nieczytelna odpowiedź
+
+# OrderCreateRequest z tokenem wielorazowym
+
+
+
+# integration tests:
+
+# (run on payu sandbox)
+# open browser, display widget, input data, run widget, fetch token
+# 
diff --git a/src/club/payu/views.py b/src/club/payu/views.py
new file mode 100644 (file)
index 0000000..f84bf76
--- /dev/null
@@ -0,0 +1,79 @@
+from hashlib import md5, sha256
+from django.conf import settings
+from django import http
+from django.shortcuts import get_object_or_404
+from django.utils.decorators import method_decorator
+from django.utils.translation import get_language
+from django.views.decorators.csrf import csrf_exempt
+from django.views.generic import FormView, TemplateView, View
+
+
+class Payment(TemplateView):
+    pass
+
+
+class RecPayment(FormView):
+    """ Set form_class to a CardTokenForm. """
+    template_name = 'payu/rec_payment.html'
+
+    def get_context_data(self, *args, **kwargs):
+        ctx = super().get_context_data(*args, **kwargs)
+
+        schedule = self.get_schedule()
+        pos = self.get_pos()
+
+        widget_args = {
+            'merchant-pos-id': pos.pos_id,
+            'shop-name': "SHOW NAME",
+            'total-amount': str(int(schedule.amount * 100)),
+            'currency-code': pos.currency_code,
+            'customer-language': get_language(), # filter to pos.languages
+            'customer-email': schedule.email,
+            'store-card': 'true',
+            'recurring-payment': 'true',
+        }
+        widget_sig = sha256(
+            (
+                "".join(v for (k, v) in sorted(widget_args.items())) +
+                pos.secondary_key
+            ).encode('utf-8')
+        ).hexdigest()
+
+        ctx['widget_args'] = widget_args
+        ctx['widget_sig'] = widget_sig
+        ctx['schedule'] = schedule
+        ctx['pos'] = pos
+        return ctx
+
+    def form_valid(self, form):
+        form.save(self)
+        return super().form_valid(form)
+
+
+
+@method_decorator(csrf_exempt, name='dispatch')
+class NotifyView(View):
+    """ Set `order_model` in subclass. """
+    def post(self, request, pk):
+        order = get_object_or_404(self.order_model, pk=pk)
+
+        try:
+            openpayu = request.META['HTTP_OPENPAYU_SIGNATURE']
+            openpayu = dict(term.split('=') for term in openpayu.split(';'))
+            assert openpayu['algorithm'] == 'MD5'
+            assert openpayu['content'] == 'DOCUMENT'
+            assert openpayu['sender'] == 'checkout'
+            sig = openpayu['signature']
+        except (KeyError, ValueError, AssertionError):
+            return http.HttpResponseBadRequest('bad')
+
+        document = request.body + order.get_pos().secondary_key.encode('latin1')
+        if md5(document).hexdigest() != sig:
+            return http.HttpResponseBadRequest('wrong')
+
+        notification = order.notification_set.create(
+            body=request.body
+        )
+        notification.apply()
+
+        return http.HttpResponse('ok')
index a24e603..2809c5e 100644 (file)
@@ -1,4 +1,4 @@
-{% extends "base/base.html" %}
+{% extends request.session.from_app|yesno:"base/app.html,base/base.html" %}
 
 
 {% block titleextra %}Towarzystwo Wolnych Lektur{% endblock %}
        <p>     {{ schedule.amount }}</p>
        <p>     {{ schedule.plan.get_interval_display }}</p>
 
-<form method="POST" action="">
+<!--form method="POST" action="">
   {% csrf_token %}
+
   {{ form.as_p }}
   <button type='submit'>Zapłać</button>
+</form-->
+
+
+{% if request.GET.p == 'inline' %}
+<div id="payu-widget"></div>
+{% else %}
+{% if request.GET.p == 'popup' %}
+<form action="http://exampledomain.com/processOrder.php" method="post">
+    <button id="pay-button">Pay now</button>
 </form>
+<script
+    src="https://secure.payu.com/front/widget/js/payu-bootstrap.js"
+    pay-button="#pay-button"
+    merchant-pos-id="145227"
+    shop-name="Nazwa sklepu"
+    total-amount="9.99"
+    currency-code="PLN"
+    customer-language="pl"
+    store-card="true"
+    customer-email="email@exampledomain.com"
+    sig="250f5f53e465777b6fefb04f171a21b598ccceb2899fc9f229604ad529c69532">
+</script>
+
+
+{% else %}
+ <form method="POST" action="">
+  {% csrf_token %}
+
+  {{ form.as_p }}
+  <button type='submit'>Zapłać</button>
+</form>
+{% endif %}
+{% endif %}
 
 </div>
 
+<script
+    src="https://secure.payu.com/front/widget/js/payu-bootstrap.js"
+    merchant-pos-id="145227"
+    shop-name="TEST"
+    total-amount="12345"
+    currency-code="PLN"
+    customer-language="en"
+    store-card="true"
+    payu-brand="false"
+    success-callback="test"
+    widget-mode="use"
+    customer-email="test@test.com"
+    sig="203ec8c4b9571ce6b4c03058f57264f04d06d00a86da19390d47ba1be4551578"
+</script>
+
 {% endblock %}
index d0c3250..db2a8ed 100644 (file)
@@ -1,4 +1,4 @@
-{% extends "base/base.html" %}
+{% extends request.session.from_app|yesno:"base/app.html,base/base.html" %}
 
 
 {% block titleextra %}Towarzystwo Wolnych Lektur{% endblock %}
diff --git a/src/club/templates/payu/rec_payment.html b/src/club/templates/payu/rec_payment.html
new file mode 100644 (file)
index 0000000..56425e4
--- /dev/null
@@ -0,0 +1,44 @@
+{% extends request.session.from_app|yesno:"base/app.html,base/base.html" %}
+
+
+{% block titleextra %}Towarzystwo Wolnych Lektur{% endblock %}
+
+
+{% block body %}
+<div class="white-box normal-text">
+
+       <h1>Płatność cykliczna przez PayU</h1>
+
+       <p>     {{ schedule.email }}</p>
+       <p>     {{ schedule.amount }}</p>
+       <p>     {{ schedule.plan.get_interval_display }}</p>
+
+       <form id="theform" method='POST'>
+               {% csrf_token %}
+               {{ form }}
+       </form>
+
+
+
+<script>
+       function yeahwellwhatever(data) {
+               $("#theform #id_token").val(data.value);
+               $("#theform").submit()
+       }
+</script>
+
+<div id="payu-widget"></div>
+<script
+     src="{{ pos.get_api_host }}/front/widget/js/payu-bootstrap.js"
+
+    {% for k, v in widget_args.items %}
+       {{ k }}="{{ v }}"
+    {% endfor %}
+
+    success-callback="yeahwellwhatever"
+    sig="{{ widget_sig }}">
+</script>
+
+</div>
+
+{% endblock %}
index dcefc22..f489661 100644 (file)
@@ -5,11 +5,15 @@ from . import views
 urlpatterns = [
     url(r'^$', views.ClubView.as_view()),
     url(r'^dolacz/$', views.JoinView.as_view(), name='club_join'),
-    url(r'^dolacz/app/$', views.AppJoinView.as_view(), name='club_join'),
 
     url(r'^plan/(?P<key>[-a-z0-9]+)/$', views.ScheduleView.as_view(), name='club_schedule'),
     url(r'^przylacz/(?P<key>[-a-z0-9]+)/$', views.claim, name='club_claim'),
     url(r'^anuluj/(?P<key>[-a-z0-9]+)/$', views.cancel, name='club_cancel'),
-
     url(r'^testowa-platnosc/(?P<key>[-a-z0-9]+)/$', views.DummyPaymentView.as_view(), name='club_dummy_payment'),
+
+    url(r'platnosc/payu/cykl/(?P<key>.+)/', views.PayURecPayment.as_view(), name='club_payu_rec_payment'),
+    url(r'platnosc/payu/(?P<key>.+)/', views.PayUPayment.as_view(), name='club_payu_payment'),
+
+    url(r'notify/(?P<pk>\d+)/', views.PayUNotifyView.as_view(), name='club_payu_notify'),
+
 ]
index 1393b96..af59b1e 100644 (file)
@@ -1,11 +1,14 @@
 from django.contrib.auth.decorators import login_required
 from django.http import HttpResponseRedirect
-from django.shortcuts import render
-from django.views.generic import FormView, CreateView, TemplateView
+from django.shortcuts import get_object_or_404, render
+from django.views.generic import FormView, CreateView, TemplateView, DetailView
 from django.views import View
-from .forms import ScheduleForm
+from .payu import POSS
+from .payu import views as payu_views
+from .forms import ScheduleForm, PayUCardTokenForm
 from . import models
 from .helpers import get_active_schedule
+from .payment_methods import payure_method
 
 
 class ClubView(TemplateView):
@@ -16,13 +19,25 @@ class JoinView(CreateView):
     form_class = ScheduleForm
     template_name = 'club/membership_form.html'
 
+    def is_app(self):
+        return self.request.GET.get('app')
+
     def get(self, request):
+        if self.is_app():
+            request.session['from_app'] = True
+        elif request.session and 'from_app' in request.session:
+            del request.session['from_app']
         schedule = get_active_schedule(request.user)
         if schedule is not None:
             return HttpResponseRedirect(schedule.get_absolute_url())
         else:
             return super(JoinView, self).get(request)
 
+    def get_form_kwargs(self):
+        kwargs = super().get_form_kwargs()
+        kwargs['request'] = self.request
+        return kwargs
+
     def get_context_data(self, form=None):
         c = super(JoinView, self).get_context_data()
         c['membership'] = getattr(self.request.user, 'membership', None)
@@ -41,24 +56,21 @@ class JoinView(CreateView):
             form.instance.save()
         return retval
 
+    def get_success_url(self):
+        return self.object.initiate_payment(self.request)
 
-class AppJoinView(JoinView):
-    template_name = 'club/membership_form_app.html'
 
+class ScheduleView(DetailView):
+    model = models.Schedule
+    slug_field = slug_url_kwarg = 'key'
+    template_name = 'club/schedule.html'
 
-class ScheduleView(View):
-    def get(self, request, key):
-        schedule = models.Schedule.objects.get(key=key)
-        if not schedule.is_active:
-            return HttpResponseRedirect(schedule.get_payment_method().get_payment_url(schedule))
+    def post(self, request, key):
+        schedule = self.get_object()
+        if not schedule.is_active():
+            return HttpResponseRedirect(schedule.initiate_payment(request))
         else:
-            return render(
-                request,
-                'club/schedule.html',
-                {
-                    'schedule': schedule,
-                }
-            )
+            return HttpResponseRedirect(schedule.get_absolute_url())
 
 
 @login_required
@@ -88,3 +100,26 @@ class DummyPaymentView(TemplateView):
         schedule = models.Schedule.objects.get(key=key)
         schedule.create_payment()
         return HttpResponseRedirect(schedule.get_absolute_url())
+
+
+class PayUPayment(payu_views.Payment):
+    pass
+
+
+class PayURecPayment(payu_views.RecPayment):
+    form_class = PayUCardTokenForm
+
+    def get_schedule(self):
+        return get_object_or_404(models.Schedule, key=self.kwargs['key'])
+
+    def get_pos(self):
+        pos_id = payure_method.pos_id
+        return POSS[pos_id]
+
+    def get_success_url(self):
+        return self.get_schedule().pay(self.request)
+
+
+class PayUNotifyView(payu_views.NotifyView):
+    order_model = models.PayUOrder
+
diff --git a/src/social/migrations/0008_auto_20190403_1510.py b/src/social/migrations/0008_auto_20190403_1510.py
new file mode 100644 (file)
index 0000000..6398316
--- /dev/null
@@ -0,0 +1,19 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.20 on 2019-04-03 13:10
+from __future__ import unicode_literals
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('social', '0007_auto_20190318_1339'),
+    ]
+
+    operations = [
+        migrations.AlterModelOptions(
+            name='cite',
+            options={'ordering': ('vip', 'text'), 'verbose_name': 'banner', 'verbose_name_plural': 'banners'},
+        ),
+    ]
index 08ad482..ab4719e 100644 (file)
@@ -30,3 +30,15 @@ CATALOGUE_MIN_INITIALS = 60
 PICTURE_PAGE_SIZE = 20
 
 CONTACT_FORMS_MODULE = 'wolnelektury.contact_forms'
+
+PAYU_POS = {
+    '300746': {
+        'client_secret': '2ee86a66e5d97e3fadc400c9f19b065d',
+        'secondary_key': 'b6ca15b0d1020e8094d9b5f8d163db54',
+        'sandbox': True,
+    },
+}
+
+CLUB_PAYU_POS = '300746'
+CLUB_PAYU_RECURRING_POS = '300746'
+