Skip to content

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

Package listing

@kody/stripe

src/stripe-core.ts

513 lines · 17.1 KB · TypeScript
/**
 * Shared Stripe transport: authenticated form-encoded requests, bracket-style
 * param serialization, cursor pagination, search pagination, money helpers,
 * dry-run previews, and permission-aware errors.
 *
 * Auth is secret-backed (not OAuth). The default user secret is `stripeApiKey`.
 * Extra Stripe accounts use `account: "work"` → secret `stripeApiKey-work`.
 * Placeholders resolve on approved hosts only.
 */

export const API_BASE_URL = 'https://api.stripe.com'
export const FILES_BASE_URL = 'https://files.stripe.com'
export const API_HOST = 'api.stripe.com'
export const FILES_HOST = 'files.stripe.com'
export const DEFAULT_API_KEY_SECRET = 'stripeApiKey'
export const DEFAULT_WEBHOOK_SECRET = 'stripeWebhookSecret'

const DEFAULT_API_KEY_PLACEHOLDER = '{{secret:stripeApiKey|scope=user}}'

export const API_KEY_SETUP_URL =
	'https://kody.codes/account/secrets/new?name=stripeApiKey&description=Stripe%20secret%20API%20key%20(sk_live_%20or%20rk_live_)%20for%20customers%2C%20payments%2C%20invoices%2C%20and%20subscriptions&allowedHosts=api.stripe.com,files.stripe.com&scope=user'

export const WEBHOOK_SECRET_SETUP_URL =
	'https://kody.codes/account/secrets/new?name=stripeWebhookSecret&description=Stripe%20webhook%20signing%20secret%20(whsec_)%20from%20Developers%20%E2%86%92%20Webhooks&scope=user'

export const DASHBOARD_API_KEYS_URL = 'https://dashboard.stripe.com/apikeys'
export const DASHBOARD_WEBHOOKS_URL = 'https://dashboard.stripe.com/webhooks'

const SECRET_NAME_PATTERN = /^stripeApiKey(?:-[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 StripeAuthOptions = {
	/**
	 * Extra Stripe account label. `work` reads secret `stripeApiKey-work`.
	 * Omit (or pass `default`) for `stripeApiKey`.
	 */
	account?: string
	/** Override the API key secret name. Must be `stripeApiKey` or `stripeApiKey-<label>`. */
	secretName?: string
}

export type StripeObject = Record<string, any>

export class StripeApiError extends Error {
	status: number
	type: string | null
	code: string | null
	param: string | null
	requestId: string | null
	details: unknown
	missingPermission: string | null
	setupUrl: string

	constructor(
		message: string,
		input: {
			status: number
			type?: string | null
			code?: string | null
			param?: string | null
			requestId?: string | null
			details?: unknown
			missingPermission?: string | null
			setupUrl?: string
		},
	) {
		super(message)
		this.name = 'StripeApiError'
		this.status = input.status
		this.type = input.type ?? null
		this.code = input.code ?? null
		this.param = input.param ?? null
		this.requestId = input.requestId ?? null
		this.details = input.details ?? null
		this.missingPermission = input.missingPermission ?? null
		this.setupUrl = input.setupUrl ?? API_KEY_SETUP_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 "live" (letters, numbers, _ or -).',
		)
	}
	return trimmed
}

/** Resolve the user-scoped Stripe API key secret name for this call. */
export function resolveApiKeySecretName(input: StripeAuthOptions = {}): string {
	if (input.secretName != null && String(input.secretName).trim() !== '') {
		const name = String(input.secretName).trim()
		if (!SECRET_NAME_PATTERN.test(name)) {
			throw new Error(
				'secretName must be stripeApiKey or stripeApiKey-<account>. Got ' +
					name +
					'. Save it at ' +
					secretSetupUrl(name) +
					'.',
			)
		}
		return name
	}
	const account = accountLabel(input.account)
	return account ? DEFAULT_API_KEY_SECRET + '-' + account : DEFAULT_API_KEY_SECRET
}

export function secretSetupUrl(secretName: string = DEFAULT_API_KEY_SECRET): string {
	if (secretName === DEFAULT_API_KEY_SECRET) return API_KEY_SETUP_URL
	const encoded = encodeURIComponent(secretName)
	return API_KEY_SETUP_URL.replace('name=stripeApiKey', 'name=' + encoded)
}

export function webhookSecretSetupUrl(secretName: string = DEFAULT_WEBHOOK_SECRET): string {
	if (secretName === DEFAULT_WEBHOOK_SECRET) return WEBHOOK_SECRET_SETUP_URL
	return WEBHOOK_SECRET_SETUP_URL.replace('name=stripeWebhookSecret', 'name=' + encodeURIComponent(secretName))
}

export function resolveWebhookSecretName(input: StripeAuthOptions = {}): string {
	const account = accountLabel(input.account)
	return account ? DEFAULT_WEBHOOK_SECRET + '-' + account : DEFAULT_WEBHOOK_SECRET
}

function apiKeyPlaceholder(input: StripeAuthOptions = {}): string {
	const name = resolveApiKeySecretName(input)
	if (name === DEFAULT_API_KEY_SECRET) return DEFAULT_API_KEY_PLACEHOLDER
	return '{{secret:' + name + '|scope=user}}'
}

function appendParam(params: URLSearchParams, key: string, value: unknown) {
	if (value === undefined || value === null || value === '') return
	if (value instanceof Date) {
		params.append(key, String(Math.floor(value.getTime() / 1000)))
	} else if (Array.isArray(value)) {
		for (const item of value) appendParam(params, key + '[]', item)
	} else if (typeof value === 'object') {
		for (const [childKey, childValue] of Object.entries(value as Record<string, unknown>)) {
			appendParam(params, key + '[' + childKey + ']', childValue)
		}
	} else {
		params.append(key, String(value))
	}
}

/** Serialize nested params into Stripe's bracket/array form encoding. */
export function stripeParams(input: Record<string, unknown> = {}) {
	const params = new URLSearchParams()
	for (const [key, value] of Object.entries(input)) appendParam(params, key, value)
	return params
}

function sleep(ms: number) {
	return new Promise((resolve) => setTimeout(resolve, ms))
}

export type StripeRequestInput = StripeAuthOptions & {
	/** Path under /v1, e.g. 'customers' or '/v1/customers/cus_123'. */
	path: string
	method?: 'GET' | 'POST' | 'DELETE'
	/** Query params (GET) serialized with Stripe bracket encoding. Supports `expand` arrays. */
	query?: Record<string, unknown>
	/** Body params (POST) serialized as application/x-www-form-urlencoded. */
	body?: Record<string, unknown>
	headers?: Record<string, string>
	/** Sent as Idempotency-Key. Required to retry POSTs safely. */
	idempotencyKey?: string
	maxAttempts?: number
}

function extractMissingPermission(message: string): string | null {
	const having = message.match(/Having the '([^']+)' permission/i)
	if (having?.[1]) return having[1]
	const required = message.match(/requires the ['"]([^'"]+)['"] permission/i)
	if (required?.[1]) return required[1]
	const rak = message.match(/\b(rak_[a-z0-9_]+)\b/i)
	return rak?.[1] ?? null
}

function permissionHint(permission: string | null, secretName: string): string {
	const keyUrl = secretSetupUrl(secretName)
	if (!permission) {
		return (
			'This Stripe key is missing a required restricted-key permission. ' +
			'Create or rotate a restricted key at ' +
			DASHBOARD_API_KEYS_URL +
			' with the permission named in Stripe\'s error, then save it as ' +
			secretName +
			' at ' +
			keyUrl +
			'.'
		)
	}
	return (
		'Missing Stripe restricted-key permission `' +
		permission +
		'`. Add that permission on the key at ' +
		DASHBOARD_API_KEYS_URL +
		', then update secret `' +
		secretName +
		'` at ' +
		keyUrl +
		'.'
	)
}

function toStripeApiError(
	response: Response,
	parsed: StripeObject | null,
	secretName: string,
): StripeApiError {
	const errorBody = (parsed?.error ?? {}) as StripeObject
	const baseMessage =
		typeof errorBody.message === 'string' && errorBody.message
			? errorBody.message
			: 'Stripe API request failed with status ' + response.status
	const missingPermission = extractMissingPermission(baseMessage)
	const setupUrl = secretSetupUrl(secretName)

	if (response.status === 401) {
		return new StripeApiError(
			'Stripe rejected the API key (HTTP 401). Save a secret key (sk_...) or restricted key (rk_...) as `' +
				secretName +
				'`. Next step: ' +
				setupUrl,
			{
				status: 401,
				type: errorBody.type ?? 'invalid_request_error',
				code: errorBody.code ?? 'invalid_api_key',
				param: errorBody.param ?? null,
				requestId: response.headers.get('request-id'),
				details: parsed,
				setupUrl,
			},
		)
	}

	if (response.status === 403) {
		return new StripeApiError(baseMessage + ' ' + permissionHint(missingPermission, secretName), {
			status: 403,
			type: errorBody.type ?? 'invalid_request_error',
			code: errorBody.code ?? 'insufficient_permissions',
			param: errorBody.param ?? null,
			requestId: response.headers.get('request-id'),
			details: parsed,
			missingPermission,
			setupUrl,
		})
	}

	return new StripeApiError(baseMessage, {
		status: response.status,
		type: errorBody.type ?? null,
		code: errorBody.code ?? null,
		param: errorBody.param ?? null,
		requestId: response.headers.get('request-id'),
		details: parsed,
		missingPermission,
		setupUrl,
	})
}

/**
 * Authenticated Stripe request with parse + typed error + retry on 429/5xx.
 * POST requests are retried only when an idempotencyKey is provided.
 */
export async function stripeRequest(input: StripeRequestInput): Promise<any> {
	const method = input.method ?? 'GET'
	const secretName = resolveApiKeySecretName(input)
	const rawPath = input.path.startsWith('http')
		? input.path
		: API_BASE_URL + (input.path.startsWith('/') ? input.path : '/v1/' + input.path)
	const url = new URL(rawPath)
	if (url.origin !== API_BASE_URL || !url.pathname.startsWith('/v1/')) {
		throw new Error('Stripe requests must stay on https://api.stripe.com/v1.')
	}
	const query = stripeParams(input.query)
	for (const [key, value] of query.entries()) url.searchParams.append(key, value)
	const body = method === 'GET' ? undefined : stripeParams(input.body)
	const maxAttempts = Math.max(1, input.maxAttempts ?? 3)
	const canRetry = method !== 'POST' || Boolean(input.idempotencyKey)
	let lastError: Error | null = null
	for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
		const response = await fetch(url, {
			method,
			headers: {
				Authorization: 'Bearer ' + apiKeyPlaceholder(input),
				Accept: 'application/json',
				...(body ? { 'Content-Type': 'application/x-www-form-urlencoded' } : {}),
				...(input.idempotencyKey ? { 'Idempotency-Key': input.idempotencyKey } : {}),
				...(input.headers ?? {}),
			},
			body,
		})
		const text = await response.text()
		let parsed: StripeObject | null = null
		try {
			parsed = text ? JSON.parse(text) : null
		} catch {
			parsed = { raw: text }
		}
		if (response.ok) return parsed
		lastError = toStripeApiError(response, parsed, secretName)
		const retryable = response.status === 429 || response.status >= 500
		if (!retryable || !canRetry || attempt >= maxAttempts) throw lastError
		const retryAfterHeader = response.headers.get('retry-after')
		const retryMs = retryAfterHeader
			? Number(retryAfterHeader) * 1000
			: Math.min(5000, 500 * 2 ** (attempt - 1))
		await sleep(Number.isFinite(retryMs) ? retryMs : 1000)
	}
	throw lastError ?? new Error('Stripe request failed.')
}

export type StripeListOptions = StripeAuthOptions & {
	query?: Record<string, unknown>
	/** Follow has_more cursors until this many items are collected (default: one page). */
	maxItems?: number
}

/** List a Stripe collection endpoint, following starting_after cursors up to maxItems. */
export async function stripeList(path: string, options: StripeListOptions = {}) {
	const maxItems = options.maxItems ?? 0
	const items: StripeObject[] = []
	let startingAfter: string | undefined
	let hasMore = false
	do {
		const page = await stripeRequest({
			path,
			account: options.account,
			secretName: options.secretName,
			query: {
				...(options.query ?? {}),
				...(maxItems > 0 ? { limit: Math.min(100, maxItems - items.length) } : {}),
				...(startingAfter ? { starting_after: startingAfter } : {}),
			},
		})
		const pageItems: StripeObject[] = Array.isArray(page?.data) ? page.data : []
		items.push(...pageItems)
		hasMore = Boolean(page?.has_more) && pageItems.length > 0
		startingAfter = pageItems.at(-1)?.id
	} while (hasMore && maxItems > 0 && items.length < maxItems)
	return { items, hasMore }
}

export type StripeSearchOptions = StripeAuthOptions & {
	searchQuery: string
	query?: Record<string, unknown>
	maxItems?: number
}

/** Search a Stripe search endpoint, following next_page tokens up to maxItems. */
export async function stripeSearch(path: string, options: StripeSearchOptions) {
	const maxItems = options.maxItems ?? 0
	const items: StripeObject[] = []
	let page: string | undefined
	let hasMore = false
	do {
		const result = await stripeRequest({
			path,
			account: options.account,
			secretName: options.secretName,
			query: {
				...(options.query ?? {}),
				query: options.searchQuery,
				...(maxItems > 0 ? { limit: Math.min(100, maxItems - items.length) } : {}),
				...(page ? { page } : {}),
			},
		})
		const pageItems: StripeObject[] = Array.isArray(result?.data) ? result.data : []
		items.push(...pageItems)
		hasMore = Boolean(result?.has_more) && typeof result?.next_page === 'string'
		page = result?.next_page ?? undefined
	} while (hasMore && maxItems > 0 && items.length < maxItems)
	return { items, hasMore }
}

const zeroDecimalCurrencies = new Set([
	'bif', 'clp', 'djf', 'gnf', 'jpy', 'kmf', 'krw', 'mga',
	'pyg', 'rwf', 'ugx', 'vnd', 'vuv', 'xaf', 'xof', 'xpf',
])

/** Format a Stripe integer amount (smallest currency unit) as a display string. */
export function formatStripeAmount(amount: unknown, currency: unknown) {
	if (typeof amount !== 'number' || !Number.isFinite(amount)) return null
	const code = typeof currency === 'string' ? currency.toLowerCase() : 'usd'
	const value = zeroDecimalCurrencies.has(code) ? amount : amount / 100
	try {
		return new Intl.NumberFormat('en-US', { style: 'currency', currency: code.toUpperCase() }).format(value)
	} catch {
		return value.toFixed(2) + ' ' + code.toUpperCase()
	}
}

/** Convert a Stripe unix timestamp (seconds) to an ISO string, or null. */
export function stripeDate(seconds: unknown) {
	if (typeof seconds !== 'number' || !Number.isFinite(seconds)) return null
	return new Date(seconds * 1000).toISOString()
}

/** Extract the id when Stripe returns either an id string or an expanded object. */
export function idOf(value: unknown): string | null {
	if (typeof value === 'string') return value
	if (value && typeof value === 'object' && typeof (value as StripeObject).id === 'string') {
		return (value as StripeObject).id
	}
	return null
}

/** Throw unless the caller passed confirm: true for a money-moving/destructive action. */
export function requireConfirm(input: { confirm?: boolean }, action: string) {
	if (input.confirm !== true) {
		throw new Error(
			'Refusing to ' +
				action +
				' without confirm: true. This action changes live Stripe state; pass dryRun: true to preview, or confirm: true to proceed.',
		)
	}
}

export type StripeDryRun = {
	dryRun: true
	action: string
	method: 'POST' | 'DELETE'
	path: string
	body?: Record<string, unknown>
}

export type MutationGuardInput = StripeAuthOptions & {
	dryRun?: boolean
	confirm?: boolean
}

/**
 * Preview a mutation when `dryRun: true`. Destructive helpers also require
 * `confirm: true` before they contact Stripe.
 */
export function mutationPreview(
	input: MutationGuardInput,
	options: {
		action: string
		method: 'POST' | 'DELETE'
		path: string
		body?: Record<string, unknown>
		requireConfirm?: boolean
	},
): StripeDryRun | null {
	if (input.dryRun === true) {
		return {
			dryRun: true,
			action: options.action,
			method: options.method,
			path: options.path,
			body: options.body,
		}
	}
	if (options.requireConfirm) requireConfirm(input, options.action)
	return null
}

export type StripeUploadFileInput = StripeAuthOptions & {
	/** Stripe file purpose, e.g. 'dispute_evidence' or 'invoice_statement_descriptor'. */
	purpose: string
	fileName: string
	/** File content as UTF-8 text, or base64 when encoding is 'base64'. */
	content: string
	encoding?: 'utf-8' | 'base64'
	contentType?: string
}

/** Upload a file to files.stripe.com (multipart). */
export async function stripeUploadFile(input: StripeUploadFileInput): Promise<any> {
	const secretName = resolveApiKeySecretName(input)
	const bytes =
		input.encoding === 'base64'
			? Uint8Array.from(atob(input.content), (char) => char.charCodeAt(0))
			: new TextEncoder().encode(input.content)
	const form = new FormData()
	form.set('purpose', input.purpose)
	form.set(
		'file',
		new Blob([bytes], { type: input.contentType ?? 'application/octet-stream' }),
		input.fileName,
	)
	const response = await fetch(FILES_BASE_URL + '/v1/files', {
		method: 'POST',
		headers: { Authorization: 'Bearer ' + apiKeyPlaceholder(input) },
		body: form,
	})
	const parsed: StripeObject = await response.json()
	if (!response.ok) throw toStripeApiError(response, parsed, secretName)
	return parsed
}