Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions save-payment-method/.env.example
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions save-payment-method/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
.env
13 changes: 13 additions & 0 deletions save-payment-method/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Save Payment Method Example

This folder contains example code for a PayPal Save Payment Method integration using both the JS SDK and Node.js to complete transactions with the PayPal REST API.

## Instructions

1. [Create an application](https://developer.paypal.com/dashboard/applications/sandbox/create)
2. Rename `.env.example` to `.env` and update `PAYPAL_CLIENT_ID` and `PAYPAL_CLIENT_SECRET`
3. Replace `test` in [client/app.js](client/app.js) with your app's client-id
4. Run `npm install`
5. Run `npm start`
6. Open http://localhost:8888
7. Click "PayPal" and log in with one of your [Sandbox test accounts](https://developer.paypal.com/dashboard/accounts)
97 changes: 97 additions & 0 deletions save-payment-method/client/app.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
window.paypal
.Buttons({
async createOrder() {
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...<br><br>${error}`);
}
},
async onApprove(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 errorDetail = orderData?.details?.[0];

if (errorDetail?.issue === "INSTRUMENT_DECLINED") {
// (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) {
// (2) Other non-recoverable errors -> Show a failure message
throw new Error(`${errorDetail.description} (${orderData.debug_id})`);
} else if (!orderData.purchase_units) {
throw new Error(JSON.stringify(orderData));
} else {
// (3) Successful transaction -> Show confirmation or thank you message
// Or go to another URL: actions.redirect('thank_you.html');
const transaction =
orderData?.purchase_units?.[0]?.payments?.captures?.[0] ||
orderData?.purchase_units?.[0]?.payments?.authorizations?.[0];
resultMessage(
`Transaction ${transaction.status}: ${transaction.id}<br><br>See console for all available details.<br>
<a href='/?customerID=${orderData.payment_source.paypal.attributes.vault.customer.id}'>See the return buyer experience</a>
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought this was a clever way to show the return buyer experience without a session or cookie dependency, but LMK if you have other ideas! I'm also curious if this implementation is not 100% correct because after the one click payment completes, a new customer id is generated (maybe it should be sent through during the order creation? <- need to investigate further)

`,
);

console.log(
"Capture result",
orderData,
JSON.stringify(orderData, null, 2),
);
}
} catch (error) {
console.error(error);
resultMessage(
`Sorry, your transaction could not be processed...<br><br>${error}`,
);
}
},
})
.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;
}
24 changes: 24 additions & 0 deletions save-payment-method/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"name": "paypal-save-payment-method",
"description": "Sample Node.js web app to integrate PayPal Save Payment Method 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"
}
}
193 changes: 193 additions & 0 deletions save-payment-method/server/server.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
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");

// 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 authenticate = async (bodyParams) => {
const params = {
grant_type: "client_credentials",
response_type: "id_token",
...bodyParams,
};

// pass the url encoded value as the body of the post call
const urlEncodedParams = new URLSearchParams(params).toString();
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: urlEncodedParams,
headers: {
Authorization: `Basic ${auth}`,
},
});
return handleResponse(response);
} catch (error) {
console.error("Failed to generate Access Token:", error);
}
};

const generateAccessToken = async () => {
const { jsonResponse } = await authenticate();
return jsonResponse.access_token;
};

/**
* 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: "110.00",
},
},
],
payment_source: {
paypal: {
attributes: {
vault: {
store_in_vault: "ON_SUCCESS",
usage_type: "MERCHANT",
customer_type: "CONSUMER",
},
},
experience_context: {
return_url: "http://example.com",
cancel_url: "http://example.com",
shipping_preference: "NO_SHIPPING",
},
},
},
};

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);
console.log("capture response", jsonResponse);
res.status(httpStatusCode).json(jsonResponse);
} catch (error) {
console.error("Failed to create order:", error);
res.status(500).json({ error: "Failed to capture order." });
}
});

// render checkout page with client id & user id token
app.get("/", async (req, res) => {
try {
const { jsonResponse } = await authenticate({
target_customer_id: req.query.customerID,
});
res.render("checkout", {
clientId: PAYPAL_CLIENT_ID,
userIdToken: jsonResponse.id_token,
});
} catch (err) {
res.status(500).send(err.message);
}
});

app.listen(PORT, () => {
console.log(`Node server listening at http://localhost:${PORT}/`);
});
17 changes: 17 additions & 0 deletions save-payment-method/server/views/checkout.ejs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PayPal JS SDK Save Payment Method Integration</title>
</head>
<body>
<div id="paypal-button-container"></div>
<p id="result-message"></p>
<script
src="https://www.paypal.com/sdk/js?client-id=<%= clientId %>"
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm also seeing this warning in the console:
Screenshot 2023-11-03 at 08 51 47

though the docs do not include vault=true in the code sample

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added it in d4ea5c3 and the warning went away 🥳

data-user-id-token="<%= userIdToken %>"
></script>
<script src="app.js"></script>
</body>
</html>