Skip to content

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

Package listing

@kody/plaid

src/plaid-core.ts

554 lines · 19.1 KB · TypeScript
/**
 * Shared Plaid transport: sandbox-first hosts, secret-backed client id +
 * secret headers, access-token resolution, dry-run mutations, and setup-aware
 * errors.
 *
 * Auth is secret-backed (not OAuth and not a bank login). Defaults are
 * `plaidClientId` and `plaidSecret`. Extra apps use `account: "work"` →
 * `plaidClientId-work` / `plaidSecret-work`. Item reads use
 * `plaidAccessToken` the same way. Placeholders resolve on approved hosts only.
 */

export const SANDBOX_API_BASE_URL = 'https://sandbox.plaid.com'
export const PRODUCTION_API_BASE_URL = 'https://production.plaid.com'
export const SANDBOX_API_HOST = 'sandbox.plaid.com'
export const PRODUCTION_API_HOST = 'production.plaid.com'
export const PLAID_VERSION = '2020-09-14'
export const DASHBOARD_KEYS_URL = 'https://dashboard.plaid.com/developers/keys'
export const DEFAULT_CLIENT_ID_SECRET = 'plaidClientId'
export const DEFAULT_SECRET_SECRET = 'plaidSecret'
export const DEFAULT_ACCESS_TOKEN_SECRET = 'plaidAccessToken'
export const DEFAULT_COUNTRY_CODES = ['US'] as const
export const DEFAULT_PRODUCTS = ['transactions', 'auth', 'identity'] as const
export const SANDBOX_INSTITUTION_ID = 'ins_109508'

const CLIENT_ID_PATTERN = /^plaidClientId(?:-[A-Za-z0-9][A-Za-z0-9_-]{0,47})?$/
const SECRET_PATTERN = /^plaidSecret(?:-[A-Za-z0-9][A-Za-z0-9_-]{0,47})?$/
const ACCESS_TOKEN_SECRET_PATTERN = /^plaidAccessToken(?:-[A-Za-z0-9][A-Za-z0-9_-]{0,47})?$/
const ACCOUNT_LABEL_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,47}$/

export type PlaidEnvironment = 'sandbox' | 'production'

export type PlaidAuthInput = {
	/** Defaults to sandbox. Pass `production` for live Items. */
	environment?: PlaidEnvironment
	apiBaseUrl?: string
	/** Extra app label. `work` reads `plaidClientId-work`. */
	account?: string
	clientIdSecret?: string
	secretSecret?: string
	accessTokenSecret?: string
	/**
	 * Item access token already obtained (for example from
	 * `exchangePublicToken`). Prefer saving it as `plaidAccessToken` instead of
	 * pasting it in chat. Never pass a bank password here.
	 */
	accessToken?: string
}

export type PlaidObject = Record<string, any>

export class PlaidApiError extends Error {
	status: number
	errorType: string | null
	errorCode: string | null
	requestId: string | null
	details: unknown
	setup: { clientIdUrl: string; secretUrl: string; hosts: string[]; dashboard: string }

	constructor(
		message: string,
		input: {
			status: number
			errorType?: string | null
			errorCode?: string | null
			requestId?: string | null
			details?: unknown
			auth?: PlaidAuthInput
		},
	) {
		super(message)
		this.name = 'PlaidApiError'
		this.status = input.status
		this.errorType = input.errorType ?? null
		this.errorCode = input.errorCode ?? null
		this.requestId = input.requestId ?? null
		this.details = input.details ?? null
		this.setup = {
			clientIdUrl: clientIdSetupUrl(resolveClientIdSecretName(input.auth)),
			secretUrl: secretSetupUrl(resolveSecretSecretName(input.auth)),
			hosts: [SANDBOX_API_HOST, PRODUCTION_API_HOST],
			dashboard: DASHBOARD_KEYS_URL,
		}
	}
}

export function assertNever(value: never, message: string): never {
	throw new Error(message + String(value))
}

export function parseAction<T extends string>(
	value: unknown,
	allowed: readonly T[],
	fallback: T,
	label: string,
): T {
	const action = (value == null || value === '' ? fallback : value) as unknown
	if (typeof action === 'string' && (allowed as readonly string[]).includes(action)) {
		return action as T
	}
	throw new Error(
		'Unknown ' + label + ' action: ' + String(action) + '. Valid actions: ' + allowed.join(', '),
	)
}

function accountLabel(value: string | undefined): string | null {
	const trimmed = (value ?? '').trim()
	if (!trimmed || trimmed === 'default') return null
	if (!ACCOUNT_LABEL_PATTERN.test(trimmed)) {
		throw new Error(
			'account must be a short label such as "work" or "sandbox" (letters, numbers, _ or -).',
		)
	}
	return trimmed
}

function suffixName(base: string, account: string | null): string {
	return account ? base + '-' + account : base
}

/** Resolve the user-scoped Plaid client-id secret name for this call. */
export function resolveClientIdSecretName(input: PlaidAuthInput = {}): string {
	if (input.clientIdSecret != null && String(input.clientIdSecret).trim() !== '') {
		const name = String(input.clientIdSecret).trim()
		if (!CLIENT_ID_PATTERN.test(name)) {
			throw new Error(
				'clientIdSecret must be plaidClientId or plaidClientId-<account>. Got ' +
					name +
					'. Save it at ' +
					clientIdSetupUrl(DEFAULT_CLIENT_ID_SECRET) +
					'.',
			)
		}
		return name
	}
	return suffixName(DEFAULT_CLIENT_ID_SECRET, accountLabel(input.account))
}

/** Resolve the user-scoped Plaid secret secret name for this call. */
export function resolveSecretSecretName(input: PlaidAuthInput = {}): string {
	if (input.secretSecret != null && String(input.secretSecret).trim() !== '') {
		const name = String(input.secretSecret).trim()
		if (!SECRET_PATTERN.test(name)) {
			throw new Error(
				'secretSecret must be plaidSecret or plaidSecret-<account>. Got ' +
					name +
					'. Save it at ' +
					secretSetupUrl(DEFAULT_SECRET_SECRET) +
					'.',
			)
		}
		return name
	}
	return suffixName(DEFAULT_SECRET_SECRET, accountLabel(input.account))
}

/** Resolve the user-scoped Item access-token secret name for this call. */
export function resolveAccessTokenSecretName(input: PlaidAuthInput = {}): string {
	if (input.accessTokenSecret != null && String(input.accessTokenSecret).trim() !== '') {
		const name = String(input.accessTokenSecret).trim()
		if (!ACCESS_TOKEN_SECRET_PATTERN.test(name)) {
			throw new Error(
				'accessTokenSecret must be plaidAccessToken or plaidAccessToken-<account>. Got ' +
					name +
					'. Save it at ' +
					accessTokenSetupUrl(DEFAULT_ACCESS_TOKEN_SECRET) +
					'.',
			)
		}
		return name
	}
	return suffixName(DEFAULT_ACCESS_TOKEN_SECRET, accountLabel(input.account))
}

export const CLIENT_ID_SETUP_URL =
	'https://kody.codes/account/secrets/new?name=plaidClientId&description=Plaid%20API%20client%20id%20from%20dashboard.plaid.com%2Fdevelopers%2Fkeys%20(PLAID_CLIENT_ID)&allowedHosts=sandbox.plaid.com,production.plaid.com&scope=user'

export const SECRET_SETUP_URL =
	'https://kody.codes/account/secrets/new?name=plaidSecret&description=Plaid%20API%20secret%20from%20dashboard.plaid.com%2Fdevelopers%2Fkeys%20(PLAID_SECRET)&allowedHosts=sandbox.plaid.com,production.plaid.com&scope=user'

export const ACCESS_TOKEN_SETUP_URL =
	'https://kody.codes/account/secrets/new?name=plaidAccessToken&description=Plaid%20Item%20access_token%20from%20Link%20or%20sandbox%20public-token%20exchange.%20Not%20a%20bank%20password.&allowedHosts=sandbox.plaid.com,production.plaid.com&scope=user'

export function clientIdSetupUrl(secretName: string = DEFAULT_CLIENT_ID_SECRET): string {
	if (secretName === DEFAULT_CLIENT_ID_SECRET) return CLIENT_ID_SETUP_URL
	return CLIENT_ID_SETUP_URL.replace('name=plaidClientId', 'name=' + encodeURIComponent(secretName))
}

export function secretSetupUrl(secretName: string = DEFAULT_SECRET_SECRET): string {
	if (secretName === DEFAULT_SECRET_SECRET) return SECRET_SETUP_URL
	return SECRET_SETUP_URL.replace('name=plaidSecret', 'name=' + encodeURIComponent(secretName))
}

export function accessTokenSetupUrl(secretName: string = DEFAULT_ACCESS_TOKEN_SECRET): string {
	if (secretName === DEFAULT_ACCESS_TOKEN_SECRET) return ACCESS_TOKEN_SETUP_URL
	return ACCESS_TOKEN_SETUP_URL.replace(
		'name=plaidAccessToken',
		'name=' + encodeURIComponent(secretName),
	)
}

export function resolveEnvironment(input: PlaidAuthInput = {}): PlaidEnvironment {
	const environment = (input.environment ?? 'sandbox') as PlaidEnvironment | string
	switch (environment) {
		case 'sandbox':
		case 'production':
			return environment
		default: {
			const unexpected: never = environment as never
			return assertNever(unexpected, 'environment must be "sandbox" or "production". Got ')
		}
	}
}

export function getPlaidApiBaseUrl(input: PlaidAuthInput = {}) {
	if (input.apiBaseUrl) return String(input.apiBaseUrl).replace(/\/+$/, '')
	return resolveEnvironment(input) === 'production' ? PRODUCTION_API_BASE_URL : SANDBOX_API_BASE_URL
}

export function getPlaidApiHost(input: PlaidAuthInput = {}) {
	return new URL(getPlaidApiBaseUrl(input)).host
}

function clientIdPlaceholder(input: PlaidAuthInput = {}): string {
	return '{{secret:' + resolveClientIdSecretName(input) + '|scope=user}}'
}

function secretPlaceholder(input: PlaidAuthInput = {}): string {
	return '{{secret:' + resolveSecretSecretName(input) + '|scope=user}}'
}

function accessTokenPlaceholder(input: PlaidAuthInput = {}): string {
	return '{{secret:' + resolveAccessTokenSecretName(input) + '|scope=user}}'
}

/** Access token for a live request. Uses a secret placeholder when omitted. */
export function accessTokenForRequest(input: PlaidAuthInput = {}): string {
	if (input.accessToken != null && String(input.accessToken).trim() !== '') {
		return String(input.accessToken).trim()
	}
	return accessTokenPlaceholder(input)
}

/**
 * Inert preview for dry-run bodies. Never emit a resolvable `{{secret:…}}`
 * placeholder into chat or logs.
 */
export function accessTokenForPreview(input: PlaidAuthInput = {}): string {
	if (input.accessToken != null && String(input.accessToken).trim() !== '') {
		return '<access_token>'
	}
	return 'secret:' + resolveAccessTokenSecretName(input)
}

export function requireConfirm(input: { confirm?: boolean }, action: string) {
	if (input.confirm !== true) {
		throw new Error(
			'Refusing to ' +
				action +
				' without confirm: true. This action changes Plaid state; omit confirm (default dry-run) or pass dryRun: true to preview.',
		)
	}
}

export type PlaidDryRun = {
	dryRun: true
	action: string
	method: 'POST'
	path: string
	environment: PlaidEnvironment
	host: string
	body?: Record<string, unknown>
}

export type MutationGuardInput = PlaidAuthInput & {
	/** Defaults to true unless `confirm: true` is also set. */
	dryRun?: boolean
	confirm?: boolean
}

/**
 * Mutations and link-token creation default to a dry-run preview. Live calls
 * require `confirm: true` (and `dryRun` not true).
 */
export function mutationPreview(
	input: MutationGuardInput,
	options: {
		action: string
		method: 'POST'
		path: string
		body?: Record<string, unknown>
	},
): PlaidDryRun | null {
	const defaultDryRun = input.dryRun !== false && input.confirm !== true
	if (defaultDryRun || input.dryRun === true) {
		return {
			dryRun: true,
			action: options.action,
			method: options.method,
			path: options.path,
			environment: resolveEnvironment(input),
			host: getPlaidApiHost(input),
			body: options.body,
		}
	}
	requireConfirm(input, options.action)
	return null
}

function hintForError(status: number, errorCode: string | null, errorType: string | null): string {
	if (status === 401 || errorCode === 'INVALID_API_KEYS') {
		return (
			' Save plaidClientId and plaidSecret at the prefilled secrets URLs and approve ' +
			SANDBOX_API_HOST +
			' (and ' +
			PRODUCTION_API_HOST +
			' if you use production).'
		)
	}
	if (errorCode === 'INVALID_ACCESS_TOKEN' || errorCode === 'INVALID_ACCOUNT_ID') {
		return (
			' Save the Item access_token as plaidAccessToken (not a bank password) at ' +
			ACCESS_TOKEN_SETUP_URL +
			', or pass accessToken from a confirmed public-token exchange.'
		)
	}
	if (
		errorCode === 'ITEM_LOGIN_REQUIRED' ||
		errorCode === 'USER_PERMISSION_REVOKED' ||
		errorType === 'ITEM_ERROR'
	) {
		return (
			' The Item needs a Link update — never ask for a bank username or password in chat. Create a link token with update mode, have the owner complete Plaid Link, then exchange the new public_token.'
		)
	}
	if (errorCode === 'PRODUCTS_NOT_SUPPORTED' || errorCode === 'ADDITIONAL_CONSENT_REQUIRED') {
		return ' This Item was not created with the product you requested. Recreate it in Sandbox or re-Link with that product consented.'
	}
	return ''
}

function toPlaidApiError(
	response: Response,
	parsed: PlaidObject | null,
	auth: PlaidAuthInput,
): PlaidApiError {
	const errorCode = typeof parsed?.error_code === 'string' ? parsed.error_code : null
	const errorType = typeof parsed?.error_type === 'string' ? parsed.error_type : null
	const baseMessage =
		typeof parsed?.error_message === 'string' && parsed.error_message
			? parsed.error_message
			: 'Plaid API request failed with status ' + response.status
	return new PlaidApiError(baseMessage + hintForError(response.status, errorCode, errorType), {
		status: response.status,
		errorType,
		errorCode,
		requestId:
			(typeof parsed?.request_id === 'string' ? parsed.request_id : null) ??
			response.headers.get('plaid-request-id'),
		details: parsed,
		auth,
	})
}

export type PlaidRequestInput = PlaidAuthInput & {
	path: string
	body?: Record<string, unknown>
	/** When true, include an access_token (from input or plaidAccessToken). */
	includeAccessToken?: boolean
}

/**
 * Authenticated Plaid POST. Client id + secret go in headers so they never
 * appear in returned dry-run bodies. Access tokens stay in the JSON body.
 */
export async function plaidRequest(input: PlaidRequestInput): Promise<any> {
	const baseUrl = getPlaidApiBaseUrl(input)
	const path = input.path.startsWith('/') ? input.path : '/' + input.path
	const url = new URL(path, baseUrl + '/')
	const allowedHosts = new Set([SANDBOX_API_HOST, PRODUCTION_API_HOST])
	if (!allowedHosts.has(url.host)) {
		throw new Error('Plaid requests must stay on sandbox.plaid.com or production.plaid.com.')
	}
	const body: Record<string, unknown> = { ...(input.body ?? {}) }
	if (input.includeAccessToken) {
		body.access_token = accessTokenForRequest(input)
	}
	const response = await fetch(url, {
		method: 'POST',
		headers: {
			'Content-Type': 'application/json',
			Accept: 'application/json',
			'Plaid-Version': PLAID_VERSION,
			'PLAID-CLIENT-ID': clientIdPlaceholder(input),
			'PLAID-SECRET': secretPlaceholder(input),
		},
		body: JSON.stringify(body),
	})
	const text = await response.text()
	let parsed: PlaidObject | null = null
	try {
		parsed = text ? JSON.parse(text) : null
	} catch {
		parsed = { raw: text }
	}
	if (!response.ok) throw toPlaidApiError(response, parsed, input)
	return parsed
}

export function normalizeMoney(balance: unknown) {
	if (!balance || typeof balance !== 'object') return null
	const record = balance as Record<string, unknown>
	return {
		available: typeof record.available === 'number' ? record.available : null,
		current: typeof record.current === 'number' ? record.current : null,
		limit: typeof record.limit === 'number' ? record.limit : null,
		iso_currency_code: typeof record.iso_currency_code === 'string' ? record.iso_currency_code : null,
		unofficial_currency_code:
			typeof record.unofficial_currency_code === 'string' ? record.unofficial_currency_code : null,
	}
}

export function normalizeAccount(account: any) {
	if (!account || typeof account !== 'object') return null
	return {
		account_id: account.account_id ?? null,
		name: account.name ?? null,
		official_name: account.official_name ?? null,
		mask: account.mask ?? null,
		type: account.type ?? null,
		subtype: account.subtype ?? null,
		balances: normalizeMoney(account.balances),
	}
}

export function normalizeItem(item: any) {
	if (!item || typeof item !== 'object') return null
	return {
		item_id: item.item_id ?? null,
		institution_id: item.institution_id ?? null,
		institution_name: item.institution_name ?? null,
		webhook: item.webhook ?? null,
		available_products: Array.isArray(item.available_products) ? item.available_products : [],
		billed_products: Array.isArray(item.billed_products) ? item.billed_products : [],
		products: Array.isArray(item.products) ? item.products : [],
		consent_expiration_time: item.consent_expiration_time ?? null,
		error: item.error
			? {
					error_type: item.error.error_type ?? null,
					error_code: item.error.error_code ?? null,
					error_message: item.error.error_message ?? null,
				}
			: null,
	}
}

export function normalizeTransaction(txn: any) {
	if (!txn || typeof txn !== 'object') return null
	return {
		transaction_id: txn.transaction_id ?? null,
		account_id: txn.account_id ?? null,
		amount: typeof txn.amount === 'number' ? txn.amount : null,
		iso_currency_code: txn.iso_currency_code ?? null,
		date: txn.date ?? null,
		authorized_date: txn.authorized_date ?? null,
		name: txn.name ?? null,
		merchant_name: txn.merchant_name ?? null,
		pending: Boolean(txn.pending),
		category: Array.isArray(txn.category) ? txn.category : [],
		personal_finance_category: txn.personal_finance_category
			? {
					primary: txn.personal_finance_category.primary ?? null,
					detailed: txn.personal_finance_category.detailed ?? null,
				}
			: null,
	}
}

export function normalizeIdentity(identity: any) {
	if (!identity || typeof identity !== 'object') return null
	const owners = Array.isArray(identity.owners) ? identity.owners : []
	return {
		account_id: identity.account_id ?? null,
		name: identity.name ?? null,
		mask: identity.mask ?? null,
		type: identity.type ?? null,
		subtype: identity.subtype ?? null,
		owners: owners.map((owner: any) => ({
			names: Array.isArray(owner?.names) ? owner.names : [],
			phone_numbers: Array.isArray(owner?.phone_numbers)
				? owner.phone_numbers.map((phone: any) => ({
						data: phone?.data ?? null,
						primary: Boolean(phone?.primary),
						type: phone?.type ?? null,
					}))
				: [],
			emails: Array.isArray(owner?.emails)
				? owner.emails.map((email: any) => ({
						data: email?.data ?? null,
						primary: Boolean(email?.primary),
						type: email?.type ?? null,
					}))
				: [],
			addresses: Array.isArray(owner?.addresses)
				? owner.addresses.map((address: any) => ({
						primary: Boolean(address?.primary),
						city: address?.data?.city ?? null,
						region: address?.data?.region ?? null,
						postal_code: address?.data?.postal_code ?? null,
						country: address?.data?.country ?? null,
					}))
				: [],
		})),
	}
}

export function normalizeInstitution(institution: any) {
	if (!institution || typeof institution !== 'object') return null
	return {
		institution_id: institution.institution_id ?? null,
		name: institution.name ?? null,
		products: Array.isArray(institution.products) ? institution.products : [],
		country_codes: Array.isArray(institution.country_codes) ? institution.country_codes : [],
		url: institution.url ?? null,
		oauth: Boolean(institution.oauth),
	}
}

export function asStringArray(value: unknown, fallback: readonly string[]): string[] {
	if (value == null) return [...fallback]
	if (!Array.isArray(value) || value.length === 0) {
		throw new Error('Expected a non-empty string array.')
	}
	return value.map((entry) => {
		if (typeof entry !== 'string' || entry.trim() === '') {
			throw new Error('Expected a non-empty string array.')
		}
		return entry
	})
}

export function requireString(value: unknown, field: string): string {
	if (typeof value !== 'string' || value.trim() === '') {
		throw new Error(field + ' is required.')
	}
	return value.trim()
}

export function isoDate(value: string | Date | undefined, field: string): string | undefined {
	if (value == null) return undefined
	if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value)) return value
	const date = value instanceof Date ? value : new Date(value)
	if (Number.isNaN(date.getTime())) throw new Error('Invalid ' + field + ': ' + String(value))
	return date.toISOString().slice(0, 10)
}