diff --git a/apps/api/src/db/schema/consumer.ts b/apps/api/src/db/schema/consumer.ts index 47e5d589..4a63ccff 100644 --- a/apps/api/src/db/schema/consumer.ts +++ b/apps/api/src/db/schema/consumer.ts @@ -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>() + // [pricingPlanLineItemSlug: string]: string + _stripeSubscriptionItemIdMap: jsonb() + .$type() .default({}) .notNull(), @@ -132,7 +136,7 @@ export const consumerRelationsSchema: z.ZodType = 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({ diff --git a/apps/api/src/db/schema/deployment.ts b/apps/api/src/db/schema/deployment.ts index 672370dc..c251df01 100644 --- a/apps/api/src/db/schema/deployment.ts +++ b/apps/api/src/db/schema/deployment.ts @@ -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 - pricingPlanMap: jsonb().$type().notNull() + // Array + pricingPlans: jsonb().$type().notNull() // coupons: jsonb().$type().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 }) diff --git a/apps/api/src/db/schema/types.ts b/apps/api/src/db/schema/types.ts index 6e6939be..ee2fc119 100644 --- a/apps/api/src/db/schema/types.ts +++ b/apps/api/src/db/schema/types.ts @@ -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 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 -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 + .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 = + {} + 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 + 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 + +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({ diff --git a/apps/api/src/db/schema/utils.ts b/apps/api/src/db/schema/utils.ts index 390d5443..9ea308c5 100644 --- a/apps/api/src/db/schema/utils.ts +++ b/apps/api/src/db/schema/utils.ts @@ -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 ) } diff --git a/apps/api/src/lib/billing/upsert-consumer.ts b/apps/api/src/lib/billing/upsert-consumer.ts index 5f1c27e4..9aadd57f 100644 --- a/apps/api/src/lib/billing/upsert-consumer.ts +++ b/apps/api/src/lib/billing/upsert-consumer.ts @@ -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, diff --git a/apps/api/src/lib/billing/upsert-stripe-pricing.ts b/apps/api/src/lib/billing/upsert-stripe-pricing.ts index 9c58a852..c6660184 100644 --- a/apps/api/src/lib/billing/upsert-stripe-pricing.ts +++ b/apps/api/src/lib/billing/upsert-stripe-pricing.ts @@ -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({ diff --git a/apps/api/src/lib/billing/upsert-stripe-subscription.ts b/apps/api/src/lib/billing/upsert-stripe-subscription.ts index 9ca56e55..7452f973 100644 --- a/apps/api/src/lib/billing/upsert-stripe-subscription.ts +++ b/apps/api/src/lib/billing/upsert-stripe-subscription.ts @@ -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) diff --git a/eslint.config.js b/eslint.config.js index b0dae551..50950b0a 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -11,7 +11,8 @@ export default [ }, rules: { ...drizzle.configs.recommended.rules, - 'no-console': 'error' + 'no-console': 'error', + 'unicorn/no-array-reduce': 'off' } } ]