kopia lustrzana https://github.com/transitive-bullshit/chatgpt-api
feat: WIP stripe billing refactor update for 2025
rodzic
d0be1a6aa1
commit
ca90a37d8c
|
@ -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 })
|
||||||
|
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
@ -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({
|
||||||
|
|
|
@ -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))
|
|
||||||
])
|
|
||||||
}
|
}
|
||||||
}
|
}
|
Ładowanie…
Reference in New Issue