feat: getting closer to stripe perfection <3

pull/715/head
Travis Fischer 2025-05-18 17:58:51 +07:00
rodzic 353f0ff46a
commit cd77eef88b
8 zmienionych plików z 269 dodań i 203 usunięć

Wyświetl plik

@ -11,6 +11,10 @@ import { z } from '@hono/zod-openapi'
import { deployments, deploymentSelectSchema } from './deployment'
import { projects, projectSelectSchema } from './project'
import {
type StripeSubscriptionItemIdMap,
stripeSubscriptionItemIdMapSchema
} from './types'
import { users, userSelectSchema } from './user'
import {
createInsertSchema,
@ -89,9 +93,9 @@ export const consumers = pgTable(
// Main Stripe Subscription id
_stripeSubscriptionId: stripeId(),
// [lineItemSlug: string]: string
_stripeSubscriptionLineItemIdMap: jsonb()
.$type<Record<string, string>>()
// [pricingPlanLineItemSlug: string]: string
_stripeSubscriptionItemIdMap: jsonb()
.$type<StripeSubscriptionItemIdMap>()
.default({})
.notNull(),
@ -132,7 +136,7 @@ export const consumerRelationsSchema: z.ZodType<ConsumerRelationFields> =
z.enum(['user', 'project', 'deployment'])
export const consumerSelectSchema = createSelectSchema(consumers, {
_stripeSubscriptionLineItemIdMap: z.record(z.string(), z.string()),
_stripeSubscriptionItemIdMap: stripeSubscriptionItemIdMapSchema,
deploymentId: (schema) =>
schema.refine((id) => validators.deploymentId(id), {
@ -146,7 +150,7 @@ export const consumerSelectSchema = createSelectSchema(consumers, {
})
.omit({
_stripeSubscriptionId: true,
_stripeSubscriptionLineItemIdMap: true,
_stripeSubscriptionItemIdMap: true,
_stripeCustomerId: true
})
.extend({

Wyświetl plik

@ -12,10 +12,10 @@ import { z } from '@hono/zod-openapi'
import { projects } from './project'
import { teams, teamSelectSchema } from './team'
import {
type PricingPlanList,
pricingPlanListSchema
// type Coupon,
// couponSchema,
type PricingPlanMap,
pricingPlanMapSchema
} from './types'
import { users, userSelectSchema } from './user'
import {
@ -64,8 +64,8 @@ export const deployments = pgTable(
// Backend API URL
_url: text().notNull(),
// Record<string, PricingPlan>
pricingPlanMap: jsonb().$type<PricingPlanMap>().notNull()
// Array<PricingPlan>
pricingPlans: jsonb().$type<PricingPlanList>().notNull()
// coupons: jsonb().$type<Coupon[]>().default([]).notNull()
},
@ -107,7 +107,7 @@ export const deploymentSelectSchema = createSelectSchema(deployments, {
// build: z.object({}),
// env: z.object({}),
pricingPlanMap: pricingPlanMapSchema
pricingPlans: pricingPlanListSchema
// coupons: z.array(couponSchema)
})
.omit({
@ -145,11 +145,9 @@ export const deploymentInsertSchema = createInsertSchema(deployments, {
_url: (schema) => schema.url(),
// TODO: should this public resource be decoupled from the internal pricing
// plan structure?
pricingPlanMap: pricingPlanMapSchema
pricingPlans: pricingPlanListSchema
// TODO
// TODOp
// coupons: z.array(couponSchema).optional()
})
.omit({ id: true, createdAt: true, updatedAt: true })

Wyświetl plik

@ -95,7 +95,16 @@ export const pricingPlanLineItemHashSchema = z
export const pricingPlanLineItemSlugSchema = z
.string()
.nonempty()
.describe('PricingPlanLineItem slug')
.describe(
'PricingPlanLineItem slug which acts as a unique lookup key for LineItems across deployments. They must be lower and kebab-cased ("base", "requests", "image-transformations").'
)
export const pricingPlanSlugSchema = z
.string()
.nonempty()
.describe(
'PricingPlan slug which acts as a unique lookup key for PricingPlans across deployments. They must be lower and kebab-cased and should have the interval as a suffix ("free", "starter-monthly", "pro-annual").'
)
export const stripePriceIdMapSchema = z
.record(pricingPlanLineItemHashSchema, z.string().describe('Stripe Price id'))
@ -256,7 +265,13 @@ export type PricingPlanLineItem = z.infer<typeof pricingPlanLineItemSchema>
export const pricingPlanSchema = z
.object({
name: z.string().nonempty().openapi('name', { example: 'Starter Monthly' }),
slug: z.string().nonempty().openapi('slug', { example: 'starter-monthly' }),
slug: z
.string()
.nonempty()
.describe(
'PricingPlan slug ("free", "starter-monthly", "pro-annual", etc)'
)
.openapi('slug', { example: 'starter-monthly' }),
/**
* The frequency at which a subscription is billed.
@ -325,27 +340,69 @@ export const stripeProductIdMapSchema = z
.openapi('StripeProductIdMap')
export type StripeProductIdMap = z.infer<typeof stripeProductIdMapSchema>
export const pricingPlanMapSchema = z
.record(
z
.string()
.describe(
'PricingPlan slug ("free", "starter-monthly", "pro-annual", etc)'
),
pricingPlanSchema
)
.refine((data) => Object.keys(data).length > 0, {
export const pricingPlanListSchema = z
.array(pricingPlanSchema)
.nonempty({
message: 'Must contain at least one PricingPlan'
})
.describe('Map from PricingPlan slug to PricingPlan')
export type PricingPlanMap = z.infer<typeof pricingPlanMapSchema>
.refine(
(pricingPlans) => {
const slugs = new Set(pricingPlans.map((p) => p.slug))
return slugs.size === pricingPlans.length
},
{
message: `Invalid PricingPlanList: duplicate PricingPlan slugs`
}
)
.refine(
(pricingPlans) => {
const pricingPlanLineItemSlugMap: Record<string, PricingPlanLineItem[]> =
{}
for (const pricingPlan of pricingPlans) {
for (const lineItem of pricingPlan.lineItems) {
if (!pricingPlanLineItemSlugMap[lineItem.slug]) {
pricingPlanLineItemSlugMap[lineItem.slug] = []
}
// TODO
// export const _stripeSubscriptionLineItemIdMapSchema = z
// .record(pricingPlanLineItemHashSchema, z.string().describe('Stripe LineItem id'))
// .describe('Map from internal PricingPlanLineItem **hash** to Stripe LineItem id')
// .openapi('StripeSubscriptionLineItemMap')
// export type StripeSubscriptionLineItemMap = z.infer<typeof stripeSubscriptionLineItemMapSchema>
pricingPlanLineItemSlugMap[lineItem.slug]!.push(lineItem)
}
}
for (const lineItems of Object.values(pricingPlanLineItemSlugMap)) {
if (lineItems.length <= 1) continue
const lineItem0 = lineItems[0]!
for (let i = 1; i < lineItems.length; ++i) {
const lineItem = lineItems[i]!
if (lineItem.usageType !== lineItem0.usageType) {
return false
}
}
}
return true
},
{
message: `Invalid PricingPlanList: all pricing plans which contain the same LineItems (by slug) must have the same usage type (licensed or metered).`
}
)
.describe('List of PricingPlans')
export type PricingPlanList = z.infer<typeof pricingPlanListSchema>
export const stripeSubscriptionItemIdMapSchema = z
.record(
pricingPlanLineItemSlugSchema,
z.string().describe('Stripe Subscription Item id')
)
.describe(
'Map from internal PricingPlanLineItem **slug** to Stripe Subscription Item id'
)
.openapi('StripeSubscriptionItemIdMap')
export type StripeSubscriptionItemIdMap = z.infer<
typeof stripeSubscriptionItemIdMapSchema
>
// export const couponSchema = z
// .object({

Wyświetl plik

@ -20,7 +20,7 @@ import type {
PricingInterval,
PricingPlan,
PricingPlanLineItem,
PricingPlanMap
PricingPlanList
} from './types'
const usernameAndTeamSlugLength = 64 as const
@ -164,15 +164,35 @@ export function getPricingPlanLineItemHashForStripePrice({
return `price:${pricingPlan.slug}:${pricingPlanLineItem.slug}:${hash}`
}
export function getStripePriceIdForPricingPlanLineItem({
pricingPlan,
pricingPlanLineItem,
project
}: {
pricingPlan: PricingPlan
pricingPlanLineItem: PricingPlanLineItem
project: RawProject
}): string | undefined {
const pricingPlanLineItemHash = getPricingPlanLineItemHashForStripePrice({
pricingPlan,
pricingPlanLineItem,
project
})
return project._stripePriceIdMap[pricingPlanLineItemHash]
}
export function getPricingPlansByInterval({
pricingInterval,
pricingPlanMap
pricingPlans
}: {
pricingInterval: PricingInterval
pricingPlanMap: PricingPlanMap
pricingPlans: PricingPlanList
}): PricingPlan[] {
return Object.values(pricingPlanMap).filter(
(pricingPlan) => pricingPlan.interval === pricingInterval
return pricingPlans.filter(
(pricingPlan) =>
pricingPlan.interval === undefined ||
pricingPlan.interval === pricingInterval
)
}

Wyświetl plik

@ -108,7 +108,7 @@ export async function upsertConsumer(
)
if (plan) {
const pricingPlan = deployment.pricingPlanMap[plan]
const pricingPlan = deployment.pricingPlans.find((p) => p.slug === plan)
assert(
pricingPlan,
400,

Wyświetl plik

@ -242,13 +242,13 @@ export async function upsertStripePricing({
// Validate deployment pricing plans to ensure they contain at least one valid
// plan per pricing interval configured on the project.
// TODO: move some of this `pricingPlanMap` validation to a separate function?
// TODO: move some of this `pricingPlans` validation to a separate function?
// We really wouldn't want to create some resources and then fail partway when
// this validation or some of the validation above fails.
for (const pricingInterval of project.pricingIntervals) {
const pricingPlans = getPricingPlansByInterval({
pricingInterval,
pricingPlanMap: deployment.pricingPlanMap
pricingPlans: deployment.pricingPlans
})
assert(
@ -258,7 +258,7 @@ export async function upsertStripePricing({
)
}
for (const pricingPlan of Object.values(deployment.pricingPlanMap)) {
for (const pricingPlan of deployment.pricingPlans) {
for (const pricingPlanLineItem of pricingPlan.lineItems) {
upserts.push(() =>
upsertStripeResourcesForPricingPlanLineItem({

Wyświetl plik

@ -10,6 +10,7 @@ import {
type RawUser,
schema
} from '@/db'
import { getStripePriceIdForPricingPlanLineItem } from '@/db/schema'
import { stripe } from '@/lib/stripe'
import { assert } from '@/lib/utils'
@ -62,16 +63,43 @@ export async function upsertStripeSubscription(
if (consumer._stripeSubscriptionId) {
// customer has an existing subscription
const existing = await stripe.subscriptions.retrieve(
const existingStripeSubscription = await stripe.subscriptions.retrieve(
consumer._stripeSubscriptionId,
...stripeConnectParams
)
const existingItems = existing.items.data
const existingStripeSubscriptionItems =
existingStripeSubscription.items.data
logger.debug()
logger.debug('existing subscription', JSON.stringify(existing, null, 2))
logger.debug(
'existing stripe subscription',
JSON.stringify(existingStripeSubscription, null, 2)
)
logger.debug()
const update: Stripe.SubscriptionUpdateParams = {}
assert(
existingStripeSubscription.metadata?.userId === consumer.userId,
500,
`Error updating stripe subscription: invalid existing subscription "${existingStripeSubscription.id}" metadata for consumer "${consumer.id}"`
)
assert(
existingStripeSubscription.metadata?.consumerId === consumer.id,
500,
`Error updating stripe subscription: invalid existing subscription "${existingStripeSubscription.id}" metadata for consumer "${consumer.id}"`
)
assert(
existingStripeSubscription.metadata?.projectId === project.id,
500,
`Error updating stripe subscription: invalid existing subscription "${existingStripeSubscription.id}" metadata for project "${project.id}"`
)
const updateParams: Stripe.SubscriptionUpdateParams = {
metadata: {
userId: consumer.userId,
consumerId: consumer.id,
projectId: project.id,
deployment: deployment.id
}
}
if (plan) {
assert(
@ -80,62 +108,50 @@ export async function upsertStripeSubscription(
`Unable to update stripe subscription for invalid pricing plan "${plan}"`
)
let items: Stripe.SubscriptionUpdateParams.Item[] = [
{
plan: pricingPlan.stripeBasePlanId,
id: consumer.stripeSubscriptionBaseItemId
},
{
plan: pricingPlan.stripeRequestPlanId,
id: consumer.stripeSubscriptionRequestItemId
}
]
const items: Stripe.SubscriptionUpdateParams.Item[] =
pricingPlan.lineItems.map((lineItem) => {
const priceId = getStripePriceIdForPricingPlanLineItem({
pricingPlan,
pricingPlanLineItem: lineItem,
project
})
assert(
priceId,
500,
`Error updating stripe subscription: missing expected Stripe Price for plan "${pricingPlan.slug}" line item "${lineItem.slug}"`
)
for (const metric of pricingPlan.metrics) {
const { slug: metricSlug } = metric
logger.debug({
metricSlug,
plan: pricingPlan.stripeMetricPlans[metricSlug],
id: consumer.stripeSubscriptionMetricItems[metricSlug]
// An existing Stripe Subscription Item may or may not exist for this
// LineItem. It should exist if this is an update to an existing
// LineItem. It won't exist if it's a new LineItem.
const id = consumer._stripeSubscriptionItemIdMap[lineItem.slug]
return {
price: priceId,
id,
metadata: {
lineItemSlug: lineItem.slug
}
}
})
items.push({
plan: pricingPlan.stripeMetricPlans[metricSlug]!,
id: consumer.stripeSubscriptionMetricItems[metricSlug]
})
}
const invalidItems = items.filter((item) => !item.plan)
if (plan && invalidItems.length) {
logger.error('billing warning found invalid items', invalidItems)
}
items = items.filter((item) => item.plan)
// Sanity check that LineItems we think should exist are all present in
// the current subscription's items.
for (const item of items) {
if (item.id) {
const existingItem = existingItems.find(
const existingItem = existingStripeSubscriptionItems.find(
(existingItem) => item.id === existingItem.id
)
if (!existingItem) {
logger.error(
'billing warning found new item that has a subscription item id but should not',
{ item }
)
delete item.id
}
assert(
existingItem,
500,
`Error updating stripe subscription: invalid pricing plan "${plan}" missing existing Subscription Item for "${item.id}"`
)
}
}
// TODO: We should never use clear_usage because it causes us to lose money.
// A customer could downgrade their subscription at the end of a pay period
// and this would clear all usage for their period, effectively allowing them
// to hack the service for free usage.
// The solution to this problem is to always have an equivalent free plan for
// every paid plan.
for (const existingItem of existingItems) {
for (const existingItem of existingStripeSubscriptionItems) {
const updatedItem = items.find((item) => item.id === existingItem.id)
if (!updatedItem) {
@ -144,10 +160,6 @@ export async function upsertStripeSubscription(
deleted: true
}
if (existingItem.plan.usage_type === 'metered') {
deletedItem.clear_usage = true
}
items.push(deletedItem)
}
}
@ -164,63 +176,88 @@ export async function upsertStripeSubscription(
}
}
update.items = items
updateParams.items = items
if (pricingPlan.trialPeriodDays) {
update.trial_end =
const trialEnd =
Math.trunc(Date.now() / 1000) +
24 * 60 * 60 * pricingPlan.trialPeriodDays
// Reuse the existing trial end date if one exists. Otherwise, set a new
// one for the updated subscription.
updateParams.trial_end =
existingStripeSubscription.trial_end ?? trialEnd
} else if (existingStripeSubscription.trial_end) {
// If the existing subscription has a trial end date, but the updated
// subscription doesn't, we should end the trial now.
updateParams.trial_end = 'now'
}
logger.debug('subscription', action, { items })
} else {
update.cancel_at_period_end = true
updateParams.cancel_at_period_end = true
}
if (project.isStripeConnectEnabled && project.applicationFeePercent > 0) {
update.application_fee_percent = project.applicationFeePercent
updateParams.application_fee_percent = project.applicationFeePercent
}
subscription = await stripe.subscriptions.update(
consumer._stripeSubscriptionId,
update,
updateParams,
...stripeConnectParams
)
// TODO: this will cancel the subscription without resolving current usage / invoices
// await stripe.subscriptions.del(consumer.stripeSubscription)
} else {
// Creating a new subscription for this consumer for the first time.
assert(
pricingPlan,
404,
`Unable to update stripe subscription for invalid pricing plan "${plan}"`
)
let items: Stripe.SubscriptionCreateParams.Item[] = [
{
plan: pricingPlan.stripeBasePlanId
},
{
plan: pricingPlan.stripeRequestPlanId
}
]
const items: Stripe.SubscriptionCreateParams.Item[] =
pricingPlan.lineItems.map((lineItem) => {
const priceId = getStripePriceIdForPricingPlanLineItem({
pricingPlan,
pricingPlanLineItem: lineItem,
project
})
assert(
priceId,
500,
`Error creating stripe subscription: missing expected Stripe Price for plan "${pricingPlan.slug}" line item "${lineItem.slug}"`
)
for (const metric of pricingPlan.metrics) {
const { slug: metricSlug } = metric
items.push({
plan: pricingPlan.stripeMetricPlans[metricSlug]!
// An existing Stripe Subscription Item may or may not exist for this
// LineItem. It should exist if this is an update to an existing
// LineItem. It won't exist if it's a new LineItem.
const id = consumer._stripeSubscriptionItemIdMap[lineItem.slug]
assert(
!id,
500,
`Error creating stripe subscription: consumer contains a Stripe Subscription Item for LineItem "${lineItem.slug}" and pricing plan "${pricingPlan.slug}"`
)
return {
price: priceId,
metadata: {
lineItemSlug: lineItem.slug
}
}
})
}
items = items.filter((item) => item.plan)
assert(
items.length,
500,
`Error creating stripe subscription for invalid plan "${pricingPlan.slug}"`
`Error creating stripe subscription: invalid plan "${plan}"`
)
const createParams: Stripe.SubscriptionCreateParams = {
customer: stripeCustomerId,
description: `Agentic subscription to project "${project.id}"`,
// TODO: coupons
// coupon: filterConsumerCoupon(ctx, consumer, deployment),
items,
@ -249,105 +286,54 @@ export async function upsertStripeSubscription(
consumer._stripeSubscriptionId = subscription.id
}
// ----------------------------------------------------
// Same codepath for updating, creating, and cancelling
// ----------------------------------------------------
assert(subscription, 500, 'Missing stripe subscription')
logger.debug('subscription', subscription)
const consumerUpdate: ConsumerUpdate = consumer
consumerUpdate.stripeStatus = subscription.status
if (plan) {
consumerUpdate.stripeStatus = subscription.status
} else {
// TODO
consumerUpdate._stripeSubscriptionId = null
consumerUpdate.stripeStatus = 'cancelled'
}
// if (!plan) {
// TODO: we cancel at the end of the billing interval, so we shouldn't
// invalidate the stripe subscription just yet. That should happen via
// webhook. And we should never set `_stripeSubscriptionId` to `null`.
// consumerUpdate._stripeSubscriptionId = null
// consumerUpdate.stripeStatus = 'cancelled'
// }
if (pricingPlan?.stripeBasePlanId) {
const subscriptionItem = subscription.items.data.find(
(item) => item.plan.id === pricingPlan.stripeBasePlanId
)
assert(
subscriptionItem,
500,
`Error initializing stripe subscription for base plan "${subscription.id}"`
)
if (pricingPlan) {
for (const lineItem of pricingPlan.lineItems) {
const stripeSubscriptionItemId =
consumer._stripeSubscriptionItemIdMap[lineItem.slug]
consumerUpdate.stripeSubscriptionBaseItemId = subscriptionItem.id
assert(
consumerUpdate.stripeSubscriptionBaseItemId,
500,
`Error initializing stripe subscription for base plan [${subscription.id}]`
)
} else {
// TODO
consumerUpdate.stripeSubscriptionBaseItemId = null
}
if (pricingPlan?.stripeRequestPlanId) {
const subscriptionItem = subscription.items.data.find(
(item) => item.plan.id === pricingPlan.stripeRequestPlanId
)
assert(
subscriptionItem,
500,
`Error initializing stripe subscription for metric "requests" on plan "${subscription.id}"`
)
consumerUpdate.stripeSubscriptionRequestItemId = subscriptionItem.id
assert(
consumerUpdate.stripeSubscriptionRequestItemId,
500,
`Error initializing stripe subscription for metric "requests" on plan "${subscription.id}"`
)
} else {
// TODO
consumerUpdate.stripeSubscriptionRequestItemId = null
}
const metricSlugs = (
pricingPlan?.metrics.map((metric) => metric.slug) ?? []
).concat(Object.keys(consumer.stripeSubscriptionMetricItems))
const isMetricInPricingPlan = (metricSlug: string) =>
pricingPlan?.metrics.find((metric) => metric.slug === metricSlug)
for (const metricSlug of metricSlugs) {
logger.debug({
metricSlug,
pricingPlan
})
const metricPlan = pricingPlan?.stripeMetricPlans[metricSlug]
if (metricPlan) {
const subscriptionItem: Stripe.SubscriptionItem | undefined =
subscription.items.data.find((item) => item.plan.id === metricPlan)
if (isMetricInPricingPlan(metricSlug)) {
assert(
subscriptionItem,
500,
`Error initializing stripe subscription for metric "${metricSlug}" on plan [${subscription.id}]`
const stripeSubscriptionItem: Stripe.SubscriptionItem | undefined =
subscription.items.data.find((item) =>
stripeSubscriptionItemId
? item.id === stripeSubscriptionItemId
: item.metadata?.lineItemSlug === lineItem.slug
)
consumerUpdate.stripeSubscriptionMetricItems![metricSlug] =
subscriptionItem.id
assert(
consumerUpdate.stripeSubscriptionMetricItems![metricSlug],
500,
`Error initializing stripe subscription for metric "${metricSlug}" on plan [${subscription.id}]`
)
}
} else {
// TODO
delete consumerUpdate.stripeSubscriptionMetricItems![metricSlug]
assert(
stripeSubscriptionItem,
500,
`Error post-processing stripe subscription for line item "${lineItem.slug}" on plan "${pricingPlan.slug}"`
)
consumerUpdate._stripeSubscriptionItemIdMap![lineItem.slug] =
stripeSubscriptionItem.id
assert(
consumerUpdate._stripeSubscriptionItemIdMap![lineItem.slug],
500,
`Error post-processing stripe subscription for line item "${lineItem.slug}" on plan "${pricingPlan.slug}"`
)
}
}
logger.debug()
logger.debug('consumer update', {
...consumer,
...consumerUpdate
})
logger.debug('consumer update', consumerUpdate)
const [updatedConsumer] = await db
.update(schema.consumers)

Wyświetl plik

@ -11,7 +11,8 @@ export default [
},
rules: {
...drizzle.configs.recommended.rules,
'no-console': 'error'
'no-console': 'error',
'unicorn/no-array-reduce': 'off'
}
}
]