From ca336bd1f9658cf713681d1412d4153e5c4d9c93 Mon Sep 17 00:00:00 2001 From: Radek Czajka Date: Tue, 16 Apr 2019 16:49:33 +0200 Subject: [PATCH] PayU payments working. --- src/club/admin.py | 31 ++-- src/club/forms.py | 9 +- src/club/helpers.py | 2 +- .../migrations/0002_auto_20190416_1024.py | 90 +++++++++++ .../0003_remove_payuorder_amount.py | 19 +++ .../migrations/0004_payucardtoken_pos_id.py | 21 +++ .../migrations/0005_auto_20190416_1052.py | 29 ++++ .../migrations/0006_auto_20190416_1236.py | 19 +++ .../migrations/0007_auto_20190416_1625.py | 30 ++++ src/club/models.py | 108 +++++++++---- src/club/payment_methods.py | 64 ++++++-- src/club/payu/__init__.py | 8 + src/club/payu/forms.py | 14 ++ src/club/payu/models.py | 142 ++++++++++++++++++ src/club/payu/pos.py | 47 ++++++ src/club/payu/tests/__init__.py | 0 src/club/payu/tests/integration.py | 20 +++ .../payu/tests/res/first_response_3ds.json | 8 + .../tests/res/first_response_success.json | 18 +++ src/club/payu/tests/tests.py | 17 +++ src/club/payu/views.py | 79 ++++++++++ src/club/templates/club/dummy_payment.html | 52 ++++++- src/club/templates/club/membership_form.html | 2 +- src/club/templates/payu/rec_payment.html | 44 ++++++ src/club/urls.py | 8 +- src/club/views.py | 69 ++++++--- .../migrations/0008_auto_20190403_1510.py | 19 +++ src/wolnelektury/settings/custom.py | 12 ++ 28 files changed, 898 insertions(+), 83 deletions(-) create mode 100644 src/club/migrations/0002_auto_20190416_1024.py create mode 100644 src/club/migrations/0003_remove_payuorder_amount.py create mode 100644 src/club/migrations/0004_payucardtoken_pos_id.py create mode 100644 src/club/migrations/0005_auto_20190416_1052.py create mode 100644 src/club/migrations/0006_auto_20190416_1236.py create mode 100644 src/club/migrations/0007_auto_20190416_1625.py create mode 100644 src/club/payu/__init__.py create mode 100644 src/club/payu/forms.py create mode 100644 src/club/payu/models.py create mode 100644 src/club/payu/pos.py create mode 100644 src/club/payu/tests/__init__.py create mode 100644 src/club/payu/tests/integration.py create mode 100644 src/club/payu/tests/res/first_response_3ds.json create mode 100644 src/club/payu/tests/res/first_response_success.json create mode 100644 src/club/payu/tests/tests.py create mode 100644 src/club/payu/views.py create mode 100644 src/club/templates/payu/rec_payment.html create mode 100644 src/social/migrations/0008_auto_20190403_1510.py diff --git a/src/club/admin.py b/src/club/admin.py index 76cc71ed0..85b85201f 100644 --- a/src/club/admin.py +++ b/src/club/admin.py @@ -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) diff --git a/src/club/forms.py b/src/club/forms.py index c5d57819e..bede0cb62 100644 --- a/src/club/forms.py +++ b/src/club/forms.py @@ -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 diff --git a/src/club/helpers.py b/src/club/helpers.py index b7592fadd..4ece5f9ab 100644 --- a/src/club/helpers.py +++ b/src/club/helpers.py @@ -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 index 000000000..9c876ad2a --- /dev/null +++ b/src/club/migrations/0002_auto_20190416_1024.py @@ -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 index 000000000..0b3996401 --- /dev/null +++ b/src/club/migrations/0003_remove_payuorder_amount.py @@ -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 index 000000000..f432a0a2e --- /dev/null +++ b/src/club/migrations/0004_payucardtoken_pos_id.py @@ -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 index 000000000..111e93ce0 --- /dev/null +++ b/src/club/migrations/0005_auto_20190416_1052.py @@ -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 index 000000000..c5eaca569 --- /dev/null +++ b/src/club/migrations/0006_auto_20190416_1236.py @@ -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 index 000000000..9730d441e --- /dev/null +++ b/src/club/migrations/0007_auto_20190416_1625.py @@ -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'), + ), + ] diff --git a/src/club/models.py b/src/club/models.py index ede1a8d30..f5cbf6e7f 100644 --- a/src/club/models.py +++ b/src/club/models.py @@ -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') diff --git a/src/club/payment_methods.py b/src/club/payment_methods.py index 363d4451c..29221ee6d 100644 --- a/src/club/payment_methods.py +++ b/src/club/payment_methods.py @@ -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 index 000000000..b59b58ef4 --- /dev/null +++ b/src/club/payu/__init__.py @@ -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 index 000000000..24c24a0ad --- /dev/null +++ b/src/club/payu/forms.py @@ -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 index 000000000..4ab2c0aa2 --- /dev/null +++ b/src/club/payu/models.py @@ -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 index 000000000..c9dca99e7 --- /dev/null +++ b/src/club/payu/pos.py @@ -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 index 000000000..e69de29bb diff --git a/src/club/payu/tests/integration.py b/src/club/payu/tests/integration.py new file mode 100644 index 000000000..3619b9083 --- /dev/null +++ b/src/club/payu/tests/integration.py @@ -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 index 000000000..64d3e0e6c --- /dev/null +++ b/src/club/payu/tests/res/first_response_3ds.json @@ -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 index 000000000..324579f87 --- /dev/null +++ b/src/club/payu/tests/res/first_response_success.json @@ -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 index 000000000..bcfd95158 --- /dev/null +++ b/src/club/payu/tests/tests.py @@ -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 index 000000000..f84bf7656 --- /dev/null +++ b/src/club/payu/views.py @@ -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') diff --git a/src/club/templates/club/dummy_payment.html b/src/club/templates/club/dummy_payment.html index a24e603a6..2809c5e1a 100644 --- a/src/club/templates/club/dummy_payment.html +++ b/src/club/templates/club/dummy_payment.html @@ -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 %} @@ -13,12 +13,60 @@

{{ schedule.amount }}

{{ schedule.plan.get_interval_display }}

-
+ + + +{% if request.GET.p == 'inline' %} +
+{% else %} +{% if request.GET.p == 'popup' %} + +
+ + + +{% else %} +
+ {% csrf_token %} + + {{ form.as_p }} + +
+{% endif %} +{% endif %} + + +
+ + + + +{% endblock %} diff --git a/src/club/urls.py b/src/club/urls.py index dcefc222d..f48966173 100644 --- a/src/club/urls.py +++ b/src/club/urls.py @@ -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[-a-z0-9]+)/$', views.ScheduleView.as_view(), name='club_schedule'), url(r'^przylacz/(?P[-a-z0-9]+)/$', views.claim, name='club_claim'), url(r'^anuluj/(?P[-a-z0-9]+)/$', views.cancel, name='club_cancel'), - url(r'^testowa-platnosc/(?P[-a-z0-9]+)/$', views.DummyPaymentView.as_view(), name='club_dummy_payment'), + + url(r'platnosc/payu/cykl/(?P.+)/', views.PayURecPayment.as_view(), name='club_payu_rec_payment'), + url(r'platnosc/payu/(?P.+)/', views.PayUPayment.as_view(), name='club_payu_payment'), + + url(r'notify/(?P\d+)/', views.PayUNotifyView.as_view(), name='club_payu_notify'), + ] diff --git a/src/club/views.py b/src/club/views.py index 1393b9693..af59b1e9a 100644 --- a/src/club/views.py +++ b/src/club/views.py @@ -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 index 000000000..639831672 --- /dev/null +++ b/src/social/migrations/0008_auto_20190403_1510.py @@ -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'}, + ), + ] diff --git a/src/wolnelektury/settings/custom.py b/src/wolnelektury/settings/custom.py index 08ad482b3..ab4719e5a 100644 --- a/src/wolnelektury/settings/custom.py +++ b/src/wolnelektury/settings/custom.py @@ -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' + -- 2.20.1