diff --git a/advanced-integration/new/README.md b/advanced-integration/new/README.md new file mode 100644 index 00000000..923a5234 --- /dev/null +++ b/advanced-integration/new/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/client/app.js b/advanced-integration/new/client/app.js similarity index 100% rename from advanced-integration/client/app.js rename to advanced-integration/new/client/app.js diff --git a/advanced-integration/new/package.json b/advanced-integration/new/package.json new file mode 100644 index 00000000..ff3f5b41 --- /dev/null +++ b/advanced-integration/new/package.json @@ -0,0 +1,24 @@ +{ + "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", + "ejs": "^3.1.9", + "express": "^4.18.2", + "node-fetch": "^3.3.2" + }, + "devDependencies": { + "nodemon": "^3.0.1" + } +} diff --git a/advanced-integration/server/server.js b/advanced-integration/new/server/server.js similarity index 100% rename from advanced-integration/server/server.js rename to advanced-integration/new/server/server.js diff --git a/advanced-integration/server/views/checkout.ejs b/advanced-integration/new/server/views/checkout.ejs similarity index 100% rename from advanced-integration/server/views/checkout.ejs rename to advanced-integration/new/server/views/checkout.ejs diff --git a/advanced-integration/package.json b/advanced-integration/package.json index ff3f5b41..d17aa000 100644 --- a/advanced-integration/package.json +++ b/advanced-integration/package.json @@ -6,10 +6,10 @@ "type": "module", "scripts": { "test": "echo \"Error: no test specified\" && exit 1", - "start": "nodemon server/server.js", + "start": "nodemon 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" + "lint": "npx eslint server.js paypal-api.js --env=node && npx eslint public/*.js --env=browser" }, "license": "Apache-2.0", "dependencies": { diff --git a/advanced-integration/paypal-api.js b/advanced-integration/paypal-api.js new file mode 100644 index 00000000..6e6c8aaf --- /dev/null +++ b/advanced-integration/paypal-api.js @@ -0,0 +1,100 @@ +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/public/app.js b/advanced-integration/public/app.js new file mode 100644 index 00000000..f475472d --- /dev/null +++ b/advanced-integration/public/app.js @@ -0,0 +1,160 @@ +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/server.js b/advanced-integration/server.js new file mode 100644 index 00000000..73076fcb --- /dev/null +++ b/advanced-integration/server.js @@ -0,0 +1,44 @@ +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/views/checkout.ejs b/advanced-integration/views/checkout.ejs new file mode 100644 index 00000000..12522326 --- /dev/null +++ b/advanced-integration/views/checkout.ejs @@ -0,0 +1,101 @@ + + + + + + + + +
+
+
+ +
+
+
+ +
+
+
+ +
+
+
+ + +
+ + +
+
+ + +
+
+ +
+
+ +
+
+ +
+
+ +
+

+ +
+
+ + +