Skip to content
← Public packages

@kentcdodds/stripe

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

src/subscriptions.ts

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

export function summarizeSubscription(subscription: any) {
	if (!subscription || typeof subscription !== 'object') return null
	const items = subscription.items?.data ?? []
	return {
		id: subscription.id ?? null,
		status: subscription.status ?? null,
		customerId: idOf(subscription.customer),
		created: stripeDate(subscription.created),
		currentPeriodEnd: stripeDate(
			subscription.current_period_end ?? items[0]?.current_period_end,
		),
		cancelAtPeriodEnd: Boolean(subscription.cancel_at_period_end),
		canceledAt: stripeDate(subscription.canceled_at),
		items: items.map((item: any) => ({
			id: item.id,
			priceId: idOf(item.price),
			productId: idOf(item.price?.product),
			quantity: item.quantity ?? null,
			unitAmount: item.price?.unit_amount ?? null,
			currency: item.price?.currency ?? null,
			display: formatStripeAmount(item.price?.unit_amount, item.price?.currency),
			interval: item.price?.recurring?.interval ?? null,
		})),
		metadata: subscription.metadata ?? {},
	}
}

export type ListSubscriptionsInput = {
	customerId?: string
	/** Defaults to Stripe's default (excludes canceled); pass 'all' to include everything. */
	status?:
		| 'active'
		| 'past_due'
		| 'unpaid'
		| 'canceled'
		| 'incomplete'
		| 'incomplete_expired'
		| 'trialing'
		| 'paused'
		| 'all'
	priceId?: string
	maxItems?: number
}

/** List subscriptions, optionally filtered by customer, status, or price. */
export async function listSubscriptions(input: ListSubscriptionsInput = {}) {
	const { items, hasMore } = await stripeList('subscriptions', {
		maxItems: input.maxItems ?? 25,
		query: { customer: input.customerId, status: input.status, price: input.priceId },
	})
	return { hasMore, subscriptions: items.map(summarizeSubscription) }
}

/** Get one subscription by id (full Stripe object). */
export async function getSubscription(input: { subscriptionId: string }) {
	return await stripeRequest({ path: 'subscriptions/' + input.subscriptionId })
}

/**
 * Search subscriptions with Stripe's search query language.
 * @example searchSubscriptions({ searchQuery: "status:'active' AND metadata['plan']:'pro'" })
 */
export async function searchSubscriptions(input: { searchQuery: string; maxItems?: number }) {
	const { items, hasMore } = await stripeSearch('subscriptions/search', {
		searchQuery: input.searchQuery,
		maxItems: input.maxItems ?? 25,
	})
	return { hasMore, subscriptions: items.map(summarizeSubscription) }
}

export type CreateSubscriptionInput = {
	customerId: string
	priceId: string
	quantity?: number
	trialDays?: number
	metadata?: Record<string, string>
	idempotencyKey?: string
	/** Subscriptions bill real money; must be true. */
	confirm?: boolean
}

/** Create a subscription for an existing customer + price. Requires confirm: true. */
export async function createSubscription(input: CreateSubscriptionInput) {
	requireConfirm(input, 'create a subscription for ' + input.customerId)
	const subscription = await stripeRequest({
		path: 'subscriptions',
		method: 'POST',
		idempotencyKey: input.idempotencyKey,
		body: {
			customer: input.customerId,
			items: [{ price: input.priceId, quantity: input.quantity ?? 1 }],
			trial_period_days: input.trialDays,
			metadata: input.metadata,
		},
	})
	return summarizeSubscription(subscription)
}

export type CancelSubscriptionInput = {
	subscriptionId: string
	/** Default true: cancel at period end instead of immediately. */
	atPeriodEnd?: boolean
	confirm?: boolean
}

/** Cancel a subscription (at period end by default). Requires confirm: true. */
export async function cancelSubscription(input: CancelSubscriptionInput) {
	requireConfirm(input, 'cancel subscription ' + input.subscriptionId)
	if (input.atPeriodEnd === false) {
		return summarizeSubscription(
			await stripeRequest({
				path: 'subscriptions/' + input.subscriptionId,
				method: 'DELETE',
			}),
		)
	}
	return summarizeSubscription(
		await stripeRequest({
			path: 'subscriptions/' + input.subscriptionId,
			method: 'POST',
			body: { cancel_at_period_end: true },
		}),
	)
}

/**
 * Subscriptions dispatcher. Defaults to list-subscriptions.
 * Actions: list-subscriptions, get-subscription, search-subscriptions,
 * create-subscription, cancel-subscription.
 */
export default async function subscriptions(input: Record<string, unknown> = {}) {
	const action = String(input.action ?? 'list-subscriptions')
	switch (action) {
		case 'list-subscriptions':
			return await listSubscriptions(input as ListSubscriptionsInput)
		case 'get-subscription':
			return await getSubscription(input as never)
		case 'search-subscriptions':
			return await searchSubscriptions(input as never)
		case 'create-subscription':
			return await createSubscription(input as CreateSubscriptionInput)
		case 'cancel-subscription':
			return await cancelSubscription(input as CancelSubscriptionInput)
		default:
			throw new Error('Unknown subscriptions action: ' + action)
	}
}