diff --git a/pip-packages.txt b/pip-packages.txt index 445f377d76cfda7f5b2412d1f12c9871595e00c4..ce6254516f9f814849844a3708121e4a89006338 100644 --- a/pip-packages.txt +++ b/pip-packages.txt @@ -25,5 +25,6 @@ pytz==2019.1 requests==2.28.2 six==1.12.0 sqlparse==0.3.0 +squareconnect==2.20190410.0 squareup==26.0.0.20230419 -urllib3==1.25 +urllib3==1.26.15 diff --git a/src/squarepay/payments.py b/src/squarepay/payments.py index b41885e6c80bd7bd356fd5c2dd961f0b8b038dcc..7c69beeb8e7d552b58cdb9dd0f7af13803ac9675 100644 --- a/src/squarepay/payments.py +++ b/src/squarepay/payments.py @@ -5,6 +5,9 @@ import logging from django.conf import settings from django.core.exceptions import ImproperlyConfigured +from square.client import Client + +log = logging.getLogger('squarepay') # load the configuration values app_id = getattr(settings, 'SQUARE_APP_ID', None) @@ -15,7 +18,9 @@ access_key = getattr(settings, 'SQUARE_ACCESS_TOKEN', None) if (app_id is None) or (loc_id is None) or (access_key is None): raise ImproperlyConfigured("Please define SQUARE_APP_ID, SQUARE_LOCATION and SQUARE_ACCESS_TOKEN in settings.py") -def try_capture_payment(card_payment, nonce): +client = Client(access_token=access_key, environment='sandbox') + +def try_capture_payment(card_payment, source_id, verification_token): """ attempt to charge the customer associated with the given card nonce (created by the PaymentForm in JS) Note: this can be called multiple times with the same CardPayment instance but the customer will not @@ -24,10 +29,26 @@ def try_capture_payment(card_payment, nonce): """ request_body = { 'idempotency_key': card_payment.idempotency_key, - 'card_nonce': nonce, + 'source_id': source_id, + 'verification_token': verification_token, 'amount_money': { 'amount': card_payment.amount, 'currency': 'AUD' } } + result = client.payments.create_payment(body=request_body) + + if result.is_success(): + card_payment.set_paid() + return { + "success": True, + "receipt_url": result.body["payment"]["receipt_url"], + } + # Call the error method to see if the call failed + elif result.is_error(): + return { + "success": False, + "errors": result.errors, + } + diff --git a/src/squarepay/views.py b/src/squarepay/views.py index c80825f531d6848bf3eff53cb27033cfbb58d9e6..fd8ae945a7dc4a21a2cde7e2818a6b75e16129a3 100644 --- a/src/squarepay/views.py +++ b/src/squarepay/views.py @@ -1,3 +1,4 @@ +import json import uuid from django.views.generic.base import RedirectView, View from django.views.generic.detail import DetailView @@ -34,23 +35,32 @@ class PaymentFormMixin: payment.set_paid() messages.success(self.request, "Your payment of $%1.2f was successful." % (payment.amount / 100.0)) - def payment_error(self, payment): + def payment_error(self, payment, error): messages.error(self.request, "Your payment of $%1.2f was unsuccessful. Please try again later." % (payment.amount / 100.0)) + messages.error(self.request, error) payment.delete() def post(self, request, *args, **kwargs): - nonce = request.POST.get('nonce', None) + data = json.loads(request.body.decode('utf-8')) + source_id = data['sourceId'] + verification_token = data['verificationToken'] + card_payment = self.get_object() amount_aud = card_payment.amount / 100.0 - - if (nonce is None or nonce == ""): + + if (source_id is None or source_id == ""): messages.error(request, "Failed to collect card details. Please reload the page and submit again.") return self.get(request) - if try_capture_payment(card_payment, nonce): + payment = try_capture_payment(card_payment, source_id, verification_token) + if payment["success"]: self.payment_success(card_payment) + messages.success(request, "Receipt: %s" % payment["receipt_url"]) + return HttpResponse(json.dumps(payment)) else: - self.payment_error(card_payment) + error = payment['errors'][0]['detail'] + self.payment_error(card_payment, error) + return HttpResponse(json.dumps(payment), status=403) # redirect to success URL, or redisplay the form with a success message if none is given return HttpResponseRedirect(self.get_completed_url()) @@ -110,7 +120,8 @@ class MembershipPaymentView(MemberAccessMixin, PaymentFormMixin, DetailView): if (self.object is None): # the membership is already marked as paid and no CardPayment exists # so we add an error and redirect to member home - messages.error(request, "Your membership is already paid. Check the cokelog (/home/other/coke/cokelog) for more details.") + if "/payment/" not in self.request.META['HTTP_REFERER']: + messages.error(request, "Your membership is already paid. Check the cokelog (/home/other/coke/cokelog) for more details.") return HttpResponseRedirect(self.get_completed_url()) else: return super().dispatch(request, *args, **kwargs) diff --git a/src/static/squarepay.css b/src/static/squarepay.css index 91776e920b9cb75bf8848afdb097b613b29b2c02..44080ce36be3a1d58897f12749acabc6f7a16d79 100644 --- a/src/static/squarepay.css +++ b/src/static/squarepay.css @@ -119,7 +119,6 @@ div#sq-expiration-date, div#sq-card-number, div#sq-cvv { .payment-info { float: left; - width: 380px; margin: 20px auto; box-sizing: content-box; border: 2px solid #eee; @@ -134,73 +133,6 @@ div#sq-expiration-date, div#sq-card-number, div#sq-cvv { margin: 0; } -/*#content, #container, body { - height: 100%; -} -*/ -/* Customize the Apple Pay on the Web button */ -#sq-apple-pay { - width: 100%; - height: 48px; - padding: 0; - margin: 24px 0 16px 0; - background-image: -webkit-named-image(apple-pay-logo-white); - background-color: black; - background-size: 100% 60%; - background-repeat: no-repeat; - background-position: 50% 50%; - border-radius: 4px; - cursor: pointer; - display: none; -} - -/* Customize the Masterpass button */ -#sq-masterpass { - width: 100%; - height: 48px; - padding: 0; - margin: 24px 0 24px; - background-image: url("https://masterpass.com/dyn/img/acc/global/mp_mark_hor_wht.svg"); - background-color: black; - background-size: 60% 60%; - background-repeat: no-repeat; - background-position: calc((100% - 32px) / 2) 50%; - border-radius: 4px; - cursor: pointer; - display: none; -} - -/*#sq-masterpass::after { - box-sizing: border-box; - float: right; - width: 32px; - height: 48px; - padding-top: 12px; - 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 { - min-width: 200px; - min-height: 40px; - padding: 11px 24px; - margin: 10px; - background-color: #000; - background-image: url(data:image/svg+xml,%3Csvg%20width%3D%22103%22%20height%3D%2217%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cg%20fill%3D%22none%22%20fill-rule%3D%22evenodd%22%3E%3Cpath%20d%3D%22M.148%202.976h3.766c.532%200%201.024.117%201.477.35.453.233.814.555%201.085.966.27.41.406.863.406%201.358%200%20.495-.124.924-.371%201.288s-.572.64-.973.826v.084c.504.177.912.471%201.225.882.313.41.469.891.469%201.442a2.6%202.6%200%200%201-.427%201.47c-.285.43-.667.763-1.148%201.001A3.5%203.5%200%200%201%204.082%2013H.148V2.976zm3.696%204.2c.448%200%20.81-.14%201.085-.42.275-.28.413-.602.413-.966s-.133-.684-.399-.959c-.266-.275-.614-.413-1.043-.413H1.716v2.758h2.128zm.238%204.368c.476%200%20.856-.15%201.141-.448.285-.299.427-.644.427-1.036%200-.401-.147-.749-.441-1.043-.294-.294-.688-.441-1.183-.441h-2.31v2.968h2.366zm5.379.903c-.453-.518-.679-1.239-.679-2.163V5.86h1.54v4.214c0%20.579.138%201.013.413%201.302.275.29.637.434%201.085.434.364%200%20.686-.096.966-.287.28-.191.495-.446.644-.763a2.37%202.37%200%200%200%20.224-1.022V5.86h1.54V13h-1.456v-.924h-.084c-.196.336-.5.611-.91.826-.41.215-.845.322-1.302.322-.868%200-1.528-.259-1.981-.777zm9.859.161L16.352%205.86h1.722l2.016%204.858h.056l1.96-4.858H23.8l-4.41%2010.164h-1.624l1.554-3.416zm8.266-6.748h1.666l1.442%205.11h.056l1.61-5.11h1.582l1.596%205.11h.056l1.442-5.11h1.638L36.392%2013h-1.624L33.13%207.876h-.042L31.464%2013h-1.596l-2.282-7.14zm12.379-1.337a1%201%200%200%201-.301-.735%201%201%200%200%201%20.301-.735%201%201%200%200%201%20.735-.301%201%201%200%200%201%20.735.301%201%201%200%200%201%20.301.735%201%201%200%200%201-.301.735%201%201%200%200%201-.735.301%201%201%200%200%201-.735-.301zM39.93%205.86h1.54V13h-1.54V5.86zm5.568%207.098a1.967%201.967%200%200%201-.686-.406c-.401-.401-.602-.947-.602-1.638V7.218h-1.246V5.86h1.246V3.844h1.54V5.86h1.736v1.358H45.75v3.36c0%20.383.075.653.224.812.14.187.383.28.728.28.159%200%20.299-.021.42-.063.121-.042.252-.11.392-.203v1.498c-.308.14-.681.21-1.12.21-.317%200-.616-.051-.896-.154zm3.678-9.982h1.54v2.73l-.07%201.092h.07c.205-.336.511-.614.917-.833.406-.22.842-.329%201.309-.329.868%200%201.53.254%201.988.763.457.509.686%201.202.686%202.079V13h-1.54V8.688c0-.541-.142-.947-.427-1.218-.285-.27-.656-.406-1.113-.406-.345%200-.656.098-.931.294a2.042%202.042%200%200%200-.651.777%202.297%202.297%200%200%200-.238%201.029V13h-1.54V2.976zm32.35-.341v4.083h2.518c.6%200%201.096-.202%201.488-.605.403-.402.605-.882.605-1.437%200-.544-.202-1.018-.605-1.422-.392-.413-.888-.62-1.488-.62h-2.518zm0%205.52v4.736h-1.504V1.198h3.99c1.013%200%201.873.337%202.582%201.012.72.675%201.08%201.497%201.08%202.466%200%20.991-.36%201.819-1.08%202.482-.697.665-1.559.996-2.583.996h-2.485v.001zm7.668%202.287c0%20.392.166.718.499.98.332.26.722.391%201.168.391.633%200%201.196-.234%201.692-.701.497-.469.744-1.019.744-1.65-.469-.37-1.123-.555-1.962-.555-.61%200-1.12.148-1.528.442-.409.294-.613.657-.613%201.093m1.946-5.815c1.112%200%201.989.297%202.633.89.642.594.964%201.408.964%202.442v4.932h-1.439v-1.11h-.065c-.622.914-1.45%201.372-2.486%201.372-.882%200-1.621-.262-2.215-.784-.594-.523-.891-1.176-.891-1.96%200-.828.313-1.486.94-1.976s1.463-.735%202.51-.735c.892%200%201.629.163%202.206.49v-.344c0-.522-.207-.966-.621-1.33a2.132%202.132%200%200%200-1.455-.547c-.84%200-1.504.353-1.995%201.062l-1.324-.834c.73-1.045%201.81-1.568%203.238-1.568m11.853.262l-5.02%2011.53H96.42l1.864-4.034-3.302-7.496h1.635l2.387%205.749h.032l2.322-5.75z%22%20fill%3D%22%23FFF%22%2F%3E%3Cpath%20d%3D%22M75.448%207.134c0-.473-.04-.93-.116-1.366h-6.344v2.588h3.634a3.11%203.11%200%200%201-1.344%202.042v1.68h2.169c1.27-1.17%202.001-2.9%202.001-4.944%22%20fill%3D%22%234285F4%22%2F%3E%3Cpath%20d%3D%22M68.988%2013.7c1.816%200%203.344-.595%204.459-1.621l-2.169-1.681c-.603.406-1.38.643-2.29.643-1.754%200-3.244-1.182-3.776-2.774h-2.234v1.731a6.728%206.728%200%200%200%206.01%203.703%22%20fill%3D%22%2334A853%22%2F%3E%3Cpath%20d%3D%22M65.212%208.267a4.034%204.034%200%200%201%200-2.572V3.964h-2.234a6.678%206.678%200%200%200-.717%203.017c0%201.085.26%202.11.717%203.017l2.234-1.731z%22%20fill%3D%22%23FABB05%22%2F%3E%3Cpath%20d%3D%22M68.988%202.921c.992%200%201.88.34%202.58%201.008v.001l1.92-1.918c-1.165-1.084-2.685-1.75-4.5-1.75a6.728%206.728%200%200%200-6.01%203.702l2.234%201.731c.532-1.592%202.022-2.774%203.776-2.774%22%20fill%3D%22%23E94235%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E); - background-origin: content-box; - background-position: center; - background-repeat: no-repeat; - background-size: contain; - border: 0; - border-radius: 4px; - box-shadow: 0 1px 1px 0 rgba(60, 64, 67, 0.30), 0 1px 3px 1px rgba(60, 64, 67, 0.15); - outline: 0; - cursor: pointer; - display: none; -} - #error { width: 100%; margin-top: 16px; @@ -211,3 +143,191 @@ div#sq-expiration-date, div#sq-card-number, div#sq-cvv { opacity: 0.8; display: none; } + +#payment-form { + max-width: 550px; + min-width: 300px; + } + + .buyer-inputs { + display: flex; + gap: 20px; + justify-content: space-between; + border: none; + margin: 0; + padding: 0; + } + + #card-container { + /* this height depends on the size of the container element */ + /* We transition from a single row to double row at 485px */ + /* Settting this min-height minimizes the impact of the card form loading */ + min-height: 90px; + } + + #gift-card-container { + margin-top: 45px; + min-height: 90px; + } + + @media screen and (max-width: 500px) { + #card-container { + min-height: 140px; + } + } + + #ach-button { + margin-top: 20px; + } + + #landing-page-layout { + width: 80%; + margin: 150px auto; + max-width: 1000px; + } + + #its-working { + color: #737373; + } + + #example-container { + width: 100%; + border: 1px solid #b3b3b3; + padding: 48px; + margin: 32px 0; + border-radius: 12px; + } + + #example-list { + display: flex; + flex-direction: column; + gap: 15px; + } + + #customer-input { + margin-bottom: 40px; + } + + #card-input { + margin-top: 0; + margin-bottom: 40px; + } + + h3 { + margin: 0; + } + + p { + line-height: 24px; + } + + label { + font-size: 12px; + width: 100%; + } + + input { + padding: 12px; + width: 100%; + border-radius: 5px; + border-width: 1px; + margin-top: 20px; + font-size: 16px; + border: 1px solid rgba(0, 0, 0, 0.15); + } + + input:focus { + border: 1px solid #006aff; + } + + button { + color: #ffffff; + background-color: #006aff; + border-radius: 5px; + cursor: pointer; + border-style: none; + user-select: none; + outline: none; + font-size: 16px; + font-weight: 500; + line-height: 24px; + padding: 12px; + width: 100%; + box-shadow: 1px; + } + + button:active { + background-color: rgb(0, 85, 204); + } + + button:disabled { + background-color: rgba(0, 0, 0, 0.05); + color: rgba(0, 0, 0, 0.3); + } + + button.success { + color: #FFFFFF; + background-color:rgb(82, 165, 0) + } + + button.failure { + color: #FFFFFF; + background-color: #E02F2F; + } + + .lds-ellipsis { + display: inline-block; + position: relative; + width: 80px; + height: 80px; + } + .lds-ellipsis div { + position: absolute; + top: 33px; + width: 13px; + height: 13px; + border-radius: 50%; + background: #3e3e3e; + animation-timing-function: cubic-bezier(0, 1, 1, 0); + } + .lds-ellipsis div:nth-child(1) { + left: 8px; + animation: lds-ellipsis1 0.6s infinite; + } + .lds-ellipsis div:nth-child(2) { + left: 8px; + animation: lds-ellipsis2 0.6s infinite; + } + .lds-ellipsis div:nth-child(3) { + left: 32px; + animation: lds-ellipsis2 0.6s infinite; + } + .lds-ellipsis div:nth-child(4) { + left: 56px; + animation: lds-ellipsis3 0.6s infinite; + } + @keyframes lds-ellipsis1 { + 0% { + transform: scale(0); + } + 100% { + transform: scale(1); + } + } + @keyframes lds-ellipsis3 { + 0% { + transform: scale(1); + } + 100% { + transform: scale(0); + } + } + @keyframes lds-ellipsis2 { + 0% { + transform: translate(0, 0); + } + 100% { + transform: translate(24px, 0); + } + } + \ No newline at end of file diff --git a/src/templates/payment_form.html b/src/templates/payment_form.html index 90aa360d3d9760fb15accfcad78db44869be6054..489042ed5252817240159304f3c238d78e70ce3b 100644 --- a/src/templates/payment_form.html +++ b/src/templates/payment_form.html @@ -5,14 +5,165 @@ {% block extrahead %} +<script type="text/javascript" src="web.squarecdn.com/v1/square.js"></script> {# bring the location IDs into javascript so the next bits know about them #} <script type="text/javascript"> -var applicationId = "{{ app_id }}"; + +var appId = "{{ app_id }}"; var locationId = "{{ loc_id }}"; var amount = "{{ payment.amount }}"; + +async function initializeCard(payments) { + const card = await payments.card(); + await card.attach('#card-container'); + return card; +} + +// Call this function to send a payment token, buyer name, and other details +// to the project server code so that a payment can be created with +// Payments API +async function createPayment(token, verificationToken) { + const csrfToken = document.querySelector('input[name=csrfmiddlewaretoken]').value; + const body = JSON.stringify({ + locationId, + sourceId: token, + verificationToken: verificationToken, + }); + + // if (verificationToken !== undefined) { + // body.verificationToken = verificationToken; + // } + + const paymentResponse = await fetch(location.href, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': csrfToken, + }, + body, + credentials: 'same-origin', + }); + + return paymentResponse.json(); +} + +// This function tokenizes a payment method. +// The ‘error’ thrown from this async function denotes a failed tokenization, +// which is due to buyer error (such as an expired card). It is up to the +// developer to handle the error and provide the buyer the chance to fix +// their mistakes. +async function tokenize(paymentMethod) { + const tokenResult = await paymentMethod.tokenize(); + if (tokenResult.status === 'OK') { + return tokenResult.token; + } else { + let errorMessage = `Tokenization failed-status: ${tokenResult.status}`; + if (tokenResult.errors) { + errorMessage += ` and errors: ${JSON.stringify( + tokenResult.errors + )}`; + } + throw new Error(errorMessage); + } +} + +async function verifyBuyer(payments, token) { + const verificationDetails = { + amount: '{{ payment.amount }}', + /* collected from the buyer */ + billingContact: {}, + currencyCode: 'AUD', + intent: 'CHARGE', + }; + + const verificationResults = await payments.verifyBuyer( + token, + verificationDetails + ); + + return verificationResults.token; +} + +// Helper method for displaying the Payment Status on the screen. +// status is either SUCCESS or FAILURE; +function displayPaymentResults(status) { + const cardButton = document.getElementById( + 'card-button' + ); + if (status === 'SUCCESS') { + cardButton.classList.remove('failure'); + cardButton.classList.add('success'); + cardButton.innerText = 'Payment successful! Redirecting...'; + } else { + cardButton.classList.remove('success'); + cardButton.classList.add('failure'); + cardButton.innerText = 'Payment failed :( Refreshing...'; + } +} + + +document.addEventListener('DOMContentLoaded', async function() { + const loader = document.getElementById('spinner'); + const cardButton = document.getElementById( + 'card-button' + ); + + cardButton.disabled = true; + if (!window.Square) { + throw new Error('Square.js failed to load properly'); + } + const payments = window.Square.payments(appId, locationId); + let card; + try { + card = await initializeCard(payments); + loader.style.display = 'none'; + cardButton.disabled = false; + } catch (e) { + console.error('Initializing Card failed', e); + return; + } + + async function handlePaymentMethodSubmission(event, paymentMethod, shouldVerify = true) { + event.preventDefault(); + cardButton.disabled = true; + cardButton.innerText = "Processing..."; + const token = await tokenize(paymentMethod); + + let verificationToken; + if (shouldVerify) { + verificationToken = await verifyBuyer( + payments, + token + ); + } + + const paymentResults = await createPayment(token, verificationToken); + if (paymentResults.success) { + displayPaymentResults('SUCCESS'); + setTimeout( + function(){window.location.href = "{{ payment.completed_url }}";}, 3000 + ); + } else { + cardButton.disabled = false; + displayPaymentResults('FAILURE'); + console.error(paymentResults.errors); + setTimeout( + function(){window.location.href = "{{ payment.completed_url }}";}, 3000 + ); + } + console.debug('Payment Success', paymentResults); + + } + + cardButton.addEventListener('click', async function(event) { + await handlePaymentMethodSubmission(event, card, true); + }); + +}); </script> <link rel="stylesheet" type="text/css" href="{% static 'squarepay.css' %}"> + {% endblock %} @@ -21,15 +172,17 @@ var amount = "{{ payment.amount }}"; <div class="form-container text-center"> <div class="form-header"> <span class="tips {% if payment.is_paid %}error{% endif %}">{% block tips %} - {% if payment.is_paid %} - <p><b>It appears you have already successfully attempted this payment.</b> - {% if payment.completed_url %} - <br><br>Perhaps you were meaning to find <a href="{{ payment.completed_url }}">this page</a>. - {% endif %}</p> - {% else %} - <b><i>Please fill in your card details below.</i></b> - {% endif %} - {% endblock %}</span> + {% if payment.is_paid %} + <p><b>It appears you have already successfully attempted this payment.</b> + {% if payment.completed_url %} + <br><br>Perhaps you were meaning to find <a href="{{ payment.completed_url }}">this page</a>. + {% endif %} + </p> + {% else %} + <b><i>Please fill in your card details below.</i></b> + {% endif %} + </span> +{% endblock %} <noscript> <span class="tips error">Please enable javascript to use the payment form.</span> @@ -48,6 +201,14 @@ var amount = "{{ payment.amount }}"; </div> <!-- the element #form-container is used to create the Square payment form (hardcoded) --> <div id="form-container" class="payment-info"> + <div class="float-container"> + {% csrf_token %} + <div id=spinner class="lds-ellipsis" id="payment-form"><div></div><div></div><div></div><div></div></div> + <form id="payment-form"> + <div id="card-container"></div> + <button id="card-button" type="button">Pay {{ amount }}</button> + </form> + </div> <!-- end #float-container --> </div> <!-- end #form-container --> </div> </div>