From f8a4c754b954645de11cf9d9d53841a4ce5ccfc2 Mon Sep 17 00:00:00 2001 From: Joe Carstairs <65492573+Sycamost@users.noreply.github.com> Date: Sat, 5 Aug 2023 22:31:05 +0100 Subject: Defines Order type --- api/Order.d.ts | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 api/Order.d.ts (limited to 'api') diff --git a/api/Order.d.ts b/api/Order.d.ts new file mode 100644 index 0000000..cf1ff81 --- /dev/null +++ b/api/Order.d.ts @@ -0,0 +1,7 @@ +type Order = { + productDescription: string; + shortDescription: string; + totalPrice: number; +}; + +export default Order; -- cgit v1.2.3 From 0f092ef5391262c5f69838e97687f30edd5434c6 Mon Sep 17 00:00:00 2001 From: Joe Carstairs <65492573+Sycamost@users.noreply.github.com> Date: Sat, 5 Aug 2023 22:31:25 +0100 Subject: Adds env config in paypal function --- api/_env.ts | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 api/_env.ts (limited to 'api') diff --git a/api/_env.ts b/api/_env.ts new file mode 100644 index 0000000..69ed902 --- /dev/null +++ b/api/_env.ts @@ -0,0 +1,48 @@ +import path from 'path'; +import dotenv from 'dotenv'; + +// Parsing the env file. +dotenv.config({ path: path.resolve(__dirname, '../../.env') }); + +type Env = { + PAYPAL_LIVE_CLIENT_ID: string, + PAYPAL_LIVE_SECRET: string, + PAYPAL_SANDBOX_CLIENT_ID: string, + PAYPAL_SANDBOX_SECRET: string, + ENVIRONMENT: 'dev' | 'prod', +}; + +type DirtyEnv = { [key in keyof Env]?: string }; + +const ENV: { [key in keyof Env]: number } = { + PAYPAL_LIVE_CLIENT_ID: 1, + PAYPAL_LIVE_SECRET: 1, + PAYPAL_SANDBOX_CLIENT_ID: 1, + PAYPAL_SANDBOX_SECRET: 1, + ENVIRONMENT: 1, +}; + +const { env }: { env: DirtyEnv } = process; + +const sanitisedEnvEntries = + (Object.keys(ENV) as (keyof Env)[]) + .map<[keyof Env, string]>(key => { + const value = env[key]; + if (!value) { + throw new Error(`Environment not configured correctly. ${key} was not defined.`); + } + + if (key === 'ENVIRONMENT') { + if (!['dev', 'prod'].includes(value)) { + throw new Error(` + Environment not configured correctly. ${key} must be + either 'dev' or 'prod', but '${value}' was provided. + `); + } + } + return [key, value]; + }); + +const sanitisedEnv = Object.fromEntries(sanitisedEnvEntries) as Env; + +export default sanitisedEnv; \ No newline at end of file -- cgit v1.2.3 From 59b0bf5e441a9b6352962d2b50220c7c1be1ea41 Mon Sep 17 00:00:00 2001 From: Joe Carstairs <65492573+Sycamost@users.noreply.github.com> Date: Sat, 5 Aug 2023 22:31:44 +0100 Subject: Defines PayPal util logic --- api/_paypal.ts | 114 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 api/_paypal.ts (limited to 'api') diff --git a/api/_paypal.ts b/api/_paypal.ts new file mode 100644 index 0000000..348c972 --- /dev/null +++ b/api/_paypal.ts @@ -0,0 +1,114 @@ +import type Order from './Order'; +import env from './_env'; + +export async function paypalCreateOrder(order: Order) { + const accessToken = await getLazyAccessToken(); + const url = `${PAYPAL_URL}/v2/checkout/orders`; + const response = await fetch(url, { + method: 'POST', + headers: paypalCreateOrderRequestHeaders(accessToken), + body: paypalCreateOrderRequestBody(order), + }); + return await handle(response); +} + +export async function paypalCaptureOrder(orderId: string) { + const accessToken = await getLazyAccessToken(); + const url = `${PAYPAL_URL}/v2/checkout/orders/${orderId}/capture`; + const response = await fetch(url, { + method: 'POST', + headers: paypalCaptureOrderRequestHeaders(accessToken), + }); + return await handle(response); +} + +async function handle(response: Response) { + if (response.ok) { + return await response.json(); + } + throw new Error('Failed to communicate with PayPal. ' + await response.text()); +} + +function paypalCreateOrderRequestBody(order: Order) { + const body = JSON.stringify({ + intent: 'CAPTURE', + purchase_units: [ + { + description: order.productDescription, + soft_descriptor: order.shortDescription, + amount: { + currency_code: 'GBP', + value: order.totalPrice.toFixed(2), + }, + }, + ], + }); + return body; +} + +function paypalCreateOrderRequestHeaders(accessToken: string) { + return { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + }; +} + +function paypalCaptureOrderRequestHeaders(accessToken: string) { + return { + Authorization: `Bearer ${accessToken}`, + }; +} + +const getLazyAccessToken = (() => { + let accessToken: string; + let timeRequested: number; + let timeExpires: number; + return async function (): Promise { + const isNearlyExpired = timeExpires - Date.now() < 10_000; + if (!accessToken || isNearlyExpired) { + timeRequested = Date.now(); + let secondsUntilExpires: number; + ({ accessToken, secondsUntilExpires } = await generateAccessToken()); + const msUntilExpires = secondsUntilExpires * 1000; + timeExpires = timeRequested + msUntilExpires; + } + return accessToken; + }; +})(); + +async function generateAccessToken(): Promise { + const auth = Buffer.from(PAYPAL_CLIENT_ID + ':' + PAYPAL_SECRET).toString('base64'); + const response = await fetch(`${PAYPAL_URL}/v1/oauth2/token`, { + method: 'POST', + body: 'grant_type=client_credentials', + headers: { + Authorization: `Basic ${auth}`, + }, + }); + const { access_token, expires_in } = await response.json(); + if (access_token && expires_in) { + return { + accessToken: access_token, + secondsUntilExpires: expires_in, + }; + } + throw new Error('Could not fetch access token from PayPal.'); +} + +type AccessTokenResponse = { + accessToken: string; + secondsUntilExpires: number; +}; + +const PAYPAL_CLIENT_ID = + env.ENVIRONMENT === 'prod' + ? env.PAYPAL_LIVE_CLIENT_ID + : env.PAYPAL_SANDBOX_CLIENT_ID; +const PAYPAL_SECRET = + env.ENVIRONMENT === 'prod' + ? env.PAYPAL_LIVE_SECRET + : env.PAYPAL_SANDBOX_SECRET; +const PAYPAL_URL = + env.ENVIRONMENT === 'prod' + ? 'https://api-m.paypal.com' + : 'https://api-m.sandbox.paypal.com' \ No newline at end of file -- cgit v1.2.3 From 925f6de56ef43013583a48dbc49a350ee84cdb3e Mon Sep 17 00:00:00 2001 From: Joe Carstairs <65492573+Sycamost@users.noreply.github.com> Date: Sat, 5 Aug 2023 21:37:27 +0100 Subject: Defines createPaypalOrder endpoint --- api/createPaypalOrder.ts | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 api/createPaypalOrder.ts (limited to 'api') diff --git a/api/createPaypalOrder.ts b/api/createPaypalOrder.ts new file mode 100644 index 0000000..1a6e810 --- /dev/null +++ b/api/createPaypalOrder.ts @@ -0,0 +1,28 @@ +import type { VercelRequest, VercelResponse } from '@vercel/node'; +import type Order from './Order'; +import { paypalCreateOrder } from './_paypal'; + +export default async function handler(request: VercelRequest, response: VercelResponse) { + const { productDescription, shortDescription, totalPrice }: Partial = request.body; + + if (request.method !== 'POST') { + response.status(400); + return; + } + + if (!productDescription) { + response.status(400).send('Expected body to contain productDescription, but none provided.'); + } else if (!shortDescription) { + response.status(400).send('Expected body to contain shortDescription, but none provided.'); + } else if (!totalPrice) { + response.status(400).send('Expected body to contain totalPrice, but none provided.'); + } else { + const paypalResponse = await paypalCreateOrder({ productDescription, shortDescription, totalPrice }); + + if (paypalResponse.ok) { + response.status(200).json({ + orderId: paypalResponse.id, + }); + } + } +} -- cgit v1.2.3