Skip to content
← Public packages

@kentcdodds/stripe

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

src/invoices.ts

177 lines · 6.0 KB · TypeScript
import {
	formatStripeAmount,
	idOf,
	requireConfirm,
	stripeDate,
	stripeList,
	stripeRequest,
	stripeSearch,
} from './stripe-core.ts'

export function summarizeInvoice(invoice: any) {
	if (!invoice || typeof invoice !== 'object') return null
	return {
		id: invoice.id ?? null,
		number: invoice.number ?? null,
		status: invoice.status ?? null,
		customerId: idOf(invoice.customer),
		customerEmail: invoice.customer_email ?? null,
		customerName: invoice.customer_name ?? null,
		currency: invoice.currency ?? null,
		amountDue: invoice.amount_due ?? null,
		amountPaid: invoice.amount_paid ?? null,
		total: invoice.total ?? null,
		display: formatStripeAmount(invoice.total, invoice.currency),
		created: stripeDate(invoice.created),
		dueDate: stripeDate(invoice.due_date),
		hostedInvoiceUrl: invoice.hosted_invoice_url ?? null,
		invoicePdf: invoice.invoice_pdf ?? null,
		description: invoice.description ?? null,
		lineCount: invoice.lines?.total_count ?? null,
	}
}

export type ListInvoicesInput = {
	customerId?: string
	status?: 'draft' | 'open' | 'paid' | 'uncollectible' | 'void'
	maxItems?: number
}

/** List invoices, optionally filtered by customer and status. */
export async function listInvoices(input: ListInvoicesInput = {}) {
	const { items, hasMore } = await stripeList('invoices', {
		maxItems: input.maxItems ?? 25,
		query: { customer: input.customerId, status: input.status },
	})
	return { hasMore, invoices: items.map(summarizeInvoice) }
}

/** Get one invoice by id (full Stripe object, lines included). */
export async function getInvoice(input: { invoiceId: string }) {
	return await stripeRequest({ path: 'invoices/' + input.invoiceId })
}

/**
 * Search invoices with Stripe's search query language.
 * @example searchInvoices({ searchQuery: "total>500 AND status:'paid'" })
 */
export async function searchInvoices(input: { searchQuery: string; maxItems?: number }) {
	const { items, hasMore } = await stripeSearch('invoices/search', {
		searchQuery: input.searchQuery,
		maxItems: input.maxItems ?? 25,
	})
	return { hasMore, invoices: items.map(summarizeInvoice) }
}

export type CreateInvoiceInput = {
	customerId: string
	description?: string
	/** 'send_invoice' emails the customer a hosted invoice; 'charge_automatically' uses the default payment method. */
	collectionMethod?: 'charge_automatically' | 'send_invoice'
	/** Required when collectionMethod is 'send_invoice'. */
	daysUntilDue?: number
	metadata?: Record<string, string>
	/** Line items to add before returning the draft. */
	lines?: Array<{
		description: string
		/** Amount in smallest currency unit, e.g. cents. */
		amount: number
		currency?: string
		quantity?: number
	}>
	idempotencyKey?: string
}

/** Create a draft invoice with optional line items. Finalize/send separately. */
export async function createInvoice(input: CreateInvoiceInput) {
	const invoice = await stripeRequest({
		path: 'invoices',
		method: 'POST',
		idempotencyKey: input.idempotencyKey,
		body: {
			customer: input.customerId,
			description: input.description,
			collection_method: input.collectionMethod ?? 'send_invoice',
			days_until_due:
				(input.collectionMethod ?? 'send_invoice') === 'send_invoice'
					? (input.daysUntilDue ?? 30)
					: undefined,
			metadata: input.metadata,
			auto_advance: false,
		},
	})
	for (const line of input.lines ?? []) {
		await stripeRequest({
			path: 'invoiceitems',
			method: 'POST',
			body: {
				customer: input.customerId,
				invoice: invoice.id,
				description: line.description,
				unit_amount: line.amount,
				currency: line.currency ?? 'usd',
				quantity: line.quantity ?? 1,
			},
		})
	}
	return summarizeInvoice(await stripeRequest({ path: 'invoices/' + invoice.id }))
}

/** Finalize a draft invoice so it can be sent or paid. Requires confirm: true. */
export async function finalizeInvoice(input: { invoiceId: string; confirm?: boolean }) {
	requireConfirm(input, 'finalize invoice ' + input.invoiceId)
	return summarizeInvoice(
		await stripeRequest({ path: 'invoices/' + input.invoiceId + '/finalize', method: 'POST' }),
	)
}

/** Email a finalized invoice to the customer. Requires confirm: true. */
export async function sendInvoice(input: { invoiceId: string; confirm?: boolean }) {
	requireConfirm(input, 'send invoice ' + input.invoiceId)
	return summarizeInvoice(
		await stripeRequest({ path: 'invoices/' + input.invoiceId + '/send', method: 'POST' }),
	)
}

/** Void a finalized invoice (irreversible). Requires confirm: true. */
export async function voidInvoice(input: { invoiceId: string; confirm?: boolean }) {
	requireConfirm(input, 'void invoice ' + input.invoiceId)
	return summarizeInvoice(
		await stripeRequest({ path: 'invoices/' + input.invoiceId + '/void', method: 'POST' }),
	)
}

/** Delete a draft invoice (drafts only). Requires confirm: true. */
export async function deleteDraftInvoice(input: { invoiceId: string; confirm?: boolean }) {
	requireConfirm(input, 'delete draft invoice ' + input.invoiceId)
	return await stripeRequest({ path: 'invoices/' + input.invoiceId, method: 'DELETE' })
}

/**
 * Invoices dispatcher. Defaults to list-invoices.
 * Actions: list-invoices, get-invoice, search-invoices, create-invoice,
 * finalize-invoice, send-invoice, void-invoice, delete-draft-invoice.
 */
export default async function invoices(input: Record<string, unknown> = {}) {
	const action = String(input.action ?? 'list-invoices')
	switch (action) {
		case 'list-invoices':
			return await listInvoices(input as ListInvoicesInput)
		case 'get-invoice':
			return await getInvoice(input as never)
		case 'search-invoices':
			return await searchInvoices(input as never)
		case 'create-invoice':
			return await createInvoice(input as CreateInvoiceInput)
		case 'finalize-invoice':
			return await finalizeInvoice(input as never)
		case 'send-invoice':
			return await sendInvoice(input as never)
		case 'void-invoice':
			return await voidInvoice(input as never)
		case 'delete-draft-invoice':
			return await deleteDraftInvoice(input as never)
		default:
			throw new Error('Unknown invoices action: ' + action)
	}
}