From 21613a357d5f27b17c1b16df4b4ce78be0ef4fbc Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer <_@chrisjrn.com> Date: Wed, 21 Sep 2016 10:17:55 +1000 Subject: [PATCH 01/22] Initial commit --- .gitignore | 89 ++++++++++++++++++++++++ LICENSE | 201 +++++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 2 + 3 files changed, 292 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..72364f99 --- /dev/null +++ b/.gitignore @@ -0,0 +1,89 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +env/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +*.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*,cover +.hypothesis/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# IPython Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# dotenv +.env + +# virtualenv +venv/ +ENV/ + +# Spyder project settings +.spyderproject + +# Rope project settings +.ropeproject diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..8dada3ed --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright {yyyy} {name of copyright owner} + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 00000000..f85b1c25 --- /dev/null +++ b/README.md @@ -0,0 +1,2 @@ +# registrasion-stripe +Provides Credit Card processing for Registrasion using the Stripe API. From f932841cda09cadedc2a4d3d2312c959ff530748 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Wed, 21 Sep 2016 10:24:26 +1000 Subject: [PATCH 02/22] Commits an initial django app. --- registripe/__init__.py | 0 registripe/admin.py | 3 +++ registripe/apps.py | 7 +++++++ registripe/migrations/__init__.py | 0 registripe/models.py | 5 +++++ registripe/tests.py | 3 +++ registripe/views.py | 3 +++ requirements.txt | 3 +++ 8 files changed, 24 insertions(+) create mode 100644 registripe/__init__.py create mode 100644 registripe/admin.py create mode 100644 registripe/apps.py create mode 100644 registripe/migrations/__init__.py create mode 100644 registripe/models.py create mode 100644 registripe/tests.py create mode 100644 registripe/views.py create mode 100644 requirements.txt diff --git a/registripe/__init__.py b/registripe/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/registripe/admin.py b/registripe/admin.py new file mode 100644 index 00000000..8c38f3f3 --- /dev/null +++ b/registripe/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/registripe/apps.py b/registripe/apps.py new file mode 100644 index 00000000..c33f2b84 --- /dev/null +++ b/registripe/apps.py @@ -0,0 +1,7 @@ +from __future__ import unicode_literals + +from django.apps import AppConfig + + +class RegistripeConfig(AppConfig): + name = 'registripe' diff --git a/registripe/migrations/__init__.py b/registripe/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/registripe/models.py b/registripe/models.py new file mode 100644 index 00000000..bd4b2abe --- /dev/null +++ b/registripe/models.py @@ -0,0 +1,5 @@ +from __future__ import unicode_literals + +from django.db import models + +# Create your models here. diff --git a/registripe/tests.py b/registripe/tests.py new file mode 100644 index 00000000..7ce503c2 --- /dev/null +++ b/registripe/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/registripe/views.py b/registripe/views.py new file mode 100644 index 00000000..91ea44a2 --- /dev/null +++ b/registripe/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 00000000..a4071534 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +pinax-stripe==3.2.1 +requests>=2.11.1 +stripe==1.38.0 From 79fa80ea33f7552a7b3af634a097af86a5175d60 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Wed, 21 Sep 2016 10:41:02 +1000 Subject: [PATCH 03/22] Adds urls.py --- registripe/urls.py | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 registripe/urls.py diff --git a/registripe/urls.py b/registripe/urls.py new file mode 100644 index 00000000..c1e49702 --- /dev/null +++ b/registripe/urls.py @@ -0,0 +1,10 @@ +from django.conf.urls import url + +from pinax.stripe.views import ( + Webhook, +) + + +urlpatterns = [ + url(r"^webhook/$", Webhook.as_view(), name="pinax_stripe_webhook"), +] From 8334d40fe931966d9a2112f65dc5a5a0e8131bfe Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Wed, 21 Sep 2016 13:16:59 +1000 Subject: [PATCH 04/22] Adds stripe.js-based form for processing credit card payments --- registripe/forms.py | 152 ++++++++++++++++++++++++++ registripe/migrations/0001_initial.py | 27 +++++ registripe/models.py | 7 +- registripe/urls.py | 4 + registripe/views.py | 119 +++++++++++++++++++- requirements.txt | 1 + 6 files changed, 307 insertions(+), 3 deletions(-) create mode 100644 registripe/forms.py create mode 100644 registripe/migrations/0001_initial.py diff --git a/registripe/forms.py b/registripe/forms.py new file mode 100644 index 00000000..c45b6e98 --- /dev/null +++ b/registripe/forms.py @@ -0,0 +1,152 @@ +from functools import partial + +from django import forms +from django.core.urlresolvers import reverse +from django.core.exceptions import ValidationError +from django.forms import widgets + +from django_countries import countries +from django_countries.fields import LazyTypedChoiceField +from django_countries.widgets import CountrySelectWidget + + +class NoRenderWidget(forms.widgets.HiddenInput): + + def render(self, name, value, attrs=None): + return "" + + +def secure_striped(widget): + ''' Calls stripe() with secure=True. ''' + return striped(widget, True) + + +def striped(WidgetClass, secure=False): + ''' Takes a given widget and overrides the render method to be suitable + for stripe.js. + + Arguments: + widget: The widget class + + secure: if True, only the `data-stripe` attribute will be set. Name + will be set to None. + + ''' + + class StripedWidget(WidgetClass): + + def render(self, name, value, attrs=None): + + if not attrs: + attrs = {} + + attrs["data-stripe"] = name + + if secure: + name = "" + + return super(StripedWidget, self).render( + name, value, attrs=attrs + ) + + return StripedWidget + + +class CreditCardForm(forms.Form): + + def _media(self): + js = ( + 'https://js.stripe.com/v2/', + reverse("registripe_pubkey"), + ) + + return forms.Media(js=js) + + media = property(_media) + + number = forms.CharField( + required=False, + max_length=255, + widget=secure_striped(widgets.TextInput)(), + ) + exp_month = forms.CharField( + required=False, + max_length=2, + widget=secure_striped(widgets.TextInput)(), + ) + exp_year = forms.CharField( + required=False, + max_length=4, + widget=secure_striped(widgets.TextInput)(), + ) + cvc = forms.CharField( + required=False, + max_length=4, + widget=secure_striped(widgets.TextInput)(), + ) + + stripe_token = forms.CharField( + max_length=255, + #required=True, + widget=NoRenderWidget(), + ) + + name = forms.CharField( + required=True, + max_length=255, + widget=striped(widgets.TextInput), + ) + address_line1 = forms.CharField( + required=True, + max_length=255, + widget=striped(widgets.TextInput), + ) + address_line2 = forms.CharField( + required=False, + max_length=255, + widget=striped(widgets.TextInput), + ) + address_city = forms.CharField( + required=True, + max_length=255, + widget=striped(widgets.TextInput), + ) + address_state = forms.CharField( + required=True, max_length=255, + widget=striped(widgets.TextInput), + ) + address_zip = forms.CharField( + required=True, + max_length=255, + widget=striped(widgets.TextInput), + ) + address_country = LazyTypedChoiceField( + choices=countries, + widget=striped(CountrySelectWidget), + ) + + +'''{ +From stripe.js details: + +Card details: + +The first argument to createToken is a JavaScript object containing credit card data entered by the user. It should contain the following required members: + +number: card number as a string without any separators (e.g., "4242424242424242") +exp_month: two digit number representing the card's expiration month (e.g., 12) +exp_year: two or four digit number representing the card's expiration year (e.g., 2017) +(The expiration date can also be passed as a single string.) + +cvc: optional, but we highly recommend you provide it to help prevent fraud. This is the card's security code, as a string (e.g., "123"). +The following fields are entirely optional and cannot result in a token creation failure: + +name: cardholder name +address_line1: billing address line 1 +address_line2: billing address line 2 +address_city: billing address city +address_state: billing address state +address_zip: billing postal code as a string (e.g., "94301") +address_country: billing address country +} +''' diff --git a/registripe/migrations/0001_initial.py b/registripe/migrations/0001_initial.py new file mode 100644 index 00000000..23276400 --- /dev/null +++ b/registripe/migrations/0001_initial.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.2 on 2016-09-21 06:19 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('pinax_stripe', '0003_make_cvc_check_blankable'), + ('registrasion', '0005_auto_20160905_0945'), + ] + + operations = [ + migrations.CreateModel( + name='StripePayment', + fields=[ + ('paymentbase_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='registrasion.PaymentBase')), + ('charge', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='pinax_stripe.Charge')), + ], + bases=('registrasion.paymentbase',), + ), + ] diff --git a/registripe/models.py b/registripe/models.py index bd4b2abe..668558e3 100644 --- a/registripe/models.py +++ b/registripe/models.py @@ -1,5 +1,10 @@ from __future__ import unicode_literals from django.db import models +from registrasion.models import commerce +from pinax.stripe.models import Charge -# Create your models here. + +class StripePayment(commerce.PaymentBase): + + charge = models.ForeignKey(Charge) diff --git a/registripe/urls.py b/registripe/urls.py index c1e49702..e5958902 100644 --- a/registripe/urls.py +++ b/registripe/urls.py @@ -1,10 +1,14 @@ from django.conf.urls import url +from registripe import views + from pinax.stripe.views import ( Webhook, ) urlpatterns = [ + url(r"^card/([0-9]*)/$", views.card, name="registripe_card"), + url(r"^pubkey/$", views.pubkey_script, name="registripe_pubkey"), url(r"^webhook/$", Webhook.as_view(), name="pinax_stripe_webhook"), ] diff --git a/registripe/views.py b/registripe/views.py index 91ea44a2..d5083fa0 100644 --- a/registripe/views.py +++ b/registripe/views.py @@ -1,3 +1,118 @@ -from django.shortcuts import render +import forms +import models -# Create your views here. +from django.core.exceptions import ValidationError +from django.conf import settings +from django.contrib import messages +from django.db import transaction +from django.http import HttpResponse +from django.shortcuts import redirect, render + +from registrasion.controllers.invoice import InvoiceController +from registrasion.models import commerce + +from pinax.stripe import actions +from stripe.error import StripeError + +from symposion.conference.models import Conference + +CURRENCY = settings.INVOICE_CURRENCY +CONFERENCE_ID = settings.CONFERENCE_ID + + +def pubkey_script(request): + ''' Returns a JS snippet that sets the Stripe public key for Stripe.js. ''' + + script_template = "Stripe.setPublishableKey('%s');" + script = script_template % settings.PINAX_STRIPE_PUBLIC_KEY + + return HttpResponse(script, content_type="text/javascript") + + +def card(request, invoice_id): + + form = forms.CreditCardForm(request.POST or None) + + inv = InvoiceController.for_id_or_404(str(invoice_id)) + + if not inv.can_view(user=request.user): + raise Http404() + + to_invoice = redirect("invoice", inv.invoice.id) + + if request.POST and form.is_valid(): + try: + inv.validate_allowed_to_pay() # Verify that we're allowed to do this. + process_card(request, form, inv) + return to_invoice + except StripeError as e: + form.add_error(None, ValidationError(e)) + except ValidationError as ve: + form.add_error(None, ve) + + data = { + "invoice": inv.invoice, + "form": form, + } + + return render( + request, "registrasion/stripe/credit_card_payment.html", data + ) + + +@transaction.atomic +def process_card(request, form, inv): + ''' Processes the given credit card form + + Arguments: + request: the current request context + form: a CreditCardForm + inv: an InvoiceController + ''' + + conference = Conference.objects.get(id=CONFERENCE_ID) + amount_to_pay = inv.invoice.balance_due() + + token = form.cleaned_data["stripe_token"] + + customer = actions.customers.get_customer_for_user(request.user) + + if not customer: + customer = actions.customers.create(request.user) + + card = actions.sources.create_card(customer, token) + + description="Payment for %s invoice #%s" % ( + conference.title, inv.invoice.id + ) + + try: + charge = actions.charges.create( + amount_to_pay, + customer, + currency=CURRENCY, + description=description, + capture=False, + ) + + receipt = charge.stripe_charge.receipt_number + if not receipt: + receipt = charge.stripe_charge.id + reference = "Paid with Stripe receipt number: " + receipt + + # Create the payment object + models.StripePayment.objects.create( + invoice=inv.invoice, + reference=reference, + amount=charge.amount, + charge=charge, + ) + except StripeError as e: + raise e + finally: + # Do not actually charge the account until we've reconciled locally. + actions.charges.capture(charge) + + inv.update_status() + + messages.success(request, "This invoice was successfully paid.") diff --git a/requirements.txt b/requirements.txt index a4071534..13955911 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ +django-countries==4.0 pinax-stripe==3.2.1 requests>=2.11.1 stripe==1.38.0 From 830864df2c57cc7b42e41e243bead39a5d9782c5 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Wed, 21 Sep 2016 19:07:10 +1000 Subject: [PATCH 05/22] Adds verification data to the payments form. --- registripe/forms.py | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/registripe/forms.py b/registripe/forms.py index c45b6e98..786d3c24 100644 --- a/registripe/forms.py +++ b/registripe/forms.py @@ -1,9 +1,12 @@ from functools import partial + + from django import forms from django.core.urlresolvers import reverse from django.core.exceptions import ValidationError from django.forms import widgets +from django.utils import timezone from django_countries import countries from django_countries.fields import LazyTypedChoiceField @@ -66,21 +69,28 @@ class CreditCardForm(forms.Form): number = forms.CharField( required=False, + label="Credit card Number", + help_text="Your credit card number, with or without spaces.", max_length=255, widget=secure_striped(widgets.TextInput)(), ) - exp_month = forms.CharField( + exp_month = forms.IntegerField( required=False, - max_length=2, + label="Card expiry month", + min_value=1, + max_value=12, widget=secure_striped(widgets.TextInput)(), ) - exp_year = forms.CharField( + exp_year = forms.IntegerField( required=False, - max_length=4, + label="Card expiry year", + help_text="The expiry year for your card in 4-digit form", + min_value=lambda: timezone.now().year, widget=secure_striped(widgets.TextInput)(), ) cvc = forms.CharField( required=False, + min_length=3, max_length=4, widget=secure_striped(widgets.TextInput)(), ) @@ -93,34 +103,43 @@ class CreditCardForm(forms.Form): name = forms.CharField( required=True, + label="Cardholder name", + help_text="The cardholder's name, as it appears on the credit card", max_length=255, widget=striped(widgets.TextInput), ) address_line1 = forms.CharField( required=True, + label="Cardholder account address, line 1", max_length=255, widget=striped(widgets.TextInput), ) address_line2 = forms.CharField( required=False, + label="Cardholder account address, line 2", max_length=255, widget=striped(widgets.TextInput), ) address_city = forms.CharField( required=True, + label="Cardholder account city", max_length=255, widget=striped(widgets.TextInput), ) address_state = forms.CharField( - required=True, max_length=255, + required=True, + max_length=255, + label="Cardholder account state or province", widget=striped(widgets.TextInput), ) address_zip = forms.CharField( required=True, max_length=255, + label="Cardholder account postal code", widget=striped(widgets.TextInput), ) address_country = LazyTypedChoiceField( + label="Cardholder account country", choices=countries, widget=striped(CountrySelectWidget), ) From cbf3f5814bb5d8bce6d3b172668cfc9eddb602c1 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Wed, 21 Sep 2016 19:36:57 +1000 Subject: [PATCH 06/22] DRYs up the way to define a Stripe-style form field. --- registripe/forms.py | 109 +++++++++++++++++++++----------------------- 1 file changed, 52 insertions(+), 57 deletions(-) diff --git a/registripe/forms.py b/registripe/forms.py index 786d3c24..4f8915fb 100644 --- a/registripe/forms.py +++ b/registripe/forms.py @@ -1,6 +1,4 @@ -from functools import partial - - +import copy from django import forms from django.core.urlresolvers import reverse @@ -19,40 +17,47 @@ class NoRenderWidget(forms.widgets.HiddenInput): return "" -def secure_striped(widget): +def secure_striped(field): ''' Calls stripe() with secure=True. ''' - return striped(widget, True) + return striped(field, True) -def striped(WidgetClass, secure=False): - ''' Takes a given widget and overrides the render method to be suitable - for stripe.js. +def striped(field, secure=False): - Arguments: - widget: The widget class + oldwidget = field.widget + field.widget = StripeWidgetProxy(oldwidget, secure) + return field - secure: if True, only the `data-stripe` attribute will be set. Name - will be set to None. - ''' +class StripeWidgetProxy(widgets.Widget): - class StripedWidget(WidgetClass): + def __init__(self, underlying, secure=False): + self.underlying = underlying + self.secure = secure - def render(self, name, value, attrs=None): + def __deepcopy__(self, memo): + copy_underlying = copy.deepcopy(self.underlying, memo) + return type(self)(copy_underlying) - if not attrs: - attrs = {} + def __getattribute__(self, attr): + spr = super(StripeWidgetProxy, self).__getattribute__ + if attr in ("underlying", "render", "secure", "__deepcopy__"): + return spr(attr) + else: + return getattr(self.underlying, attr) - attrs["data-stripe"] = name + def render(self, name, value, attrs=None): - if secure: - name = "" + print "RENDER: " + name + if not attrs: + attrs = {} - return super(StripedWidget, self).render( - name, value, attrs=attrs - ) + attrs["data-stripe"] = name - return StripedWidget + if self.secure: + name = "" + + return self.underlying.render(name, value, attrs=attrs) class CreditCardForm(forms.Form): @@ -67,33 +72,29 @@ class CreditCardForm(forms.Form): media = property(_media) - number = forms.CharField( + number = secure_striped(forms.CharField( required=False, label="Credit card Number", help_text="Your credit card number, with or without spaces.", max_length=255, - widget=secure_striped(widgets.TextInput)(), - ) - exp_month = forms.IntegerField( + )) + exp_month = secure_striped(forms.IntegerField( required=False, label="Card expiry month", min_value=1, max_value=12, - widget=secure_striped(widgets.TextInput)(), - ) - exp_year = forms.IntegerField( + )) + exp_year = secure_striped(forms.IntegerField( required=False, label="Card expiry year", help_text="The expiry year for your card in 4-digit form", min_value=lambda: timezone.now().year, - widget=secure_striped(widgets.TextInput)(), - ) - cvc = forms.CharField( + )) + cvc = secure_striped(forms.CharField( required=False, min_length=3, max_length=4, - widget=secure_striped(widgets.TextInput)(), - ) + )) stripe_token = forms.CharField( max_length=255, @@ -101,48 +102,42 @@ class CreditCardForm(forms.Form): widget=NoRenderWidget(), ) - name = forms.CharField( + name = striped(forms.CharField( required=True, label="Cardholder name", help_text="The cardholder's name, as it appears on the credit card", max_length=255, - widget=striped(widgets.TextInput), - ) - address_line1 = forms.CharField( + )) + address_line1 = striped(forms.CharField( required=True, label="Cardholder account address, line 1", max_length=255, - widget=striped(widgets.TextInput), - ) - address_line2 = forms.CharField( + )) + address_line2 = striped(forms.CharField( required=False, label="Cardholder account address, line 2", max_length=255, - widget=striped(widgets.TextInput), - ) - address_city = forms.CharField( + )) + address_city = striped(forms.CharField( required=True, label="Cardholder account city", max_length=255, - widget=striped(widgets.TextInput), - ) - address_state = forms.CharField( + )) + address_state = striped(forms.CharField( required=True, max_length=255, label="Cardholder account state or province", - widget=striped(widgets.TextInput), - ) - address_zip = forms.CharField( + )) + address_zip = striped(forms.CharField( required=True, max_length=255, label="Cardholder account postal code", - widget=striped(widgets.TextInput), - ) - address_country = LazyTypedChoiceField( + )) + address_country = striped(LazyTypedChoiceField( label="Cardholder account country", choices=countries, - widget=striped(CountrySelectWidget), - ) + widget=CountrySelectWidget, + )) '''{ From 6c87b9d08a2d8e0f211d5d67593999e28fc664d8 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Thu, 22 Sep 2016 09:38:22 +1000 Subject: [PATCH 07/22] Documentation, and edge case. --- registripe/views.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/registripe/views.py b/registripe/views.py index d5083fa0..8207e2b7 100644 --- a/registripe/views.py +++ b/registripe/views.py @@ -30,6 +30,13 @@ def pubkey_script(request): def card(request, invoice_id): + ''' View that shows and processes a Stripe CreditCardForm to pay the given + invoice. Redirects back to the invoice once the invoice is fully paid. + + Arguments: + invoice_id (castable to str): The invoice id for the invoice to pay. + + ''' form = forms.CreditCardForm(request.POST or None) @@ -40,6 +47,9 @@ def card(request, invoice_id): to_invoice = redirect("invoice", inv.invoice.id) + if inv.invoice.balance_due() <= 0: + return to_invoice + if request.POST and form.is_valid(): try: inv.validate_allowed_to_pay() # Verify that we're allowed to do this. From abee9e3c6231d77d76b7d8b7ccce83704405055d Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Thu, 22 Sep 2016 11:04:43 +1000 Subject: [PATCH 08/22] Adds support for refunds --- registripe/forms.py | 51 +++++++++++++++++++++++++++++++++ registripe/urls.py | 1 + registripe/views.py | 70 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 122 insertions(+) diff --git a/registripe/forms.py b/registripe/forms.py index 4f8915fb..0f4a4db0 100644 --- a/registripe/forms.py +++ b/registripe/forms.py @@ -1,8 +1,10 @@ import copy +import models from django import forms from django.core.urlresolvers import reverse from django.core.exceptions import ValidationError +from django.db.models import F, Q from django.forms import widgets from django.utils import timezone @@ -10,6 +12,8 @@ from django_countries import countries from django_countries.fields import LazyTypedChoiceField from django_countries.widgets import CountrySelectWidget +from pinax.stripe import models as pinax_stripe_models + class NoRenderWidget(forms.widgets.HiddenInput): @@ -140,6 +144,53 @@ class CreditCardForm(forms.Form): )) +class StripeRefundForm(forms.Form): + + def __init__(self, *args, **kwargs): + ''' + + Arguments: + user (User): The user whose charges we should filter to. + min_value (Decimal): The minimum value of the charges we should + show (currently, credit notes can only be cashed out in full.) + + ''' + user = kwargs.pop('user', None) + min_value = kwargs.pop('min_value', None) + super(StripeRefundForm, self).__init__(*args, **kwargs) + + payment_field = self.fields['payment'] + qs = payment_field.queryset + + if user: + qs = qs.filter( + charge__customer__user=user, + ) + + if min_value is not None: + # amount >= amount_to_refund + amount_refunded + # No refunds yet + q1 = ( + Q(charge__amount_refunded__isnull=True) & + Q(charge__amount__gte=min_value) + ) + # There are some refunds + q2 = ( + Q(charge__amount_refunded__isnull=False) & + Q(charge__amount__gte=( + F("charge__amount_refunded") + min_value) + ) + ) + qs = qs.filter(q1 | q2) + + payment_field.queryset = qs + + payment = forms.ModelChoiceField( + required=True, + queryset=models.StripePayment.objects.all(), + ) + + '''{ From stripe.js details: diff --git a/registripe/urls.py b/registripe/urls.py index e5958902..04a249ba 100644 --- a/registripe/urls.py +++ b/registripe/urls.py @@ -10,5 +10,6 @@ from pinax.stripe.views import ( urlpatterns = [ url(r"^card/([0-9]*)/$", views.card, name="registripe_card"), url(r"^pubkey/$", views.pubkey_script, name="registripe_pubkey"), + url(r"^refund/([0-9]*)/$", views.refund, name="registripe_refund"), url(r"^webhook/$", Webhook.as_view(), name="pinax_stripe_webhook"), ] diff --git a/registripe/views.py b/registripe/views.py index 8207e2b7..bd54a528 100644 --- a/registripe/views.py +++ b/registripe/views.py @@ -4,14 +4,18 @@ import models from django.core.exceptions import ValidationError from django.conf import settings from django.contrib import messages +from django.contrib.auth.decorators import user_passes_test from django.db import transaction from django.http import HttpResponse from django.shortcuts import redirect, render +from registrasion.controllers.credit_note import CreditNoteController from registrasion.controllers.invoice import InvoiceController from registrasion.models import commerce from pinax.stripe import actions +from pinax.stripe.actions import refunds as pinax_stripe_actions_refunds + from stripe.error import StripeError from symposion.conference.models import Conference @@ -20,6 +24,11 @@ CURRENCY = settings.INVOICE_CURRENCY CONFERENCE_ID = settings.CONFERENCE_ID +def _staff_only(user): + ''' Returns true if the user is staff. ''' + return user.is_staff + + def pubkey_script(request): ''' Returns a JS snippet that sets the Stripe public key for Stripe.js. ''' @@ -126,3 +135,64 @@ def process_card(request, form, inv): inv.update_status() messages.success(request, "This invoice was successfully paid.") + + +@user_passes_test(_staff_only) +def refund(request, credit_note_id): + ''' Allows staff to select a Stripe charge for the owner of the credit + note, and refund the credit note into stripe. ''' + + cn = CreditNoteController.for_id_or_404(str(credit_note_id)) + + to_credit_note = redirect("credit_note", cn.credit_note.id) + + if not cn.credit_note.is_unclaimed: + return to_credit_note + + form = forms.StripeRefundForm( + request.POST or None, + user=cn.credit_note.invoice.user, + min_value=cn.credit_note.value, + ) + + if request.POST and form.is_valid(): + try: + process_refund(cn, form) + return to_credit_note + except StripeError as se: + form.add_error(None, ValidationError(se)) + + data = { + "credit_note": cn.credit_note, + "form": form, + } + + return render( + request, "registrasion/stripe/refund.html", data + ) + + +def process_refund(cn, form): + payment = form.cleaned_data["payment"] + charge = payment.charge + + to_refund = cn.credit_note.value + stripe_charge_id = charge.stripe_charge.id + + # Test that the given charge is allowed to be refunded. + max_refund = actions.charges.calculate_refund_amount(charge) + + if max_refund < to_refund: + raise ValidationError( + "You must select a payment holding greater value than " + "the credit note." + ) + + refund = actions.refunds.create(charge, to_refund) + + commerce.CreditNoteRefund.objects.create( + parent=cn.credit_note, + reference="Refunded %s to Stripe charge %s" % ( + to_refund, stripe_charge_id + ) + ) From e47e11acfdd4fd11bc4cb99fd2defc265576abc6 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Thu, 22 Sep 2016 11:28:38 +1000 Subject: [PATCH 09/22] setup.py because heroku needs it apparently --- registripe/__init__.py | 1 + registripe/setup.py | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+) create mode 100644 registripe/setup.py diff --git a/registripe/__init__.py b/registripe/__init__.py index e69de29b..607f7a49 100644 --- a/registripe/__init__.py +++ b/registripe/__init__.py @@ -0,0 +1 @@ +__version__ = "0.1.0-dev" diff --git a/registripe/setup.py b/registripe/setup.py new file mode 100644 index 00000000..f3bda01f --- /dev/null +++ b/registripe/setup.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python +import os +from setuptools import setup, find_packages + +import registrasion + + +def read_file(filename): + """Read a file into a string.""" + path = os.path.abspath(os.path.dirname(__file__)) + filepath = os.path.join(path, filename) + try: + return open(filepath).read() + except IOError: + return '' + +setup( + name="registrasion", + author="Christopher Neugebauer", + author_email="_@chrisjrn.com", + version=registrasion.__version__, + description="A registration app for the Symposion conference management " + "system.", + url="http://github.com/chrisjrn/registrasion/", + packages=find_packages(), + include_package_data=True, + classifiers=( + "Development Status :: 3 - Alpha", + "Programming Language :: Python", + "Framework :: Django", + "Intended Audience :: Developers", + "Natural Language :: English", + "License :: OSI Approved :: Apache Software License", + ), + install_requires=read_file("requirements/base.txt").splitlines(), + dependency_links=read_file("requirements/dependencies.txt").splitlines(), +) From fb3f1411c7c21824d22748b3afc89512e37386bc Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Thu, 22 Sep 2016 11:30:28 +1000 Subject: [PATCH 10/22] Puts setup.py in the right place (oops) --- registripe/setup.py => setup.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) rename registripe/setup.py => setup.py (65%) diff --git a/registripe/setup.py b/setup.py similarity index 65% rename from registripe/setup.py rename to setup.py index f3bda01f..c803f16d 100644 --- a/registripe/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ import os from setuptools import setup, find_packages -import registrasion +import registripe def read_file(filename): @@ -15,13 +15,12 @@ def read_file(filename): return '' setup( - name="registrasion", + name="registrasion-stripe", author="Christopher Neugebauer", author_email="_@chrisjrn.com", - version=registrasion.__version__, - description="A registration app for the Symposion conference management " - "system.", - url="http://github.com/chrisjrn/registrasion/", + version=registripe.__version__, + description="Stripe-based payments for the Registrasion conference registration package.", + url="http://github.com/chrisjrn/registrasion-stripe/", packages=find_packages(), include_package_data=True, classifiers=( @@ -32,6 +31,5 @@ setup( "Natural Language :: English", "License :: OSI Approved :: Apache Software License", ), - install_requires=read_file("requirements/base.txt").splitlines(), - dependency_links=read_file("requirements/dependencies.txt").splitlines(), + install_requires=read_file("requirements.txt").splitlines(), ) From ed1087d9d3802104b3f34fabda4059cb15c6d3e3 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Thu, 22 Sep 2016 11:38:12 +1000 Subject: [PATCH 11/22] Fixes bug in payment form --- registripe/forms.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/registripe/forms.py b/registripe/forms.py index 0f4a4db0..ce62d8e2 100644 --- a/registripe/forms.py +++ b/registripe/forms.py @@ -92,7 +92,7 @@ class CreditCardForm(forms.Form): required=False, label="Card expiry year", help_text="The expiry year for your card in 4-digit form", - min_value=lambda: timezone.now().year, + min_value=timezone.now().year, )) cvc = secure_striped(forms.CharField( required=False, From 26b249d48d94994cb7658ad686547a29d33b706a Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Thu, 22 Sep 2016 11:44:30 +1000 Subject: [PATCH 12/22] Always immediately capture payments. --- registripe/views.py | 42 ++++++++++++++++++------------------------ 1 file changed, 18 insertions(+), 24 deletions(-) diff --git a/registripe/views.py b/registripe/views.py index bd54a528..d6eca5b6 100644 --- a/registripe/views.py +++ b/registripe/views.py @@ -105,32 +105,26 @@ def process_card(request, form, inv): conference.title, inv.invoice.id ) - try: - charge = actions.charges.create( - amount_to_pay, - customer, - currency=CURRENCY, - description=description, - capture=False, - ) + charge = actions.charges.create( + amount_to_pay, + customer, + currency=CURRENCY, + description=description, + capture=True, + ) - receipt = charge.stripe_charge.receipt_number - if not receipt: - receipt = charge.stripe_charge.id - reference = "Paid with Stripe receipt number: " + receipt + receipt = charge.stripe_charge.receipt_number + if not receipt: + receipt = charge.stripe_charge.id + reference = "Paid with Stripe receipt number: " + receipt - # Create the payment object - models.StripePayment.objects.create( - invoice=inv.invoice, - reference=reference, - amount=charge.amount, - charge=charge, - ) - except StripeError as e: - raise e - finally: - # Do not actually charge the account until we've reconciled locally. - actions.charges.capture(charge) + # Create the payment object + models.StripePayment.objects.create( + invoice=inv.invoice, + reference=reference, + amount=charge.amount, + charge=charge, + ) inv.update_status() From 4b1d109714a8445da383d0538727fa1a9993c9d6 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Thu, 22 Sep 2016 20:30:03 +1000 Subject: [PATCH 13/22] Fixes issues with rendering stripe widgets securely. --- registripe/forms.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/registripe/forms.py b/registripe/forms.py index ce62d8e2..4d906fd6 100644 --- a/registripe/forms.py +++ b/registripe/forms.py @@ -41,7 +41,7 @@ class StripeWidgetProxy(widgets.Widget): def __deepcopy__(self, memo): copy_underlying = copy.deepcopy(self.underlying, memo) - return type(self)(copy_underlying) + return type(self)(copy_underlying, self.secure) def __getattribute__(self, attr): spr = super(StripeWidgetProxy, self).__getattribute__ @@ -52,7 +52,6 @@ class StripeWidgetProxy(widgets.Widget): def render(self, name, value, attrs=None): - print "RENDER: " + name if not attrs: attrs = {} From 2d434432d90af03c84c53dc411dff6e7b140a5c5 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Thu, 22 Sep 2016 20:32:10 +1000 Subject: [PATCH 14/22] Oops --- registripe/views.py | 1 + 1 file changed, 1 insertion(+) diff --git a/registripe/views.py b/registripe/views.py index d6eca5b6..62e78170 100644 --- a/registripe/views.py +++ b/registripe/views.py @@ -6,6 +6,7 @@ from django.conf import settings from django.contrib import messages from django.contrib.auth.decorators import user_passes_test from django.db import transaction +from django.http import Http404 from django.http import HttpResponse from django.shortcuts import redirect, render From fd5754e679b388d30ab44712c0d084c8c3c5b140 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Fri, 23 Sep 2016 16:42:47 +1000 Subject: [PATCH 15/22] Allows unauthenticated payments. Links Credit Note Refunds to the Stripe Charge. --- .../migrations/0002_stripecreditnoterefund.py | 26 +++++++++++++++++++ registripe/models.py | 4 +++ registripe/urls.py | 1 + registripe/views.py | 23 ++++++++-------- 4 files changed, 43 insertions(+), 11 deletions(-) create mode 100644 registripe/migrations/0002_stripecreditnoterefund.py diff --git a/registripe/migrations/0002_stripecreditnoterefund.py b/registripe/migrations/0002_stripecreditnoterefund.py new file mode 100644 index 00000000..d10f69e8 --- /dev/null +++ b/registripe/migrations/0002_stripecreditnoterefund.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.2 on 2016-09-23 06:36 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('pinax_stripe', '0003_make_cvc_check_blankable'), + ('registrasion', '0005_auto_20160905_0945'), + ('registripe', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='StripeCreditNoteRefund', + fields=[ + ('creditnoterefund_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='registrasion.CreditNoteRefund')), + ('charge', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='pinax_stripe.Charge')), + ], + bases=('registrasion.creditnoterefund',), + ), + ] diff --git a/registripe/models.py b/registripe/models.py index 668558e3..6adb65a6 100644 --- a/registripe/models.py +++ b/registripe/models.py @@ -8,3 +8,7 @@ from pinax.stripe.models import Charge class StripePayment(commerce.PaymentBase): charge = models.ForeignKey(Charge) + +class StripeCreditNoteRefund(commerce.CreditNoteRefund): + + charge = models.ForeignKey(Charge) diff --git a/registripe/urls.py b/registripe/urls.py index 04a249ba..f13e3473 100644 --- a/registripe/urls.py +++ b/registripe/urls.py @@ -9,6 +9,7 @@ from pinax.stripe.views import ( urlpatterns = [ url(r"^card/([0-9]*)/$", views.card, name="registripe_card"), + url(r"^card/([0-9]*)/([0-9A-Za-z]*)/$", views.card, name="registripe_card"), url(r"^pubkey/$", views.pubkey_script, name="registripe_pubkey"), url(r"^refund/([0-9]*)/$", views.refund, name="registripe_refund"), url(r"^webhook/$", Webhook.as_view(), name="pinax_stripe_webhook"), diff --git a/registripe/views.py b/registripe/views.py index 62e78170..575559fa 100644 --- a/registripe/views.py +++ b/registripe/views.py @@ -39,12 +39,14 @@ def pubkey_script(request): return HttpResponse(script, content_type="text/javascript") -def card(request, invoice_id): +def card(request, invoice_id, access_code=None): ''' View that shows and processes a Stripe CreditCardForm to pay the given invoice. Redirects back to the invoice once the invoice is fully paid. Arguments: invoice_id (castable to str): The invoice id for the invoice to pay. + access_code (str): The optional access code for the invoice (for + unauthenticated payment) ''' @@ -52,10 +54,10 @@ def card(request, invoice_id): inv = InvoiceController.for_id_or_404(str(invoice_id)) - if not inv.can_view(user=request.user): + if not inv.can_view(user=request.user, access_code=access_code): raise Http404() - to_invoice = redirect("invoice", inv.invoice.id) + to_invoice = redirect("invoice", inv.invoice.id, access_code) if inv.invoice.balance_due() <= 0: return to_invoice @@ -92,13 +94,13 @@ def process_card(request, form, inv): conference = Conference.objects.get(id=CONFERENCE_ID) amount_to_pay = inv.invoice.balance_due() - + user = inv.invoice.user token = form.cleaned_data["stripe_token"] - customer = actions.customers.get_customer_for_user(request.user) + customer = actions.customers.get_customer_for_user(user) if not customer: - customer = actions.customers.create(request.user) + customer = actions.customers.create(user) card = actions.sources.create_card(customer, token) @@ -114,10 +116,8 @@ def process_card(request, form, inv): capture=True, ) - receipt = charge.stripe_charge.receipt_number - if not receipt: - receipt = charge.stripe_charge.id - reference = "Paid with Stripe receipt number: " + receipt + receipt = charge.stripe_charge.id + reference = "Paid with Stripe reference: " + receipt # Create the payment object models.StripePayment.objects.create( @@ -185,8 +185,9 @@ def process_refund(cn, form): refund = actions.refunds.create(charge, to_refund) - commerce.CreditNoteRefund.objects.create( + models.StripeCreditNoteRefund.objects.create( parent=cn.credit_note, + charge=charge, reference="Refunded %s to Stripe charge %s" % ( to_refund, stripe_charge_id ) From 7a7aa9587464c1c9201930ee93d757b4fbeebf77 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Sun, 25 Sep 2016 09:56:16 +1000 Subject: [PATCH 16/22] Fixes issue with accessing stripe page without access code. --- registripe/views.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/registripe/views.py b/registripe/views.py index 575559fa..361e49e1 100644 --- a/registripe/views.py +++ b/registripe/views.py @@ -57,7 +57,10 @@ def card(request, invoice_id, access_code=None): if not inv.can_view(user=request.user, access_code=access_code): raise Http404() - to_invoice = redirect("invoice", inv.invoice.id, access_code) + args = [inv.invoice.id] + if access_code: + args.append(access_code) + to_invoice = redirect("invoice", *args) if inv.invoice.balance_due() <= 0: return to_invoice From 4ba7f630e0010757a45f1c40fdaf265b6a4c7894 Mon Sep 17 00:00:00 2001 From: Christopher Neugebauer Date: Mon, 12 Dec 2016 18:56:34 +1100 Subject: [PATCH 17/22] Bills the correct credit card. --- registripe/views.py | 1 + 1 file changed, 1 insertion(+) diff --git a/registripe/views.py b/registripe/views.py index 361e49e1..1759cc29 100644 --- a/registripe/views.py +++ b/registripe/views.py @@ -114,6 +114,7 @@ def process_card(request, form, inv): charge = actions.charges.create( amount_to_pay, customer, + source=card, currency=CURRENCY, description=description, capture=True, From 9719546bdaba5f81f232c114112ef753ead76b45 Mon Sep 17 00:00:00 2001 From: Sachi King Date: Mon, 17 Apr 2017 22:56:21 +1000 Subject: [PATCH 18/22] Fix imports - not relative --- registripe/forms.py | 2 +- registripe/views.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/registripe/forms.py b/registripe/forms.py index 4d906fd6..bc126332 100644 --- a/registripe/forms.py +++ b/registripe/forms.py @@ -1,5 +1,5 @@ import copy -import models +from registripe import models from django import forms from django.core.urlresolvers import reverse diff --git a/registripe/views.py b/registripe/views.py index 1759cc29..b11219e2 100644 --- a/registripe/views.py +++ b/registripe/views.py @@ -1,5 +1,5 @@ -import forms -import models +from registripe import forms +from registripe import models from django.core.exceptions import ValidationError from django.conf import settings From d360e880d9935c2da15ef7a4b7999228369b1b4f Mon Sep 17 00:00:00 2001 From: Sachi King Date: Sat, 22 Apr 2017 19:06:30 +1000 Subject: [PATCH 19/22] Flake8 fixes --- registripe/admin.py | 3 --- registripe/forms.py | 27 ++++++++++--------- registripe/migrations/0001_initial.py | 11 ++++++-- .../migrations/0002_stripecreditnoterefund.py | 11 ++++++-- registripe/models.py | 1 + registripe/tests.py | 3 --- registripe/urls.py | 7 +++-- registripe/views.py | 8 +++--- 8 files changed, 40 insertions(+), 31 deletions(-) delete mode 100644 registripe/admin.py delete mode 100644 registripe/tests.py diff --git a/registripe/admin.py b/registripe/admin.py deleted file mode 100644 index 8c38f3f3..00000000 --- a/registripe/admin.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.contrib import admin - -# Register your models here. diff --git a/registripe/forms.py b/registripe/forms.py index bc126332..91370957 100644 --- a/registripe/forms.py +++ b/registripe/forms.py @@ -3,7 +3,6 @@ from registripe import models from django import forms from django.core.urlresolvers import reverse -from django.core.exceptions import ValidationError from django.db.models import F, Q from django.forms import widgets from django.utils import timezone @@ -12,8 +11,6 @@ from django_countries import countries from django_countries.fields import LazyTypedChoiceField from django_countries.widgets import CountrySelectWidget -from pinax.stripe import models as pinax_stripe_models - class NoRenderWidget(forms.widgets.HiddenInput): @@ -101,7 +98,7 @@ class CreditCardForm(forms.Form): stripe_token = forms.CharField( max_length=255, - #required=True, + # required=True, widget=NoRenderWidget(), ) @@ -177,8 +174,7 @@ class StripeRefundForm(forms.Form): q2 = ( Q(charge__amount_refunded__isnull=False) & Q(charge__amount__gte=( - F("charge__amount_refunded") + min_value) - ) + F("charge__amount_refunded") + min_value)) ) qs = qs.filter(q1 | q2) @@ -195,15 +191,22 @@ From stripe.js details: Card details: -The first argument to createToken is a JavaScript object containing credit card data entered by the user. It should contain the following required members: +The first argument to createToken is a JavaScript object containing credit +card data entered by the user. It should contain the following required +members: -number: card number as a string without any separators (e.g., "4242424242424242") -exp_month: two digit number representing the card's expiration month (e.g., 12) -exp_year: two or four digit number representing the card's expiration year (e.g., 2017) +number: card number as a string without any separators +(e.g., "4242424242424242") +exp_month: two digit number representing the card's expiration month +(e.g., 12) +exp_year: two or four digit number representing the card's expiration year +(e.g., 2017) (The expiration date can also be passed as a single string.) -cvc: optional, but we highly recommend you provide it to help prevent fraud. This is the card's security code, as a string (e.g., "123"). -The following fields are entirely optional and cannot result in a token creation failure: +cvc: optional, but we highly recommend you provide it to help prevent fraud. +This is the card's security code, as a string (e.g., "123"). +The following fields are entirely optional and cannot result in a token +creation failure: name: cardholder name address_line1: billing address line 1 diff --git a/registripe/migrations/0001_initial.py b/registripe/migrations/0001_initial.py index 23276400..9fe1778c 100644 --- a/registripe/migrations/0001_initial.py +++ b/registripe/migrations/0001_initial.py @@ -19,8 +19,15 @@ class Migration(migrations.Migration): migrations.CreateModel( name='StripePayment', fields=[ - ('paymentbase_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='registrasion.PaymentBase')), - ('charge', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='pinax_stripe.Charge')), + ('paymentbase_ptr', models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, primary_key=True, serialize=False, + to='registrasion.PaymentBase')), + ('charge', + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to='pinax_stripe.Charge')), ], bases=('registrasion.paymentbase',), ), diff --git a/registripe/migrations/0002_stripecreditnoterefund.py b/registripe/migrations/0002_stripecreditnoterefund.py index d10f69e8..bfe8e17b 100644 --- a/registripe/migrations/0002_stripecreditnoterefund.py +++ b/registripe/migrations/0002_stripecreditnoterefund.py @@ -18,8 +18,15 @@ class Migration(migrations.Migration): migrations.CreateModel( name='StripeCreditNoteRefund', fields=[ - ('creditnoterefund_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='registrasion.CreditNoteRefund')), - ('charge', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='pinax_stripe.Charge')), + ('creditnoterefund_ptr', models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, primary_key=True, serialize=False, + to='registrasion.CreditNoteRefund')), + ('charge', + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to='pinax_stripe.Charge')), ], bases=('registrasion.creditnoterefund',), ), diff --git a/registripe/models.py b/registripe/models.py index 6adb65a6..be3aab6b 100644 --- a/registripe/models.py +++ b/registripe/models.py @@ -9,6 +9,7 @@ class StripePayment(commerce.PaymentBase): charge = models.ForeignKey(Charge) + class StripeCreditNoteRefund(commerce.CreditNoteRefund): charge = models.ForeignKey(Charge) diff --git a/registripe/tests.py b/registripe/tests.py deleted file mode 100644 index 7ce503c2..00000000 --- a/registripe/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/registripe/urls.py b/registripe/urls.py index f13e3473..40e6b144 100644 --- a/registripe/urls.py +++ b/registripe/urls.py @@ -2,14 +2,13 @@ from django.conf.urls import url from registripe import views -from pinax.stripe.views import ( - Webhook, -) +from pinax.stripe.views import Webhook urlpatterns = [ url(r"^card/([0-9]*)/$", views.card, name="registripe_card"), - url(r"^card/([0-9]*)/([0-9A-Za-z]*)/$", views.card, name="registripe_card"), + url(r"^card/([0-9]*)/([0-9A-Za-z]*)/$", views.card, + name="registripe_card"), url(r"^pubkey/$", views.pubkey_script, name="registripe_pubkey"), url(r"^refund/([0-9]*)/$", views.refund, name="registripe_refund"), url(r"^webhook/$", Webhook.as_view(), name="pinax_stripe_webhook"), diff --git a/registripe/views.py b/registripe/views.py index b11219e2..50b8181a 100644 --- a/registripe/views.py +++ b/registripe/views.py @@ -12,10 +12,8 @@ from django.shortcuts import redirect, render from registrasion.controllers.credit_note import CreditNoteController from registrasion.controllers.invoice import InvoiceController -from registrasion.models import commerce from pinax.stripe import actions -from pinax.stripe.actions import refunds as pinax_stripe_actions_refunds from stripe.error import StripeError @@ -67,7 +65,7 @@ def card(request, invoice_id, access_code=None): if request.POST and form.is_valid(): try: - inv.validate_allowed_to_pay() # Verify that we're allowed to do this. + inv.validate_allowed_to_pay() process_card(request, form, inv) return to_invoice except StripeError as e: @@ -107,7 +105,7 @@ def process_card(request, form, inv): card = actions.sources.create_card(customer, token) - description="Payment for %s invoice #%s" % ( + description = "Payment for %s invoice #%s" % ( conference.title, inv.invoice.id ) @@ -187,7 +185,7 @@ def process_refund(cn, form): "the credit note." ) - refund = actions.refunds.create(charge, to_refund) + refund = actions.refunds.create(charge, to_refund) # noqa models.StripeCreditNoteRefund.objects.create( parent=cn.credit_note, From a162559a05f51965d18c73f6d8b9fd4d17d8b254 Mon Sep 17 00:00:00 2001 From: Sachi King Date: Sat, 22 Apr 2017 19:06:47 +1000 Subject: [PATCH 20/22] NotImplmented refund We don't actually do it. Ban it for now. --- registripe/views.py | 1 + 1 file changed, 1 insertion(+) diff --git a/registripe/views.py b/registripe/views.py index 50b8181a..daf685d0 100644 --- a/registripe/views.py +++ b/registripe/views.py @@ -170,6 +170,7 @@ def refund(request, credit_note_id): def process_refund(cn, form): + raise NotImplementedError("Does not actually refund a user") payment = form.cleaned_data["payment"] charge = payment.charge From 2d4b29e6d9dc29424c59a16f6c6fdd688742b879 Mon Sep 17 00:00:00 2001 From: Sachi King Date: Mon, 24 Apr 2017 23:09:21 +1000 Subject: [PATCH 21/22] Add a CSS class on required fields labels This makes it possible to add a ' *' required notifier to labels without needing a bunch of custom form code in templates. --- registripe/forms.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/registripe/forms.py b/registripe/forms.py index 91370957..3816c5de 100644 --- a/registripe/forms.py +++ b/registripe/forms.py @@ -62,6 +62,8 @@ class StripeWidgetProxy(widgets.Widget): class CreditCardForm(forms.Form): + required_css_class = 'label-required' + def _media(self): js = ( 'https://js.stripe.com/v2/', @@ -142,6 +144,8 @@ class CreditCardForm(forms.Form): class StripeRefundForm(forms.Form): + required_css_class = 'label-required' + def __init__(self, *args, **kwargs): ''' From bafa4c9a2b798aefd7b2c05185c4244da6373842 Mon Sep 17 00:00:00 2001 From: Sachi King Date: Sat, 27 May 2017 21:02:24 +1000 Subject: [PATCH 22/22] Prepare to vendor --- .gitignore | 89 ----------------------------------- README.md | 2 - LICENSE => registripe/LICENSE | 0 requirements.txt | 4 -- setup.py | 35 -------------- 5 files changed, 130 deletions(-) delete mode 100644 .gitignore delete mode 100644 README.md rename LICENSE => registripe/LICENSE (100%) delete mode 100644 requirements.txt delete mode 100644 setup.py diff --git a/.gitignore b/.gitignore deleted file mode 100644 index 72364f99..00000000 --- a/.gitignore +++ /dev/null @@ -1,89 +0,0 @@ -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python -env/ -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -*.egg-info/ -.installed.cfg -*.egg - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*,cover -.hypothesis/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -target/ - -# IPython Notebook -.ipynb_checkpoints - -# pyenv -.python-version - -# celery beat schedule file -celerybeat-schedule - -# dotenv -.env - -# virtualenv -venv/ -ENV/ - -# Spyder project settings -.spyderproject - -# Rope project settings -.ropeproject diff --git a/README.md b/README.md deleted file mode 100644 index f85b1c25..00000000 --- a/README.md +++ /dev/null @@ -1,2 +0,0 @@ -# registrasion-stripe -Provides Credit Card processing for Registrasion using the Stripe API. diff --git a/LICENSE b/registripe/LICENSE similarity index 100% rename from LICENSE rename to registripe/LICENSE diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 13955911..00000000 --- a/requirements.txt +++ /dev/null @@ -1,4 +0,0 @@ -django-countries==4.0 -pinax-stripe==3.2.1 -requests>=2.11.1 -stripe==1.38.0 diff --git a/setup.py b/setup.py deleted file mode 100644 index c803f16d..00000000 --- a/setup.py +++ /dev/null @@ -1,35 +0,0 @@ -#!/usr/bin/env python -import os -from setuptools import setup, find_packages - -import registripe - - -def read_file(filename): - """Read a file into a string.""" - path = os.path.abspath(os.path.dirname(__file__)) - filepath = os.path.join(path, filename) - try: - return open(filepath).read() - except IOError: - return '' - -setup( - name="registrasion-stripe", - author="Christopher Neugebauer", - author_email="_@chrisjrn.com", - version=registripe.__version__, - description="Stripe-based payments for the Registrasion conference registration package.", - url="http://github.com/chrisjrn/registrasion-stripe/", - packages=find_packages(), - include_package_data=True, - classifiers=( - "Development Status :: 3 - Alpha", - "Programming Language :: Python", - "Framework :: Django", - "Intended Audience :: Developers", - "Natural Language :: English", - "License :: OSI Approved :: Apache Software License", - ), - install_requires=read_file("requirements.txt").splitlines(), -)