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
+
+
+
+
+
+
+
+
+