Skip to content
← Public packages

@kentcdodds/paypal

PayPal helpers for invoices, sending invoices, transactions, and reimbursements.

src/paypal-core.ts

280 lines · 8.5 KB · TypeScript
import { oauthClientCredentials } from 'kody:runtime'
import {
	balancesGet,
	invoicesCreate,
	invoicesGet,
	invoicesList,
	invoicesSearchInvoices,
	invoicesSend,
	searchGet,
	type QueryInput,
	type ScaffoldInput,
	type ScaffoldOptions,
} from './openapi-client.ts'

export type PayPalEnvironment = 'live' | 'sandbox'

export type PayPalAuthInput = {
	environment?: PayPalEnvironment
	apiBaseUrl?: string
	clientIdSecret?: string
	clientSecretSecret?: string
	secretScope?: 'user' | 'app' | 'session'
}

const liveApiBaseUrl = 'https://api-m.paypal.com'
const sandboxApiBaseUrl = 'https://api-m.sandbox.paypal.com'

export class PayPalApiError extends Error {
	status: number
	statusText: string
	details: unknown

	constructor(message: string, input: { status: number; statusText: string; details: unknown }) {
		super(message)
		this.name = 'PayPalApiError'
		this.status = input.status
		this.statusText = input.statusText
		this.details = input.details
	}
}

export function getPayPalApiBaseUrl(input: PayPalAuthInput = {}) {
	const baseUrl = input.apiBaseUrl ?? (input.environment === 'sandbox' ? sandboxApiBaseUrl : liveApiBaseUrl)
	return String(baseUrl).replace(/\/+$/, '')
}

/**
 * Mint a short-lived PayPal access token via oauthClientCredentials.
 * Token stays in-memory for the call; never log or return it.
 */
async function getAccessToken(input: PayPalAuthInput = {}) {
	const apiBaseUrl = getPayPalApiBaseUrl(input)
	const payload = await oauthClientCredentials({
		tokenUrl: apiBaseUrl + '/v1/oauth2/token',
		clientIdSecret: input.clientIdSecret ?? 'paypalClientId',
		clientSecretSecret: input.clientSecretSecret ?? 'paypalClientSecret',
		scope: input.secretScope ?? 'user',
		authStyle: 'basic',
	})
	const accessToken = payload && typeof payload.access_token === 'string' ? payload.access_token : null
	if (!accessToken) {
		throw new Error('PayPal token response did not include an access_token string.')
	}
	return accessToken
}

async function parseResponse(response: Response) {
	const text = await response.text()
	if (!text) return null
	try {
		return JSON.parse(text)
	} catch {
		return { raw: text }
	}
}

async function parseOkJson(response: Response) {
	const data = await parseResponse(response)
	if (!response.ok) {
		const errorName =
			data && typeof data === 'object' && 'name' in data
				? String((data as Record<string, unknown>).name)
				: 'PayPal API request failed'
		const message =
			data && typeof data === 'object' && 'message' in data
				? String((data as Record<string, unknown>).message)
				: errorName
		throw new PayPalApiError(message, {
			status: response.status,
			statusText: response.statusText,
			details: data,
		})
	}
	return data
}

type AuthCallOptions = PayPalAuthInput & {
	headers?: Record<string, string>
}

async function callAuthenticated(
	fn: (input?: ScaffoldInput, options?: ScaffoldOptions) => Promise<Response>,
	scaffoldInput: ScaffoldInput,
	auth: AuthCallOptions = {},
) {
	const accessToken = await getAccessToken(auth)
	const response = await fn(
		{
			...scaffoldInput,
			headers: {
				Accept: 'application/json',
				Authorization: 'Bearer ' + accessToken,
				...(scaffoldInput.headers ?? {}),
				...(auth.headers ?? {}),
			},
		},
		{ apiBaseUrl: getPayPalApiBaseUrl(auth) },
	)
	return parseOkJson(response)
}

/** List invoices via OpenAPI slug `invoices_list`. */
export async function paypalListInvoices(
	input: PayPalAuthInput & { query?: QueryInput } = {},
) {
	return callAuthenticated(invoicesList, { query: input.query }, input)
}

/** Get one invoice via OpenAPI slug `invoices_get`. */
export async function paypalGetInvoice(
	input: PayPalAuthInput & { invoiceId: string; query?: QueryInput },
) {
	return callAuthenticated(
		invoicesGet,
		{ params: { invoice_id: input.invoiceId }, query: input.query },
		input,
	)
}

/** Search invoices via OpenAPI slug `invoices_search_invoices` (read-only POST). */
export async function paypalSearchInvoices(
	input: PayPalAuthInput & { query?: QueryInput; body?: unknown } = {},
) {
	return callAuthenticated(
		invoicesSearchInvoices,
		{ query: input.query, body: input.body ?? {} },
		input,
	)
}

/** Create a draft invoice via OpenAPI slug `invoices_create`. */
export async function paypalCreateInvoice(
	input: PayPalAuthInput & { body: unknown; query?: QueryInput },
) {
	return callAuthenticated(
		invoicesCreate,
		{
			query: input.query,
			body: input.body,
			headers: { Prefer: 'return=representation' },
		},
		input,
	)
}

/** Send an invoice via OpenAPI slug `invoices_send`. */
export async function paypalSendInvoice(
	input: PayPalAuthInput & {
		invoiceId: string
		body?: unknown
		query?: QueryInput
	},
) {
	return callAuthenticated(
		invoicesSend,
		{
			params: { invoice_id: input.invoiceId },
			query: input.query,
			body: input.body ?? { send_to_recipient: true, send_to_invoicer: true },
		},
		input,
	)
}

/** List transactions via OpenAPI slug `search_get`. */
export async function paypalListTransactions(
	input: PayPalAuthInput & { query?: QueryInput } = {},
) {
	return callAuthenticated(searchGet, { query: input.query }, input)
}

/** List balances via OpenAPI slug `balances_get`. */
export async function paypalListBalances(
	input: PayPalAuthInput & { query?: QueryInput } = {},
) {
	return callAuthenticated(balancesGet, { query: input.query }, input)
}

export function appendDefined(params: URLSearchParams, key: string, value: unknown) {
	if (value == null || value === '') return
	params.set(key, String(value))
}

export function normalizeMoney(money: unknown) {
	if (!money || typeof money !== 'object') return null
	const record = money as Record<string, unknown>
	return {
		currency_code: typeof record.currency_code === 'string' ? record.currency_code : null,
		value: typeof record.value === 'string' ? record.value : null,
	}
}

export function numericMoneyValue(money: unknown) {
	const normalized = normalizeMoney(money)
	if (!normalized?.value) return null
	const value = Number(normalized.value)
	return Number.isFinite(value) ? value : null
}

export function normalizeInvoiceSummary(invoice: any) {
	if (!invoice || typeof invoice !== 'object') return null
	return {
		id: invoice.id ?? null,
		status: invoice.status ?? null,
		invoice_number: invoice.detail?.invoice_number ?? null,
		invoice_date: invoice.detail?.invoice_date ?? null,
		due_date: invoice.detail?.payment_term?.due_date ?? null,
		currency_code: invoice.amount?.currency_code ?? invoice.detail?.currency_code ?? null,
		amount: invoice.amount?.value ?? invoice.amount?.breakdown?.item_total?.value ?? null,
		amount_money: invoice.amount ?? null,
		recipient_email: invoice.primary_recipients?.[0]?.billing_info?.email_address ?? null,
		recipient_name: invoice.primary_recipients?.[0]?.billing_info?.name ?? null,
		merchant_name: invoice.detail?.merchant_info?.business_name ?? invoice.detail?.merchant_info?.name ?? null,
		reference: invoice.detail?.reference ?? null,
		memo: invoice.detail?.memo ?? null,
		item_count: Array.isArray(invoice.items) ? invoice.items.length : null,
		links: Array.isArray(invoice.links)
			? invoice.links.map((link: any) => ({
					rel: link.rel ?? null,
					method: link.method ?? null,
					href: link.href ?? null,
				}))
			: [],
	}
}

export function normalizeTransactionSummary(detail: any) {
	const transaction = detail?.transaction_info ?? {}
	const payer = detail?.payer_info ?? {}
	const payerName = payer.payer_name ?? {}
	return {
		transaction_id: transaction.transaction_id ?? null,
		transaction_event_code: transaction.transaction_event_code ?? null,
		transaction_status: transaction.transaction_status ?? null,
		transaction_initiation_date: transaction.transaction_initiation_date ?? null,
		transaction_updated_date: transaction.transaction_updated_date ?? null,
		transaction_amount: transaction.transaction_amount ?? null,
		fee_amount: transaction.fee_amount ?? null,
		ending_balance: transaction.ending_balance ?? null,
		invoice_id: transaction.invoice_id ?? null,
		transaction_subject: transaction.transaction_subject ?? null,
		transaction_note: transaction.transaction_note ?? null,
		payer_email: payer.email_address ?? null,
		payer_name: payerName.alternate_full_name ?? payerName.given_name ?? null,
	}
}

export function includesText(value: unknown, query: string) {
	return String(value ?? '')
		.toLowerCase()
		.includes(query.toLowerCase())
}

export function toPayPalDate(input: string | Date) {
	const date = input instanceof Date ? input : new Date(input)
	if (Number.isNaN(date.getTime())) {
		throw new Error('Invalid PayPal date: ' + String(input))
	}
	return date.toISOString().replace(/\.\d{3}Z$/, 'Z')
}