diff --git a/README.md b/README.md index 13667f71..87c5d6cc 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,18 @@ hugo mod clean --all Then run the `hugo serve` again. +### Testing locally + +#### Payments +The `getting-help` page contains products for sale. + +It is possible to test the product checkout flow. +The development environment provides a connection to the staging Paygate server, +which allows testing of the full checkout process, including payment. + +To pay for a product, it is necessary to use +[LHV sandbox cards](https://merchant.lhv.ee/help/en/articles/12807566-test-cards). + ## Documentation The documentation is located in a [separate repository][documentation-repo]. diff --git a/site/assets/js/modules/paygate/purchases.js b/site/assets/js/modules/paygate/purchases.js index 2a91f14b..6c10dbb1 100644 --- a/site/assets/js/modules/paygate/purchases.js +++ b/site/assets/js/modules/paygate/purchases.js @@ -43,13 +43,13 @@ */ /** - * Paygate product data returned by the product endpoint. + * Paygate order data returned by the purchase endpoint. * - * @typedef {Object} PaygateProduct - * @property {string} id product identifier - * @property {string} name product display name - * @property {string} description product description shown on checkout - * @property {PaygateMoney} netAmount product price before VAT + * @typedef {Object} PaygateOrder + * @property {string} orderId paygate order ID + * @property {string} productTitle product display title + * @property {string} productDescription product description shown on checkout + * @property {boolean} paymentCompleted whether the order was already paid */ /** @@ -158,8 +158,8 @@ * Paygate purchase endpoint methods used by checkout. * * @typedef {Object} PaygatePurchaseClient - * @property {function(string): Promise} getProduct - * loads checkout product data by product ID + * @property {function(string): Promise} getOrder + * loads checkout order data by order ID * @property {function(string): Promise} placeOrder * creates a checkout order for the given product ID * @property {function(CalculateChargesRequest): Promise} calculateCharges @@ -176,8 +176,8 @@ */ export function createPurchaseClient(serverUrl) { return { - getProduct(productId) { - return getJson(`${serverUrl}/products/${encodeURIComponent(productId)}`); + getOrder(orderId) { + return getJson(`${serverUrl}/purchases/${encodeURIComponent(orderId)}`); }, placeOrder(productId) { return postJson(`${serverUrl}/purchases/place-order`, {productId}); diff --git a/site/assets/js/pages/checkout/dom.js b/site/assets/js/pages/checkout/dom.js index 2fff1c10..d31bdcaf 100644 --- a/site/assets/js/pages/checkout/dom.js +++ b/site/assets/js/pages/checkout/dom.js @@ -44,7 +44,7 @@ * @property {JQuery} $loadingSpinner summary spinner element * @property {JQuery} $loadingText summary loading text element * @property {JQuery} $loadingSupport summary support text element - * @property {JQuery} $productName product name element + * @property {JQuery} $productTitle product title element * @property {JQuery} $productDescription product description * element * @property {JQuery} $subtotalValue subtotal value element @@ -53,7 +53,7 @@ * @property {JQuery} $totalValue total amount element * @property {JQuery} $submitButton checkout submit button * @property {JQuery} $errorModal generic checkout error modal - * @property {JQuery} $notFound product-not-found result panel + * @property {JQuery} $notFound order-not-found result panel * @property {JQuery} $summaryError generic checkout summary-error panel * @property {HTMLFormElement} form native checkout form element */ @@ -78,7 +78,7 @@ export function getCheckoutDom() { $loadingSpinner: $('#checkout-summary-loading-spinner'), $loadingText: $('#checkout-summary-loading-text'), $loadingSupport: $('#checkout-summary-support'), - $productName: $('#checkout-product-name'), + $productTitle: $('#checkout-product-title'), $productDescription: $('#checkout-product-description'), $subtotalValue: $('#checkout-subtotal-value'), $vatLabel: $('#checkout-vat-label'), diff --git a/site/assets/js/pages/checkout/index.js b/site/assets/js/pages/checkout/index.js index 053c96ed..e13867e4 100644 --- a/site/assets/js/pages/checkout/index.js +++ b/site/assets/js/pages/checkout/index.js @@ -43,18 +43,16 @@ $( return; } - const purchaseClient = createPurchaseClient(params.paygate.serverurl); - const productId = getProductId(); + const purchaseClient = createPurchaseClient(params.payment.paygateurl); + const orderId = getOrderId(); const view = createCheckoutView(dom); const formController = createCheckoutFormController({dom}); - let orderId = null; - let orderPromise = null; let countryManuallySelected = false; let phoneCountryManuallySelected = false; const chargeController = createChargeController({ purchaseClient, view, - ensureOrderId, + ensureOrderId: () => Promise.resolve(orderId), getBuyerCountryCode: () => dom.$country.val(), getVatId: () => (dom.$vatId.val() || '').trim(), onFieldValidationStateChange: state => { @@ -64,9 +62,8 @@ $( logApiError }); - if (!productId) { - chargeController.invalidate(); - view.showNotFoundView(); + if (!orderId) { + redirectToGettingHelp(); return; } @@ -74,7 +71,7 @@ $( formController.updatePhoneCountryDisplay(); formController.bindPhoneEvents(); chargeController.updateSubmitState(); - loadProduct(); + loadOrder(); bindEvents(); /** @@ -136,16 +133,16 @@ $( } /** - * Loads product details for the checkout page from the current checkout URL. + * Loads order details for the checkout page from the current checkout URL. * - * @return {Promise} resolves when the initial product load flow finishes + * @return {Promise} resolves when the initial order load flow finishes */ - async function loadProduct() { + async function loadOrder() { view.setSummaryLoading(true); try { - const product = await purchaseClient.getProduct(productId); - view.fillProductSummary(product); + const order = await purchaseClient.getOrder(orderId); + view.fillOrderSummary(order); view.setSummaryLoading(false); dom.$form.prop('hidden', false); chargeController.updateSubmitState(); @@ -197,12 +194,19 @@ $( } /** - * Reads the product ID from the `product` query parameter. + * Reads the order ID from the `orderId` query parameter. * - * @return {string} checkout product ID, or empty string when unavailable + * @return {string} checkout order ID, or empty string when unavailable */ - function getProductId() { - return (new URLSearchParams(window.location.search).get('product') || '').trim(); + function getOrderId() { + return (new URLSearchParams(window.location.search).get('orderId') || '').trim(); + } + + /** + * Redirects visitors with incomplete checkout links to the help page. + */ + function redirectToGettingHelp() { + window.location.replace('/getting-help'); } /** @@ -238,31 +242,5 @@ $( chargeController.requestIfReady(); } - - /** - * Places the order once and reuses it for all later charge calculations. - * - * @return {Promise} paygate order ID - */ - async function ensureOrderId() { - if (orderId) { - return orderId; - } - - if (!orderPromise) { - orderPromise = purchaseClient - .placeOrder(productId) - .then(response => { - orderId = response.orderId; - return orderId; - }) - .catch(error => { - orderPromise = null; - throw error; - }); - } - - return orderPromise; - } } ); diff --git a/site/assets/js/pages/checkout/view-controller.js b/site/assets/js/pages/checkout/view-controller.js index f6dfec6e..e49a6009 100644 --- a/site/assets/js/pages/checkout/view-controller.js +++ b/site/assets/js/pages/checkout/view-controller.js @@ -36,8 +36,8 @@ * @typedef {Object} CheckoutViewController * @property {function(): void} closeErrorModal * closes the generic checkout error modal - * @property {function(Object): void} fillProductSummary - * fills summary fields with product data + * @property {function(Object): void} fillOrderSummary + * fills summary fields with order data * @property {function(): boolean} isFormHidden * checks whether the checkout form is currently hidden * @property {function(boolean): void} setSubmitDisabled @@ -47,7 +47,7 @@ * @property {function(): void} showErrorModal * opens the generic checkout error modal * @property {function(): void} showNotFoundView - * shows the checkout product-not-found panel + * shows the checkout order-not-found panel * @property {function(): void} showSummaryError * shows the generic checkout summary-error panel * @property {function(Object): void} updateCharges @@ -81,27 +81,27 @@ export function createCheckoutView(dom) { } /** - * Fills the order summary with product details returned by Paygate. + * Fills the order summary with order details returned by Paygate. * - * @param {Object} product paygate product data for the current order + * @param {Object} order paygate order data for the current order */ - function fillProductSummary(product) { - if (!product) { + function fillOrderSummary(order) { + if (!order) { return; } - dom.$productName.text(product.name || 'Unnamed product').prop('hidden', false); + dom.$productTitle.text(order.productTitle || 'Untitled product').prop('hidden', false); - if (product.description) { - dom.$productDescription.text(product.description).prop('hidden', false); + if (order.productDescription) { + dom.$productDescription.text(order.productDescription).prop('hidden', false); } else { dom.$productDescription.text('').prop('hidden', true); } - dom.$subtotalValue.text(formatMoney(product.netAmount)); + dom.$subtotalValue.text(formatMoney(order.netAmount)); dom.$vatLabel.text('VAT'); - dom.$vatValue.text(formatMoney(zeroMoney(product.netAmount.currency))); - dom.$totalValue.text(formatMoney(product.netAmount)); + dom.$vatValue.text(formatMoney(zeroMoney(order.netAmount.currency))); + dom.$totalValue.text(formatMoney(order.netAmount)); } /** @@ -206,7 +206,7 @@ export function createCheckoutView(dom) { return { closeErrorModal, - fillProductSummary, + fillOrderSummary, isFormHidden, setSubmitDisabled, setSummaryLoading, diff --git a/site/assets/js/pages/pricing.js b/site/assets/js/pages/pricing.js index 3af57b6f..3c934aaa 100644 --- a/site/assets/js/pages/pricing.js +++ b/site/assets/js/pages/pricing.js @@ -27,6 +27,7 @@ 'use strict'; import * as params from '@params'; +import {createPurchaseClient} from 'js/modules/paygate/purchases'; /** * The user's consent. @@ -38,25 +39,27 @@ import * as params from '@params'; * process his personal information * @property {boolean} supportAgreementConsent indicates whether the user agrees to be * bound by the terms of the Development Support Agreement + * @property {boolean} newsletterConsent indicates whether the user agrees to receive + * newsletters */ /** - * The user's consent transaction data. + * The user's consent submission data. * - *

The user's consents and the reCAPTCHA token. The token uses to verify whether it is a + *

The user's consents and the reCAPTCHA token. The token is used to verify whether it is a * real user or some malicious code. * - * @typedef {Object} TransactionRequest - * @property {Consent} Consent the user's consent - * @property {string} token the reCAPTCHA token generated by Google API + * @typedef {Object} ConsentRequest + * @property {string} orderId paygate order ID + * @property {Consent} consent the user's consent + * @property {{token: string}} recaptcha reCAPTCHA verification payload */ /** - * The user's consent transaction ID and 2checkout signature. + * Paygate response for the `place-order` endpoint. * - * @typedef {Object} Transaction - * @property {string} id consent transaction ID - * @property {string} signature HMAC signature for 2checkout payment form processing + * @typedef {Object} PlaceOrderResponse + * @property {string} orderId created paygate order ID */ $( function () { @@ -70,9 +73,12 @@ $( const $linkBack = $redirectScreen.find('#linkBack'); const $consentCheckboxes = $("input[type='checkbox'].consent"); + const purchaseClient = createPurchaseClient(params.payment.paygateurl); const reCaptchaSiteKey = params.payment.recaptchakey; - const orderUrl = params.payment.orderurl; - const apiUrl = params.payment.apiurl; + const consentUrl = params.payment.consenturl; + const productId = ($('[data-paygate-product-id]').attr('data-paygate-product-id') || '') + .trim(); + const shouldSubmitConsent = params.environment === 'production'; $orderButtonHolder.tooltip('enable'); @@ -120,59 +126,122 @@ $( } /** - * Submits consent transaction. + * Places a Paygate order, sends consent in production, and opens checkout. * - *

Prepares transaction URL, Privacy and Support Agreement consents data, generates - * the reCAPTCHA token and adds it to the request data. After that calls send transaction - * function. + *

Development builds skip consent submission so local Paygate testing is not blocked by + * the production consent service. */ - function submitOrder() { - const transactionUrl = `${apiUrl}/transaction`; - const privacyConsent = $privacyConsent.prop("checked"); - const supportAgreementConsent = $supportAgreementConsent.prop("checked"); - + async function submitOrder() { showRedirect(); + setOrderButtonProcessing(true); - grecaptcha.ready(() => { - grecaptcha - .execute(reCaptchaSiteKey, {action: 'submitPaymentTransaction'}) - .then((token) => { - const transactionRequest = { - consent: {privacyConsent, supportAgreementConsent}, - token - }; - sendPaymentTransaction(transactionUrl, transactionRequest); - }); - }); + try { + if (!productId) { + throw new Error('Paygate product ID is not configured.'); + } + + const order = await purchaseClient.placeOrder(productId); + const orderId = order.orderId; + + if (!orderId) { + throw new Error('Paygate did not return an order ID.'); + } + + if (shouldSubmitConsent) { + const token = await getRecaptchaToken(); + await submitConsent(buildConsentRequest(orderId, token)); + } + + redirectToCheckout(orderId); + } catch (error) { + logOrderError(error); + showErrorMessage(); + setOrderButtonProcessing(false); + } } /** - * Generates the payment processing transaction. - * - *

Sends the transaction data and returns the transaction ID. If the request is - * successful, redirects to the Payment screen. + * Builds the production consent API request. * - * @param {string} transactionUrl the transaction API URL - * @param {TransactionRequest} transactionRequest the Consent transaction registration - * request + * @param {string} orderId paygate order ID + * @param {string} token reCAPTCHA token generated by Google API + * @return {ConsentRequest} consent request body */ - function sendPaymentTransaction(transactionUrl, transactionRequest) { - $.ajax(transactionUrl, { - type: 'POST', - data: JSON.stringify(transactionRequest), - contentType: 'application/json', - success: (transaction) => { - redirectToPaymentPage(transaction); - hideRedirect(); + function buildConsentRequest(orderId, token) { + return { + orderId, + consent: { + privacyConsent: $privacyConsent.prop('checked'), + supportAgreementConsent: $supportAgreementConsent.prop('checked'), }, - error: (jqXhr) => { - console.error(`${jqXhr.status}: ${jqXhr.statusText}`); - console.error(`Error message: ${jqXhr.responseJSON.message}`); - showErrorMessage(); + recaptcha: { + token } + }; + } + + /** + * Gets the invisible reCAPTCHA token for consent submission. + * + * @return {Promise} generated reCAPTCHA token + */ + function getRecaptchaToken() { + return new Promise((resolve, reject) => { + if (!window.grecaptcha) { + reject(new Error('reCAPTCHA is unavailable.')); + return; + } + + grecaptcha.ready(() => { + grecaptcha + .execute(reCaptchaSiteKey, {action: 'submitConsent'}) + .then(resolve) + .catch(reject); + }); }); } + /** + * Sends the consent payload to the production consent endpoint. + * + * @param {ConsentRequest} consentRequest consent request body + * @return {Promise} resolves when consent is accepted + */ + async function submitConsent(consentRequest) { + const response = await fetch(consentUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(consentRequest) + }); + + if (!response.ok) { + throw ({ + status: response.status, + statusText: response.statusText, + body: await readResponseBody(response) + }); + } + } + + /** + * Parses a fetch response body as JSON when possible, otherwise as text. + * + * @param {Response} response fetch response to parse + * @return {Promise<*>} parsed JSON, text, or null when there is no readable body + */ + async function readResponseBody(response) { + const contentType = response.headers.get('content-type') || ''; + try { + return contentType.includes('application/json') + ? await response.json() + : await response.text(); + } catch (ignored) { + return null; + } + } + /** * Shows the redirect screen. */ @@ -195,15 +264,42 @@ $( */ function hideRedirect() { $redirectScreen.hide(); + setOrderButtonProcessing(false); + } + + /** + * Enables or disables the order button while the order flow is in progress. + * + * @param {boolean} isProcessing whether the order flow is currently running + */ + function setOrderButtonProcessing(isProcessing) { + $orderButton.prop('disabled', isProcessing || !isConsentObtained()); + $orderButton.toggleClass('disabled', isProcessing || !isConsentObtained()); } /** - * Redirects to the payment page. + * Redirects to the checkout page for the created order. * - * @param {Transaction} transaction the consent transaction response. + * @param {string} orderId paygate order ID */ - function redirectToPaymentPage(transaction) { - window.location = `${orderUrl}&customer-ext-ref=${transaction.id}&signature=${transaction.signature}`; + function redirectToCheckout(orderId) { + window.location = `/checkout/?orderId=${encodeURIComponent(orderId)}`; + } + + /** + * Logs order-flow failures in a compact and consistent format. + * + * @param {Object|Error} error order-flow error + */ + function logOrderError(error) { + console.error( + `${error.status || 'Order flow error'}: ` + + `${error.statusText || error.message || 'Request failed'}` + ); + + if (error.body) { + console.error('Error response:', error.body); + } } } ); diff --git a/site/config/_default/hugo.toml b/site/config/_default/hugo.toml index fbfaeb14..0d71eaf5 100644 --- a/site/config/_default/hugo.toml +++ b/site/config/_default/hugo.toml @@ -15,13 +15,10 @@ disableKinds = ['taxonomy', 'term'] description = 'Open-source CQRS/ES framework for modern cloud applications' [params.payment] - apiURL = 'https://us-central1-spine-site-server.cloudfunctions.net/paymentTransaction' - orderURL = 'https://secure.2checkout.com/checkout/buy?merchant=999999999589¤cy=EUR&tpl=default&prod=4HJME1JJDF&qty=1' + consentURL = 'https://us-central1-spine-site-server.cloudfunctions.net/api/consent' + paygateURL = 'https://paygate.teamdev.com' reCaptchaKey = '6Lef7b0ZAAAAAHPIbf6XQIzzeyCoSzS56GTej1c0' -[params.paygate] - serverURL = 'https://stag.paygate.teamdev.com' - [params.libs.docsearch] appId = 'DUYV0WFHKV' apiKey = '50ce4ead490484f1436ae042c0a1a4dd' diff --git a/site/config/development/hugo.toml b/site/config/development/hugo.toml index ab1f3487..c5c23c67 100644 --- a/site/config/development/hugo.toml +++ b/site/config/development/hugo.toml @@ -1,5 +1,3 @@ [params.payment] - apiURL = 'http://localhost:5002/spine-site-server/us-central1/paymentTransaction' - -[params.paygate] - serverURL = 'https://stag.paygate.teamdev.com' + consentURL = 'http://localhost:5002/spine-site-server/us-central1/api/consent' + paygateURL = 'https://stag.paygate.teamdev.com' diff --git a/site/content/getting-help/services.json b/site/content/getting-help/services.json index 1076ed7c..85c9a690 100644 --- a/site/content/getting-help/services.json +++ b/site/content/getting-help/services.json @@ -4,6 +4,7 @@ "header_cols": [ { "icon": "fas fa-brackets-curly", + "paygate_product_id": "spine-standard-support", "title": "Standard Support Pack", "subtitle": "Get ongoing guidance when implementing your project with Spine" }, diff --git a/site/layouts/_partials/getting-help/comparable-services-footer.html b/site/layouts/_partials/getting-help/comparable-services-footer.html index 8e3cce5e..c82d36cf 100644 --- a/site/layouts/_partials/getting-help/comparable-services-footer.html +++ b/site/layouts/_partials/getting-help/comparable-services-footer.html @@ -27,7 +27,7 @@ - + - -

- {{ partial "getting-help/mailto-button.html" . }}
diff --git a/site/layouts/_partials/getting-help/services.html b/site/layouts/_partials/getting-help/services.html index 501a954c..35751883 100644 --- a/site/layouts/_partials/getting-help/services.html +++ b/site/layouts/_partials/getting-help/services.html @@ -31,8 +31,6 @@ The Google reCaptcha is used for the payment option. --> - - {{ partial "scripts/body/recaptcha.html" . }} {{ with .Resources.GetMatch "services.json" }} @@ -43,7 +41,8 @@

{{ $data.title | markdownify }}

{{ $comparableServices := $data.comparable_services }} {{ range $comparableServices.header_cols }} -
+

{{ .title | markdownify }}

diff --git a/site/layouts/_partials/scripts/body-scripts.html b/site/layouts/_partials/scripts/body-scripts.html index 85783cf8..dd2c48e4 100644 --- a/site/layouts/_partials/scripts/body-scripts.html +++ b/site/layouts/_partials/scripts/body-scripts.html @@ -26,12 +26,10 @@ {{ $environment := hugo.Environment }} {{ $payment := site.Params.payment }} -{{ $paygate := site.Params.paygate }} {{ $params := (dict "environment" $environment "payment" $payment - "paygate" $paygate )}} {{ partial "theme/scripts/body/baseurl.html" . }} diff --git a/site/layouts/checkout/single.html b/site/layouts/checkout/single.html index e8c53796..a4fadf00 100644 --- a/site/layouts/checkout/single.html +++ b/site/layouts/checkout/single.html @@ -23,7 +23,7 @@ Something went wrong, please contact {{ $emailLink }}.

- +
Subtotal @@ -241,15 +241,15 @@

Something went w