feat: WIP stripe billing refactor update for 2025

pull/715/head
Travis Fischer 2025-05-17 20:35:33 +07:00
rodzic d0be1a6aa1
commit ca90a37d8c
4 zmienionych plików z 97 dodań i 57 usunięć

Wyświetl plik

@ -5,7 +5,7 @@ import type { AuthenticatedEnv } from '@/lib/types'
import { and, db, eq, schema } from '@/db' import { and, db, eq, schema } from '@/db'
import { upsertStripeConnectCustomer } from '@/lib/billing/upsert-stripe-connect-customer' import { upsertStripeConnectCustomer } from '@/lib/billing/upsert-stripe-connect-customer'
import { upsertStripeCustomer } from '@/lib/billing/upsert-stripe-customer' import { upsertStripeCustomer } from '@/lib/billing/upsert-stripe-customer'
import { upsertStripePricingPlans } from '@/lib/billing/upsert-stripe-pricing-plans' import { upsertStripePricing } from '@/lib/billing/upsert-stripe-pricing'
import { upsertStripeSubscription } from '@/lib/billing/upsert-stripe-subscription' import { upsertStripeSubscription } from '@/lib/billing/upsert-stripe-subscription'
import { import {
openapiAuthenticatedSecuritySchemas, openapiAuthenticatedSecuritySchemas,
@ -123,10 +123,10 @@ export function registerV1ConsumersUpsertConsumer(
assert(consumer, 500, 'Error creating consumer') assert(consumer, 500, 'Error creating consumer')
// make sure all pricing plans exist // Ensure that all Stripe pricing resources exist for this deployment
await upsertStripePricingPlans({ deployment, project }) await upsertStripePricing({ deployment, project })
// make sure that customer and default source are created on stripe connect acct // Ensure that customer and default source are created on the stripe connect account
// TODO: is this necessary? // TODO: is this necessary?
// consumer._stripeAccount = project._stripeAccount // consumer._stripeAccount = project._stripeAccount
await upsertStripeConnectCustomer({ stripeCustomer, consumer, project }) await upsertStripeConnectCustomer({ stripeCustomer, consumer, project })

Wyświetl plik

@ -1,5 +1,7 @@
import { z } from '@hono/zod-openapi' import { z } from '@hono/zod-openapi'
import { assert } from '@/lib/utils'
export const authProviderTypeSchema = z export const authProviderTypeSchema = z
.enum(['github', 'google', 'spotify', 'twitter', 'linkedin', 'stripe']) .enum(['github', 'google', 'spotify', 'twitter', 'linkedin', 'stripe'])
.openapi('AuthProviderType') .openapi('AuthProviderType')
@ -126,11 +128,15 @@ const commonPricingPlanMetricSchema = z.object({
*/ */
interval: pricingIntervalSchema.optional(), interval: pricingIntervalSchema.optional(),
label: z.string().optional().openapi('label', { example: 'API calls' }), label: z.string().optional().openapi('label', { example: 'API calls' })
stripePriceId: z.string().optional()
}) })
/**
* PricingPlanMetrics represent a single line-item in a Stripe Subscription.
*
* They map to a Stripe billing `Price` and possibly a corresponding Stripe
* `Metric` for metered usage.
*/
export const pricingPlanMetricSchema = z export const pricingPlanMetricSchema = z
.discriminatedUnion('usageType', [ .discriminatedUnion('usageType', [
commonPricingPlanMetricSchema.merge( commonPricingPlanMetricSchema.merge(
@ -211,19 +217,33 @@ export const pricingPlanMetricSchema = z
*/ */
round: z.enum(['down', 'up']) round: z.enum(['down', 'up'])
}) })
.optional(), .optional()
/**
* Stripe Meter id, which is created lazily upon first use.
*/
stripeMeterId: z.string().optional()
}) })
) )
]) ])
.describe('Stripe billing Price and possibly corresponding Metric') .refine((data) => {
assert(
!(data.slug === 'base' && data.usageType !== 'licensed'),
`Invalid pricing plan metric "${data.slug}": "base" pricing plan metrics are reserved for "licensed" usage type.`
)
assert(
!(data.slug === 'requests' && data.usageType !== 'metered'),
`Invalid pricing plan metric "${data.slug}": "requests" pricing plan metrics are reserved for "metered" usage type.`
)
return data
})
.describe(
'PricingPlanMetrics represent a single line-item in a Stripe Subscription. They map to a Stripe billing `Price` and possibly a corresponding Stripe `Metric` for metered usage.'
)
.openapi('PricingPlanMetric') .openapi('PricingPlanMetric')
export type PricingPlanMetric = z.infer<typeof pricingPlanMetricSchema> export type PricingPlanMetric = z.infer<typeof pricingPlanMetricSchema>
/**
* Represents the config for a Stripe subscription with one or more
* PricingPlanMetrics as line-items.
*/
export const pricingPlanSchema = z export const pricingPlanSchema = z
.object({ .object({
name: z.string().nonempty().openapi('name', { example: 'Starter Monthly' }), name: z.string().nonempty().openapi('name', { example: 'Starter Monthly' }),
@ -258,6 +278,9 @@ export const pricingPlanSchema = z
return data return data
}) })
.describe(
'Represents the config for a Stripe subscription with one or more PricingPlanMetrics as line-items.'
)
.openapi('PricingPlan') .openapi('PricingPlan')
export type PricingPlan = z.infer<typeof pricingPlanSchema> export type PricingPlan = z.infer<typeof pricingPlanSchema>

Wyświetl plik

@ -13,7 +13,7 @@ import { createSchemaFactory } from '@fisch0920/drizzle-zod'
import { z } from '@hono/zod-openapi' import { z } from '@hono/zod-openapi'
import { createId } from '@paralleldrive/cuid2' import { createId } from '@paralleldrive/cuid2'
import { hashObject, omit } from '@/lib/utils' import { hashObject } from '@/lib/utils'
import type { RawProject } from '../types' import type { RawProject } from '../types'
import type { import type {
@ -134,25 +134,33 @@ export const { createInsertSchema, createSelectSchema, createUpdateSchema } =
* This hash is used as the key for the `Project._stripePriceIdMap`. * This hash is used as the key for the `Project._stripePriceIdMap`.
*/ */
export function getPricingPlanMetricHashForStripePrice({ export function getPricingPlanMetricHashForStripePrice({
pricingPlan,
pricingPlanMetric, pricingPlanMetric,
project project
}: { }: {
pricingPlan: PricingPlan
pricingPlanMetric: PricingPlanMetric pricingPlanMetric: PricingPlanMetric
project: RawProject project: RawProject
}) { }) {
// TODO: use pricingPlan.slug as well here? // TODO: use pricingPlan.slug as well here?
// 'price:free:base:<hash>' // TODO: not sure if this is needed or not...
// 'price:basic-monthly:base:<hash>' // With pricing plan slug:
// 'price:basic-monthly:requests:<hash>' // - 'price:free:base:<hash>'
// - 'price:basic-monthly:base:<hash>'
// - 'price:basic-monthly:requests:<hash>'
// Without pricing plan slug:
// - 'price:base:<hash>'
// - 'price:base:<hash>'
// - 'price:requests:<hash>'
const hash = hashObject({ const hash = hashObject({
...omit(pricingPlanMetric, 'stripePriceId', 'stripeMeterId'), ...pricingPlanMetric,
projectId: project.id, projectId: project.id,
stripeAccountId: project._stripeAccountId, stripeAccountId: project._stripeAccountId,
currency: project.pricingCurrency currency: project.pricingCurrency
}) })
return `price:${pricingPlanMetric.slug}:${hash}` return `price:${pricingPlan.slug}:${pricingPlanMetric.slug}:${hash}`
} }
export function getPricingPlansByInterval({ export function getPricingPlansByInterval({

Wyświetl plik

@ -12,13 +12,31 @@ import {
import { stripe } from '@/lib/stripe' import { stripe } from '@/lib/stripe'
import { assert } from '@/lib/utils' import { assert } from '@/lib/utils'
export async function upsertStripeProductsAndPricing({ /**
* Upserts all the Stripe resources corresponding to a Deployment's pricing
* plans.
*
* This includes Stripe `Product`, `Meter`, and `Price` objects.
*
* All Stripe resource IDs are stored in the `_stripeProductIdMap`,
* `_stripeMeterIdMap`, and `_stripePriceIdMap` fields of the given `project`.
*
* The `project` will be updated in the DB with any changes.
* The `deployment` is readonly and will not be updated, since all Stripe
* resources persist on its Project in case they're the same across deployments.
*/
export async function upsertStripePricing({
deployment, deployment,
project project
}: { }: {
deployment: RawDeployment deployment: Readonly<RawDeployment>
project: RawProject project: RawProject
}): Promise<void> { }): Promise<void> {
assert(
deployment.projectId === project.id,
'Deployment and project must match'
)
const stripeConnectParams = project._stripeAccountId const stripeConnectParams = project._stripeAccountId
? [ ? [
{ {
@ -28,7 +46,7 @@ export async function upsertStripeProductsAndPricing({
: [] : []
let dirty = false let dirty = false
async function upsertStripeProductAndPricingForMetric({ async function upsertStripeResourcesForPricingPlanMetric({
pricingPlan, pricingPlan,
pricingPlanMetric pricingPlanMetric
}: { }: {
@ -38,12 +56,6 @@ export async function upsertStripeProductsAndPricing({
const { slug: pricingPlanSlug } = pricingPlan const { slug: pricingPlanSlug } = pricingPlan
const { slug: pricingPlanMetricSlug } = pricingPlanMetric const { slug: pricingPlanMetricSlug } = pricingPlanMetric
const pricingPlanMetricHashForStripePrice =
getPricingPlanMetricHashForStripePrice({
pricingPlanMetric,
project
})
// Upsert the Stripe Product // Upsert the Stripe Product
if (!project._stripeProductIdMap[pricingPlanMetricSlug]) { if (!project._stripeProductIdMap[pricingPlanMetricSlug]) {
const productParams: Stripe.ProductCreateParams = { const productParams: Stripe.ProductCreateParams = {
@ -100,23 +112,23 @@ export async function upsertStripeProductsAndPricing({
} }
assert(project._stripeMeterIdMap[pricingPlanMetricSlug]) assert(project._stripeMeterIdMap[pricingPlanMetricSlug])
if (!pricingPlanMetric.stripeMeterId) {
pricingPlanMetric.stripeMeterId =
project._stripeMeterIdMap[pricingPlanMetricSlug]
dirty = true
assert(pricingPlanMetric.stripeMeterId)
}
} else { } else {
assert(pricingPlanMetric.usageType === 'licensed') assert(pricingPlanMetric.usageType === 'licensed', 400)
assert( assert(
!project._stripeMeterIdMap[pricingPlanMetricSlug], !project._stripeMeterIdMap[pricingPlanMetricSlug],
400,
`Invalid pricing plan metric "${pricingPlanMetricSlug}" for pricing plan "${pricingPlanSlug}": licensed pricing plan metrics cannot replace a previous metered pricing plan metric. Use a different pricing plan metric slug for the new licensed plan.` `Invalid pricing plan metric "${pricingPlanMetricSlug}" for pricing plan "${pricingPlanSlug}": licensed pricing plan metrics cannot replace a previous metered pricing plan metric. Use a different pricing plan metric slug for the new licensed plan.`
) )
} }
const pricingPlanMetricHashForStripePrice =
getPricingPlanMetricHashForStripePrice({
pricingPlan,
pricingPlanMetric,
project
})
// Upsert the Stripe Price // Upsert the Stripe Price
if (!project._stripePriceIdMap[pricingPlanMetricHashForStripePrice]) { if (!project._stripePriceIdMap[pricingPlanMetricHashForStripePrice]) {
const interval = const interval =
@ -159,10 +171,12 @@ export async function upsertStripeProductsAndPricing({
if (pricingPlanMetric.billingScheme === 'tiered') { if (pricingPlanMetric.billingScheme === 'tiered') {
assert( assert(
pricingPlanMetric.tiers?.length, pricingPlanMetric.tiers?.length,
400,
`Invalid pricing plan metric "${pricingPlanMetricSlug}" for pricing plan "${pricingPlanSlug}": tiered billing schemes must have at least one tier.` `Invalid pricing plan metric "${pricingPlanMetricSlug}" for pricing plan "${pricingPlanSlug}": tiered billing schemes must have at least one tier.`
) )
assert( assert(
!pricingPlanMetric.transformQuantity, !pricingPlanMetric.transformQuantity,
400,
`Invalid pricing plan metric "${pricingPlanMetricSlug}" for pricing plan "${pricingPlanSlug}": tiered billing schemes cannot have transformQuantity.` `Invalid pricing plan metric "${pricingPlanMetricSlug}" for pricing plan "${pricingPlanSlug}": tiered billing schemes cannot have transformQuantity.`
) )
@ -185,14 +199,17 @@ export async function upsertStripeProductsAndPricing({
} else { } else {
assert( assert(
pricingPlanMetric.billingScheme === 'per_unit', pricingPlanMetric.billingScheme === 'per_unit',
400,
`Invalid pricing plan metric "${pricingPlanMetricSlug}" for pricing plan "${pricingPlanSlug}": invalid billing scheme.` `Invalid pricing plan metric "${pricingPlanMetricSlug}" for pricing plan "${pricingPlanSlug}": invalid billing scheme.`
) )
assert( assert(
pricingPlanMetric.unitAmount !== undefined, pricingPlanMetric.unitAmount !== undefined,
400,
`Invalid pricing plan metric "${pricingPlanMetricSlug}" for pricing plan "${pricingPlanSlug}": unitAmount is required for per_unit billing schemes.` `Invalid pricing plan metric "${pricingPlanMetricSlug}" for pricing plan "${pricingPlanSlug}": unitAmount is required for per_unit billing schemes.`
) )
assert( assert(
!pricingPlanMetric.tiers, !pricingPlanMetric.tiers,
400,
`Invalid pricing plan metric "${pricingPlanMetricSlug}" for pricing plan "${pricingPlanSlug}": per_unit billing schemes cannot have tiers.` `Invalid pricing plan metric "${pricingPlanMetricSlug}" for pricing plan "${pricingPlanSlug}": per_unit billing schemes cannot have tiers.`
) )
@ -219,17 +236,15 @@ export async function upsertStripeProductsAndPricing({
} }
assert(project._stripePriceIdMap[pricingPlanMetricHashForStripePrice]) assert(project._stripePriceIdMap[pricingPlanMetricHashForStripePrice])
if (!pricingPlanMetric.stripePriceId) {
pricingPlanMetric.stripePriceId =
project._stripePriceIdMap[pricingPlanMetricHashForStripePrice]
}
assert(pricingPlanMetric.stripePriceId)
} }
const upserts: Array<() => Promise<void>> = [] const upserts: Array<() => Promise<void>> = []
// 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?
// 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) { for (const pricingInterval of project.pricingIntervals) {
const pricingPlans = getPricingPlansByInterval({ const pricingPlans = getPricingPlansByInterval({
pricingInterval, pricingInterval,
@ -238,6 +253,7 @@ export async function upsertStripeProductsAndPricing({
assert( assert(
pricingPlans.length > 0, pricingPlans.length > 0,
400,
`Invalid pricing config for deployment "${deployment.id}": no pricing plans for interval "${pricingInterval}"` `Invalid pricing config for deployment "${deployment.id}": no pricing plans for interval "${pricingInterval}"`
) )
} }
@ -245,7 +261,7 @@ export async function upsertStripeProductsAndPricing({
for (const pricingPlan of Object.values(deployment.pricingPlanMap)) { for (const pricingPlan of Object.values(deployment.pricingPlanMap)) {
for (const pricingPlanMetric of Object.values(pricingPlan.metricsMap)) { for (const pricingPlanMetric of Object.values(pricingPlan.metricsMap)) {
upserts.push(() => upserts.push(() =>
upsertStripeProductAndPricingForMetric({ upsertStripeResourcesForPricingPlanMetric({
pricingPlan, pricingPlan,
pricingPlanMetric pricingPlanMetric
}) })
@ -256,16 +272,9 @@ export async function upsertStripeProductsAndPricing({
await pAll(upserts, { concurrency: 4 }) await pAll(upserts, { concurrency: 4 })
if (dirty) { if (dirty) {
await Promise.all([ await db
db .update(schema.projects)
.update(schema.projects) .set(project)
.set(project) .where(eq(schema.projects.id, project.id))
.where(eq(schema.projects.id, project.id)),
db
.update(schema.deployments)
.set(deployment)
.where(eq(schema.deployments.id, deployment.id))
])
} }
} }