summaryrefslogtreecommitdiff
path: root/api
diff options
context:
space:
mode:
authorJoe Carstairs <65492573+Sycamost@users.noreply.github.com>2023-08-07 19:41:20 +0100
committerGitHub <noreply@github.com>2023-08-07 19:41:20 +0100
commit500997042e98784c9d3b7a5ad684e36bf67cd0fa (patch)
treebfa129cae6a2b981d5b6dc5c3cc16cfb2bc5c95a /api
parent2fcbccfbd49c6eaf433272749e32589a6f490200 (diff)
parente08094210c15ee0114ae4f11142aa5f89d81c7aa (diff)
Merge pull request #1 from Sycamost/master
Update
Diffstat (limited to 'api')
-rw-r--r--api/Order.d.ts7
-rw-r--r--api/_env.ts48
-rw-r--r--api/_paypal.ts114
-rw-r--r--api/createPaypalOrder.ts28
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,
+ });
+ }
+ }
+}