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 { 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 })
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
}
|
Ładowanie…
Reference in New Issue