diff options
| author | Joe Carstairs <65492573+Sycamost@users.noreply.github.com> | 2023-08-07 20:46:07 +0100 |
|---|---|---|
| committer | Joe Carstairs <65492573+Sycamost@users.noreply.github.com> | 2023-08-07 20:50:17 +0100 |
| commit | 48f6b38c333fdaf57fe418c6e6a5fc4bfb928921 (patch) | |
| tree | 2f13763840bd55545b5ce7085517ce369dbf1bef /api-src | |
| parent | 1ec33f27f5bff2b96c80b954b53a62951b6cd354 (diff) | |
Pre-compiles api
Diffstat (limited to 'api-src')
| -rw-r--r-- | api-src/Order.d.ts | 7 | ||||
| -rw-r--r-- | api-src/_env.ts | 48 | ||||
| -rw-r--r-- | api-src/_paypal.ts | 114 | ||||
| -rw-r--r-- | api-src/createPaypalOrder.ts | 34 | ||||
| -rw-r--r-- | api-src/tsconfig.json | 15 |
5 files changed, 218 insertions, 0 deletions
diff --git a/api-src/Order.d.ts b/api-src/Order.d.ts new file mode 100644 index 0000000..cf1ff81 --- /dev/null +++ b/api-src/Order.d.ts @@ -0,0 +1,7 @@ +type Order = { + productDescription: string; + shortDescription: string; + totalPrice: number; +}; + +export default Order; diff --git a/api-src/_env.ts b/api-src/_env.ts new file mode 100644 index 0000000..69ed902 --- /dev/null +++ b/api-src/_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-src/_paypal.ts b/api-src/_paypal.ts new file mode 100644 index 0000000..348c972 --- /dev/null +++ b/api-src/_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-src/createPaypalOrder.ts b/api-src/createPaypalOrder.ts new file mode 100644 index 0000000..c0053d4 --- /dev/null +++ b/api-src/createPaypalOrder.ts @@ -0,0 +1,34 @@ +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(404); + 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 { + try { + const paypalResponse = await paypalCreateOrder({ productDescription, shortDescription, totalPrice }); + + if (paypalResponse.ok) { + response.status(200).json({ + orderId: paypalResponse.id, + }); + } else { + throw new Error(`PayPal returned ${paypalResponse.statusCode} ${paypalResponse.statusMessage}`); + } + } catch (err) { + response.status(500).send(`Internal server error. ${err}`); + } + } +} diff --git a/api-src/tsconfig.json b/api-src/tsconfig.json new file mode 100644 index 0000000..84d8095 --- /dev/null +++ b/api-src/tsconfig.json @@ -0,0 +1,15 @@ +{ + "strict": true, + "compilerOptions": { + "target": "ES5", + "module": "CommonJS", + "sourceMap": true, + "moduleResolution": "node", + "esModuleInterop": true, + "baseUrl": ".", + "outDir": "../api", + "lib": ["ESNext", "DOM"] + }, + "include": ["."], + "exclude": ["./node_modules"] +} |
