diff options
| author | Joe Carstairs <65492573+Sycamost@users.noreply.github.com> | 2023-08-07 19:41:20 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2023-08-07 19:41:20 +0100 |
| commit | 500997042e98784c9d3b7a5ad684e36bf67cd0fa (patch) | |
| tree | bfa129cae6a2b981d5b6dc5c3cc16cfb2bc5c95a /api | |
| parent | 2fcbccfbd49c6eaf433272749e32589a6f490200 (diff) | |
| parent | e08094210c15ee0114ae4f11142aa5f89d81c7aa (diff) | |
Merge pull request #1 from Sycamost/master
Update
Diffstat (limited to 'api')
| -rw-r--r-- | api/Order.d.ts | 7 | ||||
| -rw-r--r-- | api/_env.ts | 48 | ||||
| -rw-r--r-- | api/_paypal.ts | 114 | ||||
| -rw-r--r-- | api/createPaypalOrder.ts | 28 |
4 files changed, 197 insertions, 0 deletions
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; 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 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<string> { + 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<AccessTokenResponse> { + 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 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<Order> = 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, + }); + } + } +} |
