From 8102086dc7faad5693189a57712240d1b6ed9666 Mon Sep 17 00:00:00 2001 From: Greg Jopa <534034+gregjopa@users.noreply.github.com> Date: Wed, 20 Dec 2023 16:28:53 -0600 Subject: [PATCH] Update hosted-fields example to new standards --- advanced-integration/v1/client/app.js | 186 ++++++++++++++++++ advanced-integration/v1/package.json | 6 +- advanced-integration/v1/paypal-api.js | 100 ---------- advanced-integration/v1/public/app.js | 160 --------------- advanced-integration/v1/server.js | 44 ----- advanced-integration/v1/server/server.js | 178 +++++++++++++++++ .../v1/server/views/checkout.ejs | 104 ++++++++++ 7 files changed, 471 insertions(+), 307 deletions(-) create mode 100644 advanced-integration/v1/client/app.js delete mode 100644 advanced-integration/v1/paypal-api.js delete mode 100644 advanced-integration/v1/public/app.js delete mode 100644 advanced-integration/v1/server.js create mode 100644 advanced-integration/v1/server/server.js create mode 100644 advanced-integration/v1/server/views/checkout.ejs diff --git a/advanced-integration/v1/client/app.js b/advanced-integration/v1/client/app.js new file mode 100644 index 00000000..65f048d7 --- /dev/null +++ b/advanced-integration/v1/client/app.js @@ -0,0 +1,186 @@ +async function createOrderCallback() { + try { + const response = await fetch("/api/orders", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + // use the "body" param to optionally pass additional order information + // like product ids and quantities + body: JSON.stringify({ + cart: [ + { + id: "YOUR_PRODUCT_ID", + quantity: "YOUR_PRODUCT_QUANTITY", + }, + ], + }), + }); + + const orderData = await response.json(); + + if (orderData.id) { + return orderData.id; + } else { + const errorDetail = orderData?.details?.[0]; + const errorMessage = errorDetail + ? `${errorDetail.issue} ${errorDetail.description} (${orderData.debug_id})` + : JSON.stringify(orderData); + + throw new Error(errorMessage); + } + } catch (error) { + console.error(error); + resultMessage(`Could not initiate PayPal Checkout...

${error}`); + } +} + +async function onApproveCallback(data, actions) { + try { + const response = await fetch(`/api/orders/${data.orderID}/capture`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + }); + + const orderData = await response.json(); + // Three cases to handle: + // (1) Recoverable INSTRUMENT_DECLINED -> call actions.restart() + // (2) Other non-recoverable errors -> Show a failure message + // (3) Successful transaction -> Show confirmation or thank you message + + const transaction = + orderData?.purchase_units?.[0]?.payments?.captures?.[0] || + orderData?.purchase_units?.[0]?.payments?.authorizations?.[0]; + const errorDetail = orderData?.details?.[0]; + + // this actions.restart() behavior only applies to the Buttons component + if (errorDetail?.issue === "INSTRUMENT_DECLINED" && !data.card && actions) { + // (1) Recoverable INSTRUMENT_DECLINED -> call actions.restart() + // recoverable state, per https://developer.paypal.com/docs/checkout/standard/customize/handle-funding-failures/ + return actions.restart(); + } else if ( + errorDetail || + !transaction || + transaction.status === "DECLINED" + ) { + // (2) Other non-recoverable errors -> Show a failure message + let errorMessage; + if (transaction) { + errorMessage = `Transaction ${transaction.status}: ${transaction.id}`; + } else if (errorDetail) { + errorMessage = `${errorDetail.description} (${orderData.debug_id})`; + } else { + errorMessage = JSON.stringify(orderData); + } + + throw new Error(errorMessage); + } else { + // (3) Successful transaction -> Show confirmation or thank you message + // Or go to another URL: actions.redirect('thank_you.html'); + resultMessage( + `Transaction ${transaction.status}: ${transaction.id}

See console for all available details`, + ); + console.log( + "Capture result", + orderData, + JSON.stringify(orderData, null, 2), + ); + } + } catch (error) { + console.error(error); + resultMessage( + `Sorry, your transaction could not be processed...

${error}`, + ); + } +} + +window.paypal + .Buttons({ + createOrder: createOrderCallback, + onApprove: onApproveCallback, + }) + .render("#paypal-button-container"); + +// Example function to show a result to the user. Your site's UI library can be used instead. +function resultMessage(message) { + const container = document.querySelector("#result-message"); + container.innerHTML = message; +} + +// If this returns false or the card fields aren't visible, see Step #1. +if (window.paypal.HostedFields.isEligible()) { + // Renders card fields + window.paypal.HostedFields.render({ + // Call your server to set up the transaction + createOrder: createOrderCallback, + styles: { + ".valid": { + color: "green", + }, + ".invalid": { + color: "red", + }, + }, + fields: { + number: { + selector: "#card-number", + placeholder: "4111 1111 1111 1111", + }, + cvv: { + selector: "#cvv", + placeholder: "123", + }, + expirationDate: { + selector: "#expiration-date", + placeholder: "MM/YY", + }, + }, + }).then((cardFields) => { + document.querySelector("#card-form").addEventListener("submit", (event) => { + event.preventDefault(); + cardFields + .submit({ + // Cardholder's first and last name + cardholderName: document.getElementById("card-holder-name").value, + // Billing Address + billingAddress: { + // Street address, line 1 + streetAddress: document.getElementById( + "card-billing-address-street", + ).value, + // Street address, line 2 (Ex: Unit, Apartment, etc.) + extendedAddress: document.getElementById( + "card-billing-address-unit", + ).value, + // State + region: document.getElementById("card-billing-address-state").value, + // City + locality: document.getElementById("card-billing-address-city") + .value, + // Postal Code + postalCode: document.getElementById("card-billing-address-zip") + .value, + // Country Code + countryCodeAlpha2: document.getElementById( + "card-billing-address-country", + ).value, + }, + }) + .then((data) => { + return onApproveCallback(data); + }) + .catch((orderData) => { + resultMessage( + `Sorry, your transaction could not be processed...

${JSON.stringify( + orderData, + )}`, + ); + }); + }); + }); +} else { + // Hides card fields if the merchant isn't eligible + document.querySelector("#card-form").style = "display: none"; +} diff --git a/advanced-integration/v1/package.json b/advanced-integration/v1/package.json index e1b47190..ff3f5b41 100644 --- a/advanced-integration/v1/package.json +++ b/advanced-integration/v1/package.json @@ -2,14 +2,14 @@ "name": "paypal-advanced-integration", "description": "Sample Node.js web app to integrate PayPal Advanced Checkout for online payments", "version": "1.0.0", - "main": "server.js", + "main": "server/server.js", "type": "module", "scripts": { "test": "echo \"Error: no test specified\" && exit 1", - "start": "nodemon server.js", + "start": "nodemon server/server.js", "format": "npx prettier --write **/*.{js,md}", "format:check": "npx prettier --check **/*.{js,md}", - "lint": "npx eslint server.js paypal-api.js --env=node && npx eslint public/*.js --env=browser" + "lint": "npx eslint server/*.js --env=node && npx eslint client/*.js --env=browser" }, "license": "Apache-2.0", "dependencies": { diff --git a/advanced-integration/v1/paypal-api.js b/advanced-integration/v1/paypal-api.js deleted file mode 100644 index 6e6c8aaf..00000000 --- a/advanced-integration/v1/paypal-api.js +++ /dev/null @@ -1,100 +0,0 @@ -import fetch from "node-fetch"; - -// set some important variables -const { PAYPAL_CLIENT_ID, PAYPAL_CLIENT_SECRET } = process.env; -const base = "https://api-m.sandbox.paypal.com"; - -/** - * Create an order - * @see https://developer.paypal.com/docs/api/orders/v2/#orders_create - */ -export async function createOrder() { - const purchaseAmount = "100.00"; // TODO: pull prices from a database - const accessToken = await generateAccessToken(); - const url = `${base}/v2/checkout/orders`; - const response = await fetch(url, { - method: "post", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${accessToken}`, - }, - body: JSON.stringify({ - intent: "CAPTURE", - purchase_units: [ - { - amount: { - currency_code: "USD", - value: purchaseAmount, - }, - }, - ], - }), - }); - - return handleResponse(response); -} - -/** - * Capture payment for an order - * @see https://developer.paypal.com/docs/api/orders/v2/#orders_capture - */ -export async function capturePayment(orderId) { - const accessToken = await generateAccessToken(); - const url = `${base}/v2/checkout/orders/${orderId}/capture`; - const response = await fetch(url, { - method: "post", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${accessToken}`, - }, - }); - - return handleResponse(response); -} - -/** - * Generate an OAuth 2.0 access token - * @see https://developer.paypal.com/api/rest/authentication/ - */ -export async function generateAccessToken() { - const auth = Buffer.from( - PAYPAL_CLIENT_ID + ":" + PAYPAL_CLIENT_SECRET, - ).toString("base64"); - const response = await fetch(`${base}/v1/oauth2/token`, { - method: "post", - body: "grant_type=client_credentials", - headers: { - Authorization: `Basic ${auth}`, - }, - }); - const jsonData = await handleResponse(response); - return jsonData.access_token; -} - -/** - * Generate a client token - * @see https://developer.paypal.com/docs/checkout/advanced/integrate/#link-sampleclienttokenrequest - */ -export async function generateClientToken() { - const accessToken = await generateAccessToken(); - const response = await fetch(`${base}/v1/identity/generate-token`, { - method: "post", - headers: { - Authorization: `Bearer ${accessToken}`, - "Accept-Language": "en_US", - "Content-Type": "application/json", - }, - }); - console.log("response", response.status); - const jsonData = await handleResponse(response); - return jsonData.client_token; -} - -async function handleResponse(response) { - if (response.status === 200 || response.status === 201) { - return response.json(); - } - - const errorMessage = await response.text(); - throw new Error(errorMessage); -} diff --git a/advanced-integration/v1/public/app.js b/advanced-integration/v1/public/app.js deleted file mode 100644 index f475472d..00000000 --- a/advanced-integration/v1/public/app.js +++ /dev/null @@ -1,160 +0,0 @@ -window.paypal - .Buttons({ - // Sets up the transaction when a payment button is clicked - createOrder: function () { - return fetch("/api/orders", { - method: "post", - // use the "body" param to optionally pass additional order information - // like product skus and quantities - body: JSON.stringify({ - cart: [ - { - sku: "", - quantity: "", - }, - ], - }), - }) - .then((response) => response.json()) - .then((order) => order.id); - }, - // Finalize the transaction after payer approval - onApprove: function (data) { - return fetch(`/api/orders/${data.orderID}/capture`, { - method: "post", - }) - .then((response) => response.json()) - .then((orderData) => { - // Successful capture! For dev/demo purposes: - console.log( - "Capture result", - orderData, - JSON.stringify(orderData, null, 2), - ); - const transaction = orderData.purchase_units[0].payments.captures[0]; - alert(`Transaction ${transaction.status}: ${transaction.id} - - See console for all available details - `); - // When ready to go live, remove the alert and show a success message within this page. For example: - // var element = document.getElementById('paypal-button-container'); - // element.innerHTML = '

Thank you for your payment!

'; - // Or go to another URL: actions.redirect('thank_you.html'); - }); - }, - }) - .render("#paypal-button-container"); - -// If this returns false or the card fields aren't visible, see Step #1. -if (window.paypal.HostedFields.isEligible()) { - let orderId; - - // Renders card fields - window.paypal.HostedFields.render({ - // Call your server to set up the transaction - createOrder: () => { - return fetch("/api/orders", { - method: "post", - // use the "body" param to optionally pass additional order information - // like product skus and quantities - body: JSON.stringify({ - cart: [ - { - sku: "", - quantity: "", - }, - ], - }), - }) - .then((res) => res.json()) - .then((orderData) => { - orderId = orderData.id; // needed later to complete capture - return orderData.id; - }); - }, - styles: { - ".valid": { - color: "green", - }, - ".invalid": { - color: "red", - }, - }, - fields: { - number: { - selector: "#card-number", - placeholder: "4111 1111 1111 1111", - }, - cvv: { - selector: "#cvv", - placeholder: "123", - }, - expirationDate: { - selector: "#expiration-date", - placeholder: "MM/YY", - }, - }, - }).then((cardFields) => { - document.querySelector("#card-form").addEventListener("submit", (event) => { - event.preventDefault(); - cardFields - .submit({ - // Cardholder's first and last name - cardholderName: document.getElementById("card-holder-name").value, - // Billing Address - billingAddress: { - // Street address, line 1 - streetAddress: document.getElementById( - "card-billing-address-street", - ).value, - // Street address, line 2 (Ex: Unit, Apartment, etc.) - extendedAddress: document.getElementById( - "card-billing-address-unit", - ).value, - // State - region: document.getElementById("card-billing-address-state").value, - // City - locality: document.getElementById("card-billing-address-city") - .value, - // Postal Code - postalCode: document.getElementById("card-billing-address-zip") - .value, - // Country Code - countryCodeAlpha2: document.getElementById( - "card-billing-address-country", - ).value, - }, - }) - .then(() => { - fetch(`/api/orders/${orderId}/capture`, { - method: "post", - }) - .then((res) => res.json()) - .then((orderData) => { - // Two cases to handle: - // (1) Other non-recoverable errors -> Show a failure message - // (2) Successful transaction -> Show confirmation or thank you - // This example reads a v2/checkout/orders capture response, propagated from the server - // You could use a different API or structure for your 'orderData' - const errorDetail = - Array.isArray(orderData.details) && orderData.details[0]; - if (errorDetail) { - var msg = "Sorry, your transaction could not be processed."; - if (errorDetail.description) - msg += "\n\n" + errorDetail.description; - if (orderData.debug_id) msg += " (" + orderData.debug_id + ")"; - return alert(msg); // Show a failure message - } - // Show a success message or redirect - alert("Transaction completed!"); - }); - }) - .catch((err) => { - alert("Payment could not be captured! " + JSON.stringify(err)); - }); - }); - }); -} else { - // Hides card fields if the merchant isn't eligible - document.querySelector("#card-form").style = "display: none"; -} diff --git a/advanced-integration/v1/server.js b/advanced-integration/v1/server.js deleted file mode 100644 index 73076fcb..00000000 --- a/advanced-integration/v1/server.js +++ /dev/null @@ -1,44 +0,0 @@ -import "dotenv/config"; -import express from "express"; -import * as paypal from "./paypal-api.js"; -const { PORT = 8888 } = process.env; - -const app = express(); -app.set("view engine", "ejs"); -app.use(express.static("public")); - -// render checkout page with client id & unique client token -app.get("/", async (req, res) => { - const clientId = process.env.PAYPAL_CLIENT_ID; - try { - const clientToken = await paypal.generateClientToken(); - res.render("checkout", { clientId, clientToken }); - } catch (err) { - res.status(500).send(err.message); - } -}); - -// create order -app.post("/api/orders", async (req, res) => { - try { - const order = await paypal.createOrder(); - res.json(order); - } catch (err) { - res.status(500).send(err.message); - } -}); - -// capture payment -app.post("/api/orders/:orderID/capture", async (req, res) => { - const { orderID } = req.params; - try { - const captureData = await paypal.capturePayment(orderID); - res.json(captureData); - } catch (err) { - res.status(500).send(err.message); - } -}); - -app.listen(PORT, () => { - console.log(`Server listening at http://localhost:${PORT}/`); -}); diff --git a/advanced-integration/v1/server/server.js b/advanced-integration/v1/server/server.js new file mode 100644 index 00000000..a7d84407 --- /dev/null +++ b/advanced-integration/v1/server/server.js @@ -0,0 +1,178 @@ +import express from "express"; +import fetch from "node-fetch"; +import "dotenv/config"; + +const { PAYPAL_CLIENT_ID, PAYPAL_CLIENT_SECRET, PORT = 8888 } = process.env; +const base = "https://api-m.sandbox.paypal.com"; +const app = express(); +app.set("view engine", "ejs"); +app.set("views", "./server/views"); +app.use(express.static("client")); + +// parse post params sent in body in json format +app.use(express.json()); + +/** + * Generate an OAuth 2.0 access token for authenticating with PayPal REST APIs. + * @see https://developer.paypal.com/api/rest/authentication/ + */ +const generateAccessToken = async () => { + try { + if (!PAYPAL_CLIENT_ID || !PAYPAL_CLIENT_SECRET) { + throw new Error("MISSING_API_CREDENTIALS"); + } + const auth = Buffer.from( + PAYPAL_CLIENT_ID + ":" + PAYPAL_CLIENT_SECRET, + ).toString("base64"); + const response = await fetch(`${base}/v1/oauth2/token`, { + method: "POST", + body: "grant_type=client_credentials", + headers: { + Authorization: `Basic ${auth}`, + }, + }); + + const data = await response.json(); + return data.access_token; + } catch (error) { + console.error("Failed to generate Access Token:", error); + } +}; + +/** + * Generate a client token for rendering the hosted card fields. + * @see https://developer.paypal.com/docs/checkout/advanced/integrate/#link-integratebackend + */ +const generateClientToken = async () => { + const accessToken = await generateAccessToken(); + const url = `${base}/v1/identity/generate-token`; + const response = await fetch(url, { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "Accept-Language": "en_US", + "Content-Type": "application/json", + }, + }); + + return handleResponse(response); +}; + +/** + * Create an order to start the transaction. + * @see https://developer.paypal.com/docs/api/orders/v2/#orders_create + */ +const createOrder = async (cart) => { + // use the cart information passed from the front-end to calculate the purchase unit details + console.log( + "shopping cart information passed from the frontend createOrder() callback:", + cart, + ); + + const accessToken = await generateAccessToken(); + const url = `${base}/v2/checkout/orders`; + const payload = { + intent: "CAPTURE", + purchase_units: [ + { + amount: { + currency_code: "USD", + value: "100.00", + }, + }, + ], + }; + + const response = await fetch(url, { + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${accessToken}`, + // Uncomment one of these to force an error for negative testing (in sandbox mode only). Documentation: + // https://developer.paypal.com/tools/sandbox/negative-testing/request-headers/ + // "PayPal-Mock-Response": '{"mock_application_codes": "MISSING_REQUIRED_PARAMETER"}' + // "PayPal-Mock-Response": '{"mock_application_codes": "PERMISSION_DENIED"}' + // "PayPal-Mock-Response": '{"mock_application_codes": "INTERNAL_SERVER_ERROR"}' + }, + method: "POST", + body: JSON.stringify(payload), + }); + + return handleResponse(response); +}; + +/** + * Capture payment for the created order to complete the transaction. + * @see https://developer.paypal.com/docs/api/orders/v2/#orders_capture + */ +const captureOrder = async (orderID) => { + const accessToken = await generateAccessToken(); + const url = `${base}/v2/checkout/orders/${orderID}/capture`; + + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${accessToken}`, + // Uncomment one of these to force an error for negative testing (in sandbox mode only). Documentation: + // https://developer.paypal.com/tools/sandbox/negative-testing/request-headers/ + // "PayPal-Mock-Response": '{"mock_application_codes": "INSTRUMENT_DECLINED"}' + // "PayPal-Mock-Response": '{"mock_application_codes": "TRANSACTION_REFUSED"}' + // "PayPal-Mock-Response": '{"mock_application_codes": "INTERNAL_SERVER_ERROR"}' + }, + }); + + return handleResponse(response); +}; + +async function handleResponse(response) { + try { + const jsonResponse = await response.json(); + return { + jsonResponse, + httpStatusCode: response.status, + }; + } catch (err) { + const errorMessage = await response.text(); + throw new Error(errorMessage); + } +} + +// render checkout page with client id & unique client token +app.get("/", async (req, res) => { + try { + const { jsonResponse } = await generateClientToken(); + res.render("checkout", { + clientId: PAYPAL_CLIENT_ID, + clientToken: jsonResponse.client_token, + }); + } catch (err) { + res.status(500).send(err.message); + } +}); + +app.post("/api/orders", async (req, res) => { + try { + // use the cart information passed from the front-end to calculate the order amount detals + const { cart } = req.body; + const { jsonResponse, httpStatusCode } = await createOrder(cart); + res.status(httpStatusCode).json(jsonResponse); + } catch (error) { + console.error("Failed to create order:", error); + res.status(500).json({ error: "Failed to create order." }); + } +}); + +app.post("/api/orders/:orderID/capture", async (req, res) => { + try { + const { orderID } = req.params; + const { jsonResponse, httpStatusCode } = await captureOrder(orderID); + res.status(httpStatusCode).json(jsonResponse); + } catch (error) { + console.error("Failed to create order:", error); + res.status(500).json({ error: "Failed to capture order." }); + } +}); + +app.listen(PORT, () => { + console.log(`Node server listening at http://localhost:${PORT}/`); +}); diff --git a/advanced-integration/v1/server/views/checkout.ejs b/advanced-integration/v1/server/views/checkout.ejs new file mode 100644 index 00000000..85cd7085 --- /dev/null +++ b/advanced-integration/v1/server/views/checkout.ejs @@ -0,0 +1,104 @@ + + + + + + PayPal JS SDK Advanced Integration + + + + +
+
+
+ +
+
+
+ +
+
+
+ +
+
+
+ + +
+ + +
+
+ + +
+
+ +
+
+ +
+
+ +
+
+ +
+

+ +
+

+
+ + +