Skip to content
← Public packages

@kentcdodds/stripe

Stripe helpers for customers, payments, invoices, subscriptions, products, payment links, refunds, and balance.

src/stripe-core.ts

283 lines · 9.6 KB · TypeScript
/**
 * Shared Stripe transport: authenticated form-encoded requests, bracket-style
 * param serialization, cursor pagination, search pagination, and money helpers.
 * Auth uses the user-scoped `stripeSecretKey` secret placeholder resolved by
 * the Kody gateway on approved hosts only.
 */

const apiBaseUrl = 'https://api.stripe.com'
const filesBaseUrl = 'https://files.stripe.com'
const secretKey = '{{secret:stripeSecretKey|scope=user}}'

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

	constructor(
		message: string,
		input: {
			status: number
			type?: string | null
			code?: string | null
			param?: string | null
			requestId?: string | null
			details?: unknown
		},
	) {
		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
	}
}

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 = {
	/** 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
}

/**
 * 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 rawPath = input.path.startsWith('http')
		? input.path
		: apiBaseUrl + (input.path.startsWith('/') ? input.path : '/v1/' + input.path)
	const url = new URL(rawPath)
	if (url.origin !== apiBaseUrl || !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 ' + secretKey,
				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: any = null
		try {
			parsed = text ? JSON.parse(text) : null
		} catch {
			parsed = { raw: text }
		}
		if (response.ok) return parsed
		const errorBody = parsed?.error ?? {}
		lastError = new StripeApiError(
			errorBody.message ?? 'Stripe API request failed with status ' + response.status,
			{
				status: response.status,
				type: errorBody.type ?? null,
				code: errorBody.code ?? null,
				param: errorBody.param ?? null,
				requestId: response.headers.get('request-id'),
				details: parsed,
			},
		)
		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 = {
	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: any[] = []
	let startingAfter: string | undefined
	let hasMore = false
	do {
		const page = await stripeRequest({
			path,
			query: {
				...(options.query ?? {}),
				...(maxItems > 0 ? { limit: Math.min(100, maxItems - items.length) } : {}),
				...(startingAfter ? { starting_after: startingAfter } : {}),
			},
		})
		const pageItems: any[] = 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 = {
	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: any[] = []
	let page: string | undefined
	let hasMore = false
	do {
		const result = await stripeRequest({
			path,
			query: {
				...(options.query ?? {}),
				query: options.searchQuery,
				...(maxItems > 0 ? { limit: Math.min(100, maxItems - items.length) } : {}),
				...(page ? { page } : {}),
			},
		})
		const pageItems: any[] = 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 any).id === 'string') {
		return (value as any).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 confirm: true to proceed.',
		)
	}
}

export type StripeUploadFileInput = {
	/** 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 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(filesBaseUrl + '/v1/files', {
		method: 'POST',
		headers: { Authorization: 'Bearer ' + secretKey },
		body: form,
	})
	const parsed: any = await response.json()
	if (!response.ok) {
		throw new StripeApiError(parsed?.error?.message ?? 'Stripe file upload failed', {
			status: response.status,
			type: parsed?.error?.type ?? null,
			requestId: response.headers.get('request-id'),
			details: parsed,
		})
	}
	return parsed
}