summaryrefslogtreecommitdiff
path: root/api/payments
diff options
context:
space:
mode:
Diffstat (limited to 'api/payments')
-rw-r--r--api/payments/_paypal.ts116
-rw-r--r--api/payments/order.ts86
2 files changed, 202 insertions, 0 deletions
diff --git a/api/payments/_paypal.ts b/api/payments/_paypal.ts
new file mode 100644
index 0000000..12d1cd7
--- /dev/null
+++ b/api/payments/_paypal.ts
@@ -0,0 +1,116 @@
+import type Order from '../types/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,
+ },
+ },
+ ],
+ });
+ return body;
+}
+
+function paypalCreateOrderRequestHeaders(accessToken: string) {
+ return {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${accessToken}`,
+ };
+}
+
+function paypalCaptureOrderRequestHeaders(accessToken: string) {
+ return {
+ 'Content-Type': 'application/json',
+ 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/payments/order.ts b/api/payments/order.ts
new file mode 100644
index 0000000..434abf9
--- /dev/null
+++ b/api/payments/order.ts
@@ -0,0 +1,86 @@
+import type Order from '../types/Order';
+import type { VercelRequest, VercelResponse } from '@vercel/node';
+
+import { paypalCaptureOrder, paypalCreateOrder } from './_paypal';
+
+export default async function handler(request: VercelRequest, response: VercelResponse) {
+ if (request.method === 'POST') {
+ await handlePost(request, response);
+ } else if (request.method === 'PATCH') {
+ await handlePatch(request, response);
+ } else {
+ console.warn('Method was not POST. Method was ' + request.method);
+ response.status(404);
+ return;
+ }
+}
+
+async function handlePost(request: VercelRequest, response: VercelResponse) {
+ const { productDescription, shortDescription, totalPrice }: Partial<Order> = request.body;
+
+ 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 });
+
+ const { id, links } = paypalResponse;
+ const isApprovalLink = (o: object) => (
+ 'rel' in o &&
+ o.rel === 'approve' &&
+ 'href' in o &&
+ typeof(o.href) === 'string' &&
+ o.href.length > 0
+ );
+
+ if (!id || typeof(id) !== 'string' || id.length === 0) {
+ throw new Error(`
+ PayPal did not return the created order ID
+ in an expected format. PayPal returned: ${paypalResponse}
+ `);
+ }
+ if (!links || !('length' in links) || !links.some(isApprovalLink)) {
+ throw new Error(`
+ PayPal did not return the order approval link in
+ an expected format. PayPal returned: ${paypalResponse}
+ `);
+ }
+
+ response.status(201).json({
+ orderId: id,
+ approvalLink: links.find(isApprovalLink).href,
+ });
+ } catch (err) {
+ response.status(500).send(`Internal server error. ${err}`);
+ }
+ }
+}
+
+async function handlePatch(request: VercelRequest, response: VercelResponse) {
+ const { id, isApproved }: Partial<PatchRequestBody> = request.body;
+
+ if (!id) {
+ response.status(400).send('Expected body to contain id, but none provided.');
+ } else if (!isApproved) {
+ response.status(400).send('Expected body to contain isApproved, but none provided.');
+ } else {
+ try {
+ const paypalResponse = await paypalCaptureOrder(id);
+ response.status(200).json({
+ orderId: paypalResponse.id,
+ paypalStatus: paypalResponse.status,
+ });
+ } catch (err) {
+ response.status(500).send(`Internal server error. ${err}`);
+ }
+ }
+}
+
+type PatchRequestBody = {
+ id: string;
+ isApproved: boolean;
+};