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 { upsertStripeConnectCustomer } from '@/lib/billing/upsert-stripe-connect-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 {
openapiAuthenticatedSecuritySchemas,
@ -123,10 +123,10 @@ export function registerV1ConsumersUpsertConsumer(
assert(consumer, 500, 'Error creating consumer')
// make sure all pricing plans exist
await upsertStripePricingPlans({ deployment, project })
// Ensure that all Stripe pricing resources exist for this deployment
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?
// consumer._stripeAccount = project._stripeAccount
await upsertStripeConnectCustomer({ stripeCustomer, consumer, project })

Wyświetl plik

@ -1,5 +1,7 @@
import { z } from '@hono/zod-openapi'
import { assert } from '@/lib/utils'
export const authProviderTypeSchema = z
.enum(['github', 'google', 'spotify', 'twitter', 'linkedin', 'stripe'])
.openapi('AuthProviderType')
@ -126,11 +128,15 @@ const commonPricingPlanMetricSchema = z.object({
*/
interval: pricingIntervalSchema.optional(),
label: z.string().optional().openapi('label', { example: 'API calls' }),
stripePriceId: z.string().optional()
label: z.string().optional().openapi('label', { example: 'API calls' })
})
/**
* 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
.discriminatedUnion('usageType', [
commonPricingPlanMetricSchema.merge(
@ -211,19 +217,33 @@ export const pricingPlanMetricSchema = z
*/
round: z.enum(['down', 'up'])
})
.optional(),
/**
* Stripe Meter id, which is created lazily upon first use.
*/
stripeMeterId: z.string().optional()
.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')
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
.object({
name: z.string().nonempty().openapi('name', { example: 'Starter Monthly' }),
@ -258,6 +278,9 @@ export const pricingPlanSchema = z
return data
})
.describe(
'Represents the config for a Stripe subscription with one or more PricingPlanMetrics as line-items.'
)
.openapi('PricingPlan')
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 { createId } from '@paralleldrive/cuid2'
import { hashObject, omit } from '@/lib/utils'
import { hashObject } from '@/lib/utils'
import type { RawProject } from '../types'
import type {
@ -134,25 +134,33 @@ export const { createInsertSchema, createSelectSchema, createUpdateSchema } =
* This hash is used as the key for the `Project._stripePriceIdMap`.
*/
export function getPricingPlanMetricHashForStripePrice({
pricingPlan,
pricingPlanMetric,
project
}: {
pricingPlan: PricingPlan
pricingPlanMetric: PricingPlanMetric
project: RawProject
}) {
// TODO: use pricingPlan.slug as well here?
// 'price:free:base:<hash>'
// 'price:basic-monthly:base:<hash>'
// 'price:basic-monthly:requests:<hash>'
// TODO: not sure if this is needed or not...
// With pricing plan slug:
// - '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({
...omit(pricingPlanMetric, 'stripePriceId', 'stripeMeterId'),
...pricingPlanMetric,
projectId: project.id,
stripeAccountId: project._stripeAccountId,
currency: project.pricingCurrency
})
return `price:${pricingPlanMetric.slug}:${hash}`
return `price:${pricingPlan.slug}:${pricingPlanMetric.slug}:${hash}`
}
export function getPricingPlansByInterval({

Wyświetl plik

@ -12,13 +12,31 @@ import {
import { stripe } from '@/lib/stripe'
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,
project
}: {
deployment: RawDeployment
deployment: Readonly<RawDeployment>
project: RawProject
}): Promise<void> {
assert(
deployment.projectId === project.id,
'Deployment and project must match'
)
const stripeConnectParams = project._stripeAccountId
? [
{
@ -28,7 +46,7 @@ export async function upsertStripeProductsAndPricing({
: []
let dirty = false
async function upsertStripeProductAndPricingForMetric({
async function upsertStripeResourcesForPricingPlanMetric({
pricingPlan,
pricingPlanMetric
}: {
@ -38,12 +56,6 @@ export async function upsertStripeProductsAndPricing({
const { slug: pricingPlanSlug } = pricingPlan
const { slug: pricingPlanMetricSlug } = pricingPlanMetric
const pricingPlanMetricHashForStripePrice =
getPricingPlanMetricHashForStripePrice({
pricingPlanMetric,
project
})
// Upsert the Stripe Product
if (!project._stripeProductIdMap[pricingPlanMetricSlug]) {
const productParams: Stripe.ProductCreateParams = {
@ -100,23 +112,23 @@ export async function upsertStripeProductsAndPricing({
}
assert(project._stripeMeterIdMap[pricingPlanMetricSlug])
if (!pricingPlanMetric.stripeMeterId) {
pricingPlanMetric.stripeMeterId =
project._stripeMeterIdMap[pricingPlanMetricSlug]
dirty = true
assert(pricingPlanMetric.stripeMeterId)
}
} else {
assert(pricingPlanMetric.usageType === 'licensed')
assert(pricingPlanMetric.usageType === 'licensed', 400)
assert(
!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.`
)
}
const pricingPlanMetricHashForStripePrice =
getPricingPlanMetricHashForStripePrice({
pricingPlan,
pricingPlanMetric,
project
})
// Upsert the Stripe Price
if (!project._stripePriceIdMap[pricingPlanMetricHashForStripePrice]) {
const interval =
@ -159,10 +171,12 @@ export async function upsertStripeProductsAndPricing({
if (pricingPlanMetric.billingScheme === 'tiered') {
assert(
pricingPlanMetric.tiers?.length,
400,
`Invalid pricing plan metric "${pricingPlanMetricSlug}" for pricing plan "${pricingPlanSlug}": tiered billing schemes must have at least one tier.`
)
assert(
!pricingPlanMetric.transformQuantity,
400,
`Invalid pricing plan metric "${pricingPlanMetricSlug}" for pricing plan "${pricingPlanSlug}": tiered billing schemes cannot have transformQuantity.`
)
@ -185,14 +199,17 @@ export async function upsertStripeProductsAndPricing({
} else {
assert(
pricingPlanMetric.billingScheme === 'per_unit',
400,
`Invalid pricing plan metric "${pricingPlanMetricSlug}" for pricing plan "${pricingPlanSlug}": invalid billing scheme.`
)
assert(
pricingPlanMetric.unitAmount !== undefined,
400,
`Invalid pricing plan metric "${pricingPlanMetricSlug}" for pricing plan "${pricingPlanSlug}": unitAmount is required for per_unit billing schemes.`
)
assert(
!pricingPlanMetric.tiers,
400,
`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])
if (!pricingPlanMetric.stripePriceId) {
pricingPlanMetric.stripePriceId =
project._stripePriceIdMap[pricingPlanMetricHashForStripePrice]
}
assert(pricingPlanMetric.stripePriceId)
}
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) {
const pricingPlans = getPricingPlansByInterval({
pricingInterval,
@ -238,6 +253,7 @@ export async function upsertStripeProductsAndPricing({
assert(
pricingPlans.length > 0,
400,
`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 pricingPlanMetric of Object.values(pricingPlan.metricsMap)) {
upserts.push(() =>
upsertStripeProductAndPricingForMetric({
upsertStripeResourcesForPricingPlanMetric({
pricingPlan,
pricingPlanMetric
})
@ -256,16 +272,9 @@ export async function upsertStripeProductsAndPricing({
await pAll(upserts, { concurrency: 4 })
if (dirty) {
await Promise.all([
db
.update(schema.projects)
.set(project)
.where(eq(schema.projects.id, project.id)),
db
.update(schema.deployments)
.set(deployment)
.where(eq(schema.deployments.id, deployment.id))
])
await db
.update(schema.projects)
.set(project)
.where(eq(schema.projects.id, project.id))
}
}