Skip to content

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

Package listing

@kody/stripe

src/subscriptions.ts

206 lines · 6.0 KB · TypeScript
import {
	formatStripeAmount,
	idOf,
	mutationPreview,
	parseAction,
	stripeDate,
	stripeList,
	stripeRequest,
	stripeSearch,
	type StripeAuthOptions,
} 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 = StripeAuthOptions & {
	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', {
		account: input.account,
		secretName: input.secretName,
		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: StripeAuthOptions & { subscriptionId: string }) {
	return await stripeRequest({
		path: 'subscriptions/' + input.subscriptionId,
		account: input.account,
		secretName: input.secretName,
	})
}

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

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

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

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

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

const subscriptionActions = [
	'list-subscriptions',
	'get-subscription',
	'search-subscriptions',
	'create-subscription',
	'cancel-subscription',
] as const

/**
 * Subscriptions dispatcher. Defaults to list-subscriptions.
 */
export default async function subscriptions(input: Record<string, unknown> = {}) {
	const action = parseAction(
		input.action,
		subscriptionActions,
		'list-subscriptions',
		'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: {
			const exhaustive: never = action
			throw new Error('Unhandled subscriptions action: ' + String(exhaustive))
		}
	}
}