Skip to content

Built for people who want to own their automations. Join the waitlist for an invite.

Package listing

@kody/paypal

src/paypal-core.ts

272 lines · 7.4 KB · TypeScript
/**
 * Shared PayPal transport: client-credentials auth, OpenAPI wrappers,
 * dry-run previews, and setup-aware errors.
 *
 * Auth is secret-backed (not user OAuth). Defaults are `paypalClientId` and
 * `paypalClientSecret`. Extra accounts use `account: "work"` →
 * `paypalClientId-work` / `paypalClientSecret-work`.
 */

import { oauthClientCredentials } from 'kody:runtime'
import {
	balancesGet,
	invoicesCreate,
	invoicesGet,
	invoicesList,
	invoicesSearchInvoices,
	invoicesSend,
	payoutsCreate,
	payoutsGet,
	payoutsItemGet,
	searchGet,
	type QueryInput,
	type ScaffoldInput,
	type ScaffoldOptions,
} from './openapi-client.ts'
import {
	LIVE_API_HOST,
	SANDBOX_API_HOST,
	clientIdSetupUrl,
	clientSecretSetupUrl,
	getPayPalApiBaseUrl,
	resolveClientIdSecretName,
	resolveClientSecretSecretName,
	type PayPalAuthInput,
} from './paypal-helpers.ts'

export {
	CLIENT_ID_SETUP_URL,
	CLIENT_SECRET_SETUP_URL,
	DASHBOARD_APPS_URL,
	DEFAULT_CLIENT_ID_SECRET,
	DEFAULT_CLIENT_SECRET_SECRET,
	LIVE_API_BASE_URL,
	LIVE_API_HOST,
	SANDBOX_API_BASE_URL,
	SANDBOX_API_HOST,
	assertNever,
	buildInvoiceBody,
	buildPayoutBody,
	buildPayoutItem,
	clientIdSetupUrl,
	clientSecretSetupUrl,
	getPayPalApiBaseUrl,
	includesText,
	moneyValue,
	mutationPreview,
	normalizeInvoiceSummary,
	normalizeMoney,
	normalizePayoutSummary,
	normalizeTransactionSummary,
	numericMoneyValue,
	parseAction,
	requireConfirm,
	resolveClientIdSecretName,
	resolveClientSecretSecretName,
	toPayPalDate,
} from './paypal-helpers.ts'
export type {
	MutationGuardInput,
	PayPalAuthInput,
	PayPalDryRun,
	PayPalEnvironment,
	PayoutBodyInput,
	PayoutItemInput,
	PayoutRecipientType,
} from './paypal-helpers.ts'

export class PayPalApiError extends Error {
	status: number
	statusText: string
	details: unknown
	setup: { clientIdUrl: string; clientSecretUrl: string; hosts: string[] }

	constructor(
		message: string,
		input: { status: number; statusText: string; details: unknown; auth?: PayPalAuthInput },
	) {
		super(message)
		this.name = 'PayPalApiError'
		this.status = input.status
		this.statusText = input.statusText
		this.details = input.details
		this.setup = {
			clientIdUrl: clientIdSetupUrl(resolveClientIdSecretName(input.auth)),
			clientSecretUrl: clientSecretSetupUrl(resolveClientSecretSecretName(input.auth)),
			hosts: [LIVE_API_HOST, SANDBOX_API_HOST],
		}
	}
}

/**
 * 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: resolveClientIdSecretName(input),
		clientSecretSecret: resolveClientSecretSecretName(input),
		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, auth: PayPalAuthInput = {}) {
	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
		const hint =
			response.status === 401
				? ' Save paypalClientId and paypalClientSecret at the prefilled secrets URLs and approve api-m.paypal.com.'
				: ''
		throw new PayPalApiError(message + hint, {
			status: response.status,
			statusText: response.statusText,
			details: data,
			auth,
		})
	}
	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, auth)
}

/** 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)
}

/** Create a payout batch via `payouts_create`. */
export async function paypalCreatePayout(
	input: PayPalAuthInput & { body: unknown; requestId?: string },
) {
	return callAuthenticated(
		payoutsCreate,
		{
			body: input.body,
			headers: input.requestId ? { 'PayPal-Request-Id': input.requestId } : {},
		},
		input,
	)
}

/** Get a payout batch via `payouts_get`. */
export async function paypalGetPayout(input: PayPalAuthInput & { payoutBatchId: string; query?: QueryInput }) {
	return callAuthenticated(
		payoutsGet,
		{ params: { payout_batch_id: input.payoutBatchId }, query: input.query },
		input,
	)
}

/** Get one payout item via `payouts_item_get`. */
export async function paypalGetPayoutItem(input: PayPalAuthInput & { payoutItemId: string }) {
	return callAuthenticated(payoutsItemGet, { params: { payout_item_id: input.payoutItemId } }, input)
}