From 2b1f24e8b115f6785c65bc4a7329896eff9fc683 Mon Sep 17 00:00:00 2001 From: Anders Hammarquist Date: Sat, 8 Feb 2020 04:27:59 +0100 Subject: [PATCH 1/3] Update Stripe integration for PCA --- conference/cart.py | 75 +++++++------ .../migrations/0019_add_stripe_session_id.py | 20 ++++ conference/models.py | 1 + conference/payments.py | 100 ++++++++++++++++++ templates/ep19/bs/cart/_order_summary.html | 2 + templates/ep19/bs/cart/step_4_payment.html | 44 ++++---- 6 files changed, 189 insertions(+), 53 deletions(-) create mode 100644 conference/migrations/0019_add_stripe_session_id.py diff --git a/conference/cart.py b/conference/cart.py index af8826fe8..9475033a8 100644 --- a/conference/cart.py +++ b/conference/cart.py @@ -33,7 +33,7 @@ is_business_order, is_non_conference_ticket_order, ) -from .payments import PaymentError, charge_for_payment +from .payments import PaymentError, prepare_for_payment, verify_payment GLOBAL_MAX_PER_FARE_TYPE = 6 @@ -164,56 +164,64 @@ def cart_step4_payment(request, order_uuid): return redirect( "cart:step5_congrats_order_complete", order_uuid=order.uuid ) + else: + XXX - stripe_payment = StripePayment.objects.create( - order=order, - user=request.user, - uuid=str(uuid.uuid4()), - amount=order.total(), - token=request.POST.get("stripeToken"), - token_type=request.POST.get("stripeTokenType"), - email=request.POST.get("stripeEmail"), - description=f"payment for order {order.pk} by {request.user.email}", - ) - try: - # Save the payment information as soon as it goes through to - # avoid data loss. - charge_for_payment(stripe_payment) - order.payment_date = timezone.now() - order.save() - - with transaction.atomic(): - create_invoices_for_order(order) - current_site = get_current_site(request) - send_order_confirmation_email(order, current_site) - - return redirect( - "cart:step5_congrats_order_complete", order_uuid=order.uuid - ) - except PaymentError: - # Redirect to the same page, show information about failed - # payment(s) and reshow the same Pay with Card button - return redirect(".") # sanity/security check to make sure we don't publish the the wrong key stripe_key = settings.STRIPE_PUBLISHABLE_KEY assert stripe_key.startswith("pk_") + stripe_session_id = None + if total_for_stripe > 0: + stripe_session = prepare_for_payment( + request, + order = order, + description = f"payment for order {order.pk} by {request.user.email}" + ) + stripe_session_id = stripe_session.id return TemplateResponse( request, "ep19/bs/cart/step_4_payment.html", { "order": order, - "payments": payments, + "payment": payments, "stripe_key": stripe_key, "total_for_stripe": total_for_stripe, + "stripe_session_id": stripe_session_id, }, ) +@login_required +def cart_step4b_verify_payment(request, payment_uuid, session_id): + payment = get_object_or_404(StripePayment, uuid=payment_uuid) + order = payment.order + if payment.status != 'SUCCESSFUL': + try: + verify_payment(payment, session_id) + except PaymentError: + return redirect("cart:step4_payment", order_uuid=order.uuid) + + if order.payment_date != None: + # XXX This order was already paid for(!) + payment.message = 'Duplicate payment?!?' + payment.save() + else: + order.payment_date = timezone.now() + order.save() + + with transaction.atomic(): + create_invoices_for_order(order) + current_site = get_current_site(request) + send_order_confirmation_email(order, current_site) + + return redirect("cart:step5_congrats_order_complete", order.uuid) + @login_required def cart_step5_congrats_order_complete(request, order_uuid): order = get_object_or_404(Order, uuid=order_uuid) + return TemplateResponse( request, "ep19/bs/cart/step_5_congrats_order_complete.html", @@ -351,6 +359,11 @@ class Meta: cart_step4_payment, name="step4_payment", ), + url( + r"^verify/(?P[\w-]+)/(?P[-_\w{}]+)/$", + cart_step4b_verify_payment, + name="step4b_verify_payment", + ), url( r"^thanks/(?P[\w-]+)/$", cart_step5_congrats_order_complete, diff --git a/conference/migrations/0019_add_stripe_session_id.py b/conference/migrations/0019_add_stripe_session_id.py new file mode 100644 index 000000000..1324dd60e --- /dev/null +++ b/conference/migrations/0019_add_stripe_session_id.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +import conference.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('conference', '0018_remove_unused_models'), + ] + + operations = [ + migrations.AddField( + model_name='stripepayment', + name='session_id', + field=models.CharField(max_length=100, null=True), + ), + ] diff --git a/conference/models.py b/conference/models.py index 669f31588..4d5c96bd8 100644 --- a/conference/models.py +++ b/conference/models.py @@ -1351,6 +1351,7 @@ class StripePayment(models.Model): token = models.CharField(max_length=100) token_type = models.CharField(max_length=20) charge_id = models.CharField(max_length=100, null=True) + session_id = models.CharField(max_length=100, null=True) email = models.CharField(max_length=255) user = models.ForeignKey(get_user_model()) diff --git a/conference/payments.py b/conference/payments.py index af26203d3..d325d16a5 100644 --- a/conference/payments.py +++ b/conference/payments.py @@ -1,7 +1,9 @@ import stripe +import uuid from django.conf import settings +from django.core.urlresolvers import reverse from conference.models import StripePayment @@ -13,6 +15,104 @@ class PaymentError(Exception): pass +def prepare_for_payment(request, order, description): + stripe.api_key = settings.STRIPE_SECRET_KEY + + try: + stripe_payment = StripePayment.objects.create( + order=order, + user=request.user, + uuid=str(uuid.uuid4()), + amount=order.total(), + email=request.user.email, + description=f"payment for order {order.code} ({order.pk}) by {request.user.email}", + ) + + success_url = request.build_absolute_uri( + reverse('cart:step4b_verify_payment', + kwargs={'payment_uuid': stripe_payment.uuid, + 'session_id': 'CHECKOUT_SESSION_ID' + } + )) + # XXX {} must not be url-encoded + success_url = success_url.replace('CHECKOUT_SESSION_ID', '{CHECKOUT_SESSION_ID}') + + cancel_url = request.build_absolute_uri( + reverse('cart:step4_payment', + kwargs={'order_uuid': order.uuid}, + )) + line_items = [] + line_items.append({ + 'name': 'Invoice', + 'description': stripe_payment.description, + 'amount': stripe_payment.amount_for_stripe(), + 'currency': STRIPE_PAYMENTS_CURRENCY, + 'quantity': 1, + }) + + session = stripe.checkout.Session.create( + payment_intent_data=dict( + metadata={ + 'order': request.build_absolute_uri(reverse('admin:assopy_order_change', args=(order.id,))), + 'order_uuid': order.uuid, + 'order_code': order.code, + 'order_pk': order.pk, + }), + # XXX Setting email disables editing it. Not sure we want that. + # customer_email=stripe_payment.email, + success_url=success_url, + cancel_url=cancel_url, + payment_method_types=['card'], + line_items=line_items, + ) + + stripe_payment.session_id = session.id + stripe_payment.save() + + return session + + except stripe.error.StripeError as e: + stripe_error = e.json_body['error'] + stripe_payment.status = stripe_payment.STATUS_CHOICES.FAILED + stripe_payment.message = ( + f"{stripe_error['type']} -- {stripe_error['message']}" + ) + stripe_payment.save() + + raise PaymentError(stripe_error) + + +def verify_payment(stripe_payment, session_id): + stripe.api_key = settings.STRIPE_SECRET_KEY + + try: + session = stripe.checkout.Session.retrieve(session_id) + payment_intent = stripe.PaymentIntent.retrieve(session.payment_intent) + if payment_intent.status == 'succeeded' and payment_intent.amount_received == stripe_payment.amount_for_stripe(): + charge = payment_intent.charges.data[0] + stripe_payment.status = stripe_payment.STATUS_CHOICES.SUCCESSFUL + stripe_payment.charge_id = charge.id + stripe_payment.save() + else: + stripe_payment.status = stripe_payment.STATUS_CHOICES.FAILED + stripe_payment.message = ( + "There was an error processing your payment." + ) + stripe_payment.save() + raise PaymentError({'type': 'unknown', + 'message': 'error processing payment'}) + + + except stripe.error.StripeError as e: + stripe_error = e.json_body['error'] + stripe_payment.status = stripe_payment.STATUS_CHOICES.FAILED + stripe_payment.message = ( + f"{stripe_error['type']} -- {stripe_error['message']}" + ) + stripe_payment.save() + + raise PaymentError(stripe_error) + def charge_for_payment(stripe_payment): assert isinstance(stripe_payment, StripePayment) diff --git a/templates/ep19/bs/cart/_order_summary.html b/templates/ep19/bs/cart/_order_summary.html index 2a7cf53f1..198d10bd9 100644 --- a/templates/ep19/bs/cart/_order_summary.html +++ b/templates/ep19/bs/cart/_order_summary.html @@ -13,10 +13,12 @@

Summary

{{ item.code }} {% if item.ticket %}{{ item.ticket }}{% else %}{{ item.description }}{% endif %} {{ item.price }} + {% if item.ticket %} Configure ticket Assign ticket + {% endif %} {% endfor %} diff --git a/templates/ep19/bs/cart/step_4_payment.html b/templates/ep19/bs/cart/step_4_payment.html index d9e4d8dd4..d42ed91c7 100644 --- a/templates/ep19/bs/cart/step_4_payment.html +++ b/templates/ep19/bs/cart/step_4_payment.html @@ -18,31 +18,31 @@

Step 4: Payment

{% include "ep19/bs/cart/_order_summary.html" %} - {% if not order.is_complete %} -
+ {% if total_for_stripe == 0 %} + {% csrf_token %} - {% if total_for_stripe == 0 %} - - {% else %} - - {% endif %} -
+ + + {% else %} + + + + {% endif %} {% else %} - This order was paid in full on {{ order.payment_date|date:"Y-m-d" }} - See the confirmation page +
This order was paid in full on {{ order.payment_date|date:"Y-m-d" }} + See the confirmation page
{% endif %}{# order.is_complete #} {% if payments %} From 4767bc5ead36548a04a26a54a3f6245c9b9c96ac Mon Sep 17 00:00:00 2001 From: Anders Hammarquist Date: Sat, 8 Feb 2020 04:30:04 +0100 Subject: [PATCH 2/3] cleanup --- conference/cart.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/conference/cart.py b/conference/cart.py index 9475033a8..2a3d91b0d 100644 --- a/conference/cart.py +++ b/conference/cart.py @@ -164,8 +164,6 @@ def cart_step4_payment(request, order_uuid): return redirect( "cart:step5_congrats_order_complete", order_uuid=order.uuid ) - else: - XXX # sanity/security check to make sure we don't publish the the wrong key From 5f5d31fdd3a0bd8de509eda23de92616ac6874fa Mon Sep 17 00:00:00 2001 From: Anders Hammarquist Date: Sat, 8 Feb 2020 05:44:45 +0100 Subject: [PATCH 3/3] fix test --- tests/test_cart.py | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/tests/test_cart.py b/tests/test_cart.py index 318fab9c2..ad2d6fbaa 100644 --- a/tests/test_cart.py +++ b/tests/test_cart.py @@ -1,6 +1,7 @@ from datetime import timedelta from decimal import Decimal from unittest import mock +import uuid from pytest import mark, raises, approx @@ -654,19 +655,26 @@ def test_cart_payment_with_zero_total(db, user_client): assert email_sent_with_subject(ORDER_CONFIRMATION_EMAIL_SUBJECT) -@mock.patch('conference.cart.charge_for_payment') -def test_cart_payment_with_non_zero_total(mock_charge_for_payment, db, user_client): +@mock.patch('conference.cart.prepare_for_payment') +@mock.patch('conference.cart.verify_payment') +def test_cart_payment_with_non_zero_total(mock_prepare_for_payment, mock_verify_payment, db, user_client): _, fares = setup_conference_with_typical_fares() order = OrderFactory(items=[(fares[0], {"qty": 1})]) - - payload = dict( - stripeToken='fakeToken', - stripeTokenType='fakeTokenType', - stripeEmail='fakeStripeEmail', + payment = StripePayment.objects.create( + amount=fares[0].price, + order=order, + user=user_client.user, + uuid=str(uuid.uuid4()), ) + + mock_prepare_for_payment.return_value = payment + payment_step_url = reverse("cart:step4_payment", args=[order.uuid]) - response = user_client.post(payment_step_url, data=payload) + response = user_client.get(payment_step_url) + verify_payment_url = reverse("cart:step4b_verify_payment", args=(order.stripepayment_set.all()[0].uuid, + 'SESSION_ID')) + response = user_client.get(verify_payment_url) order_complete_url = reverse("cart:step5_congrats_order_complete", args=[order.uuid]) assert redirects_to(response, order_complete_url) @@ -676,7 +684,8 @@ def test_cart_payment_with_non_zero_total(mock_charge_for_payment, db, user_clie # assert order.payment_date.date() == timezone.now().date() assert order.invoices.count() == 1 assert StripePayment.objects.count() == 1 - assert mock_charge_for_payment.call_count == 1 + assert mock_prepare_for_payment.call_count == 1 + assert mock_verify_payment.call_count == 1 assert email_sent_with_subject(ORDER_CONFIRMATION_EMAIL_SUBJECT)