diff --git a/.devcontainer/advanced-integration-beta/devcontainer.json b/.devcontainer/advanced-integration-beta/devcontainer.json new file mode 100644 index 00000000..44c2d10c --- /dev/null +++ b/.devcontainer/advanced-integration-beta/devcontainer.json @@ -0,0 +1,40 @@ +// For more details, see https://aka.ms/devcontainer.json. +{ + "name": "PayPal Advanced Integration (beta)", + "image": "mcr.microsoft.com/devcontainers/javascript-node:20", + "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}/advanced-integration/beta", + // Use 'onCreateCommand' to run commands when creating the container. + "onCreateCommand": "bash ../.devcontainer/advanced-integration-beta/welcome-message.sh", + // Use 'postCreateCommand' to run commands after the container is created. + "postCreateCommand": "npm install", + // Use 'postAttachCommand' to run commands when attaching to the container. + "postAttachCommand": { + "Start server": "npm start" + }, + // Use 'forwardPorts' to make a list of ports inside the container available locally. + "forwardPorts": [8888], + "portsAttributes": { + "8888": { + "label": "Preview of Advanced Checkout Flow", + "onAutoForward": "openBrowserOnce" + } + }, + "secrets": { + "PAYPAL_CLIENT_ID": { + "description": "Sandbox client ID of the application.", + "documentationUrl": "https://developer.paypal.com/dashboard/applications/sandbox" + }, + "PAYPAL_CLIENT_SECRET": { + "description": "Sandbox secret of the application.", + "documentationUrl": "https://developer.paypal.com/dashboard/applications/sandbox" + } + }, + "customizations": { + "vscode": { + "extensions": ["vsls-contrib.codetour"], + "settings": { + "git.openRepositoryInParentFolders": "always" + } + } + } +} diff --git a/.devcontainer/advanced-integration-beta/welcome-message.sh b/.devcontainer/advanced-integration-beta/welcome-message.sh new file mode 100644 index 00000000..ae9a72f9 --- /dev/null +++ b/.devcontainer/advanced-integration-beta/welcome-message.sh @@ -0,0 +1,23 @@ +#!/bin/sh + +set -e + +WELCOME_MESSAGE=" +👋 Welcome to the \"PayPal Advanced Checkout Integration Example\" + +🛠️ Your environment is fully setup with all the required software. + +🚀 Once you rename the \".env.example\" file to \".env\" and update \"PAYPAL_CLIENT_ID\" and \"PAYPAL_CLIENT_SECRET\", the checkout page will automatically open in the browser after the server is restarted." + +ALTERNATE_WELCOME_MESSAGE=" +👋 Welcome to the \"PayPal Advanced Checkout Integration Example\" + +🛠️ Your environment is fully setup with all the required software. + +🚀 The checkout page will automatically open in the browser after the server is started." + +if [ -n "$PAYPAL_CLIENT_ID" ] && [ -n "$PAYPAL_CLIENT_SECRET" ]; then + WELCOME_MESSAGE="${ALTERNATE_WELCOME_MESSAGE}" +fi + +sudo bash -c "echo \"${WELCOME_MESSAGE}\" > /usr/local/etc/vscode-dev-containers/first-run-notice.txt" diff --git a/advanced-integration/beta/.env.example b/advanced-integration/beta/.env.example new file mode 100644 index 00000000..2251fbbb --- /dev/null +++ b/advanced-integration/beta/.env.example @@ -0,0 +1,5 @@ +# Create an application to obtain credentials at +# https://developer.paypal.com/dashboard/applications/sandbox + +PAYPAL_CLIENT_ID=YOUR_CLIENT_ID_GOES_HERE +PAYPAL_CLIENT_SECRET=YOUR_SECRET_GOES_HERE diff --git a/advanced-integration/beta/README.md b/advanced-integration/beta/README.md new file mode 100644 index 00000000..923a5234 --- /dev/null +++ b/advanced-integration/beta/README.md @@ -0,0 +1,11 @@ +# Advanced Integration Example + +This folder contains example code for an Advanced PayPal integration using both the JS SDK and Node.js to complete transactions with the PayPal REST API. + +## Instructions + +1. Rename `.env.example` to `.env` and update `PAYPAL_CLIENT_ID` and `PAYPAL_CLIENT_SECRET`. +2. Run `npm install` +3. Run `npm start` +4. Open http://localhost:8888 +5. Enter the credit card number provided from one of your [sandbox accounts](https://developer.paypal.com/dashboard/accounts) or [generate a new credit card](https://developer.paypal.com/dashboard/creditCardGenerator) diff --git a/advanced-integration/beta/client/checkout.html b/advanced-integration/beta/client/checkout.html new file mode 100644 index 00000000..a052a57a --- /dev/null +++ b/advanced-integration/beta/client/checkout.html @@ -0,0 +1,24 @@ + + + + + + + PayPal JS SDK Advanced Integration - Checkout Flow + + +
+
+
+
+
+
+ +
+

+ + + + + diff --git a/advanced-integration/beta/client/checkout.js b/advanced-integration/beta/client/checkout.js new file mode 100644 index 00000000..517edeb8 --- /dev/null +++ b/advanced-integration/beta/client/checkout.js @@ -0,0 +1,144 @@ +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"); + +const cardField = window.paypal.CardFields({ + createOrder: createOrderCallback, + onApprove: onApproveCallback, +}); + +// Render each field after checking for eligibility +if (cardField.isEligible()) { + const nameField = cardField.NameField(); + nameField.render("#card-name-field-container"); + + const numberField = cardField.NumberField(); + numberField.render("#card-number-field-container"); + + const cvvField = cardField.CVVField(); + cvvField.render("#card-cvv-field-container"); + + const expiryField = cardField.ExpiryField(); + expiryField.render("#card-expiry-field-container"); + + // Add click listener to submit button and call the submit function on the CardField component + document + .getElementById("multi-card-field-button") + .addEventListener("click", () => { + cardField.submit().catch((error) => { + resultMessage( + `Sorry, your transaction could not be processed...

${error}`, + ); + }); + }); +} else { + // Hides card fields if the merchant isn't eligible + document.querySelector("#card-form").style = "display: none"; +} + +// 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; +} diff --git a/advanced-integration/beta/package.json b/advanced-integration/beta/package.json new file mode 100644 index 00000000..c87347ac --- /dev/null +++ b/advanced-integration/beta/package.json @@ -0,0 +1,23 @@ +{ + "name": "paypal-advanced-integration", + "description": "Sample Node.js web app to integrate PayPal Advanced Checkout for online payments", + "version": "1.0.0", + "main": "server/server.js", + "type": "module", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1", + "start": "nodemon server/server.js", + "format": "npx prettier --write **/*.{js,md}", + "format:check": "npx prettier --check **/*.{js,md}", + "lint": "npx eslint server/*.js --env=node && npx eslint client/*.js --env=browser" + }, + "license": "Apache-2.0", + "dependencies": { + "dotenv": "^16.3.1", + "express": "^4.18.2", + "node-fetch": "^3.3.2" + }, + "devDependencies": { + "nodemon": "^3.0.1" + } +} diff --git a/advanced-integration/beta/server/server.js b/advanced-integration/beta/server/server.js new file mode 100644 index 00000000..0d8d3cb8 --- /dev/null +++ b/advanced-integration/beta/server/server.js @@ -0,0 +1,152 @@ +import express from "express"; +import fetch from "node-fetch"; +import "dotenv/config"; +import path from "path"; + +const { PAYPAL_CLIENT_ID, PAYPAL_CLIENT_SECRET, PORT = 8888 } = process.env; +const base = "https://api-m.sandbox.paypal.com"; +const app = express(); + +// host static files +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); + } +}; + +/** + * 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); + } +} + +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." }); + } +}); + +// serve index.html +app.get("/", (req, res) => { + res.sendFile(path.resolve("./client/checkout.html")); +}); + +app.listen(PORT, () => { + console.log(`Node server listening at http://localhost:${PORT}/`); +});