diff --git a/gms/squarepay/admin.py b/gms/squarepay/admin.py index 8c38f3f3dad51e4585f3984282c2a4bec5349c1e..7f8f4f2ddd5254b6744ffe1e1c5bf99151003a29 100644 --- a/gms/squarepay/admin.py +++ b/gms/squarepay/admin.py @@ -1,3 +1,15 @@ -from django.contrib import admin +from django.utils.html import format_html -# Register your models here. +from gms import admin +from .models import CardPayment + +class CardPaymentAdmin(admin.ModelAdmin): + list_display = ['amount', 'url_field', 'date_created', 'is_paid'] + readonly_fields = ['token', 'idempotency_key'] + + def url_field(self, obj): + return format_html('<a href="{}">Goto payment page</a>', obj.get_absolute_url()) + url_field.short_description = 'Payment URL' + url_field.allow_tags = True + +admin.site.register(CardPayment, CardPaymentAdmin) \ No newline at end of file diff --git a/gms/squarepay/models.py b/gms/squarepay/models.py index 71a836239075aa6e6e4ecb700e9c42c95c022d91..7000231201cd48f1e9dda1d868064f2fc37742a5 100644 --- a/gms/squarepay/models.py +++ b/gms/squarepay/models.py @@ -1,3 +1,24 @@ +import uuid +from django.core.management.utils import get_random_secret_key from django.db import models +from django.urls import reverse -# Create your models here. +class CardPayment(models.Model): + token = models.CharField('Unique payment token', max_length=64, editable=False, default=get_random_secret_key) + description = models.CharField('Description', max_length=255) + amount = models.IntegerField('Amount in cents', null=False, blank=False) + idempotency_key = models.CharField('Square Transactions API idempotency key', max_length=64, editable=False, default=uuid.uuid1) + is_paid = models.BooleanField('Has been paid', blank=True, default=False) + completed_url = models.CharField('Redirect URL on success', max_length=255, null=True, editable=False) + dispense_synced = models.BooleanField('Payment lodged in dispense', blank=True, default=False) + date_created = models.DateTimeField('Date created', auto_now_add=True) + date_paid = models.DateTimeField('Date paid (payment captured)', null=True, blank=True) + + def save(self, *args, **kwargs): + # generate a token by default. maybe possible using default=...? + if (self.token is None): + self.token = get_random_secret_key() + super().save(*args, **kwargs) + + def get_absolute_url(self): + return reverse('squarepay:pay', kwargs={ 'pk': self.pk, 'token': self.token }) \ No newline at end of file diff --git a/gms/squarepay/static/squarepay.css b/gms/squarepay/static/squarepay.css index 4856870e9837a285167a27a1c67bfc3ec51c7882..f84f01fa02f65cf6c8b9f1a4f6f365d0fb43a26f 100644 --- a/gms/squarepay/static/squarepay.css +++ b/gms/squarepay/static/squarepay.css @@ -29,30 +29,36 @@ fieldset { text-transform: uppercase; } -.third { +.half { float: left; - width: calc((100% - 32px) / 3); + width: calc((100% - 16px) / 2); padding: 0; - margin: 0 16px 16px 0; + margin-right: 16px; } -.third:last-of-type { +.half:last-of-type { margin-right: 0; } /* Define how SqPaymentForm iframes should look */ -.sq-input { +.sq-input, +#sq-expiration-date, #sq-card-number, #sq-cvv { box-sizing: border-box; border: 1px solid #E0E2E3; border-radius: 4px; outline-offset: -2px; - display: inline-block; + /*display: inline-block;*/ + height: 56px; -webkit-transition: border-color .2s ease-in-out, background .2s ease-in-out; -moz-transition: border-color .2s ease-in-out, background .2s ease-in-out; -ms-transition: border-color .2s ease-in-out, background .2s ease-in-out; transition: border-color .2s ease-in-out, background .2s ease-in-out; } +div#sq-expiration-date, div#sq-card-number, div#sq-cvv { + background-color: #f2f2f2; +} + /* Define how SqPaymentForm iframes should look when they have focus */ .sq-input--focus { border: 1px solid #4A90E2; @@ -72,6 +78,22 @@ fieldset { /* Customize the "Pay with Credit Card" button */ .button-credit-card { + width: 100%; + height: 56px; + margin-top: 10px; + background: #ccf; + border-radius: 4px; + cursor: default; + display: block; + color: #FFFFFF; + font-size: 16px; + line-height: 24px; + font-weight: 700; + letter-spacing: 0; + text-align: center; + +} +.button-credit-card:enabled { width: 100%; height: 56px; margin-top: 10px; @@ -91,11 +113,28 @@ fieldset { transition: background .2s ease-in-out; } -.button-credit-card:hover { +.button-credit-card:hover:enabled { background-color: #4281CB; } +#form-container { + position: relative; + width: 380px; + margin: 20px auto; + box-sizing: content-box; + border: 2px solid #eee; + background-color: white; + padding: 16px ; +} +.form-row { + margin: 0; +} + +/*#content, #container, body { + height: 100%; +} +*/ /* Customize the Apple Pay on the Web button */ #sq-apple-pay { width: 100%; @@ -120,7 +159,7 @@ fieldset { margin: 24px 0 24px; background-image: url("https://masterpass.com/dyn/img/acc/global/mp_mark_hor_wht.svg"); background-color: black; - background-size: 100% 60%; + background-size: 60% 60%; background-repeat: no-repeat; background-position: calc((100% - 32px) / 2) 50%; border-radius: 4px; @@ -128,7 +167,7 @@ fieldset { display: none; } -#sq-masterpass::after { +/*#sq-masterpass::after { box-sizing: border-box; float: right; width: 32px; @@ -137,7 +176,7 @@ fieldset { content: url("data:image/svg+xml; utf8, <svg width='14' height='24' viewBox='0 0 14 24' xmlns='http://www.w3.org/2000/svg'><path d='M1.891 23.485c-.389 0-.778-.144-1.075-.436a1.46 1.46 0 0 1 0-2.102l9.141-8.944L.817 3.06a1.463 1.463 0 0 1 0-2.104 1.544 1.544 0 0 1 2.15 0l10.217 9.994a1.464 1.464 0 0 1 0 2.105L2.966 23.049a1.525 1.525 0 0 1-1.075.436' fill='#FFF' fill-rule='evenodd'/></svg>"); background-color: #E6761F; border-radius: 0 4px 4px 0; -} +}*/ /* Customize the Google Pay button */ .button-google-pay { @@ -159,39 +198,6 @@ fieldset { display: none; } - -#sq-walletbox { - margin: 0; - padding: 0; - text-align: center; - vertical-align: top; - width:100%; - height:123; - display:none; -} - -#sq-walletbox-divider { - height: 24px; - margin: 24px 0 24px; - color: #CCC; - font-size: 14px; - font-weight: bold; - line-height: 24px; - letter-spacing: 0.5; - text-align: center; - text-transform: uppercase; - width: 100%; -} - -#sq-walletbox-divider-label { - padding: 0 24px; - background-color: #FFF; -} - -#sq-walletbox-divider hr { - margin-top: -12px; -} - #error { width: 100%; margin-top: 16px; @@ -200,4 +206,5 @@ fieldset { font-weight: 500; text-align: center; opacity: 0.8; + display: none; } diff --git a/gms/squarepay/static/squarepay.js b/gms/squarepay/static/squarepay.js index 7b264ce5760d9d8a7a33c783584db1c030019fd7..38c6f3e45fa58df6081e1221373f34197da99506 100644 --- a/gms/squarepay/static/squarepay.js +++ b/gms/squarepay/static/squarepay.js @@ -1,5 +1,6 @@ /** Javascript to handle the Square payments form. - * Adapted from https://docs.connect.squareup.com/payments/sqpaymentform/setup */ + * Adapted from https://docs.connect.squareup.com/payments/sqpaymentform/setup + * SqPaymentForm documentation at https://docs.connect.squareup.com/api/paymentform */ /* * function: requestCardNonce @@ -41,20 +42,10 @@ var paymentForm = new SqPaymentForm({ _mozOsxFontSmoothing: 'grayscale' }], - // Initialize Apple Pay placeholder ID - applePay: { - elementId: 'sq-apple-pay' - }, - - // Initialize Masterpass placeholder ID - masterpass: { - elementId: 'sq-masterpass' - }, - - // Initialize Google Pay placeholder ID - googlePay: { - elementId: 'sq-google-pay' - }, + /* digital wallets are only supported by Square for accounts in the US :'( */ + applePay: false, + masterpass: false, + googlePay: false, // Initialize the credit card placeholders cardNumber: { @@ -69,43 +60,15 @@ var paymentForm = new SqPaymentForm({ elementId: 'sq-expiration-date', placeholder: 'MM/YY' }, - postalCode: { - elementId: 'sq-postal-code', - placeholder: '6009' - }, + + /* postal code is not required in AU */ + postalCode: false, // SqPaymentForm callback functions callbacks: { - /* callback function: methodsSupported - * Triggered when: the page is loaded. */ - methodsSupported: function (methods) { - var walletBox = document.getElementById('sq-walletbox'); - var applePayBtn = document.getElementById('sq-apple-pay'); - var googlePayBtn = document.getElementById('sq-google-pay'); - var masterpassBtn = document.getElementById('sq-masterpass'); - - // Only show the button if Apple Pay for Web is enabled - // Otherwise, display the wallet not enabled message. - if (methods.applePay === true) { - walletBox.style.display = 'block'; - applePayBtn.style.display = 'block'; - } - // Only show the button if Masterpass is enabled - // Otherwise, display the wallet not enabled message. - if (methods.masterpass === true) { - walletBox.style.display = 'block'; - masterpassBtn.style.display = 'block'; - } - // Only show the button if Google Pay is enabled - if (methods.googlePay === true) { - walletBox.style.display = 'block'; - googlePayBtn.style.display = 'inline-block'; - } - }, - /* callback function: createPaymentRequest * Triggered when: a digital wallet payment button is clicked. */ - createPaymentRequest: function () { +/* createPaymentRequest: function () { return { requestShippingAddress: false, @@ -125,27 +88,16 @@ var paymentForm = new SqPaymentForm({ } ] } - }, - - /* callback function: validateShippingContact - * Triggered when: a shipping address is selected/changed in a digital - * wallet UI that supports address selection. */ - validateShippingContact: function (contact) { - - var validationErrorObj; - /* ADD CODE TO SET validationErrorObj IF ERRORS ARE FOUND */ - return validationErrorObj; - }, + },*/ /* callback function: cardNonceResponseReceived * Triggered when: SqPaymentForm completes a card nonce request */ cardNonceResponseReceived: function (errors, nonce, cardData) { if (errors) { // Log errors from nonce generation to the Javascript console - console.log("Encountered errors:"); + console.log("cardNonceResponseReceived encountered errors:"); errors.forEach(function (error) { console.log(' ' + error.message); - alert(error.message); }); return; @@ -155,7 +107,6 @@ var paymentForm = new SqPaymentForm({ // POST the nonce form to the payment processing page document.getElementById('nonce-form').submit(); - }, /* callback function: unsupportedBrowserDetected @@ -167,6 +118,7 @@ var paymentForm = new SqPaymentForm({ /* callback function: inputEventReceived * Triggered when: visitors interact with SqPaymentForm iframe elements. */ inputEventReceived: function (inputEvent) { + var e = document.getElementById("error"); switch (inputEvent.eventType) { case 'focusClassAdded': /* HANDLE AS DESIRED */ @@ -175,11 +127,12 @@ var paymentForm = new SqPaymentForm({ /* HANDLE AS DESIRED */ break; case 'errorClassAdded': - document.getElementById("error").innerHTML = "Please fix card information errors before continuing."; + e.innerHTML = "Please fix card information errors before continuing."; + e.style.display = "block"; break; case 'errorClassRemoved': /* HANDLE AS DESIRED */ - document.getElementById("error").style.display = "none"; + e.style.display = "none"; break; case 'cardBrandChanged': /* HANDLE AS DESIRED */ @@ -195,13 +148,19 @@ var paymentForm = new SqPaymentForm({ paymentFormLoaded: function () { /* HANDLE AS DESIRED */ console.log("The form loaded!"); + btn = document.getElementById("sq-creditcard"); + btn.disabled = false; } } }); document.addEventListener("DOMContentLoaded", function(event) { - if (SqPaymentForm.isSupportedBrowser()) { + /* for testing, you can add ...?unsupported to the URL */ + if (SqPaymentForm.isSupportedBrowser() && !window.location.href.includes("unsupported")) { + console.log("loading Square payment form..."); paymentForm.build(); paymentForm.recalculateSize(); + } else { + console.log("not loading form: unsupported browser!"); } }); \ No newline at end of file diff --git a/gms/squarepay/templates/payment_form.html b/gms/squarepay/templates/payment_form.html index dab48743873a3ac9cda853b6b12bfa25f4df1eb4..51751cf7f3ec5607b0f6d0446d23479648e3ad30 100644 --- a/gms/squarepay/templates/payment_form.html +++ b/gms/squarepay/templates/payment_form.html @@ -1,6 +1,8 @@ {% extends "base.html" %} {% load static %} +{% block title %}UCC Payment Gateway{% endblock %} + {% block extrahead %} <!-- link to the SqPaymentForm library --> <script type="text/javascript" src="https://js.squareup.com/v2/paymentform"></script> @@ -9,74 +11,67 @@ <script type="text/javascript"> var applicationId = "{{ app_id }}"; var locationId = "{{ loc_id }}"; +var amount = {{ payment.amount }}; </script> <script type="text/javascript" src="{% static 'squarepay.js' %}"></script> <link rel="stylesheet" type="text/css" href="{% static 'squarepay.css' %}"> {% endblock %} -{% block content %} -{% if response %} -<h1>{{ response }}</h1> -{% endif %} +{% block content_title %}<h1>Pay with card</h1>{% endblock %} +{% block content %} +<div class="form-container"> +<div class="form-header"> + <span class="tips">{% block tips %}<b><i>Please fill in your card details below.</i></b>{% endblock %}</span> + {% if payment.is_paid %}<span class="tips"> + <p><b>It appears you have already successfully attempted this payment.</b><br><br> + Note that you will not be charged twice if even you submit again.</p> + </span>{% endif %} + <noscript> + <span class="tips error">Please enable javascript to use the payment form.</span> + </noscript> +</div> +<!-- the element #form-container is used to create the Square payment form (hardcoded) --> <div id="form-container"> -<div id="sq-walletbox"> - <!-- Placeholder for Apple Pay for Web button --> - <button id="sq-apple-pay"></button> - - <!-- Placeholder for Masterpass button --> - <button id="sq-masterpass"></button> - - <!-- Placeholder for Google Pay button--> - <button id="sq-google-pay" class="button-google-pay"></button> - <div id="sq-walletbox-divider"> - <span id="sq-walletbox-divider-label">Or</span> - <hr /> - </div> -</div> -<div id="sq-ccbox"> - <!-- - Be sure to replace the action attribute of the form with the path of - the Transaction API charge endpoint URL you want to POST the nonce to - (for example, "/process-card") - --> - <form id="nonce-form" novalidate action="{% url 'squarepay:pay' %}" method="post"> - {% csrf_token %} - <fieldset> - <div class="form-row"> - <span class="label">Card Number</span> - <div id="sq-card-number"></div> - </div> + <div id="sq-ccbox"> + <!-- + Be sure to replace the action attribute of the form with the path of + the Transaction API charge endpoint URL you want to POST the nonce to + (for example, "/process-card") + --> + <form id="nonce-form" novalidate action="#" method="post"> + {% csrf_token %} + <div id="error"></div> - <div class="form-row"> - <div class="third"> - <span class="label">Expiration</span> - <div id="sq-expiration-date"></div> + <fieldset> + <div class="form-row"> + <span class="label">Card Number</span> + <div id="sq-card-number"></div> </div> - <div class="third"> - <span class="label">CVV</span> - <div id="sq-cvv"></div> - </div> + <div class="form-row"> + <div class="half"> + <span class="label">Expiration</span> + <div id="sq-expiration-date"></div> + </div> - <div class="third"> - <span class="label">Postcode</span> - <div id="sq-postal-code"></div> + <div class="half"> + <span class="label">CVV</span> + <div id="sq-cvv"></div> + </div> </div> - </div> - </fieldset> - - <button id="sq-creditcard" class="button-credit-card" onclick="requestCardNonce(event)">Pay $1.00</button> + </fieldset> + <button id="sq-creditcard" class="button-credit-card" onclick="requestCardNonce(event)" disabled>Pay with card</button> - <div id="error"></div> - - <!-- - After a nonce is generated it will be assigned to this hidden input field. - --> - <input type="hidden" id="card-nonce" name="nonce"> - </form> -</div> <!-- end #sq-ccbox --> + <!-- + After a nonce is generated it will be assigned to this hidden input field. + --> + <input type="hidden" id="card-nonce" name="nonce"> + </form> + </div> <!-- end #sq-ccbox --> </div> <!-- end #form-container --> +</div> +</div> {% endblock %} \ No newline at end of file diff --git a/gms/squarepay/urls.py b/gms/squarepay/urls.py index 59c1b711f2de2402322302532cdfab7fc20ad612..b31bd3166a46e8834b99639215daccc48ec19f2b 100644 --- a/gms/squarepay/urls.py +++ b/gms/squarepay/urls.py @@ -4,5 +4,5 @@ from .views import PaymentFormView app_name = 'squarepay' urlpatterns = [ - path('pay/', PaymentFormView.as_view(), name='pay'), + path('pay/<int:pk>/<str:token>/', PaymentFormView.as_view(), name='pay'), ] \ No newline at end of file diff --git a/gms/squarepay/views.py b/gms/squarepay/views.py index 959d6ea547e616e27faaa41c7515ad7b18fc1540..3ee1d09619105c68b464d8908009dc58d94cfa09 100644 --- a/gms/squarepay/views.py +++ b/gms/squarepay/views.py @@ -1,6 +1,6 @@ import uuid -from django.shortcuts import render -from django.views.generic.base import TemplateView +from django.views.generic.detail import DetailView +from django.http import HttpResponse, HttpResponseRedirect, Http404 from django.contrib import messages from django.conf import settings @@ -9,26 +9,28 @@ from squareconnect.rest import ApiException from squareconnect.apis.transactions_api import TransactionsApi from squareconnect.apis.locations_api import LocationsApi -class PaymentFormView(TemplateView): +from .models import CardPayment + +class PaymentFormView(DetailView): """ Handles the backend stuff for the Square payment form. See https://docs.connect.squareup.com/payments/sqpaymentform/setup """ template_name = 'payment_form.html' - methods = ['get', 'post'] - app_id = None - loc_id = None - access_key = None - amount = None - idempotency_key = None - sqapi = None - charge_response = None + app_id = None # square app ID (can be accessed by clients) + loc_id = None # square location key (can also be accessed by clients) + access_key = None # this is secret + sqapi = None # keep an instance of the Square API handy + model = CardPayment + slug_field = 'token' + slug_url_kwarg = 'token' + query_pk_and_slug = True + context_object_name = 'payment' def __init__(self, *args, **kwargs): - self.amount = kwargs.pop('payment_amount', 100) super().__init__(*args, **kwargs) # get things from settings @@ -45,40 +47,36 @@ class PaymentFormView(TemplateView): context.update({ 'app_id': self.app_id, 'loc_id': self.loc_id, - 'response': self.charge_response, }) return context def post(self, request, *args, **kwargs): nonce = request.POST.get('nonce', None) if (nonce is None or nonce == ""): - messages.error(request, "No nonce was passed or invalid card data.") + messages.error(request, "No nonce was generated! Please try reloading the page and submit again.") return self.get(request) api_inst = TransactionsApi(self.sqapi) - # this can be reused so we don't double charge the customer - if (self.idempotency_key is None): - self.idempotency_key = str(uuid.uuid1()) - body = { 'idempotency_key': self.idempotency_key, 'card_nonce': nonce, 'amount_money': { - 'amount': self.amount, + 'amount': amount, 'currency': 'AUD' } } try: api_response = api_inst.charge(self.loc_id, body) - self.charge_response = api_response.transaction + messages.success(request, "Your payment of %1.2f was successful.", amount) except ApiException as e: - self.charge_response = None messages.error(request, "Exception while calling TransactionApi::charge: %s" % e) - return self.get(request) - + # redirect to success URL + if (self.object.completed_url is None): + return self.get(request) + return HttpResponseRedirect(self.object.completed_url)