diff options
Diffstat (limited to 'api/payments')
| -rw-r--r-- | api/payments/_paypal.ts | 116 | ||||
| -rw-r--r-- | api/payments/order.ts | 86 |
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; +}; |
